sillyspec 3.26.15 → 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-explore/SKILL.md +10 -3
- package/.claude/skills/sillyspec-verify/SKILL.md +2 -0
- 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/db.js +14 -3
- package/src/docs-check.js +598 -570
- package/src/doctor-diagnostics.js +53 -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 +22 -3
- package/src/index.js +49 -6
- package/src/module-impact.js +21 -1
- package/src/module-resolve.js +211 -0
- package/src/modules.js +82 -2
- package/src/progress/step-store.js +12 -8
- package/src/progress.js +6 -2
- package/src/run/complete-handlers.js +17 -2
- package/src/run/complete.js +43 -7
- package/src/run/gates.js +83 -14
- package/src/run/prompt.js +16 -2
- package/src/run/quick-audit.js +5 -2
- package/src/run/shared.js +46 -18
- package/src/run/stage.js +37 -3
- package/src/stage-contract.js +14 -1
- package/src/stages/archive.js +3 -1
- package/src/stages/execute.js +170 -18
- package/src/stages/explore.js +11 -2
- package/src/stages/quick.js +4 -3
- package/src/stages/verify.js +9 -2
- package/src/sync.js +44 -3
- package/src/task-review.js +55 -7
- package/src/taskcard.js +52 -22
- package/src/verify-postcheck.js +223 -6
- package/src/worktree-apply.js +28 -10
- package/src/worktree-deps.js +59 -5
- 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
|
-
|
|
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
|
+
})
|
|
@@ -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
|
-
|
|
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
|
@@ -2209,11 +2209,12 @@ SillySpec platform — SillyHub 平台同步
|
|
|
2209
2209
|
SillySpec taskcard — 生成 Windows 安全的 TaskCard 骨架
|
|
2210
2210
|
|
|
2211
2211
|
用法:
|
|
2212
|
-
sillyspec taskcard <change-name> --task task-01[,task-02...] [--title <t>] [--title-zh <t>] [--force]
|
|
2213
|
-
sillyspec taskcard <change-name> --all [--force]
|
|
2212
|
+
sillyspec taskcard <change-name> --task task-01[,task-02...] [--title <t>] [--title-zh <t>] [--set key=value]... [--force]
|
|
2213
|
+
sillyspec taskcard <change-name> --all [--set key=value]... [--force]
|
|
2214
2214
|
|
|
2215
|
-
骨架由 CLI 直写(LF 行尾 + frontmatter 闭合 + 硬校验 9 字段齐全),标题自动取自
|
|
2216
|
-
checkbox
|
|
2215
|
+
骨架由 CLI 直写(LF 行尾 + frontmatter 闭合 + 硬校验 9 字段齐全),标题自动取自 tasks.md
|
|
2216
|
+
checkbox 行;depends_on 自动反填行内注解 "(depends_on: task-01,02)";--set 批量注入 design
|
|
2217
|
+
提炼的字段值(priority 等,yamlScalar 转义)。之后用 Edit 填充占位符即可,勿手写整卡。
|
|
2217
2218
|
`);
|
|
2218
2219
|
break;
|
|
2219
2220
|
}
|
|
@@ -2230,6 +2231,20 @@ checkbox 行;之后用 Edit 填充占位符即可,勿手写整卡(CRLF/漏
|
|
|
2230
2231
|
const taskVal = getVal('--task');
|
|
2231
2232
|
const titleVal = getVal('--title');
|
|
2232
2233
|
const titleZhVal = getVal('--title-zh');
|
|
2234
|
+
// --set key=value(可重复,坑 taskcard-design-field-conflicts):批量生成时从 design/
|
|
2235
|
+
// plan 提炼的字段值直接注入骨架(yamlScalar 转义、白名单键校验在 buildTaskcardSkeleton),
|
|
2236
|
+
// 省主代理逐卡 Edit 裁决
|
|
2237
|
+
const sets = {};
|
|
2238
|
+
for (let i = 0; i < filteredArgs.length; i++) {
|
|
2239
|
+
if (filteredArgs[i] === '--set' && filteredArgs[i + 1]) {
|
|
2240
|
+
const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(filteredArgs[i + 1]);
|
|
2241
|
+
if (!m) {
|
|
2242
|
+
console.error(`❌ --set 格式非法:${filteredArgs[i + 1]}(应为 key=value,可重复)`);
|
|
2243
|
+
process.exit(2);
|
|
2244
|
+
}
|
|
2245
|
+
sets[m[1]] = m[2];
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2233
2248
|
|
|
2234
2249
|
if (all && taskVal) {
|
|
2235
2250
|
console.error('❌ --all 与 --task 互斥(--all 取 plan.md 全部任务,--task 显式指定)');
|
|
@@ -2255,7 +2270,7 @@ checkbox 行;之后用 Edit 填充占位符即可,勿手写整卡(CRLF/漏
|
|
|
2255
2270
|
|
|
2256
2271
|
const { cmdTaskcard } = await import('./taskcard.js');
|
|
2257
2272
|
try {
|
|
2258
|
-
const result = cmdTaskcard(tcName, { cwd: dir, specDir, taskIds, title: titleVal, titleZh: titleZhVal, force });
|
|
2273
|
+
const result = cmdTaskcard(tcName, { cwd: dir, specDir, taskIds, title: titleVal, titleZh: titleZhVal, force, sets });
|
|
2259
2274
|
for (const f of result.created) console.log(`✅ 已生成: ${f}`);
|
|
2260
2275
|
for (const f of result.skipped) console.log(`⏭️ 已存在,跳过(--force 覆盖): ${f}`);
|
|
2261
2276
|
if (result.created.length > 0) {
|
|
@@ -2431,6 +2446,12 @@ SillySpec modules — 模块文档管理
|
|
|
2431
2446
|
sillyspec modules rebuild [--force] 从模块卡片 + 源码重建 _module-map.yaml(默认 dry-run 预览,--force 才覆盖)
|
|
2432
2447
|
sillyspec modules status 显示模块索引状态
|
|
2433
2448
|
sillyspec modules migrate 旧格式模块文档迁移到新格式
|
|
2449
|
+
sillyspec modules resolve --change <名> [--spec-dir <path>] [--json]
|
|
2450
|
+
per-task 模块卡级联解析(跨全部 _module-map.yaml 最长前缀匹配,
|
|
2451
|
+
子项目细卡优先于根层大卡)——execute 加载上下文同源注入
|
|
2452
|
+
sillyspec modules split-changelog [--force]
|
|
2453
|
+
把模块卡「变更索引」历史段迁出到 <module>.changelog.md sidecar
|
|
2454
|
+
(默认 dry-run 预览,--force 才写入;治大卡单调膨胀)
|
|
2434
2455
|
`);
|
|
2435
2456
|
break;
|
|
2436
2457
|
}
|
|
@@ -2444,8 +2465,30 @@ SillySpec modules — 模块文档管理
|
|
|
2444
2465
|
} else if (modulesSub === 'migrate') {
|
|
2445
2466
|
const { migrateModuleDocs } = await import('./modules.js');
|
|
2446
2467
|
await migrateModuleDocs(dir);
|
|
2468
|
+
} else if (modulesSub === 'split-changelog') {
|
|
2469
|
+
const { splitChangelog } = await import('./modules.js');
|
|
2470
|
+
const scForce = filteredArgs.includes('--force');
|
|
2471
|
+
splitChangelog(dir, { force: scForce });
|
|
2472
|
+
} else if (modulesSub === 'resolve') {
|
|
2473
|
+
// per-task 模块卡级联解析(token 成本优化 P0a):execute「加载上下文」步的
|
|
2474
|
+
// {MODULE_RESOLVE_TABLE} 注入同源;独立命令供 agent/人随时查(细卡优先,防整读大卡)
|
|
2475
|
+
const mrChangeIdx = args.indexOf('--change');
|
|
2476
|
+
const mrChange = mrChangeIdx >= 0 && args[mrChangeIdx + 1] ? args[mrChangeIdx + 1] : null;
|
|
2477
|
+
if (!mrChange) {
|
|
2478
|
+
console.error('用法: sillyspec modules resolve --change <名> [--spec-dir <path>] [--json]\n 输出 per-task 最优模块卡表(跨全部 _module-map.yaml 最长前缀匹配,细卡优先)');
|
|
2479
|
+
process.exit(2);
|
|
2480
|
+
}
|
|
2481
|
+
assertSafeChangeName(mrChange, '--change 变更名');
|
|
2482
|
+
const mrSpecBase = resolvePlatformSpecDir(dir, specDir) || join(dir, '.sillyspec');
|
|
2483
|
+
const { resolveChangeModuleCards, renderModuleResolveTable } = await import('./module-resolve.js');
|
|
2484
|
+
if (filteredArgs.includes('--json')) {
|
|
2485
|
+
const r = resolveChangeModuleCards({ cwd: dir, specBase: mrSpecBase, changeName: mrChange });
|
|
2486
|
+
console.log(JSON.stringify(r, null, 2));
|
|
2487
|
+
} else {
|
|
2488
|
+
console.log(renderModuleResolveTable({ cwd: dir, specBase: mrSpecBase, changeName: mrChange }));
|
|
2489
|
+
}
|
|
2447
2490
|
} else {
|
|
2448
|
-
const modSubs = ['rebuild', 'status', 'migrate'];
|
|
2491
|
+
const modSubs = ['rebuild', 'status', 'migrate', 'resolve', 'split-changelog'];
|
|
2449
2492
|
const sug = didYouMean(modulesSub, modSubs);
|
|
2450
2493
|
console.error(`❌ 未知子命令: modules ${modulesSub}`);
|
|
2451
2494
|
if (sug) console.error(` 你是想输入「modules ${sug}」吗?`);
|
package/src/module-impact.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|