agent-exec transport e8ba59dd3e1444a79fdda6e7f21ed7bd/7c8fcf53d035

This commit is contained in:
agent-exec-transport
2026-10-16 08:28:08 +00:00
parent 24185ac066
commit 56df3b11b7
3 changed files with 362 additions and 33 deletions
+8 -1
View File
@@ -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/<slug>"; exit 2; fi
@bash $(TASK) start "$(NAME)"
+155 -30
View File
@@ -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 <feature|fix|hotfix|release>/<slug>"
echo "$name" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || die "branch name must match (feature|fix|hotfix|release)/<slug>"
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=<parent initiative branch>"
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; }
# 两级分支元数据行:'- 分支类型:<kind>;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/<slug> KIND=initiative (or KIND=standalone)"
elif [ "$n" -eq 1 ]; then
b=${list%%|*}
echo "[verdict] JOIN — join initiative ${b}: make task-start NAME=${b}-<topic> 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/<target> 的记录;兼容 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 <reason>]"
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 <precheck|start|resume|sync|close|finish> [args]"; exit 2 ;;
*) echo "usage: task.sh <precheck|triage|start|resume|sync|close|finish> [args]"; exit 2 ;;
esac
+199 -2
View File
@@ -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()