Files
Agent-System-Dev/scripts/task.sh
T
weichen beaefb7549 Add task-card autoclose wiring: close subcommand, post-merge hook, gate reports
Why: 11 merged branches had left their task cards open (bookkeeping debt found on 2026-09-30); closure was manual and never triggered, so card status rotted after each merge.
Evidence: dogfood run of `task.sh close --fold` closed and archived the 11 cards (previous commit); temp-repo matrix 33/33 PASS covering detection, pending-branch idempotence, dirty/no-fold refusals, post-merge auto-close on merge-type pulls, worktree guard, and the full lifecycle (closure PR merge archives cards on main).
Impact: scripts/task.sh (+close, precheck [cards] report, finish-tail trigger), scripts/hooks/post-merge (new), Makefile (+task-close target, setup chmod), AGENTS.md rule; archive fixes correct PR links missed by the first dogfood run; no module or gitlink change.
Refs: task card feature/card-autoclose-r1
2026-09-30 07:12:54 +00:00

288 lines
15 KiB
Bash
Executable File
Raw 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 / start / sync / close / finish(配套 Makefile 同名目标)。
# 规范依据:collaboration.md 共同基线与同步节点、治理清理;delivery.md §1/§5。
set -euo pipefail
TARGET_BRANCH="${TASK_TARGET_BRANCH:-main}"
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"; }
cmd_precheck() {
need_repo
git fetch -q origin "${TARGET_BRANCH}" 2>/dev/null || true
local branch head rmain dirty sub_drift sub_uninit
branch=$(git rev-parse --abbrev-ref HEAD)
head=$(git rev-parse HEAD)
rmain=$(git rev-parse --verify -q "origin/${TARGET_BRANCH}" 2>/dev/null || echo "$head")
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 ahead
behind=$(git rev-list --count "HEAD..origin/${TARGET_BRANCH}" 2>/dev/null || echo 0)
ahead=$(git rev-list --count "origin/${TARGET_BRANCH}..HEAD" 2>/dev/null || echo 0)
echo "[repo] branch=${branch} head=$(echo "$head" | cut -c1-10)"
echo "[remote] origin/${TARGET_BRANCH}=$(echo "$rmain" | cut -c1-10) ahead=${ahead} behind=${behind}"
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=$(git branch --merged "origin/${TARGET_BRANCH}" 2>/dev/null | { grep -vE 'main|master|\*' || true; } | tr -d ' +')
[ "${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
if git log --merges --format='%s' "origin/${TARGET_BRANCH}" 2>/dev/null | grep -qF " from $pb into ${TARGET_BRANCH}"; then
open_cards="${open_cards} ${pc##*/}"
fi
done
[ -n "$open_cards" ] && echo "[cards] 已合入未闭合任务卡:$(echo $open_cards)——make task-close 回填归档(主检出运行)"
echo "[required-reading] core/baseline.md, workflows/delivery.md §1$([ "$dirty" -gt 0 ] && echo ", standards/collaboration.md(树脏=他人或未收尾现场,不得写入)")"
echo "[R0] $(echo "$rmain" | cut -c1-10)(完整:$rmain)"
if [ "$dirty" -gt 0 ] || [ "${sub_drift:-0}" -gt 0 ]; then
echo "[verdict] SHARED_WORKSPACE_BUSY — 工作树不干净,不得写入;先收尾或经 owner 确认"
exit 3
fi
if [ "$behind" -gt 0 ] && [ "$branch" = "${TARGET_BRANCH}" ]; then
echo "[verdict] SYNC_REQUIRED — 本地 ${TARGET_BRANCH} 落后远端 ${behind} 提交;先 git pull --ff-only"
exit 4
fi
echo "[verdict] READY — 可建任务工作区:make task-start NAME=feature/your-branch"
}
cmd_start() {
need_repo
local name="${1:-}"; [ -n "$name" ] || die "usage: task.sh start <feature|fix|hotfix>/<slug>"
echo "$name" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || die "branch name must match (feature|fix|hotfix|release)/<slug>"
git fetch -q origin "${TARGET_BRANCH}" 2>/dev/null || true
[ "$(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 slug wt
rmain=$(git rev-parse "origin/${TARGET_BRANCH}")
slug=$(echo "$name" | tr '/' '-')
wt="$(dirname "$(pwd)")/$(basename "$(pwd)")-wt-${slug}"
[ -e "$wt" ] && die "worktree path exists: $wt"
git worktree add -q "$wt" -b "$name" "$rmain"
(cd "$wt" && git submodule update --init --recursive >/dev/null 2>&1 || echo "[warn] submodule init incomplete in worktree")
local card="$wt/.task/${slug}.md"
mkdir -p "$wt/.task"
cat > "$card" <<EOF
# ${name} 任务卡
任务主记录:<PR 链接,task-finish 时填写>;状态:进行中;owner:$(git config user.name || echo unknown)
## 基线与授权
- 权威仓:$(git -C "$wt" remote get-url origin 2>/dev/null || echo '(local)');目标分支:${TARGET_BRANCH};R0(initial_base_sha)= ${rmain}
- 工作区:${wt}(worktree);分支:${name}
- 授权与交付终点:<本次允许的动作与终点;未授权事项写明>
## 目标与非目标
<预期结果、受影响入口/模块/契约;明确不做什么。>
## 同步记录
| 节点 | sync_base_sha | tested_head_sha | checked_at |
|---|---|---|---|
| 启动 | ${rmain} | $(git -C "$wt" rev-parse HEAD) | $(date -u +%FT%TZ) |
## 结果与验证(完成后填写)
- 结果:
- 验证:
- 影响与剩余项:
EOF
git -C "$wt" add .task && git -C "$wt" commit -q -m "Record the task card for ${slug}" && echo "[card] $card(已随分支提交)"
echo "[started] branch=${name} worktree=${wt} R0=$(echo "$rmain" | cut -c1-10)"
}
cmd_sync() {
need_repo
git fetch -q origin "${TARGET_BRANCH}"
[ "$(git status --porcelain | grep -v '^.. \.zcode/' | wc -l)" -eq 0 ] || die "dirty tree — 先显式提交;不隐式 stash(collaboration.md:11)"
git merge --no-edit "origin/${TARGET_BRANCH}"
git merge-base --is-ancestor "origin/${TARGET_BRANCH}" HEAD || die "sync incomplete"
local base head card
base=$(git rev-parse "origin/${TARGET_BRANCH}"); head=$(git rev-parse HEAD)
card=$(ls .task/*.md 2>/dev/null | head -1 || true)
if [ -n "$card" ]; then
printf '| 同步前验收 | %s | %s | %s |\n' "$base" "$head" "$(date -u +%FT%TZ)" >> "$card"
git add "$card"
git commit -q -m "Record the sync checkpoint" || true
fi
echo "[synced] sync_base=$(echo "$base" | cut -c1-10) tested_head=$(echo "$head" | cut -c1-10) checked_at=$(date -u +%FT%TZ)"
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 合入)。合并判定用合并提交主题(分支删除后仍可追溯),不依赖分支存活。
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; }
card_merge() { # $1=branch → "sha|subject"(该分支合入 origin/<target> 的合并提交)
git log --merges --format='%H|%s' "origin/${TARGET_BRANCH}" 2>/dev/null \
| grep -F " from $1 into ${TARGET_BRANCH}" | head -1
}
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(原地改写)
python3 - "$1" "$2" "$3" "$4" "$5" <<'PY'
import re, sys
path, pr, sha, subject, base = sys.argv[1:6]
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/main,分支成果已落盘;本段为合入后回填。
- 合并主题:{subject}
- 归档:随闭合移入 .task/archive/;任务执行期的过程证据见原任务记录与 PR。
'''
open(path, 'w', encoding='utf-8').write(s)
PY
}
cmd_close() {
need_repo
local fold=0
[ "${1:-}" = "--fold" ] && fold=1
git fetch -q origin "${TARGET_BRANCH}" 2>/dev/null || true
# 默认值取远端通告的 Web 面(git push 输出的 PR 建议链接);可用 TASK_CARD_WEB_BASE 覆盖
local web_base="${TASK_CARD_WEB_BASE:-https://git-panel.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=""
branch=$(git rev-parse --abbrev-ref HEAD)
for c in .task/*.md; do
[ -f "$c" ] || continue
b=$(card_of_branch "$c"); [ -n "$b" ] || continue
st=$(card_status "$c"); case "$st" in 已完成*) continue ;; esac
if card_pending "${c##*/}"; then
echo "[close] ${c##*/} 已有待合入的闭合分支,跳过(等其 PR 合入)"
continue
fi
m=$(card_merge "$b") || m=""
[ -n "$m" ] || continue
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 closed_list=""
while IFS='|' read -r c b m; do
[ -n "$c" ] || continue
sha="${m%%|*}"; subject="${m#*|}"
pr=$(printf '%s' "$subject" | sed -n 's/.*(#\([0-9]\+\)) from.*/\1/p')
[ -n "$pr" ] || pr=unknown
card_backfill "$c" "$pr" "$sha" "$subject" "${web_base}/${owner}/${repo}"
mkdir -p .task/archive
git mv "$c" ".task/archive/${c##*/}"
closed_list="$closed_list ${c##*/}(PR#${pr})"
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 ${TARGET_BRANCH} 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 against origin/${TARGET_BRANCH}."
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"
git push -q -u origin HEAD
local headbr pr_url=""
headbr=$(git rev-parse --abbrev-ref HEAD)
if [ -n "${GITEA_TOKEN:-}" ]; then
local resp
resp=$(curl -sS -X POST -H "Authorization: token ${GITEA_TOKEN}" -H 'Content-Type: application/json' \
"${web_base}/api/v1/repos/${owner}/${repo}/pulls" \
-d "{\"head\":\"${headbr}\",\"base\":\"${TARGET_BRANCH}\",\"title\":\"Close merged task cards (${headbr})\",\"body\":\"Auto-generated by task.sh close; see commit message for evidence.\"}" 2>/dev/null || true)
pr_url=$(printf '%s' "$resp" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("html_url",""))' 2>/dev/null || true)
fi
if [ -n "$newbranch" ]; then git checkout -q "${TARGET_BRANCH}"; fi
if [ -n "$pr_url" ]; then
echo "[pr] ${pr_url}(合入权在所有者)"
else
echo "[next] 建 PR:${web_base}/${owner}/${repo}/pulls/new?head=${headbr}(合入权在所有者)"
fi
echo "[closed] ${n} 张已合入卡片已回填归档,提交于 ${headbr} 并推送"
}
cmd_finish() {
need_repo
local abandon="${1:-}"
local branch head common mainrepo
branch=$(git rev-parse --abbrev-ref HEAD)
head=$(git rev-parse HEAD)
common=$(git rev-parse --git-common-dir); common=$(cd "$common" && pwd)
mainrepo=$(dirname "$common")
# 防护(T0 事故教训):finish 必须在任务 worktree 内运行——本脚本把 $PWD 当
# worktree 回收(rm -rf);从主检出运行会误删主检出。mainrepo==PWD 即主检出。
[ "$(cd "$mainrepo" && pwd)" = "$PWD" ] && die "task-finish 须在任务 worktree 内运行(cd 到 worktree 再执行),防止误删主检出"
git fetch -q origin "${TARGET_BRANCH}" 2>/dev/null || true
if [ "$abandon" = "--abandon" ]; then
local reason="${2:-}"; [ -n "$reason" ] || die "--abandon 需要原因:task.sh finish --abandon <reason>"
local untracked; untracked=$(git status --porcelain | grep -c '^??' || true)
[ "$untracked" -gt 0 ] && die "存在 ${untracked} 个未跟踪文件 — 先处置或用 task-finish FORCE_ABANDON=1"
[ "${FORCE_ABANDON:-0}" = "1" ] || { echo "[confirm] 放弃未合入工作属破坏性动作:重跑 FORCE_ABANDON=1 task.sh finish --abandon \"${reason}\""; exit 5; }
echo "[abandoned] branch=${branch} reason=${reason} recorded_at=$(date -u +%FT%TZ)"
else
git merge-base --is-ancestor "$head" "origin/${TARGET_BRANCH}" || die "分支未合入 origin/${TARGET_BRANCH}——先走 PR 合入;放弃则用 --abandon <reason>"
echo "[verified] ${branch} 已包含于 origin/${TARGET_BRANCH};成果已落盘"
fi
git submodule deinit --all -f >/dev/null 2>&1 || true
# git 对含 gitlink 的 worktree 无条件拒绝 remove(查索引,与 deinit 无关):
# 已验证合入且子模块已 deinit 后,直接清目录并 prune。
git -C "$mainrepo" worktree remove "$PWD" 2>/dev/null || { rm -rf "$PWD"; git -C "$mainrepo" worktree prune; }
git -C "$mainrepo" branch -d "$branch" 2>/dev/null || git -C "$mainrepo" branch -D "$branch"
git -C "$mainrepo" worktree prune
git push -q --delete origin "$branch" 2>/dev/null || true
echo "[recycled] worktree 与分支 ${branch} 已回收(本地+远端,$(date -u +%FT%TZ))"
# 合入成功→自动闭合任务卡(含本任务卡;经 feature/card-close-* 分支+PR,合入权在所有者)
( cd "$mainrepo" && bash scripts/task.sh close ) \
|| echo "[next] 卡片自动闭合未完成——在主检出运行 make task-close"
}
case "${1:-}" in
precheck) shift; cmd_precheck "$@" ;;
start) shift; cmd_start "$@" ;;
sync) shift; cmd_sync "$@" ;;
close) shift; cmd_close "$@" ;;
finish) shift; cmd_finish "$@" ;;
*) echo "usage: task.sh <precheck|start|sync|close|finish> [args]"; exit 2 ;;
esac