sillyspec 3.29.4 → 3.31.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 (148) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +1 -1
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +2 -2
  3. package/.claude/skills/sillyspec-commit/SKILL.md +1 -1
  4. package/.claude/skills/sillyspec-flow/SKILL.md +57 -0
  5. package/.claude/skills/sillyspec-quick/SKILL.md +7 -105
  6. package/AGENTS.md +38 -20
  7. package/SKILL.md +9 -3
  8. package/assets/command-cards/flow.md +32 -0
  9. package/assets/command-cards/run-archive.md +26 -0
  10. package/assets/command-cards/run-brainstorm.md +33 -0
  11. package/assets/command-cards/run-execute.md +29 -0
  12. package/assets/command-cards/run-plan.md +27 -0
  13. package/assets/command-cards/run-quick.md +17 -0
  14. package/assets/command-cards/run-verify.md +29 -0
  15. package/assets/command-cards/status.md +27 -0
  16. package/package.json +2 -1
  17. package/packages/dashboard/server/executor.js +2 -1
  18. package/packages/dashboard/server/parser.js +7 -7
  19. package/r7-test-env/README-R7-TEST.md +93 -0
  20. package/r7-test-env/demo-project/lib/calc.js +6 -0
  21. package/r7-test-env/demo-project/package.json +1 -0
  22. package/r7-test-env/demo-project/test.js +10 -0
  23. package/r7-test-env/prompt-R7-SMOKE.md +41 -0
  24. package/r7-test-env/sillyspec-r7.cmd +3 -0
  25. package/round4/account.mjs +98 -0
  26. package/round4/b1-analyze-task08.mjs +40 -0
  27. package/round4/b1-b1-target-validation.md +32 -0
  28. package/round4/b3a-prompt-audit-result.md +66 -0
  29. package/round4/b3a-prompt-audit.mjs +89 -0
  30. package/round4/brief-autocompact.md +34 -0
  31. package/round4/brief-session-replay.md +50 -0
  32. package/round4/deep-read-synthesis.md +38 -0
  33. package/round4/extract-full.mjs +38 -0
  34. package/round4/extract-timeline.mjs +43 -0
  35. package/round4/find-sessions.mjs +13 -0
  36. package/round4/full-r4-l.md +4707 -0
  37. package/round4/full-r4-o-l.md +4300 -0
  38. package/round4/full-r4-os.md +1681 -0
  39. package/round4/full-r4-sf.md +4255 -0
  40. package/round4/full-r4-sq.md +1734 -0
  41. package/round4/gate-audit-relay-receipt.md +30 -0
  42. package/round4/gate-value-audit.md +52 -0
  43. package/round4/optimization-plan.md +165 -0
  44. package/round4/prereg.md +67 -0
  45. package/round4/prompt-R4-L.md +63 -0
  46. package/round4/prompt-R4-O-L.md +59 -0
  47. package/round4/prompt-R4-O-S.md +56 -0
  48. package/round4/prompt-R4-S-F.md +60 -0
  49. package/round4/prompt-R4-S-Q.md +66 -0
  50. package/round4/r4-final-report.html +347 -0
  51. package/round4/r4-os-timeline.txt +297 -0
  52. package/round4/r4-sf-timeline.txt +738 -0
  53. package/round4/r4-sq-timeline.txt +322 -0
  54. package/round4/r4l-timeline.txt +793 -0
  55. package/round4/r4ol-timeline.txt +729 -0
  56. package/round4/r4sf-timeline.txt +852 -0
  57. package/round4/r5-scenario-matrix.md +46 -0
  58. package/round4/result-S.md +171 -0
  59. package/round4/result-final.md +46 -0
  60. package/round4/result-quality.md +63 -0
  61. package/src/change-list.js +9 -0
  62. package/src/classify-change.js +6 -0
  63. package/src/command-cards.js +74 -0
  64. package/src/commit-guard.js +2 -0
  65. package/src/config-schema.js +64 -12
  66. package/src/contract-matrix.js +26 -5
  67. package/src/db.js +35 -4
  68. package/src/decision-distill.js +9 -3
  69. package/src/diagnostic-codes.js +2 -1
  70. package/src/docs-check.js +1 -1
  71. package/src/doctor-diagnostics.js +44 -2
  72. package/src/flow-draft.js +748 -0
  73. package/src/flow-parity.js +236 -0
  74. package/src/flow-review.js +184 -0
  75. package/src/flow.js +1492 -0
  76. package/src/foreign-declared.js +17 -1
  77. package/src/fr-index.js +396 -22
  78. package/src/fs-atomic.js +5 -1
  79. package/src/git-helper.js +2 -2
  80. package/src/greenfield-bootstrap.js +98 -0
  81. package/src/handoff.js +120 -7
  82. package/src/hunk-attribution.js +191 -0
  83. package/src/index.js +602 -21
  84. package/src/init.js +25 -8
  85. package/src/knowledge-classify.js +47 -1
  86. package/src/knowledge-digest.js +162 -0
  87. package/src/knowledge-quicklog.js +246 -0
  88. package/src/machine-draft.js +128 -0
  89. package/src/machine-interface.js +194 -7
  90. package/src/mcp-server.js +1 -0
  91. package/src/module-impact.js +51 -22
  92. package/src/module-resolve.js +3 -1
  93. package/src/preview-progress.js +160 -0
  94. package/src/progress/change-registry.js +11 -3
  95. package/src/progress/shared.js +3 -2
  96. package/src/progress.js +56 -13
  97. package/src/quicklog.js +44 -6
  98. package/src/redomain.js +147 -0
  99. package/src/review-material-pack.js +204 -1
  100. package/src/run/archive-distill.js +4 -2
  101. package/src/run/bg-sync.js +67 -0
  102. package/src/run/command.js +127 -10
  103. package/src/run/complete-handlers.js +363 -73
  104. package/src/run/complete.js +356 -74
  105. package/src/run/gate-snapshot-ledger.js +213 -0
  106. package/src/run/gate-snapshot.js +260 -38
  107. package/src/run/gates.js +482 -268
  108. package/src/run/green-cache.js +123 -0
  109. package/src/run/junction-rm.js +70 -0
  110. package/src/run/next.js +14 -0
  111. package/src/run/prompt.js +185 -55
  112. package/src/run/quick-audit.js +213 -14
  113. package/src/run/shared.js +123 -4
  114. package/src/run/stage.js +182 -216
  115. package/src/run/test-ledger.js +264 -0
  116. package/src/run/verify-quality-scan.js +84 -7
  117. package/src/scope-audit.js +6 -1
  118. package/src/sentinel-assertions.js +114 -0
  119. package/src/setup.js +3 -3
  120. package/src/spec-sync.js +41 -1
  121. package/src/stage-contract.js +26 -8
  122. package/src/stage-review-checklist.js +2 -0
  123. package/src/stage-review.js +7 -2
  124. package/src/stage-templates.js +157 -0
  125. package/src/stages/archive.js +2 -0
  126. package/src/stages/brainstorm.js +24 -106
  127. package/src/stages/execute.js +222 -85
  128. package/src/stages/knowledge.js +74 -1
  129. package/src/stages/plan-postcheck.js +385 -20
  130. package/src/stages/plan.js +23 -1
  131. package/src/stages/quick.js +10 -2
  132. package/src/stages/verify.js +4 -2
  133. package/src/sync.js +81 -23
  134. package/src/task-done.js +181 -0
  135. package/src/task-review.js +8 -55
  136. package/src/taskcard-frontmatter.js +57 -0
  137. package/src/test-bindings.js +549 -0
  138. package/src/timeline.js +218 -0
  139. package/src/ui-visual.js +175 -0
  140. package/src/verify-draft.js +214 -0
  141. package/src/verify-postcheck.js +919 -159
  142. package/src/verify-probes.js +103 -25
  143. package/src/watcher.js +1031 -0
  144. package/src/worktree-apply.js +44 -4
  145. package/src/worktree-deps.js +22 -8
  146. package/src/worktree-policy.js +42 -0
  147. package/src/worktree.js +102 -15
  148. package/templates/agents-instruction.md +37 -18
@@ -0,0 +1,128 @@
1
+ /**
2
+ * machine-draft.js — MACHINE-DRAFT 指纹三件套泛化(R7 切片三 task-04 / D-004 / FR-08)。
3
+ *
4
+ * 从 verify-draft.js(verify 填槽制 v2 件二)抽出通用原语,供 flow 四件机器稿(flow-draft)
5
+ * 复用;verify-draft 改为消费方(导出签名与行为逐字不动,零回归硬门)。
6
+ *
7
+ * 三件套语义(护栏#2:守卫必须是验收侧的机制,永远不是生成侧的劝说):
8
+ * - wrapSection:机器预填段以 MACHINE-DRAFT begin/end 注释对包裹,begin 内嵌段内容
9
+ * sha256(CRLF 归一);amendCmd 按消费方参数化(verify-result 指向 verify-probes
10
+ * --amend-draft,flow 稿指向 flow amend-draft——勿把 agent 引向错误命令);
11
+ * - verifyMarkers:三态拒收判定——标记缺失(被删/整份重写)/内容哈希失配(改写)/
12
+ * 手工重锚未审计(标记哈希与台账不一致);
13
+ * - reanchorText:按当前内容重锚哈希的纯文本变换(amend 留痕通道的机械半边;
14
+ * 台账审计与落盘归消费方)。
15
+ *
16
+ * 本模块纯函数(无 fs)——md 文本与台账对象由消费方读写;段 key 命名空间归消费方
17
+ * (verify-result 单文件段键 / flow 按文件名+段键组合)。
18
+ */
19
+ import { createHash } from 'node:crypto'
20
+
21
+ export const MARK_BEGIN_RE = /^<!--\s*MACHINE-DRAFT:([\w.-]+):([0-9a-f]{64}):begin.*-->\s*$/
22
+ const MARK_END_RE = /^<!--\s*MACHINE-DRAFT:([\w.-]+):end.*-->\s*$/
23
+
24
+ /**
25
+ * 任务勾选态归一(2026-09-25-thin-done-gate-calibration 坑1):勾选是 flow start 横幅教的
26
+ * 预期写面(收口哨兵的证据面),不该参与「被改写」判定——哈希前把 `- [x] task-NN` 归一回
27
+ * `- [ ]`(draft 产出恒 [ ],存量台账两侧归一后同哈希,兼容;真实改写任务文本/增删行仍失配)。
28
+ * 口径只认 task-NN 行,普通清单勾选行不归一(不扩大书写面)。
29
+ */
30
+ const TASK_CHECK_STATE_RE = /^([-*] \[)[ xX]\](?= task-\d)/gm
31
+ function normalizeTaskCheckboxState(s) {
32
+ return String(s).replace(TASK_CHECK_STATE_RE, '$1 ]')
33
+ }
34
+
35
+ /** 段内容哈希(CRLF 归一——跨平台写盘行尾漂移不炸指纹;task-NN 勾选态归一——见上)。 */
36
+ export function bodyHash(s) {
37
+ return createHash('sha256').update(normalizeTaskCheckboxState(String(s).replace(/\r\n/g, '\n'))).digest('hex')
38
+ }
39
+
40
+ /**
41
+ * 包裹机器段(begin 标记 + body + end 标记 + 尾空行)。标记格式与既有 MARK_*_RE 逐字兼容。
42
+ * @returns {string} 四行块(含尾空行,消费方按原位替换)
43
+ */
44
+ export function wrapSection({ key, body, amendCmd, guardNote = '整段改写会被 verify --done 拒收' }) {
45
+ return [
46
+ `<!-- MACHINE-DRAFT:${key}:${bodyHash(body)}:begin 机器预填段——${guardNote};确要修改:${amendCmd} 留痕重锚 -->`,
47
+ body,
48
+ `<!-- MACHINE-DRAFT:${key}:end -->`,
49
+ '',
50
+ ].join('\n')
51
+ }
52
+
53
+ /**
54
+ * 解析 md 中全部标记段(CRLF 归一)。
55
+ * @returns {{key:string, markerHash:string, contentLines:string[]}[]}
56
+ */
57
+ export function parseMarkerBlocks(text) {
58
+ const lines = String(text).replace(/\r\n/g, '\n').split('\n')
59
+ const blocks = []
60
+ let current = null
61
+ for (const line of lines) {
62
+ const b = line.match(MARK_BEGIN_RE)
63
+ if (b) { current = { key: b[1], markerHash: b[2], contentLines: [] }; blocks.push(current); continue }
64
+ const e = line.match(MARK_END_RE)
65
+ if (e) { current = null; continue }
66
+ if (current) current.contentLines.push(line)
67
+ }
68
+ return blocks
69
+ }
70
+
71
+ /**
72
+ * 三态拒收判定(--done 验收侧承重件的纯函数半边)。
73
+ * @param {object} p
74
+ * @param {string} p.text md 全文
75
+ * @param {Object<string,{hash:string}>} p.sections 台账段哈希(sidecar.sections 形态)
76
+ * @param {string} p.amendCmd 修复指引命令文案(消费方参数化)
77
+ * @returns {string[]} violations(空=干净;文案含修复指引)
78
+ */
79
+ export function verifyMarkers({ text, sections, amendCmd }) {
80
+ const blocks = parseMarkerBlocks(text)
81
+ const byKey = new Map(blocks.map((b) => [b.key, b]))
82
+ const violations = []
83
+ const fix = (key) => `还原机器段原文,或确要修改时跑 ${amendCmd} 留痕重锚(段键 ${key})`
84
+ for (const [key, rec] of Object.entries(sections || {})) {
85
+ const b = byKey.get(key)
86
+ if (!b) {
87
+ violations.push(`机器段「${key}」的 MACHINE-DRAFT 标记缺失(被删除或整份重写)——${fix(key)}`)
88
+ continue
89
+ }
90
+ const actual = bodyHash(b.contentLines.join('\n'))
91
+ if (actual !== rec.hash) {
92
+ violations.push(`机器段「${key}」内容与指纹失配(被改写)——${fix(key)}`)
93
+ } else if (b.markerHash !== rec.hash) {
94
+ // 内容未动但标记哈希与台账不一致 = 标记被手工重锚而未经 amend 审计
95
+ violations.push(`机器段「${key}」标记指纹与 sidecar 台账不一致(未经 --amend-draft 的手工重锚)——${fix(key)}`)
96
+ }
97
+ }
98
+ return violations
99
+ }
100
+
101
+ /**
102
+ * 按当前内容重锚全部标记段(纯文本变换;不落盘不记台账——消费方持 mdPath/台账)。
103
+ * @returns {{text:string, keys:string[], contentByKey:Object<string,string>}}
104
+ */
105
+ export function reanchorText({ text, amendCmd, guardNote = '整段改写会被 verify --done 拒收' }) {
106
+ const lines = String(text).replace(/\r\n/g, '\n').split('\n')
107
+ const contentLinesByKey = new Map()
108
+ let current = null
109
+ for (const line of lines) {
110
+ const b = line.match(MARK_BEGIN_RE)
111
+ if (b) { current = b[1]; contentLinesByKey.set(current, []); continue }
112
+ const e = line.match(MARK_END_RE)
113
+ if (e) { current = null; continue }
114
+ if (current) contentLinesByKey.get(current).push(line)
115
+ }
116
+ const keys = [...contentLinesByKey.keys()]
117
+ const next = lines.map((line) => {
118
+ const b = line.match(MARK_BEGIN_RE)
119
+ if (!b) return line
120
+ const content = (contentLinesByKey.get(b[1]) || []).join('\n')
121
+ return `<!-- MACHINE-DRAFT:${b[1]}:${bodyHash(content)}:begin 机器预填段——${guardNote};确要修改:${amendCmd} 留痕重锚 -->`
122
+ }).join('\n')
123
+ const contentByKey = {}
124
+ for (const k of keys) contentByKey[k] = (contentLinesByKey.get(k) || []).join('\n')
125
+ return { text: next, keys, contentByKey }
126
+ }
127
+
128
+ export default { wrapSection, parseMarkerBlocks, verifyMarkers, reanchorText, bodyHash, MARK_BEGIN_RE }
@@ -15,17 +15,17 @@
15
15
  * 仅依赖 Node 18+ 原生 API,零新增外部依赖。
16
16
  */
17
17
 
18
- import { existsSync, readFileSync } from 'fs';
18
+ import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs';
19
19
  import { join } from 'path';
20
20
  import { ProgressManager, resolveSpecDir } from './progress.js';
21
21
  import { resolveRuntimeRoot } from './run/shared.js';
22
22
  import { runValidators, checkTransition, checkExecuteCodeEvidence } from './stage-contract.js';
23
23
  import { validateTaskReviews, resolveLatestExecuteRunId } from './task-review.js';
24
24
  import { runVerifyTestCheck } from './verify-postcheck.js';
25
+ import { computeGateFingerprint, lookupGreenCache, storeGreenCache, greenCacheNotice } from './run/green-cache.js';
25
26
  import { checkCode } from './diagnostic-codes.js';
26
27
 
27
28
  // ============ 退出码常量(D-004@v1)============
28
-
29
29
  export const EXIT_OK = 0; // 核验通过(可含 warnings)
30
30
  export const EXIT_BLOCKED = 1; // 事实性阻断(JSON 含 errors)
31
31
  export const EXIT_UNKNOWN = 2; // 无法核验(用法错/变更不存在/环境错/内部异常)
@@ -117,7 +117,31 @@ export function buildEnvelope({
117
117
  * 让两函数按 ctx 切跨仓 gitDir / per-repo cwd。null 时两函数按既有单仓行为零回归。
118
118
  * @returns {Promise<{ envelope: object, exitCode: number }>}
119
119
  */
120
- export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, specDriftAnchor, ctx = null } = {}) {
120
+ /**
121
+ * verify 门禁 check 的绿结果缓存包装(R8 对撞修复 2026-09-23,实现见 src/run/green-cache.js)。
122
+ * 同指纹(HEAD+代码脏面+local.yaml)近期真跑的绿结果复用:命中 → synth(hit) 合成等价结果
123
+ * (带 cached 披露字段,调用方进 warnings——「结果是真的、本次没跑」必须明示);未命中 →
124
+ * 真跑,status==='passed' 时写缓存。fail-open:无根/指纹失败/缓存异常一律回退真跑(零回归)。
125
+ */
126
+ function greenCachedCheck({ kind, greenRoot, cwd, specBase, changeName }, runReal, synth) {
127
+ if (!greenRoot) return runReal();
128
+ let fingerprint = null;
129
+ try { fingerprint = computeGateFingerprint({ cwd, specBase }); } catch { fingerprint = null; }
130
+ if (!fingerprint) return runReal();
131
+ const scope = `change:${changeName}`;
132
+ let hit = null;
133
+ try { hit = lookupGreenCache({ runtimeRoot: greenRoot, scope, kind, fingerprint }); } catch { hit = null; }
134
+ if (hit) return synth(hit);
135
+ const result = runReal();
136
+ try {
137
+ if (result && result.status === 'passed') {
138
+ storeGreenCache({ runtimeRoot: greenRoot, scope, kind, fingerprint, result: { status: 'passed', exitCode: result.exitCode ?? 0, mode: result.mode ?? null } });
139
+ }
140
+ } catch { /* best-effort 写缓存 */ }
141
+ return result;
142
+ }
143
+
144
+ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, specDriftAnchor, ctx = null, full = false } = {}) {
121
145
  const specRoot = specBase || resolveSpecDir(cwd);
122
146
  // A4:pm 用 specRoot(而非默认 resolveSpecDir(cwd))——--spec-dir/平台模式下 specBase 是真实
123
147
  // specDir,默认解析会指向本地孤儿库,与下方 planContent/changeDir 的 specRoot 两套事实源混拼。
@@ -289,14 +313,21 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
289
313
 
290
314
  // ── d. verify 阶段追加 verify-test(CLI 实测 local.yaml commands.test)──
291
315
  if (stage === 'verify') {
292
- const vt = runVerifyTestCheck({ cwd, specBase: specRoot, changeName, ctx });
316
+ // 绿缓存根:与 runtimeRoot 解析同源(drift 锚主仓口径);解析失败 null → 不缓存不命中
317
+ let greenRoot = null;
318
+ try { greenRoot = resolveRuntimeRoot({ runtimeRoot, specDriftAnchor }, specRoot); } catch { greenRoot = null; }
319
+ const vt = greenCachedCheck({ kind: 'test', greenRoot, cwd, specBase: specRoot, changeName },
320
+ () => runVerifyTestCheck({ cwd, specBase: specRoot, changeName, ctx }),
321
+ (hit) => ({ status: 'passed', exitCode: 0, durationMs: 0, resultPath: null, mode: null, fallbackReason: null, exemptedCount: 0, cached: true, reason: greenCacheNotice(hit, 'verify-test') }));
293
322
  const vtWarnings = [];
323
+ if (vt.cached) vtWarnings.push(vt.reason);
294
324
  if (vt.status === 'skipped') {
295
325
  vtWarnings.push(`⚠️ verify-test SKIPPED — gate 未核验测试(${vt.reason || 'local.yaml 未配置 commands.test 或显式无测试'})。本次 gate 结论不含测试客观核验,driver 不应据 exit 0 判定测试通过;integration-critical 变更应在 verify 阶段降级 FAIL`);
296
326
  }
297
327
  // known_failures 豁免披露:本次 PASS 含豁免,driver 应提示人工复核清单是否过宽
328
+ // (quick-E,2026-09-20 报告问题 E:提示原不带清单位置,复核要先翻一遍才找到——带路径)
298
329
  if (vt.exemptedCount > 0) {
299
- vtWarnings.push(`⚠️ verify-test PASS 含 ${vt.exemptedCount} 个 known_failures 豁免(${vt.reason || ''});driver 应提示人工复核豁免清单是否过宽(避免误豁免本变更引入的真实失败)`);
330
+ vtWarnings.push(`⚠️ verify-test PASS 含 ${vt.exemptedCount} 个 known_failures 豁免(${vt.reason || ''});driver 应提示人工复核豁免清单是否过宽(避免误豁免本变更引入的真实失败)——清单位置:${join(specRoot, 'local.yaml')} 的 known_failures 键`);
300
331
  }
301
332
  // 全量 fallback 明示:跑了全量 commands.test 但非变更范围子集,失败可能含未变更
302
333
  // 模块的预存错误——driver 不应据 exit 0 判定本次变更范围已客观测试(见 3.24 verify 坑1)。
@@ -311,6 +342,7 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
311
342
  warnings: vtWarnings,
312
343
  data: {
313
344
  status: vt.status,
345
+ cached: vt.cached === true,
314
346
  exitCode: vt.exitCode,
315
347
  durationMs: vt.durationMs,
316
348
  resultPath: vt.resultPath,
@@ -318,6 +350,127 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
318
350
  fallbackReason: vt.fallbackReason ?? null,
319
351
  },
320
352
  });
353
+
354
+ // ── d2. verify-lint(parity 补齐,2026-09-21 R4-S-F 实证)──
355
+ // gate verify 原先只查 test 不查 lint,而 verify --done 的 lint 隔离快照门会拦——
356
+ // gate PASS / --done FAIL 两套口径并存,误导 agent 诊断 16 分钟(对撞会话 db 实证)。
357
+ // 本检查与 --done 同引擎(runVerifyLintCheck + triageLintOwnership 归属降档);口径差异
358
+ // 永久明示:gate 在工作树直跑 lint,--done 在隔离快照(HEAD+归属文件 overlay)跑——
359
+ // 不一致时以 --done 为准。lint 未配置/不可跑 → informational,不阻断只读查询。
360
+ try {
361
+ const { runVerifyLintCheck, triageLintOwnership, resolveVerifyChangedFiles } = await import('./verify-postcheck.js');
362
+ const lc = greenCachedCheck({ kind: 'lint', greenRoot, cwd, specBase: specRoot, changeName },
363
+ () => runVerifyLintCheck({ cwd, specBase: specRoot }),
364
+ (hit) => ({ status: 'passed', exitCode: 0, durationMs: 0, failureFiles: [], cached: true, reason: greenCacheNotice(hit, 'verify-lint') }));
365
+ if (lc.status === 'skipped') {
366
+ checks.push({
367
+ id: 'verify-lint',
368
+ code: checkCode('verify-lint'),
369
+ ok: true,
370
+ informational: true,
371
+ warnings: [`verify-lint SKIPPED — ${lc.reason || 'local.yaml 未配置 commands.lint'};本次 gate 结论不含 lint 客观核验`],
372
+ data: { status: lc.status },
373
+ });
374
+ } else {
375
+ const lcWarnings = [
376
+ '口径说明:gate verify 的 lint 在工作树直跑;verify --done 的 lint 在隔离快照(HEAD+归属文件 overlay)跑——两口径不一致时以 --done 为准。',
377
+ ];
378
+ if (lc.cached) lcWarnings.push(lc.reason);
379
+ let ownership = null;
380
+ let lintOk = lc.status !== 'failed';
381
+ if (lc.status === 'failed') {
382
+ const changed = resolveVerifyChangedFiles(cwd, changeName, null, { specBase: specRoot }) || [];
383
+ ownership = triageLintOwnership({ failureFiles: lc.failureFiles || [], changeFiles: changed });
384
+ if (ownership.verdict === 'pre-existing') {
385
+ lintOk = true; // 存量债不拦只读查询(与 --done 的 advisory 降档同口径)
386
+ lcWarnings.push('lint 失败文件与本变更零交集(HEAD 存量债)→ 本 check 降档放行;--done 同口径 advisory,建议顺手清偿解锁全仓 lint 门。');
387
+ }
388
+ }
389
+ checks.push({
390
+ id: 'verify-lint',
391
+ code: checkCode('verify-lint'),
392
+ ok: lintOk,
393
+ errors: !lintOk ? [`lint 失败(工作树口径): ${lc.reason || ''}`] : [],
394
+ warnings: lcWarnings,
395
+ data: {
396
+ status: lc.status,
397
+ cached: lc.cached === true,
398
+ exitCode: lc.exitCode,
399
+ durationMs: lc.durationMs,
400
+ ownership: ownership ? ownership.verdict : null,
401
+ overlap: ownership ? ownership.overlap : [],
402
+ },
403
+ });
404
+ }
405
+ } catch (e) {
406
+ checks.push({
407
+ id: 'verify-lint',
408
+ code: checkCode('verify-lint'),
409
+ ok: true,
410
+ informational: true,
411
+ warnings: [`verify-lint 检查装配失败(fail-open): ${e && e.message ? e.message : e}`],
412
+ data: { status: 'error' },
413
+ });
414
+ }
415
+ }
416
+
417
+ // ── e. --full 只读预检档(P1 / FR-01 / D-002@batch3,2026-09-21-r5-efficiency-batch3 task-03)──
418
+ // 覆盖 --done 独有的贵门(batch2 实证四类盲区:reconcile 两轮/stage review 缺失各吃一整 agent
419
+ // 回合):①target_files reconcile 只读跑(reconcileTargetFiles 纯计算不落盘,--done 的写结果
420
+ // 文件在其调用方);②stage review 在场探测(marker+review.json——缺则报预期路径,同 --done
421
+ // Stage Review Gate 口径,只报缺不生成)。全只读零副作用;未传 full 零行为变化。
422
+ if (full && (stage === 'verify' || stage === 'execute')) {
423
+ // e1. reconcile(verify --done 的 target_files 对账门)
424
+ if (stage === 'verify') {
425
+ try {
426
+ const { reconcileTargetFiles } = await import('./verify-postcheck.js');
427
+ const rec = reconcileTargetFiles({ cwd, specBase: specRoot, changeName });
428
+ const recOk = rec.status !== 'missing_declared';
429
+ checks.push({
430
+ id: 'full-target-files-reconcile',
431
+ code: checkCode('full-target-files-reconcile') || 'full_target_files_reconcile',
432
+ ok: recOk,
433
+ errors: !recOk
434
+ ? (rec.missing || []).map((m) => `task-${m.task || '?'}: ${m.path} 声明未做(②类计划落空)——补做交付或按声明修正通道改卡(与 review 结论一致)`)
435
+ : [],
436
+ warnings: (rec.undeclared || []).length > 0
437
+ ? [`${(rec.undeclared || []).length} 个未声明文件(warning 级,${(rec.undeclared || []).slice(0, 3).map((u) => u.path).join('、')}${(rec.undeclared || []).length > 3 ? ' 等' : ''})——并行会话归因噪音或漏声明,verify 报告须裁决`]
438
+ : [],
439
+ data: { status: rec.status, matched: (rec.matched || []).length, missing: (rec.missing || []).length, undeclared: (rec.undeclared || []).length },
440
+ });
441
+ } catch (e) {
442
+ checks.push({
443
+ id: 'full-target-files-reconcile',
444
+ code: 'full_target_files_reconcile',
445
+ ok: true,
446
+ informational: true,
447
+ warnings: [`--full reconcile 检查装配失败(fail-open): ${e && e.message ? e.message : e}`],
448
+ });
449
+ }
450
+ }
451
+ // e2. stage review 在场探测(verify/execute --done 的 Stage Review Gate)
452
+ try {
453
+ const { getLatestStageReviewRunId } = await import('./stage-review.js');
454
+ const rrRoot = runtimeRoot || join(specRoot, '.runtime');
455
+ const runId = getLatestStageReviewRunId(rrRoot, stage, changeName);
456
+ checks.push({
457
+ id: 'full-stage-review',
458
+ code: checkCode('full-stage-review') || 'full_stage_review_missing',
459
+ ok: !!runId,
460
+ errors: !runId
461
+ ? [`缺 ${stage} 阶段的 stage review.json(tier=independent 要求独立审查产出)——--done 将被 Stage Review Gate 拦;可先 sillyspec register-stage-review --change ${changeName} --stage ${stage} 生成骨架再填(降级条款照 review.json reviewerNotes 首行留痕)`]
462
+ : [],
463
+ data: { runId: runId || null },
464
+ });
465
+ } catch (e) {
466
+ checks.push({
467
+ id: 'full-stage-review',
468
+ code: 'full_stage_review_missing',
469
+ ok: true,
470
+ informational: true,
471
+ warnings: [`--full stage review 探测装配失败(fail-open): ${e && e.message ? e.message : e}`],
472
+ });
473
+ }
321
474
  }
322
475
 
323
476
  // ── 综合结论:所有非 informational check 均 ok ──
@@ -461,9 +614,14 @@ export async function runDerive(facet, changeName, { cwd, specBase, runtimeRoot,
461
614
 
462
615
  // ── b. verify-test:真实执行测试命令并取证 ──
463
616
  case 'verify-test': {
464
- const vt = runVerifyTestCheck({ cwd, specBase: specRoot, changeName, ctx });
617
+ let greenRoot = null;
618
+ try { greenRoot = resolveRuntimeRoot({ runtimeRoot, specDriftAnchor }, specRoot); } catch { greenRoot = null; }
619
+ const vt = greenCachedCheck({ kind: 'test', greenRoot, cwd, specBase: specRoot, changeName },
620
+ () => runVerifyTestCheck({ cwd, specBase: specRoot, changeName, ctx }),
621
+ (hit) => ({ status: 'passed', exitCode: 0, durationMs: 0, resultPath: null, mode: null, fallbackReason: null, exemptedCount: 0, cached: true, reason: greenCacheNotice(hit, 'verify-test') }));
465
622
  data = {
466
623
  status: vt.status,
624
+ cached: vt.cached === true,
467
625
  exitCode: vt.exitCode,
468
626
  durationMs: vt.durationMs,
469
627
  resultPath: vt.resultPath,
@@ -472,7 +630,9 @@ export async function runDerive(facet, changeName, { cwd, specBase, runtimeRoot,
472
630
  };
473
631
  ok = vt.status !== 'failed';
474
632
  errors = vt.status === 'failed' ? [`测试失败: ${vt.reason || ''}`] : [];
475
- warnings = vt.status === 'skipped'
633
+ warnings = vt.cached
634
+ ? [vt.reason]
635
+ : vt.status === 'skipped'
476
636
  ? ['测试被跳过']
477
637
  : (vt.mode === 'full' && vt.fallbackReason)
478
638
  ? [`⚠️ verify-test 跑的是全量 commands.test(${vt.fallbackReason});失败可能含未变更模块的预存错误`]
@@ -667,3 +827,30 @@ export function runStatusOverview({ cwd, specBase } = {}) {
667
827
  return { envelope, exitCode: EXIT_UNKNOWN };
668
828
  }
669
829
  }
830
+
831
+ // ============ gate 失败明细稳定路径落盘(quick-D,2026-09-20 报告问题 D)============
832
+
833
+ /**
834
+ * `sillyspec gate <stage> --change <name>` 的失败信封落盘(稳定路径,每次覆盖)。
835
+ * 背景:gate 失败明细只在 stdout——宿主把长命令转后台截断输出后 ❌ 段落实际取不回
836
+ * (2026-09-20 实证:agent 靠推断绕 3 轮)。runGate 本体保持只读契约(D-002@v1,不写
837
+ * sillyspec.db),落盘由本函数承担(CLI 层调用,fail-soft)——审计附件非状态。
838
+ * @param {{ specBase: string, stage: string, changeName: string, envelope: object }} opts
839
+ * @returns {string|null} 成功返回落盘路径;失败返回 null
840
+ */
841
+ export function writeGateResultArtifact({ specBase, stage, changeName, envelope }) {
842
+ try {
843
+ const runsDir = join(specBase, '.runtime', 'verify-runs')
844
+ const resultPath = join(runsDir, `gate-${stage}-${changeName}.json`)
845
+ mkdirSync(runsDir, { recursive: true })
846
+ writeFileSync(resultPath, JSON.stringify({
847
+ written_at: new Date().toISOString(),
848
+ stage,
849
+ change: changeName,
850
+ envelope,
851
+ }, null, 2) + '\n')
852
+ return resultPath
853
+ } catch {
854
+ return null // fail-soft:落盘是审计附件,失败只缺附件不误门
855
+ }
856
+ }
package/src/mcp-server.js CHANGED
@@ -93,6 +93,7 @@ function runCli(cliArgs) {
93
93
  cwd: process.cwd(),
94
94
  env: process.env,
95
95
  stdio: ['ignore', 'pipe', 'pipe'],
96
+ windowsHide: true,
96
97
  })
97
98
  let out = ''
98
99
  let err = ''
@@ -48,7 +48,15 @@ export function parseModuleMapPaths(yamlText) {
48
48
  }
49
49
  if (inPaths && currentModule) {
50
50
  const item = line.match(/^ - (.+)$/)
51
- if (item) map.get(currentModule).push(item[1].trim().replace(/\\/g, '/'))
51
+ if (item) {
52
+ // 引号剥离(坑 map-quoted-path-deadmatch,2026-09-21 R4-L 实证):docs/<p>/modules/
53
+ // _module-map.yaml 存在带引号条目("frontend/src/app/page.tsx")——只 trim 不剥引号时
54
+ // classifyFile 的 === / startsWith 恒失配,引号条目全是死路径(frontend map 790 条
55
+ // 几乎全带引号=整图失效)。仅首尾成对引号才剥(路径内部引号不动)。
56
+ const raw = item[1].trim()
57
+ const unquoted = raw.replace(/^(['"])([\s\S]*)\1$/, '$2')
58
+ map.get(currentModule).push(unquoted.replace(/\\/g, '/'))
59
+ }
52
60
  }
53
61
  }
54
62
  return map
@@ -89,18 +97,33 @@ export function syncModuleDocSidecars({ cwd, changeName, note, specDir = null })
89
97
  // 复用 generateModuleImpactSkeleton 的 map 发现 + 归类(diff 来源)
90
98
  const skel = generateModuleImpactSkeleton({ cwd, changeName, specDir })
91
99
  if (!skel) return { synced: [], unmatched: [], skipped: [], reason: '无 module-map 或无 diff 可归类' }
92
- const modulesDir = findModulesDir(specBase)
93
- if (!modulesDir) return { synced: [], unmatched: [], skipped: [], reason: 'modules 目录不存在' }
94
- // 从骨架 markdown 反解归类结果(命中模块集合 + 未匹配清单)
95
- const hitModules = [...skel.markdown.matchAll(/^\| (\S+) \| `([^`]+)`/gm)].map(m => m[1])
96
- const unmatched = [...skel.markdown.matchAll(/^- `([^`]+)` /gm)].map(m => m[1])
100
+ // 结构化归类直用(classified 含 project 归属;multimap-first-found-mismatch 同坑修复面)——
101
+ // 不再反解 markdown(正则脆且丢 project 维度)。classified 缺席时兜底反解 + 首个 modules 目录。
102
+ let mods = []
103
+ if (Array.isArray(skel.classified) && skel.classified.length >= 0) {
104
+ const seen = new Set()
105
+ for (const r of skel.classified) {
106
+ const key = r.project + '/' + r.module
107
+ if (seen.has(key)) continue
108
+ seen.add(key)
109
+ mods.push({ project: r.project, module: r.module, modulesDir: join(specBase, 'docs', r.project, 'modules') })
110
+ }
111
+ } else {
112
+ const modulesDir = findModulesDir(specBase)
113
+ if (!modulesDir) return { synced: [], unmatched: [], skipped: [], reason: 'modules 目录不存在' }
114
+ mods = [...new Set([...skel.markdown.matchAll(/^\| (\S+) \| `([^`]+)`/gm)].map(m => m[1]))]
115
+ .map(mod => ({ project: null, module: mod, modulesDir }))
116
+ }
117
+ const unmatched = Array.isArray(skel.unmatchedFiles)
118
+ ? skel.unmatchedFiles
119
+ : [...skel.markdown.matchAll(/^- `([^`]+)` /gm)].map(m => m[1])
97
120
  const synced = []
98
121
  const skipped = []
99
- for (const mod of [...new Set(hitModules)]) {
122
+ for (const { project: proj, module: mod, modulesDir } of mods) {
100
123
  const sidecarPath = join(modulesDir, `${mod}.changelog.md`)
101
124
  let sidecar = ''
102
125
  try { sidecar = readFileSync(sidecarPath, 'utf8') } catch { sidecar = '' }
103
- if (sidecar.includes(`- ${changeName} |`)) { skipped.push(mod); continue }
126
+ if (sidecar.includes(`- ${changeName} |`)) { skipped.push(proj ? `${proj}/${mod}` : mod); continue }
104
127
  const NL = String.fromCharCode(10)
105
128
  const line = `- ${changeName} | ${note || '(见该变更 quicklog/归档)'}`
106
129
  const base = sidecar
@@ -114,7 +137,7 @@ export function syncModuleDocSidecars({ cwd, changeName, note, specDir = null })
114
137
  const stamped = card.replace(/^updated_at:.*$/m, `updated_at: ${new Date().toISOString().slice(0, 19) + '+08:00'}`)
115
138
  if (stamped !== card) writeFileSync(cardPath, stamped)
116
139
  } catch { /* 卡不存在——sidecar 照写,卡内容留给 agent */ }
117
- synced.push(mod)
140
+ synced.push(proj ? `${proj}/${mod}` : mod)
118
141
  }
119
142
  return { synced, unmatched, skipped }
120
143
  }
@@ -133,22 +156,22 @@ function findModulesDir(specBase) {
133
156
  export function generateModuleImpactSkeleton({ cwd, changeName, specDir = null, sourceFiles = null, origin = null }) {
134
157
  const specBase = resolveSpecDir(cwd, { specDir })
135
158
 
136
- // 找 _module-map.yaml(docs/<p>/modules/;多项目取首个含 modules 索引的)
137
- let moduleMapPath = null
159
+ // 多项目 map 联合归类(坑 multimap-first-found-mismatch,2026-09-21 R4-L 实证):docs/ 下
160
+ // 各项目可各带 _module-map.yaml(对撞仓 5 个),原「取首个命中」让 daemon/frontend 文件对
161
+ // 按目录序排前的 backend map 全失配——24 文件全未匹配、sidecar 也写错项目目录。联合口径:
162
+ // 逐项目 classify、目录序首个命中 wins(单项目时联合退化为一图,行为不变)。
163
+ const moduleMaps = []
138
164
  try {
139
165
  for (const p of readdirSync(join(specBase, 'docs'), { withFileTypes: true })) {
140
166
  if (!p.isDirectory()) continue
141
167
  const candidate = join(specBase, 'docs', p.name, 'modules', '_module-map.yaml')
142
168
  if (existsSync(candidate)) {
143
- moduleMapPath = candidate
144
- break
169
+ const pm = parseModuleMapPaths(readFileSync(candidate, 'utf8'))
170
+ if (pm.size > 0) moduleMaps.push({ project: p.name, modulePaths: pm })
145
171
  }
146
172
  }
147
173
  } catch { /* 无 docs/ */ }
148
- if (!moduleMapPath) return null
149
-
150
- const modulePaths = parseModuleMapPaths(readFileSync(moduleMapPath, 'utf8'))
151
- if (modulePaths.size === 0) return null
174
+ if (moduleMaps.length === 0) return null
152
175
 
153
176
  let sourceFileList
154
177
  if (sourceFiles) {
@@ -169,12 +192,18 @@ export function generateModuleImpactSkeleton({ cwd, changeName, specDir = null,
169
192
  if (sourceFileList.length === 0) return null
170
193
 
171
194
  const byModule = new Map()
195
+ const classified = [] // {project, module, file}——syncModuleDocSidecars 按 project 落 sidecar 用
172
196
  const unmatched = []
173
197
  for (const f of sourceFileList) {
174
- const mod = classifyFile(f, modulePaths)
175
- if (mod) {
176
- if (!byModule.has(mod)) byModule.set(mod, [])
177
- byModule.get(mod).push(f)
198
+ let hit = null
199
+ for (const m of moduleMaps) {
200
+ const mod = classifyFile(f, m.modulePaths)
201
+ if (mod) { hit = { project: m.project, moduleId: mod }; break }
202
+ }
203
+ if (hit) {
204
+ if (!byModule.has(hit.moduleId)) byModule.set(hit.moduleId, [])
205
+ byModule.get(hit.moduleId).push(f)
206
+ classified.push({ project: hit.project, module: hit.moduleId, file: f })
178
207
  } else {
179
208
  unmatched.push(f)
180
209
  }
@@ -234,5 +263,5 @@ export function generateModuleImpactSkeleton({ cwd, changeName, specDir = null,
234
263
  L.push('规则:execute/verify 完成文档同步后把对应行回填 done;确定不同步的行改 skipped 并在操作列写明原因。')
235
264
  L.push('')
236
265
 
237
- return { markdown: L.join('\n'), matchedCount: sourceFileList.length - unmatched.length, unmatchedCount: unmatched.length }
266
+ return { markdown: L.join('\n'), matchedCount: sourceFileList.length - unmatched.length, unmatchedCount: unmatched.length, classified, unmatchedFiles: unmatched }
238
267
  }
@@ -78,8 +78,10 @@ export function collectModuleMaps({ cwd, specBase }) {
78
78
  /**
79
79
  * 路径归一:反斜杠→正斜杠,剥尾部 glob(/** 与 /*)与目录斜杠。
80
80
  * `backend/**` / `backend/` / `backend` → `backend`。
81
+ * (导出供 flow-parity.reconcileModuleDocs 复用——2026-09-27-thin-module-scope-persist:
82
+ * 模块对账与模块卡解析同一归一口径,防两处漂移)
81
83
  */
82
- function normalizeMapPath(raw) {
84
+ export function normalizeMapPath(raw) {
83
85
  return String(raw).replace(/\\/g, '/').replace(/\/\*\*$/, '').replace(/\/\*$/, '').replace(/\/$/, '')
84
86
  }
85
87