Merge pull request 'Enforce immediate targets, accept the integration block, and pin draft.12' (#596) from feature/development-governance-v2-target-integrity into feature/development-governance-v2
ci / svc-realcombo (pull_request) Successful in 1m59s
ci / test (pull_request) Successful in 7m18s
ci / test-pg (pull_request) Successful in 7m56s

This commit was merged in pull request #596.
This commit is contained in:
2026-10-17 02:59:38 +00:00
14 changed files with 923 additions and 53 deletions
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 the host to write the unit. The legacy value `all-unavailable` remains
parseable and likewise grants no host write authority. Cursor uses parseable and likewise grants no host write authority. Cursor uses
`cursor-agent-stable`. `model: auto` lets each CLI use its configured default `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 ```agent-execution
schema: agent-execution/v1 schema: agent-execution/v1
@@ -66,4 +68,13 @@ profiles:
executable: codex executable: codex
model: auto model: auto
enabled: true 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
``` ```
+3 -3
View File
@@ -88,12 +88,12 @@ policy/delivery.md。
## 任务启动门禁(强制) ## 任务启动门禁(强制)
- **新需求分流检查**:每次接到新需求,在创建分支前先运行 `make task-triage` 检查目标仓库中是否有接纳中的大任务集成分支(`branch_kind=initiative` 且 `accepting_requirements=true`)。三态分流:`JOIN`(建立对应大任务的子任务 `make task-start KIND=child INITIATIVE=feature/<initiative> NAME=feature/<initiative>-<slug>`)、`NEW`(建立普通任务或新的大任务 `make task-start [KIND=initiative] NAME=feature/<slug>`)、`DECISION_REQUIRED`(存在多个候选或改变范围/优先级,由人工决策)。 - **新需求分流检查**:每次接到新需求,在创建分支前以远端为准运行 `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` 运行一次启动检查并保存 `RECEIPT=/tmp/<本次唯一名称>.json CMD="make precheck" make evidence` 运行一次启动检查并保存
完整脱敏收据(不要求已有任务卡);按输出完成必读。`SHARED_WORKSPACE_BUSY / SYNC_REQUIRED / 完整脱敏收据(不要求已有任务卡);按输出完成必读。`SHARED_WORKSPACE_BUSY / SYNC_REQUIRED /
REMOTE_UNVERIFIED` 均不得进入写入流程;失败后排除原因再启动,不把缓存远端当最新事实。 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 用 建立独立 worktree 与任务卡并导入首次收据。恢复已有登记任务在原 worktree 用
`make task-resume PRECHECK_RECEIPT=/tmp/<本次唯一名称>.json` 验证并原样导入收据。 `make task-resume PRECHECK_RECEIPT=/tmp/<本次唯一名称>.json` 验证并原样导入收据。
默认 delivery 要求严格 READY;局部恢复接口、启用条件与旧收据处理见 [开发门禁](policy/governance.md#启动与恢复)。任务中途不重复启动检查。 默认 delivery 要求严格 READY;局部恢复接口、启用条件与旧收据处理见 [开发门禁](policy/governance.md#启动与恢复)。任务中途不重复启动检查。
@@ -103,7 +103,7 @@ policy/delivery.md。
任务卡记录主干 R0、父分支及其 SHA、父 PR;子分支命名 `<type>/<父slug>-<子题>`, 任务卡记录主干 R0、父分支及其 SHA、父 PR;子分支命名 `<type>/<父slug>-<子题>`,
不嵌 SHA;合入顺序固定父→子;父合入后子分支须 `make task-sync` 并入最新 main 并把 PR 不嵌 SHA;合入顺序固定父→子;父合入后子分支须 `make task-sync` 并入最新 main 并把 PR
base 改回 main,方可声明可合入(分支保护要求 head 含 origin/main,未同步必被拦)。 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` 写入任务卡(见「证据与验证门禁」),不手抄。 测试三段输出由 `make evidence` 写入任务卡(见「证据与验证门禁」),不手抄。
- **多个分支交付 / CI 调度**:先读 [合入调度](policy/merge-queue.md),核对采纳状态、协调者、 - **多个分支交付 / CI 调度**:先读 [合入调度](policy/merge-queue.md),核对采纳状态、协调者、
记录及当前窗口。新模式尚未启用时保留既有同步节点;启用后最终同步至合入核验共用窗口, 记录及当前窗口。新模式尚未启用时保留既有同步节点;启用后最终同步至合入核验共用窗口,
+3 -1
View File
@@ -6,7 +6,8 @@
- 过程文档位置:modules/documents(docs/records/) - 过程文档位置:modules/documents(docs/records/)
- 版本权威:父仓 gitlink - 版本权威:父仓 gitlink
- 批准/生效依据:Dev PR#1(19/19 正负例 + make test);guidelines PR#1 - 批准/生效依据: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 记录为准。 - 过程计划固定:Documents PR #96 实际合入 `129cc3579d9c9e3edfb46c99270d65f3e1dad0a0`,包含既有主干文档及获批 U1–U6 计划;当前进展以任务/PR 记录为准。
| 规范/工作流 | 适用条件与范围 | 执行方式/证据 | | 规范/工作流 | 适用条件与范围 | 执行方式/证据 |
@@ -24,6 +25,7 @@
| 同上(zcode 实例) | zcode 客户端写路径 | policy/tool-adaptation-zcode.md:PreToolUse 写守卫;脚本负例与客户端接线分开,客户端 pending-live | | 同上(zcode 实例) | zcode 客户端写路径 | policy/tool-adaptation-zcode.md:PreToolUse 写守卫;脚本负例与客户端接线分开,客户端 pending-live |
| core/baseline.md「规范层级」+ documentation.md「记忆分层/机制化」(draft.9) | 层级效力、记忆权威、经验机制化 | manual:本仓 policy/ 仅收紧不放宽;记忆四层按条文执行(任务卡=任务记忆,documents/records=项目记忆,Dev 仓 AGENTS.md 指针已就位) | | 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.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),不由本行推断 |
## 项目差异 ## 项目差异
+12 -7
View File
@@ -154,17 +154,22 @@ host 可以直接编写并提交的豁免只有 `.task/**`、`docs/superpowers/s
本仓采用两级业务交付模型: 本仓采用两级业务交付模型:
- **`main`**:稳定基线,仅接纳已完成并通过最终组合准入的结果。 - **`main`**:稳定基线,仅接纳已完成并通过最终组合准入的结果。
- **大任务集成分支(initiative)**:从最新 `origin/main` 建立,根目标与即时目标均为 `main`。负责跨单元目标的阶段集成、组合验证与最终交付。合入主干后关闭、归档与回收,不作为长期存在的 develop 分支。 - **大任务集成分支(initiative)**:从最新 `origin/main` 建立,根目标与即时目标均为 `main`。负责跨单元目标的阶段集成、组合验证与最终交付。合入主干后关闭、归档与回收,不作为长期存在的 develop 分支。
- **子任务分支(child)**:从所属大任务分支固定 HEAD 建立,即时目标为大任务分支。负责一个有界单元的实施、独立 Review 与局部验证,通过 PR 合入大任务分支。 - **子任务分支(child)**:从远端所属大任务分支的当前头建立(须等于分流收据核验的 head,`make task-start` 校验),即时目标为大任务分支。负责一个有界单元的实施、独立 Review 与局部验证,通过 PR 合入大任务分支。
- **普通任务分支(standalone)**:无需阶段集成的小需求或独立修复,直接从最新 `origin/main` 建立并向 `main` 交付。 - **普通任务分支(standalone)**:无需阶段集成的小需求或独立修复,直接从最新 `origin/main` 建立并向 `main` 交付。
每次接到新需求,创建分支前必须执行分流检查(`make task-triage`),扫描仓库中进行中且允许接纳需求(`accepting_requirements=true`)的大任务分支: 每个任务卡记录 `root_target` 与 `immediate_target`(子任务为所属大任务分支,其余为 `main`)。`make task-resume`、`make task-sync` 与 `make task-finish` 以 `immediate_target` 为准;`make task-close` 仅在 `FOLD=--fold` 时以其为闭合 PR base。
- `JOIN`:加入唯一匹配的大任务,从大任务分支创建子任务分支交付;
- `NEW`:无接纳中的大任务,建立普通任务或新的大任务; 每次接到新需求,创建分支前必须分流,事实来源为 origin(本地 `.task/` 不参与发现):
- `DECISION_REQUIRED`:存在多个候选大任务或加入将改变交付边界/优先级,交由人工决策。 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` 合并大任务分支最新状态,解决文本与语义冲突,完成直接相关测试与独立 Review 后通过 PR 合入大任务分支; 1. **子任务合入大任务**:子任务完成时,通过 `make task-sync` 合并大任务分支的远端最新状态(delivery 只认远端头),解决文本与语义冲突,完成直接相关测试与独立 Review 后,以大任务分支为 base 提 PR,并通过 `make task-pr-check` 与 CI `PR base gate`;
2. **大任务合入主干**:所有必需子任务合入后,关闭需求接纳(`accepting_requirements=false`),同步最新 `origin/main`,执行完整组合验证、独立 Review 与必需 CI,然后合入 `main`。 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 ```bash
make task-triage REQ='<需求>' TRIAGE_RECEIPT=/tmp/my-task-triage.json
RECEIPT=/tmp/my-task-precheck.json CMD="make precheck" make evidence 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 操作、组件漂移和未知远端仍拒绝。 恢复核验实际 worktree/卡片/分支、R0 祖先关系及源码外原始收据;脏现场、Git 操作、组件漂移和未知远端仍拒绝。
旧收据允许作为历史证据原样导入,不自动成为当前恢复或交付准入;格式继续为 schema 1。 旧收据允许作为历史证据原样导入,不自动成为当前恢复或交付准入;格式继续为 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。 卡片同时记录父分支、父 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 物化未修改组件,并核对实际组件工作树。 `make task-sync` 在父仓合并后按固定 gitlink 物化未修改组件,并核对实际组件工作树。
同步目标取卡片 `immediate_target`:子任务为远端所属大任务分支头,其余为 `origin/main`。
组件 feature 分支若与新指针不同,先保留其分支,交给 owner 显式同步并更新组合; 组件 feature 分支若与新指针不同,先保留其分支,交给 owner 显式同步并更新组合;
不能用自动 checkout 丢弃组件分支现场。任何失败都保留现场并阻止同步成功声明。 不能用自动 checkout 丢弃组件分支现场。任何失败都保留现场并阻止同步成功声明。
@@ -121,14 +131,14 @@ RECEIPT=/tmp/my-attempt-worktree-check.json CMD="make test-worktree" make eviden
子模块另有登记 worktree 时拒绝回收,避免删除其他检出依赖的 Git 管理目录。 子模块另有登记 worktree 时拒绝回收,避免删除其他检出依赖的 Git 管理目录。
可再生缓存由 owner 按已识别路径单独处置;无法确认用途的文件保留。 可再生缓存由 owner 按已识别路径单独处置;无法确认用途的文件保留。
任务合入需以本次成功获取的权威目标验证。合入提交不能证明未提交文件已经落盘。 任务合入需以本次成功获取的权威目标验证:子任务为其所属大任务分支的远端头,其余为 `origin/main`(即 `immediate_target`)。合入提交不能证明未提交文件已经落盘。
Git 对含子模块的工作区需要显式删除选项时,只有全部现场检查通过才允许该路径; Git 对含子模块的工作区需要显式删除选项时,只有全部现场检查通过才允许该路径;
它不豁免任何脏状态、忽略文件、独有提交/引用或锁检查。普通删除失败不转入强删兜底。 它不豁免任何脏状态、忽略文件、独有提交/引用或锁检查。普通删除失败不转入强删兜底。
保留无法确认安全的目录和分支,输出下一步处理依据。 保留无法确认安全的目录和分支,输出下一步处理依据。
子仓 pin 之外的 refs/stash/reflog 历史采取保守拒绝,即使其中部分可能已在远端: 子仓 pin 之外的 refs/stash/reflog 历史采取保守拒绝,即使其中部分可能已在远端:
owner 须先独立确认其保存与归属,工具不把缓存的远端引用当作唯一数据已经备份的证据。 owner 须先独立确认其保存与归属,工具不把缓存的远端引用当作唯一数据已经备份的证据。
远端分支删除与本地回收分别报告,失败不能声明两者全部完成。 远端分支删除与本地回收分别报告,失败不能声明两者全部完成。
回收后由所有者执行 `make task-close` 闭合卡片;回收命令不顺带改写共享主检出。 回收后由所有者执行 `make task-close` 闭合卡片;回收命令不顺带改写共享主检出。闭合 PR 默认 base 为 `main`;仅 `FOLD=--fold`(随任务分支合入)时 base 为卡片 `immediate_target`。
post-merge 仅提示,不自动调用闭合。`make task-close` 会提交、推送并可能创建 PR, post-merge 仅提示,不自动调用闭合。`make task-close` 会提交、推送并可能创建 PR,
执行者先按迭代交付规则核对该动作的授权及所选任务,保留未授权的其他任务现场。 执行者先按迭代交付规则核对该动作的授权及所选任务,保留未授权的其他任务现场。
回收命令使用待删目录之外的 `RECEIPT` 保存输出,清理结果由后续闭合记录引用。 回收命令使用待删目录之外的 `RECEIPT` 保存输出,清理结果由后续闭合记录引用。
+82 -2
View File
@@ -5,7 +5,7 @@ import ast
import re import re
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path, PurePosixPath
from typing import Any from typing import Any
SCHEMA_V1 = "agent-execution/v1" 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,})$" 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( EXECUTION_KEYS = frozenset(
{ {
"fresh_process_per_unit", "fresh_process_per_unit",
@@ -116,6 +126,14 @@ class CustomAdapter:
output_format: str output_format: str
@dataclass(frozen=True)
class IntegrationConfig:
exempt_paths: tuple[str, ...]
transport_command: str
admission_command: str
cleanup_command: str
@dataclass(frozen=True) @dataclass(frozen=True)
class ExecutionConfig: class ExecutionConfig:
schema: str schema: str
@@ -123,6 +141,7 @@ class ExecutionConfig:
roles: Mapping[str, tuple[str, ...]] roles: Mapping[str, tuple[str, ...]]
profiles: Mapping[str, Profile] profiles: Mapping[str, Profile]
custom_adapters: Mapping[str, CustomAdapter] custom_adapters: Mapping[str, CustomAdapter]
integration: IntegrationConfig | None = None
def load_config(repo: Path) -> ExecutionConfig: 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_adapters(profiles, roles)
_validate_normal_role_exactness(profiles, roles) _validate_normal_role_exactness(profiles, roles)
integration = None
if "integration" in raw:
integration = _build_integration(raw["integration"])
return ExecutionConfig( return ExecutionConfig(
schema=schema, schema=schema,
execution=execution, execution=execution,
roles=roles, roles=roles,
profiles=profiles, profiles=profiles,
custom_adapters=custom_adapters, custom_adapters=custom_adapters,
integration=integration,
) )
@@ -634,6 +658,62 @@ def _build_custom_adapters(raw: Any) -> dict[str, CustomAdapter]:
return out 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, ...]: def _argv_tuple(value: Any, label: str) -> tuple[str, ...]:
if not isinstance(value, list): if not isinstance(value, list):
raise ConfigError(f"{label} must be a 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): def check_receipt(value):
if not isinstance(value, dict) or type(value.get("schema")) is not int or value["schema"] != evidence.SCHEMA: if not isinstance(value, dict) or type(value.get("schema")) is not int or value["schema"] != evidence.SCHEMA:
raise ValueError("unsupported or malformed receipt") raise ValueError("unsupported or malformed receipt")
expected = {"schema", "source", "timestamp", "command", "command_sha256", "cwd", "common_dir", if not evidence.receipt_keys_ok(value):
"before", "after", "after_error", "exit_code", "output", "output_sha256", "receipt_sha256"}
if set(value) not in (expected, expected | {"capture_note"}):
raise ValueError("invalid receipt fields") 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"): for key in ("source", "timestamp", "command", "cwd", "common_dir", "output"):
if not isinstance(value[key], str): if not isinstance(value[key], str):
raise ValueError("invalid receipt field: " + key) 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) 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") SHA256 = re.compile(r"[0-9a-f]{64}\Z")
GIT_SHA = re.compile(r"(?:[0-9a-f]{40}|[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): 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, 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.""" """Package an actual captured run; callers must not reconstruct missing output."""
root_target, immediate_target = targets or task_targets()
receipt = sanitize_tree({ receipt = sanitize_tree({
"schema": SCHEMA, "source": "SELF", "schema": SCHEMA, "source": "SELF",
"timestamp": timestamp or datetime.datetime.now(datetime.timezone.utc).isoformat(), "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(), "common_dir": git_common_dir or common_dir(),
"before": before, "after": after, "after_error": after_error, "before": before, "after": after, "after_error": after_error,
"exit_code": exit_code, "output": output, "exit_code": exit_code, "output": output,
"root_target": root_target, "immediate_target": immediate_target,
}) })
if capture_note is not None: if capture_note is not None:
receipt["capture_note"] = sanitize(capture_note) receipt["capture_note"] = sanitize(capture_note)
@@ -159,10 +197,11 @@ def read_receipt(filename):
value = json.loads(Path(filename).read_text()) value = json.loads(Path(filename).read_text())
if not isinstance(value, dict) or type(value.get("schema")) is not int or value["schema"] != SCHEMA: if not isinstance(value, dict) or type(value.get("schema")) is not int or value["schema"] != SCHEMA:
raise EvidenceError("Unsupported receipt schema") raise EvidenceError("Unsupported receipt schema")
expected = {"schema", "source", "timestamp", "command", "command_sha256", "cwd", "common_dir", if not receipt_keys_ok(value):
"before", "after", "after_error", "exit_code", "output", "output_sha256", "receipt_sha256"}
if set(value) not in (expected, expected | {"capture_note"}):
raise EvidenceError("Invalid receipt fields") 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", for key in ("source", "timestamp", "command", "command_sha256", "cwd", "common_dir",
"output", "output_sha256", "receipt_sha256"): "output", "output_sha256", "receipt_sha256"):
if not isinstance(value.get(key), str): if not isinstance(value.get(key), str):
@@ -350,6 +389,7 @@ def main():
raise EvidenceError("Capture requires an absolute, unused receipt path") raise EvidenceError("Capture requires an absolute, unused receipt path")
card = find_card() if path is None else None card = find_card() if path is None else None
before = snapshot() before = snapshot()
targets = task_targets()
source_cwd, source_common = str(Path.cwd().resolve()), common_dir() source_cwd, source_common = str(Path.cwd().resolve()), common_dir()
timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat() timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat()
child_env = os.environ.copy() child_env = os.environ.copy()
@@ -366,7 +406,8 @@ def main():
after_error = str(error) after_error = str(error)
receipt = create_receipt(command, result.stdout.decode("utf-8", "replace"), child_exit, receipt = create_receipt(command, result.stdout.decode("utf-8", "replace"), child_exit,
before, after, timestamp=timestamp, cwd=source_cwd, 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: if path is not None:
write_receipt(path, receipt) write_receipt(path, receipt)
report(receipt, "receipt=" + str(path)) report(receipt, "receipt=" + str(path))
+98 -25
View File
@@ -59,7 +59,7 @@ cmd_precheck() {
card_pending "${pc##*/}" && continue card_pending "${pc##*/}" && continue
# card_merge 兼容 merge 提交与 squash 落地(squash 走 Gitea API 兜底,无 token 时只认 merge 提交); # card_merge 兼容 merge 提交与 squash 落地(squash 走 Gitea API 兜底,无 token 时只认 merge 提交);
# 其内部以完整消费输出取首行,避免 grep -q 早退令 git log 吃 SIGPIPE(141 under pipefail)。 # 其内部以完整消费输出取首行,避免 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##*/}" open_cards="${open_cards} ${pc##*/}"
fi fi
done done
@@ -162,12 +162,13 @@ cmd_start() {
TRIAGE_RECEIPT=$(realpath -- "$TRIAGE_RECEIPT") TRIAGE_RECEIPT=$(realpath -- "$TRIAGE_RECEIPT")
local -a triage_args=(verify --receipt "$TRIAGE_RECEIPT" --kind "$kind") local -a triage_args=(verify --receipt "$TRIAGE_RECEIPT" --kind "$kind")
if [ "$kind" = child ]; then triage_args+=(--initiative "$initiative"); fi if [ "$kind" = child ]; then triage_args+=(--initiative "$initiative"); fi
local triage_out triage_line triage_verdict triage_init triage_rsha triage_digest triage_note 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 if ! triage_out=$(python3 "${SCRIPT_DIR}/task_triage.py" "${triage_args[@]}"); then
printf '%s\n' "$triage_out" >&2 printf '%s\n' "$triage_out" >&2
die "triage receipt rejected; no worktree or branch was created" die "triage receipt rejected; no worktree or branch was created"
fi fi
triage_line=$(printf '%s\n' "$triage_out" | sed -n 's/^\[triage-verified\] //p') 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_verdict=$(triage_field verdict "$triage_line")
triage_init=$(triage_field selected_initiative "$triage_line") triage_init=$(triage_field selected_initiative "$triage_line")
triage_rsha=$(triage_field requirement_sha256 "$triage_line") triage_rsha=$(triage_field requirement_sha256 "$triage_line")
@@ -187,7 +188,7 @@ cmd_start() {
[ "$(git status --porcelain | { grep -v '^.. \.zcode/' || true; } | wc -l)" -eq 0 ] || die "dirty tree — resolve first (precheck verdict BUSY)" [ "$(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 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" 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}") rmain=$(git rev-parse "origin/${TARGET_BRANCH}")
start_ref=$rmain start_ref=$rmain
slug=$(echo "$name" | tr '/' '-') slug=$(echo "$name" | tr '/' '-')
@@ -196,9 +197,18 @@ cmd_start() {
initiative) initiative)
branch_meta="- 分支类型:initiative;initiative_id:${slug};root_target:${TARGET_BRANCH};immediate_target:${TARGET_BRANCH};accepting_requirements:true" ;; branch_meta="- 分支类型:initiative;initiative_id:${slug};root_target:${TARGET_BRANCH};immediate_target:${TARGET_BRANCH};accepting_requirements:true" ;;
child) child)
parent_head=$(git rev-parse --verify "refs/heads/${initiative}^{commit}" 2>/dev/null) || die "parent initiative branch ${initiative} missing locally" 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 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:-无}" ;; 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" ;; branch_meta="- 分支类型:standalone;root_target:${TARGET_BRANCH};immediate_target:${TARGET_BRANCH};accepting_requirements:false" ;;
esac esac
@@ -363,8 +373,22 @@ cmd_resume() {
local receipt local receipt
receipt=$(realpath -- "$PRECHECK_RECEIPT") receipt=$(realpath -- "$PRECHECK_RECEIPT")
VALIDATE_RECEIPT="$receipt" VALIDATE_RECEIPT_PURPOSE="resume-${mode}" bash "${SCRIPT_DIR}/evidence.sh" 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" 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() { cmd_sync() {
@@ -462,12 +486,35 @@ active_initiatives() { # → 每行 "branch|initiative_id|card":根目标为
case "$(card_status "$pc")" in 已完成*) continue ;; esac case "$(card_status "$pc")" in 已完成*) continue ;; esac
pb=$(card_of_branch "$pc"); [ -n "$pb" ] || continue pb=$(card_of_branch "$pc"); [ -n "$pb" ] || continue
card_pending "${pc##*/}" && continue card_pending "${pc##*/}" && continue
card_merge "$pb" >/dev/null && continue card_merged_into "$pc" "$pb" >/dev/null && continue
printf '%s|%s|%s\n' "$pb" "$(card_initiative_id "$pc")" "$pc" printf '%s|%s|%s\n' "$pb" "$(card_initiative_id "$pc")" "$pc"
done done
} }
resolve_parent_ref() { # $1=父 initiative 分支 → 打印用于同步/核验的提交(远端与本地取包含对方的一侧) remote_parent_head() { # $1=父 initiative 分支 → 打印刚从 origin 取回的头(不看本地);不可查询返回 6,远端缺失返回 1
git check-ref-format "refs/heads/$1" >/dev/null || die "invalid parent branch $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 local remote="" local_ref="" rc=0
git ls-remote --exit-code --heads origin "refs/heads/$1" >/dev/null 2>&1 || rc=$? git ls-remote --exit-code --heads origin "refs/heads/$1" >/dev/null 2>&1 || rc=$?
if [ "$rc" -eq 0 ]; then if [ "$rc" -eq 0 ]; then
@@ -515,17 +562,37 @@ triage_field() { printf '%s\n' "$2" | tr ' ' '\n' | sed -n "s/^$1=//p"; }
card_merge() { # $1=branch → "sha|subject|pr"(该分支合入 origin/<target> 的记录;兼容 merge 提交与 squash 落地) card_merge() { # $1=branch → "sha|subject|pr"(该分支合入 origin/<target> 的记录;兼容 merge 提交与 squash 落地)
local hit pr local hit pr
hit=$(git log --merges --format='%H|%s' "origin/${TARGET_BRANCH}" 2>/dev/null \ hit=$(git log --merges --format='%H|%s' "origin/$2" 2>/dev/null \
| grep -F " from $1 into ${TARGET_BRANCH}" | sed -n '1p') || hit="" | grep -F " from $1 into $3" | sed -n '1p') || hit=""
if [ -n "$hit" ]; then if [ -n "$hit" ]; then
pr=$(printf '%s' "$hit" | sed -n 's/.*(#\([0-9]\+\)) from.*/\1/p') pr=$(printf '%s' "$hit" | sed -n 's/.*(#\([0-9]\+\)) from.*/\1/p')
printf '%s|%s\n' "$hit" "${pr:-unknown}" printf '%s|%s\n' "$hit" "${pr:-unknown}"
return 0 return 0
fi 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> 祖先,否则不认(不可核即不闭合)。 # 返回 "merge_commit_sha|PR标题|PR号";sha 必须可核为 origin/<target> 祖先,否则不认(不可核即不闭合)。
# 仅查最近 5 页(250 条):card_merge 的 merge 提交路径仍是主路径,此兜底面向新近任务卡。 # 仅查最近 5 页(250 条):card_merge 的 merge 提交路径仍是主路径,此兜底面向新近任务卡。
local token="${GITEA_TOKEN:-}" web_base url repo owner api payload pg found sha local token="${GITEA_TOKEN:-}" web_base url repo owner api payload pg found sha
@@ -566,7 +633,7 @@ for p in entries if isinstance(entries, list) else []:
done done
[ -n "$found" ] || return 1 [ -n "$found" ] || return 1
sha="${found%%|*}" 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" printf '%s\n' "$found"
} }
card_pending() { # $1=卡片文件名——闭合是否已在某 origin/feature/card-close-* 分支待合入 card_pending() { # $1=卡片文件名——闭合是否已在某 origin/feature/card-close-* 分支待合入
@@ -576,10 +643,10 @@ card_pending() { # $1=卡片文件名——闭合是否已在某 origin/feature/
done done
return 1 return 1
} }
card_backfill() { # $1=card $2=pr_num $3=merge_sha $4=merge_subject $5=web_base(原地改写) 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" <<'PY' python3 - "$1" "$2" "$3" "$4" "$5" "$6" <<'PY'
import re, sys 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 = open(path, encoding='utf-8').read()
s = re.sub(r'状态:[^;\n]*', '状态:已完成(回填)', s, count=1) s = re.sub(r'状态:[^;\n]*', '状态:已完成(回填)', s, count=1)
m = re.search(r'任务主记录:([^;\n]*)', s) m = re.search(r'任务主记录:([^;\n]*)', s)
@@ -590,7 +657,7 @@ s = s.rstrip('\n') + f'''
## 闭合记录(card-autoclose 回填) ## 闭合记录(card-autoclose 回填)
- 依据:merge {sha}(PR #{pr})已包含于 origin/main,分支成果已落盘;本段为合入后回填。 - 依据:merge {sha}(PR #{pr})已包含于 origin/{target},分支成果已落盘;本段为合入后回填。
- 合并主题:{subject} - 合并主题:{subject}
- 归档:随闭合移入 .task/archive/;任务执行期的过程证据见原任务记录与 PR。 - 归档:随闭合移入 .task/archive/;任务执行期的过程证据见原任务记录与 PR。
''' '''
@@ -609,8 +676,13 @@ cmd_close() {
local url owner repo local url owner repo
url=$(git remote get-url origin); url="${url%.git}" url=$(git remote get-url origin); url="${url%.git}"
repo=$(basename "$url"); owner=$(basename "$(dirname "$url")") 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) 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 for c in .task/*.md; do
[ -f "$c" ] || continue [ -f "$c" ] || continue
b=$(card_of_branch "$c"); [ -n "$b" ] || continue b=$(card_of_branch "$c"); [ -n "$b" ] || continue
@@ -621,7 +693,7 @@ cmd_close() {
echo "[close] ${c##*/} 已有待合入的闭合分支,跳过(等其 PR 合入)" echo "[close] ${c##*/} 已有待合入的闭合分支,跳过(等其 PR 合入)"
continue continue
fi fi
m=$(card_merge "$b") || m="" m=$(card_merged_into "$c" "$b") || m=""
if [ -z "$m" ]; then if [ -z "$m" ]; then
[ "$done_unarchived" = 1 ] || continue [ "$done_unarchived" = 1 ] || continue
m="ARCHIVE" m="ARCHIVE"
@@ -640,9 +712,10 @@ $c|$b|$m"
elif [ "$fold" != "1" ]; then elif [ "$fold" != "1" ]; then
die "close 在任务分支内须加 --fold(并入当前任务 PR);或到主检出运行(将建 feature/card-close-* 分支+PR)" die "close 在任务分支内须加 --fold(并入当前任务 PR);或到主检出运行(将建 feature/card-close-* 分支+PR)"
fi 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 while IFS='|' read -r c b m; do
[ -n "$c" ] || continue [ -n "$c" ] || continue
tgt="${m%%|*}"; m="${m#*|}"
if [ "$m" = "ARCHIVE" ]; then if [ "$m" = "ARCHIVE" ]; then
mkdir -p .task/archive mkdir -p .task/archive
git mv "$c" ".task/archive/${c##*/}" git mv "$c" ".task/archive/${c##*/}"
@@ -652,10 +725,10 @@ $c|$b|$m"
fi fi
sha="${m%%|*}"; rest="${m#*|}"; pr="${rest##*|}"; subject="${rest%|*}" sha="${m%%|*}"; rest="${m#*|}"; pr="${rest##*|}"; subject="${rest%|*}"
case "$pr" in "$rest"|''|*[!0-9]*) pr=unknown ;; esac 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 mkdir -p .task/archive
git mv "$c" ".task/archive/${c##*/}" 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)) n=$((n+1))
done <<EOF done <<EOF
$cards $cards
@@ -663,8 +736,8 @@ EOF
local msg; msg=$(mktemp) local msg; msg=$(mktemp)
{ echo "Close ${n} merged task card(s) and archive them" { echo "Close ${n} merged task card(s) and archive them"
echo echo
echo "Why: branches merged into ${TARGET_BRANCH} had left their task cards open; backfill status and archive to prevent bookkeeping debt." 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 origin/${TARGET_BRANCH}." 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 "Impact: .task/*.md moved to .task/archive/ with status backfilled; no code or submodule change."
echo "Refs: task.sh close (card-autoclose-r1)" echo "Refs: task.sh close (card-autoclose-r1)"
} > "$msg" } > "$msg"
@@ -682,7 +755,7 @@ EOF
for attempt in 1 2 3 4; do 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' \ resp=$(curl -sS -m 25 -X POST -H "Authorization: token ${GITEA_TOKEN}" -H 'Content-Type: application/json' \
"$api" \ "$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) 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 [ -n "$pr_url" ] && break
echo "[close] 建 PR 第 ${attempt} 次未成功${resp:+($(printf '%s' "$resp" | head -c 120))},退避重试" >&2 echo "[close] 建 PR 第 ${attempt} 次未成功${resp:+($(printf '%s' "$resp" | head -c 120))},退避重试" >&2
+111
View File
@@ -792,6 +792,117 @@ class AgentExecConfigTests(unittest.TestCase):
self.assertTrue(cfg.profiles["codex"].enabled) self.assertTrue(cfg.profiles["codex"].enabled)
self.assertEqual(cfg.profiles["codex"].adapter, "codex") 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: def _legacy_custom_block() -> str:
"""No emergency_profile and adapter custom on every role.""" """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) result = self.run_evidence(VALIDATE_RECEIPT=self.receipt)
self.assertNotEqual(result.returncode, 0) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+146 -1
View File
@@ -25,6 +25,7 @@ class FakeGitea:
def __init__(self, token="dummy"): def __init__(self, token="dummy"):
self.token = token self.token = token
self.prs = [] self.prs = []
self.posted = []
stub = self stub = self
class Handler(http.server.BaseHTTPRequestHandler): class Handler(http.server.BaseHTTPRequestHandler):
@@ -49,8 +50,16 @@ class FakeGitea:
start = (page - 1) * stub.PAGE start = (page - 1) * stub.PAGE
return self._send(200, stub.prs[start:start + 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.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" 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() threading.Thread(target=self.server.serve_forever, daemon=True).start()
def close(self): def close(self):
@@ -1260,6 +1269,7 @@ class TaskLifecycleTests(unittest.TestCase):
(parent / "parent-later").write_text("later initiative work\n") (parent / "parent-later").write_text("later initiative work\n")
self.git(parent, "add", "parent-later") self.git(parent, "add", "parent-later")
self.git(parent, "commit", "-m", "Later initiative work") self.git(parent, "commit", "-m", "Later initiative work")
self.publish_initiative("feature/alpha") # delivery 只认远端父分支头
parent_head = self.git(parent, "rev-parse", "HEAD").stdout.strip() parent_head = self.git(parent, "rev-parse", "HEAD").stdout.strip()
result = self.task(wt, "sync") result = self.task(wt, "sync")
self.assertIn("changed=1", result.stdout) self.assertIn("changed=1", result.stdout)
@@ -1283,6 +1293,7 @@ class TaskLifecycleTests(unittest.TestCase):
self.assertIn("has not merged into feature/alpha", refused.stdout) self.assertIn("has not merged into feature/alpha", refused.stdout)
self.assertTrue(wt.exists()) self.assertTrue(wt.exists())
self.git(parent, "merge", "--no-ff", "feature/alpha-login", "-m", "Merge child into initiative") 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") result = self.task(wt, "finish")
self.assertIn("[verified] feature/alpha-login is included in parent initiative feature/alpha", self.assertIn("[verified] feature/alpha-login is included in parent initiative feature/alpha",
result.stdout) result.stdout)
@@ -1345,6 +1356,140 @@ class TaskLifecycleTests(unittest.TestCase):
self.assertIn("remote initiative candidates changed", result.stdout) self.assertIn("remote initiative candidates changed", result.stdout)
self.assertFalse((self.root / "repo-wt-feature-alpha-login").exists()) 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__": if __name__ == "__main__":
unittest.main() unittest.main()