Enforce immediate targets, accept the integration block, and pin draft.12 #596

Merged
weichen merged 11 commits from feature/development-governance-v2-target-integrity into feature/development-governance-v2 2026-10-17 02:59:39 +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
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
```
+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` 运行一次启动检查并保存
完整脱敏收据(不要求已有任务卡);按输出完成必读。`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#启动与恢复)。任务中途不重复启动检查。
@@ -103,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),核对采纳状态、协调者、
记录及当前窗口。新模式尚未启用时保留既有同步节点;启用后最终同步至合入核验共用窗口,
+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),不由本行推断 |
## 项目差异
+12 -7
View File
@@ -154,17 +154,22 @@ host 可以直接编写并提交的豁免只有 `.task/**`、`docs/superpowers/s
本仓采用两级业务交付模型:
- **`main`**:稳定基线,仅接纳已完成并通过最终组合准入的结果。
- **大任务集成分支(initiative)**:从最新 `origin/main` 建立,根目标与即时目标均为 `main`。负责跨单元目标的阶段集成、组合验证与最终交付。合入主干后关闭、归档与回收,不作为长期存在的 develop 分支。
- **子任务分支(child)**:从所属大任务分支固定 HEAD 建立,即时目标为大任务分支。负责一个有界单元的实施、独立 Review 与局部验证,通过 PR 合入大任务分支。
- **子任务分支(child)**:从远端所属大任务分支的当前头建立(须等于分流收据核验的 head,`make task-start` 校验),即时目标为大任务分支。负责一个有界单元的实施、独立 Review 与局部验证,通过 PR 合入大任务分支。
- **普通任务分支(standalone)**:无需阶段集成的小需求或独立修复,直接从最新 `origin/main` 建立并向 `main` 交付。
每次接到新需求,创建分支前必须执行分流检查(`make task-triage`),扫描仓库中进行中且允许接纳需求(`accepting_requirements=true`)的大任务分支:
- `JOIN`:加入唯一匹配的大任务,从大任务分支创建子任务分支交付;
- `NEW`:无接纳中的大任务,建立普通任务或新的大任务;
- `DECISION_REQUIRED`:存在多个候选大任务或加入将改变交付边界/优先级,交由人工决策。
每个任务卡记录 `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` 合并大任务分支最新状态,解决文本与语义冲突,完成直接相关测试与独立 Review 后通过 PR 合入大任务分支;
2. **大任务合入主干**:所有必需子任务合入后,关闭需求接纳(`accepting_requirements=false`),同步最新 `origin/main`,执行完整组合验证、独立 Review 与必需 CI,然后合入 `main`。
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))
+98 -25
View File
@@ -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
@@ -162,12 +162,13 @@ cmd_start() {
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_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
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")
@@ -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 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
slug=$(echo "$name" | tr '/' '-')
@@ -196,9 +197,18 @@ cmd_start() {
initiative)
branch_meta="- 分支类型:initiative;initiative_id:${slug};root_target:${TARGET_BRANCH};immediate_target:${TARGET_BRANCH};accepting_requirements:true" ;;
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
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" ;;
esac
@@ -363,8 +373,22 @@ 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() {
@@ -462,12 +486,35 @@ active_initiatives() { # → 每行 "branch|initiative_id|card":根目标为
case "$(card_status "$pc")" in 已完成*) continue ;; esac
pb=$(card_of_branch "$pc"); [ -n "$pb" ] || 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"
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"
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
@@ -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 落地)
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
@@ -566,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-* 分支待合入
@@ -576,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)
@@ -590,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。
'''
@@ -609,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
@@ -621,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"
@@ -640,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##*/}"
@@ -652,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
@@ -663,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"
@@ -682,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
+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()
+146 -1
View File
@@ -25,6 +25,7 @@ class FakeGitea:
def __init__(self, token="dummy"):
self.token = token
self.prs = []
self.posted = []
stub = self
class Handler(http.server.BaseHTTPRequestHandler):
@@ -49,8 +50,16 @@ class FakeGitea:
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.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()
def close(self):
@@ -1260,6 +1269,7 @@ class TaskLifecycleTests(unittest.TestCase):
(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)
@@ -1283,6 +1293,7 @@ class TaskLifecycleTests(unittest.TestCase):
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)
@@ -1345,6 +1356,140 @@ class TaskLifecycleTests(unittest.TestCase):
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()