sillyspec 3.25.0 → 3.25.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/sillyspec-doctor/SKILL.md +9 -0
- package/.playwright-mcp/console-2026-07-27T15-45-50-621Z.log +1 -0
- package/.playwright-mcp/page-2026-07-27T15-45-51-459Z.yml +144 -0
- package/.playwright-mcp/prompt-final.png +0 -0
- package/.playwright-mcp/prompt-view.png +0 -0
- package/CLAUDE.md +18 -0
- package/package.json +1 -1
- package/src/change-risk-profile.js +60 -12
- package/src/check-primitives.js +65 -0
- package/src/db.js +28 -8
- package/src/hooks/worktree-guard.js +9 -2
- package/src/index.js +140 -11
- package/src/machine-interface.js +13 -7
- package/src/progress/change-registry.js +217 -0
- package/src/progress/consistency-doctor.js +259 -0
- package/src/progress/shared.js +33 -0
- package/src/progress/stage-machine.js +535 -0
- package/src/progress/step-store.js +256 -0
- package/src/progress.js +75 -1191
- package/src/quicklog.js +3 -1
- package/src/run/command.js +1088 -0
- package/src/run/complete-handlers.js +874 -0
- package/src/run/complete.js +725 -0
- package/src/run/gates.js +326 -0
- package/src/run/prompt.js +619 -0
- package/src/run/quick-audit.js +107 -0
- package/src/run/scan-profile.js +238 -0
- package/src/run/shared.js +418 -0
- package/src/run/stage.js +414 -0
- package/src/run.js +23 -4379
- package/src/stage-contract-engine.js +150 -0
- package/src/stage-contract-spec.js +517 -0
- package/src/stage-contract.js +87 -150
- package/src/stage-review.js +63 -0
- package/src/stages/brainstorm.js +3 -1
- package/src/stages/execute.js +5 -2
- package/src/stages/plan-postcheck.js +38 -27
- package/src/stages/plan.js +12 -49
- package/src/stages/propose.js +2 -1
- package/src/stages/scan.js +3 -1
- package/src/workflow.js +9 -17
- package/src/worktree-apply.js +5 -2
- package/src/worktree-deps.js +114 -32
- package/src/worktree.js +23 -11
- package/src/brainstorm-postcheck.js +0 -158
package/src/index.js
CHANGED
|
@@ -16,6 +16,8 @@ import { detectLocalYaml } from './local-detect.js';
|
|
|
16
16
|
|
|
17
17
|
// ── CLI 入口 ──
|
|
18
18
|
|
|
19
|
+
// did-you-mean 从 ./run/shared.js 复用(命令级 + flag 级 typo 建议)。
|
|
20
|
+
import { didYouMean, assertSafeChangeName } from './run/shared.js'
|
|
19
21
|
function printUsage() {
|
|
20
22
|
console.log(`
|
|
21
23
|
SillySpec CLI — 规范驱动开发工具包
|
|
@@ -73,11 +75,20 @@ SillySpec CLI — 规范驱动开发工具包
|
|
|
73
75
|
doctor [--fix] [--stale-hours N] 健康检查 + 修复
|
|
74
76
|
|
|
75
77
|
sillyspec local detect [--dir <path>] 生成本地配置 local.yaml(纯 fs 嗅探,零 token、不跑 scan)
|
|
78
|
+
sillyspec runtime list [--json] 枚举 .sillyspec/.runtime/ 运行时产物(只读,看手上有哪些证据/状态文件)
|
|
76
79
|
|
|
77
|
-
sillyspec workflow check <name> [--project <p>] [--change <c>] [--json]
|
|
80
|
+
sillyspec workflow check <name> [--project <p>] [--change <c>] [--json] [--save]
|
|
81
|
+
全局 workflow 校验(--save 归档到 .sillyspec/.runtime/workflow-runs/)
|
|
78
82
|
sillyspec workflow list
|
|
79
83
|
sillyspec gate <stage> --change <name> [--json] 机器门控:阶段能否标记完成(只读)
|
|
80
84
|
sillyspec derive <facet> --change <name> [--json] 单项事实核验(facet: execute-evidence|verify-test|task-reviews|artifacts)
|
|
85
|
+
|
|
86
|
+
sillyspec doctor [子命令] 进度库健康检查 + 修复(顶层命令,非 worktree doctor)
|
|
87
|
+
(无子命令) 跑诊断,列出问题
|
|
88
|
+
--align-execute-progress [--change <名>] [--confirm] 基于 plan.md 对齐 execute 派生戳(默认 dry-run)
|
|
89
|
+
--cleanup-remnant [--confirm] 删除 0 字节空占位 db(默认 dry-run,仅 --confirm 落盘)
|
|
90
|
+
--dump-db --path <db 路径> dump 指定 db 内容到文件(取证用)
|
|
91
|
+
--json 结构化诊断 + 落盘 .sillyspec/.runtime/doctor-diagnosis.json
|
|
81
92
|
sillyspec modules <rebuild | status | migrate>
|
|
82
93
|
sillyspec change-rename <旧变更名> <新变更名>
|
|
83
94
|
sillyspec knowledge <search --query "..." --limit N
|
|
@@ -175,6 +186,13 @@ async function main() {
|
|
|
175
186
|
}
|
|
176
187
|
}
|
|
177
188
|
|
|
189
|
+
// F3:--interactive(全局 flag,被上面吞进 interactive 变量)与 --non-interactive(透传到 filteredArgs)
|
|
190
|
+
// 语义相反,同时给是矛盾。在此层检测(command.js 看不到 --interactive)。
|
|
191
|
+
if (interactive && filteredArgs.includes('--non-interactive')) {
|
|
192
|
+
console.error('❌ --non-interactive 与 --interactive 互斥(一个禁用交互、一个强制交互),请只保留一个。');
|
|
193
|
+
process.exit(2);
|
|
194
|
+
}
|
|
195
|
+
|
|
178
196
|
const command = filteredArgs[0];
|
|
179
197
|
// 支持 sillyspec init /path/to/project 语法:如果第二个参数看起来像路径,当作 targetDir
|
|
180
198
|
if (command === 'init' && filteredArgs[1] && !filteredArgs[1].startsWith('-')) {
|
|
@@ -233,9 +251,19 @@ async function main() {
|
|
|
233
251
|
case 'show':
|
|
234
252
|
pm.show(progDir, progChangeName);
|
|
235
253
|
break;
|
|
236
|
-
case 'check':
|
|
237
|
-
await pm.checkConsistency(progDir, progChangeName);
|
|
254
|
+
case 'check': {
|
|
255
|
+
const result = await pm.checkConsistency(progDir, progChangeName);
|
|
256
|
+
// checkConsistency 只返回 {ok, issues, warnings},不打印——必须在此输出,
|
|
257
|
+
// 否则 agent 跑只读健康检查得到一片寂静,无法分辨「正常」还是「已损坏」。
|
|
258
|
+
if (!result || result.ok) {
|
|
259
|
+
console.log(`✅ 状态一致性检查通过${progChangeName ? `(变更 ${progChangeName})` : '(所有变更)'}`);
|
|
260
|
+
} else {
|
|
261
|
+
console.error(`❌ 状态一致性检查发现问题(${result.issues?.length || 0} 项):`);
|
|
262
|
+
for (const issue of (result.issues || [])) console.error(` - ${issue}`);
|
|
263
|
+
console.error(` 修复:sillyspec progress repair${progChangeName ? ` --change ${progChangeName}` : ''}(先 dry-run 看可修项,加 --apply 执行)`);
|
|
264
|
+
}
|
|
238
265
|
break;
|
|
266
|
+
}
|
|
239
267
|
case 'repair': {
|
|
240
268
|
const repairApply = filteredArgs.includes('--apply');
|
|
241
269
|
await pm.repairConsistency(progDir, { apply: repairApply, changeName: progChangeName });
|
|
@@ -340,8 +368,8 @@ async function main() {
|
|
|
340
368
|
for (const e of (c.errors || [])) console.log(` ✗ ${e}`);
|
|
341
369
|
for (const w of (c.warnings || [])) console.log(` ⚠ ${w}`);
|
|
342
370
|
}
|
|
343
|
-
|
|
344
|
-
|
|
371
|
+
// 顶层 errors/warnings 是各 check 的聚合,已在上面按 check 分组显示;不再重复打印——
|
|
372
|
+
// 非 json 是 agent 默认消费路径,重复文本纯耗 context(W1-J)。
|
|
345
373
|
}
|
|
346
374
|
process.exitCode = exitCode;
|
|
347
375
|
break;
|
|
@@ -402,7 +430,7 @@ async function main() {
|
|
|
402
430
|
const { runCommand } = await import('./run.js')
|
|
403
431
|
// 平台模式(--spec-dir 已指定)时,--dir 是明确的 source_root,不应被 resolveEffectiveDir 纠正
|
|
404
432
|
const effectiveDir = specDir ? dir : resolveEffectiveDir(dir)
|
|
405
|
-
await runCommand(filteredArgs.slice(1), effectiveDir, specDir)
|
|
433
|
+
await runCommand(filteredArgs.slice(1), effectiveDir, specDir, { json })
|
|
406
434
|
break
|
|
407
435
|
}
|
|
408
436
|
// 顶层命令别名:转发 runCommand,与 case 'run': 路径行为一致。
|
|
@@ -513,7 +541,7 @@ async function main() {
|
|
|
513
541
|
}
|
|
514
542
|
// 否则:保持原有 prompt 驱动的 bash 自检流程
|
|
515
543
|
const { runCommand } = await import('./run.js');
|
|
516
|
-
await runCommand([command, ...filteredArgs.slice(1)], doctorEffectiveDir, specDir);
|
|
544
|
+
await runCommand([command, ...filteredArgs.slice(1)], doctorEffectiveDir, specDir, { json });
|
|
517
545
|
break;
|
|
518
546
|
}
|
|
519
547
|
case 'scan':
|
|
@@ -528,7 +556,7 @@ async function main() {
|
|
|
528
556
|
const { runCommand } = await import('./run.js')
|
|
529
557
|
const stageArgs = [command, ...filteredArgs.slice(1)]
|
|
530
558
|
const effectiveDir = specDir ? dir : resolveEffectiveDir(dir)
|
|
531
|
-
await runCommand(stageArgs, effectiveDir, specDir)
|
|
559
|
+
await runCommand(stageArgs, effectiveDir, specDir, { json })
|
|
532
560
|
break
|
|
533
561
|
}
|
|
534
562
|
case 'knowledge': {
|
|
@@ -610,6 +638,9 @@ SillySpec worktree — git worktree 隔离管理
|
|
|
610
638
|
console.error('❌ 用法: sillyspec worktree create <change-name> [--base <branch>]');
|
|
611
639
|
process.exit(1);
|
|
612
640
|
}
|
|
641
|
+
// F6 路径穿越消毒:worktree 名用于分支名 + worktree 目录,含 ../ 会逃出 .sillyspec/worktrees/。
|
|
642
|
+
try { assertSafeChangeName(wtName, 'worktree 变更名'); }
|
|
643
|
+
catch (e) { console.error(`❌ ${e.message}`); process.exit(2); }
|
|
613
644
|
const baseIdx = args.indexOf('--base');
|
|
614
645
|
const base = baseIdx >= 0 && args[baseIdx + 1] ? args[baseIdx + 1] : undefined;
|
|
615
646
|
try {
|
|
@@ -662,7 +693,14 @@ SillySpec worktree — git worktree 隔离管理
|
|
|
662
693
|
} else if (result.merged) {
|
|
663
694
|
console.log(`✅ 已通过 git merge 应用变更(baseline 漂移降级,引入合并提交)${result.mergeSummary ? ':' + result.mergeSummary : ''}`);
|
|
664
695
|
} else {
|
|
665
|
-
console.log(`✅ 已应用 ${result.changedFiles.length}
|
|
696
|
+
console.log(`✅ 已应用 ${result.changedFiles.length} 个文件变更:`);
|
|
697
|
+
for (const f of result.changedFiles) {
|
|
698
|
+
console.log(` ${f}`);
|
|
699
|
+
}
|
|
700
|
+
// filterDeliverableFiles 一刀切排除 .sillyspec/ + meta.json:
|
|
701
|
+
// 模块文档/变更文档等不会 auto-apply 到主工作区。须告知 agent,
|
|
702
|
+
// 否则它期望这些文件落地却找不到(memory: worktree-apply-excludes-module-docs)。
|
|
703
|
+
console.log(` ℹ️ 注:.sillyspec/ 下的文件(模块文档/变更文档等)按规则不自动 apply,如需请手动从 worktree 分支或 dangling commit 取(git show sillyspec/${wtName}:<path>)。`);
|
|
666
704
|
}
|
|
667
705
|
if (result.warnings && result.warnings.length > 0) {
|
|
668
706
|
for (const w of result.warnings) {
|
|
@@ -708,7 +746,9 @@ SillySpec worktree — git worktree 隔离管理
|
|
|
708
746
|
if (applyResult.errors.length > 0) {
|
|
709
747
|
console.error('❌ apply 失败:', applyResult.errors.join('; '));
|
|
710
748
|
} else {
|
|
711
|
-
console.log(`✅ 已自动应用 ${applyResult.changedFiles.length}
|
|
749
|
+
console.log(`✅ 已自动应用 ${applyResult.changedFiles.length} 个文件变更:`);
|
|
750
|
+
for (const f of applyResult.changedFiles) console.log(` ${f}`);
|
|
751
|
+
console.log(` ℹ️ 注:.sillyspec/ 下的文件按规则不自动 apply,如需请手动从分支取(git show sillyspec/${wtName}:<path>)。`);
|
|
712
752
|
}
|
|
713
753
|
} else {
|
|
714
754
|
console.log('Action: blocked');
|
|
@@ -970,6 +1010,14 @@ SillySpec platform — SillyHub 平台同步
|
|
|
970
1010
|
console.error('❌ 用法: sillyspec change-rename <旧变更名> <新变更名>');
|
|
971
1011
|
process.exit(1);
|
|
972
1012
|
}
|
|
1013
|
+
// F6 路径穿越消毒:renameChange 会 mv changes/<old> → changes/<new>,名含 ../ 会逃出 changes/。
|
|
1014
|
+
try {
|
|
1015
|
+
assertSafeChangeName(oldName, '旧变更名');
|
|
1016
|
+
assertSafeChangeName(newName, '新变更名');
|
|
1017
|
+
} catch (e) {
|
|
1018
|
+
console.error(`❌ ${e.message}`);
|
|
1019
|
+
process.exit(2);
|
|
1020
|
+
}
|
|
973
1021
|
const pm = new ProgressManager({ specDir: resolvePlatformSpecDir(dir, specDir) });
|
|
974
1022
|
await pm.renameChange(dir, oldName, newName);
|
|
975
1023
|
break;
|
|
@@ -1100,7 +1148,11 @@ SillySpec workflow — 工作流管理
|
|
|
1100
1148
|
}
|
|
1101
1149
|
process.exit(result.status === 'pass' ? 0 : 1);
|
|
1102
1150
|
} else {
|
|
1151
|
+
const wfSubs = ['check', 'list'];
|
|
1152
|
+
const sug = didYouMean(wfSub, wfSubs);
|
|
1103
1153
|
console.error(`❌ 未知子命令: workflow ${wfSub}`);
|
|
1154
|
+
if (sug) console.error(` 你是想输入「workflow ${sug}」吗?`);
|
|
1155
|
+
console.error(` 可用子命令:${wfSubs.join(' | ')}(运行 sillyspec workflow 查看用法)`);
|
|
1104
1156
|
process.exit(1);
|
|
1105
1157
|
}
|
|
1106
1158
|
break;
|
|
@@ -1128,7 +1180,11 @@ SillySpec modules — 模块文档管理
|
|
|
1128
1180
|
const { migrateModuleDocs } = await import('./modules.js');
|
|
1129
1181
|
await migrateModuleDocs(dir);
|
|
1130
1182
|
} else {
|
|
1183
|
+
const modSubs = ['rebuild', 'status', 'migrate'];
|
|
1184
|
+
const sug = didYouMean(modulesSub, modSubs);
|
|
1131
1185
|
console.error(`❌ 未知子命令: modules ${modulesSub}`);
|
|
1186
|
+
if (sug) console.error(` 你是想输入「modules ${sug}」吗?`);
|
|
1187
|
+
console.error(` 可用子命令:${modSubs.join(' | ')}(运行 sillyspec modules 查看用法)`);
|
|
1132
1188
|
process.exit(1);
|
|
1133
1189
|
}
|
|
1134
1190
|
break;
|
|
@@ -1180,10 +1236,83 @@ SillySpec modules — 模块文档管理
|
|
|
1180
1236
|
console.log(` 路径: ${localYamlPath}`);
|
|
1181
1237
|
break;
|
|
1182
1238
|
}
|
|
1183
|
-
|
|
1239
|
+
case 'runtime': {
|
|
1240
|
+
// D2:.runtime/ 产物索引。CLI 往 .runtime/ 写各种证据/状态文件(db、doctor-diagnosis、
|
|
1241
|
+
// workflow-runs、gate-status、user-inputs…),却没有命令让 agent 一眼看到「我手上有哪些产物」。
|
|
1242
|
+
// 多会话/压缩后尤其痛:上轮写的证据文件,下轮不知去哪找。纯只读枚举,不写盘。
|
|
1243
|
+
const runtimeSubCmd = filteredArgs[1];
|
|
1244
|
+
if (runtimeSubCmd && runtimeSubCmd !== 'list') {
|
|
1245
|
+
const sug = didYouMean(runtimeSubCmd, ['list']);
|
|
1246
|
+
console.error(`❌ 未知子命令: runtime ${runtimeSubCmd}`);
|
|
1247
|
+
if (sug) console.error(` 你是想输入「runtime ${sug}」吗?`);
|
|
1248
|
+
console.error('用法: sillyspec runtime list 枚举 .sillyspec/.runtime/ 产物(只读)');
|
|
1249
|
+
process.exit(2);
|
|
1250
|
+
}
|
|
1251
|
+
const specRoot = resolvePlatformSpecDir(dir, specDir) || join(dir, '.sillyspec');
|
|
1252
|
+
const runtimeDir = join(specRoot, '.runtime');
|
|
1253
|
+
if (!existsSync(runtimeDir)) {
|
|
1254
|
+
if (json) { console.log(JSON.stringify({ runtimeDir, exists: false, artifacts: [] }, null, 2)); break; }
|
|
1255
|
+
console.log(`📭 无 .runtime/ 目录(${runtimeDir})——尚未产生任何运行时产物。`);
|
|
1256
|
+
break;
|
|
1257
|
+
}
|
|
1258
|
+
// 已知产物 → 用途说明。agent 据此判断每份产物能否作为证据/该读哪个。
|
|
1259
|
+
const KNOWN = {
|
|
1260
|
+
'sillyspec.db': '进度库 SQLite(stages/steps/changes 表)',
|
|
1261
|
+
'sillyspec.db.bak': '进度库备份(写前快照)',
|
|
1262
|
+
'sillyspec.db.schema-version': '进度库 schema 版本标记',
|
|
1263
|
+
'doctor-diagnosis.json': 'doctor --json 结构化诊断快照',
|
|
1264
|
+
'doctor-dumps': 'doctor --dump-db 取证输出目录',
|
|
1265
|
+
'workflow-runs': 'workflow check --save 归档目录',
|
|
1266
|
+
'gate-status.json': '门控结果缓存',
|
|
1267
|
+
'scan-guard.json': 'scan 覆盖保护记录',
|
|
1268
|
+
'quick-sessions': 'quick 会话记录目录',
|
|
1269
|
+
'quick-guard.json': 'quick baseline 守卫(旧位置)',
|
|
1270
|
+
'worktrees': 'worktree meta(hooks 侧)',
|
|
1271
|
+
'user-inputs.md': '步骤输出/输入历史追加日志',
|
|
1272
|
+
'current-quick-run-id': '当前 quick sessionId 指针(--done 无 --change 时 fallback)',
|
|
1273
|
+
'scan-runs': '平台模式 scan/workflow 取证目录',
|
|
1274
|
+
'artifacts': '派生产物目录(init 预建)',
|
|
1275
|
+
'contract-artifacts': 'execute endpoint 契约产物(verify 阶段读取)',
|
|
1276
|
+
'history': '历史记录目录(init 预建)',
|
|
1277
|
+
'logs': '日志目录(init 预建)',
|
|
1278
|
+
'templates': '工作流模板目录(init 预建)',
|
|
1279
|
+
};
|
|
1280
|
+
let entries;
|
|
1281
|
+
try {
|
|
1282
|
+
entries = readdirSync(runtimeDir, { withFileTypes: true });
|
|
1283
|
+
} catch (e) {
|
|
1284
|
+
console.error(`❌ 读取 .runtime/ 失败: ${e.message}`);
|
|
1285
|
+
process.exit(1);
|
|
1286
|
+
}
|
|
1287
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
1288
|
+
if (json) {
|
|
1289
|
+
console.log(JSON.stringify({
|
|
1290
|
+
runtimeDir, exists: true,
|
|
1291
|
+
artifacts: entries.map(e => ({ name: e.name, type: e.isDirectory() ? 'dir' : 'file', desc: KNOWN[e.name] || null })),
|
|
1292
|
+
}, null, 2));
|
|
1293
|
+
break;
|
|
1294
|
+
}
|
|
1295
|
+
console.log(`📂 .runtime/ 产物(${runtimeDir})`);
|
|
1296
|
+
if (entries.length === 0) { console.log(' (空目录)'); break; }
|
|
1297
|
+
for (const e of entries) {
|
|
1298
|
+
const kind = e.isDirectory() ? '📁' : '📄';
|
|
1299
|
+
const desc = KNOWN[e.name] ? ` — ${KNOWN[e.name]}` : '';
|
|
1300
|
+
console.log(` ${kind} ${e.name}${desc}`);
|
|
1301
|
+
}
|
|
1302
|
+
const unknown = entries.filter(e => !KNOWN[e.name]);
|
|
1303
|
+
if (unknown.length > 0) {
|
|
1304
|
+
console.log(`\n (${unknown.length} 项未登记:${unknown.map(e => e.name).join(', ')})`);
|
|
1305
|
+
}
|
|
1306
|
+
break;
|
|
1307
|
+
}
|
|
1308
|
+
default: {
|
|
1309
|
+
const topCommands = ['init', 'setup', 'run', 'progress', 'worktree', 'local', 'workflow', 'gate', 'derive', 'modules', 'change-rename', 'knowledge', 'platform', 'scan', 'brainstorm', 'plan', 'execute', 'verify', 'archive', 'quick', 'explore', 'status', 'doctor', 'auto', 'runtime'];
|
|
1310
|
+
const suggestion = didYouMean(command, topCommands);
|
|
1184
1311
|
console.error(`❌ 未知命令: ${command}`);
|
|
1312
|
+
if (suggestion) console.error(` 你是想输入「${suggestion}」吗?`);
|
|
1185
1313
|
printUsage();
|
|
1186
1314
|
process.exit(1);
|
|
1315
|
+
}
|
|
1187
1316
|
}
|
|
1188
1317
|
}
|
|
1189
1318
|
|
package/src/machine-interface.js
CHANGED
|
@@ -125,10 +125,16 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot }
|
|
|
125
125
|
const currentStage = progress.currentStage || '';
|
|
126
126
|
const checks = [];
|
|
127
127
|
|
|
128
|
+
// W4-G (D-008):execute 阶段预计算 code evidence 一次,供 artifacts check(经 runValidators
|
|
129
|
+
// → validateExecuteOutputs)与下方 execute-evidence check 共享——避免各调一次 checkExecuteCodeEvidence
|
|
130
|
+
// 各 spawn 2 个 git 进程(gate execute 一次省 ≈ 60-200ms on Windows)。
|
|
131
|
+
const sharedEvidence = stage === 'execute' ? checkExecuteCodeEvidence(cwd, changeName) : undefined;
|
|
132
|
+
|
|
128
133
|
// ── a. artifacts:阶段产物校验(全部阶段)──
|
|
129
134
|
const r = runValidators(stage, cwd, changeName, {
|
|
130
135
|
projectName: progress.project,
|
|
131
136
|
specRoot,
|
|
137
|
+
...(sharedEvidence ? { evidence: sharedEvidence } : {}),
|
|
132
138
|
});
|
|
133
139
|
checks.push({
|
|
134
140
|
id: 'artifacts',
|
|
@@ -137,24 +143,24 @@ export async function runGate(stage, changeName, { cwd, specBase, runtimeRoot }
|
|
|
137
143
|
warnings: r.warnings || [],
|
|
138
144
|
});
|
|
139
145
|
|
|
140
|
-
// ── b. transition
|
|
141
|
-
// 与 run.js
|
|
142
|
-
// transition
|
|
146
|
+
// ── b. transition:状态转换合法性(参与综合 ok,与 completeStep 硬阻断一致)──
|
|
147
|
+
// 与 run.js runStage 同源:传 fromStageData 触发 failed_post_check 门控。
|
|
148
|
+
// transition 必须参与 ok:否则 gate 返回 ok=true、exit 0,Agent 据此 --done 却被
|
|
149
|
+
// runStage 的 checkTransition 硬阻断(exit 1),gate/run 判定分裂(design §8 漂移)。
|
|
143
150
|
const fromStageData = (progress.stages && currentStage && progress.stages[currentStage]) || undefined;
|
|
144
151
|
const t = checkTransition(currentStage, stage, fromStageData ? { fromStageData } : {});
|
|
145
152
|
checks.push({
|
|
146
153
|
id: 'transition',
|
|
147
154
|
ok: t.allowed,
|
|
148
|
-
informational: true,
|
|
149
155
|
errors: t.allowed ? [] : [t.reason].filter(Boolean),
|
|
150
156
|
warnings: [],
|
|
151
157
|
});
|
|
152
158
|
|
|
153
159
|
// ── c. execute 阶段追加 execute-evidence + task-reviews ──
|
|
154
|
-
// D-008
|
|
155
|
-
//
|
|
160
|
+
// D-008 + W4-G:checkExecuteCodeEvidence 整个 runGate 只调一次(顶部 sharedEvidence 预计算),
|
|
161
|
+
// 这里复用——真正落实去重(原注释声称"只调一次"但实现里 execute-evidence check 仍重复调了第二次)。
|
|
156
162
|
if (stage === 'execute') {
|
|
157
|
-
const ev =
|
|
163
|
+
const ev = sharedEvidence;
|
|
158
164
|
checks.push({
|
|
159
165
|
id: 'execute-evidence',
|
|
160
166
|
ok: ev.status !== 'unchanged',
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// W6 Step9b: 变更注册表(changes 表生命周期)—— 注册/注销/重命名/隔离状态/平台同步戳/审批状态。
|
|
2
|
+
// 从 src/progress.js 单体 ProgressManager 抽出。持有 pm 引用(构造注入),调 pm._ensureDB /
|
|
3
|
+
// pm._changePath(persistence-core 留 facade 本体)。无共享常量依赖。
|
|
4
|
+
import { existsSync, mkdirSync, renameSync } from 'fs';
|
|
5
|
+
|
|
6
|
+
export class ChangeRegistry {
|
|
7
|
+
constructor(pm) {
|
|
8
|
+
this.pm = pm;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 列出所有活跃变更名
|
|
13
|
+
* SQL: SELECT name FROM changes WHERE status = 'active'
|
|
14
|
+
*/
|
|
15
|
+
async listChanges(cwd) {
|
|
16
|
+
const db = await this.pm._ensureDB(cwd);
|
|
17
|
+
const sqlDb = db.getDb();
|
|
18
|
+
const rows = sqlDb.exec("SELECT name FROM changes WHERE status = 'active' ORDER BY name");
|
|
19
|
+
if (!rows || rows.length === 0) return [];
|
|
20
|
+
return rows[0].values.map(r => r[0]);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 注册变更到活跃列表
|
|
25
|
+
* SQL: INSERT OR IGNORE → 若已 archived 则 UPDATE status='active'
|
|
26
|
+
*/
|
|
27
|
+
async registerChange(cwd, changeName) {
|
|
28
|
+
if (!changeName) {
|
|
29
|
+
console.warn('⚠️ registerChange: changeName 为空,跳过');
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const db = await this.pm._ensureDB(cwd);
|
|
33
|
+
db.transaction((sqlDb) => {
|
|
34
|
+
const now = new Date().toISOString();
|
|
35
|
+
// 尝试插入新行
|
|
36
|
+
sqlDb.run(
|
|
37
|
+
`INSERT OR IGNORE INTO changes (name, created_at, last_active)
|
|
38
|
+
VALUES (?, ?, ?)`,
|
|
39
|
+
[changeName, now, now]
|
|
40
|
+
);
|
|
41
|
+
// 注意:不复活已归档的变更——归档是不可逆操作
|
|
42
|
+
// 如果变更已存在且为 archived,保持 archived 状态不变
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 更新变更的隔离状态
|
|
48
|
+
* @param {string} cwd - 项目根目录
|
|
49
|
+
* @param {string} changeName - 变更名
|
|
50
|
+
* @param {{ status: string, mode?: string, reason?: string }} isolation
|
|
51
|
+
*/
|
|
52
|
+
async updateChangeIsolation(cwd, changeName, isolation) {
|
|
53
|
+
const db = await this.pm._ensureDB(cwd);
|
|
54
|
+
const sqlDb = db.getDb();
|
|
55
|
+
try {
|
|
56
|
+
sqlDb.run(
|
|
57
|
+
`UPDATE changes SET isolation_status = ?, isolation_mode = ?, isolation_reason = ?, last_active = ? WHERE name = ?`,
|
|
58
|
+
[isolation.status, isolation.mode || null, isolation.reason || null, new Date().toISOString(), changeName]
|
|
59
|
+
);
|
|
60
|
+
db._save();
|
|
61
|
+
} catch (err) {
|
|
62
|
+
console.warn('⚠️ 更新 isolation 状态失败:', err.message);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 读取变更的隔离状态
|
|
68
|
+
* @param {string} cwd - 项目根目录
|
|
69
|
+
* @param {string} changeName - 变更名
|
|
70
|
+
* @returns {{ status: string|null, mode: string|null, reason: string|null }|null}
|
|
71
|
+
*/
|
|
72
|
+
async readChangeIsolation(cwd, changeName) {
|
|
73
|
+
const db = await this.pm._ensureDB(cwd);
|
|
74
|
+
const sqlDb = db.getDb();
|
|
75
|
+
try {
|
|
76
|
+
const rows = sqlDb.exec(
|
|
77
|
+
`SELECT isolation_status, isolation_mode, isolation_reason FROM changes WHERE name = ?`,
|
|
78
|
+
[changeName]
|
|
79
|
+
);
|
|
80
|
+
if (!rows || rows.length === 0 || rows[0].values.length === 0) return null;
|
|
81
|
+
const [status, mode, reason] = rows[0].values[0];
|
|
82
|
+
return { status: status || null, mode: mode || null, reason: reason || null };
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async _updatePlatformLastSync(cwd, changeName) {
|
|
89
|
+
if (!changeName) return;
|
|
90
|
+
const db = await this.pm._ensureDB(cwd);
|
|
91
|
+
db.transaction((sqlDb) => {
|
|
92
|
+
sqlDb.run(
|
|
93
|
+
'UPDATE changes SET platform_last_sync = ?, platform_sync_enabled = 1 WHERE name = ?',
|
|
94
|
+
[new Date().toISOString(), changeName]
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async _updateApprovalStatus(cwd, changeName, status, reason = null) {
|
|
100
|
+
if (!changeName || !status) return;
|
|
101
|
+
const db = await this.pm._ensureDB(cwd);
|
|
102
|
+
db.transaction((sqlDb) => {
|
|
103
|
+
const rows = sqlDb.exec('SELECT id FROM changes WHERE name = ?', [changeName]);
|
|
104
|
+
if (!rows || rows.length === 0 || rows[0].values.length === 0) return;
|
|
105
|
+
const changeId = rows[0].values[0][0];
|
|
106
|
+
const now = new Date().toISOString();
|
|
107
|
+
sqlDb.run(
|
|
108
|
+
`INSERT INTO approvals (change_id, status, requested_at, approved_at, rejection_reason)
|
|
109
|
+
VALUES (?, ?, ?, ?, ?)
|
|
110
|
+
ON CONFLICT(change_id) DO UPDATE SET
|
|
111
|
+
status = excluded.status,
|
|
112
|
+
approved_at = excluded.approved_at,
|
|
113
|
+
rejection_reason = excluded.rejection_reason`,
|
|
114
|
+
[
|
|
115
|
+
changeId,
|
|
116
|
+
status,
|
|
117
|
+
now,
|
|
118
|
+
status === 'approved' ? now : null,
|
|
119
|
+
status === 'rejected' ? reason : null,
|
|
120
|
+
]
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 重命名变更:同步更新 DB + 目录
|
|
127
|
+
* @param {string} cwd - 项目根目录
|
|
128
|
+
* @param {string} oldName - 旧变更名
|
|
129
|
+
* @param {string} newName - 新变更名
|
|
130
|
+
*/
|
|
131
|
+
async renameChange(cwd, oldName, newName) {
|
|
132
|
+
if (!oldName || !newName) {
|
|
133
|
+
console.warn('⚠️ renameChange: 旧名或新名为空,跳过');
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (oldName === newName) {
|
|
137
|
+
console.warn('⚠️ renameChange: 新旧名称相同,跳过');
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const db = await this.pm._ensureDB(cwd);
|
|
141
|
+
// 检查旧名是否存在
|
|
142
|
+
const existing = db.transaction((sqlDb) => {
|
|
143
|
+
const row = sqlDb.exec(`SELECT name, status FROM changes WHERE name = ?`, [oldName]);
|
|
144
|
+
if (!row || !row[0] || row[0].values.length === 0) return null;
|
|
145
|
+
return { name: row[0].values[0][0], status: row[0].values[0][1] };
|
|
146
|
+
});
|
|
147
|
+
if (!existing) {
|
|
148
|
+
console.error(`❌ 变更 ${oldName} 不存在`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
// 检查新名是否已存在
|
|
152
|
+
const conflict = db.transaction((sqlDb) => {
|
|
153
|
+
const row = sqlDb.exec(`SELECT name FROM changes WHERE name = ?`, [newName]);
|
|
154
|
+
return row && row[0] && row[0].values.length > 0;
|
|
155
|
+
});
|
|
156
|
+
if (conflict) {
|
|
157
|
+
console.error(`❌ 变更 ${newName} 已存在`);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
// 先更新 DB,再重命名目录;FS 失败则回滚 DB,避免"目录已改名但 DB 旧名"的孤儿
|
|
161
|
+
// (旧实现 FS-first 无补偿:renameSync 成功后 DB transaction 抛 EPERM/EBUSY 会让
|
|
162
|
+
// 目录已是 newName、DB 仍是 oldName,read(两名) 都失联且无自动恢复)。
|
|
163
|
+
const oldDir = this.pm._changePath(cwd, oldName);
|
|
164
|
+
const newDir = this.pm._changePath(cwd, newName);
|
|
165
|
+
const now = new Date().toISOString();
|
|
166
|
+
try {
|
|
167
|
+
db.transaction((sqlDb) => {
|
|
168
|
+
sqlDb.run(`UPDATE changes SET name = ?, last_active = ? WHERE name = ?`, [newName, now, oldName]);
|
|
169
|
+
});
|
|
170
|
+
} catch (e) {
|
|
171
|
+
console.error(`❌ 重命名失败:更新数据库时出错(${e.message})`);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
let renamed = true;
|
|
175
|
+
if (existsSync(oldDir)) {
|
|
176
|
+
try {
|
|
177
|
+
renameSync(oldDir, newDir);
|
|
178
|
+
} catch (e) {
|
|
179
|
+
renamed = false;
|
|
180
|
+
// FS 重命名失败:回滚 DB 恢复 oldName,保持 DB 与目录一致
|
|
181
|
+
try {
|
|
182
|
+
db.transaction((sqlDb) => {
|
|
183
|
+
sqlDb.run(`UPDATE changes SET name = ?, last_active = ? WHERE name = ?`, [oldName, now, newName]);
|
|
184
|
+
});
|
|
185
|
+
console.error(`❌ 重命名失败:移动目录出错(${e.message}),已回滚数据库`);
|
|
186
|
+
} catch (rollbackErr) {
|
|
187
|
+
// 回滚本身也失败:DB 是 newName、目录是 oldName,两端分裂且无自动恢复。
|
|
188
|
+
// 不能再撒谎"已回滚"——必须显式报错让用户跑 doctor 修复。
|
|
189
|
+
console.error(`❌ 重命名失败且数据库回滚也失败(状态分裂,需手动修复):原错=${e.message} 回滚错=${rollbackErr.message}。请跑 sillyspec doctor --json`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
mkdirSync(newDir, { recursive: true });
|
|
194
|
+
}
|
|
195
|
+
if (renamed) console.log(`✅ 变更已重命名:${oldName} → ${newName}`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 从活跃列表移除变更(归档时调用,不物理删除)
|
|
200
|
+
* SQL: UPDATE changes SET status = 'archived'
|
|
201
|
+
*/
|
|
202
|
+
async unregisterChange(cwd, changeName) {
|
|
203
|
+
if (!changeName) {
|
|
204
|
+
console.warn('⚠️ unregisterChange: changeName 为空,跳过');
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const db = await this.pm._ensureDB(cwd);
|
|
208
|
+
db.transaction((sqlDb) => {
|
|
209
|
+
const now = new Date().toISOString();
|
|
210
|
+
sqlDb.run(
|
|
211
|
+
`UPDATE changes SET status = 'archived', last_active = ? WHERE name = ?`,
|
|
212
|
+
[now, changeName]
|
|
213
|
+
);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|