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
@@ -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,
@@ -836,6 +888,7 @@ export async function cleanupGhostChanges({ cwd, specDir = null, confirm = false
836
888
 
837
889
  let db = null;
838
890
  const archived = [];
891
+ const finalized = [];
839
892
  const errors = [];
840
893
  const skippedNonEmpty = [];
841
894
  const removedDirs = [];
@@ -850,9 +903,56 @@ export async function cleanupGhostChanges({ cwd, specDir = null, confirm = false
850
903
  if (confirm) {
851
904
  const now = new Date().toISOString();
852
905
  const stmt = db.prepare("UPDATE changes SET status = 'archived', last_active = ? WHERE name = ? AND status = 'active'");
906
+ // 终态一致化(坑 manual-archive-desync-status-only,2026-08-21 实证):手动搬目录到 archive/
907
+ // 绕过标准归档的变更在此被当幽灵归档——只翻 status 会留「已归档 + current_stage 停留 +
908
+ // 归档 0/5」的矛盾终态,平台渲染成「进度丢失」。有 archive/ 实体证据的幽灵同步收尾
909
+ // archive 阶段(current_stage/stages/steps);目录真丢失(无实体证据)保持 status-only
910
+ //(收尾=宣称归档完成属伪造,保持可逆的原语义)。步骤名取 stageRegistry 单一真相。
911
+ const { stageRegistry } = await import('./stages/index.js').catch(() => ({ stageRegistry: null }));
912
+ const archiveStepNames = Array.isArray(stageRegistry?.archive?.steps)
913
+ ? stageRegistry.archive.steps.map(s => s.name) : [];
914
+ const archiveDir = join(changesDir, 'archive');
915
+ const finalizeTerminal = (name) => {
916
+ if (archiveStepNames.length === 0) return false;
917
+ try {
918
+ const row = db.prepare('SELECT id FROM changes WHERE name = ?').get(name);
919
+ if (!row) return false;
920
+ db.prepare("UPDATE changes SET current_stage = 'archive' WHERE id = ?").run(row.id);
921
+ db.prepare(
922
+ `INSERT INTO stages (change_id, stage, status, started_at, completed_at)
923
+ VALUES (?, 'archive', 'completed', ?, ?)
924
+ ON CONFLICT(change_id, stage) DO UPDATE SET status = 'completed', completed_at = excluded.completed_at`
925
+ ).run(row.id, now, now);
926
+ const stageRow = db.prepare('SELECT id FROM stages WHERE change_id = ? AND stage = ?').get(row.id, 'archive');
927
+ if (!stageRow) return false;
928
+ db.prepare('UPDATE steps SET status = ?, completed_at = ? WHERE stage_id = ?').run('completed', now, stageRow.id);
929
+ const existing = new Set(
930
+ db.prepare('SELECT name FROM steps WHERE stage_id = ?').all(stageRow.id).map(r => r.name)
931
+ );
932
+ let order = existing.size;
933
+ const ins = db.prepare(
934
+ `INSERT INTO steps (stage_id, name, status, completed_at, ordering) VALUES (?, ?, 'completed', ?, ?)`
935
+ );
936
+ for (const sn of archiveStepNames) {
937
+ if (!existing.has(sn)) ins.run(stageRow.id, sn, now, order++)
938
+ }
939
+ return true;
940
+ } catch { return false }
941
+ };
853
942
  for (const n of ghosts) {
854
943
  try {
855
944
  stmt.run(now, n);
945
+ // 手动归档证据:changes/archive/ 下有该变更目录 → 一并收尾终态
946
+ const archivedEvidence = (() => {
947
+ try {
948
+ if (!existsSync(archiveDir)) return false;
949
+ return readdirSync(archiveDir).some(e => e === n && existsSync(join(archiveDir, e, 'plan.md')))
950
+ || readdirSync(archiveDir).some(e => e.startsWith(n) && existsSync(join(archiveDir, e, 'plan.md')));
951
+ } catch { return false }
952
+ })();
953
+ if (archivedEvidence && finalizeTerminal(n)) {
954
+ finalized.push(n);
955
+ }
856
956
  archived.push(n);
857
957
  } catch (e) {
858
958
  errors.push({ name: n, error: e.message });
@@ -886,6 +986,7 @@ export async function cleanupGhostChanges({ cwd, specDir = null, confirm = false
886
986
  skipped_nonempty: skippedNonEmpty,
887
987
  removed_dirs: removedDirs,
888
988
  archived,
989
+ finalized, // 手动归档型幽灵已同步收尾 archive 阶段终态(坑 manual-archive-desync-status-only)
889
990
  errors,
890
991
  count: ghosts.length + emptyShells.length,
891
992
  active_after: activeAfter,
@@ -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
+ })
@@ -18,7 +18,9 @@ import { execFileSync } from 'child_process'
18
18
  const WORKTREE_STAGES = ['execute'] // 这些阶段必须在 worktree 里
19
19
 
20
20
  const FILE_WHITELIST_EXTS = ['.md']
21
- const FILE_WHITELIST_NAMES = ['package.json', 'tsconfig.json', 'local.yaml', 'local.yml']
21
+ // local.yaml/local.yml 不在白名单:同名文件只允许存在于 .sillyspec/ 下(shouldBlockWrite
22
+ // 规则 0 拦 .sillyspec/ 之外的写入,2026-08-21 root-local-yaml 治理)
23
+ const FILE_WHITELIST_NAMES = ['package.json', 'tsconfig.json']
22
24
 
23
25
  /** 只读命令(命令名) */
24
26
  const READONLY_COMMANDS = new Set([
@@ -175,7 +177,10 @@ function readScanGuard(scanDocInfo, projectRoot) {
175
177
  if (!existsSync(p)) continue
176
178
  try {
177
179
  return JSON.parse(readFileSync(p, 'utf8'))
178
- } 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}`)
179
184
  return null
180
185
  }
181
186
  }
@@ -402,6 +407,12 @@ function parseSimpleYaml(content) {
402
407
  return trimmed
403
408
  }
404
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
+
405
416
  for (const line of content.split('\n')) {
406
417
  const noComment = line.replace(/\s+#.*$/, '')
407
418
  const trimmed = noComment.trim()
@@ -413,6 +424,11 @@ function parseSimpleYaml(content) {
413
424
  if (!topLevelMatch) continue
414
425
  const key = topLevelMatch[1]
415
426
  const value = topLevelMatch[2]
427
+ if (UNSAFE_KEYS.has(key.trim())) {
428
+ topKey = null
429
+ childKey = null
430
+ continue
431
+ }
416
432
  topKey = key
417
433
  childKey = null
418
434
 
@@ -435,6 +451,7 @@ function parseSimpleYaml(content) {
435
451
  if (indent === 2) {
436
452
  const childMatch = trimmed.match(/^([^:]+):\s*(.*)$/)
437
453
  if (!childMatch) continue
454
+ if (UNSAFE_KEYS.has(childMatch[1].trim())) continue
438
455
  childKey = childMatch[1]
439
456
  const value = childMatch[2]
440
457
  if (typeof result[topKey] !== 'object' || Array.isArray(result[topKey])) result[topKey] = {}
@@ -498,8 +515,12 @@ function isSingleCommandReadonly(cmd, extraReadonlyCommands = []) {
498
515
  return true
499
516
  }
500
517
 
501
- // local.yaml 扩展
502
- 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
+ }
503
524
 
504
525
  // git 只读子命令
505
526
  if (cmdName === 'git') {
@@ -681,6 +702,23 @@ export function shouldBlockWrite(filePath, cwd) {
681
702
  const projectRoot = findProjectRoot(callerCwd)
682
703
  const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(callerCwd, filePath)
683
704
 
705
+ // 0. local.yaml 位置门禁(2026-08-21 root-local-yaml 治理):local.yaml 只允许存在于
706
+ // .sillyspec/ 下。agent 按根目录惯例找/建根级 local.yaml,而 loadLocalConfig 候选链
707
+ // 真会读它 → 根级副本会遮蔽/分裂真实配置。阶段无关——任何阶段写 .sillyspec/ 之外的
708
+ // 同名文件一律拦截并指路(真实读配置走 sillyspec config cat)。
709
+ const yamlBase = path.basename(absPath)
710
+ if ((yamlBase === 'local.yaml' || yamlBase === 'local.yml')
711
+ && !absPath.split(path.sep).includes('.sillyspec')) {
712
+ return {
713
+ blocked: true,
714
+ reason: [
715
+ `local.yaml 只能存在于 .sillyspec/ 目录下(当前目标:${absPath})。`,
716
+ `真实配置:${path.join(projectRoot, '.sillyspec', 'local.yaml')}(gitignored,勿在别处建副本)。`,
717
+ '读配置:sillyspec config cat(自动定位真实路径,worktree 内也解析到主仓);键清单:sillyspec config schema。',
718
+ ].join('\n'),
719
+ }
720
+ }
721
+
684
722
  // 1. 阶段门禁(直读 sillyspec.db)
685
723
  const stage = readCurrentStage(projectRoot) || '(none)'
686
724