Enforce immediate targets, accept the integration block, and pin draft.12 #596
File diff suppressed because one or more lines are too long
+12
-1
@@ -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
|
||||
```
|
||||
|
||||
@@ -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),核对采纳状态、协调者、
|
||||
记录及当前窗口。新模式尚未启用时保留既有同步节点;启用后最终同步至合入核验共用窗口,
|
||||
|
||||
Vendored
+1
-1
Submodule deps/development-guidelines updated: 2f5b19872b...2688fd2be6
+3
-1
@@ -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
@@ -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
@@ -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` 保存输出,清理结果由后续闭合记录引用。
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user