sillyspec 3.26.14 → 3.27.1

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 (70) hide show
  1. package/.claude/skills/sillyspec-archive/SKILL.md +2 -0
  2. package/.claude/skills/sillyspec-brainstorm/SKILL.md +1 -1
  3. package/.claude/skills/sillyspec-commit/SKILL.md +18 -60
  4. package/.claude/skills/sillyspec-continue/SKILL.md +2 -0
  5. package/.claude/skills/sillyspec-execute/SKILL.md +10 -4
  6. package/.claude/skills/sillyspec-explore/SKILL.md +10 -3
  7. package/.claude/skills/sillyspec-plan/SKILL.md +10 -2
  8. package/.claude/skills/sillyspec-quick/SKILL.md +2 -2
  9. package/.claude/skills/sillyspec-resume/SKILL.md +2 -0
  10. package/.claude/skills/sillyspec-verify/SKILL.md +4 -0
  11. package/.claude/skills/sillyspec-workspace/SKILL.md +7 -1
  12. package/README.md +1 -1
  13. package/package.json +1 -1
  14. package/packages/dashboard/server/index.js +22 -6
  15. package/src/change-risk-profile.js +21 -6
  16. package/src/commit-suggest.js +202 -0
  17. package/src/config-cat.js +88 -0
  18. package/src/db.js +14 -3
  19. package/src/docs-check.js +598 -534
  20. package/src/doctor-diagnostics.js +102 -1
  21. package/src/endpoint-extractor.js +21 -2
  22. package/src/fs-atomic.js +4 -2
  23. package/src/git-helper.js +73 -5
  24. package/src/hooks/claude-pre-tool-use.cjs +10 -5
  25. package/src/hooks/worktree-guard.js +42 -4
  26. package/src/index.js +557 -12
  27. package/src/init.js +1 -1
  28. package/src/local-register.js +98 -0
  29. package/src/module-impact.js +166 -0
  30. package/src/module-resolve.js +211 -0
  31. package/src/modules.js +82 -2
  32. package/src/progress/change-registry.js +43 -4
  33. package/src/progress/stage-machine.js +10 -5
  34. package/src/progress/step-store.js +12 -8
  35. package/src/progress.js +19 -3
  36. package/src/quicklog.js +106 -1
  37. package/src/run/command.js +95 -1
  38. package/src/run/complete-handlers.js +73 -5
  39. package/src/run/complete.js +47 -14
  40. package/src/run/gates.js +144 -17
  41. package/src/run/multi-repo-context.js +3 -1
  42. package/src/run/next.js +128 -0
  43. package/src/run/prompt.js +36 -4
  44. package/src/run/quick-audit.js +5 -2
  45. package/src/run/shared.js +48 -20
  46. package/src/run/stage.js +37 -3
  47. package/src/scan-postcheck.js +142 -2
  48. package/src/stage-contract-spec.js +1 -1
  49. package/src/stage-contract.js +14 -1
  50. package/src/stage-review.js +15 -6
  51. package/src/stages/archive.js +4 -2
  52. package/src/stages/brainstorm.js +1 -0
  53. package/src/stages/execute.js +275 -44
  54. package/src/stages/explore.js +11 -2
  55. package/src/stages/knowledge.js +3 -2
  56. package/src/stages/quick.js +18 -9
  57. package/src/stages/scan.js +1 -0
  58. package/src/stages/verify.js +16 -51
  59. package/src/sync.js +44 -3
  60. package/src/task-review.js +273 -11
  61. package/src/taskcard.js +52 -22
  62. package/src/verify-postcheck.js +223 -6
  63. package/src/verify-probes.js +264 -0
  64. package/src/workspace.js +125 -0
  65. package/src/worktree-apply.js +43 -11
  66. package/src/worktree-deps.js +59 -5
  67. package/src/worktree.js +128 -14
  68. package/templates/claude-instruction.md +1 -1
  69. package/templates/prompts/taskcard-rules.md +1 -1
  70. package/templates/prompts/verify-probes.md +13 -47
package/src/init.js CHANGED
@@ -308,7 +308,7 @@ function doInstall(projectDir, tools, subprojects = [], specDir = null, options
308
308
  mkdirSync(knowledgeDir, { recursive: true });
309
309
  const indexPath = join(knowledgeDir, 'INDEX.md');
310
310
  if (!existsSync(indexPath)) {
311
- writeFileSync(indexPath, `# Knowledge Index\n\n> 子代理任务开始前查询此文件,按关键词匹配,只读命中的知识文件。\n> execute/quick 执行中发现的坑自动追加到 uncategorized.md,经用户确认后归类到对应文件。\n\n<!-- 格式:关键词1|关键词2|关键词3 → 文件路径 -->\n<!-- 示例:mybatis-plus|分页|Page → pagination.md -->\n<!-- 示例:跨域|CORS|preflight → cors.md -->\n`);
311
+ writeFileSync(indexPath, `# Knowledge Index\n\n> 子代理任务开始前查询此文件,按关键词匹配,只读命中的知识文件。\n> 只有遇到真正的项目特有坑(跨变更可复用、未来 agent 可能再次踩到)时才追加到 uncategorized.md;不要为了完成任务硬凑条目。经用户确认后归类到对应文件并更新本索引。\n\n<!-- 格式:关键词1|关键词2|关键词3 → 文件路径 -->\n<!-- 示例:mybatis-plus|分页|Page → pagination.md -->\n<!-- 示例:跨域|CORS|preflight → cors.md -->\n`);
312
312
  }
313
313
  const uncatPath = join(knowledgeDir, 'uncategorized.md');
314
314
  if (!existsSync(uncatPath)) {
@@ -0,0 +1,98 @@
1
+ /**
2
+ * local-register.js — `sillyspec local register-repo <key> <path>` 的 repos: 段外科手术式写入。
3
+ *
4
+ * 背景(2026-08-21 agent-手工产出审计项④):跨仓 change 的 repo key 必须在 local.yaml
5
+ * `repos:` 段注册(MultiRepoContext 约束② fail-closed),此前靠 agent 手编 YAML——正是
6
+ * agent 满目录找/在根目录乱建 local.yaml 的上游。本模块只动 `repos:` 段:已有该 key 就地
7
+ * 改值,没有就插入段内;其余内容(注释/凭据段/手调配置)逐行原样保留,绝不整文件重序列化。
8
+ *
9
+ * 写入口径与 parseRepoRegistry(plan-postcheck.js,读侧)成对:
10
+ * repos:
11
+ * sillyspec: C:/Users/you/IdeaProjects/sillyspec
12
+ * # main 不用注册(隐式 = cwd)
13
+ *
14
+ * 文件不存在时新建只含 repos: 段的 minimal local.yaml(真实 local.yaml 其余段由
15
+ * `sillyspec local detect` / `platform connect` 各自补齐)。落盘走 writeAtomicSync
16
+ * (tmp+rename,防多会话并发写半截,与 local detect 写入口径一致)。
17
+ */
18
+ import { existsSync, readFileSync, mkdirSync } from 'fs'
19
+ import { dirname } from 'path'
20
+ import { writeAtomicSync } from './fs-atomic.js'
21
+
22
+ function escapeRe(s) {
23
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
24
+ }
25
+
26
+ /**
27
+ * 把 `<key>: <path>` 注册进 local.yaml 的 repos: 段(幂等,已有该 key 则改值)。
28
+ *
29
+ * @param {string} yamlPath - local.yaml 绝对路径(不存在则新建)
30
+ * @param {string} key - repo key(限 [A-Za-z0-9_.\-]+,'main' 隐式禁止注册)
31
+ * @param {string} repoPath - 跨仓仓根绝对路径(写入时统一正斜杠,与示例格式一致)
32
+ * @returns {{ fileCreated: boolean, sectionCreated: boolean, replaced: boolean }}
33
+ * @throws {Error} key 非法 / key='main' / repoPath 空 / 读写失败
34
+ */
35
+ export function registerRepoInLocalYaml(yamlPath, key, repoPath) {
36
+ if (!yamlPath) throw new Error('registerRepoInLocalYaml: yamlPath 不能为空')
37
+ if (!key || !/^[A-Za-z0-9_.\-]+$/.test(key)) {
38
+ throw new Error(`repo key 非法: "${key}"(限字母数字 . _ -,与 parseRepoRegistry 读侧口径一致)`)
39
+ }
40
+ if (key === 'main') {
41
+ throw new Error("repo key 'main' 不用注册(隐式 = 主仓 cwd),跨仓仓才需要 register-repo")
42
+ }
43
+ if (!repoPath) throw new Error('registerRepoInLocalYaml: repoPath 不能为空')
44
+
45
+ const existed = existsSync(yamlPath)
46
+ // CRLF 归一后按 LF 写回(同 task 卡锚点写入的 CRLF 坑;local.yaml 各 CLI 写入口径均 LF)
47
+ const raw = existed ? readFileSync(yamlPath, 'utf8').replace(/\r\n?/g, '\n') : ''
48
+ const posixPath = String(repoPath).replace(/\\/g, '/')
49
+ const entryLine = ` ${key}: ${posixPath}`
50
+
51
+ const write = (text) => {
52
+ mkdirSync(dirname(yamlPath), { recursive: true })
53
+ writeAtomicSync(yamlPath, text)
54
+ }
55
+
56
+ const lines = raw.split('\n')
57
+ // repos: 段起点(顶层 key 行,与 parseRepoRegistry 同口径)
58
+ let reposIdx = -1
59
+ for (let i = 0; i < lines.length; i++) {
60
+ if (/^repos:\s*(?:#.*)?$/.test(lines[i])) { reposIdx = i; break }
61
+ }
62
+
63
+ if (reposIdx === -1) {
64
+ // 无 repos: 段 → 文件尾追加(保留既有内容与注释;空文件/新文件直接起段)
65
+ const sep = raw.trim() === '' ? '' : (raw.endsWith('\n') ? '' : '\n') + '\n'
66
+ write(raw + sep + `repos:\n${entryLine}\n`)
67
+ return { fileCreated: !existed, sectionCreated: true, replaced: false }
68
+ }
69
+
70
+ // 段范围:reposIdx+1 起到下一个顶层 key(行首非空白非注释)为止(与 parseRepoRegistry 同口径)
71
+ let end = lines.length
72
+ for (let i = reposIdx + 1; i < lines.length; i++) {
73
+ const l = lines[i]
74
+ if (l.length > 0 && !/^[\s#]/.test(l) && l.trim() !== '') { end = i; break }
75
+ }
76
+
77
+ // 已有该 key → 就地改值(幂等)
78
+ const keyRe = new RegExp(`^\\s+${escapeRe(key)}:\\s`)
79
+ for (let i = reposIdx + 1; i < end; i++) {
80
+ if (keyRe.test(lines[i])) {
81
+ if (lines[i] === entryLine) {
82
+ return { fileCreated: false, sectionCreated: false, replaced: false } // 值未变,幂等跳过
83
+ }
84
+ lines[i] = entryLine
85
+ write(lines.join('\n'))
86
+ return { fileCreated: false, sectionCreated: false, replaced: true }
87
+ }
88
+ }
89
+
90
+ // 无该 key → 插到段内最后一个非空行(条目或段内注释)之后
91
+ let insertAt = reposIdx + 1
92
+ for (let i = reposIdx + 1; i < end; i++) {
93
+ if (lines[i].trim() !== '') insertAt = i + 1
94
+ }
95
+ lines.splice(insertAt, 0, entryLine)
96
+ write(lines.join('\n'))
97
+ return { fileCreated: false, sectionCreated: false, replaced: false }
98
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * module-impact.js — `sillyspec module-impact` 的归类骨架生成
3
+ * (2026-08-21 agent-手工产出审计第三批 D3)。
4
+ *
5
+ * archive 阶段的 module-impact.md 要求「模块影响矩阵」+「未匹配文件」两章节、≥20 行,
6
+ * agent 此前从零手写整份矩阵。文件 × 模块的归属是纯机械分类(module-map paths 前缀匹配,
7
+ * 与 scan diff 归属同源),影响类型判定(逻辑/数据结构/接口/调用关系/配置/新增)是语义——
8
+ * 骨架预填「模块|文件|状态」三列,影响类型列留 <!--TODO--> 由 agent 逐行填。
9
+ *
10
+ * diff 源:resolveVerifyChangedFiles(worktree-aware,与 task-review 同源);
11
+ * module-map:.sillyspec/docs/<p>/modules/_module-map.yaml 的 modules.<id>.paths
12
+ * (目录条目以 / 结尾按前缀匹配,与模块上下文索引读侧口径一致)。
13
+ */
14
+ import { existsSync, readFileSync, readdirSync } from 'fs'
15
+ import { join } from 'path'
16
+ import { resolveVerifyChangedFiles } from './verify-postcheck.js'
17
+ import { resolveSpecDir } from './run/shared.js'
18
+
19
+ /**
20
+ * 轻量解析 _module-map.yaml:modules.<id>.paths 列表(手写解析同 parseRepoRegistry 风格,
21
+ * 只取 paths 平面信息,status/doc 等忽略)。
22
+ * @returns {Map<string, string[]>} moduleId → paths(posix)
23
+ */
24
+ export function parseModuleMapPaths(yamlText) {
25
+ const map = new Map()
26
+ if (!yamlText) return map
27
+ let currentModule = null
28
+ let inPaths = false
29
+ for (const line of yamlText.split('\n')) {
30
+ if (!line.trim() || line.trim().startsWith('#')) continue
31
+ if (!/^\s/.test(line)) {
32
+ inPaths = line.startsWith('modules:')
33
+ currentModule = null
34
+ continue
35
+ }
36
+ const modMatch = line.match(/^ ([A-Za-z0-9_.\-]+):\s*$/)
37
+ if (modMatch) {
38
+ currentModule = modMatch[1]
39
+ inPaths = false
40
+ continue
41
+ }
42
+ if (currentModule && /^ paths:\s*$/.test(line)) {
43
+ inPaths = true
44
+ if (!map.has(currentModule)) map.set(currentModule, [])
45
+ continue
46
+ }
47
+ if (inPaths && currentModule) {
48
+ const item = line.match(/^ - (.+)$/)
49
+ if (item) map.get(currentModule).push(item[1].trim().replace(/\\/g, '/'))
50
+ }
51
+ }
52
+ return map
53
+ }
54
+
55
+ function classifyFile(posixPath, modulePaths) {
56
+ for (const [moduleId, paths] of modulePaths) {
57
+ for (const p of paths) {
58
+ // 目录条目以 / 结尾 → 前缀匹配;文件条目 → 精确或子路径(与模块上下文索引读侧口径一致)
59
+ if (p.endsWith('/')) {
60
+ if (posixPath.startsWith(p)) return moduleId
61
+ } else if (posixPath === p || posixPath.startsWith(p + '/')) {
62
+ return moduleId
63
+ }
64
+ }
65
+ }
66
+ return null
67
+ }
68
+
69
+ /**
70
+ * 生成 module-impact.md 骨架。
71
+ * @param {{ cwd: string, changeName: string, specDir?: string|null }} opts
72
+ * @returns {{ markdown: string, matchedCount: number, unmatchedCount: number }|null}
73
+ * 无 module-map / 无 diff → null(无可归类输入,agent 全手写)
74
+ */
75
+ export function generateModuleImpactSkeleton({ cwd, changeName, specDir = null }) {
76
+ const specBase = resolveSpecDir(cwd, { specDir })
77
+
78
+ // 找 _module-map.yaml(docs/<p>/modules/;多项目取首个含 modules 索引的)
79
+ let moduleMapPath = null
80
+ try {
81
+ for (const p of readdirSync(join(specBase, 'docs'), { withFileTypes: true })) {
82
+ if (!p.isDirectory()) continue
83
+ const candidate = join(specBase, 'docs', p.name, 'modules', '_module-map.yaml')
84
+ if (existsSync(candidate)) {
85
+ moduleMapPath = candidate
86
+ break
87
+ }
88
+ }
89
+ } catch { /* 无 docs/ */ }
90
+ if (!moduleMapPath) return null
91
+
92
+ const modulePaths = parseModuleMapPaths(readFileSync(moduleMapPath, 'utf8'))
93
+ if (modulePaths.size === 0) return null
94
+
95
+ // includeWorkingTree 同 verify module 子集(坑 module-subset-zero-hit-uncommitted):
96
+ // worktree 未提交改动也计入模块影响面(module-impact 按实际 diff 归属模块)
97
+ const diffFiles = resolveVerifyChangedFiles(cwd, changeName, null, { includeWorkingTree: true }) || []
98
+ const sourceFiles = diffFiles.map(f => f.split('\\').join('/')).filter(f => !f.startsWith('.sillyspec/'))
99
+ if (sourceFiles.length === 0) return null
100
+
101
+ const byModule = new Map()
102
+ const unmatched = []
103
+ for (const f of sourceFiles) {
104
+ const mod = classifyFile(f, modulePaths)
105
+ if (mod) {
106
+ if (!byModule.has(mod)) byModule.set(mod, [])
107
+ byModule.get(mod).push(f)
108
+ } else {
109
+ unmatched.push(f)
110
+ }
111
+ }
112
+
113
+ const L = [
114
+ '# 模块影响分析(骨架由 `sillyspec module-impact --change <变更名>` 生成)',
115
+ '',
116
+ '> 文件×模块归属由 CLI 按 _module-map.yaml paths 前缀匹配预填;',
117
+ '> **影响类型**(逻辑变更/数据结构变更/接口变更/调用关系变更/配置变更/新增)与 review 标记是语义判断,',
118
+ '> 逐行把 <!--TODO--> 替换为真实结论——以 git diff 为准(真实 > 声明)。',
119
+ '',
120
+ '## 模块影响矩阵',
121
+ '',
122
+ '| 模块 | 变更文件 | 影响类型 | 需 review |',
123
+ '|---|---|---|---|',
124
+ ]
125
+ const rows = [...byModule.entries()].sort((a, b) => a[0].localeCompare(b[0]))
126
+ for (const [moduleId, files] of rows) {
127
+ for (const f of files) {
128
+ L.push(`| ${moduleId} | \`${f}\` | <!--TODO--> | <!--TODO--> |`)
129
+ }
130
+ }
131
+ L.push('')
132
+ L.push('## 未匹配文件')
133
+ L.push('')
134
+ if (unmatched.length === 0) {
135
+ L.push('无(全部变更文件已归属到模块)。')
136
+ } else {
137
+ L.push('以下变更文件未命中 _module-map.yaml 任何模块 paths——确认是模块索引过期(该跑 `sillyspec modules rebuild`)还是真的游离文件:')
138
+ L.push('')
139
+ for (const f of unmatched) L.push(`- \`${f}\` <!--TODO: 归属判定-->`)
140
+ }
141
+ L.push('')
142
+ L.push('## 影响类型说明')
143
+ L.push('')
144
+ L.push('逻辑变更 / 数据结构变更 / 接口变更 / 调用关系变更 / 配置变更 / 新增;不确定的影响标 needs review。')
145
+ L.push('')
146
+ // 「更新结果」表骨架(2026-08-21 审查 CLI-3):verify --done 死信门(extractPendingDocSyncRows)
147
+ // 与 archive 移动前校验同一口径硬校验本表无 pending 行,但骨架此前不产出该表——agent 只能
148
+ // 从 gate 报错反推格式(plan.js prompt 自认的坑)。此处按矩阵命中模块机械生成行,agent 只回填状态。
149
+ L.push('## 更新结果')
150
+ L.push('')
151
+ L.push('| 目标 | 操作 | 状态 |')
152
+ L.push('|------|------|------|')
153
+ for (const [moduleId] of rows) {
154
+ L.push(`| \`modules/${moduleId}.md\` | 更新${moduleId}模块卡(本次变更涉及) | pending |`)
155
+ }
156
+ if (unmatched.length > 0) {
157
+ L.push('| `_module-map.yaml` | <!--TODO: 有未匹配文件,判定模块索引是否需增改(modules rebuild)--> | pending |')
158
+ } else {
159
+ L.push('| `_module-map.yaml` | 无变化(未增删模块) | skipped |')
160
+ }
161
+ L.push('')
162
+ L.push('规则:execute/verify 完成文档同步后把对应行回填 done;确定不同步的行改 skipped 并在操作列写明原因。')
163
+ L.push('')
164
+
165
+ return { markdown: L.join('\n'), matchedCount: sourceFiles.length - unmatched.length, unmatchedCount: unmatched.length }
166
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * module-resolve.js — 模块卡两级级联解析(token 成本优化 P0a,2026-08-22-token-cost-optimization)
3
+ *
4
+ * 背景(multi-agent-platform 2026-08-22 实测):monorepo 根层 _module-map.yaml 把整个子项目
5
+ * (backend/**)映射到单张大卡(backend.md 58KB),而子项目细粒度卡(docs/backend/modules/
6
+ * agent.md,~6KB)由 scan 生成却无人消费——execute 每个 task 子代理都被指引去读整张大卡,
7
+ * 单卡一项全流程烧 20 万+ tokens 输入,且大卡「变更索引」段每变更追加、单调膨胀。
8
+ *
9
+ * 本模块 = 纯机械分类(与 module-impact.js 同性质,CLI 算事实):
10
+ * - collectModuleMaps:收齐 <spec>/docs/<project>/modules/_module-map.yaml 全部层级;
11
+ * project 名恰为仓库顶层目录(monorepo 子项目,如 backend/)→ 其 paths 视为相对该
12
+ * 子目录(effective = `<project>/` + path);否则视为仓库根相对(根层粗粒度 map)
13
+ * - matchModuleCard:跨全部 map 最长有效前缀匹配(细卡 agent/** 胜过粗卡 backend/**)
14
+ * - resolveChangeModuleCards:tasks.md 注册表 × tasks/task-NN.md allowed_paths → per-task
15
+ * 最优卡表;卡的体量(字节数)一并给出,读取建议按体量分级(>12KB 按节读)
16
+ *
17
+ * 铁律(对齐仓内先例):
18
+ * - 纯读:不写任何文件,不跑 git
19
+ * - 无 map / 无 tasks.md / 无 task 卡 → 空结果(调用方注入占位说明,不抛)
20
+ * - 判定语义(哪个模块才是「对的」)仍归 agent——本表只是机械最优匹配
21
+ */
22
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs'
23
+ import { join, relative } from 'path'
24
+ import { parseModuleMapSimple } from './modules.js'
25
+ import { parseTaskRegistry } from './stages/execute.js'
26
+ import { parseAllowedPaths } from './stages/plan-postcheck.js'
27
+
28
+ /** 模块卡「整卡可读」软上限(字节)。超过则建议按节读(契约摘要/注意事项/定位),跳过历史累积段。 */
29
+ export const MODULE_CARD_SOFT_LIMIT_BYTES = 12 * 1024
30
+
31
+ /**
32
+ * 收齐 spec 下全部模块映射层。
33
+ * @param {{ cwd: string, specBase: string }} opts
34
+ * @returns {Array<{ project: string, mapPath: string, modulesDir: string, prefix: string,
35
+ * entries: Map<string, { paths: string[], doc: string|null }> }>}
36
+ * prefix:project 名为仓库顶层目录时 `<project>/`(子项目细 map),否则 ''(根层粗 map)
37
+ */
38
+ export function collectModuleMaps({ cwd, specBase }) {
39
+ const maps = []
40
+ const docsDir = join(specBase, 'docs')
41
+ if (!existsSync(docsDir)) return maps
42
+ let projects = []
43
+ try {
44
+ projects = readdirSync(docsDir, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name)
45
+ } catch { return maps }
46
+ for (const project of projects) {
47
+ const modulesDir = join(docsDir, project, 'modules')
48
+ const mapPath = join(modulesDir, '_module-map.yaml')
49
+ if (!existsSync(mapPath)) continue
50
+ let parsed = {}
51
+ try {
52
+ parsed = parseModuleMapSimple(readFileSync(mapPath, 'utf8'))
53
+ } catch { continue }
54
+ const entries = new Map()
55
+ for (const [id, data] of Object.entries(parsed)) {
56
+ const paths = Array.isArray(data?.paths) ? data.paths : []
57
+ if (paths.length === 0) continue
58
+ entries.set(id, { paths, doc: typeof data?.doc === 'string' ? data.doc : null })
59
+ }
60
+ if (entries.size === 0) continue
61
+ // 子项目判定:project 名恰为仓库顶层目录 → map 的 paths 相对该子目录(monorepo 细 map)。
62
+ // 根层 map(project = 仓名)paths 本就仓库根相对,prefix=''。
63
+ let prefix = ''
64
+ try {
65
+ const topDir = join(cwd, project)
66
+ if (existsSync(topDir) && statSync(topDir).isDirectory()) prefix = `${project}/`
67
+ } catch { /* stat 失败按根层处理 */ }
68
+ maps.push({ project, mapPath, modulesDir, prefix, entries })
69
+ }
70
+ return maps
71
+ }
72
+
73
+ /**
74
+ * 路径归一:反斜杠→正斜杠,剥尾部 glob(/** 与 /*)与目录斜杠。
75
+ * `backend/**` / `backend/` / `backend` → `backend`。
76
+ */
77
+ function normalizeMapPath(raw) {
78
+ return String(raw).replace(/\\/g, '/').replace(/\/\*\*$/, '').replace(/\/\*$/, '').replace(/\/$/, '')
79
+ }
80
+
81
+ /**
82
+ * 跨全部 map 最长有效前缀匹配一个仓库根相对路径。
83
+ * @param {string} posixPath 仓库根相对 posix 路径
84
+ * @param {Array} maps collectModuleMaps 产物
85
+ * @returns {{ moduleId: string, map: object, effectivePrefix: string }|null}
86
+ */
87
+ export function matchModuleCard(posixPath, maps) {
88
+ const p = String(posixPath).replace(/\\/g, '/')
89
+ let best = null
90
+ for (const map of maps) {
91
+ for (const [moduleId, entry] of map.entries) {
92
+ for (const raw of entry.paths) {
93
+ const effective = (map.prefix + normalizeMapPath(raw)).replace(/\/+$/, '')
94
+ if (!effective) continue
95
+ if (p === effective || p.startsWith(effective + '/')) {
96
+ if (!best || effective.length > best.effectivePrefix.length) {
97
+ best = { moduleId, map, effectivePrefix: effective }
98
+ }
99
+ }
100
+ }
101
+ }
102
+ }
103
+ return best
104
+ }
105
+
106
+ /**
107
+ * 解析变更全部 task 的最优模块卡。
108
+ * @param {{ cwd: string, specBase: string, changeName: string }} opts
109
+ * @returns {{ hasMaps: boolean, rows: Array<{ taskId: string, taskName: string,
110
+ * moduleId: string|null, granularity: 'fine'|'coarse'|null,
111
+ * cardPath: string|null, cardBytes: number, advice: string,
112
+ * matchedCount: number, pathCount: number }> }}
113
+ * granularity:按胜者有效前缀段数判——1 段(整子项目)=coarse 大卡,≥2 段=fine 细卡
114
+ */
115
+ export function resolveChangeModuleCards({ cwd, specBase, changeName }) {
116
+ const maps = collectModuleMaps({ cwd, specBase })
117
+ const changeDir = join(specBase, 'changes', changeName || '')
118
+ let registry = []
119
+ const tasksMd = join(changeDir, 'tasks.md')
120
+ if (existsSync(tasksMd)) {
121
+ registry = parseTaskRegistry(readFileSync(tasksMd, 'utf8'))
122
+ }
123
+ const rows = []
124
+ for (const task of registry) {
125
+ // 任务路径源:task 卡 allowed_paths(主)> 注册表行内 (文件路径) 尾注(兜底)
126
+ let paths = []
127
+ const cardFile = join(changeDir, 'tasks', `${task.id}.md`)
128
+ if (existsSync(cardFile)) {
129
+ paths = parseAllowedPaths(readFileSync(cardFile, 'utf8'))
130
+ }
131
+ if (paths.length === 0 && task.file) paths = [task.file]
132
+ paths = paths.map(p => p.replace(/\\/g, '/')).filter(Boolean)
133
+
134
+ // 全路径逐个匹配,取「最长有效前缀」的模块为该 task 的最优卡。粒度按有效前缀段数判:
135
+ // 1 段(backend/frontend 整子项目)=粗层大卡;≥2 段=细层卡(段数与卡体量正相关,
136
+ // SillyHub 这类根相对细 map 同样正确——不能按 map 的 prefix 有无判,会误标)
137
+ let winner = null
138
+ let matchedCount = 0
139
+ for (const p of paths) {
140
+ const m = matchModuleCard(p, maps)
141
+ if (!m) continue
142
+ matchedCount++
143
+ if (!winner || m.effectivePrefix.length > winner.effectivePrefix.length) winner = m
144
+ }
145
+ let granularity = null
146
+ if (winner) {
147
+ const segs = winner.effectivePrefix.split('/').filter(Boolean).length
148
+ granularity = segs >= 2 ? 'fine' : 'coarse'
149
+ }
150
+
151
+ let cardPath = null
152
+ let cardBytes = 0
153
+ let advice = '—'
154
+ if (winner) {
155
+ const docRel = winner.map.entries.get(winner.moduleId)?.doc || `modules/${winner.moduleId}.md`
156
+ const abs = join(winner.map.modulesDir, '..', docRel)
157
+ if (existsSync(abs)) {
158
+ const rel = relative(cwd, abs).replace(/\\/g, '/')
159
+ cardPath = rel.startsWith('..') ? abs.replace(/\\/g, '/') : rel
160
+ try { cardBytes = statSync(abs).size } catch { cardBytes = 0 }
161
+ advice = cardBytes > MODULE_CARD_SOFT_LIMIT_BYTES
162
+ ? `按节读(契约摘要/注意事项/定位;跳过变更索引/人工备注)`
163
+ : '整卡可读'
164
+ } else {
165
+ cardPath = relative(cwd, abs).replace(/\\/g, '/')
166
+ advice = '卡文件缺失(modules rebuild?)'
167
+ }
168
+ }
169
+ rows.push({
170
+ taskId: task.id,
171
+ taskName: task.name,
172
+ moduleId: winner ? winner.moduleId : null,
173
+ granularity,
174
+ cardPath,
175
+ cardBytes,
176
+ advice,
177
+ matchedCount,
178
+ pathCount: paths.length,
179
+ })
180
+ }
181
+ return { hasMaps: maps.length > 0, rows }
182
+ }
183
+
184
+ /**
185
+ * 渲染 per-task 模块卡表(注入 execute「加载上下文」步 prompt 用)。
186
+ * 无 map → 提示跳过;有 map 无命中行 → 单行说明。恒返回非空字符串(占位符必被替换)。
187
+ */
188
+ export function renderModuleResolveTable({ cwd, specBase, changeName }) {
189
+ const { hasMaps, rows } = resolveChangeModuleCards({ cwd, specBase, changeName })
190
+ if (!hasMaps) return '(未找到 _module-map.yaml——本项目无模块卡,跳过模块文档加载)'
191
+ const withMatch = rows.filter(r => r.moduleId)
192
+ if (withMatch.length === 0) {
193
+ return '(已找到 _module-map.yaml,但当前变更任务未命中任何模块 paths——跳过模块文档加载;游离文件可在 Wave 收尾后跑 `sillyspec modules rebuild`)'
194
+ }
195
+ const kb = (n) => n > 0 ? `${(n / 1024).toFixed(1)}KB` : '?'
196
+ const L = [
197
+ '| task | 模块 | 粒度 | 模块卡(只读命中的卡,勿按目录整读) | 体量 | 读取建议 |',
198
+ '|---|---|---|---|---|---|',
199
+ ]
200
+ for (const r of rows) {
201
+ if (r.moduleId) {
202
+ L.push(`| ${r.taskId} | ${r.moduleId} | ${r.granularity === 'fine' ? '细' : '粗'} | \`${r.cardPath}\` | ${kb(r.cardBytes)} | ${r.advice} |`)
203
+ } else {
204
+ L.push(`| ${r.taskId} | — | — | (无命中,跳过模块文档) | — | — |`)
205
+ }
206
+ }
207
+ L.push('')
208
+ L.push('> 匹配规则:全部 _module-map.yaml 跨层最长前缀(子项目细卡优先于根层大卡),源=tasks 卡 allowed_paths。')
209
+ L.push('> 细卡整卡可读;仅粗卡命中且体量大时**按节读**——只读「契约摘要/注意事项/定位」,跳过「变更索引/人工备注」历史累积段。')
210
+ return L.join('\n')
211
+ }
package/src/modules.js CHANGED
@@ -181,11 +181,91 @@ export async function rebuildModuleMap(cwd, { force = false } = {}) {
181
181
  return { dryRun: false, path: mapPath, modules: allModuleIds.size }
182
182
  }
183
183
 
184
+ /**
185
+ * split-changelog:把模块卡的「变更索引」历史段迁出到同目录 <module>.changelog.md sidecar
186
+ * (token 成本优化 P0b,2026-08-22-token-cost-optimization)。
187
+ *
188
+ * 背景:变更索引段每变更追加、单调膨胀(multi-agent-platform backend.md 实测 19.9KB/占整卡
189
+ * 36%,18 天 3.6 倍),而子代理实现新 task 只需要卡的「定位/契约摘要/注意事项」当前态——
190
+ * 历史段是纯读取税。迁出后卡只留指针行,新条目由 prompt 约定直接追加 sidecar。
191
+ *
192
+ * 安全(对齐 rebuild 的破坏性保护):默认 dry-run 只预览不写,--force 才落盘。
193
+ * 只动「## 变更索引」一节,卡内其他节原样保留;sidecar 已存在则追加(幂等可重跑)。
194
+ *
195
+ * @param {string} cwd 项目根
196
+ * @param {{ force?: boolean }} opts
197
+ * @returns {{ dryRun: boolean, files: Array<{ card: string, sidecar: string, bytesMoved: number }>, skipped: number }}
198
+ */
199
+ export function splitChangelog(cwd, { force = false } = {}) {
200
+ const docsDir = join(cwd, '.sillyspec', 'docs');
201
+ const results = [];
202
+ let skipped = 0;
203
+ const projects = existsSync(docsDir)
204
+ ? readdirSync(docsDir, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name)
205
+ : [];
206
+
207
+ for (const project of projects) {
208
+ const modulesDir = join(docsDir, project, 'modules');
209
+ if (!existsSync(modulesDir)) continue;
210
+ const cards = readdirSync(modulesDir).filter(f => f.endsWith('.md') && f !== '_module-map.yaml');
211
+ for (const f of cards) {
212
+ const cardPath = join(modulesDir, f);
213
+ const content = readFileSync(cardPath, 'utf8');
214
+ // 提取「## 变更索引」节(CRLF 归一后行级匹配,与 modules.js 其余解析同防线)
215
+ const lines = content.replace(/\r\n/g, '\n').split('\n');
216
+ const startIdx = lines.findIndex(l => /^##\s*变更索引\s*$/.test(l));
217
+ if (startIdx < 0) { skipped++; continue; }
218
+ let endIdx = lines.length;
219
+ for (let i = startIdx + 1; i < lines.length; i++) {
220
+ if (/^##\s/.test(lines[i])) { endIdx = i; break; }
221
+ }
222
+ const body = lines.slice(startIdx + 1, endIdx).join('\n').trim();
223
+ // 已迁出(只剩指针行)或本就为空 → 跳过
224
+ if (!body || /^见\s/.test(body) || body.includes('.changelog.md')) { skipped++; continue; }
225
+ const bytesMoved = Buffer.byteLength(body, 'utf8');
226
+ const stem = f.replace(/\.md$/, '');
227
+ const sidecarPath = join(modulesDir, `${stem}.changelog.md`);
228
+ results.push({ card: cardPath, sidecar: sidecarPath, bytesMoved, _lines: lines, _startIdx: startIdx, _endIdx: endIdx, _body: body });
229
+ }
230
+ }
231
+
232
+ if (!force) {
233
+ if (results.length === 0) {
234
+ console.log('📭 没有需要迁出的「变更索引」段(无模块卡 / 已迁出 / 段为空)');
235
+ } else {
236
+ console.log('⚠️ split-changelog 默认不写入(破坏性保护):会把模块卡「变更索引」节迁出到同目录 <module>.changelog.md');
237
+ for (const r of results) {
238
+ console.log(` ${r.card} → ${r.sidecar}(${(r.bytesMoved / 1024).toFixed(1)}KB)`);
239
+ }
240
+ console.log(' 本次为预览,未写入磁盘。确认迁出请运行:sillyspec modules split-changelog --force');
241
+ }
242
+ return { dryRun: true, files: results.map(({ card, sidecar, bytesMoved }) => ({ card, sidecar, bytesMoved })), skipped };
243
+ }
244
+
245
+ for (const r of results) {
246
+ // sidecar:已存在追加(分隔线 + 迁出来源),否则新建带头部
247
+ const header = `# ${r.card.split(/[\\/]/).pop().replace(/\.md$/, '')} 变更索引\n\n> 由 sillyspec modules split-changelog 自模块卡迁出;新条目继续追加到本文件,勿写回模块卡。\n`;
248
+ let sidecarContent = header;
249
+ if (existsSync(r.sidecar)) {
250
+ sidecarContent = readFileSync(r.sidecar, 'utf8').replace(/\s*$/, '') + '\n\n---\n\n' + r._body + '\n';
251
+ } else {
252
+ sidecarContent = header + '\n' + r._body + '\n';
253
+ }
254
+ writeFileSync(r.sidecar, sidecarContent, 'utf8');
255
+ // 卡内节体替换为指针行(只动该节,其余逐字保留;_lines 已是 LF 归一)
256
+ const stem = r.card.split(/[\\/]/).pop().replace(/\.md$/, '');
257
+ const newLines = [...r._lines.slice(0, r._startIdx + 1), '', `见 \`${stem}.changelog.md\`——历史条目已迁出;新条目直接追加 sidecar,勿写回本卡。`, '', ...r._lines.slice(r._endIdx)];
258
+ writeFileSync(r.card, newLines.join('\n'), 'utf8');
259
+ console.log(`✅ ${r.card} → ${r.sidecar}(迁出 ${(r.bytesMoved / 1024).toFixed(1)}KB,卡内留指针)`);
260
+ }
261
+ if (results.length === 0) console.log('📭 没有需要迁出的「变更索引」段');
262
+ return { dryRun: false, files: results.map(({ card, sidecar, bytesMoved }) => ({ card, sidecar, bytesMoved })), skipped };
263
+ }
264
+
184
265
  /**
185
266
  * status: 显示模块索引状态
186
267
  */
187
- export async function showModuleStatus(cwd) {
188
- const mapPath = findModuleMapPath(cwd);
268
+ export async function showModuleStatus(cwd) { const mapPath = findModuleMapPath(cwd);
189
269
  if (!mapPath || !existsSync(mapPath)) {
190
270
  console.log('📭 未找到 _module-map.yaml');
191
271
  console.log(' 提示:先运行 sillyspec run scan 生成模块文档');
@@ -276,8 +276,15 @@ export class ChangeRegistry {
276
276
  /**
277
277
  * 从活跃列表移除变更(归档时调用,不物理删除)
278
278
  * SQL: UPDATE changes SET status = 'archived'
279
+ *
280
+ * 归档终态一致化(坑 manual-archive-desync-status-only,2026-08-21 实证):手动搬目录绕过
281
+ * `run archive --done --confirm` 后,自愈/幽灵清理路径只改 status 一个字段,留下「已归档 +
282
+ * current_stage 停在 execute + 归档 0/5 步」的自相矛盾终态,推送平台后渲染成「进度丢失」。
283
+ * opts.archiveStepNames(调用方取自 stageRegistry 单一真相)给定时同事务收尾:
284
+ * current_stage='archive' + stages.archive=completed + 其步骤行全 completed
285
+ * (缺行按定义补种——平台按步骤数展示完成度,零行会显示 0/5)。
279
286
  */
280
- unregisterChange(cwd, changeName) {
287
+ unregisterChange(cwd, changeName, opts = {}) {
281
288
  if (!changeName) {
282
289
  console.warn('⚠️ unregisterChange: changeName 为空,跳过');
283
290
  return;
@@ -286,9 +293,41 @@ export class ChangeRegistry {
286
293
  db.transaction(() => {
287
294
  const sqlDb = db.getDb();
288
295
  const now = new Date().toISOString();
289
- sqlDb.prepare(
290
- `UPDATE changes SET status = 'archived', last_active = ? WHERE name = ?`
291
- ).run(now, changeName);
296
+ let sql = `UPDATE changes SET status = 'archived', last_active = ?`;
297
+ const params = [now];
298
+ if (Array.isArray(opts.archiveStepNames)) sql += `, current_stage = 'archive'`;
299
+ sql += ` WHERE name = ?`;
300
+ params.push(changeName);
301
+ sqlDb.prepare(sql).run(...params);
302
+ if (Array.isArray(opts.archiveStepNames)) {
303
+ const row = sqlDb.prepare('SELECT id FROM changes WHERE name = ?').get(changeName);
304
+ if (row) {
305
+ sqlDb.prepare(
306
+ `INSERT INTO stages (change_id, stage, status, started_at, completed_at)
307
+ VALUES (?, 'archive', 'completed', ?, ?)
308
+ ON CONFLICT(change_id, stage) DO UPDATE SET status = 'completed', completed_at = excluded.completed_at`
309
+ ).run(row.id, now, now);
310
+ const stageRow = sqlDb.prepare(
311
+ 'SELECT id FROM stages WHERE change_id = ? AND stage = ?'
312
+ ).get(row.id, 'archive');
313
+ if (stageRow) {
314
+ // 现有步骤行(含 pending/waiting)全收尾;定义里有而行里没有的(阶段从未初始化)按定义补种
315
+ sqlDb.prepare(
316
+ `UPDATE steps SET status = 'completed', completed_at = ? WHERE stage_id = ?`
317
+ ).run(now, stageRow.id);
318
+ const existing = new Set(
319
+ sqlDb.prepare('SELECT name FROM steps WHERE stage_id = ?').all(stageRow.id).map(r => r.name)
320
+ );
321
+ let order = existing.size;
322
+ const ins = sqlDb.prepare(
323
+ `INSERT INTO steps (stage_id, name, status, completed_at, ordering) VALUES (?, ?, 'completed', ?, ?)`
324
+ );
325
+ for (const name of opts.archiveStepNames) {
326
+ if (!existing.has(name)) { ins.run(stageRow.id, name, now, order++) }
327
+ }
328
+ }
329
+ }
330
+ }
292
331
  // 本地脏度(D-013 / task-04):归档也是本地状态推进
293
332
  this.pm._touchLocalModified(cwd, changeName, now);
294
333
  });