sillyspec 3.26.14 → 3.26.15
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-archive/SKILL.md +2 -0
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +1 -1
- package/.claude/skills/sillyspec-commit/SKILL.md +18 -60
- package/.claude/skills/sillyspec-continue/SKILL.md +2 -0
- package/.claude/skills/sillyspec-execute/SKILL.md +10 -4
- package/.claude/skills/sillyspec-plan/SKILL.md +10 -2
- package/.claude/skills/sillyspec-quick/SKILL.md +2 -2
- package/.claude/skills/sillyspec-resume/SKILL.md +2 -0
- package/.claude/skills/sillyspec-verify/SKILL.md +2 -0
- package/.claude/skills/sillyspec-workspace/SKILL.md +7 -1
- package/package.json +1 -1
- package/src/commit-suggest.js +202 -0
- package/src/config-cat.js +88 -0
- package/src/docs-check.js +48 -12
- package/src/doctor-diagnostics.js +49 -0
- package/src/hooks/worktree-guard.js +20 -1
- package/src/index.js +508 -6
- package/src/init.js +1 -1
- package/src/local-register.js +98 -0
- package/src/module-impact.js +146 -0
- package/src/progress/change-registry.js +43 -4
- package/src/progress/stage-machine.js +10 -5
- package/src/progress.js +13 -1
- package/src/quicklog.js +106 -1
- package/src/run/command.js +95 -1
- package/src/run/complete-handlers.js +56 -3
- package/src/run/complete.js +4 -7
- package/src/run/gates.js +62 -4
- package/src/run/multi-repo-context.js +3 -1
- package/src/run/next.js +128 -0
- package/src/run/prompt.js +20 -2
- package/src/run/shared.js +2 -2
- package/src/scan-postcheck.js +142 -2
- package/src/stage-contract-spec.js +1 -1
- package/src/stage-review.js +15 -6
- package/src/stages/archive.js +1 -1
- package/src/stages/brainstorm.js +1 -0
- package/src/stages/execute.js +105 -26
- package/src/stages/knowledge.js +3 -2
- package/src/stages/quick.js +14 -6
- package/src/stages/scan.js +1 -0
- package/src/stages/verify.js +7 -49
- package/src/task-review.js +218 -4
- package/src/verify-probes.js +264 -0
- package/src/workspace.js +125 -0
- package/src/worktree-apply.js +15 -1
- package/src/worktree.js +58 -8
- package/templates/claude-instruction.md +1 -1
- package/templates/prompts/taskcard-rules.md +1 -1
- package/templates/prompts/verify-probes.md +13 -47
|
@@ -283,7 +283,10 @@ export async function archiveChangeDirectory(pm, cwd, progress, specBase, platfo
|
|
|
283
283
|
const alreadyArchivedDir = findAlreadyArchivedDir(archiveDir, archiveChangeName)
|
|
284
284
|
if (alreadyArchivedDir) {
|
|
285
285
|
console.log(`ℹ️ 源目录不存在但变更已在 archive/(${basename(alreadyArchivedDir)}),判定已归档,自愈进度 DB`)
|
|
286
|
-
|
|
286
|
+
// 终态一致化(坑 manual-archive-desync-status-only):手动搬目录绕过标准归档后,本自愈路径
|
|
287
|
+
// 不能只翻 status——同时收尾 archive 阶段(steps/stages/current_stage),否则推送平台的终态
|
|
288
|
+
// 是「已归档 + 归档 0/5 + 停在 execute」的矛盾体,详情页渲染成「进度丢失」
|
|
289
|
+
pm.unregisterChange(cwd, archiveChangeName, { archiveStepNames: typeof pm.archiveStepNamesForArchive === 'function' ? pm.archiveStepNamesForArchive() : null })
|
|
287
290
|
await archiveWorktreeCleanup(cwd, archiveChangeName, specBase, platformOpts)
|
|
288
291
|
console.log(`📦 已自愈归档:${archiveChangeName} → archive/${basename(alreadyArchivedDir)}/`)
|
|
289
292
|
return alreadyArchivedDir
|
|
@@ -332,7 +335,9 @@ export async function archiveChangeDirectory(pm, cwd, progress, specBase, platfo
|
|
|
332
335
|
process.exit(1)
|
|
333
336
|
}
|
|
334
337
|
|
|
335
|
-
|
|
338
|
+
// 正常路径同样走终态一致化(坑 manual-archive-desync-status-only):标准 --done --confirm 流程
|
|
339
|
+
// 中 completeStep 随后也会逐项完成,此处先收尾是幂等写同值——保证任何走出本函数的归档终态一致
|
|
340
|
+
pm.unregisterChange(cwd, archiveChangeName, { archiveStepNames: typeof pm.archiveStepNamesForArchive === 'function' ? pm.archiveStepNamesForArchive() : null })
|
|
336
341
|
|
|
337
342
|
// CLI 下沉 git add(坑4,FR-04):确定性暂存归档目录 + 模块文档,不靠 step5 prompt 驱动。
|
|
338
343
|
// step5 prompt 的 git add 保留作幂等兜底;POSIX 路径跨平台(git 接受正斜杠)。
|
|
@@ -343,6 +348,53 @@ export async function archiveChangeDirectory(pm, cwd, progress, specBase, platfo
|
|
|
343
348
|
safeGit(cwd, ['add', '--', '.sillyspec/docs/'])
|
|
344
349
|
} catch {}
|
|
345
350
|
|
|
351
|
+
// ── 他者半归档残留探测(坑 archive-other-residual-rename,2026-08-21 实证)──
|
|
352
|
+
// 并行变更的手动归档把 R 残留(源目录 rename 目标行)留在暂存区;本变更归档提交时
|
|
353
|
+
// git status 显示它们,agent 会误判「还没提交完 / 要做第二次提交」。区分:
|
|
354
|
+
// 本变更:未暂存的源侧移动(` D changes/<me>/...` + 未跟踪 archive/<me>/...)→ 补暂存
|
|
355
|
+
// 让归档成单次原子提交;
|
|
356
|
+
// 他者:已暂存的 rename(`R changes/<他人>/... -> changes/archive/<他人>/...`)→ 只 warn
|
|
357
|
+
// 提示归属(不 stage 不动——别人的归别人的)。
|
|
358
|
+
try {
|
|
359
|
+
const raw = gitQuiet(cwd, ['status', '--porcelain'], { trim: false, timeout: 30000 })
|
|
360
|
+
if (raw && raw.trim()) {
|
|
361
|
+
const changesPrefix = '.sillyspec/changes/'
|
|
362
|
+
const minePaths = []
|
|
363
|
+
const othersResidual = []
|
|
364
|
+
for (const line of raw.split('\n')) {
|
|
365
|
+
if (!line || line.length < 4) continue
|
|
366
|
+
const x = line[0], y = line[1]
|
|
367
|
+
const body = line.slice(3).trim()
|
|
368
|
+
const arrow = body.indexOf(' -> ')
|
|
369
|
+
const src = arrow !== -1 ? body.slice(0, arrow).replace(/^"|"$/g, '') : null
|
|
370
|
+
const dst = (arrow !== -1 ? body.slice(arrow + 4) : body).replace(/^"|"$/g, '')
|
|
371
|
+
if (x === 'R' && y === ' ' && arrow !== -1 && dst.startsWith(changesPrefix + 'archive/')) {
|
|
372
|
+
// 已暂存 rename → 半归档残留;按目标目录名归属他者变更
|
|
373
|
+
const m = /^\.sillyspec\/changes\/archive\/([^/]+)\//.exec(dst)
|
|
374
|
+
const owner = m ? m[1] : '?'
|
|
375
|
+
if (owner !== archiveChangeName) othersResidual.push(owner)
|
|
376
|
+
} else if (x === ' ' && (y === 'D' || y === 'A' || y === 'M')) {
|
|
377
|
+
// 未暂存的源侧移动:源目录 D / 新位置 A——指向本变更的补暂存
|
|
378
|
+
const p = dst || src
|
|
379
|
+
if (p && (p.startsWith(changesPrefix + archiveChangeName + '/')
|
|
380
|
+
|| p.startsWith(changesPrefix + 'archive/' + archiveChangeName + '/'))) {
|
|
381
|
+
minePaths.push(p)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (minePaths.length > 0) {
|
|
386
|
+
try { safeGit(cwd, ['add', '-A', '--', changesPrefix]) } catch {}
|
|
387
|
+
console.log(`🧾 已补暂存本变更归档的源侧移动(${minePaths.length} 项,归档成单次原子提交)`)
|
|
388
|
+
}
|
|
389
|
+
if (othersResidual.length > 0) {
|
|
390
|
+
const owners = [...new Set(othersResidual)].filter(Boolean)
|
|
391
|
+
console.warn(`⚠️ 检测到「他者半归档」残留(暂存区存在其他变更的 rename 记录):${owners.join('、')}`)
|
|
392
|
+
console.warn(` 这些是别的变更此前手动归档留下的暂存项,不属于本次归档——git status 里看到它们是正常的,`)
|
|
393
|
+
console.warn(` 本变更归档已完成,无需为其做第二次提交;如需清理走它们自己的收尾(或 git restore --staged 后核对)。`)
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
} catch { /* 探测失败不阻断归档(advisory) */ }
|
|
397
|
+
|
|
346
398
|
// 归档时清理可能残留的 worktree(自愈路径也复用,见上方 srcDir 缺失分支)。
|
|
347
399
|
await archiveWorktreeCleanup(cwd, archiveChangeName, specBase, platformOpts)
|
|
348
400
|
|
|
@@ -1037,7 +1089,8 @@ async function closeSingleQuickLinkedChange({ pm, cwd, specBase, changeName, pla
|
|
|
1037
1089
|
return { closed: false, reason: `移动目录失败:${e.message}` }
|
|
1038
1090
|
}
|
|
1039
1091
|
|
|
1040
|
-
|
|
1092
|
+
// 终态一致化同标准归档(坑 manual-archive-desync-status-only):轻量归档路径不能只翻 status
|
|
1093
|
+
pm.unregisterChange(cwd, changeName, { archiveStepNames: typeof pm.archiveStepNamesForArchive === 'function' ? pm.archiveStepNamesForArchive() : null })
|
|
1041
1094
|
await archiveWorktreeCleanup(cwd, changeName, specBase, platformOpts)
|
|
1042
1095
|
safeGit(cwd, ['add', '--', `.sillyspec/changes/archive/${destName}/`])
|
|
1043
1096
|
|
package/src/run/complete.js
CHANGED
|
@@ -456,7 +456,7 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
|
|
|
456
456
|
console.log(`\n${summary}`)
|
|
457
457
|
} catch (e) {
|
|
458
458
|
// summary 失败不影响主流程
|
|
459
|
-
console.log(
|
|
459
|
+
console.log(`\n👉 下一步:sillyspec run verify${changeName ? ` --change ${changeName}` : ''}(验证通过后才能归档)`)
|
|
460
460
|
}
|
|
461
461
|
} else if (stageName === 'archive') {
|
|
462
462
|
console.log('\n👉 归档完成!现在可以提交了:git commit -m "..."')
|
|
@@ -534,12 +534,9 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
|
|
|
534
534
|
// 易被 tail 截断),agent 常到 --done 被拦才第一次见到模板。推进到末步的此刻用底部短块预告
|
|
535
535
|
// 模板 + 可照抄命令,省一轮拦截往返(拦截路径保留兜底)。
|
|
536
536
|
if (stageName === 'quick' && nextPendingIdx === steps.length - 1) {
|
|
537
|
-
console.log(`\n📌 本步是 quick 末步:--done
|
|
538
|
-
console.log(` sillyspec run quick --done${changeName ? ` --change ${changeName}` : ''} --
|
|
539
|
-
console.log(`
|
|
540
|
-
console.log(` 方案:怎么改的`)
|
|
541
|
-
console.log(` 结果:验证情况(测试数 / lint / typecheck / 部署状态)"`)
|
|
542
|
-
console.log(` 四字段逐项一句话、不可「见前述」;可选 --file-notes "path::括注 || path2" 落多行文件括注`)
|
|
537
|
+
console.log(`\n📌 本步是 quick 末步:--done 必须给全四字段(CLI 硬校验,缺任一项会被拒、补全重跑不丢进度)。推荐四参数形式(CLI 自动合成,无格式事故面):`)
|
|
538
|
+
console.log(` sillyspec run quick --done${changeName ? ` --change ${changeName}` : ''} --req "一句话语义化短标题(即 QUICKLOG 条目标题)" --cause "为什么这样改(纯新增/样式则写「无,纯新增/纯样式」)" --solution "怎么改的" --result "验证情况(测试数 / lint / typecheck / 部署状态)"`)
|
|
539
|
+
console.log(` 兼容旧形式:--output "需求:… 根因:… 方案:… 结果:…"(四字段逐项一句话、不可「见前述」,正文内禁嵌套全角冒号);可选 --file-notes "path::括注 || path2" 落多行文件括注`)
|
|
543
540
|
}
|
|
544
541
|
}
|
|
545
542
|
return { stageCompleted: false, currentIdx, nextPendingIdx }
|
package/src/run/gates.js
CHANGED
|
@@ -65,9 +65,43 @@ export function validateSymbolImpactReport({ reportContent, planContent }) {
|
|
|
65
65
|
for (const id of missing) {
|
|
66
66
|
errors.push(`${id} 未在 symbol-impact.md 中出现——每个 task 都要有一行结论(含「无签名级变更」的显式声明)`)
|
|
67
67
|
}
|
|
68
|
+
// 防骨架直接过门(2026-08-21 agent-手工产出审计项⑤):CLI 会代生成逐 task TODO 骨架
|
|
69
|
+
//(generateSymbolImpactSkeleton),占位未替换即拦——骨架消灭的是「从零手写/漏 task」错误面,
|
|
70
|
+
// 不是把「逐 task 结论」本身变成走过场。
|
|
71
|
+
const report = String(reportContent)
|
|
72
|
+
const todoPending = taskIds.filter(id => new RegExp(`^[-*]\\s*${id}[^\\n]*<!--TODO-->`, 'm').test(report))
|
|
73
|
+
for (const id of todoPending) {
|
|
74
|
+
errors.push(`${id} 的结论仍是骨架 <!--TODO--> 占位——替换为真实结论(无签名级变更也显式写「无」)`)
|
|
75
|
+
}
|
|
68
76
|
return { ok: errors.length === 0, errors }
|
|
69
77
|
}
|
|
70
78
|
|
|
79
|
+
/**
|
|
80
|
+
* 生成 symbol-impact.md 逐 task TODO 骨架(2026-08-21 审计项⑤「报错即生成」)。
|
|
81
|
+
*
|
|
82
|
+
* gate 此前只报错不代填 → agent 从零手写整份报告,忘一个 task 就被拦、再补再撞。
|
|
83
|
+
* 骨架从 tasks.md 注册表生成逐 task 占位行,agent 只需逐行填结论;占位 <!--TODO-->
|
|
84
|
+
* 由 validateSymbolImpactReport 拒绝,骨架不能直接过门(防偷懒)。
|
|
85
|
+
*
|
|
86
|
+
* @param {string} tasksContent - 任务注册表(tasks.md,回退 plan.md)内容
|
|
87
|
+
* @returns {string|null} 骨架全文(LF);注册表无 task 行 → null
|
|
88
|
+
*/
|
|
89
|
+
export function generateSymbolImpactSkeleton(tasksContent) {
|
|
90
|
+
const taskIds = extractTaskIdsFromRegistry(tasksContent)
|
|
91
|
+
if (taskIds.length === 0) return null
|
|
92
|
+
const lines = [
|
|
93
|
+
'# 符号影响面报告',
|
|
94
|
+
'',
|
|
95
|
+
'> 骨架由 CLI 生成(`sillyspec symbol-impact --change <变更名>`,gate 失败时也会自动落一份)。',
|
|
96
|
+
'> 逐行把 `<!--TODO-->` 替换为真实结论:涉及签名级变更(构造函数参数/接口/DTO/方法签名增删改)',
|
|
97
|
+
'> 写变更类型 + 受影响调用点 + 是否在任务范围内;无签名级变更也要显式写「无签名级变更」。',
|
|
98
|
+
'> **gate 拒绝仍含 <!--TODO--> 的行**——骨架不能直接过门。',
|
|
99
|
+
'',
|
|
100
|
+
]
|
|
101
|
+
for (const id of taskIds) lines.push(`- ${id}: <!--TODO-->`)
|
|
102
|
+
return lines.join('\n') + '\n'
|
|
103
|
+
}
|
|
104
|
+
|
|
71
105
|
/**
|
|
72
106
|
* 符号影响面报告硬门:execute「加载上下文」步 --done 时校验 symbol-impact.md 落盘 + task 覆盖度。
|
|
73
107
|
* fail-closed:不通过 → 当前步标 blocked + exit 1(进度不推进)。非目标步骤/阶段直接放行。
|
|
@@ -88,7 +122,19 @@ export async function enforceSymbolImpactGate(stageName, changeName, currentStep
|
|
|
88
122
|
console.error(` 「加载上下文」步的符号影响面检查报告未落盘或不完整:`)
|
|
89
123
|
console.error(` 期望路径:${reportPath}`)
|
|
90
124
|
for (const e of result.errors) console.error(` • ${e}`)
|
|
91
|
-
|
|
125
|
+
// 报错即生成(2026-08-21 审计项⑤):报告缺失时自动落一份逐 task TODO 骨架,agent 从
|
|
126
|
+
// 「从零手写整份」变「逐行填结论」;TODO 占位由 validate 拒绝,骨架不能直接过门。
|
|
127
|
+
let skeletonNote = ''
|
|
128
|
+
if (!existsSync(reportPath)) {
|
|
129
|
+
const skeleton = generateSymbolImpactSkeleton(tasksContent)
|
|
130
|
+
if (skeleton) {
|
|
131
|
+
try {
|
|
132
|
+
writeFileSync(reportPath, skeleton)
|
|
133
|
+
skeletonNote = `\n 📄 已代生成逐 task 骨架:${reportPath}(逐行替换 <!--TODO--> 为结论,无签名级变更也显式写「无」)`
|
|
134
|
+
} catch { /* 写失败只影响提示,不掩盖原始错误 */ }
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
console.error(` 修复:按「加载上下文」步操作 11 完成符号影响面扫描,逐 task 写结论后重跑 execute --done。${skeletonNote}`)
|
|
92
138
|
process.exit(1)
|
|
93
139
|
}
|
|
94
140
|
|
|
@@ -195,7 +241,7 @@ export async function enforceAlignExecuteReviewGate({ cwd, changeName, specBase,
|
|
|
195
241
|
}
|
|
196
242
|
const searchDirs = [effectiveSpecBase, reviewChangeDir, cwd].filter(Boolean)
|
|
197
243
|
const reviewResult = validateStageReview({ stage: 'execute', reviewType: 'acceptance', runtimeRoot, reviewRunId, searchDirs })
|
|
198
|
-
printStageReviewResult(reviewResult, { stage: 'execute', reviewRunId, runtimeRoot })
|
|
244
|
+
printStageReviewResult(reviewResult, { stage: 'execute', reviewRunId, runtimeRoot, changeName })
|
|
199
245
|
if (!reviewResult.ok) return true // printStageReviewResult 已给明细,不再重复列
|
|
200
246
|
}
|
|
201
247
|
} catch (e) {
|
|
@@ -292,6 +338,18 @@ export async function enforceDepsGate(stageName, cwd, changeName, step, steps, c
|
|
|
292
338
|
*/
|
|
293
339
|
export async function enforceReviewJsonGate(stageName, cwd, changeName, step, steps, currentIdx, specBase, platformOpts) {
|
|
294
340
|
if (stageName !== 'execute' || !changeName) return true
|
|
341
|
+
// head 锡点自动落盘(2026-08-21 审计项③,D-010 补对称):跨仓 task base_commit 已在派发时
|
|
342
|
+
// CLI 落盘,head_commit 此前靠主 agent 按 prompt 手跑 rev-parse 手写(漏抄/抄错炸 review gate)。
|
|
343
|
+
// 每次 --done 时机幂等补齐(已存在不覆盖)。best-effort:失败只 warn,不阻断 --done 主流程。
|
|
344
|
+
try {
|
|
345
|
+
const { stampCrossRepoHeadCommits } = await import('../stages/execute.js')
|
|
346
|
+
const stamped = await stampCrossRepoHeadCommits({ changeName, cwd, specBase, platformOpts })
|
|
347
|
+
if (stamped && stamped.stamped > 0) {
|
|
348
|
+
console.log(`📌 已自动落盘跨仓 task head_commit 锡点:${stamped.stampedTasks.join(', ')}(幂等,此后 --done 不再重复写)`)
|
|
349
|
+
}
|
|
350
|
+
} catch (e) {
|
|
351
|
+
console.warn(`⚠️ head_commit 锡点自动落盘失败(降级留给 agent 手写): ${e.message}`)
|
|
352
|
+
}
|
|
295
353
|
const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
|
|
296
354
|
const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
|
|
297
355
|
const planPath = join(specBase, 'changes', changeName, 'plan.md')
|
|
@@ -340,7 +398,7 @@ export async function enforceReviewJsonGate(stageName, cwd, changeName, step, st
|
|
|
340
398
|
console.error(` • ${f.taskId}(${kindLabel}): ${f.reviewPath}`)
|
|
341
399
|
for (const e of f.errors) console.error(` - ${e}`)
|
|
342
400
|
}
|
|
343
|
-
console.error('
|
|
401
|
+
console.error(' 修复:mechanics 字段(base/head/changedFiles/schemaVersion)缺错可跑 sillyspec backfill-reviews --change ' + changeName + ' --adopt 一键代填(verdict 保留);verdict/证据缺失需人工补全后重跑 execute --done。')
|
|
344
402
|
process.exit(1)
|
|
345
403
|
}
|
|
346
404
|
|
|
@@ -531,7 +589,7 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
|
|
|
531
589
|
: 'acceptance'
|
|
532
590
|
const searchDirs = [effectiveSpecBase, reviewChangeDir, cwd].filter(Boolean)
|
|
533
591
|
const reviewResult = validateStageReview({ stage: stageName, reviewType, runtimeRoot, reviewRunId, searchDirs })
|
|
534
|
-
printStageReviewResult(reviewResult, { stage: stageName, reviewRunId, runtimeRoot })
|
|
592
|
+
printStageReviewResult(reviewResult, { stage: stageName, reviewRunId, runtimeRoot, changeName })
|
|
535
593
|
if (!reviewResult.ok) {
|
|
536
594
|
return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
|
|
537
595
|
}
|
|
@@ -79,7 +79,9 @@ export class MultiRepoContext {
|
|
|
79
79
|
throw new Error(
|
|
80
80
|
`MultiRepoContext: 以下 repo 未在 local.yaml repos: 段注册:[${unregistered.join(', ')}]。` +
|
|
81
81
|
`当前已注册 repo:[${registered.join(', ')}]。` +
|
|
82
|
-
|
|
82
|
+
`一键注册(每个缺的 key 各跑一次,勿手编 YAML):` +
|
|
83
|
+
unregistered.map(k => `sillyspec local register-repo ${k} <${k} 仓根路径>`).join(';') +
|
|
84
|
+
`。补注册后再启动 execute(约束② fail-closed,跨仓 apply 走错仓=数据所有权事故)。`
|
|
83
85
|
);
|
|
84
86
|
}
|
|
85
87
|
|
package/src/run/next.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* next.js — `sillyspec next` 项目状态探测(吸收 continue/resume 两个 skill 的手工探测表,
|
|
3
|
+
* 2026-08-21 agent-手工产出审计第二批 G7/G8)。
|
|
4
|
+
*
|
|
5
|
+
* 两个 skill 各自维护一套「文件存在性状态机」推断表且内容重复,agent 每次手工跑
|
|
6
|
+
* ls/cat 探测再查表——纯机械探测是 CLI 的本职。本模块输出「状态描述 + 下一步命令 +
|
|
7
|
+
* 依据文件」,agent 按输出执行即可;skill 保留作兜底(CLI 不可用/输出异常时)。
|
|
8
|
+
*
|
|
9
|
+
* 纯 fs 探测(零 DB 依赖、零 token、快):进度库的权威状态归 `sillyspec progress show`,
|
|
10
|
+
* 本命令管「没有活跃进度时从产物文件反推该干什么」。决策表(两个 skill 合并):
|
|
11
|
+
*
|
|
12
|
+
* 1. HANDOFF.json 存在 → 交接恢复
|
|
13
|
+
* 2. 活跃变更(changes/ 非 archive)逐个推断:
|
|
14
|
+
* 无文件→补 proposal / 无 design.md→brainstorm / 无 tasks.md→brainstorm 末步 /
|
|
15
|
+
* 无 plan.md→plan / tasks.md 有未勾→execute / 全勾无 verify-result.md→verify /
|
|
16
|
+
* 有 verify-result.md→archive
|
|
17
|
+
* 3. 有 docs/<p>/scan/ 无活跃变更 → brainstorm(已扫描待开变更)
|
|
18
|
+
* 4. 有 REQUIREMENTS.md/ROADMAP.md 无变更 → brainstorm(绿地已有需求)
|
|
19
|
+
* 5. 什么都没有 → init(新项目)或 scan(棕地)
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, readdirSync, readFileSync } from 'fs'
|
|
22
|
+
import { join } from 'path'
|
|
23
|
+
import { resolveSpecDir } from './shared.js'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 数 tasks.md checkbox:返回 { checked, total }(无文件 → {checked:0,total:0})
|
|
27
|
+
*/
|
|
28
|
+
function countTasks(tasksPath) {
|
|
29
|
+
if (!existsSync(tasksPath)) return { checked: 0, total: 0 }
|
|
30
|
+
let checked = 0
|
|
31
|
+
let total = 0
|
|
32
|
+
for (const line of readFileSync(tasksPath, 'utf8').split('\n')) {
|
|
33
|
+
const m = line.match(/^[-*]\s*\[([ xX])\]\s*task-\d+\b/)
|
|
34
|
+
if (!m) continue
|
|
35
|
+
total++
|
|
36
|
+
if (m[1] !== ' ') checked++
|
|
37
|
+
}
|
|
38
|
+
return { checked, total }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 单个活跃变更的产物推断:返回 { state, next, evidence }
|
|
43
|
+
*/
|
|
44
|
+
function inferChange(changeDir, name) {
|
|
45
|
+
const has = (f) => existsSync(join(changeDir, f))
|
|
46
|
+
const ev = (f) => join('.sillyspec', 'changes', name, f)
|
|
47
|
+
const t = countTasks(join(changeDir, 'tasks.md'))
|
|
48
|
+
|
|
49
|
+
if (!has('proposal.md') && !has('design.md') && !has('tasks.md') && !has('plan.md')) {
|
|
50
|
+
return { state: `${name}:变更目录为空`, next: 'sillyspec run brainstorm(完善 proposal)或清理该空目录', evidence: [ev('(无产物)')] }
|
|
51
|
+
}
|
|
52
|
+
if (!has('design.md')) {
|
|
53
|
+
return { state: `${name}:有 proposal 缺 design.md`, next: `sillyspec run brainstorm --change ${name}(推进到设计产物)`, evidence: [ev('proposal.md')] }
|
|
54
|
+
}
|
|
55
|
+
if (!has('tasks.md')) {
|
|
56
|
+
return { state: `${name}:有 design 缺 tasks.md`, next: `sillyspec run brainstorm --change ${name}(末步生成规范文件)`, evidence: [ev('design.md')] }
|
|
57
|
+
}
|
|
58
|
+
if (!has('plan.md')) {
|
|
59
|
+
return { state: `${name}:有 tasks.md 缺 plan.md`, next: `sillyspec run plan --change ${name}`, evidence: [ev('tasks.md')] }
|
|
60
|
+
}
|
|
61
|
+
if (t.total > 0 && t.checked < t.total) {
|
|
62
|
+
return {
|
|
63
|
+
state: `${name}:执行中(task ${t.checked}/${t.total})`,
|
|
64
|
+
next: `sillyspec run execute --change ${name}`,
|
|
65
|
+
evidence: [ev('plan.md'), ev('tasks.md')],
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (!has('verify-result.md')) {
|
|
69
|
+
return { state: `${name}:task 全勾,待验证`, next: `sillyspec run verify --change ${name}`, evidence: [ev('tasks.md'), ev('plan.md')] }
|
|
70
|
+
}
|
|
71
|
+
return { state: `${name}:已验证,待归档`, next: `sillyspec run archive --change ${name}`, evidence: [ev('verify-result.md')] }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 探测当前项目状态与下一步命令。
|
|
76
|
+
* @param {{ cwd: string, specDir?: string|null }} opts
|
|
77
|
+
* @returns {{ state: string, next: string, evidence: string[], activeChanges: Array<{name,checked,total}> }}
|
|
78
|
+
* activeChanges 附带各变更 task 勾选进度(供 --json 消费);evidence 为相对 spec 根的路径
|
|
79
|
+
*/
|
|
80
|
+
export function detectNextStep({ cwd, specDir = null }) {
|
|
81
|
+
const specBase = resolveSpecDir(cwd, { specDir })
|
|
82
|
+
const changesRoot = join(specBase, 'changes')
|
|
83
|
+
|
|
84
|
+
// 1. HANDOFF 交接
|
|
85
|
+
for (const handoff of [join(cwd, 'HANDOFF.json'), join(specBase, 'HANDOFF.json')]) {
|
|
86
|
+
if (existsSync(handoff)) {
|
|
87
|
+
return { state: '存在 HANDOFF.json(上次会话交接)', next: '读 HANDOFF.json 后从交接点继续(sillyspec progress show 看活跃进度)', evidence: [handoff], activeChanges: [] }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 2. 活跃变更(排除 archive)
|
|
92
|
+
let active = []
|
|
93
|
+
try {
|
|
94
|
+
active = readdirSync(changesRoot, { withFileTypes: true })
|
|
95
|
+
.filter(e => e.isDirectory() && e.name !== 'archive')
|
|
96
|
+
.map(e => e.name)
|
|
97
|
+
.sort()
|
|
98
|
+
} catch { /* 无 changes/ 目录 */ }
|
|
99
|
+
if (active.length > 0) {
|
|
100
|
+
// 多活跃时逐个推断全报告,next 取第一个(字典序最前)的;调用方输出含全部状态行
|
|
101
|
+
const inferences = active.map(name => inferChange(join(changesRoot, name), name))
|
|
102
|
+
const progress = active.map((name, i) => {
|
|
103
|
+
const t = countTasks(join(changesRoot, name, 'tasks.md'))
|
|
104
|
+
return { name, checked: t.checked, total: t.total, state: inferences[i].state }
|
|
105
|
+
})
|
|
106
|
+
return { state: inferences.map(i => i.state).join(';'), next: inferences[0].next, evidence: inferences[0].evidence, activeChanges: progress }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 3. 已扫描无变更
|
|
110
|
+
const docsDir = join(specBase, 'docs')
|
|
111
|
+
try {
|
|
112
|
+
const scanned = readdirSync(docsDir, { withFileTypes: true })
|
|
113
|
+
.filter(e => e.isDirectory() && existsSync(join(docsDir, e.name, 'scan')))
|
|
114
|
+
if (scanned.length > 0) {
|
|
115
|
+
return { state: `已扫描(${scanned.length} 个项目文档),无活跃变更`, next: 'sillyspec run brainstorm "你的想法"', evidence: [join('.sillyspec', 'docs', scanned[0].name, 'scan')], activeChanges: [] }
|
|
116
|
+
}
|
|
117
|
+
} catch { /* 无 docs/ */ }
|
|
118
|
+
|
|
119
|
+
// 4. 绿地已有需求
|
|
120
|
+
for (const f of ['REQUIREMENTS.md', 'ROADMAP.md']) {
|
|
121
|
+
if (existsSync(join(specBase, f))) {
|
|
122
|
+
return { state: `绿地项目已有 ${f},无活跃变更`, next: 'sillyspec run brainstorm "你的想法"', evidence: [join('.sillyspec', f)], activeChanges: [] }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 5. 什么都没有
|
|
127
|
+
return { state: '未初始化(无 .sillyspec 产物)', next: 'sillyspec init(新项目)或 sillyspec scan(棕地项目)', evidence: [], activeChanges: [] }
|
|
128
|
+
}
|
package/src/run/prompt.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import { basename, join } from 'node:path'
|
|
22
22
|
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
23
23
|
import { stageRegistry } from '../stages/index.js'
|
|
24
|
-
import { resolvePromptIncludes, resolveRuntimeRoot, safeGit, WAIT_MARKER_RE } from './shared.js'
|
|
24
|
+
import { resolvePromptIncludes, resolveRuntimeRoot, safeGit, WAIT_MARKER_RE, QUICK_SID_RE } from './shared.js'
|
|
25
25
|
import { renderStageContract } from '../stage-contract-spec.js'
|
|
26
26
|
import { parseModuleMapSimple } from '../modules.js'
|
|
27
27
|
import { REVIEW_SCHEMA_VERSION, isValidExecuteRunId } from '../task-review.js'
|
|
@@ -236,6 +236,16 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
|
|
|
236
236
|
promptText = promptText.replace(/<now-datetime>/g, nowDatetime)
|
|
237
237
|
promptText = promptText.replace(/<now-timestamp>/g, nowTimestamp)
|
|
238
238
|
promptText = promptText.replace(/<now-date>/g, nowDate)
|
|
239
|
+
// <now-iso-datetime>(scan frontmatter updated_at 用;与 scan-fix-headers 同款秒级格式)
|
|
240
|
+
promptText = promptText.replace(/<now-iso-datetime>/g, now.toISOString().slice(0, 19).replace('T', ' '))
|
|
241
|
+
// <git-head-short>(scan frontmatter source_commit 用;CLI 代查,agent 勿自跑 rev-parse)
|
|
242
|
+
if (promptText.includes('<git-head-short>')) {
|
|
243
|
+
let headShort = 'unknown'
|
|
244
|
+
try {
|
|
245
|
+
headShort = safeGit(cwd, ['rev-parse', '--short', 'HEAD']).value || 'unknown'
|
|
246
|
+
} catch { /* git 不可用 → unknown,scan-postcheck 失配时再核 */ }
|
|
247
|
+
promptText = promptText.replace(/<git-head-short>/g, headShort)
|
|
248
|
+
}
|
|
239
249
|
// 替换 {REVIEW_SCHEMA_VERSION} 占位符(task review 示例模板用,值=CLI 当前 REVIEW_SCHEMA_VERSION 常量,
|
|
240
250
|
// 避免 agent 照抄 design 目标版本与 CLI 写侧常量漂移;与 stage review 契约 renderReviewJsonContract 动态注入同源)
|
|
241
251
|
if (promptText.includes('{REVIEW_SCHEMA_VERSION}')) {
|
|
@@ -722,7 +732,15 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
|
|
|
722
732
|
// 普通本地保持相对 '.sillyspec' 展示;runtimeRoot-only 组合不再 join(null) 崩溃
|
|
723
733
|
const changeDirBase = platformOpts?.specRoot || platformOpts?.specDriftAnchor || '.sillyspec'
|
|
724
734
|
const changeDir = join(changeDirBase, 'changes', changeName)
|
|
725
|
-
|
|
735
|
+
// quick 会话(quick-<hex8>)按设计无实体变更目录(progress.js initChange 同款跳过),
|
|
736
|
+
// 纯代码 quick 根本不产 spec 文档——「所有变更文件必须写入 changes/<change>/」的硬规则
|
|
737
|
+
// 对它是误导(ql-20260821-011 实证:CLI 反复提示一个用不到的目录)。改述条件化:
|
|
738
|
+
// 仅「若本 quick 决定补文档」才落该目录,代码改动照常写源码目录。
|
|
739
|
+
if (QUICK_SID_RE.test(changeName)) {
|
|
740
|
+
console.log(`- **文件路径规则(quick 会话):纯代码改动直接写源码目录,无目录限制。仅当本 quick 需要落 spec 文档(design/plan 等,复杂任务建议升级完整流程)时才写 \`${changeDir}/\`;QUICKLOG/tasks.md 记录由 CLI 接管,不要手建。**`)
|
|
741
|
+
} else {
|
|
742
|
+
console.log(`- **文件路径规则:所有变更文件必须写入 \`${changeDir}/\` 目录下。不要自己拼接路径,直接使用 changeDir 值。示例:\`${changeDir}/proposal.md\`**`)
|
|
743
|
+
}
|
|
726
744
|
}
|
|
727
745
|
}
|
|
728
746
|
const changeFlag = changeName ? ` --change ${changeName}` : ''
|
package/src/run/shared.js
CHANGED
|
@@ -460,8 +460,8 @@ async function raceWithAbort(op, timeoutMs = SYNC_TOTAL_TIMEOUT_MS) {
|
|
|
460
460
|
|
|
461
461
|
// quick 会话 ID 形态(quick-<hex8>)。与 run/command.js QUICK_SID_RE、progress.js
|
|
462
462
|
// initChange 的跳过实体目录判断同源;shared.js 不反向 import command.js(重模块),
|
|
463
|
-
//
|
|
464
|
-
const QUICK_SID_RE = /^quick-[0-9a-f]{8}$/
|
|
463
|
+
// 局部复制正则保持轻载——改形态时三处同改(prompt.js 经本导出消费,勿再复制一份)。
|
|
464
|
+
export const QUICK_SID_RE = /^quick-[0-9a-f]{8}$/
|
|
465
465
|
|
|
466
466
|
export async function triggerSync(cwd, changeName, platformOpts = {}, opts = {}) {
|
|
467
467
|
// 平台模式(SillyHub)走自己的回传链路,不走 CLI 内置 sync
|
package/src/scan-postcheck.js
CHANGED
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
* 平台模式下必须通过所有 check 才能 success,否则降级。
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync } from 'fs'
|
|
8
|
+
import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync, renameSync, rmdirSync } from 'fs'
|
|
9
9
|
import { join, basename } from 'path'
|
|
10
10
|
import { SCAN_STATUS, CHECK_SEVERITY, SCAN_REQUIRED_DOCS, SCAN_REQUIRED_DOCS_QUICK } from './constants.js'
|
|
11
11
|
import { validateScriptCommands } from './stages/cmd-existence.js'
|
|
12
|
+
import { git } from './git-helper.js'
|
|
12
13
|
|
|
13
14
|
const REQUIRED_SCAN_DOCS = SCAN_REQUIRED_DOCS
|
|
14
15
|
|
|
@@ -112,7 +113,7 @@ export function runScanPostCheck({ cwd, specDir, outputText = '', scanMeta = {},
|
|
|
112
113
|
checks.push({
|
|
113
114
|
name: 'docs_missing_header',
|
|
114
115
|
severity: CHECK_SEVERITY.WARNING,
|
|
115
|
-
detail: `${docsMissingHeader.length} 份文档缺少 author/created_at: ${docsMissingHeader.join(', ')}
|
|
116
|
+
detail: `${docsMissingHeader.length} 份文档缺少 author/created_at: ${docsMissingHeader.join(', ')}(sillyspec scan-fix-headers 一键补齐)`
|
|
116
117
|
})
|
|
117
118
|
}
|
|
118
119
|
|
|
@@ -369,3 +370,142 @@ export function printScanPostCheckResult(result) {
|
|
|
369
370
|
}
|
|
370
371
|
console.log(` 📋 最终状态: ${result.status}`)
|
|
371
372
|
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* scan 文档 header 幂等补填(2026-08-21 agent-手工产出审计第三批 E3)。
|
|
376
|
+
*
|
|
377
|
+
* runScanPostCheck 对缺 author/created_at 只 warning,agent 手改常忘——本函数补齐:
|
|
378
|
+
* author = git user.name(回退 unknown),created_at = 当前时间。已有两键的文件不动;
|
|
379
|
+
* 有 frontmatter(--- 开头)就地插缺失键,无则补一段 frontmatter。
|
|
380
|
+
* `sillyspec scan-fix-headers [--project <名>]` 入口;与 runScanPostCheck 的
|
|
381
|
+
* 「头部 512 字符含 author:/created_at:」判定口径一致。
|
|
382
|
+
*
|
|
383
|
+
* @param {{ cwd: string, specDir?: string|null, project?: string|null }} opts
|
|
384
|
+
* @returns {{ fixed: string[], skipped: string[] }}
|
|
385
|
+
*/
|
|
386
|
+
export function fixScanDocHeaders({ cwd, specDir = null, project = null }) {
|
|
387
|
+
const specBase = specDir ? specDir : join(cwd, '.sillyspec')
|
|
388
|
+
const docsRoot = join(specBase, 'docs')
|
|
389
|
+
const projects = project
|
|
390
|
+
? [project]
|
|
391
|
+
: (existsSync(docsRoot) ? readdirSync(docsRoot, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => e.name) : [])
|
|
392
|
+
|
|
393
|
+
let author = 'unknown'
|
|
394
|
+
try {
|
|
395
|
+
author = git(cwd, ['config', 'user.name']) || 'unknown'
|
|
396
|
+
} catch { /* git 不可用 → unknown */ }
|
|
397
|
+
const now = new Date().toISOString().slice(0, 19).replace('T', ' ')
|
|
398
|
+
|
|
399
|
+
const fixed = []
|
|
400
|
+
const skipped = []
|
|
401
|
+
for (const p of projects) {
|
|
402
|
+
const scanDir = join(docsRoot, p, 'scan')
|
|
403
|
+
if (!existsSync(scanDir)) continue
|
|
404
|
+
for (const f of readdirSync(scanDir)) {
|
|
405
|
+
if (!f.endsWith('.md')) continue
|
|
406
|
+
const filePath = join(scanDir, f)
|
|
407
|
+
let content
|
|
408
|
+
try {
|
|
409
|
+
content = readFileSync(filePath, 'utf8')
|
|
410
|
+
} catch {
|
|
411
|
+
continue
|
|
412
|
+
}
|
|
413
|
+
const header = content.slice(0, 512)
|
|
414
|
+
const hasAuthor = /author\s*:/.test(header)
|
|
415
|
+
const hasCreated = /created_at\s*:/.test(header)
|
|
416
|
+
if (hasAuthor && hasCreated) {
|
|
417
|
+
skipped.push(filePath)
|
|
418
|
+
continue
|
|
419
|
+
}
|
|
420
|
+
const inserts = []
|
|
421
|
+
if (!hasAuthor) inserts.push(`author: ${author}`)
|
|
422
|
+
if (!hasCreated) inserts.push(`created_at: ${now}`)
|
|
423
|
+
let newContent
|
|
424
|
+
if (content.startsWith('---\n') || content.startsWith('---\r\n')) {
|
|
425
|
+
// 有 frontmatter:插到开标签后(CRLF 归一写回,与其它 CLI 写入口径一致)
|
|
426
|
+
const normalized = content.replace(/\r\n?/g, '\n')
|
|
427
|
+
newContent = normalized.replace(/^---\n/, `---\n${inserts.join('\n')}\n`)
|
|
428
|
+
} else {
|
|
429
|
+
newContent = `---\n${inserts.join('\n')}\n---\n\n` + content
|
|
430
|
+
}
|
|
431
|
+
try {
|
|
432
|
+
writeFileSync(filePath, newContent)
|
|
433
|
+
fixed.push(filePath)
|
|
434
|
+
} catch { /* 只读文件等写失败 → 跳过不抛 */ }
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return { fixed, skipped }
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* source_root 污染一键修复(2026-08-21 agent-手工产出审计第四批 C-3)。
|
|
442
|
+
*
|
|
443
|
+
* runScanPostCheck 对「agent 把产物写到 <source_root>/.sillyspec/ 而非平台 specRoot」只报
|
|
444
|
+
* FAILED 不修——agent 手工搬目录常搬错。本函数把检查枚举的同款清单(docs/projects/workflows/
|
|
445
|
+
* knowledge 目录 + manifest.json/local.yaml)搬到 specRoot 对应位置(已存在的不覆盖,报告
|
|
446
|
+
* skipped),搬完源目录若空则删除。与检查的路径口径逐字一致(pollutePaths/polluteFiles)。
|
|
447
|
+
*
|
|
448
|
+
* @param {{ cwd: string, specDir: string }} opts cwd=source_root,specDir=平台 spec_root 绝对路径
|
|
449
|
+
* @returns {{ moved: string[], skipped: string[], removedDirs: string[] }}
|
|
450
|
+
*/
|
|
451
|
+
export function fixSourceRootLeak({ cwd, specDir }) {
|
|
452
|
+
if (!cwd || !specDir) return { moved: [], skipped: [], removedDirs: [] }
|
|
453
|
+
const moved = []
|
|
454
|
+
const skipped = []
|
|
455
|
+
const removedDirs = []
|
|
456
|
+
const pollutePaths = ['docs', 'projects', 'workflows', 'knowledge']
|
|
457
|
+
const polluteFiles = ['manifest.json', 'local.yaml']
|
|
458
|
+
|
|
459
|
+
// 目标目录确保存在
|
|
460
|
+
try { mkdirSync(join(specDir, '.runtime'), { recursive: true }) } catch {}
|
|
461
|
+
|
|
462
|
+
for (const file of polluteFiles) {
|
|
463
|
+
const src = join(cwd, '.sillyspec', file)
|
|
464
|
+
const dst = join(specDir, file)
|
|
465
|
+
if (!existsSync(src)) continue
|
|
466
|
+
if (existsSync(dst)) {
|
|
467
|
+
skipped.push(`local.yaml 已存在于 specRoot(保留两份,人工合并凭据后删源)`.replace('local.yaml', file))
|
|
468
|
+
continue
|
|
469
|
+
}
|
|
470
|
+
try {
|
|
471
|
+
renameSync(src, dst)
|
|
472
|
+
moved.push(`.sillyspec/${file} → specRoot/${file}`)
|
|
473
|
+
} catch (e) {
|
|
474
|
+
skipped.push(`.sillyspec/${file} 搬移失败: ${e.message}`)
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
for (const sub of pollutePaths) {
|
|
479
|
+
const srcDir = join(cwd, '.sillyspec', sub)
|
|
480
|
+
if (!existsSync(srcDir)) continue
|
|
481
|
+
let entries = []
|
|
482
|
+
try { entries = readdirSync(srcDir, { withFileTypes: true }) } catch { continue }
|
|
483
|
+
if (entries.length === 0) {
|
|
484
|
+
try { rmdirSync(srcDir); removedDirs.push(`.sillyspec/${sub}/(空目录)`) } catch {}
|
|
485
|
+
continue
|
|
486
|
+
}
|
|
487
|
+
const dstDir = join(specDir, sub)
|
|
488
|
+
mkdirSync(dstDir, { recursive: true })
|
|
489
|
+
let allMoved = true
|
|
490
|
+
for (const e of entries) {
|
|
491
|
+
const src = join(srcDir, e.name)
|
|
492
|
+
const dst = join(dstDir, e.name)
|
|
493
|
+
if (existsSync(dst)) {
|
|
494
|
+
skipped.push(`.sillyspec/${sub}/${e.name}(specRoot 已有同名,不覆盖——人工比对后择一保留)`)
|
|
495
|
+
allMoved = false
|
|
496
|
+
continue
|
|
497
|
+
}
|
|
498
|
+
try {
|
|
499
|
+
renameSync(src, dst)
|
|
500
|
+
moved.push(`.sillyspec/${sub}/${e.name} → specRoot/${sub}/${e.name}`)
|
|
501
|
+
} catch (err) {
|
|
502
|
+
skipped.push(`.sillyspec/${sub}/${e.name} 搬移失败: ${err.message}`)
|
|
503
|
+
allMoved = false
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if (allMoved) {
|
|
507
|
+
try { rmdirSync(srcDir); removedDirs.push(`.sillyspec/${sub}/`) } catch {}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return { moved, skipped, removedDirs }
|
|
511
|
+
}
|
|
@@ -330,7 +330,7 @@ const PLAN_RULES = [
|
|
|
330
330
|
id: 'plan.task-plan-reconciliation', stage: 'plan', source: 'validateBlueprintConsistency', severity: 'error', kind: 'task-plan-reconciliation',
|
|
331
331
|
target: { root: 'change', path: 'tasks' },
|
|
332
332
|
data: {
|
|
333
|
-
messageMissingCards: 'plan.md 声明了 ${declared} 个任务,但 tasks/ 只有部分卡片。缺卡: ${missing}\n
|
|
333
|
+
messageMissingCards: 'plan.md 声明了 ${declared} 个任务,但 tasks/ 只有部分卡片。缺卡: ${missing}\n 无卡任务的 allowed_paths/review.json 都不覆盖,不进 Wave、不受 execute 审计 → 必然漏做(典型:尾部文档同步类任务)。\n 修复:跑 sillyspec taskcard <change> --all 一键补齐全部缺卡安全骨架(已存在的卡不动,之后填充语义字段),或从 tasks.md 删除该任务声明。',
|
|
334
334
|
messageOrphanCards: 'tasks/ 存在 plan.md 未声明的卡片: ${orphans}\n 这些任务不进任何 Wave,execute 不会执行。修复:在 plan.md 补声明,或删除孤儿卡片。',
|
|
335
335
|
},
|
|
336
336
|
spec: 'plan.md 中声明的每个 task-NN 必须有对应 tasks/task-NN.md 卡片(反向亦然),否则阻断——无卡片任务不受 execute 审计,必然漏做。',
|
package/src/stage-review.js
CHANGED
|
@@ -65,9 +65,12 @@ export function renderReviewJsonContract({ stage, changeDir, reviewRunId, tier }
|
|
|
65
65
|
'- `checklist`(可选): 扁平数组 —— 每项 { item: string, result: ∈ { ' + CHECKLIST_RESULTS.join(' / ') + ' }, note?: string }。注意是**扁平数组**,不是按层(定义/一致/可行性)嵌套对象',
|
|
66
66
|
'- `reviewerNotes`: 说明(verdict=fail 时写明阻断项)',
|
|
67
67
|
'',
|
|
68
|
-
'### docHash
|
|
69
|
-
|
|
70
|
-
'-
|
|
68
|
+
'### docHash(一键代填,勿手算)',
|
|
69
|
+
'- **推荐**:docHash 先占位(如 `"TODO"`),review.json 写完后跑',
|
|
70
|
+
' `sillyspec register-stage-review --change <变更名> --stage ' + stageLbl + ' --refresh-hash`',
|
|
71
|
+
' —— CLI 重算真实 sha256 代填,verdict/checklist 原样保留;主文档改版后同样跑这条',
|
|
72
|
+
' (多阶段联动用 `--all` 一次处理)。gate 拦 docHash 失配时也是同一条命令修复。',
|
|
73
|
+
'- 兜底手算口径(极端无 CLI 场景):node `crypto.createHash(\'sha256\').update(readFileSync(...))` (原始字节)/ `sha256sum <file>` 首列 hex。',
|
|
71
74
|
'- ⚠️ **review.json 写入后若 ' + mainDoc + ' 再被改,必须重算 docHash** —— gate 会重算 reviewedFiles[0] 的 sha256 比对,不符判伪造(历史翻车根因:design 改了 rev 后 docHash 仍是旧值)',
|
|
72
75
|
'',
|
|
73
76
|
'### 完整 JSON 示例(照抄改值)',
|
|
@@ -209,8 +212,9 @@ export function verifyStageReviewDocHash(review, searchDirs) {
|
|
|
209
212
|
if (claimed !== actualRaw.toLowerCase() && claimed !== actualLf.toLowerCase()) {
|
|
210
213
|
errors.push(
|
|
211
214
|
`docHash 与主审查文档 ${primaryRel} 的实际内容不匹配。` +
|
|
212
|
-
`若刚改过该文档(如 design
|
|
213
|
-
|
|
215
|
+
`若刚改过该文档(如 design 修订后忘重算),一键修复:` +
|
|
216
|
+
`sillyspec register-stage-review --change <变更名> --stage <阶段> --refresh-hash` +
|
|
217
|
+
`(CLI 重算 docHash 代填,verdict 保留;多阶段联动加 --all);` +
|
|
214
218
|
`若未改文档,则 review.json 疑似伪造(未真正读取文档)。`
|
|
215
219
|
)
|
|
216
220
|
return { ok: false, errors, warnings }
|
|
@@ -414,7 +418,7 @@ export function validateStageReview(opts) {
|
|
|
414
418
|
* @param {object} context - { stage, reviewRunId } 用于错误提示路径
|
|
415
419
|
*/
|
|
416
420
|
export function printStageReviewResult(result, context = {}) {
|
|
417
|
-
const { stage, reviewRunId, runtimeRoot } = context
|
|
421
|
+
const { stage, reviewRunId, runtimeRoot, changeName } = context
|
|
418
422
|
if (result.ok && result.warnings.length === 0) {
|
|
419
423
|
console.log(`\n✅ Stage Review Gate — ${stage} 阶段独立审查通过`)
|
|
420
424
|
return
|
|
@@ -435,6 +439,11 @@ export function printStageReviewResult(result, context = {}) {
|
|
|
435
439
|
}
|
|
436
440
|
console.error(`\n 提示:tier=independent 要求独立审查子代理产出 review.json,补全后重新 --done`)
|
|
437
441
|
console.error(` 可用 sillyspec register-stage-review --change <名> --stage ${stage} [--from <已有review.json>] 一步生成 run 目录 + review.json 骨架(docHash 自动算)+ 写 marker + 自检,省掉手动建目录/写 marker`)
|
|
442
|
+
// docHash 失配精确指路(2026-08-21 agent-手工产出审计项②):改版后忘重算是最高频失败,
|
|
443
|
+
// 一键重算命令带上真实 change 名,agent 可直接照抄执行(--refresh-hash 保 verdict)。
|
|
444
|
+
if (result.errors.some(e => String(e).includes('docHash')) && changeName) {
|
|
445
|
+
console.error(` docHash 失配修复:sillyspec register-stage-review --change ${changeName} --stage ${stage} --refresh-hash(主文档改版多阶段联动用 --all)`)
|
|
446
|
+
}
|
|
438
447
|
}
|
|
439
448
|
|
|
440
449
|
if (result.warnings.length > 0) {
|