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.
- package/.claude/skills/sillyspec-archive/SKILL.md +2 -0
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +1 -1
- package/.claude/skills/sillyspec-commit/SKILL.md +18 -60
- package/.claude/skills/sillyspec-continue/SKILL.md +2 -0
- package/.claude/skills/sillyspec-execute/SKILL.md +10 -4
- package/.claude/skills/sillyspec-explore/SKILL.md +10 -3
- package/.claude/skills/sillyspec-plan/SKILL.md +10 -2
- package/.claude/skills/sillyspec-quick/SKILL.md +2 -2
- package/.claude/skills/sillyspec-resume/SKILL.md +2 -0
- package/.claude/skills/sillyspec-verify/SKILL.md +4 -0
- package/.claude/skills/sillyspec-workspace/SKILL.md +7 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/packages/dashboard/server/index.js +22 -6
- package/src/change-risk-profile.js +21 -6
- package/src/commit-suggest.js +202 -0
- package/src/config-cat.js +88 -0
- package/src/db.js +14 -3
- package/src/docs-check.js +598 -534
- package/src/doctor-diagnostics.js +102 -1
- package/src/endpoint-extractor.js +21 -2
- package/src/fs-atomic.js +4 -2
- package/src/git-helper.js +73 -5
- package/src/hooks/claude-pre-tool-use.cjs +10 -5
- package/src/hooks/worktree-guard.js +42 -4
- package/src/index.js +557 -12
- package/src/init.js +1 -1
- package/src/local-register.js +98 -0
- package/src/module-impact.js +166 -0
- package/src/module-resolve.js +211 -0
- package/src/modules.js +82 -2
- package/src/progress/change-registry.js +43 -4
- package/src/progress/stage-machine.js +10 -5
- package/src/progress/step-store.js +12 -8
- package/src/progress.js +19 -3
- package/src/quicklog.js +106 -1
- package/src/run/command.js +95 -1
- package/src/run/complete-handlers.js +73 -5
- package/src/run/complete.js +47 -14
- package/src/run/gates.js +144 -17
- package/src/run/multi-repo-context.js +3 -1
- package/src/run/next.js +128 -0
- package/src/run/prompt.js +36 -4
- package/src/run/quick-audit.js +5 -2
- package/src/run/shared.js +48 -20
- package/src/run/stage.js +37 -3
- package/src/scan-postcheck.js +142 -2
- package/src/stage-contract-spec.js +1 -1
- package/src/stage-contract.js +14 -1
- package/src/stage-review.js +15 -6
- package/src/stages/archive.js +4 -2
- package/src/stages/brainstorm.js +1 -0
- package/src/stages/execute.js +275 -44
- package/src/stages/explore.js +11 -2
- package/src/stages/knowledge.js +3 -2
- package/src/stages/quick.js +18 -9
- package/src/stages/scan.js +1 -0
- package/src/stages/verify.js +16 -51
- package/src/sync.js +44 -3
- package/src/task-review.js +273 -11
- package/src/taskcard.js +52 -22
- package/src/verify-postcheck.js +223 -6
- package/src/verify-probes.js +264 -0
- package/src/workspace.js +125 -0
- package/src/worktree-apply.js +43 -11
- package/src/worktree-deps.js +59 -5
- package/src/worktree.js +128 -14
- package/templates/claude-instruction.md +1 -1
- package/templates/prompts/taskcard-rules.md +1 -1
- 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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|