sillyspec 3.22.1 → 3.22.3
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-brainstorm/SKILL.md +2 -0
- package/.claude/skills/sillyspec-doctor/SKILL.md +65 -61
- package/.claude/skills/sillyspec-execute/SKILL.md +1 -1
- package/.claude/skills/sillyspec-quick/SKILL.md +84 -84
- package/.claude/skills/sillyspec-verify/SKILL.md +1 -1
- package/package.json +1 -1
- package/src/doctor-diagnostics.js +696 -575
- package/src/index.js +65 -0
- package/src/progress.js +111 -0
- package/src/run.js +40 -11
- package/src/stage-contract.js +32 -11
- package/src/task-review.js +25 -3
- package/src/worktree-apply.js +2 -4
- package/src/worktree.js +24 -1
- package/.claude/CLAUDE.md +0 -23
package/src/index.js
CHANGED
|
@@ -297,6 +297,20 @@ async function main() {
|
|
|
297
297
|
break;
|
|
298
298
|
}
|
|
299
299
|
case 'run': {
|
|
300
|
+
// doctor --json 等价 doctor 顶层 --json(结构化诊断),与 case 'doctor' 行为一致——
|
|
301
|
+
// 否则 sillyspec doctor --json 走 runDoctorDiagnostics,sillyspec run doctor --json 走 runCommand
|
|
302
|
+
// prompt 自检,两者输出不等价(违反"顶层别名 == run <stage>"契约)。
|
|
303
|
+
if (filteredArgs[1] === 'doctor' && json) {
|
|
304
|
+
const doctorEffectiveDir = specDir ? dir : resolveEffectiveDir(dir)
|
|
305
|
+
const { runDoctorDiagnostics, formatDoctorJson, writeDoctorDiagnosis } = await import('./doctor-diagnostics.js')
|
|
306
|
+
const result = await runDoctorDiagnostics({ cwd: doctorEffectiveDir })
|
|
307
|
+
const output = formatDoctorJson(result, { source_root: dir })
|
|
308
|
+
const written = writeDoctorDiagnosis(output, result.authoritySpecDir)
|
|
309
|
+
if (written) console.error(`📁 诊断结果已写入: ${written}`)
|
|
310
|
+
console.log(JSON.stringify(output, null, 2))
|
|
311
|
+
process.exitCode = output.overall_status === 'pass' ? 0 : 1
|
|
312
|
+
break
|
|
313
|
+
}
|
|
300
314
|
const { runCommand } = await import('./run.js')
|
|
301
315
|
// 平台模式(--spec-dir 已指定)时,--dir 是明确的 source_root,不应被 resolveEffectiveDir 纠正
|
|
302
316
|
const effectiveDir = specDir ? dir : resolveEffectiveDir(dir)
|
|
@@ -317,6 +331,57 @@ async function main() {
|
|
|
317
331
|
const pathIdx = filteredArgs.indexOf('--path');
|
|
318
332
|
const dbPath = pathIdx >= 0 && filteredArgs[pathIdx + 1] ? filteredArgs[pathIdx + 1] : null;
|
|
319
333
|
|
|
334
|
+
// --align-execute-progress:基于 plan.md 声明对齐 execute 派生戳(仿 --cleanup-remnant 范式)
|
|
335
|
+
// 默认 dry-run(只报告将补哪些 step),加 --confirm 才写。命中即 break,绝不 fall-through。
|
|
336
|
+
const alignFlag = filteredArgs.includes('--align-execute-progress');
|
|
337
|
+
if (alignFlag) {
|
|
338
|
+
// --change 解析:显式优先,缺省用单活跃变更自动兜底(与 run.js resolveChangeNameAuto 同逻辑)
|
|
339
|
+
const alignChangeIdx = filteredArgs.indexOf('--change');
|
|
340
|
+
let alignChange = alignChangeIdx >= 0 && filteredArgs[alignChangeIdx + 1] ? filteredArgs[alignChangeIdx + 1] : null;
|
|
341
|
+
if (!alignChange) {
|
|
342
|
+
const changesDir = join(resolvePlatformSpecDir(doctorEffectiveDir, specDir) || join(doctorEffectiveDir, '.sillyspec'), 'changes');
|
|
343
|
+
if (existsSync(changesDir)) {
|
|
344
|
+
const activeChanges = readdirSync(changesDir, { withFileTypes: true })
|
|
345
|
+
.filter(e => e.isDirectory() && e.name !== 'archive')
|
|
346
|
+
.map(e => e.name);
|
|
347
|
+
if (activeChanges.length === 1) alignChange = activeChanges[0];
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (!alignChange) {
|
|
351
|
+
console.error('❌ 无法确定变更名:请用 --change <name>,或确保仅有一个活跃变更');
|
|
352
|
+
process.exitCode = 2;
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// 复用顶层静态 import(line 12),不动态 await import
|
|
357
|
+
const pm = new ProgressManager({ specDir: resolvePlatformSpecDir(doctorEffectiveDir, specDir) });
|
|
358
|
+
const specBase = resolvePlatformSpecDir(doctorEffectiveDir, specDir) || join(doctorEffectiveDir, '.sillyspec');
|
|
359
|
+
let r;
|
|
360
|
+
try {
|
|
361
|
+
r = await pm.alignExecuteToPlan(doctorEffectiveDir, alignChange, specBase, { confirm: doctorConfirm });
|
|
362
|
+
} catch (e) {
|
|
363
|
+
console.error(`❌ 对齐失败:${e.message}`);
|
|
364
|
+
process.exitCode = 1;
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (json) {
|
|
369
|
+
console.log(JSON.stringify(r, null, 2));
|
|
370
|
+
} else if (r.ok === false) {
|
|
371
|
+
console.error(`❌ ${r.reason || '对齐未执行'}`);
|
|
372
|
+
} else {
|
|
373
|
+
console.log(`✅ 已基于 plan.md 声明对齐 ${r.aligned} 个 step(plan: ${r.planChecked}/${r.planTotal})。`);
|
|
374
|
+
if (!doctorConfirm) {
|
|
375
|
+
console.log(` (dry-run:未写盘。加 --confirm 实际落盘,并置 execute 阶段 status=completed。)`);
|
|
376
|
+
} else {
|
|
377
|
+
console.log(` 已落盘,execute 阶段 status=completed。请确认 verify 通过。`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// ok=false 或 reason 非空 → 1;否则 0(与 --cleanup-remnant 的 r.errors 逻辑同构)
|
|
381
|
+
process.exitCode = (r.ok === false || r.reason) ? 1 : 0;
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
|
|
320
385
|
if (cleanupRemnant) {
|
|
321
386
|
const { cleanupRemnantDbs } = await import('./doctor-diagnostics.js');
|
|
322
387
|
const r = await cleanupRemnantDbs({ cwd: doctorEffectiveDir, confirm: doctorConfirm });
|
package/src/progress.js
CHANGED
|
@@ -1799,4 +1799,115 @@ export class ProgressManager {
|
|
|
1799
1799
|
writeFileSync(gitignorePath, rule + '\n');
|
|
1800
1800
|
}
|
|
1801
1801
|
}
|
|
1802
|
+
|
|
1803
|
+
// ── plan.md 对齐(doctor --align-execute-progress 入口)──
|
|
1804
|
+
|
|
1805
|
+
/**
|
|
1806
|
+
* 解析 changeDir/plan.md(回退 tasks.md)的 task checkbox 统计。
|
|
1807
|
+
* 仅匹配 `- [ ] task-NN` / `- [x] task-NN` 形态的行(task- 前缀锚定,避免误捞非任务项)。
|
|
1808
|
+
* @param {string} changeDir - 变更目录绝对路径(含 plan.md/tasks.md)
|
|
1809
|
+
* @returns {{ total: number, checked: number }}
|
|
1810
|
+
*/
|
|
1811
|
+
readPlanCheckboxStatus(changeDir) {
|
|
1812
|
+
if (!changeDir || typeof changeDir !== 'string') {
|
|
1813
|
+
throw new Error('changeDir 不能为空');
|
|
1814
|
+
}
|
|
1815
|
+
const planPath = join(changeDir, 'plan.md');
|
|
1816
|
+
const tasksPath = join(changeDir, 'tasks.md');
|
|
1817
|
+
let content = null;
|
|
1818
|
+
if (existsSync(planPath)) {
|
|
1819
|
+
content = readFileSync(planPath, 'utf8');
|
|
1820
|
+
} else if (existsSync(tasksPath)) {
|
|
1821
|
+
content = readFileSync(tasksPath, 'utf8');
|
|
1822
|
+
} else {
|
|
1823
|
+
return { total: 0, checked: 0 };
|
|
1824
|
+
}
|
|
1825
|
+
// 匹配 `- [ ] task-NN` / `- [x] task-NN`(允许 [x] 大小写、空格弹性、task- 前缀)
|
|
1826
|
+
const re = /^\s*[-*]\s+\[([ xX])\]\s+task-\d+/gm;
|
|
1827
|
+
let total = 0;
|
|
1828
|
+
let checked = 0;
|
|
1829
|
+
let m;
|
|
1830
|
+
while ((m = re.exec(content)) !== null) {
|
|
1831
|
+
total++;
|
|
1832
|
+
if (m[1] === 'x' || m[1] === 'X') checked++;
|
|
1833
|
+
}
|
|
1834
|
+
return { total, checked };
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
/**
|
|
1838
|
+
* 按 plan.md 声明对齐 execute 阶段派生进度戳。
|
|
1839
|
+
* 仅当 plan.md 所有 task checkbox 全勾时,把 execute 阶段所有非 completed step 标 completed,
|
|
1840
|
+
* 并显式置 execute stageData.status='completed' + completedAt(绕过 completeStep 推导,D-003@v2)。
|
|
1841
|
+
* 不复核代码,信任 plan.md 声明(与 archive 同源,verify 阶段兜底,D-002/D-004)。
|
|
1842
|
+
*
|
|
1843
|
+
* @param {string} cwd
|
|
1844
|
+
* @param {string} changeName
|
|
1845
|
+
* @param {string} specBase - platformOpts.specRoot || join(cwd, '.sillyspec'),用于定位 changes 目录
|
|
1846
|
+
* @param {object} [opts]
|
|
1847
|
+
* @param {boolean} [opts.confirm=false] - 默认 dry-run,仅当 confirm=true 才落盘
|
|
1848
|
+
* @returns {Promise<{ ok: boolean, aligned: number, skipped: number, planTotal: number, planChecked: number, reason?: string, dryRun?: boolean }>}
|
|
1849
|
+
*/
|
|
1850
|
+
async alignExecuteToPlan(cwd, changeName, specBase, opts = {}) {
|
|
1851
|
+
if (!changeName) throw new Error('changeName 不能为空');
|
|
1852
|
+
const { confirm = false } = opts;
|
|
1853
|
+
|
|
1854
|
+
// 1. 读 progress;无 progress / 无 execute 阶段 → 拒绝
|
|
1855
|
+
const progress = await this.read(cwd, changeName);
|
|
1856
|
+
if (!progress || !progress.stages || !progress.stages.execute) {
|
|
1857
|
+
return { ok: false, aligned: 0, skipped: 0, planTotal: 0, planChecked: 0, reason: 'execute 阶段无进度数据' };
|
|
1858
|
+
}
|
|
1859
|
+
const executeStage = progress.stages.execute;
|
|
1860
|
+
const steps = Array.isArray(executeStage.steps) ? executeStage.steps : [];
|
|
1861
|
+
if (steps.length === 0) {
|
|
1862
|
+
return { ok: false, aligned: 0, skipped: 0, planTotal: 0, planChecked: 0, reason: 'execute 阶段无进度数据' };
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
// 2. 读 plan.md checkbox;未全勾 → 拒绝(D-002)
|
|
1866
|
+
const specRoot = specBase || this._getSpecDir(cwd);
|
|
1867
|
+
const changeDir = join(specRoot, CHANGES_SUBDIR, changeName);
|
|
1868
|
+
const { total: planTotal, checked: planChecked } = this.readPlanCheckboxStatus(changeDir);
|
|
1869
|
+
if (planTotal === 0) {
|
|
1870
|
+
return { ok: false, aligned: 0, skipped: 0, planTotal, planChecked, reason: 'plan.md 无 task checkbox(无法判定完成度)' };
|
|
1871
|
+
}
|
|
1872
|
+
if (planChecked < planTotal) {
|
|
1873
|
+
return {
|
|
1874
|
+
ok: false,
|
|
1875
|
+
aligned: 0,
|
|
1876
|
+
skipped: 0,
|
|
1877
|
+
planTotal,
|
|
1878
|
+
planChecked,
|
|
1879
|
+
reason: `plan.md 有未勾选 task(${planChecked}/${planTotal}),拒绝对齐`,
|
|
1880
|
+
};
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
// 3. 全勾:计算将补哪些 step
|
|
1884
|
+
const now = new Date().toISOString();
|
|
1885
|
+
let aligned = 0;
|
|
1886
|
+
let skipped = 0;
|
|
1887
|
+
for (const step of steps) {
|
|
1888
|
+
if (step.status === 'completed') {
|
|
1889
|
+
skipped++;
|
|
1890
|
+
continue;
|
|
1891
|
+
}
|
|
1892
|
+
aligned++;
|
|
1893
|
+
if (confirm) {
|
|
1894
|
+
step.status = 'completed';
|
|
1895
|
+
step.completedAt = now;
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
// dry-run:只报告,不落盘
|
|
1900
|
+
if (!confirm) {
|
|
1901
|
+
return { ok: true, aligned, skipped, planTotal, planChecked, dryRun: true };
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
// 4. confirm:显式置 execute stageData.status='completed' + completedAt(D-003@v2,复刻 run.js:2303-2304)
|
|
1905
|
+
executeStage.status = 'completed';
|
|
1906
|
+
executeStage.completedAt = now;
|
|
1907
|
+
progress.currentStage = progress.currentStage || 'execute';
|
|
1908
|
+
progress.lastActive = now;
|
|
1909
|
+
await this._write(cwd, progress, changeName);
|
|
1910
|
+
|
|
1911
|
+
return { ok: true, aligned, skipped, planTotal, planChecked };
|
|
1912
|
+
}
|
|
1802
1913
|
}
|
package/src/run.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* CLI 成为流程引擎,AI 变成步骤执行器。
|
|
5
5
|
* 支持多变更并行:每个变更状态存储在 sillyspec.db 中。
|
|
6
6
|
*/
|
|
7
|
-
import { basename, join, resolve, dirname } from 'path'
|
|
7
|
+
import { basename, join, resolve, dirname, relative, isAbsolute } from 'path'
|
|
8
8
|
import { existsSync, readdirSync, mkdirSync, writeFileSync, appendFileSync, readFileSync, rmSync, statSync } from 'fs'
|
|
9
9
|
import { createRequire } from 'module'
|
|
10
10
|
const require = createRequire(import.meta.url)
|
|
@@ -61,7 +61,8 @@ export function validateParsedProjects(projects, sourceRoot) {
|
|
|
61
61
|
errors.push(`项目 "${id}" 的 path 包含 .. ,拒绝越界`)
|
|
62
62
|
} else {
|
|
63
63
|
const absPath = resolve(safeRoot, proj.path)
|
|
64
|
-
|
|
64
|
+
const rel = relative(safeRoot, absPath)
|
|
65
|
+
if (rel.startsWith('..') || isAbsolute(rel)) {
|
|
65
66
|
errors.push(`项目 "${id}" 的 path "${proj.path}" 解析后超出 source_root`)
|
|
66
67
|
}
|
|
67
68
|
if (!existsSync(absPath)) {
|
|
@@ -1585,7 +1586,7 @@ export async function runCommand(args, cwd, specDir = null) {
|
|
|
1585
1586
|
|
|
1586
1587
|
// --skip
|
|
1587
1588
|
if (isSkip) {
|
|
1588
|
-
return await skipStep(pm, progress, stageName, cwd, effectiveChange)
|
|
1589
|
+
return await skipStep(pm, progress, stageName, cwd, effectiveChange, platformOpts)
|
|
1589
1590
|
}
|
|
1590
1591
|
|
|
1591
1592
|
// --wait: 将 step 设为 waiting(独立于 --done)
|
|
@@ -2388,19 +2389,35 @@ function isCurrentWaveAllNoDepsVerify(stepName, changeDir) {
|
|
|
2388
2389
|
async function enforceDepsGate(stageName, cwd, changeName, step, steps, currentIdx, specBase, platformOpts) {
|
|
2389
2390
|
if (stageName !== 'execute') return true
|
|
2390
2391
|
let meta = null
|
|
2392
|
+
let wm = null
|
|
2391
2393
|
try {
|
|
2392
2394
|
const { WorktreeManager } = await import('./worktree.js')
|
|
2393
|
-
|
|
2395
|
+
wm = new WorktreeManager({ cwd })
|
|
2396
|
+
meta = wm.getMeta(changeName)
|
|
2394
2397
|
} catch {}
|
|
2395
2398
|
const depsStatus = meta?.depsStatus
|
|
2396
2399
|
if (['linked', 'installed', 'n/a'].includes(depsStatus)) return true
|
|
2397
2400
|
const changeDir = changeName ? join(specBase, 'changes', changeName) : null
|
|
2398
2401
|
if (isCurrentWaveAllNoDepsVerify(step?.name, changeDir)) return true
|
|
2399
2402
|
if (steps && steps[currentIdx]) steps[currentIdx].status = 'blocked'
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2403
|
+
// ── 诊断分支(Phase 2,G2/R3 修正:判定基于物理目录而非 !meta)──
|
|
2404
|
+
// getMeta 对"目录不存在"与"meta 损坏"都返回 null,后者会误判终态 → 用物理目录存在性判定。
|
|
2405
|
+
let worktreeGone = true
|
|
2406
|
+
try {
|
|
2407
|
+
worktreeGone = !!(wm && changeName) && !existsSync(wm.getWorktreePath(changeName))
|
|
2408
|
+
} catch {}
|
|
2409
|
+
// ── fail-loud 块(Phase 3,D-005@v1:仅改拒绝侧 stderr)──
|
|
2410
|
+
console.error('❌ ── deps 门控阻断(本次 --done 未完成,进度未推进)──')
|
|
2411
|
+
if (worktreeGone) {
|
|
2412
|
+
console.error(' worktree 不可用(已 cleanup 或目录不存在)。')
|
|
2413
|
+
console.error(` 修复:sillyspec doctor --align-execute-progress${changeName ? ` --change ${changeName}` : ''} 按 plan.md 对齐进度`)
|
|
2414
|
+
console.error(` 或: sillyspec worktree create ${changeName || '<change>'} 重建 worktree 继续跑`)
|
|
2415
|
+
} else {
|
|
2416
|
+
console.error(` 原因:依赖未就绪(depsStatus=${depsStatus || 'unknown'}),不得在无构建/测试能力时声称完成。`)
|
|
2417
|
+
console.error(` 修复:sillyspec worktree doctor --fix${changeName ? ` --change ${changeName}` : ''}`)
|
|
2418
|
+
console.error(' 或在 worktree 内手动安装依赖后重试。')
|
|
2419
|
+
if (meta?.depsError) console.error(` 上次供给错误:${meta.depsError}`)
|
|
2420
|
+
}
|
|
2404
2421
|
process.exit(1)
|
|
2405
2422
|
}
|
|
2406
2423
|
|
|
@@ -2481,7 +2498,19 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
2481
2498
|
if (currentStepDef.requiresWait === true && !currentStep.waitAnswer) {
|
|
2482
2499
|
// 检查 --done 是否带了 --answer:如果是,自动补全 waitAnswer 状态,一步完成
|
|
2483
2500
|
const doneAnswer = typeof options !== 'undefined' && options.doneAnswer ? options.doneAnswer : null
|
|
2484
|
-
|
|
2501
|
+
// B4: 前置 step 已对同一问题确认则自动跳过重复 wait。
|
|
2502
|
+
// 归一化去掉「最终/再次/重复」等修饰词,避免 step N「确认 X」与 step M「最终确认 X」重复打断。
|
|
2503
|
+
const normalizeReason = (r) => (r || '').replace(/(最终|再次|重复|最后|首|初次|首次)/g, '').trim()
|
|
2504
|
+
const currentReason = normalizeReason(currentStepDef.waitReason)
|
|
2505
|
+
const priorConfirmed = currentReason && steps.slice(0, currentIdx).some(s =>
|
|
2506
|
+
s.waitAnswer && s.status === 'completed' && normalizeReason(s.waitReason) === currentReason
|
|
2507
|
+
)
|
|
2508
|
+
if (priorConfirmed) {
|
|
2509
|
+
currentStep.status = 'waiting'
|
|
2510
|
+
currentStep.waitAnswer = '前置步骤已对同一问题确认 — 自动跳过重复 wait'
|
|
2511
|
+
currentStep.waitReason = currentStepDef.waitReason || '等待用户输入'
|
|
2512
|
+
console.log(`⚠️ Step "${currentStep.name}" 的确认(${currentStepDef.waitReason})已在前置步骤完成,自动跳过。`)
|
|
2513
|
+
} else if (doneAnswer) {
|
|
2485
2514
|
currentStep.status = 'waiting'
|
|
2486
2515
|
currentStep.waitAnswer = doneAnswer
|
|
2487
2516
|
currentStep.waitReason = currentStepDef.waitReason || '等待用户输入'
|
|
@@ -3096,7 +3125,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
3096
3125
|
executeRunId = generateExecuteRunId()
|
|
3097
3126
|
}
|
|
3098
3127
|
|
|
3099
|
-
const reviewResult = validateTaskReviews({ planContent, runtimeRoot, executeRunId })
|
|
3128
|
+
const reviewResult = validateTaskReviews({ planContent, runtimeRoot, executeRunId, changeDir: planFile })
|
|
3100
3129
|
printReviewResult(reviewResult)
|
|
3101
3130
|
|
|
3102
3131
|
if (!reviewResult.ok) {
|
|
@@ -3292,7 +3321,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
3292
3321
|
return { stageCompleted: false, currentIdx, nextPendingIdx }
|
|
3293
3322
|
}
|
|
3294
3323
|
|
|
3295
|
-
async function skipStep(pm, progress, stageName, cwd, changeName) {
|
|
3324
|
+
async function skipStep(pm, progress, stageName, cwd, changeName, platformOpts = {}) {
|
|
3296
3325
|
const stageData = progress.stages[stageName]
|
|
3297
3326
|
if (!stageData || !stageData.steps) {
|
|
3298
3327
|
console.error(`❌ 阶段 ${stageName} 未初始化`)
|
package/src/stage-contract.js
CHANGED
|
@@ -253,14 +253,21 @@ function validateBrainstormOutputs(cwd, changeName, context = {}) {
|
|
|
253
253
|
warnings.push('design.md 缺少「自审」章节')
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
-
// P1: 涉及生命周期关键词时,design.md
|
|
256
|
+
// P1: 涉及生命周期关键词时,design.md 必须包含生命周期契约表(除非显式声明不涉及)
|
|
257
257
|
const hasLifecycleKeyword = /\b(session|lease|agent[._-]?run|daemon|lifecycle|state[._-]?transition|claim|heartbeat)\b/i.test(content)
|
|
258
258
|
if (hasLifecycleKeyword) {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
if (
|
|
263
|
-
|
|
259
|
+
// 显式声明本变更不涉及生命周期契约(覆盖字段名/错误码/否定声明场景):
|
|
260
|
+
// 历史教训:design 提到 daemon_id 字段名或 daemon_not_owned 错误码就触发,被迫加空表(B3a)
|
|
261
|
+
const declaresNotApplicable = /(生命周期|lifecycle)[^\n]{0,40}(n\/?a|不涉及|无|none|not[ _-]?applicable)|(不涉及|无需|没有)[^\n]{0,40}(生命周期|lifecycle)/i.test(content)
|
|
262
|
+
if (declaresNotApplicable) {
|
|
263
|
+
warnings.push('design.md 显式声明不涉及生命周期契约 — 已豁免「生命周期契约表」要求')
|
|
264
|
+
} else {
|
|
265
|
+
const hasLifecycleTable =
|
|
266
|
+
/生命周期契约表|lifecycle[._-]?contract|lifecycle[._-]?matrix|Lifecycle Contract/i.test(content) ||
|
|
267
|
+
/事件.*发起方.*接收方.*必需字段.*状态变化/.test(content)
|
|
268
|
+
if (!hasLifecycleTable) {
|
|
269
|
+
errors.push('design.md 涉及生命周期关键词(session/lease/agent_run/daemon/lifecycle)但缺少「生命周期契约表」— 必须列出完整的事件×状态转换矩阵;或显式声明「不涉及生命周期契约」并附理由豁免')
|
|
270
|
+
}
|
|
264
271
|
}
|
|
265
272
|
}
|
|
266
273
|
}
|
|
@@ -380,6 +387,22 @@ function validatePlanOutputs(cwd, changeName, context = {}) {
|
|
|
380
387
|
|
|
381
388
|
return { ok: errors.length === 0, errors, warnings }
|
|
382
389
|
}
|
|
390
|
+
/**
|
|
391
|
+
* 从 verify-result.md 提取结论关键词(PASS / PASS WITH NOTES / FAIL)。
|
|
392
|
+
* 标题放宽:含「结论/Conclusion/Result/结果」的二级标题均可(B3c),
|
|
393
|
+
* PASS/FAIL 可在标题行本身(如「## 验收结论:✅ PASS」)或紧邻标题的正文里。
|
|
394
|
+
* 历史教训:原正则锚定确切「## 结论」,用户写「## 验收结论:✅ PASS」不被识别。
|
|
395
|
+
*/
|
|
396
|
+
function extractVerifyConclusion(verify) {
|
|
397
|
+
const headingRe = /^##\s[^\n]*(?:结论|conclusion|result|结果)/im
|
|
398
|
+
const headingMatch = verify.match(headingRe)
|
|
399
|
+
if (!headingMatch) return ''
|
|
400
|
+
const start = headingMatch.index
|
|
401
|
+
const slice = verify.slice(start, start + 400)
|
|
402
|
+
const kw = slice.match(/\b(PASS(?:\s+WITH\s+NOTES)?|FAIL)\b/i)
|
|
403
|
+
return kw ? kw[1].toUpperCase().replace(/\s+/g, ' ') : ''
|
|
404
|
+
}
|
|
405
|
+
|
|
383
406
|
function validateVerifyOutputs(cwd, changeName, context = {}) {
|
|
384
407
|
const { specRoot } = context
|
|
385
408
|
const changeDir = resolveChangeDir(cwd, changeName, specRoot)
|
|
@@ -419,12 +442,11 @@ function validateVerifyOutputs(cwd, changeName, context = {}) {
|
|
|
419
442
|
// ── FAIL 结论门控(适用于所有变更,不限风险等级)──
|
|
420
443
|
// verify-result.md 结论为 FAIL 时,verify 阶段不能 completed。
|
|
421
444
|
// 历史教训:CLI 曾不校验结论,AI 写 FAIL 后 verify 仍被标记完成并提示"验证通过可以归档"。
|
|
422
|
-
const
|
|
423
|
-
const conclusionStr = conclusionLine ? conclusionLine[1].toUpperCase().replace(/\s+/g, ' ') : ''
|
|
445
|
+
const conclusionStr = extractVerifyConclusion(verify)
|
|
424
446
|
if (conclusionStr === 'FAIL') {
|
|
425
447
|
errors.push('verify-result.md 结论为 FAIL — 验证未通过,不能标记 verify 完成;请修复后重新运行验证')
|
|
426
448
|
} else if (!conclusionStr) {
|
|
427
|
-
warnings.push('verify-result.md
|
|
449
|
+
warnings.push('verify-result.md 未识别到结论章节(含 结论/Conclusion/Result/结果 的二级标题,后跟 PASS / PASS WITH NOTES / FAIL)')
|
|
428
450
|
}
|
|
429
451
|
|
|
430
452
|
// ── P0: Change Risk Gate — 核心功能缺少真实集成验证时 FAIL ──
|
|
@@ -433,8 +455,7 @@ function validateVerifyOutputs(cwd, changeName, context = {}) {
|
|
|
433
455
|
planContent: readIfExists(join(changeDir, 'plan.md')),
|
|
434
456
|
})
|
|
435
457
|
if (['integration-critical', 'deployment-critical'].includes(changeRiskProfile.level)) {
|
|
436
|
-
const
|
|
437
|
-
const conclusion = conclusionMatch ? conclusionMatch[1] : ''
|
|
458
|
+
const conclusion = extractVerifyConclusion(verify)
|
|
438
459
|
if (conclusion === 'PASS WITH NOTES' || conclusion === 'PASS') {
|
|
439
460
|
const evidenceCheck = checkIntegrationEvidence(verify, changeRiskProfile.requiredVerification)
|
|
440
461
|
if (!evidenceCheck.ok) {
|
package/src/task-review.js
CHANGED
|
@@ -128,8 +128,26 @@ export function readReview(reviewPath) {
|
|
|
128
128
|
* @param {boolean} [opts.allowCannotVerify=true] - 是否允许 cannot_verify(默认允许,给 warning)
|
|
129
129
|
* @returns {{ ok: boolean, errors: string[], warnings: string[], requiredEvidence: Array<{task: string, verdict: string, evidence: string[]}> }}
|
|
130
130
|
*/
|
|
131
|
+
/**
|
|
132
|
+
* 读取 task-XX.md frontmatter,判断是否声明 low_risk: true(type-only/机械迁移等低逻辑风险)。
|
|
133
|
+
* 声明 low_risk 的 task 缺 review.json 时只 warning 不 error(B2:免逐个评审仪式)。
|
|
134
|
+
*/
|
|
135
|
+
function isTaskLowRisk(changeDir, taskId) {
|
|
136
|
+
if (!changeDir) return false
|
|
137
|
+
const taskFile = join(changeDir, 'tasks', `${taskId}.md`)
|
|
138
|
+
if (!existsSync(taskFile)) return false
|
|
139
|
+
try {
|
|
140
|
+
const content = readFileSync(taskFile, 'utf8')
|
|
141
|
+
const fm = content.match(/^---\n([\s\S]*?)\n---/)
|
|
142
|
+
if (!fm) return false
|
|
143
|
+
return /^low_risk:\s*true\s*$/im.test(fm[1])
|
|
144
|
+
} catch {
|
|
145
|
+
return false
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
131
149
|
export function validateTaskReviews(opts) {
|
|
132
|
-
const { planContent, runtimeRoot, executeRunId, allowCannotVerify = true } = opts
|
|
150
|
+
const { planContent, runtimeRoot, executeRunId, allowCannotVerify = true, changeDir = null } = opts
|
|
133
151
|
|
|
134
152
|
const taskIds = parseTaskIdsFromPlan(planContent)
|
|
135
153
|
|
|
@@ -156,8 +174,12 @@ export function validateTaskReviews(opts) {
|
|
|
156
174
|
// review.json 存在且 JSON 合法,但 schema 校验失败
|
|
157
175
|
errors.push(`${taskId}: review.json 校验失败 — ${result.errors.join('; ')}`)
|
|
158
176
|
} else {
|
|
159
|
-
// review.json
|
|
160
|
-
|
|
177
|
+
// review.json 不存在:声明 low_risk 的 task 豁免(B2:type-only/机械迁移免逐个评审)
|
|
178
|
+
if (isTaskLowRisk(changeDir, taskId)) {
|
|
179
|
+
warnings.push(`${taskId}: 缺少 review.json — task 声明 low_risk: true,已豁免评审(type-only/机械迁移)`)
|
|
180
|
+
} else {
|
|
181
|
+
errors.push(`${taskId}: 缺少 review.json — task 未经过评审`)
|
|
182
|
+
}
|
|
161
183
|
}
|
|
162
184
|
continue
|
|
163
185
|
}
|
package/src/worktree-apply.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { execSync } from 'child_process';
|
|
16
|
-
import { existsSync, unlinkSync, writeFileSync, mkdtempSync, rmSync } from 'fs';
|
|
16
|
+
import { existsSync, unlinkSync, writeFileSync, mkdtempSync, rmSync, readdirSync, readFileSync } from 'fs';
|
|
17
17
|
import { join, resolve } from 'path';
|
|
18
18
|
import { tmpdir } from 'os';
|
|
19
19
|
import { createHash } from 'crypto';
|
|
@@ -362,7 +362,6 @@ export function assessApplyRisk(changeName, { cwd } = {}) {
|
|
|
362
362
|
const tasksDir = join(projectRoot, CHANGES_REL, changeName, 'tasks');
|
|
363
363
|
const allowedPaths = new Set();
|
|
364
364
|
if (existsSync(tasksDir)) {
|
|
365
|
-
const { readdirSync, readFileSync } = require('fs');
|
|
366
365
|
for (const tf of readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f))) {
|
|
367
366
|
const content = readFileSync(join(tasksDir, tf), 'utf8');
|
|
368
367
|
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
@@ -511,8 +510,7 @@ export function formatExecuteSummary({ changeName, stepsCompleted, stepsTotal, a
|
|
|
511
510
|
// worktree 还在,用 baselineCommit 或 baseHash 做 diff
|
|
512
511
|
try {
|
|
513
512
|
const diffBase = meta.baselineCommit || meta.baseHash;
|
|
514
|
-
const
|
|
515
|
-
const filesRaw = es(`git -C ${meta.worktreePath} diff --name-only ${diffBase} 2>/dev/null`, { encoding: 'utf8' });
|
|
513
|
+
const filesRaw = execSync(`git -C ${meta.worktreePath} diff --name-only ${diffBase} 2>/dev/null`, { encoding: 'utf8' });
|
|
516
514
|
const files = filesRaw ? filesRaw.trim().split('\n').filter(Boolean) : [];
|
|
517
515
|
if (files.length > 0) {
|
|
518
516
|
lines.push(``);
|
package/src/worktree.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { execSync } from 'child_process';
|
|
12
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync } from 'fs';
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync, lstatSync, readlinkSync, unlinkSync } from 'fs';
|
|
13
13
|
import { join, resolve, dirname } from 'path';
|
|
14
14
|
import { createHash } from 'crypto';
|
|
15
15
|
import { provisionDeps, lockfileHash } from './worktree-deps.js';
|
|
@@ -590,6 +590,29 @@ export class WorktreeManager {
|
|
|
590
590
|
|
|
591
591
|
const branch = (meta && meta.branch) || BRANCH_PREFIX + name;
|
|
592
592
|
|
|
593
|
+
// Windows 保护:先解链接 worktree/node_modules(junction 指向主 checkout),
|
|
594
|
+
// 否则后续 git worktree remove / rmSync recursive 会跟随 junction 误删主 node_modules 内容。
|
|
595
|
+
if (!isInPlace && existsSync(worktreePath)) {
|
|
596
|
+
const wtNodeModules = join(worktreePath, 'node_modules');
|
|
597
|
+
if (existsSync(wtNodeModules)) {
|
|
598
|
+
let isLink = false;
|
|
599
|
+
try { isLink = lstatSync(wtNodeModules).isSymbolicLink(); } catch {}
|
|
600
|
+
if (isLink) {
|
|
601
|
+
try {
|
|
602
|
+
if (process.platform === 'win32') {
|
|
603
|
+
// Windows rmdir 删 junction(reparse point)不跟随目标,保护主 checkout
|
|
604
|
+
execSync(`rmdir "${wtNodeModules}"`, { shell: 'cmd.exe' });
|
|
605
|
+
} else {
|
|
606
|
+
unlinkSync(wtNodeModules);
|
|
607
|
+
}
|
|
608
|
+
details.push('worktree node_modules junction/symlink removed (protect main checkout)');
|
|
609
|
+
} catch (e) {
|
|
610
|
+
details.push(`node_modules link remove failed: ${e.message}`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
593
616
|
// 1. git worktree remove(带 retry)—— in-place 跳过:无 git worktree 注册,且 worktreePath 即主工作区
|
|
594
617
|
let gitRemoveOk = false;
|
|
595
618
|
if (!isInPlace && existsSync(worktreePath)) {
|
package/.claude/CLAUDE.md
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
# Claude Code 指引
|
|
2
|
-
|
|
3
|
-
# SillySpec工具定位
|
|
4
|
-
1. SillySpec 是给 Agent 调用的 CLI 工具,不是给人类直接使用的产品
|
|
5
|
-
2. SillySpec 是管理 Agent 工作流的 CLI 工具,不是处理业务逻辑的工具
|
|
6
|
-
3. 你是 Agent。SillySpec 是你的流程控制器。你通过 CLI 命令告诉它"我在哪",它告诉你"下一步该做什么"。你执行步骤,它校验产出、推进状态。人类用户只在关键决策点介入审批
|
|
7
|
-
|
|
8
|
-
## 文件生命周期文档同步
|
|
9
|
-
每次修改 `src/stages/` 下的阶段定义(prompt、步骤、输出文件名等)或 `src/run.js`、`src/progress.js` 等影响文件生命周期的代码后,**必须同步更新** `docs/sillyspec/file-lifecycle.md`,确保文档与代码一致。
|
|
10
|
-
|
|
11
|
-
### 触发更新的典型改动
|
|
12
|
-
- 新增/删除/重命名阶段步骤
|
|
13
|
-
- 修改步骤 prompt 中的输出文件名(如 verify-result.md)
|
|
14
|
-
- 修改阶段间的流转逻辑(如 archive 归档方式)
|
|
15
|
-
- 新增/删除运行时文件类型(如 gate-status.json)
|
|
16
|
-
- 修改 ProgressManager 的数据存储方式(如 SQLite 表结构变更)
|
|
17
|
-
|
|
18
|
-
### 更新检查清单
|
|
19
|
-
- [ ] 文件名引用一致(prompt 输出的文件名 == validateFileLocations 期望的文件名)
|
|
20
|
-
- [ ] 阶段步骤描述与 `src/stages/*.js` 一致
|
|
21
|
-
- [ ] 归档/清理流程描述与实际代码逻辑一致
|
|
22
|
-
- [ ] 数据库 Schema 描述与 `src/db.js` 一致
|
|
23
|
-
- [ ] 更新文档头部 `updated_at` 时间戳
|