sillyspec 3.32.5 → 3.32.7
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/AGENTS.md +2 -0
- package/package.json +1 -1
- package/src/config-schema.js +20 -0
- package/src/doctor-diagnostics.js +61 -1
- package/src/flow-parity.js +215 -4
- package/src/flow-review.js +75 -10
- package/src/flow.js +297 -55
- package/src/index.js +94 -1
- package/src/local-register.js +20 -1
- package/src/machine-interface.js +10 -5
- package/src/patch-reanchor.js +131 -0
- package/src/review-dispatch.js +18 -3
- package/src/review-probes.js +161 -0
- package/src/run/complete-handlers.js +48 -15
- package/src/run/gates.js +17 -2
- package/src/run/shared.js +14 -0
- package/src/stage-contract.js +2 -2
- package/src/stage-review.js +67 -1
- package/src/task-tick.js +43 -2
- package/src/verify-probes.js +1 -1
- package/src/worktree-apply.js +22 -0
- package/src/worktree-cross.js +158 -9
package/src/index.js
CHANGED
|
@@ -85,6 +85,9 @@ SillySpec CLI — 规范驱动开发工具包
|
|
|
85
85
|
cleanup <change> [--force] 清理 worktree
|
|
86
86
|
doctor [--fix] [--stale-hours N] 健康检查 + 修复
|
|
87
87
|
--cleanup-ghosts [--confirm] 归档幽灵 active 记录(db 有行无目录 / 空壳目录 0 文件超 7 天,SS-2/2b)
|
|
88
|
+
--prune-cross-worktrees [--confirm] 清跨仓仓幽灵 worktree 注册(目录已删注册残留——dry-run 预览,--confirm 执行 prune;locked 条目只提示)
|
|
89
|
+
|
|
90
|
+
sillyspec patch-reanchor --change <名> 归档收口留痕锚点回写主仓(worktree 收口后 apply 重放致 baseline/head 悬空的修复;幂等,doctor 检出时指路至此)
|
|
88
91
|
|
|
89
92
|
sillyspec local detect [--dir <path>] 生成本地配置 local.yaml(纯 fs 嗅探,零 token、不跑 scan)
|
|
90
93
|
sillyspec local register-repo <key> <path> 注册跨仓仓到 local.yaml repos: 段(外科手术式写入,其余内容保留)
|
|
@@ -410,6 +413,12 @@ async function main() {
|
|
|
410
413
|
console.error(' 非 init/scan 类命令需要已初始化的项目根。');
|
|
411
414
|
console.error(' 修复:① cd 回项目根(含 .sillyspec 的目录)再跑;② 新项目先 sillyspec init(棕地项目 sillyspec scan);③ 或 --spec-dir <路径> 显式指定 .sillyspec 位置。');
|
|
412
415
|
console.error(' 查询/诊断类命令(status/doctor/progress/next/knowledge 等)不受此拦。');
|
|
416
|
+
// 跨仓精确指路(2026-10-12-cli-friction-signal):register-repo 登记过的子仓跑 CLI——
|
|
417
|
+
// 通用三出路之上叠加登记事实与工作区路径(dogfood:三连跑错目录靠人工发现)。无标记零变化。
|
|
418
|
+
if (uninitGate.crossRepo) {
|
|
419
|
+
console.error(` 🎯 此仓已登记为 SillySpec 工作区的跨仓${uninitGate.crossRepo.key ? `「${uninitGate.crossRepo.key}」` : ''}——工作区根:${uninitGate.crossRepo.workspace}`);
|
|
420
|
+
console.error(` 请回工作区根运行本命令,或 --dir "${uninitGate.crossRepo.workspace}" 显式指定。(标记:${uninitGate.gitRoot}/.git/sillyspec-crossrepo.json)`);
|
|
421
|
+
}
|
|
413
422
|
process.exit(2);
|
|
414
423
|
}
|
|
415
424
|
if (uninitGate.verdict === 'pass' && uninitGate.anchor && resolve(dir) !== uninitGate.anchor) {
|
|
@@ -923,6 +932,9 @@ task done 四合一(r5l 方案1):review write(落 review.json+自动勾
|
|
|
923
932
|
const tTask = tTaskIdx >= 0 && filteredArgs[tTaskIdx + 1] && !String(filteredArgs[tTaskIdx + 1]).startsWith("--") ? filteredArgs[tTaskIdx + 1] : null;
|
|
924
933
|
const tNoteIdx = filteredArgs.indexOf('--note');
|
|
925
934
|
const tNote = tNoteIdx >= 0 && filteredArgs[tNoteIdx + 1] && !String(filteredArgs[tNoteIdx + 1]).startsWith("--") ? filteredArgs[tNoteIdx + 1] : null;
|
|
935
|
+
// --evidence(2026-10-12-tick-evidence-multi-anchor):tick 自愿证据补录(自由文本,@路径 读文件)
|
|
936
|
+
const tEvidenceIdx = filteredArgs.indexOf('--evidence');
|
|
937
|
+
const tEvidence = tEvidenceIdx >= 0 && filteredArgs[tEvidenceIdx + 1] && !String(filteredArgs[tEvidenceIdx + 1]).startsWith("--") ? filteredArgs[tEvidenceIdx + 1] : null;
|
|
926
938
|
if (!tChange) { console.error('❌ 缺 --change <变更名>'); process.exit(2); }
|
|
927
939
|
if (taskSub !== 'list') {
|
|
928
940
|
if (!tTask || !/^task-\d+$/.test(tTask)) { console.error('❌ --task 格式应为 task-NN'); process.exit(2); }
|
|
@@ -960,7 +972,7 @@ task done 四合一(r5l 方案1):review write(落 review.json+自动勾
|
|
|
960
972
|
// ── 轻量勾选动词(2026-10-03-voluntary-task-tick):翻格+进度回显+下一任务指针——
|
|
961
973
|
// 纯反馈闭环(watcher task-done 事件自动上平台),自愿使用;幂等,无 review/verdict 仪式。──
|
|
962
974
|
const { runTaskTick } = await import('./task-tick.js')
|
|
963
|
-
await runTaskTick({ changeName: tChange, cwd: dir, taskId: tTask, specBase: tSpecBase })
|
|
975
|
+
await runTaskTick({ changeName: tChange, cwd: dir, taskId: tTask, specBase: tSpecBase, evidence: tEvidence })
|
|
964
976
|
} else if (taskSub === 'done') {
|
|
965
977
|
// ── task done 四合一(r5l-forensic-verdict 方案 1 / 评审护栏#3):13 任务 × 4 连收尾
|
|
966
978
|
// 往返(review write+勾选 / finish / wt-commit)合并为单进程串行 + 合并输出——每次省
|
|
@@ -2528,6 +2540,27 @@ ${generated.length} 个骨架已就绪——逐节把 <!--TODO--> 替换为语
|
|
|
2528
2540
|
console.log(` docHash 已与当前 ${result.mainDoc} 一致,跳过重写(幂等 no-op,不动文件)`);
|
|
2529
2541
|
} else {
|
|
2530
2542
|
console.log(` 下一步:独立审查子代理对照 ${result.mainDoc} 填 verdict/checklist 后重跑 --done`);
|
|
2543
|
+
// 派发前任务书出口(2026-10-12-thick-taskbook-cli):新生成骨架 = 待派发形态——打印
|
|
2544
|
+
// 可原样粘贴的完整评审任务书(检查单生产权在 CLI,治主代理手写 leading prompt)。
|
|
2545
|
+
// 条件性:--from adopt(评审已产)与 --refresh-hash(刷新既有)不打印;--json 面不混文案。
|
|
2546
|
+
if (!rsrFrom && !wantRefresh) {
|
|
2547
|
+
try {
|
|
2548
|
+
const { renderStageReviewerTaskbook } = await import('./stage-review.js');
|
|
2549
|
+
const { assembleProbeLinesForScope } = await import('./review-probes.js');
|
|
2550
|
+
const rsrSpecBase = rsrPlatformOpts.specRoot || join(dir, '.sillyspec');
|
|
2551
|
+
const _probeAsm = assembleProbeLinesForScope(rsrSpecBase, 'thick');
|
|
2552
|
+
for (const _w of _probeAsm.warnings) console.warn(`⚠️ review_probes:${_w}`);
|
|
2553
|
+
console.log(`\n📋 评审员任务书(原样复制给干净上下文子代理——检查单生产权在 CLI,勿手写改写):`);
|
|
2554
|
+
console.log(renderStageReviewerTaskbook({
|
|
2555
|
+
stage: st,
|
|
2556
|
+
changeDir: join(rsrSpecBase, 'changes', rsrChange),
|
|
2557
|
+
changeName: rsrChange,
|
|
2558
|
+
reviewRunId: result.reviewRunId,
|
|
2559
|
+
reviewPath: result.reviewPath,
|
|
2560
|
+
probeLines: _probeAsm.lines.length > 0 ? _probeAsm.lines : null,
|
|
2561
|
+
}));
|
|
2562
|
+
} catch { /* 任务书打印 best-effort:失败不影响注册主流程 */ }
|
|
2563
|
+
}
|
|
2531
2564
|
}
|
|
2532
2565
|
}
|
|
2533
2566
|
} catch (e) {
|
|
@@ -2911,6 +2944,7 @@ ${generated.length} 个骨架已就绪——逐节把 <!--TODO--> 替换为语
|
|
|
2911
2944
|
// 执行流:--cleanup-remnant / --cleanup-ghosts / --gc-unstamped-runs / --dump-db(结构化诊断之外的修复/取证动作)
|
|
2912
2945
|
const cleanupRemnant = filteredArgs.includes('--cleanup-remnant');
|
|
2913
2946
|
const cleanupGhosts = filteredArgs.includes('--cleanup-ghosts');
|
|
2947
|
+
const pruneCrossWts = filteredArgs.includes('--prune-cross-worktrees');
|
|
2914
2948
|
const gcUnstampedRuns = filteredArgs.includes('--gc-unstamped-runs');
|
|
2915
2949
|
const dumpDbFlag = filteredArgs.includes('--dump-db');
|
|
2916
2950
|
const doctorConfirm = filteredArgs.includes('--confirm');
|
|
@@ -3017,6 +3051,31 @@ ${generated.length} 个骨架已就绪——逐节把 <!--TODO--> 替换为语
|
|
|
3017
3051
|
process.exitCode = r.errors.length > 0 ? 1 : 0;
|
|
3018
3052
|
break;
|
|
3019
3053
|
}
|
|
3054
|
+
if (pruneCrossWts) {
|
|
3055
|
+
// 坑 cross-wt-ghost-metadata 缺口③(fire-equipment 2026-10-10 实证):跨仓仓 .git/worktrees/
|
|
3056
|
+
// 幽灵注册(目录已删、注册残留占分支)对 cleanup/归档链隐身(meta.json 随目录同亡)——
|
|
3057
|
+
// 本动作从注册面反扫清理。默认 dry-run,--confirm 才 prune;locked 条目只提示 unlock
|
|
3058
|
+
// 不自动解除(用户显式保护意图)。
|
|
3059
|
+
const { pruneCrossWorktreeGhosts } = await import('./worktree-cross.js');
|
|
3060
|
+
const r = pruneCrossWorktreeGhosts({ cwd: doctorEffectiveDir, confirm: doctorConfirm });
|
|
3061
|
+
if (json) {
|
|
3062
|
+
console.log(JSON.stringify(r, null, 2));
|
|
3063
|
+
} else {
|
|
3064
|
+
console.log(
|
|
3065
|
+
doctorConfirm
|
|
3066
|
+
? `👻 跨仓幽灵 worktree 注册清理(repos: ${r.repos_scanned ?? '—'}):${(r.pruned || []).length} 已 prune / ${(r.skipped_locked || []).length} locked 跳过 / ${(r.remaining || []).length} 残留`
|
|
3067
|
+
: `👻 跨仓幽灵 worktree 注册扫描(repos: ${r.repos_scanned ?? '—'}):待清理 ${r.count} 个(dry-run)`,
|
|
3068
|
+
);
|
|
3069
|
+
for (const g of (doctorConfirm ? r.pruned : r.ghosts) || []) console.log(` ${doctorConfirm ? '✅' : '-'} ${g.repoKey}: ${g.worktreePath}`);
|
|
3070
|
+
for (const g of (r.skipped_locked || [])) console.log(` 🔒 ${g.repoKey}: ${g.worktreePath}(locked——先 git -C ${g.repoRoot} worktree unlock ${g.worktreePath} 后重跑)`);
|
|
3071
|
+
const lockedPaths = new Set((r.skipped_locked || []).map((s) => s.worktreePath));
|
|
3072
|
+
for (const g of (r.remaining || []).filter((x) => !lockedPaths.has(x.worktreePath))) console.log(` ⚠️ ${g.repoKey}: ${g.worktreePath}(prune 后复扫仍在)`);
|
|
3073
|
+
for (const e of (r.errors || [])) console.log(` ❌ ${e.repoKey || ''}${e.repoRoot ? `(${e.repoRoot})` : ''}: ${e.error}`);
|
|
3074
|
+
if (!doctorConfirm && r.count > 0) console.log(`\n加 --confirm 执行 prune(git 只清指向不存在路径的注册,不动活 worktree、不碰分支)。`);
|
|
3075
|
+
}
|
|
3076
|
+
process.exitCode = (r.errors && r.errors.length > 0) ? 1 : 0;
|
|
3077
|
+
break;
|
|
3078
|
+
}
|
|
3020
3079
|
if (gcUnstampedRuns) {
|
|
3021
3080
|
// ql-20260908-006-5f04:无戳 execute-runs 一次性清扫。默认 dry-run,--confirm 才删。
|
|
3022
3081
|
// 不进 archive 热路径(pruneArchivedChangeRuntime 故意不猜无戳 run)。
|
|
@@ -3999,6 +4058,40 @@ SillySpec worktree — git worktree 隔离管理
|
|
|
3999
4058
|
await cmdAgentLog(filteredArgs.slice(1), { json, cwd: dir, specDir });
|
|
4000
4059
|
break;
|
|
4001
4060
|
}
|
|
4061
|
+
case 'patch-reanchor': {
|
|
4062
|
+
// 收口留痕锚点回写(2026-10-12-close-trace-freeze-integrity FR-03):worktree 内收口的
|
|
4063
|
+
// change-patch.json 记 worktree 本地 SHA,apply 重放回主仓后悬空——按提交主题归属推导
|
|
4064
|
+
// 主仓锚点区间回写 + change.patch 重冻结(幂等;doctor 检出悬空时指路本命令)。
|
|
4065
|
+
const prChangeIdx = filteredArgs.indexOf('--change');
|
|
4066
|
+
const prChange = prChangeIdx >= 0 && filteredArgs[prChangeIdx + 1] && !String(filteredArgs[prChangeIdx + 1]).startsWith('--') ? filteredArgs[prChangeIdx + 1] : null;
|
|
4067
|
+
if (!prChange) {
|
|
4068
|
+
console.error('用法: sillyspec patch-reanchor --change <名> [--spec-dir <路径>] [--json]');
|
|
4069
|
+
process.exit(2);
|
|
4070
|
+
}
|
|
4071
|
+
const specRootPr = resolvePlatformSpecDir(dir, specDir) || join(dir, '.sillyspec');
|
|
4072
|
+
const { locateChangeDir, reAnchorChangePatch } = await import('./patch-reanchor.js');
|
|
4073
|
+
const changeDirPr = locateChangeDir(specRootPr, prChange);
|
|
4074
|
+
if (!changeDirPr) {
|
|
4075
|
+
console.error(`❌ 变更目录未找到(活跃区与归档区均无):${prChange}`);
|
|
4076
|
+
process.exit(1);
|
|
4077
|
+
}
|
|
4078
|
+
const r = reAnchorChangePatch({ projectRoot: dir, changeDir: changeDirPr, changeName: prChange });
|
|
4079
|
+
if (!r.ok) {
|
|
4080
|
+
console.error(`❌ 锚点回写未执行:${r.reason}`);
|
|
4081
|
+
process.exit(1);
|
|
4082
|
+
}
|
|
4083
|
+
if (json) {
|
|
4084
|
+
console.log(JSON.stringify({ schema_version: 1, change: prChange, noop: r.noop === true, baseline: r.baseline || null, head: r.head || null, rewritten: r.rewritten || [] }, null, 2));
|
|
4085
|
+
} else if (r.noop) {
|
|
4086
|
+
console.log(`✅ 锚点已可解析,无需回写(幂等 no-op):${prChange}`);
|
|
4087
|
+
} else {
|
|
4088
|
+
console.log(`📦 锚点已回写主仓:${prChange}`);
|
|
4089
|
+
console.log(` baseline → ${r.baseline.slice(0, 10)}(原 worktree 锚留档 worktreeBaseline)`);
|
|
4090
|
+
console.log(` head → ${r.head.slice(0, 10)}(原 worktree 锚留档 worktreeHead)`);
|
|
4091
|
+
console.log(` change.patch 已按主仓区间重冻结,patchSha256 重锚(${(r.rewritten || []).join('、')})`);
|
|
4092
|
+
}
|
|
4093
|
+
break;
|
|
4094
|
+
}
|
|
4002
4095
|
case 'handoff': {
|
|
4003
4096
|
// 阶段跑者瘦会话交接块(P1-5 v1,2026-09-20 对撞实验驱动):打印下一阶段新会话
|
|
4004
4097
|
// 启动块(保持 SILLYSPEC_SESSION_ID)+ 状态摘要。纯只读。CLI prompt 自足——
|
package/src/local-register.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* `sillyspec local detect` / `platform connect` 各自补齐)。落盘走 writeAtomicSync
|
|
16
16
|
* (tmp+rename,防多会话并发写半截,与 local detect 写入口径一致)。
|
|
17
17
|
*/
|
|
18
|
-
import { existsSync, readFileSync, mkdirSync } from 'fs'
|
|
18
|
+
import { existsSync, readFileSync, mkdirSync, statSync } from 'fs'
|
|
19
19
|
import { dirname, join } from 'path'
|
|
20
20
|
import { writeAtomicSync } from './fs-atomic.js'
|
|
21
21
|
import { withFileLock } from './quicklog.js'
|
|
@@ -58,9 +58,27 @@ export async function registerRepoInLocalYaml(yamlPath, key, repoPath) {
|
|
|
58
58
|
const posixPath = String(repoPath).replace(/\\/g, '/')
|
|
59
59
|
const entryLine = ` ${key}: ${posixPath}`
|
|
60
60
|
|
|
61
|
+
// 跨仓标记(2026-10-12-cli-friction-signal):注册成功后 best-effort 向目标仓 .git/ 内写
|
|
62
|
+
// 指路标记——uninit 门据此把「在子仓跑 CLI」精确指回工作区(dogfood:三连跑错目录靠人工
|
|
63
|
+
// 发现)。.git/ 内零工作区污染;linked worktree 的 .git 是文件则跳过;失败仅告警不拦注册。
|
|
64
|
+
const tryWriteMarker = () => {
|
|
65
|
+
try {
|
|
66
|
+
const gitDir = join(String(repoPath), '.git')
|
|
67
|
+
if (!existsSync(gitDir) || !statSync(gitDir).isDirectory()) return
|
|
68
|
+
writeAtomicSync(join(gitDir, 'sillyspec-crossrepo.json'), JSON.stringify({
|
|
69
|
+
workspace: dirname(dirname(yamlPath)).replace(/\\/g, '/'),
|
|
70
|
+
key,
|
|
71
|
+
registeredAt: new Date().toISOString(),
|
|
72
|
+
}, null, 2) + '\n')
|
|
73
|
+
} catch (e) {
|
|
74
|
+
console.warn(`⚠️ 跨仓标记写入失败(${e && e.message ? e.message : e})——注册仍成功,未初始化门指路退化为通用文案`)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
61
78
|
const write = (text) => {
|
|
62
79
|
mkdirSync(dirname(yamlPath), { recursive: true })
|
|
63
80
|
writeAtomicSync(yamlPath, text)
|
|
81
|
+
tryWriteMarker()
|
|
64
82
|
}
|
|
65
83
|
|
|
66
84
|
const lines = raw.split('\n')
|
|
@@ -93,6 +111,7 @@ export async function registerRepoInLocalYaml(yamlPath, key, repoPath) {
|
|
|
93
111
|
// 编辑器写入),内存归一后比对相等直接跳过 → 磁盘永不治愈 + CLI 报 ✅ 假成功 → 解析侧
|
|
94
112
|
// (修复前)空 Map → MultiRepoContext 报「未注册」→ 死循环。幂等路径也落盘一次治愈。
|
|
95
113
|
if (hadCr) write(lines.join('\n'))
|
|
114
|
+
tryWriteMarker() // 幂等重注册也补标记(标记被删后可经重注册恢复——uninit 门指路依赖)
|
|
96
115
|
return { fileCreated: false, sectionCreated: false, replaced: false } // 值未变,幂等跳过
|
|
97
116
|
}
|
|
98
117
|
lines[i] = entryLine
|
package/src/machine-interface.js
CHANGED
|
@@ -469,8 +469,10 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
|
|
|
469
469
|
});
|
|
470
470
|
}
|
|
471
471
|
}
|
|
472
|
-
// e2. stage review 在场探测(
|
|
473
|
-
|
|
472
|
+
// e2. stage review 在场探测(execute --done 的 Stage Review Gate——verify 不在其列,
|
|
473
|
+
// 坑 stage-review-verify-enum-gap 2026-10-11:gate 实际只拦 brainstorm/plan/execute,
|
|
474
|
+
// register 枚举亦无 verify,verify 阶段不产 stage review 期望;verify 的真实门面是 e1 对账)
|
|
475
|
+
if (stage === 'execute') try {
|
|
474
476
|
const { getLatestStageReviewRunId } = await import('./stage-review.js');
|
|
475
477
|
const rrRoot = runtimeRoot || join(specRoot, '.runtime');
|
|
476
478
|
const runId = getLatestStageReviewRunId(rrRoot, stage, changeName);
|
|
@@ -479,7 +481,7 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
|
|
|
479
481
|
code: checkCode('full-stage-review') || 'full_stage_review_missing',
|
|
480
482
|
ok: !!runId,
|
|
481
483
|
errors: !runId
|
|
482
|
-
? [`缺 ${stage} 阶段的 stage review.json(tier=independent
|
|
484
|
+
? [`缺 ${stage} 阶段的 stage review.json(tier=independent 要求独立审查产出)——execute --done 将被 Stage Review Gate 拦;可先 sillyspec register-stage-review --change ${changeName} --stage ${stage} 生成骨架再填(降级条款照 review.json reviewerNotes 首行留痕)`]
|
|
483
485
|
: [],
|
|
484
486
|
data: { runId: runId || null },
|
|
485
487
|
});
|
|
@@ -500,7 +502,10 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
|
|
|
500
502
|
// (同一 getLatestStageReviewRunId 只读 marker 存在性,秒级):缺 review → warning 带
|
|
501
503
|
// register 指引;在场 → 静默 ok。独立 check id 不与 --full 的 full-stage-review 碰撞,
|
|
502
504
|
// error 语义(--done 拦)仍归 --full 档与 --done 本体。
|
|
503
|
-
|
|
505
|
+
// stage 期望面对齐(坑 stage-review-verify-enum-gap,2026-10-11 实证):Stage Review Gate
|
|
506
|
+
// 实际只拦 brainstorm/plan/execute(gates.js),register-stage-review 枚举亦无 verify——
|
|
507
|
+
// verify 阶段不产 stage review 提示(提示命令不可执行 + 指称的门禁不存在,双重误导)。
|
|
508
|
+
if (!full && stage === 'execute') {
|
|
504
509
|
try {
|
|
505
510
|
const { getLatestStageReviewRunId } = await import('./stage-review.js');
|
|
506
511
|
const rrRoot = runtimeRoot || join(specRoot, '.runtime');
|
|
@@ -511,7 +516,7 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot, s
|
|
|
511
516
|
ok: true,
|
|
512
517
|
informational: true,
|
|
513
518
|
warnings: runId ? [] : [
|
|
514
|
-
`缺 ${stage} 阶段的 stage review.json——
|
|
519
|
+
`缺 ${stage} 阶段的 stage review.json——execute --done 的 Stage Review Gate 会拦(本提示不改变 gate 结论)。先产出评审再注册:sillyspec register-stage-review --change ${changeName} --stage ${stage}(骨架生成后填,或 --from review.json 过继独立评审产出)`,
|
|
515
520
|
],
|
|
516
521
|
data: { runId: runId || null },
|
|
517
522
|
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* patch-reanchor.js — 收口留痕锚点回写(2026-10-12-close-trace-freeze-integrity FR-03)。
|
|
3
|
+
*
|
|
4
|
+
* 事故形态(multi-agent-platform 2026-10-12 ws-ref 五变更取证):thin 流程在会话专属
|
|
5
|
+
* worktree(独立克隆/事后清理)内收口,change-patch.json 冻结的 baseline/head 是 worktree
|
|
6
|
+
* 本地 SHA;worktree apply 重放回主仓时 SHA 全部改写且原对象被清理——归档锚点悬空,
|
|
7
|
+
* `git diff baseline..head` 不可解析,归档溯源死链。
|
|
8
|
+
*
|
|
9
|
+
* 回写约定:按「提交主题含变更名」归属(仓内提交规范 `(<变更名>)` 后缀族,与
|
|
10
|
+
* filterCommittedFace 的 commitAttribution 同一约定),`git log --all --grep` 取归属提交链:
|
|
11
|
+
* head=拓扑末笔、baseline=首笔^;区间一致性(首笔^ 必须是 head 祖先、区间 diff 与留档
|
|
12
|
+
* files[] 至少一文件重叠)不过即 fail-closed 不改写。原 worktree 锚降级为
|
|
13
|
+
* worktreeBaseline/worktreeHead 溯源字段,patchSha256 按重冻正文重锚。
|
|
14
|
+
*
|
|
15
|
+
* 依赖边界:本模块被 worktree-apply.js 静态引入(apply 尾声自动回写),而 scope-audit.js
|
|
16
|
+
* 已静态引入 worktree-apply(评审 G3「内核绝不 import scope-audit」)——故本模块禁止
|
|
17
|
+
* import scope-audit/worktree-apply(防环),diff 采集自实现(pathspec 分块)。
|
|
18
|
+
*/
|
|
19
|
+
import { createHash } from 'node:crypto'
|
|
20
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
21
|
+
import { join } from 'node:path'
|
|
22
|
+
import { execFileSync } from 'node:child_process'
|
|
23
|
+
import { writeAtomicSync } from './fs-atomic.js'
|
|
24
|
+
|
|
25
|
+
function git(cwd, args, opts = {}) {
|
|
26
|
+
try {
|
|
27
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', timeout: 60000, windowsHide: true, ...opts }).trim()
|
|
28
|
+
} catch { return null }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const resolves = (cwd, sha) => Boolean(sha) && /^[0-9a-f]{40}$/i.test(String(sha)) && git(cwd, ['cat-file', '-e', `${sha}^{commit}`]) !== null
|
|
32
|
+
|
|
33
|
+
/** pathspec 分块(命令行长度上限防御,与 worktree-apply.chunkPaths 同上限语义,本地实现防环) */
|
|
34
|
+
function chunkPaths(paths, maxChars = 8000) {
|
|
35
|
+
const chunks = []
|
|
36
|
+
let cur = []
|
|
37
|
+
let len = 0
|
|
38
|
+
for (const p of paths) {
|
|
39
|
+
if (cur.length > 0 && len + p.length + 1 > maxChars) { chunks.push(cur); cur = []; len = 0 }
|
|
40
|
+
cur.push(p); len += p.length + 1
|
|
41
|
+
}
|
|
42
|
+
if (cur.length > 0) chunks.push(cur)
|
|
43
|
+
return chunks
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 定位变更目录:活跃区 → 归档区精确名 → 归档区 `<日期>-<名>` 后缀匹配(归档重命名形态)。
|
|
48
|
+
* @returns {string|null}
|
|
49
|
+
*/
|
|
50
|
+
export function locateChangeDir(specBase, changeName) {
|
|
51
|
+
const active = join(specBase, 'changes', changeName)
|
|
52
|
+
if (existsSync(join(active, 'change-patch.json')) || existsSync(join(active, 'design.md'))) return active
|
|
53
|
+
const archiveExact = join(specBase, 'changes', 'archive', changeName)
|
|
54
|
+
if (existsSync(join(archiveExact, 'change-patch.json')) || existsSync(join(archiveExact, 'design.md'))) return archiveExact
|
|
55
|
+
try {
|
|
56
|
+
const archiveRoot = join(specBase, 'changes', 'archive')
|
|
57
|
+
if (existsSync(archiveRoot)) {
|
|
58
|
+
const suffix = `-${changeName}`
|
|
59
|
+
const hit = readdirSync(archiveRoot).filter((d) => d.endsWith(suffix)).sort()
|
|
60
|
+
if (hit.length === 1) return join(archiveRoot, hit[0])
|
|
61
|
+
}
|
|
62
|
+
} catch { /* 归档扫描失败走 null */ }
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 归档锚点回写核心(幂等)。
|
|
68
|
+
* @param projectRoot 主仓根(锚点可解析性判定与重冻区间的仓)
|
|
69
|
+
* @param changeDir 变更目录(活跃或归档侧)
|
|
70
|
+
* @param changeName 变更名(提交主题归属 grep 锚)
|
|
71
|
+
* @returns {{ ok: boolean, noop?: boolean, baseline?: string, head?: string, reason?: string, rewritten?: string[] }}
|
|
72
|
+
*/
|
|
73
|
+
export function reAnchorChangePatch({ projectRoot, changeDir, changeName }) {
|
|
74
|
+
const cpPath = join(changeDir, 'change-patch.json')
|
|
75
|
+
if (!existsSync(cpPath)) return { ok: false, reason: 'change-patch.json 不在案(本变更无收口冻结件)' }
|
|
76
|
+
let meta
|
|
77
|
+
try { meta = JSON.parse(readFileSync(cpPath, 'utf8')) } catch (e) { return { ok: false, reason: `change-patch.json 不可解析:${e && e.message ? e.message.split('\n')[0] : e}` } }
|
|
78
|
+
|
|
79
|
+
const headOk = resolves(projectRoot, meta.head)
|
|
80
|
+
const baseOk = resolves(projectRoot, meta.baseline)
|
|
81
|
+
if (headOk && (baseOk || !meta.baseline)) return { ok: true, noop: true }
|
|
82
|
+
|
|
83
|
+
// 归属提交链:主题含变更名(提交规范后缀族);--all 覆盖主干/分支,date-order 保拓扑可读
|
|
84
|
+
const grepRaw = git(projectRoot, ['log', '--all', '--date-order', '--format=%H%x01%s', `--grep=${changeName}`])
|
|
85
|
+
if (!grepRaw) return { ok: false, reason: `主仓无可归属提交(git log --all --grep=${changeName} 空/失败)——锚点推导无据,fail-closed` }
|
|
86
|
+
const rows = grepRaw.split('\n').map((l) => l.trim()).filter(Boolean).map((l) => { const i = l.indexOf('\u0001'); return [l.slice(0, i), l.slice(i + 1)] })
|
|
87
|
+
if (rows.length === 0) return { ok: false, reason: `主仓无可归属提交——锚点推导无据,fail-closed` }
|
|
88
|
+
const oldest = rows[rows.length - 1][0]
|
|
89
|
+
const newest = rows[0][0]
|
|
90
|
+
const isAncestor = (a, b) => git(projectRoot, ['merge-base', '--is-ancestor', a, b]) !== null
|
|
91
|
+
if (!isAncestor(oldest, newest)) return { ok: false, reason: `归属提交链拓扑不一致(首笔 ${oldest.slice(0, 10)} 非末笔 ${newest.slice(0, 10)} 祖先)——疑似同名异源提交混入,fail-closed` }
|
|
92
|
+
const baseRaw = git(projectRoot, ['rev-parse', `${oldest}^`])
|
|
93
|
+
if (!baseRaw || !resolves(projectRoot, baseRaw)) return { ok: false, reason: `首笔归属提交无第一父(根提交形态)——baseline 不可推导,fail-closed` }
|
|
94
|
+
if (!isAncestor(baseRaw, newest)) return { ok: false, reason: `推导区间不一致(首笔^ 非 head 祖先)——fail-closed` }
|
|
95
|
+
|
|
96
|
+
// 区间与留档 files[] 至少一文件重叠(防错归属整链改写)
|
|
97
|
+
const rangeFiles = String(git(projectRoot, ['diff', '--name-only', '--no-color', baseRaw, newest]) || '')
|
|
98
|
+
.split('\n').map((s) => s.trim().replace(/\\/g, '/')).filter(Boolean)
|
|
99
|
+
const recorded = (Array.isArray(meta.files) ? meta.files : []).map((f) => String(f).replace(/\\/g, '/'))
|
|
100
|
+
const overlap = rangeFiles.filter((f) => recorded.includes(f) || recorded.some((r) => r.startsWith(`${f}/`) || f.startsWith(`${r}/`)))
|
|
101
|
+
if (recorded.length > 0 && overlap.length === 0) {
|
|
102
|
+
return { ok: false, reason: `推导区间(${baseRaw.slice(0, 10)}..${newest.slice(0, 10)})与留档 files[] 零重叠——归属可疑,fail-closed` }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 重冻正文:区间 diff 按 files[] pathspec 限面(重放后的主仓区间即归档真相)
|
|
106
|
+
const face = recorded.length > 0 ? recorded : rangeFiles
|
|
107
|
+
const parts = []
|
|
108
|
+
for (const chunk of chunkPaths(face)) {
|
|
109
|
+
const d = git(projectRoot, ['diff', '--no-color', baseRaw, newest, '--', ...chunk])
|
|
110
|
+
if (d) parts.push(d)
|
|
111
|
+
}
|
|
112
|
+
const patchBody = parts.length > 0 ? (parts.join('\n').endsWith('\n') ? parts.join('\n') : parts.join('\n') + '\n') : null
|
|
113
|
+
if (!patchBody) return { ok: false, reason: `主仓区间 diff 为空(${baseRaw.slice(0, 10)}..${newest.slice(0, 10)} × ${face.length} 文件)——重冻无据,fail-closed` }
|
|
114
|
+
|
|
115
|
+
const sha256 = createHash('sha256').update(patchBody.replace(/\r\n/g, '\n'), 'utf8').digest('hex')
|
|
116
|
+
const rewritten = []
|
|
117
|
+
if (meta.baseline !== baseRaw) { meta.worktreeBaseline = meta.baseline ?? null; meta.baseline = baseRaw; rewritten.push('baseline') }
|
|
118
|
+
if (meta.head !== newest) { meta.worktreeHead = meta.head ?? null; meta.head = newest; rewritten.push('head') }
|
|
119
|
+
if (meta.scopeAudit && typeof meta.scopeAudit === 'object' && meta.scopeAudit.baseAnchor !== baseRaw) {
|
|
120
|
+
meta.scopeAudit = { ...meta.scopeAudit, baseAnchor: baseRaw }
|
|
121
|
+
rewritten.push('scopeAudit.baseAnchor')
|
|
122
|
+
}
|
|
123
|
+
meta.patchSha256 = sha256
|
|
124
|
+
meta.patchStatus = 'ok'
|
|
125
|
+
meta.reanchoredAt = new Date().toISOString()
|
|
126
|
+
meta.reanchorNote = `apply/回填后主仓重锚(原 worktree 锚悬空:${meta.worktreeBaseline ? String(meta.worktreeBaseline).slice(0, 10) : '—'}/${meta.worktreeHead ? String(meta.worktreeHead).slice(0, 10) : '—'};归属约定=提交主题含变更名)——2026-10-12-close-trace-freeze-integrity FR-03`
|
|
127
|
+
writeAtomicSync(cpPath, JSON.stringify(meta, null, 2) + '\n')
|
|
128
|
+
writeAtomicSync(join(changeDir, 'change.patch'), patchBody)
|
|
129
|
+
rewritten.push('change.patch', 'patchSha256')
|
|
130
|
+
return { ok: true, noop: false, baseline: baseRaw, head: newest, rewritten }
|
|
131
|
+
}
|
package/src/review-dispatch.js
CHANGED
|
@@ -22,6 +22,7 @@ import { join, dirname } from 'node:path'
|
|
|
22
22
|
import jsYaml from 'js-yaml'
|
|
23
23
|
import { writeAtomicSync } from './fs-atomic.js'
|
|
24
24
|
import { REVIEW_CHECKLISTS } from './stage-review-checklist.js'
|
|
25
|
+
import { assembleProbeLinesForScope } from './review-probes.js'
|
|
25
26
|
import { CEREMONY_TIERS } from './ceremony-tier.js'
|
|
26
27
|
import { withFileLock } from './quicklog.js'
|
|
27
28
|
import {
|
|
@@ -161,15 +162,20 @@ export function extractReviewFromArtifacts(artifacts) {
|
|
|
161
162
|
* docblock 规则)②review.json 契约(与 gate 同源)③期望产出方式(read_only worker:
|
|
162
163
|
* 结论经 artifacts 返回,**不写任何文件**——落盘归 CLI)④铁律段(禁改文件/禁 git commit)。
|
|
163
164
|
*/
|
|
164
|
-
export function buildReviewerTaskBook({ stage, changeDir, reviewRunId, channelPriority }) {
|
|
165
|
+
export function buildReviewerTaskBook({ stage, changeDir, reviewRunId, channelPriority, probeLines = null }) {
|
|
165
166
|
const mainDoc = STAGE_MAIN_DOC[stage] || 'design.md'
|
|
166
167
|
const items = (REVIEW_CHECKLISTS[stage] || []).map((t, i) => `${i + 1}. ${t}`).join('\n')
|
|
167
168
|
const contract = renderReviewJsonContract({ stage, changeDir, reviewRunId, tier: 'independent', channelPriority })
|
|
169
|
+
// 项目评审探针(2026-10-12-review-probe-injection):local.yaml 数据驱动注入,缺省零变化。
|
|
170
|
+
const probeSection = Array.isArray(probeLines) && probeLines.length > 0
|
|
171
|
+
? ['', '## 项目评审探针(local.yaml 注入——逐条 pass/gap/fail + 证据锚点,未答=未审)', ...probeLines.map((l) => `- ${l}`)]
|
|
172
|
+
: []
|
|
168
173
|
const workerPrompt = [
|
|
169
174
|
`你是独立审查者(platform 通道派发)。审查对象:${changeDir ? join(changeDir, mainDoc) : mainDoc}`,
|
|
170
175
|
'',
|
|
171
176
|
'## 审查清单(逐条给 pass/gap/fail + 证据锚点)',
|
|
172
177
|
items,
|
|
178
|
+
...probeSection,
|
|
173
179
|
'',
|
|
174
180
|
contract,
|
|
175
181
|
'',
|
|
@@ -285,7 +291,7 @@ export async function runReviewDispatch(opts = {}) {
|
|
|
285
291
|
} catch { /* marker 写失败不阻断派发(status 回收时再补) */ }
|
|
286
292
|
}
|
|
287
293
|
const changeDir = join(specBase, 'changes', changeName)
|
|
288
|
-
const book = buildReviewerTaskBook({ stage, changeDir, reviewRunId: runId, channelPriority: readReviewChannelPriority(cwd) })
|
|
294
|
+
const book = buildReviewerTaskBook({ stage, changeDir, reviewRunId: runId, channelPriority: readReviewChannelPriority(cwd), probeLines: probeLinesForThick(specBase) })
|
|
289
295
|
const mission = await client.createMission({ objective: book.objective, orchestrationMode: 'external', budgetUsd })
|
|
290
296
|
const missionId = mission && mission.missionId
|
|
291
297
|
if (!missionId) return { ok: false, reason: 'create_mission 未返回 missionId', guidance: renderFallbackGuidance(cwd) }
|
|
@@ -694,7 +700,7 @@ export async function dispatchShadowReview(opts = {}) {
|
|
|
694
700
|
// 影子 runId 独立生成(R-06 隔离通道②:绝不读写主线 marker / stageReviewMarkerPath)
|
|
695
701
|
const shadowRunId = generateShadowReviewRunId()
|
|
696
702
|
const changeDir = join(specBase, 'changes', changeName)
|
|
697
|
-
const book = buildReviewerTaskBook({ stage, changeDir, reviewRunId: shadowRunId, channelPriority: readReviewChannelPriority(cwd) })
|
|
703
|
+
const book = buildReviewerTaskBook({ stage, changeDir, reviewRunId: shadowRunId, channelPriority: readReviewChannelPriority(cwd), probeLines: probeLinesForThick(specBase) })
|
|
698
704
|
const workerPrompt = book.workerPrompt + '\n(影子审查:本结论将由 CLI 回收到 stage-reviews-shadow/ 独立命名空间做轻/重对照记账,不影响主线任何判定。)\n'
|
|
699
705
|
const mission = await client.createMission({ objective: book.objective, orchestrationMode: 'external', budgetUsd: budget })
|
|
700
706
|
const missionId = mission && mission.missionId
|
|
@@ -840,6 +846,15 @@ export async function dispatchShadowReview(opts = {}) {
|
|
|
840
846
|
|
|
841
847
|
// task-06 消费契约预留(配置读取在该 task 落地;此处常量为缺省值单一来源)
|
|
842
848
|
const DEFAULT_REVIEW_DISPATCH_CONFIG = { budget_usd: 1.0, stall_ms: 900_000, timeout_ms: 0 }
|
|
849
|
+
/** 厚档任务书探针装配(2026-10-12-review-probe-injection):失败/未配置返回 null(零注入零阻断)。 */
|
|
850
|
+
function probeLinesForThick(specBase) {
|
|
851
|
+
try {
|
|
852
|
+
const { lines, warnings } = assembleProbeLinesForScope(specBase, 'thick')
|
|
853
|
+
for (const w of warnings) console.warn(`⚠️ review_probes:${w}`)
|
|
854
|
+
return lines.length > 0 ? lines : null
|
|
855
|
+
} catch { return null }
|
|
856
|
+
}
|
|
857
|
+
|
|
843
858
|
export function readReviewDispatchConfig(cwd) {
|
|
844
859
|
try {
|
|
845
860
|
const p = join(cwd || process.cwd(), '.sillyspec', 'local.yaml')
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* review-probes.js — 项目评审探针(2026-10-12-review-probe-injection)。
|
|
3
|
+
*
|
|
4
|
+
* 定位:把「项目特有的评审必查面」从主代理手写检查单收归 CLI 单源注入——dogfood 实证
|
|
5
|
+
* (2026-10-10~12 fire-equipment 会话):三个生产缺陷(dva 双注册、MyBatis typeAliases
|
|
6
|
+
* 撞名、原型表头未实现)全部由用户报障发现,独立评审零捕获,根因是检查单由主代理手写、
|
|
7
|
+
* 主代理没想到的面无人查。
|
|
8
|
+
*
|
|
9
|
+
* 开放世界红线(用户 2026-10-12 定规):CLI 零内容枚举、零语言/框架绑定——探针是纯数据
|
|
10
|
+
* (local.yaml `review_probes` 段:id 自由文本 / lines 自由文本清单行 / command 任意 shell
|
|
11
|
+
* 命令,stdout 非空行追加),CLI 只做装配与容量治理。scope 枚举(thin|thick|all)是 CLI
|
|
12
|
+
* 自有的两个任务书面(轻量 renderReviewerTaskbook / 厚档 buildReviewerTaskBook),非内容
|
|
13
|
+
* 枚举。与 ceremony/docs-check 等 local.yaml 段同消费哲学:读键容错、缺省空、未配置零注入。
|
|
14
|
+
*
|
|
15
|
+
* 容量帽:防误配把任务书撑爆或命令拖死渲染——条目 ≤16、每条静态行 ≤12、单行 ≤300 字符、
|
|
16
|
+
* 命令超时 10s、命令输出 ≤2KB 且 ≤20 行;超帽截断并在 provenance 留痕(不静默吞)。
|
|
17
|
+
* fail-soft:坏条目跳过+告警、命令失败/超时跳过该行源——探针面永不阻断任务书渲染。
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
20
|
+
import { join } from 'node:path'
|
|
21
|
+
import { spawnSync } from 'node:child_process'
|
|
22
|
+
import yaml from 'js-yaml'
|
|
23
|
+
|
|
24
|
+
export const PROBE_LIMITS = {
|
|
25
|
+
MAX_PROBES: 16,
|
|
26
|
+
MAX_LINES_PER_PROBE: 12,
|
|
27
|
+
MAX_LINE_CHARS: 300,
|
|
28
|
+
COMMAND_TIMEOUT_MS: 10_000,
|
|
29
|
+
COMMAND_OUTPUT_MAX_BYTES: 2048,
|
|
30
|
+
COMMAND_OUTPUT_MAX_LINES: 20,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** scope 合法面(CLI 自有任务书面集合——封闭小集,非内容枚举)。 */
|
|
34
|
+
const SCOPES = new Set(['thin', 'thick', 'all'])
|
|
35
|
+
|
|
36
|
+
const cleanLine = (s) => String(s ?? '').replace(/\s+$/, '')
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 校验+归一探针声明(纯函数,零抛出)。
|
|
40
|
+
* @param {unknown} raw - local.yaml review_probes 段原值
|
|
41
|
+
* @returns {{ probes: Array<{id:string, scope:string, lines:string[], command:string|null}>, warnings: string[] }}
|
|
42
|
+
*/
|
|
43
|
+
export function parseReviewProbes(raw) {
|
|
44
|
+
const warnings = []
|
|
45
|
+
if (raw == null) return { probes: [], warnings }
|
|
46
|
+
if (!Array.isArray(raw)) return { probes: [], warnings: ['review_probes 段非数组——整段忽略'] }
|
|
47
|
+
const probes = []
|
|
48
|
+
raw.slice(0, PROBE_LIMITS.MAX_PROBES).forEach((entry, i) => {
|
|
49
|
+
const label = `review_probes[${i}]`
|
|
50
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
51
|
+
warnings.push(`${label} 非对象——跳过`); return
|
|
52
|
+
}
|
|
53
|
+
const id = cleanLine(entry.id)
|
|
54
|
+
if (!id || id.length > 64) { warnings.push(`${label} id 缺失或超 64 字符——跳过`); return }
|
|
55
|
+
let scope = entry.scope == null ? 'all' : String(entry.scope)
|
|
56
|
+
if (!SCOPES.has(scope)) { warnings.push(`${label}(${id}) scope 非法(${scope},合法 thin|thick|all)——按 all 处理`); scope = 'all' }
|
|
57
|
+
const lines = Array.isArray(entry.lines)
|
|
58
|
+
? entry.lines.map(cleanLine).filter((l) => l.length > 0)
|
|
59
|
+
: []
|
|
60
|
+
if (lines.length > PROBE_LIMITS.MAX_LINES_PER_PROBE) {
|
|
61
|
+
warnings.push(`${label}(${id}) lines ${lines.length} 行超帽 ${PROBE_LIMITS.MAX_LINES_PER_PROBE}——截断`)
|
|
62
|
+
lines.length = PROBE_LIMITS.MAX_LINES_PER_PROBE
|
|
63
|
+
}
|
|
64
|
+
for (const l of lines) {
|
|
65
|
+
if (l.length > PROBE_LIMITS.MAX_LINE_CHARS) {
|
|
66
|
+
warnings.push(`${label}(${id}) 单行超 ${PROBE_LIMITS.MAX_LINE_CHARS} 字符——截断`)
|
|
67
|
+
break
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const linesCapped = lines.map((l) => l.length > PROBE_LIMITS.MAX_LINE_CHARS ? l.slice(0, PROBE_LIMITS.MAX_LINE_CHARS) : l)
|
|
71
|
+
const command = typeof entry.command === 'string' && entry.command.trim() ? entry.command : null
|
|
72
|
+
if (linesCapped.length === 0 && !command) { warnings.push(`${label}(${id}) lines 与 command 均空——跳过`); return }
|
|
73
|
+
probes.push({ id, scope, lines: linesCapped, command })
|
|
74
|
+
})
|
|
75
|
+
if (raw.length > PROBE_LIMITS.MAX_PROBES) {
|
|
76
|
+
warnings.push(`review_probes ${raw.length} 条超帽 ${PROBE_LIMITS.MAX_PROBES}——仅取前 ${PROBE_LIMITS.MAX_PROBES} 条`)
|
|
77
|
+
}
|
|
78
|
+
return { probes, warnings }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 装载 local.yaml 的 review_probes 段(IO,容错缺省空——无段/无文件/解析失败均零探针零阻断)。
|
|
83
|
+
* @returns {{ probes: Array, warnings: string[] }}
|
|
84
|
+
*/
|
|
85
|
+
export function loadReviewProbes(specBase) {
|
|
86
|
+
try {
|
|
87
|
+
const p = join(specBase || '', 'local.yaml')
|
|
88
|
+
if (!existsSync(p)) return { probes: [], warnings: [] }
|
|
89
|
+
const doc = yaml.load(readFileSync(p, 'utf8'))
|
|
90
|
+
const section = doc && typeof doc === 'object' ? doc.review_probes : null
|
|
91
|
+
return parseReviewProbes(section)
|
|
92
|
+
} catch (e) {
|
|
93
|
+
return { probes: [], warnings: [`review_probes 装载失败(${e && e.message ? e.message : e})——按未配置处理`] }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 命令输出封帽(纯函数,评审 P3①② 清偿):字节帽按 UTF-8 实字节数(非 UTF-16 字符数——中文
|
|
99
|
+
* 场景旧口径实际可注入 ~3× 标称值);行帽 20;命令输出行同享静态行单行 300 字符帽。
|
|
100
|
+
* 多字节截断可能切在字符中间(Buffer subarray + utf8 解码产生替换符)——留痕已示截断,可接受。
|
|
101
|
+
*/
|
|
102
|
+
export function capCommandOutput(raw) {
|
|
103
|
+
let out = String(raw ?? '')
|
|
104
|
+
const provenance = []
|
|
105
|
+
const bytes = Buffer.byteLength(out, 'utf8')
|
|
106
|
+
if (bytes > PROBE_LIMITS.COMMAND_OUTPUT_MAX_BYTES) {
|
|
107
|
+
out = Buffer.from(out, 'utf8').subarray(0, PROBE_LIMITS.COMMAND_OUTPUT_MAX_BYTES).toString('utf8')
|
|
108
|
+
provenance.push(`命令输出超 ${PROBE_LIMITS.COMMAND_OUTPUT_MAX_BYTES}B 截断`)
|
|
109
|
+
}
|
|
110
|
+
let lines = out.split(/\r?\n/).map(cleanLine).filter((l) => l.length > 0)
|
|
111
|
+
if (lines.some((l) => l.length > PROBE_LIMITS.MAX_LINE_CHARS)) {
|
|
112
|
+
lines = lines.map((l) => (l.length > PROBE_LIMITS.MAX_LINE_CHARS ? l.slice(0, PROBE_LIMITS.MAX_LINE_CHARS) : l))
|
|
113
|
+
provenance.push(`命令输出单行超 ${PROBE_LIMITS.MAX_LINE_CHARS} 字符截断`)
|
|
114
|
+
}
|
|
115
|
+
if (lines.length > PROBE_LIMITS.COMMAND_OUTPUT_MAX_LINES) {
|
|
116
|
+
lines = lines.slice(0, PROBE_LIMITS.COMMAND_OUTPUT_MAX_LINES)
|
|
117
|
+
provenance.push(`命令输出超 ${PROBE_LIMITS.COMMAND_OUTPUT_MAX_LINES} 行截断`)
|
|
118
|
+
}
|
|
119
|
+
return { lines, provenance }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 缺省命令执行器:shell 执行 + 超时 + 输出帽(字节/行/单行三帽,超帽截断留痕)。 */
|
|
123
|
+
function defaultRunCommand(command) {
|
|
124
|
+
const r = spawnSync(command, { shell: true, timeout: PROBE_LIMITS.COMMAND_TIMEOUT_MS, encoding: 'utf8', windowsHide: true })
|
|
125
|
+
if (r.error || r.status !== 0) {
|
|
126
|
+
const why = r.error && r.error.code === 'ETIMEDOUT' ? `超时 >${PROBE_LIMITS.COMMAND_TIMEOUT_MS}ms` : `退出码 ${r.status ?? '?'}`
|
|
127
|
+
return { ok: false, warning: `命令失败(${why})`, lines: [] }
|
|
128
|
+
}
|
|
129
|
+
const { lines, provenance } = capCommandOutput(r.stdout)
|
|
130
|
+
return { ok: true, lines, provenance }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 解析某任务书面的探针清单行(纯函数,runCommand 注入可测)。
|
|
135
|
+
* @param {{ probes: Array, scope?: 'thin'|'thick', runCommand?: (cmd:string)=>{ok:boolean,lines:string[],warning?:string,provenance?:string[]} }} opts
|
|
136
|
+
* @returns {{ lines: string[], provenance: string[] }}
|
|
137
|
+
*/
|
|
138
|
+
export function resolveProbeLines({ probes, scope = 'thin', runCommand = defaultRunCommand } = {}) {
|
|
139
|
+
const lines = []
|
|
140
|
+
const provenance = []
|
|
141
|
+
for (const p of probes || []) {
|
|
142
|
+
if (p.scope !== 'all' && p.scope !== scope) continue
|
|
143
|
+
for (const l of p.lines) lines.push(`${p.id}|${l}`)
|
|
144
|
+
if (p.command) {
|
|
145
|
+
const r = runCommand(p.command)
|
|
146
|
+
if (!r.ok) { provenance.push(`探针 ${p.id} ${r.warning || '命令失败'}——命令行源跳过`); continue }
|
|
147
|
+
for (const l of r.lines) lines.push(`${p.id}|${l}`)
|
|
148
|
+
for (const pv of r.provenance || []) provenance.push(`探针 ${p.id}:${pv}`)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { lines, provenance }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 装配便捷面(调用点单行接线):装载→scope 过滤→行合并,warnings 汇含装载告警与帽留痕。 */
|
|
155
|
+
export function assembleProbeLinesForScope(specBase, scope) {
|
|
156
|
+
const { probes, warnings } = loadReviewProbes(specBase)
|
|
157
|
+
const { lines, provenance } = resolveProbeLines({ probes, scope })
|
|
158
|
+
return { lines, warnings: [...warnings, ...provenance] }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export default { parseReviewProbes, loadReviewProbes, resolveProbeLines, assembleProbeLinesForScope, capCommandOutput, PROBE_LIMITS }
|