sillyspec 3.26.13 → 3.26.14
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/package.json +1 -1
- package/src/index.js +39 -15
- package/src/progress.js +5 -2
- package/src/run/command.js +57 -38
- package/src/run/complete-handlers.js +6 -1
- package/src/run/complete.js +6 -1
- package/src/run/concurrent-detect.js +3 -1
- package/src/run/gates.js +24 -0
- package/src/run/prompt.js +8 -4
- package/src/run/scan-profile.js +35 -23
- package/src/run/shared.js +26 -5
- package/src/stage-review.js +40 -1
- package/src/stages/brainstorm.js +25 -12
- package/src/stages/doctor.js +2 -4
- package/src/stages/execute.js +6 -1
- package/src/stages/knowledge.js +18 -3
- package/src/stages/plan-postcheck.js +24 -10
- package/src/stages/plan.js +14 -9
- package/src/sync.js +19 -7
- package/src/task-review.js +38 -6
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -90,7 +90,7 @@ SillySpec CLI — 规范驱动开发工具包
|
|
|
90
90
|
sillyspec gate <stage> --change <name> [--json] 机器门控:阶段能否标记完成(只读)
|
|
91
91
|
sillyspec derive <facet> --change <name> [--json] 单项事实核验(facet: execute-evidence|verify-test|task-reviews|artifacts)
|
|
92
92
|
sillyspec backfill-reviews --change <name> [--json] 为手动补的 task 生成 review.json 草稿(cannot_verify,解 archive 客观完成度阻断)
|
|
93
|
-
sillyspec register-stage-review --change <name> --stage <brainstorm|plan|execute> [--from <review.json>] [--json]
|
|
93
|
+
sillyspec register-stage-review --change <name> (--stage <brainstorm|plan|execute> | --all) [--from <review.json>] [--refresh-hash] [--json]
|
|
94
94
|
生成/adopt stage 级 review.json(docHash 自动算 + 写 marker,治 tier=independent marker 死锁)
|
|
95
95
|
|
|
96
96
|
sillyspec doctor [子命令] 进度库健康检查 + 修复(顶层命令,非 worktree doctor)
|
|
@@ -574,8 +574,14 @@ async function main() {
|
|
|
574
574
|
const rsrStage = rsrStageIdx >= 0 && args[rsrStageIdx + 1] ? args[rsrStageIdx + 1] : null;
|
|
575
575
|
const rsrFromIdx = args.indexOf('--from');
|
|
576
576
|
const rsrFrom = rsrFromIdx >= 0 && args[rsrFromIdx + 1] ? args[rsrFromIdx + 1] : null;
|
|
577
|
-
|
|
578
|
-
|
|
577
|
+
// docHash 联动(坑 stage-review-dochash-manual-resync,2026-08-21 实证):改一版 design 要
|
|
578
|
+
// 重算 2-3 个 stage review 的 docHash,手工易漏、gate 报错才补。--refresh-hash 就地刷新单个
|
|
579
|
+
// 既有 review 的 hash(保留结论);--all 一次处理三个 stage(有 review 刷 hash,无 review
|
|
580
|
+
// 生成骨架——改版后一条命令完成全部联动)。
|
|
581
|
+
const rsrRefreshHash = filteredArgs.includes('--refresh-hash');
|
|
582
|
+
const rsrAll = filteredArgs.includes('--all');
|
|
583
|
+
if (!rsrChange || (!rsrStage && !rsrAll) || (rsrAll && rsrStage)) {
|
|
584
|
+
console.error('用法: sillyspec register-stage-review --change <名> (--stage <brainstorm|plan|execute> | --all) [--from <review.json>] [--refresh-hash] [--spec-dir <path>] [--json]\n 生成/adopt/刷新 stage 级 review.json(docHash 自动算 + 写 marker);--all 一次处理三个 stage(有 review 刷新 hash、无 review 生成骨架);--refresh-hash 就地刷新既有 review 的 docHash(保留 verdict 待确认续用)');
|
|
579
585
|
process.exit(2);
|
|
580
586
|
}
|
|
581
587
|
// 与 run 入口同源消毒(防路径穿越;register 下游写 marker、拼 changes/review 路径)
|
|
@@ -583,20 +589,38 @@ async function main() {
|
|
|
583
589
|
const { registerStageReview } = await import('./stage-review.js');
|
|
584
590
|
const rsrPlatformOpts = {};
|
|
585
591
|
if (specDir) rsrPlatformOpts.specRoot = specDir;
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
592
|
+
const rsrStages = rsrAll ? ['brainstorm', 'plan', 'execute'] : [rsrStage];
|
|
593
|
+
const rsrResults = [];
|
|
594
|
+
let rsrFailed = 0;
|
|
595
|
+
for (const st of rsrStages) {
|
|
596
|
+
try {
|
|
597
|
+
// --all 的单 stage 语义:有既有 review → refresh-hash;无 → 骨架。显式 --refresh-hash
|
|
598
|
+
// 则强制 refresh(无既有 review 时报错,提示先生成骨架)
|
|
599
|
+
const { getLatestStageReviewRunId, stageReviewMarkerPath } = await import('./stage-review.js');
|
|
600
|
+
const { resolveRuntimeRoot } = await import('./run/shared.js');
|
|
601
|
+
const rsrRuntimeRoot = resolveRuntimeRoot(rsrPlatformOpts, rsrPlatformOpts.specRoot || join(dir, '.sillyspec'));
|
|
602
|
+
const hasExisting = !!getLatestStageReviewRunId(rsrRuntimeRoot, st, rsrChange);
|
|
603
|
+
const wantRefresh = rsrRefreshHash || (rsrAll && hasExisting);
|
|
604
|
+
const result = registerStageReview({ changeName: rsrChange, stage: st, fromFile: rsrFrom, cwd: dir, platformOpts: rsrPlatformOpts, refreshHash: wantRefresh });
|
|
605
|
+
rsrResults.push({ stage: st, ...result });
|
|
606
|
+
if (json) {
|
|
607
|
+
process.stdout.write(JSON.stringify({ ok: true, command: 'register-stage-review', change: rsrChange, stage: st, ...result }) + '\n');
|
|
608
|
+
} else {
|
|
609
|
+
console.log(`✅ 已注册 ${st} stage review [${result.reviewRunId}] → ${result.reviewPath}(mode: ${result.mode})`);
|
|
610
|
+
console.log(` marker → ${result.markerPath}`);
|
|
611
|
+
if (result.mode === 'refreshed') {
|
|
612
|
+
console.log(` docHash 已按当前 ${result.mainDoc} 重算(verdict 结论是否仍适用需人工确认)`);
|
|
613
|
+
} else {
|
|
614
|
+
console.log(` 下一步:独立审查子代理对照 ${result.mainDoc} 填 verdict/checklist 后重跑 --done`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
} catch (e) {
|
|
618
|
+
rsrFailed++;
|
|
619
|
+
if (json) process.stdout.write(JSON.stringify({ ok: false, command: 'register-stage-review', change: rsrChange, stage: st, error: e.message }) + '\n');
|
|
620
|
+
else console.error(`❌ [${st}] ` + e.message);
|
|
594
621
|
}
|
|
595
|
-
} catch (e) {
|
|
596
|
-
if (json) process.stdout.write(JSON.stringify({ ok: false, command: 'register-stage-review', error: e.message }));
|
|
597
|
-
else console.error('❌ ' + e.message);
|
|
598
|
-
process.exitCode = 1;
|
|
599
622
|
}
|
|
623
|
+
if (rsrFailed > 0) process.exitCode = 1;
|
|
600
624
|
break;
|
|
601
625
|
}
|
|
602
626
|
case 'docs': {
|
package/src/progress.js
CHANGED
|
@@ -1188,8 +1188,11 @@ export class ProgressManager {
|
|
|
1188
1188
|
if (projectRow === undefined) return null;
|
|
1189
1189
|
const projectName = projectRow.name;
|
|
1190
1190
|
|
|
1191
|
-
// 2.
|
|
1192
|
-
|
|
1191
|
+
// 2. 活跃变更(取 last_active 最新的一个)
|
|
1192
|
+
// ql-20260821-003:多活跃仓(变更隔离是常态,单仓可同时存在 N 个 active)下
|
|
1193
|
+
// 原 ORDER BY name 恒取字典序最前(通常是最老的变更),「当前变更/最后活动」
|
|
1194
|
+
// 停留在历史数据。dump 语义是「当前工作流状态」→ 取最近活跃的那个。
|
|
1195
|
+
const changeRows = sqlDb.prepare("SELECT id, name, current_stage, last_active FROM changes WHERE status = 'active' ORDER BY last_active DESC").all();
|
|
1193
1196
|
if (changeRows.length === 0) {
|
|
1194
1197
|
// 无活跃变更 → 返回仅含 project 的骨架
|
|
1195
1198
|
return {
|
package/src/run/command.js
CHANGED
|
@@ -368,8 +368,7 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
368
368
|
// 纯只读比较:零会话/QUICKLOG 副作用,仅解析 --base/--full/--report 后转发 scan-diff 退出码。
|
|
369
369
|
if (stageName === 'scan' && flags.includes('--diff')) {
|
|
370
370
|
const { runScanDiff } = await import('../scan-diff.js')
|
|
371
|
-
const
|
|
372
|
-
const diffBase = diffBaseIdx >= 0 && flags[diffBaseIdx + 1] ? flags[diffBaseIdx + 1] : null
|
|
371
|
+
const diffBase = getFlagValue('--base')
|
|
373
372
|
process.exitCode = runScanDiff({
|
|
374
373
|
projectRoot: cwd,
|
|
375
374
|
specBase,
|
|
@@ -418,26 +417,26 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
418
417
|
}
|
|
419
418
|
|
|
420
419
|
// 解析 --output
|
|
420
|
+
// F4 守卫统一走 getFlagValue(坑 raw-flag-value-bypass):裸 flags[idx+1] 会把漏值后紧跟的
|
|
421
|
+
// flag 名当值(--output --input x → output="--input"),--change --done 甚至会建出名为
|
|
422
|
+
// --done 的变更目录(assertSafeChangeName 的 [\w.-] 恰好放行)。getFlagValue 的 F4 注释
|
|
423
|
+
// 点名过本坑,此前只有 from-step 等少数 flag 用了它。
|
|
421
424
|
let outputText = null
|
|
422
|
-
const
|
|
423
|
-
if (
|
|
424
|
-
outputText = flags[outputIdx + 1]
|
|
425
|
-
}
|
|
425
|
+
const outputValue = getFlagValue('--output')
|
|
426
|
+
if (outputValue !== null) outputText = outputValue
|
|
426
427
|
|
|
427
428
|
// 解析 --input
|
|
428
429
|
let inputText = null
|
|
429
|
-
const
|
|
430
|
-
if (
|
|
431
|
-
inputText = flags[inputIdx + 1]
|
|
432
|
-
}
|
|
430
|
+
const inputValue = getFlagValue('--input')
|
|
431
|
+
if (inputValue !== null) inputText = inputValue
|
|
433
432
|
|
|
434
433
|
// 解析 --linked-changes <a,b|none>(quick 专用:显式声明关联变更,CI/脚本友好)
|
|
435
434
|
// 与 --change 解耦:--linked-changes 语义清晰(关联变更),不与「指定变更名」混淆。
|
|
436
435
|
// null = 未指定(走持久化/交互/兼容回退);[] = 显式 none(不关联);[...] = 显式列表
|
|
437
436
|
let explicitLinked = null
|
|
438
|
-
const
|
|
439
|
-
if (
|
|
440
|
-
const v =
|
|
437
|
+
const linkedValue = getFlagValue('--linked-changes')
|
|
438
|
+
if (linkedValue !== null) {
|
|
439
|
+
const v = linkedValue.trim()
|
|
441
440
|
explicitLinked = v.toLowerCase() === 'none' ? [] : v.split(',').map(s => s.trim()).filter(Boolean)
|
|
442
441
|
}
|
|
443
442
|
|
|
@@ -445,9 +444,9 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
445
444
|
// 历史写法,语义与「指定变更名」冲突,新用法建议改用 --linked-changes)
|
|
446
445
|
let changeName = null
|
|
447
446
|
let linkedChanges = []
|
|
448
|
-
const
|
|
449
|
-
if (
|
|
450
|
-
changeName =
|
|
447
|
+
const changeValue = getFlagValue('--change')
|
|
448
|
+
if (changeValue !== null) {
|
|
449
|
+
changeName = changeValue
|
|
451
450
|
if (stageName === 'quick') {
|
|
452
451
|
linkedChanges = changeName.split(',').map(s => s.trim()).filter(Boolean)
|
|
453
452
|
changeName = null
|
|
@@ -489,7 +488,9 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
489
488
|
// 1373-1376 已把 quick 的 --change 值清进 linkedChanges、changeName 置 null。
|
|
490
489
|
// 此处回看 --change 原始值:若恰好是单个 quick-<8hex> → 识别为本会话 sessionId(精确恢复),
|
|
491
490
|
// 并撤销把它当 linkedChanges 的误判。多值或不匹配 → 维持 linkedChanges 语义(旧兼容)。
|
|
492
|
-
|
|
491
|
+
// F4 守卫同款(changeIdx 变量已随裸读清理移除,此处改 getFlagValue 复读原始值)
|
|
492
|
+
const rawChangeValue = getFlagValue('--change')
|
|
493
|
+
const rawChange = rawChangeValue !== null ? rawChangeValue.trim() : null
|
|
493
494
|
const rawIsSingleSid = rawChange && rawChange.indexOf(',') === -1 && QUICK_SID_RE.test(rawChange)
|
|
494
495
|
if (rawIsSingleSid) {
|
|
495
496
|
// --done --change quick-<uuid8>:精确恢复(撤销 1373-1376 把它误当 linkedChanges)
|
|
@@ -549,18 +550,18 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
549
550
|
process.exit(2) // 用法错 → exit 2
|
|
550
551
|
}
|
|
551
552
|
let quickFiles = []
|
|
552
|
-
const
|
|
553
|
-
if (
|
|
554
|
-
quickFiles =
|
|
553
|
+
const filesValue = getFlagValue('--files')
|
|
554
|
+
if (filesValue !== null) {
|
|
555
|
+
quickFiles = filesValue.split(',').map(f => f.trim()).filter(Boolean)
|
|
555
556
|
}
|
|
556
557
|
|
|
557
558
|
// 解析 --file-notes "path::括注 || path::括注"(quick 专用:QUICKLOG「文件:」行落盘多行括注)。
|
|
558
559
|
// 旁路注入 quicklog.js(per-process setter),不经 complete 收尾路径透传(避碰多 agent 并发改的收尾热点)。
|
|
559
560
|
// completeQuicklogEntry 读后即清;不传 → '' → 回填审计到的实际文件单行(向后兼容)。
|
|
560
561
|
let quickFileNotes = ''
|
|
561
|
-
const
|
|
562
|
-
if (
|
|
563
|
-
quickFileNotes =
|
|
562
|
+
const fileNotesValue = getFlagValue('--file-notes')
|
|
563
|
+
if (fileNotesValue !== null) {
|
|
564
|
+
quickFileNotes = fileNotesValue
|
|
564
565
|
}
|
|
565
566
|
setQuickFileNotes(quickFileNotes)
|
|
566
567
|
|
|
@@ -771,16 +772,20 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
771
772
|
let autoName = changeName || resolveChangeNameAuto(cwd, specRoot) || 'default'
|
|
772
773
|
// archive 特例:归档后变更从活跃列表排除(listChanges WHERE status='active'),
|
|
773
774
|
// 不带 --change 回退 default 会读错变更。无活跃变更时取最新归档变更,读其现有 progress。
|
|
775
|
+
// 归档目录名 = 原变更名恒等迁移(archiveDestDirName 不改名),直接原名对读——此前按
|
|
776
|
+
// 「日期前缀过滤 + 剥日期」找,剥出的短名与 DB 行名(含日期)恒不符 → pm.read 返回
|
|
777
|
+
// null → initChange 凭空建幻影变更再跑 archive(坑 archive-fallback-phantom-change)。
|
|
774
778
|
if (stageName === 'archive' && autoName === 'default') {
|
|
775
779
|
try {
|
|
776
780
|
const archiveDir = join(specBase, 'changes', 'archive')
|
|
777
781
|
if (existsSync(archiveDir)) {
|
|
778
|
-
const latest = readdirSync(archiveDir)
|
|
779
|
-
.filter(d =>
|
|
782
|
+
const latest = readdirSync(archiveDir, { withFileTypes: true })
|
|
783
|
+
.filter(d => d.isDirectory())
|
|
784
|
+
.map(d => d.name)
|
|
780
785
|
.sort()
|
|
781
786
|
.pop()
|
|
782
787
|
if (latest) {
|
|
783
|
-
autoName = latest
|
|
788
|
+
autoName = latest
|
|
784
789
|
progress = pm.read(cwd, autoName)
|
|
785
790
|
}
|
|
786
791
|
}
|
|
@@ -887,12 +892,20 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
|
|
|
887
892
|
// stale/revising 阶段可能 steps 为空,或者 execute 阶段的 steps 需要从最新 plan.md 刷新
|
|
888
893
|
const stageDataPre = progress.stages[stageName]
|
|
889
894
|
const needsInit = !stageDataPre || !stageDataPre.steps || stageDataPre.steps.length === 0
|
|
890
|
-
// execute 阶段在 reopen 时需要从最新 plan.md 重新解析 steps(plan
|
|
895
|
+
// execute 阶段在 reopen 时需要从最新 plan.md 重新解析 steps(plan 可能已变更)。
|
|
896
|
+
// 按名保留旧状态(坑 execute-reopen-wipes-prior-completion):reopenStage 的语义是
|
|
897
|
+
// 「i < fromStep 保持原状(completed)」,此前预置全 pending 会把 fromStep 之前已完成的
|
|
898
|
+
// 步骤一并清空——CLI 打印「从步骤 N 开始修订」、prompt 注入「之前已完成不需要重做」,
|
|
899
|
+
// 实际 currentIdx 却回到 0,agent 被迫从头重做,修订语义完全失效。
|
|
891
900
|
if (needsInit || stageName === 'execute') {
|
|
892
901
|
const freshSteps = await getStageSteps(stageName, cwd, progress, specRoot)
|
|
893
902
|
if (freshSteps && freshSteps.length > 0) {
|
|
894
903
|
if (!progress.stages[stageName]) progress.stages[stageName] = { status: 'stale', steps: [] }
|
|
895
|
-
progress.stages[stageName].steps
|
|
904
|
+
const oldSteps = progress.stages[stageName].steps || []
|
|
905
|
+
progress.stages[stageName].steps = freshSteps.map(s => {
|
|
906
|
+
const old = oldSteps.find(step => step.name === s.name)
|
|
907
|
+
return old ? { ...old } : { name: s.name, status: 'pending' }
|
|
908
|
+
})
|
|
896
909
|
pm._write(cwd, progress, effectiveChange)
|
|
897
910
|
progress = pm.read(cwd, effectiveChange) || progress
|
|
898
911
|
}
|
|
@@ -1148,15 +1161,17 @@ async function resetStage(pm, progress, stageName, cwd, changeName, platformOpts
|
|
|
1148
1161
|
async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts = {}) {
|
|
1149
1162
|
const flowStages = ['brainstorm', 'plan', 'execute', 'verify', 'archive']
|
|
1150
1163
|
const isDone = flags.includes('--done')
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
const
|
|
1154
|
-
|
|
1164
|
+
// F4 守卫(坑 raw-flag-value-bypass,与 runCommand getFlagValue 同款):裸 flags[idx+1] 会把
|
|
1165
|
+
// 漏值后紧跟的 flag 名当值(--output --done → outputText="--done" 污染落盘 output)。
|
|
1166
|
+
const autoFlagValue = (name) => {
|
|
1167
|
+
const i = flags.indexOf(name)
|
|
1168
|
+
const next = i !== -1 ? flags[i + 1] : undefined
|
|
1169
|
+
return next && !String(next).startsWith('--') ? next : null
|
|
1170
|
+
}
|
|
1171
|
+
const outputText = autoFlagValue('--output')
|
|
1172
|
+
const inputText = autoFlagValue('--input')
|
|
1155
1173
|
const skipApproval = flags.includes('--skip-approval')
|
|
1156
|
-
const explicitMode = (
|
|
1157
|
-
const m = flags.indexOf('--mode')
|
|
1158
|
-
return m !== -1 && flags[m + 1] ? flags[m + 1] : null
|
|
1159
|
-
})()
|
|
1174
|
+
const explicitMode = autoFlagValue('--mode')
|
|
1160
1175
|
const specBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
|
|
1161
1176
|
|
|
1162
1177
|
// Helper: 在 auto 模式下获取步骤定义
|
|
@@ -1193,7 +1208,10 @@ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts =
|
|
|
1193
1208
|
return progress
|
|
1194
1209
|
}
|
|
1195
1210
|
}
|
|
1196
|
-
|
|
1211
|
+
// specRoot 必传(坑 auto-mode-specroot-miss):平台模式 specRoot 是外部目录,漏传时
|
|
1212
|
+
// getStageSteps 从 cwd 推导 miss 掉 plan/execute 动态步骤(buildPlanSteps(null) 只剩
|
|
1213
|
+
// 2 步 fixedPrefix),种出的步骤表两次 --done 即判阶段完成,与 getAutoSteps 渲染双轨失配
|
|
1214
|
+
const changed = await ensureStageSteps(progress, stage, cwd, platformOpts?.specRoot || null)
|
|
1197
1215
|
if (stageChanged || changed) {
|
|
1198
1216
|
pm._write(cwd, progress, changeName)
|
|
1199
1217
|
triggerSync(cwd, changeName, platformOpts)
|
|
@@ -1369,7 +1387,8 @@ async function runAutoMode(pm, progress, cwd, flags, changeName, platformOpts =
|
|
|
1369
1387
|
progress.stages[next].startedAt = new Date().toLocaleString('zh-CN',{hour12:false})
|
|
1370
1388
|
}
|
|
1371
1389
|
progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
|
|
1372
|
-
|
|
1390
|
+
// specRoot 同上(坑 auto-mode-specroot-miss):平台模式动态步骤表必须从外部 specRoot 构建
|
|
1391
|
+
await ensureStageSteps(progress, next, cwd, platformOpts?.specRoot || null)
|
|
1373
1392
|
pm._write(cwd, progress, changeName)
|
|
1374
1393
|
triggerSync(cwd, changeName, platformOpts)
|
|
1375
1394
|
progress = pm.read(cwd, changeName)
|
|
@@ -135,9 +135,14 @@ async function archiveWorktreeCleanup(cwd, archiveChangeName, specBase, platform
|
|
|
135
135
|
try {
|
|
136
136
|
const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
|
|
137
137
|
if (existsSync(runtimeRoot)) {
|
|
138
|
+
// stage-review marker 精确匹配(坑 marker-suffix-overmatch):marker 名是
|
|
139
|
+
// current-stage-review-run-id-<stage>-<change>,按 endsWith('-'+change) 后缀匹配会在归档
|
|
140
|
+
// 手工短名变更(如 login)时误删自动日期前缀变更(2026-08-01-login 天然以 '-login' 结尾)
|
|
141
|
+
// 的 marker。stage 集合与写入侧 stageReviewMarkerPath 的调用方一致。
|
|
142
|
+
const REVIEW_STAGES = ['brainstorm', 'plan', 'execute', 'verify', 'archive']
|
|
138
143
|
const markers = readdirSync(runtimeRoot).filter((f) =>
|
|
139
144
|
f === `current-execute-run-id-${archiveChangeName}` ||
|
|
140
|
-
(f
|
|
145
|
+
REVIEW_STAGES.some(st => f === `current-stage-review-run-id-${st}-${archiveChangeName}`),
|
|
141
146
|
)
|
|
142
147
|
for (const m of markers) {
|
|
143
148
|
try { unlinkSync(join(runtimeRoot, m)) } catch {}
|
package/src/run/complete.js
CHANGED
|
@@ -24,7 +24,7 @@ import { triggerSync, WAIT_MARKER_RE, getStageSteps, formatWaitOptions, resolveR
|
|
|
24
24
|
import { executeScanPreflight, executeScanPostcheck, computeScanProfile } from './scan-profile.js'
|
|
25
25
|
import { executePlanPostcheck as runPlanPostcheckLib } from '../stages/plan-postcheck.js'
|
|
26
26
|
import { outputStep } from './prompt.js'
|
|
27
|
-
import { enforceDepsGate, enforceReviewJsonGate, enforceSymbolImpactGate, completeStageGates, readDesignScale } from './gates.js'
|
|
27
|
+
import { enforceDepsGate, enforceReviewJsonGate, enforceSymbolImpactGate, warnMissingUiPrototype, completeStageGates, readDesignScale } from './gates.js'
|
|
28
28
|
import { handleArchiveConfirmStep, handlePlanGeneratePlanStep, handleScanProjectListStep, handleWorkflowPostCheck, handleQuickStageCompletion, handleExecuteWaveArtifact } from './complete-handlers.js'
|
|
29
29
|
import { formatExecuteSummary } from '../worktree-apply.js'
|
|
30
30
|
import { isEndToEndTaskText } from '../change-risk-profile.js'
|
|
@@ -229,6 +229,8 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
|
|
|
229
229
|
// 符号影响面报告硬门(ql-20260816-005-3d7f):execute「加载上下文」步产出落盘核验——
|
|
230
230
|
// symbol-impact.md 存在 + plan 每 task 有结论行;防前缀步被一句「上下文在会话内」盖章跳过。
|
|
231
231
|
await enforceSymbolImpactGate(stageName, changeName, steps[currentIdx]?.name, specBase)
|
|
232
|
+
// UI 原型软提醒(非阻断):brainstorm 收尾步 design.md 命中前端文件但无 prototype → 提示用户可否决
|
|
233
|
+
warnMissingUiPrototype(stageName, changeName, steps[currentIdx]?.name, specBase)
|
|
232
234
|
|
|
233
235
|
// ── noAI 步骤硬门(坑 noai-done-bypass):noAI 步骤的确定性校验不可被 --done 绕过 ──
|
|
234
236
|
// 正常路径 agent 跑 `run <stage>` 推进到 noAI step 时,runStage 自动执行 _cliAction
|
|
@@ -321,6 +323,9 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
|
|
|
321
323
|
const _drafts = await generateTaskReviewDrafts({ changeName, cwd, platformOpts, ctx: _draftCtx })
|
|
322
324
|
if (_drafts.generated > 0) {
|
|
323
325
|
console.log(' 📄 自动补写 ' + _drafts.generated + ' 个 per-task review.json 草稿(cannot_verify,主 agent 实现模式兜底,需 agent 复核后升级为 pass/fail)')
|
|
326
|
+
if (_drafts.noAttribution > 0) {
|
|
327
|
+
console.warn(' ⚠️ 其中 ' + _drafts.noAttribution + ' 个 task 的 allowed_paths 未命中本次 diff(无归属草稿,changedFiles 为空)——需人工确认实际改动后升级 verdict,确属未实现则回 fail')
|
|
328
|
+
}
|
|
324
329
|
if (_drafts.unattributed && _drafts.unattributed.length > 0) {
|
|
325
330
|
console.warn(' ⚠️ ' + _drafts.unattributed.length + ' 个变更文件未归属任何 task(顺带修复/非源码),草稿未覆盖:' + _drafts.unattributed.join(', '))
|
|
326
331
|
}
|
|
@@ -104,8 +104,10 @@ export function detectConcurrentChanges(cwd, { changeName, linkedChanges = [], o
|
|
|
104
104
|
if (!file) continue
|
|
105
105
|
|
|
106
106
|
// rule1:落 .sillyspec/changes/<dir>/ 且 <dir> 非本变更非关联 → otherActiveChanges 去重。
|
|
107
|
+
// 'archive' 排除(坑 archive-dir-as-other-change):归档移动产生 .sillyspec/changes/archive/...
|
|
108
|
+
// 脏路径,extractChangeDir 返回 'archive',不排除会把归档动作误报成「他者活跃变更: archive」。
|
|
107
109
|
const dir = extractChangeDir(file)
|
|
108
|
-
if (dir && !ownChangeSet.has(normalizeGitPath(dir))) {
|
|
110
|
+
if (dir && dir !== 'archive' && !ownChangeSet.has(normalizeGitPath(dir))) {
|
|
109
111
|
otherActiveSet.add(dir)
|
|
110
112
|
continue
|
|
111
113
|
}
|
package/src/run/gates.js
CHANGED
|
@@ -92,6 +92,26 @@ export async function enforceSymbolImpactGate(stageName, changeName, currentStep
|
|
|
92
92
|
process.exit(1)
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* UI 原型软提醒(非阻断):brainstorm「生成规范文件」步 --done 时,design.md 命中前端文件
|
|
97
|
+
* 但变更目录无 prototype-*.html → console.warn 一行,用户可否决。程度判断(布局级才必须出)
|
|
98
|
+
* 是语义判断,CLI 不硬卡只提示——治 agent 对明显 UI 改造静默跳过原型的坑。
|
|
99
|
+
*/
|
|
100
|
+
export function warnMissingUiPrototype(stageName, changeName, currentStepName, specBase) {
|
|
101
|
+
if (stageName !== 'brainstorm' || !currentStepName || !currentStepName.includes('生成规范文件')) return
|
|
102
|
+
if (!changeName || !specBase) return
|
|
103
|
+
const changeDir = join(specBase, 'changes', changeName)
|
|
104
|
+
const designPath = join(changeDir, 'design.md')
|
|
105
|
+
if (!existsSync(designPath)) return
|
|
106
|
+
try {
|
|
107
|
+
if (readdirSync(changeDir).some(f => /^prototype-.*\.html$/i.test(f))) return
|
|
108
|
+
} catch { return } // 目录不可读(平台模式 specRoot 差异等)→ 静默跳过,不制造噪音
|
|
109
|
+
const design = readFileSync(designPath, 'utf8')
|
|
110
|
+
const feExts = [...new Set((design.match(/\.(vue|svelte|astro|tsx|jsx|css|scss|sass|less|styl|html)\b/gi) || []).map(s => s.replace('.', '').toLowerCase()))]
|
|
111
|
+
if (feExts.length === 0) return
|
|
112
|
+
console.warn(` ⚠️ design.md 命中前端文件类型(${feExts.join('/')})但变更目录无 prototype-*.html——轻微样式改动可忽略;布局/交互流程级变化应补原型(sillyspec run brainstorm --reopen 回到「分段展示设计」,或直接让 agent 补生成)`)
|
|
113
|
+
}
|
|
114
|
+
|
|
95
115
|
/**
|
|
96
116
|
* 判断当前 execute step 所在 wave 是否全部 task 都声明 no_deps_verify: true(D-006@v2)。
|
|
97
117
|
* 仅 wave 执行步骤(名如 "Wave N 执行")可 opt-out;非 wave 步骤恒返回 false(保守过门)。
|
|
@@ -854,6 +874,10 @@ export async function completeStageGates({ stageName, cwd, changeName, platformO
|
|
|
854
874
|
stageData.steps = freshSteps
|
|
855
875
|
stageData.status = 'pending'
|
|
856
876
|
stageData.completedAt = null
|
|
877
|
+
// scan 的 quick 档 profile 随步骤表一并失效(坑 scan-quick-profile-step-mismatch 收尾):
|
|
878
|
+
// getStageSteps 感知 scanProfile 返回 3 步表,重置后 DB 是 11 步注册表,残留 profile 会让
|
|
879
|
+
// 下次 --status 走 ensureStageSteps 时 11 vs 3 误报漂移重播种。下次 run scan 会重算 profile。
|
|
880
|
+
if (stageName === 'scan' && stageData.scanProfile) delete stageData.scanProfile
|
|
857
881
|
if (progress.currentStage === stageName) progress.currentStage = ''
|
|
858
882
|
pm._write(cwd, progress, changeName)
|
|
859
883
|
}
|
package/src/run/prompt.js
CHANGED
|
@@ -183,8 +183,11 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
|
|
|
183
183
|
console.log(`project: ${projectName}`)
|
|
184
184
|
if (changeName) {
|
|
185
185
|
console.log(`change: ${changeName}`)
|
|
186
|
-
|
|
187
|
-
|
|
186
|
+
// changeDir 根(坑 change-dir-base-mismatch):平台/漂移锚定用绝对根(specDriftAnchor 防
|
|
187
|
+
// worktree 副本错位),普通本地保持相对 '.sillyspec' 展示契约(test 钉死)。此前
|
|
188
|
+
// isPlatform 用 specRoot||runtimeRoot 判定、取值只拿 specRoot——runtimeRoot-only 平台组合
|
|
189
|
+
// join(null,...) 直接 TypeError。
|
|
190
|
+
const changeDirBase = platformOpts?.specRoot || platformOpts?.specDriftAnchor || '.sillyspec'
|
|
188
191
|
const changeDir = join(changeDirBase, 'changes', changeName)
|
|
189
192
|
console.log(`changeDir: ${changeDir}`)
|
|
190
193
|
}
|
|
@@ -715,8 +718,9 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
|
|
|
715
718
|
console.log('- **平台模式:local.yaml 中的 commands 必须在 package.json scripts 中真实存在,不存在的标记 unavailable。**')
|
|
716
719
|
}
|
|
717
720
|
if (changeName) {
|
|
718
|
-
|
|
719
|
-
|
|
721
|
+
// changeDir 根与上方头部同源(坑 change-dir-base-mismatch):平台/漂移锚定绝对根,
|
|
722
|
+
// 普通本地保持相对 '.sillyspec' 展示;runtimeRoot-only 组合不再 join(null) 崩溃
|
|
723
|
+
const changeDirBase = platformOpts?.specRoot || platformOpts?.specDriftAnchor || '.sillyspec'
|
|
720
724
|
const changeDir = join(changeDirBase, 'changes', changeName)
|
|
721
725
|
console.log(`- **文件路径规则:所有变更文件必须写入 \`${changeDir}/\` 目录下。不要自己拼接路径,直接使用 changeDir 值。示例:\`${changeDir}/proposal.md\`**`)
|
|
722
726
|
}
|
package/src/run/scan-profile.js
CHANGED
|
@@ -105,21 +105,15 @@ export function computeScanProfile(cwd, platformOpts) {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
/**
|
|
108
|
-
*
|
|
109
|
-
* quick
|
|
110
|
-
*
|
|
108
|
+
* quick 档 3 步表构建(唯一真相):applyScanProfileSteps(首进程裁剪)与 getStageSteps
|
|
109
|
+
* (后续进程重取定义)共用。此前 quick 步骤表只在首进程 applyScanProfileSteps 里内联构建,
|
|
110
|
+
* getStageSteps('scan') 恒返回 11 步注册表 → 同进程 outputStep 用注册表 defSteps[1] 渲染错位
|
|
111
|
+
* prompt;跨进程 ensureStageSteps 见 3 vs 11 触发长度漂移重播种,quick 已完成步丢失、
|
|
112
|
+
* agent 从未做过的注册表步骤被 --done(坑 scan-quick-profile-step-mismatch)。
|
|
111
113
|
*/
|
|
112
|
-
export function
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (mode === 'quick') {
|
|
117
|
-
const specBase = platformOpts?.specRoot || join(cwd, '.sillyspec')
|
|
118
|
-
const projectName = basename(cwd)
|
|
119
|
-
const docsRoot = join(specBase, 'docs', projectName)
|
|
120
|
-
|
|
121
|
-
// Step 1: CLI preflight(不调 AI,自动完成)
|
|
122
|
-
const step1 = {
|
|
114
|
+
export function buildQuickScanSteps() {
|
|
115
|
+
return [
|
|
116
|
+
{
|
|
123
117
|
name: '项目概览(自动探测)',
|
|
124
118
|
status: 'pending',
|
|
125
119
|
noAI: true,
|
|
@@ -127,9 +121,8 @@ export function applyScanProfileSteps(stageData, profile, cwd, platformOpts) {
|
|
|
127
121
|
prompt: '',
|
|
128
122
|
outputHint: 'preflight 结果',
|
|
129
123
|
optional: false
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const step2 = {
|
|
124
|
+
},
|
|
125
|
+
{
|
|
133
126
|
name: '生成核心文档',
|
|
134
127
|
status: 'pending',
|
|
135
128
|
prompt: `## Quick Scan — 核心文档生成
|
|
@@ -156,13 +149,32 @@ export function applyScanProfileSteps(stageData, profile, cwd, platformOpts) {
|
|
|
156
149
|
生成的文件列表`,
|
|
157
150
|
outputHint: '文件列表',
|
|
158
151
|
optional: false
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
// 名字与注册表末步一致(按名匹配/迁移语义);prompt 清空、noAI postcheck 自动执行
|
|
155
|
+
name: '自检和提交',
|
|
156
|
+
status: 'pending',
|
|
157
|
+
prompt: '',
|
|
158
|
+
outputHint: '结果',
|
|
159
|
+
optional: false,
|
|
160
|
+
noAI: true,
|
|
161
|
+
_cliAction: 'scanPostcheck'
|
|
159
162
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
163
|
+
]
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 根据 scanProfile 裁剪步骤
|
|
168
|
+
* quick: 3 步 — CLI preflight / AI generate / CLI postcheck
|
|
169
|
+
* standard: 跳过续扫检测(4), 跳过可选步骤(9)
|
|
170
|
+
*/
|
|
171
|
+
export function applyScanProfileSteps(stageData, profile, cwd, platformOpts) {
|
|
172
|
+
const steps = stageData.steps
|
|
173
|
+
const mode = profile.mode
|
|
174
|
+
|
|
175
|
+
if (mode === 'quick') {
|
|
176
|
+
// 3 步表构建抽至 buildQuickScanSteps(getStageSteps 同源复用,防跨进程 3 vs 11 漂移)
|
|
177
|
+
stageData.steps = buildQuickScanSteps()
|
|
166
178
|
return
|
|
167
179
|
}
|
|
168
180
|
|
package/src/run/shared.js
CHANGED
|
@@ -367,8 +367,14 @@ export function writePlatformPointer(cwd, platformOpts, extra = {}) {
|
|
|
367
367
|
declaredAt: new Date().toISOString(),
|
|
368
368
|
}
|
|
369
369
|
try {
|
|
370
|
-
|
|
371
|
-
|
|
370
|
+
// platform-scan.json 只在 specRoot 存在时可写(坑 pointer-runtime-root-only-null-join):
|
|
371
|
+
// runtimeRoot-only 平台组合(command.js 明确允许)下 specRoot 为 null,此前 join(null,…)
|
|
372
|
+
// 抛 ERR_INVALID_ARG_TYPE 落入总 catch → 指针与接管声明也一并静默未写,双入口 fail-closed
|
|
373
|
+
// 对该组合整体失效。分步写:specRoot 缺失只跳过 platform-scan.json,指针/声明照写。
|
|
374
|
+
if (platformOpts.specRoot) {
|
|
375
|
+
mkdirSync(join(platformOpts.specRoot, '.runtime'), { recursive: true })
|
|
376
|
+
writeFileSync(join(platformOpts.specRoot, '.runtime', 'platform-scan.json'), JSON.stringify(payload, null, 2) + '\n')
|
|
377
|
+
}
|
|
372
378
|
writeFileSync(join(cwd, '.sillyspec-platform.json'), JSON.stringify(payload, null, 2) + '\n')
|
|
373
379
|
writeFileSync(join(cwd, PLATFORM_MANAGED_FILENAME), JSON.stringify(declaration, null, 2) + '\n')
|
|
374
380
|
return true
|
|
@@ -913,13 +919,17 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
|
|
|
913
919
|
// 致 flag 对 status 失效。)
|
|
914
920
|
if ((!forceBaseline && result.baselineHit.length > 0) || (!allowDelete && result.deletedFiles.length > 0) || result.reasons.some(r => r.startsWith('危险') || r.startsWith('删除'))) {
|
|
915
921
|
result.status = 'blocked'
|
|
916
|
-
} else if ((!allowNew && result.newFiles.
|
|
922
|
+
} else if ((!allowNew && result.newFiles.some(f => !isQuickMetadata(f, guard.linkedChanges))) || (allowedFiles.length > 0 && result.reasons.some(r => r.startsWith('超出')))) {
|
|
923
|
+
// newFiles 按元数据过滤后判定(坑 quick-metadata-warning-no-reason):.sillyspec/ 被 git
|
|
924
|
+
// 跟踪的仓库里 quick 新建的 quicklog/*.md 是合法元数据,不过滤会 status=warning 而
|
|
925
|
+
// reasons 为空(reasons 侧已过滤),打印空原因的 WARNING 噪声。
|
|
917
926
|
result.status = 'warning'
|
|
918
927
|
}
|
|
919
928
|
|
|
920
|
-
// quicklog
|
|
929
|
+
// quicklog 存在性检查(specBase 感知:平台模式 quicklog 写在 specRoot 下,stage.js
|
|
930
|
+
// allocateQuicklogEntry 同源;硬编码 cwd/.sillyspec 会恒误报「目录不存在」抬 warning)
|
|
921
931
|
try {
|
|
922
|
-
const quicklogDir = join(cwd, '.sillyspec', 'quicklog')
|
|
932
|
+
const quicklogDir = join(specBase || join(cwd, '.sillyspec'), 'quicklog')
|
|
923
933
|
if (existsSync(quicklogDir)) {
|
|
924
934
|
const qlFiles = readdirSync(quicklogDir).filter(f => f.endsWith('.md'))
|
|
925
935
|
if (qlFiles.length === 0) {
|
|
@@ -1342,6 +1352,17 @@ export async function getStageSteps(stageName, cwd, progress, specDir = null) {
|
|
|
1342
1352
|
const changeDir = resolveChangeDir(cwd, progress, specDir)
|
|
1343
1353
|
return buildPlanSteps(changeDir)
|
|
1344
1354
|
}
|
|
1355
|
+
if (stageName === 'scan') {
|
|
1356
|
+
// quick 档感知(坑 scan-quick-profile-step-mismatch):scan 的 scanProfile 在首进程
|
|
1357
|
+
// runStage 里算出并持久化(stageData.scanProfile),quick 档把 11 步注册表裁成 3 步。
|
|
1358
|
+
// 此处恒返回注册表会让后续进程 ensureStageSteps 见 3 vs 11 触发漂移重播种(quick 已完成
|
|
1359
|
+
// 步丢失、从未执行的注册表步骤被 --done),同进程 outputStep 也用错位 defSteps 渲染。
|
|
1360
|
+
// quick 档 → 与 applyScanProfileSteps 同源返回 3 步表;standard/deep 与注册表同长度零影响。
|
|
1361
|
+
if (progress?.stages?.scan?.scanProfile?.mode === 'quick') {
|
|
1362
|
+
const { buildQuickScanSteps } = await import('./scan-profile.js')
|
|
1363
|
+
return buildQuickScanSteps()
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1345
1366
|
const def = stageRegistry[stageName]
|
|
1346
1367
|
return def ? def.steps : null
|
|
1347
1368
|
}
|
package/src/stage-review.js
CHANGED
|
@@ -465,7 +465,7 @@ export function printStageReviewResult(result, context = {}) {
|
|
|
465
465
|
* @returns {{ ok: boolean, reviewRunId: string, reviewPath: string, markerPath: string, mode: 'skeleton'|'adopted', mainDoc: string, review: object }}
|
|
466
466
|
* @throws {Error} changeName 空 / stage 非法 / 主文档缺失 / --from 不存在或 schema 不过
|
|
467
467
|
*/
|
|
468
|
-
export function registerStageReview({ changeName, stage, fromFile, cwd, platformOpts = {} }) {
|
|
468
|
+
export function registerStageReview({ changeName, stage, fromFile, cwd, platformOpts = {}, refreshHash = false }) {
|
|
469
469
|
if (!changeName) throw new Error('register-stage-review: changeName 不能为空')
|
|
470
470
|
if (!stage || !STAGE_MAIN_DOC[stage]) {
|
|
471
471
|
throw new Error(`register-stage-review: stage 无效 "${stage}"(应为 brainstorm|plan|execute)`)
|
|
@@ -486,6 +486,45 @@ export function registerStageReview({ changeName, stage, fromFile, cwd, platform
|
|
|
486
486
|
}
|
|
487
487
|
const reviewedFiles = [`changes/${changeName}/${mainDoc}`]
|
|
488
488
|
|
|
489
|
+
// ── refresh-hash 模式(坑 stage-review-dochash-manual-resync,2026-08-21 实证):改一版 design
|
|
490
|
+
// 要重算 2-3 个 stage review 的 docHash(brainstorm/execute 都锚 design.md),手工易漏、gate
|
|
491
|
+
// 报错才补。就地更新 marker 指向的已有 review.json——保留 verdict/checklist/requiredEvidence
|
|
492
|
+
// (结论是否仍适用由人确认),仅重算 docHash + reviewerNotes 追加刷新记录;不换 run 目录
|
|
493
|
+
// (换新 run 会把 marker 重定向到骨架,丢已审结论)。无既有 review → 报错(该用骨架模式生成)。
|
|
494
|
+
if (refreshHash) {
|
|
495
|
+
if (fromFile) throw new Error('register-stage-review: --refresh-hash 与 --from 互斥(refresh 就地更新既有 review,adopt 引入新文件)')
|
|
496
|
+
const existingRunId = getLatestStageReviewRunId(runtimeRoot, stage, changeName)
|
|
497
|
+
const existingPath = existingRunId
|
|
498
|
+
? join(runtimeRoot, 'stage-reviews', `${stage}-${existingRunId}`, 'review.json')
|
|
499
|
+
: null
|
|
500
|
+
if (!existingPath || !existsSync(existingPath)) {
|
|
501
|
+
throw new Error(`register-stage-review: --refresh-hash 找不到 ${stage} 的既有 review.json(marker ${existingRunId || '无'} 无对应 run)——先去掉 --refresh-hash 生成骨架,或派审查子代理产出 review`)
|
|
502
|
+
}
|
|
503
|
+
let existing
|
|
504
|
+
try {
|
|
505
|
+
existing = JSON.parse(readFileSync(existingPath, 'utf8'))
|
|
506
|
+
} catch (e) {
|
|
507
|
+
throw new Error(`register-stage-review: 既有 review.json 解析失败 ${existingPath}: ${e.message}`)
|
|
508
|
+
}
|
|
509
|
+
const refreshedAt = new Date().toISOString().slice(0, 19).replace('T', ' ')
|
|
510
|
+
const review = {
|
|
511
|
+
...existing,
|
|
512
|
+
docHash,
|
|
513
|
+
reviewedFiles,
|
|
514
|
+
reviewerNotes: `${existing.reviewerNotes || ''}\ndocHash refreshed at ${refreshedAt}(主文档改版后机械重算;verdict 结论是否仍适用需人工确认)`.trim(),
|
|
515
|
+
}
|
|
516
|
+
writeFileSync(existingPath, JSON.stringify(review, null, 2) + '\n')
|
|
517
|
+
const schemaRecheck = validateStageReviewSchema(review)
|
|
518
|
+
if (!schemaRecheck.ok) {
|
|
519
|
+
throw new Error(`register-stage-review: refresh 后 schema 自检失败 — ${schemaRecheck.errors.join('; ')}(原 review 字段异常,先修原文件)`)
|
|
520
|
+
}
|
|
521
|
+
const hashRecheck = verifyStageReviewDocHash(review, [specBase, changeDir, cwd])
|
|
522
|
+
if (!hashRecheck.ok) {
|
|
523
|
+
throw new Error(`register-stage-review: refresh 后 docHash 自检失败(不应发生)— ${hashRecheck.errors.join('; ')}`)
|
|
524
|
+
}
|
|
525
|
+
return { ok: true, reviewRunId: existingRunId, reviewPath: existingPath, markerPath: stageReviewMarkerPath(runtimeRoot, stage, changeName), mode: 'refreshed', mainDoc, review }
|
|
526
|
+
}
|
|
527
|
+
|
|
489
528
|
let review
|
|
490
529
|
let mode
|
|
491
530
|
if (fromFile) {
|
package/src/stages/brainstorm.js
CHANGED
|
@@ -218,32 +218,44 @@ export const definition = {
|
|
|
218
218
|
migratedFrom: ['HTML 原型生成'],
|
|
219
219
|
requiresWait: true,
|
|
220
220
|
waitReason: '等待用户确认设计方案',
|
|
221
|
-
waitOptions: ['确认', '需要修改', '推翻重来'],
|
|
222
|
-
prompt:
|
|
221
|
+
waitOptions: ['确认', '需要修改', '补原型', '推翻重来'],
|
|
222
|
+
prompt: `展示完整设计方案供用户确认;涉及 UI 改动时按下方分级决定是否生成 HTML 原型——原型动作必须在暂停之前落定,不能"等确认后再补"。
|
|
223
223
|
|
|
224
224
|
### 操作
|
|
225
225
|
1. 简单项目:几句话整体描述
|
|
226
226
|
2. 复杂项目:按模块/Phase 分段展示,每段 200-300 字
|
|
227
227
|
3. 展示完整设计方案(不要逐段停顿,一次性展示)
|
|
228
228
|
4. 确认变更名(格式:\`YYYY-MM-DD-<简短描述>\`,例如 \`2026-05-13-user-auth\`)
|
|
229
|
-
5.
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
-
|
|
235
|
-
|
|
236
|
-
|
|
229
|
+
5. 按「HTML 原型分级」生成原型或写出跳过原因(二选一必填,见下)
|
|
230
|
+
6. 暂停等待用户确认或修改意见
|
|
231
|
+
|
|
232
|
+
### HTML 原型分级(按改动程度,不是"是否碰前端文件")
|
|
233
|
+
判定对象是**改造后的界面相对现状的变化程度**:
|
|
234
|
+
- **必须生成**(布局/结构/流程级变化):
|
|
235
|
+
- 新增页面、视图、路由
|
|
236
|
+
- 页面布局变化:区域增删、骨架重排、信息层级调整(如单栏改多栏、列表改看板)
|
|
237
|
+
- 新交互流程:多步流程、跨页流转、状态驱动的界面变化
|
|
238
|
+
- 用户提供了截图/设计稿要求按其改造(原型即对照基准)
|
|
239
|
+
- **建议生成**(组件级变化、页面骨架不变):
|
|
240
|
+
- 表单重构、列表列调整、弹窗/抽屉流程等局部组件变化
|
|
241
|
+
- 拿不准时默认生成——宁多勿漏,原型在 execute 阶段可直接对照复用
|
|
242
|
+
- **跳过**(无需生成,但必须声明原因):
|
|
243
|
+
- 文案、颜色、间距、字号、图标等样式微调
|
|
244
|
+
- 纯后端逻辑/CLI/配置/文档,无任何界面变化
|
|
245
|
+
|
|
246
|
+
### 生成要求
|
|
247
|
+
生成一个独立的 HTML 文件(内联 CSS + JS),保存到:
|
|
248
|
+
\`{SPEC_ROOT}/changes/<change-name>/prototype-<名称>.html\`(目录不存在则先创建)
|
|
237
249
|
- 单文件,浏览器直接打开
|
|
238
250
|
- 高保真:布局/组件/交互流程按真实效果呈现,后续 execute 阶段可直接对照复用
|
|
239
251
|
- 与项目代码风格一致:优先复用项目现有技术栈/组件库/设计 token(先查 scan 文档与现有前端代码),不另起风格
|
|
240
|
-
展示给用户确认设计方向。不适合则跳过。
|
|
241
252
|
|
|
242
253
|
### 铁律 — 必须等待用户确认设计
|
|
243
254
|
- 展示完整设计方案后必须暂停等用户确认,**不要自己说"设计已充分确认"然后推进**(命令由 CLI 在下方注入)
|
|
255
|
+
- 原型生成或跳过声明必须在暂停**之前**完成——暂停是本步骤最后动作,用户确认后直接进入写 design.md,不会回头补
|
|
244
256
|
|
|
245
257
|
### 输出
|
|
246
|
-
完整设计方案 +
|
|
258
|
+
完整设计方案 + 变更名 + 原型文件路径 或 跳过原因(二选一必填;跳过时写明分级依据,用户可当场否决要求补)
|
|
247
259
|
|
|
248
260
|
### 注意
|
|
249
261
|
- 不要一次输出大段文字,按模块/Phase 分段
|
|
@@ -333,6 +345,7 @@ design.md 第一行标题必须用中文:# 设计文档(Design)— <变更
|
|
|
333
345
|
- design.md 含「自审」字面命中章节(自审 / Self-Review / Self-review)——「生成规范文件」步完成契约 brainstorm.design.self-review 硬要求,写在本步自审段即可
|
|
334
346
|
- 如存在 decisions.md,design.md 是否引用所有当前版本 D-xxx@vN
|
|
335
347
|
- 涉及 session/lease/agent_run/daemon/lifecycle 等关键词时,是否含「生命周期契约表」
|
|
348
|
+
- UI 原型核对:文件清单含前端文件(.vue/.tsx/.jsx/.svelte/.html/.css 等)且改动达到「必须生成/建议生成」原型级别(分级见「分段展示设计」步)时,变更目录应已有 \`prototype-*.html\`——没有则本步补生成;确不生成时把跳过原因记入 design.md 风险登记,不得静默缺位
|
|
336
349
|
5. 缺章节 → 补齐后重检;章节齐全 → 进入下一步(Design Grill 做语义层交叉审查)
|
|
337
350
|
|
|
338
351
|
### 输出
|
package/src/stages/doctor.js
CHANGED
|
@@ -73,16 +73,14 @@ const dir = '.sillyspec/changes';
|
|
|
73
73
|
const subs = fs.existsSync(dir) ? fs.readdirSync(dir).filter(f => { try { return fs.statSync(dir+'/'+f).isDirectory() && f !== 'archive'; } catch { return false; } }) : [];
|
|
74
74
|
let known;
|
|
75
75
|
try { const db = new DatabaseSync('.sillyspec/.runtime/sillyspec.db', { readOnly: true }); known = new Set(db.prepare(\"SELECT name FROM changes WHERE status='active'\").all().map(r => r.name)); db.close(); } catch (e) { console.log('⚠️ db 读取失败: ' + e.message + '(跳过孤儿判定,不做任何清理建议)'); known = null; }
|
|
76
|
+
// known 为 null(db 不可读)时只列目录不判定孤儿,后续僵尸检查同样跳过——不守卫会 TypeError 半途崩
|
|
76
77
|
if (known) subs.forEach(s => {
|
|
77
78
|
console.log(known.has(s) ? '✅ ' + s + ' — 已关联' : '⚠️ ' + s + ' — 孤儿目录(先 dump 取证再手工归位,勿直接 rm)');
|
|
78
79
|
});
|
|
79
|
-
subs.forEach(s => {
|
|
80
|
-
console.log(known.has(s) ? '✅ ' + s + ' — 已关联' : '⚠️ ' + s + ' — 孤儿目录(可清理)');
|
|
81
|
-
});
|
|
82
80
|
// 反向检查(multi-agent-review Q1):DB active 且匹配 quick-<hex> 但无目录 → 僵尸 quick 会话
|
|
83
81
|
// (quick 收尾未注销,active 行累积污染 listChanges/doctor)。新代码已修收尾,此项清理历史残留。
|
|
84
82
|
const QUICK_RE = /^quick-[0-9a-f]{8}$/;
|
|
85
|
-
const zombieQuick = [...known].filter(n => QUICK_RE.test(n) && !subs.includes(n));
|
|
83
|
+
const zombieQuick = known ? [...known].filter(n => QUICK_RE.test(n) && !subs.includes(n)) : [];
|
|
86
84
|
if (zombieQuick.length > 0) {
|
|
87
85
|
console.log('⚠️ 检测到 ' + zombieQuick.length + ' 个僵尸 quick 会话(DB active 但无目录):');
|
|
88
86
|
zombieQuick.forEach(n => console.log(' - ' + n));
|
package/src/stages/execute.js
CHANGED
|
@@ -118,7 +118,8 @@ export function validatePlanForExecute(tasksContent, planContent) {
|
|
|
118
118
|
if (count > 1) errors.push(`task id 重复: ${id} 在 tasks.md 出现 ${count} 次`)
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
// 检查 3: task id 连续性(从 1
|
|
121
|
+
// 检查 3: task id 连续性(从 1 开始;ids[0]!==1 时整体跳过 = 兼容旧变更编号不从 1 起,
|
|
122
|
+
// 契约由 test/plan-execute-contract.test.mjs Case 10 钉死,勿改)
|
|
122
123
|
const ids = allTasks.map(t => t.index).sort((a, b) => a - b)
|
|
123
124
|
if (ids.length > 0 && ids[0] === 1) {
|
|
124
125
|
for (let i = 0; i < ids.length; i++) {
|
|
@@ -577,6 +578,10 @@ function writeBaseCommitToTaskCard(taskFilePath, baseCommit) {
|
|
|
577
578
|
} catch {
|
|
578
579
|
return false
|
|
579
580
|
}
|
|
581
|
+
// CRLF 归一(坑 base-commit-crlf-frontmatter,plan-postcheck.js 同款):跨仓 task 卡被
|
|
582
|
+
// 编辑器/子代理写成 CRLF 后 `^---\n` 匹配失败 → base_commit 锚点静默不落盘,而 Wave
|
|
583
|
+
// prompt 仍声称「CLI 已落盘」。归一后按 LF 写回(taskcard 生成侧本就强制 LF)。
|
|
584
|
+
content = content.replace(/\r\n?/g, '\n')
|
|
580
585
|
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
|
|
581
586
|
if (!fmMatch) return false
|
|
582
587
|
const fm = fmMatch[1]
|
package/src/stages/knowledge.js
CHANGED
|
@@ -276,10 +276,25 @@ export async function cmdRefresh(dir, args, opts = {}) {
|
|
|
276
276
|
// refresh 只写 generated/ 区
|
|
277
277
|
mkdirSync(generatedDir, { recursive: true })
|
|
278
278
|
|
|
279
|
-
// 扫描 scan
|
|
279
|
+
// 扫描 scan 文档。项目名用 DB 注册名优先(坑 knowledge-basename-project-mismatch):
|
|
280
|
+
// scan 写侧 docs/<project> 的 project 来自 dbProjectName || basename(cwd),注册名 ≠ 目录名
|
|
281
|
+
// (projects yaml name/path 分离、平台模式 specRoot≠sourceRoot)时,用 basename(dir) 拼
|
|
282
|
+
// scanDir 永远找不到 → refresh 恒报 scan_docs_missing。DB 读失败回退 basename(与写侧
|
|
283
|
+
// default 同口径)。查不到注册名对应目录时再回退 basename 试一次(双形态兼容)。
|
|
280
284
|
const base = opts.specDir || join(dir, '.sillyspec')
|
|
281
|
-
|
|
282
|
-
|
|
285
|
+
let projectName = null
|
|
286
|
+
try {
|
|
287
|
+
const { DatabaseSync } = await import('node:sqlite')
|
|
288
|
+
const db = new DatabaseSync(join(base, '.runtime', 'sillyspec.db'), { readOnly: true })
|
|
289
|
+
const row = db.prepare('SELECT name FROM project LIMIT 1').get()
|
|
290
|
+
db.close()
|
|
291
|
+
if (row && row.name) projectName = row.name
|
|
292
|
+
} catch { /* db 不可读 → 走 basename 兜底 */ }
|
|
293
|
+
if (!projectName) projectName = basename(dir)
|
|
294
|
+
let scanDir = join(base, 'docs', projectName, 'scan')
|
|
295
|
+
if (!existsSync(scanDir) && projectName !== basename(dir)) {
|
|
296
|
+
scanDir = join(base, 'docs', basename(dir), 'scan')
|
|
297
|
+
}
|
|
283
298
|
|
|
284
299
|
if (!existsSync(scanDir)) {
|
|
285
300
|
output(false, {}, { code: 'scan_docs_missing', path: scanDir })
|
|
@@ -348,7 +348,10 @@ function parseTaskWavesFromPlan(planPath) {
|
|
|
348
348
|
// (旧变更读侧兼容——新格式由 validatePlanForExecute 拦旧形态,此处不重复把关)
|
|
349
349
|
const tm = line.match(/^[-*]\s+(?:\[[ x]\]\s*)?(task-\d+)\s*[::\s]*$/i)
|
|
350
350
|
|| line.match(/^[-*]\s*\[[ x]\]\s*(task-\d+)\b/i)
|
|
351
|
-
|
|
351
|
+
// id 归一补零(与卡片/注册表 task-01 口径一致,坑 wave-ref-unpadded):plan.md 写
|
|
352
|
+
// `- task-1` 时裸用捕获原文做 key,永远匹配不到卡片 id task-01 → 被判「未列入任何
|
|
353
|
+
// Wave」,同 Wave 共享文件检测静默跳过。execute.js parseWavesFromPlan 已补零,对齐。
|
|
354
|
+
if (tm && currentWave !== null) map.set(String(tm[1]).replace(/^task-(\d+)$/i, (_, n) => `task-${n.padStart(2, '0')}`), currentWave)
|
|
352
355
|
}
|
|
353
356
|
return map
|
|
354
357
|
}
|
|
@@ -614,16 +617,19 @@ export function parseLocalYamlModules(yamlText) {
|
|
|
614
617
|
const modules = {}
|
|
615
618
|
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
616
619
|
const line = lines[i]
|
|
617
|
-
const entry = line.match(/^
|
|
620
|
+
const entry = line.match(/^([ \t]+)([A-Za-z0-9_.\-]+):\s*(.*)$/)
|
|
618
621
|
if (!entry) {
|
|
619
622
|
// 遇到新的顶层 key(行首非空格且非注释)→ modules 块结束
|
|
620
623
|
if (line.length > 0 && !line.startsWith(' ') && !line.startsWith('#') && line.trim() !== '') break
|
|
621
624
|
continue
|
|
622
625
|
}
|
|
623
|
-
|
|
626
|
+
// 条目缩进放宽为「任意非零空白」(坑 modules-indent-hardcoded):此前硬编码恰 2 空格,
|
|
627
|
+
// 4 空格/Tab 缩进的条目不匹配且不触发块结束 → 被静默跳过 → modules 丢失 →
|
|
628
|
+
// validateScriptCommands 退化为只查根 package.json,monorepo 子包命令被误报不存在。
|
|
629
|
+
const rest = entry[3] || ''
|
|
624
630
|
// path 值:带引号或不带引号(flow mapping),取第一个匹配
|
|
625
631
|
const pathMatch = rest.match(/path:\s*"([^"]+)"/) || rest.match(/path:\s*([^,\s}]+)/)
|
|
626
|
-
if (pathMatch) modules[entry[
|
|
632
|
+
if (pathMatch) modules[entry[2]] = { path: pathMatch[1] }
|
|
627
633
|
}
|
|
628
634
|
return Object.keys(modules).length > 0 ? modules : null
|
|
629
635
|
}
|
|
@@ -669,9 +675,14 @@ export function validateTaskCommands(changeDir, projectRoot, modules = null) {
|
|
|
669
675
|
continue // 非法 YAML 由 feasibility 处理,不重复
|
|
670
676
|
}
|
|
671
677
|
|
|
672
|
-
// 合并 verify + implementation
|
|
673
|
-
|
|
674
|
-
|
|
678
|
+
// 合并 verify + implementation 文本(命令可能出现在任一字段)。
|
|
679
|
+
// 字段两形态都收(坑 taskcard-block-list-noop):规范格式(taskcard.js 骨架 / plan 模板)的
|
|
680
|
+
// verify/implementation 是 YAML 块列表 → js-yaml 解析为数组;此前只收 string,规范卡片
|
|
681
|
+
// 恒落空 → 「命令存在性校验」(hard-error)对合规 TaskCard 整体 no-op,只有违规写成
|
|
682
|
+
// 纯标量的卡片才被校验。数组按行 join 回文本,与标量同管道。
|
|
683
|
+
const toCmdText = (v) => Array.isArray(v) ? v.map(String).join('\n') : (typeof v === 'string' ? v : '')
|
|
684
|
+
const verifyText = toCmdText(fm.verify)
|
|
685
|
+
const implText = toCmdText(fm.implementation)
|
|
675
686
|
const text = `${verifyText}\n${implText}`
|
|
676
687
|
if (!text.trim()) continue
|
|
677
688
|
|
|
@@ -1047,8 +1058,10 @@ export function validatePlanFeasibility(changeDir, projectRoot = null) {
|
|
|
1047
1058
|
if (allTaskIds.length > 0) {
|
|
1048
1059
|
const nums = allTaskIds.map(id => {
|
|
1049
1060
|
const m = id.match(/task-(\d+)/i)
|
|
1050
|
-
return m ? parseInt(m[1]) : null
|
|
1061
|
+
return m ? parseInt(m[1], 10) : null
|
|
1051
1062
|
}).filter(n => n !== null).sort((a, b) => a - b)
|
|
1063
|
+
// nums[0]!==1 时跳过 = 兼容旧变更编号不从 1 起(契约同 validatePlanForExecute 检查 3,
|
|
1064
|
+
// test/plan-execute-contract.test.mjs Case 10 钉死,勿改)
|
|
1052
1065
|
if (nums.length > 0 && nums[0] === 1) {
|
|
1053
1066
|
for (let i = 0; i < nums.length; i++) {
|
|
1054
1067
|
if (nums[i] !== i + 1) {
|
|
@@ -1280,11 +1293,12 @@ export async function executePlanPostcheck(context) {
|
|
|
1280
1293
|
currentWaveTasks = null
|
|
1281
1294
|
continue
|
|
1282
1295
|
}
|
|
1283
|
-
// 2026-08-20-task-truth-unify:Wave 段下纯 ID 引用行(- task-NN);旧 checkbox
|
|
1296
|
+
// 2026-08-20-task-truth-unify:Wave 段下纯 ID 引用行(- task-NN);旧 checkbox 行同收。
|
|
1297
|
+
// 编号补零对齐卡片口径(坑 wave-ref-unpadded,同 parseTaskWavesFromPlan)
|
|
1284
1298
|
const tm = line.match(/^[-*]\s+(?:\[[ x]\]\s*)?task-(\d+)\s*[::\s]*$/i)
|
|
1285
1299
|
|| line.match(/^[-*]\s*\[[ x]\]\s*task-(\d+)/i)
|
|
1286
1300
|
if (tm && currentWaveTasks) {
|
|
1287
|
-
currentWaveTasks.push(`task-${tm[1]}`)
|
|
1301
|
+
currentWaveTasks.push(`task-${String(tm[1]).padStart(2, '0')}`)
|
|
1288
1302
|
}
|
|
1289
1303
|
}
|
|
1290
1304
|
if (currentWaveTasks) existingWaves.push(currentWaveTasks)
|
package/src/stages/plan.js
CHANGED
|
@@ -564,7 +564,9 @@ export const fixedSuffix = [] // postcheck 是动态生成的(需要 changeDir
|
|
|
564
564
|
*/
|
|
565
565
|
function parseTaskCount(registryContent) {
|
|
566
566
|
if (!registryContent || typeof registryContent !== 'string') return 0
|
|
567
|
-
|
|
567
|
+
// [ xX] + i(坑 taskcount-uppercase-x):parseTaskNames / gates.js extractTaskIdsFromRegistry
|
|
568
|
+
// 均收大写 [X],此处漏收 → 全大写勾选的注册表计数 0 → 误走「无任务」分支丢 coordinator 步
|
|
569
|
+
const matches = registryContent.match(/^[-*]\s*\[[ xX]\]\s*task-\d+/gim)
|
|
568
570
|
return matches ? matches.length : 0
|
|
569
571
|
}
|
|
570
572
|
|
|
@@ -624,15 +626,18 @@ export function buildPlanSteps(changeDir = null, planContent = null) {
|
|
|
624
626
|
return [...fixedPrefix, ...postcheck]
|
|
625
627
|
}
|
|
626
628
|
|
|
627
|
-
//
|
|
629
|
+
// 解析任务名(源与计数同口径,坑 plan-tasknames-empty-under-registry-contract):新契约
|
|
630
|
+
// (2026-08-20-task-truth-unify)下 plan.md Wave 段是纯 ID 引用行(无 checkbox 无任务名),
|
|
631
|
+
// 此前只从 planContent/plan.md 解析 → taskNames 恒空 → coordinator 步骤任务清单空白,
|
|
632
|
+
// 主 agent 只能自行读 tasks.md 兜底易漏派。tasks.md 优先(与 readTaskRegistryContent 同序)。
|
|
628
633
|
let taskNames = []
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
634
|
+
let namesSource = planContent || null
|
|
635
|
+
if (changeDir) {
|
|
636
|
+
const registryContent = readTaskRegistryContent(changeDir)
|
|
637
|
+
if (registryContent != null) namesSource = registryContent
|
|
638
|
+
}
|
|
639
|
+
if (namesSource) {
|
|
640
|
+
taskNames = parseTaskNames(namesSource)
|
|
636
641
|
}
|
|
637
642
|
|
|
638
643
|
// 生成协调器步骤(TaskCard 生成)+ postcheck
|
package/src/sync.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync, chmodSync } from 'fs';
|
|
12
|
+
import { writeAtomicSync } from './fs-atomic.js';
|
|
12
13
|
import { join } from 'path';
|
|
13
14
|
import { resolvePlatformSpecDir } from './progress.js';
|
|
14
15
|
import { safeGit } from './git-helper.js';
|
|
@@ -131,12 +132,14 @@ function replaceTopLevelSection(text, name, body) {
|
|
|
131
132
|
return `${stripped}\n\n${name}:\n${body}\n`;
|
|
132
133
|
}
|
|
133
134
|
|
|
134
|
-
/** 文本级写 local.yaml(保留传入 text 的注释/结构);确保 .sillyspec 目录存在。
|
|
135
|
+
/** 文本级写 local.yaml(保留传入 text 的注释/结构);确保 .sillyspec 目录存在。
|
|
136
|
+
* 原子写(fs-atomic 契约:local.yaml 被 hook/probe 等其他进程并发读,裸 writeFileSync 的
|
|
137
|
+
* 写入窗口可读到半截文件 → mcp 段解析失败回退 env → 误判 no-config 降级)。 */
|
|
135
138
|
function writeLocalYamlRaw(cwd, text) {
|
|
136
139
|
const dir = join(cwd, '.sillyspec');
|
|
137
140
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
138
141
|
const yamlPath = join(cwd, LOCAL_YAML);
|
|
139
|
-
|
|
142
|
+
writeAtomicSync(yamlPath, text, 'utf8');
|
|
140
143
|
// 体检 SEC-04:local.yaml 含明文 token,收紧为仅属主可读写。POSIX chmod 0600 生效;
|
|
141
144
|
// Windows 上 Node chmod 只映射只读位(近似 no-op),属可接受 best-effort
|
|
142
145
|
try { chmodSync(yamlPath, 0o600); } catch { /* chmod 失败不阻断连接流程 */ }
|
|
@@ -364,7 +367,7 @@ export class SyncManager {
|
|
|
364
367
|
// 删段后纯空白(无任何段也无注释)→ 删除整个文件;注释算内容,有注释则保留
|
|
365
368
|
try { unlinkSync(p); } catch { /* best effort */ }
|
|
366
369
|
} else {
|
|
367
|
-
|
|
370
|
+
writeAtomicSync(p, newText, 'utf8');
|
|
368
371
|
}
|
|
369
372
|
}
|
|
370
373
|
}
|
|
@@ -609,7 +612,10 @@ export class SyncManager {
|
|
|
609
612
|
return { synced: 0, errors: ['未指定变更名称'] };
|
|
610
613
|
}
|
|
611
614
|
|
|
612
|
-
|
|
615
|
+
// 树根与进度/spec 树同源(safePlatformSpecDir,BUG-01 同族):此前硬编码 cwd/.sillyspec/changes,
|
|
616
|
+
// 平台模式(specRoot 指向外部)下路径不存在 → syncDocuments 恒报「变更不存在」且调用方不查
|
|
617
|
+
// 返回值 → 文档在平台模式下静默永不同步。
|
|
618
|
+
const changeDir = join(safePlatformSpecDir(this.cwd) || join(this.cwd, '.sillyspec'), 'changes', changeName);
|
|
613
619
|
if (!existsSync(changeDir)) {
|
|
614
620
|
console.warn(`[sync] 变更不存在: ${changeName}`);
|
|
615
621
|
return { synced: 0, errors: [`变更不存在: ${changeName}`] };
|
|
@@ -997,7 +1003,10 @@ export class SyncManager {
|
|
|
997
1003
|
const { ProgressManager } = await import('./progress.js');
|
|
998
1004
|
const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
|
|
999
1005
|
const db = pm._ensureDB(this.cwd).getDb();
|
|
1000
|
-
|
|
1006
|
+
// 参数侧同样 COALESCE(坑 keep-local-base-ts-null-param):SQLite 标量 MAX() 任一参数
|
|
1007
|
+
// 为 NULL 即返回 NULL——冲突文件 platform_last_pushed_at 为 null 时 MAX(NULL, 旧值)
|
|
1008
|
+
// 会把已回填的 base_ts 清空,恰好违背本注释的单调防回退意图。
|
|
1009
|
+
db.prepare('UPDATE changes SET last_synced_platform_ts = MAX(COALESCE(?, last_synced_platform_ts), COALESCE(last_synced_platform_ts, ?)) WHERE name = ?')
|
|
1001
1010
|
.run(platformPushedAt, platformPushedAt, changeName);
|
|
1002
1011
|
} catch (err) {
|
|
1003
1012
|
return { ok: false, resolved: false, reason: `keep-local 更新 base_ts 失败: ${err.message}` };
|
|
@@ -1024,9 +1033,12 @@ export class SyncManager {
|
|
|
1024
1033
|
}
|
|
1025
1034
|
|
|
1026
1035
|
if (mode === 'take-platform' && cf) {
|
|
1027
|
-
// 用冲突文件的 platform_progress 调 import 覆盖本地(保隔离:import 不覆盖 isolation_
|
|
1036
|
+
// 用冲突文件的 platform_progress 调 import 覆盖本地(保隔离:import 不覆盖 isolation_*)。
|
|
1037
|
+
// fail-closed(坑 take-platform-empty-import):缺 platform_progress 时必须直接返回——
|
|
1038
|
+
// { ...null } 展开为 {},import 会照常跑完整事务(DELETE stages 后按空 JSON 重建 =
|
|
1039
|
+
// 清空本地进度),返回值却报 ok:false「未执行」,用户以为无事发生实则数据已丢。
|
|
1028
1040
|
if (!cf.platform_progress) {
|
|
1029
|
-
|
|
1041
|
+
return { ok: false, resolved: false, reason: '冲突文件缺 platform_progress,无法 take-platform(建议先 platform pull)' };
|
|
1030
1042
|
}
|
|
1031
1043
|
try {
|
|
1032
1044
|
const { ProgressManager } = await import('./progress.js');
|
package/src/task-review.js
CHANGED
|
@@ -1023,6 +1023,7 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
|
|
|
1023
1023
|
const taskFiles = readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f)).sort()
|
|
1024
1024
|
let generated = 0
|
|
1025
1025
|
let skipped = 0
|
|
1026
|
+
let noAttributionCount = 0
|
|
1026
1027
|
const attributed = new Set()
|
|
1027
1028
|
|
|
1028
1029
|
for (const tf of taskFiles) {
|
|
@@ -1081,11 +1082,6 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
|
|
|
1081
1082
|
: []
|
|
1082
1083
|
}
|
|
1083
1084
|
|
|
1084
|
-
// 空 changedFiles 的 task 不生成(verifyReviewGitEvidence 判空 diff 伪造,留给 agent 手补)
|
|
1085
|
-
if (taskChangedFiles.length === 0) {
|
|
1086
|
-
skipped++
|
|
1087
|
-
continue
|
|
1088
|
-
}
|
|
1089
1085
|
// 主仓 task 缺 base/head(meta 缺失 / HEAD 解析失败,见上方 :968-981 注释承诺的"后续跳过"):
|
|
1090
1086
|
// 跳过留给 agent 手补。此前 null.slice(0,8) 直接 TypeError 崩掉整个草稿生成(体检 BUG-06)
|
|
1091
1087
|
if (!crossEntry && (!taskBase || !taskHead)) {
|
|
@@ -1097,6 +1093,42 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
|
|
|
1097
1093
|
taskChangedFiles.forEach(f => attributed.add(f))
|
|
1098
1094
|
}
|
|
1099
1095
|
|
|
1096
|
+
// 空 changedFiles 的 task 也生成「无归属」草稿(坑 task-review-draft-skip-leak,2026-08-21
|
|
1097
|
+
// 实证:task-08/task-10 两次都因 allowed_paths 未命中 diff 被静默跳过,--done gate 拦截后
|
|
1098
|
+
// agent 只能从零手写 review.json)。cannot_verify 草稿本就待复核(verify 阶段兑现
|
|
1099
|
+
// requiredEvidence),空 changedFiles 不触发 emptyDiff 伪造误判(该判定看真实 git diff 而非
|
|
1100
|
+
// review.changedFiles),shouldAutoCheckTask 零 diff 守卫也保证不会被自动勾选——草稿从
|
|
1101
|
+
// 「漏了让 agent 从零写」变成「给了骨架让 agent 升级 verdict」。
|
|
1102
|
+
if (taskChangedFiles.length === 0) {
|
|
1103
|
+
const draft = {
|
|
1104
|
+
schemaVersion: REVIEW_SCHEMA_VERSION,
|
|
1105
|
+
task: taskId,
|
|
1106
|
+
base: taskBase,
|
|
1107
|
+
head: taskHead,
|
|
1108
|
+
changedFiles: [],
|
|
1109
|
+
specVerdict: 'cannot_verify',
|
|
1110
|
+
qualityVerdict: 'cannot_verify',
|
|
1111
|
+
requiredEvidence: ['auto-generated draft (no-attributed-diff): ' + taskId +
|
|
1112
|
+
' 的 allowed_paths 未命中本次 diff 任何文件(task 未实现 / 改动与他人同文件 / 路径不匹配)——' +
|
|
1113
|
+
'需人工确认实际改动后升级 verdict,确属未实现则回 fail'],
|
|
1114
|
+
reviewerNotes: 'auto-generated draft from git diff ' + taskBase.slice(0, 8) + '..' + taskHead.slice(0, 8) +
|
|
1115
|
+
';verdict=未评审(无归属 diff 兜底草稿,坑 task-review-draft-skip-leak)',
|
|
1116
|
+
}
|
|
1117
|
+
if (draftRepo) draft.repo = draftRepo
|
|
1118
|
+
// fail-open(对齐函数头契约 + execute-run-dir-fail-loud 测试锁定的「写失败返回统计不抛」):
|
|
1119
|
+
// 无归属草稿写入失败(如 tasks/ 被普通文件占位 ENOTDIR)降级 skipped,不崩整个草稿生成
|
|
1120
|
+
try {
|
|
1121
|
+
mkdirSync(reviewDir, { recursive: true })
|
|
1122
|
+
writeFileSync(reviewPath, JSON.stringify(draft, null, 2) + '\n')
|
|
1123
|
+
generated++
|
|
1124
|
+
noAttributionCount++
|
|
1125
|
+
} catch (e) {
|
|
1126
|
+
console.error(`[sillyspec] task ${taskId} 无归属草稿落盘失败(降级留给 agent 手补): ${e.message}`)
|
|
1127
|
+
skipped++
|
|
1128
|
+
}
|
|
1129
|
+
continue
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1100
1132
|
const draft = {
|
|
1101
1133
|
schemaVersion: REVIEW_SCHEMA_VERSION,
|
|
1102
1134
|
task: taskId,
|
|
@@ -1115,7 +1147,7 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
|
|
|
1115
1147
|
}
|
|
1116
1148
|
|
|
1117
1149
|
const unattributed = (Array.isArray(diffFiles) ? diffFiles : []).filter(f => !attributed.has(f))
|
|
1118
|
-
return { generated, skipped, unattributed, executeRunId }
|
|
1150
|
+
return { generated, skipped, noAttribution: noAttributionCount, unattributed, executeRunId }
|
|
1119
1151
|
}
|
|
1120
1152
|
|
|
1121
1153
|
/**
|