sillyspec 3.28.3 → 3.28.5

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 (77) hide show
  1. package/.claude/skills/sillyspec-auto/SKILL.md +54 -54
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +114 -114
  3. package/.claude/skills/sillyspec-continue/SKILL.md +17 -17
  4. package/.claude/skills/sillyspec-execute/SKILL.md +222 -222
  5. package/.claude/skills/sillyspec-knowledge/SKILL.md +40 -40
  6. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  7. package/.claude/skills/sillyspec-propose/SKILL.md +16 -16
  8. package/.claude/skills/sillyspec-resume/SKILL.md +21 -21
  9. package/.claude/skills/sillyspec-state/SKILL.md +25 -25
  10. package/.claude/skills/sillyspec-workspace/SKILL.md +33 -33
  11. package/AGENTS.md +25 -0
  12. package/INSTRUCTIONS.md +12 -0
  13. package/package.json +1 -1
  14. package/results-review-tier.md +18 -0
  15. package/src/agent-session-log.js +4 -3
  16. package/src/archive-delta.js +82 -0
  17. package/src/change-delete.js +2 -1
  18. package/src/change-list.js +18 -3
  19. package/src/change-risk-profile.js +77 -6
  20. package/src/commit-suggest.js +52 -1
  21. package/src/config-cat.js +17 -1
  22. package/src/constants.js +3 -1
  23. package/src/decision-distill.js +45 -5
  24. package/src/decisions-io.js +96 -0
  25. package/src/design-facts.js +3 -0
  26. package/src/dispatch/probe.js +31 -8
  27. package/src/docs-check.js +102 -3
  28. package/src/doctor-diagnostics.js +441 -5
  29. package/src/foreign-declared.js +53 -2
  30. package/src/index.js +606 -22
  31. package/src/mcp-server.js +144 -0
  32. package/src/module-impact.js +84 -12
  33. package/src/modules.js +46 -34
  34. package/src/plan-adopt-waves.js +13 -5
  35. package/src/progress/change-registry.js +34 -0
  36. package/src/progress.js +75 -2
  37. package/src/quick-recommend.js +6 -1
  38. package/src/quicklog.js +70 -8
  39. package/src/review-dispatch.js +377 -0
  40. package/src/run/archive-distill.js +48 -0
  41. package/src/run/command.js +48 -13
  42. package/src/run/complete-handlers.js +215 -9
  43. package/src/run/complete.js +184 -2
  44. package/src/run/gate-snapshot.js +88 -0
  45. package/src/run/gates.js +206 -58
  46. package/src/run/prompt.js +150 -14
  47. package/src/run/quick-audit.js +38 -13
  48. package/src/run/shared.js +183 -12
  49. package/src/run/stage.js +52 -2
  50. package/src/run/verify-quality-scan.js +336 -0
  51. package/src/runtime-hygiene.js +84 -0
  52. package/src/scope-audit.js +735 -0
  53. package/src/sillyhub-mcp/client.js +58 -3
  54. package/src/spec-sync.js +63 -8
  55. package/src/stage-contract.js +83 -3
  56. package/src/stage-review-checklist.js +44 -0
  57. package/src/stage-review.js +125 -4
  58. package/src/stages/archive.js +60 -167
  59. package/src/stages/brainstorm.js +7 -17
  60. package/src/stages/doctor.js +41 -515
  61. package/src/stages/execute.js +13 -10
  62. package/src/stages/plan-postcheck.js +191 -7
  63. package/src/stages/plan.js +7 -35
  64. package/src/stages/quick.js +11 -11
  65. package/src/stages/verify.js +24 -39
  66. package/src/sync-noise.js +158 -0
  67. package/src/sync.js +93 -27
  68. package/src/task-review.js +133 -17
  69. package/src/taskcard.js +46 -3
  70. package/src/validate-artifacts.js +163 -0
  71. package/src/verify-facts-schema.js +141 -0
  72. package/src/verify-postcheck.js +347 -29
  73. package/src/verify-probes.js +275 -24
  74. package/src/workflow.js +6 -0
  75. package/src/worktree-apply.js +167 -24
  76. package/src/worktree.js +5 -1
  77. package/templates/agents-instruction.md +1 -0
@@ -0,0 +1,144 @@
1
+ /**
2
+ * mcp-server.js(P2-g = MCP Phase 2,noai-ir-roadmap §5):SillySpec 最小 stdio MCP server。
3
+ *
4
+ * 动机:agent 此前靠 shell 调 CLI + 解析人类可读文本,flag 幻觉与文本解析漂移是真实痛点
5
+ * (铁律里专门有一条「不确定命令时停下问用户」)。MCP tools 的 schema 化参数把 flag 面锁死;
6
+ * machine-interface 的 JSON envelope(gate/derive/progress)与 next 的 JSON 输出是现成地基。
7
+ *
8
+ * 形态:`sillyspec mcp` 启动(hosts 配 command=sillyspec args=[mcp])。stdio 上跑 JSON-RPC 2.0
9
+ * 行协议(每行一个请求/响应)。**只读 tools**(Phase 2 边界:--done 等状态推进不暴露——
10
+ * Phase 3 另计;工具全部经子进程 `--json` 执行,stdout 纪律天然隔离,不 import 重模块):
11
+ * - sillyspec_next:状态探测与下一步建议(detectNextStep)
12
+ * - sillyspec_gate:阶段完成门控聚合(machine-interface runGate envelope)
13
+ * - sillyspec_derive:单项事实核验(runDerive envelope)
14
+ * - sillyspec_progress:全局进度总览(progress show --json envelope)
15
+ */
16
+ import { createInterface } from 'node:readline'
17
+ import { spawn } from 'node:child_process'
18
+ import { join, dirname } from 'node:path'
19
+ import { fileURLToPath } from 'node:url'
20
+
21
+ const __dirname = dirname(fileURLToPath(import.meta.url))
22
+ const CLI_ENTRY = join(__dirname, 'index.js')
23
+ const PROTOCOL_VERSION = '2025-06-18'
24
+ const SERVER_INFO = { name: 'sillyspec', version: '3.28.4' }
25
+
26
+ const TOOLS = [
27
+ {
28
+ name: 'sillyspec_next',
29
+ description: '项目状态探测:状态 + 下一步命令 + 依据 + 活跃变更列表(只读;无活跃进度时从产物反推)',
30
+ inputSchema: { type: 'object', properties: { spec_dir: { type: 'string', description: '规范目录(缺省 <cwd>/.sillyspec)' } } },
31
+ },
32
+ {
33
+ name: 'sillyspec_gate',
34
+ description: '阶段完成门控聚合:变更的 <stage> 阶段此刻能否标记完成(envelope:ok/errors/warnings/checks;exit 0/1/2)',
35
+ inputSchema: {
36
+ type: 'object',
37
+ properties: {
38
+ stage: { type: 'string', enum: ['brainstorm', 'plan', 'execute', 'verify', 'archive'], description: '目标阶段' },
39
+ change: { type: 'string', description: '变更名(必填)' },
40
+ spec_dir: { type: 'string' },
41
+ },
42
+ required: ['stage', 'change'],
43
+ },
44
+ },
45
+ {
46
+ name: 'sillyspec_derive',
47
+ description: '单项事实核验(facet:execute-evidence / verify-test / task-reviews / artifacts)',
48
+ inputSchema: {
49
+ type: 'object',
50
+ properties: {
51
+ facet: { type: 'string', enum: ['execute-evidence', 'verify-test', 'task-reviews', 'artifacts'] },
52
+ change: { type: 'string' },
53
+ spec_dir: { type: 'string' },
54
+ },
55
+ required: ['facet', 'change'],
56
+ },
57
+ },
58
+ {
59
+ name: 'sillyspec_progress',
60
+ description: '全局进度总览(活跃变更 × 阶段 × 步骤态,envelope JSON;只读)',
61
+ inputSchema: { type: 'object', properties: { spec_dir: { type: 'string' } } },
62
+ },
63
+ ]
64
+
65
+ /** tools/call 路由:子进程 --json(stdout 纪律隔离;CLI 的 stderr 透传进 text 供诊断) */
66
+ async function callTool(name, args) {
67
+ const argv = ['--meta'] // 占位防误配:--meta 是 run 的 flag,工具不走 run
68
+ argv.length = 0
69
+ const specDir = args && typeof args.spec_dir === 'string' && args.spec_dir ? ['--spec-dir', args.spec_dir] : []
70
+ switch (name) {
71
+ case 'sillyspec_next':
72
+ return await runCli(['next', '--json', ...specDir])
73
+ case 'sillyspec_gate':
74
+ return await runCli(['gate', String(args.stage), '--change', String(args.change), '--json', ...specDir])
75
+ case 'sillyspec_derive':
76
+ return await runCli(['derive', String(args.facet), '--change', String(args.change), '--json', ...specDir])
77
+ case 'sillyspec_progress':
78
+ return await runCli(['progress', 'show', '--json', ...specDir])
79
+ default:
80
+ return { text: `未知工具: ${name}`, isError: true }
81
+ }
82
+ }
83
+
84
+ function runCli(cliArgs) {
85
+ return new Promise((resolve) => {
86
+ const p = spawn(process.execPath, [CLI_ENTRY, ...cliArgs], {
87
+ cwd: process.cwd(),
88
+ env: process.env,
89
+ stdio: ['ignore', 'pipe', 'pipe'],
90
+ })
91
+ let out = ''
92
+ let err = ''
93
+ p.stdout.on('data', (d) => { out += String(d) })
94
+ p.stderr.on('data', (d) => { err += String(d) })
95
+ p.on('close', (code) => {
96
+ // envelope 原样透传(text = stdout JSON 全文;machine 侧自解);stderr 仅失败时附诊断
97
+ const text = (out.trim() || err.trim().slice(-2000) || `(exit ${code},无输出)`)
98
+ resolve({ text, isError: code !== null && code !== 0 && code !== 1, exitCode: code })
99
+ })
100
+ p.on('error', (e) => resolve({ text: `CLI 子进程失败: ${e.message}`, isError: true }))
101
+ })
102
+ }
103
+
104
+ /** 单请求分发。返回 result 对象(或抛 {code, message} 形态错误由上层包 error)。 */
105
+ async function handleMessage(msg) {
106
+ const { id, method, params } = msg
107
+ switch (method) {
108
+ case 'initialize':
109
+ return { protocolVersion: (params && params.protocolVersion) || PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: SERVER_INFO }
110
+ case 'ping':
111
+ return {}
112
+ case 'tools/list':
113
+ return { tools: TOOLS }
114
+ case 'tools/call': {
115
+ const name = params && params.name
116
+ const args = (params && params.arguments) || {}
117
+ const r = await callTool(name, args)
118
+ return { content: [{ type: 'text', text: r.text }], ...(r.isError ? { isError: true } : {}) }
119
+ }
120
+ default:
121
+ throw { code: -32601, message: `Method not found: ${method}` }
122
+ }
123
+ }
124
+
125
+ /** stdio 行协议主循环(readline 逐行 JSON;通知无 id 不回包)。导出供测试与 `sillyspec mcp` 接线。 */
126
+ export function startMcpServer({ input = process.stdin, output = process.stdout } = {}) {
127
+ const rl = createInterface({ input })
128
+ rl.on('line', async (line) => {
129
+ const trimmed = line.trim()
130
+ if (!trimmed) return
131
+ let msg
132
+ try { msg = JSON.parse(trimmed) } catch { return /* 非 JSON 行静默忽略(协议健壮性) */ }
133
+ if (msg === null || typeof msg !== 'object' || !msg.method) return
134
+ if (msg.id === undefined || msg.id === null) return // notification(如 notifications/initialized)
135
+ try {
136
+ const result = await handleMessage(msg)
137
+ output.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result }) + '\n')
138
+ } catch (e) {
139
+ const err = e && typeof e === 'object' && e.code !== undefined ? e : { code: -32603, message: (e && e.message) || String(e) }
140
+ output.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, error: err }) + '\n')
141
+ }
142
+ })
143
+ return new Promise((resolve) => rl.on('close', () => resolve()))
144
+ }
@@ -10,8 +10,10 @@
10
10
  * diff 源:resolveVerifyChangedFiles(worktree-aware,与 task-review 同源);
11
11
  * module-map:.sillyspec/docs/<p>/modules/_module-map.yaml 的 modules.<id>.paths
12
12
  * (目录条目以 / 结尾按前缀匹配,与模块上下文索引读侧口径一致)。
13
+ * 声明源(plan --done 首版,2026-09-08 刀②):design 文件变更清单——代码未写、无 diff,
14
+ * 归属分类同一套 module-map 前缀匹配,仅输入来源不同。
13
15
  */
14
- import { existsSync, readFileSync, readdirSync } from 'fs'
16
+ import { existsSync, readFileSync, readdirSync, writeFileSync } from 'fs'
15
17
  import { join } from 'path'
16
18
  import { resolveVerifyChangedFiles } from './verify-postcheck.js'
17
19
  import { resolveSpecDir } from './run/shared.js'
@@ -68,11 +70,67 @@ function classifyFile(posixPath, modulePaths) {
68
70
 
69
71
  /**
70
72
  * 生成 module-impact.md 骨架。
71
- * @param {{ cwd: string, changeName: string, specDir?: string|null }} opts
73
+ * @param {{ cwd: string, changeName: string, specDir?: string|null, sourceFiles?: string[]|null, origin?: string|null }} opts
74
+ * sourceFiles:声明来源(plan --done 首版用 design 文件变更清单——此时代码未写、无 diff 可取);
75
+ * 缺省走 diff 来源(resolveVerifyChangedFiles,archive/execute 后的真实改动)。
76
+ * origin:骨架首行的生成方说明(区分 plan 首版与 CLI 命令生成,追溯来源用)。
72
77
  * @returns {{ markdown: string, matchedCount: number, unmatchedCount: number }|null}
73
- * 无 module-map / 无 diff → null(无可归类输入,agent 全手写)
78
+ * 无 module-map / 无可归类文件 → null(无可归类输入,agent 全手写)
74
79
  */
75
- export function generateModuleImpactSkeleton({ cwd, changeName, specDir = null }) {
80
+ /**
81
+ * 模块文档 sidecar 同步命令化(2026-09-09 刀批,轮次经济学 §3.1 模块文档同步项):
82
+ * quick/archive 两处 prompt 让 agent 手写 sidecar 追加行——机械部分(变更归属模块、
83
+ * sidecar 追加、卡 updated_at 戳)CLI 代算,agent 只留卡片正文语义更新。
84
+ * 幂等:sidecar 已含 `- <change> |` 行则跳过该模块;卡不存在只建 sidecar 不建卡(卡内容是语义)。
85
+ * @returns {{ synced: string[], unmatched: string[], skipped: string[] }}
86
+ */
87
+ export function syncModuleDocSidecars({ cwd, changeName, note, specDir = null }) {
88
+ const specBase = resolveSpecDir(cwd, { specDir })
89
+ // 复用 generateModuleImpactSkeleton 的 map 发现 + 归类(diff 来源)
90
+ const skel = generateModuleImpactSkeleton({ cwd, changeName, specDir })
91
+ 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])
97
+ const synced = []
98
+ const skipped = []
99
+ for (const mod of [...new Set(hitModules)]) {
100
+ const sidecarPath = join(modulesDir, `${mod}.changelog.md`)
101
+ let sidecar = ''
102
+ try { sidecar = readFileSync(sidecarPath, 'utf8') } catch { sidecar = '' }
103
+ if (sidecar.includes(`- ${changeName} |`)) { skipped.push(mod); continue }
104
+ const NL = String.fromCharCode(10)
105
+ const line = `- ${changeName} | ${note || '(见该变更 quicklog/归档)'}`
106
+ const base = sidecar
107
+ ? sidecar.replace(/\n*$/, NL)
108
+ : `# ${mod} 变更索引` + NL + NL
109
+ writeFileSync(sidecarPath, base + line + NL)
110
+ // 卡 updated_at 戳(存在才戳)
111
+ const cardPath = join(modulesDir, `${mod}.md`)
112
+ try {
113
+ const card = readFileSync(cardPath, 'utf8')
114
+ const stamped = card.replace(/^updated_at:.*$/m, `updated_at: ${new Date().toISOString().slice(0, 19) + '+08:00'}`)
115
+ if (stamped !== card) writeFileSync(cardPath, stamped)
116
+ } catch { /* 卡不存在——sidecar 照写,卡内容留给 agent */ }
117
+ synced.push(mod)
118
+ }
119
+ return { synced, unmatched, skipped }
120
+ }
121
+
122
+ function findModulesDir(specBase) {
123
+ try {
124
+ for (const p of readdirSync(join(specBase, 'docs'), { withFileTypes: true })) {
125
+ if (!p.isDirectory()) continue
126
+ const cand = join(specBase, 'docs', p.name, 'modules')
127
+ if (existsSync(join(cand, '_module-map.yaml'))) return cand
128
+ }
129
+ } catch { /* 无 docs/ */ }
130
+ return null
131
+ }
132
+
133
+ export function generateModuleImpactSkeleton({ cwd, changeName, specDir = null, sourceFiles = null, origin = null }) {
76
134
  const specBase = resolveSpecDir(cwd, { specDir })
77
135
 
78
136
  // 找 _module-map.yaml(docs/<p>/modules/;多项目取首个含 modules 索引的)
@@ -92,15 +150,27 @@ export function generateModuleImpactSkeleton({ cwd, changeName, specDir = null }
92
150
  const modulePaths = parseModuleMapPaths(readFileSync(moduleMapPath, 'utf8'))
93
151
  if (modulePaths.size === 0) return null
94
152
 
95
- // includeWorkingTree 同 verify module 子集(坑 module-subset-zero-hit-uncommitted):
96
- // worktree 未提交改动也计入模块影响面(module-impact 按实际 diff 归属模块)
97
- const diffFiles = resolveVerifyChangedFiles(cwd, changeName, null, { includeWorkingTree: true, specBase }) || []
98
- const sourceFiles = diffFiles.map(f => f.split('\\').join('/')).filter(f => !f.startsWith('.sillyspec/'))
99
- if (sourceFiles.length === 0) return null
153
+ let sourceFileList
154
+ if (sourceFiles) {
155
+ // NEW: 前缀剥离(坑 module-impact-new-prefix-mismatch,2026-09-10 驾驭小结③,用户实锤):
156
+ // design 清单待建文件带 NEW: 标记,pathMatches 比对侧早已剥(坑
157
+ // platform-sync-progress-rollback-and-db-corruption 坑3「仅在比对处剥」),本归类是同
158
+ // 语义比对(目标文件 × module-map paths)却漏剥——前缀不剥则 classifyFile 前缀匹配必
159
+ // 失配,NEW 文件全部落「未匹配文件」逼手动回填。剥后与裸路径去重(NEW: a.js 与 a.js 同文件)。
160
+ sourceFileList = [...new Set(sourceFiles
161
+ .map(f => String(f).replace(/^NEW:\s*/, '').split('\\').join('/').trim())
162
+ .filter(f => f && !f.startsWith('.sillyspec/')))]
163
+ } else {
164
+ // includeWorkingTree 同 verify module 子集(坑 module-subset-zero-hit-uncommitted):
165
+ // worktree 未提交改动也计入模块影响面(module-impact 按实际 diff 归属模块)
166
+ const diffFiles = resolveVerifyChangedFiles(cwd, changeName, null, { includeWorkingTree: true, specBase }) || []
167
+ sourceFileList = diffFiles.map(f => f.split('\\').join('/')).filter(f => !f.startsWith('.sillyspec/'))
168
+ }
169
+ if (sourceFileList.length === 0) return null
100
170
 
101
171
  const byModule = new Map()
102
172
  const unmatched = []
103
- for (const f of sourceFiles) {
173
+ for (const f of sourceFileList) {
104
174
  const mod = classifyFile(f, modulePaths)
105
175
  if (mod) {
106
176
  if (!byModule.has(mod)) byModule.set(mod, [])
@@ -111,7 +181,9 @@ export function generateModuleImpactSkeleton({ cwd, changeName, specDir = null }
111
181
  }
112
182
 
113
183
  const L = [
114
- '# 模块影响分析(骨架由 `sillyspec module-impact --change <变更名>` 生成)',
184
+ origin
185
+ ? `# 模块影响分析(骨架由 ${origin} 生成)`
186
+ : '# 模块影响分析(骨架由 `sillyspec module-impact --change <变更名>` 生成)',
115
187
  '',
116
188
  '> 文件×模块归属由 CLI 按 _module-map.yaml paths 前缀匹配预填;',
117
189
  '> **影响类型**(逻辑变更/数据结构变更/接口变更/调用关系变更/配置变更/新增)与 review 标记是语义判断,',
@@ -162,5 +234,5 @@ export function generateModuleImpactSkeleton({ cwd, changeName, specDir = null }
162
234
  L.push('规则:execute/verify 完成文档同步后把对应行回填 done;确定不同步的行改 skipped 并在操作列写明原因。')
163
235
  L.push('')
164
236
 
165
- return { markdown: L.join('\n'), matchedCount: sourceFiles.length - unmatched.length, unmatchedCount: unmatched.length }
237
+ return { markdown: L.join('\n'), matchedCount: sourceFileList.length - unmatched.length, unmatchedCount: unmatched.length }
166
238
  }
package/src/modules.js CHANGED
@@ -87,18 +87,16 @@ export async function rebuildModuleMap(cwd, { force = false } = {}) {
87
87
  }
88
88
  }
89
89
 
90
- // 解析现有 _module-map.yaml 保留已有字段(复用 parseModuleMapSimple:原内置解析只收模块名、
91
- // 从不收集列表项 → existingPaths 恒空 → --force 重建后 core_files 全丢,scan-diff scope 退化
92
- // 为全量、module-impact classifyFile 全 unmatched——注释声称的「从已有 map 保留」名存实亡)
90
+ // 解析现有 _module-map.yaml(复用 parseModuleMapSimple canonical 解析)。P2-c
91
+ //(noai-ir-roadmap §5 字段分离):existing 条目**全字段**整体保留——merge 语义下
92
+ // rebuild 只做「补缺 + 增新卡」,--force 不再可能清空 tags/main_symbols/depends_on/
93
+ // used_by/status/needs_review 等人工维护字段(旧版只保 3 个列表字段,实例 map 文件头
94
+ // 常年挂着「勿跑 rebuild --force 会清空手动维护」的警告——该警告随本变更退役)。
93
95
  let existingModules = {};
94
96
  if (existingMap) {
95
97
  const parsed = parseModuleMapSimple(existingMap);
96
98
  for (const [id, fields] of Object.entries(parsed || {})) {
97
- existingModules[id] = {
98
- core_files: Array.isArray(fields.core_files) ? fields.core_files : [],
99
- test_files: Array.isArray(fields.test_files) ? fields.test_files : [],
100
- entrypoints: Array.isArray(fields.entrypoints) ? fields.entrypoints : [],
101
- };
99
+ existingModules[id] = { ...(fields || {}) };
102
100
  }
103
101
  }
104
102
 
@@ -132,53 +130,67 @@ export async function rebuildModuleMap(cwd, { force = false } = {}) {
132
130
  ...cards.map(c => c.moduleId)
133
131
  ]);
134
132
 
133
+ // ── P2-c merge 语义发射:existing 全字段优先(人工维护不丢)→ 卡片补缺 → 骨架默认垫底 ──
134
+ const stripQuotes = (v) => String(v).replace(/^"|"$/g, '')
135
135
  for (const moduleId of allModuleIds) {
136
136
  const card = cards.find(c => c.moduleId === moduleId);
137
137
  const cardContent = card?.content || '';
138
138
 
139
139
  // 从模块卡片提取 context index 字段
140
140
  const role = extractSection(cardContent, '定位') || '';
141
- const contract = extractSection(cardContent, '契约摘要') || '';
142
- const logic = extractSection(cardContent, '关键逻辑') || '';
143
- const notes = extractSection(cardContent, '注意事项') || '';
144
141
 
145
- yaml += ` ${moduleId}:\n`;
146
- yaml += ` status: active\n`;
147
- if (card) yaml += ` doc: modules/${card.filename}\n`;
148
- else yaml += ` doc: modules/${moduleId}.md\n`;
149
- yaml += ` needs_review: false\n`;
150
- yaml += ` review_reasons: []\n`;
151
- // context index 字段(v2)
152
- if (role) yaml += ` role: "${escapeYamlString(role)}"\n`;
153
- // core_files / test_files / entrypoints 从已有 _module-map.yaml 全量保留(列表项级保真)
154
142
  const existing = existingModules[moduleId] || {};
155
- for (const [field, items] of [['core_files', existing.core_files], ['test_files', existing.test_files], ['entrypoints', existing.entrypoints]]) {
156
- if (Array.isArray(items) && items.length > 0) {
157
- yaml += ` ${field}:\n`;
158
- for (const p of items) yaml += ` - ${p}\n`;
159
- }
143
+ const merged = {
144
+ // 骨架默认(垫底)
145
+ status: 'active',
146
+ doc: `modules/${(card && card.filename) || moduleId + '.md'}`,
147
+ needs_review: false,
148
+ review_reasons: [],
149
+ risk_level: 'low',
150
+ verify_commands: [],
151
+ related_docs: [],
152
+ // existing 全字段覆盖(人工维护优先)
153
+ ...existing,
154
+ }
155
+ // 卡片补缺:role 仅当 existing 没有时从卡片取;doc 在卡片在场时以卡片文件名为准
156
+ //(existing.doc 可能指向已改名的旧文件);卡片不在场则保留 existing.doc
157
+ if (!merged.role && role) merged.role = role
158
+ if (card) merged.doc = `modules/${card.filename}`
159
+
160
+ yaml += ` ${moduleId}:\n`;
161
+ for (const k of ['status', 'doc', 'needs_review']) {
162
+ if (merged[k] !== undefined) yaml += ` ${k}: ${typeof merged[k] === 'boolean' ? merged[k] : merged[k]}\n`;
163
+ }
164
+ if (merged.role) yaml += ` role: "${escapeYamlString(stripQuotes(merged.role))}"\n`;
165
+ // 列表字段全量保真(existing 用什么字段名就保什么——paths/core_files 双形态兼容读者)
166
+ for (const k of ['paths', 'core_files', 'test_files', 'entrypoints', 'main_symbols', 'tags', 'aliases', 'depends_on', 'used_by', 'review_reasons', 'related_docs', 'verify_commands']) {
167
+ const items = merged[k];
168
+ if (items === undefined) continue;
169
+ if (Array.isArray(items) && items.length === 0) { yaml += ` ${k}: []\n`; continue }
170
+ if (!Array.isArray(items) || items.length === 0) continue;
171
+ yaml += ` ${k}:\n`;
172
+ for (const p of items) yaml += ` - ${p}\n`;
160
173
  }
161
- if (card) yaml += ` verify_commands: []\n`;
162
- yaml += ` risk_level: low\n`;
163
- if (contract || logic) yaml += ` related_docs: []\n`;
174
+ if (merged.risk_level !== undefined) yaml += ` risk_level: ${merged.risk_level}\n`;
164
175
  yaml += `\n`;
165
176
  }
166
177
 
167
- // 破坏性保护(multi-agent-platform 坑 modules-rebuild-destructive):默认 dry-run 只预览不写,--force 才覆盖。
178
+ // 写入保护(multi-agent-platform 坑 modules-rebuild-destructive):默认 dry-run 只预览不写。
179
+ // P2-c 后 --force 为 merge 语义(手动字段全量保留),但重写仍会刷新 generated_at/source_commit
180
+ // 与排版——dry-run 预览默认保留,确认写入用 --force。
168
181
  if (!force && existingMap) {
169
- console.log('⚠️ rebuild 默认不写入(破坏性保护):该命令会覆盖 _module-map.yaml,清空 tags/entrypoints/main_symbols/depends_on/used_by 等手动维护字段');
170
- console.log(' 本次为预览,未写入磁盘。确认覆盖请运行:sillyspec modules rebuild --force');
182
+ console.log('⚠️ rebuild 默认不写入(预览保护)。--force 为 merge 语义:existing 全字段保留(tags/main_symbols/depends_on/used_by/status/needs_review 等手动维护字段不丢),只补缺 + 增新卡');
183
+ console.log(' 本次为预览,未写入磁盘。确认写入请运行:sillyspec modules rebuild --force');
171
184
  return { dryRun: true, path: mapPath }
172
185
  }
173
186
 
174
187
  writeFileSync(mapPath, yaml, 'utf8');
175
- console.log(`✅ _module-map.yaml 已重建:${mapPath}`);
188
+ console.log(`✅ _module-map.yaml 已重建(merge 语义):${mapPath}`);
176
189
  console.log(` 模块数量:${allModuleIds.size}`);
177
190
  for (const id of allModuleIds) {
178
191
  console.log(` - ${id}`);
179
192
  }
180
- console.log(`\n⚠️ 注意:rebuild 只重建骨架。tags/entrypoints/main_symbols/depends_on/used_by 需要重新运行 scan 或手动补充。`);
181
- console.log(`ℹ️ schema 已升级到 v2,支持 role/core_files/test_files/entrypoints/depends_on/risk_level/verify_commands 等字段。`);
193
+ console.log(`\nℹ️ 手动维护字段(tags/entrypoints/main_symbols/depends_on/used_by/status/needs_review 等)已全量保留;rebuild 只补缺与增新卡。`);
182
194
  return { dryRun: false, path: mapPath, modules: allModuleIds.size }
183
195
  }
184
196
 
@@ -60,12 +60,17 @@ function updateSummaryWaveColumn(lines, waveOf) {
60
60
  }
61
61
 
62
62
  /**
63
- * @param {{ changeDir: string, dryRun?: boolean }} opts
63
+ * @param {{ changeDir: string, dryRun?: boolean, mode?: 'write'|'proposal' }} opts
64
+ * mode='proposal'(2026-09-09-plan-derived):只读产拓扑布局提案——渲染 Wave 段草稿与
65
+ * W 列同步结果但不落盘,供 postcheck「提案-验证-落盘」自动修复(FR-01)验证后决断写否。
64
66
  * @returns {{ ok: boolean, error?: string, waves?: string[][], planPath: string, dryRun: boolean,
65
67
  * waveBlock?: string[], tableRowsUpdated?: number, tableRowsSkipped?: number,
68
+ * planMdDraft?: string, rewritten?: boolean,
66
69
  * postcheck?: { errors: string[], warnings: string[] } }}
70
+ * planMdDraft:proposal 档下的完整 plan.md 草稿文本;rewritten:与原文比较是否有实际变更
71
+ * (幂等重排零变更 → false,调用方静默);write 档沿用既有 postcheck 字段。
67
72
  */
68
- export function adoptPlanWaves({ changeDir, dryRun = false }) {
73
+ export function adoptPlanWaves({ changeDir, dryRun = false, mode = 'write' }) {
69
74
  const planPath = join(changeDir, 'plan.md')
70
75
  if (!existsSync(planPath)) return { ok: false, error: `plan.md 不存在: ${planPath}`, planPath, dryRun }
71
76
  const tasksDir = join(changeDir, 'tasks')
@@ -108,9 +113,11 @@ export function adoptPlanWaves({ changeDir, dryRun = false }) {
108
113
 
109
114
  const { updated, skipped } = updateSummaryWaveColumn(lines, waveOf)
110
115
  const content = lines.join('\n').replace(/\n*$/, '\n')
116
+ const rewritten = content !== original.replace(/\r\n/g, '\n').replace(/\n*$/, '\n')
111
117
 
112
- if (dryRun) {
113
- return { ok: true, waves, planPath, dryRun: true, waveBlock, tableRowsUpdated: updated, tableRowsSkipped: skipped }
118
+ if (dryRun || mode === 'proposal') {
119
+ return { ok: true, waves, planPath, dryRun: true, waveBlock, tableRowsUpdated: updated, tableRowsSkipped: skipped,
120
+ planMdDraft: content, rewritten }
114
121
  }
115
122
 
116
123
  writeFileSync(planPath, content, 'utf8')
@@ -122,5 +129,6 @@ export function adoptPlanWaves({ changeDir, dryRun = false }) {
122
129
  } catch (e) {
123
130
  postcheck = { errors: [`复跑校验异常: ${e?.message || e}`], warnings: [] }
124
131
  }
125
- return { ok: true, waves, planPath, dryRun: false, waveBlock, tableRowsUpdated: updated, tableRowsSkipped: skipped, postcheck }
132
+ return { ok: true, waves, planPath, dryRun: false, waveBlock, tableRowsUpdated: updated, tableRowsSkipped: skipped,
133
+ rewritten, postcheck }
126
134
  }
@@ -46,6 +46,40 @@ export class ChangeRegistry {
46
46
  * @param {string} changeName - 变更名
47
47
  * @returns {{ current_stage: string, status: string }|null} 无该行返回 null(未注册目录桩)
48
48
  */
49
+ /**
50
+ * 读某变更某阶段的 completed_at(2026-09-08-ir-verify-facts FR-02:verifyStartAt 基准——
51
+ * evidence 分类核验的 mtime 窗口下界,取 execute 行 completed_at)。只读不抛:无行/读失败
52
+ * 返回 null(调用方走 R-05 fallback,与 getChangeCreatedAt 同容错风格)。
53
+ */
54
+ getStageCompletedAt(cwd, changeName, stage) {
55
+ try {
56
+ const db = this.pm._ensureDB(cwd);
57
+ const row = db.getDb().prepare(
58
+ `SELECT s.completed_at FROM stages s JOIN changes c ON s.change_id = c.id
59
+ WHERE c.name = ? AND s.stage = ? ORDER BY s.completed_at DESC LIMIT 1`
60
+ ).get(changeName, stage);
61
+ return row && row.completed_at ? row.completed_at : null;
62
+ } catch { return null; }
63
+ }
64
+
65
+ /**
66
+ * 读某变更某阶段的 started_at(2026-09-10 用户反馈②:evidence mtime 窗口锚点从
67
+ * execute completed_at 放宽到 started_at——证据合法产自 execute 或 verify 两窗口,
68
+ * 锚「完成时刻」会把 execute 期间产的证据判旧,逼出「先提交则 diff 空、不提交则
69
+ * mtime 旧」的时序两难)。只读不抛:无行/列空/读失败返回 null(调用方退
70
+ * getStageCompletedAt → R-05 fallback,与 getStageCompletedAt 同族容错)。
71
+ */
72
+ getStageStartedAt(cwd, changeName, stage) {
73
+ try {
74
+ const db = this.pm._ensureDB(cwd);
75
+ const row = db.getDb().prepare(
76
+ `SELECT s.started_at FROM stages s JOIN changes c ON s.change_id = c.id
77
+ WHERE c.name = ? AND s.stage = ? ORDER BY s.started_at DESC LIMIT 1`
78
+ ).get(changeName, stage);
79
+ return row && row.started_at ? row.started_at : null;
80
+ } catch { return null; }
81
+ }
82
+
49
83
  getChangeStage(cwd, changeName) {
50
84
  const db = this.pm._ensureDB(cwd);
51
85
  const sqlDb = db.getDb();
package/src/progress.js CHANGED
@@ -10,7 +10,7 @@
10
10
  * worktree-guard hook 直读 sillyspec.db,不再有 gate-status.json 缓存双源(task-10 废除)。
11
11
  */
12
12
 
13
- import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, appendFileSync, copyFileSync, readdirSync, statSync } from 'fs';
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, appendFileSync, copyFileSync, readdirSync, statSync, realpathSync } from 'fs';
14
14
  import { join, basename, dirname, resolve, sep } from 'path';
15
15
  import { tmpdir } from 'os';
16
16
  import { writeAtomicSync } from './fs-atomic.js';
@@ -25,13 +25,19 @@ import { STAGE_ORDER, MAIN_FLOW_ORDER, VALID_STAGES, STAGE_LABELS, SPEC_DIR_NAME
25
25
  // resolveSpecDir 单一真相源在 src/run/shared.js(含 home 拒绝守卫,坑 cwd-correction-home-collision
26
26
  // 根治:home 下 .sillyspec 恒不命中,防 smoke/临时目录污染自我延续)。此处 re-export 保持
27
27
  // 既有 import 路径兼容;run/shared.js 对 progress.js 只有动态 import,无静态循环。
28
- import { resolveSpecDir, checkPlatformManaged, PLATFORM_MANAGED_FILENAME, isSelfReferentialSpecRoot } from './run/shared.js';
28
+ import { resolveSpecDir, checkPlatformManaged, PLATFORM_MANAGED_FILENAME, isSelfReferentialSpecRoot, isTempResidueSpecRoot } from './run/shared.js';
29
29
  export { resolveSpecDir };
30
30
 
31
31
  // 默认规范目录名(相对于 cwd)
32
32
  // SPEC_DIR_NAME → ./progress/shared.js(W6 Step9d)
33
33
  const RUNTIME_SUBDIR = '.runtime';
34
34
 
35
+ // import() 的 pre-import 快照保留份数。这类快照只服务「刚 pull 完发现平台盖错、要退回
36
+ // pull 前」这一个场景,最新一份即可覆盖;越旧的快照 schema 可能已 bump,恢复价值随时间衰减。
37
+ // 可用 SILLYSPEC_PREIMPORT_BAK_KEEP 覆盖(灾难排查期想多留几份时)。
38
+ const PRE_IMPORT_BAK_KEEP_DEFAULT = 1;
39
+ const PRE_IMPORT_BAK_RE = /^sillyspec\.db\.pre-import-.*\.bak$/;
40
+
35
41
  /**
36
42
  * 向上查找含 .sillyspec 目录的祖先目录——实现已收敛至 src/run/shared.js(单一真相源,
37
43
  * 含 home 拒绝守卫),此处 re-export。见顶部 import 说明。
@@ -95,6 +101,18 @@ export function resolvePlatformSpecDir(cwd, explicitSpecDir = null) {
95
101
  // 指针丢失不该静默当纯本地项目处理(会建本地进度库 → 状态分裂)。
96
102
  const decl = checkPlatformManaged(cwd);
97
103
  if (decl) {
104
+ // temp 残留降级(2026-09-08 temp 投毒治理):声明 specRoot 在系统 temp 且 cwd 不在
105
+ // temp → 可证伪的测试/联调残留(真实平台 spec 根永不在 temp),与指针路径的 temp
106
+ // 警告(下方 resolvedTmp 分支)及自指声明降级(run/command.js 恢复链)同类。warn +
107
+ // 自动清理声明后按本地模式继续——不清理则每次命令重复 warn,且 disconnect 会连带
108
+ // 清 local.yaml platform 段误伤真实平台连接,不适合作为残留清理手段。
109
+ // 删除失败仍按本地继续(best effort;下次命令重复 warn 提示手动清理)。
110
+ if (isTempResidueSpecRoot(cwd, decl.specRoot)) {
111
+ const declarationPath = join(resolve(cwd), PLATFORM_MANAGED_FILENAME);
112
+ console.warn(`⚠️ 检测到 temp 残留平台接管声明(specRoot 指向系统 temp 目录:${decl.specRoot},测试/联调投毒特征),已自动清理并按本地模式运行: ${declarationPath}`);
113
+ try { unlinkSync(declarationPath) } catch { /* best effort */ }
114
+ return resolveSpecDir(cwd);
115
+ }
98
116
  throw new PlatformManagedError({
99
117
  declarationPath: join(resolve(cwd), PLATFORM_MANAGED_FILENAME),
100
118
  specRoot: decl.specRoot,
@@ -141,6 +159,14 @@ export function resolvePlatformSpecDir(cwd, explicitSpecDir = null) {
141
159
  ` 修复:sillyspec platform pointer --cleanup(删除 ${pointerPath}),或删除该 pointer 文件后回到项目目录重跑。`
142
160
  );
143
161
  }
162
+ // junction 规范化(2026-09-10 驾驭小结第五批①,用户实锤「design-init/endpoints 落盘路径
163
+ // 偶发锚到 daemon junction 副本」):daemon 写入的 pointer specRoot 可能是通向真实 spec 根的
164
+ // junction/symlink 路径——经 junction 读写与真实路径同文件(无害)但路径呈现与比对漂移、
165
+ // junction 随 daemon 生命周期可能蒸发。realpath 坍缩为真实路径(失败回退原值,保守不干预)。
166
+ try {
167
+ const canonical = realpathSync(ptr.specRoot)
168
+ if (canonical && canonical !== ptr.specRoot) return canonical
169
+ } catch { /* realpath 失败(权限/竞态删除)→ 原值(上方已过存在性检查) */ }
144
170
  return ptr.specRoot;
145
171
  }
146
172
 
@@ -613,6 +639,14 @@ export class ProgressManager {
613
639
  if (existsSync(walSidecar)) {
614
640
  try { copyFileSync(walSidecar, `${bakPath}-wal`); } catch { /* 侧车消失=已 checkpoint,无碍 */ }
615
641
  }
642
+ // 写完即回收旧快照:每次 pull / resolve --take-platform 都新增一份全库快照,无上限时
643
+ // 按同步次数无限累积(实证单仓 292 份 / 317MB)。裁剪放在写入侧而非独立 GC 命令——
644
+ // 靠人工记得跑 GC 等于没有回收。fail-open:卫生动作不能阻断 import 主流程。
645
+ try {
646
+ this._pruneImportBaks(cwd, bakPath);
647
+ } catch (e) {
648
+ console.warn(`⚠️ pre-import 快照裁剪失败(不阻断 import): ${e.message}`);
649
+ }
616
650
 
617
651
  const pushedAt = progressObj.pushed_at ?? null;
618
652
  const now = new Date().toISOString();
@@ -707,6 +741,38 @@ export class ProgressManager {
707
741
  return { ok: true, imported: cn, reason: undefined, bakPath };
708
742
  }
709
743
 
744
+ /**
745
+ * 滚动裁剪 .runtime/ 下的 pre-import 快照,只留最新 N 份(含各自 -wal 侧车)。
746
+ *
747
+ * 排序依据:文件名内嵌 ISO 时间戳(冒号/点已替换为 '-',见 import() 的 bakTs),
748
+ * 定宽且无进位歧义,字典序 == 时间序,无需 statSync(mtime 会被 copy/同步工具改写)。
749
+ *
750
+ * @param {string} cwd
751
+ * @param {string|null} keepPath - 本次刚写的快照路径,绝不裁(并发 import 互删护栏)
752
+ * @returns {number} 实际删除的快照份数
753
+ */
754
+ _pruneImportBaks(cwd, keepPath) {
755
+ let keep = Number(process.env.SILLYSPEC_PREIMPORT_BAK_KEEP);
756
+ if (!Number.isInteger(keep) || keep < 1) keep = PRE_IMPORT_BAK_KEEP_DEFAULT;
757
+
758
+ const runtimeDir = this._runtimePath(cwd);
759
+ const baks = readdirSync(runtimeDir).filter(f => PRE_IMPORT_BAK_RE.test(f)).sort();
760
+ const keepName = keepPath ? basename(keepPath) : null;
761
+ const doomed = baks.slice(0, Math.max(0, baks.length - keep)).filter(f => f !== keepName);
762
+
763
+ let removed = 0;
764
+ for (const f of doomed) {
765
+ // 单份删除失败(他进程持句柄 / 权限)不连坐其余份:跳过即可,下次 import 再收。
766
+ try { unlinkSync(join(runtimeDir, f)); } catch { continue; }
767
+ removed++;
768
+ const wal = join(runtimeDir, `${f}-wal`);
769
+ if (existsSync(wal)) {
770
+ try { unlinkSync(wal); } catch { /* 侧车残留无害:不参与恢复链,下次裁剪再收 */ }
771
+ }
772
+ }
773
+ return removed;
774
+ }
775
+
710
776
  /**
711
777
  * 本地脏度标记(D-013 / task-04):写入路径末尾更新 changes.last_local_modified_ts。
712
778
  * 在写方法的事务内调用则属于该事务(原子,失败整体回滚);import 是例外不调本方法
@@ -839,6 +905,13 @@ export class ProgressManager {
839
905
  // 单变更阶段查询(quick 轻量归档阶段闸):无行 null,读失败抛(调用方 fail-closed)
840
906
  getChangeStage(cwd, changeName) { return this._changeRegistry.getChangeStage(cwd, changeName); }
841
907
 
908
+ // verifyStartAt 基准只读(2026-09-08-ir-verify-facts):execute 行 completed_at;无行 null 走 R-05 fallback
909
+ getStageCompletedAt(cwd, changeName, stage) { return this._changeRegistry.getStageCompletedAt(cwd, changeName, stage); }
910
+
911
+ // evidence mtime 窗口锚点(2026-09-10 用户反馈②):execute 行 started_at——证据合法产自 execute
912
+ // 或 verify 两窗口,锚「完成时刻」会把 execute 期间产的证据判旧(时序两难);无行 null 走 completed_at → R-05
913
+ getStageStartedAt(cwd, changeName, stage) { return this._changeRegistry.getStageStartedAt(cwd, changeName, stage); }
914
+
842
915
  registerChange(cwd, changeName) { return this._changeRegistry.registerChange(cwd, changeName); }
843
916
 
844
917
  updateChangeIsolation(cwd, changeName, isolation) { return this._changeRegistry.updateChangeIsolation(cwd, changeName, isolation); }
@@ -84,12 +84,17 @@ export function recommendChanges({
84
84
  taskDescription = '',
85
85
  }) {
86
86
  if (!activeChanges || activeChanges.length === 0) return []
87
+ // 活跃 quick 会话过滤(2026-09-09 §7-3):quick-<hex8> 是会话不是完整流程变更——无
88
+ // design/proposal 语义可关联(冒烟实证:新 quick 被自动挂上另一活跃 quick 当关联变更)。
89
+ // 形态单源 QUICK_SID_RE(run/shared.js);此处不 import 防把 fs 纯函数拖进 shared 依赖链。
90
+ const QUICK_SESSION_ID_RE = /^quick-[0-9a-f]{8}$/
91
+ const candidateChanges = activeChanges.filter((name) => !QUICK_SESSION_ID_RE.test(name))
87
92
 
88
93
  const dirtyFiles = [...new Set([...baselineFiles, ...quickFiles])]
89
94
  .filter(f => f && !f.startsWith('.sillyspec/'))
90
95
  const descTokens = [...new Set(tokenizeDescription(taskDescription))]
91
96
 
92
- const results = activeChanges.map(name => {
97
+ const results = candidateChanges.map(name => {
93
98
  const reasons = []
94
99
 
95
100
  // 信号 1:脏文件命中 design.md 文件变更清单