sillyspec 3.26.15 → 3.27.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.claude/skills/sillyspec-explore/SKILL.md +10 -3
  2. package/.claude/skills/sillyspec-verify/SKILL.md +2 -0
  3. package/README.md +2 -2
  4. package/package.json +1 -1
  5. package/packages/dashboard/server/index.js +22 -6
  6. package/src/agent-session-log.js +763 -0
  7. package/src/change-list.js +22 -2
  8. package/src/change-risk-profile.js +21 -6
  9. package/src/contract-matrix.js +91 -7
  10. package/src/db.js +14 -3
  11. package/src/docs-check.js +598 -570
  12. package/src/doctor-diagnostics.js +53 -1
  13. package/src/endpoint-extractor.js +21 -2
  14. package/src/fs-atomic.js +4 -2
  15. package/src/git-helper.js +73 -5
  16. package/src/hooks/claude-pre-tool-use.cjs +10 -5
  17. package/src/hooks/worktree-guard.js +22 -3
  18. package/src/index.js +59 -7
  19. package/src/module-impact.js +21 -1
  20. package/src/module-resolve.js +211 -0
  21. package/src/modules.js +82 -2
  22. package/src/progress/step-store.js +12 -8
  23. package/src/progress.js +6 -2
  24. package/src/quicklog.js +11 -3
  25. package/src/run/command.js +40 -0
  26. package/src/run/complete-handlers.js +17 -2
  27. package/src/run/complete.js +81 -8
  28. package/src/run/gates.js +83 -14
  29. package/src/run/prompt.js +16 -2
  30. package/src/run/quick-audit.js +22 -2
  31. package/src/run/shared.js +70 -19
  32. package/src/run/stage.js +60 -3
  33. package/src/stage-contract.js +14 -1
  34. package/src/stages/archive.js +3 -1
  35. package/src/stages/execute.js +171 -18
  36. package/src/stages/explore.js +11 -2
  37. package/src/stages/plan-postcheck.js +3 -1
  38. package/src/stages/plan.js +3 -0
  39. package/src/stages/quick.js +4 -3
  40. package/src/stages/verify.js +9 -2
  41. package/src/sync.js +98 -3
  42. package/src/task-review.js +55 -7
  43. package/src/taskcard.js +52 -22
  44. package/src/verify-postcheck.js +223 -6
  45. package/src/worktree-apply.js +28 -10
  46. package/src/worktree-deps.js +71 -5
  47. package/src/worktree.js +70 -6
@@ -582,6 +582,57 @@ function detectExecuteProgressPlanMismatch(authoritySpecRoot, authDb) {
582
582
 
583
583
  // ── 主入口 ────────────────────────────────────────────────────────────
584
584
 
585
+ // ── 文档膨胀检测(token 成本优化 P0b/P2b,2026-08-22-token-cost-optimization)──
586
+ // 模块卡与 knowledge/uncategorized.md 是每个 task 子代理的重复读取税:实测 backend.md
587
+ // 18 天 15KB→55KB(变更索引段每变更追加、单调膨胀)、uncategorized.md 38KB 从未迁出。
588
+ // 只读软检查(WARNING 级,不阻断):超限给 safe_action 指路,不自动改文件。
589
+
590
+ /** 模块卡整卡可读软上限(字节),与 module-resolve.js MODULE_CARD_SOFT_LIMIT_BYTES 同值 */
591
+ const DOC_BLOAT_MODULE_CARD_LIMIT = 12 * 1024;
592
+ /** knowledge/uncategorized.md 软上限(字节)——待确认条目应经知识审阅迁移,不该只增不减 */
593
+ const DOC_BLOAT_UNCATEGORIZED_LIMIT = 20 * 1024;
594
+
595
+ export function detectDocBloat(specRoot) {
596
+ const dim = { name: 'doc_bloat', label: '文档膨胀(模块卡/知识库读取税)', safe_actions: [] };
597
+ const findings = [];
598
+ if (!specRoot || !existsSync(specRoot)) {
599
+ return { ...dim, findings: ['无可达 specRoot,跳过文档膨胀检测'], pass: true, severity: null };
600
+ }
601
+ // 模块卡:docs/<p>/modules/*.md(排除 _module-map.yaml 与 *.changelog.md sidecar)
602
+ try {
603
+ for (const proj of readdirSync(join(specRoot, 'docs'), { withFileTypes: true })) {
604
+ if (!proj.isDirectory()) continue;
605
+ const modulesDir = join(specRoot, 'docs', proj.name, 'modules');
606
+ if (!existsSync(modulesDir)) continue;
607
+ for (const f of readdirSync(modulesDir)) {
608
+ if (!f.endsWith('.md') || f === '_module-map.yaml' || f.endsWith('.changelog.md')) continue;
609
+ const size = statSync(join(modulesDir, f)).size;
610
+ if (size > DOC_BLOAT_MODULE_CARD_LIMIT) {
611
+ findings.push(`模块卡超软上限 ${DOC_BLOAT_MODULE_CARD_LIMIT / 1024}KB:docs/${proj.name}/modules/${f}(${(size / 1024).toFixed(1)}KB)——子代理每次整读都是读取税`);
612
+ }
613
+ }
614
+ }
615
+ } catch { /* docs 不可读 → 跳过该项 */ }
616
+ if (findings.length > 0) {
617
+ dim.safe_actions.push({ action: '跑 sillyspec modules split-changelog(先 dry-run 预览,确认后 --force)把「变更索引」历史段迁出大卡', risk: 'low' });
618
+ }
619
+ // knowledge/uncategorized.md
620
+ try {
621
+ const uncPath = join(specRoot, 'knowledge', 'uncategorized.md');
622
+ if (existsSync(uncPath)) {
623
+ const size = statSync(uncPath).size;
624
+ if (size > DOC_BLOAT_UNCATEGORIZED_LIMIT) {
625
+ findings.push(`knowledge/uncategorized.md 超软上限 ${DOC_BLOAT_UNCATEGORIZED_LIMIT / 1024}KB(${(size / 1024).toFixed(1)}KB)——已确认/已修复条目应迁入专题文件后从待确认清单删除,而非只增不减`);
626
+ dim.safe_actions.push({ action: 'execute 末步「知识库审阅」把已确认条目归档迁出 uncategorized.md(迁入专题文件后删除原条目)', risk: 'low' });
627
+ }
628
+ }
629
+ } catch { /* knowledge 不可读 → 跳过该项 */ }
630
+
631
+ dim.findings = findings;
632
+ return findings.length === 0
633
+ ? { ...dim, findings: ['模块卡与知识库体量在软限内'], pass: true, severity: CHECK_SEVERITY.PASSED }
634
+ : { ...dim, pass: false, severity: CHECK_SEVERITY.WARNING };
635
+ }
585
636
  export async function runDoctorDiagnostics({ cwd }) {
586
637
  const pointer = resolvePointer(cwd);
587
638
  const multiDb = detectMultiDb(cwd, pointer);
@@ -598,8 +649,9 @@ export async function runDoctorDiagnostics({ cwd }) {
598
649
  : null;
599
650
  const authDb = (multiDb.dbs || []).find((d) => d.role === DB_ROLE.AUTHORITY);
600
651
  const executeMismatch = detectExecuteProgressPlanMismatch(authoritySpecDir, authDb);
652
+ const docBloat = detectDocBloat(authoritySpecDir);
601
653
 
602
- const dimensions = [multiDb, pointerHealth, changesSplit, changeDb, executeMismatch];
654
+ const dimensions = [multiDb, pointerHealth, changesSplit, changeDb, executeMismatch, docBloat];
603
655
 
604
656
  return {
605
657
  dimensions,
@@ -160,8 +160,13 @@ export function extractSpringEndpoints(filePath) {
160
160
  * @returns {Array<{ method: string, path: string, source: string, line: number }>}
161
161
  */
162
162
  export function scanBackendEndpoints(dir, opts = {}) {
163
+ // 排除清单对齐 scanFrontendApiCalls(坑 parity-scan-stale-dirs:.claude/worktrees 陈旧检出
164
+ // 与各类 build 产物混入端点扫描全是噪音)——backend 侧另含 __pycache__/.gradle/maven target
163
165
  const excludePatterns = opts.excludePatterns || [
164
- /^__pycache__$/, /^node_modules$/, /^\.venv$/, /^\.git$/, /^\.gradle$/,
166
+ /^__pycache__$/, /^node_modules$/, /^\.venv$/, /^venv$/, /^\.git$/, /^\.gradle$/,
167
+ /^\.claude$/, /^\.vscode$/, /^\.idea$/, /^\.cursor$/, /^\.next$/, /^\.nuxt$/,
168
+ /^\.output$/, /^\.turbo$/, /^\.parcel-cache$/, /^coverage$/,
169
+ /^\.sillyspec$/, /^\.worktrees$/, /^\.d\.ts$/,
165
170
  /^(dist|build|target|out)$/i,
166
171
  /test/i,
167
172
  ]
@@ -273,7 +278,19 @@ export function extractFrontendApiCalls(filePath) {
273
278
  */
274
279
  export function scanFrontendApiCalls(dir, opts = {}) {
275
280
  const filePattern = opts.filePattern || /\.(ts|tsx|js|jsx)$/
276
- const excludePatterns = opts.excludePatterns || [/node_modules/, /\.next/, /dist/, /__tests__/, /\.d\.ts$/]
281
+ // 排除清单(坑 parity-scan-stale-dirs,2026-08-21 实证:扫进 .claude/worktrees/agent-* 陈旧
282
+ // 检出与 build 产物,872 条调用全是噪音)——目录名匹配,覆盖:
283
+ // 版本控制/编辑器:.git .claude(含 worktrees/agent-* 陈旧检出).vscode .idea .cursor
284
+ // 构建产物:dist build out .next .nuxt .output .turbo .parcel-cache coverage
285
+ // 依赖/运行时:node_modules .venv venv target(rust/java)__pycache__
286
+ // 本工具自身:.sillyspec .sillyspec-platform.json 所在 .worktrees
287
+ // 文件级:.d.ts 声明、*.min.js / *.bundle.js 压缩产物(正则命中即跳)
288
+ const excludePatterns = opts.excludePatterns || [
289
+ /^node_modules$/, /^\.git$/, /^\.claude$/, /^\.vscode$/, /^\.idea$/, /^\.cursor$/,
290
+ /^dist$/, /^build$/, /^out$/, /^\.next$/, /^\.nuxt$/, /^\.output$/, /^\.turbo$/,
291
+ /^\.parcel-cache$/, /^coverage$/, /^\.venv$/, /^venv$/, /^target$/, /^__pycache__$/,
292
+ /^\.sillyspec$/, /^\.worktrees$/, /^__tests__$/, /^\.d\.ts$/,
293
+ ]
277
294
 
278
295
  const results = []
279
296
  if (!existsSync(dir)) return results
@@ -285,6 +302,8 @@ export function scanFrontendApiCalls(dir, opts = {}) {
285
302
  if (excludePatterns.some(p => p.test(entry.name))) continue
286
303
  walk(full)
287
304
  } else if (entry.isFile() && filePattern.test(entry.name)) {
305
+ if (/\.d\.ts$/.test(entry.name)) continue
306
+ if (/\.min\.(js|ts)$/.test(entry.name) || /\.bundle\.(js|ts)$/.test(entry.name)) continue
288
307
  try {
289
308
  results.push(...extractFrontendApiCalls(full))
290
309
  } catch {}
package/src/fs-atomic.js CHANGED
@@ -8,6 +8,7 @@
8
8
  * 不经此处的 writeAtomicSync;sillyspec.db 不走原子写改名。
9
9
  */
10
10
  import { writeFileSync, renameSync, unlinkSync } from 'fs';
11
+ import { randomBytes } from 'node:crypto';
11
12
  import { dirname, basename, join } from 'path';
12
13
 
13
14
  // Windows 上杀毒 / 索引 / IDE 占用文件时,rename 偶发这些错误,短退避重试即可
@@ -58,8 +59,9 @@ export function renameSyncRetry(from, to, retries = 5) {
58
59
  */
59
60
  export function writeAtomicSync(filePath, content) {
60
61
  const dir = dirname(filePath);
61
- // pid + 随机段双因子:Windows PID 重用激进,两进程可能撞同 pid,tmp 名碰撞 rename 互相覆盖
62
- const rnd = Math.random().toString(36).slice(2, 10);
62
+ // pid + 随机段双因子:Windows PID 重用激进,两进程可能撞同 pid,tmp 名碰撞 rename 互相覆盖。
63
+ // crypto 随机源(Math.random 可预测,tmp 名被预猜中有 symlink 预放置理论面)
64
+ const rnd = randomBytes(6).toString('hex');
63
65
  const tmpPath = join(dir, `.${basename(filePath)}.${process.pid}.${rnd}.tmp`);
64
66
  writeFileSync(tmpPath, content);
65
67
  try {
package/src/git-helper.js CHANGED
@@ -14,6 +14,69 @@
14
14
  */
15
15
  import { execFileSync } from 'node:child_process'
16
16
 
17
+ /**
18
+ * git C 风格引号路径解码:`\\` `\"` `\n` `\t` 与 `\NNN` 八进制**原始字节**。
19
+ * 八进制转义的字节需聚合后按 UTF-8 解码(quotepath 关闭前非 ASCII 整段转义、
20
+ * 关闭后控制字符路径仍会转义),逐字节单独 toString 会拆出 U+FFFD。
21
+ * @param {string} s 已去外层引号的内容
22
+ * @returns {string}
23
+ */
24
+ export function unquoteGitPath(s) {
25
+ const parts = []
26
+ let out = ''
27
+ let bytes = []
28
+ const flushText = () => {
29
+ if (bytes.length) {
30
+ parts.push(Buffer.from(bytes).toString('utf8'))
31
+ bytes = []
32
+ }
33
+ }
34
+ for (let i = 0; i < s.length; i++) {
35
+ const ch = s[i]
36
+ if (ch !== '\\') {
37
+ out += ch
38
+ continue
39
+ }
40
+ const n = s[i + 1]
41
+ if (n === 'n' || n === 't' || n === '"' || n === '\\') {
42
+ flushText()
43
+ out += n === 'n' ? '\n' : n === 't' ? '\t' : n
44
+ i++
45
+ continue
46
+ }
47
+ const m = /^[0-7]{3}/.exec(s.slice(i + 1, i + 4))
48
+ if (m) {
49
+ if (out) {
50
+ parts.push(out)
51
+ out = ''
52
+ }
53
+ bytes.push(parseInt(m[0], 8))
54
+ i += 3
55
+ continue
56
+ }
57
+ out += ch // 未知转义保留原样
58
+ }
59
+ flushText()
60
+ if (out) parts.push(out)
61
+ return parts.join('')
62
+ }
63
+
64
+ /**
65
+ * 构造带统一 per-command 配置的 git 参数前缀。
66
+ * core.quotepath=false:非 ASCII 路径在 porcelain/diff 输出原始 UTF-8 而非 "\346\226\207" 八进制
67
+ * 转义(中文文件名经 parsePorcelainPath 的 \\(.) 解引会被拆成裸数字拼坏路径)。
68
+ */
69
+ function buildFullArgs(cwd, args) {
70
+ return ['-c', `safe.directory=${cwd}`, '-c', 'core.quotepath=false', '-C', cwd, ...args]
71
+ }
72
+
73
+ // maxBuffer:execFileSync 默认 1MB,untracked 清单/大 diff 超限抛 ENOBUFS——
74
+ // safeGit 吞成 null、调用方 `|| ''` 会把"输出很大"静默变成"输出为空"(补丁漏文件级联)。
75
+ // 32MB 对齐 verify-postcheck 的 diff 读取口径。
76
+ // 256MB(坑 apply-spawnsync-enobufs,2026-08-22 实证:大 diff binary patch 超 32MB 抛
77
+ // ENOBUFS 只能 git merge 绕行)——上限只在真输出那么大时才占用
78
+ const GIT_MAX_BUFFER = 256 * 1024 * 1024
79
+
17
80
  /**
18
81
  * 安全执行 git:失败不抛,返回 { value, error } 结构。
19
82
  * @param {string} cwd
@@ -29,12 +92,12 @@ import { execFileSync } from 'node:child_process'
29
92
  */
30
93
  export function safeGit(cwd, args, opts = {}) {
31
94
  const { trim = true, timeout = 5000, retryOnTimeout = false } = opts
32
- const fullArgs = ['-c', `safe.directory=${cwd}`, '-C', cwd, ...args]
95
+ const fullArgs = buildFullArgs(cwd, args)
33
96
  // 单次尝试:返回 { value, errorObj }(errorObj 为原始 Error 或 null),不在内部格式化 message。
34
97
  // 格式化(取首行)推迟到最终返回,避免重试分支重复格式化,并保留原始 code 供重试判定。
35
98
  const attempt = (t) => {
36
99
  try {
37
- let value = execFileSync('git', fullArgs, { encoding: 'utf8', timeout: t, stdio: ['ignore', 'pipe', 'pipe'] })
100
+ let value = execFileSync('git', fullArgs, { encoding: 'utf8', timeout: t, maxBuffer: GIT_MAX_BUFFER, stdio: ['ignore', 'pipe', 'pipe'] })
38
101
  if (trim) value = value.trim()
39
102
  return { value, errorObj: null }
40
103
  } catch (e) {
@@ -60,14 +123,16 @@ export function safeGit(cwd, args, opts = {}) {
60
123
  */
61
124
  export function git(cwd, args, opts = {}) {
62
125
  const { trim = true, timeout = 5000, encoding = 'utf8' } = opts
63
- const fullArgs = ['-c', `safe.directory=${cwd}`, '-C', cwd, ...args]
64
- const value = execFileSync('git', fullArgs, { encoding, timeout, stdio: ['ignore', 'pipe', 'pipe'] })
126
+ const fullArgs = buildFullArgs(cwd, args)
127
+ const value = execFileSync('git', fullArgs, { encoding, timeout, maxBuffer: GIT_MAX_BUFFER, stdio: ['ignore', 'pipe', 'pipe'] })
65
128
  if (Buffer.isBuffer(value)) return value
66
129
  return trim ? value.trim() : value
67
130
  }
68
131
 
69
132
  /**
70
133
  * 静默版 git:内部调 git,失败 catch 返回 null(对齐 worktree 本地 gitQuiet() 语义)。
134
+ * ENOBUFS(输出超 32MB)不是"无输出",静默成 null 会让调用方把大输出误当空清单——
135
+ * 至少 stderr 留一行,不与正常空结果同语义。
71
136
  * @param {string} cwd
72
137
  * @param {string[]} args
73
138
  * @param {{ trim?: boolean, timeout?: number }} [opts]
@@ -76,7 +141,10 @@ export function git(cwd, args, opts = {}) {
76
141
  export function gitQuiet(cwd, args, opts = {}) {
77
142
  try {
78
143
  return git(cwd, args, opts)
79
- } catch {
144
+ } catch (e) {
145
+ if (e && e.code === 'ENOBUFS') {
146
+ console.error(`[sillyspec] git 输出超过 32MB 上限被截断:git ${args.join(' ')}`)
147
+ }
80
148
  return null
81
149
  }
82
150
  }
@@ -33,16 +33,18 @@ async function main() {
33
33
  let input = ''
34
34
  try {
35
35
  input = await readStdin()
36
- } catch {
37
- // 无法读取 stdin → 放行(安全优先于阻断)
36
+ } catch (e) {
37
+ // 无法读取 stdin → 放行(安全优先于阻断),但留痕——本 hook 是阶段门禁唯一执行点,
38
+ // 静默解除全部防护会让故障无从诊断
39
+ console.error(`[sillyspec-hook] stdin 读取失败(放行): ${e.message}`)
38
40
  process.exit(0)
39
41
  }
40
42
 
41
43
  let parsed
42
44
  try {
43
45
  parsed = JSON.parse(input)
44
- } catch {
45
- // 非法 JSON → 放行
46
+ } catch (e) {
47
+ console.error(`[sillyspec-hook] stdin JSON 解析失败(放行): ${e.message};input 长度 ${input.length}`)
46
48
  process.exit(0)
47
49
  }
48
50
 
@@ -122,4 +124,7 @@ function readStdin() {
122
124
  })
123
125
  }
124
126
 
125
- main().catch(() => process.exit(0))
127
+ main().catch(e => {
128
+ console.error(`[sillyspec-hook] 未捕获异常(放行): ${e && e.message}`)
129
+ process.exit(0)
130
+ })
@@ -177,7 +177,10 @@ function readScanGuard(scanDocInfo, projectRoot) {
177
177
  if (!existsSync(p)) continue
178
178
  try {
179
179
  return JSON.parse(readFileSync(p, 'utf8'))
180
- } catch {
180
+ } catch (e) {
181
+ // 半截 JSON(写入窗口读到 truncate 中间态等)不能静默当"无 guard"放行覆盖——
182
+ // stderr 留痕便于诊断;放行语义不变(fail-open 是既定策略,见 DB 路径对照注释)
183
+ console.error(`[sillyspec hook] scan-guard.json 解析失败(${e.message.split('\n')[0]}):${p}`)
181
184
  return null
182
185
  }
183
186
  }
@@ -404,6 +407,12 @@ function parseSimpleYaml(content) {
404
407
  return trimmed
405
408
  }
406
409
 
410
+ // 原型污染防护:local.yaml 是 agent 可写输入。`__proto__:` 顶层标量行赋值被
411
+ // setter 忽略但 topKey 落在 '__proto__',随后缩进子键行经原型链读到的是
412
+ // Object.prototype(typeof 'object' 跳过重建)直接全局污染——危险键整节拒绝,
413
+ // 既不进结果也不作为子键挂载点。
414
+ const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
415
+
407
416
  for (const line of content.split('\n')) {
408
417
  const noComment = line.replace(/\s+#.*$/, '')
409
418
  const trimmed = noComment.trim()
@@ -415,6 +424,11 @@ function parseSimpleYaml(content) {
415
424
  if (!topLevelMatch) continue
416
425
  const key = topLevelMatch[1]
417
426
  const value = topLevelMatch[2]
427
+ if (UNSAFE_KEYS.has(key.trim())) {
428
+ topKey = null
429
+ childKey = null
430
+ continue
431
+ }
418
432
  topKey = key
419
433
  childKey = null
420
434
 
@@ -437,6 +451,7 @@ function parseSimpleYaml(content) {
437
451
  if (indent === 2) {
438
452
  const childMatch = trimmed.match(/^([^:]+):\s*(.*)$/)
439
453
  if (!childMatch) continue
454
+ if (UNSAFE_KEYS.has(childMatch[1].trim())) continue
440
455
  childKey = childMatch[1]
441
456
  const value = childMatch[2]
442
457
  if (typeof result[topKey] !== 'object' || Array.isArray(result[topKey])) result[topKey] = {}
@@ -500,8 +515,12 @@ function isSingleCommandReadonly(cmd, extraReadonlyCommands = []) {
500
515
  return true
501
516
  }
502
517
 
503
- // local.yaml 扩展
504
- if (extraReadonlyCommands.includes(cmdName)) return true
518
+ // local.yaml 扩展:配置面文档化(config-schema),但 local.yaml 本身是 agent 可写路径——
519
+ // 命中扩展而非内置白名单时 stderr 留痕,提醒人工复核该放行来源(防 agent 预写扩权静默生效)
520
+ if (extraReadonlyCommands.includes(cmdName)) {
521
+ console.error(`[sillyspec hook] 放行依据 = local.yaml worktreeHook.readonlyCommands 扩展(${cmdName}),非内置只读白名单`)
522
+ return true
523
+ }
505
524
 
506
525
  // git 只读子命令
507
526
  if (cmdName === 'git') {
package/src/index.js CHANGED
@@ -85,6 +85,7 @@ SillySpec CLI — 规范驱动开发工具包
85
85
  sillyspec config cat [--json] 读取 local.yaml 实际内容(自动定位真实路径,worktree 内解析到主仓)
86
86
  sillyspec runtime list [--json] 枚举 .sillyspec/.runtime/ 运行时产物(只读,看手上有哪些证据/状态文件)
87
87
  sillyspec dispatch <probe | hint> SillyHub 派发能力探测 + 策略生成(agent 调用桥,仅渲染不执行 tool)
88
+ sillyspec agent-log [--detect] [--json] 本地 agent 会话日志查询/现场探测(run 命令自动上报平台 POST /api/agent-logs 并本地留底)
88
89
 
89
90
  sillyspec workflow check <name> [--project <p>] [--change <c>] [--json] [--save]
90
91
  全局 workflow 校验(--save 归档到 .sillyspec/.runtime/workflow-runs/)
@@ -1843,6 +1844,13 @@ SillySpec worktree — git worktree 隔离管理
1843
1844
  }
1844
1845
  break;
1845
1846
  }
1847
+ case 'agent-log': {
1848
+ // 本地 agent 会话日志路径查询/探测(平台会话解析入口):读 <runtimeRoot>/agent-session-log.json
1849
+ //(run 命令在 agent 环境内自动登记),--detect 现场探测不落盘。协议见 docs/platform-agent-log-protocol.md。
1850
+ const { cmdAgentLog } = await import('./agent-session-log.js');
1851
+ await cmdAgentLog(filteredArgs.slice(1), { json, cwd: dir, specDir });
1852
+ break;
1853
+ }
1846
1854
  case 'dispatch': {
1847
1855
  // SillyHub 派发抽象层的 agent 调用桥(design.md §Phase2 / D-007@v1):
1848
1856
  // 仅做能力探测(probe)与派发策略生成(hint),**不执行任何 tool 调用**——
@@ -2209,11 +2217,12 @@ SillySpec platform — SillyHub 平台同步
2209
2217
  SillySpec taskcard — 生成 Windows 安全的 TaskCard 骨架
2210
2218
 
2211
2219
  用法:
2212
- sillyspec taskcard <change-name> --task task-01[,task-02...] [--title <t>] [--title-zh <t>] [--force]
2213
- sillyspec taskcard <change-name> --all [--force]
2220
+ sillyspec taskcard <change-name> --task task-01[,task-02...] [--title <t>] [--title-zh <t>] [--set key=value]... [--force]
2221
+ sillyspec taskcard <change-name> --all [--set key=value]... [--force]
2214
2222
 
2215
- 骨架由 CLI 直写(LF 行尾 + frontmatter 闭合 + 硬校验 9 字段齐全),标题自动取自 plan.md
2216
- checkbox 行;之后用 Edit 填充占位符即可,勿手写整卡(CRLF/漏闭合 ---/漏字段的源头治理)。
2223
+ 骨架由 CLI 直写(LF 行尾 + frontmatter 闭合 + 硬校验 9 字段齐全),标题自动取自 tasks.md
2224
+ checkbox 行;depends_on 自动反填行内注解 "(depends_on: task-01,02)";--set 批量注入 design
2225
+ 提炼的字段值(priority 等,yamlScalar 转义)。之后用 Edit 填充占位符即可,勿手写整卡。
2217
2226
  `);
2218
2227
  break;
2219
2228
  }
@@ -2230,6 +2239,20 @@ checkbox 行;之后用 Edit 填充占位符即可,勿手写整卡(CRLF/漏
2230
2239
  const taskVal = getVal('--task');
2231
2240
  const titleVal = getVal('--title');
2232
2241
  const titleZhVal = getVal('--title-zh');
2242
+ // --set key=value(可重复,坑 taskcard-design-field-conflicts):批量生成时从 design/
2243
+ // plan 提炼的字段值直接注入骨架(yamlScalar 转义、白名单键校验在 buildTaskcardSkeleton),
2244
+ // 省主代理逐卡 Edit 裁决
2245
+ const sets = {};
2246
+ for (let i = 0; i < filteredArgs.length; i++) {
2247
+ if (filteredArgs[i] === '--set' && filteredArgs[i + 1]) {
2248
+ const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(filteredArgs[i + 1]);
2249
+ if (!m) {
2250
+ console.error(`❌ --set 格式非法:${filteredArgs[i + 1]}(应为 key=value,可重复)`);
2251
+ process.exit(2);
2252
+ }
2253
+ sets[m[1]] = m[2];
2254
+ }
2255
+ }
2233
2256
 
2234
2257
  if (all && taskVal) {
2235
2258
  console.error('❌ --all 与 --task 互斥(--all 取 plan.md 全部任务,--task 显式指定)');
@@ -2255,7 +2278,7 @@ checkbox 行;之后用 Edit 填充占位符即可,勿手写整卡(CRLF/漏
2255
2278
 
2256
2279
  const { cmdTaskcard } = await import('./taskcard.js');
2257
2280
  try {
2258
- const result = cmdTaskcard(tcName, { cwd: dir, specDir, taskIds, title: titleVal, titleZh: titleZhVal, force });
2281
+ const result = cmdTaskcard(tcName, { cwd: dir, specDir, taskIds, title: titleVal, titleZh: titleZhVal, force, sets });
2259
2282
  for (const f of result.created) console.log(`✅ 已生成: ${f}`);
2260
2283
  for (const f of result.skipped) console.log(`⏭️ 已存在,跳过(--force 覆盖): ${f}`);
2261
2284
  if (result.created.length > 0) {
@@ -2431,6 +2454,12 @@ SillySpec modules — 模块文档管理
2431
2454
  sillyspec modules rebuild [--force] 从模块卡片 + 源码重建 _module-map.yaml(默认 dry-run 预览,--force 才覆盖)
2432
2455
  sillyspec modules status 显示模块索引状态
2433
2456
  sillyspec modules migrate 旧格式模块文档迁移到新格式
2457
+ sillyspec modules resolve --change <名> [--spec-dir <path>] [--json]
2458
+ per-task 模块卡级联解析(跨全部 _module-map.yaml 最长前缀匹配,
2459
+ 子项目细卡优先于根层大卡)——execute 加载上下文同源注入
2460
+ sillyspec modules split-changelog [--force]
2461
+ 把模块卡「变更索引」历史段迁出到 <module>.changelog.md sidecar
2462
+ (默认 dry-run 预览,--force 才写入;治大卡单调膨胀)
2434
2463
  `);
2435
2464
  break;
2436
2465
  }
@@ -2444,8 +2473,30 @@ SillySpec modules — 模块文档管理
2444
2473
  } else if (modulesSub === 'migrate') {
2445
2474
  const { migrateModuleDocs } = await import('./modules.js');
2446
2475
  await migrateModuleDocs(dir);
2476
+ } else if (modulesSub === 'split-changelog') {
2477
+ const { splitChangelog } = await import('./modules.js');
2478
+ const scForce = filteredArgs.includes('--force');
2479
+ splitChangelog(dir, { force: scForce });
2480
+ } else if (modulesSub === 'resolve') {
2481
+ // per-task 模块卡级联解析(token 成本优化 P0a):execute「加载上下文」步的
2482
+ // {MODULE_RESOLVE_TABLE} 注入同源;独立命令供 agent/人随时查(细卡优先,防整读大卡)
2483
+ const mrChangeIdx = args.indexOf('--change');
2484
+ const mrChange = mrChangeIdx >= 0 && args[mrChangeIdx + 1] ? args[mrChangeIdx + 1] : null;
2485
+ if (!mrChange) {
2486
+ console.error('用法: sillyspec modules resolve --change <名> [--spec-dir <path>] [--json]\n 输出 per-task 最优模块卡表(跨全部 _module-map.yaml 最长前缀匹配,细卡优先)');
2487
+ process.exit(2);
2488
+ }
2489
+ assertSafeChangeName(mrChange, '--change 变更名');
2490
+ const mrSpecBase = resolvePlatformSpecDir(dir, specDir) || join(dir, '.sillyspec');
2491
+ const { resolveChangeModuleCards, renderModuleResolveTable } = await import('./module-resolve.js');
2492
+ if (filteredArgs.includes('--json')) {
2493
+ const r = resolveChangeModuleCards({ cwd: dir, specBase: mrSpecBase, changeName: mrChange });
2494
+ console.log(JSON.stringify(r, null, 2));
2495
+ } else {
2496
+ console.log(renderModuleResolveTable({ cwd: dir, specBase: mrSpecBase, changeName: mrChange }));
2497
+ }
2447
2498
  } else {
2448
- const modSubs = ['rebuild', 'status', 'migrate'];
2499
+ const modSubs = ['rebuild', 'status', 'migrate', 'resolve', 'split-changelog'];
2449
2500
  const sug = didYouMean(modulesSub, modSubs);
2450
2501
  console.error(`❌ 未知子命令: modules ${modulesSub}`);
2451
2502
  if (sug) console.error(` 你是想输入「modules ${sug}」吗?`);
@@ -2653,6 +2704,7 @@ SillySpec config — local.yaml 配置键速查
2653
2704
  'quick-guard.json': 'quick baseline 守卫(旧位置)',
2654
2705
  'worktrees': 'worktree meta(hooks 侧)',
2655
2706
  'user-inputs.md': '步骤输出/输入历史追加日志',
2707
+ 'agent-session-log.json': '本地 agent 会话日志登记留底(run 命令自动 REST 上报平台 POST /api/agent-logs,见 docs/platform-agent-log-protocol.md)',
2656
2708
  'current-quick-run-id': '当前 quick sessionId 指针(--done 无 --change 时 fallback)',
2657
2709
  'scan-runs': '平台模式 scan/workflow 取证目录',
2658
2710
  'artifacts': '派生产物目录(init 预建)',
@@ -2690,7 +2742,7 @@ SillySpec config — local.yaml 配置键速查
2690
2742
  break;
2691
2743
  }
2692
2744
  default: {
2693
- const topCommands = ['init', 'setup', 'run', 'progress', 'worktree', 'dispatch', 'local', 'workflow', 'gate', 'derive', 'backfill-reviews', 'register-stage-review', 'modules', 'change-rename', 'knowledge', 'platform', 'scan', 'brainstorm', 'plan', 'execute', 'verify', 'archive', 'quick', 'explore', 'status', 'doctor', 'auto', 'runtime'];
2745
+ const topCommands = ['init', 'setup', 'run', 'progress', 'worktree', 'dispatch', 'agent-log', 'local', 'workflow', 'gate', 'derive', 'backfill-reviews', 'register-stage-review', 'modules', 'change-rename', 'knowledge', 'platform', 'scan', 'brainstorm', 'plan', 'execute', 'verify', 'archive', 'quick', 'explore', 'status', 'doctor', 'auto', 'runtime'];
2694
2746
  const suggestion = didYouMean(command, topCommands);
2695
2747
  console.error(`❌ 未知命令: ${command}`);
2696
2748
  if (command === '--status') {
@@ -92,7 +92,9 @@ export function generateModuleImpactSkeleton({ cwd, changeName, specDir = null }
92
92
  const modulePaths = parseModuleMapPaths(readFileSync(moduleMapPath, 'utf8'))
93
93
  if (modulePaths.size === 0) return null
94
94
 
95
- const diffFiles = resolveVerifyChangedFiles(cwd, changeName) || []
95
+ // includeWorkingTree 同 verify module 子集(坑 module-subset-zero-hit-uncommitted):
96
+ // worktree 未提交改动也计入模块影响面(module-impact 按实际 diff 归属模块)
97
+ const diffFiles = resolveVerifyChangedFiles(cwd, changeName, null, { includeWorkingTree: true }) || []
96
98
  const sourceFiles = diffFiles.map(f => f.split('\\').join('/')).filter(f => !f.startsWith('.sillyspec/'))
97
99
  if (sourceFiles.length === 0) return null
98
100
 
@@ -141,6 +143,24 @@ export function generateModuleImpactSkeleton({ cwd, changeName, specDir = null }
141
143
  L.push('')
142
144
  L.push('逻辑变更 / 数据结构变更 / 接口变更 / 调用关系变更 / 配置变更 / 新增;不确定的影响标 needs review。')
143
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('')
144
164
 
145
165
  return { markdown: L.join('\n'), matchedCount: sourceFiles.length - unmatched.length, unmatchedCount: unmatched.length }
146
166
  }