Merge pull request 'Deliver the layered delivery model and slim the governance spec' (#608) from feature/delivery-model-v3 into main
ci / svc-realcombo (push) Successful in 1m56s
ci / test (push) Successful in 7m13s
ci / test-pg (push) Successful in 7m41s

This commit was merged in pull request #608.
This commit is contained in:
2026-10-18 05:17:18 +00:00
28 changed files with 2258 additions and 330 deletions
+6
View File
@@ -186,6 +186,9 @@ jobs:
# 缺服务时失败而非跳过(make test-backend-pg 硬校验环境变量);
# 组件版本随 job 输出固定,供验收记录对齐(方案 KTD12/U2)。
test-pg:
# 分级门禁:base 非 main 的 PR(L2 child → L1 initiative)只跑轻量 CI(test 作业);
# 进入 main 的 PR 与 main 推送跑全量门禁。
if: ${{ github.event_name != 'pull_request' || github.base_ref == 'main' }}
runs-on: ubuntu-latest
env:
GOPROXY: https://goproxy.cn,direct
@@ -479,6 +482,9 @@ jobs:
# identity 领养/verify、release 切换、伴生工作台、RDM 全轨部署。缺件即失败,
# 不静默跳过(SVC_TEST_REAL=1 硬开关)。
svc-realcombo:
# 分级门禁:base 非 main 的 PR(L2 child → L1 initiative)只跑轻量 CI(test 作业);
# 进入 main 的 PR 与 main 推送跑全量门禁。
if: ${{ github.event_name != 'pull_request' || github.base_ref == 'main' }}
runs-on: ubuntu-latest
env:
GOPROXY: https://goproxy.cn,direct
File diff suppressed because one or more lines are too long
+45 -134
View File
@@ -2,164 +2,77 @@
本仓开发遵循 Development-Guidelines(见 `deps/development-guidelines`,gitlink 固定版本):
核心规则与术语见其 `core/`,按任务触发阅读 `standards/` 专题;通用交付依据为 `workflows/delivery.md`。
本项目的需求、设计试验、实施调整及交付**必读 [policy/delivery.md](policy/delivery.md)**:
目标、范围和交付终点一次确认;范围内的分析、方案、实施、修复、验证、代码 Review、
PR 合入及安全收口由智能体连续推进。只在目标/范围/验收条件变更、存在会影响用户结果的
重大取舍、触及高后果操作,或业务验收必须由用户判定时请求人工决策。智能体解决冲突并确认
当前 HEAD 的 CI 通过后执行合入,无需另请人工操作。具体决策点、版本及授权条件见
policy/delivery.md。
本项目的需求、设计试验、实施调整及交付**必读 [policy/delivery.md](policy/delivery.md)**(交付规则唯一权威):
目标、范围和交付终点一次确认;范围内的分析、方案、实施、修复、验证、代码 Review、PR 合入及安全收口由智能体连续推进。
只在目标/范围/验收条件变更、存在会影响用户结果的重大取舍、触及高后果操作,或业务验收必须由用户判定时请求人工决策。
智能体解决冲突并确认当前 HEAD 的 CI 通过后执行合入,无需另请人工操作。本文件是入口摘要,细则以所链文档为准。
## 立即生效的最高优先级(强制)
- 在既有授权与强制安全边界内,**先交付用户明确要求的产物**。不得用基础设施、工具治理、
通用化、加固、重构或“以后可能用到”替代当前目标;尤其禁止把简单 Markdown、配置或说明
修改擅自升级成平台、框架、自动化系统或大规模工程。
- 采用最小充分实现:先让目标可用、系统可用并通过最小直接验证,然后立即停止并交付;体验、
结构、性能与治理完善必须作为后续独立增量,不得拖延当前结果。
- 只修复直接阻塞目标、可用性、明确验收条件或强制安全门禁的问题。高价值非阻塞项只简记,
低影响噪音直接忽略;Review/迭代循环只围绕阻塞项,阻塞项闭合即结束。
- 测试与风险成比例:实施中只跑最小直接检查;完整/长耗时套件仅在最终候选、规范明确要求或
实际影响面确有必要时运行一次。禁止在每个小文档、配置或 adapter 修改后重复全量测试。
- 编排成本不得超过任务本身。实施、修复与测试按 AGENT-EXECUTION.md 的候选和尝试上限调度;
不限制单次 CLI 的推理时长、工具次数、规划方式或首次写文件时间。除非用户明确要求或已有证据
证明是当前目标的直接阻塞,否则不得新增嵌套控制器、额外 worktree、复杂收据体系或新架构。
执行路由是手段,不能成为推迟用户产物的理由。
- **先交付用户产物**:在既有授权与强制安全边界内,先交付用户明确要求的产物;不得用基础设施、工具治理、通用化、加固、重构或“以后可能用到”替代当前目标,禁止把简单 Markdown、配置或说明修改擅自升级成平台、框架、自动化系统或大规模工程。
- **最小充分实现**:目标可用、系统可用并通过最小直接验证后立即停止并交付;体验、结构、性能与治理完善作为后续独立增量,不得拖延当前结果。
- **只修阻塞项**:只修复直接阻塞目标、可用性、明确验收条件或强制安全门禁的问题;高价值非阻塞项只简记,低影响噪音直接忽略;Review/迭代只围绕阻塞项,阻塞项闭合即结束。
- **测试与风险成比例**:实施中只跑最小直接检查;完整/长耗时套件仅在最终候选、规范明确要求或影响面确有必要时运行一次,禁止每个小文档、配置或 adapter 修改后重复全量测试。
- **编排成本不得超过任务本身**:按 AGENT-EXECUTION.md 的候选和尝试上限调度,不限制单次 CLI 的推理时长、工具次数、规划方式或首次写文件时间;除非用户明确要求或有证据证明直接阻塞,不新增嵌套控制器、额外 worktree、复杂收据体系或新架构;执行路由不能成为推迟用户产物的理由。细则见 [最高优先级](policy/delivery.md#最高优先级目标兑现与最小充分交付)。
## 任务目标与范围(强制)
- 执行优先级固定为:授权与安全边界 → 本次任务目标和系统可用 → 阻塞目标的必要问题 →
非阻塞改进。完成标准是已批准验收条件成立且受影响系统保持可用,不是把所有附近问题一并清零。
- 绝对禁止私自扩大范围。影响目标的问题仅在既有授权内作最小充分修复;必要修复超出范围时,说明
阻塞、最小扩围和影响,取得授权后再做。“相关”、“顺手”或“更完美”不构成扩围授权。
- 新发现按对当前目标的影响分流:阻塞验收、导致不可用/明确回归、违反安全边界或必需门禁失败的问题必须
处理;其余不阻塞交付。只有具备明确影响和后续价值的非阻塞问题才记录,不为低影响噪音制造新任务。
- 先实现端到端可用和可验证的最小闭环,再将体验、结构、性能及治理优化作为独立增量迭代;非阻塞 Review
意见不得阻止当前目标交付。
- 优先级固定为:授权与安全边界 → 本次任务目标和系统可用 → 阻塞目标的必要问题 → 非阻塞改进。完成标准是已批准验收条件成立且受影响系统保持可用,不是清零所有附近问题。
- 绝对禁止私自扩大范围:影响目标的问题只在既有授权内作最小充分修复;必要修复超出范围时,说明阻塞、最小扩围和影响,取得授权后再做。“相关”“顺手”“更完美”不构成扩围授权。
- 新发现按影响分流:阻塞验收、导致不可用/明确回归、违反安全边界或必需门禁失败的必须处理;其余不阻塞交付,只有明确影响和后续价值的才记录。先实现端到端可用的最小闭环,非阻塞 Review 意见不阻止交付(见 [问题分流](policy/delivery.md#目标优先范围控制与问题分流))。
## 实施执行路由(强制)
- 实施、修复、测试与审阅调度**必读根目录 [AGENT-EXECUTION.md](AGENT-EXECUTION.md)**(唯一项目执行路由配置)。通用工程 Agent 契约(agent-per-unit 隔离执行、会话独立、JSON 终态收据、无 host 隐式代写、受控 transport/admission 准入)遵循 Development-Guidelines [core/baseline.md(硬边界 8)](deps/development-guidelines/core/baseline.md) 与 [standards/agent-execution.md](deps/development-guidelines/standards/agent-execution.md);项目根目录配置仅声明角色候选顺序与适配器参数。
- 产生仓库变更的实施按可独立实施的单元拆分;任务卡记 `实施执行:agent-per-unit(配置:AGENT-EXECUTION.md)`。
- 外部 Agent 在新进程/隔离工作区执行一个有界单元;host 负责计划、拆解、范围审阅、权威验证、规范提交、
PR/CI/合入、证据与任务元数据及安全清理。Worker 不得提交、推送、开或合 PR、部署、改路由或派遣同伴。
- 实施、修复与测试的正常候选只有 Cursor、Antigravity(`agy`)和 PI,顺序以配置为准。每个候选对
安全可恢复失败(`unavailable`,或外部无副作用已被机制证明的 `failed-clean`)最多 3 次全新尝试,然后才换下一候选。
单元包的 `network=false`/`external_cost=false` 只是声明,不是证明;没有强制隔离或可信对账时,
已启动的失败尝试即使私有克隆可精确恢复也记 `failed-unknown`。成功还须从该 CLI 实际事件信封解出
有效的 `completed` 终态记录(schema 写在 worker 提示词中);缺失或无效即 fail closed。
三者都安全耗尽后,才允许一次全新的 Codex CLI 紧急尝试。Codex 平时只用于规划、方案设计、实施计划
或独立 Review,不进入这三个正常角色。`fallback_to_host` 为 `never`。安全耗尽不发出
host-fallback-authorized,终态为 unavailable 或 blocked,不授权 host 撰写该单元。旧值
`all-unavailable` 仍可解析,同样不产生 host 撰写授权。
- 显式 fail-stop 立即停止,不重试、不换候选、不启动紧急尝试:`failed-unknown`、`scope-expansion`、
`blocked`、`cancelled`、无效配置、未对账 Git 状态与安全失败。总超时,以及 stdin/stdout/stderr
都没有活动的空闲超时,归入 `failed-unknown`。
- 一次尝试只在显式终态失败、进程退出、总超时或上述空闲超时时结束。CLI 可在目标、范围与安全边界内
自主检查、计划、测试并迭代。
- 实施变更只经成功 transport 由 `scripts/agent-exec apply` 集成,或由 pre-commit `admit` 按精确内容
做本地完整性核对。本地 HMAC 历史与该钩子只是本地完整性/篡改证据(SELF),不是 GATE、可信身份,
也不防同一主机 OS 用户;本地记录缺失或无效仍拒绝,交付信任只来自精确 HEAD 的 CI 或独立 REPLAY。
host 直接管理的豁免只有 `.task/**`、`docs/superpowers/specs/**` 与
`docs/superpowers/plans/**`。host 不直接撰写实施单元。细则见
[policy/delivery.md](policy/delivery.md) 与 [policy/governance.md](policy/governance.md)。
不削弱 goal-first、严格范围、先可用、独立 Review、证据、平台准入与高后果双钥匙规则。
- 调度**必读 [AGENT-EXECUTION.md](AGENT-EXECUTION.md)**(唯一项目执行路由配置);通用契约遵循 guidelines [core/baseline.md(硬边界 8)](deps/development-guidelines/core/baseline.md) 与 [standards/agent-execution.md](deps/development-guidelines/standards/agent-execution.md)。产生仓库变更的实施按可独立实施的单元拆分,任务卡记 `实施执行:agent-per-unit(配置:AGENT-EXECUTION.md)`。
- 外部 Agent 在新进程/隔离工作区执行一个有界单元;host 负责计划、拆解、范围审阅、权威验证、规范提交、PR/CI/合入、证据与任务元数据及安全清理。Worker 不得提交、推送、开或合 PR、部署、改路由或派遣同伴。
- 实施、修复与测试的正常候选只有 Cursor、Antigravity(`agy`)和 PI,顺序以配置为准;每个候选对安全可恢复失败(`unavailable`,或外部无副作用已被机制证明的 `failed-clean`)最多 3 次全新尝试后才换候选。`network=false`/`external_cost=false` 只是声明;没有强制隔离或可信对账时,已启动的失败尝试记 `failed-unknown`。成功须从该 CLI 实际事件信封解出有效的 `completed` 终态记录,缺失或无效即 fail closed。
- 三者都安全耗尽后才允许一次全新的 Codex CLI 紧急尝试;Codex 平时只用于规划、方案设计、实施计划或独立 Review。`fallback_to_host` 为 `never`(旧值 `all-unavailable` 同样不授权):安全耗尽不发出 host-fallback-authorized,终态为 unavailable 或 blocked,host 不撰写该单元。
- 显式 fail-stop 立即停止,不重试、不换候选、不启动紧急尝试:`failed-unknown`、`scope-expansion`、`blocked`、`cancelled`、无效配置、未对账 Git 状态与安全失败;总超时及 stdin/stdout/stderr 都无活动的空闲超时归入 `failed-unknown`。一次尝试只在显式终态失败、进程退出、总超时或空闲超时时结束,CLI 可在目标、范围与安全边界内自主检查、计划、测试并迭代。
- 实施变更只经成功 transport 由 `scripts/agent-exec apply` 集成,或由 pre-commit `admit` 按精确内容核对。本地 HMAC 历史与该钩子只是本地完整性/篡改证据(SELF),不是 GATE、可信身份,也不防同一主机 OS 用户;本地记录缺失或无效仍拒绝,交付信任只来自精确 HEAD 的 CI 或独立 REPLAY。host 直接管理的豁免只有 `.task/**`、`docs/superpowers/specs/**` 与 `docs/superpowers/plans/**`,host 不直接撰写实施单元。细则见 [policy/delivery.md](policy/delivery.md#agent-实施单元与主模型职责) 与 [policy/governance.md](policy/governance.md);不削弱 goal-first、严格范围、先可用、独立 Review、证据、平台准入与高后果双钥匙规则。
### 阶段与会话隔离(强制)
- 需求分析、方案、设计、计划、实施、测试、Review 与验收默认各启动一个全新会话;不同阶段、
不同实施单元不得复用同一 CLI 会话、conversation、thread 或 session。
- Review 必须由当前主模型执行,或交给**全新且独立**的 CLI session。绝对禁止实施者使用原 session
通过 `continue`、`resume`、conversation ID、session ID/路径或等价机制继续充当评审者。
- 独立评审只接收固定的目标/需求、base/head、实际 diff、测试与证据;默认不转交实施聊天、思维过程、
中间自评或原会话历史。确需补充信息时只提供可复核产物,不把实施上下文整体灌入评审会话。
- 每阶段记录工具、模型、stage、attempt/session 身份及 `fresh_session`。工具不暴露 session ID 时标
`not-observable`,但仍须用新进程并禁用恢复/续聊;无法证明会话独立时,不得把结果标为独立 Review。
- 需求分析、方案、设计、计划、实施、测试、Review 与验收默认各启动一个全新会话;不同阶段、不同实施单元不得复用同一 CLI 会话、conversation、thread 或 session。
- Review 由当前主模型或**全新且独立**的 CLI session 执行;绝对禁止实施者通过 `continue`、`resume`、conversation/session ID、session 路径或等价机制继续充当评审者。
- 评审只接收固定的目标/需求、base/head、实际 diff、测试与证据,默认不转交实施聊天、思维过程、中间自评或原会话历史;确需补充时只提供可复核产物。
- 每阶段记录工具、模型、stage、attempt/session 身份及 `fresh_session`;工具不暴露 session ID 时标 `not-observable`,但仍须新进程并禁用恢复/续聊;无法证明会话独立时不得标为独立 Review(见 [会话隔离](policy/delivery.md#阶段会话隔离与独立-review))。
## UI 设计门禁(强制)
- 本仓一切界面生成/修改**先读根目录 `DESIGN.md`**(四原则:美观、现代、简洁、易用),
再动手;违反视为设计系统缺陷,不是风格偏好。
- 颜色/圆角/字号只消费 `@ui/tokens.css` 令牌(字号走 `--fs-ui-*` 标尺);动效只用
`@ui/motion.css` 的 `mk-*` 工具类;折叠一律用 `@ui` Collapsible 原语,禁止 `<details>`。
- 守护测试(UI 仓与 Frontend 仓各一份 `design-guard.test.ts`)以棘轮拦截新增裸色值、
私有 `@keyframes` 与行内字号;风格调整须在同一变更集中关联 UI 令牌 PR 与 Dev 的
`DESIGN.md`/gitlink 组合 PR,并验证精确组件 SHA(跨仓不要求两个文件出现在同一 PR)。
- 本仓一切界面生成/修改**先读根目录 `DESIGN.md`**(四原则:美观、现代、简洁、易用)再动手;违反视为设计系统缺陷,不是风格偏好。
- 颜色/圆角/字号只消费 `@ui/tokens.css` 令牌(字号走 `--fs-ui-*` 标尺);动效只用 `@ui/motion.css` 的 `mk-*` 工具类;折叠一律用 `@ui` Collapsible 原语,禁止 `<details>`。
- 守护测试(UI 仓与 Frontend 仓各一份 `design-guard.test.ts`)以棘轮拦截新增裸色值、私有 `@keyframes` 与行内字号;风格调整须在同一变更集中关联 UI 令牌 PR 与 Dev 的 `DESIGN.md`/gitlink 组合 PR,并验证精确组件 SHA(跨仓不要求两个文件出现在同一 PR)。
- 异步状态只有一个所有者。事件按 seq 处理,过期事件丢弃。
## 任务启动门禁(强制)
- **新需求分流检查**:每次接到新需求,在创建分支前以远端为准运行 `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> TRIAGE_RECEIPT=<分流收据路径> PRECHECK_RECEIPT=/tmp/<本次唯一名称>.json`,
建立独立 worktree 与任务卡并导入首次收据。恢复已有登记任务在原 worktree 用
`make task-resume PRECHECK_RECEIPT=/tmp/<本次唯一名称>.json` 验证并原样导入收据。
默认 delivery 要求严格 READY;局部恢复接口、启用条件与旧收据处理见 [开发门禁](policy/governance.md#启动与恢复)。任务中途不重复启动检查。
- **分支层级**:任务分支一律从权威 `origin/main` 切(`task-start` 已强制);同批互不依赖的
子任务用同一 R0 的兄弟分支。确有顺序依赖需从父 feature 分支切子分支时(collaboration.md
允许的堆叠例外):启动命令增加 `PARENT=feature/<父slug> PARENT_PR=<父PR链接>`,
任务卡记录主干 R0、父分支及其 SHA、父 PR;子分支命名 `<type>/<父slug>-<子题>`,
不嵌 SHA;合入顺序固定父→子;父合入后子分支须 `make task-sync` 并入最新 main 并把 PR
base 改回 main,方可声明可合入(分支保护要求 head 含 origin/main,未同步必被拦)。
- **验收、请求审核、合入前**各运行一次 `make task-sync`,并运行 `make task-pr-check`(大任务收口用 `FINAL=1`)核验 PR base/head 门禁;完成声明的 precheck、task-sync、
测试三段输出由 `make evidence` 写入任务卡(见「证据与验证门禁」),不手抄。
- **多个分支交付 / CI 调度**:先读 [合入调度](policy/merge-queue.md),核对采纳状态、协调者、
记录及当前窗口。新模式尚未启用时保留既有同步节点;启用后最终同步至合入核验共用窗口,
同基线 `task-sync` 只核验,固定后检查用源码外 RECEIPT 保存(见 [证据流程](policy/governance.md#候选固定前后))。
- 合入后核对所有写者退出;能从当前会话、进程、锁与工作区状态证明时,智能体在
**已登记任务 worktree 根目录**运行
`TASK_WRITERS_STOPPED=1 make task-finish`。父子仓改动、未跟踪/ignored 唯一文件先保存或显式处置;删除被拒时
保留现场并按原因处理,禁止强删兜底;无法证明写者已退出时才请 owner 确认。放弃未合入工作用
`make task-finish ABANDON="--abandon <原因>"` 并记录原因;放弃不豁免现场保护。
- **任务卡闭合(防欠账)**:分支合入 main 后,其任务卡须回填"已完成"+主记录 PR 链接并
归档至 `.task/archive/`。`task-finish` 回收后按提示运行 `make task-close`,不借回收修改
共享主检出或推送另一任务。主检出 pull/merge 的 post-merge 钩子仅提示待办,不自动写入或推送。
`make task-close` 会提交、推送并可能创建 PR,标准闭合属于已授权交付的收口;仍须核对只包含本任务卡,否则停止扩围。闭合在主检出运行→建
`feature/card-close-*` 分支+PR,任务分支内加 `FOLD=--fold` 随当前 PR 合入。
precheck 的 `[cards]` 行报告欠账,验收前应清零;闭合 PR 同样由智能体按
policy/delivery.md 完成 Review、冲突处置及当前 HEAD 的 CI 核验后合入。
- **新需求分流**:创建分支前以远端为准运行 `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` 建子任务(`KIND=child INITIATIVE=feature/<initiative> NAME=feature/<initiative>-<slug>`),`NEW` 建普通任务或新大任务(`[KIND=initiative] NAME=feature/<slug>`),`DECISION_REQUIRED` 为有候选时的待决策状态,改变范围/优先级的加入由人工决策。已登记在父卡 `## 子任务登记` 的计划内 L2 子任务可免分流收据。
- **启动检查**:启动或恢复后、第一次项目写操作前运行一次 `RECEIPT=/tmp/<本次唯一名称>.json CMD="make precheck" make evidence` 保存完整脱敏收据并按输出完成必读;`make task-start` 省略 `PRECHECK_RECEIPT` 时自动捕获,恢复在原 worktree 用 `make task-resume PRECHECK_RECEIPT=<路径>`。`SHARED_WORKSPACE_BUSY / SYNC_REQUIRED / REMOTE_UNVERIFIED` 均不得进入写入流程,失败后排除原因再启动,不把缓存远端当最新事实。默认要求严格 READY,任务中途不重复;局部恢复见 [开发门禁](policy/governance.md#启动与恢复)。
- **分支层级**:一律从权威 `origin/main` 切,同批互不依赖的子任务用同一 R0 的兄弟分支。确有顺序依赖的堆叠例外:加 `PARENT=feature/<父slug> PARENT_PR=<父PR链接>`,卡记主干 R0、父分支及 SHA、父 PR,命名 `<type>/<父slug>-<子题>` 不嵌 SHA,合入顺序父→子,父合入后子须 `make task-sync` 并把 PR base 改回 main 才可合入(见 [policy/delivery.md](policy/delivery.md) 与 [开发门禁](policy/governance.md#启动与恢复))。
- **同步与 PR 门禁**:验收、请求审核、合入前各运行一次 `make task-sync` 与 `make task-pr-check`(大任务收口用 `FINAL=1`);三段完成证据由 `make evidence` 写入任务卡。层级、任务通道与分级门禁见 [层级与分级门禁](policy/delivery.md#层级任务通道与分级门禁),多分支交付的最终窗口见 [合入调度](policy/delivery.md#合入调度)。
- **回收**:合入后核对所有写者退出;能从当前会话、进程、锁与工作区状态证明时,智能体在**已登记任务 worktree 根目录**运行 `TASK_WRITERS_STOPPED=1 make task-finish`,无法证明时才请 owner 确认。父子仓改动、未跟踪/ignored 唯一文件先保存或显式处置;删除被拒时保留现场,禁止强删兜底。放弃未合入工作用 `make task-finish ABANDON="--abandon <原因>"` 并记录原因,不豁免现场保护。
- **任务卡闭合**:默认在最终同步/合入前于任务 worktree 运行 `make task-close SELF=1`(卡改为“已完成(合入前闭合)”、写入本 PR 链接并归档,随同一 PR 合入;会提交并推送本分支,只处理本分支的卡)。post-merge 钩子仅提示。主检出 `make task-close` 或 `FOLD=--fold` 只用于已合入未闭合的遗留卡,须核对只含目标卡,其闭合 PR 同样经智能体 Review、冲突处置与当前 HEAD CI 后合入。precheck `[cards]` 欠账验收前须清零。
## 证据与验证门禁(强制)
原则:**「声明已就位」≠「能力已就位」**。任何来源(用户输入、方案、报告、他人或自己的
叙述)的结果声明都不是证据;证据 = 命令 × 被测对象 SHA × 可复核输出,三者齐备才算事实。
规范依据:guidelines `standards/verification.md`「声明与证据来源」(draft.10)。
- **来源三分**:产出物(任务卡、PR、报告、review 结论)中的通过类断言须标注来源——
`GATE`(可核验身份及固定对象的 CI/受信执行器)/ `REPLAY`(独立审阅者的重跑记录)/
`SELF`(作者或本地工具记录,含 precheck)。工具写入、摘要和来源标签不提升可信等级。
合入与验收须核验 GATE 的运行链接/身份及 SHA,或 REPLAY 的独立审阅者、命令和对象;
本地 evidence 只生成 SELF,不能靠 `SRC` 自行升级;与重验不一致时以重验为准。
- **证据由工具写入**:命令输出进入任务卡/PR 一律经 `CMD="<命令>" make evidence` 写入
(记录脱敏命令、执行前后对象、退出码、完整脱敏输出与摘要)。凭据只经环境或受保护文件
引用;命令、输出和摘要统一脱敏,摘要只证明记录完整性,不证明执行者身份。手抄、转写、
凭记忆重构的输出视为无效证据;补写输出与伪造测试同级红线。
- **完成声明三段输出**(precheck / task-sync / 测试)必须由 evidence 工具写入任务卡,
PR 描述引用任务卡段落,不另手抄。测试通过的权威源是 CI(`.gitea/workflows/ci.yaml`,
在 PR HEAD 上 `make test`+`make build`),本地输出降级为辅助线索。
- **验收即重放**:请求审核后,审阅者先在评审环境重放最小重放集(关键门禁命令 + 变更
直接相关检查)再读作者证据;重放与声明不一致直接阻塞,并作为失实事件登记。
- **外部事实零信任**:用户/文档/旧报告里的「可用/已就位」必须先执行 `policy/facts.md`
登记表中对应验证命令(或当场实测)才能写进产出物;无法当场复核的结论标 `[未经证实]`,
禁止裸写。登记表每条事实带 `last_verified` 与 TTL,precheck `[facts]` 报告过期项,
`make facts-verify` 逐条重验。
- **声明不是证据**:「声明已就位」≠「能力已就位」;任何来源的结果声明都不是证据,证据 = 命令 × 被测对象 SHA × 可复核输出(guidelines `standards/verification.md`「声明与证据来源」)。
- **来源三分**:通过类断言标注 `GATE`(可核验身份及固定对象的 CI/受信执行器)/ `REPLAY`(独立审阅者的重跑记录)/ `SELF`(作者或本地工具记录,含 precheck)。工具写入、摘要和标签不提升可信等级,本地 evidence 只生成 SELF,不能靠 `SRC` 升级;合入与验收须核验 GATE 的运行链接/身份及 SHA,或 REPLAY 的审阅者、命令和对象,与重验不一致以重验为准。
- **证据由工具写入**:命令输出只经 `CMD="<命令>" make evidence` 写入(卡内写摘要块,完整脱敏收据一次性存于仓外,见 [开发门禁](policy/governance.md#本地记录与独立证据));凭据只经环境或受保护文件引用。手抄、转写、凭记忆重构的输出无效,补写输出与伪造测试同级红线。
- **完成声明三段输出**(precheck / task-sync / 测试)由 evidence 写入任务卡,PR 描述引用不另手抄;测试通过的权威源是 CI(`.gitea/workflows/ci.yaml` 在 PR HEAD 上 `make test`+`make build`),本地输出只是辅助线索。
- **验收即重放**:请求审核后,审阅者先在评审环境重放最小重放集(关键门禁命令 + 变更直接相关检查)再读作者证据;不一致直接阻塞并登记为失实事件。
- **外部事实零信任**:用户/文档/旧报告中的“可用/已就位”须先执行 `policy/facts.md` 对应验证命令或当场实测才能写入产出物,无法复核标 `[未经证实]`;登记项带 `last_verified` 与 TTL,precheck `[facts]` 报告过期项,`make facts-verify` 逐条重验。
- **诚实出口**:未执行/未验证是合法状态,如实标注(NOT_RUN/未验证)优于补写。
## 提交与 PR
- 主题行:英文祈使句一行(≤100 列,不以句点结尾);body 写 `Why: / Evidence: / Impact: / Refs:`
四段;任务卡与 PR 描述为同一份文档。
- 主题行:英文祈使句一行(≤100 列,不以句点结尾);body 写 `Why: / Evidence: / Impact: / Refs:` 四段;任务卡与 PR 描述为同一份文档。
- 跨仓 gitlink 变更须经组合验证(`make test`)后在 Dev 仓推进;受保护分支经 PR 合入。
- **高后果操作双钥匙**:改写历史、解除/变更分支保护、删除归档引用、发布版本——Agent 只准备
方案与命令,由用户确认并亲手执行;Agent 不得自行实施(baseline 硬边界 1 的授权范围要求)。
- 模块边界、公开契约与隔离档位见 `policy/modules/`(如有)与 PROJECT.md。
- 验证命令见 PROJECT.md「验证」节;完成声明的三段输出按「证据与验证门禁」节由
`make evidence` 写入任务卡,不手抄。
- **等待 CI** 只用一条阻塞命令 `make ci-wait SHA=<完整 SHA>`(15 s 轮询、失败即停、默认 20 分钟超时);
禁止手写 `sleep` + `curl` 轮询循环,禁止对同一 SHA 重复/并行轮询;等待期间去做其他独立工作。
- **高后果操作双钥匙**:改写历史、解除/变更分支保护、删除归档引用、发布版本——Agent 只准备方案与命令,由用户确认并亲手执行;Agent 不得自行实施(baseline 硬边界 1 的授权范围要求)。
- 模块边界、公开契约与隔离档位见 `policy/modules/`(如有)与 PROJECT.md;验证命令见 PROJECT.md「验证」节。
- 首次克隆本仓后运行 `make setup` 启用本地 pre-commit 钩子。
- 治理脚本改动须运行 `make test-governance`:临时真实仓库覆盖正常、拒绝及异常路径;该套件
同时纳入 `make test`。证据、工作区和 CI 凭据边界的操作细则见 `policy/governance.md`。
- 治理脚本改动须运行 `make test-governance`(临时真实仓库覆盖正常、拒绝及异常路径,已纳入 `make test`);证据、工作区和 CI 凭据边界细则见 `policy/governance.md`。
## Gitea 与 Git 访问事实(2026-09-30 实测;登记表 `policy/facts.md`)
@@ -173,10 +86,8 @@ policy/delivery.md。
API 前缀 `/api/v1`;偶发 broken pipe / 超时,重试即可)。
- **Git over SSH**:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/<Repo>.git`
(.gitmodules 与各仓 origin 均为此格式)。
- **API token**:`~/.secrets/gitea`,两行键值 `URL_PREFIX=…` 与
`ACCESS_TOKEN=<40 位 hex>`;取值 `grep ACCESS_TOKEN ~/.secrets/gitea | cut -d= -f2-`,
用作 `Authorization: token <值>` 请求头。**token 值不入仓、不回显、不写进产出物**
(baseline 硬边界 2:只记录来源、字段与可用状态)。
- **API token**:`~/.secrets/gitea` HOME/IMC 段各有 `- URL_PREFIX=`/`- ACCESS_TOKEN=<40 hex>`,本仓取 HOME 段(F2):
`awk '/^HOME:/{h=1;next} /^IMC:/{h=0} h&&sub(/^- ACCESS_TOKEN=/,""){print;exit}' ~/.secrets/gitea`,作 `Authorization: token <值>` 头。**token 值不入仓、不回显、不进产出物**(baseline 硬边界 2)。
- **SSH key**:`~/.ssh/home_gitea`(ED25519,公钥指纹
`SHA256:GYXaOXIMeloQ1fKSfdJ/ndgPQzs/Ix7Hk62ekfkfQ20`,Gitea 侧密钥名 GMKtec);
`~/.ssh/config` 已有 `git-ssh.u1680832.nyat.app` 主机条目,克隆/推送直接可用。
+18 -6
View File
@@ -13,7 +13,7 @@ PLUGINS := plugins
COMBO := core=$(shell git -C $(CORE) rev-parse --short HEAD 2>/dev/null)+backend=$(shell git -C $(BACKEND) rev-parse --short HEAD 2>/dev/null)+frontend=$(shell git -C $(FRONTEND) rev-parse --short HEAD 2>/dev/null)+ui=$(shell git -C $(UI) rev-parse --short HEAD 2>/dev/null)+agentcmd=$(shell git -C deps/agent-command rev-parse --short HEAD 2>/dev/null)+documents=$(shell git -C modules/documents rev-parse --short HEAD 2>/dev/null)+native=$(shell git -C deps/agent-native rev-parse --short HEAD 2>/dev/null)+squad=$(shell git -C deps/agent-squad rev-parse --short HEAD 2>/dev/null)+intent=$(shell git -C deps/agent-intent rev-parse --short HEAD 2>/dev/null)
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null)+$(COMBO)
.PHONY: help sync status build frontend svc svc-list svc-info svc-commits svc-history svc-update svc-recover svc-watchdog test-backend test-backend-pg test-multi-instance test-lifecycle test-frontend test-ui test-core test-agentcmd test-pins test-libs dev pin clean setup precheck task-triage task-triage-decide task-start task-resume task-sync task-close task-finish task-pr-check facts-verify evidence version agent-exec-validate agent-exec-apply agent-exec-admit
.PHONY: help sync status build frontend svc svc-list svc-info svc-commits svc-history svc-update svc-recover svc-watchdog test-backend test-backend-pg test-multi-instance test-lifecycle test-frontend test-ui test-core test-agentcmd test-pins test-libs dev pin clean setup precheck task-triage task-triage-decide task-start task-resume task-sync task-close task-finish task-pr-check ci-wait facts-verify evidence version agent-exec-validate agent-exec-apply agent-exec-admit
version:
@echo "agent-system $(VERSION)"
@@ -32,6 +32,7 @@ export TASK_INITIATIVE
setup:
git config core.hooksPath scripts/hooks
git config submodule.fetchJobs 8
chmod +x scripts/hooks/pre-commit scripts/hooks/post-merge scripts/task.sh scripts/evidence.sh scripts/facts-verify.sh scripts/agent-exec
@echo "OK: pre-commit/post-merge 钩子已启用(core.hooksPath=scripts/hooks)"
@@ -45,7 +46,7 @@ task-triage-decide:
@VERDICT="$(VERDICT)" INITIATIVE="$(INITIATIVE)" REASON="$(REASON)" bash $(TASK) triage-decide
task-start:
@if [ -z "$(NAME)" ]; then echo "usage: make task-start NAME=feature/<slug> TRIAGE_RECEIPT=<path>"; exit 2; fi
@if [ -z "$(NAME)" ]; then echo "usage: make task-start NAME=feature/<slug> TRIAGE_RECEIPT=<path> [PRECHECK_RECEIPT=<path>](KIND=child 已登记在父卡时可省略 TRIAGE_RECEIPT)"; exit 2; fi
@bash $(TASK) start "$(NAME)"
task-resume:
@@ -54,8 +55,10 @@ task-resume:
task-sync:
@bash $(TASK) sync
# 任务卡闭合:默认在任务 worktree 合入前运行 SELF=1(本卡随当前 PR 完成并归档,无需单独闭合 PR);
# 不带参数(主检出)或 FOLD=--fold 仅用于已合入未闭合的遗留卡。
task-close:
@bash $(TASK) close $(FOLD)
@bash $(TASK) close $(if $(SELF),--self,$(FOLD))
task-finish:
@bash $(TASK) finish $(ABANDON)
@@ -64,6 +67,10 @@ task-finish:
task-pr-check:
python3 scripts/task_pr_check.py local $(if $(PR),--pr $(PR)) $(if $(FINAL),--final)
# CI 等待(单条阻塞命令):轮询 Gitea 合并状态,失败即停;两次连续全绿才算成功。默认 SHA=HEAD、20 分钟超时。
ci-wait:
python3 scripts/ci_wait.py $(if $(SHA),--sha $(SHA)) $(if $(TIMEOUT),--timeout $(TIMEOUT)) $(if $(INTERVAL),--interval $(INTERVAL)) $(if $(CONTEXTS),--contexts $(CONTEXTS))
# 证据写入:执行命令并把结果行追加进当前任务卡(AGENTS.md「证据与验证门禁」)。
# 本地执行只记 SELF;RECEIPT 捕获启动收据,IMPORT_RECEIPT 导入原始收据而不重跑。
# 用法:CMD="<单行命令>" make evidence [TAIL=15];退出码随被执行命令。
@@ -96,8 +103,9 @@ help:
@echo "make agent-exec-admit 校验暂存路径具备 transport 来源或属于 host 豁免"
@echo "make task-triage REQ='<需求>' TRIAGE_RECEIPT=<path> 以远端为准发现接纳中的 initiative 并写分流收据(候选存在时 exit 5,待决策)"
@echo "make task-triage-decide TRIAGE_RECEIPT=<path> VERDICT=JOIN|NEW [INITIATIVE=<branch>] REASON='<理由>' 记录显式分流决策(不按数量自动 JOIN)"
@echo "make task-start NAME=feature/<slug> TRIAGE_RECEIPT=<path> [KIND=...] [INITIATIVE=...] 建任务前核验分流收据(失败不建分支)"
@echo "make task-start NAME=feature/<slug> TRIAGE_RECEIPT=<path> [KIND=...] [INITIATIVE=...] [PRECHECK_RECEIPT=<path>] 建任务前核验分流收据(失败不建分支);KIND=child 已登记在父卡「子任务登记」时可省略 TRIAGE_RECEIPT;省略 PRECHECK_RECEIPT 时自动运行启动检查"
@echo "make task-pr-check [PR=N] [FINAL=1] 核验当前任务分支 PR 的 base/head 门禁(远端为准;FINAL=1 用于 initiative 收口)"
@echo "make ci-wait [SHA=<完整 SHA>] [TIMEOUT=1200] [INTERVAL=15] [CONTEXTS=a,b] 阻塞等待该 SHA 的 CI 结论(失败即停)"
agent-exec-validate:
scripts/agent-exec validate --repo .
@@ -110,7 +118,7 @@ agent-exec-admit:
scripts/agent-exec admit --repo .
sync:
git submodule update --init --recursive
git submodule update --init --recursive --jobs 8
status:
@git submodule status
@@ -197,7 +205,11 @@ plugin-new:
@test -n "$(NAME)" || (echo "用法:make plugin-new NAME=<id>"; exit 2)
bash scripts/plugin-new.sh "$(NAME)"
.PHONY: test test-governance test-worktree contracts
.PHONY: test test-governance test-worktree test-affected contracts
# host 最小直接检查:按 BASE(缺省为卡片 immediate_target 或 origin/main)以来的改动选 make 目标;DRY=1 只打印。
test-affected:
python3 scripts/test_affected.py $(if $(BASE),--base $(BASE)) $(if $(DRY),--dry-run)
test-governance:
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -p 'test_*.py' -v
+3 -1
View File
@@ -12,6 +12,8 @@
目录中的既有文档不因被收录而自动成为已批准规划。记录、任务关联及收尾建议按
[交付规范的长期方向与任务衔接](policy/delivery.md#长期方向与任务衔接) 执行。
- [分层分支交付与规范精简 r1](modules/documents/docs/proposals/2026-10-18-delivery-model-v3.md):2026-10-18 用户批准实施。固定两级分支、任务卡合入前折叠闭合、按层级分配门禁、任务分类通道与规范精简;由治理大任务 `feature/delivery-model-v3` 推进。
- [文档职责矩阵整改 r2](modules/documents/docs/proposals/2026-10-15-rdm-duty-matrix-rework.md):项目设置的文档职责改为三视图分配,不创建文档、不执行审签。2026-10-15 按实施方案执行;部署与真实账号验收未授权。
- [svc 插件组合部署(--with)方案 r1](modules/documents/docs/proposals/2026-10-14-svc-plugin-compose-deploy.md):待评审。部署时把登记插件与 Dev 基座编成一个二进制并校验激活。收录不构成批准。
@@ -61,7 +63,7 @@ U2 起实施与部署分别评审。
## 验证
多个分支向 Dev main 交付时按[并行开发与串行合入](policy/merge-queue.md)执行:等待分支不追 main,轮到后同步一次、固定候选、运行当前 HEAD 的完整 CI,再由智能体合入。实际采纳范围见[采纳记录](policy/adoption.md)。
多个分支向 Dev main 交付时按[合入调度](policy/delivery.md#合入调度)执行:等待分支不追 main,轮到后同步一次、固定候选、运行当前 HEAD 的完整 CI,再由智能体合入。实际采纳范围见[采纳记录](policy/adoption.md)。
`make test` 先核对子模块工作树与固定指针,再运行治理脚本、公开边界、跨仓指针、库及各模块
测试。实际目标以根 Makefile 为准。`make test-governance` 可独立检查开发门禁,使用临时
+1 -1
View File
@@ -7,7 +7,7 @@
- 版本权威:父仓 gitlink
- 批准/生效依据:Dev PR#1(19/19 正负例 + make test);guidelines PR#1
- 规范源码版本:`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 并由智能体合入。该规则不要求先完成全写者审计或第二轮试行,也不宣称自动互斥已经实现。
- 合入调度通用版本:`0.1.0-draft.11`,实际合入提交 `2f5b19872be2a4597838a4131ad75d1be089ee5e`(Guidelines PR #5);draft.12 未修改其合入调度文件(standards/merge-queue.md、templates/merge-queue.md)。Dev 采用 [manual 串行合入规则](delivery.md#合入调度):并行开发,轮到后才同步、跑最终 CI 并由智能体合入。该规则不要求先完成全写者审计或第二轮试行,也不宣称自动互斥已经实现。
- 过程计划固定:Documents PR #96 实际合入 `129cc3579d9c9e3edfb46c99270d65f3e1dad0a0`,包含既有主干文档及获批 U1–U6 计划;当前进展以任务/PR 记录为准。
| 规范/工作流 | 适用条件与范围 | 执行方式/证据 |
+77 -5
View File
@@ -162,7 +162,7 @@ host 可以直接编写并提交的豁免只有 `.task/**`、`docs/superpowers/s
- **子任务分支(child)**:从远端所属大任务分支的当前头建立(须等于分流收据核验的 head,`make task-start` 校验),即时目标为大任务分支。负责一个有界单元的实施、独立 Review 与局部验证,通过 PR 合入大任务分支。
- **普通任务分支(standalone)**:无需阶段集成的小需求或独立修复,直接从最新 `origin/main` 建立并向 `main` 交付。
每个任务卡记录 `root_target` 与 `immediate_target`(子任务为所属大任务分支,其余为 `main`)。`make task-resume`、`make task-sync` 与 `make task-finish` 以 `immediate_target` 为准;`make task-close` 仅在 `FOLD=--fold` 时以其为闭合 PR base。
每个任务卡记录 `root_target` 与 `immediate_target`(子任务为所属大任务分支,其余为 `main`)。`make task-resume`、`make task-sync` 与 `make task-finish` 以 `immediate_target` 为准;任务卡默认在合入前以 `make task-close SELF=1` 闭合并随本 PR 合入(归档后上述命令仍从 `.task/archive/` 读取合入前闭合的卡);遗留卡的 `make task-close` 仅在 `FOLD=--fold` 时以其为闭合 PR base。
每次接到新需求,创建分支前必须分流,事实来源为 origin(本地 `.task/` 不参与发现):
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`。
@@ -173,9 +173,43 @@ host 可以直接编写并提交的豁免只有 `.task/**`、`docs/superpowers/s
PR 目标:子任务 PR 的 base 为所属大任务分支;普通任务与大任务 PR 的 base 为 `main`。`make task-pr-check` 在本地核验 base 与 head(远端为准),CI 的 `PR base gate` 以同一规则复核。
两级合入与验证:
1. **子任务合入大任务**:子任务完成时,通过 `make task-sync` 合并大任务分支的远端最新状态(delivery 只认远端头),解决文本与语义冲突,完成直接相关测试与独立 Review 后,以大任务分支为 base 提 PR,并通过 `make task-pr-check` 与 CI `PR base gate`;
1. **子任务合入大任务**:子任务完成时,通过 `make task-sync` 合并大任务分支的远端最新状态(delivery 只认远端头),解决文本与语义冲突,完成直接相关测试与独立 Review 后,以大任务分支为 base 提 PR,并通过 `make task-pr-check` 与 CI `PR base gate`;L2→L1 按下文「层级、任务通道与分级门禁」的轻量门禁执行,不排 #518 窗口、不要求三段证据;
2. **大任务合入主干**:所有必需子任务已合入或已延期后,将大任务卡的 `accepting_requirements` 置为 `false`,通过 `make task-sync` 同步最新 `origin/main`,再运行 `make task-pr-check FINAL=1` 核验 `## 子任务登记` 中必需子任务均为「已合入」或「已延期」;随后执行完整组合验证、独立 Review 与必需 CI,以 `main` 为 base 合入。
### 层级、任务通道与分级门禁
层级固定两级,不再加深:
- **L1 initiative**:从 `main` 切出,最终一次合入 `main`。
- **L2 child**:从 L1 切出,合入 L1。L2 内部用 agent-exec 单元控制上下文,单元不开 PR。
- L1 超过约 2 周或 6–8 个子任务时,横向拆成多个并列 L1,用规划主记录串联;L2 过大时拆成兄弟 L2 或更多单元。
L1 章程与运行:
- 章程写在 L1 任务卡 `## 目标与非目标`:用途与目标、接纳范围与非目标、方案/计划链接、分级测试策略、验收条件、追加领域规则、生命周期;并维护 `## 子任务登记`。
- 建立 L1 时即开一个 base 为 `main` 的 [WIP] PR,供分流发现;其评论区作为该 L1 的仓外调度记录。
- 已登记在 `## 子任务登记` 的子任务可免分流收据,直接 `make task-start KIND=child …`。
- 子任务可继承 L1 章程中的全部或部分领域规则,并在子任务卡注明继承范围。
- L1 不追 `main`,只在最终窗口、需要 `main` 上某修复、或长周期 L1 的里程碑/每周一次时运行 `make task-sync`。
任务通道按 修复/完善/扩展/新增 分类:
- **修复**:`fix/`(P0 生产事故用 `hotfix/`),standalone 直达 `main`,在合入队列中优先;hotfix 可在 #518 写明原因后插队。
- **完善**:进入所在领域的“完善列车” L1,按周或按批合入 `main`。
- **扩展**:优先 JOIN 同主题在途 L1,没有时新建。
- **新增**:建立 L1 并写章程。
- 治理与规范调整集中到一个治理 L1。
- 只改记录(任务卡、报告)的变更不单独推进 `main`,随所属任务 PR 合入。
- 组件 pin 跟随用到它的 L1/任务一起推进,不单独开 pin PR(紧急修复除外)。
分级门禁:
| 层级 | 检查 | Review | CI | 合入窗口与证据 | 人工 |
|---|---|---|---|---|---|
| 单元→L2 | worker 自检 + host 最小直接检查(`make test-affected`) | host 审阅实际 diff | 无 | 无 | 无 |
| L2→L1 | `make task-sync` 同步 L1 头 + `make test-affected` | 一次全新会话独立 Review | 轻量 CI(PR base 非 `main` 时只跑 `test` 作业,`test-pg`/`svc-realcombo` 跳过)作为 GATE | 不排 #518 窗口、不需分流收据(已登记子任务)、不要求三段证据;任务卡合入前 `make task-close SELF=1` | 无 |
| L1 的 WIP PR | — | — | 完整 CI,作为非阻塞预警 | — | — |
| L1 或修复→main | 最终窗口内 `make task-sync` + 完整组合验证 | 整体独立 Review | 完整 CI(`test`、`test-pg`、`svc-realcombo`) | #518 窗口、三段证据、合入前折叠闭合 | L1 最终一次用户专属验收(如有) |
人工审批只保留 L1 章程确认、L1 最终用户专属验收与高后果双钥匙操作;等待 CI 一律 `make ci-wait SHA=<完整 SHA>`。
## 目标优先、范围控制与问题分流
执行顺序为:
@@ -217,7 +251,7 @@ Review 只将阻塞项列为必修;非阻塞意见标明后续建议,不得
正式测试和最终 Review 的顺序不限制设计阶段试验、实施期测试或提前审阅。
文本合并无冲突不证明组合兼容;合入后仍核验真实内容与被测候选是否一致。
多个就绪任务的最终验证与合入按 [合入调度](merge-queue.md) 的采纳状态执行。
多个就绪任务的最终验证与合入按 [合入调度](#合入调度) 的采纳状态执行。
新模式生效后,阶段 7–9 在同一目标交付窗口内完成;对象未变时各节点重复核验,
不因记录检查再创建提交或重跑有效 CI。窗口领取不代替已触发的必要决策、独立 Review 或平台准入;
未生效时继续既有同步规则。固定后的证据按 [源码外收据流程](governance.md#候选固定前后) 保存。
@@ -315,6 +349,42 @@ Review 只将阻塞项列为必修;非阻塞意见标明后续建议,不得
对 head/base、依赖、需求或契约变化重新判断证据及评审的有效性,按影响补测和复审。
阶段状态、测试状态、代码审核状态及合入状态分别记录,不以一个“完成”互相替代。
## 合入调度
状态:active;执行方式:manual。目标是多个分支并行开发,同时避免各分支随 `main` 移动反复同步、
解决冲突和重跑最终 CI。本节只安排 Dev `refs/heads/main` 的最终交付顺序;需求、授权、阶段状态和
证据仍在原任务卡,受保护分支的最终准入仍由 Gitea 执行。
- **队列**:Dev main 只用一份[合入队列 #518](https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/issues/518),
只记录顺序和当前窗口:任务/PR、owner、依赖、就绪状态、当前 base/head、CI、合入结果。
- **职责**:协调智能体维护顺序和窗口;任务 owner 负责分支内容和必要整改;窗口内的同步、冲突处置、
Review、当前 HEAD CI 核验和所有 PR 合入由智能体执行。
- **范围**:只有推进 Dev main 的 PR 使用窗口,即 standalone/修复 PR、L1→main 与遗留卡闭合 PR;L2→L1 合入不排窗口
(见 [层级、任务通道与分级门禁](#层级任务通道与分级门禁))。组件仓按各自目标分支单独安排。
- **入队与顺序**:入队表示内容基本就绪且已取得所需授权,不要求已含最新 main。依赖未完成或待必要
人工决策的任务标 `blocked`,不占窗口。默认按就绪顺序;`fix/`、`hotfix/` 优先,插队须在 #518 写明原因。
- **等待**:等待分支继续独立开发、提交和局部检查,但不因 main 前进而合并最新 main、解决无关冲突、
推送新最终候选或重跑完整最终 CI,只记“待轮到后同步”;开发期检查不作为之后新 HEAD 的准入证据。
同一时间只给一个 Dev main 任务最终窗口。领取前确认前一窗口已释放、依赖已完成,并在 #518 记录
任务、PR、owner、当前 source HEAD 和领取时间。窗口内按序执行:
1. 运行一次 `make task-sync` 纳入最新 `origin/main`,解决冲突并核对组件组合;
2. 完成并提交候选内容(含任务卡),固定 `base_sha` 与最终 `head_sha`;
3. 运行任务要求的本地检查,固定后的收据保存在源码外(见[候选固定前后](governance.md#候选固定前后)),不为记结果改变 HEAD;
4. 对固定 HEAD 运行完整 CI(`test`、`test-pg`、`svc-realcombo`),用 `make ci-wait SHA=<完整 SHA>` 等待;
5. 完成当前 HEAD 的必要人工决策(如有)和智能体 Review,合入前再核对 main、head、冲突和三项 CI;
6. 以固定 `head_commit_id`、`force_merge=false` 经 PR 合入,回读实际 main 与候选一致后释放窗口,结果写回 #518。
同一 base/head 下,验收、Review 和合入前的 `make task-sync` 只核验(`changed=0`),不创建额外提交,
也不重复仍有效的完整 CI。目标 base、候选 HEAD、组件 pin、检查定义或相关环境任一变化,旧最终结果即失效;
受影响任务回队等待新窗口,不复制旧绿色状态、不为省 CI 降低检查。窗口外仍有 main 推进时按对象变化处理并记录。
冲突、代码/Review/CI 失败或等待必要人工决策时,当前任务保存现场、标 `blocked` 并释放窗口,其它分支继续
局部开发。合入请求超时或结果不明时先查询 PR 与 main 的真实状态,查清前暂停该目标,不重复发送合入请求。
本流程不建设、也不得推断已存在自动队列、优先级、合并列车、批量预测合并、原子租约或旧执行者 fencing,
不修改分支保护、CI workflow 或 runner 配置。
## 合入、清理与恢复
### 智能体 Review 与 PR 合入
@@ -358,8 +428,10 @@ Review 只将阻塞项列为必修;非阻塞意见标明后续建议,不得
清理前保存任务主记录与证据。执行回收时将命令输出用 evidence 的 `RECEIPT` 模式保存到
待删目录之外;回收后的结果写入后续闭合记录,不能向已删除的任务卡补写收据。
任务卡须回填真实主 PR 和完成状态并归档;闭合 PR 的创建核对当前闭合授权,合入按上述
智能体 Review 与 PR 合入流程执行。
任务卡默认在最终同步/合入前于任务 worktree 运行 `make task-close SELF=1`:写入本 PR 链接与
"已完成(合入前闭合)"状态并归档,随同一 PR 合入,不另建闭合 PR。仅已合入未闭合的遗留卡在主检出
运行 `make task-close`(`feature/card-close-*` PR)或用 `FOLD=--fold` 回填归档;其闭合 PR 的创建核对
当前闭合授权,合入按上述智能体 Review 与 PR 合入流程执行。
post-merge 默认只提示,不因 pull 或 merge 自动提交、推送或创建闭合 PR。
中断或交接时记录当前有效版本、批准范围、未决假设、最新试验结论、剩余事项及下一动作。
+10 -17
View File
@@ -16,21 +16,8 @@
源码外;未知终态(`failed-unknown`)与 `scope-expansion` 的隔离工作区须保留待对账,不得静默丢弃。
清理须有精确恢复或保留现场的可复核证据。
实施、修复与测试的正常候选只有 Cursor、Antigravity(`agy`)和 PI。每个候选对安全可恢复失败
最多 3 次全新尝试,全部安全耗尽后才允许一次 Codex CLI 紧急尝试。Codex 平时只用于规划、方案设计、
实施计划或独立 Review。`fallback_to_host` 为 `never`。安全耗尽不发出 host-fallback-authorized,
终态为 unavailable 或 blocked,不授权 host 撰写实施单元。显式 fail-stop 立即停止。一次尝试只在显式终态失败、进程退出、总超时,
或 stdin/stdout/stderr 都没有活动的空闲超时时结束;推理时长、工具次数、规划方式和首次写文件时间
都不是结束条件。CLI 可在目标、范围与安全边界内自主检查、计划、测试并迭代。路由细则见
[迭代交付](delivery.md)。
实施变更进入仓库,只通过成功 transport 的 `scripts/agent-exec apply`,或 pre-commit `admit`
对暂存内容的本地完整性核对。本地 HMAC 执行历史与该钩子只记作本地完整性/篡改证据(SELF),
不是 GATE、可信身份,也不防同一主机 OS 用户;本地记录缺失或无效仍拒绝,交付信任来自精确 HEAD 的
CI 或独立 REPLAY。host 可以直接管理的豁免只有 `.task/**`、`docs/superpowers/specs/**`
与 `docs/superpowers/plans/**`。host 不直接撰写实施单元。`failed-unknown`、`scope-expansion`、
无效配置、未对账 Git 状态与安全失败都不授权继续路由或代写。`network=false`/`external_cost=false`
声明不证明外部干净,已启动的失败尝试在没有强制隔离或可信对账时记 `failed-unknown`。
候选、尝试上限、Codex 紧急尝试、fallback、fail-stop、尝试结束条件,以及 transport/admit
与 host 豁免路径,以[迭代交付](delivery.md#agent-实施单元与主模型职责)为唯一权威。
## 启动与恢复
@@ -57,7 +44,7 @@ make task-start NAME=feature/my-task TRIAGE_RECEIPT=/tmp/my-task-triage.json PRE
默认 `TASK_WORK_MODE=delivery`,继续要求严格 READY 和当前目标一致。
工作区忙或落后时先由 owner 保存现场并同步;远端不可达时只继续不依赖新鲜基线的阅读和分析。
局部恢复接口已具备,正式交付仍按[合入调度](merge-queue.md)的单一最终窗口执行。
局部恢复接口已具备,正式交付仍按[合入调度](delivery.md#合入调度)的单一最终窗口执行。
启用后,既有任务可以显式选择 local,保持原 R0,标记落后,继续已授权局部工作:
```bash
@@ -103,6 +90,11 @@ PR 合入由智能体执行:先完成有效 Review,再同步并解决冲突
命令含敏感值时展示脱敏形式,并记录原命令摘要。执行前后对象分别记录;命令改变 HEAD
时不把执行前 SHA 冒充执行后对象。不能读取执行后状态时明确记录缺项。
写入任务卡(`CMD` 直写或 `IMPORT_RECEIPT` 导入)时,卡内只放摘要块(命令、退出码、前后 HEAD、
输出尾部及各摘要);完整收据以 0600 一次性写到仓外的记录路径(默认
`~/.cache/agent-system-dev/evidence/<receipt_sha256>.json`,可用 `AGENT_EVIDENCE_DIR` 覆盖),
不覆盖不同内容。`check-evidence` 在本机可读时用完整收据核对摘要,缺失则警告未验证;两者仍是 SELF。
### 候选固定前后
固定前,完成同步、必要检查、任务卡及本地收据提交,核对干净的组件组合,再读取和推送最终 HEAD。
@@ -138,7 +130,8 @@ Git 对含子模块的工作区需要显式删除选项时,只有全部现场
子仓 pin 之外的 refs/stash/reflog 历史采取保守拒绝,即使其中部分可能已在远端:
owner 须先独立确认其保存与归属,工具不把缓存的远端引用当作唯一数据已经备份的证据。
远端分支删除与本地回收分别报告,失败不能声明两者全部完成。
回收后由所有者执行 `make task-close` 闭合卡片;回收命令不顺带改写共享主检出。闭合 PR 默认 base 为 `main`;仅 `FOLD=--fold`(随任务分支合入)时 base 为卡片 `immediate_target`。
任务卡默认在合入前闭合:最终同步/合入前在任务 worktree 运行 `make task-close SELF=1`,本卡改为"已完成(合入前闭合)"并归档,随同一 PR 合入,无需单独闭合 PR;回收命令不顺带改写共享主检出。
主检出的 `make task-close`(闭合 PR base 为 `main`)或 `FOLD=--fold`(随任务分支合入,base 为卡片 `immediate_target`)只用于已合入未闭合的遗留卡。
post-merge 仅提示,不自动调用闭合。`make task-close` 会提交、推送并可能创建 PR,
执行者先按迭代交付规则核对该动作的授权及所选任务,保留未授权的其他任务现场。
回收命令使用待删目录之外的 `RECEIPT` 保存输出,清理结果由后续闭合记录引用。
+3 -73
View File
@@ -1,74 +1,4 @@
# 并行开发与串行合入
# 并行开发与串行合入(已并入)
状态:active;执行方式:manual。目标是让多个分支并行开发,同时避免所有分支随着
`main` 持续移动而反复同步、解决冲突和重跑最终 CI。
本规则只安排 Dev `refs/heads/main` 的最终交付顺序。各任务的需求、授权、阶段状态和证据
仍保存在原任务卡;受保护分支的最终准入仍由 Gitea 执行。
## 队列与职责
- Dev main 使用一份[合入队列 #518](https://git-home.u1680832.nyat.app:30319/weichen/Agent-System-Dev/issues/518)。
- 队列只记录顺序和当前窗口:任务/PR、owner、依赖、就绪状态、当前 base/head、CI、合入结果。
- 协调智能体维护顺序和窗口;任务 owner 负责分支内容和必要整改;窗口内的同步、冲突处置、Review、当前 HEAD CI 核验和所有 PR 合入由智能体执行。
- 普通功能 PR、修复 PR 和任务卡闭合 PR 都会移动 main,因此使用同一队列。组件仓按各自目标分支单独安排。
入队表示内容基本就绪且已取得该任务所需授权,不要求分支已经包含不断移动的最新 main。
依赖未完成或仍待必要人工决策的任务标为 `blocked`,不占窗口。默认按就绪顺序处理;紧急插队须在
#518 写明原因。
## 开发与等待
等待中的分支继续独立开发、提交和运行必要的局部检查,但不因为别的任务合入 main 就自动:
- 合并最新 main;
- 解决与最终交付无关的冲突;
- 推送新的最终候选;
- 重跑完整的最终 PR CI。
main 前进时,等待任务只记录“待轮到后同步”。开发期检查可以按任务需要运行,但不能作为之后
新 HEAD 的最终准入证据。
## 最终交付窗口
同一时间只给一个 Dev main 任务最终窗口。领取窗口前,协调智能体确认前一窗口已经释放、依赖
已完成,并在 #518 记录任务、PR、owner、当前 source HEAD 和领取时间。
窗口内按以下顺序执行:
1. 运行一次 `make task-sync`,纳入当时最新的 `origin/main`,解决冲突并核对组件组合。
2. 完成需要进入候选的代码、文档和任务卡内容,提交后固定 `base_sha` 与最终 `head_sha`。
3. 运行任务要求的本地检查;固定后的命令收据保存在源码外,避免为了记结果再改变 HEAD。
4. 对固定 PR HEAD 运行现有完整 CI;当前必需作业为 `test`、`test-pg`、`svc-realcombo`。
5. 完成当前 HEAD 的必要人工决策结论(如有)和智能体 Review。合入前再次核对 main、head、冲突和三项 CI。
6. 智能体使用固定 `head_commit_id`、`force_merge=false` 经 PR 合入,回读实际 main 与候选内容一致后释放窗口。
同一 base/head 下,验收、Review 和合入前的 `make task-sync` 只核验,输出 `changed=0`,不创建
额外提交,也不重复仍然有效的完整 CI。
## 对象变化与失败
以下任一对象变化都会使旧的最终结果失效:目标 base、候选 HEAD、组件 pin、检查定义或与结果
相关的环境。受影响任务保留旧结果的适用对象,回到队列等待新窗口;不得复制旧绿色状态或为了
省 CI 而降低检查。
冲突、代码/Review/CI 失败或等待必要人工决策时,当前任务保存现场、标为 `blocked` 并释放窗口,
其它分支继续局部开发。合入请求超时或返回不明时,先查询 PR 和 main 的真实状态;结果查清前
保持该目标暂停,不重复发送合入请求。
若窗口之外仍有 main 推进,当前固定候选按对象变化处理。协调智能体记录实际推进和受影响任务,
然后继续按队列安排;不要求为启用本规则证明所有历史写者、所有角色或自动互斥机制已经覆盖。
## 验收标准
一次排队交付满足以下条件即完成:
- 等待任务没有仅为追 main 而反复生成最终候选或重跑最终 CI;
- 轮到的任务只在窗口内同步最新 main,冲突在固定候选前解决;
- 实际合入的 HEAD 拥有当前必需 CI 和有效 Review;
- 合入由智能体执行,实际 main 内容与受审候选一致;
- 窗口结果写回 #518,随后才处理下一任务。
本规则不建设自动优先级、合并列车、批量预测合并、原子租约、旧执行者 fencing,也不修改
分支保护、CI workflow 或 runner 配置。将来需要自动队列时另行设计;不得从本 manual 流程推断
这些能力已经存在。
本规则已并入 [policy/delivery.md 合入调度](delivery.md#合入调度),以该节为唯一权威;
本文件只为保持旧链接可用而保留。
+1 -1
View File
@@ -16,7 +16,7 @@
| A7 证据 | 本地 SELF 收据;外部来源独立核验 | 首次捕获、完整脱敏输出/摘要、对象记录、原样导入 | `tests/test_evidence.py`;CI/审阅链接另核 |
| A8 阶段记录与迭代 | goal-first + agent-per-unit(AGENT-EXECUTION.md);模板默认连续执行,仅真实决策点需人工,没有全流程授权强制;实施/修复/测试仅 Cursor、agy、PI,每候选对安全可恢复失败最多 3 次全新尝试,全部安全耗尽后一次 Codex CLI 紧急尝试;Codex 平时仅规划、设计、计划或独立 Review;显式 fail-stop 立即停止;不限制推理时长、工具次数、规划方式或首次写文件时间;集成经成功 transport 的 agent-exec apply,或 pre-commit admit 精确内容来源;host 豁免仅为 .task/** 与 docs/superpowers/specs/**、plans/**;host 不直接实施;fallback_to_host 为 never;安全耗尽不发出 host-fallback-authorized,终态为 unavailable 或 blocked | policy/delivery.md、AGENT-EXECUTION.md;任务卡分别记录阶段状态、决策点、Agent 实施收据(配置摘要/角色/候选/attempt/CLI-模型/终态/transport/紧急尝试/源码外路径)、Review、冲突、当前 HEAD 的 CI 和合入;post-merge 默认仅提示 | `tests/test_task.py` 核对 goal-first/agent-per-unit 模板、正常路由、尝试上限、fail-stop、空闲超时与 apply/admit 来源表述,及钩子不自动写入;`scripts/agent-exec validate`(CI)与 parser·adapter·controller 假 CLI 契约(Development-Manager internal/agentexec 测试,经 deps/development-manager 钉住)为本地 SELF 强制行为;已安装 provider/model 身份待 U5,远端平台准入为 GATE;智能体核对授权范围、实施单元边界、Review、冲突及匹配 HEAD 的 CI/平台准入 |
| A9 长期方向与后续任务 | manual;模板不校验规划有效性或自动调度任务 | policy/delivery.md;PROJECT.md 提供规划入口,任务卡关联规划/里程碑并记录下一步建议 | `tests/test_task.py` 核对生成字段与未授权默认值;智能体核对当前规划、进展、重复任务和建议依据,新任务仍不自动获得授权 |
| A10 交付窗口 | manual;无自动互斥或接管 | [合入调度](merge-queue.md)与 #518:同一目标一个最终窗口,等待分支不追 main;轮到后同步、固定、验证并由智能体合入 | 每个实际 PR 核对窗口记录、精确 HEAD 三项 GATE、Review 和合入后 main;不要求额外试行或全写者审计 |
| A10 交付窗口 | manual;无自动互斥或接管 | [合入调度](delivery.md#合入调度)与 #518:同一目标一个最终窗口,等待分支不追 main;轮到后同步、固定、验证并由智能体合入 | 每个实际 PR 核对窗口记录、精确 HEAD 三项 GATE、Review 和合入后 main;不要求额外试行或全写者审计 |
| A11 固定后证据 | manual 选择源码外位置;工具捕获绝对未用路径/原始输出;无 OS 冻结 | policy/governance.md;RECEIPT 捕获,后续授权闭合再原样导入 | `tests/test_evidence.py` 固定对象、权限、覆盖拒绝、原样导入与幂等 |
## 失败处理
+72 -1
View File
@@ -97,6 +97,72 @@ def check_receipt(value):
return before_stale or after_stale
SUMMARY_FIELDS = {"summary_schema", "source", "timestamp", "command", "exit_code", "cwd",
"before_head", "after_head", "branch", "target_branch", "target_head",
"before_tracked_diff_sha256", "after_tracked_diff_sha256", "output_tail",
"output_sha256", "receipt_sha256", "receipt_path"}
def optional_match(pattern, value):
return value is None or (isinstance(value, str) and pattern.fullmatch(value) is not None)
def check_summary_fields(value):
if type(value.get("summary_schema")) is not int or value["summary_schema"] != evidence.SUMMARY_SCHEMA:
raise ValueError("unsupported summary schema")
keys = set(value)
targets = keys & set(evidence.TARGET_FIELDS)
if targets not in (set(), set(evidence.TARGET_FIELDS)) or keys - targets != SUMMARY_FIELDS:
raise ValueError("invalid summary fields")
for key in targets:
if not evidence.valid_branch(value[key]):
raise ValueError("invalid summary target: " + key)
for key in ("source", "timestamp", "command", "cwd", "branch", "target_branch", "receipt_path"):
if not isinstance(value[key], str):
raise ValueError("invalid summary field: " + key)
if value["source"] != "SELF" or type(value["exit_code"]) is not int or not 0 <= value["exit_code"] <= 255:
raise ValueError("invalid summary source or exit code")
try:
datetime.fromisoformat(value["timestamp"].replace("Z", "+00:00"))
except ValueError:
raise ValueError("invalid summary timestamp") from None
if not isinstance(value["before_head"], str) or not evidence.GIT_SHA.fullmatch(value["before_head"]):
raise ValueError("invalid summary before_head")
for key in ("after_head", "target_head"):
if not optional_match(evidence.GIT_SHA, value[key]):
raise ValueError("invalid summary field: " + key)
for key in ("before_tracked_diff_sha256", "output_sha256", "receipt_sha256"):
if not isinstance(value[key], str) or not evidence.SHA256.fullmatch(value[key]):
raise ValueError("invalid summary digest: " + key)
if not optional_match(evidence.SHA256, value["after_tracked_diff_sha256"]) or (
(value["after_head"] is None) != (value["after_tracked_diff_sha256"] is None)):
raise ValueError("invalid summary digest: after_tracked_diff_sha256")
if not isinstance(value["output_tail"], list) or not all(isinstance(line, str) for line in value["output_tail"]):
raise ValueError("invalid summary output tail")
path = Path(value["receipt_path"])
if not Path(value["cwd"]).is_absolute() or not path.is_absolute() or (
path.name != value["receipt_sha256"] + ".json"):
raise ValueError("invalid summary paths")
def check_summary(label, value):
"""Verify a card summary against its full receipt when it is stored on this machine."""
check_summary_fields(value)
path = Path(value["receipt_path"])
if not path.is_file():
warn(label, f"unverified: full receipt not available locally ({path})")
try:
heads = [value["before_head"]] + ([value["after_head"]] if value["after_head"] else [])
return any([check_head(head) for head in heads])
except (ValueError, OSError):
return False
full = json.loads(path.read_text(encoding="utf-8"))
stale = check_receipt(full)
if evidence.build_summary(full, value["receipt_path"], len(value["output_tail"])) != value:
raise ValueError("summary does not match full receipt")
return stale
def records(content):
"""Yield fenced JSON and timestamped legacy rows without interpreting text."""
block = None
@@ -125,7 +191,12 @@ def main():
return
for label, kind, record in records(content):
try:
stale = check_receipt(json.loads(record)) if kind == "json" else check_head(record)
if kind == "json":
value = json.loads(record)
stale = (check_summary(label, value) if isinstance(value, dict) and "summary_schema" in value
else check_receipt(value))
else:
stale = check_head(record)
except (ValueError, TypeError, KeyError, OSError, evidence.EvidenceError) as exc:
warn(label, "unverified: " + str(exc))
continue
+1 -1
View File
@@ -97,7 +97,7 @@ def materialize(repo):
for child, expected in submodules(repo):
check_children(repo, "before-update")
result = subprocess.run(["git", "-C", str(repo), "submodule", "update", "--init",
"--checkout", "--", str(child.relative_to(repo))])
"--jobs", "8", "--checkout", "--", str(child.relative_to(repo))])
if result.returncode:
raise RuntimeError(f"component materialization failed: {child}; preserve and reconcile before retrying")
materialize(child)
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""CI 等待:以单条阻塞命令代替手写 `sleep N; curl` 轮询。
用法:ci_wait.py [--sha SHA] [--interval SECONDS] [--timeout SECONDS] [--contexts NAME[,NAME...]]
--sha 缺省为 `git rev-parse HEAD`,须解析为完整 40 位 SHA;--interval 缺省 15(最小 5);--timeout 缺省 1200。
轮询 Gitea 合并状态 GET {api}/repos/{owner}/{repo}/commits/{sha}/status:
- 任一状态为 failure/error:逐条打印 `[ci-wait] FAIL <context> <target_url>`,立即退出 1;
- 成功须同时满足:至少一条状态、全部 success(skipped/warning 视同 success,不算失败)、每个 --contexts 名称是某个 context 的子串,
且连续两次轮询观察到同一全绿集合(防止迟注册的 job 被提前判绿);
- 超时:打印最后观察到的状态,退出 2;
- 远端/API/token 问题:`[verdict] REMOTE_UNVERIFIED`,退出 6(瞬时错误在超时内重试,
截止前从未成功轮询才以 6 放弃)。
状态变化时才打印一行,结束时打印 `[verdict] CI_SUCCESS|CI_FAILED|CI_TIMEOUT sha=<sha>`。
退出码:0 CI_SUCCESS;1 CI_FAILED;2 CI_TIMEOUT 或用法错误;6 REMOTE_UNVERIFIED。
仅使用标准库,复用 task_triage 的 Gitea 客户端与 token 读取。token 不打印、不写入任何产物。
"""
import argparse
import re
import subprocess
import sys
import time
import urllib.parse
from task_triage import EXIT_REMOTE, Stop, api_base, http_get_json, read_token, repo_slug
EXIT_FAILED = 1
EXIT_TIMEOUT = 2
EXIT_USAGE = 2
SHA_RE = re.compile(r"^[0-9a-f]{40}$")
FAILING = ("failure", "error")
# skipped:分级门禁下 base 非 main 的 PR 跳过全量作业;与 warning 一样是非失败终态。
DONE_OK = ("success", "skipped", "warning")
FATAL_HTTP = (400, 401, 403, 404, 410)
def git(*args):
proc = subprocess.run(["git", *args], capture_output=True, text=True, encoding="utf-8", errors="replace")
return proc.stdout.strip() if proc.returncode == 0 else None
def resolve_sha(sha):
value = (sha or "HEAD").strip()
if not SHA_RE.match(value.lower()):
value = git("rev-parse", "--verify", "--quiet", f"{value}^{{commit}}") or ""
value = value.lower()
if not SHA_RE.match(value):
raise Stop(EXIT_USAGE, f"--sha {sha or 'HEAD'} does not resolve to a full 40-hex SHA")
return value
def interval_arg(text):
value = float(text)
if value < 5:
raise argparse.ArgumentTypeError("interval must be at least 5 seconds")
return value
def timeout_arg(text):
value = float(text)
if value <= 0:
raise argparse.ArgumentTypeError("timeout must be positive")
return value
def observe(data):
"""[(context, state, target_url)],按 context 排序;忽略非 dict 项。"""
items = data.get("statuses") if isinstance(data, dict) else None
rows = []
for item in items if isinstance(items, list) else []:
if not isinstance(item, dict):
continue
state = str(item.get("status") or item.get("state") or "").strip().lower() or "unknown"
rows.append((str(item.get("context") or "(no context)"), state, str(item.get("target_url") or "")))
return sorted(rows)
def describe(rows):
return " ".join(f"{ctx}={state}" for ctx, state, _ in rows) if rows else "(no statuses yet)"
def green_key(rows, contexts):
"""满足全绿(success/skipped/warning)与 --contexts 时返回可比较的集合键,否则 None。"""
if not rows or any(state not in DONE_OK for _, state, _ in rows):
return None
if any(not any(name in ctx for ctx, _, _ in rows) for name in contexts):
return None
return tuple((ctx, state) for ctx, state, _ in rows)
def wait(sha, interval, timeout, contexts, sleep, clock):
token = read_token()
if not token:
raise Stop(EXIT_REMOTE, "no Gitea token (GITEA_TOKEN or ~/.secrets/gitea ACCESS_TOKEN=)")
origin = git("remote", "get-url", "origin")
if not origin:
raise Stop(EXIT_REMOTE, "cannot read origin remote URL")
owner, name = (urllib.parse.quote(part, safe="") for part in repo_slug(origin).split("/"))
url = f"{api_base()}/repos/{owner}/{name}/commits/{sha}/status"
deadline = clock() + timeout
rows, shown, previous_green, polled, last_error = [], None, None, False, None
while True:
try:
data = http_get_json(url, token)
except Stop as stop:
if stop.status in FATAL_HTTP:
raise
last_error = stop.message
else:
polled = True
rows = observe(data)
line = describe(rows)
if line != shown:
print(f"[ci-wait] sha={sha[:12]} {line}", flush=True)
shown = line
failing = [row for row in rows if row[1] in FAILING]
if failing:
for ctx, _, target in failing:
print(f"[ci-wait] FAIL {ctx} {target or '(no target_url)'}")
print(f"[verdict] CI_FAILED sha={sha}")
return EXIT_FAILED
key = green_key(rows, contexts)
if key is not None and key == previous_green:
print(f"[verdict] CI_SUCCESS sha={sha}")
return 0
previous_green = key
remaining = deadline - clock()
if remaining <= 0:
break
sleep(min(interval, remaining))
if not polled:
raise Stop(EXIT_REMOTE, last_error or "no successful status poll before the deadline")
print(f"[ci-wait] timeout after {timeout:g}s; last observed: {describe(rows)}")
missing = [n for n in contexts if not any(n in ctx for ctx, _, _ in rows)]
if missing:
print(f"[ci-wait] missing contexts: {', '.join(missing)}")
print(f"[verdict] CI_TIMEOUT sha={sha}")
return EXIT_TIMEOUT
def main(argv=None, sleep=time.sleep, clock=time.monotonic):
parser = argparse.ArgumentParser(prog="ci_wait.py", description="阻塞等待指定 SHA 的 Gitea CI 结论")
parser.add_argument("--sha")
parser.add_argument("--interval", type=interval_arg, default=15.0)
parser.add_argument("--timeout", type=timeout_arg, default=1200.0)
parser.add_argument("--contexts", default="")
args = parser.parse_args(argv)
contexts = [name.strip() for name in args.contexts.split(",") if name.strip()]
try:
sha = resolve_sha(args.sha)
return wait(sha, args.interval, args.timeout, contexts, sleep, clock)
except Stop as stop:
print(f"[ci-wait] ERROR {stop.message}")
if stop.code == EXIT_REMOTE:
print("[verdict] REMOTE_UNVERIFIED")
return stop.code
if __name__ == "__main__":
sys.exit(main())
+85 -3
View File
@@ -14,15 +14,19 @@ from pathlib import Path
import re
import subprocess
import sys
import tempfile
SCHEMA = 1
SUMMARY_SCHEMA = 1
DEFAULT_TAIL = 15
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")
PRE_MERGE_CLOSED = "已完成(合入前闭合)"
BASE_FIELDS = {"schema", "source", "timestamp", "command", "command_sha256", "cwd", "common_dir",
"before", "after", "after_error", "exit_code", "output", "output_sha256", "receipt_sha256"}
@@ -255,7 +259,7 @@ def task_context(current=None):
if (root / ".task").is_symlink():
raise EvidenceError("Task registration must belong to the current checkout")
card = find_card()
if card.is_symlink() or card.resolve().parent != root / ".task":
if card.is_symlink() or card.resolve().parent not in (root / ".task", root / ".task" / "archive"):
raise EvidenceError("Task registration must belong to the current checkout")
content = card.read_text()
locations = re.findall(r"^- 工作区:(.+?)\s*[((]worktree[))];分支:([^\s]+)\s*$", content, re.M)
@@ -318,14 +322,92 @@ def find_card():
card = root / ".task" / (branch.replace("/", "-") + ".md")
if card.is_file():
return card
owner = re.compile(r"^- 工作区:.*分支:" + re.escape(branch) + r"(?:\s|$)", re.M)
for candidate in (root / ".task").glob("*.md"):
if re.search(r"^- 工作区:.*分支:" + re.escape(branch) + r"(?:\s|$)", candidate.read_text(), re.M):
if owner.search(candidate.read_text()):
return candidate
# A card fold-closed before merge (task.sh close --self) lives in the archive until its PR lands.
for candidate in sorted((root / ".task" / "archive").glob("*.md")):
text = candidate.read_text()
status = re.search(r";状态:([^;\n]*)", text)
if owner.search(text) and status and status.group(1).startswith(PRE_MERGE_CLOSED):
return candidate
raise EvidenceError("No task card exists for the current branch")
def evidence_dir():
configured = os.environ.get("AGENT_EVIDENCE_DIR")
if configured:
base = Path(configured)
else:
base = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "agent-system-dev" / "evidence"
base.mkdir(mode=0o700, parents=True, exist_ok=True)
return base.resolve()
def store_receipt(receipt):
"""Write the full receipt once under its digest; identical existing content is accepted."""
directory = evidence_dir()
path = directory / (receipt["receipt_sha256"] + ".json")
encoded = json.dumps(receipt, ensure_ascii=False, indent=2) + "\n"
if path.exists() or path.is_symlink():
try:
same = not path.is_symlink() and json.loads(path.read_text(encoding="utf-8")) == receipt
except (OSError, ValueError):
same = False
if not same:
raise EvidenceError("Full receipt path holds different content: " + str(path))
return path
fd, temporary = tempfile.mkstemp(prefix=".receipt-", dir=directory)
try:
with os.fdopen(fd, "w", encoding="utf-8") as stream:
os.fchmod(stream.fileno(), 0o600)
stream.write(encoded)
stream.flush()
os.fsync(stream.fileno())
try:
os.link(temporary, path)
except FileExistsError:
return store_receipt(receipt)
finally:
os.unlink(temporary)
return path
def tail_lines():
value = os.environ.get("TAIL", "")
return int(value) if value.isdigit() else DEFAULT_TAIL
def build_summary(receipt, receipt_path, tail=None):
"""Card-sized view of a full receipt; every field is derived from the sanitized receipt."""
tail = tail_lines() if tail is None else tail
before, after = receipt["before"], receipt["after"]
summary = {
"summary_schema": SUMMARY_SCHEMA, "source": "SELF",
"timestamp": receipt["timestamp"], "command": receipt["command"],
"exit_code": receipt["exit_code"], "cwd": receipt["cwd"],
"before_head": before["head"], "after_head": after["head"] if after else None,
"branch": before["branch"], "target_branch": before["target_branch"],
"target_head": before["target_head"],
}
for key in TARGET_FIELDS:
if key in receipt:
summary[key] = receipt[key]
summary.update({
"before_tracked_diff_sha256": before["tracked_diff_sha256"],
"after_tracked_diff_sha256": after["tracked_diff_sha256"] if after else None,
"output_tail": receipt["output"].splitlines()[-tail:] if tail > 0 else [],
"output_sha256": receipt["output_sha256"],
"receipt_sha256": receipt["receipt_sha256"],
"receipt_path": str(receipt_path),
})
return summary
def append_card(card, receipt):
encoded = json.dumps(receipt, ensure_ascii=False, indent=2)
path = store_receipt(receipt)
encoded = json.dumps(build_summary(receipt, path), ensure_ascii=False, indent=2)
block = "\n## 证据(Evidence)\n\n" + (
"<!-- Local receipt: SELF. Digests detect corruption; they do not authenticate a reviewer or CI. -->\n"
+ "```json\n" + encoded + "\n```\n")
+4 -3
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
# 本地钩子(post-merge):pull/merge 落地后提示 owner 按授权闭合任务卡;ff-only 也会触发。
# precheck [cards] 报告欠账;此钩子只提示,不执行闭合或其他写入。
# 本地钩子(post-merge):pull/merge 落地后提示任务卡闭合方式;ff-only 也会触发。
# 新任务在合入前用 make task-close SELF=1 闭合本卡;precheck [cards] 报告的遗留卡才需主检出闭合。
# 此钩子只提示,不执行闭合或其他写入。
# 触发条件收紧到"主检出 + 集成分支 main"——任务 worktree 里 task-sync 的 merge 不触发,
# 防止把卡片闭合提示混进任务分支现场。
common=$(git rev-parse --git-common-dir) && common=$(cd "$common" 2>/dev/null && pwd) || exit 0
@@ -8,5 +9,5 @@ mainrepo=$(dirname "$common")
[ "$mainrepo" = "$PWD" ] || exit 0
[ "$(git rev-parse --abbrev-ref HEAD)" = "main" ] || exit 0
[ -f "$mainrepo/scripts/task.sh" ] || exit 0
echo "[next] owner 按已有授权在主检出运行 make task-close 闭合已合入任务卡;未授权时先取得授权。"
echo "[next] 新任务在合入前于任务 worktree 运行 make task-close SELF=1 闭合本卡;主检出的 make task-close 仅用于 precheck [cards] 报告的遗留卡,owner 按已有授权运行,未授权时先取得授权。"
exit 0
+218 -32
View File
@@ -152,29 +152,59 @@ cmd_start() {
echo "$initiative" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || die "invalid parent initiative branch"
case "${name#*/}" in "${initiative#*/}"-?*) ;; *) die "child slug must start with parent initiative slug plus '-'" ;; esac
fi
[ -n "${TRIAGE_RECEIPT:-}" ] || die "TRIAGE_RECEIPT required: make task-triage REQ='<需求>' TRIAGE_RECEIPT=<path> and decide first (no branch created)"
local slug planned_child=0
slug=$(echo "$name" | tr '/' '-')
if [ -z "${TRIAGE_RECEIPT:-}" ]; then
[ "$kind" = child ] || die "TRIAGE_RECEIPT required: make task-triage REQ='<需求>' TRIAGE_RECEIPT=<path> and decide first (no branch created)"
planned_child=1
fi
fetch_target
[ -n "${PRECHECK_RECEIPT:-}" ] || die "PRECHECK_RECEIPT required: capture RECEIPT=/tmp/task-precheck.json CMD='make precheck' make evidence first"
if [ -z "${PRECHECK_RECEIPT:-}" ]; then
PRECHECK_RECEIPT=$(mktemp "${TMPDIR:-/tmp}/task-precheck-${slug}.XXXXXX")
# evidence 捕获只接受未占用路径(独占创建):mktemp 只用来取唯一名。
rm -f -- "$PRECHECK_RECEIPT"
# start 只接受严格 READY 的 delivery precheck,local 模式的 LOCAL_READY 不能用于建任务。
(cd "$(git rev-parse --show-toplevel)" \
&& TASK_WORK_MODE=delivery RECEIPT="$PRECHECK_RECEIPT" CMD='make precheck' bash "${SCRIPT_DIR}/evidence.sh") \
|| die "auto precheck is not READY (receipt=${PRECHECK_RECEIPT}); resolve the verdict above first (no branch created)"
echo "[precheck] auto receipt=${PRECHECK_RECEIPT}"
fi
PRECHECK_RECEIPT=$(realpath -- "$PRECHECK_RECEIPT")
VALIDATE_RECEIPT="$PRECHECK_RECEIPT" VALIDATE_RECEIPT_PURPOSE=start bash "${SCRIPT_DIR}/evidence.sh"
# 分流核验必须先于任何 worktree/分支创建:收据与当前远端候选不一致则无副作用拒绝。
[ -f "$TRIAGE_RECEIPT" ] || die "triage receipt missing: ${TRIAGE_RECEIPT} (no branch created)"
TRIAGE_RECEIPT=$(realpath -- "$TRIAGE_RECEIPT")
local -a triage_args=(verify --receipt "$TRIAGE_RECEIPT" --kind "$kind")
if [ "$kind" = child ]; then triage_args+=(--initiative "$initiative"); fi
local triage_out triage_line triage_head triage_verdict triage_init triage_rsha triage_digest triage_note
if ! triage_out=$(python3 "${SCRIPT_DIR}/task_triage.py" "${triage_args[@]}"); then
printf '%s\n' "$triage_out" >&2
die "triage receipt rejected; no worktree or branch was created"
local triage_out triage_line triage_head="" triage_verdict triage_init triage_rsha triage_digest triage_note
local parent_head="" parent_source="" parent_card
if [ "$planned_child" -eq 1 ]; then
# 计划内子任务免分流:父 initiative 卡片的子任务登记已列出本分支(delivery 读 origin,local 读本地父分支)。
if [ "$(work_mode)" = delivery ]; then
parent_head=$(remote_parent_head "$initiative") || die "remote parent initiative ${initiative} unavailable; 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
parent_card=".task/$(echo "$initiative" | tr '/' '-').md"
git show "${parent_head}:${parent_card}" 2>/dev/null | registered_child "$name" \
|| die "${name} is not registered in ${initiative} ${parent_card} ## 子任务登记; register it in the parent card first or provide TRIAGE_RECEIPT (no worktree or branch was created)"
triage_note="- 需求分流:免(父 initiative 子任务登记已列出 ${name};parent_head_sha=${parent_head})"
else
# 分流核验必须先于任何 worktree/分支创建:收据与当前远端候选不一致则无副作用拒绝。
[ -f "$TRIAGE_RECEIPT" ] || die "triage receipt missing: ${TRIAGE_RECEIPT} (no branch created)"
TRIAGE_RECEIPT=$(realpath -- "$TRIAGE_RECEIPT")
local -a triage_args=(verify --receipt "$TRIAGE_RECEIPT" --kind "$kind")
if [ "$kind" = child ]; then triage_args+=(--initiative "$initiative"); fi
if ! triage_out=$(python3 "${SCRIPT_DIR}/task_triage.py" "${triage_args[@]}"); then
printf '%s\n' "$triage_out" >&2
die "triage receipt rejected; no worktree or branch was created"
fi
triage_line=$(printf '%s\n' "$triage_out" | sed -n 's/^\[triage-verified\] //p')
triage_head=$(triage_field selected_head_sha "$(printf '%s\n' "$triage_out" | sed -n 's/^\[triage-head\] //p')")
triage_verdict=$(triage_field verdict "$triage_line")
triage_init=$(triage_field selected_initiative "$triage_line")
triage_rsha=$(triage_field requirement_sha256 "$triage_line")
triage_digest=$(triage_field receipt_sha256 "$triage_line")
[ -n "$triage_verdict" ] && [ -n "$triage_rsha" ] && [ -n "$triage_digest" ] || die "triage verification output unparsable; no branch created"
triage_note="- 需求分流:verdict=${triage_verdict};selected_initiative=${triage_init:-无};requirement_sha256=${triage_rsha};triage_receipt=${TRIAGE_RECEIPT};receipt_sha256=${triage_digest}"
fi
triage_line=$(printf '%s\n' "$triage_out" | sed -n 's/^\[triage-verified\] //p')
triage_head=$(triage_field selected_head_sha "$(printf '%s\n' "$triage_out" | sed -n 's/^\[triage-head\] //p')")
triage_verdict=$(triage_field verdict "$triage_line")
triage_init=$(triage_field selected_initiative "$triage_line")
triage_rsha=$(triage_field requirement_sha256 "$triage_line")
triage_digest=$(triage_field receipt_sha256 "$triage_line")
[ -n "$triage_verdict" ] && [ -n "$triage_rsha" ] && [ -n "$triage_digest" ] || die "triage verification output unparsable; no branch created"
triage_note="- 需求分流:verdict=${triage_verdict};selected_initiative=${triage_init:-无};requirement_sha256=${triage_rsha};triage_receipt=${TRIAGE_RECEIPT};receipt_sha256=${triage_digest}"
check_worktree before-sync
# 已合入的同名分支不得重建(2026-10-05 实测:合入后再 task-start 同名会从新 main 长出
# 第二段历史,空卡覆盖已合入任务卡,两轮证据差点丢失)。复核/追加沿用原分支或换新 slug。
@@ -188,16 +218,17 @@ 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_source="" parent_note=""
local rmain wt mainroot start_ref parent_note=""
rmain=$(git rev-parse "origin/${TARGET_BRANCH}")
start_ref=$rmain
slug=$(echo "$name" | tr '/' '-')
local branch_meta
case "$kind" in
initiative)
branch_meta="- 分支类型:initiative;initiative_id:${slug};root_target:${TARGET_BRANCH};immediate_target:${TARGET_BRANCH};accepting_requirements:true" ;;
child)
if [ "$(work_mode)" = delivery ]; then
if [ "$planned_child" -eq 1 ]; then
: # 父头已在登记核验时解析(delivery 为刚取回的 origin 头)。
elif [ "$(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"
@@ -230,7 +261,7 @@ cmd_start() {
git worktree add -q "$wt" -b "$name" "$start_ref"
# 物化后按 submodule status 实际状态核对,不只信退出码:网络抖动或嵌套指针
# 漂移时 update 可能静默留坑(2026-09-30 实测深嵌套层带病通过,构建期才炸)。
(cd "$wt" && git submodule update --init --recursive >/dev/null 2>&1) \
(cd "$wt" && git submodule update --init --recursive --jobs 8 >/dev/null 2>&1) \
|| echo "[warn] submodule update 非零退出,见下方状态核对"
local sub_drift
sub_drift=$(git -C "$wt" submodule status --recursive 2>/dev/null | grep -cE '^[-+U]' || true)
@@ -238,14 +269,26 @@ cmd_start() {
&& echo "[warn] worktree 子模块物化不完整:${sub_drift} 项漂移/未初始化——构建前先核对(git -C $wt submodule status --recursive)"
local card="$wt/.task/${slug}.md"
mkdir -p "$wt/.task"
cat > "$card" <<EOF
# 模板:initiative→章程卡;child 与独立 fix/hotfix→精简卡;其余独立任务→完整卡。机读行在三者中逐字一致。
local template=full
case "$kind" in
initiative) template=charter ;;
child) template=lite ;;
*) case "$name" in fix/*|hotfix/*) template=lite ;; esac ;;
esac
local origin_url owner
origin_url=$(git -C "$wt" remote get-url origin 2>/dev/null || echo '(local)')
owner=$(git config user.name || echo unknown)
{
cat <<EOF
# ${name} 任务卡
任务主记录:<PR 链接,交付前填写>;状态:进行中;owner:$(git config user.name || echo unknown)
任务主记录:<PR 链接,交付前填写>;状态:进行中;owner:${owner}
## 基线与授权
- 权威仓:$(git -C "$wt" remote get-url origin 2>/dev/null || echo '(local)');目标分支:${TARGET_BRANCH};R0(initial_base_sha)= ${rmain}
EOF
[ "$template" = lite ] || printf '## 基线与授权\n\n'
cat <<EOF
- 权威仓:${origin_url};目标分支:${TARGET_BRANCH};R0(initial_base_sha)= ${rmain}
- 工作区:${wt}(worktree);分支:${name}
${branch_meta}
${triage_note}
@@ -254,21 +297,41 @@ ${parent_note}
- 交付模式:goal-first(目标、范围和交付终点一次确认;范围内连续推进)
- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md)
- 合入执行:agent(已授权交付范围内,Review 通过、冲突解决且当前 HEAD 的 CI 通过后合入)
EOF
[ "$template" = lite ] || cat <<'EOF'
- 人工决策点:仅限目标/范围/验收变更、重大取舍、高后果操作或无法自动判定的业务验收
- 当前阶段:需求拆解;当前增量:I1(待定义)
EOF
cat <<'EOF'
- 流程依据:仓库根目录 policy/delivery.md(归档后仍按仓库根目录定位)
## 目标与非目标
<预期结果、受影响入口/模块/契约;明确不做什么。>
EOF
if [ "$template" = charter ]; then
cat <<'EOF'
<章程:用途与目标、接纳范围与非目标、方案/计划链接、分级测试策略、验收条件、追加领域规则、生命周期。>
## 子任务登记
| 子任务分支 | 必需 | 状态 | PR |
|---|---|---|---|
| 待登记 | 否 | — | — |
EOF
else
echo '<预期结果、受影响入口/模块/契约;明确不做什么。>'
fi
cat <<'EOF'
## Agent 实施收据
按根目录 AGENT-EXECUTION.md 路由;外部 Agent 一单元一新进程/工作区。实施、修复与测试的正常候选仅为 Cursor、Antigravity(agy)和 PI;每个候选对安全可恢复失败最多 3 次全新尝试,然后才换下一候选。三者都安全耗尽后,才允许一次全新的 Codex CLI 紧急尝试。Codex 平时只用于规划、方案设计、实施计划或独立 Review。显式 fail-stop(failed-unknown、scope-expansion、blocked、cancelled、无效配置、未对账 Git 状态与安全失败)立即停止。不因推理时长、工具次数、规划方式或首次写文件时间判定失败;尝试只在显式终态失败、进程退出、总超时,或 stdin/stdout/stderr 均无活动的空闲超时时失败。CLI 可在目标、范围与安全边界内自主检查、计划、测试并迭代。实施集成必须是成功 transport 上的 scripts/agent-exec apply,或 pre-commit admit 的精确内容来源。host 直接管理的豁免仅为 .task/**、docs/superpowers/specs/** 与 docs/superpowers/plans/**。fallback 列只记录该行是否为 Codex 紧急尝试。安全耗尽不发出 host-fallback-authorized,终态为 unavailable 或 blocked。不授权 host 直接撰写该单元。原始日志保留在源码外收据路径。
路由与 fail-stop 规则见根目录 AGENT-EXECUTION.md 与 policy/delivery.md;每次尝试一行,原始日志留在源码外收据路径。
| 单元 ID | 预期范围 | 配置摘要 | 角色 | 候选顺序 | attempt | CLI/模型 | 终态 | transport | fallback | 源码外收据路径 |
|---|---|---|---|---|---|---|---|---|---|---|
| 待定义 | — | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | — |
EOF
[ "$template" = lite ] || cat <<'EOF'
## 关联方向与规划
@@ -308,6 +371,8 @@ ${parent_note}
| 增量/试验 | 假设 | 范围 | 判断和停止条件 | 授权/决策依据 | 结果 | 变更影响 | 需重审补测 |
|---|---|---|---|---|---|---|---|
| 待定义 | — | — | — | 待核对 | NOT_RUN | 待评估 | 待评估 |
EOF
cat <<EOF
## 同步记录
@@ -320,20 +385,26 @@ ${parent_note}
<!-- CMD="<命令>" make evidence 写入;手抄/转写输出视为无效证据(AGENTS.md 证据与验证门禁)。 -->
| 时间(UTC) | 命令 | 被测HEAD | exit | 来源 | sha256(16) |
|---|---|---|---|---|---|
EOF
[ "$template" = lite ] || cat <<'EOF'
## 交付调度
- 调度状态:未入队;调度记录:无
- entry_id:无;attempt_id:无;窗口执行者:无
- 固定候选:无(base/head、组件与相关配置引用实际尝试)
- 调度依据:policy/merge-queue.md;新模式启用前按既有交付门禁
- 调度依据:policy/delivery.md#合入调度
- 固定后证据:源码外 RECEIPT;实际结果后按授权原样导入和闭合
EOF
cat <<'EOF'
## 结果与验证(完成后填写)
- 结果:
- 验证:<结论摘要 PASS/FAIL/SKIP/NOT_RUN 逐项;逐条证据由 make evidence 写入上方证据表>
- 影响与剩余项:
EOF
[ "$template" = lite ] || cat <<'EOF'
## 规划进展与下一步
@@ -344,6 +415,8 @@ ${parent_note}
- 预期产物/验收条件:待定义
- 重复任务核对:待核对(已有/在途任务、PR 与规划记录)
- 启动授权:未授权(建议不自动启动新任务,仍须明确授权)
EOF
cat <<'EOF'
## 合入与清理
@@ -353,6 +426,7 @@ ${parent_note}
- 合入记录:NOT_RUN(交付授权范围、PR、被核验 HEAD、合入 SHA、目标分支及内容核验)
- 清理记录:NOT_RUN(授权依据、写者退出确认、worktree/分支/任务卡处置)
EOF
} > "$card"
(cd "$wt" && IMPORT_RECEIPT="$PRECHECK_RECEIPT" bash "${SCRIPT_DIR}/evidence.sh")
git -C "$wt" add .task
git -C "$wt" commit -q -m "Record the task card for ${slug}" \
@@ -456,6 +530,8 @@ cmd_sync() {
# (PR 链接+合并 SHA)与闭合记录,并 git mv 到 .task/archive/。变更落点:主检出运行→
# 新建 feature/card-close-* 分支推送(集成分支受保护,不直推);任务分支内运行须 --fold
# (随当前任务 PR 合入)。合并判定用合并提交主题(分支删除后仍可追溯),不依赖分支存活。
# 默认闭合方式是 close --self:任务分支在合入前把本分支的卡改为"已完成(合入前闭合)"并归档,
# 随本 PR 合入;上述合入后回填只用于遗留卡。
card_of_branch() { sed -n 's/^- 工作区:.*分支:\([A-Za-z0-9._/-]\+\).*/\1/p' "$1" | head -1; }
card_status() { sed -n 's/^.*;状态:\([^;]*\).*/\1/p' "$1" | head -1; }
@@ -467,13 +543,20 @@ card_initiative_id() { card_field "$1" initiative_id; }
card_root_target() { local v; v=$(card_field "$1" root_target); printf '%s\n' "${v:-${TARGET_BRANCH}}"; }
card_immediate_target() { local v; v=$(card_field "$1" immediate_target); printf '%s\n' "${v:-${TARGET_BRANCH}}"; }
card_accepting_requirements() { local v; v=$(card_field "$1" accepting_requirements); printf '%s\n' "${v:-false}"; }
PRE_MERGE_CLOSED="已完成(合入前闭合)"
branch_card() { # $1=branch → 当前检出中该分支的任务卡路径(无则空)
# 顶层优先;顶层无卡时只认 .task/archive/ 中状态为"已完成(合入前闭合)"的卡(close --self 合入前已归档)。
local pc=".task/$(echo "$1" | tr '/' '-').md"
[ -f "$pc" ] && { printf '%s\n' "$pc"; return 0; }
for pc in .task/*.md; do
[ -f "$pc" ] || continue
[ "$(card_of_branch "$pc")" = "$1" ] && { printf '%s\n' "$pc"; return 0; }
done
for pc in .task/archive/*.md; do
[ -f "$pc" ] || continue
[ "$(card_of_branch "$pc")" = "$1" ] || continue
case "$(card_status "$pc")" in "${PRE_MERGE_CLOSED}"*) printf '%s\n' "$pc"; return 0 ;; esac
done
return 0
}
active_initiatives() { # → 每行 "branch|initiative_id|card":根目标为集成分支、未完成、仍接收需求且未合入/待闭合
@@ -502,6 +585,16 @@ remote_parent_head() { # $1=父 initiative 分支 → 打印刚从 origin 取回
fi
git rev-parse --verify "refs/remotes/origin/$1^{commit}"
}
registered_child() { # stdin=父 initiative 卡片;$1=子任务分支 → 子任务登记表(task_pr_check 同一解析)列出该分支返回 0
python3 -c 'import sys
sys.path.insert(0, sys.argv[1])
from task_pr_check import Stop, child_registry
try:
rows = child_registry(sys.stdin.read())
except Stop as error:
sys.exit("ERROR: " + error.message)
sys.exit(0 if any(row["子任务分支"].strip("`").strip() == sys.argv[2] for row in rows) else 1)' "$SCRIPT_DIR" "$1"
}
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
@@ -665,8 +758,101 @@ open(path, 'w', encoding='utf-8').write(s)
PY
}
self_close_pr() { # $1=branch → 打印 Gitea 上 head 为该分支的开放 PR 号(空格分隔;token 只在进程内使用)
CLOSE_BRANCH="$1" TASK_SCRIPT_DIR="$SCRIPT_DIR" python3 - <<'PY'
import os, subprocess, sys
sys.path.insert(0, os.environ["TASK_SCRIPT_DIR"])
from task_triage import Stop, api_base, open_pulls, read_token, repo_slug
branch = os.environ["CLOSE_BRANCH"]
try:
origin = subprocess.run(["git", "remote", "get-url", "origin"], capture_output=True, text=True)
if origin.returncode != 0:
raise Stop(6, "origin remote is not configured")
token = read_token()
if not token:
raise Stop(6, "no Gitea token: set GITEA_TOKEN or ~/.secrets/gitea ACCESS_TOKEN")
pulls = open_pulls(api_base(), repo_slug(origin.stdout.strip()), token)
except Stop as stop:
print(f"ERROR: cannot look up the open PR for {branch}: {stop.message}", file=sys.stderr)
sys.exit(6)
numbers = []
for pr in pulls:
if not isinstance(pr, dict) or pr.get("state", "open") != "open":
continue
head = pr.get("head") if isinstance(pr.get("head"), dict) else {}
if head.get("ref") == branch and str(pr.get("number", "")).isdigit():
numbers.append(str(pr["number"]))
print(" ".join(numbers))
PY
}
card_self_close() { # $1=card $2=pr_num $3=pr_url(原地改写:状态、主记录、结果节标题、合入前闭合记录)
python3 - "$1" "$2" "$3" <<'PY'
import re, sys
path, pr, link = sys.argv[1:4]
s = open(path, encoding='utf-8').read()
s, n = re.subn(r';状态:[^;\n]*', ';状态:已完成(合入前闭合)', s, count=1)
if n != 1:
sys.exit(f"ERROR: {path} has no ';状态:' field; refuse to fold-close")
s = re.sub(r'任务主记录:[^;\n]*', f'任务主记录:PR #{pr} {link}', s, count=1)
s = s.replace('## 结果与验证(完成后填写)', '## 结果与验证', 1)
s = s.rstrip('\n') + f'''
## 闭合记录(合入前折叠)
- 本卡在 PR #{pr}({link})合入前闭合并归档,随该 PR 一同合入。
- 该 PR 的合并提交即本任务的落地记录。
- 合入后无需回填,也无需单独的闭合 PR。
'''
open(path, 'w', encoding='utf-8').write(s)
PY
}
cmd_close_self() {
local branch card dest pr nums web_base url owner repo link msg
branch=$(git symbolic-ref --quiet --short HEAD) || die "close --self 须在任务分支上运行(当前为 detached HEAD)"
[ "$branch" != "${TARGET_BRANCH}" ] || die "close --self 不能在 ${TARGET_BRANCH} 上运行;请在任务 worktree 内对本分支的卡运行"
[ -z "$(git status --porcelain | grep -v '^.. \.zcode/' || true)" ] || die "任务工作区不干净——先提交或处置现场再 close --self"
card=$(branch_card "$branch")
[ -n "$card" ] || die "找不到分支 ${branch} 的任务卡"
case "$card" in .task/archive/*) die "${card} 已在合入前闭合归档,无需重复闭合" ;; esac
dest=".task/archive/${card##*/}"
[ ! -e "$dest" ] || die "归档目标已存在:${dest}"
if [ -n "${TASK_PR:-}" ]; then
case "$TASK_PR" in *[!0-9]*) die "TASK_PR 只能是 PR 号(纯数字)" ;; esac
pr="$TASK_PR"
else
nums=$(self_close_pr "$branch") || die "无法经 Gitea 查询 ${branch} 的开放 PR;可用 TASK_PR=<号> 显式指定"
case "$nums" in
''|*' '*) die "需要恰好一个 head 为 ${branch} 的开放 PR(查到:${nums:-无});先建 PR,或用 TASK_PR=<号> 指定" ;;
esac
pr="$nums"
fi
web_base="${TASK_CARD_WEB_BASE:-https://git-home.u1680832.nyat.app:30319}"
url=$(git remote get-url origin); url="${url%.git}"
repo=$(basename "$url"); owner=$(basename "$(dirname "$url")")
link="${web_base}/${owner}/${repo}/pulls/${pr}"
card_self_close "$card" "$pr" "$link"
mkdir -p .task/archive
git mv "$card" "$dest"
git add -- "$dest"
msg=$(mktemp)
{ echo "Fold-close the task card before merge"
echo
echo "Why: close the task card inside its own PR so landing it needs no post-merge backfill or separate close PR."
echo "Evidence: ${card} -> ${dest}; status ${PRE_MERGE_CLOSED}; PR #${pr} ${link}."
echo "Impact: task metadata only; the card is archived within PR #${pr} and lands with its merge commit."
echo "Refs: task.sh close --self"
} > "$msg"
git commit -q -F "$msg"; rm -f "$msg"
# push 失败重试一次:家服隧道间歇抖动(同 cmd_close)。
git push -q -u origin HEAD || { sleep 5; git push -q -u origin HEAD; }
echo "[closed] ${card} 已在 PR #${pr} 合入前闭合并归档;合入后无需 task-close"
}
cmd_close() {
need_repo
if [ "${1:-}" = "--self" ]; then shift; cmd_close_self "$@"; return; fi
local fold=0
[ "${1:-}" = "--fold" ] && fold=1
fetch_target
@@ -854,7 +1040,7 @@ cmd_finish() {
return 1
fi
# Keep card closure separate: its push/PR side effects are not implied by local disposal.
echo "[next] inspect task-card closure with make task-close in the main checkout when authorized"
echo "[next] cards are normally fold-closed before merge with make task-close SELF=1; only legacy cards reported by precheck [cards] need make task-close in the main checkout when authorized"
}
case "${1:-}" in
+21 -8
View File
@@ -8,7 +8,8 @@
须仍有开放 PR 指向其 root_target。--final(仅 initiative):accepting_requirements=false,且
`## 子任务登记` 中「必需=是」的子任务为「已合入」(PR 已合并入本 initiative)或「已延期」。
ci
仅 pull_request 事件:按 GITHUB_HEAD_REF 在检出的 .task/ 顶层取卡片,核对 GITHUB_BASE_REF。
仅 pull_request 事件:按 GITHUB_HEAD_REF 在检出的 .task/ 顶层取卡片(顶层无卡时取 .task/archive/
中状态为「已完成(合入前闭合)」的卡),核对 GITHUB_BASE_REF。
不联网、不需要 token;非 pull_request 事件跳过(退出 0)。
期望目标:child → immediate_target;其余(initiative / standalone / 无卡)→ root_target(无卡即 main)。
@@ -24,11 +25,12 @@ import sys
import urllib.parse
from pathlib import Path
from task_triage import (EXIT_REMOTE, Stop, api_base, as_dict, card_branch, http_get_json,
from task_triage import (EXIT_REMOTE, Stop, api_base, as_dict, card_branch, card_status, http_get_json,
open_pulls, read_token, repo_slug, target_branch)
EXIT_FAILED = 1
EXIT_USAGE = 2
PRE_MERGE_CLOSED = "已完成(合入前闭合)"
CHILD_COLUMNS = ("子任务分支", "必需", "状态", "PR")
DONE_STATES = ("已合入", "已延期")
@@ -64,16 +66,27 @@ def expected_base(meta):
return meta["immediate_target"] if meta["kind"] == "child" else meta["root_target"]
def find_card(branch):
"""只看检出树的 .task/ 顶层(archive 不参与);返回 (路径, 文本),无卡返回 (None, None)。"""
def matching_card(directory, branch, archived):
"""directory 下声明该分支的唯一卡片;archived=True 时只认状态以「已完成(合入前闭合)」开头的卡。"""
found = []
for path in sorted(Path(".task").glob("*.md")):
for path in sorted(Path(directory).glob("*.md")):
text = path.read_text(encoding="utf-8", errors="replace")
if card_branch(text) == branch:
found.append((path, text))
if card_branch(text) != branch:
continue
if archived and not card_status(text).startswith(PRE_MERGE_CLOSED):
continue
found.append((path, text))
if len(found) > 1:
raise Stop(EXIT_FAILED, f"ambiguous task cards for {branch}: {', '.join(str(p) for p, _ in found)}")
return found[0] if found else (None, None)
return found[0] if found else None
def find_card(branch):
"""先看检出树的 .task/ 顶层;顶层无卡时再看 .task/archive/ 中合入前闭合(close --self)的卡,
其余归档卡不参与。返回 (路径, 文本),无卡返回 (None, None)。"""
return (matching_card(".task", branch, False)
or matching_card(".task/archive", branch, True)
or (None, None))
def branch_meta(branch, target):
+46 -16
View File
@@ -35,6 +35,8 @@ DEFAULT_MAX_AGE = 14400
PAGE_LIMIT = 50
MAX_PAGES = 100
SECRETS_FILE = "~/.secrets/gitea"
SECTION_RE = re.compile(r"^([A-Za-z][A-Za-z0-9_-]*):$")
TOKEN_RE = re.compile(r"[A-Za-z0-9._~+/=-]+")
CARD_RE = re.compile(r"^\.task/[^/]+\.md$")
CANDIDATE_KEYS = ("branch", "head_sha", "card_path", "card_sha256", "pr_number", "pr_state", "pr_base", "pr_head_sha")
RECEIPT_KEYS = ("repo", "root_target", "root_target_sha", "requirement", "discovered_at",
@@ -51,10 +53,11 @@ EXIT_VERIFY = 8
class Stop(Exception):
"""受控失败:退出码 + 不含 token 的说明。"""
def __init__(self, code, message):
def __init__(self, code, message, status=None):
super().__init__(message)
self.code = code
self.message = message
self.status = status
def utc_now():
@@ -116,21 +119,43 @@ def api_base():
return (os.environ.get("GITEA_API_URL", "").strip() or DEFAULT_API).rstrip("/")
def read_token():
token = os.environ.get("GITEA_TOKEN", "").strip()
if token:
return token
def secrets_file_token():
"""HOME/IMC 分段时只取 HOME 段的 ACCESS_TOKEN(同 policy/facts.md F2);无分段时取第一条。"""
try:
with open(os.path.expanduser(SECRETS_FILE), encoding="utf-8") as fh:
for raw in fh:
line = raw.strip()
if line.startswith("- "):
line = line[2:].strip()
if line.startswith("ACCESS_TOKEN="):
return line.split("=", 1)[1].strip() or None
lines = fh.read().splitlines()
except (OSError, UnicodeDecodeError):
return None
return None
first = home = None
sectioned = False
section = None
for raw in lines:
line = raw.strip()
header = SECTION_RE.match(line)
if header:
sectioned = True
section = header.group(1)
continue
if line.startswith("- "):
line = line[2:].strip()
if not line.startswith("ACCESS_TOKEN="):
continue
value = line.split("=", 1)[1].strip()
if first is None:
first = value
if section == "HOME" and home is None:
home = value
return (home if sectioned else first) or None
def read_token():
token = os.environ.get("GITEA_TOKEN", "").strip() or secrets_file_token()
if not token:
return None
if not TOKEN_RE.fullmatch(token):
raise Stop(EXIT_REMOTE, "Gitea token is malformed (expected one value without whitespace); "
"check GITEA_TOKEN or ~/.secrets/gitea")
return token
def remote_heads():
@@ -146,15 +171,20 @@ def remote_heads():
def http_get_json(url, token):
request = urllib.request.Request(
url, headers={"Accept": "application/json", "Authorization": f"token {token}"})
try:
request = urllib.request.Request(
url, headers={"Accept": "application/json", "Authorization": f"token {token}"})
with urllib.request.urlopen(request, timeout=20) as response:
body = response.read()
except urllib.error.HTTPError as exc:
raise Stop(EXIT_REMOTE, f"Gitea API returned HTTP {exc.code}")
raise Stop(EXIT_REMOTE, f"Gitea API returned HTTP {exc.code}", status=exc.code) from None
except (urllib.error.URLError, http.client.HTTPException, OSError) as exc:
raise Stop(EXIT_REMOTE, f"Gitea API unreachable ({type(exc).__name__})")
raise Stop(EXIT_REMOTE, f"Gitea API unreachable ({type(exc).__name__})") from None
except Stop:
raise
except Exception as exc:
# 异常文本可能含 Authorization 头(如 http.client 的 Invalid header value),只报类型名。
raise Stop(EXIT_REMOTE, f"Gitea API request failed ({type(exc).__name__})") from None
try:
return json.loads(body.decode("utf-8"))
except ValueError:
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""按分支改动选择最小直接检查:host 实施中的 make 目标子集(完整套件仍以 CI 为准)。
用法:test_affected.py [--base REF] [--dry-run]
base:--base,否则环境变量 BASE,否则当前分支卡片的 immediate_target(task_pr_check 助手),
再否则 origin/main;裸分支名在 origin/<name> 存在时解析为 origin/<name>。
改动路径 = `git diff <merge-base>...HEAD` ∪ 未提交的已跟踪改动(相对 HEAD)。路径到目标:
scripts/** tests/** .gitea/** policy/** Makefile AGENTS.md AGENT-EXECUTION.md PROJECT.md → test-governance
modules/{core,backend,frontend,ui} → test-{core,backend,frontend,ui};deps/agent-command → test-agentcmd;
deps/agent-{intent,native,squad} → test-libs;gitlink 本身或 .gitmodules → test-pins;
modules/** 或 scripts/lint-boundaries.sh → test-boundaries;其余(.task/**、docs/**、其他 *.md)不选。
选中任一目标时先跑 test-worktree;单次 `make <targets>` 执行,退出码随 make。
退出码:make 的退出码;无目标或 --dry-run 为 0;用法/ref 错误为 2。仅使用标准库。
"""
import argparse
import os
import subprocess
import sys
DEFAULT_BASE = "origin/main"
EXIT_USAGE = 2
GITLINK_MODE = "160000"
ORDER = ("test-governance", "test-boundaries", "test-pins", "test-libs", "test-core",
"test-backend", "test-frontend", "test-ui", "test-agentcmd")
GOVERNANCE_DIRS = ("scripts/", "tests/", ".gitea/", "policy/")
GOVERNANCE_FILES = ("Makefile", "AGENTS.md", "AGENT-EXECUTION.md", "PROJECT.md")
COMPONENTS = {
"modules/core": "test-core",
"modules/backend": "test-backend",
"modules/frontend": "test-frontend",
"modules/ui": "test-ui",
"deps/agent-command": "test-agentcmd",
"deps/agent-intent": "test-libs",
"deps/agent-native": "test-libs",
"deps/agent-squad": "test-libs",
}
class Stop(Exception):
pass
def git(*args):
proc = subprocess.run(["git", *args], capture_output=True, text=True, encoding="utf-8", errors="replace")
if proc.returncode != 0:
raise Stop(f"git {' '.join(args)} failed: {proc.stderr.strip() or proc.returncode}")
return proc.stdout
def ref_exists(ref):
return subprocess.run(["git", "rev-parse", "--verify", "--quiet", f"{ref}^{{commit}}"],
capture_output=True).returncode == 0
def card_target():
"""当前分支卡片的 immediate_target;无分支/无助手/无卡时返回 None。"""
try:
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import task_pr_check
meta, text = task_pr_check.branch_meta(task_pr_check.current_branch(), "main")
except Exception:
return None
return meta.get("immediate_target") if text is not None else None
def resolve_base(requested):
name = (requested or os.environ.get("BASE", "")).strip() or card_target() or DEFAULT_BASE
if not name.startswith("origin/") and ref_exists(f"refs/remotes/origin/{name}"):
name = f"origin/{name}"
if not ref_exists(name):
raise Stop(f"base {name} does not resolve to a commit")
return name
def raw_changes(*diff_args):
"""{path: is_gitlink},取自 `git diff --raw -z --no-renames`。"""
out = git("diff", "--raw", "-z", "--no-renames", *diff_args)
fields = out.split("\0")
changes = {}
for meta, path in zip(fields[0::2], fields[1::2]):
modes = meta.lstrip(":").split()[:2]
changes[path] = changes.get(path, False) or GITLINK_MODE in modes
return changes
def changed_paths(base):
merge_base = git("merge-base", base, "HEAD").strip()
changes = raw_changes(merge_base, "HEAD")
for path, gitlink in raw_changes("HEAD").items():
changes[path] = changes.get(path, False) or gitlink
return changes
def targets_for(path, gitlink):
found = set()
if path.startswith(GOVERNANCE_DIRS) or path in GOVERNANCE_FILES:
found.add("test-governance")
for prefix, target in COMPONENTS.items():
if path == prefix or path.startswith(prefix + "/"):
found.add(target)
if gitlink or path == ".gitmodules":
found.add("test-pins")
if path.startswith("modules/") or path == "scripts/lint-boundaries.sh":
found.add("test-boundaries")
return found
def select(changes):
found = set()
for path, gitlink in changes.items():
found |= targets_for(path, gitlink)
return ["test-worktree", *(t for t in ORDER if t in found)] if found else []
def main(argv=None):
parser = argparse.ArgumentParser(prog="test_affected.py", description="按分支改动运行最小直接检查")
parser.add_argument("--base")
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args(argv)
try:
os.chdir(git("rev-parse", "--show-toplevel").strip())
base = resolve_base(args.base)
changes = changed_paths(base)
except Stop as stop:
print(f"[affected] ERROR {stop}")
return EXIT_USAGE
targets = select(changes)
print(f"[affected] base={base} changed={len(changes)} targets={','.join(targets) or 'none'}", flush=True)
if not targets:
print("[affected] nothing to test")
return 0
if args.dry_run:
print(f"[affected] dry-run: make {' '.join(targets)}")
return 0
return subprocess.run(["make", *targets]).returncode
if __name__ == "__main__":
sys.exit(main())
+123
View File
@@ -0,0 +1,123 @@
"""Exercise check-evidence.py on card summaries and legacy full receipt blocks."""
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
class CheckEvidenceTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.base = Path(self.temp.name)
self.repo = self.base / "repo"
self.repo.mkdir()
self.git("init", "-b", "main")
self.git("config", "user.name", "Evidence Test")
self.git("config", "user.email", "evidence@example.invalid")
(self.repo / "tracked").write_text("initial\n")
self.git("add", "tracked")
self.git("commit", "-qm", "Initial")
self.git("update-ref", "refs/remotes/origin/main", self.git("rev-parse", "HEAD"))
self.card = self.repo / ".task/main.md"
self.card.parent.mkdir()
self.card.write_text("# Test task\n")
self.env = os.environ.copy()
for key in ("CMD", "SRC", "RECEIPT", "IMPORT_RECEIPT", "VALIDATE_RECEIPT", "TAIL"):
self.env.pop(key, None)
self.env["AGENT_EVIDENCE_DIR"] = str(self.base / "evidence")
def git(self, *args):
return subprocess.check_output(["git", *args], cwd=self.repo, text=True,
stderr=subprocess.DEVNULL).strip()
def record(self, command="printf 'checked\\n'"):
result = subprocess.run(["bash", str(SCRIPTS / "evidence.sh")], cwd=self.repo,
env=self.env | {"CMD": command}, capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
text = self.card.read_text()
block = text.split("```json\n", 1)[1].split("\n```", 1)[0]
return json.loads(block)
def check(self):
result = subprocess.run(["python3", str(SCRIPTS / "check-evidence.py"), str(self.card)],
cwd=self.repo, capture_output=True, text=True)
return result.stdout + result.stderr
def replace_block(self, value):
prefix, rest = self.card.read_text().split("```json\n", 1)
suffix = rest.split("\n```", 1)[1]
self.card.write_text(prefix + "```json\n" + json.dumps(value, indent=2) + "\n```" + suffix)
def advance(self):
(self.repo / "tracked").write_text("changed\n")
self.git("commit", "-qam", "Change code")
def test_fresh_summary_passes(self):
summary = self.record()
self.assertNotIn("submodules", self.card.read_text())
self.assertEqual(self.check(), "")
self.assertTrue(Path(summary["receipt_path"]).is_file())
def test_summary_detects_stale_head_through_full_receipt(self):
self.record()
self.advance()
output = self.check()
self.assertIn("recorded HEAD predates non-card changes", output)
self.assertNotIn("unverified", output)
def test_tampered_summary_warns(self):
summary = self.record()
for field, value in (("command", "make test"), ("exit_code", 1), ("output_tail", ["forged"]),
("before_head", "0" * 40)):
with self.subTest(field=field):
self.replace_block(dict(summary, **{field: value}))
self.assertIn("unverified: summary does not match full receipt", self.check())
def test_malformed_summary_fields_warn(self):
summary = self.record()
for field, value in (("receipt_sha256", "zz"), ("exit_code", True), ("output_tail", "text"),
("receipt_path", "relative.json")):
with self.subTest(field=field):
self.replace_block(dict(summary, **{field: value}))
self.assertIn("unverified", self.check())
missing = dict(summary)
del missing["command"]
self.replace_block(missing)
self.assertIn("unverified: invalid summary fields", self.check())
def test_tampered_full_receipt_warns(self):
summary = self.record()
path = Path(summary["receipt_path"])
full = json.loads(path.read_text())
full["output"] = "changed\n"
path.write_text(json.dumps(full))
self.assertIn("unverified: receipt checksum mismatch", self.check())
def test_missing_full_receipt_warns_and_still_checks_summary_heads(self):
summary = self.record()
path = Path(summary["receipt_path"])
path.unlink()
output = self.check()
self.assertIn(f"unverified: full receipt not available locally ({path})", output)
self.assertNotIn("predates", output)
self.advance()
self.assertIn("recorded HEAD predates non-card changes", self.check())
def test_legacy_full_receipt_block_is_still_accepted(self):
summary = self.record()
full = json.loads(Path(summary["receipt_path"]).read_text())
self.replace_block(full)
self.assertEqual(self.check(), "")
full["output"] = "changed\n"
self.replace_block(full)
self.assertIn("unverified: receipt checksum mismatch", self.check())
if __name__ == "__main__":
unittest.main()
+36
View File
@@ -0,0 +1,36 @@
"""scripts/check-worktree.py 的组件物化参数:逐个 submodule 物化,并以 --jobs 8 并行拉取。"""
import importlib.util
import subprocess
import unittest
from pathlib import Path
from unittest import mock
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "check-worktree.py"
spec = importlib.util.spec_from_file_location("check_worktree", SCRIPT)
check_worktree = importlib.util.module_from_spec(spec)
spec.loader.exec_module(check_worktree)
class MaterializeTests(unittest.TestCase):
def test_submodule_update_uses_parallel_jobs_per_component(self):
repo = Path("/fixture/repo")
child = repo / "modules" / "core"
calls = []
def fake_submodules(path):
return [(child, "a" * 40)] if path == repo else []
def fake_run(argv, *args, **kwargs):
calls.append(argv)
return subprocess.CompletedProcess(argv, 0)
with mock.patch.object(check_worktree, "submodules", fake_submodules), \
mock.patch.object(check_worktree, "check_children", lambda *a: None), \
mock.patch.object(check_worktree.subprocess, "run", fake_run):
check_worktree.materialize(repo)
self.assertEqual(calls, [["git", "-C", str(repo), "submodule", "update", "--init",
"--jobs", "8", "--checkout", "--", "modules/core"]])
if __name__ == "__main__":
unittest.main()
+235
View File
@@ -0,0 +1,235 @@
"""CI 等待(scripts/ci_wait.py):临时 Git 仓库 + 进程内 Gitea 合并状态桩 + 注入的时钟与 sleep。
全部离线运行:不联网、不读取真实 ~/.secrets(HOME 指向临时目录),token 为合成值。
"""
import contextlib
import http.server
import io
import json
import os
import socket
import subprocess
import sys
import tempfile
import threading
import unittest
from pathlib import Path
from unittest import mock
SOURCE = Path(__file__).resolve().parents[1]
SCRIPT = SOURCE / "scripts" / "ci_wait.py"
sys.path.insert(0, str(SOURCE / "scripts"))
import ci_wait # noqa: E402
TOKEN = "synthetic-gitea-token-not-real"
def status(context, state, url=None):
return {"context": context, "status": state, "target_url": url or f"https://ci.invalid/{context}"}
class FakeGitea:
"""Gitea 合并状态桩(127.0.0.1):校验 token;每次 GET 依次返回脚本中的下一份 statuses(末项重复)。"""
def __init__(self, token=TOKEN):
self.token = token
self.script = []
self.requests = []
stub = self
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def _send(self, code, obj):
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.headers.get("Authorization") != f"token {stub.token}":
return self._send(401, {"message": "unauthorized"})
parts = self.path.split("?", 1)[0].split("/")
if parts[1:4] != ["api", "v1", "repos"] or len(parts) != 9 or parts[6] != "commits" \
or parts[8] != "status":
return self._send(404, {"message": "not found"})
stub.requests.append("/".join(parts[4:6]) + "@" + parts[7])
statuses = stub.script.pop(0) if len(stub.script) > 1 else (stub.script or [[]])[0]
return self._send(200, {"sha": parts[7], "statuses": statuses})
self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
self.url = f"http://127.0.0.1:{self.server.server_address[1]}/api/v1"
threading.Thread(target=self.server.serve_forever, daemon=True).start()
def close(self):
self.server.shutdown()
self.server.server_close()
def closed_port_url():
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
return f"http://127.0.0.1:{port}/api/v1"
class FakeClock:
def __init__(self):
self.now = 0.0
self.sleeps = []
def clock(self):
return self.now
def sleep(self, seconds):
self.sleeps.append(seconds)
self.now += seconds
class CiWaitTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix="ci-wait-")
self.addCleanup(self.tmp.cleanup)
root = Path(self.tmp.name)
self.repo = root / "repo"
env = {k: v for k, v in os.environ.items()
if not k.startswith("GIT_") and k not in {"GITEA_API_URL", "GITEA_REPO", "GITEA_TOKEN"}}
env.update({"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null", "GIT_TERMINAL_PROMPT": "0",
"GIT_AUTHOR_NAME": "CI Wait Test", "GIT_AUTHOR_EMAIL": "ci-wait@example.invalid",
"GIT_COMMITTER_NAME": "CI Wait Test", "GIT_COMMITTER_EMAIL": "ci-wait@example.invalid",
"HOME": str(root / "no-home"),
"NO_PROXY": "127.0.0.1,localhost", "no_proxy": "127.0.0.1,localhost",
"GITEA_TOKEN": TOKEN})
self.gitea = FakeGitea()
self.addCleanup(self.gitea.close)
env["GITEA_API_URL"] = self.gitea.url
self.env = env
env_patch = mock.patch.dict(os.environ, env, clear=True)
env_patch.start()
self.addCleanup(env_patch.stop)
self.git(root, "init", "--initial-branch=main", str(self.repo))
(self.repo / "README").write_text("fixture\n")
self.git(self.repo, "add", "README")
self.git(self.repo, "commit", "-m", "Create fixture")
self.git(self.repo, "remote", "add", "origin", "ssh://git@git-ssh.example.invalid:31411/fixture/repo.git")
self.sha = self.git(self.repo, "rev-parse", "HEAD")
cwd = os.getcwd()
os.chdir(self.repo)
self.addCleanup(os.chdir, cwd)
def git(self, cwd, *args):
return subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, check=True).stdout.strip()
def run_wait(self, *args, env=None):
clock = FakeClock()
out = io.StringIO()
with mock.patch.dict(os.environ, env or {}), contextlib.redirect_stdout(out), \
contextlib.redirect_stderr(out):
code = ci_wait.main(list(args), sleep=clock.sleep, clock=clock.clock)
text = out.getvalue()
self.assertNotIn(TOKEN, text)
return code, text, clock
def test_success_requires_two_consecutive_all_green_polls(self):
self.gitea.script = [[status("ci/test", "pending")], [status("ci/test", "pending")],
[status("ci/test", "success")], [status("ci/test", "success")]]
code, out, clock = self.run_wait("--timeout", "300")
self.assertEqual(code, 0, out)
self.assertIn(f"[verdict] CI_SUCCESS sha={self.sha}", out)
self.assertEqual(len(self.gitea.requests), 4, out)
self.assertEqual(self.gitea.requests[0], f"fixture/repo@{self.sha}")
self.assertEqual(clock.sleeps, [15.0, 15.0, 15.0], out)
# 状态行只在变化时打印:pending 与 success 各一行。
lines = [line for line in out.splitlines() if line.startswith("[ci-wait] sha=")]
self.assertEqual(len(lines), 2, out)
def test_late_registering_job_does_not_succeed_early(self):
self.gitea.script = [[status("ci/test", "success")],
[status("ci/test", "success"), status("ci/build", "pending")],
[status("ci/test", "success"), status("ci/build", "success")],
[status("ci/test", "success"), status("ci/build", "success")]]
code, out, _ = self.run_wait("--timeout", "300")
self.assertEqual(code, 0, out)
self.assertEqual(len(self.gitea.requests), 4, out)
self.assertIn("ci/build=pending", out)
def test_failure_exits_fast_with_target_url(self):
url = "https://ci.invalid/runs/42"
self.gitea.script = [[status("ci/test", "pending"), status("ci/build", "success")],
[status("ci/test", "failure", url), status("ci/build", "success")]]
code, out, _ = self.run_wait("--sha", self.sha, "--timeout", "300")
self.assertEqual(code, 1, out)
self.assertEqual(len(self.gitea.requests), 2, out)
self.assertIn(f"[ci-wait] FAIL ci/test {url}", out)
self.assertNotIn("FAIL ci/build", out)
self.assertIn(f"[verdict] CI_FAILED sha={self.sha}", out)
def test_missing_required_context_waits_until_timeout(self):
self.gitea.script = [[status("ci/test", "success")]]
code, out, clock = self.run_wait("--contexts", "ci/test,e2e", "--interval", "5", "--timeout", "30")
self.assertEqual(code, 2, out)
self.assertEqual(clock.now, 30.0)
self.assertIn("last observed: ci/test=success", out)
self.assertIn("missing contexts: e2e", out)
self.assertIn(f"[verdict] CI_TIMEOUT sha={self.sha}", out)
def test_skipped_and_warning_contexts_count_as_done_not_failed(self):
self.gitea.script = [[status("ci/test", "pending"), status("ci/test-pg", "skipped")],
[status("ci/test", "success"), status("ci/test-pg", "skipped"),
status("ci/svc-realcombo", "warning")]]
code, out, _ = self.run_wait("--contexts", "test-pg", "--timeout", "300")
self.assertEqual(code, 0, out)
self.assertEqual(len(self.gitea.requests), 3, out)
self.assertNotIn("FAIL", out)
self.assertIn(f"[verdict] CI_SUCCESS sha={self.sha}", out)
def test_skipped_does_not_mask_a_pending_context(self):
self.gitea.script = [[status("ci/test", "pending"), status("ci/test-pg", "skipped")]]
code, out, _ = self.run_wait("--interval", "5", "--timeout", "30")
self.assertEqual(code, 2, out)
self.assertIn(f"[verdict] CI_TIMEOUT sha={self.sha}", out)
def test_context_names_match_as_substrings(self):
self.gitea.script = [[status("CI / test (pull_request)", "success")]]
code, out, _ = self.run_wait("--contexts", "test", "--timeout", "60")
self.assertEqual(code, 0, out)
def test_unreachable_api_exits_6_after_retrying_until_deadline(self):
code, out, clock = self.run_wait("--timeout", "60", env={"GITEA_API_URL": closed_port_url()})
self.assertEqual(code, 6, out)
self.assertEqual(clock.now, 60.0)
self.assertIn("[verdict] REMOTE_UNVERIFIED", out)
def test_unauthorized_or_missing_token_exits_6_immediately(self):
code, out, clock = self.run_wait("--timeout", "60", env={"GITEA_TOKEN": "wrong-token"})
self.assertEqual(code, 6, out)
self.assertEqual(clock.sleeps, [], out)
self.assertIn("HTTP 401", out)
with mock.patch.dict(os.environ):
del os.environ["GITEA_TOKEN"]
code, out, _ = self.run_wait("--timeout", "60")
self.assertEqual(code, 6, out)
self.assertIn("[verdict] REMOTE_UNVERIFIED", out)
def test_sha_must_resolve_to_full_sha(self):
code, out, _ = self.run_wait("--sha", "not-a-ref")
self.assertEqual(code, 2, out)
self.assertIn("full 40-hex SHA", out)
self.assertEqual(self.gitea.requests, [])
def test_cli_entry_point_reports_remote_unverified(self):
env = self.env | {"GITEA_API_URL": closed_port_url()}
result = subprocess.run([sys.executable, str(SCRIPT), "--interval", "5", "--timeout", "0.2"],
cwd=self.repo, env=env, capture_output=True, text=True, timeout=60)
out = result.stdout + result.stderr
self.assertEqual(result.returncode, 6, out)
self.assertIn("[verdict] REMOTE_UNVERIFIED", out)
self.assertNotIn(TOKEN, out)
if __name__ == "__main__":
unittest.main()
+80
View File
@@ -57,6 +57,7 @@ class EvidenceTests(unittest.TestCase):
self.head = self.git("rev-parse", "HEAD")
self.git("update-ref", "refs/remotes/origin/main", self.head)
self.receipt = self.base / "receipt.json"
self.evidence_dir = self.base / "evidence"
self.card = self.repo / ".task/main.md"
self.card.parent.mkdir()
self.card.write_text("# Test task\n")
@@ -71,6 +72,7 @@ class EvidenceTests(unittest.TestCase):
for key in ("CMD", "SRC", "RECEIPT", "IMPORT_RECEIPT", "VALIDATE_RECEIPT",
"VALIDATE_RECEIPT_PURPOSE", "TASK_WORK_MODE"):
env.pop(key, None)
env["AGENT_EVIDENCE_DIR"] = str(self.evidence_dir)
if command is not None:
env["CMD"] = command
env.update({key: str(value) for key, value in values.items()})
@@ -337,6 +339,58 @@ class EvidenceTests(unittest.TestCase):
recorded = self.card.read_text()
self.assertEqual(self.import_receipt().returncode, 0)
self.assertEqual(self.card.read_text(), recorded)
self.assertEqual(recorded.count("```json"), 1)
def card_summaries(self):
blocks = self.card.read_text().split("```json\n")[1:]
return [json.loads(block.split("\n```", 1)[0]) for block in blocks]
def test_card_holds_summary_and_full_receipt_is_stored_outside_repo(self):
result = self.run_evidence("seq 1 30")
self.assertEqual(result.returncode, 0, result.stderr)
text = self.card.read_text()
self.assertIn("<!-- Local receipt: SELF.", text)
self.assertNotIn('"submodules"', text)
[summary] = self.card_summaries()
self.assertEqual(summary["summary_schema"], 1)
self.assertEqual(summary["source"], "SELF")
self.assertEqual(summary["output_tail"], [str(n) for n in range(16, 31)])
path = Path(summary["receipt_path"])
self.assertEqual(path, self.evidence_dir.resolve() / (summary["receipt_sha256"] + ".json"))
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
self.assertEqual(self.evidence_dir.stat().st_mode & 0o777, 0o700)
full = json.loads(path.read_text())
payload = {key: value for key, value in full.items() if key != "receipt_sha256"}
self.assertEqual(full["receipt_sha256"], hashlib.sha256(json.dumps(
payload, sort_keys=True, ensure_ascii=False, separators=(",", ":")).encode()).hexdigest())
self.assertEqual(full["output"], "".join(f"{n}\n" for n in range(1, 31)))
self.assertEqual((summary["before_head"], summary["after_head"]), (self.head, self.head))
self.assertEqual(summary["output_sha256"], full["output_sha256"])
self.assertIn("submodules", full["before"])
def test_card_summary_honors_tail(self):
self.assertEqual(self.run_evidence("seq 1 30", TAIL=2).returncode, 0)
self.assertEqual(self.card_summaries()[0]["output_tail"], ["29", "30"])
def test_imported_receipt_is_stored_verbatim_and_deduplicated(self):
data = self.capture()
self.assertEqual(self.import_receipt().returncode, 0)
[summary] = self.card_summaries()
self.assertEqual(json.loads(Path(summary["receipt_path"]).read_text()), data)
self.assertEqual(summary["receipt_sha256"], data["receipt_sha256"])
self.assertEqual(self.import_receipt().returncode, 0)
self.assertEqual(len(self.card_summaries()), 1)
def test_full_receipt_store_refuses_different_content(self):
data = self.capture()
self.evidence_dir.mkdir()
occupied = self.evidence_dir / (data["receipt_sha256"] + ".json")
occupied.write_text('{"other": true}\n')
result = self.import_receipt()
self.assertNotEqual(result.returncode, 0)
self.assertIn("different content", result.stderr)
self.assertEqual(occupied.read_text(), '{"other": true}\n')
self.assertEqual(self.card.read_text(), "# Test task\n")
def test_full_output_is_preserved_not_only_tail(self):
data = self.capture("seq 1 30", TAIL=2)
@@ -385,6 +439,32 @@ class EvidenceTests(unittest.TestCase):
cwd=self.repo, capture_output=True, text=True)
self.assertNotIn("WARN", checked.stdout + checked.stderr)
def archive_card(self, status):
self.card.unlink()
archived = self.repo / ".task/archive/feature-done.md"
archived.parent.mkdir()
archived.write_text(f"# Done task\n任务主记录:PR #3 fixture;状态:{status};owner:test\n"
"- 工作区:fixture(worktree);分支:main\n"
"- 分支类型:child;initiative_id:feature-alpha;root_target:main;"
"immediate_target:feature/alpha;accepting_requirements:false\n")
return archived
def test_evidence_appends_to_pre_merge_closed_archived_card(self):
archived = self.archive_card("已完成(合入前闭合)")
result = self.run_evidence("printf 'archived\\n'")
self.assertEqual(result.returncode, 0, result.stderr + result.stdout)
text = archived.read_text()
self.assertIn("## 证据(Evidence)", text)
self.assertIn('"immediate_target": "feature/alpha"', text)
def test_evidence_ignores_archived_card_with_other_status(self):
archived = self.archive_card("已完成(回填)")
original = archived.read_text()
result = self.run_evidence("touch should-not-exist")
self.assertNotEqual(result.returncode, 0)
self.assertFalse((self.repo / "should-not-exist").exists())
self.assertEqual(archived.read_text(), original)
def test_receipts_without_target_metadata_default_to_main(self):
data = self.capture()
self.assertEqual((data["root_target"], data["immediate_target"]), ("main", "main"))
+252 -27
View File
@@ -1,6 +1,7 @@
"""Lifecycle regressions against real disposable Git repositories and remotes."""
import os
import json
import re
import hashlib
import http.server
import threading
@@ -15,6 +16,10 @@ import urllib.parse
SOURCE = Path(__file__).resolve().parents[1]
TRIAGE_COMMANDS = ("start", "triage", "triage-decide")
ROUTING_LINE = ("路由与 fail-stop 规则见根目录 AGENT-EXECUTION.md 与 policy/delivery.md;"
"每次尝试一行,原始日志留在源码外收据路径。")
LITE_SECTIONS = ["## 目标与非目标", "## Agent 实施收据", "## 同步记录", "## 证据(Evidence)",
"## 结果与验证(完成后填写)", "## 合入与清理"]
class FakeGitea:
@@ -89,6 +94,9 @@ class TaskLifecycleTests(unittest.TestCase):
"GIT_CONFIG_COUNT": "2", "GIT_CONFIG_KEY_0": "gc.auto",
"GIT_CONFIG_VALUE_0": "0", "GIT_CONFIG_KEY_1": "maintenance.auto",
"GIT_CONFIG_VALUE_1": "false",
# task-start 自动 precheck 收据落在 TMPDIR:限定在夹具目录内随测试清理。
"TMPDIR": str(self.root),
"AGENT_EVIDENCE_DIR": str(self.root / "evidence"),
# 桩位于 127.0.0.1:禁止经代理转发,保证测试不触达外部网络。
"NO_PROXY": "127.0.0.1,localhost", "no_proxy": "127.0.0.1,localhost"})
for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GITEA_TOKEN", "GITEA_API_URL",
@@ -649,7 +657,8 @@ class TaskLifecycleTests(unittest.TestCase):
def rewrite_receipt(self, card, change):
prefix, block = card.read_text().split("```json\n", 1)
record, suffix = block.split("\n```", 1)
receipt = json.loads(record)
# The card holds a summary; rewrite its full receipt back into the card as a legacy block.
receipt = json.loads(Path(json.loads(record)["receipt_path"]).read_text())
change(receipt)
receipt.pop("receipt_sha256")
serialized = json.dumps(receipt, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
@@ -771,12 +780,30 @@ class TaskLifecycleTests(unittest.TestCase):
else:
self.gitea.prs.append(FakeGitea.pr(len(self.gitea.prs) + 1, branch, sha))
def test_start_requires_precheck_receipt_before_creating_worktree(self):
def test_start_without_precheck_receipt_runs_and_imports_auto_precheck(self):
triage = self.triage_receipt()
result = self.task(self.repo, "start", "feature/new",
env=self.env | {"TRIAGE_RECEIPT": str(triage)})
line = next(row for row in result.stdout.splitlines() if row.startswith("[precheck] auto receipt="))
receipt = Path(line.split("=", 1)[1])
self.assertEqual(receipt.parent, self.root)
self.assertIn("feature-new", receipt.name)
data = json.loads(receipt.read_text())
self.assertEqual(data["command"], "make precheck")
self.assertIn("[verdict] READY", data["output"])
card = (self.root / "repo-wt-feature-new/.task/feature-new.md").read_text()
self.assertIn(data["receipt_sha256"], card)
def test_start_refuses_busy_auto_precheck_without_side_effects(self):
triage = self.triage_receipt()
(self.repo / "stray").write_text("unfinished\n")
result = self.task(self.repo, "start", "feature/new", check=False,
env=self.env | {"TRIAGE_RECEIPT": str(triage)})
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn("SHARED_WORKSPACE_BUSY", result.stdout)
self.assertIn("auto precheck is not READY", result.stdout)
self.assertFalse((self.root / "repo-wt-feature-new").exists(), result.stdout)
self.assertEqual(self.git(self.repo, "branch", "--list", "feature/new").stdout.strip(), "")
def test_start_preserves_merged_local_and_remote_branch_names(self):
receipt = self.capture_precheck()
@@ -833,23 +860,9 @@ class TaskLifecycleTests(unittest.TestCase):
"| 待定义 | — | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | NOT_RUN | — |",
agent_receipts,
)
self.assertIn("Cursor、Antigravity(agy)和 PI", agent_receipts)
self.assertIn("最多 3 次全新尝试", agent_receipts)
self.assertIn("一次全新的 Codex CLI 紧急尝试", agent_receipts)
self.assertIn("规划、方案设计、实施计划或独立 Review", agent_receipts)
self.assertIn("显式 fail-stop", agent_receipts)
self.assertIn("不因推理时长、工具次数、规划方式或首次写文件时间判定失败", agent_receipts)
self.assertIn("stdin/stdout/stderr 均无活动的空闲超时", agent_receipts)
self.assertIn("自主检查、计划、测试并迭代", agent_receipts)
self.assertIn("scripts/agent-exec apply", agent_receipts)
self.assertIn("pre-commit admit", agent_receipts)
self.assertIn(".task/**", agent_receipts)
self.assertIn("docs/superpowers/specs/**", agent_receipts)
self.assertIn("docs/superpowers/plans/**", agent_receipts)
self.assertIn("fallback 列只记录该行是否为 Codex 紧急尝试", agent_receipts)
self.assertIn("不授权 host 直接撰写该单元", agent_receipts)
self.assertIn("安全耗尽不发出 host-fallback-authorized", agent_receipts)
self.assertIn("终态为 unavailable 或 blocked", agent_receipts)
self.assertIn(ROUTING_LINE, agent_receipts)
self.assertNotIn("Cursor、Antigravity(agy)和 PI", agent_receipts)
self.assertNotIn("## 子任务登记", card)
for field in ("当前阶段", "当前增量", "方案引用", "实施计划引用", "测试计划引用"):
self.assertIn(field + ":", card)
self.assertIn("## 交付调度\n", card)
@@ -1252,6 +1265,8 @@ class TaskLifecycleTests(unittest.TestCase):
self.assertEqual(self.card_helper("card_immediate_target", card), "feature/alpha")
self.assertEqual(self.card_helper("card_initiative_id", card), "feature-alpha")
self.assertEqual(self.card_helper("card_root_target", card), "main")
self.assertIn("- 需求分流:verdict=JOIN;selected_initiative=feature/alpha;", text)
self.assert_lite_card(text, "feature/alpha-login")
def test_start_child_rejects_missing_initiative_or_wrong_slug(self):
self.start_initiative()
@@ -1263,6 +1278,104 @@ class TaskLifecycleTests(unittest.TestCase):
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertFalse(wt.exists())
def register_child(self, parent, child, initiative="feature/alpha", publish=True):
card = parent / ".task" / (initiative.replace("/", "-") + ".md")
text = card.read_text()
self.assertIn("| 待登记 | 否 | — | — |", text)
card.write_text(text.replace("| 待登记 | 否 | — | — |", f"| `{child}` | 是 | 进行中 | — |", 1))
self.git(parent, "add", ".task")
self.git(parent, "commit", "-m", f"Register {child}")
if publish:
self.publish_initiative(initiative)
return self.git(parent, "rev-parse", "HEAD").stdout.strip()
def planned_child_env(self):
return self.env | {"TASK_BRANCH_KIND": "child", "TASK_INITIATIVE": "feature/alpha",
"TASK_PARENT_PR": "https://example.invalid/repo/pulls/7"}
def assert_lite_card(self, text, name):
self.assertEqual([line for line in text.splitlines() if line.startswith("## ")], LITE_SECTIONS)
self.assertEqual(text.splitlines()[0], f"# {name} 任务卡")
self.assertRegex(text, r"(?m)^任务主记录:<PR 链接,交付前填写>;状态:进行中;owner:.+$")
self.assertRegex(text, r"(?m)^- 权威仓:.+;目标分支:main;R0(initial_base_sha)= [0-9a-f]{40}$")
self.assertRegex(text, rf"(?m)^- 工作区:.+(worktree);分支:{re.escape(name)}$")
for prefix in ("- 分支类型:", "- 需求分流:", "- 授权与交付终点:",
"- 交付模式:goal-first(目标、范围和交付终点一次确认;范围内连续推进)",
"- 实施执行:agent-per-unit(配置:AGENT-EXECUTION.md)",
"- 合入执行:agent(",
"- 流程依据:仓库根目录 policy/delivery.md(归档后仍按仓库根目录定位)"):
self.assertRegex(text, "(?m)^" + re.escape(prefix))
self.assertIn(ROUTING_LINE, text)
self.assertIn("| 待定义 | — | NOT_RUN | NOT_RUN |", text)
self.assertRegex(text, r"(?m)^\| 启动 \| [0-9a-f]{40} \| [0-9a-f]{40} \| ")
self.assertIn("| 时间(UTC) | 命令 | 被测HEAD | exit | 来源 | sha256(16) |", text)
for field in ("Review 记录", "冲突记录", "CI 记录", "合入记录", "清理记录"):
self.assertIn(field + ":NOT_RUN", text)
self.assertNotIn("## 阶段记录", text)
def test_start_planned_child_without_triage_when_registered_on_origin(self):
parent = self.start_initiative()
parent_head = self.register_child(parent, "feature/alpha-login")
result = self.task(self.repo, "start", "feature/alpha-login", env=self.planned_child_env())
self.assertIn("[precheck] auto receipt=", result.stdout)
wt = self.root / "repo-wt-feature-alpha-login"
card = wt / ".task/feature-alpha-login.md"
text = card.read_text()
self.assertIn("- 需求分流:免(父 initiative 子任务登记已列出 feature/alpha-login;"
f"parent_head_sha={parent_head})", text)
self.assertIn(f"parent_head_sha:{parent_head};parent_pr:https://example.invalid/repo/pulls/7;"
"parent_source:origin", text)
self.git(wt, "merge-base", "--is-ancestor", parent_head, "HEAD")
self.assert_lite_card(text, "feature/alpha-login")
self.assertEqual(self.card_helper("card_immediate_target", card), "feature/alpha")
def test_start_planned_child_refuses_unregistered_child_without_side_effects(self):
parent = self.start_initiative()
self.publish_initiative("feature/alpha")
for case in ("placeholder-only", "registered-locally-only"):
with self.subTest(case=case):
if case == "registered-locally-only":
self.register_child(parent, "feature/alpha-login", publish=False)
result = self.task(self.repo, "start", "feature/alpha-login", check=False,
env=self.planned_child_env())
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn("not registered in feature/alpha", result.stdout)
self.assertIn("provide TRIAGE_RECEIPT", 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(), "")
self.assertEqual(self.git(self.repo, "ls-remote", "--heads", "origin",
"feature/alpha-login").stdout.strip(), "")
def test_start_initiative_card_is_charter_with_child_registry(self):
wt = self.start_initiative()
card = wt / ".task/feature-alpha.md"
text = card.read_text()
goal = text.split("## 目标与非目标\n", 1)[1].split("\n## ", 1)[0]
for item in ("用途与目标", "接纳范围与非目标", "方案/计划链接", "分级测试策略", "验收条件",
"追加领域规则", "生命周期"):
self.assertIn(item, goal)
sections = [line for line in text.splitlines() if line.startswith("## ")]
self.assertEqual(sections[sections.index("## 目标与非目标") + 1], "## 子任务登记")
self.assertIn("## 阶段记录", sections)
self.assertIn(ROUTING_LINE, text)
rows = self.run_cmd(self.repo, "python3", "-c",
"import json, sys; sys.path.insert(0, 'scripts');"
"from task_pr_check import child_registry;"
"print(json.dumps(child_registry(open(sys.argv[1]).read()), ensure_ascii=False))",
str(card)).stdout
self.assertEqual(json.loads(rows), [{"子任务分支": "待登记", "必需": "否", "状态": "—", "PR": "—"}])
def test_start_fix_and_hotfix_cards_are_lite(self):
triage = self.triage_receipt()
for name in ("fix/typo", "hotfix/urgent"):
with self.subTest(name=name):
self.task(self.repo, "start", name, env=self.env | {"TRIAGE_RECEIPT": str(triage)})
slug = name.replace("/", "-")
text = (self.root / f"repo-wt-{slug}/.task/{slug}.md").read_text()
self.assert_lite_card(text, name)
self.assertIn("- 分支类型:standalone;root_target:main;", text)
self.assertIn("- 需求分流:verdict=NEW;", text)
def test_sync_child_merges_new_parent_initiative_commits(self):
parent = self.start_initiative()
wt, _ = self.start_child()
@@ -1315,14 +1428,16 @@ class TaskLifecycleTests(unittest.TestCase):
def test_start_without_triage_receipt_creates_no_worktree_or_branch(self):
receipt = self.capture_precheck()
result = self.task(self.repo, "start", "feature/untriaged", check=False,
env=self.env | {"PRECHECK_RECEIPT": str(receipt)})
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn("TRIAGE_RECEIPT required", result.stdout)
self.assertFalse((self.root / "repo-wt-feature-untriaged").exists())
self.assertEqual(self.git(self.repo, "branch", "--list", "feature/untriaged").stdout.strip(), "")
self.assertEqual(self.git(self.repo, "ls-remote", "--heads", "origin",
"feature/untriaged").stdout.strip(), "")
for kind in ("standalone", "initiative"):
with self.subTest(kind=kind):
result = self.task(self.repo, "start", "feature/untriaged", check=False,
env=self.env | {"PRECHECK_RECEIPT": str(receipt), "TASK_BRANCH_KIND": kind})
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn("TRIAGE_RECEIPT required", result.stdout)
self.assertFalse((self.root / "repo-wt-feature-untriaged").exists())
self.assertEqual(self.git(self.repo, "branch", "--list", "feature/untriaged").stdout.strip(), "")
self.assertEqual(self.git(self.repo, "ls-remote", "--heads", "origin",
"feature/untriaged").stdout.strip(), "")
def test_start_child_refuses_non_join_receipt_without_side_effects(self):
self.start_initiative("feature/alpha")
@@ -1490,6 +1605,115 @@ class TaskLifecycleTests(unittest.TestCase):
self.assertIn("已包含于 origin/main", archived)
self.assertIn("PR #5", archived)
# ---- close --self:合入前在任务分支内闭合本卡 ----
def self_close_env(self):
return self.close_env() | {"GITEA_API_URL": self.gitea.url, "GITEA_REPO": "fixture/repo"}
def start_standalone(self, name="feature/solo"):
receipt = self.capture_precheck()
triage = self.triage_receipt()
self.task(self.repo, "start", name,
env=self.env | {"PRECHECK_RECEIPT": str(receipt), "TRIAGE_RECEIPT": str(triage)})
return self.root / ("repo-wt-" + name.replace("/", "-"))
def open_pr(self, wt, branch, base="main"):
self.git(wt, "push", "-q", "-u", "origin", branch)
number = len(self.gitea.prs) + 1
sha = self.git(wt, "rev-parse", "HEAD").stdout.strip()
self.gitea.prs.append(FakeGitea.pr(number, branch, sha, base=base))
return number
def test_close_self_archives_own_card_inside_its_pr(self):
wt = self.start_standalone()
(wt / "work").write_text("task result\n")
self.git(wt, "add", "work")
self.git(wt, "commit", "-m", "Task work")
number = self.open_pr(wt, "feature/solo")
result = self.task(wt, "close", "--self", env=self.self_close_env())
self.assertIn(f"[closed] .task/feature-solo.md 已在 PR #{number} 合入前闭合并归档;合入后无需 task-close",
result.stdout)
self.assertNotIn(self.gitea.token, result.stdout)
self.assertFalse((wt / ".task/feature-solo.md").exists())
text = (wt / ".task/archive/feature-solo.md").read_text()
self.assertIn(";状态:已完成(合入前闭合);", text)
self.assertIn(f"任务主记录:PR #{number} {self.gitea.web}/{self.root.name}/remote/pulls/{number};", text)
self.assertIn("## 结果与验证\n", text)
self.assertNotIn("## 结果与验证(完成后填写)", text)
self.assertIn("## 闭合记录(合入前折叠)", text)
self.assertIn(f"PR #{number}", text.split("## 闭合记录(合入前折叠)", 1)[1])
self.assertEqual(self.git(wt, "log", "-1", "--format=%s").stdout.strip(),
"Fold-close the task card before merge")
body = self.git(wt, "log", "-1", "--format=%B").stdout
for field in ("Why:", "Evidence:", "Impact:", "Refs: task.sh close --self"):
self.assertIn(field, body)
self.assertEqual(self.git(wt, "status", "--porcelain").stdout, "")
head = self.git(wt, "rev-parse", "HEAD").stdout.strip()
self.assertEqual(self.git(self.remote, "rev-parse", "refs/heads/feature/solo").stdout.strip(), head)
self.assertEqual(self.git(self.remote, "show", "feature/solo:.task/archive/feature-solo.md").stdout, text)
again = self.task(wt, "close", "--self", check=False, env=self.self_close_env())
self.assertNotEqual(again.returncode, 0, again.stdout)
self.assertEqual(self.git(wt, "rev-parse", "HEAD").stdout.strip(), head)
def test_close_self_accepts_explicit_pr_number_without_lookup(self):
wt = self.start_standalone()
env = self.close_env() | {"TASK_PR": "42", "GITEA_API_URL": "http://127.0.0.1:9/api/v1"}
result = self.task(wt, "close", "--self", env=env)
self.assertIn("PR #42 合入前闭合", result.stdout)
self.assertIn("任务主记录:PR #42 ", (wt / ".task/archive/feature-solo.md").read_text())
def test_close_self_refuses_main_detached_dirty_and_missing_pr(self):
main_head = self.git(self.repo, "rev-parse", "HEAD").stdout.strip()
on_main = self.task(self.repo, "close", "--self", check=False, env=self.self_close_env())
self.assertNotEqual(on_main.returncode, 0, on_main.stdout)
self.assertIn("不能在 main 上运行", on_main.stdout)
self.assertEqual(self.git(self.repo, "rev-parse", "HEAD").stdout.strip(), main_head)
wt = self.start_standalone()
head = self.git(wt, "rev-parse", "HEAD").stdout.strip()
card = wt / ".task/feature-solo.md"
original = card.read_text()
def assert_refused(result, reason):
self.assertNotEqual(result.returncode, 0, result.stdout)
self.assertIn(reason, result.stdout)
self.assertNotIn(self.gitea.token, result.stdout)
self.assertEqual(self.git(wt, "rev-parse", "HEAD").stdout.strip(), head)
self.assertEqual(card.read_text(), original)
self.assertFalse((wt / ".task/archive").exists())
(wt / "stray").write_text("uncommitted\n")
assert_refused(self.task(wt, "close", "--self", check=False, env=self.self_close_env()), "不干净")
(wt / "stray").unlink()
assert_refused(self.task(wt, "close", "--self", check=False, env=self.self_close_env()),
"需要恰好一个 head 为 feature/solo 的开放 PR")
assert_refused(self.task(wt, "close", "--self", check=False,
env=self.self_close_env() | {"TASK_PR": "7; rm"}), "TASK_PR")
self.git(wt, "checkout", "-q", "--detach")
assert_refused(self.task(wt, "close", "--self", check=False, env=self.self_close_env()), "detached HEAD")
def test_close_self_child_keeps_initiative_target_from_archive(self):
parent = self.start_initiative()
wt, _ = self.start_child()
number = self.open_pr(wt, "feature/alpha-login", base="feature/alpha")
self.task(wt, "close", "--self", env=self.self_close_env())
archived = wt / ".task/archive/feature-alpha-login.md"
self.assertIn("immediate_target:feature/alpha", archived.read_text())
self.assertIn(f"PR #{number}", archived.read_text())
(parent / "parent-later").write_text("later initiative work\n")
self.git(parent, "add", "parent-later")
self.git(parent, "commit", "-m", "Later initiative work")
self.publish_initiative("feature/alpha")
parent_head = self.git(parent, "rev-parse", "HEAD").stdout.strip()
synced = self.task(wt, "sync")
self.assertIn("sync_target=feature/alpha", synced.stdout)
self.assertIn(f"sync_base={parent_head}", synced.stdout)
self.assertIn(f"| 同步(记录前对象) | {parent_head} |", archived.read_text())
self.git(parent, "merge", "--no-ff", "feature/alpha-login", "-m", "Merge child into initiative")
self.publish_initiative("feature/alpha")
finished = self.task(wt, "finish")
self.assertIn("[verified] feature/alpha-login is included in parent initiative feature/alpha",
finished.stdout)
if __name__ == "__main__":
unittest.main()
@@ -1511,6 +1735,7 @@ class FinishGuardFixTests(unittest.TestCase):
"GIT_AUTHOR_NAME": "Guard Test", "GIT_AUTHOR_EMAIL": "guard@example.invalid",
"GIT_COMMITTER_NAME": "Guard Test", "GIT_COMMITTER_EMAIL": "guard@example.invalid",
"TASK_WRITERS_STOPPED": "1",
"AGENT_EVIDENCE_DIR": self.root + "/evidence",
"NO_PROXY": "127.0.0.1,localhost", "no_proxy": "127.0.0.1,localhost"})
for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GITEA_TOKEN", "GITEA_API_URL",
"GITEA_REPO", "TRIAGE_RECEIPT", "PRECHECK_RECEIPT"):
+36
View File
@@ -396,6 +396,42 @@ class PrCheckTests(unittest.TestCase):
self.assertEqual(result.returncode, code, result.out)
self.assertIn(text, result.out)
def archive_child_card(self, status):
"""把子任务卡移到 .task/archive/ 并改写状态(模拟 close --self 合入前折叠)。"""
self.build_child()
top = self.repo / ".task" / "feature-init-child.md"
text = top.read_text(encoding="utf-8").replace(";状态:进行中;", f";状态:{status};")
archive = self.repo / ".task" / "archive"
archive.mkdir(exist_ok=True)
(archive / top.name).write_text(text, encoding="utf-8")
top.unlink()
self.git(self.repo, "add", "-A", ".task")
self.git(self.repo, "commit", "-m", "Archive child card")
def test_ci_mode_reads_pre_merge_closed_card_from_archive(self):
self.archive_child_card("已完成(合入前闭合)")
result = self.ci(base="main", head="feature/init-child")
self.assertEqual(result.returncode, 1, result.out)
self.assertIn("expected 'feature/init' for child branch feature/init-child", result.out)
result = self.ci(base="feature/init", head="feature/init-child")
self.assertEqual(result.returncode, 0, result.out)
self.assertIn("[verdict] PR_CHECK_OK base=feature/init head=feature/init-child kind=child", result.out)
def test_ci_mode_ignores_archived_card_with_other_status(self):
self.archive_child_card("已完成(回填)")
result = self.ci(base="feature/init", head="feature/init-child")
self.assertEqual(result.returncode, 1, result.out)
self.assertIn("expected 'main' for standalone branch feature/init-child", result.out)
def test_ci_mode_rejects_ambiguous_archived_cards(self):
self.archive_child_card("已完成(合入前闭合)")
archive = self.repo / ".task" / "archive"
(archive / "copy.md").write_text((archive / "feature-init-child.md").read_text(encoding="utf-8"),
encoding="utf-8")
result = self.ci(base="feature/init", head="feature/init-child")
self.assertEqual(result.returncode, 1, result.out)
self.assertIn("ambiguous task cards for feature/init-child", result.out)
if __name__ == "__main__":
unittest.main()
+56
View File
@@ -11,6 +11,7 @@ import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from test_task import FakeGitea # noqa: E402
@@ -397,5 +398,60 @@ class RedactionTests(unittest.TestCase):
self.assertEqual(task_triage.redact_url("/tmp/plain/remote.git"), "/tmp/plain/remote.git")
class TokenSafetyTests(unittest.TestCase):
FAKE_A = "a" * 40
FAKE_B = "b" * 40
def setUp(self):
tmp = tempfile.TemporaryDirectory(prefix="triage-token-")
self.addCleanup(tmp.cleanup)
self.home = Path(tmp.name)
def env(self, token=""):
return mock.patch.dict(os.environ, {"HOME": str(self.home), "GITEA_TOKEN": token})
def write_secrets(self, text):
(self.home / ".secrets").mkdir(exist_ok=True)
(self.home / ".secrets" / "gitea").write_text(text, encoding="utf-8")
def test_multiline_env_token_is_rejected_without_echoing_values(self):
with self.env(f"{self.FAKE_A}\n{self.FAKE_B}"):
with self.assertRaises(task_triage.Stop) as caught:
task_triage.read_token()
self.assertEqual(caught.exception.code, task_triage.EXIT_REMOTE)
self.assertIn("malformed", caught.exception.message)
for value in (self.FAKE_A, self.FAKE_B):
self.assertNotIn(value, caught.exception.message)
self.assertNotIn(value, str(caught.exception))
def test_secrets_file_with_sections_yields_home_value(self):
self.write_secrets("IMC:\n- URL_PREFIX=https://imc.invalid\n- ACCESS_TOKEN=" + self.FAKE_B + "\n"
"HOME:\n- URL_PREFIX=https://home.invalid\n- ACCESS_TOKEN=" + self.FAKE_A + "\n")
with self.env():
self.assertEqual(task_triage.read_token(), self.FAKE_A)
def test_secrets_file_without_home_access_token_returns_none(self):
self.write_secrets("HOME:\n- URL_PREFIX=https://home.invalid\nIMC:\n- ACCESS_TOKEN=" + self.FAKE_B + "\n")
with self.env():
self.assertIsNone(task_triage.read_token())
def test_missing_configuration_returns_none(self):
with self.env():
self.assertIsNone(task_triage.read_token())
def test_request_value_error_does_not_leak_token(self):
token = self.FAKE_A
leak = ValueError(f"Invalid header value b'token {token}\\n{self.FAKE_B}'")
with mock.patch.object(task_triage.urllib.request, "urlopen", side_effect=leak):
with self.assertRaises(task_triage.Stop) as caught:
task_triage.http_get_json("https://gitea.invalid/api/v1/x", token)
self.assertEqual(caught.exception.code, task_triage.EXIT_REMOTE)
self.assertEqual(caught.exception.message, "Gitea API request failed (ValueError)")
self.assertIsNone(caught.exception.__cause__)
self.assertTrue(caught.exception.__suppress_context__)
for value in (token, self.FAKE_B):
self.assertNotIn(value, caught.exception.message)
if __name__ == "__main__":
unittest.main()
+150
View File
@@ -0,0 +1,150 @@
"""按改动选检查(scripts/test_affected.py):临时 Git 仓库 + 本地裸 origin,全部离线。"""
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SOURCE = Path(__file__).resolve().parents[1]
SCRIPT = SOURCE / "scripts" / "test_affected.py"
class TestAffectedTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix="test-affected-")
self.addCleanup(self.tmp.cleanup)
root = Path(self.tmp.name)
self.repo = root / "repo"
self.env = {k: v for k, v in os.environ.items()
if not k.startswith(("GIT_", "MAKE")) and k not in {"BASE", "TASK_TARGET_BRANCH"}}
self.env.update({"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null",
"GIT_TERMINAL_PROMPT": "0", "HOME": str(root / "no-home"),
"GIT_AUTHOR_NAME": "Affected Test", "GIT_AUTHOR_EMAIL": "affected@example.invalid",
"GIT_COMMITTER_NAME": "Affected Test",
"GIT_COMMITTER_EMAIL": "affected@example.invalid"})
self.git(root, "init", "-q", "--bare", "--initial-branch=main", str(root / "origin.git"))
self.git(root, "init", "-q", "--initial-branch=main", str(self.repo))
self.git(self.repo, "remote", "add", "origin", str(root / "origin.git"))
self.write("README.md", "fixture\n")
self.write("modules/ui/notes.txt", "ui\n")
self.commit("Create fixture")
self.git(self.repo, "push", "-q", "origin", "main")
self.git(self.repo, "checkout", "-q", "-b", "feature/work")
def git(self, cwd, *args):
return subprocess.run(["git", *args], cwd=cwd, env=self.env, capture_output=True, text=True,
check=True).stdout.strip()
def write(self, rel, text):
path = self.repo / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
def commit(self, message, gitlinks=()):
self.git(self.repo, "add", "-A")
sha = self.git(self.repo, "rev-parse", "HEAD") if gitlinks else None
for path in gitlinks:
self.git(self.repo, "update-index", "--add", "--cacheinfo", f"160000,{sha},{path}")
self.git(self.repo, "commit", "-q", "-m", message)
def run_affected(self, *args, base=None):
env = self.env | ({"BASE": base} if base is not None else {})
result = subprocess.run([sys.executable, str(SCRIPT), *args], cwd=self.repo, env=env,
capture_output=True, text=True, timeout=60)
return result.returncode, result.stdout + result.stderr
def summary(self, out):
lines = [line for line in out.splitlines() if line.startswith("[affected] base=")]
self.assertEqual(len(lines), 1, out)
return dict(part.split("=", 1) for part in lines[0].split()[1:])
def test_mapping_dry_run_orders_and_dedupes_targets(self):
self.write("scripts/tool.py", "print('x')\n")
self.write("tests/test_tool.py", "\n")
self.write("modules/core/a.go", "package a\n")
self.write("deps/agent-native/x.go", "package x\n")
self.write("deps/agent-squad/y.go", "package y\n")
self.write("docs/guide.md", "doc\n")
self.write(".task/card.md", "card\n")
self.commit("Branch work", gitlinks=["deps/agent-command"])
self.write("modules/ui/notes.txt", "uncommitted tracked change\n")
code, out = self.run_affected("--dry-run")
self.assertEqual(code, 0, out)
info = self.summary(out)
self.assertEqual(info["base"], "origin/main")
self.assertEqual(info["changed"], "9", out)
self.assertEqual(info["targets"], "test-worktree,test-governance,test-boundaries,test-pins,test-libs,"
"test-core,test-ui,test-agentcmd", out)
self.assertIn("[affected] dry-run: make test-worktree test-governance", out)
def test_gitlink_and_gitmodules_select_pins_and_module_target(self):
self.write(".gitmodules", "[submodule \"modules/backend\"]\n\tpath = modules/backend\n")
self.commit("Bump backend", gitlinks=["modules/backend"])
code, out = self.run_affected("--dry-run")
self.assertEqual(code, 0, out)
self.assertEqual(self.summary(out)["targets"],
"test-worktree,test-boundaries,test-pins,test-backend", out)
def test_lint_boundaries_and_governance_docs_select_targets(self):
self.write("scripts/lint-boundaries.sh", "#!/bin/sh\n")
self.write("AGENT-EXECUTION.md", "x\n")
self.commit("Lint")
code, out = self.run_affected("--dry-run")
self.assertEqual(code, 0, out)
self.assertEqual(self.summary(out)["targets"], "test-worktree,test-governance,test-boundaries", out)
def test_docs_only_change_runs_nothing(self):
self.write("docs/guide.md", "doc\n")
self.write(".task/card.md", "card\n")
self.write("NOTES.md", "notes\n")
self.commit("Docs only")
self.write("README.md", "edited\n")
code, out = self.run_affected()
self.assertEqual(code, 0, out)
info = self.summary(out)
self.assertEqual((info["changed"], info["targets"]), ("4", "none"), out)
self.assertIn("[affected] nothing to test", out)
self.assertNotIn("dry-run", out)
def test_base_env_resolves_bare_branch_to_origin(self):
self.write("scripts/tool.py", "x\n")
self.commit("Tool")
code, out = self.run_affected("--dry-run", base="main")
self.assertEqual(code, 0, out)
self.assertEqual(self.summary(out)["base"], "origin/main")
def test_base_env_local_branch_limits_changes_since_that_base(self):
self.write("modules/core/a.go", "package a\n")
self.commit("Core")
self.git(self.repo, "branch", "local-base")
self.write("docs/later.md", "later\n")
self.commit("Later docs")
code, out = self.run_affected("--dry-run", base="local-base")
self.assertEqual(code, 0, out)
info = self.summary(out)
self.assertEqual((info["base"], info["changed"], info["targets"]), ("local-base", "1", "none"), out)
code, out = self.run_affected("--dry-run", "--base", "origin/main", base="local-base")
self.assertEqual(self.summary(out)["base"], "origin/main", out)
def test_unresolvable_base_is_a_usage_error(self):
code, out = self.run_affected("--dry-run", base="no-such-branch")
self.assertEqual(code, 2, out)
self.assertIn("[affected] ERROR base no-such-branch does not resolve", out)
def test_default_base_is_card_immediate_target(self):
self.git(self.repo, "push", "-q", "origin", "main:feature/init")
self.git(self.repo, "fetch", "-q", "origin")
self.write(".task/child.md", "# child\n- 工作区:/tmp/x;分支:feature/work\n"
"- 分支类型:child;initiative_id:init;root_target:main;immediate_target:feature/init\n")
self.commit("Card")
code, out = self.run_affected("--dry-run")
self.assertEqual(code, 0, out)
self.assertEqual(self.summary(out)["base"], "origin/feature/init", out)
self.git(self.repo, "checkout", "-q", "--detach")
code, out = self.run_affected("--dry-run")
self.assertEqual(self.summary(out)["base"], "origin/main", out)
if __name__ == "__main__":
unittest.main()