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.
- package/CHANGELOG.md +9 -1
- package/docs/direction.md +12 -0
- package/package.json +1 -1
- package/presets/claude-code/.claude-plugin/plugin.json +1 -1
- package/presets/codex/.codex-plugin/plugin.json +1 -1
- package/presets/kimi-code/.kimi-plugin/plugin.json +1 -1
- package/presets/opencode/cowork-flow/plugin-core.js +51 -3
- package/presets/qoder/.qoder-plugin/plugin.json +1 -1
- package/presets/zcode/.zcode-plugin/plugin.json +1 -1
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/scripts/adapters/cli/spec_check.py +10 -1
- package/template/.cowork-flow/scripts/adapters/cli/task_lifecycle_commands.py +3 -0
- package/template/.cowork-flow/scripts/adapters/cli/task_parser.py +1 -0
- package/template/.cowork-flow/scripts/adapters/host/workflow_state_hook.py +21 -0
- package/template/.cowork-flow/scripts/services/ac_evidence.py +107 -0
- package/template/.cowork-flow/scripts/services/fact_view.py +16 -2
- package/template/.cowork-flow/scripts/services/lifecycle_checks.py +84 -0
- package/template/.cowork-flow/scripts/services/lifecycle_policy.py +5 -0
- package/template/.cowork-flow/scripts/services/spec_check.py +11 -1
- package/template/.cowork-flow/scripts/services/task_lifecycle.py +12 -0
- package/template/.cowork-flow/spec/contracts/context-injection.md +5 -3
- package/template/.cowork-flow/spec/contracts/decision-anchor.md +1 -0
- package/template/.cowork-flow/spec/contracts/evidence-completion.md +48 -0
- package/template/.cowork-flow/spec/contracts/index.md +1 -0
- package/template/.cowork-flow/spec/contracts/spec-checks.md +49 -82
- package/template/.cowork-flow/spec/runtime/contract-registry.json +13 -0
- package/template/.opencode/cowork-flow/plugin-core.js +51 -3
- package/template/skills/spec-sync/SKILL.md +1 -0
- package/template/skills/task-review/SKILL.md +3 -2
- 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.
|
|
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
|
@@ -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
|
|
578
|
-
if (match)
|
|
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 +1 @@
|
|
|
1
|
-
1.
|
|
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
|
|
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
|
-
{
|
|
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
|
|
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
|
-
-
|
|
244
|
-
|
|
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
|
-
|
|
4
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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("没跑成"不是"通过"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
`
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
93
|
+
## 附录 D 开放决策
|
|
121
94
|
|
|
122
|
-
|
|
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
|
|
578
|
-
if (match)
|
|
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
|
-
- **
|
|
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
|
|
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:
|