sillyspec 3.28.0 → 3.28.2

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 (41) hide show
  1. package/package.json +1 -1
  2. package/src/archive-delta.js +419 -0
  3. package/src/design-facts.js +354 -0
  4. package/src/docs-check.js +79 -8
  5. package/src/doctor-diagnostics.js +11 -2
  6. package/src/endpoint-baseline.js +109 -0
  7. package/src/git-helper.js +31 -0
  8. package/src/index.js +213 -4
  9. package/src/modules.js +41 -12
  10. package/src/progress/change-registry.js +20 -0
  11. package/src/progress/consistency-doctor.js +124 -5
  12. package/src/progress/stage-machine.js +13 -2
  13. package/src/progress.js +3 -0
  14. package/src/quicklog.js +51 -0
  15. package/src/run/command.js +70 -1
  16. package/src/run/complete-handlers.js +53 -4
  17. package/src/run/complete.js +46 -1
  18. package/src/run/gates.js +333 -2
  19. package/src/run/prompt.js +70 -0
  20. package/src/run/scan-profile.js +98 -6
  21. package/src/run/shared.js +12 -1
  22. package/src/run/stage.js +7 -1
  23. package/src/scan-diff.js +54 -1
  24. package/src/scan-facts.js +173 -0
  25. package/src/scan-postcheck.js +110 -3
  26. package/src/spec-sync.js +19 -2
  27. package/src/stage-review.js +62 -3
  28. package/src/stages/archive.js +5 -5
  29. package/src/stages/brainstorm.js +10 -10
  30. package/src/stages/execute.js +15 -13
  31. package/src/stages/plan-postcheck.js +194 -1
  32. package/src/stages/plan.js +6 -2
  33. package/src/stages/scan.js +44 -102
  34. package/src/stages/verify.js +9 -7
  35. package/src/taskcard.js +8 -1
  36. package/src/verify-postcheck.js +610 -2
  37. package/src/verify-probes.js +89 -10
  38. package/src/workflow.js +30 -0
  39. package/templates/agents-instruction.md +1 -1
  40. package/templates/prompts/taskcard-rules.md +5 -0
  41. package/templates/workflows/scan-docs.yaml +6 -0
package/src/scan-diff.js CHANGED
@@ -26,6 +26,7 @@ import { safeGit } from './git-helper.js'
26
26
  import { parseSourceCommit } from './scan-staleness.js'
27
27
  import { matchFilesToModules } from './docs-debt.js'
28
28
  import { parseModuleMapSimple } from './modules.js'
29
+ import { collectDocRefs } from './docs-check.js'
29
30
 
30
31
  /** safeGit 超时(仿 docs-debt 的 GIT_TIMEOUT_MS 模式:大 diff 慢时降级不挂起) */
31
32
  const GIT_TIMEOUT_MS = 10000
@@ -40,7 +41,8 @@ const DEFAULT_LIST_CAP = 5
40
41
  * specBase / projectName:scan 文档与 module-map 定位(specBase/docs/<projectName>/...)
41
42
  * base:基线 commit(短/长哈希、引用均可);缺省读 scan 文档 frontmatter 的 source_commit
42
43
  * @returns {{ ok: true, base, baseSource, head, behindCommits, daysSinceScan, scope,
43
- * added, deleted, modified, unknown, unmapped, outOfScope, renameMap, byModule, warnings, driftCount }
44
+ * added, deleted, modified, unknown, unmapped, outOfScope, renameMap, byModule,
45
+ * staleRefs, warnings, driftCount }
44
46
  * | { ok: false, error: string }}
45
47
  * ok:false = 硬错误(参数缺失 / 无 source_commit / 无效 base / git diff 失败)
46
48
  * added/deleted/modified/unknown:范围内变更文件(POSIX 相对路径;R/C 记新路径)
@@ -163,6 +165,40 @@ export function computeScanDiff({ projectRoot, specBase, projectName, base } = {
163
165
  byModule[id] = { doc: entry.doc || '', added: a, deleted: d, modified: m, files: entry.files }
164
166
  }
165
167
 
168
+ // ── 引用级漂移(archify 借鉴,2026-09-04):scan 文档显式 file:line 引用 × 基线后变更文件集。
169
+ // 变更集取 parseNameStatus 全量——**不经 module-map scope 过滤**(引用自带范围:指向「已变更
170
+ // 但范围外」文件的引用同样过时,advisory 信号不该被 scope 吞掉);rename 的旧路径也入集
171
+ //(文档写在 scan 时点,引用的是旧路径,重命名后引用必失效——最强失效信号)。
172
+ // best-effort(读不到文档/异常 → 空清单不阻断文件级结果),不计入 driftCount / 退出码
173
+ //(advisory:引用指向已变更文件 ≠ 引用失效,提示人工核对)。──
174
+ const staleRefs = []
175
+ try {
176
+ const scanDir = join(specBase, 'docs', projectName, 'scan')
177
+ if (existsSync(scanDir)) {
178
+ const fullChanged = new Map() // POSIX path → A/D/M/R
179
+ for (const item of parseNameStatus(diffRes.value)) {
180
+ if (item.status === 'A' || item.status === 'D') fullChanged.set(item.path, item.status)
181
+ else if (item.status === 'M' || item.status === 'C') fullChanged.set(item.path, 'M')
182
+ else if (item.status === 'R') { fullChanged.set(item.path, 'R'); fullChanged.set(item.oldPath, 'R') }
183
+ }
184
+ const hitChange = (p) => {
185
+ if (fullChanged.has(p)) return { file: p, change: fullChanged.get(p) }
186
+ // src/ 前缀双向归一:文档 src 相对写法 ↔ git 仓库根路径
187
+ const alt = p.startsWith('src/') ? p.slice(4) : `src/${p}`
188
+ return fullChanged.has(alt) ? { file: alt, change: fullChanged.get(alt) } : null
189
+ }
190
+ for (const f of readdirSync(scanDir)) {
191
+ if (!f.endsWith('.md')) continue
192
+ const content = readFileSync(join(scanDir, f), 'utf8')
193
+ for (const ref of collectDocRefs(content)) {
194
+ if (ref.repo !== null) continue
195
+ const hit = hitChange(ref.file.replace(/\\/g, '/'))
196
+ if (hit) staleRefs.push({ doc: f, ref: ref.ref, change: hit.change, file: hit.file })
197
+ }
198
+ }
199
+ }
200
+ } catch { /* 引用级漂移为 advisory,异常静默降级 */ }
201
+
166
202
  return {
167
203
  ok: true,
168
204
  base: baseDisplay,
@@ -174,6 +210,7 @@ export function computeScanDiff({ projectRoot, specBase, projectName, base } = {
174
210
  added, deleted, modified, unknown, unmapped, outOfScope,
175
211
  renameMap,
176
212
  byModule,
213
+ staleRefs,
177
214
  warnings,
178
215
  driftCount: added.length + deleted.length + modified.length,
179
216
  }
@@ -240,6 +277,13 @@ function renderTerminal(r, { full }) {
240
277
  if (r.unknown.length > 0) {
241
278
  console.log(`无法归类(unknown,${r.unknown.length} 条):${r.unknown.slice(0, cap).join(', ')}`)
242
279
  }
280
+ if (r.staleRefs && r.staleRefs.length > 0) {
281
+ console.log(`引用过时(scan 文档 file:line 引用指向基线后已变更的文件,${r.staleRefs.length} 条,advisory):`)
282
+ for (const s of r.staleRefs.slice(0, cap)) {
283
+ console.log(` - ${s.doc}: \`${s.ref}\`(${s.change === 'D' ? '文件已删除' : s.change === 'A' ? '文件为基线后新增' : s.change === 'R' ? '文件已重命名' : '文件已变更'})`)
284
+ }
285
+ if (r.staleRefs.length > cap) console.log(` ... 其余 ${r.staleRefs.length - cap} 条,--full 展开`)
286
+ }
243
287
  console.log(`漂移合计:${r.driftCount} 条(A${r.added.length} / D${r.deleted.length} / M${r.modified.length})`)
244
288
  }
245
289
 
@@ -304,6 +348,15 @@ function writeReport(r, { specBase, projectName }) {
304
348
  for (const f of r.outOfScope) lines.push(`- \`${f}\``)
305
349
  lines.push('')
306
350
  }
351
+ if (r.staleRefs && r.staleRefs.length > 0) {
352
+ lines.push('## 引用过时(advisory,不计入漂移合计)', '')
353
+ lines.push('scan 文档 file:line 引用指向基线后已变更的文件——不必然失效,逐条核对:', '')
354
+ for (const s of r.staleRefs) {
355
+ const label = s.change === 'D' ? '文件已删除' : s.change === 'A' ? '文件为基线后新增' : s.change === 'R' ? '文件已重命名' : '文件已变更'
356
+ lines.push(`- ${s.doc}: \`${s.ref}\`(${label})`)
357
+ }
358
+ lines.push('')
359
+ }
307
360
  if (r.warnings.length > 0) {
308
361
  lines.push('## 提示', '')
309
362
  for (const w of r.warnings) lines.push(`- ${w}`)
@@ -0,0 +1,173 @@
1
+ /**
2
+ * scan-facts.js — scan 事实底稿(archify 借鉴:机械事实预咀嚼,2026-09-05)
3
+ *
4
+ * 定位:把 scan 子代理「各自从零 grep 探索」的机械部分(端点清单 / 依赖与脚本清单 /
5
+ * 源码规模 / git 基线)收敛为 CLI 一次确定性抽取的共享底稿——agent 从「发现」降级为
6
+ * 「解读」,探索 token 不再按子代理数量重复计费(为什么 archify 快的机制 2/7,分析见
7
+ * docs/sillyspec/archify-reference-analysis-2026-09-04.md)。
8
+ *
9
+ * 产物:docs/<project>/scan/_facts.md(下划线前缀 = 非必需文档,workflow check 与
10
+ * postcheck 的 7 份清单不查它)。底稿里的 file:line 引用天然进入 scan_doc_ref_invalid
11
+ * 核验与 scan-diff staleRefs 漂移追踪——这份底稿即 scan IR 的「机器半边」;
12
+ * agent 半边(per-doc claims sidecar)走正式 change 另行落地。
13
+ *
14
+ * 约束:fail-soft——任何一段抽取失败只跳过该段(md 里附注记),绝不抛出、绝不阻断 scan。
15
+ */
16
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
17
+ import { join, relative } from 'node:path'
18
+ import { scanBackendEndpoints, scanFrontendApiCalls } from './endpoint-extractor.js'
19
+ import { estimateSourceSize } from './run/scan-profile.js'
20
+ import { safeGit } from './git-helper.js'
21
+
22
+ /** 底稿各清单的上限(防大仓把底稿撑爆反而变成新的 token 黑洞) */
23
+ const MAX_ENDPOINTS = 60
24
+ const MAX_FRONTEND_CALLS = 30
25
+ const MAX_DEPS = 40
26
+ const MAX_SCRIPTS = 20
27
+
28
+ /** 依赖与脚本清单:package.json 优先(结构化最稳),pyproject.toml 做行级 best-effort,其余清单记存在性 */
29
+ function readManifestFacts(root) {
30
+ const manifests = []
31
+ for (const f of ['package.json', 'pyproject.toml', 'pom.xml', 'go.mod', 'build.gradle', 'Cargo.toml', 'requirements.txt', 'Gemfile', 'composer.json']) {
32
+ if (existsSync(join(root, f))) manifests.push(f)
33
+ }
34
+ const deps = []
35
+ const scripts = []
36
+ let pkgName = null
37
+ if (existsSync(join(root, 'package.json'))) {
38
+ try {
39
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
40
+ pkgName = pkg.name || null
41
+ for (const key of ['dependencies', 'devDependencies', 'peerDependencies']) {
42
+ for (const dep of Object.keys(pkg[key] || {})) deps.push(dep)
43
+ }
44
+ for (const [name, cmd] of Object.entries(pkg.scripts || {})) scripts.push(`${name} = ${cmd}`)
45
+ } catch { /* 坏 package.json 不阻断,仅缺该段 */ }
46
+ } else if (existsSync(join(root, 'pyproject.toml'))) {
47
+ try {
48
+ const lines = readFileSync(join(root, 'pyproject.toml'), 'utf8').split(/\r?\n/)
49
+ // 两种形态:PEP 621 `dependencies = ["pkg>=x", ...]` 数组;poetry `[tool.poetry.dependencies]` 段
50
+ let inArray = false
51
+ let section = ''
52
+ for (const line of lines) {
53
+ const sec = line.match(/^\[([^\]]+)\]\s*$/)
54
+ if (sec) { section = sec[1]; inArray = false; continue }
55
+ if (/^dependencies\s*=\s*\[/.test(line)) inArray = true
56
+ if (inArray) {
57
+ for (const m of line.matchAll(/["']([A-Za-z0-9_.-]+)\s*[\[<>=!~;]/g)) deps.push(m[1])
58
+ if (/\]\s*$/.test(line) && !/^dependencies/.test(line)) inArray = false
59
+ } else if (section === 'tool.poetry.dependencies') {
60
+ const m = line.match(/^\s*([A-Za-z0-9_.-]+)\s*=\s*["'{]/)
61
+ if (m && m[1] !== 'python') deps.push(m[1])
62
+ }
63
+ }
64
+ } catch { /* best-effort */ }
65
+ }
66
+ return { manifests, pkgName, deps: [...new Set(deps)].slice(0, MAX_DEPS), depsTruncated: new Set(deps).size > MAX_DEPS, scripts: scripts.slice(0, MAX_SCRIPTS) }
67
+ }
68
+
69
+ /** 端点与前端调用抽取(source 转 cwd 相对 POSIX 路径,引用形态可直接被 docs-check/staleRefs 消费) */
70
+ function collectEndpointFacts(cwd, root) {
71
+ let backend = []
72
+ let frontend = []
73
+ try { backend = scanBackendEndpoints(root) } catch { /* fail-soft */ }
74
+ try { frontend = scanFrontendApiCalls(root) } catch { /* fail-soft */ }
75
+ const rel = (abs) => relative(cwd, abs).replace(/\\/g, '/')
76
+ return {
77
+ backend: backend.slice(0, MAX_ENDPOINTS).map(e => ({ method: e.method, path: e.path, ref: `${rel(e.source)}:${e.line}` })),
78
+ backendTotal: backend.length,
79
+ frontend: frontend.slice(0, MAX_FRONTEND_CALLS).map(e => ({ method: e.method, path: e.path, ref: `${rel(e.source)}:${e.line}` })),
80
+ frontendTotal: frontend.length,
81
+ }
82
+ }
83
+
84
+ /**
85
+ * 确定性收集 scan 机械事实(纯同步,不写盘)。
86
+ * @param {{ cwd: string, projectPath?: string|null }} opts cwd:仓库根(引用解析锚);projectPath:子项目相对路径(缺省主项目)
87
+ * @returns {object} facts(renderScanFactsMd 的输入)
88
+ */
89
+ export function collectScanFacts({ cwd, projectPath = null }) {
90
+ const root = projectPath ? join(cwd, projectPath) : cwd
91
+ const manifest = readManifestFacts(root)
92
+ const size = (() => { try { return estimateSourceSize(root) } catch { return { fileCount: 0, sourceBytes: 0 } } })()
93
+ const git = safeGit(cwd, ['rev-parse', '--short', 'HEAD'])
94
+ const type = manifest.manifests.includes('package.json') ? 'nodejs'
95
+ : (manifest.manifests.includes('pyproject.toml') || manifest.manifests.includes('requirements.txt')) ? 'python'
96
+ : manifest.manifests.includes('pom.xml') ? 'maven'
97
+ : manifest.manifests.includes('go.mod') ? 'go'
98
+ : manifest.manifests.includes('Cargo.toml') ? 'rust'
99
+ : 'generic'
100
+ return {
101
+ generatedAt: new Date().toISOString().replace('T', ' ').slice(0, 19),
102
+ projectPath,
103
+ type,
104
+ pkgName: manifest.pkgName,
105
+ gitHead: git && git.value ? git.value.trim() : null,
106
+ manifests: manifest.manifests,
107
+ deps: manifest.deps,
108
+ depsTruncated: manifest.depsTruncated,
109
+ scripts: manifest.scripts,
110
+ size,
111
+ endpoints: collectEndpointFacts(cwd, root),
112
+ }
113
+ }
114
+
115
+ /**
116
+ * 渲染事实底稿 markdown。引用一律 `cwd 相对路径:行号`(反引号),可直接被
117
+ * collectDocRefs / scan_doc_ref_invalid / staleRefs 消费。
118
+ * @param {object} facts collectScanFacts 返回值
119
+ * @returns {string} markdown 全文
120
+ */
121
+ export function renderScanFactsMd(facts) {
122
+ const scope = facts.projectPath ? `${facts.projectPath}/` : '(主项目根)'
123
+ const lines = []
124
+ lines.push(`# scan 事实底稿(CLI 确定性抽取)`)
125
+ lines.push('')
126
+ lines.push(`> 生成:sillyspec scan facts · ${facts.generatedAt} · 范围:${scope} · git HEAD:${facts.gitHead || '(不可用)'}`)
127
+ lines.push('> 本文件由 CLI 机器生成(零 AI token)。子代理**禁止重新 grep 发现**下列机械事实,只做解读与成文;与源码冲突时以本底稿为准并标注差异。')
128
+ lines.push('')
129
+ lines.push('## 项目类型与清单')
130
+ lines.push('')
131
+ lines.push(`- 类型判定:${facts.type}${facts.pkgName ? `(包名 ${facts.pkgName})` : ''}`)
132
+ lines.push(`- 清单文件:${facts.manifests.length > 0 ? facts.manifests.join(' / ') : '(未发现)'}`)
133
+ lines.push(`- 源码规模:${facts.size.fileCount} 个源文件 / ${Math.round(facts.size.sourceBytes / 1024)}KB`)
134
+ lines.push('')
135
+ if (facts.deps.length > 0) {
136
+ lines.push(`## 依赖清单${facts.depsTruncated ? `(前 ${MAX_DEPS} 个,共更多)` : ''}`)
137
+ lines.push('')
138
+ lines.push(facts.deps.join(', '))
139
+ lines.push('')
140
+ }
141
+ if (facts.scripts.length > 0) {
142
+ lines.push('## package.json scripts')
143
+ lines.push('')
144
+ for (const s of facts.scripts) lines.push(`- ${s}`)
145
+ lines.push('')
146
+ }
147
+ lines.push(`## 后端端点(${facts.endpoints.backendTotal} 条${facts.endpoints.backendTotal > MAX_ENDPOINTS ? `,仅列前 ${MAX_ENDPOINTS}` : ''})`)
148
+ lines.push('')
149
+ if (facts.endpoints.backend.length === 0) lines.push('(未抽取到路由定义——非后端项目或框架不在支持列表)')
150
+ for (const e of facts.endpoints.backend) lines.push(`- \`${e.method} ${e.path}\` — \`${e.ref}\``)
151
+ lines.push('')
152
+ if (facts.endpoints.frontend.length > 0) {
153
+ lines.push(`## 前端 API 调用(${facts.endpoints.frontendTotal} 条${facts.endpoints.frontendTotal > MAX_FRONTEND_CALLS ? `,仅列前 ${MAX_FRONTEND_CALLS}` : ''})`)
154
+ lines.push('')
155
+ for (const e of facts.endpoints.frontend) lines.push(`- \`${e.method} ${e.path}\` — \`${e.ref}\``)
156
+ lines.push('')
157
+ }
158
+ return lines.join('\n') + '\n'
159
+ }
160
+
161
+ /**
162
+ * 收集并落盘事实底稿(scan 步骤 3 / `sillyspec scan facts` CLI 共用入口)。
163
+ * @param {{ cwd: string, specDir: string, projectName: string, projectPath?: string|null }} opts
164
+ * @returns {{ outPath: string, facts: object }}
165
+ */
166
+ export function buildAndWriteScanFacts({ cwd, specDir, projectName, projectPath = null }) {
167
+ const facts = collectScanFacts({ cwd, projectPath })
168
+ const outDir = join(specDir, 'docs', projectName, 'scan')
169
+ mkdirSync(outDir, { recursive: true })
170
+ const outPath = join(outDir, '_facts.md')
171
+ writeFileSync(outPath, renderScanFactsMd(facts), 'utf8')
172
+ return { outPath, facts }
173
+ }
@@ -11,9 +11,41 @@ import { SCAN_STATUS, CHECK_SEVERITY, SCAN_REQUIRED_DOCS, SCAN_REQUIRED_DOCS_QUI
11
11
  import { validateScriptCommands } from './stages/cmd-existence.js'
12
12
  import { git } from './git-helper.js'
13
13
  import { nowWallClock } from './datetime.js'
14
+ import { collectInvalidDocRefs } from './docs-check.js'
14
15
 
15
16
  const REQUIRED_SCAN_DOCS = SCAN_REQUIRED_DOCS
16
17
 
18
+ /**
19
+ * scan 文档引用事实核验(archify 借鉴 P0,2026-09-04):
20
+ * 对 scan 产物里显式写的 file:line 引用跑 docs-check 的联合核验(层1 存在性+行号边界 +
21
+ * 层2 引用行符号窗口断言,语义单一源 collectInvalidDocRefs)。
22
+ * repo:// 跨仓引用需要本机映射,跳过本地校验(防跨设备误报,与 docs-check 立场一致)。
23
+ * 只核验「显式写了引用的」事实;没写引用的散文不在范围(P2 的 scan IR 才覆盖)。
24
+ * WARNING 级:scan 刚写完窗口极小,失效多为笔误;docs-gate ratchet 负责长期增量收敛。
25
+ * @param {string} cwd 源码项目根(引用解析锚点)
26
+ * @param {string} scanDir scan 文档目录
27
+ * @param {string[]} docs 存在的文档文件名列表
28
+ * @returns {object|null} check 条目;无引用或全部有效时返回 null
29
+ */
30
+ function checkScanDocRefs(cwd, scanDir, docs) {
31
+ let verified
32
+ try {
33
+ verified = collectInvalidDocRefs(cwd, docs.map(d => ({ name: d, absPath: join(scanDir, d) })))
34
+ } catch {
35
+ return null // 核验器自身故障不阻断 postcheck(fail-open,仅损失一层 advisory)
36
+ }
37
+ const { invalid, totalRefs, skippedCrossRepo } = verified
38
+ if (invalid.length === 0) return null
39
+ const sample = invalid.slice(0, 3).map(i => `${i.doc}: \`${i.ref}\`(${i.reason})`).join(';')
40
+ return {
41
+ name: 'scan_doc_ref_invalid',
42
+ severity: CHECK_SEVERITY.WARNING,
43
+ detail: `${invalid.length} 条 file:line 引用未通过核验(${totalRefs} 条中): ${sample}${invalid.length > 3 ? ` 等 ${invalid.length} 条` : ''}`,
44
+ evidence: { invalidCount: invalid.length, totalRefs, skippedCrossRepo, checkedDocs: docs.length, sample: invalid.slice(0, 3) },
45
+ supportedFixes: ['运行 sillyspec docs check --suggest 查看候选锚点后修正引用', '删除无法核验的引用(引用了不存在的文件/行号)'],
46
+ }
47
+ }
48
+
17
49
  /**
18
50
  * @param {object} opts
19
51
  * @param {string} opts.cwd - 源码项目根目录 (source_root)
@@ -47,6 +79,11 @@ export function runScanPostCheck({ cwd, specDir, outputText = '', scanMeta = {},
47
79
  checks.push({ name: 'quick_profile_notice', severity: CHECK_SEVERITY.WARNING, detail: 'quick scan:仅生成 4 份核心文档(PROJECT/ARCHITECTURE/CONVENTIONS/STRUCTURE),深度扫描待补齐' })
48
80
  }
49
81
 
82
+ // 引用事实核验(archify P0):本地模式与平台模式同规则,防「本地过平台挂」
83
+ const existingLocalDocs = requiredDocs.filter(f => existsSync(join(scanDir, f)))
84
+ const refCheck = checkScanDocRefs(cwd, scanDir, existingLocalDocs)
85
+ if (refCheck) checks.push(refCheck)
86
+
50
87
  const hasWarning = checks.some(c => c.severity === 'warning')
51
88
  return { status: hasWarning ? 'completed_with_warnings' : 'success', checks }
52
89
  }
@@ -114,10 +151,16 @@ export function runScanPostCheck({ cwd, specDir, outputText = '', scanMeta = {},
114
151
  checks.push({
115
152
  name: 'docs_missing_header',
116
153
  severity: CHECK_SEVERITY.WARNING,
117
- detail: `${docsMissingHeader.length} 份文档缺少 author/created_at: ${docsMissingHeader.join(', ')}(sillyspec scan-fix-headers 一键补齐)`
154
+ detail: `${docsMissingHeader.length} 份文档缺少 author/created_at: ${docsMissingHeader.join(', ')}(sillyspec scan-fix-headers 一键补齐)`,
155
+ evidence: { count: docsMissingHeader.length, docs: docsMissingHeader },
156
+ supportedFixes: ['sillyspec scan-fix-headers'],
118
157
  })
119
158
  }
120
159
 
160
+ // 3.5 引用事实核验(archify 借鉴 P0):scan 文档里显式写的 file:line 引用逐条核验
161
+ const refCheck = checkScanDocRefs(cwd, specScanDir, existingDocs)
162
+ if (refCheck) checks.push(refCheck)
163
+
121
164
  // 4. local.yaml 校验
122
165
  const localYamlPath = join(specDir, 'local.yaml')
123
166
  if (existsSync(localYamlPath)) {
@@ -132,7 +175,9 @@ export function runScanPostCheck({ cwd, specDir, outputText = '', scanMeta = {},
132
175
  checks.push({
133
176
  name: 'local_config_invalid',
134
177
  severity: CHECK_SEVERITY.WARNING,
135
- detail: `local.yaml 引用不存在的命令: ${invalid.map(i => `${i.cmd} (${i.reason})`).join('; ')}`
178
+ detail: `local.yaml 引用不存在的命令: ${invalid.map(i => `${i.cmd} (${i.reason})`).join('; ')}`,
179
+ evidence: { invalid },
180
+ supportedFixes: ['修正 .sillyspec/local.yaml commands 中引用的 script 名(与 package.json scripts 对齐)'],
136
181
  })
137
182
  }
138
183
  }
@@ -267,6 +312,9 @@ export function formatStructuredResult(result, meta = {}) {
267
312
  name: c.name,
268
313
  severity: c.severity === 'failed' ? 'critical' : c.severity,
269
314
  detail: c.detail,
315
+ // 诊断信封升级(archify 借鉴 P1a,2026-09-04):additive 字段,机器可路由的修复建议
316
+ ...(c.evidence !== undefined ? { evidence: c.evidence } : {}),
317
+ ...(Array.isArray(c.supportedFixes) ? { supportedFixes: c.supportedFixes } : {}),
270
318
  })),
271
319
  }
272
320
 
@@ -285,7 +333,7 @@ export function formatStructuredResult(result, meta = {}) {
285
333
  structured.failure_categories.missing_outputs.push(entry)
286
334
  }
287
335
  // 引用无效类
288
- else if (check.name === 'local_config_invalid') {
336
+ else if (check.name === 'local_config_invalid' || check.name === 'scan_doc_ref_invalid') {
289
337
  structured.failure_categories.bad_references.push(entry)
290
338
  }
291
339
  // AI 输出质量类
@@ -450,6 +498,65 @@ export function backfillFrontmatter(content, { author, createdAt }) {
450
498
  return { changed: true, content: `---\n${inserts.join('\n')}\n---\n\n` + content.replace(/\r\n?/g, '\n') }
451
499
  }
452
500
 
501
+ /**
502
+ * scan 文档 frontmatter CLI 原子注入(2026-09-05 ②,archify 借鉴:机器校验替代 agent 自检)。
503
+ *
504
+ * agent 只写正文,元数据由 CLI「盖章」:author / created_at / source_commit(HEAD 短哈希)/
505
+ * updated_at / generator: sillyspec-scan(quick 档另加 scan_depth: quick)。
506
+ * 只补缺失键、**绝不覆盖已有**(增量扫描时旧文档保留原 source_commit 溯源);
507
+ * 插入口径与 backfillFrontmatter 逐字一致(有 --- 就地插入、无则前置整块、CRLF 归一写回)。
508
+ * 幂等——三个调用路径(scanFinalize / quick postcheck / 平台收尾)重复执行无害。
509
+ * 消灭 agent 手抄 CLI 注入值的失败模式(scan-fix-headers 从「兜底」降级为「历史文档修复工具」)。
510
+ *
511
+ * @param {{ cwd: string, specDir?: string|null, project?: string|null, mode?: string|null }} opts
512
+ * @returns {{ fixed: string[], skipped: string[], stampedKeys: number }}
513
+ */
514
+ export function stampScanDocHeaders({ cwd, specDir = null, project = null, mode = null }) {
515
+ const specBase = specDir ? specDir : join(cwd, '.sillyspec')
516
+ const docsRoot = join(specBase, 'docs')
517
+ const projects = project
518
+ ? [project]
519
+ : (existsSync(docsRoot) ? readdirSync(docsRoot, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name) : [])
520
+
521
+ let author = 'unknown'
522
+ try { author = git(cwd, ['config', 'user.name']) || 'unknown' } catch { /* git 不可用 → unknown */ }
523
+ let headShort = null
524
+ try { headShort = String(git(cwd, ['rev-parse', '--short', 'HEAD']) || '').trim() || null } catch { /* 非 git 仓 → 省略 source_commit */ }
525
+ const now = nowWallClock()
526
+ const updatedIso = new Date().toISOString()
527
+
528
+ const fixed = []
529
+ const skipped = []
530
+ let stampedKeys = 0
531
+ for (const p of projects) {
532
+ const scanDir = join(docsRoot, p, 'scan')
533
+ if (!existsSync(scanDir)) continue
534
+ for (const f of readdirSync(scanDir)) {
535
+ if (!f.endsWith('.md')) continue
536
+ const filePath = join(scanDir, f)
537
+ let content
538
+ try { content = readFileSync(filePath, 'utf8') } catch { continue }
539
+ const header = content.slice(0, 600)
540
+ const inserts = []
541
+ if (!/author\s*:/.test(header)) { inserts.push(`author: ${author}`); stampedKeys++ }
542
+ if (!/created_at\s*:/.test(header)) { inserts.push(`created_at: ${now}`); stampedKeys++ }
543
+ if (headShort && !/source_commit\s*:/.test(header)) { inserts.push(`source_commit: ${headShort}`); stampedKeys++ }
544
+ if (!/updated_at\s*:/.test(header)) { inserts.push(`updated_at: ${updatedIso}`); stampedKeys++ }
545
+ if (!/generator\s*:/.test(header)) { inserts.push(`generator: sillyspec-scan`); stampedKeys++ }
546
+ if (mode === 'quick' && !/scan_depth\s*:/.test(header)) { inserts.push(`scan_depth: quick`); stampedKeys++ }
547
+ if (inserts.length === 0) { skipped.push(filePath); continue }
548
+ const out = content.startsWith('---\n') || content.startsWith('---\r\n')
549
+ ? content.replace(/\r\n?/g, '\n').replace(/^---\n/, `---\n${inserts.join('\n')}\n`)
550
+ : `---\n${inserts.join('\n')}\n---\n\n` + content.replace(/\r\n?/g, '\n')
551
+ try {
552
+ writeFileSync(filePath, out)
553
+ fixed.push(filePath)
554
+ } catch { /* 只读文件等写失败 → 跳过不抛 */ }
555
+ }
556
+ }
557
+ return { fixed, skipped, stampedKeys }
558
+ }
559
+
453
560
  /**
454
561
  * source_root 污染一键修复(2026-08-21 agent-手工产出审计第四批 C-3)。
455
562
  *
package/src/spec-sync.js CHANGED
@@ -45,6 +45,23 @@ function debugLog(msg) {
45
45
  if (process.env.SILLYSPEC_DEBUG_SYNC) console.warn(msg);
46
46
  }
47
47
 
48
+ // 同步失败分类文案(坑 spec-sync-aborted-looks-exception,2026-09-07 平台执行环境实证):
49
+ // undici 被取消时 err.message 是英文原始串(AbortError=「This operation was aborted」、
50
+ // TimeoutError=「The operation was aborted due to timeout」),原样拼进 warn 看起来像未知
51
+ // 异常。按 err.name 翻译:AbortError=外部总熔断取消(run/shared.js raceWithAbort,预算经
52
+ // SILLYSPEC_SYNC_TIMEOUT_MS 可调,故不写死秒数);TimeoutError=单请求超时(清单 10s /
53
+ // 推送 30s 口径);其余错误维持原样。判别口径同 sync.js fetchJson 的 AbortError/
54
+ // TimeoutError 先例(外部 signal abort 抛 AbortError、AbortSignal.timeout 到期抛 TimeoutError)。
55
+ export function describeSyncError(err) {
56
+ if (err && err.name === 'AbortError') {
57
+ return '平台响应慢,本轮同步被总预算熔断让路(best-effort,下一条命令自动重试)';
58
+ }
59
+ if (err && err.name === 'TimeoutError') {
60
+ return '单请求超时(清单 10s / 推送 30s 口径,best-effort,下次自动重试)';
61
+ }
62
+ return err && err.message ? err.message : String(err);
63
+ }
64
+
48
65
  function _hashBuffer(buf) {
49
66
  return createHash('sha256').update(buf).digest('hex');
50
67
  }
@@ -477,7 +494,7 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
477
494
  const body = await res.json().catch(() => ({}));
478
495
  serverManifest = body.files || {};
479
496
  } catch (err) {
480
- console.warn(`[spec-sync] 拉取清单异常(文件树本次未同步,下次自动重试): ${changeName}: ${err.message}`);
497
+ console.warn(`[spec-sync] 拉取清单异常(文件树本次未同步,下次自动重试): ${changeName}: ${describeSyncError(err)}`);
481
498
  return { synced: 0 };
482
499
  }
483
500
 
@@ -605,7 +622,7 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
605
622
  writeBaseSnapshot(specRoot, localFiles);
606
623
  return { synced: ops.length };
607
624
  } catch (err) {
608
- console.warn(`[spec-sync] 同步异常(文件树本次未同步,下次自动重试): ${changeName}: ${err.message}`);
625
+ console.warn(`[spec-sync] 同步异常(文件树本次未同步,下次自动重试): ${changeName}: ${describeSyncError(err)}`);
609
626
  return { synced: 0 };
610
627
  }
611
628
  }
@@ -69,9 +69,13 @@ export function renderReviewJsonContract({ stage, changeDir, reviewRunId, tier }
69
69
  '- **推荐**:docHash 先占位(如 `"TODO"`),review.json 写完后跑',
70
70
  ' `sillyspec register-stage-review --change <变更名> --stage ' + stageLbl + ' --refresh-hash`',
71
71
  ' —— CLI 重算真实 sha256 代填,verdict/checklist 原样保留;主文档改版后同样跑这条',
72
- ' (多阶段联动用 `--all` 一次处理)。gate 拦 docHash 失配时也是同一条命令修复。',
72
+ ' (多阶段联动用 `--all` 一次处理)。',
73
+ '- **忘了跑也没关系**:' + mainDoc + ' 改版后 --done 的 gate 检测到 docHash 失配会**自动机械重算放行**',
74
+ ' (verdict 保留;结论是否仍适用于新文档需人工确认,reviewerNotes 会留审计行)。',
73
75
  '- 兜底手算口径(极端无 CLI 场景):node `crypto.createHash(\'sha256\').update(readFileSync(...))` (原始字节)/ `sha256sum <file>` 首列 hex。',
74
- '- ⚠️ **review.json 写入后若 ' + mainDoc + ' 再被改,必须重算 docHash** —— gate 会重算 reviewedFiles[0] 的 sha256 比对,不符判伪造(历史翻车根因:design 改了 rev 后 docHash 仍是旧值)',
76
+ '- ⚠️ **review.json 写入后若 ' + mainDoc + ' 再被改,须重算 docHash** —— 忘算也没关系:gate 会自动机械重算放行,',
77
+ ' 但那只是「机械对齐」;大幅改版(设计要点/接口变更)时审查结论可能已过时,应人工确认结论续用或重审,',
78
+ ' 不要把自动放行当「新文档已审过」(历史翻车根因:design 改了 rev 后结论未复核)。',
75
79
  '',
76
80
  '### 完整 JSON 示例(照抄改值)',
77
81
  '```json',
@@ -212,7 +216,8 @@ export function verifyStageReviewDocHash(review, searchDirs) {
212
216
  if (claimed !== actualRaw.toLowerCase() && claimed !== actualLf.toLowerCase()) {
213
217
  errors.push(
214
218
  `docHash 与主审查文档 ${primaryRel} 的实际内容不匹配。` +
215
- `若刚改过该文档(如 design 修订后忘重算),一键修复:` +
219
+ `若刚改过该文档(如 design 修订后忘重算),--done gate 会自动机械重算放行(verdict 保留);` +
220
+ `本调用方未走自动刷新时可手动执行:` +
216
221
  `sillyspec register-stage-review --change <变更名> --stage <阶段> --refresh-hash` +
217
222
  `(CLI 重算 docHash 代填,verdict 保留;多阶段联动加 --all);` +
218
223
  `若未改文档,则 review.json 疑似伪造(未真正读取文档)。`
@@ -412,6 +417,60 @@ export function validateStageReview(opts) {
412
417
  return { ok: errors.length === 0, errors, warnings, review: parsed }
413
418
  }
414
419
 
420
+ /**
421
+ * validateStageReview 的 gate 时刻自动刷新包装(坑 stage-review-refresh-hash-manual-forget,
422
+ * 2026-09-04 用户实证③:design 每次改版都要手动重跑 register-stage-review --refresh-hash,
423
+ * 易忘——忘则 --done 被 docHash 失配拦一轮,纯机械损耗)。
424
+ *
425
+ * 语义与 --refresh-hash 完全同源:主文档改版后的 hash 机械重算,verdict/checklist/
426
+ * requiredEvidence 原样保留(结论是否仍适用由人工确认——reviewerNotes 追加审计行留痕)。
427
+ * 自动刷新不放宽防伪造边界:
428
+ * - 真正判伪造/错位的「主文档在候选基准下均不存在」不触发自动刷新(仍 fail-closed);
429
+ * - schema 失败 / verdict=fail 不碰(照常阻断);
430
+ * - 写盘失败回落原失败结果(fail-closed,不静默放行)。
431
+ *
432
+ * @param {object} opts - 同 validateStageReview,另加 opts.autoRefresh(true 才尝试自动刷新)
433
+ * @returns {{ result: object, autoRefreshed: boolean, refreshedPath?: string, previousErrors?: string[] }}
434
+ */
435
+ export function validateStageReviewWithAutoRefresh(opts) {
436
+ const result = validateStageReview(opts)
437
+ if (result.ok) return { result, autoRefreshed: false }
438
+ if (!opts.autoRefresh) return { result, autoRefreshed: false }
439
+ // 仅「docHash 与主审查文档 …不匹配」类错误才自动刷新;「均不存在」(路径伪造/基准错位)
440
+ // 与 schema/verdict 错误一律原样返回
441
+ const mismatchOnly = result.review
442
+ && Array.isArray(result.errors) && result.errors.length > 0
443
+ && result.errors.every(e => String(e).includes('docHash 与主审查文档'))
444
+ if (!mismatchOnly) return { result, autoRefreshed: false }
445
+
446
+ const reviewDir = join(opts.runtimeRoot, 'stage-reviews', `${opts.stage}-${opts.reviewRunId}`)
447
+ const reviewPath = join(reviewDir, 'review.json')
448
+ // 定位主文档并重算 hash(与 verifyStageReviewDocHash 同法:reviewedFiles[0] × searchDirs)
449
+ const primaryRel = Array.isArray(result.review.reviewedFiles) ? result.review.reviewedFiles[0] : null
450
+ let docHash = null
451
+ if (primaryRel) {
452
+ for (const base of (opts.searchDirs || [])) {
453
+ const abs = join(base, primaryRel)
454
+ if (existsSync(abs)) { docHash = computeDocHash(abs); break }
455
+ }
456
+ }
457
+ if (!docHash) return { result, autoRefreshed: false }
458
+
459
+ const refreshedAt = new Date().toISOString().slice(0, 19).replace('T', ' ')
460
+ const refreshed = {
461
+ ...result.review,
462
+ docHash,
463
+ reviewerNotes: `${result.review.reviewerNotes || ''}\ndocHash auto-refreshed at ${refreshedAt}(gate 检测主文档改版,机械重算;verdict 保留,结论是否仍适用需人工确认)`.trim(),
464
+ }
465
+ try {
466
+ writeFileSync(reviewPath, JSON.stringify(refreshed, null, 2) + '\n')
467
+ } catch {
468
+ return { result, autoRefreshed: false } // 写失败回落原失败结果,不静默放行
469
+ }
470
+ const recheck = validateStageReview(opts)
471
+ return { result: recheck, autoRefreshed: recheck.ok, refreshedPath: reviewPath, previousErrors: result.errors }
472
+ }
473
+
415
474
  /**
416
475
  * 打印 stage review 校验结果
417
476
  * @param {{ ok: boolean, errors: string[], warnings: string[] }} result
@@ -186,14 +186,14 @@ created_at: <now-datetime>
186
186
  },
187
187
  {
188
188
  name: '更新路线图和提交',
189
- prompt: `更新路线图并暂存变更。
189
+ prompt: `更新路线图并确认归档。
190
190
 
191
191
  ### 操作
192
192
  1. 如果 \`.sillyspec/ROADMAP.md\` 存在,标记对应 Phase 为已完成
193
- 2. \`git add .sillyspec/changes/archive/\` — 暂存归档结果(archive/ 下仅本次归档新增,不会裹挟 changes/ 下其他活跃变更;不要 commit,由用户通过统一提交工具处理)
194
- 3. \`git add .sillyspec/docs/<project>/modules/\` — 暂存模块文档更新(如有;精确到本次同步的模块文档,勿 add 整个 .sillyspec/docs/)
195
- 4. \`git add .sillyspec/knowledge/decisions/\` — 暂存决策知识库更新(如有;decision-distill 步骤的提炼产物,精确到 decisions 子目录,勿 add 整个 .sillyspec/knowledge/;不要 commit,由用户通过统一提交工具处理)
196
- 5. 确认 sillyspec.db 中该变更已不再 active(确认归档步骤由 CLI 调用 unregisterChange)
193
+ 2. 确认 sillyspec.db 中该变更已不再 active(确认归档步骤由 CLI 调用 unregisterChange)
194
+
195
+ ### 说明
196
+ - **git 暂存已由 CLI 自动完成**(归档完成时自动 add:\`.sillyspec/changes/archive/\`、本次同步的模块文档、\`.sillyspec/knowledge/decisions/\`——未提交,由用户通过统一提交工具处理)——你不需要也不应该手动 git add
197
197
 
198
198
  ### 输出
199
199
  归档完成确认 + 累积规范统计`,
@@ -7,21 +7,20 @@ export const definition = {
7
7
  name: '进度确认',
8
8
  // 重命名自「状态检查」(③ status 消歧):老进度里的「状态检查」completed 由迁移逻辑承接,防 currentIdx 回跳
9
9
  migratedFrom: ['状态检查'],
10
- prompt: `检查当前变更的进度状态(sillyspec.db)。用 \`sillyspec progress show\` 查流程进度,不要用 \`sillyspec status\`(项目级快照,不推进流程)。
10
+ prompt: `检查当前变更的进度状态(快照已由 CLI 注入,勿再跑 progress show——那是全量输出,浪费上下文;\`sillyspec status\` 是项目级快照,也不推进流程)。
11
+
12
+ ### 进度快照(CLI 注入)
13
+ {PROGRESS_SNAPSHOT}
11
14
 
12
15
  ### 操作
13
- 1. 运行 \`sillyspec progress show\`
14
- 2. 确认 currentStage 为 "brainstorm"
15
- 3. 如果有进行中的 brainstorm,提示选择继续或重新开始
16
- 4. 如果未初始化,提示先运行 sillyspec init
17
- 5. **检查变更名称是否有意义**:如果当前变更名是自动生成的(如 \`2026-06-02-new-change-a3f2b7c1\`),询问用户确认实际变更名,然后运行 \`sillyspec change-rename <旧名> <新名>\` 重命名
16
+ 1. **检查变更名称是否有意义**:如果当前变更名是自动生成的(如 \`2026-06-02-new-change-a3f2b7c1\`),询问用户确认实际变更名,然后运行 \`sillyspec change-rename <旧名> <新名>\` 重命名
17
+ 2. 如果快照显示阶段不符或未初始化,输出正确提示并停止
18
18
 
19
19
  ### 输出
20
20
  当前状态摘要(1-2 句话)
21
21
 
22
22
  ### 注意
23
- - 以 CLI 返回为准,不要自行推断阶段
24
- - 如果阶段不对,输出正确提示并停止
23
+ - 以 CLI 注入快照为准,不要自行推断阶段
25
24
  - **不要用 mv 命令重命名变更目录**,必须使用 \`sillyspec change-rename\`,否则 DB 和目录会脱节`,
26
25
  outputHint: '状态摘要',
27
26
  optional: false
@@ -33,6 +32,7 @@ export const definition = {
33
32
  ### 操作
34
33
  1. 读取项目总览 \`{SPEC_ROOT}/docs/<project>/scan/PROJECT.md\` + 共享规范 + 子项目上下文
35
34
  - 漂移事实(CLI 算):{SCAN_STALENESS}
35
+ - 机械事实底稿(CLI 注入,无 scan 底稿则为空):{SCAN_FACTS}
36
36
  2. 加载项目信息:\`cat {SPEC_ROOT}/projects/*.yaml 2>/dev/null\`
37
37
  3. 加载本地配置:\`cat {SPEC_ROOT}/local.yaml 2>/dev/null\`
38
38
  4. 棕地项目:读取 {SPEC_ROOT}/docs/<project>/scan/ 下的 STRUCTURE.md、CONVENTIONS.md、ARCHITECTURE.md
@@ -348,7 +348,7 @@ design.md 第一行标题必须用中文:# 设计文档(Design)— <变更
348
348
  ### 操作
349
349
  1. 确认变更目录存在:\`mkdir -p {SPEC_ROOT}/changes/<change-name>\`(Windows 用 \`mkdir {SPEC_ROOT}/changes\\<变更名>\` 或 PowerShell \`New-Item -ItemType Directory -Force -Path {SPEC_ROOT}/changes/<change-name>\`)
350
350
  - 变更名格式必须为 \`YYYY-MM-DD-<简短描述>\`(如 \`2026-05-13-user-auth\`)
351
- 2. 将确认的设计写入 \`{SPEC_ROOT}/changes/<change-name>/design.md\`
351
+ 2. 将确认的设计写入 \`{SPEC_ROOT}/changes/<change-name>/design.md\`——优先跑 \`sillyspec design-init --change <change-name>\` 生成骨架再填散文(决策追踪表已按 decisions.md 当前版本预填,逐行补覆盖点后把「待确认」改「已覆盖」;文件清单表骨架就位);存量手写路径仍合法(design-init 对已存在的 design.md 不覆盖)
352
352
  3. 对账整理 \`{SPEC_ROOT}/changes/<change-name>/decisions.md\`(「对话式探索」「提出方案」步已按增量规则随答落盘,本步做对账而非从零写入):
353
353
  - decisions.md 是本次变更的决策台账,不是长期术语表
354
354
  - 只记录有实现/验收影响的决策,闲聊和低风险偏好不记录
@@ -358,7 +358,7 @@ design.md 第一行标题必须用中文:# 设计文档(Design)— <变更
358
358
  - 每条记录必须包含:type、status、source、question、answer、normalized_requirement、impacts、evidence、priority
359
359
  - 九字段之外可另加四个可选字段,按需填写、不强制全填(旧格式决策记录缺这些字段不受影响):
360
360
  - 锚点:决策落点的主文件,格式 <src 路径>:<行号或符号>(status=confirmed 时必填)
361
- - 模块域:决策涉及的模块 ID,取自 \`{SPEC_ROOT}/docs/<project>/modules/_module-map.yaml\`,可多个、用逗号分隔
361
+ - 模块域:决策涉及的模块 ID,取自 \`{SPEC_ROOT}/docs/<project>/modules/_module-map.yaml\`,可多个、用逗号分隔;规划中的新模块用 NEW:模块名 前缀声明(冒号后不加空格——\`NEW:foo\` 合法、\`NEW: foo\` 属书写错误),「生成规范文件」步 --done 的模块域核验会豁免 NEW 前缀并在模块落地后补录
362
362
  - 否决理由:一句话说明为何否决(status=rejected 时必填)
363
363
  - 复潮条件:什么前提下可重新考虑该决策(status=rejected 时必填)
364
364
  - 长期术语只在 archive/scan 时再提升到 \`{SPEC_ROOT}/docs/<project>/glossary.md\`