agent-exec transport b9c7fb196afb6984f4367f30ec8ec18a/162bb30bb46e

This commit is contained in:
agent-exec-transport
2026-10-18 00:49:09 +00:00
parent b103f3c58a
commit 2757b6f68e
11 changed files with 325 additions and 24 deletions
+7 -6
View File
@@ -113,12 +113,13 @@ policy/delivery.md。
`TASK_WRITERS_STOPPED=1 make task-finish`。父子仓改动、未跟踪/ignored 唯一文件先保存或显式处置;删除被拒时 `TASK_WRITERS_STOPPED=1 make task-finish`。父子仓改动、未跟踪/ignored 唯一文件先保存或显式处置;删除被拒时
保留现场并按原因处理,禁止强删兜底;无法证明写者已退出时才请 owner 确认。放弃未合入工作用 保留现场并按原因处理,禁止强删兜底;无法证明写者已退出时才请 owner 确认。放弃未合入工作用
`make task-finish ABANDON="--abandon <原因>"` 并记录原因;放弃不豁免现场保护。 `make task-finish ABANDON="--abandon <原因>"` 并记录原因;放弃不豁免现场保护。
- **任务卡闭合(防欠账)**:分支合入 main 后,其任务卡须回填"已完成"+主记录 PR 链接并 - **任务卡闭合(防欠账)**:默认在合入前闭合——最终同步/合入前,在任务 worktree 运行
归档至 `.task/archive/`。`task-finish` 回收后按提示运行 `make task-close`,不借回收修改 `make task-close SELF=1`:本分支任务卡改为"已完成(合入前闭合)"、主记录写入本 PR 链接并归档至
共享主检出或推送另一任务。主检出 pull/merge 的 post-merge 钩子仅提示待办,不自动写入或推送。 `.task/archive/`,随同一 PR 合入,该 PR 的合并提交即落地记录,无需单独闭合 PR 或合入后回填。
`make task-close` 会提交、推送并可能创建 PR,标准闭合属于已授权交付的收口;仍须核对只包含本任务卡,否则停止扩围。闭合在主检出运行→建 它会提交并推送当前任务分支,属于已授权交付的收口;只处理本分支的卡。主检出 pull/merge 的
`feature/card-close-*` 分支+PR,任务分支内加 `FOLD=--fold` 随当前 PR 合入。 post-merge 钩子仅提示,不自动写入或推送。主检出的 `make task-close`(建 `feature/card-close-*`
precheck 的 `[cards]` 行报告欠账,验收前应清零;闭合 PR 同样由智能体按 分支+PR)或任务分支内 `FOLD=--fold` 只用于已合入但未闭合的遗留卡;仍须核对只包含目标任务卡,
否则停止扩围。precheck 的 `[cards]` 行报告欠账,验收前应清零;遗留卡的闭合 PR 同样由智能体按
policy/delivery.md 完成 Review、冲突处置及当前 HEAD 的 CI 核验后合入。 policy/delivery.md 完成 Review、冲突处置及当前 HEAD 的 CI 核验后合入。
## 证据与验证门禁(强制) ## 证据与验证门禁(强制)
+3 -1
View File
@@ -54,8 +54,10 @@ task-resume:
task-sync: task-sync:
@bash $(TASK) sync @bash $(TASK) sync
# 任务卡闭合:默认在任务 worktree 合入前运行 SELF=1(本卡随当前 PR 完成并归档,无需单独闭合 PR);
# 不带参数(主检出)或 FOLD=--fold 仅用于已合入未闭合的遗留卡。
task-close: task-close:
@bash $(TASK) close $(FOLD) @bash $(TASK) close $(if $(SELF),--self,$(FOLD))
task-finish: task-finish:
@bash $(TASK) finish $(ABANDON) @bash $(TASK) finish $(ABANDON)
+5 -3
View File
@@ -157,7 +157,7 @@ host 可以直接编写并提交的豁免只有 `.task/**`、`docs/superpowers/s
- **子任务分支(child)**:从远端所属大任务分支的当前头建立(须等于分流收据核验的 head,`make task-start` 校验),即时目标为大任务分支。负责一个有界单元的实施、独立 Review 与局部验证,通过 PR 合入大任务分支。 - **子任务分支(child)**:从远端所属大任务分支的当前头建立(须等于分流收据核验的 head,`make task-start` 校验),即时目标为大任务分支。负责一个有界单元的实施、独立 Review 与局部验证,通过 PR 合入大任务分支。
- **普通任务分支(standalone)**:无需阶段集成的小需求或独立修复,直接从最新 `origin/main` 建立并向 `main` 交付。 - **普通任务分支(standalone)**:无需阶段集成的小需求或独立修复,直接从最新 `origin/main` 建立并向 `main` 交付。
每个任务卡记录 `root_target` 与 `immediate_target`(子任务为所属大任务分支,其余为 `main`)。`make task-resume`、`make task-sync` 与 `make task-finish` 以 `immediate_target` 为准;`make task-close` 仅在 `FOLD=--fold` 时以其为闭合 PR base。 每个任务卡记录 `root_target` 与 `immediate_target`(子任务为所属大任务分支,其余为 `main`)。`make task-resume`、`make task-sync` 与 `make task-finish` 以 `immediate_target` 为准;任务卡默认在合入前以 `make task-close SELF=1` 闭合并随本 PR 合入(归档后上述命令仍从 `.task/archive/` 读取合入前闭合的卡);遗留卡的 `make task-close` 仅在 `FOLD=--fold` 时以其为闭合 PR base。
每次接到新需求,创建分支前必须分流,事实来源为 origin(本地 `.task/` 不参与发现): 每次接到新需求,创建分支前必须分流,事实来源为 origin(本地 `.task/` 不参与发现):
1. **发现**:`make task-triage REQ='<需求>' TRIAGE_RECEIPT=<路径>` 查询远端。候选须已推送、存在以 `main` 为 base 的开放 PR,且该远端分支的任务卡声明 `branch_kind=initiative`、`accepting_requirements=true` 且未标记已完成。无候选时收据直接为 `NEW`;有候选时为 `DECISION_REQUIRED`(退出码 5)。远端或 Gitea 不可核验时记为 `REMOTE_UNVERIFIED`(退出码 6),不得得出 `NEW`。 1. **发现**:`make task-triage REQ='<需求>' TRIAGE_RECEIPT=<路径>` 查询远端。候选须已推送、存在以 `main` 为 base 的开放 PR,且该远端分支的任务卡声明 `branch_kind=initiative`、`accepting_requirements=true` 且未标记已完成。无候选时收据直接为 `NEW`;有候选时为 `DECISION_REQUIRED`(退出码 5)。远端或 Gitea 不可核验时记为 `REMOTE_UNVERIFIED`(退出码 6),不得得出 `NEW`。
@@ -353,8 +353,10 @@ Review 只将阻塞项列为必修;非阻塞意见标明后续建议,不得
清理前保存任务主记录与证据。执行回收时将命令输出用 evidence 的 `RECEIPT` 模式保存到 清理前保存任务主记录与证据。执行回收时将命令输出用 evidence 的 `RECEIPT` 模式保存到
待删目录之外;回收后的结果写入后续闭合记录,不能向已删除的任务卡补写收据。 待删目录之外;回收后的结果写入后续闭合记录,不能向已删除的任务卡补写收据。
任务卡须回填真实主 PR 和完成状态并归档;闭合 PR 的创建核对当前闭合授权,合入按上述 任务卡默认在最终同步/合入前于任务 worktree 运行 `make task-close SELF=1`:写入本 PR 链接与
智能体 Review 与 PR 合入流程执行。 "已完成(合入前闭合)"状态并归档,随同一 PR 合入,不另建闭合 PR。仅已合入未闭合的遗留卡在主检出
运行 `make task-close`(`feature/card-close-*` PR)或用 `FOLD=--fold` 回填归档;其闭合 PR 的创建核对
当前闭合授权,合入按上述智能体 Review 与 PR 合入流程执行。
post-merge 默认只提示,不因 pull 或 merge 自动提交、推送或创建闭合 PR。 post-merge 默认只提示,不因 pull 或 merge 自动提交、推送或创建闭合 PR。
中断或交接时记录当前有效版本、批准范围、未决假设、最新试验结论、剩余事项及下一动作。 中断或交接时记录当前有效版本、批准范围、未决假设、最新试验结论、剩余事项及下一动作。
+2 -1
View File
@@ -138,7 +138,8 @@ Git 对含子模块的工作区需要显式删除选项时,只有全部现场
子仓 pin 之外的 refs/stash/reflog 历史采取保守拒绝,即使其中部分可能已在远端: 子仓 pin 之外的 refs/stash/reflog 历史采取保守拒绝,即使其中部分可能已在远端:
owner 须先独立确认其保存与归属,工具不把缓存的远端引用当作唯一数据已经备份的证据。 owner 须先独立确认其保存与归属,工具不把缓存的远端引用当作唯一数据已经备份的证据。
远端分支删除与本地回收分别报告,失败不能声明两者全部完成。 远端分支删除与本地回收分别报告,失败不能声明两者全部完成。
回收后由所有者执行 `make task-close` 闭合卡片;回收命令不顺带改写共享主检出。闭合 PR 默认 base 为 `main`;仅 `FOLD=--fold`(随任务分支合入)时 base 为卡片 `immediate_target`。 任务卡默认在合入前闭合:最终同步/合入前在任务 worktree 运行 `make task-close SELF=1`,本卡改为"已完成(合入前闭合)"并归档,随同一 PR 合入,无需单独闭合 PR;回收命令不顺带改写共享主检出。
主检出的 `make task-close`(闭合 PR base 为 `main`)或 `FOLD=--fold`(随任务分支合入,base 为卡片 `immediate_target`)只用于已合入未闭合的遗留卡。
post-merge 仅提示,不自动调用闭合。`make task-close` 会提交、推送并可能创建 PR, post-merge 仅提示,不自动调用闭合。`make task-close` 会提交、推送并可能创建 PR,
执行者先按迭代交付规则核对该动作的授权及所选任务,保留未授权的其他任务现场。 执行者先按迭代交付规则核对该动作的授权及所选任务,保留未授权的其他任务现场。
回收命令使用待删目录之外的 `RECEIPT` 保存输出,清理结果由后续闭合记录引用。 回收命令使用待删目录之外的 `RECEIPT` 保存输出,清理结果由后续闭合记录引用。
+10 -2
View File
@@ -23,6 +23,7 @@ GIT_SHA = re.compile(r"(?:[0-9a-f]{40}|[0-9a-f]{64})\Z")
BRANCH = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/-]{0,199}\Z") BRANCH = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/-]{0,199}\Z")
# 可选的分支目标字段:两者须同时出现或同时缺省(旧收据没有)。 # 可选的分支目标字段:两者须同时出现或同时缺省(旧收据没有)。
TARGET_FIELDS = ("root_target", "immediate_target") TARGET_FIELDS = ("root_target", "immediate_target")
PRE_MERGE_CLOSED = "已完成(合入前闭合)"
BASE_FIELDS = {"schema", "source", "timestamp", "command", "command_sha256", "cwd", "common_dir", BASE_FIELDS = {"schema", "source", "timestamp", "command", "command_sha256", "cwd", "common_dir",
"before", "after", "after_error", "exit_code", "output", "output_sha256", "receipt_sha256"} "before", "after", "after_error", "exit_code", "output", "output_sha256", "receipt_sha256"}
@@ -255,7 +256,7 @@ def task_context(current=None):
if (root / ".task").is_symlink(): if (root / ".task").is_symlink():
raise EvidenceError("Task registration must belong to the current checkout") raise EvidenceError("Task registration must belong to the current checkout")
card = find_card() card = find_card()
if card.is_symlink() or card.resolve().parent != root / ".task": if card.is_symlink() or card.resolve().parent not in (root / ".task", root / ".task" / "archive"):
raise EvidenceError("Task registration must belong to the current checkout") raise EvidenceError("Task registration must belong to the current checkout")
content = card.read_text() content = card.read_text()
locations = re.findall(r"^- 工作区:(.+?)\s*[((]worktree[))];分支:([^\s]+)\s*$", content, re.M) locations = re.findall(r"^- 工作区:(.+?)\s*[((]worktree[))];分支:([^\s]+)\s*$", content, re.M)
@@ -318,8 +319,15 @@ def find_card():
card = root / ".task" / (branch.replace("/", "-") + ".md") card = root / ".task" / (branch.replace("/", "-") + ".md")
if card.is_file(): if card.is_file():
return card return card
owner = re.compile(r"^- 工作区:.*分支:" + re.escape(branch) + r"(?:\s|$)", re.M)
for candidate in (root / ".task").glob("*.md"): for candidate in (root / ".task").glob("*.md"):
if re.search(r"^- 工作区:.*分支:" + re.escape(branch) + r"(?:\s|$)", candidate.read_text(), re.M): if owner.search(candidate.read_text()):
return candidate
# A card fold-closed before merge (task.sh close --self) lives in the archive until its PR lands.
for candidate in sorted((root / ".task" / "archive").glob("*.md")):
text = candidate.read_text()
status = re.search(r";状态:([^;\n]*)", text)
if owner.search(text) and status and status.group(1).startswith(PRE_MERGE_CLOSED):
return candidate return candidate
raise EvidenceError("No task card exists for the current branch") raise EvidenceError("No task card exists for the current branch")
+4 -3
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# 本地钩子(post-merge):pull/merge 落地后提示 owner 按授权闭合任务卡;ff-only 也会触发。 # 本地钩子(post-merge):pull/merge 落地后提示任务卡闭合方式;ff-only 也会触发。
# precheck [cards] 报告欠账;此钩子只提示,不执行闭合或其他写入。 # 新任务在合入前用 make task-close SELF=1 闭合本卡;precheck [cards] 报告的遗留卡才需主检出闭合。
# 此钩子只提示,不执行闭合或其他写入。
# 触发条件收紧到"主检出 + 集成分支 main"——任务 worktree 里 task-sync 的 merge 不触发, # 触发条件收紧到"主检出 + 集成分支 main"——任务 worktree 里 task-sync 的 merge 不触发,
# 防止把卡片闭合提示混进任务分支现场。 # 防止把卡片闭合提示混进任务分支现场。
common=$(git rev-parse --git-common-dir) && common=$(cd "$common" 2>/dev/null && pwd) || exit 0 common=$(git rev-parse --git-common-dir) && common=$(cd "$common" 2>/dev/null && pwd) || exit 0
@@ -8,5 +9,5 @@ mainrepo=$(dirname "$common")
[ "$mainrepo" = "$PWD" ] || exit 0 [ "$mainrepo" = "$PWD" ] || exit 0
[ "$(git rev-parse --abbrev-ref HEAD)" = "main" ] || exit 0 [ "$(git rev-parse --abbrev-ref HEAD)" = "main" ] || exit 0
[ -f "$mainrepo/scripts/task.sh" ] || exit 0 [ -f "$mainrepo/scripts/task.sh" ] || exit 0
echo "[next] owner 按已有授权在主检出运行 make task-close 闭合已合入任务卡;未授权时先取得授权。" echo "[next] 新任务在合入前于任务 worktree 运行 make task-close SELF=1 闭合本卡;主检出的 make task-close 仅用于 precheck [cards] 报告的遗留卡,owner 按已有授权运行,未授权时先取得授权。"
exit 0 exit 0
+102
View File
@@ -456,6 +456,8 @@ cmd_sync() {
# (PR 链接+合并 SHA)与闭合记录,并 git mv 到 .task/archive/。变更落点:主检出运行→ # (PR 链接+合并 SHA)与闭合记录,并 git mv 到 .task/archive/。变更落点:主检出运行→
# 新建 feature/card-close-* 分支推送(集成分支受保护,不直推);任务分支内运行须 --fold # 新建 feature/card-close-* 分支推送(集成分支受保护,不直推);任务分支内运行须 --fold
# (随当前任务 PR 合入)。合并判定用合并提交主题(分支删除后仍可追溯),不依赖分支存活。 # (随当前任务 PR 合入)。合并判定用合并提交主题(分支删除后仍可追溯),不依赖分支存活。
# 默认闭合方式是 close --self:任务分支在合入前把本分支的卡改为"已完成(合入前闭合)"并归档,
# 随本 PR 合入;上述合入后回填只用于遗留卡。
card_of_branch() { sed -n 's/^- 工作区:.*分支:\([A-Za-z0-9._/-]\+\).*/\1/p' "$1" | head -1; } 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_status() { sed -n 's/^.*;状态:\([^;]*\).*/\1/p' "$1" | head -1; }
@@ -467,13 +469,20 @@ 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_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_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}"; } card_accepting_requirements() { local v; v=$(card_field "$1" accepting_requirements); printf '%s\n' "${v:-false}"; }
PRE_MERGE_CLOSED="已完成(合入前闭合)"
branch_card() { # $1=branch → 当前检出中该分支的任务卡路径(无则空) branch_card() { # $1=branch → 当前检出中该分支的任务卡路径(无则空)
# 顶层优先;顶层无卡时只认 .task/archive/ 中状态为"已完成(合入前闭合)"的卡(close --self 合入前已归档)。
local pc=".task/$(echo "$1" | tr '/' '-').md" local pc=".task/$(echo "$1" | tr '/' '-').md"
[ -f "$pc" ] && { printf '%s\n' "$pc"; return 0; } [ -f "$pc" ] && { printf '%s\n' "$pc"; return 0; }
for pc in .task/*.md; do for pc in .task/*.md; do
[ -f "$pc" ] || continue [ -f "$pc" ] || continue
[ "$(card_of_branch "$pc")" = "$1" ] && { printf '%s\n' "$pc"; return 0; } [ "$(card_of_branch "$pc")" = "$1" ] && { printf '%s\n' "$pc"; return 0; }
done 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 return 0
} }
active_initiatives() { # → 每行 "branch|initiative_id|card":根目标为集成分支、未完成、仍接收需求且未合入/待闭合 active_initiatives() { # → 每行 "branch|initiative_id|card":根目标为集成分支、未完成、仍接收需求且未合入/待闭合
@@ -665,8 +674,101 @@ open(path, 'w', encoding='utf-8').write(s)
PY 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() { cmd_close() {
need_repo need_repo
if [ "${1:-}" = "--self" ]; then shift; cmd_close_self "$@"; return; fi
local fold=0 local fold=0
[ "${1:-}" = "--fold" ] && fold=1 [ "${1:-}" = "--fold" ] && fold=1
fetch_target fetch_target
+21 -8
View File
@@ -8,7 +8,8 @@
须仍有开放 PR 指向其 root_target。--final(仅 initiative):accepting_requirements=false,且 须仍有开放 PR 指向其 root_target。--final(仅 initiative):accepting_requirements=false,且
`## 子任务登记` 中「必需=是」的子任务为「已合入」(PR 已合并入本 initiative)或「已延期」。 `## 子任务登记` 中「必需=是」的子任务为「已合入」(PR 已合并入本 initiative)或「已延期」。
ci ci
仅 pull_request 事件:按 GITHUB_HEAD_REF 在检出的 .task/ 顶层取卡片,核对 GITHUB_BASE_REF。 仅 pull_request 事件:按 GITHUB_HEAD_REF 在检出的 .task/ 顶层取卡片(顶层无卡时取 .task/archive/
中状态为「已完成(合入前闭合)」的卡),核对 GITHUB_BASE_REF。
不联网、不需要 token;非 pull_request 事件跳过(退出 0)。 不联网、不需要 token;非 pull_request 事件跳过(退出 0)。
期望目标:child → immediate_target;其余(initiative / standalone / 无卡)→ root_target(无卡即 main)。 期望目标:child → immediate_target;其余(initiative / standalone / 无卡)→ root_target(无卡即 main)。
@@ -24,11 +25,12 @@ import sys
import urllib.parse import urllib.parse
from pathlib import Path from pathlib import Path
from task_triage import (EXIT_REMOTE, Stop, api_base, as_dict, card_branch, http_get_json, from task_triage import (EXIT_REMOTE, Stop, api_base, as_dict, card_branch, card_status, http_get_json,
open_pulls, read_token, repo_slug, target_branch) open_pulls, read_token, repo_slug, target_branch)
EXIT_FAILED = 1 EXIT_FAILED = 1
EXIT_USAGE = 2 EXIT_USAGE = 2
PRE_MERGE_CLOSED = "已完成(合入前闭合)"
CHILD_COLUMNS = ("子任务分支", "必需", "状态", "PR") CHILD_COLUMNS = ("子任务分支", "必需", "状态", "PR")
DONE_STATES = ("已合入", "已延期") DONE_STATES = ("已合入", "已延期")
@@ -64,16 +66,27 @@ def expected_base(meta):
return meta["immediate_target"] if meta["kind"] == "child" else meta["root_target"] return meta["immediate_target"] if meta["kind"] == "child" else meta["root_target"]
def find_card(branch): def matching_card(directory, branch, archived):
"""只看检出树的 .task/ 顶层(archive 不参与);返回 (路径, 文本),无卡返回 (None, None)。""" """directory 下声明该分支的唯一卡片;archived=True 时只认状态以「已完成(合入前闭合)」开头的卡。"""
found = [] found = []
for path in sorted(Path(".task").glob("*.md")): for path in sorted(Path(directory).glob("*.md")):
text = path.read_text(encoding="utf-8", errors="replace") text = path.read_text(encoding="utf-8", errors="replace")
if card_branch(text) == branch: if card_branch(text) != branch:
found.append((path, text)) continue
if archived and not card_status(text).startswith(PRE_MERGE_CLOSED):
continue
found.append((path, text))
if len(found) > 1: if len(found) > 1:
raise Stop(EXIT_FAILED, f"ambiguous task cards for {branch}: {', '.join(str(p) for p, _ in found)}") raise Stop(EXIT_FAILED, f"ambiguous task cards for {branch}: {', '.join(str(p) for p, _ in found)}")
return found[0] if found else (None, None) return found[0] if found else None
def find_card(branch):
"""先看检出树的 .task/ 顶层;顶层无卡时再看 .task/archive/ 中合入前闭合(close --self)的卡,
其余归档卡不参与。返回 (路径, 文本),无卡返回 (None, None)。"""
return (matching_card(".task", branch, False)
or matching_card(".task/archive", branch, True)
or (None, None))
def branch_meta(branch, target): def branch_meta(branch, target):
+26
View File
@@ -385,6 +385,32 @@ class EvidenceTests(unittest.TestCase):
cwd=self.repo, capture_output=True, text=True) cwd=self.repo, capture_output=True, text=True)
self.assertNotIn("WARN", checked.stdout + checked.stderr) self.assertNotIn("WARN", checked.stdout + checked.stderr)
def archive_card(self, status):
self.card.unlink()
archived = self.repo / ".task/archive/feature-done.md"
archived.parent.mkdir()
archived.write_text(f"# Done task\n任务主记录:PR #3 fixture;状态:{status};owner:test\n"
"- 工作区:fixture(worktree);分支:main\n"
"- 分支类型:child;initiative_id:feature-alpha;root_target:main;"
"immediate_target:feature/alpha;accepting_requirements:false\n")
return archived
def test_evidence_appends_to_pre_merge_closed_archived_card(self):
archived = self.archive_card("已完成(合入前闭合)")
result = self.run_evidence("printf 'archived\\n'")
self.assertEqual(result.returncode, 0, result.stderr + result.stdout)
text = archived.read_text()
self.assertIn("## 证据(Evidence)", text)
self.assertIn('"immediate_target": "feature/alpha"', text)
def test_evidence_ignores_archived_card_with_other_status(self):
archived = self.archive_card("已完成(回填)")
original = archived.read_text()
result = self.run_evidence("touch should-not-exist")
self.assertNotEqual(result.returncode, 0)
self.assertFalse((self.repo / "should-not-exist").exists())
self.assertEqual(archived.read_text(), original)
def test_receipts_without_target_metadata_default_to_main(self): def test_receipts_without_target_metadata_default_to_main(self):
data = self.capture() data = self.capture()
self.assertEqual((data["root_target"], data["immediate_target"]), ("main", "main")) self.assertEqual((data["root_target"], data["immediate_target"]), ("main", "main"))
+109
View File
@@ -1490,6 +1490,115 @@ class TaskLifecycleTests(unittest.TestCase):
self.assertIn("已包含于 origin/main", archived) self.assertIn("已包含于 origin/main", archived)
self.assertIn("PR #5", archived) self.assertIn("PR #5", archived)
# ---- close --self:合入前在任务分支内闭合本卡 ----
def self_close_env(self):
return self.close_env() | {"GITEA_API_URL": self.gitea.url, "GITEA_REPO": "fixture/repo"}
def start_standalone(self, name="feature/solo"):
receipt = self.capture_precheck()
triage = self.triage_receipt()
self.task(self.repo, "start", name,
env=self.env | {"PRECHECK_RECEIPT": str(receipt), "TRIAGE_RECEIPT": str(triage)})
return self.root / ("repo-wt-" + name.replace("/", "-"))
def open_pr(self, wt, branch, base="main"):
self.git(wt, "push", "-q", "-u", "origin", branch)
number = len(self.gitea.prs) + 1
sha = self.git(wt, "rev-parse", "HEAD").stdout.strip()
self.gitea.prs.append(FakeGitea.pr(number, branch, sha, base=base))
return number
def test_close_self_archives_own_card_inside_its_pr(self):
wt = self.start_standalone()
(wt / "work").write_text("task result\n")
self.git(wt, "add", "work")
self.git(wt, "commit", "-m", "Task work")
number = self.open_pr(wt, "feature/solo")
result = self.task(wt, "close", "--self", env=self.self_close_env())
self.assertIn(f"[closed] .task/feature-solo.md 已在 PR #{number} 合入前闭合并归档;合入后无需 task-close",
result.stdout)
self.assertNotIn(self.gitea.token, result.stdout)
self.assertFalse((wt / ".task/feature-solo.md").exists())
text = (wt / ".task/archive/feature-solo.md").read_text()
self.assertIn(";状态:已完成(合入前闭合);", text)
self.assertIn(f"任务主记录:PR #{number} {self.gitea.web}/{self.root.name}/remote/pulls/{number};", text)
self.assertIn("## 结果与验证\n", text)
self.assertNotIn("## 结果与验证(完成后填写)", text)
self.assertIn("## 闭合记录(合入前折叠)", text)
self.assertIn(f"PR #{number}", text.split("## 闭合记录(合入前折叠)", 1)[1])
self.assertEqual(self.git(wt, "log", "-1", "--format=%s").stdout.strip(),
"Fold-close the task card before merge")
body = self.git(wt, "log", "-1", "--format=%B").stdout
for field in ("Why:", "Evidence:", "Impact:", "Refs: task.sh close --self"):
self.assertIn(field, body)
self.assertEqual(self.git(wt, "status", "--porcelain").stdout, "")
head = self.git(wt, "rev-parse", "HEAD").stdout.strip()
self.assertEqual(self.git(self.remote, "rev-parse", "refs/heads/feature/solo").stdout.strip(), head)
self.assertEqual(self.git(self.remote, "show", "feature/solo:.task/archive/feature-solo.md").stdout, text)
again = self.task(wt, "close", "--self", check=False, env=self.self_close_env())
self.assertNotEqual(again.returncode, 0, again.stdout)
self.assertEqual(self.git(wt, "rev-parse", "HEAD").stdout.strip(), head)
def test_close_self_accepts_explicit_pr_number_without_lookup(self):
wt = self.start_standalone()
env = self.close_env() | {"TASK_PR": "42", "GITEA_API_URL": "http://127.0.0.1:9/api/v1"}
result = self.task(wt, "close", "--self", env=env)
self.assertIn("PR #42 合入前闭合", result.stdout)
self.assertIn("任务主记录:PR #42 ", (wt / ".task/archive/feature-solo.md").read_text())
def test_close_self_refuses_main_detached_dirty_and_missing_pr(self):
main_head = self.git(self.repo, "rev-parse", "HEAD").stdout.strip()
on_main = self.task(self.repo, "close", "--self", check=False, env=self.self_close_env())
self.assertNotEqual(on_main.returncode, 0, on_main.stdout)
self.assertIn("不能在 main 上运行", on_main.stdout)
self.assertEqual(self.git(self.repo, "rev-parse", "HEAD").stdout.strip(), main_head)
wt = self.start_standalone()
head = self.git(wt, "rev-parse", "HEAD").stdout.strip()
card = wt / ".task/feature-solo.md"
original = card.read_text()
def assert_refused(result, reason):
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn(reason, result.stdout)
self.assertNotIn(self.gitea.token, result.stdout)
self.assertEqual(self.git(wt, "rev-parse", "HEAD").stdout.strip(), head)
self.assertEqual(card.read_text(), original)
self.assertFalse((wt / ".task/archive").exists())
(wt / "stray").write_text("uncommitted\n")
assert_refused(self.task(wt, "close", "--self", check=False, env=self.self_close_env()), "不干净")
(wt / "stray").unlink()
assert_refused(self.task(wt, "close", "--self", check=False, env=self.self_close_env()),
"需要恰好一个 head 为 feature/solo 的开放 PR")
assert_refused(self.task(wt, "close", "--self", check=False,
env=self.self_close_env() | {"TASK_PR": "7; rm"}), "TASK_PR")
self.git(wt, "checkout", "-q", "--detach")
assert_refused(self.task(wt, "close", "--self", check=False, env=self.self_close_env()), "detached HEAD")
def test_close_self_child_keeps_initiative_target_from_archive(self):
parent = self.start_initiative()
wt, _ = self.start_child()
number = self.open_pr(wt, "feature/alpha-login", base="feature/alpha")
self.task(wt, "close", "--self", env=self.self_close_env())
archived = wt / ".task/archive/feature-alpha-login.md"
self.assertIn("immediate_target:feature/alpha", archived.read_text())
self.assertIn(f"PR #{number}", archived.read_text())
(parent / "parent-later").write_text("later initiative work\n")
self.git(parent, "add", "parent-later")
self.git(parent, "commit", "-m", "Later initiative work")
self.publish_initiative("feature/alpha")
parent_head = self.git(parent, "rev-parse", "HEAD").stdout.strip()
synced = self.task(wt, "sync")
self.assertIn("sync_target=feature/alpha", synced.stdout)
self.assertIn(f"sync_base={parent_head}", synced.stdout)
self.assertIn(f"| 同步(记录前对象) | {parent_head} |", archived.read_text())
self.git(parent, "merge", "--no-ff", "feature/alpha-login", "-m", "Merge child into initiative")
self.publish_initiative("feature/alpha")
finished = self.task(wt, "finish")
self.assertIn("[verified] feature/alpha-login is included in parent initiative feature/alpha",
finished.stdout)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+36
View File
@@ -396,6 +396,42 @@ class PrCheckTests(unittest.TestCase):
self.assertEqual(result.returncode, code, result.out) self.assertEqual(result.returncode, code, result.out)
self.assertIn(text, result.out) self.assertIn(text, result.out)
def archive_child_card(self, status):
"""把子任务卡移到 .task/archive/ 并改写状态(模拟 close --self 合入前折叠)。"""
self.build_child()
top = self.repo / ".task" / "feature-init-child.md"
text = top.read_text(encoding="utf-8").replace(";状态:进行中;", f";状态:{status};")
archive = self.repo / ".task" / "archive"
archive.mkdir(exist_ok=True)
(archive / top.name).write_text(text, encoding="utf-8")
top.unlink()
self.git(self.repo, "add", "-A", ".task")
self.git(self.repo, "commit", "-m", "Archive child card")
def test_ci_mode_reads_pre_merge_closed_card_from_archive(self):
self.archive_child_card("已完成(合入前闭合)")
result = self.ci(base="main", head="feature/init-child")
self.assertEqual(result.returncode, 1, result.out)
self.assertIn("expected 'feature/init' for child branch feature/init-child", result.out)
result = self.ci(base="feature/init", head="feature/init-child")
self.assertEqual(result.returncode, 0, result.out)
self.assertIn("[verdict] PR_CHECK_OK base=feature/init head=feature/init-child kind=child", result.out)
def test_ci_mode_ignores_archived_card_with_other_status(self):
self.archive_child_card("已完成(回填)")
result = self.ci(base="feature/init", head="feature/init-child")
self.assertEqual(result.returncode, 1, result.out)
self.assertIn("expected 'main' for standalone branch feature/init-child", result.out)
def test_ci_mode_rejects_ambiguous_archived_cards(self):
self.archive_child_card("已完成(合入前闭合)")
archive = self.repo / ".task" / "archive"
(archive / "copy.md").write_text((archive / "feature-init-child.md").read_text(encoding="utf-8"),
encoding="utf-8")
result = self.ci(base="feature/init", head="feature/init-child")
self.assertEqual(result.returncode, 1, result.out)
self.assertIn("ambiguous task cards for feature/init-child", result.out)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()