#!/usr/bin/env bash # 任务启动门禁:precheck / 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; } } 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) fetch_target 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 "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 ahead behind=$(git rev-list --count "HEAD..origin/${TARGET_BRANCH}") ahead=$(git rev-list --count "origin/${TARGET_BRANCH}..HEAD") 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 回填归档(主检出运行)" 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)" 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 IFS='|' read -r _ fid _ _ lv ttl _; do 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 ]; 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 python3 "${SCRIPT_DIR}/evidence.py" --local-context || return 2 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 /" echo "$name" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || die "branch name must match (feature|fix|hotfix|release)/" 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") VALIDATE_RECEIPT="$PRECHECK_RECEIPT" VALIDATE_RECEIPT_PURPOSE=start bash "${SCRIPT_DIR}/evidence.sh" 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 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 [ -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" 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 slug=$(echo "$name" | tr '/' '-') # 落点须为主检出的平级目录:$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 >/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" cat > "$card" <;状态:进行中;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} ${parent_note} - 授权与交付终点:<本次允许的动作与终点;未授权事项写明> - 交付模式:manual(前序人工决策,智能体 Review/合入);其他阶段明确授权后可记为 authorized-continuous - 合入执行:agent(已授权交付范围内,Review 通过、冲突解决且当前 HEAD 的 CI 通过后合入) - 自动推进授权:无;授权范围:无;授权来源:无;授权终点:无;停止条件:无(未授权) - 当前阶段:需求拆解;当前增量:I1(待定义) - 流程依据:仓库根目录 policy/delivery.md(归档后仍按仓库根目录定位) ## 目标与非目标 <预期结果、受影响入口/模块/契约;明确不做什么。> ## 关联方向与规划 - 适用状态:待核对(无适用规划时说明原因) - 主记录路径/版本:待核对(路径按仓库根目录定位,归档后不变;只引用唯一正文) - 对应里程碑:待核对 ## 需求分析 | ID | 描述 | 项目符合度及依据 | 优先级 | 验收条件 | 依赖 | 结论 | |---|---|---|---|---|---|---| | R1 | 待填写 | 待评估 | 待定 | 待定义 | 待确认 | 待评审 | ## 方案与计划 - 方案引用:待填写(含对象版本) - 实施计划引用:待填写(含增量与需求 ID) - 测试计划引用:待填写(含需求 ID 与验收条件) ## 阶段评审 | 阶段 | 对象版本 | 结论 | 评审人 | 时间 | 依据 | |---|---|---|---|---|---| | 需求拆解 | 待定 | 未评审 | — | — | — | | 需求评估 | 待定 | 未评审 | — | — | — | | 需求表确认 | 待定 | 未评审 | — | — | — | | 方案设计 | 待定 | 未评审 | — | — | — | | 实施计划 | 待定 | 未评审 | — | — | — | | 实施与自检 | 待定 | 未评审 | — | — | — | | 正式测试与验收 | 待定 | 未评审 | — | — | — | | 代码 Review | 待定 | 未评审 | — | — | — | | 合入 | 待定 | 未评审 | — | — | — | | 合入核验与收口 | 待定 | 未评审 | — | — | — | ## 探索试验与迭代 | 增量/试验 | 假设 | 范围 | 判断和停止条件 | 批准依据 | 结果 | 变更影响 | 需重审补测 | |---|---|---|---|---|---|---|---| | 待定义 | — | — | — | 未批准 | NOT_RUN | 待评估 | 待评估 | ## 同步记录 | 节点 | sync_base_sha | tested_head_sha | checked_at | |---|---|---|---| | 启动 | ${rmain} | $(git -C "$wt" rev-parse HEAD) | $(date -u +%FT%TZ) | ## 证据(Evidence) | 时间(UTC) | 命令 | 被测HEAD | exit | 来源 | sha256(16) | |---|---|---|---|---|---| ## 结果与验证(完成后填写) - 结果: - 验证:<结论摘要 PASS/FAIL/SKIP/NOT_RUN 逐项;逐条证据由 make evidence 写入上方证据表> - 影响与剩余项: ## 规划进展与下一步 - 本次推进/剩余:待核对(依据实际成果更新适用方案/规划) - 首选下一任务:待核对(无适用规划、已完成或受阻时如实说明,不虚构任务) - 推荐依据:待填写 - 前置条件/待确认:待核对 - 预期产物/验收条件:待定义 - 重复任务核对:待核对(已有/在途任务、PR 与规划记录) - 启动授权:未授权(建议不自动启动新任务,仍须明确授权) ## 合入与清理 - Review 记录:NOT_RUN(审阅智能体/平台身份、base/head、结论、问题闭合及重放依据) - 冲突记录:NOT_RUN(最新目标同步、冲突处置、组合兼容核验及复审/补测) - CI 记录:NOT_RUN(当前 HEAD 完整 SHA、全部必需检查、运行链接、执行身份及 GATE 结果) - 合入记录:NOT_RUN(交付授权范围、PR、被核验 HEAD、合入 SHA、目标分支及内容核验) - 清理记录:NOT_RUN(授权依据、写者退出确认、worktree/分支/任务卡处置) EOF (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) fetch_target [ -n "${PRECHECK_RECEIPT:-}" ] || die "PRECHECK_RECEIPT required: capture the current task precheck before resume" local receipt receipt=$(realpath -- "$PRECHECK_RECEIPT") VALIDATE_RECEIPT="$receipt" VALIDATE_RECEIPT_PURPOSE="resume-${mode}" bash "${SCRIPT_DIR}/evidence.sh" IMPORT_RECEIPT="$receipt" bash "${SCRIPT_DIR}/evidence.sh" echo "[resumed] work_mode=${mode} source_head=$(git rev-parse HEAD) observed_target=$(git rev-parse "origin/${TARGET_BRANCH}")" } 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}") 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" 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) 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" 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}" echo "[next] 跑正式验收(make test 等),证据写入任务卡" } # ---- 卡片闭合(防欠账)---- # 依据:delivery.md §5 治理清理;机制化 2026-09-30 治理缺口(11 张已合入卡片未闭合)。 # 检测 .task/*.md 中"分支已合入 origin/ 但状态非已完成"的卡片:回填状态、主记录 # (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/ 的合并提交) 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 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="" 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") done_unarchived=0 case "$st" in 已完成*) done_unarchived=1 ;; esac if card_pending "${c##*/}"; then echo "[close] ${c##*/} 已有待合入的闭合分支,跳过(等其 PR 合入)" continue fi m=$(card_merge "$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 closed_list="" while IFS='|' read -r c b m; do [ -n "$c" ] || continue 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%%|*}"; 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 < "$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\":\"${TARGET_BRANCH}\",\"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:+($(printf '%s' "$resp" | head -c 120))},退避重试" >&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 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 ]" 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" 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" 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. check_worktree finish local -a removal=(worktree remove) if git ls-files --stage | grep -q '^160000 '; 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" if [ "$abandon" = "--abandon" ]; then echo "[recycled] worktree removed; local branch=${branch} retained; remote=NOT_RUN" elif git branch -d "$branch"; 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] inspect task-card closure with make task-close in the main checkout when authorized" } case "${1:-}" in precheck) shift; cmd_precheck "$@" ;; start) shift; cmd_start "$@" ;; resume) shift; cmd_resume "$@" ;; sync) shift; cmd_sync "$@" ;; close) shift; cmd_close "$@" ;; finish) shift; cmd_finish "$@" ;; *) echo "usage: task.sh [args]"; exit 2 ;; esac