Merge pull request 'Deliver the layered delivery model and slim the governance spec' (#608) from feature/delivery-model-v3 into main
This commit was merged in pull request #608.
This commit is contained in:
@@ -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
@@ -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` 主机条目,克隆/推送直接可用。
|
||||
|
||||
@@ -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
@@ -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
Submodule modules/documents updated: 73bbb4cea6...e2f5729e8f
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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#合入调度),以该节为唯一权威;
|
||||
本文件只为保持旧链接可用而保留。
|
||||
|
||||
@@ -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` 固定对象、权限、覆盖拒绝、原样导入与幂等 |
|
||||
|
||||
## 失败处理
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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"):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user