cowork-flow 1.7.3 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/CHANGELOG.md +9 -1
  2. package/docs/direction.md +12 -0
  3. package/package.json +1 -1
  4. package/presets/claude-code/.claude-plugin/plugin.json +1 -1
  5. package/presets/codex/.codex-plugin/plugin.json +1 -1
  6. package/presets/kimi-code/.kimi-plugin/plugin.json +1 -1
  7. package/presets/opencode/cowork-flow/plugin-core.js +51 -3
  8. package/presets/qoder/.qoder-plugin/plugin.json +1 -1
  9. package/presets/zcode/.zcode-plugin/plugin.json +1 -1
  10. package/template/.cowork-flow/.version +1 -1
  11. package/template/.cowork-flow/scripts/adapters/cli/spec_check.py +10 -1
  12. package/template/.cowork-flow/scripts/adapters/cli/task_lifecycle_commands.py +3 -0
  13. package/template/.cowork-flow/scripts/adapters/cli/task_parser.py +1 -0
  14. package/template/.cowork-flow/scripts/adapters/host/workflow_state_hook.py +21 -0
  15. package/template/.cowork-flow/scripts/services/ac_evidence.py +107 -0
  16. package/template/.cowork-flow/scripts/services/fact_view.py +16 -2
  17. package/template/.cowork-flow/scripts/services/lifecycle_checks.py +84 -0
  18. package/template/.cowork-flow/scripts/services/lifecycle_policy.py +5 -0
  19. package/template/.cowork-flow/scripts/services/spec_check.py +11 -1
  20. package/template/.cowork-flow/scripts/services/task_lifecycle.py +12 -0
  21. package/template/.cowork-flow/spec/contracts/context-injection.md +5 -3
  22. package/template/.cowork-flow/spec/contracts/decision-anchor.md +1 -0
  23. package/template/.cowork-flow/spec/contracts/evidence-completion.md +48 -0
  24. package/template/.cowork-flow/spec/contracts/index.md +1 -0
  25. package/template/.cowork-flow/spec/contracts/spec-checks.md +49 -82
  26. package/template/.cowork-flow/spec/runtime/contract-registry.json +13 -0
  27. package/template/.opencode/cowork-flow/plugin-core.js +51 -3
  28. package/template/skills/spec-sync/SKILL.md +1 -0
  29. package/template/skills/task-review/SKILL.md +3 -2
  30. package/template/skills/test-first/SKILL.md +4 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,13 @@
5
5
  ### Added
6
6
 
7
7
 
8
+ ## [1.8.0] - 2026-10-08
9
+
10
+ ### Added
11
+
12
+ - **spec-check 采纳闭环(1.8.x 主线)**:让 spec-check 机制从"存在但沉默"变为可遵循。① 零声明可见性:仓库无生效声明时 `spec-check` 文本模式输出显式事实行 `spec-check: no checks declared (<N> spec files scanned)`(与"有声明且全过"可区分),`--json` 顶层新增 `specFiles`、summary 新增 `declarations`——"没挂声明"不再与"干净通过"同形(零声明保持退出码 0:不挂命令是合法状态,但必须被看见)。② 注入契约 `SPEC_CHECKS_V1`:`contract-registry.json` 新增第 10 条(digest 2 条 + readWhen 3 条),python/zcode/opencode 三线渲染在 digest 块输出 `- SPEC_CHECKS_V1: .cowork-flow/spec/contracts/spec-checks.md`。③ `spec-checks.md` 重排为使用者核心区(何时挂 / 语法含 UTF-8 无 BOM 注意点 / 三态与门禁含零声明语义 / 时机与输出 / 归属)+ 维护者附录(宿主能力矩阵 / delegated 缺口与补偿 / digest 注入 / 开放决策),零信息丢失由独立复核逐项清点确认。④ AI 挂命令职责三处一致:契约归属段、`task-review`(User specs 逐条问"可机检吗、已挂吗" + `user_spec_review` 列「条款 → 命令」清单)、`spec-sync`(同次维护挂/刷新声明);质量纪律四条(干净树跑出 pass / 反例能报错 / `files` 限定 / 挂后跑 spec-check 确认解析)获测试锁(`test_review_contract.py` markers)。不新增硬门禁——放权语义:主会话 AI 主动挂声明,review 报告留痕。
13
+ - **证据化完成(1.8.0 主线)**:任务完成从"流程走完"升级为"证据齐全"。decision-anchor 的验收标准(AC)成为机器可查询事实——`services/ac_evidence.py` 解析 `## 验收标准` 的勾选状态并读取任务目录 `evidence.jsonl`(追加式,`{ac, kind: test|command|manual, ref, note?, recordedAt, by}`);完成动作新增两条稳定门禁 code:存在未勾选 AC 阻断(`LIFECYCLE-AC-001`)、已声明 AC 缺有效证据记录阻断(`LIFECYCLE-AC-002`),新旗标 `--allow-missing-evidence` 豁免后者并把 `meta.evidenceExempt`(含 `at` 与 `missing`)留在任务记录;decision-anchor 注入块在 `Acceptance:` 行后追加 `AC evidence: n/m`(缺失时附 `missing=` 列表),三线(Python / zcode shim / opencode 插件)逐字节同格式并由矩阵测试锁定;`run state --json` 与 MCP 事实视图新增 `evidenceCoverage`。无 AC 的存量任务零影响(兼容路径有测试锁定),契约见 `.cowork-flow/spec/contracts/evidence-completion.md`。
14
+
8
15
  ## [1.7.3] - 2026-10-06
9
16
 
10
17
  ### Fixed
@@ -289,7 +296,8 @@
289
296
 
290
297
  此版本未保留本地条目正文,详情见 [GitHub Release](https://github.com/fishlikewater/cowork-flow/releases/tag/v0.0.47)。
291
298
 
292
- [Unreleased]: https://github.com/fishlikewater/cowork-flow/compare/v1.7.3...HEAD
299
+ [Unreleased]: https://github.com/fishlikewater/cowork-flow/compare/v1.8.0...HEAD
300
+ [1.8.0]: https://github.com/fishlikewater/cowork-flow/compare/v1.7.3...v1.8.0
293
301
  [1.7.3]: https://github.com/fishlikewater/cowork-flow/compare/v1.7.2...v1.7.3
294
302
  [1.7.2]: https://github.com/fishlikewater/cowork-flow/compare/v1.7.1...v1.7.2
295
303
  [1.7.1]: https://github.com/fishlikewater/cowork-flow/compare/v1.7.0...v1.7.1
package/docs/direction.md CHANGED
@@ -17,6 +17,9 @@
17
17
  | 1.5→1.7 宿主广度 + 发布链(七宿主) | ✅ 完成 | CHANGELOG 1.5.0-1.7.2 | — |
18
18
  | 1.7.3 维护:ZCode shim .mjs + 项目根查找修复 | ✅ 完成 | 10-04-zcode-shim-mjs(d0716a9) | — |
19
19
  | 1.8.0 立项:证据化完成 | 🔄 立项完成,待启动 | 10-05-direction-1-8-0 / 10-05-evidence-completion | 在宿主会话启动主线任务 |
20
+ | 1.8.0 主线实现:证据化完成 | ✅ 完成 | 10-05-evidence-completion(baa386d,CI test + windows-core 全绿) | 真实宿主冒烟后发布 1.8.0 |
21
+ | 1.8.x 收尾:Skill 证据补课 | 🔄 进行中 | 10-06-skill-evidence-training | 合入后与冒烟共同构成 1.8.0 发布判据 |
22
+ | 1.9.0 前置:task adopt 命令 | 📋 立项 | 10-06-task-adopt-command | 下一会话实现(agent-teams 就绪的地基) |
20
23
 
21
24
  **当前结论**:方向路线图阶段 0-3 全部落地。项目当前形态:「多主机 Agent 协作的运行时上下文与协作事实层」——事实视图(CLI + MCP)、结构化注入、跨宿主一致性治理、执行者归属与证据位均已就绪并有测试锁定。后续演进见「不做什么」边界与观察期事项。
22
25
 
@@ -156,6 +159,14 @@
156
159
 
157
160
  5. **采用**:npm 周下载与激活项目数——1.8.0 之后用数字验证"深度路线"是否兑现。
158
161
 
162
+ ### 10.5 2026-10-06 增补:1.8.x 收尾与 1.9.0 排期
163
+
164
+ - **1.8.0 主线已实现**(10-05-evidence-completion,baa386d):AC 事实化、`evidence.jsonl`、完成门禁 `LIFECYCLE-AC-001/002`、三线注入覆盖行、`evidenceCoverage` 事实视图;主线任务自身以新门禁完成(四 AC 证据齐全,无豁免)。
165
+ - **1.8.0 发布判据**:CI 双平台(已绿)+ 真实宿主冒烟(维护者:带 AC 任务在 ZCode/Qoder 实跑一轮,观察覆盖行与门禁体验)+ Skill 证据补课合入。
166
+ - **1.9.0 主线候选:agent-teams 就绪**,按 10.3 排期;前置小任务 = **task adopt 命令**(孤儿任务收养:显式重绑执行者 + 陈旧基线降级 + 审计留痕)。需求证据:2026-10-06 归档两个滞留任务时,takeover 只重绑执行者,陈旧基线需手改 task.json meta 才能走降级归档——该操作目前无运行时命令承载。10.5 增补时立项(10-06-task-adopt-command),设计注意"基线永不覆盖"不变量与显式收养语义的边界。
167
+ - **Skill 证据补课**(10-06-skill-evidence-training):1.8.0 只改了运行时与契约,教 agent 写证据的目前只有门禁报错文案;task-review 等 Skill 正文需补 evidence.jsonl 约定。
168
+ - **维持**:文档英文化待维护者拍板(与 AGENTS.md 文档语言约定冲突);缓行清单不变。**观察项**:1.8.0 的机器可解析 AC 是与 Spec Kit / OpenSpec 互操作的第一块砖(spec 制品 ↔ decision-anchor 映射),符合"SDD 工具之下的状态层"定位,待 agent-teams 排期落定后议。
169
+
159
170
  ## 11. 修订记录
160
171
 
161
172
  - 2026-08-27:创建。基于三路只读探查(注入层/状态模型/治理层)+ 大模型趋势分析;用户拍板方案 A;落库任务 08-27-direction-doc。
@@ -165,3 +176,4 @@
165
176
  - 2026-08-29:阶段 3 完成并更新状态追踪——路线图阶段 0-3 全部落地。`run mcp-state` 无依赖 MCP stdio 只读服务(task_state / task_list)、`spec/contracts/fact-layer-access.md` 接入契约(只读保证 + 薄 adapter 立场)(a88474e)。观察期事项:AGNTCY 等跨 agent 协议收敛后按薄壳接入;MCP 写工具默认拒绝,提案须先重开 fact-layer-access 契约。
166
177
  - 2026-08-29:MCP 全局入口补充——npm CLI 新增 `cowork-flow mcp-state` 透传命令(cwd 向上定位项目运行时、stdio inherit、退出码透传),MCP 客户端全局注册一次即可服务所有项目,消除逐项目 `.mcp.json` 配置(9a1fa86)。
167
178
  - 2026-08-29:后续方向 P0-P3 执行完毕。**P0**:1.1.0 发布——dev→master 合并、CHANGELOG 1.1.0 段(含 1.0.0 载体错位注明)、tag v1.1.0 与 GitHub Release 创建;publish CI 首跑曝光 node:full 存量问题(dsh-hook 依赖 gitignored 运行时、Windows 无法 exec shebang 脚本、test runner IPC 崩溃)并全部修复——双平台门禁现已全绿。**发布确认:npm registry `cowork-flow@1.1.0` 已上线(dist-tag latest),AC-004 闭合,发版任务归档。** **P1**:`docs/mcp-client-setup.md`(codex/opencode/claude-code/zcode 真实注册格式)+ `test/mcp-client-matrix.test.js` 客户端等效启动验证。**P1'**:publish.yml 链路实测核对并写入 README 发布节。**P2**:flaky 组合复跑 5 轮未复现(记录为单次现象);doctor 会话卫生无残留。**P3**:观察期边界维持——MCP 写工具默认拒绝,AGNTCY 等协议收敛后按薄壳接入。- 2026-10-05:2026-10 修订落库(任务 10-05-direction-1-8-0)。1.7.3 维护完成(10-04-zcode-shim-mjs,d0716a9:ZCode shim 改 .mjs、findWorkflowRoot off-by-one 修复);1.8.0 主线定为**证据化完成**,主线任务 10-05-evidence-completion 立项(planning 就绪待启动);候选 B(agent-teams 就绪)列为 1.9.0 候选主线;文档英文化列为待决项,待维护者拍板。方向文档自本次起恢复版本控制(087ef72 untrack 后首次入库)。
179
+ - 2026-10-06:10.5 增补落库(任务 10-06-direction-post-1-8-0)。1.8.0 主线实现完成(10-05-evidence-completion,baa386d,CI 双平台全绿);立项 10-06-skill-evidence-training(1.8.0 收尾)与 10-06-task-adopt-command(1.9.0 前置);1.8.0 发布判据 = CI + 真实宿主冒烟 + Skill 补课。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "1.7.3",
3
+ "version": "1.8.0",
4
4
  "description": "CLI for installing and maintaining cowork-flow collaboration templates.",
5
5
  "keywords": [
6
6
  "ai-agent",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "1.7.3",
3
+ "version": "1.8.0",
4
4
  "description": "AI 协作工作流引擎:任务生命周期、子代理调度、门禁与状态注入。将 cowork-flow 的完整工作流带入 Claude Code。",
5
5
  "displayName": "Cowork Flow",
6
6
  "author": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "1.7.3",
3
+ "version": "1.8.0",
4
4
  "description": "AI 协作工作流引擎:任务生命周期、子代理调度、门禁与状态注入。将 cowork-flow 的完整工作流带入 Codex。",
5
5
  "author": {
6
6
  "name": "fishlikewater",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "1.7.3",
3
+ "version": "1.8.0",
4
4
  "description": "AI 协作工作流引擎:任务生命周期、子代理调度、门禁与状态注入。将 cowork-flow 的完整工作流带入 Kimi Code。",
5
5
  "author": {
6
6
  "name": "fishlikewater",
@@ -574,8 +574,14 @@ function parseDecisionAnchor(text) {
574
574
  if (section === "目标") {
575
575
  if (line) goalLines.push(line)
576
576
  } else if (section === "验收标准") {
577
- const match = raw.match(/^\s*-\s*\[[ xX]?\]\s*(AC-[A-Za-z0-9-]+)\s*[::]\s*(.+?)\s*$/)
578
- if (match) result.acceptanceCriteria.push({ id: match[1], text: match[2] })
577
+ const match = raw.match(/^\s*-\s*(?:\[([ xX])\]?\s*)?(AC-[A-Za-z0-9-]+)\s*[::]\s*(.+?)\s*$/)
578
+ if (match) {
579
+ result.acceptanceCriteria.push({
580
+ id: match[2],
581
+ text: match[3],
582
+ checked: match[1] === 'x' || match[1] === 'X',
583
+ })
584
+ }
579
585
  } else if (section === "被拒方案") {
580
586
  const match = raw.match(/^\s*-\s*\*\*(.+?)\*\*/)
581
587
  if (match) result.rejectedOptions.push(match[1].trim())
@@ -836,6 +842,36 @@ function withStageFacts(block, root, taskPath, status, readonly = false) {
836
842
  return parts.length > 0 ? `${parts.join("\n\n")}\n\n${block}` : block
837
843
  }
838
844
 
845
+ // Evidence coverage for the anchor block: one read of the task's
846
+ // evidence.jsonl, tolerating noise exactly like the python line. Returns
847
+ // null when the file is absent so the coverage line degrades silently.
848
+ function readEvidenceCoverage(root, taskPath) {
849
+ let text = ''
850
+ try {
851
+ text = readFileSync(resolve(root, taskPath, 'evidence.jsonl'), 'utf8')
852
+ } catch {
853
+ return null
854
+ }
855
+ const covered = new Set()
856
+ for (const raw of text.split('\n')) {
857
+ const line = raw.trim()
858
+ if (!line) continue
859
+ let record
860
+ try {
861
+ record = JSON.parse(line)
862
+ } catch {
863
+ continue
864
+ }
865
+ if (!record || typeof record !== 'object' || Array.isArray(record)) continue
866
+ const ac = typeof record.ac === 'string' ? record.ac.trim() : ''
867
+ const ref = typeof record.ref === 'string' ? record.ref.trim() : ''
868
+ if (ac && ref && ['test', 'command', 'manual'].includes(record.kind)) {
869
+ covered.add(ac)
870
+ }
871
+ }
872
+ return covered
873
+ }
874
+
839
875
  function decisionAnchorBlock(root, taskPath, status) {
840
876
  if (!taskPath) {
841
877
  return null
@@ -868,6 +904,17 @@ function decisionAnchorBlock(root, taskPath, status) {
868
904
  lines.push(
869
905
  `Acceptance: ${parsed.acceptanceCriteria.slice(0, 8).map((item) => `${item.id} ${item.text.slice(0, 80)}`).join("; ")}`
870
906
  )
907
+ const covered = readEvidenceCoverage(root, taskPath)
908
+ if (covered) {
909
+ const total = parsed.acceptanceCriteria.length
910
+ const missing = parsed.acceptanceCriteria
911
+ .map((item) => item.id)
912
+ .filter((id) => !covered.has(id))
913
+ const detail = missing.length
914
+ ? ` missing=${missing.slice(0, 5).join(',')}`
915
+ : ''
916
+ lines.push(`AC evidence: ${total - missing.length}/${total}${detail}`)
917
+ }
871
918
  }
872
919
  if (parsed.rejectedOptions.length > 0) {
873
920
  lines.push(`Rejected: ${parsed.rejectedOptions.slice(0, 6).join("; ")}`)
@@ -1073,5 +1120,6 @@ export {
1073
1120
  runEditSpecCheck,
1074
1121
  contractFingerprint,
1075
1122
  stableStringify,
1076
- stageContractBlock
1123
+ stageContractBlock,
1124
+ decisionAnchorBlock
1077
1125
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "1.7.3",
3
+ "version": "1.8.0",
4
4
  "description": "AI 协作工作流引擎:任务生命周期、子代理调度、门禁与状态注入。将 cowork-flow 的完整工作流带入 Qoder。",
5
5
  "displayName": "Cowork Flow",
6
6
  "author": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "1.7.3",
3
+ "version": "1.8.0",
4
4
  "description": "AI 协作工作流引擎:任务生命周期、子代理调度、门禁与状态注入。将 cowork-flow 的完整工作流带入 ZCode。",
5
5
  "author": {
6
6
  "name": "fishlikewater",
@@ -1 +1 @@
1
- 1.7.3
1
+ 1.8.0
@@ -108,13 +108,22 @@ def _print_text_report(report: dict, *, verbose: bool) -> None:
108
108
  print(_parse_error_line(parse_errors))
109
109
  return
110
110
 
111
+ summary = report.get("summary", {})
112
+ if not parse_errors and summary.get("declarations") == 0:
113
+ # Zero declarations is a fact, not a pass: name it so a missing or
114
+ # silently ignored declaration block cannot look like a clean run.
115
+ print(
116
+ "spec-check: no checks declared "
117
+ f"({report.get('specFiles', 0)} spec files scanned)"
118
+ )
119
+ return
120
+
111
121
  from services.spec_check import normalized_one_line
112
122
 
113
123
  line = normalized_one_line(report)
114
124
  if line:
115
125
  print(line)
116
126
  return
117
- summary = report.get("summary", {})
118
127
  print(
119
128
  "spec-check: {pass} passed, {violation} violations, "
120
129
  "{unchecked} unchecked".format(
@@ -336,6 +336,9 @@ def cmd_complete(args: argparse.Namespace) -> int:
336
336
  task_dir,
337
337
  execution_context=execution_context,
338
338
  allow_unchecked_specs=bool(getattr(args, "allow_unchecked", False)),
339
+ allow_missing_evidence=bool(
340
+ getattr(args, "allow_missing_evidence", False)
341
+ ),
339
342
  )
340
343
  if not result.ok:
341
344
  if result.code == "LIFECYCLE-TRANSITION-001":
@@ -86,6 +86,7 @@ def _add_next_output_options(parser: argparse.ArgumentParser) -> None:
86
86
  )
87
87
  parser.add_argument(
88
88
  "--allow-unchecked",
89
+ "--allow-missing-evidence",
89
90
  dest="allow_unchecked",
90
91
  action="store_true",
91
92
  help="With task completion: allow unchecked spec-check commands "
@@ -382,12 +382,33 @@ def _decision_anchor_block(
382
382
  for item in parsed["acceptanceCriteria"][:8]
383
383
  )
384
384
  lines.append(f"Acceptance: {items}")
385
+ coverage = _acceptance_evidence_coverage(root, task_path)
386
+ if coverage:
387
+ detail = ""
388
+ if coverage["missing"]:
389
+ detail = f" missing={','.join(coverage['missing'][:5])}"
390
+ lines.append(
391
+ f"AC evidence: {coverage['withEvidence']}/{coverage['total']}{detail}"
392
+ )
385
393
  if parsed["rejectedOptions"]:
386
394
  lines.append("Rejected: " + "; ".join(parsed["rejectedOptions"][:6]))
387
395
  lines.append("</decision-anchor>")
388
396
  return "\n".join(lines)
389
397
 
390
398
 
399
+ def _acceptance_evidence_coverage(root: Path, task_path: str | None):
400
+ """AC evidence coverage facts for the anchor block; a supplementary fact
401
+ that degrades to absence on any failure instead of degrading injection."""
402
+ if not task_path:
403
+ return None
404
+ try:
405
+ from services.ac_evidence import coverage_summary
406
+
407
+ return coverage_summary(root / task_path)
408
+ except Exception:
409
+ return None
410
+
411
+
391
412
  def _stage_contract_block(
392
413
  root: Path, task_path: str | None, status: str
393
414
  ) -> str | None:
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Acceptance-criteria evidence facts for task completion.
4
+
5
+ Single read entry for the completion gate, the fact view, and injection:
6
+ which AC ids the decision anchor declares, which are checked, and which
7
+ have at least one evidence record in the task's evidence.jsonl. Judgment
8
+ about whether a record really proves an AC stays with review; the machine
9
+ only decides existence facts.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from services.fact_view import parse_decision_anchor
19
+
20
+ EVIDENCE_FILE = "evidence.jsonl"
21
+ EVIDENCE_KINDS = ("test", "command", "manual")
22
+
23
+
24
+ def read_decision_anchor(task_dir: Path) -> str | None:
25
+ path = Path(task_dir) / "decision-anchor.md"
26
+ try:
27
+ text = path.read_text(encoding="utf-8")
28
+ except (OSError, UnicodeDecodeError):
29
+ return None
30
+ return text or None
31
+
32
+
33
+ def load_acceptance_criteria(task_dir: Path) -> list[dict[str, Any]]:
34
+ """AC rows (id / text / checked) declared by the task's decision anchor.
35
+
36
+ Empty list when the anchor is missing or declares no AC rows — the
37
+ compatibility path that keeps pre-existing tasks exempt from the gate."""
38
+ text = read_decision_anchor(task_dir)
39
+ if not text:
40
+ return []
41
+ return list(parse_decision_anchor(text)["acceptanceCriteria"])
42
+
43
+
44
+ def read_evidence(task_dir: Path) -> list[dict[str, Any]]:
45
+ """Valid evidence records appended by the implementer.
46
+
47
+ Bad lines and malformed records are skipped, never fatal: the gate
48
+ blocks on missing coverage, not on file noise."""
49
+ path = Path(task_dir) / EVIDENCE_FILE
50
+ try:
51
+ text = path.read_text(encoding="utf-8")
52
+ except (OSError, UnicodeDecodeError):
53
+ return []
54
+ records: list[dict[str, Any]] = []
55
+ for line in text.splitlines():
56
+ line = line.strip()
57
+ if not line:
58
+ continue
59
+ try:
60
+ record = json.loads(line)
61
+ except json.JSONDecodeError:
62
+ continue
63
+ if isinstance(record, dict) and _valid_record(record):
64
+ records.append(record)
65
+ return records
66
+
67
+
68
+ def _valid_record(record: dict[str, Any]) -> bool:
69
+ ac = record.get("ac")
70
+ ref = record.get("ref")
71
+ kind = record.get("kind")
72
+ return (
73
+ isinstance(ac, str)
74
+ and bool(ac.strip())
75
+ and isinstance(ref, str)
76
+ and bool(ref.strip())
77
+ and kind in EVIDENCE_KINDS
78
+ )
79
+
80
+
81
+ def evidence_coverage(
82
+ acceptance: list[dict[str, Any]],
83
+ evidence: list[dict[str, Any]],
84
+ ) -> dict[str, Any]:
85
+ """Coverage facts over declared AC ids: total, covered, missing ids."""
86
+ covered = {
87
+ record["ac"].strip()
88
+ for record in evidence
89
+ }
90
+ ids = [item["id"] for item in acceptance]
91
+ missing = [ac_id for ac_id in ids if ac_id not in covered]
92
+ return {
93
+ "total": len(ids),
94
+ "withEvidence": len(ids) - len(missing),
95
+ "missing": missing,
96
+ }
97
+
98
+
99
+ def coverage_summary(task_dir: Path) -> dict[str, Any] | None:
100
+ """End-to-end coverage facts for one task.
101
+
102
+ None when the task declares no acceptance criteria — consumers treat
103
+ that as "gate not applicable" instead of a fake zero."""
104
+ acceptance = load_acceptance_criteria(task_dir)
105
+ if not acceptance:
106
+ return None
107
+ return evidence_coverage(acceptance, read_evidence(task_dir))
@@ -23,7 +23,7 @@ VERIFY_COMMAND_MAX = 5
23
23
  SCOPE_BOUNDARY_TRUNCATE = 160
24
24
  ANCHOR_SECTIONS = ("目标", "验收标准", "被拒方案", "验证命令", "范围边界")
25
25
  ACCEPTANCE_RE = re.compile(
26
- r"^\s*-\s*\[[ xX]?\]\s*(AC-[A-Za-z0-9-]+)\s*[::]\s*(.+?)\s*$"
26
+ r"^\s*-\s*(?:\[([ xX])\]?\s*)?(AC-[A-Za-z0-9-]+)\s*[::]\s*(.+?)\s*$"
27
27
  )
28
28
  REJECTED_RE = re.compile(r"^\s*-\s*\*\*(.+?)\*\*")
29
29
  _HEADING_RE = re.compile(r"^##\s*(.+?)\s*$")
@@ -53,7 +53,11 @@ def parse_decision_anchor(text: str) -> dict[str, Any]:
53
53
  match = ACCEPTANCE_RE.match(raw)
54
54
  if match:
55
55
  acceptance.append(
56
- {"id": match.group(1), "text": match.group(2)}
56
+ {
57
+ "id": match.group(2),
58
+ "text": match.group(3),
59
+ "checked": match.group(1) in ("x", "X"),
60
+ }
57
61
  )
58
62
  elif section == "被拒方案":
59
63
  match = REJECTED_RE.match(raw)
@@ -488,6 +492,15 @@ def build_fact_view(repo_root: Path, task_dir: Path) -> dict[str, Any]:
488
492
  else:
489
493
  anchor = {"exists": False}
490
494
 
495
+ evidence_coverage: dict[str, Any] | None = None
496
+ if anchor.get("acceptanceCriteria"):
497
+ try:
498
+ from services.ac_evidence import coverage_summary
499
+
500
+ evidence_coverage = coverage_summary(task_dir)
501
+ except Exception:
502
+ evidence_coverage = None
503
+
491
504
  meta = task.get("meta") if isinstance(task, dict) else None
492
505
  plan_file = meta.get("planFile") if isinstance(meta, dict) else None
493
506
  plan: dict[str, Any] = {"bound": bool(plan_file)}
@@ -501,6 +514,7 @@ def build_fact_view(repo_root: Path, task_dir: Path) -> dict[str, Any]:
501
514
  "taskPath": rel_task,
502
515
  "task": task,
503
516
  "decisionAnchor": anchor,
517
+ "evidenceCoverage": evidence_coverage,
504
518
  "plan": plan,
505
519
  "whitelist": file_scope_whitelist(repo_root, task_dir),
506
520
  "sessions": _bound_sessions(repo_root, rel_task),
@@ -45,6 +45,7 @@ class LifecycleCheckResult:
45
45
  blockers: tuple[str, ...] = ()
46
46
  issues: tuple[LifecycleCheckIssue, ...] = ()
47
47
  spec_check: dict | None = None
48
+ evidence_exemption: dict | None = None
48
49
 
49
50
  def __post_init__(self) -> None:
50
51
  if self.issues and not self.blockers:
@@ -96,6 +97,7 @@ class LifecycleCheckRunner:
96
97
  *,
97
98
  allow_spec_file_modifications: bool | None = None,
98
99
  allow_unchecked_specs: bool | None = None,
100
+ allow_missing_evidence: bool | None = None,
99
101
  execution_policy: LifecycleExecutionPolicy | None = None,
100
102
  ) -> LifecycleCheckResult:
101
103
  spec_report = _spec_check_report(self.repo_root)
@@ -107,6 +109,14 @@ class LifecycleCheckRunner:
107
109
  else _policy_allows_unchecked(execution_policy)
108
110
  ),
109
111
  )
112
+ ac_issues, evidence_exemption = _ac_evidence_completion_facts(
113
+ task_dir,
114
+ allow_missing_evidence=(
115
+ bool(allow_missing_evidence)
116
+ if allow_missing_evidence is not None
117
+ else _policy_allows_missing_evidence(execution_policy)
118
+ ),
119
+ )
110
120
  return LifecycleCheckResult(
111
121
  stage="complete",
112
122
  issues=tuple(
@@ -119,8 +129,10 @@ class LifecycleCheckRunner:
119
129
  ),
120
130
  ))
121
131
  + list(spec_issues)
132
+ + list(ac_issues)
122
133
  ),
123
134
  spec_check=spec_report,
135
+ evidence_exemption=evidence_exemption,
124
136
  )
125
137
 
126
138
 
@@ -139,6 +151,78 @@ def _policy_allows_unchecked(
139
151
  return bool(execution_policy is not None and execution_policy.allow_unchecked_specs)
140
152
 
141
153
 
154
+ def _policy_allows_missing_evidence(
155
+ execution_policy: LifecycleExecutionPolicy | None,
156
+ ) -> bool:
157
+ return bool(
158
+ execution_policy is not None
159
+ and execution_policy.allow_missing_evidence
160
+ )
161
+
162
+
163
+ def _ac_evidence_completion_facts(
164
+ task_dir: Path,
165
+ *,
166
+ allow_missing_evidence: bool,
167
+ ) -> tuple[list[LifecycleCheckIssue], dict | None]:
168
+ """Completion gate over acceptance-criteria evidence.
169
+
170
+ The gate only decides existence facts: which AC ids the decision anchor
171
+ declares, which are checked, and which carry at least one evidence
172
+ record. Tasks without AC rows are exempt (compatibility path); an
173
+ unreadable evidence module degrades the same way instead of blocking
174
+ every legacy task. Returns the issues plus the recorded exemption
175
+ (missing ids) when --allow-missing-evidence absorbed the gap."""
176
+ try:
177
+ from services.ac_evidence import (
178
+ evidence_coverage,
179
+ load_acceptance_criteria,
180
+ read_evidence,
181
+ )
182
+ except Exception: # pragma: no cover - broken runtime copy degrades
183
+ return [], None
184
+ acceptance = load_acceptance_criteria(task_dir)
185
+ if not acceptance:
186
+ return [], None
187
+ coverage = evidence_coverage(acceptance, read_evidence(task_dir))
188
+ unchecked = [item["id"] for item in acceptance if not item["checked"]]
189
+ unproven = [
190
+ ac_id for ac_id in coverage["missing"] if ac_id not in unchecked
191
+ ]
192
+ issues: list[LifecycleCheckIssue] = []
193
+ if unchecked:
194
+ issues.append(
195
+ LifecycleCheckIssue(
196
+ code="LIFECYCLE-AC-001",
197
+ message=(
198
+ f"decision-anchor has {len(unchecked)} unchecked "
199
+ "acceptance criterion(ies): "
200
+ f"{', '.join(unchecked[:5])}; finish them and tick the "
201
+ "checkbox, or record evidence in evidence.jsonl"
202
+ ),
203
+ )
204
+ )
205
+ exemption: dict | None = None
206
+ if unproven:
207
+ if allow_missing_evidence:
208
+ exemption = {"missing": unproven}
209
+ else:
210
+ issues.append(
211
+ LifecycleCheckIssue(
212
+ code="LIFECYCLE-AC-002",
213
+ message=(
214
+ f"{len(unproven)} acceptance criterion(ies) lack "
215
+ "evidence records: "
216
+ f"{', '.join(unproven[:5])}; append "
217
+ "{ac, kind: test|command|manual, ref, note?, "
218
+ "recordedAt, by} records to evidence.jsonl, or pass "
219
+ "--allow-missing-evidence to record the exemption"
220
+ ),
221
+ )
222
+ )
223
+ return issues, exemption
224
+
225
+
142
226
  def _spec_check_report(repo_root: Path) -> dict | None:
143
227
  """Run the lifecycle-phase spec checks. A structural failure degrades to
144
228
  an unchecked report — never to a silently passing gate."""
@@ -35,6 +35,7 @@ class LifecycleExecutionPolicy:
35
35
  execution_scope: str
36
36
  allow_spec_file_modifications: bool
37
37
  allow_unchecked_specs: bool = False
38
+ allow_missing_evidence: bool = False
38
39
 
39
40
 
40
41
  def start_readiness_failure(
@@ -93,17 +94,20 @@ def resolve_execution_policy(
93
94
  *,
94
95
  allow_spec_file_modifications: bool | None = None,
95
96
  allow_unchecked_specs: bool | None = None,
97
+ allow_missing_evidence: bool | None = None,
96
98
  ) -> LifecycleExecutionPolicy:
97
99
  """Resolve lifecycle scope capability from execution facts."""
98
100
  if execution_context is None:
99
101
  if (
100
102
  allow_spec_file_modifications is not None
101
103
  or allow_unchecked_specs is not None
104
+ or allow_missing_evidence is not None
102
105
  ):
103
106
  return LifecycleExecutionPolicy(
104
107
  execution_scope="explicit",
105
108
  allow_spec_file_modifications=bool(allow_spec_file_modifications),
106
109
  allow_unchecked_specs=bool(allow_unchecked_specs),
110
+ allow_missing_evidence=bool(allow_missing_evidence),
107
111
  )
108
112
  execution_context = ExecutionContext()
109
113
 
@@ -117,6 +121,7 @@ def resolve_execution_policy(
117
121
  execution_scope=execution_context.mode,
118
122
  allow_spec_file_modifications=allowed,
119
123
  allow_unchecked_specs=bool(allow_unchecked_specs),
124
+ allow_missing_evidence=bool(allow_missing_evidence),
120
125
  )
121
126
 
122
127
 
@@ -357,9 +357,16 @@ def run_checks(
357
357
  if file_filter:
358
358
  changed = [file_filter.replace("\\", "/")]
359
359
 
360
+ collected = collect_spec_declarations(repo_root)
361
+ # Repository-wide declaration count, phase-independent: "no checks
362
+ # declared" is a property of the repo, not of this phase's result list
363
+ # (a lifecycle run skips every edit-only declaration and would otherwise
364
+ # read as "nothing declared").
365
+ declarations_total = sum(len(spec_decls.decls) for spec_decls in collected)
366
+
360
367
  results: list[CheckResult] = []
361
368
  parse_errors: list[dict[str, str]] = []
362
- for spec_decls in collect_spec_declarations(repo_root):
369
+ for spec_decls in collected:
363
370
  for message in spec_decls.errors:
364
371
  parse_errors.append({"spec": spec_decls.spec, "error": message})
365
372
  for decl in spec_decls.decls:
@@ -400,11 +407,14 @@ def run_checks(
400
407
  "pass": sum(1 for r in results if r.status == "pass"),
401
408
  "violation": sum(1 for r in results if r.status == "violation"),
402
409
  "unchecked": sum(1 for r in results if r.status == "unchecked"),
410
+ "declarations": declarations_total,
403
411
  }
404
412
  return {
405
413
  "schemaVersion": SCHEMA_VERSION,
406
414
  "phase": phase,
407
415
  "changedFiles": changed,
416
+ "declarations": declarations_total,
417
+ "specFiles": len(collected),
408
418
  "results": [r.to_json() for r in results],
409
419
  "parseErrors": parse_errors,
410
420
  "summary": summary,
@@ -160,6 +160,7 @@ class TaskLifecycleService:
160
160
  completed_at: str | None = None,
161
161
  allow_spec_file_modifications: bool | None = None,
162
162
  allow_unchecked_specs: bool = False,
163
+ allow_missing_evidence: bool = False,
163
164
  execution_context: object | None = None,
164
165
  ) -> LifecycleResult:
165
166
  return self.execute(
@@ -168,6 +169,7 @@ class TaskLifecycleService:
168
169
  completed_at=completed_at,
169
170
  allow_spec_file_modifications=allow_spec_file_modifications,
170
171
  allow_unchecked_specs=allow_unchecked_specs,
172
+ allow_missing_evidence=allow_missing_evidence,
171
173
  execution_context=execution_context,
172
174
  )
173
175
 
@@ -179,6 +181,7 @@ class TaskLifecycleService:
179
181
  preflight: Preflight | None = None,
180
182
  allow_spec_file_modifications: bool | None = None,
181
183
  allow_unchecked_specs: bool = False,
184
+ allow_missing_evidence: bool = False,
182
185
  completed_at: str | None = None,
183
186
  execution_context: object | None = None,
184
187
  executor: str | None = None,
@@ -191,6 +194,7 @@ class TaskLifecycleService:
191
194
  execution_context,
192
195
  allow_spec_file_modifications=allow_spec_file_modifications,
193
196
  allow_unchecked_specs=allow_unchecked_specs,
197
+ allow_missing_evidence=allow_missing_evidence,
194
198
  )
195
199
  prepared = self._prepare_transition(
196
200
  stage, task_dir, preflight, executor=executor, takeover=takeover
@@ -590,6 +594,7 @@ class TaskLifecycleService:
590
594
  execution_policy.allow_spec_file_modifications
591
595
  ),
592
596
  allow_unchecked_specs=execution_policy.allow_unchecked_specs,
597
+ allow_missing_evidence=execution_policy.allow_missing_evidence,
593
598
  execution_policy=execution_policy,
594
599
  )
595
600
  else:
@@ -678,6 +683,13 @@ class TaskLifecycleService:
678
683
  meta["specCheckSummary"] = dict(
679
684
  spec_report.get("summary") or {}
680
685
  )
686
+ if getattr(check_result, "evidence_exemption", None):
687
+ meta["evidenceExempt"] = {
688
+ "at": datetime.now(timezone.utc).strftime(
689
+ "%Y-%m-%dT%H:%M:%SZ"
690
+ ),
691
+ **check_result.evidence_exemption,
692
+ }
681
693
  if (
682
694
  execution_policy is not None
683
695
  and execution_policy.allow_unchecked_specs
@@ -233,6 +233,7 @@ means without re-reading the file:
233
233
  <decision-anchor task=".cowork-flow/tasks/08-28-demo">
234
234
  Goal: <first goal line, max 160 chars>
235
235
  Acceptance: AC-001 <text, max 80>; AC-002 <...> (first 8 items)
236
+ AC evidence: <with evidence>/<total>[ missing=<id,...>, max 5]
236
237
  Rejected: <rejected option name>; ... (first 6 names)
237
238
  </decision-anchor>
238
239
  ```
@@ -240,9 +241,10 @@ Rejected: <rejected option name>; ... (first 6 names)
240
241
  - Extracted from `decision-anchor.md` with the line-level parser frozen in
241
242
  `services/fact_view.py` (`parse_decision_anchor`); the zcode and opencode JS
242
243
  lines carry an identical parser shape.
243
- - Missing anchor file, no extractable essentials, or terminal statuses
244
- (`completed`) inject nothing.
245
-
244
+ - The `AC evidence:` row appears only for tasks that declare AC rows, right
245
+ after the `Acceptance:` row, and renders byte-identically across the
246
+ python / zcode-shim / opencode lines (matrix-locked). Schema and gate
247
+ semantics live in `evidence-completion.md`.
246
248
  - Missing anchor file, no extractable essentials, or terminal statuses
247
249
  (`completed`) inject nothing.
248
250
 
@@ -66,6 +66,7 @@
66
66
  ## Lifecycle Check 联动
67
67
 
68
68
  - `task next <dir> --run` 启动任务时的 preflight 检查 `decision-anchor.md` 非空,并要求 `## 目标` 和 `## 验收标准` 章节存在。
69
+ - `## 验收标准` 行的勾选状态(`- [ ]` / `- [x]`)与证据记录(任务目录 `evidence.jsonl`)是完成门禁的事实源:存在未勾选 AC(`LIFECYCLE-AC-001`)或已声明 AC 缺证据(`LIFECYCLE-AC-002`)时完成被阻断。schema 与豁免见 `evidence-completion.md`。
69
70
  - `cowork-implement` 读取:见 .cowork-flow/spec/contracts/subagent-dispatch.md
70
71
  - `cowork-check` 读取:同上
71
72
  - `debug.jsonl` 引用:可选,偏差诊断时写入 anchor 锚点差异
@@ -0,0 +1,48 @@
1
+ # Evidence Completion Contract
2
+
3
+ **任务完成从"流程走完"升级为"证据齐全":decision-anchor 的验收标准(AC)是机器可查询的事实,完成门禁校验每个 AC 的证据记录存在性。**
4
+
5
+ 机器只裁决存在性事实(AC 声明、勾选状态、证据条目有无);一条证据是否真正证明对应 AC,由 review 判断。
6
+
7
+ ## 证据记录:`evidence.jsonl`
8
+
9
+ 任务目录下的追加式 JSONL 文件,由实现方(主会话或子代理)在实现过程中写入,runtime 只读:
10
+
11
+ ```json
12
+ {"ac": "AC-001", "kind": "test", "ref": "tests/test_ac_evidence.py", "note": "覆盖计算三态断言", "recordedAt": "2026-10-06", "by": "cwf-cli-1004"}
13
+ ```
14
+
15
+ | 字段 | 必填 | 含义 |
16
+ |---|---|---|
17
+ | `ac` | 是 | 对应的 AC id(`AC-xxx`,与 decision-anchor 一致) |
18
+ | `kind` | 是 | `test`(自动化测试)、`command`(命令及退出码)、`manual`(手工验证记录) |
19
+ | `ref` | 是 | 证据指向:测试文件/用例、命令、手工记录位置;非空字符串 |
20
+ | `note` | 否 | 核心断言一句话 |
21
+ | `recordedAt` | 否 | ISO 日期 |
22
+ | `by` | 否 | 写入者会话/执行者标识 |
23
+
24
+ 坏行、缺字段、未知 `kind` 的记录被读取端跳过(与 context JSONL 容错一致),门禁只看有效记录。
25
+
26
+ ## 完成门禁
27
+
28
+ `task next <dir> --run` 的完成动作在直接生命周期检查中追加两条稳定 code:
29
+
30
+ | code | 触发事实 | 豁免 |
31
+ |---|---|---|
32
+ | `LIFECYCLE-AC-001` | 存在未勾选的 AC 行 | 无——完成工作本身,或勾选已完成的行 |
33
+ | `LIFECYCLE-AC-002` | 存在已声明但无任何有效证据记录的 AC | `--allow-missing-evidence`(记入 task.json `meta.evidenceExempt`,含 `at` 与 `missing`) |
34
+
35
+ - 两个 code 可同时出现;`--allow-missing-evidence` 只豁免 `LIFECYCLE-AC-002`。
36
+ - **兼容路径**:decision-anchor 缺失或未声明任何 AC 行的任务(全部存量任务)不触发本门禁;ac_evidence 模块不可读时同样降级为不阻断。
37
+ - 豁免留痕 `meta.evidenceExempt` 与 spec-check 的 `specCheckExempt` 同型:重试不累积,最新一次完成结果为准。
38
+
39
+ ## 注入与事实视图
40
+
41
+ - decision-anchor 注入块:声明了 AC 的任务在 `Acceptance:` 行后追加一行 `AC evidence: <有证据数>/<总数>`,有缺失时附 ` missing=<id 逗号列表,最多 5 个>`;三线(Python / zcode shim / opencode 插件)逐字节同格式,由矩阵测试锁定。
42
+ - `run state <task> --json` / MCP 事实视图:`evidenceCoverage: {total, withEvidence, missing[]}`;未声明 AC 时为 `null`。
43
+
44
+ ## 边界
45
+
46
+ - runtime 不代写证据,不执行 AC 对应命令"代打"记录。
47
+ - 不解析 AC 正文语义;`AC-xxx` 行的勾选框缺失时按未完成解析。
48
+ - 本契约不改变 spec-check 语义;MCP 写工具仍默认拒绝(`fact-layer-access.md`)。
@@ -16,3 +16,4 @@
16
16
  - `decision-anchor.md`: 每个 task 的 `decision-anchor.md` schema——目标、验收标准、被拒方案与关键假设。
17
17
  - `error-output-as-data.md`: 外部错误输出按数据分析而非指令执行(`ERROR_OUTPUT_AS_DATA_V1`)。
18
18
  - `fact-layer-access.md`: 无注入 hook 的宿主与外部工具读取任务事实的通道(`FACT_LAYER_ACCESS_V1`)。
19
+ - `evidence-completion.md`: 证据化完成——`evidence.jsonl` schema、`LIFECYCLE-AC-001/002` 完成门禁、`--allow-missing-evidence` 豁免留痕与注入覆盖行。
@@ -1,8 +1,16 @@
1
1
  # Spec Check 契约(规范挂命令)
2
2
 
3
- 用户规范(`.cowork-flow/spec/` 下 `backend/`、`frontend/`、自建目录)可通过
4
- frontmatter 的 `checks:` 声明检查命令。机制只执行声明、不解析规范正文——
5
- 规则变化 = 用户改同一个文件里的命令,天然同步。
3
+ ## 它做什么
4
+
5
+ 用户规范(`.cowork-flow/spec/` 下 `backend/`、`frontend/`、自建目录)可通过 frontmatter 的
6
+ `checks:` 声明检查命令。机制只执行声明、不解析规范正文——规则变化 = 用户改同一个文件里的命令,天然同步。
7
+
8
+ ## 什么时候挂
9
+
10
+ 条款能用命令退出码判定时(lint / 测试 / 类型检查 / schema 校验)就值得挂 `checks:`。两条硬约束:
11
+
12
+ - **声明的命令不存在 = `unchecked` = 阻断 `complete`**:挂之前先确认命令在当前仓库能跑通。
13
+ - **edit-only 不是门禁**:`when: edit` 只是编辑期 best-effort 提示——Bash 直写绕过 PostToolUse、无编辑期快跑能力的宿主(见附录 A)都收不到这层反馈,收口期也不补跑;需要硬门禁的检查必须声明 `when: lifecycle` 或默认 `both`。
6
14
 
7
15
  ## 语法
8
16
 
@@ -19,17 +27,12 @@ checks:
19
27
  # 规范正文(机制不读)
20
28
  ```
21
29
 
22
- - `files` 只支持两种形式:目录前缀(`src/`,以 `/` 结尾或自动补齐)与
23
- 扩展名(`*.ts`)。完整 glob 形态(`**`、`[]`)解析为错误,声明不执行,
24
- doctor 报告——绝不回退为"匹配一切"。多值形态(如 `files: "src/", "lib/"`)
25
- 每个 token 按同样的引号规则去引号后生效。
26
- - `when: edit` 的声明在编辑期(PostToolUse)对命中 `files` 的改动文件
27
- 就地执行,超时被钳制到 2.5 秒(为解释器启动与 cmd 包装留 spawn 预算);
28
- 慢命令请声明 `when: lifecycle`。
29
- - 引号规则:仅值整体为单对引号时去引号;`"python" -c "..."`
30
- 这类含多对引号的命令值原样保留,由执行器按 shell 规则拆分。
30
+ - `files` 只支持两种形式:目录前缀(`src/`,以 `/` 结尾或自动补齐)与扩展名(`*.ts`)。完整 glob 形态(`**`、`[]`)解析为错误、声明不执行、doctor 报告——绝不回退为"匹配一切";多值(`files: "src/", "lib/"`)每个 token 按同一引号规则去引号后生效。
31
+ - 引号规则:仅值整体为单对引号时去引号;`"python" -c "..."` 这类含多对引号的命令值原样保留,由执行器按 shell 规则拆分。
32
+ - `when: edit` 在编辑期(PostToolUse)对命中 `files` 的改动文件就地执行,超时被钳制到 2.5 秒(为解释器启动与 cmd 包装留 spawn 预算);慢命令请声明 `when: lifecycle`。
33
+ - **文件必须 UTF-8 无 BOM**:BOM 开头的 frontmatter 会被解析器静默忽略(声明数 0、无解析错误)——声明无声失效。挂完声明请跑一次 `spec-check` 确认被解析。
31
34
 
32
- ## 三态结果(完成门禁语义)
35
+ ## 三态与门禁
33
36
 
34
37
  | 状态 | 含义 | complete 门禁 |
35
38
  | --- | --- | --- |
@@ -37,58 +40,26 @@ checks:
37
40
  | `violation` | 退出码非 0 | **阻断**(状态不推进) |
38
41
  | `unchecked` | 命令不存在/解释器缺失/超时 | **阻断**;显式 `--allow-unchecked` 可放行,豁免留痕进 task.json `meta.specCheckExempt` |
39
42
 
40
- unchecked 永不冒充 pass("没跑成"不是"通过")。frontmatter 畸形视该
41
- spec 为无检查并进 doctor 报告,不炸流程。
42
-
43
- 解析失败是独立于三态之外的信号:`files` 形态非法等声明解析错误归入
44
- `parseErrors`,不折进 `pass`/`unchecked`,退出码与 `unchecked` 同为 2。
45
- 文本模式与 `--json` 必须使用同一退出码,且文本模式必须打印解析错误行
46
- (`spec-check: N parse error(s): <spec>: <error>`)——只打印
47
- `0 passed, 0 violations, 0 unchecked` 会让畸形声明看起来像干净通过。
48
-
49
- 退出码契约:`0` 全部 pass;`1` 存在 violation;`2` 存在 unchecked 或
50
- `parseErrors`。`--allow-unchecked` 只豁免 unchecked 的阻断,不豁免
51
- 解析错误。
52
-
53
- ## 时机与单行输出
54
-
55
- - **编辑期**(zcode/claude):违规输出单行
56
- `spec-check[<spec>] violation: <首个违规行>`;同一文件 10 秒内只报一次
57
- (节流状态在 `.cowork-flow/.runtime/spec-edit-throttle.json`,检查完整
58
- 跑过才写节流——执行器崩溃不消费时间窗,重试仍会执行);执行器
59
- 缺失或超时静默,绝不阻断编辑流。delegated 子代理会话同样收到 spec
60
- 违规反馈(子代理是主力写码者);scope 警告保持 main-only。zcode 主
61
- 会话的 hook 身份(对话自身 session id)不会被绑定——激活发生在 Bash
62
- 侧显式身份下——显示与编辑期警告按最新主会话兜底;claude/codex 保持
63
- 严格会话身份(其 Bash env 携带同一 session id)。zcode 经
64
- shim 转发到单源入口(`inject.py`),scope 警告与 spec 警告合并进同一个
65
- additionalContext 载荷;claude 走 exit 2 stderr 反馈。
66
- - **收口期**(task complete):全量执行,结果进 `meta.specCheckSummary`
67
- 遥测;阻断消息只列未过项摘要。
68
- - 手动查询:`./.cowork-flow/run spec-check [--file <path>] [--json]
69
- [--verbose]`(退出码见上)。
70
- - **edit-only 是 best-effort 提示,不是门禁**:Bash 直写绕过 PostToolUse
71
- 不会触发编辑期反馈;无编辑期快跑能力的宿主(见能力矩阵)同样没有
72
- 这层提示,且收口期不会对 `when: edit` 声明补跑。需要硬门禁的检查
73
- 必须声明 `when: lifecycle` 或默认 `both`。
74
- - **开放决策**:收口期全量执行没有聚合预算——声明多且慢的仓库 complete
75
- 会线性变慢;是否给收口加并行/预算上限留给后续产品决策,当前保持
76
- 简单串行。
77
-
78
- ## 归属与 sync 策略
79
-
80
- - **`spec/` 整体是 sync 保护前缀**:已存在的 spec 文件不会被 `sync`
81
- 覆盖——模板里 spec 的更新只对 `init` 新装生效,已安装项目的本地
82
- 内容不会被同步任务改写。
83
- - **具名例外**:`spec/contracts/` 下列入 `spec/runtime/host-assets.json`
84
- 的 `syncPolicy.safeFiles` 条目(本文件与 `workflow-state-templates.md`)
85
- 随 sync 更新,使契约修正能到达已安装项目;其余 spec 文件保持保护。
86
- - **声明由使用方维护**:模板自带的 spec 文件不含生效的 `checks:` 声明
87
- ——模板无法预知项目命令,而命令缺失会归 `unchecked` 并阻断
88
- `complete`。请把声明写进自建 spec 文件(如 `spec/team-xxx.md` 或
89
- 自建子目录),sync 不触碰,声明随规范正文一起由使用方维护。
90
-
91
- ## 宿主能力矩阵
43
+ unchecked 永不冒充 pass("没跑成"不是"通过");frontmatter 畸形视该 spec 为无检查并进 doctor 报告,不炸流程。解析失败是独立于三态之外的信号:`files` 形态非法等声明解析错误归入 `parseErrors`,不折进 `pass`/`unchecked`,退出码与 `unchecked` 同为 2。文本模式与 `--json` 必须使用同一退出码,且文本模式必须打印解析错误行(`spec-check: N parse error(s): <spec>: <error>`)——只打印 `0 passed, 0 violations, 0 unchecked` 会让畸形声明看起来像干净通过。
44
+
45
+ 退出码契约:`0` 全部 pass;`1` 存在 violation;`2` 存在 unchecked 或 `parseErrors`。`--allow-unchecked` 只豁免 unchecked 的阻断,不豁免解析错误。
46
+
47
+ **零声明是合法状态,但必须显式可见**:仓库无任何生效声明时退出码为 `0`,文本模式输出独立事实行 `spec-check: no checks declared (<N> spec files scanned)`,与"有声明且全过"的 `spec-check: <n> passed, 0 violations, 0 unchecked` 可区分;`--json` 顶层含 `specFiles`、summary 含 `declarations`。零声明 ≠ 干净通过——它意味着规范链上当前没有可机检约束,是需要被看见的事实,而不是可冒充通过的绿色。
48
+
49
+ ## 时机与输出
50
+
51
+ - **编辑期**(PostToolUse):仅 `when: edit`/`both` 且改动命中 `files` 时执行,违规输出单行 `spec-check[<spec>] violation: <首个违规行>`;执行器缺失或超时静默,绝不阻断编辑流。宿主差异与节流细节见附录 A。
52
+ - **收口期**(task complete):全量执行,结果进 `meta.specCheckSummary` 遥测;阻断消息只列未过项摘要。
53
+ - **手动查询**:`./.cowork-flow/run spec-check [--file <path>] [--json] [--verbose]`(退出码见上)。
54
+
55
+ ## 声明归使用方维护
56
+
57
+ - **模板不预置声明**:模板自带的 spec 文件不含生效的 `checks:` 声明——模板无法预知项目命令,而命令缺失会归 `unchecked` 并阻断 `complete`。请把声明写进自建 spec 文件(如 `spec/team-xxx.md` 或自建子目录),sync 不触碰,声明随规范正文一起由使用方维护。
58
+ - **sync 保护前缀与具名例外**:`spec/` 整体是 sync 保护前缀,已存在的 spec 文件不会被 `sync` 覆盖(模板里 spec 的更新只对 `init` 新装生效);唯一具名例外是 `spec/contracts/` 下列入 `spec/runtime/host-assets.json` `syncPolicy.safeFiles` 的条目(本文件与 `workflow-state-templates.md`),它们随 sync 更新,使契约修正能到达已安装项目。
59
+ - **识别并挂声明是主会话 AI 的职责**:声明归项目所有。review / 规范维护时,主会话 AI 负责识别可机检条款并补 `checks:` 声明(条款正文不擅自改——发现条款与实现冲突时按 review 流程提出,由用户决策);仅主会话写入 spec,delegated 子代理只上报候选、不越 scope。完整职责见 `task-review` 与 `spec-sync` Skill。
60
+ - **质量纪律四条**:① 在干净树上跑出 `pass`;② 用一个反例证明该检查确实能报错(不是恒过的装饰);③ 用 `files` 限定适用范围;④ 挂后跑一次 `spec-check` 确认声明被解析(防 BOM / `files` 形态错误静默失效);并在 review 报告列出「条款 → 命令」清单。
61
+
62
+ ## 附录 A 宿主能力矩阵
92
63
 
93
64
  | 宿主 | digest 注入 | 编辑期快跑 | 收口强制 |
94
65
  | --- | --- | --- | --- |
@@ -102,27 +73,23 @@ spec 为无检查并进 doctor 报告,不炸流程。
102
73
 
103
74
  zcode / codex / claude-code 经 Python 单源共享 `run_edit_checks`;opencode 在插件内以 CLI 拉取同一执行器(`run spec-check --phase edit --throttled`),dsh 经预设插件的 `tools/post-execute` 调同一 Python 协议,qoder 经插件 hook 的 `PostToolUse` 调同一执行器(该事件在 Qoder 不可阻断,告警以 `additionalContext` 随 exit 0 返回,而非 claude-code/codex 的 stderr + exit 2)——具备编辑期快跑的六家共享同一节流状态与三态语义,不各自实现检查逻辑;kimi-code 只走收口强制与子代理自查。
104
75
 
105
- **delegated 子代理编辑期缺口(zcode 实测,2026-09-09 两轮)**:ZCode 的插件
106
- hook 只在主会话工具流上派发(事件模型七事件均挂主会话;子代理是独立内部
107
- 会话流 `sess_subagent_agent_*`),Agent 子代理的 Edit/Write 收不到
108
- `spec-check[...] violation` 行——runtime 绑定与检查逻辑均正常,缺的是宿主
109
- 派发。补偿机制(subagent-dispatch.md 契约固化):
76
+ 编辑期节流:同一文件 10 秒内只报一次;节流状态在 `.cowork-flow/.runtime/spec-edit-throttle.json`,检查完整跑过才写节流——执行器崩溃不消费时间窗,重试仍会执行。delegated 子代理会话同样收到 spec 违规反馈(子代理是主力写码者);scope 警告保持 main-only。zcode 主会话的 hook 身份(对话自身 session id)不会被绑定——激活发生在 Bash 侧显式身份下——显示与编辑期警告按最新主会话兜底;claude/codex 保持严格会话身份(其 Bash env 携带同一 session id)。zcode 经 shim 转发到单源入口(`inject.py`),scope 警告与 spec 警告合并进同一个 additionalContext 载荷;claude 走 exit 2 stderr 反馈。
77
+
78
+ ## 附录 B delegated 编辑期缺口与补偿
110
79
 
111
- 1. 子代理完成前自查:`./.cowork-flow/run spec-check` 全量并修复(拉取路径,
112
- 实测有效);
80
+ **delegated 子代理编辑期缺口(zcode 实测,2026-09-09 两轮)**:ZCode 的插件 hook 只在主会话工具流上派发(事件模型七事件均挂主会话;子代理是独立内部会话流 `sess_subagent_agent_*`),Agent 子代理的 Edit/Write 收不到 `spec-check[...] violation` 行——runtime 绑定与检查逻辑均正常,缺的是宿主派发。补偿机制(subagent-dispatch.md 契约固化):
81
+
82
+ 1. 子代理完成前自查:`./.cowork-flow/run spec-check` 全量并修复(拉取路径,实测有效);
113
83
  2. 父会话验收前再查:violation 视为验收阻塞(反馈时点从收口提前到返回);
114
84
  3. 实现类任务 Verify 命令默认含 spec-check 自查;
115
85
  4. 收口硬门禁兜底不变(violation 阻断 complete)。
116
86
 
117
- 治理方向:向宿主提需求扩展子代理工具流的 hook 派发;落地后第 1 条自动
118
- 退化为冗余保险。
87
+ 治理方向:向宿主提需求扩展子代理工具流的 hook 派发;落地后第 1 条自动退化为冗余保险。
88
+
89
+ ## 附录 C digest 注入
90
+
91
+ 进入 in_progress 后,stage-contract 的 Specs 行升级为 `Specs: <spec>(<h2 标题树>); …`:最多 6 个 h2 条目、每条截断 24 字符、剔除 `();` 字符;文件缺失不加注解。digest 是条目名索引不是正文,受 scope-rules.json `stageContract.budget` 硬顶,超预算整行让位(Scope/Gates 行永不丢)。python/zcode 输出同源于单一 Python 入口,opencode 镜像与它逐字相等由 matrix 用例锁定(`test/stage-contract.test.js` matrix)。
119
92
 
120
- ## digest 注入
93
+ ## 附录 D 开放决策
121
94
 
122
- 进入 in_progress 后,stage-contract 的 Specs 行升级为
123
- `Specs: <spec>(<h2 标题树>); …`:最多 6 个 h2 条目、每条截断 24 字符、剔除
124
- `();` 字符;文件缺失不加注解。digest 是条目名索引不是正文,受
125
- scope-rules.json `stageContract.budget` 硬顶,超预算整行让位
126
- (Scope/Gates 行永不丢)。python/zcode 输出同源于单一 Python 入口,
127
- opencode 镜像与它逐字相等由 matrix 用例锁定
128
- (`test/stage-contract.test.js` matrix)。
95
+ **收口期聚合预算**:收口期全量执行没有聚合预算——声明多且慢的仓库 complete 会线性变慢;是否给收口加并行/预算上限留给后续产品决策,当前保持简单串行。
@@ -115,6 +115,19 @@
115
115
  "when changing task start readiness",
116
116
  "when changing resume context for active tasks"
117
117
  ]
118
+ },
119
+ {
120
+ "id": "SPEC_CHECKS_V1",
121
+ "path": ".cowork-flow/spec/contracts/spec-checks.md",
122
+ "digest": [
123
+ "Specs may declare `checks:` commands in frontmatter; the mechanism runs declarations, never prose.",
124
+ "A declared command that cannot be found counts as unchecked and blocks completion."
125
+ ],
126
+ "readWhen": [
127
+ "before adding or editing spec files",
128
+ "when a spec clause can be judged by a command exit code",
129
+ "when spec-check reports violations or unchecked"
130
+ ]
118
131
  }
119
132
  ],
120
133
  "references": [
@@ -574,8 +574,14 @@ function parseDecisionAnchor(text) {
574
574
  if (section === "目标") {
575
575
  if (line) goalLines.push(line)
576
576
  } else if (section === "验收标准") {
577
- const match = raw.match(/^\s*-\s*\[[ xX]?\]\s*(AC-[A-Za-z0-9-]+)\s*[::]\s*(.+?)\s*$/)
578
- if (match) result.acceptanceCriteria.push({ id: match[1], text: match[2] })
577
+ const match = raw.match(/^\s*-\s*(?:\[([ xX])\]?\s*)?(AC-[A-Za-z0-9-]+)\s*[::]\s*(.+?)\s*$/)
578
+ if (match) {
579
+ result.acceptanceCriteria.push({
580
+ id: match[2],
581
+ text: match[3],
582
+ checked: match[1] === 'x' || match[1] === 'X',
583
+ })
584
+ }
579
585
  } else if (section === "被拒方案") {
580
586
  const match = raw.match(/^\s*-\s*\*\*(.+?)\*\*/)
581
587
  if (match) result.rejectedOptions.push(match[1].trim())
@@ -836,6 +842,36 @@ function withStageFacts(block, root, taskPath, status, readonly = false) {
836
842
  return parts.length > 0 ? `${parts.join("\n\n")}\n\n${block}` : block
837
843
  }
838
844
 
845
+ // Evidence coverage for the anchor block: one read of the task's
846
+ // evidence.jsonl, tolerating noise exactly like the python line. Returns
847
+ // null when the file is absent so the coverage line degrades silently.
848
+ function readEvidenceCoverage(root, taskPath) {
849
+ let text = ''
850
+ try {
851
+ text = readFileSync(resolve(root, taskPath, 'evidence.jsonl'), 'utf8')
852
+ } catch {
853
+ return null
854
+ }
855
+ const covered = new Set()
856
+ for (const raw of text.split('\n')) {
857
+ const line = raw.trim()
858
+ if (!line) continue
859
+ let record
860
+ try {
861
+ record = JSON.parse(line)
862
+ } catch {
863
+ continue
864
+ }
865
+ if (!record || typeof record !== 'object' || Array.isArray(record)) continue
866
+ const ac = typeof record.ac === 'string' ? record.ac.trim() : ''
867
+ const ref = typeof record.ref === 'string' ? record.ref.trim() : ''
868
+ if (ac && ref && ['test', 'command', 'manual'].includes(record.kind)) {
869
+ covered.add(ac)
870
+ }
871
+ }
872
+ return covered
873
+ }
874
+
839
875
  function decisionAnchorBlock(root, taskPath, status) {
840
876
  if (!taskPath) {
841
877
  return null
@@ -868,6 +904,17 @@ function decisionAnchorBlock(root, taskPath, status) {
868
904
  lines.push(
869
905
  `Acceptance: ${parsed.acceptanceCriteria.slice(0, 8).map((item) => `${item.id} ${item.text.slice(0, 80)}`).join("; ")}`
870
906
  )
907
+ const covered = readEvidenceCoverage(root, taskPath)
908
+ if (covered) {
909
+ const total = parsed.acceptanceCriteria.length
910
+ const missing = parsed.acceptanceCriteria
911
+ .map((item) => item.id)
912
+ .filter((id) => !covered.has(id))
913
+ const detail = missing.length
914
+ ? ` missing=${missing.slice(0, 5).join(',')}`
915
+ : ''
916
+ lines.push(`AC evidence: ${total - missing.length}/${total}${detail}`)
917
+ }
871
918
  }
872
919
  if (parsed.rejectedOptions.length > 0) {
873
920
  lines.push(`Rejected: ${parsed.rejectedOptions.slice(0, 6).join("; ")}`)
@@ -1073,5 +1120,6 @@ export {
1073
1120
  runEditSpecCheck,
1074
1121
  contractFingerprint,
1075
1122
  stableStringify,
1076
- stageContractBlock
1123
+ stageContractBlock,
1124
+ decisionAnchorBlock
1077
1125
  }
@@ -33,6 +33,7 @@ Keep each update concrete:
33
33
  - Show the contract or command shape.
34
34
  - Include good/bad cases when useful.
35
35
  - State tests or checks that protect the behavior.
36
+ - When a clause is machine-checkable (可机检), add or refresh its `checks:` declaration in the same pass (main session only — never rewrite the clause text): the command must pass on a clean tree, a negative case must prove it can fail, `files` must narrow the scope, and a `spec-check` run after writing must confirm the declaration parses.
36
37
  - Update the matching `index.md` when adding a new topic.
37
38
  - Check for duplicate guidance and remove stale wording before finishing.
38
39
 
@@ -37,7 +37,8 @@ Read only what is needed for the current task:
37
37
  - **Scope**: every changed file is planned or justified; no unrelated cleanup sneaks in.
38
38
  - **Behavior**: acceptance criteria are satisfied through observable behavior, not implementation-shaped assertions.
39
39
  - **Tests**: test intent is explicit; tests fail for meaningful regressions, reject shallow tests such as existence/mock/snapshot-only checks, and cover boundary/error paths when relevant.
40
- - **User specs**: every applicable backend/frontend requirement is verified against the diff and marked `pass`, `finding`, or `not_applicable` with reason. Every `finding` is fixed during review; an unfixed spec violation blocks completion and is never accepted as-is or deferred to the user.
40
+ - **Evidence coverage**: when the decision anchor declares acceptance criteria (`AC-xxx`), verify each one has an evidence record in `<task>/evidence.jsonl` — or a recorded `meta.evidenceExempt` exemption from an explicit `--allow-missing-evidence` completion. Schema and gate codes (`LIFECYCLE-AC-001/002`) are defined in `.cowork-flow/spec/contracts/evidence-completion.md`. A record must point at real proof (test, command output, or manual verification note); a record that restates the AC without evidence is a finding.
41
+ - **User specs**: every applicable backend/frontend requirement is verified against the diff and marked `pass`, `finding`, or `not_applicable` with reason. Every `finding` is fixed during review; an unfixed spec violation blocks completion and is never accepted as-is or deferred to the user. For each requirement, also ask whether it is machine-checkable (可机检) and already declared with `checks:`; identifying checkable clauses and adding or refreshing declarations is a main-session duty (never rewrite the clause text — raise a conflict through the review instead), and a declared check must earn its place: the command passes on a clean tree, a negative case proves it can fail, `files` narrows the scope, and a `spec-check` run after declaring confirms the declaration parses. Delegated subagents do not edit specs; they report candidates up.
41
42
  - **Specs**: project specs are updated when behavior/contracts changed, or the review states why no spec update is needed.
42
43
  - **Code quality**: naming, layering, error handling, state boundaries, security-sensitive paths, and complexity are reviewed against applicable user specs.
43
44
  - **Lifecycle blockers**: state/scope blockers are fixed before acceptance; review does not invent hard blockers for natural-language specs.
@@ -61,7 +62,7 @@ Return a concise review result with:
61
62
  - `status`: `pass`, `needs_fix`, or `blocked`.
62
63
  - `findings`: severity, file, line/scope, impact, and fix.
63
64
  - `test_intent_review`: why tests prove the intended behavior or what is missing.
64
- - `user_spec_review`: applicable spec files, per-requirement result, and findings.
65
+ - `user_spec_review`: applicable spec files, per-requirement result, findings, and the "clause → command" list for every `checks:` declaration added or refreshed in this review (`none` when nothing became machine-checkable).
65
66
  - `lifecycle_check_review`: lifecycle commands run, blocker status, and resolution.
66
67
  - `verification`: exact commands run from this checkout.
67
68
  - `specUpdates`: files updated or reason no update was needed.
@@ -39,6 +39,10 @@ If red-first is skipped, state the reason before or with the implementation summ
39
39
 
40
40
  Do not write TDD evidence objects to `check.jsonl`, do not create `tdd.jsonl`, and do not create TDD exemption records. Report the exact red/green commands in the agent response or task review narrative when useful; the workflow does not validate them as separate evidence artifacts.
41
41
 
42
+ ## Evidence records
43
+
44
+ When the task's decision anchor declares acceptance criteria, a green test from a red-green cycle is the canonical completion evidence for the mapped AC: append one record to the task's `evidence.jsonl` — `{"ac": "AC-xxx", "kind": "test", "ref": "<test file>::<case>", "note": "<core assertion>", "recordedAt": "<date>", "by": "<session>"}`. This is task-level completion evidence governed by `.cowork-flow/spec/contracts/evidence-completion.md`; it is a different artifact from the forbidden `tdd.jsonl` above, and this skill still does not require or validate it — recording it as you go simply spares the completion gate a block later.
45
+
42
46
  ## Anti-Rationalization
43
47
 
44
48
  Do not use these excuses to skip meaningful behavior tests: