Merge authoritative main into the task branch

Why: include the current integration baseline before verification.

Evidence: fetched origin/main=682a4e8263fe007cb102fc9deaf71bda514ee387.

Impact: integrate upstream changes without rewriting task history.

Refs: task-sync
This commit is contained in:
2026-10-17 05:39:39 +00:00
26 changed files with 4029 additions and 90 deletions
+2 -1
View File
@@ -1,7 +1,6 @@
name: ci
on:
pull_request:
branches: [main]
push:
branches: [main]
@@ -156,6 +155,8 @@ jobs:
print("Checkout complete; credentials were not persisted in Git configuration")
PY_CHECKOUT
unset TOKEN SUBMODULE_TOKEN
- name: PR base gate
run: python3 scripts/task_pr_check.py ci
- name: Install Go 1.27.1
run: |
set -euo pipefail
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+12 -1
View File
@@ -19,7 +19,9 @@ candidate ends `unavailable` or `blocked`, and that result does not authorize
the host to write the unit. The legacy value `all-unavailable` remains
parseable and likewise grants no host write authority. Cursor uses
`cursor-agent-stable`. `model: auto` lets each CLI use its configured default
model; PI therefore uses the operator-selected MiniMax default.
model; PI therefore uses the operator-selected MiniMax default. The
`integration` block declares the host-exempt paths and the transport, admission,
and cleanup entry points.
```agent-execution
schema: agent-execution/v1
@@ -66,4 +68,13 @@ profiles:
executable: codex
model: auto
enabled: true
integration:
exempt_paths: [".task/**", "docs/superpowers/specs/**", "docs/superpowers/plans/**"]
transport:
command: python3 scripts/agent_exec.py apply
admission:
command: python3 scripts/agent_exec.py admit
cleanup:
command: make task-finish
```
+4 -3
View File
@@ -38,7 +38,7 @@ policy/delivery.md。
## 实施执行路由(强制)
- 实施、修复、测试与审阅调度**必读根目录 [AGENT-EXECUTION.md](AGENT-EXECUTION.md)**(唯一项目执行路由配置)。
- 实施、修复、测试与审阅调度**必读根目录 [AGENT-EXECUTION.md](AGENT-EXECUTION.md)**(唯一项目执行路由配置)。通用工程 Agent 契约(agent-per-unit 隔离执行、会话独立、JSON 终态收据、无 host 隐式代写、受控 transport/admission 准入)遵循 Development-Guidelines [core/baseline.md(硬边界 8)](deps/development-guidelines/core/baseline.md) 与 [standards/agent-execution.md](deps/development-guidelines/standards/agent-execution.md);项目根目录配置仅声明角色候选顺序与适配器参数。
- 产生仓库变更的实施按可独立实施的单元拆分;任务卡记 `实施执行:agent-per-unit(配置:AGENT-EXECUTION.md)`。
- 外部 Agent 在新进程/隔离工作区执行一个有界单元;host 负责计划、拆解、范围审阅、权威验证、规范提交、
PR/CI/合入、证据与任务元数据及安全清理。Worker 不得提交、推送、开或合 PR、部署、改路由或派遣同伴。
@@ -88,11 +88,12 @@ policy/delivery.md。
## 任务启动门禁(强制)
- **新需求分流检查**:每次接到新需求,在创建分支前以远端为准运行 `make task-triage REQ='<需求>' TRIAGE_RECEIPT=<路径>` 检查接纳中的大任务集成分支(`branch_kind=initiative` 且 `accepting_requirements=true`);有候选时再运行 `make task-triage-decide TRIAGE_RECEIPT=<路径> VERDICT=JOIN|NEW … REASON='<理由>'`;随后 `make task-start … TRIAGE_RECEIPT=<路径>` 核验收据,通过后才建分支。三态分流:`JOIN`(建立对应大任务的子任务 `make task-start KIND=child INITIATIVE=feature/<initiative> NAME=feature/<initiative>-<slug> TRIAGE_RECEIPT=<路径>`)、`NEW`(建立普通任务或新的大任务 `make task-start [KIND=initiative] NAME=feature/<slug> TRIAGE_RECEIPT=<路径>`)、`DECISION_REQUIRED`(存在候选时的待决策状态;改变范围/优先级的加入由人工决策)。
- 任务启动或恢复后,**第一次项目写操作前**通过
`RECEIPT=/tmp/<本次唯一名称>.json CMD="make precheck" make evidence` 运行一次启动检查并保存
完整脱敏收据(不要求已有任务卡);按输出完成必读。`SHARED_WORKSPACE_BUSY / SYNC_REQUIRED /
REMOTE_UNVERIFIED` 均不得进入写入流程;失败后排除原因再启动,不把缓存远端当最新事实。
- 新任务运行 `make task-start NAME=feature/<slug> PRECHECK_RECEIPT=/tmp/<本次唯一名称>.json`,
- 新任务运行 `make task-start NAME=feature/<slug> TRIAGE_RECEIPT=<分流收据路径> PRECHECK_RECEIPT=/tmp/<本次唯一名称>.json`,
建立独立 worktree 与任务卡并导入首次收据。恢复已有登记任务在原 worktree 用
`make task-resume PRECHECK_RECEIPT=/tmp/<本次唯一名称>.json` 验证并原样导入收据。
默认 delivery 要求严格 READY;局部恢复接口、启用条件与旧收据处理见 [开发门禁](policy/governance.md#启动与恢复)。任务中途不重复启动检查。
@@ -102,7 +103,7 @@ policy/delivery.md。
任务卡记录主干 R0、父分支及其 SHA、父 PR;子分支命名 `<type>/<父slug>-<子题>`,
不嵌 SHA;合入顺序固定父→子;父合入后子分支须 `make task-sync` 并入最新 main 并把 PR
base 改回 main,方可声明可合入(分支保护要求 head 含 origin/main,未同步必被拦)。
- **验收、请求审核、合入前**各运行一次 `make task-sync`;完成声明的 precheck、task-sync、
- **验收、请求审核、合入前**各运行一次 `make task-sync`,并运行 `make task-pr-check`(大任务收口用 `FINAL=1`)核验 PR base/head 门禁;完成声明的 precheck、task-sync、
测试三段输出由 `make evidence` 写入任务卡(见「证据与验证门禁」),不手抄。
- **多个分支交付 / CI 调度**:先读 [合入调度](policy/merge-queue.md),核对采纳状态、协调者、
记录及当前窗口。新模式尚未启用时保留既有同步节点;启用后最终同步至合入核验共用窗口,
+21 -3
View File
@@ -13,18 +13,22 @@ PLUGINS := plugins
COMBO := core=$(shell git -C $(CORE) rev-parse --short HEAD 2>/dev/null)+backend=$(shell git -C $(BACKEND) rev-parse --short HEAD 2>/dev/null)+frontend=$(shell git -C $(FRONTEND) rev-parse --short HEAD 2>/dev/null)+ui=$(shell git -C $(UI) rev-parse --short HEAD 2>/dev/null)+agentcmd=$(shell git -C deps/agent-command rev-parse --short HEAD 2>/dev/null)+documents=$(shell git -C modules/documents rev-parse --short HEAD 2>/dev/null)+native=$(shell git -C deps/agent-native rev-parse --short HEAD 2>/dev/null)+squad=$(shell git -C deps/agent-squad rev-parse --short HEAD 2>/dev/null)+intent=$(shell git -C deps/agent-intent rev-parse --short HEAD 2>/dev/null)
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null)+$(COMBO)
.PHONY: help sync status build frontend svc svc-list svc-info svc-commits svc-history svc-update svc-recover svc-watchdog test-backend test-backend-pg test-multi-instance test-lifecycle test-frontend test-ui test-core test-agentcmd test-pins test-libs dev pin clean setup precheck task-start task-resume task-sync task-close task-finish facts-verify evidence version agent-exec-validate agent-exec-apply agent-exec-admit
.PHONY: help sync status build frontend svc svc-list svc-info svc-commits svc-history svc-update svc-recover svc-watchdog test-backend test-backend-pg test-multi-instance test-lifecycle test-frontend test-ui test-core test-agentcmd test-pins test-libs dev pin clean setup precheck task-triage task-triage-decide task-start task-resume task-sync task-close task-finish task-pr-check facts-verify evidence version agent-exec-validate agent-exec-apply agent-exec-admit
version:
@echo "agent-system $(VERSION)"
TASK := scripts/task.sh
export PRECHECK_RECEIPT RECEIPT IMPORT_RECEIPT TASK_WRITERS_STOPPED
export PRECHECK_RECEIPT RECEIPT IMPORT_RECEIPT TASK_WRITERS_STOPPED TRIAGE_RECEIPT
TASK_WORK_MODE ?= delivery
export TASK_WORK_MODE
TASK_PARENT_BRANCH ?= $(PARENT)
TASK_PARENT_PR ?= $(PARENT_PR)
export TASK_PARENT_BRANCH TASK_PARENT_PR
TASK_BRANCH_KIND ?= $(KIND)
export TASK_BRANCH_KIND
TASK_INITIATIVE ?= $(INITIATIVE)
export TASK_INITIATIVE
setup:
git config core.hooksPath scripts/hooks
@@ -34,8 +38,14 @@ setup:
precheck:
@bash $(TASK) precheck
task-triage:
@bash $(TASK) triage "$(REQ)"
task-triage-decide:
@VERDICT="$(VERDICT)" INITIATIVE="$(INITIATIVE)" REASON="$(REASON)" bash $(TASK) triage-decide
task-start:
@if [ -z "$(NAME)" ]; then echo "usage: make task-start NAME=feature/<slug>"; exit 2; fi
@if [ -z "$(NAME)" ]; then echo "usage: make task-start NAME=feature/<slug> TRIAGE_RECEIPT=<path>"; exit 2; fi
@bash $(TASK) start "$(NAME)"
task-resume:
@@ -50,6 +60,10 @@ task-close:
task-finish:
@bash $(TASK) finish $(ABANDON)
# PR base/head 门禁(两级分支):本地分支卡片期望目标 vs Gitea PR 与 origin;FINAL=1 用于 initiative 收口。
task-pr-check:
python3 scripts/task_pr_check.py local $(if $(PR),--pr $(PR)) $(if $(FINAL),--final)
# 证据写入:执行命令并把结果行追加进当前任务卡(AGENTS.md「证据与验证门禁」)。
# 本地执行只记 SELF;RECEIPT 捕获启动收据,IMPORT_RECEIPT 导入原始收据而不重跑。
# 用法:CMD="<单行命令>" make evidence [TAIL=15];退出码随被执行命令。
@@ -80,6 +94,10 @@ help:
@echo "make agent-exec-validate 校验 AGENT-EXECUTION.md(agent_exec validate)"
@echo "make agent-exec-apply RECEIPT=<path> 快进当前分支到成功收据的 transport"
@echo "make agent-exec-admit 校验暂存路径具备 transport 来源或属于 host 豁免"
@echo "make task-triage REQ='<需求>' TRIAGE_RECEIPT=<path> 以远端为准发现接纳中的 initiative 并写分流收据(候选存在时 exit 5,待决策)"
@echo "make task-triage-decide TRIAGE_RECEIPT=<path> VERDICT=JOIN|NEW [INITIATIVE=<branch>] REASON='<理由>' 记录显式分流决策(不按数量自动 JOIN)"
@echo "make task-start NAME=feature/<slug> TRIAGE_RECEIPT=<path> [KIND=...] [INITIATIVE=...] 建任务前核验分流收据(失败不建分支)"
@echo "make task-pr-check [PR=N] [FINAL=1] 核验当前任务分支 PR 的 base/head 门禁(远端为准;FINAL=1 用于 initiative 收口)"
agent-exec-validate:
PYTHONDONTWRITEBYTECODE=1 python3 scripts/agent_exec.py validate --repo .
@@ -0,0 +1,81 @@
# development-governance-v2 统一治理主记录
本文件是治理大任务跨仓状态的唯一汇总。每行状态只取以下值之一,不用笼统的“完成”:
`PLANNED / IMPLEMENTED / LOCALLY_VERIFIED / PR_OPEN / REVIEWED / GATE_PASS / MERGED / ADOPTED_ACTIVE / RELEASED / CLOSED`。
通过类结论须带来源(GATE / REPLAY / SELF)与对象 SHA;历史对象证据不得充当当前 HEAD 证据。
全局任务只有在全部正式仓达到 `ADOPTED_ACTIVE`、新项目入口启用、主 PR 全部合入并完成清理后才能标记 `CLOSED`。
## 交付主线
| Phase | 内容 | 仓库 / 分支 | PR | 状态 | 依赖 |
|---|---|---|---|---|---|
| 0 | 纠正候选状态、建立本主记录 | Dev `feature/development-governance-v2` | [#585](https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/pulls/585) | IMPLEMENTED | — |
| 1 | Guidelines 合同加固 | Guidelines `feature/development-governance-v2-contract-hardening` → `feature/development-governance-v2` | [#7](https://git-home.u1680832.nyat.app:30319/weichen/Development-Guidelines/pulls/7)(initiative→main) | PLANNED | — |
| 2 | Dev triage 与两级生命周期整改(U2.1 远端发现与强制 triage;U2.2 即时目标完整性;U2.3 PR base 门禁) | Dev `feature/development-governance-v2-triage-hardening` → initiative | — | PLANNED | 1(合同) |
| 3 | 真实两级 PR 演练 | Dev child PR → initiative → #585 → main | — | PLANNED | 2 |
| 4 | Guidelines 合入 → Dev gitlink → Review → 三项 CI → 合入 #585 → 收口 | Guidelines / Dev | #7 / #585 | PLANNED | 1–3 |
| 5 | Development-Manager 共享执行器 `agent-exec`;Dev Python 实现退役 | DM `feature/shared-agent-executor` | — | PLANNED | 4 |
| 6 | 存量仓逐仓采纳 | 各仓独立 initiative | — | PLANNED | 5 |
| 7 | 新项目治理初始化与 CI 准入 | DM | — | PLANNED | 5 |
## 当前候选对象与历史证据
| 仓库 | 分支 | 对象 | 证据 | 说明 |
|---|---|---|---|---|
| Development-Guidelines | `feature/development-governance-v2` | `ed4726c` | 无当前 Review/GATE | PR #7 open,未合入 |
| Agent-System-Dev | `feature/development-governance-v2` | `611f855`(整改前)→ `e7e8ee0`(同步 main 后) | CI run 3417(对象 `9bc61ef`)、3419(对象 `611f855`)为历史对象证据 | PR #585 open,未合入;`accepting_requirements=false` 直至整改完成后按需重开 |
## 已确认阻塞缺陷(整改前)
1. Dev `task-triage` 按接纳中 initiative 数量直接给出 `JOIN`,不做适配判断(违反 KTD1)。
2. Dev `task-triage` 只扫描当前检出的 `.task/`,看不到只存在于远端分支的 initiative(违反 KTD2)。
3. `task-start KIND=child` 依赖本机已有父分支,未校验父 initiative 仍开放、接纳需求(违反 KTD3)。
4. `task-start` 不要求 triage 收据;无 PR base 门禁;闭合记录与证据未贯穿 `immediate_target`。
5. Guidelines 缺 terminal receipt 机器 Schema;配置 Schema 未覆盖 transport/admission/豁免/清理;`all-unavailable` 未与新项目准入隔离;triage 合同仍为数量判定。
6. 原 U4 实施方式为 host 预写补丁交 CLI 应用,后续单元须由 CLI 自主实施。
## 正式仓库清单与采纳矩阵
来源:Gitea `/user/repos`(2026-10-16 实测)。排除:`Agent-System`(冻结参考仓,不再开发)、`SMDF-Agent-System`(空仓)、
`governance-drill-2026-09` 与 `merge-queue-admission-fixture-20261014`(演练夹具)。本机 `*-wt-*`、备份与组合目录不计入。
| 批次 | 仓库 | Guidelines 固定版本 | AGENT-EXECUTION.md | 共享执行器入口 | 正例 | 负例 | Review/CI | 状态 |
|---|---|---|---|---|---|---|---|---|
| A | Development-Guidelines | 自身 | — | — | — | — | — | PLANNED |
| A | Agent-System-Dev | 待更新 | 有(本地 Python 控制器) | 待切换 | — | — | — | PLANNED |
| A | Development-Manager | 待更新 | 无 | 实现方 | — | — | — | PLANNED |
| B | Agent-System-Core | — | 无 | 无 | — | — | — | PLANNED |
| B | Agent-System-Backend | — | 无 | 无 | — | — | — | PLANNED |
| B | Agent-System-RDM | — | 无 | 无 | — | — | — | PLANNED |
| C | Agent-System-Frontend | — | 无 | 无 | — | — | — | PLANNED |
| C | Agent-System-UI | — | 无 | 无 | — | — | — | PLANNED |
| C | Agent-System-Documents | — | 无 | 无 | — | — | — | PLANNED |
| C | Agent-System-Releases | — | 无 | 无 | — | — | — | PLANNED |
| D | Agent-Command | — | 无 | 无 | — | — | — | PLANNED |
| D | Agent-Intent | — | 无 | 无 | — | — | — | PLANNED |
| D | Agent-Native | — | 无 | 无 | — | — | — | PLANNED |
| D | Agent-Squad | — | 无 | 无 | — | — | — | PLANNED |
| E | Error-Code-System | — | 无 | 无 | — | — | — | PLANNED |
| E | Logging-System | — | 无 | 无 | — | — | — | PLANNED |
| E | Agent-Document-Renderer | — | 无 | 无 | — | — | — | PLANNED |
## Owner 决策记录
| 日期 | 决策 | 影响 |
|---|---|---|
| 2026-10-16 | cursor 服务端故障期间临时让 agy 成为首选;实际以不含 cursor 包装脚本的 PATH 启动执行器实现(停用 cursor profile 会使 Dev 参考解析器拒绝配置),cursor 冒烟恢复后回到正常 PATH | 无配置改动;收据中 cursor 如实记为 unavailable |
| 2026-10-17 | 共享执行器分发:各正式仓像 Guidelines 一样固定 Development-Manager gitlink(`deps/development-manager`),薄入口 `scripts/agent-exec` 构建/运行该固定版本的 `dm-server agent-exec`;CI 用同一固定版本运行 `agent-exec validate` | Phase 6 每仓增加 DM gitlink 与薄入口;Dev Python 实现退役后同样切到该入口 |
| 2026-10-17 | CI 读取私有 Guidelines/DM 子模块所需的 `SUBMODULE_TOKEN`:owner 授权智能体以 `~/.secrets/gitea` 的 API token 作为该 secret 值(只记录来源,不记录值) | 已为 Development-Manager 配置;Phase 6 按需为其他仓配置 |
| 2026-10-17 | DM CI 自 2026-09-30 起从未成功(runner 无法拉取 github.com 上的 actions/checkout);按 Dev 已审阅的内联检出模式改为自包含 CI(DM-U6) | Phase 6 中使用 `uses:` 的仓(Error-Code-System、Logging-System)同样需要检查 |
## 停止条件
无法获取权威远端 initiative;triage 收据与需求或候选 HEAD 不一致;child PR base 错误;Guidelines gitlink 未指向真实已合入版本;
CLI 终态无效或副作用未知;Review、CI 或平台准入缺失;主干在最终 CI 后继续前进;存量仓无共享执行器却启用强制规则;
需要发布规范 tag 而用户尚未执行双钥匙操作。出现任一项即停止对应交付窗口。
## 用户双钥匙事项
- Development-Guidelines 正式版本 tag:Agent 只准备命令,由用户执行。
- 分支保护变更(如新增必需检查):Agent 只准备方案,由用户执行。
@@ -0,0 +1,79 @@
# 共享 Agent 执行器差异审计与架构归属(U3)
## 一、背景与审计目的
在推进“开发分支与 Agent 执行规范升级”(`development-governance-v2`)治理大任务中,核心要求之一是将 Agent CLI 执行约束由 Agent-System-Dev 的项目私有规则提升为 Development-Guidelines 的通用工程规范,并解决各仓库共享执行器的归属与复用问题。
本审计针对当前已存在的两个主要实现进行差异比对:
1. **Agent-System-Dev 控制器**:`scripts/agent_exec.py` + `scripts/agent_exec_adapters.py` + `scripts/agent_exec_config.py`;
2. **Development-Manager 任务运行器**:`internal/runner/runner.go` + 相关适配器。
目标是确定唯一所有者边界,避免在十几个仓库中复制代码,也不在通用规范库中塞入守护进程与语言特定绑定。
---
## 二、两套实现的详细比对
| 维度 | Agent-System-Dev (`agent_exec.py`) | Development-Manager (`runner.go`) | 契约对齐与差距分析 |
|---|---|---|---|
| **调用模型** | 按需由 Host(CLI / Makefile / 脚本)调起的有界执行单元控制器 | 后台常驻守护进程,从 PostgreSQL 数据库轮询 `tasks` 记录并执行 | DM 属于异步平台运行器,Dev 属于交互式/本地工程单元执行器。两者面向阶段不同。 |
| **配置权威** | 严格读取项目根目录 `AGENT-EXECUTION.md`,校验 schema 与角色路由 | 数据库中记录项目与任务信息,硬编码或配置注入 adapter 列表 | DM 未对接各项目独立的 `AGENT-EXECUTION.md` 配置。 |
| **角色路由** | 支持 `implementation`、`repair`、`test`、`review` 四个独立角色候选列表 | 仅支持单一全局适配器列表遍历 | DM 缺乏按任务角色(实施/修复/测试/审查)精细路由的能力。 |
| **候选轮换与重试** | 严格遵循候选顺序,每候选最多 3 次全新尝试;安全可恢复失败才换候选;全耗尽后支持一次 emergency profile | 尝试按序获取可用适配器,未实现针对 profile 的精确尝试计数与失败分类 | DM 缺少对失败语义的严格分流。 |
| **失败分类与 Fail-Stop** | 细分为 `unavailable`、`failed-clean`、`failed-unknown`、`scope-expansion`、`blocked`、`cancelled`;未知副作用严格 fail-stop | 仅判断 err 是否为 nil,超时则记录预算耗尽,未提交改动记失败 | DM 未对未知副作用进行防扩散控制,缺乏 fail-stop 门禁。 |
| **会话与进程隔离** | 每次尝试必为全新临时进程与工作区;禁止 `--continue`/`--resume` | 在 `.worktrees/` 下新建 worktree,但适配器内部进程管理由适配器各自实现 | 两者均采用独立 worktree,但 Dev 控制器对 CLI 命令行参数和 session flag 做了强制隔离。 |
| **终态收据解析** | 实时消费事件流,严格从 CLI 原生信封(Cursor `result`、agy `result`/`json`、PI `message_end`)解码结构化收据 | 依赖适配器返回 `Receipt` 结构体,不校验底层事件信封篡改 | Dev 控制器的解码更防伪造,严格区分自然语言完成与结构化 completed。 |
| **Git 权限与推送边界** | Worker 完全无 Git 推送与 PR 权限;通过受控 transport 快照集成,并由 pre-commit admit 校验 HMAC | Runner 代办 `gitPush` 并调用 Gitea API 创建 PR,但 Worker 需在 worktree 内完成 commit | DM 模型直接将 Worker 的 commit 推送到远端,缺少 Host 主动审查与 transport 完整性准入。 |
| **Host Fallback** | `fallback_to_host: never`,安全耗尽后输出 `blocked`/`unavailable`,绝不代写业务代码 | 任务失败后将任务置为 `open` 或打标签,但未约束本地主协调者行为 | 通用规范要求明确禁止 Host 隐式代写。 |
---
## 三、架构归属与分工决策
根据 Development-Guidelines 的要求与实现分离原则:
```
+-----------------------------------------------------------+
| Development-Guidelines (规范权威) |
| - 核心规则硬边界 8 (Core Baseline MUST) |
| - 通用 Agent 执行契约 (standards/agent-execution.md) |
| - 机器校验 Schema (templates/schemas/agent-execution.json)|
| - 项目执行配置模板 (templates/AGENT-EXECUTION.md.template) |
+-----------------------------------------------------------+
│ 规定合同与合格判据
▼
+-----------------------------------------------------------+
| 共享工具层 (Development-Manager 或 独立执行控制器包) |
| - 可复用执行控制器 (Execution Controller) |
| - 标准 CLI 适配器 (Cursor / Antigravity / PI / Codex) |
| - 事件流解码器与收据校验器 (Terminal Receipt Validator) |
| - 受控 Transport 打包器与 Admission 准入校验器 |
+-----------------------------------------------------------+
│ 被各项目通过轻量入口调用
▼
+-----------------------------------------------------------+
| 宿主项目仓库 (Agent-System-Dev / Core / Backend / ...) |
| - 项目根目录 AGENT-EXECUTION.md (角色、候选、超时、豁免) |
| - Makefile 薄封装 (make agent-exec-validate/apply/admit) |
| - 项目特有豁免路径与测试验证入口 |
+-----------------------------------------------------------+
```
### 1. 为什么不将控制器直接放入 Development-Guidelines?
Development-Guidelines 纯粹以文档、契约、schema 和规则为主,不依赖任何编程语言运行时(Python/Go/Node),也不包含守护进程。将几十页 Python 脚本或 Go 源码放入规范仓会破坏其轻量、项目无关和通用性。
### 2. 为什么不把 Dev 的 `scripts/agent_exec.py` 复制到各个仓库?
在 15 个以上的仓库中各复制一份 2700 行的 Python 脚本,会导致版本漂移、修复成本倍增和维护灾难。
### 3. 工具层演进路径
- **短期(当前试点期)**:以 `Agent-System-Dev` 中验证成熟的 `scripts/agent_exec.py`、`agent_exec_adapters.py`、`agent_exec_config.py` 作为黄金参考实现(Reference Implementation)。
- **中期(共享工具化)**:将该控制器抽取为独立 CLI 工具(如 `agent-exec` CLI)或作为 `Development-Manager` 的底层执行子命令(`dm exec`),供本地协调者与 CI/CD 统一调用。
- **长期(自动化闭环)**:`Development-Manager` 异步调度器在后台派发任务时,直接复用该底层执行内核,确保自动化派发与本地 Host 派发遵循完全一致的失败分流、收据解析与受控集成契约。
---
## 四、结论与验收判据
1. **合同在先,实现对齐**:通用规范(Development-Guidelines)只约束合同(有界单元、会话隔离、收据格式、受控准入、禁止代写),不绑定具体语言实现。
2. **工具符合性**:任何执行器(无论是 Dev 的 Python 脚本还是 DM 的 Go runner)必须通过相同的正负例验收判据(A8)才能声明为合规执行器。
3. **消除隐式代写**:所有工具实现均必须支持 `fallback_to_host: never`,在候选耗尽时明确返回受阻终态,不得放行 Host 隐式撰写业务代码。
@@ -0,0 +1,92 @@
# 存量仓采纳矩阵与新项目默认准入规范(U6 & U7)
## 一、背景与定位
在 `Development-Guidelines`(v0.1.0-draft.10)确立通用工程 Agent 契约(硬边界 8、agent-per-unit、会话隔离、JSON 终态收据、无 host 隐式代写、受控 transport/admission)以及两级分支模型(`initiative` → `child` → `main`)后,各仓库需要统一演进路径,避免规则漂移与层级倒置。
本规范制定存量仓库分批采纳矩阵(U6)与新项目默认准入标准(U7)。
---
## 二、存量仓库采纳矩阵(U6)
### 1. 仓库清单与角色定位
| 仓库名称 | 路径/标识 | 当前类型 | 治理批次 | 重点要求 |
|---|---|---|---|---|
| **Development-Guidelines** | `deps/development-guidelines` | 规范权威仓 | **Phase 1**(当前) | 核心硬边界 8、`standards/agent-execution.md`、模板与 JSON Schema(PR #7) |
| **Agent-System-Dev** | `.`(编排根仓) | 顶层试点仓 | **Phase 1**(当前) | 两级分支与 triage 门禁、`AGENT-EXECUTION.md` 引用上游标准、本地 `agent_exec.py` 严格校验 |
| **Development-Manager** | `Development-Manager` | 平台工具/服务 | **Phase 2** | `runner.go` 协议对齐 Draft.10 Schema、提供共享执行与 transport/admission 能力 |
| **Agent-System-Core** | `modules/core` | 核心引擎 | **Phase 3** | 配置 `AGENT-EXECUTION.md`、接入 pre-commit admit 校验、支持 initiative 子任务交付 |
| **Agent-System-Backend** | `modules/backend` | 业务后端 | **Phase 3** | 配置 `AGENT-EXECUTION.md`、子模块对齐、API 变更必须经隔离执行单元 |
| **Agent-System-Frontend** | `modules/frontend` | 业务前端 | **Phase 4** | UI 设计门禁(Tokens/Motion)+ Agent 执行门禁,双重校验 |
| **Agent-System-UI** | `modules/ui` | 组件库 | **Phase 4** | 独立 tokens 变更、组件重构必须保持单元隔离与独立 Review |
| **Agent-System-Documents** | `modules/documents` | 文档服务 | **Phase 4** | Python 环境与轻量脚本走受控执行 |
| **Agent-System-RDM** | 独立 RDM 仓 | 数据研发管理 | **Phase 4** | 数据模型与工作台变更走两级分支 |
| **Shared Libraries** (`agent-command`, `agent-native`, `agent-squad`, `agent-intent`, `error-code-system`, `logging-system`) | `deps/*` | 基础叶子库 | **Phase 5** | 极简 `AGENT-EXECUTION.md` 配置,杜绝跨仓随意手工修改 |
### 2. 分批推进策略与准出条件
```mermaid
flowchart TD
P1["Phase 1: Guidelines 规范升级 & Dev 试点<br>(PR #7 + Dev 两级分支 & triage)"] --> P2["Phase 2: Development-Manager 共享执行器对齐<br>(Runner Schema & Transport 统一)"]
P2 --> P3["Phase 3: Core & Backend 核心系统采纳<br>(两级分支 + AGENT-EXECUTION.md + admit)"]
P3 --> P4["Phase 4: Frontend, UI & 周边服务采纳<br>(设计门禁 + 执行门禁联动)"]
P4 --> P5["Phase 5: 共享叶子库全面接入<br>(无免死金牌,全代码库强制受控实施)"]
```
#### Phase 1(本大任务范围):规范权威冻结与编排仓试点
- **目标**:上游 Guidelines 确立合同与 Schema,Dev 仓实现两级生命周期与 triage,并作为试点验证完整 agent-per-unit 交付流程。
- **准出**:Guidelines PR #7 进入 review/待合并状态;Dev 仓 `test_task.py` 全部通过,`task-triage` 正常分流,`policy/delivery.md` 与 `AGENTS.md` 对齐。
#### Phase 2:共享执行器与平台工具对齐
- **目标**:Development-Manager 基于 [docs/superpowers/specs/2026-10-16-shared-executor-audit.md](2026-10-16-shared-executor-audit.md) 收敛 runner 实现,支持机器可读收据、HMAC 完整性签名及快进应用。
- **准出**:DM CLI 可直接作为通用底层,替代零散拷贝。
#### Phase 3:核心后端系统(Core / Backend)采纳
- **目标**:业务最密集、风险最高的两个系统接入:
1. 根目录建立 `AGENT-EXECUTION.md`。
2. 启用本地 pre-commit 钩子(集成分支防直推 + `admit` 校验)。
3. 大任务(如数据流重构、跨模块协议升级)强制切 `feature/<initiative>`,日常小卡片切 `feature/<initiative>-<child>` 或 standalone。
#### Phase 4:前端、UI 与周边服务采纳
- **目标**:与现行 `DESIGN.md` 设计门禁深度绑定,UI 令牌与组件重构使用隔离单元执行与独立 Review。
#### Phase 5:全量叶子库采纳
- **目标**:消灭任何“因为是小库就可以由主智能体直接手工写”的特权空间。所有库统一受控。
---
## 三、每个仓库采纳的标准动作清单
每个存量仓库在纳入治理时,必须完成以下 5 步:
1. **引入项目配置**:
从 `templates/AGENT-EXECUTION.md.template` 复制到仓库根目录 `AGENT-EXECUTION.md`,根据仓库需求配置角色候选顺序(默认:implementation/repair 为 cursor/antigravity/pi,test/review 为 pi/antigravity/cursor/codex)。
2. **挂接本地门禁**:
在 `scripts/hooks/pre-commit` 中接入集成分支防直提检查与 `agent_exec.py admit`(或平台提供的 admit 检查)。
3. **引入两级分支管理**:
在任务管理脚本中支持 `branch_kind`(initiative / child / standalone)、`task-triage` 检查与 `immediate_target` 同步。
4. **任务卡模板升级**:
采纳带两级分支元数据与 Agent 实施收据表格的统一模板(`templates/task-card.md`)。
5. **在 `policy/facts.md` 与 `adoption.md` 中登记**:
记录采纳版本、已验证的 CLI 候选及最后审计时间戳。
---
## 四、新项目默认准入规范(U7)
对于任何新建仓库(包含微服务、工具库、实验项目):
1. **非可选硬门禁**:
- 根据 `workflows/adoption.md`,`AGENT-EXECUTION.md` 与项目任务管理门禁为**必需项(MANDATORY)**。
- “本项目没有产品业务 Agent”**不得作为豁免工程 Agent 执行规则的理由**。
2. **脚手架默认预置**:
- 仓库初始化模版必须内置:
- `AGENT-EXECUTION.md`(配置 Draft.10 Schema 标准)
- `policy/delivery.md`(明确 goal-first 与两级分支交付)
- `scripts/hooks/pre-commit`(接线执行门禁)
3. **CI 流水线验证**:
- CI 流水线必须包含 `agent-execution.schema.json` 语法校验,防止配置损坏。
4. **高后果双钥匙规则**:
- 仓库初始化及初始 main 分支保护由人类用户创建,禁止智能体自行关闭保护。
+3 -1
View File
@@ -6,7 +6,8 @@
- 过程文档位置:modules/documents(docs/records/)
- 版本权威:父仓 gitlink
- 批准/生效依据:Dev PR#1(19/19 正负例 + make test);guidelines PR#1
- 合入调度通用版本:`0.1.0-draft.11`,实际合入提交 `2f5b19872be2a4597838a4131ad75d1be089ee5e`(Guidelines PR #5);本仓 gitlink 固定该源码版本。Dev 采用 [manual 串行合入规则](merge-queue.md):并行开发,轮到后才同步、跑最终 CI 并由智能体合入。该规则不要求先完成全写者审计或第二轮试行,也不宣称自动互斥已经实现。
- 规范源码版本:`0.1.0-draft.12`,实际合入提交 `2688fd2be6a52c0bf110e7e6366b6f291ab5e4eb`(Guidelines PR #7);本仓 gitlink(`deps/development-guidelines`)固定该源码版本,版本权威为父仓 gitlink。
- 合入调度通用版本:`0.1.0-draft.11`,实际合入提交 `2f5b19872be2a4597838a4131ad75d1be089ee5e`(Guidelines PR #5);draft.12 未修改其合入调度文件(standards/merge-queue.md、templates/merge-queue.md)。Dev 采用 [manual 串行合入规则](merge-queue.md):并行开发,轮到后才同步、跑最终 CI 并由智能体合入。该规则不要求先完成全写者审计或第二轮试行,也不宣称自动互斥已经实现。
- 过程计划固定:Documents PR #96 实际合入 `129cc3579d9c9e3edfb46c99270d65f3e1dad0a0`,包含既有主干文档及获批 U1–U6 计划;当前进展以任务/PR 记录为准。
| 规范/工作流 | 适用条件与范围 | 执行方式/证据 |
@@ -24,6 +25,7 @@
| 同上(zcode 实例) | zcode 客户端写路径 | policy/tool-adaptation-zcode.md:PreToolUse 写守卫;脚本负例与客户端接线分开,客户端 pending-live |
| core/baseline.md「规范层级」+ documentation.md「记忆分层/机制化」(draft.9) | 层级效力、记忆权威、经验机制化 | manual:本仓 policy/ 仅收紧不放宽;记忆四层按条文执行(任务卡=任务记忆,documents/records=项目记忆,Dev 仓 AGENTS.md 指针已就位) |
| draft.11 合入调度 | active/manual;Dev main 使用 #518 排序和单一最终窗口 | 等待分支不追 main;窗口任务同步一次、固定 HEAD、跑当前必需 CI、由智能体合入并回读结果。Issue 不是原子锁,平台保护仍承担最终准入 |
| draft.12 工程 Agent 执行标准、终态收据 Schema、两级交付与分流(硬边界 8、agent-execution、A8) | 实施、交付、分流与 PR 目标;任务启动前 | active/manual:`make task-triage` 收据为 `make task-start … TRIAGE_RECEIPT=…` 的前置,远端不可核验时 REMOTE_UNVERIFIED(exit 6)并拒绝建分支;child PR 由 `make task-pr-check` 本地核验与 CI `PR base gate` 门禁;AGENT-EXECUTION.md 已声明 `integration` 块。draft.12 Schema 正负例与平台侧负例在项目中的真实运行本次未执行(NOT_RUN),不由本行推断 |
## 项目差异
+23 -1
View File
@@ -68,7 +68,7 @@
## Agent 实施单元与主模型职责
产生仓库变更的实施工作拆成可独立实施的单元。任务卡默认记
产生仓库变更的实施工作拆成可独立实施的单元。通用规则遵循上游 [Development-Guidelines 核心硬边界 8 与 agent-execution 标准](../deps/development-guidelines/standards/agent-execution.md)(agent-per-unit、会话隔离、JSON 终态收据、无 host 隐式代写、受控 transport/admission 准入)。任务卡默认记
`实施执行:agent-per-unit(配置:AGENT-EXECUTION.md)`。执行路由以根目录
[AGENT-EXECUTION.md](../AGENT-EXECUTION.md) 为唯一权威;按角色声明的候选顺序在新进程与
隔离工作区中尝试外部 Agent。不得跨单元复用同一外部进程,也不得 resume 同一次尝试。
@@ -149,6 +149,28 @@ host 可以直接编写并提交的豁免只有 `.task/**`、`docs/superpowers/s
- 会话隔离不要求为简单任务增加新平台或复杂编排。平凡文档/配置任务仍按最小闭环执行;需要独立 Review
时只额外启动一个最小、一次性的评审 session,不复用实施 session,也不扩展任务范围。
### 两级分支交付模型与分流检查
本仓采用两级业务交付模型:
- **`main`**:稳定基线,仅接纳已完成并通过最终组合准入的结果。
- **大任务集成分支(initiative)**:从最新 `origin/main` 建立,根目标与即时目标均为 `main`。负责跨单元目标的阶段集成、组合验证与最终交付。合入主干后关闭、归档与回收,不作为长期存在的 develop 分支。
- **子任务分支(child)**:从远端所属大任务分支的当前头建立(须等于分流收据核验的 head,`make task-start` 校验),即时目标为大任务分支。负责一个有界单元的实施、独立 Review 与局部验证,通过 PR 合入大任务分支。
- **普通任务分支(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。
每次接到新需求,创建分支前必须分流,事实来源为 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`。
2. **决策**(仅有候选时):`make task-triage-decide TRIAGE_RECEIPT=<路径> VERDICT=JOIN INITIATIVE=<分支> REASON='<加入理由>'`,或 `VERDICT=NEW REASON='<不适配理由>'`,显式记录结论与理由。禁止按候选数量自动 `JOIN`;加入会改变范围、关键路径或交付边界时交用户决策。
3. **启动**:`make task-start … TRIAGE_RECEIPT=<路径>` 在创建任何 worktree 或分支前核验收据(默认 4 小时内有效)、候选对象未变化,且结论与分支类型及所选 initiative 一致;不一致或远端不可核验时拒绝,不建分支。
- `JOIN` 建立子任务(child);`NEW` 建立普通任务(standalone)或新的大任务(initiative)。
PR 目标:子任务 PR 的 base 为所属大任务分支;普通任务与大任务 PR 的 base 为 `main`。`make task-pr-check` 在本地核验 base 与 head(远端为准),CI 的 `PR base gate` 以同一规则复核。
两级合入与验证:
1. **子任务合入大任务**:子任务完成时,通过 `make task-sync` 合并大任务分支的远端最新状态(delivery 只认远端头),解决文本与语义冲突,完成直接相关测试与独立 Review 后,以大任务分支为 base 提 PR,并通过 `make task-pr-check` 与 CI `PR base gate`;
2. **大任务合入主干**:所有必需子任务已合入或已延期后,将大任务卡的 `accepting_requirements` 置为 `false`,通过 `make task-sync` 同步最新 `origin/main`,再运行 `make task-pr-check FINAL=1` 核验 `## 子任务登记` 中必需子任务均为「已合入」或「已延期」;随后执行完整组合验证、独立 Review 与必需 CI,以 `main` 为 base 合入。
## 目标优先、范围控制与问题分流
执行顺序为:
+14 -4
View File
@@ -37,10 +37,16 @@ CI 或独立 REPLAY。host 可以直接管理的豁免只有 `.task/**`、`docs/
从当前检出运行一次启动检查,使用每次唯一、仓外的绝对收据路径:
```bash
make task-triage REQ='<需求>' TRIAGE_RECEIPT=/tmp/my-task-triage.json
RECEIPT=/tmp/my-task-precheck.json CMD="make precheck" make evidence
make task-start NAME=feature/my-task PRECHECK_RECEIPT=/tmp/my-task-precheck.json
make task-start NAME=feature/my-task TRIAGE_RECEIPT=/tmp/my-task-triage.json PRECHECK_RECEIPT=/tmp/my-task-precheck.json
```
分流以远端为准(见 [迭代交付](delivery.md) 的两级分支模型)。有候选时先运行
`make task-triage-decide TRIAGE_RECEIPT=… VERDICT=JOIN|NEW …` 写入显式决策与理由。子任务使用
`KIND=child INITIATIVE=feature/<大任务> NAME=feature/<大任务>-<子题>`,收据须为选中该大任务的 JOIN。
`task-start` 对 DECISION_REQUIRED、过期、远端不可核验(REMOTE_UNVERIFIED,退出码 6)或与所选 initiative 不一致的收据直接拒绝,且不建分支。
第一步先捕获原始执行,再创建任务卡;收据保存完整脱敏输出、退出码、执行位置和对象。
第二步核验收据与当前仓库/基线,再创建工作区并导入;不靠事后重跑冒充首次检查。
同一收据不可覆盖。首次检查失败时处理原因,使用新路径重新启动。主干在两步间前进时,
@@ -63,12 +69,16 @@ LOCAL_READY 只用于局部恢复;新任务入口始终校验严格 READY,
恢复核验实际 worktree/卡片/分支、R0 祖先关系及源码外原始收据;脏现场、Git 操作、组件漂移和未知远端仍拒绝。
旧收据允许作为历史证据原样导入,不自动成为当前恢复或交付准入;格式继续为 schema 1。
堆叠任务使用 `PARENT=feature/parent PARENT_PR=<父PR链接>`。父分支须包含本批主干 R0;
堆叠(standalone)任务使用 `PARENT=feature/parent PARENT_PR=<父PR链接>`;子任务改用 `KIND=child`。父分支须包含本批主干 R0;
卡片同时记录父分支、父 SHA 和父 PR。父先合入,子同步最新 main 并改回 main 为 PR base。
当前 `make task-pr-check` 与 CI `PR base gate` 对 standalone PR 只接受 base 为 `main`,因此堆叠 PR 在 base 改回 `main` 前不能通过这两道门禁。
PR 门禁:`make task-pr-check [PR=N] [FINAL=1]` 核验当前分支 PR 的状态、base 与 head(远端为准)。子任务期望 base 为卡片 `immediate_target`(所属大任务分支,且其父仍有开放 PR 指向 `main`);普通任务与大任务为 `main`。`FINAL=1` 仅用于大任务收口,要求 `accepting_requirements=false` 且必需子任务已合入或已延期。
## 被测对象
`make task-sync` 在父仓合并后按固定 gitlink 物化未修改组件,并核对实际组件工作树。
同步目标取卡片 `immediate_target`:子任务为远端所属大任务分支头,其余为 `origin/main`。
组件 feature 分支若与新指针不同,先保留其分支,交给 owner 显式同步并更新组合;
不能用自动 checkout 丢弃组件分支现场。任何失败都保留现场并阻止同步成功声明。
@@ -121,14 +131,14 @@ RECEIPT=/tmp/my-attempt-worktree-check.json CMD="make test-worktree" make eviden
子模块另有登记 worktree 时拒绝回收,避免删除其他检出依赖的 Git 管理目录。
可再生缓存由 owner 按已识别路径单独处置;无法确认用途的文件保留。
任务合入需以本次成功获取的权威目标验证。合入提交不能证明未提交文件已经落盘。
任务合入需以本次成功获取的权威目标验证:子任务为其所属大任务分支的远端头,其余为 `origin/main`(即 `immediate_target`)。合入提交不能证明未提交文件已经落盘。
Git 对含子模块的工作区需要显式删除选项时,只有全部现场检查通过才允许该路径;
它不豁免任何脏状态、忽略文件、独有提交/引用或锁检查。普通删除失败不转入强删兜底。
保留无法确认安全的目录和分支,输出下一步处理依据。
子仓 pin 之外的 refs/stash/reflog 历史采取保守拒绝,即使其中部分可能已在远端:
owner 须先独立确认其保存与归属,工具不把缓存的远端引用当作唯一数据已经备份的证据。
远端分支删除与本地回收分别报告,失败不能声明两者全部完成。
回收后由所有者执行 `make task-close` 闭合卡片;回收命令不顺带改写共享主检出。
回收后由所有者执行 `make task-close` 闭合卡片;回收命令不顺带改写共享主检出。闭合 PR 默认 base 为 `main`;仅 `FOLD=--fold`(随任务分支合入)时 base 为卡片 `immediate_target`。
post-merge 仅提示,不自动调用闭合。`make task-close` 会提交、推送并可能创建 PR,
执行者先按迭代交付规则核对该动作的授权及所选任务,保留未授权的其他任务现场。
回收命令使用待删目录之外的 `RECEIPT` 保存输出,清理结果由后续闭合记录引用。
+82 -2
View File
@@ -5,7 +5,7 @@ import ast
import re
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from pathlib import Path, PurePosixPath
from typing import Any
SCHEMA_V1 = "agent-execution/v1"
@@ -47,7 +47,17 @@ SECRET_VALUE_RE = re.compile(
r"(?i)^(bearer\s+\S+|sk-[A-Za-z0-9_\-]{8,}|[A-Fa-f0-9]{32,})$"
)
ROOT_KEYS = frozenset({"schema", "execution", "roles", "profiles", "custom_adapters"})
ROOT_KEYS = frozenset(
{"schema", "execution", "roles", "profiles", "custom_adapters", "integration"}
)
INTEGRATION_KEYS = frozenset({"exempt_paths", "transport", "admission", "cleanup"})
ENTRYPOINT_KEYS = frozenset({"command"})
# Mirrors the host-managed prefixes enforced by scripts/agent_exec.py
# (_HOST_MANAGED_PREFIXES, the enforcement source). A declared
# integration.exempt_paths must equal this set so it cannot widen host authority.
HOST_EXEMPT_PATHS = frozenset(
{".task/**", "docs/superpowers/specs/**", "docs/superpowers/plans/**"}
)
EXECUTION_KEYS = frozenset(
{
"fresh_process_per_unit",
@@ -116,6 +126,14 @@ class CustomAdapter:
output_format: str
@dataclass(frozen=True)
class IntegrationConfig:
exempt_paths: tuple[str, ...]
transport_command: str
admission_command: str
cleanup_command: str
@dataclass(frozen=True)
class ExecutionConfig:
schema: str
@@ -123,6 +141,7 @@ class ExecutionConfig:
roles: Mapping[str, tuple[str, ...]]
profiles: Mapping[str, Profile]
custom_adapters: Mapping[str, CustomAdapter]
integration: IntegrationConfig | None = None
def load_config(repo: Path) -> ExecutionConfig:
@@ -351,12 +370,17 @@ def _build_config(raw: dict[str, Any]) -> ExecutionConfig:
_validate_normal_role_adapters(profiles, roles)
_validate_normal_role_exactness(profiles, roles)
integration = None
if "integration" in raw:
integration = _build_integration(raw["integration"])
return ExecutionConfig(
schema=schema,
execution=execution,
roles=roles,
profiles=profiles,
custom_adapters=custom_adapters,
integration=integration,
)
@@ -634,6 +658,62 @@ def _build_custom_adapters(raw: Any) -> dict[str, CustomAdapter]:
return out
def _build_integration(raw: Any) -> IntegrationConfig:
if not isinstance(raw, dict):
raise ConfigError("integration must be a mapping")
unknown = set(raw) - INTEGRATION_KEYS
if unknown:
raise ConfigError(f"unknown keys in integration: {sorted(unknown)}")
missing = INTEGRATION_KEYS - set(raw)
if missing:
raise ConfigError(f"missing keys in integration: {sorted(missing)}")
exempt_paths = _exempt_paths_tuple(raw["exempt_paths"])
if set(exempt_paths) != HOST_EXEMPT_PATHS:
raise ConfigError(
"integration.exempt_paths must exactly match the host-managed set "
f"{sorted(HOST_EXEMPT_PATHS)}; got {list(exempt_paths)}"
)
return IntegrationConfig(
exempt_paths=exempt_paths,
transport_command=_entrypoint_command(raw["transport"], "integration.transport"),
admission_command=_entrypoint_command(raw["admission"], "integration.admission"),
cleanup_command=_entrypoint_command(raw["cleanup"], "integration.cleanup"),
)
def _exempt_paths_tuple(value: Any) -> tuple[str, ...]:
if not isinstance(value, list) or not value:
raise ConfigError("integration.exempt_paths must be a non-empty list")
items: list[str] = []
for item in value:
if not isinstance(item, str) or not item:
raise ConfigError("integration.exempt_paths entries must be non-empty strings")
if item.startswith("/") or ".." in PurePosixPath(item).parts:
raise ConfigError(
f"integration.exempt_paths must be relative without '..': {item!r}"
)
if item in items:
raise ConfigError(f"duplicate integration.exempt_paths entry: {item!r}")
items.append(item)
return tuple(items)
def _entrypoint_command(raw: Any, label: str) -> str:
if not isinstance(raw, dict):
raise ConfigError(f"{label} must be a mapping")
unknown = set(raw) - ENTRYPOINT_KEYS
if unknown:
raise ConfigError(f"unknown keys in {label}: {sorted(unknown)}")
missing = ENTRYPOINT_KEYS - set(raw)
if missing:
raise ConfigError(f"missing keys in {label}: {sorted(missing)}")
command = raw["command"]
if not isinstance(command, str) or not command.strip():
raise ConfigError(f"{label}.command must be a non-empty string")
_reject_shell_or_secret_value(f"{label}.command", command)
return command
def _argv_tuple(value: Any, label: str) -> tuple[str, ...]:
if not isinstance(value, list):
raise ConfigError(f"{label} must be a list")
+4 -3
View File
@@ -64,10 +64,11 @@ def check_snapshot(snapshot):
def check_receipt(value):
if not isinstance(value, dict) or type(value.get("schema")) is not int or value["schema"] != evidence.SCHEMA:
raise ValueError("unsupported or malformed receipt")
expected = {"schema", "source", "timestamp", "command", "command_sha256", "cwd", "common_dir",
"before", "after", "after_error", "exit_code", "output", "output_sha256", "receipt_sha256"}
if set(value) not in (expected, expected | {"capture_note"}):
if not evidence.receipt_keys_ok(value):
raise ValueError("invalid receipt fields")
for key in evidence.TARGET_FIELDS:
if key in value and not evidence.valid_branch(value[key]):
raise ValueError("invalid receipt target: " + key)
for key in ("source", "timestamp", "command", "cwd", "common_dir", "output"):
if not isinstance(value[key], str):
raise ValueError("invalid receipt field: " + key)
+46 -5
View File
@@ -20,6 +20,11 @@ SCHEMA = 1
SECRET_NAME = re.compile(r"token|password|passwd|secret|api[_-]?key|credential|private[_-]?key", re.I)
SHA256 = re.compile(r"[0-9a-f]{64}\Z")
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")
# 可选的分支目标字段:两者须同时出现或同时缺省(旧收据没有)。
TARGET_FIELDS = ("root_target", "immediate_target")
BASE_FIELDS = {"schema", "source", "timestamp", "command", "command_sha256", "cwd", "common_dir",
"before", "after", "after_error", "exit_code", "output", "output_sha256", "receipt_sha256"}
class EvidenceError(Exception):
@@ -111,9 +116,41 @@ def snapshot():
}
def valid_branch(name):
return (isinstance(name, str) and BRANCH.match(name) is not None and ".." not in name
and "//" not in name and not name.endswith(("/", ".", ".lock")))
def receipt_keys_ok(keys):
keys = set(keys)
targets = keys & set(TARGET_FIELDS)
if targets not in (set(), set(TARGET_FIELDS)):
return False
rest = keys - targets
return rest in (BASE_FIELDS, BASE_FIELDS | {"capture_note"})
def task_targets():
"""Root and immediate targets named by the current branch card; no metadata means main/main."""
default = os.environ.get("TASK_TARGET_BRANCH", "main")
try:
card = find_card()
except EvidenceError:
return default, default
match = re.search(r"^- 分支类型:[^\n]*?;root_target:([^;\n]+);immediate_target:([^;\n]+)",
card.read_text(encoding="utf-8"), re.M)
if not match:
return default, default
root, immediate = match.group(1).strip(), match.group(2).strip()
if not (valid_branch(root) and valid_branch(immediate)):
raise EvidenceError("Task card target metadata is invalid")
return root, immediate
def create_receipt(command, output, exit_code, before, after, *, timestamp=None,
cwd=None, git_common_dir=None, after_error=None, capture_note=None):
cwd=None, git_common_dir=None, after_error=None, capture_note=None, targets=None):
"""Package an actual captured run; callers must not reconstruct missing output."""
root_target, immediate_target = targets or task_targets()
receipt = sanitize_tree({
"schema": SCHEMA, "source": "SELF",
"timestamp": timestamp or datetime.datetime.now(datetime.timezone.utc).isoformat(),
@@ -122,6 +159,7 @@ def create_receipt(command, output, exit_code, before, after, *, timestamp=None,
"common_dir": git_common_dir or common_dir(),
"before": before, "after": after, "after_error": after_error,
"exit_code": exit_code, "output": output,
"root_target": root_target, "immediate_target": immediate_target,
})
if capture_note is not None:
receipt["capture_note"] = sanitize(capture_note)
@@ -159,10 +197,11 @@ def read_receipt(filename):
value = json.loads(Path(filename).read_text())
if not isinstance(value, dict) or type(value.get("schema")) is not int or value["schema"] != SCHEMA:
raise EvidenceError("Unsupported receipt schema")
expected = {"schema", "source", "timestamp", "command", "command_sha256", "cwd", "common_dir",
"before", "after", "after_error", "exit_code", "output", "output_sha256", "receipt_sha256"}
if set(value) not in (expected, expected | {"capture_note"}):
if not receipt_keys_ok(value):
raise EvidenceError("Invalid receipt fields")
for key in TARGET_FIELDS:
if key in value and not valid_branch(value[key]):
raise EvidenceError("Invalid receipt target: " + key)
for key in ("source", "timestamp", "command", "command_sha256", "cwd", "common_dir",
"output", "output_sha256", "receipt_sha256"):
if not isinstance(value.get(key), str):
@@ -350,6 +389,7 @@ def main():
raise EvidenceError("Capture requires an absolute, unused receipt path")
card = find_card() if path is None else None
before = snapshot()
targets = task_targets()
source_cwd, source_common = str(Path.cwd().resolve()), common_dir()
timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat()
child_env = os.environ.copy()
@@ -366,7 +406,8 @@ def main():
after_error = str(error)
receipt = create_receipt(command, result.stdout.decode("utf-8", "replace"), child_exit,
before, after, timestamp=timestamp, cwd=source_cwd,
git_common_dir=source_common, after_error=after_error)
git_common_dir=source_common, after_error=after_error,
targets=targets)
if path is not None:
write_receipt(path, receipt)
report(receipt, "receipt=" + str(path))
+267 -50
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# 任务启动门禁:precheck / start / resume / sync / close / finish(配套 Makefile 同名目标)。
# 任务启动门禁:precheck / triage / start / resume / sync / close / finish(配套 Makefile 同名目标)。
# 规范依据:collaboration.md 共同基线与同步节点、治理清理;delivery.md §1/§5。
set -euo pipefail
@@ -59,7 +59,7 @@ cmd_precheck() {
card_pending "${pc##*/}" && continue
# card_merge 兼容 merge 提交与 squash 落地(squash 走 Gitea API 兜底,无 token 时只认 merge 提交);
# 其内部以完整消费输出取首行,避免 grep -q 早退令 git log 吃 SIGPIPE(141 under pipefail)。
if card_merge "$pb" >/dev/null; then
if card_merged_into "$pc" "$pb" >/dev/null; then
open_cards="${open_cards} ${pc##*/}"
fi
done
@@ -72,6 +72,9 @@ cmd_precheck() {
esac
done
[ -n "$done_open" ] && echo "[cards] 已完成但未归档:$(echo $done_open)"
local initiatives
initiatives=$(active_initiatives | cut -d'|' -f1)
echo "[initiatives] local cards (not authoritative): $([ -n "$initiatives" ] && echo $initiatives || echo none)"
now=$(date +%s)
while read -r ref ts; do
[ -n "$ref" ] || continue
@@ -142,10 +145,36 @@ cmd_start() {
need_repo
local name="${1:-}"; [ -n "$name" ] || die "usage: task.sh start <feature|fix|hotfix|release>/<slug>"
echo "$name" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || die "branch name must match (feature|fix|hotfix|release)/<slug>"
local kind="${TASK_BRANCH_KIND:-standalone}" initiative="${TASK_INITIATIVE:-}"
case "$kind" in standalone|initiative|child) ;; *) die "TASK_BRANCH_KIND must be standalone, initiative or child" ;; esac
if [ "$kind" = child ]; then
[ -n "$initiative" ] || die "child task requires INITIATIVE=<parent initiative branch>"
echo "$initiative" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || die "invalid parent initiative branch"
case "${name#*/}" in "${initiative#*/}"-?*) ;; *) die "child slug must start with parent initiative slug plus '-'" ;; esac
fi
[ -n "${TRIAGE_RECEIPT:-}" ] || die "TRIAGE_RECEIPT required: make task-triage REQ='<需求>' TRIAGE_RECEIPT=<path> and decide first (no branch created)"
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"
# 分流核验必须先于任何 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
local triage_out triage_line triage_head triage_verdict triage_init triage_rsha triage_digest triage_note
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}"
check_worktree before-sync
# 已合入的同名分支不得重建(2026-10-05 实测:合入后再 task-start 同名会从新 main 长出
# 第二段历史,空卡覆盖已合入任务卡,两轮证据差点丢失)。复核/追加沿用原分支或换新 slug。
@@ -159,10 +188,31 @@ cmd_start() {
[ "$(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=""
local rmain slug wt mainroot start_ref parent_head="" parent_source="" parent_note=""
rmain=$(git rev-parse "origin/${TARGET_BRANCH}")
start_ref=$rmain
if [ -n "${TASK_PARENT_BRANCH:-}${TASK_PARENT_PR:-}" ]; then
slug=$(echo "$name" | tr '/' '-')
local branch_meta
case "$kind" in
initiative)
branch_meta="- 分支类型:initiative;initiative_id:${slug};root_target:${TARGET_BRANCH};immediate_target:${TARGET_BRANCH};accepting_requirements:true" ;;
child)
if [ "$(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"
echo "$TASK_PARENT_BRANCH" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || die "invalid parent task branch"
echo "$TASK_PARENT_PR" | grep -qE '^https?://[^[:space:]]+/pulls/[0-9]+$' || die "TASK_PARENT_PR must be a parent PR URL"
@@ -172,7 +222,6 @@ cmd_start() {
start_ref=$parent_head
parent_note="- 堆叠父任务:${TASK_PARENT_BRANCH};父 PR:${TASK_PARENT_PR};parent_head_sha=${parent_head};父合入后 task-sync 并将 PR base 改回 ${TARGET_BRANCH}"
fi
slug=$(echo "$name" | tr '/' '-')
# 落点须为主检出的平级目录:$PWD 推算在子目录(或任务 worktree 内)运行时会算错,
# 以 Git 登记表定位主检出,兼容 linked worktree 与独立 git-dir。
mainroot=$(main_worktree_root)
@@ -198,6 +247,8 @@ cmd_start() {
- 权威仓:$(git -C "$wt" remote get-url origin 2>/dev/null || echo '(local)');目标分支:${TARGET_BRANCH};R0(initial_base_sha)= ${rmain}
- 工作区:${wt}(worktree);分支:${name}
${branch_meta}
${triage_note}
${parent_note}
- 授权与交付终点:<本次允许的动作与终点;未授权事项写明>
- 交付模式:goal-first(目标、范围和交付终点一次确认;范围内连续推进)
@@ -322,27 +373,57 @@ cmd_resume() {
local receipt
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 [ "$immediate" != "${TARGET_BRANCH}" ]; then
observed=$(resolve_parent_ref "$immediate") || die "immediate target ${immediate} unavailable; receipt not imported"
recorded=$(card_field "$card" parent_head_sha)
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
else
observed=$(git rev-parse "origin/${TARGET_BRANCH}")
fi
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}")"
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 slug pc changed=0 ancestry_status
base=$(git rev-parse "origin/${TARGET_BRANCH}")
local base head card branch changed=0 ancestry_status immediate="${TARGET_BRANCH}"
branch=$(git rev-parse --abbrev-ref HEAD)
card=$(branch_card "$branch")
[ -n "$card" ] && immediate=$(card_immediate_target "$card")
if [ "$immediate" != "${TARGET_BRANCH}" ]; then
base=$(resolve_parent_ref "$immediate")
else
base=$(git rev-parse "origin/${TARGET_BRANCH}")
fi
if git merge-base --is-ancestor "$base" HEAD; then
:
else
ancestry_status=$?
[ "$ancestry_status" -eq 1 ] || die "cannot verify target ancestry"
git merge --no-edit "$base" \
-m "Merge authoritative ${TARGET_BRANCH} into the task branch" \
-m "Why: include the current integration baseline before verification." \
-m "Evidence: fetched origin/${TARGET_BRANCH}=${base}." \
-m "Impact: integrate upstream changes without rewriting task history." \
-m "Refs: task-sync"
if [ "$immediate" != "${TARGET_BRANCH}" ]; then
git merge --no-edit "$base" \
-m "Merge parent initiative ${immediate} into the task branch" \
-m "Why: include the current parent initiative baseline before verification." \
-m "Evidence: resolved ${immediate}=${base}." \
-m "Impact: integrate parent initiative changes without rewriting task history." \
-m "Refs: task-sync"
else
git merge --no-edit "$base" \
-m "Merge authoritative ${TARGET_BRANCH} into the task branch" \
-m "Why: include the current integration baseline before verification." \
-m "Evidence: fetched origin/${TARGET_BRANCH}=${base}." \
-m "Impact: integrate upstream changes without rewriting task history." \
-m "Refs: task-sync"
fi
changed=1
fi
git merge-base --is-ancestor "$base" HEAD || die "sync incomplete"
@@ -350,19 +431,6 @@ cmd_sync() {
check_worktree materialize
check_worktree after-sync
head=$(git rev-parse HEAD)
branch=$(git rev-parse --abbrev-ref HEAD)
slug=$(echo "$branch" | tr '/' '-')
card=".task/${slug}.md"
if [ ! -f "$card" ]; then
card=""
for pc in .task/*.md; do
[ -f "$pc" ] || continue
if [ "$(card_of_branch "$pc")" = "$branch" ]; then
card=$pc
break
fi
done
fi
if [ -n "$card" ] && [ -f "$card" ]; then
if [ "$changed" -eq 1 ]; then
printf '| 同步(记录前对象) | %s | %s | %s |\n' "$base" "$head" "$(date -u +%FT%TZ)" >> "$card"
@@ -378,7 +446,7 @@ cmd_sync() {
echo "[warn] 找不到分支 ${branch} 的任务卡,同步记录未写入" >&2
fi
head=$(git rev-parse HEAD)
echo "[synced] sync_base=${base} tested_head=${head} checked_at=$(date -u +%FT%TZ) changed=${changed}"
echo "[synced] sync_base=${base} tested_head=${head} checked_at=$(date -u +%FT%TZ) changed=${changed} sync_target=${immediate}"
echo "[next] 跑正式验收(make test 等),证据写入任务卡"
}
@@ -391,19 +459,140 @@ cmd_sync() {
card_of_branch() { sed -n 's/^- 工作区:.*分支:\([A-Za-z0-9._/-]\+\).*/\1/p' "$1" | head -1; }
card_status() { sed -n 's/^.*;状态:\([^;]*\).*/\1/p' "$1" | head -1; }
# 两级分支元数据行:'- 分支类型:<kind>;initiative_id:…;root_target:…;immediate_target:…;accepting_requirements:…'。
# 旧卡没有该行:按 standalone、root/immediate 均为目标分支、不接收新需求处理。
card_field() { sed -n "s/^- 分支类型:.*;$2:\([^;]*\).*/\1/p" "$1" 2>/dev/null | head -1; }
card_branch_kind() { local v; v=$(sed -n 's/^- 分支类型:\([^;]*\).*/\1/p' "$1" 2>/dev/null | head -1); printf '%s\n' "${v:-standalone}"; }
card_initiative_id() { card_field "$1" initiative_id; }
card_root_target() { local v; v=$(card_field "$1" root_target); printf '%s\n' "${v:-${TARGET_BRANCH}}"; }
card_immediate_target() { local v; v=$(card_field "$1" immediate_target); printf '%s\n' "${v:-${TARGET_BRANCH}}"; }
card_accepting_requirements() { local v; v=$(card_field "$1" accepting_requirements); printf '%s\n' "${v:-false}"; }
branch_card() { # $1=branch → 当前检出中该分支的任务卡路径(无则空)
local pc=".task/$(echo "$1" | tr '/' '-').md"
[ -f "$pc" ] && { printf '%s\n' "$pc"; return 0; }
for pc in .task/*.md; do
[ -f "$pc" ] || continue
[ "$(card_of_branch "$pc")" = "$1" ] && { printf '%s\n' "$pc"; return 0; }
done
return 0
}
active_initiatives() { # → 每行 "branch|initiative_id|card":根目标为集成分支、未完成、仍接收需求且未合入/待闭合
local pc pb
for pc in .task/*.md; do
[ -f "$pc" ] || continue
[ "$(card_branch_kind "$pc")" = initiative ] || continue
[ "$(card_root_target "$pc")" = "${TARGET_BRANCH}" ] || continue
[ "$(card_accepting_requirements "$pc")" = true ] || continue
case "$(card_status "$pc")" in 已完成*) continue ;; esac
pb=$(card_of_branch "$pc"); [ -n "$pb" ] || continue
card_pending "${pc##*/}" && continue
card_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}"
}
resolve_parent_ref() { # $1=父 initiative 分支 → 打印用于同步/核验的提交(delivery 仅远端;local 取远端与本地包含对方的一侧)
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
git ls-remote --exit-code --heads origin "refs/heads/$1" >/dev/null 2>&1 || rc=$?
if [ "$rc" -eq 0 ]; then
if ! git fetch -q origin "refs/heads/$1:refs/remotes/origin/$1"; then
echo "[verdict] REMOTE_UNVERIFIED — cannot refresh origin/$1; preserve local work and retry" >&2
return 6
fi
remote=$(git rev-parse --verify "refs/remotes/origin/$1^{commit}")
elif [ "$rc" -ne 2 ]; then
echo "[verdict] REMOTE_UNVERIFIED — cannot query origin/$1; preserve local work and retry" >&2
return 6
fi
local_ref=$(git rev-parse -q --verify "refs/heads/$1^{commit}" 2>/dev/null || true)
if [ -n "$remote" ] && [ -n "$local_ref" ]; then
if git merge-base --is-ancestor "$remote" "$local_ref"; then printf '%s\n' "$local_ref"
elif git merge-base --is-ancestor "$local_ref" "$remote"; then printf '%s\n' "$remote"
else die "parent branch $1 diverged between local and origin; reconcile it first"
fi
elif [ -n "$remote" ]; then printf '%s\n' "$remote"
elif [ -n "$local_ref" ]; then printf '%s\n' "$local_ref"
else die "parent branch $1 not found locally or on origin"
fi
}
# 需求分流以 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/${TARGET_BRANCH}" 2>/dev/null \
| grep -F " from $1 into ${TARGET_BRANCH}" | sed -n '1p') || hit=""
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"
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 → squash/rebase 落地没有 merge 提交,经 Gitea API 按 head 分支找 merged PR。
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
@@ -444,7 +633,7 @@ for p in entries if isinstance(entries, list) else []:
done
[ -n "$found" ] || return 1
sha="${found%%|*}"
git merge-base --is-ancestor "$sha" "origin/${TARGET_BRANCH}" 2>/dev/null || return 1
git merge-base --is-ancestor "$sha" "origin/$2" 2>/dev/null || return 1
printf '%s\n' "$found"
}
card_pending() { # $1=卡片文件名——闭合是否已在某 origin/feature/card-close-* 分支待合入
@@ -454,10 +643,10 @@ card_pending() { # $1=卡片文件名——闭合是否已在某 origin/feature/
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'
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 = sys.argv[1:6]
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)
@@ -468,7 +657,7 @@ s = s.rstrip('\n') + f'''
## 闭合记录(card-autoclose 回填)
- 依据:merge {sha}(PR #{pr})已包含于 origin/main,分支成果已落盘;本段为合入后回填。
- 依据:merge {sha}(PR #{pr})已包含于 origin/{target},分支成果已落盘;本段为合入后回填。
- 合并主题:{subject}
- 归档:随闭合移入 .task/archive/;任务执行期的过程证据见原任务记录与 PR。
'''
@@ -487,8 +676,13 @@ cmd_close() {
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=""
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
@@ -499,7 +693,7 @@ cmd_close() {
echo "[close] ${c##*/} 已有待合入的闭合分支,跳过(等其 PR 合入)"
continue
fi
m=$(card_merge "$b") || m=""
m=$(card_merged_into "$c" "$b") || m=""
if [ -z "$m" ]; then
[ "$done_unarchived" = 1 ] || continue
m="ARCHIVE"
@@ -518,9 +712,10 @@ $c|$b|$m"
elif [ "$fold" != "1" ]; then
die "close 在任务分支内须加 --fold(并入当前任务 PR);或到主检出运行(将建 feature/card-close-* 分支+PR)"
fi
local n=0 sha subject pr closed_list=""
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##*/}"
@@ -530,10 +725,10 @@ $c|$b|$m"
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}"
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})"
closed_list="$closed_list ${c##*/}(PR#${pr} into ${tgt})"
n=$((n+1))
done <<EOF
$cards
@@ -541,8 +736,8 @@ 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 plus Gitea API squash lookup against origin/${TARGET_BRANCH}."
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"
@@ -560,7 +755,7 @@ EOF
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=""
-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:+($(printf '%s' "$resp" | head -c 120))},退避重试" >&2
@@ -594,6 +789,13 @@ cmd_finish() {
head=$(git rev-parse HEAD)
mainrepo=$(main_worktree_root)
fetch_target
local card immediate="${TARGET_BRANCH}" merged_into
card=$(branch_card "$branch")
[ -n "$card" ] && immediate=$(card_immediate_target "$card")
merged_into="origin/${TARGET_BRANCH}"
if [ "$immediate" != "${TARGET_BRANCH}" ] && [ "$abandon" != "--abandon" ]; then
merged_into=$(resolve_parent_ref "$immediate")
fi
if [ "$abandon" = "--abandon" ]; then
local reason="${2:-}"; [ -n "$reason" ] || die "--abandon requires a reason"
# An explicit abandon removes only the clean checkout; commits stay reachable.
@@ -602,15 +804,19 @@ cmd_finish() {
elif [ -n "$abandon" ]; then
die "usage: task.sh finish [--abandon <reason>]"
else
git merge-base --is-ancestor "$head" "origin/${TARGET_BRANCH}" || die "branch has not merged into origin/${TARGET_BRANCH}; preserve it or explicitly abandon the checkout"
git merge-base --is-ancestor "$head" "$merged_into" || die "branch has not merged into ${immediate}; preserve it or explicitly abandon the checkout"
remote_status=$(git ls-remote --heads origin "refs/heads/${branch}") || die "cannot verify remote task branch; preserve workspace"
if [ -n "$remote_status" ]; then
# Refresh its object, then use that exact observation as a deletion lease.
git fetch -q origin "refs/heads/${branch}" || die "cannot fetch remote task branch; preserve workspace"
remote_head=$(git rev-parse --verify FETCH_HEAD)
git merge-base --is-ancestor "$remote_head" "origin/${TARGET_BRANCH}" || die "remote task branch has unmerged work; preserve workspace and remote branch"
git merge-base --is-ancestor "$remote_head" "$merged_into" || die "remote task branch has unmerged work; preserve workspace and remote branch"
fi
if [ "$immediate" != "${TARGET_BRANCH}" ]; then
echo "[verified] ${branch} is included in parent initiative ${immediate}"
else
echo "[verified] ${branch} is included in origin/${TARGET_BRANCH}"
fi
echo "[verified] ${branch} is included in origin/${TARGET_BRANCH}"
fi
# Re-check after fetch. --force only compensates Git's gitlink limitation after all
# registered-root, owner, lock, recursive dirt and ignored-file checks have passed.
@@ -623,9 +829,18 @@ cmd_finish() {
die "Git refused worktree removal; workspace and branch retained. Owner must inspect the refusal; no rm fallback or additional force level is permitted."
fi
cd "$mainrepo"
local deleted=1
if [ "$abandon" = "--abandon" ]; then
:
elif [ "$immediate" != "${TARGET_BRANCH}" ]; then
# git branch -d compares with the main checkout HEAD; the child is merged into its parent instead.
git update-ref -d "refs/heads/${branch}" "$head" && deleted=0
else
git branch -d "$branch" && deleted=0
fi
if [ "$abandon" = "--abandon" ]; then
echo "[recycled] worktree removed; local branch=${branch} retained; remote=NOT_RUN"
elif git branch -d "$branch"; then
elif [ "$deleted" -eq 0 ]; then
if [ -z "$remote_head" ]; then
echo "[recycled] worktree and local branch=${branch} removed; remote=ALREADY_ABSENT"
elif git push -q "--force-with-lease=refs/heads/${branch}:${remote_head}" origin ":refs/heads/${branch}"; then
@@ -644,10 +859,12 @@ cmd_finish() {
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|start|resume|sync|close|finish> [args]"; exit 2 ;;
*) echo "usage: task.sh <precheck|triage|triage-decide|start|resume|sync|close|finish> [args]"; exit 2 ;;
esac
+291
View File
@@ -0,0 +1,291 @@
#!/usr/bin/env python3
"""PR base/head 门禁(两级分支模型:child → initiative → main)。
子命令:
local [--pr N] [--final]
核验当前检出的任务分支及其 PR(事实来源是 Gitea 与 origin):PR 处于开放状态、base 为卡片期望目标、
PR head 与本地 HEAD 一致、origin 分支头与本地 HEAD 一致、期望目标分支存在;child 的父 initiative
须仍有开放 PR 指向其 root_target。--final(仅 initiative):accepting_requirements=false,且
`## 子任务登记` 中「必需=是」的子任务为「已合入」(PR 已合并入本 initiative)或「已延期」。
ci
仅 pull_request 事件:按 GITHUB_HEAD_REF 在检出的 .task/ 顶层取卡片,核对 GITHUB_BASE_REF。
不联网、不需要 token;非 pull_request 事件跳过(退出 0)。
期望目标:child → immediate_target;其余(initiative / standalone / 无卡)→ root_target(无卡即 main)。
退出码:0 通过;1 门禁拒绝(PR_CHECK_FAILED);2 用法;6 远端无法核验(REMOTE_UNVERIFIED,fail closed)。
仅使用标准库,复用 task_triage 的 Gitea 客户端与 token 读取。token 不打印、不写入任何产物。
"""
import argparse
import os
import re
import subprocess
import sys
import urllib.parse
from pathlib import Path
from task_triage import (EXIT_REMOTE, Stop, api_base, as_dict, card_branch, http_get_json,
open_pulls, read_token, repo_slug, target_branch)
EXIT_FAILED = 1
EXIT_USAGE = 2
CHILD_COLUMNS = ("子任务分支", "必需", "状态", "PR")
DONE_STATES = ("已合入", "已延期")
def sh(*args):
return subprocess.run(["git", *args], capture_output=True, text=True,
encoding="utf-8", errors="replace")
def standalone(target):
return {"kind": "standalone", "initiative_id": "", "root_target": target,
"immediate_target": target, "accepting": "false"}
def card_meta(text, target):
"""两级分支元数据行;无该行即旧卡,按 standalone 处理(与 task.sh 的 card_branch_kind 一致)。"""
match = re.search(r"^- 分支类型:([^\n]*)$", text, re.M)
if not match:
return standalone(target)
line = match.group(1)
def field(name, default):
found = re.search(";" + name + r":([^;]*)", line)
return found.group(1).strip() if found else default
return {"kind": line.split(";", 1)[0].strip(), "initiative_id": field("initiative_id", ""),
"root_target": field("root_target", target),
"immediate_target": field("immediate_target", target),
"accepting": field("accepting_requirements", "false")}
def expected_base(meta):
return meta["immediate_target"] if meta["kind"] == "child" else meta["root_target"]
def find_card(branch):
"""只看检出树的 .task/ 顶层(archive 不参与);返回 (路径, 文本),无卡返回 (None, None)。"""
found = []
for path in sorted(Path(".task").glob("*.md")):
text = path.read_text(encoding="utf-8", errors="replace")
if card_branch(text) == branch:
found.append((path, text))
if len(found) > 1:
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)
def branch_meta(branch, target):
_, text = find_card(branch)
return (standalone(target) if text is None else card_meta(text, target)), text
def current_branch():
proc = sh("symbolic-ref", "--quiet", "--short", "HEAD")
if proc.returncode != 0 or not proc.stdout.strip():
raise Stop(EXIT_USAGE, "detached HEAD: check out the task branch first")
return proc.stdout.strip()
def local_head():
proc = sh("rev-parse", "--verify", "-q", "HEAD^{commit}")
if proc.returncode != 0:
raise Stop(EXIT_USAGE, "cannot resolve local HEAD")
return proc.stdout.strip()
def remote_head(branch):
proc = sh("ls-remote", "origin", f"refs/heads/{branch}")
if proc.returncode != 0:
raise Stop(EXIT_REMOTE, "git ls-remote origin failed")
for line in proc.stdout.splitlines():
sha, _, ref = line.partition("\t")
if ref == f"refs/heads/{branch}":
return sha
return None
def remote_context():
origin = sh("remote", "get-url", "origin")
if origin.returncode != 0:
raise Stop(EXIT_REMOTE, "origin remote is not configured")
slug = repo_slug(origin.stdout.strip())
token = read_token()
if not token:
raise Stop(EXIT_REMOTE, "no Gitea token: set GITEA_TOKEN or ~/.secrets/gitea ACCESS_TOKEN")
return api_base(), slug, token
def pull_request(api, slug, token, number):
owner, name = (urllib.parse.quote(part, safe="") for part in slug.split("/"))
pr = http_get_json(f"{api}/repos/{owner}/{name}/pulls/{number}", token)
if not isinstance(pr, dict):
raise Stop(EXIT_REMOTE, f"Gitea PR #{number} response is malformed")
return pr
def base_ref(pr):
return as_dict(pr.get("base")).get("ref")
def head_ref(pr):
return as_dict(pr.get("head")).get("ref")
def head_sha(pr):
return as_dict(pr.get("head")).get("sha")
def parent_pr_open(api, slug, token, parent, root):
return any(isinstance(pr, dict) and pr.get("state") == "open"
and head_ref(pr) == parent and base_ref(pr) == root
for pr in open_pulls(api, slug, token))
def pr_number(cell):
for pattern in (r"pulls/(\d+)", r"#(\d+)", r"^(\d+)$"):
found = re.search(pattern, cell.strip())
if found:
return int(found.group(1))
return None
def child_registry(text):
"""解析 `## 子任务登记` 表(列:子任务分支 | 必需 | 状态 | PR);缺失即拒绝(fail closed)。"""
lines = text.splitlines()
starts = [i for i, line in enumerate(lines) if line.strip() == "## 子任务登记"]
if not starts:
raise Stop(EXIT_FAILED, "initiative card has no ## 子任务登记 section")
rows, columns = [], None
for line in lines[starts[0] + 1:]:
if line.startswith("## "):
break
if not line.strip().startswith("|"):
continue
cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
if columns is None:
if not all(name in cells for name in CHILD_COLUMNS):
raise Stop(EXIT_FAILED, "子任务登记 table must have columns 子任务分支 | 必需 | 状态 | PR")
columns = {name: cells.index(name) for name in CHILD_COLUMNS}
continue
if all(re.fullmatch(r":?-{3,}:?", cell) for cell in cells):
continue
if len(cells) <= max(columns.values()):
raise Stop(EXIT_FAILED, f"malformed 子任务登记 row: {line.strip()}")
rows.append({name: cells[index] for name, index in columns.items()})
if columns is None:
raise Stop(EXIT_FAILED, "子任务登记 section has no table")
return rows
def final_problems(api, slug, token, branch, meta, text):
problems = []
if meta["accepting"] != "false":
problems.append(f"initiative {branch} still accepts requirements "
f"(accepting_requirements={meta['accepting']}); set false before --final")
for row in child_registry(text):
name, required, status, pr_cell = row["子任务分支"], row["必需"], row["状态"], row["PR"]
if required == "否":
continue
if required != "是":
problems.append(f"child {name} has invalid 必需 value {required!r}")
elif status == "已合入":
number = pr_number(pr_cell)
if number is None:
problems.append(f"child {name} is 已合入 but PR cell {pr_cell!r} names no PR")
continue
pr = pull_request(api, slug, token, number)
if pr.get("merged") is not True or base_ref(pr) != branch:
problems.append(f"child {name} PR #{number} is not merged into {branch}")
elif status not in DONE_STATES:
problems.append(f"required child {name} is {status!r}; must be 已合入 or 已延期")
return problems
def verdict(problems, kind, base, head):
if problems:
for problem in problems:
print(f"[pr-check] FAIL {problem}")
print("[verdict] PR_CHECK_FAILED")
return EXIT_FAILED
print(f"[verdict] PR_CHECK_OK base={base} head={head} kind={kind}")
return 0
def cmd_local(args):
if args.pr is not None and args.pr < 1:
raise Stop(EXIT_USAGE, "--pr must be a positive PR number")
branch = current_branch()
head = local_head()
meta, text = branch_meta(branch, target_branch())
kind, expected = meta["kind"], expected_base(meta)
api, slug, token = remote_context()
if args.pr is not None:
pr = pull_request(api, slug, token, args.pr)
else:
pulls = [pr for pr in open_pulls(api, slug, token) if isinstance(pr, dict) and head_ref(pr) == branch]
if not pulls:
raise Stop(EXIT_FAILED, f"no open PR has head {branch}")
if len(pulls) > 1:
raise Stop(EXIT_FAILED, f"{len(pulls)} open PRs have head {branch}; pass --pr N")
pr = pulls[0]
number = pr.get("number")
problems = []
if pr.get("merged") is True:
problems.append(f"PR #{number} is already merged")
elif pr.get("state") != "open":
problems.append(f"PR #{number} is not open (state={pr.get('state')})")
if base_ref(pr) != expected:
problems.append(f"PR #{number} base is {base_ref(pr)!r}; expected {expected!r} for {kind} branch {branch}")
if head_sha(pr) != head:
problems.append(f"PR #{number} head {head_sha(pr) or '(none)'} != local HEAD {head}")
remote = remote_head(branch)
if remote is None:
problems.append(f"origin has no branch {branch}")
elif remote != head:
problems.append(f"origin/{branch} is {remote}, local HEAD is {head}")
if remote_head(expected) is None:
problems.append(f"base branch {expected} is absent on origin")
if kind == "child" and not parent_pr_open(api, slug, token, expected, meta["root_target"]):
problems.append(f"parent initiative {expected} has no open PR into {meta['root_target']}")
if args.final and kind == "initiative":
problems.extend(final_problems(api, slug, token, branch, meta, text))
return verdict(problems, kind, expected, head)
def cmd_ci(_args):
event = os.environ.get("GITHUB_EVENT_NAME", "")
if event != "pull_request":
print(f"[pr-check] skip: event {event or '(none)'} is not pull_request")
return 0
base = os.environ.get("GITHUB_BASE_REF", "").strip()
head = os.environ.get("GITHUB_HEAD_REF", "").strip()
if not base or not head:
raise Stop(EXIT_FAILED, "pull_request event lacks GITHUB_BASE_REF or GITHUB_HEAD_REF")
meta, _ = branch_meta(head, target_branch())
expected = expected_base(meta)
if base != expected:
raise Stop(EXIT_FAILED, f"PR base {base!r} differs from expected {expected!r} "
f"for {meta['kind']} branch {head}")
return verdict([], meta["kind"], base, head)
def main(argv=None):
parser = argparse.ArgumentParser(prog="task_pr_check.py", description="PR base/head 门禁(两级分支模型)")
sub = parser.add_subparsers(dest="cmd", required=True)
local = sub.add_parser("local")
local.add_argument("--pr", type=int)
local.add_argument("--final", action="store_true")
sub.add_parser("ci")
args = parser.parse_args(argv)
try:
return cmd_local(args) if args.cmd == "local" else cmd_ci(args)
except Stop as stop:
print(f"[pr-check] FAIL {stop.message}")
print("[verdict] REMOTE_UNVERIFIED" if stop.code == EXIT_REMOTE else "[verdict] PR_CHECK_FAILED")
return stop.code
if __name__ == "__main__":
sys.exit(main())
+505
View File
@@ -0,0 +1,505 @@
#!/usr/bin/env python3
"""远端需求分流收据(task.sh triage / triage-decide / start 共用的唯一分流依据)。
事实来源是 origin:`git ls-remote` 取分支头,Gitea open PR 取 base/head 关系,候选分支提交中的
任务卡取 initiative 元数据。本地 `.task/` 不参与发现。
子命令:
discover --receipt PATH --requirement TEXT
查询远端并写收据;无候选 -> NEW;有候选 -> DECISION_REQUIRED(退出 5,不自动选择 JOIN)。
decide --receipt PATH --verdict JOIN|NEW [--initiative BRANCH] --reason TEXT
复查远端;候选集合或任一候选的 HEAD/卡片/PR 字段变化则拒绝(退出 7,收据不改)。
verify --receipt PATH --kind standalone|initiative|child [--initiative BRANCH] [--max-age-seconds N]
复查远端并核对收据;供 task.sh start 在创建任何 worktree/分支之前调用(拒绝退出 8)。
退出码:0 成功;1 本地错误;2 用法;5 DECISION_REQUIRED;6 REMOTE_UNVERIFIED;7 决策被拒;8 核验被拒。
仅使用标准库。token 只读自环境变量 GITEA_TOKEN 或 ~/.secrets/gitea 的 ACCESS_TOKEN=,不打印、不写入收据。
"""
import argparse
import datetime as dt
import hashlib
import http.client
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
SCHEMA = "agent-system/task-triage/v1"
DEFAULT_API = "https://git-home.u1680832.nyat.app:30319/api/v1"
DEFAULT_TARGET = "main"
DEFAULT_MAX_AGE = 14400
PAGE_LIMIT = 50
MAX_PAGES = 100
SECRETS_FILE = "~/.secrets/gitea"
CARD_RE = re.compile(r"^\.task/[^/]+\.md$")
CANDIDATE_KEYS = ("branch", "head_sha", "card_path", "card_sha256", "pr_number", "pr_state", "pr_base", "pr_head_sha")
RECEIPT_KEYS = ("repo", "root_target", "root_target_sha", "requirement", "discovered_at",
"candidates", "discovery_verdict", "final_verdict", "decision")
EXIT_LOCAL = 1
EXIT_USAGE = 2
EXIT_DECISION_REQUIRED = 5
EXIT_REMOTE = 6
EXIT_DECIDE = 7
EXIT_VERIFY = 8
class Stop(Exception):
"""受控失败:退出码 + 不含 token 的说明。"""
def __init__(self, code, message):
super().__init__(message)
self.code = code
self.message = message
def utc_now():
return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def parse_time(text):
value = dt.datetime.fromisoformat(text)
if value.tzinfo is None:
value = value.replace(tzinfo=dt.timezone.utc)
return value
def sha256_bytes(data):
return hashlib.sha256(data).hexdigest()
def as_dict(value):
return value if isinstance(value, dict) else {}
def target_branch():
return os.environ.get("TASK_TARGET_BRANCH", "").strip() or DEFAULT_TARGET
def git(*args):
return subprocess.run(["git", *args], capture_output=True)
def redact_url(url):
"""去掉 URL 中的 userinfo(https://user:pw@host 与 user@host:path 两种形式)。"""
if "://" in url:
parts = urllib.parse.urlsplit(url)
host = parts.netloc.rsplit("@", 1)[-1]
return urllib.parse.urlunsplit((parts.scheme, host, parts.path, parts.query, parts.fragment))
scp = re.match(r"^[^/@]+@([^/]+:.*)$", url)
return scp.group(1) if scp else url
def repo_slug(origin_url):
slug = os.environ.get("GITEA_REPO", "").strip()
if not slug:
if "://" in origin_url:
path = urllib.parse.urlsplit(origin_url).path
else:
path = origin_url.split(":", 1)[1] if re.match(r"^[^/]+:", origin_url) else origin_url
segments = [s for s in path.split("/") if s]
if len(segments) < 2:
raise Stop(EXIT_REMOTE, "cannot derive owner/repo from origin; set GITEA_REPO")
name = segments[-1][:-4] if segments[-1].endswith(".git") else segments[-1]
slug = f"{segments[-2]}/{name}"
parts = slug.split("/")
if len(parts) != 2 or not all(parts) or any(p in (".", "..") for p in parts):
raise Stop(EXIT_REMOTE, "invalid owner/repo slug")
return slug
def api_base():
return (os.environ.get("GITEA_API_URL", "").strip() or DEFAULT_API).rstrip("/")
def read_token():
token = os.environ.get("GITEA_TOKEN", "").strip()
if token:
return token
try:
with open(os.path.expanduser(SECRETS_FILE), encoding="utf-8") as fh:
for raw in fh:
line = raw.strip()
if line.startswith("- "):
line = line[2:].strip()
if line.startswith("ACCESS_TOKEN="):
return line.split("=", 1)[1].strip() or None
except (OSError, UnicodeDecodeError):
return None
return None
def remote_heads():
proc = git("ls-remote", "--heads", "origin")
if proc.returncode != 0:
raise Stop(EXIT_REMOTE, "git ls-remote --heads origin failed")
heads = {}
for line in proc.stdout.decode("utf-8", "replace").splitlines():
sha, _, ref = line.partition("\t")
if ref.startswith("refs/heads/"):
heads[ref[len("refs/heads/"):]] = sha
return heads
def http_get_json(url, token):
request = urllib.request.Request(
url, headers={"Accept": "application/json", "Authorization": f"token {token}"})
try:
with urllib.request.urlopen(request, timeout=20) as response:
body = response.read()
except urllib.error.HTTPError as exc:
raise Stop(EXIT_REMOTE, f"Gitea API returned HTTP {exc.code}")
except (urllib.error.URLError, http.client.HTTPException, OSError) as exc:
raise Stop(EXIT_REMOTE, f"Gitea API unreachable ({type(exc).__name__})")
try:
return json.loads(body.decode("utf-8"))
except ValueError:
raise Stop(EXIT_REMOTE, "Gitea API returned invalid JSON")
def open_pulls(api, slug, token):
owner, name = (urllib.parse.quote(part, safe="") for part in slug.split("/"))
pulls = []
for page in range(1, MAX_PAGES + 1):
url = f"{api}/repos/{owner}/{name}/pulls?state=open&limit={PAGE_LIMIT}&page={page}"
batch = http_get_json(url, token)
if not isinstance(batch, list):
raise Stop(EXIT_REMOTE, "Gitea pulls response is not a list")
if not batch:
return pulls
pulls.extend(batch)
raise Stop(EXIT_REMOTE, f"open pull requests exceed {MAX_PAGES} pages")
def fetch_head(branch):
proc = git("fetch", "-q", "origin", f"refs/heads/{branch}")
if proc.returncode != 0:
raise Stop(EXIT_REMOTE, f"cannot fetch origin/{branch}")
rev = git("rev-parse", "--verify", "-q", "FETCH_HEAD^{commit}")
if rev.returncode != 0:
raise Stop(EXIT_REMOTE, f"fetched origin/{branch} has no commit")
return rev.stdout.decode("utf-8", "replace").strip()
def task_card_paths(commit):
proc = git("ls-tree", "-r", "-z", "--name-only", commit, "--", ".task")
if proc.returncode != 0:
raise Stop(EXIT_REMOTE, "cannot list task cards at candidate head")
names = proc.stdout.decode("utf-8", "replace").split("\0")
return sorted(name for name in names if CARD_RE.match(name))
def blob_bytes(commit, path):
proc = git("cat-file", "blob", f"{commit}:{path}")
if proc.returncode != 0:
raise Stop(EXIT_REMOTE, f"cannot read {path} at candidate head")
return proc.stdout
def card_branch(text):
for line in text.splitlines():
match = re.match(r"^- 工作区:.*分支:([A-Za-z0-9._/-]+)", line)
if match:
return match.group(1)
return None
def card_status(text):
match = re.search(r";状态:([^;\n]*)", text)
return match.group(1).strip() if match else ""
def branch_meta(text, target):
"""分支元数据行 '- 分支类型:…;field:value…';旧卡缺省为 standalone、不接收需求。"""
match = re.search(r"^- 分支类型:([^\n]*)$", text, re.M)
if not match:
return {"kind": "standalone", "initiative_id": "", "root_target": target, "accepting": "false"}
line = match.group(1)
def field(name, default):
found = re.search(";" + name + r":([^;]*)", line)
return found.group(1).strip() if found else default
return {
"kind": line.split(";", 1)[0].strip(),
"initiative_id": field("initiative_id", ""),
"root_target": field("root_target", target),
"accepting": field("accepting_requirements", "false"),
}
def goal_excerpt(text):
lines, inside = [], False
for line in text.splitlines():
if line.startswith("## "):
if inside:
break
inside = line.strip() == "## 目标与非目标"
continue
if inside and line.strip():
lines.append(line.strip())
if len(lines) == 5:
break
return " / ".join(lines)[:300]
def inspect_branch(target, branch, head_sha, pr):
fetched = fetch_head(branch)
if fetched != head_sha:
raise Stop(EXIT_REMOTE, f"{branch} moved during discovery; retry")
matches = []
for path in task_card_paths(head_sha):
raw = blob_bytes(head_sha, path)
text = raw.decode("utf-8", "replace")
if card_branch(text) != branch:
continue
meta = branch_meta(text, target)
if meta["kind"] != "initiative" or meta["accepting"] != "true" or meta["root_target"] != target:
continue
if card_status(text).startswith("已完成"):
continue
matches.append((path, raw, text, meta))
if not matches:
return None
if len(matches) > 1:
raise Stop(EXIT_REMOTE, f"ambiguous initiative cards on {branch}")
path, raw, text, meta = matches[0]
head = as_dict(pr.get("head"))
return {
"branch": branch,
"head_sha": head_sha,
"pr_number": pr["number"],
"pr_state": pr.get("state"),
"pr_base": as_dict(pr.get("base")).get("ref"),
"pr_head_sha": head.get("sha") or "",
"card_path": path,
"card_sha256": sha256_bytes(raw),
"initiative_id": meta["initiative_id"],
"goal_excerpt": goal_excerpt(text),
}
def candidates_from(target, api, slug, token, heads):
by_branch = {}
for pr in open_pulls(api, slug, token):
if not isinstance(pr, dict) or pr.get("state") != "open":
continue
base = as_dict(pr.get("base")).get("ref")
head = as_dict(pr.get("head")).get("ref")
if base != target or not isinstance(head, str) or head == target or head not in heads:
continue
if not isinstance(pr.get("number"), int):
raise Stop(EXIT_REMOTE, "malformed pull request number")
by_branch.setdefault(head, []).append(pr)
found = []
for branch in sorted(by_branch):
if len(by_branch[branch]) != 1:
raise Stop(EXIT_REMOTE, f"ambiguous: several open PRs target {target} from {branch}")
candidate = inspect_branch(target, branch, heads[branch], by_branch[branch][0])
if candidate:
found.append(candidate)
return found
def snapshot(target):
origin = git("remote", "get-url", "origin")
if origin.returncode != 0:
raise Stop(EXIT_REMOTE, "origin remote is not configured")
origin_url = origin.stdout.decode("utf-8", "replace").strip()
heads = remote_heads()
if target not in heads:
raise Stop(EXIT_REMOTE, f"origin has no {target} branch")
slug = repo_slug(origin_url)
api = api_base()
token = read_token()
if not token:
raise Stop(EXIT_REMOTE, "no Gitea token: set GITEA_TOKEN or ~/.secrets/gitea ACCESS_TOKEN")
return {
"repo": {"origin_url": redact_url(origin_url), "api_base": redact_url(api), "repo_slug": slug},
"root_target": target,
"root_target_sha": heads[target],
"candidates": candidates_from(target, api, slug, token, heads),
}
def fingerprint(candidates):
return sorted(tuple(c[key] for key in CANDIDATE_KEYS) for c in candidates)
def check_shape(receipt):
missing = [key for key in RECEIPT_KEYS if key not in receipt]
if missing:
raise ValueError(f"missing {missing[0]}")
if not isinstance(receipt["candidates"], list):
raise ValueError("candidates")
for cand in receipt["candidates"]:
if not isinstance(cand, dict) or any(key not in cand for key in CANDIDATE_KEYS):
raise ValueError("candidate")
if not isinstance(as_dict(receipt["requirement"]).get("sha256"), str):
raise ValueError("requirement")
parse_time(receipt["discovered_at"])
def load_receipt(path, code):
try:
with open(path, "rb") as fh:
raw = fh.read()
except OSError:
raise Stop(code, f"receipt missing or unreadable: {path}")
try:
receipt = json.loads(raw.decode("utf-8"))
except ValueError:
raise Stop(code, "receipt is not valid JSON")
if not isinstance(receipt, dict) or receipt.get("schema") != SCHEMA:
raise Stop(code, f"receipt schema is not {SCHEMA}")
try:
check_shape(receipt)
except (ValueError, TypeError):
raise Stop(code, "receipt is malformed")
return receipt, raw
def write_receipt(path, receipt):
data = json.dumps(receipt, ensure_ascii=False, indent=2) + "\n"
tmp = f"{path}.tmp-{os.getpid()}"
try:
with open(tmp, "w", encoding="utf-8") as fh:
fh.write(data)
os.replace(tmp, path)
except OSError as exc:
raise Stop(EXIT_LOCAL, f"cannot write receipt {path}: {exc.strerror}")
def describe(cand):
return (f"[candidate] branch={cand['branch']} head_sha={cand['head_sha']} pr=#{cand['pr_number']} "
f"base={cand['pr_base']} card={cand['card_path']} card_sha256={cand['card_sha256']} "
f"initiative_id={cand['initiative_id'] or '无'}")
def cmd_discover(args):
text = args.requirement.strip()
if not text:
raise Stop(EXIT_USAGE, "requirement text is required")
snap = snapshot(target_branch())
now = utc_now()
receipt = {
"schema": SCHEMA,
**snap,
"requirement": {"sha256": sha256_bytes(text.encode("utf-8")),
"summary": " ".join(text.split())[:200]},
"discovered_at": now,
}
path = os.path.realpath(args.receipt)
if not snap["candidates"]:
receipt.update(discovery_verdict="NEW", final_verdict="NEW", decision={
"verdict": "NEW", "selected_initiative": None,
"rationale": "no accepting initiative", "decided_at": now})
write_receipt(args.receipt, receipt)
print(f"[verdict] NEW — 远端没有接纳中的 initiative;收据:{path}")
return 0
receipt.update(discovery_verdict="DECISION_REQUIRED", final_verdict="DECISION_REQUIRED", decision=None)
write_receipt(args.receipt, receipt)
print(f"[triage] 远端接纳中的 initiative 候选: {len(snap['candidates'])}(以 origin 为准,本地 .task 不参与)")
for cand in snap["candidates"]:
print(describe(cand))
if cand["goal_excerpt"]:
print(f"[goal] branch={cand['branch']} {cand['goal_excerpt']}")
print(f"[verdict] DECISION_REQUIRED — 须人工决策:JOIN 其一,或 NEW 并说明不适配理由;收据:{path}")
for cand in snap["candidates"]:
print(f"[decide] make task-triage-decide TRIAGE_RECEIPT={path} VERDICT=JOIN "
f"INITIATIVE={cand['branch']} REASON='<加入理由>'")
print(f"[decide] make task-triage-decide TRIAGE_RECEIPT={path} VERDICT=NEW REASON='<不适配理由>'")
return EXIT_DECISION_REQUIRED
def cmd_decide(args):
if args.verdict not in ("JOIN", "NEW"):
raise Stop(EXIT_DECIDE, "verdict must be JOIN or NEW")
receipt, _ = load_receipt(args.receipt, EXIT_DECIDE)
if receipt["discovery_verdict"] != "DECISION_REQUIRED":
raise Stop(EXIT_DECIDE, f"no decision needed: discovery verdict is {receipt['discovery_verdict']}")
reason = args.reason.strip()
if not reason:
raise Stop(EXIT_DECIDE, "--reason is required")
initiative = args.initiative.strip()
if args.verdict == "JOIN" and not initiative:
raise Stop(EXIT_DECIDE, "JOIN requires --initiative")
if args.verdict == "NEW" and initiative:
raise Stop(EXIT_DECIDE, "NEW must not select an initiative")
current = snapshot(receipt["root_target"])
if fingerprint(current["candidates"]) != fingerprint(receipt["candidates"]):
raise Stop(EXIT_DECIDE, "candidate set changed since discovery; re-run make task-triage")
if args.verdict == "JOIN" and initiative not in {c["branch"] for c in receipt["candidates"]}:
raise Stop(EXIT_DECIDE, f"{initiative} is not a discovered candidate")
receipt["decision"] = {"verdict": args.verdict, "selected_initiative": initiative or None,
"rationale": reason, "decided_at": utc_now()}
receipt["final_verdict"] = args.verdict
write_receipt(args.receipt, receipt)
print(f"[decided] verdict={args.verdict} selected_initiative={initiative or '无'} "
f"receipt={os.path.realpath(args.receipt)}")
return 0
def cmd_verify(args):
receipt, raw = load_receipt(args.receipt, EXIT_VERIFY)
final = receipt["final_verdict"]
decision = as_dict(receipt["decision"])
if final not in ("JOIN", "NEW") or decision.get("verdict") != final:
raise Stop(EXIT_VERIFY, "receipt has no final JOIN/NEW decision; run make task-triage-decide")
age = (dt.datetime.now(dt.timezone.utc) - parse_time(receipt["discovered_at"])).total_seconds()
if age > args.max_age_seconds:
raise Stop(EXIT_VERIFY, f"receipt is stale ({int(age)}s old, max {args.max_age_seconds}s); re-run make task-triage")
if age < -300:
raise Stop(EXIT_VERIFY, "receipt discovered_at is in the future")
if receipt["root_target"] != target_branch():
raise Stop(EXIT_VERIFY, "receipt root target does not match TASK_TARGET_BRANCH")
selected = decision.get("selected_initiative")
if args.kind == "child":
if final != "JOIN" or not args.initiative or args.initiative != selected:
raise Stop(EXIT_VERIFY, f"child requires a JOIN receipt selecting {args.initiative or '(none)'}")
elif final != "NEW":
raise Stop(EXIT_VERIFY, f"{args.kind} requires a NEW receipt, got {final}")
current = snapshot(receipt["root_target"])
if fingerprint(current["candidates"]) != fingerprint(receipt["candidates"]):
raise Stop(EXIT_VERIFY, "remote initiative candidates changed since triage; re-run make task-triage")
head = next((c["head_sha"] for c in receipt["candidates"] if c["branch"] == selected), None)
print(f"[triage-verified] verdict={final} selected_initiative={selected or '无'} "
f"requirement_sha256={receipt['requirement']['sha256']} receipt_sha256={sha256_bytes(raw)}")
print(f"[triage-head] selected_head_sha={head or '无'}")
return 0
def main(argv=None):
parser = argparse.ArgumentParser(prog="task_triage.py", description="远端需求分流收据")
sub = parser.add_subparsers(dest="cmd", required=True)
p = sub.add_parser("discover")
p.add_argument("--receipt", required=True)
p.add_argument("--requirement", required=True)
p = sub.add_parser("decide")
p.add_argument("--receipt", required=True)
p.add_argument("--verdict", required=True)
p.add_argument("--initiative", default="")
p.add_argument("--reason", default="")
p = sub.add_parser("verify")
p.add_argument("--receipt", required=True)
p.add_argument("--kind", required=True, choices=("standalone", "initiative", "child"))
p.add_argument("--initiative", default="")
p.add_argument("--max-age-seconds", type=int, default=DEFAULT_MAX_AGE)
args = parser.parse_args(argv)
handlers = {"discover": cmd_discover, "decide": cmd_decide, "verify": cmd_verify}
try:
return handlers[args.cmd](args)
except Stop as stop:
if stop.code == EXIT_REMOTE:
print(f"[verdict] REMOTE_UNVERIFIED — {stop.message}")
else:
print(f"[triage] refused ({stop.code}): {stop.message}", file=sys.stderr)
return stop.code
if __name__ == "__main__":
sys.exit(main())
+111
View File
@@ -792,6 +792,117 @@ class AgentExecConfigTests(unittest.TestCase):
self.assertTrue(cfg.profiles["codex"].enabled)
self.assertEqual(cfg.profiles["codex"].adapter, "codex")
def test_valid_integration_block_parses_and_is_exposed(self) -> None:
write_repo(self.repo, MINIMAL_BLOCK + "\n" + VALID_INTEGRATION)
cfg = load_config(self.repo)
self.assertIsNotNone(cfg.integration)
assert cfg.integration is not None
self.assertEqual(
cfg.integration.exempt_paths,
(".task/**", "docs/superpowers/specs/**", "docs/superpowers/plans/**"),
)
self.assertEqual(
cfg.integration.transport_command, "python3 scripts/agent_exec.py apply"
)
self.assertEqual(
cfg.integration.admission_command, "python3 scripts/agent_exec.py admit"
)
self.assertEqual(cfg.integration.cleanup_command, "make task-finish")
def test_missing_integration_still_parses_as_none(self) -> None:
write_repo(self.repo, MINIMAL_BLOCK)
self.assertIsNone(load_config(self.repo).integration)
def test_integration_rejects_unknown_and_missing_keys(self) -> None:
cases = {
"unknown key in integration": VALID_INTEGRATION.replace(
" cleanup:\n", " extra: x\n cleanup:\n"
),
"unknown key in transport": VALID_INTEGRATION.replace(
" command: python3 scripts/agent_exec.py apply\n",
" command: python3 scripts/agent_exec.py apply\n extra: x\n",
),
"missing cleanup": VALID_INTEGRATION.split(" cleanup:")[0],
}
for label, block in cases.items():
with self.subTest(label):
write_repo(self.repo, MINIMAL_BLOCK + "\n" + block)
with self.assertRaises(ConfigError):
load_config(self.repo)
def test_integration_exempt_paths_must_be_unique_nonempty_and_exact(self) -> None:
cases = {
"empty": 'exempt_paths: []',
"duplicate": (
'exempt_paths: [".task/**", ".task/**", '
'"docs/superpowers/specs/**", "docs/superpowers/plans/**"]'
),
"widened": (
'exempt_paths: [".task/**", "docs/superpowers/specs/**", '
'"docs/superpowers/plans/**", "src/**"]'
),
"narrowed": 'exempt_paths: [".task/**", "docs/superpowers/specs/**"]',
"parent traversal": (
'exempt_paths: [".task/**", "docs/superpowers/specs/**", '
'"docs/superpowers/plans/../../**"]'
),
"absolute": (
'exempt_paths: [".task/**", "docs/superpowers/specs/**", '
'"/docs/superpowers/plans/**"]'
),
}
for label, value in cases.items():
with self.subTest(label):
block = VALID_INTEGRATION.replace(
'exempt_paths: [".task/**", "docs/superpowers/specs/**", '
'"docs/superpowers/plans/**"]',
value,
)
write_repo(self.repo, MINIMAL_BLOCK + "\n" + block)
with self.assertRaises(ConfigError):
load_config(self.repo)
def test_integration_command_rejects_shell_metacharacters(self) -> None:
block = VALID_INTEGRATION.replace(
"command: python3 scripts/agent_exec.py apply",
"command: python3 scripts/agent_exec.py apply; rm -rf /",
)
write_repo(self.repo, MINIMAL_BLOCK + "\n" + block)
with self.assertRaises(ConfigError):
load_config(self.repo)
def test_host_exempt_paths_match_executor_enforcement(self) -> None:
from scripts import agent_exec
from scripts.agent_exec_config import HOST_EXEMPT_PATHS
enforced = {prefix.rstrip("/") for prefix in agent_exec._HOST_MANAGED_PREFIXES}
declared = {glob.removesuffix("/**") for glob in HOST_EXEMPT_PATHS}
self.assertEqual(declared, enforced)
def test_checked_in_project_config_declares_integration(self) -> None:
cfg = load_config(Path(__file__).resolve().parents[1])
self.assertIsNotNone(cfg.integration)
assert cfg.integration is not None
self.assertEqual(
set(cfg.integration.exempt_paths),
{".task/**", "docs/superpowers/specs/**", "docs/superpowers/plans/**"},
)
self.assertEqual(cfg.integration.cleanup_command, "make task-finish")
VALID_INTEGRATION = textwrap.dedent(
"""\
integration:
exempt_paths: [".task/**", "docs/superpowers/specs/**", "docs/superpowers/plans/**"]
transport:
command: python3 scripts/agent_exec.py apply
admission:
command: python3 scripts/agent_exec.py admit
cleanup:
command: make task-finish
"""
)
def _legacy_custom_block() -> str:
"""No emergency_profile and adapter custom on every role."""
+23
View File
@@ -374,6 +374,29 @@ class EvidenceTests(unittest.TestCase):
result = self.run_evidence(VALIDATE_RECEIPT=self.receipt)
self.assertNotEqual(result.returncode, 0)
def test_child_receipts_record_root_and_immediate_targets(self):
self.card.write_text("# Test task\n- 分支类型:child;initiative_id:feature-alpha;root_target:main;"
"immediate_target:feature/alpha;accepting_requirements:false\n")
data = self.capture()
self.assertEqual((data["root_target"], data["immediate_target"]), ("main", "feature/alpha"))
self.assertEqual(self.import_receipt().returncode, 0)
self.assertIn(data["receipt_sha256"], self.card.read_text())
checked = subprocess.run(["python3", str(SCRIPTS / "check-evidence.py"), str(self.card)],
cwd=self.repo, capture_output=True, text=True)
self.assertNotIn("WARN", checked.stdout + checked.stderr)
def test_receipts_without_target_metadata_default_to_main(self):
data = self.capture()
self.assertEqual((data["root_target"], data["immediate_target"]), ("main", "main"))
def test_receipt_target_fields_must_come_in_pairs(self):
original = self.capture()
partial = dict(original)
del partial["immediate_target"]
self.resign(partial)
self.assertNotEqual(self.import_receipt().returncode, 0)
self.assertEqual(self.card.read_text(), "# Test task\n")
if __name__ == "__main__":
unittest.main()
+506 -13
View File
@@ -2,15 +2,74 @@
import os
import json
import hashlib
import http.server
import threading
from pathlib import Path
import shutil
import shlex
import subprocess
import tempfile
import unittest
import urllib.parse
SOURCE = Path(__file__).resolve().parents[1]
TRIAGE_COMMANDS = ("start", "triage", "triage-decide")
class FakeGitea:
"""Gitea pulls API 桩(进程内 127.0.0.1):校验 token 请求头,按页返回 self.prs;从不联网。"""
PAGE = 50
def __init__(self, token="dummy"):
self.token = token
self.prs = []
self.posted = []
stub = self
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def _send(self, status, obj):
body = json.dumps(obj).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.headers.get("Authorization") != f"token {stub.token}":
return self._send(401, {"message": "unauthorized"})
parsed = urllib.parse.urlsplit(self.path)
if not parsed.path.startswith("/api/v1/repos/") or not parsed.path.endswith("/pulls"):
return self._send(404, {"message": "not found"})
page = int(urllib.parse.parse_qs(parsed.query).get("page", ["1"])[0])
start = (page - 1) * stub.PAGE
return self._send(200, stub.prs[start:start + stub.PAGE])
def do_POST(self):
if self.headers.get("Authorization") != f"token {stub.token}":
return self._send(401, {"message": "unauthorized"})
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", "0"))) or b"{}")
stub.posted.append(body)
return self._send(201, {"html_url": f"{stub.web}/fixture/repo/pulls/{len(stub.posted)}"})
self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
self.web = f"http://127.0.0.1:{self.server.server_address[1]}"
self.url = f"{self.web}/api/v1"
threading.Thread(target=self.server.serve_forever, daemon=True).start()
def close(self):
self.server.shutdown()
self.server.server_close()
@staticmethod
def pr(number, branch, sha, base="main", state="open"):
return {"number": number, "state": state, "base": {"ref": base},
"head": {"ref": branch, "sha": sha}}
class TaskLifecycleTests(unittest.TestCase):
@@ -20,6 +79,8 @@ class TaskLifecycleTests(unittest.TestCase):
self.root = Path(self.tmp.name)
self.repo = self.root / "repo"
self.remote = self.root / "remote.git"
self.gitea = FakeGitea()
self.addCleanup(self.gitea.close)
self.env = os.environ.copy()
self.env.update({"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null",
"GIT_ALLOW_PROTOCOL": "file", "GIT_TERMINAL_PROMPT": "0",
@@ -27,10 +88,13 @@ class TaskLifecycleTests(unittest.TestCase):
"GIT_COMMITTER_NAME": "Lifecycle Test", "GIT_COMMITTER_EMAIL": "test@example.invalid",
"GIT_CONFIG_COUNT": "2", "GIT_CONFIG_KEY_0": "gc.auto",
"GIT_CONFIG_VALUE_0": "0", "GIT_CONFIG_KEY_1": "maintenance.auto",
"GIT_CONFIG_VALUE_1": "false"})
for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GITEA_TOKEN", "PRECHECK_RECEIPT",
"GIT_CONFIG_VALUE_1": "false",
# 桩位于 127.0.0.1:禁止经代理转发,保证测试不触达外部网络。
"NO_PROXY": "127.0.0.1,localhost", "no_proxy": "127.0.0.1,localhost"})
for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GITEA_TOKEN", "GITEA_API_URL",
"GITEA_REPO", "TRIAGE_RECEIPT", "PRECHECK_RECEIPT",
"TASK_PARENT_BRANCH", "TASK_PARENT_PR", "FORCE_ABANDON", "TASK_TARGET_BRANCH",
"TASK_WORK_MODE", "VALIDATE_RECEIPT_PURPOSE"):
"TASK_WORK_MODE", "VALIDATE_RECEIPT_PURPOSE", "TASK_BRANCH_KIND", "TASK_INITIATIVE"):
self.env.pop(key, None)
self.git(self.root, "init", "--bare", "--initial-branch=main", str(self.remote))
# receive-pack 默认在后台跑 gc --auto,与 TemporaryDirectory 清理竞态
@@ -60,8 +124,14 @@ class TaskLifecycleTests(unittest.TestCase):
def task(self, cwd, command, *args, **kwargs):
if command == "finish" and "env" not in kwargs:
kwargs["env"] = self.env | {"TASK_WRITERS_STOPPED": "1"}
if command in TRIAGE_COMMANDS:
# 分流只读远端:注入桩地址与测试专用 token,绝不读取真实 ~/.secrets。
kwargs["env"] = self.gitea_env() | kwargs.get("env", self.env)
return self.run_cmd(cwd, "bash", str(self.repo / "scripts/task.sh"), command, *args, **kwargs)
def gitea_env(self):
return {"GITEA_API_URL": self.gitea.url, "GITEA_REPO": "fixture/repo", "GITEA_TOKEN": self.gitea.token}
def worktree(self, name="feature/test"):
wt = self.root / "worktree"
self.git(self.repo, "worktree", "add", "-b", name, str(wt), "origin/main")
@@ -671,18 +741,46 @@ class TaskLifecycleTests(unittest.TestCase):
self.assertNotEqual(result.returncode, 0, result.stdout)
def capture_precheck(self, cwd=None):
receipt = self.root / "precheck.json"
receipt = self.root / f"precheck-{len(list(self.root.glob('precheck*.json')))}.json"
env = self.env | {"RECEIPT": str(receipt), "CMD": "make precheck"}
self.run_cmd(cwd or self.repo, "bash", str(self.repo / "scripts/evidence.sh"), env=env)
return receipt
def triage_receipt(self, join=None, reason="fixture decision"):
"""经真实 task.sh triage 生成分流收据(对 Gitea 桩)。候选存在时按 join(分支名)或 NEW 显式决策。"""
receipt = self.root / f"triage-{len(list(self.root.glob('triage*.json')))}.json"
result = self.task(self.repo, "triage", "fixture requirement", check=False,
env=self.env | {"TRIAGE_RECEIPT": str(receipt)})
if result.returncode == 5:
decide_env = self.env | {"TRIAGE_RECEIPT": str(receipt), "REASON": reason,
"VERDICT": "JOIN" if join else "NEW"}
if join:
decide_env["INITIATIVE"] = join
self.task(self.repo, "triage-decide", env=decide_env)
else:
self.assertEqual(result.returncode, 0, result.stdout)
return receipt
def publish_initiative(self, branch):
"""把 initiative 分支推到 origin 并登记 open PR(模拟远端接纳中的大任务)。"""
self.git(self.repo, "push", "origin", f"refs/heads/{branch}:refs/heads/{branch}")
sha = self.git(self.repo, "rev-parse", f"refs/heads/{branch}").stdout.strip()
known = [pr for pr in self.gitea.prs if pr["head"]["ref"] == branch]
if known:
known[0]["head"]["sha"] = sha
else:
self.gitea.prs.append(FakeGitea.pr(len(self.gitea.prs) + 1, branch, sha))
def test_start_requires_precheck_receipt_before_creating_worktree(self):
result = self.task(self.repo, "start", "feature/new", check=False)
triage = self.triage_receipt()
result = self.task(self.repo, "start", "feature/new", check=False,
env=self.env | {"TRIAGE_RECEIPT": str(triage)})
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertFalse((self.root / "repo-wt-feature-new").exists(), result.stdout)
def test_start_preserves_merged_local_and_remote_branch_names(self):
receipt = self.capture_precheck()
triage = self.triage_receipt()
head = self.git(self.repo, "rev-parse", "HEAD").stdout.strip()
for kind in ("local", "remote"):
with self.subTest(kind=kind):
@@ -695,7 +793,8 @@ class TaskLifecycleTests(unittest.TestCase):
ref = "refs/remotes/origin/" + name
self.git(self.repo, "fetch", "origin", "refs/heads/" + name + ":" + ref)
result = self.task(self.repo, "start", name, check=False,
env=self.env | {"PRECHECK_RECEIPT": str(receipt)})
env=self.env | {"PRECHECK_RECEIPT": str(receipt),
"TRIAGE_RECEIPT": str(triage)})
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn("already merged", result.stdout)
self.assertFalse((self.root / ("repo-wt-" + name.replace("/", "-"))).exists())
@@ -703,11 +802,17 @@ class TaskLifecycleTests(unittest.TestCase):
def test_start_imports_precheck_and_creates_four_part_commit(self):
receipt = self.capture_precheck()
self.task(self.repo, "start", "feature/new", env=self.env | {"PRECHECK_RECEIPT": str(receipt)})
triage = self.triage_receipt()
self.task(self.repo, "start", "feature/new",
env=self.env | {"PRECHECK_RECEIPT": str(receipt), "TRIAGE_RECEIPT": str(triage)})
wt = self.root / "repo-wt-feature-new"
self.assertTrue((wt / ".task/feature-new.md").is_file())
card = (wt / ".task/feature-new.md").read_text()
self.assertIn("make precheck", card)
self.assertIn("- 需求分流:verdict=NEW;selected_initiative=无;requirement_sha256=", card)
self.assertIn(f"triage_receipt={triage.resolve()};receipt_sha256=", card)
branch_line = card.index("- 分支类型:standalone")
self.assertEqual(card.index("- 需求分流:"), card.index("\n", branch_line) + 1)
self.assertIn("交付模式:goal-first(目标、范围和交付终点一次确认;范围内连续推进)", card)
self.assertIn("实施执行:agent-per-unit(配置:AGENT-EXECUTION.md)", card)
self.assertIn("人工决策点:仅限目标/范围/验收变更、重大取舍、高后果操作或无法自动判定的业务验收", card)
@@ -974,7 +1079,9 @@ class TaskLifecycleTests(unittest.TestCase):
self.git(parent, "commit", "-m", "Parent change")
parent_head = self.git(parent, "rev-parse", "HEAD").stdout.strip()
receipt = self.capture_precheck(parent)
env = self.env | {"PRECHECK_RECEIPT": str(receipt), "TASK_PARENT_BRANCH": "feature/parent",
triage = self.triage_receipt()
env = self.env | {"PRECHECK_RECEIPT": str(receipt), "TRIAGE_RECEIPT": str(triage),
"TASK_PARENT_BRANCH": "feature/parent",
"TASK_PARENT_PR": "https://example.invalid/repo/pulls/42"}
self.task(parent, "start", "feature/parent-child", env=env)
wt = self.root / "repo-wt-feature-parent-child"
@@ -985,17 +1092,20 @@ class TaskLifecycleTests(unittest.TestCase):
self.assertEqual((wt / "parent-change").read_text(), "parent feature")
def test_start_offline_does_not_create_worktree(self):
triage = self.triage_receipt()
self.git(self.repo, "remote", "set-url", "origin", str(self.root / "missing.git"))
result = self.task(self.repo, "start", "feature/offline", check=False)
result = self.task(self.repo, "start", "feature/offline", check=False,
env=self.env | {"TRIAGE_RECEIPT": str(triage)})
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn("REMOTE_UNVERIFIED", result.stdout)
self.assertFalse((self.root / "repo-wt-feature-offline").exists())
def test_start_rejects_receipt_when_authoritative_base_changed(self):
receipt = self.capture_precheck()
triage = self.triage_receipt()
self.advance_remote()
result = self.task(self.repo, "start", "feature/stale", check=False,
env=self.env | {"PRECHECK_RECEIPT": str(receipt)})
env=self.env | {"PRECHECK_RECEIPT": str(receipt), "TRIAGE_RECEIPT": str(triage)})
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertFalse((self.root / "repo-wt-feature-stale").exists())
@@ -1005,6 +1115,381 @@ class TaskLifecycleTests(unittest.TestCase):
result = self.run_cmd(wt, "python3", "scripts/check-worktree.py", "verify", check=False)
self.assertNotEqual(result.returncode, 0, result.stdout)
# ---- 两级分支:initiative -> child -> main 与新需求强制分诊 ----
def write_initiative_card(self, branch, status="进行中", accepting="true", root="main",
kind="initiative", commit=True):
slug = branch.replace("/", "-")
card = self.repo / ".task" / f"{slug}.md"
card.parent.mkdir(exist_ok=True)
card.write_text(
f"# {branch}\n任务主记录:fixture;状态:{status};owner:Lifecycle Test\n"
f"- 工作区:fixture(worktree);分支:{branch}\n"
f"- 分支类型:{kind};initiative_id:{slug};root_target:{root};"
f"immediate_target:{root};accepting_requirements:{accepting}\n"
)
if commit:
self.git(self.repo, "add", ".task")
self.git(self.repo, "commit", "-m", f"Record {branch} card")
return card
def card_helper(self, func, card):
source = (self.repo / "scripts/task.sh").read_text()
lib = self.root / "task-lib.sh"
lib.write_text(source[:source.rindex('\ncase "${1:-}" in')] + "\n")
return self.run_cmd(self.repo, "bash", "-c", f'source {shlex.quote(str(lib))}; {func} "$1"',
"_", str(card)).stdout.strip()
def start_initiative(self, name="feature/alpha"):
receipt = self.capture_precheck()
triage = self.triage_receipt()
self.task(self.repo, "start", name,
env=self.env | {"PRECHECK_RECEIPT": str(receipt), "TRIAGE_RECEIPT": str(triage),
"TASK_BRANCH_KIND": "initiative"})
return self.root / ("repo-wt-" + name.replace("/", "-"))
def start_child(self, name="feature/alpha-login", initiative="feature/alpha", check=True):
# 远端须已登记该 initiative(PR+卡片),子任务才能经真实 JOIN 收据启动。
if initiative:
self.publish_initiative(initiative)
triage = self.triage_receipt(join=initiative)
else:
triage = self.root / "missing-triage.json"
receipt = self.capture_precheck()
env = self.env | {"PRECHECK_RECEIPT": str(receipt), "TRIAGE_RECEIPT": str(triage),
"TASK_BRANCH_KIND": "child", "TASK_INITIATIVE": initiative,
"TASK_PARENT_PR": "https://example.invalid/repo/pulls/7"}
result = self.task(self.repo, "start", name, env=env, check=check)
return self.root / ("repo-wt-" + name.replace("/", "-")), result
def test_triage_without_receipt_is_refused(self):
result = self.task(self.repo, "triage", "add login", check=False)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn("TRIAGE_RECEIPT required", result.stdout)
def test_triage_ignores_local_cards_and_records_new_without_remote_initiative(self):
# main 上的本地卡片不是权威来源:远端没有 PR/分支时必须是 NEW,而不是按本地计数 JOIN。
self.write_initiative_card("feature/local-only")
receipt = self.triage_receipt()
data = json.loads(receipt.read_text())
self.assertEqual((data["discovery_verdict"], data["final_verdict"]), ("NEW", "NEW"))
self.assertEqual(data["candidates"], [])
self.assertIsNone(data["decision"]["selected_initiative"])
def test_remote_initiative_needs_explicit_decision_and_never_auto_joins(self):
self.start_initiative("feature/alpha")
self.publish_initiative("feature/alpha")
receipt = self.root / "single-candidate.json"
result = self.task(self.repo, "triage", "add login", check=False,
env=self.env | {"TRIAGE_RECEIPT": str(receipt)})
self.assertEqual(result.returncode, 5, result.stdout)
self.assertIn("[candidate] branch=feature/alpha", result.stdout)
self.assertIn("[verdict] DECISION_REQUIRED", result.stdout)
self.assertNotIn("[verdict] JOIN", result.stdout)
data = json.loads(receipt.read_text())
self.assertEqual(data["final_verdict"], "DECISION_REQUIRED")
self.assertIsNone(data["decision"])
def test_multiple_remote_candidates_stop_at_decision_required(self):
self.start_initiative("feature/alpha")
self.publish_initiative("feature/alpha")
self.start_initiative("feature/beta")
self.publish_initiative("feature/beta")
receipt = self.root / "multi-candidate.json"
result = self.task(self.repo, "triage", "add login", check=False,
env=self.env | {"TRIAGE_RECEIPT": str(receipt)})
self.assertEqual(result.returncode, 5, result.stdout)
self.assertIn("[candidate] branch=feature/alpha", result.stdout)
self.assertIn("[candidate] branch=feature/beta", result.stdout)
self.assertIn("[verdict] DECISION_REQUIRED", result.stdout)
self.assertNotIn("[verdict] JOIN", result.stdout)
self.assertEqual(json.loads(receipt.read_text())["final_verdict"], "DECISION_REQUIRED")
def test_precheck_reports_active_initiatives(self):
result = self.task(self.repo, "precheck", check=False)
self.assertIn("[initiatives] local cards (not authoritative): none", result.stdout)
self.write_initiative_card("feature/alpha")
self.write_initiative_card("feature/beta")
self.git(self.repo, "push", "origin", "main")
result = self.task(self.repo, "precheck", check=False)
self.assertIn("[initiatives] local cards (not authoritative): feature/alpha feature/beta", result.stdout)
def test_start_initiative_records_branch_metadata(self):
wt = self.start_initiative()
card = wt / ".task/feature-alpha.md"
text = card.read_text()
self.assertIn("- 分支类型:initiative;initiative_id:feature-alpha;root_target:main;"
"immediate_target:main;accepting_requirements:true", text)
self.assertEqual(self.git(wt, "merge-base", "HEAD", "origin/main").stdout.strip(),
self.git(self.repo, "rev-parse", "origin/main").stdout.strip())
self.assertEqual(self.card_helper("card_branch_kind", card), "initiative")
self.assertEqual(self.card_helper("card_accepting_requirements", card), "true")
def test_start_standalone_records_default_metadata(self):
receipt = self.capture_precheck()
triage = self.triage_receipt()
self.task(self.repo, "start", "feature/solo",
env=self.env | {"PRECHECK_RECEIPT": str(receipt), "TRIAGE_RECEIPT": str(triage)})
text = (self.root / "repo-wt-feature-solo/.task/feature-solo.md").read_text()
self.assertIn("- 分支类型:standalone;root_target:main;immediate_target:main;"
"accepting_requirements:false", text)
def test_start_child_records_parent_head_and_immediate_target(self):
parent = self.start_initiative()
(parent / "parent-change").write_text("initiative work\n")
self.git(parent, "add", "parent-change")
self.git(parent, "commit", "-m", "Initiative work")
parent_head = self.git(parent, "rev-parse", "HEAD").stdout.strip()
wt, _ = self.start_child()
card = wt / ".task/feature-alpha-login.md"
text = card.read_text()
self.assertIn("- 分支类型:child;initiative_id:feature-alpha;root_target:main;"
"immediate_target:feature/alpha;accepting_requirements:false;"
f"parent_branch:feature/alpha;parent_head_sha:{parent_head};"
"parent_pr:https://example.invalid/repo/pulls/7", text)
self.assertEqual((wt / "parent-change").read_text(), "initiative work\n")
self.git(wt, "merge-base", "--is-ancestor", parent_head, "HEAD")
self.assertEqual(self.card_helper("card_immediate_target", card), "feature/alpha")
self.assertEqual(self.card_helper("card_initiative_id", card), "feature-alpha")
self.assertEqual(self.card_helper("card_root_target", card), "main")
def test_start_child_rejects_missing_initiative_or_wrong_slug(self):
self.start_initiative()
wt, result = self.start_child(name="feature/other-login", check=False)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn("parent initiative slug", result.stdout)
self.assertFalse(wt.exists())
wt, result = self.start_child(initiative="", check=False)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertFalse(wt.exists())
def test_sync_child_merges_new_parent_initiative_commits(self):
parent = self.start_initiative()
wt, _ = self.start_child()
(parent / "parent-later").write_text("later initiative work\n")
self.git(parent, "add", "parent-later")
self.git(parent, "commit", "-m", "Later initiative work")
self.publish_initiative("feature/alpha") # delivery 只认远端父分支头
parent_head = self.git(parent, "rev-parse", "HEAD").stdout.strip()
result = self.task(wt, "sync")
self.assertIn("changed=1", result.stdout)
self.assertIn("sync_target=feature/alpha", result.stdout)
self.assertIn(f"sync_base={parent_head}", result.stdout)
self.git(wt, "merge-base", "--is-ancestor", parent_head, "HEAD")
self.assertEqual((wt / "parent-later").read_text(), "later initiative work\n")
self.assertIn(f"| 同步(记录前对象) | {parent_head} |",
(wt / ".task/feature-alpha-login.md").read_text())
again = self.task(wt, "sync")
self.assertIn("changed=0", again.stdout)
def test_finish_recycles_child_merged_into_parent_initiative(self):
parent = self.start_initiative()
wt, _ = self.start_child()
(wt / "child-work").write_text("child result\n")
self.git(wt, "add", "child-work")
self.git(wt, "commit", "-m", "Child work")
refused = self.task(wt, "finish", check=False)
self.assertNotEqual(refused.returncode, 0, refused.stdout)
self.assertIn("has not merged into feature/alpha", refused.stdout)
self.assertTrue(wt.exists())
self.git(parent, "merge", "--no-ff", "feature/alpha-login", "-m", "Merge child into initiative")
self.publish_initiative("feature/alpha") # delivery 的 finish 只认远端父分支已含子分支
result = self.task(wt, "finish")
self.assertIn("[verified] feature/alpha-login is included in parent initiative feature/alpha",
result.stdout)
self.assertFalse(wt.exists(), result.stdout)
self.assertEqual(self.git(self.repo, "branch", "--list", "feature/alpha-login").stdout.strip(), "")
self.assertTrue(parent.exists())
self.assertEqual(self.git(parent, "branch", "--show-current").stdout.strip(), "feature/alpha")
def test_legacy_card_defaults_to_standalone_main(self):
wt = self.worktree()
card = self.record_task_card(wt)
self.assertEqual(self.card_helper("card_branch_kind", card), "standalone")
self.assertEqual(self.card_helper("card_initiative_id", card), "")
self.assertEqual(self.card_helper("card_root_target", card), "main")
self.assertEqual(self.card_helper("card_immediate_target", card), "main")
self.assertEqual(self.card_helper("card_accepting_requirements", card), "false")
receipt = self.triage_receipt()
self.assertEqual(json.loads(receipt.read_text())["final_verdict"], "NEW")
def test_start_without_triage_receipt_creates_no_worktree_or_branch(self):
receipt = self.capture_precheck()
result = self.task(self.repo, "start", "feature/untriaged", check=False,
env=self.env | {"PRECHECK_RECEIPT": str(receipt)})
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn("TRIAGE_RECEIPT required", result.stdout)
self.assertFalse((self.root / "repo-wt-feature-untriaged").exists())
self.assertEqual(self.git(self.repo, "branch", "--list", "feature/untriaged").stdout.strip(), "")
self.assertEqual(self.git(self.repo, "ls-remote", "--heads", "origin",
"feature/untriaged").stdout.strip(), "")
def test_start_child_refuses_non_join_receipt_without_side_effects(self):
self.start_initiative("feature/alpha")
self.publish_initiative("feature/alpha")
new_receipt = self.triage_receipt() # 远端存在候选,显式 NEW 决策(非适配)
receipt = self.capture_precheck()
env = self.env | {"PRECHECK_RECEIPT": str(receipt), "TRIAGE_RECEIPT": str(new_receipt),
"TASK_BRANCH_KIND": "child", "TASK_INITIATIVE": "feature/alpha",
"TASK_PARENT_PR": "https://example.invalid/repo/pulls/7"}
result = self.task(self.repo, "start", "feature/alpha-login", check=False, env=env)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn("triage receipt rejected", result.stdout)
self.assertFalse((self.root / "repo-wt-feature-alpha-login").exists())
self.assertEqual(self.git(self.repo, "branch", "--list", "feature/alpha-login").stdout.strip(), "")
def test_start_refuses_receipt_when_remote_initiative_moved_after_triage(self):
self.start_initiative("feature/alpha")
self.publish_initiative("feature/alpha")
join = self.triage_receipt(join="feature/alpha")
parent = self.root / "repo-wt-feature-alpha"
(parent / "moved").write_text("later initiative work\n")
self.git(parent, "add", "moved")
self.git(parent, "commit", "-m", "Move initiative after triage")
self.publish_initiative("feature/alpha")
receipt = self.capture_precheck()
env = self.env | {"PRECHECK_RECEIPT": str(receipt), "TRIAGE_RECEIPT": str(join),
"TASK_BRANCH_KIND": "child", "TASK_INITIATIVE": "feature/alpha",
"TASK_PARENT_PR": "https://example.invalid/repo/pulls/7"}
result = self.task(self.repo, "start", "feature/alpha-login", check=False, env=env)
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn("remote initiative candidates changed", result.stdout)
self.assertFalse((self.root / "repo-wt-feature-alpha-login").exists())
def close_env(self):
# 测试 token 与本地 Gitea 桩:建 PR 只能落到桩上,绝不触达真实 Gitea。
return self.env | {"GITEA_TOKEN": self.gitea.token, "TASK_CARD_WEB_BASE": self.gitea.web}
def child_env(self, receipt, triage):
return self.env | {"PRECHECK_RECEIPT": str(receipt), "TRIAGE_RECEIPT": str(triage),
"TASK_BRANCH_KIND": "child", "TASK_INITIATIVE": "feature/alpha",
"TASK_PARENT_PR": "https://example.invalid/repo/pulls/7"}
def test_delivery_child_ignores_ahead_local_parent(self):
parent = self.start_initiative()
self.publish_initiative("feature/alpha")
remote_head = self.git(parent, "rev-parse", "HEAD").stdout.strip()
(parent / "unadmitted").write_text("local only\n")
self.git(parent, "add", "unadmitted")
self.git(parent, "commit", "-m", "Unpublished initiative work")
triage = self.triage_receipt(join="feature/alpha")
receipt = self.capture_precheck()
self.task(self.repo, "start", "feature/alpha-login", env=self.child_env(receipt, triage))
wt = self.root / "repo-wt-feature-alpha-login"
card = (wt / ".task/feature-alpha-login.md").read_text()
self.assertIn(f"parent_head_sha:{remote_head}", card)
self.assertIn("parent_source:origin", card)
self.assertFalse((wt / "unadmitted").exists())
def test_delivery_child_ignores_stale_local_parent(self):
self.start_initiative()
self.publish_initiative("feature/alpha")
other = self.root / "other"
self.git(self.root, "clone", "-q", str(self.remote), str(other))
self.git(other, "checkout", "-q", "feature/alpha")
(other / "remote-admitted").write_text("admitted\n")
self.git(other, "add", "remote-admitted")
self.git(other, "commit", "-m", "Admitted initiative work")
self.git(other, "push", "-q", "origin", "feature/alpha")
remote_head = self.git(other, "rev-parse", "HEAD").stdout.strip()
triage = self.triage_receipt(join="feature/alpha")
receipt = self.capture_precheck()
self.task(self.repo, "start", "feature/alpha-login", env=self.child_env(receipt, triage))
wt = self.root / "repo-wt-feature-alpha-login"
self.assertIn(f"parent_head_sha:{remote_head}", (wt / ".task/feature-alpha-login.md").read_text())
self.assertEqual((wt / "remote-admitted").read_text(), "admitted\n")
def test_delivery_child_fails_when_remote_parent_is_deleted_after_triage(self):
self.start_initiative()
self.publish_initiative("feature/alpha")
triage = self.triage_receipt(join="feature/alpha")
self.git(self.repo, "push", "origin", "--delete", "feature/alpha")
receipt = self.capture_precheck()
result = self.task(self.repo, "start", "feature/alpha-login", check=False,
env=self.child_env(receipt, triage))
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertFalse((self.root / "repo-wt-feature-alpha-login").exists())
self.assertEqual(self.git(self.repo, "branch", "--list", "feature/alpha-login").stdout.strip(), "")
def test_local_mode_child_uses_local_parent_and_marks_local_source(self):
parent = self.start_initiative()
self.publish_initiative("feature/alpha")
(parent / "local-admission").write_text("local only\n")
self.git(parent, "add", "local-admission")
self.git(parent, "commit", "-m", "Local initiative work")
local_head = self.git(parent, "rev-parse", "HEAD").stdout.strip()
triage = self.triage_receipt(join="feature/alpha")
receipt = self.capture_precheck()
self.task(self.repo, "start", "feature/alpha-login",
env=self.child_env(receipt, triage) | {"TASK_WORK_MODE": "local"})
wt = self.root / "repo-wt-feature-alpha-login"
card = (wt / ".task/feature-alpha-login.md").read_text()
self.assertIn(f"parent_head_sha:{local_head}", card)
self.assertIn("parent_source:local(非正式准入)", card)
self.assertEqual((wt / "local-admission").read_text(), "local only\n")
def test_resume_child_reports_parent_as_immediate_target(self):
parent = self.start_initiative()
wt, _ = self.start_child()
parent_head = self.git(parent, "rev-parse", "HEAD").stdout.strip()
receipt = self.capture_precheck(cwd=wt)
result = self.task(wt, "resume", env=self.env | {"PRECHECK_RECEIPT": str(receipt)})
self.assertIn(f"observed_target={parent_head} immediate_target=feature/alpha", result.stdout)
def test_resume_child_rejects_rewound_immediate_target(self):
parent = self.start_initiative()
wt, _ = self.start_child()
receipt = self.capture_precheck(cwd=wt)
self.git(parent, "reset", "--hard", "origin/main")
self.git(parent, "push", "-f", "origin", "feature/alpha")
result = self.task(wt, "resume", check=False, env=self.env | {"PRECHECK_RECEIPT": str(receipt)})
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn("no longer contains recorded parent head", result.stdout)
self.assertNotIn("[resumed]", result.stdout)
def test_close_archives_child_merged_into_initiative_and_names_it(self):
parent = self.start_initiative()
wt, _ = self.start_child()
(wt / "child-work").write_text("child result\n")
self.git(wt, "add", "child-work")
self.git(wt, "commit", "-m", "Child work")
self.git(parent, "merge", "--no-ff", "feature/alpha-login", "-m",
"Merge pull request 'feature/alpha-login' (#5) from feature/alpha-login into feature/alpha")
self.publish_initiative("feature/alpha")
closed = self.task(parent, "close", "--fold", env=self.close_env())
self.assertIn("[closed] 1 张已合入卡片", closed.stdout)
self.assertEqual(self.gitea.posted[-1]["head"], "feature/alpha")
archived = self.git(parent, "show", "HEAD:.task/archive/feature-alpha-login.md").stdout
self.assertIn("已包含于 origin/feature/alpha", archived)
self.assertIn("状态:已完成(回填)", archived)
self.assertIn("PR #5", archived)
self.assertFalse((parent / ".task/feature-alpha-login.md").exists())
def test_close_child_still_closable_after_initiative_merged_and_deleted(self):
parent = self.start_initiative()
wt, _ = self.start_child()
(wt / "child-work").write_text("child result\n")
self.git(wt, "add", "child-work")
self.git(wt, "commit", "-m", "Child work")
self.git(parent, "merge", "--no-ff", "feature/alpha-login", "-m",
"Merge pull request 'feature/alpha-login' (#5) from feature/alpha-login into feature/alpha")
self.publish_initiative("feature/alpha")
self.git(self.repo, "fetch", "-q", "origin")
self.git(self.repo, "merge", "--no-ff", "origin/feature/alpha", "-m",
"Merge pull request 'feature/alpha' (#3) from feature/alpha into main")
self.git(self.repo, "push", "origin", "main")
self.git(self.repo, "push", "origin", "--delete", "feature/alpha")
closed = self.task(self.repo, "close", env=self.close_env())
self.assertIn("[closed] 2 张已合入卡片", closed.stdout)
self.assertIn("[pr] " + self.gitea.web, closed.stdout)
self.assertEqual(self.gitea.posted[-1]["base"], "main")
refs = self.git(self.repo, "for-each-ref", "--format=%(refname:short)",
"refs/heads/feature/card-close-*").stdout.splitlines()
self.assertEqual(len(refs), 1)
archived = self.git(self.repo, "show", f"{refs[0]}:.task/archive/feature-alpha-login.md").stdout
self.assertIn("已包含于 origin/main", archived)
self.assertIn("PR #5", archived)
if __name__ == "__main__":
unittest.main()
@@ -1025,9 +1510,13 @@ class FinishGuardFixTests(unittest.TestCase):
"GIT_ALLOW_PROTOCOL": "file", "GIT_TERMINAL_PROMPT": "0",
"GIT_AUTHOR_NAME": "Guard Test", "GIT_AUTHOR_EMAIL": "guard@example.invalid",
"GIT_COMMITTER_NAME": "Guard Test", "GIT_COMMITTER_EMAIL": "guard@example.invalid",
"TASK_WRITERS_STOPPED": "1"})
for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GITEA_TOKEN", "PRECHECK_RECEIPT"):
"TASK_WRITERS_STOPPED": "1",
"NO_PROXY": "127.0.0.1,localhost", "no_proxy": "127.0.0.1,localhost"})
for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GITEA_TOKEN", "GITEA_API_URL",
"GITEA_REPO", "TRIAGE_RECEIPT", "PRECHECK_RECEIPT"):
self.env.pop(key, None)
self.gitea = FakeGitea()
self.addCleanup(self.gitea.close)
self.git(self.root, "init", "--bare", "-b", "main", self.root + "/origin.git")
self.git(self.root, "init", "-b", "main", str(self.repo))
(self.repo / "tracked").write_text("base\n")
@@ -1051,8 +1540,12 @@ class FinishGuardFixTests(unittest.TestCase):
["bash", str(self.repo / "scripts" / "evidence.sh")], cwd=str(self.repo),
capture_output=True, text=True,
env=self.env | {"RECEIPT": str(receipt), "CMD": "make precheck"}, check=True)
triage = Path(self.root) / "triage.json"
triage_env = self.env | {"GITEA_API_URL": self.gitea.url, "GITEA_REPO": "fixture/repo",
"GITEA_TOKEN": self.gitea.token}
self.task(self.repo, "triage", "guard fixture", env=triage_env | {"TRIAGE_RECEIPT": str(triage)})
self.task(self.repo, "start", "feature/guard",
env=self.env | {"PRECHECK_RECEIPT": str(receipt)})
env=triage_env | {"PRECHECK_RECEIPT": str(receipt), "TRIAGE_RECEIPT": str(triage)})
wt = self.root + "/repo-wt-feature-guard"
assert Path(wt).is_dir(), "worktree not created"
return Path(wt)
+401
View File
@@ -0,0 +1,401 @@
"""PR base/head 门禁(scripts/task_pr_check.py):临时真实 Git 仓库(bare origin + clone)+ 进程内 Gitea 桩。
全部离线运行:不联网、不读取真实 ~/.secrets(HOME 指向临时目录),token 为合成值。
"""
import http.server
import json
import os
import socket
import subprocess
import sys
import tempfile
import threading
import unittest
import urllib.parse
from pathlib import Path
SOURCE = Path(__file__).resolve().parents[1]
SCRIPT = SOURCE / "scripts" / "task_pr_check.py"
TOKEN = "synthetic-gitea-token-not-real"
def card_text(branch, kind, root="main", immediate="main", accepting="false", registry=None):
slug = branch.replace("/", "-")
lines = [
f"# {branch} 任务卡",
"任务主记录:fixture;状态:进行中;owner:PR Check Test",
"- 权威仓:fixture;目标分支:main;R0(initial_base_sha)= 0",
f"- 工作区:fixture(worktree);分支:{branch}",
f"- 分支类型:{kind};initiative_id:{slug};root_target:{root};"
f"immediate_target:{immediate};accepting_requirements:{accepting}",
"",
"## 目标与非目标",
"",
"- 交付 PR 门禁",
"",
]
if registry is not None:
lines += ["## 子任务登记", "", "| 子任务分支 | 必需 | 状态 | PR |", "| --- | --- | --- | --- |"]
lines += [f"| {branch_name} | {required} | {status} | {pr} |"
for branch_name, required, status, pr in registry]
lines.append("")
return "\n".join(lines) + "\n"
def pr(number, branch, base, sha, state="open", merged=False):
return {"number": number, "state": state, "merged": merged,
"base": {"ref": base}, "head": {"ref": branch, "sha": sha}}
class FakeGitea:
"""Gitea pulls API 桩(127.0.0.1):校验 token,列表(分页+state)与单 PR 查询;从不联网。"""
PAGE = 50
def __init__(self, token=TOKEN):
self.token = token
self.prs = []
stub = self
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def _send(self, status, obj):
body = json.dumps(obj).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.headers.get("Authorization") != f"token {stub.token}":
return self._send(401, {"message": "unauthorized"})
parsed = urllib.parse.urlsplit(self.path)
query = urllib.parse.parse_qs(parsed.query)
parts = parsed.path.split("/")
if parts[1:4] != ["api", "v1", "repos"] or len(parts) not in (7, 8) or parts[6] != "pulls":
return self._send(404, {"message": "not found"})
if len(parts) == 8:
found = [item for item in stub.prs if str(item["number"]) == parts[7]]
return self._send(200, found[0]) if found else self._send(404, {"message": "not found"})
state = query.get("state", [""])[0]
items = [item for item in stub.prs if not state or item["state"] == state]
page = int(query.get("page", ["1"])[0])
start = (page - 1) * stub.PAGE
return self._send(200, items[start:start + stub.PAGE])
self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
self.url = f"http://127.0.0.1:{self.server.server_address[1]}/api/v1"
threading.Thread(target=self.server.serve_forever, daemon=True).start()
def close(self):
self.server.shutdown()
self.server.server_close()
def closed_port_url():
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
return f"http://127.0.0.1:{port}/api/v1"
class PrCheckTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix="pr-check-")
self.addCleanup(self.tmp.cleanup)
root = Path(self.tmp.name)
self.remote = root / "remote.git"
self.repo = root / "repo"
self.env = {k: v for k, v in os.environ.items()
if not k.startswith(("GITHUB_", "GIT_")) and k not in {
"GITEA_API_URL", "GITEA_REPO", "GITEA_TOKEN", "TASK_TARGET_BRANCH"}}
self.env.update({"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null",
"GIT_ALLOW_PROTOCOL": "file", "GIT_TERMINAL_PROMPT": "0",
"GIT_AUTHOR_NAME": "PR Check Test", "GIT_AUTHOR_EMAIL": "pr-check@example.invalid",
"GIT_COMMITTER_NAME": "PR Check Test", "GIT_COMMITTER_EMAIL": "pr-check@example.invalid",
"GIT_CONFIG_COUNT": "2", "GIT_CONFIG_KEY_0": "gc.auto",
"GIT_CONFIG_VALUE_0": "0", "GIT_CONFIG_KEY_1": "maintenance.auto",
"GIT_CONFIG_VALUE_1": "false",
"HOME": str(root / "no-home"),
"NO_PROXY": "127.0.0.1,localhost", "no_proxy": "127.0.0.1,localhost",
"GITEA_REPO": "fixture/repo", "GITEA_TOKEN": TOKEN})
self.git(root, "init", "--bare", "--initial-branch=main", str(self.remote))
self.git(root, "init", "--initial-branch=main", str(self.repo))
(self.repo / "README").write_text("fixture\n")
self.git(self.repo, "add", "README")
self.git(self.repo, "commit", "-m", "Create fixture")
self.git(self.repo, "remote", "add", "origin", str(self.remote))
self.git(self.repo, "push", "-u", "origin", "main")
self.gitea = FakeGitea()
self.addCleanup(self.gitea.close)
self.env["GITEA_API_URL"] = self.gitea.url
# ---- 夹具 ----
def git(self, cwd, *args):
return subprocess.run(["git", *args], cwd=cwd, env=self.env, capture_output=True,
text=True, check=True).stdout.strip()
def head(self, ref="HEAD"):
return self.git(self.repo, "rev-parse", ref)
def make_branch(self, branch, base="main"):
self.git(self.repo, "switch", "-c", branch, base)
self.git(self.repo, "push", "-u", "origin", branch)
return self.head()
def commit_card(self, branch, text, base="main"):
self.make_branch(branch, base)
path = self.repo / ".task" / (branch.replace("/", "-") + ".md")
path.parent.mkdir(exist_ok=True)
path.write_text(text, encoding="utf-8")
self.git(self.repo, "add", ".task")
self.git(self.repo, "commit", "-m", f"Record {branch}")
self.git(self.repo, "push", "origin", branch)
return self.head()
def set_card(self, branch, text):
self.git(self.repo, "switch", branch)
path = self.repo / ".task" / (branch.replace("/", "-") + ".md")
path.write_text(text, encoding="utf-8")
self.git(self.repo, "add", ".task")
self.git(self.repo, "commit", "-m", f"Update {branch}")
self.git(self.repo, "push", "origin", branch)
return self.head()
def commit_work(self, branch, push=True):
self.git(self.repo, "switch", branch)
count = len(list(self.repo.glob("work-*.txt")))
(self.repo / f"work-{count}.txt").write_text("more work\n")
self.git(self.repo, "add", ".")
self.git(self.repo, "commit", "-m", "More work")
if push:
self.git(self.repo, "push", "origin", "HEAD")
return self.head()
def build_child(self):
self.commit_card("feature/init", card_text("feature/init", "initiative", accepting="true"))
self.commit_card("feature/init-child",
card_text("feature/init-child", "child", root="main", immediate="feature/init"),
base="feature/init")
return self.head()
def invoke(self, *args, env=None, drop=()):
merged = {k: v for k, v in (self.env | (env or {})).items() if k not in drop}
result = subprocess.run([sys.executable, str(SCRIPT), *args], cwd=self.repo, env=merged,
capture_output=True, text=True, timeout=60)
result.out = result.stdout + result.stderr
self.assertNotIn(TOKEN, result.out)
return result
def local(self, *args, env=None, drop=()):
return self.invoke("local", *args, env=env, drop=drop)
@staticmethod
def fails(result):
return [line for line in result.stdout.splitlines() if line.startswith("[pr-check] FAIL")]
# ---- local:基址与分支类型 ----
def test_child_pr_into_main_is_rejected(self):
self.build_child()
self.gitea.prs = [pr(1, "feature/init", "main", self.head("feature/init")),
pr(2, "feature/init-child", "main", self.head())]
result = self.local()
self.assertEqual(result.returncode, 1, result.out)
self.assertEqual(len(self.fails(result)), 1, result.out)
self.assertIn("expected 'feature/init' for child branch feature/init-child", result.out)
self.assertIn("[verdict] PR_CHECK_FAILED", result.out)
def test_child_pr_into_initiative_passes(self):
self.build_child()
head = self.head()
self.gitea.prs = [pr(1, "feature/init", "main", self.head("feature/init")),
pr(2, "feature/init-child", "feature/init", head)]
result = self.local()
self.assertEqual(result.returncode, 0, result.out)
self.assertIn(f"[verdict] PR_CHECK_OK base=feature/init head={head} kind=child", result.out)
def test_child_is_rejected_when_parent_initiative_pr_is_closed(self):
self.build_child()
self.gitea.prs = [pr(1, "feature/init", "main", self.head("feature/init"), state="closed"),
pr(2, "feature/init-child", "feature/init", self.head())]
result = self.local()
self.assertEqual(result.returncode, 1, result.out)
self.assertEqual(len(self.fails(result)), 1, result.out)
self.assertIn("parent initiative feature/init has no open PR into main", result.out)
def test_standalone_and_initiative_base_gate(self):
self.make_branch("feature/other")
self.commit_card("feature/solo", card_text("feature/solo", "standalone"))
self.commit_card("feature/init-x", card_text("feature/init-x", "initiative", accepting="true"))
for branch, kind in (("feature/solo", "standalone"), ("feature/init-x", "initiative")):
self.git(self.repo, "switch", branch)
head = self.head()
for base, expected_ok in (("main", True), ("feature/other", False)):
with self.subTest(kind=kind, base=base):
self.gitea.prs = [pr(1, branch, base, head)]
result = self.local()
if expected_ok:
self.assertEqual(result.returncode, 0, result.out)
self.assertIn(f"[verdict] PR_CHECK_OK base=main head={head} kind={kind}", result.out)
else:
self.assertEqual(result.returncode, 1, result.out)
self.assertIn(f"expected 'main' for {kind} branch {branch}", result.out)
# ---- local:PR 状态与 head ----
def test_stale_pr_head_is_rejected(self):
self.commit_card("feature/solo", card_text("feature/solo", "standalone"))
stale = self.head()
self.gitea.prs = [pr(1, "feature/solo", "main", stale)]
fresh = self.commit_work("feature/solo")
result = self.local()
self.assertEqual(result.returncode, 1, result.out)
self.assertEqual(len(self.fails(result)), 1, result.out)
self.assertIn(f"PR #1 head {stale} != local HEAD {fresh}", result.out)
def test_remote_branch_head_differing_from_local_is_rejected(self):
self.commit_card("feature/solo", card_text("feature/solo", "standalone"))
remote = self.head()
local = self.commit_work("feature/solo", push=False)
self.gitea.prs = [pr(1, "feature/solo", "main", local)]
result = self.local()
self.assertEqual(result.returncode, 1, result.out)
self.assertEqual(len(self.fails(result)), 1, result.out)
self.assertIn(f"origin/feature/solo is {remote}, local HEAD is {local}", result.out)
def test_closed_and_merged_prs_are_rejected(self):
self.commit_card("feature/solo", card_text("feature/solo", "standalone"))
head = self.head()
for kwargs, reason in (({"state": "closed"}, "PR #1 is not open (state=closed)"),
({"state": "closed", "merged": True}, "PR #1 is already merged")):
with self.subTest(reason=reason):
self.gitea.prs = [pr(1, "feature/solo", "main", head, **kwargs)]
result = self.local("--pr", "1")
self.assertEqual(result.returncode, 1, result.out)
self.assertIn(reason, result.out)
def test_pr_lookup_requires_one_open_pr_or_explicit_number(self):
self.commit_card("feature/solo", card_text("feature/solo", "standalone"))
head = self.head()
self.make_branch("feature/other")
self.git(self.repo, "switch", "feature/solo")
self.gitea.prs = [pr(1, "feature/solo", "feature/other", head), pr(2, "feature/solo", "main", head)]
ambiguous = self.local()
self.assertEqual(ambiguous.returncode, 1, ambiguous.out)
self.assertIn("2 open PRs have head feature/solo; pass --pr N", ambiguous.out)
explicit = self.local("--pr", "2")
self.assertEqual(explicit.returncode, 0, explicit.out)
self.gitea.prs = []
missing = self.local()
self.assertEqual(missing.returncode, 1, missing.out)
self.assertIn("no open PR has head feature/solo", missing.out)
# ---- 远端不可核验:fail closed ----
def test_unreachable_or_unauthorized_api_fails_closed_with_exit_6(self):
self.build_child()
self.gitea.prs = [pr(1, "feature/init", "main", self.head("feature/init")),
pr(2, "feature/init-child", "feature/init", self.head())]
for label, env in (("unreachable", {"GITEA_API_URL": closed_port_url()}),
("unauthorized", {"GITEA_TOKEN": "wrong-token"})):
with self.subTest(label):
result = self.local(env=env)
self.assertEqual(result.returncode, 6, result.out)
self.assertIn("[verdict] REMOTE_UNVERIFIED", result.out)
def test_token_is_first_access_token_line_of_secrets_file(self):
self.commit_card("feature/solo", card_text("feature/solo", "standalone"))
home = Path(self.tmp.name) / "secrets-home"
(home / ".secrets").mkdir(parents=True)
(home / ".secrets" / "gitea").write_text(
f"URL_PREFIX=https://example.invalid\n- ACCESS_TOKEN={TOKEN}\nACCESS_TOKEN=other-token\n")
self.gitea.prs = [pr(1, "feature/solo", "main", self.head())]
result = self.local(env={"HOME": str(home)}, drop=("GITEA_TOKEN",))
self.assertEqual(result.returncode, 0, result.out)
# ---- local --final ----
def test_final_rejects_initiative_still_accepting_requirements(self):
self.commit_card("feature/init", card_text("feature/init", "initiative", accepting="true", registry=[]))
self.gitea.prs = [pr(1, "feature/init", "main", self.head())]
result = self.local("--final")
self.assertEqual(result.returncode, 1, result.out)
self.assertIn("still accepts requirements", result.out)
def test_final_requires_required_children_merged_or_deferred(self):
self.commit_card("feature/init", card_text("feature/init", "initiative", accepting="true"))
scenarios = [
("merged and deferred pass",
[("feature/init-a", "是", "已合入", "#2"), ("feature/init-b", "否", "进行中", "-"),
("feature/init-c", "是", "已延期", "-")],
[pr(2, "feature/init-a", "feature/init", "a" * 40, state="closed", merged=True)],
None),
("required child still in progress",
[("feature/init-d", "是", "进行中", "-")], [], "required child feature/init-d is '进行中'"),
("open PR is not merged",
[("feature/init-e", "是", "已合入", "#3")],
[pr(3, "feature/init-e", "feature/init", "b" * 40, state="open")],
"child feature/init-e PR #3 is not merged into feature/init"),
("merged into main instead",
[("feature/init-f", "是", "已合入", "#4")],
[pr(4, "feature/init-f", "main", "c" * 40, state="closed", merged=True)],
"child feature/init-f PR #4 is not merged into feature/init"),
]
for label, registry, children, reason in scenarios:
with self.subTest(label):
head = self.set_card("feature/init", card_text("feature/init", "initiative",
accepting="false", registry=registry))
self.gitea.prs = [pr(1, "feature/init", "main", head)] + children
result = self.local("--final")
if reason is None:
self.assertEqual(result.returncode, 0, result.out)
self.assertIn("[verdict] PR_CHECK_OK base=main", result.out)
else:
self.assertEqual(result.returncode, 1, result.out)
self.assertIn(reason, result.out)
# ---- ci 模式 ----
def ci(self, base=None, head=None, event="pull_request"):
env = {"GITHUB_EVENT_NAME": event}
if base is not None:
env["GITHUB_BASE_REF"] = base
if head is not None:
env["GITHUB_HEAD_REF"] = head
# ci 模式不得需要网络或 token:指向关闭端口并移除 token,证明零依赖。
return self.invoke("ci", env=env | {"GITEA_API_URL": closed_port_url()}, drop=("GITEA_TOKEN",))
def test_ci_mode_gates_pull_request_base_against_head_card(self):
self.build_child()
result = self.ci(base="feature/init", head="feature/no-card")
self.assertEqual(result.returncode, 1, result.out) # 无卡 → standalone → 期望 main
self.assertIn("expected 'main' for standalone branch feature/no-card", result.out)
cases = [
("skips non pull_request events", dict(event="push", base="main", head="feature/init-child"), 0,
"[pr-check] skip: event push is not pull_request"),
("child into main is rejected", dict(base="main", head="feature/init-child"), 1,
"expected 'feature/init' for child branch feature/init-child"),
("child into initiative passes", dict(base="feature/init", head="feature/init-child"), 0,
"[verdict] PR_CHECK_OK base=feature/init head=feature/init-child kind=child"),
("card-less head defaults to main", dict(base="main", head="feature/no-card"), 0,
"[verdict] PR_CHECK_OK base=main head=feature/no-card kind=standalone"),
("card-less head into other base is rejected", dict(base="feature/init", head="feature/no-card"), 1,
"expected 'main' for standalone branch feature/no-card"),
("missing base ref is rejected", dict(base=None, head="feature/init-child"), 1,
"lacks GITHUB_BASE_REF or GITHUB_HEAD_REF"),
]
for label, kwargs, code, text in cases:
with self.subTest(label):
result = self.ci(**kwargs)
self.assertEqual(result.returncode, code, result.out)
self.assertIn(text, result.out)
if __name__ == "__main__":
unittest.main()
+7 -2
View File
@@ -1,8 +1,12 @@
"""Local recovery is task-scoped and cannot grant delivery readiness."""
import json
import os
import sys
import unittest
import test_task as lifecycle
# 允许 `python3 -m unittest tests/test_task_recovery.py` 直接运行(与 discover 模式一致地导入同目录夹具)。
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import test_task as lifecycle # noqa: E402
class TaskRecoveryTests(unittest.TestCase):
@@ -178,7 +182,8 @@ class TaskRecoveryTests(unittest.TestCase):
self.assertEqual(self.card.read_bytes(), before)
start = f.task(self.wt, "start", "feature/bypass", check=False,
env=f.env | {"PRECHECK_RECEIPT": str(receipt), "TASK_WORK_MODE": "local",
"VALIDATE_RECEIPT_PURPOSE": "resume-local"})
"VALIDATE_RECEIPT_PURPOSE": "resume-local",
"TRIAGE_RECEIPT": str(f.triage_receipt())})
self.assertNotEqual(start.returncode, 0, start.stdout)
self.assertFalse((f.root / "repo-wt-feature-bypass").exists())
+401
View File
@@ -0,0 +1,401 @@
"""需求分流收据契约(scripts/task_triage.py):远端发现、显式决策、核验拒绝。
全部对真实临时 Git 仓库(bare origin + clone)与进程内 Gitea 桩运行;不联网,不读取真实 ~/.secrets。
"""
import hashlib
import json
import os
import socket
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from test_task import FakeGitea # noqa: E402
SOURCE = Path(__file__).resolve().parents[1]
SCRIPT = SOURCE / "scripts" / "task_triage.py"
sys.path.insert(0, str(SOURCE / "scripts"))
import task_triage # noqa: E402
def card_text(branch, kind="initiative", accepting="true", root="main", status="进行中"):
slug = branch.replace("/", "-")
return (
f"# {branch} 任务卡\n"
f"任务主记录:fixture;状态:{status};owner:Triage Test\n"
f"- 权威仓:fixture;目标分支:main;R0(initial_base_sha)= 0\n"
f"- 工作区:fixture(worktree);分支:{branch}\n"
f"- 分支类型:{kind};initiative_id:{slug};root_target:{root};"
f"immediate_target:{root};accepting_requirements:{accepting}\n\n"
"## 目标与非目标\n\n- 交付登录能力\n- 不做第三方登录\n\n## 其它\n\n- 无\n"
)
class TriageTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix="triage-")
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.remote = self.root / "remote.git"
self.repo = self.root / "repo"
self.receipt = self.root / "receipt.json"
self.env = os.environ.copy()
self.env.update({"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null",
"GIT_ALLOW_PROTOCOL": "file", "GIT_TERMINAL_PROMPT": "0",
"GIT_AUTHOR_NAME": "Triage Test", "GIT_AUTHOR_EMAIL": "triage@example.invalid",
"GIT_COMMITTER_NAME": "Triage Test", "GIT_COMMITTER_EMAIL": "triage@example.invalid",
"GIT_CONFIG_COUNT": "2", "GIT_CONFIG_KEY_0": "gc.auto",
"GIT_CONFIG_VALUE_0": "0", "GIT_CONFIG_KEY_1": "maintenance.auto",
"GIT_CONFIG_VALUE_1": "false",
"NO_PROXY": "127.0.0.1,localhost", "no_proxy": "127.0.0.1,localhost",
"GITEA_REPO": "fixture/repo", "GITEA_TOKEN": "dummy"})
for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GITEA_API_URL", "TASK_TARGET_BRANCH",
"TRIAGE_RECEIPT"):
self.env.pop(key, None)
self.git(self.root, "init", "--bare", "--initial-branch=main", str(self.remote))
self.git(self.root, "init", "--initial-branch=main", str(self.repo))
(self.repo / "README").write_text("fixture\n")
self.git(self.repo, "add", "README")
self.git(self.repo, "commit", "-m", "Create fixture")
self.git(self.repo, "remote", "add", "origin", str(self.remote))
self.git(self.repo, "push", "-u", "origin", "main")
self.gitea = FakeGitea(token="dummy")
self.addCleanup(self.gitea.close)
self.env["GITEA_API_URL"] = self.gitea.url
# ---- 夹具 ----
def git(self, cwd, *args):
return subprocess.run(["git", *args], cwd=cwd, env=self.env, capture_output=True, check=True)
def make_initiative(self, branch, **card):
self.git(self.repo, "switch", "-c", branch, "main")
path = self.repo / ".task" / (branch.replace("/", "-") + ".md")
path.parent.mkdir(exist_ok=True)
path.write_text(card_text(branch, **card), encoding="utf-8")
self.git(self.repo, "add", ".task")
self.git(self.repo, "commit", "-m", f"Record {branch}")
self.git(self.repo, "push", "origin", branch)
self.git(self.repo, "switch", "main")
return self.head(branch)
def advance_initiative(self, branch):
self.git(self.repo, "switch", branch)
(self.repo / "more-work.txt").write_text("later initiative work\n")
self.git(self.repo, "add", "more-work.txt")
self.git(self.repo, "commit", "-m", "More initiative work")
self.git(self.repo, "push", "origin", branch)
self.git(self.repo, "switch", "main")
def head(self, ref):
return subprocess.run(["git", "rev-parse", ref], cwd=self.repo, env=self.env,
capture_output=True, text=True, check=True).stdout.strip()
def open_pr(self, number, branch, base="main", state="open"):
self.gitea.prs.append(FakeGitea.pr(number, branch, self.head(branch), base=base, state=state))
def run_script(self, *args, env=None):
result = subprocess.run([sys.executable, str(SCRIPT), *args], cwd=self.repo,
env=self.env | (env or {}), capture_output=True, text=True, timeout=60)
result.out = result.stdout + result.stderr
return result
def discover(self, env=None, requirement="add login", receipt=None):
return self.run_script("discover", "--receipt", str(receipt or self.receipt),
"--requirement", requirement, env=env)
def decide(self, verdict, reason="", initiative=None):
args = ["decide", "--receipt", str(self.receipt), "--verdict", verdict, "--reason", reason]
if initiative:
args += ["--initiative", initiative]
return self.run_script(*args)
def verify(self, kind, initiative=None, max_age=None, env=None):
args = ["verify", "--receipt", str(self.receipt), "--kind", kind]
if initiative:
args += ["--initiative", initiative]
if max_age is not None:
args += ["--max-age-seconds", str(max_age)]
return self.run_script(*args, env=env)
def load(self):
return json.loads(self.receipt.read_text(encoding="utf-8"))
def save(self, data):
self.receipt.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
def discover_single(self, branch="feature/alpha"):
self.make_initiative(branch)
self.open_pr(len(self.gitea.prs) + 1, branch)
result = self.discover()
self.assertEqual(result.returncode, 5, result.out)
return result
# ---- 发现 ----
def test_remote_initiative_absent_from_main_tree_is_discovered(self):
head = self.make_initiative("feature/alpha")
self.open_pr(1, "feature/alpha")
self.assertNotIn(".task/feature-alpha.md",
subprocess.run(["git", "ls-tree", "-r", "--name-only", "main"], cwd=self.repo,
env=self.env, capture_output=True, text=True).stdout)
result = self.discover()
self.assertEqual(result.returncode, 5, result.out)
self.assertIn("[candidate] branch=feature/alpha", result.out)
self.assertIn("[verdict] DECISION_REQUIRED", result.out)
self.assertIn("make task-triage-decide", result.out)
data = self.load()
cand = data["candidates"][0]
self.assertEqual(cand["head_sha"], head)
self.assertEqual(cand["card_path"], ".task/feature-alpha.md")
self.assertEqual(cand["pr_base"], "main")
self.assertEqual(cand["pr_number"], 1)
self.assertIn("交付登录能力", cand["goal_excerpt"])
blob = subprocess.run(["git", "cat-file", "blob", f"{head}:.task/feature-alpha.md"],
cwd=self.repo, env=self.env, capture_output=True, check=True).stdout
self.assertEqual(cand["card_sha256"], hashlib.sha256(blob).hexdigest())
self.assertEqual((data["final_verdict"], data["discovery_verdict"]),
("DECISION_REQUIRED", "DECISION_REQUIRED"))
self.assertIsNone(data["decision"])
def test_zero_candidates_yields_new_without_selecting_an_initiative(self):
result = self.discover()
self.assertEqual(result.returncode, 0, result.out)
self.assertIn("[verdict] NEW", result.out)
data = self.load()
self.assertEqual((data["discovery_verdict"], data["final_verdict"]), ("NEW", "NEW"))
self.assertEqual(data["candidates"], [])
self.assertEqual(data["decision"]["verdict"], "NEW")
self.assertIsNone(data["decision"]["selected_initiative"])
self.assertEqual(data["decision"]["rationale"], "no accepting initiative")
self.assertEqual(data["requirement"]["sha256"], hashlib.sha256(b"add login").hexdigest())
self.assertEqual(data["repo"]["repo_slug"], "fixture/repo")
self.assertEqual(data["root_target_sha"], self.head("main"))
def test_single_candidate_requires_decision_never_auto_join(self):
self.discover_single()
data = self.load()
self.assertEqual(data["final_verdict"], "DECISION_REQUIRED")
self.assertIsNone(data["decision"])
def test_multiple_candidates_stop_at_decision_required(self):
self.make_initiative("feature/alpha")
self.make_initiative("feature/beta")
self.open_pr(1, "feature/alpha")
self.open_pr(2, "feature/beta")
result = self.discover()
self.assertEqual(result.returncode, 5, result.out)
self.assertIn("[candidate] branch=feature/alpha", result.out)
self.assertIn("[candidate] branch=feature/beta", result.out)
data = self.load()
self.assertEqual([c["branch"] for c in data["candidates"]], ["feature/alpha", "feature/beta"])
self.assertEqual(data["final_verdict"], "DECISION_REQUIRED")
def test_pr_base_not_main_and_closed_pr_are_ignored(self):
self.make_initiative("feature/alpha")
self.make_initiative("feature/beta")
self.open_pr(1, "feature/alpha", base="release")
self.open_pr(2, "feature/beta", state="closed")
result = self.discover()
self.assertEqual(result.returncode, 0, result.out)
self.assertEqual(self.load()["final_verdict"], "NEW")
def test_card_filters_ignore_closed_intake_done_plain_and_other_root(self):
self.make_initiative("feature/active")
self.make_initiative("feature/closed-intake", accepting="false")
self.make_initiative("feature/done", status="已完成(回填)")
self.make_initiative("feature/plain", kind="standalone")
self.make_initiative("feature/other-root", root="release")
for number, branch in enumerate(("feature/active", "feature/closed-intake", "feature/done",
"feature/plain", "feature/other-root"), start=1):
self.open_pr(number, branch)
result = self.discover()
self.assertEqual(result.returncode, 5, result.out)
self.assertEqual([c["branch"] for c in self.load()["candidates"]], ["feature/active"])
def test_remote_discovery_paginates_until_empty(self):
for number in range(1, 51):
self.gitea.prs.append(FakeGitea.pr(number, f"feature/noise-{number}", "0" * 40, base="release"))
self.make_initiative("feature/late")
self.open_pr(51, "feature/late")
result = self.discover()
self.assertEqual(result.returncode, 5, result.out)
self.assertEqual([c["branch"] for c in self.load()["candidates"]], ["feature/late"])
def test_api_unreachable_yields_remote_unverified_without_receipt(self):
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
result = self.discover(env={"GITEA_API_URL": f"http://127.0.0.1:{port}/api/v1"})
self.assertEqual(result.returncode, 6, result.out)
self.assertIn("[verdict] REMOTE_UNVERIFIED", result.out)
self.assertFalse(self.receipt.exists())
def test_ls_remote_failure_yields_remote_unverified_without_receipt(self):
self.git(self.repo, "remote", "set-url", "origin", str(self.root / "missing.git"))
result = self.discover()
self.assertEqual(result.returncode, 6, result.out)
self.assertIn("[verdict] REMOTE_UNVERIFIED", result.out)
self.assertFalse(self.receipt.exists())
def test_token_from_secrets_file_is_used_and_never_printed(self):
home = self.root / "home"
(home / ".secrets").mkdir(parents=True)
(home / ".secrets" / "gitea").write_text(
"URL_PREFIX=https://example.invalid\n- ACCESS_TOKEN=file-token-7f3a\nACCESS_TOKEN=second-token\n")
self.gitea.token = "file-token-7f3a"
self.make_initiative("feature/alpha")
self.open_pr(1, "feature/alpha")
result = self.discover(env={"HOME": str(home), "GITEA_TOKEN": ""})
self.assertEqual(result.returncode, 5, result.out)
self.assertNotIn("file-token-7f3a", result.out)
self.assertNotIn("file-token-7f3a", self.receipt.read_text(encoding="utf-8"))
self.gitea.token = "other-token"
rejected = self.discover(env={"HOME": str(home), "GITEA_TOKEN": ""}, receipt=self.root / "other.json")
self.assertEqual(rejected.returncode, 6, rejected.out)
self.assertIn("[verdict] REMOTE_UNVERIFIED", rejected.out)
self.assertNotIn("file-token-7f3a", rejected.out)
def test_repo_slug_is_derived_from_origin_when_gitea_repo_unset(self):
owner_dir = self.root / "fixture-owner"
owner_dir.mkdir()
named = owner_dir / "fixture-repo.git"
self.git(self.root, "init", "--bare", "--initial-branch=main", str(named))
self.git(self.repo, "remote", "set-url", "origin", str(named))
self.git(self.repo, "push", "-u", "origin", "main")
result = self.discover(env={"GITEA_REPO": ""})
self.assertEqual(result.returncode, 0, result.out)
self.assertEqual(self.load()["repo"]["repo_slug"], "fixture-owner/fixture-repo")
def test_discover_requires_requirement_text(self):
result = self.discover(requirement=" ")
self.assertEqual(result.returncode, 2, result.out)
# ---- 决策 ----
def test_join_decision_succeeds_and_verifies_child(self):
head = self.make_initiative("feature/alpha")
self.open_pr(1, "feature/alpha")
self.discover()
decided = self.decide("JOIN", "加入登录主线", initiative="feature/alpha")
self.assertEqual(decided.returncode, 0, decided.out)
data = self.load()
self.assertEqual(data["final_verdict"], "JOIN")
self.assertEqual(data["decision"]["selected_initiative"], "feature/alpha")
verified = self.verify("child", initiative="feature/alpha")
self.assertEqual(verified.returncode, 0, verified.out)
self.assertIn("[triage-verified] verdict=JOIN selected_initiative=feature/alpha "
"requirement_sha256=", verified.out)
self.assertIn(f"[triage-head] selected_head_sha={head}", verified.out)
def test_new_decision_for_single_non_fitting_candidate_requires_reason(self):
self.discover_single()
before = self.receipt.read_bytes()
no_reason = self.decide("NEW", "")
self.assertEqual(no_reason.returncode, 7, no_reason.out)
self.assertEqual(self.receipt.read_bytes(), before)
decided = self.decide("NEW", "不适配:独立的后台巡检任务")
self.assertEqual(decided.returncode, 0, decided.out)
self.assertEqual(self.load()["final_verdict"], "NEW")
self.assertEqual(self.verify("standalone").returncode, 0)
def test_decide_rejects_unknown_initiative_or_missing_reason_without_changing_receipt(self):
self.make_initiative("feature/alpha")
self.open_pr(1, "feature/alpha")
self.discover()
before = self.receipt.read_bytes()
unknown = self.decide("JOIN", "理由", initiative="feature/beta")
self.assertEqual(unknown.returncode, 7, unknown.out)
no_reason = self.decide("JOIN", "", initiative="feature/alpha")
self.assertEqual(no_reason.returncode, 7, no_reason.out)
self.assertEqual(self.receipt.read_bytes(), before)
def test_decide_rejects_candidate_head_change_and_keeps_receipt(self):
self.discover_single()
before = self.receipt.read_bytes()
self.advance_initiative("feature/alpha")
result = self.decide("JOIN", "加入", initiative="feature/alpha")
self.assertEqual(result.returncode, 7, result.out)
self.assertIn("candidate set changed", result.out)
self.assertEqual(self.receipt.read_bytes(), before)
# ---- 核验 ----
def test_verify_rejects_stale_receipt(self):
self.assertEqual(self.discover().returncode, 0)
self.assertEqual(self.verify("standalone").returncode, 0)
data = self.load()
data["discovered_at"] = "2001-01-01T00:00:00Z"
self.save(data)
result = self.verify("standalone")
self.assertEqual(result.returncode, 8, result.out)
self.assertIn("stale", result.out)
def test_verify_rejects_changed_candidate_head(self):
self.discover_single()
self.decide("JOIN", "加入", initiative="feature/alpha")
self.advance_initiative("feature/alpha")
result = self.verify("child", initiative="feature/alpha")
self.assertEqual(result.returncode, 8, result.out)
self.assertIn("candidates changed", result.out)
def test_verify_rejects_changed_card_digest(self):
self.discover_single()
self.decide("JOIN", "加入", initiative="feature/alpha")
data = self.load()
data["candidates"][0]["card_sha256"] = "0" * 64
self.save(data)
result = self.verify("child", initiative="feature/alpha")
self.assertEqual(result.returncode, 8, result.out)
def test_verify_rejects_verdict_and_kind_mismatch(self):
self.discover_single()
self.decide("JOIN", "加入", initiative="feature/alpha")
standalone = self.verify("standalone")
self.assertEqual(standalone.returncode, 8, standalone.out)
self.decide("NEW", "不适配")
child = self.verify("child", initiative="feature/alpha")
self.assertEqual(child.returncode, 8, child.out)
def test_verify_rejects_child_with_wrong_initiative(self):
self.make_initiative("feature/alpha")
self.make_initiative("feature/beta")
self.open_pr(1, "feature/alpha")
self.open_pr(2, "feature/beta")
self.discover()
self.decide("JOIN", "加入", initiative="feature/alpha")
result = self.verify("child", initiative="feature/beta")
self.assertEqual(result.returncode, 8, result.out)
def test_verify_rejects_root_target_mismatch(self):
self.discover()
result = self.verify("standalone", env={"TASK_TARGET_BRANCH": "release"})
self.assertEqual(result.returncode, 8, result.out)
self.assertIn("root target", result.out)
def test_verify_rejects_wrong_schema_and_missing_receipt(self):
self.discover()
data = self.load()
data["schema"] = "agent-system/other/v9"
self.save(data)
self.assertEqual(self.verify("standalone").returncode, 8)
self.receipt.unlink()
missing = self.verify("standalone")
self.assertEqual(missing.returncode, 8, missing.out)
class RedactionTests(unittest.TestCase):
def test_userinfo_is_stripped_from_urls_and_scp_forms(self):
self.assertEqual(task_triage.redact_url("https://user:secret@git.example:30319/o/r.git"),
"https://git.example:30319/o/r.git")
self.assertEqual(task_triage.redact_url("git@git-ssh.example:weichen/Repo.git"),
"git-ssh.example:weichen/Repo.git")
self.assertEqual(task_triage.redact_url("/tmp/plain/remote.git"), "/tmp/plain/remote.git")
if __name__ == "__main__":
unittest.main()