diff --git a/Makefile b/Makefile index 158dd4df..3b662624 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ PLUGINS := plugins COMBO := core=$(shell git -C $(CORE) rev-parse --short HEAD 2>/dev/null)+backend=$(shell git -C $(BACKEND) rev-parse --short HEAD 2>/dev/null)+frontend=$(shell git -C $(FRONTEND) rev-parse --short HEAD 2>/dev/null)+ui=$(shell git -C $(UI) rev-parse --short HEAD 2>/dev/null)+agentcmd=$(shell git -C deps/agent-command rev-parse --short HEAD 2>/dev/null)+documents=$(shell git -C modules/documents rev-parse --short HEAD 2>/dev/null)+native=$(shell git -C deps/agent-native rev-parse --short HEAD 2>/dev/null)+squad=$(shell git -C deps/agent-squad rev-parse --short HEAD 2>/dev/null)+intent=$(shell git -C deps/agent-intent rev-parse --short HEAD 2>/dev/null) VERSION := $(shell git describe --tags --always --dirty 2>/dev/null)+$(COMBO) -.PHONY: help sync status build frontend svc svc-list svc-info svc-commits svc-history svc-update svc-recover svc-watchdog test-backend test-backend-pg test-multi-instance test-lifecycle test-frontend test-ui test-core test-agentcmd test-pins test-libs dev pin clean setup precheck task-start task-resume task-sync task-close task-finish facts-verify evidence version agent-exec-validate agent-exec-apply agent-exec-admit +.PHONY: help sync status build frontend svc svc-list svc-info svc-commits svc-history svc-update svc-recover svc-watchdog test-backend test-backend-pg test-multi-instance test-lifecycle test-frontend test-ui test-core test-agentcmd test-pins test-libs dev pin clean setup precheck task-triage task-start task-resume task-sync task-close task-finish facts-verify evidence version agent-exec-validate agent-exec-apply agent-exec-admit version: @echo "agent-system $(VERSION)" @@ -25,6 +25,10 @@ export TASK_WORK_MODE TASK_PARENT_BRANCH ?= $(PARENT) TASK_PARENT_PR ?= $(PARENT_PR) export TASK_PARENT_BRANCH TASK_PARENT_PR +TASK_BRANCH_KIND ?= $(KIND) +export TASK_BRANCH_KIND +TASK_INITIATIVE ?= $(INITIATIVE) +export TASK_INITIATIVE setup: git config core.hooksPath scripts/hooks @@ -34,6 +38,9 @@ setup: precheck: @bash $(TASK) precheck +task-triage: + @bash $(TASK) triage $(REQ) + task-start: @if [ -z "$(NAME)" ]; then echo "usage: make task-start NAME=feature/"; exit 2; fi @bash $(TASK) start "$(NAME)" diff --git a/scripts/task.sh b/scripts/task.sh index a7478bce..3dddb94f 100755 --- a/scripts/task.sh +++ b/scripts/task.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# 任务启动门禁:precheck / start / resume / sync / close / finish(配套 Makefile 同名目标)。 +# 任务启动门禁:precheck / triage / start / resume / sync / close / finish(配套 Makefile 同名目标)。 # 规范依据:collaboration.md 共同基线与同步节点、治理清理;delivery.md §1/§5。 set -euo pipefail @@ -72,6 +72,9 @@ cmd_precheck() { esac done [ -n "$done_open" ] && echo "[cards] 已完成但未归档:$(echo $done_open)" + local initiatives + initiatives=$(active_initiatives | cut -d'|' -f1) + echo "[initiatives] active: $([ -n "$initiatives" ] && echo $initiatives || echo none)" now=$(date +%s) while read -r ref ts; do [ -n "$ref" ] || continue @@ -142,6 +145,13 @@ cmd_start() { need_repo local name="${1:-}"; [ -n "$name" ] || die "usage: task.sh start /" echo "$name" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || die "branch name must match (feature|fix|hotfix|release)/" + local kind="${TASK_BRANCH_KIND:-standalone}" initiative="${TASK_INITIATIVE:-}" + case "$kind" in standalone|initiative|child) ;; *) die "TASK_BRANCH_KIND must be standalone, initiative or child" ;; esac + if [ "$kind" = child ]; then + [ -n "$initiative" ] || die "child task requires INITIATIVE=" + echo "$initiative" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || die "invalid parent initiative branch" + case "${name#*/}" in "${initiative#*/}"-?*) ;; *) die "child slug must start with parent initiative slug plus '-'" ;; esac + fi fetch_target [ -n "${PRECHECK_RECEIPT:-}" ] || die "PRECHECK_RECEIPT required: capture RECEIPT=/tmp/task-precheck.json CMD='make precheck' make evidence first" PRECHECK_RECEIPT=$(realpath -- "$PRECHECK_RECEIPT") @@ -162,7 +172,19 @@ cmd_start() { local rmain slug wt mainroot start_ref parent_head="" parent_note="" rmain=$(git rev-parse "origin/${TARGET_BRANCH}") start_ref=$rmain - if [ -n "${TASK_PARENT_BRANCH:-}${TASK_PARENT_PR:-}" ]; then + slug=$(echo "$name" | tr '/' '-') + local branch_meta + case "$kind" in + initiative) + branch_meta="- 分支类型:initiative;initiative_id:${slug};root_target:${TARGET_BRANCH};immediate_target:${TARGET_BRANCH};accepting_requirements:true" ;; + child) + parent_head=$(git rev-parse --verify "refs/heads/${initiative}^{commit}" 2>/dev/null) || die "parent initiative branch ${initiative} missing locally" + start_ref=$parent_head + branch_meta="- 分支类型:child;initiative_id:$(echo "$initiative" | tr '/' '-');root_target:${TARGET_BRANCH};immediate_target:${initiative};accepting_requirements:false;parent_branch:${initiative};parent_head_sha:${parent_head};parent_pr:${TASK_PARENT_PR:-无}" ;; + *) + branch_meta="- 分支类型:standalone;root_target:${TARGET_BRANCH};immediate_target:${TARGET_BRANCH};accepting_requirements:false" ;; + esac + if [ "$kind" != child ] && [ -n "${TASK_PARENT_BRANCH:-}${TASK_PARENT_PR:-}" ]; then [ -n "${TASK_PARENT_BRANCH:-}" ] && [ -n "${TASK_PARENT_PR:-}" ] || die "stacked task requires TASK_PARENT_BRANCH and TASK_PARENT_PR" echo "$TASK_PARENT_BRANCH" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || die "invalid parent task branch" echo "$TASK_PARENT_PR" | grep -qE '^https?://[^[:space:]]+/pulls/[0-9]+$' || die "TASK_PARENT_PR must be a parent PR URL" @@ -172,7 +194,6 @@ cmd_start() { start_ref=$parent_head parent_note="- 堆叠父任务:${TASK_PARENT_BRANCH};父 PR:${TASK_PARENT_PR};parent_head_sha=${parent_head};父合入后 task-sync 并将 PR base 改回 ${TARGET_BRANCH}" fi - slug=$(echo "$name" | tr '/' '-') # 落点须为主检出的平级目录:$PWD 推算在子目录(或任务 worktree 内)运行时会算错, # 以 Git 登记表定位主检出,兼容 linked worktree 与独立 git-dir。 mainroot=$(main_worktree_root) @@ -198,6 +219,7 @@ cmd_start() { - 权威仓:$(git -C "$wt" remote get-url origin 2>/dev/null || echo '(local)');目标分支:${TARGET_BRANCH};R0(initial_base_sha)= ${rmain} - 工作区:${wt}(worktree);分支:${name} +${branch_meta} ${parent_note} - 授权与交付终点:<本次允许的动作与终点;未授权事项写明> - 交付模式:goal-first(目标、范围和交付终点一次确认;范围内连续推进) @@ -330,19 +352,35 @@ cmd_sync() { need_repo fetch_target check_worktree before-sync - local base head card branch slug pc changed=0 ancestry_status - base=$(git rev-parse "origin/${TARGET_BRANCH}") + local base head card branch changed=0 ancestry_status immediate="${TARGET_BRANCH}" + branch=$(git rev-parse --abbrev-ref HEAD) + card=$(branch_card "$branch") + [ -n "$card" ] && immediate=$(card_immediate_target "$card") + if [ "$immediate" != "${TARGET_BRANCH}" ]; then + base=$(resolve_parent_ref "$immediate") + else + base=$(git rev-parse "origin/${TARGET_BRANCH}") + fi if git merge-base --is-ancestor "$base" HEAD; then : else ancestry_status=$? [ "$ancestry_status" -eq 1 ] || die "cannot verify target ancestry" - git merge --no-edit "$base" \ - -m "Merge authoritative ${TARGET_BRANCH} into the task branch" \ - -m "Why: include the current integration baseline before verification." \ - -m "Evidence: fetched origin/${TARGET_BRANCH}=${base}." \ - -m "Impact: integrate upstream changes without rewriting task history." \ - -m "Refs: task-sync" + if [ "$immediate" != "${TARGET_BRANCH}" ]; then + git merge --no-edit "$base" \ + -m "Merge parent initiative ${immediate} into the task branch" \ + -m "Why: include the current parent initiative baseline before verification." \ + -m "Evidence: resolved ${immediate}=${base}." \ + -m "Impact: integrate parent initiative changes without rewriting task history." \ + -m "Refs: task-sync" + else + git merge --no-edit "$base" \ + -m "Merge authoritative ${TARGET_BRANCH} into the task branch" \ + -m "Why: include the current integration baseline before verification." \ + -m "Evidence: fetched origin/${TARGET_BRANCH}=${base}." \ + -m "Impact: integrate upstream changes without rewriting task history." \ + -m "Refs: task-sync" + fi changed=1 fi git merge-base --is-ancestor "$base" HEAD || die "sync incomplete" @@ -350,19 +388,6 @@ cmd_sync() { check_worktree materialize check_worktree after-sync head=$(git rev-parse HEAD) - branch=$(git rev-parse --abbrev-ref HEAD) - slug=$(echo "$branch" | tr '/' '-') - card=".task/${slug}.md" - if [ ! -f "$card" ]; then - card="" - for pc in .task/*.md; do - [ -f "$pc" ] || continue - if [ "$(card_of_branch "$pc")" = "$branch" ]; then - card=$pc - break - fi - done - fi if [ -n "$card" ] && [ -f "$card" ]; then if [ "$changed" -eq 1 ]; then printf '| 同步(记录前对象) | %s | %s | %s |\n' "$base" "$head" "$(date -u +%FT%TZ)" >> "$card" @@ -378,7 +403,7 @@ cmd_sync() { echo "[warn] 找不到分支 ${branch} 的任务卡,同步记录未写入" >&2 fi head=$(git rev-parse HEAD) - echo "[synced] sync_base=${base} tested_head=${head} checked_at=$(date -u +%FT%TZ) changed=${changed}" + echo "[synced] sync_base=${base} tested_head=${head} checked_at=$(date -u +%FT%TZ) changed=${changed} sync_target=${immediate}" echo "[next] 跑正式验收(make test 等),证据写入任务卡" } @@ -391,6 +416,85 @@ cmd_sync() { card_of_branch() { sed -n 's/^- 工作区:.*分支:\([A-Za-z0-9._/-]\+\).*/\1/p' "$1" | head -1; } card_status() { sed -n 's/^.*;状态:\([^;]*\).*/\1/p' "$1" | head -1; } +# 两级分支元数据行:'- 分支类型:;initiative_id:…;root_target:…;immediate_target:…;accepting_requirements:…'。 +# 旧卡没有该行:按 standalone、root/immediate 均为目标分支、不接收新需求处理。 +card_field() { sed -n "s/^- 分支类型:.*;$2:\([^;]*\).*/\1/p" "$1" 2>/dev/null | head -1; } +card_branch_kind() { local v; v=$(sed -n 's/^- 分支类型:\([^;]*\).*/\1/p' "$1" 2>/dev/null | head -1); printf '%s\n' "${v:-standalone}"; } +card_initiative_id() { card_field "$1" initiative_id; } +card_root_target() { local v; v=$(card_field "$1" root_target); printf '%s\n' "${v:-${TARGET_BRANCH}}"; } +card_immediate_target() { local v; v=$(card_field "$1" immediate_target); printf '%s\n' "${v:-${TARGET_BRANCH}}"; } +card_accepting_requirements() { local v; v=$(card_field "$1" accepting_requirements); printf '%s\n' "${v:-false}"; } +branch_card() { # $1=branch → 当前检出中该分支的任务卡路径(无则空) + local pc=".task/$(echo "$1" | tr '/' '-').md" + [ -f "$pc" ] && { printf '%s\n' "$pc"; return 0; } + for pc in .task/*.md; do + [ -f "$pc" ] || continue + [ "$(card_of_branch "$pc")" = "$1" ] && { printf '%s\n' "$pc"; return 0; } + done + return 0 +} +active_initiatives() { # → 每行 "branch|initiative_id|card":根目标为集成分支、未完成、仍接收需求且未合入/待闭合 + local pc pb + for pc in .task/*.md; do + [ -f "$pc" ] || continue + [ "$(card_branch_kind "$pc")" = initiative ] || continue + [ "$(card_root_target "$pc")" = "${TARGET_BRANCH}" ] || continue + [ "$(card_accepting_requirements "$pc")" = true ] || continue + case "$(card_status "$pc")" in 已完成*) continue ;; esac + pb=$(card_of_branch "$pc"); [ -n "$pb" ] || continue + card_pending "${pc##*/}" && continue + card_merge "$pb" >/dev/null && continue + printf '%s|%s|%s\n' "$pb" "$(card_initiative_id "$pc")" "$pc" + done +} +resolve_parent_ref() { # $1=父 initiative 分支 → 打印用于同步/核验的提交(远端与本地取包含对方的一侧) + git check-ref-format "refs/heads/$1" >/dev/null || die "invalid parent branch $1" + local remote="" local_ref="" rc=0 + git ls-remote --exit-code --heads origin "refs/heads/$1" >/dev/null 2>&1 || rc=$? + if [ "$rc" -eq 0 ]; then + if ! git fetch -q origin "refs/heads/$1:refs/remotes/origin/$1"; then + echo "[verdict] REMOTE_UNVERIFIED — cannot refresh origin/$1; preserve local work and retry" >&2 + return 6 + fi + remote=$(git rev-parse --verify "refs/remotes/origin/$1^{commit}") + elif [ "$rc" -ne 2 ]; then + echo "[verdict] REMOTE_UNVERIFIED — cannot query origin/$1; preserve local work and retry" >&2 + return 6 + fi + local_ref=$(git rev-parse -q --verify "refs/heads/$1^{commit}" 2>/dev/null || true) + if [ -n "$remote" ] && [ -n "$local_ref" ]; then + if git merge-base --is-ancestor "$remote" "$local_ref"; then printf '%s\n' "$local_ref" + elif git merge-base --is-ancestor "$local_ref" "$remote"; then printf '%s\n' "$remote" + else die "parent branch $1 diverged between local and origin; reconcile it first" + fi + elif [ -n "$remote" ]; then printf '%s\n' "$remote" + elif [ -n "$local_ref" ]; then printf '%s\n' "$local_ref" + else die "parent branch $1 not found locally or on origin" + fi +} + +cmd_triage() { + need_repo + local req="${*:-}" list n line b id c + [ -n "$req" ] && echo "[triage] requirement: ${req}" + list=$(active_initiatives) + n=$(printf '%s' "$list" | grep -c '|' || true) + echo "[triage] active initiatives: ${n}" + while IFS= read -r line; do + [ -n "$line" ] || continue + IFS='|' read -r b id c <<<"$line" + echo "[initiative] branch=${b} initiative_id=${id:-无} card=${c}" + done <<<"$list" + if [ "$n" -eq 0 ]; then + echo "[verdict] NEW — no active initiative; start one with make task-start NAME=feature/ KIND=initiative (or KIND=standalone)" + elif [ "$n" -eq 1 ]; then + b=${list%%|*} + echo "[verdict] JOIN — join initiative ${b}: make task-start NAME=${b}- KIND=child INITIATIVE=${b}" + else + echo "[verdict] DECISION_REQUIRED — ${n} active initiatives; the user must choose which one receives this requirement (or approve a new one)" + return 5 + fi +} card_merge() { # $1=branch → "sha|subject|pr"(该分支合入 origin/ 的记录;兼容 merge 提交与 squash 落地) local hit pr hit=$(git log --merges --format='%H|%s' "origin/${TARGET_BRANCH}" 2>/dev/null \ @@ -594,6 +698,13 @@ cmd_finish() { head=$(git rev-parse HEAD) mainrepo=$(main_worktree_root) fetch_target + local card immediate="${TARGET_BRANCH}" merged_into + card=$(branch_card "$branch") + [ -n "$card" ] && immediate=$(card_immediate_target "$card") + merged_into="origin/${TARGET_BRANCH}" + if [ "$immediate" != "${TARGET_BRANCH}" ] && [ "$abandon" != "--abandon" ]; then + merged_into=$(resolve_parent_ref "$immediate") + fi if [ "$abandon" = "--abandon" ]; then local reason="${2:-}"; [ -n "$reason" ] || die "--abandon requires a reason" # An explicit abandon removes only the clean checkout; commits stay reachable. @@ -602,15 +713,19 @@ cmd_finish() { elif [ -n "$abandon" ]; then die "usage: task.sh finish [--abandon ]" else - git merge-base --is-ancestor "$head" "origin/${TARGET_BRANCH}" || die "branch has not merged into origin/${TARGET_BRANCH}; preserve it or explicitly abandon the checkout" + git merge-base --is-ancestor "$head" "$merged_into" || die "branch has not merged into ${immediate}; preserve it or explicitly abandon the checkout" remote_status=$(git ls-remote --heads origin "refs/heads/${branch}") || die "cannot verify remote task branch; preserve workspace" if [ -n "$remote_status" ]; then # Refresh its object, then use that exact observation as a deletion lease. git fetch -q origin "refs/heads/${branch}" || die "cannot fetch remote task branch; preserve workspace" remote_head=$(git rev-parse --verify FETCH_HEAD) - git merge-base --is-ancestor "$remote_head" "origin/${TARGET_BRANCH}" || die "remote task branch has unmerged work; preserve workspace and remote branch" + git merge-base --is-ancestor "$remote_head" "$merged_into" || die "remote task branch has unmerged work; preserve workspace and remote branch" + fi + if [ "$immediate" != "${TARGET_BRANCH}" ]; then + echo "[verified] ${branch} is included in parent initiative ${immediate}" + else + echo "[verified] ${branch} is included in origin/${TARGET_BRANCH}" fi - echo "[verified] ${branch} is included in origin/${TARGET_BRANCH}" fi # Re-check after fetch. --force only compensates Git's gitlink limitation after all # registered-root, owner, lock, recursive dirt and ignored-file checks have passed. @@ -623,9 +738,18 @@ cmd_finish() { die "Git refused worktree removal; workspace and branch retained. Owner must inspect the refusal; no rm fallback or additional force level is permitted." fi cd "$mainrepo" + local deleted=1 + if [ "$abandon" = "--abandon" ]; then + : + elif [ "$immediate" != "${TARGET_BRANCH}" ]; then + # git branch -d compares with the main checkout HEAD; the child is merged into its parent instead. + git update-ref -d "refs/heads/${branch}" "$head" && deleted=0 + else + git branch -d "$branch" && deleted=0 + fi if [ "$abandon" = "--abandon" ]; then echo "[recycled] worktree removed; local branch=${branch} retained; remote=NOT_RUN" - elif git branch -d "$branch"; then + elif [ "$deleted" -eq 0 ]; then if [ -z "$remote_head" ]; then echo "[recycled] worktree and local branch=${branch} removed; remote=ALREADY_ABSENT" elif git push -q "--force-with-lease=refs/heads/${branch}:${remote_head}" origin ":refs/heads/${branch}"; then @@ -644,10 +768,11 @@ cmd_finish() { case "${1:-}" in precheck) shift; cmd_precheck "$@" ;; + triage) shift; cmd_triage "$@" ;; start) shift; cmd_start "$@" ;; resume) shift; cmd_resume "$@" ;; sync) shift; cmd_sync "$@" ;; close) shift; cmd_close "$@" ;; finish) shift; cmd_finish "$@" ;; - *) echo "usage: task.sh [args]"; exit 2 ;; + *) echo "usage: task.sh [args]"; exit 2 ;; esac diff --git a/tests/test_task.py b/tests/test_task.py index aca3b4e4..148e2e3e 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -30,7 +30,7 @@ class TaskLifecycleTests(unittest.TestCase): "GIT_CONFIG_VALUE_1": "false"}) for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GITEA_TOKEN", "PRECHECK_RECEIPT", "TASK_PARENT_BRANCH", "TASK_PARENT_PR", "FORCE_ABANDON", "TASK_TARGET_BRANCH", - "TASK_WORK_MODE", "VALIDATE_RECEIPT_PURPOSE"): + "TASK_WORK_MODE", "VALIDATE_RECEIPT_PURPOSE", "TASK_BRANCH_KIND", "TASK_INITIATIVE"): self.env.pop(key, None) self.git(self.root, "init", "--bare", "--initial-branch=main", str(self.remote)) # receive-pack 默认在后台跑 gc --auto,与 TemporaryDirectory 清理竞态 @@ -671,7 +671,7 @@ class TaskLifecycleTests(unittest.TestCase): self.assertNotEqual(result.returncode, 0, result.stdout) def capture_precheck(self, cwd=None): - receipt = self.root / "precheck.json" + receipt = self.root / f"precheck-{len(list(self.root.glob('precheck*.json')))}.json" env = self.env | {"RECEIPT": str(receipt), "CMD": "make precheck"} self.run_cmd(cwd or self.repo, "bash", str(self.repo / "scripts/evidence.sh"), env=env) return receipt @@ -1005,6 +1005,203 @@ class TaskLifecycleTests(unittest.TestCase): result = self.run_cmd(wt, "python3", "scripts/check-worktree.py", "verify", check=False) self.assertNotEqual(result.returncode, 0, result.stdout) + # ---- 两级分支:initiative -> child -> main 与新需求强制分诊 ---- + + def write_initiative_card(self, branch, status="进行中", accepting="true", root="main", + kind="initiative", commit=True): + slug = branch.replace("/", "-") + card = self.repo / ".task" / f"{slug}.md" + card.parent.mkdir(exist_ok=True) + card.write_text( + f"# {branch}\n任务主记录:fixture;状态:{status};owner:Lifecycle Test\n" + f"- 工作区:fixture(worktree);分支:{branch}\n" + f"- 分支类型:{kind};initiative_id:{slug};root_target:{root};" + f"immediate_target:{root};accepting_requirements:{accepting}\n" + ) + if commit: + self.git(self.repo, "add", ".task") + self.git(self.repo, "commit", "-m", f"Record {branch} card") + return card + + def card_helper(self, func, card): + source = (self.repo / "scripts/task.sh").read_text() + lib = self.root / "task-lib.sh" + lib.write_text(source[:source.rindex('\ncase "${1:-}" in')] + "\n") + return self.run_cmd(self.repo, "bash", "-c", f'source {shlex.quote(str(lib))}; {func} "$1"', + "_", str(card)).stdout.strip() + + def start_initiative(self, name="feature/alpha"): + receipt = self.capture_precheck() + self.task(self.repo, "start", name, + env=self.env | {"PRECHECK_RECEIPT": str(receipt), "TASK_BRANCH_KIND": "initiative"}) + return self.root / ("repo-wt-" + name.replace("/", "-")) + + def start_child(self, name="feature/alpha-login", initiative="feature/alpha", check=True): + receipt = self.capture_precheck() + env = self.env | {"PRECHECK_RECEIPT": str(receipt), "TASK_BRANCH_KIND": "child", + "TASK_INITIATIVE": initiative, + "TASK_PARENT_PR": "https://example.invalid/repo/pulls/7"} + result = self.task(self.repo, "start", name, env=env, check=check) + return self.root / ("repo-wt-" + name.replace("/", "-")), result + + def test_triage_reports_new_without_active_initiatives(self): + result = self.task(self.repo, "triage", "add login") + self.assertIn("[triage] requirement: add login", result.stdout) + self.assertIn("[triage] active initiatives: 0", result.stdout) + self.assertIn("[verdict] NEW", result.stdout) + self.assertNotIn("[initiative]", result.stdout) + + def test_triage_joins_single_active_initiative(self): + self.write_initiative_card("feature/alpha") + result = self.task(self.repo, "triage", "add login") + self.assertIn("[triage] active initiatives: 1", result.stdout) + self.assertIn("[initiative] branch=feature/alpha initiative_id=feature-alpha", result.stdout) + self.assertIn("[verdict] JOIN", result.stdout) + self.assertIn("INITIATIVE=feature/alpha", result.stdout) + + def test_triage_requires_decision_for_multiple_active_initiatives(self): + self.write_initiative_card("feature/alpha") + self.write_initiative_card("feature/beta") + result = self.task(self.repo, "triage", check=False) + self.assertEqual(result.returncode, 5, result.stdout) + self.assertIn("[triage] active initiatives: 2", result.stdout) + self.assertIn("[initiative] branch=feature/alpha", result.stdout) + self.assertIn("[initiative] branch=feature/beta", result.stdout) + self.assertIn("[verdict] DECISION_REQUIRED", result.stdout) + + def test_triage_ignores_closed_merged_pending_and_non_accepting_initiatives(self): + self.write_initiative_card("feature/active") + self.write_initiative_card("feature/done", status="已完成(回填)") + self.write_initiative_card("feature/closed-intake", accepting="false") + self.write_initiative_card("feature/plain", kind="standalone") + self.write_initiative_card("feature/other-root", root="release") + self.write_initiative_card("feature/merged-init") + self.git(self.repo, "switch", "-c", "feature/merged-init") + (self.repo / "merged-work").write_text("landed\n") + self.git(self.repo, "add", "merged-work") + self.git(self.repo, "commit", "-m", "Merged initiative work") + self.git(self.repo, "switch", "main") + self.git(self.repo, "merge", "--no-ff", "feature/merged-init", "-m", + "Merge pull request 'Init' (#9) from feature/merged-init into main") + self.write_initiative_card("feature/pending-init") + self.git(self.repo, "switch", "-c", "feature/card-close-1") + self.git(self.repo, "mv", ".task/feature-pending-init.md", ".task/archive-tmp.md") + (self.repo / ".task/archive").mkdir() + self.git(self.repo, "mv", ".task/archive-tmp.md", ".task/archive/feature-pending-init.md") + self.git(self.repo, "commit", "-m", "Close pending initiative card") + self.git(self.repo, "switch", "main") + self.git(self.repo, "push", "origin", "main", "feature/card-close-1") + result = self.task(self.repo, "triage") + self.assertIn("[triage] active initiatives: 1", result.stdout) + self.assertIn("[initiative] branch=feature/active", result.stdout) + for branch in ("done", "closed-intake", "plain", "other-root", "merged-init", "pending-init"): + self.assertNotIn(f"branch=feature/{branch} ", result.stdout) + self.assertIn("[verdict] JOIN", result.stdout) + + def test_precheck_reports_active_initiatives(self): + result = self.task(self.repo, "precheck", check=False) + self.assertIn("[initiatives] active: none", result.stdout) + self.write_initiative_card("feature/alpha") + self.write_initiative_card("feature/beta") + self.git(self.repo, "push", "origin", "main") + result = self.task(self.repo, "precheck", check=False) + self.assertIn("[initiatives] active: feature/alpha feature/beta", result.stdout) + + def test_start_initiative_records_branch_metadata(self): + wt = self.start_initiative() + card = wt / ".task/feature-alpha.md" + text = card.read_text() + self.assertIn("- 分支类型:initiative;initiative_id:feature-alpha;root_target:main;" + "immediate_target:main;accepting_requirements:true", text) + self.assertEqual(self.git(wt, "merge-base", "HEAD", "origin/main").stdout.strip(), + self.git(self.repo, "rev-parse", "origin/main").stdout.strip()) + self.assertEqual(self.card_helper("card_branch_kind", card), "initiative") + self.assertEqual(self.card_helper("card_accepting_requirements", card), "true") + + def test_start_standalone_records_default_metadata(self): + receipt = self.capture_precheck() + self.task(self.repo, "start", "feature/solo", env=self.env | {"PRECHECK_RECEIPT": str(receipt)}) + text = (self.root / "repo-wt-feature-solo/.task/feature-solo.md").read_text() + self.assertIn("- 分支类型:standalone;root_target:main;immediate_target:main;" + "accepting_requirements:false", text) + + def test_start_child_records_parent_head_and_immediate_target(self): + parent = self.start_initiative() + (parent / "parent-change").write_text("initiative work\n") + self.git(parent, "add", "parent-change") + self.git(parent, "commit", "-m", "Initiative work") + parent_head = self.git(parent, "rev-parse", "HEAD").stdout.strip() + wt, _ = self.start_child() + card = wt / ".task/feature-alpha-login.md" + text = card.read_text() + self.assertIn("- 分支类型:child;initiative_id:feature-alpha;root_target:main;" + "immediate_target:feature/alpha;accepting_requirements:false;" + f"parent_branch:feature/alpha;parent_head_sha:{parent_head};" + "parent_pr:https://example.invalid/repo/pulls/7", text) + self.assertEqual((wt / "parent-change").read_text(), "initiative work\n") + self.git(wt, "merge-base", "--is-ancestor", parent_head, "HEAD") + self.assertEqual(self.card_helper("card_immediate_target", card), "feature/alpha") + self.assertEqual(self.card_helper("card_initiative_id", card), "feature-alpha") + self.assertEqual(self.card_helper("card_root_target", card), "main") + + def test_start_child_rejects_missing_initiative_or_wrong_slug(self): + self.start_initiative() + wt, result = self.start_child(name="feature/other-login", check=False) + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertIn("parent initiative slug", result.stdout) + self.assertFalse(wt.exists()) + wt, result = self.start_child(initiative="", check=False) + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertFalse(wt.exists()) + + def test_sync_child_merges_new_parent_initiative_commits(self): + parent = self.start_initiative() + wt, _ = self.start_child() + (parent / "parent-later").write_text("later initiative work\n") + self.git(parent, "add", "parent-later") + self.git(parent, "commit", "-m", "Later initiative work") + parent_head = self.git(parent, "rev-parse", "HEAD").stdout.strip() + result = self.task(wt, "sync") + self.assertIn("changed=1", result.stdout) + self.assertIn("sync_target=feature/alpha", result.stdout) + self.assertIn(f"sync_base={parent_head}", result.stdout) + self.git(wt, "merge-base", "--is-ancestor", parent_head, "HEAD") + self.assertEqual((wt / "parent-later").read_text(), "later initiative work\n") + self.assertIn(f"| 同步(记录前对象) | {parent_head} |", + (wt / ".task/feature-alpha-login.md").read_text()) + again = self.task(wt, "sync") + self.assertIn("changed=0", again.stdout) + + def test_finish_recycles_child_merged_into_parent_initiative(self): + parent = self.start_initiative() + wt, _ = self.start_child() + (wt / "child-work").write_text("child result\n") + self.git(wt, "add", "child-work") + self.git(wt, "commit", "-m", "Child work") + refused = self.task(wt, "finish", check=False) + self.assertNotEqual(refused.returncode, 0, refused.stdout) + self.assertIn("has not merged into feature/alpha", refused.stdout) + self.assertTrue(wt.exists()) + self.git(parent, "merge", "--no-ff", "feature/alpha-login", "-m", "Merge child into initiative") + result = self.task(wt, "finish") + self.assertIn("[verified] feature/alpha-login is included in parent initiative feature/alpha", + result.stdout) + self.assertFalse(wt.exists(), result.stdout) + self.assertEqual(self.git(self.repo, "branch", "--list", "feature/alpha-login").stdout.strip(), "") + self.assertTrue(parent.exists()) + self.assertEqual(self.git(parent, "branch", "--show-current").stdout.strip(), "feature/alpha") + + def test_legacy_card_defaults_to_standalone_main(self): + wt = self.worktree() + card = self.record_task_card(wt) + self.assertEqual(self.card_helper("card_branch_kind", card), "standalone") + self.assertEqual(self.card_helper("card_initiative_id", card), "") + self.assertEqual(self.card_helper("card_root_target", card), "main") + self.assertEqual(self.card_helper("card_immediate_target", card), "main") + self.assertEqual(self.card_helper("card_accepting_requirements", card), "false") + result = self.task(self.repo, "triage") + self.assertIn("[verdict] NEW", result.stdout) + if __name__ == "__main__": unittest.main()