Files
Agent-System-Dev/scripts/task.sh

1181 lines
64 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# 任务启动门禁:precheck / triage / start / resume / sync / close / finish(配套 Makefile 同名目标)。
# 规范依据:collaboration.md 共同基线与同步节点、治理清理;delivery.md §1/§5。
set -euo pipefail
TARGET_BRANCH="${TASK_TARGET_BRANCH:-main}"
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
die() { echo "ERROR: $*" >&2; exit 1; }
need_repo() { git rev-parse --git-dir >/dev/null 2>&1 || die "not inside a git repository"; }
width() { python3 -c 'import sys,unicodedata;print(sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in sys.argv[1]))' "$1"; }
fetch_target() {
git check-ref-format "refs/heads/${TARGET_BRANCH}" >/dev/null || die "invalid target branch"
if ! git fetch -q origin "refs/heads/${TARGET_BRANCH}:refs/remotes/origin/${TARGET_BRANCH}"; then
echo "[verdict] REMOTE_UNVERIFIED — cannot refresh authoritative origin/${TARGET_BRANCH}; preserve local work and retry" >&2
return 6
fi
git rev-parse --verify "refs/remotes/origin/${TARGET_BRANCH}^{commit}" >/dev/null \
|| { echo "[verdict] REMOTE_UNVERIFIED — target commit missing" >&2; return 6; }
}
origin_transport_unavailable() { # $1=失败的远端命令 stderr → 仅明确的传输失败(DNS/连接超时/拒绝/重置/无路由,或本地缺失仓库路径)返回 0;认证/授权/权限与未知错误返回 1
local err=$1 url path
if grep -qiE 'authentication failed|permission denied|unauthori[sz]ed|forbidden|HTTP[^0-9]*40[13]|\b40[13]\b|could not read (username|password)|terminal prompts disabled|invalid credentials|access denied|host key verification failed' <<<"$err"; then
return 1
fi
if grep -qiE 'could not resolve (host|hostname|proxy)|name or service not known|temporary failure in name resolution|nodename nor servname|connection timed out|operation timed out|timed out after|connection refused|connection reset|no route to host|network is unreachable|couldn.t connect to server' <<<"$err"; then
return 0
fi
grep -qF 'does not appear to be a git repository' <<<"$err" || return 1
url=$(git remote get-url origin 2>/dev/null) || return 1
case "$url" in
file://*) path=${url#file://} ;;
/*|./*|../*) path=$url ;;
*) return 1 ;;
esac
[ ! -e "$path" ]
}
target_history() { # $1=可选基线(如任务 R0;无缓存目标时代替比较)→ 输出 ok(通告提交为基线后代;传输不可达时缓存目标为基线后代或无缓存目标)、rewound(回退或分叉)或 uncomparable(认证/授权/未知错误、目标 ref 缺失、无基线、提交本地缺失或无法比较)
local base=${1:-} cached listing advertised rc=0
cached=$(git rev-parse -q --verify "refs/remotes/origin/${TARGET_BRANCH}^{commit}" 2>/dev/null) || cached=
if ! listing=$(git ls-remote origin "refs/heads/${TARGET_BRANCH}" 2>&1); then
if ! origin_transport_unavailable "$listing"; then echo uncomparable
elif [ -z "$cached" ]; then echo ok
elif [ -z "$base" ]; then echo uncomparable
else
git merge-base --is-ancestor "$base" "$cached" 2>/dev/null || rc=$?
case "$rc" in 0) echo ok ;; 1) echo rewound ;; *) echo uncomparable ;; esac
fi
return 0
fi
[ -n "$cached" ] || cached=$base
advertised=$(printf '%s\n' "$listing" \
| awk -v ref="refs/heads/${TARGET_BRANCH}" '$2 == ref && !seen { print $1; seen = 1 }')
[ -n "$advertised" ] && [ -n "$cached" ] || { echo uncomparable; return 0; }
git cat-file -e "${advertised}^{commit}" 2>/dev/null || { echo uncomparable; return 0; }
git merge-base --is-ancestor "$cached" "$advertised" 2>/dev/null || rc=$?
case "$rc" in 0) echo ok ;; 1) echo rewound ;; *) echo uncomparable ;; esac
}
check_worktree() { python3 "${SCRIPT_DIR}/check-worktree.py" "$1"; }
main_worktree_root() { git worktree list --porcelain -z | python3 -c 'import os,sys; print(os.fsdecode(sys.stdin.buffer.read().split(bytes([0]),1)[0][9:]))'; }
work_mode() {
case "${TASK_WORK_MODE:-delivery}" in
delivery|local) printf '%s\n' "${TASK_WORK_MODE:-delivery}" ;;
*) die "TASK_WORK_MODE must be delivery or local" ;;
esac
}
cmd_precheck() {
need_repo
local mode
mode=$(work_mode)
# local 模式刷新失败只标记远端新鲜度不可用,不拿缓存远端引用冒充最新;delivery 保持 REMOTE_UNVERIFIED。
local remote_fresh=1 fetch_err=""
if [ "$mode" = local ]; then
git check-ref-format "refs/heads/${TARGET_BRANCH}" >/dev/null || die "invalid target branch"
fetch_err=$(fetch_target 2>&1 >/dev/null) || remote_fresh=0
else
fetch_target
fi
local branch head rmain="" dirty sub_drift sub_uninit
branch=$(git rev-parse --abbrev-ref HEAD)
head=$(git rev-parse HEAD)
[ "$remote_fresh" -eq 1 ] && rmain=$(git rev-parse --verify "origin/${TARGET_BRANCH}")
dirty=$(git status --porcelain | { grep -v '^.. \.zcode/' || true; } | wc -l)
sub_drift=$(git submodule status --recursive 2>/dev/null | { grep -cE '^\+|^U' || true; })
sub_uninit=$(git submodule status --recursive 2>/dev/null | { grep -cE '^-' || true; })
local behind=0 ahead=0
echo "[repo] branch=${branch} head=$(echo "$head" | cut -c1-10)"
if [ "$remote_fresh" -eq 1 ]; then
behind=$(git rev-list --count "HEAD..origin/${TARGET_BRANCH}")
ahead=$(git rev-list --count "origin/${TARGET_BRANCH}..HEAD")
echo "[remote] origin/${TARGET_BRANCH}=$(echo "$rmain" | cut -c1-10) ahead=${ahead} behind=${behind}"
else
echo "[remote] origin/${TARGET_BRANCH}=unavailable — refresh failed; remote freshness unverified (cached ref not used)"
[ -n "$fetch_err" ] && printf '%s\n' "$fetch_err" | sed -e '/^\[verdict\]/d' -e 's/^/[remote] /'
fi
echo "[tree] $([ "$dirty" -eq 0 ] && echo clean || echo "dirty(${dirty})") submodules_drift=${sub_drift} uninit=${sub_uninit}"
local wt merged
wt=$(git worktree list | tail -n +2 | wc -l)
merged=""
if git rev-parse -q --verify "refs/remotes/origin/${TARGET_BRANCH}^{commit}" >/dev/null 2>&1; then
merged=$(git branch --merged "origin/${TARGET_BRANCH}" 2>/dev/null | { grep -vE 'main|master|\*' || true; } | tr -d ' +')
fi
[ "${sub_uninit:-0}" -gt 0 ] && echo "[warn] ${sub_uninit} 个子模块未初始化——构建/测试前需物化(不阻断写入)"
echo "[worktrees] linked=${wt}"
[ -n "$merged" ] && echo "[recycle] merged un-recycled branches: $(echo $merged)"
local open_cards="" pc pb pst
for pc in .task/*.md; do
[ -f "$pc" ] || continue
pb=$(card_of_branch "$pc"); [ -n "$pb" ] || continue
pst=$(card_status "$pc"); case "$pst" in 已完成*) continue ;; esac
card_pending "${pc##*/}" && continue
# card_merge 兼容 merge 提交与 squash 落地(squash 走 Gitea API 兜底,无 token 时只认 merge 提交);
# 其内部以完整消费输出取首行,避免 grep -q 早退令 git log 吃 SIGPIPE(141 under pipefail)。
if card_merged_into "$pc" "$pb" >/dev/null; then
open_cards="${open_cards} ${pc##*/}"
fi
done
[ -n "$open_cards" ] && echo "[cards] 已合入未闭合任务卡:$(echo $open_cards)——make task-close 回填归档(主检出运行)"
local done_open="" stale="" now ts ref
for pc in .task/*.md; do
[ -f "$pc" ] || continue
case "$(card_status "$pc")" in
已完成*) done_open="${done_open} ${pc##*/}" ;;
esac
done
[ -n "$done_open" ] && echo "[cards] 已完成但未归档:$(echo $done_open)"
local initiatives
initiatives=$(active_initiatives | cut -d'|' -f1)
echo "[initiatives] local cards (not authoritative): $([ -n "$initiatives" ] && echo $initiatives || echo none)"
now=$(date +%s)
while read -r ref ts; do
[ -n "$ref" ] || continue
git merge-base --is-ancestor "$ref" "origin/${TARGET_BRANCH}" 2>/dev/null && continue
if [ $((now - ts)) -gt 86400 ]; then
stale="${stale} ${ref#origin/}"
fi
done < <(git for-each-ref --format='%(refname:short) %(committerdate:unix)' "refs/remotes/origin/feature/card-close-*")
[ -n "$stale" ] && echo "[cards] 闭合分支超过 24 小时未合入:$(echo $stale)"
# [hooks] 声明≠能力:core.hooksPath 未指向 scripts/hooks 时,"钩子已启用"只是声明。
local hp; hp=$(git config core.hooksPath 2>/dev/null || true)
if [ "$hp" = "scripts/hooks" ] && [ -x scripts/hooks/pre-commit ]; then
echo "[hooks] core.hooksPath=scripts/hooks(pre-commit/post-merge 实际接线)"
else
echo "[hooks] WARN core.hooksPath='${hp:-未设置}'——本地钩子未生效,先 make setup;本检出提交将绕过本地闸(远端准入仍在)"
fi
# [facts] 外部事实登记表过期项(policy/facts.md;引用前先跑其验证命令,AGENTS.md 证据与验证门禁)。
if [ -f policy/facts.md ]; then
local fid lv ttl expired="" ftotal=0
while read -r factline; do
# 单元格内转义竖线(\|)先中和,与 facts-verify 的还原约定一致,避免命令列把日期列顶偏
factline=${factline//\\|/$'\x01'}
IFS='|' read -r _ fid _ _ lv ttl _ <<<"$factline"
fid=$(echo "${fid:-}" | tr -d ' '); case "$fid" in F[0-9]*) ;; *) continue ;; esac
ftotal=$((ftotal+1))
lv=$(echo "$lv" | tr -d ' '); ttl=$(echo "$ttl" | tr -d ' ')
case "$lv" in [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]) ;; *) expired="$expired ${fid}(日期格式)"; continue ;; esac
case "$ttl" in ''|*[!0-9]*) ttl=30 ;; esac
if [ $(( $(date +%s) - $(date -d "$lv" +%s) )) -gt $(( ttl*86400 )) ]; then
expired="$expired ${fid}(last=${lv},ttl=${ttl}d)"
fi
done < <(grep -E '^\| *F[0-9]+ ' policy/facts.md || true)
if [ -n "$expired" ]; then
echo "[facts] WARN 过期事实:$(echo $expired)——引用前先 make facts-verify 重验并更新登记表"
else
echo "[facts] policy/facts.md ${ftotal} 条事实均在有效期内"
fi
else
echo "[facts] WARN policy/facts.md 不存在——外部事实登记表缺失(AGENTS.md 证据与验证门禁)"
fi
echo "[required-reading] core/baseline.md, workflows/delivery.md §1$([ -f policy/delivery.md ] && echo ", policy/delivery.md")$([ "$dirty" -gt 0 ] && echo ", standards/collaboration.md(树脏=他人或未收尾现场,不得写入)")"
if [ "$mode" = local ] && [ "$remote_fresh" -eq 0 ]; then
echo "[target] unavailable(remote freshness unverified; task initial baseline is reported separately)"
elif [ "$mode" = local ]; then
echo "[target] ${rmain}(observed; task initial baseline is reported separately)"
else
echo "[R0] $(echo "$rmain" | cut -c1-10)(完整:$rmain)"
fi
if [ "$dirty" -gt 0 ] || [ "${sub_drift:-0}" -gt 0 ]; then
echo "[verdict] SHARED_WORKSPACE_BUSY — 工作树不干净,不得写入;先收尾或经 owner 确认"
exit 3
fi
if [ "$mode" = local ]; then
if [ "$remote_fresh" -eq 0 ]; then
# 以活动任务卡登记的 R0 为比较基线(不把它当作远端最新值);先于任务上下文核验,使缓存目标与 R0 矛盾时给出明确判定。
case "$(target_history "$(card_r0 "$(branch_card "$branch")")")" in
rewound)
echo "[verdict] REMOTE_UNVERIFIED — origin/${TARGET_BRANCH} rewound or diverged from the known target; preserve local work and reconcile the target history"
return 6 ;;
uncomparable)
echo "[verdict] REMOTE_UNVERIFIED — origin/${TARGET_BRANCH} is missing or its advertised commit cannot be compared with the known target; preserve local work and retry the refresh"
return 6 ;;
esac
fi
python3 "${SCRIPT_DIR}/evidence.py" --local-context --active || return 2
[ "$remote_fresh" -eq 1 ] || echo "[remote-freshness] unavailable — LOCAL_READY rests on local task context only"
echo "[verdict] LOCAL_READY — existing task may continue authorized local work; final delivery still requires latest-target sync and checks"
return 0
fi
if ! check_worktree before-sync; then
echo "[verdict] SHARED_WORKSPACE_BUSY — component or active operation check failed; preserve the workspace"
exit 3
fi
if [ "$behind" -gt 0 ]; then
echo "[verdict] SYNC_REQUIRED — ${branch} 缺少 origin/${TARGET_BRANCH} 的 ${behind} 个提交;主检出 pull --ff-only,任务分支 make task-sync"
exit 4
fi
echo "[verdict] READY — 保留本次 precheck receipt 后建任务工作区:PRECHECK_RECEIPT=/tmp/task-precheck.json make task-start NAME=feature/your-branch"
}
cmd_start() {
need_repo
local name="${1:-}"; [ -n "$name" ] || die "usage: task.sh start <feature|fix|hotfix|release>/<slug>"
grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' <<<"$name" || 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>"
grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' <<<"$initiative" || die "invalid parent initiative branch"
case "${name#*/}" in "${initiative#*/}"-?*) ;; *) die "child slug must start with parent initiative slug plus '-'" ;; esac
fi
local slug planned_child=0
slug=$(echo "$name" | tr '/' '-')
if [ -z "${TRIAGE_RECEIPT:-}" ]; then
[ "$kind" = child ] || die "TRIAGE_RECEIPT required: make task-triage REQ='<需求>' TRIAGE_RECEIPT=<path> and decide first (no branch created)"
planned_child=1
fi
fetch_target
if [ -z "${PRECHECK_RECEIPT:-}" ]; then
PRECHECK_RECEIPT=$(mktemp "${TMPDIR:-/tmp}/task-precheck-${slug}.XXXXXX")
# evidence 捕获只接受未占用路径(独占创建):mktemp 只用来取唯一名。
rm -f -- "$PRECHECK_RECEIPT"
# start 只接受严格 READY 的 delivery precheck,local 模式的 LOCAL_READY 不能用于建任务。
(cd "$(git rev-parse --show-toplevel)" \
&& TASK_WORK_MODE=delivery RECEIPT="$PRECHECK_RECEIPT" CMD='make precheck' bash "${SCRIPT_DIR}/evidence.sh") \
|| die "auto precheck is not READY (receipt=${PRECHECK_RECEIPT}); resolve the verdict above first (no branch created)"
echo "[precheck] auto receipt=${PRECHECK_RECEIPT}"
fi
PRECHECK_RECEIPT=$(realpath -- "$PRECHECK_RECEIPT")
VALIDATE_RECEIPT="$PRECHECK_RECEIPT" VALIDATE_RECEIPT_PURPOSE=start bash "${SCRIPT_DIR}/evidence.sh"
local triage_out triage_line triage_head="" triage_verdict triage_init triage_rsha triage_digest triage_note
local parent_head="" parent_source="" parent_card
if [ "$planned_child" -eq 1 ]; then
# 计划内子任务免分流:父 initiative 卡片的子任务登记已列出本分支(delivery 读 origin,local 读本地父分支)。
if [ "$(work_mode)" = delivery ]; then
parent_head=$(remote_parent_head "$initiative") || die "remote parent initiative ${initiative} unavailable; no worktree or branch was created"
parent_source=origin
else
parent_head=$(git rev-parse --verify "refs/heads/${initiative}^{commit}" 2>/dev/null) || die "parent initiative branch ${initiative} missing locally"
parent_source="local(非正式准入)"
fi
parent_card=".task/$(echo "$initiative" | tr '/' '-').md"
git show "${parent_head}:${parent_card}" 2>/dev/null | registered_child "$name" \
|| die "${name} is not registered in ${initiative} ${parent_card} ## 子任务登记; register it in the parent card first or provide TRIAGE_RECEIPT (no worktree or branch was created)"
triage_note="- 需求分流:免(父 initiative 子任务登记已列出 ${name};parent_head_sha=${parent_head})"
else
# 分流核验必须先于任何 worktree/分支创建:收据与当前远端候选不一致则无副作用拒绝。
[ -f "$TRIAGE_RECEIPT" ] || die "triage receipt missing: ${TRIAGE_RECEIPT} (no branch created)"
TRIAGE_RECEIPT=$(realpath -- "$TRIAGE_RECEIPT")
local -a triage_args=(verify --receipt "$TRIAGE_RECEIPT" --kind "$kind")
if [ "$kind" = child ]; then triage_args+=(--initiative "$initiative"); fi
if ! triage_out=$(python3 "${SCRIPT_DIR}/task_triage.py" "${triage_args[@]}"); then
printf '%s\n' "$triage_out" >&2
die "triage receipt rejected; no worktree or branch was created"
fi
triage_line=$(printf '%s\n' "$triage_out" | sed -n 's/^\[triage-verified\] //p')
triage_head=$(triage_field selected_head_sha "$(printf '%s\n' "$triage_out" | sed -n 's/^\[triage-head\] //p')")
triage_verdict=$(triage_field verdict "$triage_line")
triage_init=$(triage_field selected_initiative "$triage_line")
triage_rsha=$(triage_field requirement_sha256 "$triage_line")
triage_digest=$(triage_field receipt_sha256 "$triage_line")
[ -n "$triage_verdict" ] && [ -n "$triage_rsha" ] && [ -n "$triage_digest" ] || die "triage verification output unparsable; no branch created"
triage_note="- 需求分流:verdict=${triage_verdict};selected_initiative=${triage_init:-无};requirement_sha256=${triage_rsha};triage_receipt=${TRIAGE_RECEIPT};receipt_sha256=${triage_digest}"
fi
check_worktree before-sync
# 已合入的同名分支不得重建(2026-10-05 实测:合入后再 task-start 同名会从新 main 长出
# 第二段历史,空卡覆盖已合入任务卡,两轮证据差点丢失)。复核/追加沿用原分支或换新 slug。
local guard_ref
for guard_ref in "refs/heads/${name}" "refs/remotes/origin/${name}"; do
if git rev-parse -q --verify "${guard_ref}" >/dev/null \
&& git merge-base --is-ancestor "${guard_ref}" "origin/${TARGET_BRANCH}"; then
die "branch ${name} already merged into origin/${TARGET_BRANCH} — reuse the original branch or pick a new slug (task-start must not recreate merged cards)"
fi
done
[ "$(git status --porcelain | { grep -v '^.. \.zcode/' || true; } | wc -l)" -eq 0 ] || die "dirty tree — resolve first (precheck verdict BUSY)"
[ "$(git submodule status --recursive 2>/dev/null | { grep -cE '^\+|^U' || true; })" -eq 0 ] || die "submodule drift/conflict — resolve first (precheck verdict BUSY)"
git merge-base --is-ancestor "origin/${TARGET_BRANCH}" HEAD || die "local ${TARGET_BRANCH} behind origin — pull --ff-only first"
local rmain wt mainroot start_ref parent_note=""
rmain=$(git rev-parse "origin/${TARGET_BRANCH}")
start_ref=$rmain
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)
if [ "$planned_child" -eq 1 ]; then
: # 父头已在登记核验时解析(delivery 为刚取回的 origin 头)。
elif [ "$(work_mode)" = delivery ]; then
# 交付模式:父 initiative 只认 origin 上刚取回的头,且必须等于分流收据核验的头(未准入的本地提交不算)。
[ -n "$triage_head" ] || die "triage receipt carries no selected initiative head; re-run make task-triage (no worktree or branch was created)"
parent_head=$(remote_parent_head "$initiative") || die "remote parent initiative ${initiative} unavailable; no worktree or branch was created"
[ "$parent_head" = "$triage_head" ] || die "remote ${initiative} is ${parent_head} but triage verified ${triage_head}; re-run make task-triage (no worktree or branch was created)"
parent_source=origin
else
parent_head=$(git rev-parse --verify "refs/heads/${initiative}^{commit}" 2>/dev/null) || die "parent initiative branch ${initiative} missing locally"
parent_source="local(非正式准入)"
fi
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:-无};parent_source:${parent_source}" ;;
*)
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"
grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' <<<"$TASK_PARENT_BRANCH" || die "invalid parent task branch"
grep -qE '^https?://[^[:space:]]+/pulls/[0-9]+$' <<<"$TASK_PARENT_PR" || die "TASK_PARENT_PR must be a parent PR URL"
case "${name#*/}" in "${TASK_PARENT_BRANCH#*/}"-?*) ;; *) die "stacked child slug must start with parent slug plus '-'" ;; esac
parent_head=$(git rev-parse --verify "refs/heads/${TASK_PARENT_BRANCH}^{commit}") || die "parent branch missing locally"
git merge-base --is-ancestor "$rmain" "$parent_head" || die "parent branch does not include current origin/${TARGET_BRANCH}; sync it first"
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
# 落点须为主检出的平级目录:$PWD 推算在子目录(或任务 worktree 内)运行时会算错,
# 以 Git 登记表定位主检出,兼容 linked worktree 与独立 git-dir。
mainroot=$(main_worktree_root)
wt="$(dirname "$mainroot")/$(basename "$mainroot")-wt-${slug}"
[ -e "$wt" ] && die "worktree path exists: $wt"
git worktree add -q "$wt" -b "$name" "$start_ref"
# 物化后按 submodule status 实际状态核对,不只信退出码:网络抖动或嵌套指针
# 漂移时 update 可能静默留坑(2026-09-30 实测深嵌套层带病通过,构建期才炸)。
(cd "$wt" && git submodule update --init --recursive --jobs 8 >/dev/null 2>&1) \
|| echo "[warn] submodule update 非零退出,见下方状态核对"
local sub_drift
sub_drift=$(git -C "$wt" submodule status --recursive 2>/dev/null | grep -cE '^[-+U]' || true)
[ "${sub_drift:-0}" -gt 0 ] \
&& echo "[warn] worktree 子模块物化不完整:${sub_drift} 项漂移/未初始化——构建前先核对(git -C $wt submodule status --recursive)"
local card="$wt/.task/${slug}.md"
mkdir -p "$wt/.task"
# 模板:initiative→章程卡;child 与独立 fix/hotfix→精简卡;其余独立任务→完整卡。机读行在三者中逐字一致。
local template=full
case "$kind" in
initiative) template=charter ;;
child) template=lite ;;
*) case "$name" in fix/*|hotfix/*) template=lite ;; esac ;;
esac
local origin_url owner
origin_url=$(git -C "$wt" remote get-url origin 2>/dev/null || echo '(local)')
owner=$(git config user.name || echo unknown)
{
cat <<EOF
# ${name} 任务卡
任务主记录:<PR 链接,交付前填写>;状态:进行中;owner:${owner}
EOF
[ "$template" = lite ] || printf '## 基线与授权\n\n'
cat <<EOF
- 权威仓:${origin_url};目标分支:${TARGET_BRANCH};R0(initial_base_sha)= ${rmain}
- 工作区:${wt}(worktree);分支:${name}
${branch_meta}
${triage_note}
${parent_note}
- 授权与交付终点:<本次允许的动作与终点;未授权事项写明>
- 交付模式:goal-first(目标、范围和交付终点一次确认;范围内连续推进)
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md)
- 合入执行:agent(已授权交付范围内,Review 通过、冲突解决且当前 HEAD 的 CI 通过后合入)
EOF
[ "$template" = lite ] || cat <<'EOF'
- 人工决策点:仅限目标/范围/验收变更、重大取舍、高后果操作或无法自动判定的业务验收
- 当前阶段:需求拆解;当前增量:I1(待定义)
EOF
cat <<'EOF'
- 流程依据:仓库根目录 policy/delivery.md(归档后仍按仓库根目录定位)
## 目标与非目标
EOF
if [ "$template" = charter ]; then
cat <<'EOF'
<章程:用途与目标、接纳范围与非目标、方案/计划链接、分级测试策略、验收条件、追加领域规则、生命周期。>
## 子任务登记
| 子任务分支 | 必需 | 状态 | PR |
|---|---|---|---|
| 待登记 | 否 | — | — |
EOF
else
echo '<预期结果、受影响入口/模块/契约;明确不做什么。>'
fi
cat <<'EOF'
## Agent 实施收据
路由与 fail-stop 规则见根目录 AGENT-EXECUTION.md 与 policy/delivery.md;每次尝试一行,原始日志留在源码外收据路径。
| 单元 ID | 预期范围 | 配置摘要 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|---|---|---|---|---|---|---|---|---|---|---|
| 待定义 | — | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | — |
EOF
[ "$template" = lite ] || cat <<'EOF'
## 关联方向与规划
- 适用状态:待核对(无适用规划时说明原因)
- 主记录路径/版本:待核对(路径按仓库根目录定位,归档后不变;只引用唯一正文)
- 对应里程碑:待核对
## 需求分析
| ID | 描述 | 项目符合度及依据 | 优先级 | 验收条件 | 依赖 | 结论 |
|---|---|---|---|---|---|---|
| R1 | 待填写 | 待评估 | 待定 | 待定义 | 待确认 | 待评审 |
## 方案与计划
- 方案引用:待填写(含对象版本)
- 实施计划引用:待填写(含增量与需求 ID)
- 测试计划引用:待填写(含需求 ID 与验收条件)
## 阶段记录
| 阶段 | 对象版本 | 状态 | 决策/执行者 | 时间 | 依据 |
|---|---|---|---|---|---|
| 需求拆解 | 待定 | 待执行 | — | — | — |
| 需求评估 | 待定 | 待执行 | — | — | — |
| 需求表确认 | 待定 | 待执行 | — | — | — |
| 方案设计 | 待定 | 待执行 | — | — | — |
| 实施计划 | 待定 | 待执行 | — | — | — |
| 实施与自检 | 待定 | 待执行 | — | — | — |
| 正式测试与验收 | 待定 | 待执行 | — | — | — |
| 代码 Review | 待定 | 待执行 | — | — | — |
| 合入 | 待定 | 待执行 | — | — | — |
| 合入核验与收口 | 待定 | 待执行 | — | — | — |
## 探索试验与迭代
| 增量/试验 | 假设 | 范围 | 判断和停止条件 | 授权/决策依据 | 结果 | 变更影响 | 需重审补测 |
|---|---|---|---|---|---|---|---|
| 待定义 | — | — | — | 待核对 | NOT_RUN | 待评估 | 待评估 |
EOF
cat <<EOF
## 同步记录
| 节点 | sync_base_sha | tested_head_sha | checked_at |
|---|---|---|---|
| 启动 | ${rmain} | $(git -C "$wt" rev-parse HEAD) | $(date -u +%FT%TZ) |
## 证据(Evidence)
<!-- CMD="<命令>" make evidence 写入;手抄/转写输出视为无效证据(AGENTS.md 证据与验证门禁)。 -->
| 时间(UTC) | 命令 | 被测HEAD | exit | 来源 | sha256(16) |
|---|---|---|---|---|---|
EOF
[ "$template" = lite ] || cat <<'EOF'
## 交付调度
- 调度状态:未入队;调度记录:无
- entry_id:无;attempt_id:无;窗口执行者:无
- 固定候选:无(base/head、组件与相关配置引用实际尝试)
- 调度依据:policy/delivery.md#合入调度
- 固定后证据:源码外 RECEIPT;实际结果后按授权原样导入和闭合
EOF
cat <<'EOF'
## 结果与验证(完成后填写)
- 结果:
- 验证:<结论摘要 PASS/FAIL/SKIP/NOT_RUN 逐项;逐条证据由 make evidence 写入上方证据表>
- 影响与剩余项:
EOF
[ "$template" = lite ] || cat <<'EOF'
## 规划进展与下一步
- 本次推进/剩余:待核对(依据实际成果更新适用方案/规划)
- 首选下一任务:待核对(无适用规划、已完成或受阻时如实说明,不虚构任务)
- 推荐依据:待填写
- 前置条件/待确认:待核对
- 预期产物/验收条件:待定义
- 重复任务核对:待核对(已有/在途任务、PR 与规划记录)
- 启动授权:未授权(建议不自动启动新任务,仍须明确授权)
EOF
cat <<'EOF'
## 合入与清理
- Review 记录:NOT_RUN(审阅智能体/平台身份、base/head、结论、问题闭合及重放依据)
- 冲突记录:NOT_RUN(最新目标同步、冲突处置、组合兼容核验及复审/补测)
- CI 记录:NOT_RUN(当前 HEAD 完整 SHA、全部必需检查、运行链接、执行身份及 GATE 结果)
- 合入记录:NOT_RUN(交付授权范围、PR、被核验 HEAD、合入 SHA、目标分支及内容核验)
- 清理记录:NOT_RUN(授权依据、写者退出确认、worktree/分支/任务卡处置)
EOF
} > "$card"
(cd "$wt" && IMPORT_RECEIPT="$PRECHECK_RECEIPT" bash "${SCRIPT_DIR}/evidence.sh")
git -C "$wt" add .task
git -C "$wt" commit -q -m "Record the task card for ${slug}" \
-m "Why: establish the authorized task workspace and its initial baseline." \
-m "Evidence: preserved precheck receipt in .task/${slug}.md; R0=${rmain}." \
-m "Impact: task metadata only; the feature workspace starts at ${start_ref}." \
-m "Refs: .task/${slug}.md"
echo "[card] $card(已随分支提交)"
echo "[started] branch=${name} worktree=${wt} R0=$(echo "$rmain" | cut -c1-10)"
}
cmd_resume() {
need_repo
local mode
mode=$(work_mode)
[ "$mode" = local ] || fetch_target
[ -n "${PRECHECK_RECEIPT:-}" ] || die "PRECHECK_RECEIPT required: capture the current task precheck before resume"
local receipt remote_fresh=1
receipt=$(realpath -- "$PRECHECK_RECEIPT")
VALIDATE_RECEIPT="$receipt" VALIDATE_RECEIPT_PURPOSE="resume-${mode}" bash "${SCRIPT_DIR}/evidence.sh"
# 立即目标:子任务为其父 initiative(delivery 下取远端头),其余为根目标。
local branch card immediate="${TARGET_BRANCH}" observed recorded
branch=$(git rev-parse --abbrev-ref HEAD)
card=$(branch_card "$branch")
[ -n "$card" ] && immediate=$(card_immediate_target "$card")
if [ "$mode" = local ]; then
# local 先核验收据与活动任务再刷新:刷新成功按新目标复核;失败只在远端确实不可达时以登记 R0 继续,
# 远端可达而目标缺失、回退/分叉或无法比较时保持 REMOTE_UNVERIFIED,不导入收据。
git check-ref-format "refs/heads/${TARGET_BRANCH}" >/dev/null || die "invalid target branch"
if fetch_target 2>/dev/null; then
VALIDATE_RECEIPT="$receipt" VALIDATE_RECEIPT_PURPOSE=resume-local bash "${SCRIPT_DIR}/evidence.sh"
else
remote_fresh=0
local r0 ls_rc=0
[ -n "$card" ] || die "no registered task card for ${branch}; receipt not imported"
r0=$(card_r0 "$card")
[ -n "$r0" ] || die "task card has no initial baseline; receipt not imported"
git ls-remote --exit-code --heads origin "refs/heads/${TARGET_BRANCH}" >/dev/null 2>&1 || ls_rc=$?
if [ "$ls_rc" -eq 2 ]; then
echo "[verdict] REMOTE_UNVERIFIED — origin is reachable but ${TARGET_BRANCH} is missing; preserve local work, receipt not imported" >&2
return 6
fi
case "$(target_history "$r0")" in
rewound)
echo "[verdict] REMOTE_UNVERIFIED — origin/${TARGET_BRANCH} rewound or diverged from the known target; preserve local work, receipt not imported" >&2
return 6 ;;
uncomparable)
echo "[verdict] REMOTE_UNVERIFIED — advertised origin/${TARGET_BRANCH} cannot be compared with the known target; preserve local work, receipt not imported" >&2
return 6 ;;
esac
echo "[remote-freshness] unavailable — resuming on registered R0=${r0} for authorized local work only; delivery still requires a fresh target"
fi
fi
if [ "$immediate" != "${TARGET_BRANCH}" ]; then
recorded=$(card_field "$card" parent_head_sha)
if [ "$mode" = local ]; then
# local 续作只以登记的父头为比较基准:缺失、非完整 SHA 或本地无此提交时不得回落到本地父分支。
local sha_len=40
[ "$(git rev-parse --show-object-format 2>/dev/null)" = sha256 ] && sha_len=64
if ! [[ "$recorded" =~ ^[0-9a-f]+$ ]] || [ "${#recorded}" -ne "$sha_len" ] \
|| ! git cat-file -e "${recorded}^{commit}" 2>/dev/null; then
echo "[verdict] REMOTE_UNVERIFIED — registered parent_head_sha for ${immediate} is missing, malformed or not available locally; preserve local work, receipt not imported" >&2
return 6
fi
fi
observed=$(resolve_parent_ref "$immediate") || die "immediate target ${immediate} unavailable; receipt not imported"
if [ -n "$recorded" ] && ! git merge-base --is-ancestor "$recorded" "$observed"; then
die "immediate target ${immediate} no longer contains recorded parent head ${recorded}; receipt not imported"
fi
elif [ "$remote_fresh" -eq 1 ]; then
observed=$(git rev-parse "origin/${TARGET_BRANCH}")
else
observed=unavailable
fi
IMPORT_RECEIPT="$receipt" bash "${SCRIPT_DIR}/evidence.sh"
echo "[resumed] work_mode=${mode} source_head=$(git rev-parse HEAD) observed_target=${observed} immediate_target=${immediate}"
}
cmd_sync() {
need_repo
fetch_target
check_worktree before-sync
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"
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"
check_worktree before-update
check_worktree materialize
check_worktree after-sync
head=$(git rev-parse HEAD)
if [ -n "$card" ] && [ -f "$card" ]; then
if [ "$changed" -eq 1 ]; then
printf '| 同步(记录前对象) | %s | %s | %s |\n' "$base" "$head" "$(date -u +%FT%TZ)" >> "$card"
git add "$card"
git commit -q -m "Record the sync checkpoint" \
-m "Why: bind the task validation checkpoint to the integration baseline." \
-m "Evidence: SELF recursive component checks; sync_base=${base}; before_record=${head}." \
-m "Impact: task metadata only." -m "Refs: ${card}" -- "$card"
fi
# JSON receipts carry before/after state; older table rows remain readable.
python3 "${SCRIPT_DIR}/check-evidence.py" "$card"
else
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} sync_target=${immediate}"
echo "[next] 跑正式验收(make test 等),证据写入任务卡"
}
# ---- 卡片闭合(防欠账)----
# 依据:delivery.md §5 治理清理;机制化 2026-09-30 治理缺口(11 张已合入卡片未闭合)。
# 检测 .task/*.md 中"分支已合入 origin/<target> 但状态非已完成"的卡片:回填状态、主记录
# (PR 链接+合并 SHA)与闭合记录,并 git mv 到 .task/archive/。变更落点:主检出运行→
# 新建 feature/card-close-* 分支推送(集成分支受保护,不直推);任务分支内运行须 --fold
# (随当前任务 PR 合入)。合并判定用合并提交主题(分支删除后仍可追溯),不依赖分支存活。
# 默认闭合方式是 close --self:任务分支在合入前把本分支的卡改为"已完成(合入前闭合)"并归档,
# 随本 PR 合入;上述合入后回填只用于遗留卡。
card_of_branch() { sed -n '/^- 工作区:.*分支:\([A-Za-z0-9._/-]\+\).*/{s//\1/p;q;}' "$1"; }
card_status() { sed -n '/^.*;状态:\([^;]*\).*/{s//\1/p;q;}' "$1"; }
# 两级分支元数据行:'- 分支类型:<kind>;initiative_id:…;root_target:…;immediate_target:…;accepting_requirements:…'。
# 旧卡没有该行:按 standalone、root/immediate 均为目标分支、不接收新需求处理。
card_field() { sed -n "/^- 分支类型:.*;$2:\([^;]*\).*/{s//\1/p;q;}" "$1" 2>/dev/null; }
card_r0() { sed -n '/^- 权威仓:.*;R0(initial_base_sha)= \([0-9a-f]\+\)[[:space:]]*$/{s//\1/p;q;}' "$1" 2>/dev/null; }
card_branch_kind() { local v; v=$(sed -n '/^- 分支类型:\([^;]*\).*/{s//\1/p;q;}' "$1" 2>/dev/null); 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}"; }
PRE_MERGE_CLOSED="已完成(合入前闭合)"
branch_card() { # $1=branch → 当前检出中该分支的任务卡路径(无则空)
# 顶层优先;顶层无卡时只认 .task/archive/ 中状态为"已完成(合入前闭合)"的卡(close --self 合入前已归档)。
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
for pc in .task/archive/*.md; do
[ -f "$pc" ] || continue
[ "$(card_of_branch "$pc")" = "$1" ] || continue
case "$(card_status "$pc")" in "${PRE_MERGE_CLOSED}"*) printf '%s\n' "$pc"; return 0 ;; esac
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_merged_into "$pc" "$pb" >/dev/null && continue
printf '%s|%s|%s\n' "$pb" "$(card_initiative_id "$pc")" "$pc"
done
}
remote_parent_head() { # $1=父 initiative 分支 → 打印刚从 origin 取回的头(不看本地);不可查询返回 6,远端缺失返回 1
git check-ref-format "refs/heads/$1" >/dev/null || die "invalid parent branch $1"
local rc=0
git ls-remote --exit-code --heads origin "refs/heads/$1" >/dev/null 2>&1 || rc=$?
if [ "$rc" -eq 2 ]; then echo "ERROR: parent branch $1 not found on origin" >&2; return 1; fi
if [ "$rc" -ne 0 ]; then echo "[verdict] REMOTE_UNVERIFIED — cannot query origin/$1; preserve local work and retry" >&2; return 6; fi
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
git rev-parse --verify "refs/remotes/origin/$1^{commit}"
}
registered_child() { # stdin=父 initiative 卡片;$1=子任务分支 → 子任务登记表(task_pr_check 同一解析)列出该分支返回 0
python3 -c 'import sys
sys.path.insert(0, sys.argv[1])
from task_pr_check import Stop, child_registry
try:
rows = child_registry(sys.stdin.read())
except Stop as error:
sys.exit("ERROR: " + error.message)
sys.exit(0 if any(row["子任务分支"].strip("`").strip() == sys.argv[2] for row in rows) else 1)' "$SCRIPT_DIR" "$1"
}
resolve_parent_ref() { # $1=父 initiative 分支 → 打印用于同步/核验的提交(delivery 仅远端;local 取远端与本地包含对方的一侧,远端可达但父分支缺失返回 6,仅远端不可查询时取本地)
git check-ref-format "refs/heads/$1" >/dev/null || die "invalid parent branch $1"
if [ "$(work_mode)" = delivery ]; then
# delivery 只认 origin 上刚取回的父分支头:本地领先/分叉意味着未准入的提交,不得被同步或合入。
local remote_head local_head
remote_head=$(remote_parent_head "$1") || return $?
local_head=$(git rev-parse -q --verify "refs/heads/$1^{commit}" 2>/dev/null || true)
if [ -n "$local_head" ] && ! git merge-base --is-ancestor "$local_head" "$remote_head"; then
die "local $1 has commits origin lacks; push or reconcile them first (delivery mode uses origin only)"
fi
printf '%s\n' "$remote_head"
return 0
fi
local remote="" local_ref="" rc=0 ls_err
ls_err=$(git ls-remote --exit-code --heads origin "refs/heads/$1" 2>&1 >/dev/null) || 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" -eq 2 ]; then
# 远端可达而登记的父分支缺失:权威侧已不认该父分支,本地同名分支不得替代。
echo "[verdict] REMOTE_UNVERIFIED — origin is reachable but $1 is missing; preserve local work and reconcile the parent initiative" >&2
return 6
elif ! origin_transport_unavailable "$ls_err"; then
# 认证/授权/权限或未知错误不等于远端不可达:权威侧状态未知,不得以本地父分支替代。
echo "[verdict] REMOTE_UNVERIFIED — cannot verify origin/$1 (non-transport remote error); preserve local work and retry" >&2
return 6
else
# 远端传输不可达时只把已登记的本地父分支作为局部续作参考;不声称其为最新或可交付,调用方仍核验 parent_head_sha。
local_ref=$(git rev-parse -q --verify "refs/heads/$1^{commit}" 2>/dev/null || true)
if [ -z "$local_ref" ]; then
echo "[verdict] REMOTE_UNVERIFIED — cannot query origin/$1 and no local $1 exists; preserve local work and retry" >&2
return 6
fi
echo "[remote-freshness] origin/$1 unavailable — using local $1 for authorized local continuation only; not current or delivery-ready" >&2
printf '%s\n' "$local_ref"
return 0
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
}
# 需求分流以 origin 为准(scripts/task_triage.py):远端 open PR + 远端分支卡片;本地 .task/ 不参与发现。
# 计数不自动选择 JOIN;候选存在时必须经 triage-decide 记录显式决策。
cmd_triage() {
need_repo
[ -n "${TRIAGE_RECEIPT:-}" ] || die "TRIAGE_RECEIPT required: make task-triage REQ='<需求>' TRIAGE_RECEIPT=/tmp/<name>.json"
local req="${*:-}"
[ -n "$req" ] || die "requirement text required: make task-triage REQ='<需求>' TRIAGE_RECEIPT=<path>"
python3 "${SCRIPT_DIR}/task_triage.py" discover --receipt "$TRIAGE_RECEIPT" --requirement "$req"
}
cmd_triage_decide() {
need_repo
[ -n "${TRIAGE_RECEIPT:-}" ] || die "TRIAGE_RECEIPT required: make task-triage-decide TRIAGE_RECEIPT=<path> VERDICT=JOIN|NEW REASON='<理由>'"
local -a args=(decide --receipt "$TRIAGE_RECEIPT" --verdict "${VERDICT:-}" --reason "${REASON:-}")
if [ -n "${INITIATIVE:-}" ]; then args+=(--initiative "$INITIATIVE"); fi
python3 "${SCRIPT_DIR}/task_triage.py" "${args[@]}"
}
# triage_field KEY "key=value key2=value2" → value(值不含空格)
triage_field() { printf '%s\n' "$2" | tr ' ' '\n' | sed -n "s/^$1=//p"; }
card_merge() { # $1=branch → "sha|subject|pr"(该分支合入 origin/<target> 的记录;兼容 merge 提交与 squash 落地)
local hit pr
hit=$(git log --merges --format='%H|%s' "origin/$2" 2>/dev/null \
| grep -F " from $1 into $3" | sed -n '1p') || hit=""
if [ -n "$hit" ]; then
pr=$(printf '%s' "$hit" | sed -n 's/.*(#\([0-9]\+\)) from.*/\1/p')
printf '%s|%s\n' "$hit" "${pr:-unknown}"
return 0
fi
card_merge_squash "$1" "$2"
}
# 卡片闭合判定:子任务优先核直接目标(父 initiative 仍在远端);父已合入并删除则回落到根目标历史。
# 打印 "目标分支|merge_sha|subject|pr";未合入或不可核则失败。参数:$1=card $2=branch
card_merged_into() {
local card="$1" branch="$2" imm root rc=0 hit
imm=$(card_immediate_target "$card"); root=$(card_root_target "$card")
if [ "$imm" != "$root" ]; then
git ls-remote --exit-code --heads origin "refs/heads/${imm}" >/dev/null 2>&1 || rc=$?
case "$rc" in
0)
git fetch -q origin "refs/heads/${imm}:refs/remotes/origin/${imm}" 2>/dev/null || return 1
hit=$(card_merge "$branch" "$imm" "$imm") || return 1
printf '%s|%s\n' "$imm" "$hit"
return 0 ;;
2) ;; # 父 initiative 已不在远端:子任务合入记录仍在根目标历史中
*) return 1 ;;
esac
fi
hit=$(card_merge "$branch" "$root" "$imm") || return 1
printf '%s|%s\n' "$root" "$hit"
}
card_merge_squash() { # $1=branch $2=ref → squash/rebase 落地没有 merge 提交,经 Gitea API 按 head 分支找 merged PR。
# 返回 "merge_commit_sha|PR标题|PR号";sha 必须可核为 origin/<target> 祖先,否则不认(不可核即不闭合)。
# 仅查最近 5 页(250 条):card_merge 的 merge 提交路径仍是主路径,此兜底面向新近任务卡。
local token="${GITEA_TOKEN:-}" web_base url repo owner api payload pg found sha
[ -n "$token" ] || return 1
web_base="${TASK_CARD_WEB_BASE:-https://git-home.u1680832.nyat.app:30319}"
url=$(git remote get-url origin 2>/dev/null) || return 1
url="${url%.git}"
repo=$(basename "$url"); owner=$(basename "$(dirname "$url")")
api="${web_base}/api/v1/repos/${owner}/${repo}/pulls"
found=""
for pg in 1 2 3 4 5; do
payload=$(curl -fsS -m 15 -H "Authorization: token ${token}" "${api}?state=all&limit=50&page=${pg}" 2>/dev/null) || payload=""
[ -n "$payload" ] || break
found=$(printf '%s' "$payload" | CARD_BRANCH="$1" python3 -c '
import json, os, sys
branch = os.environ.get("CARD_BRANCH", "")
try:
entries = json.load(sys.stdin)
except Exception:
entries = []
for p in entries if isinstance(entries, list) else []:
if not isinstance(p, dict):
continue
head = p.get("head") or {}
ref = head.get("ref") or ""
label = head.get("label") or ""
# head 分支被删后 Gitea 把 ref 换成 refs/pull/N/head,原始分支名留在 label
# (同仓为裸分支名,fork 为 owner:branch)。
if branch != ref and branch != label and not label.endswith(":" + branch):
continue
if not p.get("merged"):
continue
sha, title, num = p.get("merge_commit_sha") or "", p.get("title") or "", str(p.get("number") or "")
if sha and title and num:
print(f"{sha}|{title}|{num}")
break
') && [ -n "$found" ] && break
done
[ -n "$found" ] || return 1
sha="${found%%|*}"
git merge-base --is-ancestor "$sha" "origin/$2" 2>/dev/null || return 1
printf '%s\n' "$found"
}
card_pending() { # $1=卡片文件名——闭合是否已在某 origin/feature/card-close-* 分支待合入
local cb
for cb in $(git for-each-ref --format='%(refname:short)' "refs/remotes/origin/feature/card-close-*"); do
git cat-file -e "${cb}:.task/archive/$1" 2>/dev/null && return 0
done
return 1
}
card_backfill() { # $1=card $2=pr_num $3=merge_sha $4=merge_subject $5=web_base $6=contained_in_target(原地改写)
python3 - "$1" "$2" "$3" "$4" "$5" "$6" <<'PY'
import re, sys
path, pr, sha, subject, base, target = sys.argv[1:7]
s = open(path, encoding='utf-8').read()
s = re.sub(r'状态:[^;\n]*', '状态:已完成(回填)', s, count=1)
m = re.search(r'任务主记录:([^;\n]*)', s)
if m and 'pulls/' not in m.group(1):
s = s.replace(m.group(1), f'PR #{pr} {base}/pulls/{pr}(merge {sha[:10]})', 1)
s = s.replace('## 结果与验证(完成后填写)', '## 结果与验证', 1)
s = s.rstrip('\n') + f'''
## 闭合记录(card-autoclose 回填)
- 依据:merge {sha}(PR #{pr})已包含于 origin/{target},分支成果已落盘;本段为合入后回填。
- 合并主题:{subject}
- 归档:随闭合移入 .task/archive/;任务执行期的过程证据见原任务记录与 PR。
'''
open(path, 'w', encoding='utf-8').write(s)
PY
}
self_close_pr() { # $1=branch → 打印 Gitea 上 head 为该分支的开放 PR 号(空格分隔;token 只在进程内使用)
CLOSE_BRANCH="$1" TASK_SCRIPT_DIR="$SCRIPT_DIR" python3 - <<'PY'
import os, subprocess, sys
sys.path.insert(0, os.environ["TASK_SCRIPT_DIR"])
from task_triage import Stop, api_base, open_pulls, read_token, repo_slug
branch = os.environ["CLOSE_BRANCH"]
try:
origin = subprocess.run(["git", "remote", "get-url", "origin"], capture_output=True, text=True)
if origin.returncode != 0:
raise Stop(6, "origin remote is not configured")
token = read_token()
if not token:
raise Stop(6, "no Gitea token: set GITEA_TOKEN or ~/.secrets/gitea ACCESS_TOKEN")
pulls = open_pulls(api_base(), repo_slug(origin.stdout.strip()), token)
except Stop as stop:
print(f"ERROR: cannot look up the open PR for {branch}: {stop.message}", file=sys.stderr)
sys.exit(6)
numbers = []
for pr in pulls:
if not isinstance(pr, dict) or pr.get("state", "open") != "open":
continue
head = pr.get("head") if isinstance(pr.get("head"), dict) else {}
if head.get("ref") == branch and str(pr.get("number", "")).isdigit():
numbers.append(str(pr["number"]))
print(" ".join(numbers))
PY
}
card_self_close() { # $1=card $2=pr_num $3=pr_url(原地改写:状态、主记录、结果节标题、合入前闭合记录)
python3 - "$1" "$2" "$3" <<'PY'
import re, sys
path, pr, link = sys.argv[1:4]
s = open(path, encoding='utf-8').read()
s, n = re.subn(r';状态:[^;\n]*', ';状态:已完成(合入前闭合)', s, count=1)
if n != 1:
sys.exit(f"ERROR: {path} has no ';状态:' field; refuse to fold-close")
s = re.sub(r'任务主记录:[^;\n]*', f'任务主记录:PR #{pr} {link}', s, count=1)
s = s.replace('## 结果与验证(完成后填写)', '## 结果与验证', 1)
s = s.rstrip('\n') + f'''
## 闭合记录(合入前折叠)
- 本卡在 PR #{pr}({link})合入前闭合并归档,随该 PR 一同合入。
- 该 PR 的合并提交即本任务的落地记录。
- 合入后无需回填,也无需单独的闭合 PR。
'''
open(path, 'w', encoding='utf-8').write(s)
PY
}
cmd_close_self() {
local branch card dest pr nums web_base url owner repo link msg
branch=$(git symbolic-ref --quiet --short HEAD) || die "close --self 须在任务分支上运行(当前为 detached HEAD)"
[ "$branch" != "${TARGET_BRANCH}" ] || die "close --self 不能在 ${TARGET_BRANCH} 上运行;请在任务 worktree 内对本分支的卡运行"
[ -z "$(git status --porcelain | grep -v '^.. \.zcode/' || true)" ] || die "任务工作区不干净——先提交或处置现场再 close --self"
card=$(branch_card "$branch")
[ -n "$card" ] || die "找不到分支 ${branch} 的任务卡"
case "$card" in .task/archive/*) die "${card} 已在合入前闭合归档,无需重复闭合" ;; esac
dest=".task/archive/${card##*/}"
[ ! -e "$dest" ] || die "归档目标已存在:${dest}"
if [ -n "${TASK_PR:-}" ]; then
case "$TASK_PR" in *[!0-9]*) die "TASK_PR 只能是 PR 号(纯数字)" ;; esac
pr="$TASK_PR"
else
nums=$(self_close_pr "$branch") || die "无法经 Gitea 查询 ${branch} 的开放 PR;可用 TASK_PR=<号> 显式指定"
case "$nums" in
''|*' '*) die "需要恰好一个 head 为 ${branch} 的开放 PR(查到:${nums:-无});先建 PR,或用 TASK_PR=<号> 指定" ;;
esac
pr="$nums"
fi
web_base="${TASK_CARD_WEB_BASE:-https://git-home.u1680832.nyat.app:30319}"
url=$(git remote get-url origin); url="${url%.git}"
repo=$(basename "$url"); owner=$(basename "$(dirname "$url")")
link="${web_base}/${owner}/${repo}/pulls/${pr}"
card_self_close "$card" "$pr" "$link"
mkdir -p .task/archive
git mv "$card" "$dest"
git add -- "$dest"
msg=$(mktemp)
{ echo "Fold-close the task card before merge"
echo
echo "Why: close the task card inside its own PR so landing it needs no post-merge backfill or separate close PR."
echo "Evidence: ${card} -> ${dest}; status ${PRE_MERGE_CLOSED}; PR #${pr} ${link}."
echo "Impact: task metadata only; the card is archived within PR #${pr} and lands with its merge commit."
echo "Refs: task.sh close --self"
} > "$msg"
git commit -q -F "$msg"; rm -f "$msg"
# push 失败重试一次:家服隧道间歇抖动(同 cmd_close)。
git push -q -u origin HEAD || { sleep 5; git push -q -u origin HEAD; }
echo "[closed] ${card} 已在 PR #${pr} 合入前闭合并归档;合入后无需 task-close"
}
cmd_close() {
need_repo
if [ "${1:-}" = "--self" ]; then shift; cmd_close_self "$@"; return; fi
local fold=0
[ "${1:-}" = "--fold" ] && fold=1
fetch_target
# 默认值取远端通告的 Web 面(git push 输出的 PR 建议链接);可用 TASK_CARD_WEB_BASE 覆盖。
# git-panel 前缀已失效(TLS 握手失败),默认值改为实测可用的 git-home(AGENTS.md 访问事实节)。
local web_base="${TASK_CARD_WEB_BASE:-https://git-home.u1680832.nyat.app:30319}"
local url owner repo
url=$(git remote get-url origin); url="${url%.git}"
repo=$(basename "$url"); owner=$(basename "$(dirname "$url")")
local branch c b st m cards="" own pr_base="${TARGET_BRANCH}"
branch=$(git rev-parse --abbrev-ref HEAD)
if [ "$fold" = 1 ]; then
# 任务分支内折叠:PR 的 base 必须是本分支的立即目标(子任务为父 initiative,而非 main)。
own=$(branch_card "$branch")
if [ -n "$own" ]; then pr_base=$(card_immediate_target "$own"); fi
fi
for c in .task/*.md; do
[ -f "$c" ] || continue
b=$(card_of_branch "$c"); [ -n "$b" ] || continue
st=$(card_status "$c")
done_unarchived=0
case "$st" in 已完成*) done_unarchived=1 ;; esac
if card_pending "${c##*/}"; then
echo "[close] ${c##*/} 已有待合入的闭合分支,跳过(等其 PR 合入)"
continue
fi
m=$(card_merged_into "$c" "$b") || m=""
if [ -z "$m" ]; then
[ "$done_unarchived" = 1 ] || continue
m="ARCHIVE"
fi
cards="$cards
$c|$b|$m"
done
[ -n "$cards" ] || { echo "[close] 无已合入未闭合的任务卡"; exit 0; }
# 落点:主检出→feature/card-close-* 分支(提交过 pre-commit 分支闸);任务分支须显式 --fold
local newbranch=""
if [ "$branch" = "${TARGET_BRANCH}" ]; then
[ -z "$(git status --porcelain | grep -v '^.. \.zcode/' || true)" ] || die "主检出树脏——先收尾再 close(防混入未提交现场)"
newbranch="feature/card-close-$(date -u +%Y%m%d-%H%M%SZ)"
git rev-parse --verify -q "refs/heads/${newbranch}" >/dev/null && die "分支已存在:${newbranch}"
git checkout -q -b "$newbranch" "origin/${TARGET_BRANCH}"
elif [ "$fold" != "1" ]; then
die "close 在任务分支内须加 --fold(并入当前任务 PR);或到主检出运行(将建 feature/card-close-* 分支+PR)"
fi
local n=0 sha subject pr tgt closed_list=""
while IFS='|' read -r c b m; do
[ -n "$c" ] || continue
tgt="${m%%|*}"; m="${m#*|}"
if [ "$m" = "ARCHIVE" ]; then
mkdir -p .task/archive
git mv "$c" ".task/archive/${c##*/}"
closed_list="$closed_list ${c##*/}(already-complete)"
n=$((n+1))
continue
fi
sha="${m%%|*}"; rest="${m#*|}"; pr="${rest##*|}"; subject="${rest%|*}"
case "$pr" in "$rest"|''|*[!0-9]*) pr=unknown ;; esac
card_backfill "$c" "$pr" "$sha" "$subject" "${web_base}/${owner}/${repo}" "$tgt"
mkdir -p .task/archive
git mv "$c" ".task/archive/${c##*/}"
closed_list="$closed_list ${c##*/}(PR#${pr} into ${tgt})"
n=$((n+1))
done <<EOF
$cards
EOF
local msg; msg=$(mktemp)
{ echo "Close ${n} merged task card(s) and archive them"
echo
echo "Why: branches merged into their immediate or root target had left their task cards open; backfill status and archive to prevent bookkeeping debt."
echo "Evidence: $(echo $closed_list) — merges verified via git log --merges plus Gitea API squash lookup against each card's origin target."
echo "Impact: .task/*.md moved to .task/archive/ with status backfilled; no code or submodule change."
echo "Refs: task.sh close (card-autoclose-r1)"
} > "$msg"
git add .task
git commit -q -F "$msg"; rm -f "$msg"
# push 失败重试一次:家服隧道间歇抖动,单次尝试曾让钩子闭合半途而废(2026-09-30)。
git push -q -u origin HEAD || { sleep 5; git push -q -u origin HEAD; }
local headbr pr_url=""
headbr=$(git rev-parse --abbrev-ref HEAD)
if [ -n "${GITEA_TOKEN:-}" ]; then
# HTTPS 面偶发 broken pipe / 超时(AGENTS.md 访问事实节),单发 curl 会让 PR 静默
# 缺失(闭合分支推了、PR 没建,2026-09-30 三次踩中):退避重试 + 失败可见 + 终局
# 按 head 查回可能已存在的 PR(post-merge 钩子与手动 close 并发时 PR 会重复请求)。
local resp attempt api="${web_base}/api/v1/repos/${owner}/${repo}/pulls"
for attempt in 1 2 3 4; do
resp=$(curl -sS -m 25 -X POST -H "Authorization: token ${GITEA_TOKEN}" -H 'Content-Type: application/json' \
"$api" \
-d "{\"head\":\"${headbr}\",\"base\":\"${pr_base}\",\"title\":\"Close merged task cards (${headbr})\",\"body\":\"Auto-generated by task.sh close; see commit message for evidence.\"}" 2>&1) || resp=""
pr_url=$(printf '%s' "$resp" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("html_url","") if isinstance(d,dict) else "")' 2>/dev/null || true)
[ -n "$pr_url" ] && break
echo "[close] 建 PR 第 ${attempt} 次未成功${resp:+($(head -c 120 <<<"$resp"))},退避重试" >&2
[ "$attempt" = 4 ] || sleep $((attempt * 3))
done
if [ -z "$pr_url" ]; then
resp=$(curl -sS -m 25 -H "Authorization: token ${GITEA_TOKEN}" "$api?state=open" 2>/dev/null || true)
pr_url=$(printf '%s' "$resp" | python3 -c 'import json,sys
for p in json.load(sys.stdin):
if p["head"]["ref"] == sys.argv[1]:
print(p["html_url"]); break' "$headbr" 2>/dev/null || true)
fi
fi
if [ -n "$newbranch" ]; then git checkout -q "${TARGET_BRANCH}"; fi
if [ -n "$pr_url" ]; then
echo "[pr] ${pr_url}(智能体按 policy/delivery.md 完成 Review、解决冲突且当前 HEAD 的 CI 通过后合入)"
else
echo "[next] 建 PR:${web_base}/${owner}/${repo}/pulls/new?head=${headbr}(智能体按 policy/delivery.md 完成 Review、解决冲突且当前 HEAD 的 CI 通过后合入)"
fi
echo "[closed] ${n} 张已合入卡片已回填归档,提交于 ${headbr} 并推送"
}
cmd_finish() {
need_repo
local abandon="${1:-}" branch head root mainrepo remote_head="" remote_status
root=$(git rev-parse --show-toplevel)
check_worktree finish
[ "${TASK_WRITERS_STOPPED:-}" = 1 ] || die "TASK_WRITERS_STOPPED=1 required: the workspace owner must first confirm all writers have stopped"
branch=$(git symbolic-ref --quiet --short HEAD) || die "detached task worktree must be preserved"
case "$branch" in feature/*|fix/*|hotfix/*|release/*) ;; *) die "refuse to recycle non-task branch ${branch}" ;; esac
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.
git notes --ref=refs/notes/task-abandon append -m "Task ${branch} abandoned at $(date -u +%FT%TZ): ${reason}" "$head"
echo "[abandoned] branch=${branch}; commits retained; reason recorded in refs/notes/task-abandon"
elif [ -n "$abandon" ]; then
die "usage: task.sh finish [--abandon <reason>]"
else
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" "$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
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.
check_worktree finish
local -a removal=(worktree remove)
# Read the whole listing: an early-exit reader SIGPIPEs git on large indexes and pipefail drops --force.
if git ls-files --stage | awk '$1 == "160000" { found = 1 } END { exit !found }'; then
removal+=(--force)
fi
if ! git -C "$mainrepo" "${removal[@]}" "$root"; then
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 [ "$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
echo "[recycled] worktree and local branch=${branch} removed; remote=DELETED observed_head=${remote_head}"
else
echo "[partial] local worktree/branch removed; remote=${branch} retained or result unknown; query remote before retrying" >&2
return 1
fi
else
echo "[partial] worktree removed; local branch=${branch} retained because safe branch deletion was refused; remote=NOT_RUN" >&2
return 1
fi
# Keep card closure separate: its push/PR side effects are not implied by local disposal.
echo "[next] cards are normally fold-closed before merge with make task-close SELF=1; only legacy cards reported by precheck [cards] need make task-close in the main checkout when authorized"
}
case "${1:-}" in
precheck) shift; cmd_precheck "$@" ;;
triage) shift; cmd_triage "$@" ;;
triage-decide) shift; cmd_triage_decide "$@" ;;
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|triage|triage-decide|start|resume|sync|close|finish> [args]"; exit 2 ;;
esac