sillyspec 3.28.0 → 3.28.3

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 (60) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +54 -93
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
  3. package/.claude/skills/sillyspec-continue/SKILL.md +17 -47
  4. package/.claude/skills/sillyspec-execute/SKILL.md +222 -214
  5. package/.claude/skills/sillyspec-knowledge/SKILL.md +40 -270
  6. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  7. package/.claude/skills/sillyspec-propose/SKILL.md +16 -87
  8. package/.claude/skills/sillyspec-resume/SKILL.md +21 -70
  9. package/.claude/skills/sillyspec-state/SKILL.md +25 -64
  10. package/.claude/skills/sillyspec-workspace/SKILL.md +33 -175
  11. package/package.json +1 -1
  12. package/src/archive-delta.js +462 -0
  13. package/src/constants.js +6 -0
  14. package/src/design-facts.js +418 -0
  15. package/src/dispatch/backends/sillyhub-mcp.js +17 -5
  16. package/src/docs-check.js +197 -34
  17. package/src/docs-migrate.js +124 -0
  18. package/src/doctor-diagnostics.js +11 -2
  19. package/src/endpoint-baseline.js +109 -0
  20. package/src/git-helper.js +31 -0
  21. package/src/index.js +381 -22
  22. package/src/modules.js +41 -12
  23. package/src/progress/change-registry.js +39 -0
  24. package/src/progress/consistency-doctor.js +124 -5
  25. package/src/progress/stage-machine.js +13 -2
  26. package/src/progress.js +5 -0
  27. package/src/quicklog.js +51 -0
  28. package/src/run/command.js +199 -5
  29. package/src/run/complete-handlers.js +60 -4
  30. package/src/run/complete.js +105 -6
  31. package/src/run/gates.js +353 -2
  32. package/src/run/progress-confirm.js +66 -0
  33. package/src/run/prompt.js +193 -15
  34. package/src/run/quick-audit.js +12 -5
  35. package/src/run/scan-profile.js +117 -7
  36. package/src/run/shared.js +22 -6
  37. package/src/run/stage.js +14 -1
  38. package/src/scan-diff.js +54 -1
  39. package/src/scan-facts.js +173 -0
  40. package/src/scan-postcheck.js +114 -3
  41. package/src/spec-sync.js +19 -2
  42. package/src/stage-review.js +62 -3
  43. package/src/stages/archive.js +5 -5
  44. package/src/stages/brainstorm-auto.js +2 -1
  45. package/src/stages/brainstorm.js +12 -20
  46. package/src/stages/execute.js +19 -21
  47. package/src/stages/plan-postcheck.js +194 -1
  48. package/src/stages/plan.js +8 -3
  49. package/src/stages/quick.js +4 -2
  50. package/src/stages/scan.js +44 -102
  51. package/src/stages/verify.js +18 -18
  52. package/src/task-review.js +200 -0
  53. package/src/taskcard.js +8 -1
  54. package/src/verify-postcheck.js +771 -29
  55. package/src/verify-probes.js +89 -10
  56. package/src/workflow.js +30 -0
  57. package/templates/agents-instruction.md +1 -1
  58. package/templates/prompts/taskcard-rules.md +5 -0
  59. package/templates/workflows/scan-docs.yaml +6 -0
  60. package/.claude/skills/verify-per-user/SKILL.md +0 -6
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,45 @@ 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: [
46
+ 'sillyspec docs check --paths <该文档> --dry-run 查看候选锚点(fixable 条目会给出 newLine),确认后去 --dry-run 落盘重锚',
47
+ 'sillyspec docs check --paths <该文档> --fix 对 fixable 引用自动重锚(回执附修复前后失效数对比)',
48
+ '先跑 sillyspec docs check --paths <该文档> 确认零候选锚点后,再删除确认无法核验的引用行',
49
+ ],
50
+ }
51
+ }
52
+
17
53
  /**
18
54
  * @param {object} opts
19
55
  * @param {string} opts.cwd - 源码项目根目录 (source_root)
@@ -47,6 +83,11 @@ export function runScanPostCheck({ cwd, specDir, outputText = '', scanMeta = {},
47
83
  checks.push({ name: 'quick_profile_notice', severity: CHECK_SEVERITY.WARNING, detail: 'quick scan:仅生成 4 份核心文档(PROJECT/ARCHITECTURE/CONVENTIONS/STRUCTURE),深度扫描待补齐' })
48
84
  }
49
85
 
86
+ // 引用事实核验(archify P0):本地模式与平台模式同规则,防「本地过平台挂」
87
+ const existingLocalDocs = requiredDocs.filter(f => existsSync(join(scanDir, f)))
88
+ const refCheck = checkScanDocRefs(cwd, scanDir, existingLocalDocs)
89
+ if (refCheck) checks.push(refCheck)
90
+
50
91
  const hasWarning = checks.some(c => c.severity === 'warning')
51
92
  return { status: hasWarning ? 'completed_with_warnings' : 'success', checks }
52
93
  }
@@ -114,10 +155,16 @@ export function runScanPostCheck({ cwd, specDir, outputText = '', scanMeta = {},
114
155
  checks.push({
115
156
  name: 'docs_missing_header',
116
157
  severity: CHECK_SEVERITY.WARNING,
117
- detail: `${docsMissingHeader.length} 份文档缺少 author/created_at: ${docsMissingHeader.join(', ')}(sillyspec scan-fix-headers 一键补齐)`
158
+ detail: `${docsMissingHeader.length} 份文档缺少 author/created_at: ${docsMissingHeader.join(', ')}(sillyspec scan-fix-headers 一键补齐)`,
159
+ evidence: { count: docsMissingHeader.length, docs: docsMissingHeader },
160
+ supportedFixes: ['sillyspec scan-fix-headers'],
118
161
  })
119
162
  }
120
163
 
164
+ // 3.5 引用事实核验(archify 借鉴 P0):scan 文档里显式写的 file:line 引用逐条核验
165
+ const refCheck = checkScanDocRefs(cwd, specScanDir, existingDocs)
166
+ if (refCheck) checks.push(refCheck)
167
+
121
168
  // 4. local.yaml 校验
122
169
  const localYamlPath = join(specDir, 'local.yaml')
123
170
  if (existsSync(localYamlPath)) {
@@ -132,7 +179,9 @@ export function runScanPostCheck({ cwd, specDir, outputText = '', scanMeta = {},
132
179
  checks.push({
133
180
  name: 'local_config_invalid',
134
181
  severity: CHECK_SEVERITY.WARNING,
135
- detail: `local.yaml 引用不存在的命令: ${invalid.map(i => `${i.cmd} (${i.reason})`).join('; ')}`
182
+ detail: `local.yaml 引用不存在的命令: ${invalid.map(i => `${i.cmd} (${i.reason})`).join('; ')}`,
183
+ evidence: { invalid },
184
+ supportedFixes: ['修正 .sillyspec/local.yaml commands 中引用的 script 名(与 package.json scripts 对齐)'],
136
185
  })
137
186
  }
138
187
  }
@@ -267,6 +316,9 @@ export function formatStructuredResult(result, meta = {}) {
267
316
  name: c.name,
268
317
  severity: c.severity === 'failed' ? 'critical' : c.severity,
269
318
  detail: c.detail,
319
+ // 诊断信封升级(archify 借鉴 P1a,2026-09-04):additive 字段,机器可路由的修复建议
320
+ ...(c.evidence !== undefined ? { evidence: c.evidence } : {}),
321
+ ...(Array.isArray(c.supportedFixes) ? { supportedFixes: c.supportedFixes } : {}),
270
322
  })),
271
323
  }
272
324
 
@@ -285,7 +337,7 @@ export function formatStructuredResult(result, meta = {}) {
285
337
  structured.failure_categories.missing_outputs.push(entry)
286
338
  }
287
339
  // 引用无效类
288
- else if (check.name === 'local_config_invalid') {
340
+ else if (check.name === 'local_config_invalid' || check.name === 'scan_doc_ref_invalid') {
289
341
  structured.failure_categories.bad_references.push(entry)
290
342
  }
291
343
  // AI 输出质量类
@@ -450,6 +502,65 @@ export function backfillFrontmatter(content, { author, createdAt }) {
450
502
  return { changed: true, content: `---\n${inserts.join('\n')}\n---\n\n` + content.replace(/\r\n?/g, '\n') }
451
503
  }
452
504
 
505
+ /**
506
+ * scan 文档 frontmatter CLI 原子注入(2026-09-05 ②,archify 借鉴:机器校验替代 agent 自检)。
507
+ *
508
+ * agent 只写正文,元数据由 CLI「盖章」:author / created_at / source_commit(HEAD 短哈希)/
509
+ * updated_at / generator: sillyspec-scan(quick 档另加 scan_depth: quick)。
510
+ * 只补缺失键、**绝不覆盖已有**(增量扫描时旧文档保留原 source_commit 溯源);
511
+ * 插入口径与 backfillFrontmatter 逐字一致(有 --- 就地插入、无则前置整块、CRLF 归一写回)。
512
+ * 幂等——三个调用路径(scanFinalize / quick postcheck / 平台收尾)重复执行无害。
513
+ * 消灭 agent 手抄 CLI 注入值的失败模式(scan-fix-headers 从「兜底」降级为「历史文档修复工具」)。
514
+ *
515
+ * @param {{ cwd: string, specDir?: string|null, project?: string|null, mode?: string|null }} opts
516
+ * @returns {{ fixed: string[], skipped: string[], stampedKeys: number }}
517
+ */
518
+ export function stampScanDocHeaders({ cwd, specDir = null, project = null, mode = null }) {
519
+ const specBase = specDir ? specDir : join(cwd, '.sillyspec')
520
+ const docsRoot = join(specBase, 'docs')
521
+ const projects = project
522
+ ? [project]
523
+ : (existsSync(docsRoot) ? readdirSync(docsRoot, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name) : [])
524
+
525
+ let author = 'unknown'
526
+ try { author = git(cwd, ['config', 'user.name']) || 'unknown' } catch { /* git 不可用 → unknown */ }
527
+ let headShort = null
528
+ try { headShort = String(git(cwd, ['rev-parse', '--short', 'HEAD']) || '').trim() || null } catch { /* 非 git 仓 → 省略 source_commit */ }
529
+ const now = nowWallClock()
530
+ const updatedIso = new Date().toISOString()
531
+
532
+ const fixed = []
533
+ const skipped = []
534
+ let stampedKeys = 0
535
+ for (const p of projects) {
536
+ const scanDir = join(docsRoot, p, 'scan')
537
+ if (!existsSync(scanDir)) continue
538
+ for (const f of readdirSync(scanDir)) {
539
+ if (!f.endsWith('.md')) continue
540
+ const filePath = join(scanDir, f)
541
+ let content
542
+ try { content = readFileSync(filePath, 'utf8') } catch { continue }
543
+ const header = content.slice(0, 600)
544
+ const inserts = []
545
+ if (!/author\s*:/.test(header)) { inserts.push(`author: ${author}`); stampedKeys++ }
546
+ if (!/created_at\s*:/.test(header)) { inserts.push(`created_at: ${now}`); stampedKeys++ }
547
+ if (headShort && !/source_commit\s*:/.test(header)) { inserts.push(`source_commit: ${headShort}`); stampedKeys++ }
548
+ if (!/updated_at\s*:/.test(header)) { inserts.push(`updated_at: ${updatedIso}`); stampedKeys++ }
549
+ if (!/generator\s*:/.test(header)) { inserts.push(`generator: sillyspec-scan`); stampedKeys++ }
550
+ if (mode === 'quick' && !/scan_depth\s*:/.test(header)) { inserts.push(`scan_depth: quick`); stampedKeys++ }
551
+ if (inserts.length === 0) { skipped.push(filePath); continue }
552
+ const out = content.startsWith('---\n') || content.startsWith('---\r\n')
553
+ ? content.replace(/\r\n?/g, '\n').replace(/^---\n/, `---\n${inserts.join('\n')}\n`)
554
+ : `---\n${inserts.join('\n')}\n---\n\n` + content.replace(/\r\n?/g, '\n')
555
+ try {
556
+ writeFileSync(filePath, out)
557
+ fixed.push(filePath)
558
+ } catch { /* 只读文件等写失败 → 跳过不抛 */ }
559
+ }
560
+ }
561
+ return { fixed, skipped, stampedKeys }
562
+ }
563
+
453
564
  /**
454
565
  * source_root 污染一键修复(2026-08-21 agent-手工产出审计第四批 C-3)。
455
566
  *
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
  归档完成确认 + 累积规范统计`,
@@ -28,7 +28,8 @@ export const definition = {
28
28
  ### 加载上下文
29
29
  4. 读取项目总览 \`.sillyspec/docs/<project>/scan/PROJECT.md\`(如存在)+ 共享规范 + 子项目上下文
30
30
  5. 加载项目信息:\`cat .sillyspec/projects/*.yaml 2>/dev/null\`
31
- 6. 加载本地配置:\`cat .sillyspec/local.yaml 2>/dev/null\`
31
+ 6. 构建命令(CLI 自 local.yaml 注入,勿再读文件):
32
+ {LOCAL_COMMANDS}
32
33
  7. 棕地项目:读取 scan 下的 STRUCTURE.md、CONVENTIONS.md、ARCHITECTURE.md
33
34
  8. 加载模块索引:读取 \`.sillyspec/docs/<project>/modules/_module-map.yaml\`(如存在)
34
35
  9. 查看进行中的变更:\`ls .sillyspec/changes/ | grep -v archive\`