sillyspec 3.25.9 → 3.26.0
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-auto/SKILL.md +8 -13
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +8 -6
- package/.claude/skills/sillyspec-continue/SKILL.md +3 -3
- package/.claude/skills/sillyspec-doctor/SKILL.md +1 -1
- package/.claude/skills/sillyspec-execute/SKILL.md +10 -4
- package/.claude/skills/sillyspec-plan/SKILL.md +1 -1
- package/.claude/skills/sillyspec-quick/SKILL.md +3 -1
- package/.claude/skills/sillyspec-verify/SKILL.md +1 -1
- package/.sillyspec-platform-cleaned +1 -0
- package/.sillyspec-platform.json +7 -0
- package/README.md +9 -0
- package/package.json +2 -2
- package/src/change-list.js +11 -8
- package/src/change-risk-profile.js +22 -0
- package/src/db.js +170 -161
- package/src/dispatch/backends/local-agent.js +152 -0
- package/src/dispatch/backends/sillyhub-mcp.js +242 -0
- package/src/dispatch/probe.js +210 -0
- package/src/dispatch/strategy.js +121 -0
- package/src/doctor-diagnostics.js +19 -25
- package/src/fs-atomic.js +69 -69
- package/src/git-helper.js +66 -0
- package/src/hooks/worktree-guard.js +64 -55
- package/src/index.js +326 -36
- package/src/init.js +7 -14
- package/src/local-detect.js +120 -110
- package/src/machine-interface.js +54 -19
- package/src/migrate.js +14 -4
- package/src/modules.js +16 -5
- package/src/progress/change-registry.js +85 -70
- package/src/progress/consistency-doctor.js +71 -14
- package/src/progress/shared.js +8 -5
- package/src/progress/stage-machine.js +67 -65
- package/src/progress/step-store.js +72 -67
- package/src/progress.js +448 -236
- package/src/quicklog.js +59 -12
- package/src/run/command.js +78 -39
- package/src/run/complete-handlers.js +1028 -889
- package/src/run/complete.js +132 -212
- package/src/run/concurrent-detect.js +132 -0
- package/src/run/gates.js +302 -19
- package/src/run/prompt.js +22 -68
- package/src/run/quick-audit.js +10 -9
- package/src/run/shared.js +722 -600
- package/src/run/stage.js +43 -24
- package/src/scan-postcheck.js +9 -31
- package/src/sillyhub-mcp/client.js +394 -0
- package/src/sillyhub-mcp/config.js +62 -0
- package/src/spec-dir-typo.js +71 -0
- package/src/stage-contract-engine.js +22 -1
- package/src/stage-contract-spec.js +11 -4
- package/src/stage-contract.js +26 -3
- package/src/stage-review.js +118 -10
- package/src/stages/archive.js +4 -6
- package/src/stages/brainstorm-auto.js +26 -20
- package/src/stages/brainstorm.js +29 -5
- package/src/stages/cmd-existence.js +145 -0
- package/src/stages/doctor.js +10 -7
- package/src/stages/execute.js +64 -22
- package/src/stages/plan-postcheck.js +152 -0
- package/src/stages/plan.js +16 -31
- package/src/stages/quick.js +25 -9
- package/src/stages/scan.js +53 -8
- package/src/stages/status.js +0 -1
- package/src/stages/verify.js +14 -9
- package/src/sync.js +433 -16
- package/src/task-review.js +59 -1
- package/src/verify-postcheck.js +938 -862
- package/src/version.js +19 -0
- package/src/worktree-apply.js +218 -93
- package/src/worktree-deps.js +89 -5
- package/src/worktree.js +213 -111
- package/templates/prompts/taskcard-rules.md +27 -0
- package/templates/prompts/verify-probes.md +3 -3
- package/.claude/skills/sillyspec-propose/SKILL.md +0 -87
- package/src/stages/propose.js +0 -181
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* concurrent-detect — 多 agent 并发写预检的纯函数检测核心(design §7)。
|
|
3
|
+
*
|
|
4
|
+
* 单次 git status --porcelain 扫描,复用 isQuickMetadata 的「关联 vs 他者」分类口径,
|
|
5
|
+
* 产出两类并发信号:
|
|
6
|
+
* - foreignFiles: 脏文件里非 metadata、不在 ownFiles 的真实业务文件(他者未提交改动)
|
|
7
|
+
* - otherActiveChanges: 脏文件落在 .sillyspec/changes/<他者变更>/ 下去重成的变更名集合
|
|
8
|
+
*
|
|
9
|
+
* 非阻塞 advisory:检测结果仅在调用点本地消费(→ formatConcurrentWarning → console.warn),
|
|
10
|
+
* 不改 audit status / gate 通过性 / isQuickMetadata 语义(design §2/§9)。
|
|
11
|
+
*
|
|
12
|
+
* 纯函数无副作用:不写盘、不 console(console 留给调用点 task-02/03)。
|
|
13
|
+
*/
|
|
14
|
+
import { safeGit, parsePorcelainPath, isQuickMetadata } from './shared.js'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 内联解析 .sillyspec/changes/<dir>/ 路径取 <dir>。
|
|
18
|
+
* 与 shared.js isQuickMetadata 内部 regex(^\.sillyspec\/changes\/([^/]+)(\/|$))同源——
|
|
19
|
+
* D-008 deferred:暂不抽 shared.js 公共 helper,改其一改其二。
|
|
20
|
+
* @param {string} file 已归一化(正斜杠)的 git 路径
|
|
21
|
+
* @returns {string|null} 变更目录名,不在 changes/ 下返回 null
|
|
22
|
+
*/
|
|
23
|
+
function extractChangeDir(file) {
|
|
24
|
+
const m = file.match(/^\.sillyspec\/changes\/([^/]+)(\/|$)/)
|
|
25
|
+
return m ? m[1] : null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** git 路径反斜杠归一(跨平台 Windows 兼容)。 */
|
|
29
|
+
function normalizeGitPath(p) {
|
|
30
|
+
return String(p).replace(/\\/g, '/')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 检测工作树里的并发他者改动(非阻塞 advisory 用)。
|
|
35
|
+
*
|
|
36
|
+
* @param {string} cwd 主仓库根
|
|
37
|
+
* @param {{ changeName: string, linkedChanges?: string[], ownFiles?: string[], specDir?: string }} opts
|
|
38
|
+
* - changeName: 当前变更名(排除自身变更目录)
|
|
39
|
+
* - linkedChanges: 关联变更(透传给 isQuickMetadata 的关联归类,同时从 otherActiveChanges 排除)
|
|
40
|
+
* - ownFiles: 本 --done 负责的文件(从 foreignFiles 排除,避免把自己当他者)
|
|
41
|
+
* - specDir: 规范目录(保留参数,当前实现用 .sillyspec/ 前缀 regex 定位,暂未使用)
|
|
42
|
+
* @returns {{ hasForeign: boolean, foreignFiles: string[], otherActiveChanges: string[], gitError: string|null }}
|
|
43
|
+
* - foreignFiles: 脏文件里非 metadata、不在 ownFiles 的真实业务文件
|
|
44
|
+
* - otherActiveChanges: 脏文件落在 .sillyspec/changes/<他者变更>/ 下,去重成的变更名集合
|
|
45
|
+
* - gitError: git status 读失败时填错误串,hasForeign=false(FR-04 fail-open,不抛异常)
|
|
46
|
+
*/
|
|
47
|
+
export function detectConcurrentChanges(cwd, { changeName, linkedChanges = [], ownFiles = [], specDir } = {}) {
|
|
48
|
+
// safeGit 必传 trim:false(D-004):porcelain 首行前导空格是状态码一部分,trim 会削掉致
|
|
49
|
+
// parsePorcelainPath 丢首字符(坑见 shared.js auditQuickCompletion :448 注释)。
|
|
50
|
+
const statusResult = safeGit(cwd, ['status', '--porcelain'], { trim: false })
|
|
51
|
+
|
|
52
|
+
// FR-04 fail-open:git status 读不到不崩、不阻断、不误报(advisory 漏报可接受,最坏漏报而非误阻)。
|
|
53
|
+
if (statusResult.error) {
|
|
54
|
+
return { hasForeign: false, foreignFiles: [], otherActiveChanges: [], gitError: statusResult.error }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const gitStatus = statusResult.value || ''
|
|
58
|
+
// 不整段 trim:同 auditQuickCompletion :455 注释,会削首行前导空格致首文件路径丢首字符。
|
|
59
|
+
const entries = gitStatus.split('\n').filter(Boolean)
|
|
60
|
+
|
|
61
|
+
// 本变更 + 关联变更目录集合:rule1 据此把「自己/关联」从他者变更中排除。
|
|
62
|
+
const ownChangeSet = new Set(
|
|
63
|
+
(Array.isArray(linkedChanges) ? linkedChanges : [])
|
|
64
|
+
.map(normalizeGitPath)
|
|
65
|
+
.filter(Boolean)
|
|
66
|
+
)
|
|
67
|
+
if (changeName) ownChangeSet.add(normalizeGitPath(changeName))
|
|
68
|
+
|
|
69
|
+
// ownFiles 归一化集合:rule3 据此把本会话产出从他者业务文件中排除。
|
|
70
|
+
const ownFileSet = new Set(
|
|
71
|
+
(Array.isArray(ownFiles) ? ownFiles : [])
|
|
72
|
+
.map(normalizeGitPath)
|
|
73
|
+
.filter(Boolean)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
const foreignFiles = []
|
|
77
|
+
const otherActiveSet = new Set()
|
|
78
|
+
|
|
79
|
+
for (const line of entries) {
|
|
80
|
+
// parsePorcelainPath:去引号 / 处理 rename / 归一化反斜杠(跨平台)。
|
|
81
|
+
const file = parsePorcelainPath(line)
|
|
82
|
+
if (!file) continue
|
|
83
|
+
|
|
84
|
+
// rule1:落 .sillyspec/changes/<dir>/ 且 <dir> 非本变更非关联 → otherActiveChanges 去重。
|
|
85
|
+
const dir = extractChangeDir(file)
|
|
86
|
+
if (dir && !ownChangeSet.has(normalizeGitPath(dir))) {
|
|
87
|
+
otherActiveSet.add(dir)
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// rule2:isQuickMetadata 为 true(quick 元数据 / 关联变更目录 / quicklog/.runtime/modules 等)→ 跳过。
|
|
92
|
+
// 注意:当前变更自己的 changes/<changeName>/ 也走这里——changeName 通常不在 linkedChanges,
|
|
93
|
+
// isQuickMetadata 视为元数据放行,与本变更自身目录不该当他者的语义一致。
|
|
94
|
+
if (isQuickMetadata(file, linkedChanges)) continue
|
|
95
|
+
|
|
96
|
+
// rule3:真实业务文件 → 不在 ownFiles 则归入他者 foreignFiles。
|
|
97
|
+
if (!ownFileSet.has(file)) {
|
|
98
|
+
foreignFiles.push(file)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const otherActiveChanges = [...otherActiveSet]
|
|
103
|
+
const hasForeign = foreignFiles.length > 0 || otherActiveChanges.length > 0
|
|
104
|
+
return { hasForeign, foreignFiles, otherActiveChanges, gitError: null }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 把检测结果格式化为多行 ⚠️ 警告串。
|
|
109
|
+
* @param {{ hasForeign: boolean, foreignFiles?: string[], otherActiveChanges?: string[], gitError?: string|null }|null} detected
|
|
110
|
+
* @returns {string|null} 无他者并发(hasForeign 为 false / null 输入)返回 null,调用点据此跳过 console.warn
|
|
111
|
+
*/
|
|
112
|
+
export function formatConcurrentWarning(detected) {
|
|
113
|
+
if (!detected || !detected.hasForeign) return null
|
|
114
|
+
const foreignFiles = Array.isArray(detected.foreignFiles) ? detected.foreignFiles : []
|
|
115
|
+
const otherActiveChanges = Array.isArray(detected.otherActiveChanges) ? detected.otherActiveChanges : []
|
|
116
|
+
|
|
117
|
+
const lines = ['⚠️ 检测到工作树存在并发他者改动(非阻断 advisory,请人工确认):']
|
|
118
|
+
|
|
119
|
+
if (foreignFiles.length > 0) {
|
|
120
|
+
lines.push('他者业务文件(git-dirty,可能非本变更产出):')
|
|
121
|
+
for (const f of foreignFiles) lines.push(` - ${f}`)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (otherActiveChanges.length > 0) {
|
|
125
|
+
// D-005:文案用「脏变更目录」+ git-dirty 标注,勿用「活跃」防与 DB active 状态混淆。
|
|
126
|
+
lines.push('他者脏变更目录(git-dirty,非 DB active):')
|
|
127
|
+
for (const c of otherActiveChanges) lines.push(` - ${c}`)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
lines.push('提交请用显式 pathspec 隔离本变更文件,勿 git add . 扫入他者工作。')
|
|
131
|
+
return lines.join('\n')
|
|
132
|
+
}
|
package/src/run/gates.js
CHANGED
|
@@ -20,9 +20,12 @@
|
|
|
20
20
|
* 不搬:ensureDepsFreshness(execute 入口 deps 自检,调用方 runStage 非 completeStep,归属未来 execute-handler)
|
|
21
21
|
*/
|
|
22
22
|
import { basename, join } from 'node:path'
|
|
23
|
-
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
24
|
-
import { triggerSync, resolveChangeDir } from './shared.js'
|
|
23
|
+
import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
24
|
+
import { triggerSync, resolveChangeDir, resolveRuntimeRoot } from './shared.js'
|
|
25
25
|
import { runValidators } from '../stage-contract.js'
|
|
26
|
+
import { handleScanStageCompleted, handleExecuteWorktreeCleanup } from './complete-handlers.js'
|
|
27
|
+
import { detectConcurrentChanges, formatConcurrentWarning } from './concurrent-detect.js'
|
|
28
|
+
import { stageRegistry } from '../stages/index.js'
|
|
26
29
|
|
|
27
30
|
/**
|
|
28
31
|
* 判断当前 execute step 所在 wave 是否全部 task 都声明 no_deps_verify: true(D-006@v2)。
|
|
@@ -108,13 +111,24 @@ export async function enforceDepsGate(stageName, cwd, changeName, step, steps, c
|
|
|
108
111
|
*/
|
|
109
112
|
export async function enforceReviewJsonGate(stageName, cwd, changeName, step, steps, currentIdx, specBase, platformOpts) {
|
|
110
113
|
if (stageName !== 'execute' || !changeName) return true
|
|
111
|
-
const runtimeRoot = platformOpts
|
|
114
|
+
const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
|
|
112
115
|
const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
|
|
113
116
|
const planPath = join(specBase, 'changes', changeName, 'plan.md')
|
|
114
117
|
if (!existsSync(runIdFile) || !existsSync(planPath)) return true
|
|
115
|
-
|
|
118
|
+
let executeRunId = readFileSync(runIdFile, 'utf8').trim()
|
|
116
119
|
const planContent = readFileSync(planPath, 'utf8')
|
|
117
|
-
const { validateCheckedTaskReviews } = await import('../task-review.js')
|
|
120
|
+
const { validateCheckedTaskReviews, resolveLatestExecuteRunIdWithTasks } = await import('../task-review.js')
|
|
121
|
+
// marker 漂移兜底(gate-atom-a 正确修法):marker 指向的 run 缺 tasks/(generateExecuteRunId 只写
|
|
122
|
+
// marker 不建目录,漂移后新 run 不继承旧 review)时,无视 marker 改扫 execute-runs/ 取 mtime 最新
|
|
123
|
+
// 且真正含 tasks/ 的 run,用其齐备的 review.json 校验,避免误报「review.json 不存在」。注意不能用
|
|
124
|
+
// resolveLatestExecuteRunId——它见 marker 非空即原样返回(不校验目录),恰是本场景要绕开的值。
|
|
125
|
+
if (executeRunId && !existsSync(join(runtimeRoot, 'execute-runs', executeRunId, 'tasks'))) {
|
|
126
|
+
const relocated = resolveLatestExecuteRunIdWithTasks({ runtimeRoot })
|
|
127
|
+
if (relocated && relocated !== executeRunId) {
|
|
128
|
+
console.warn(`⚠️ execute run marker 漂移:${executeRunId} 无 tasks/,改用真实含 review 的 run ${relocated}`)
|
|
129
|
+
executeRunId = relocated
|
|
130
|
+
}
|
|
131
|
+
}
|
|
118
132
|
const result = validateCheckedTaskReviews({ planContent, runtimeRoot, executeRunId })
|
|
119
133
|
if (result.ok) return true
|
|
120
134
|
if (steps && steps[currentIdx]) steps[currentIdx].status = 'blocked'
|
|
@@ -158,10 +172,10 @@ function rollbackStageCompletion(stageData, steps, currentIdx) {
|
|
|
158
172
|
* 漏写 triggerSync / 写错 return 结构导致行为分裂。返回 nextPendingIdx=currentIdx,
|
|
159
173
|
* 让上层走「完成但不推进」分支,--done 被拒、agent 修复产物后重跑。
|
|
160
174
|
*/
|
|
161
|
-
|
|
175
|
+
function rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts) {
|
|
162
176
|
rollbackStageCompletion(stageData, steps, currentIdx)
|
|
163
177
|
progress.lastActive = new Date().toLocaleString('zh-CN', { hour12: false })
|
|
164
|
-
|
|
178
|
+
pm._write(cwd, progress, changeName)
|
|
165
179
|
triggerSync(cwd, changeName, platformOpts)
|
|
166
180
|
return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
|
|
167
181
|
}
|
|
@@ -216,12 +230,19 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
|
|
|
216
230
|
// 契约 parity 对账:扫前端 API 调用 vs execute 提取的 provider endpoint artifact。
|
|
217
231
|
// 接线自 contract-matrix pipeline(verifyApiParity 的 CLI 入口)。
|
|
218
232
|
const { runVerifyParityCheck, printVerifyParityCheck } = await import('../verify-postcheck.js')
|
|
219
|
-
const
|
|
233
|
+
const parityRuntimeRoot = resolveRuntimeRoot(platformOpts, specBase)
|
|
234
|
+
const parityCheck = runVerifyParityCheck({ cwd, specBase, changeName, runtimeRoot: parityRuntimeRoot })
|
|
220
235
|
printVerifyParityCheck(parityCheck)
|
|
221
236
|
// ── 删除探针(advisory,不阻断):切斯特顿栅栏护栏,用 git 事实对账静默删除代码 ──
|
|
222
237
|
const { runVerifyDeletionCheck, printVerifyDeletionCheck } = await import('../verify-postcheck.js')
|
|
223
238
|
const deletionCheck = runVerifyDeletionCheck({ cwd, specBase, changeName })
|
|
224
239
|
printVerifyDeletionCheck(deletionCheck)
|
|
240
|
+
// ── required-evidence 对账(advisory,不阻断):闭合 execute→verify evidence 死链 ──
|
|
241
|
+
// execute Task Review Gate 把 cannot_verify 任务的 evidence 落盘 verify-required-evidence.json,
|
|
242
|
+
// 本探针查每个 cannot_verify 任务是否在 verify-result.md 体现(CLI 只查提及,满足度 agent 自报告)。
|
|
243
|
+
const { runVerifyRequiredEvidenceCheck, printVerifyRequiredEvidenceCheck } = await import('../verify-postcheck.js')
|
|
244
|
+
const evidenceCheck = runVerifyRequiredEvidenceCheck({ cwd, specBase, changeName })
|
|
245
|
+
printVerifyRequiredEvidenceCheck(evidenceCheck)
|
|
225
246
|
console.log('\n✅ 验证通过,下一步:sillyspec run archive')
|
|
226
247
|
}
|
|
227
248
|
|
|
@@ -250,12 +271,12 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
|
|
|
250
271
|
}
|
|
251
272
|
}
|
|
252
273
|
|
|
253
|
-
// ── Stage Review Gate:brainstorm/plan/
|
|
274
|
+
// ── Stage Review Gate:brainstorm/plan/execute 独立审查(按 tier 分级)──
|
|
254
275
|
// tier=self 放行+审计打印;tier=independent 必须 review.json 且 verdict 非 fail,fail-closed
|
|
255
|
-
if (['brainstorm', 'plan', '
|
|
276
|
+
if (['brainstorm', 'plan', 'execute'].includes(stageName)) {
|
|
256
277
|
try {
|
|
257
278
|
const { classifyReviewTier } = await import('../review-tier.js')
|
|
258
|
-
const { validateStageReview, getLatestStageReviewRunId, printStageReviewResult } = await import('../stage-review.js')
|
|
279
|
+
const { validateStageReview, getLatestStageReviewRunId, printStageReviewResult, generateStageReviewRunId, stageReviewMarkerPath } = await import('../stage-review.js')
|
|
259
280
|
const effectiveSpecBase = platformOpts?.specRoot || specBase
|
|
260
281
|
const reviewChangeDir = resolveChangeDir(cwd, progress, platformOpts?.specRoot)
|
|
261
282
|
const designPath = reviewChangeDir ? join(reviewChangeDir, 'design.md') : null
|
|
@@ -268,15 +289,23 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
|
|
|
268
289
|
}
|
|
269
290
|
}
|
|
270
291
|
const tier = classifyReviewTier({ planLevel, designPath })
|
|
271
|
-
const runtimeRoot = platformOpts
|
|
292
|
+
const runtimeRoot = resolveRuntimeRoot(platformOpts, effectiveSpecBase)
|
|
272
293
|
|
|
273
294
|
if (tier.tier === 'self') {
|
|
274
295
|
console.log('\nℹ️ Stage Review: ' + stageName + ' tier=self(' + tier.reason + '),已降级为当前 agent 自审,不强制独立子代理。')
|
|
275
296
|
} else {
|
|
276
|
-
|
|
297
|
+
let reviewRunId = getLatestStageReviewRunId(runtimeRoot, stageName, changeName)
|
|
298
|
+
if (!reviewRunId) {
|
|
299
|
+
// marker 缺失(execute 批量完成跳过 prompt 渲染等场景)→ 自生 + 写盘
|
|
300
|
+
// 让 gate 读到确定 ID,错误从 execute-null 变 execute-review-<id>(可执行)
|
|
301
|
+
reviewRunId = generateStageReviewRunId()
|
|
302
|
+
try {
|
|
303
|
+
mkdirSync(runtimeRoot, { recursive: true })
|
|
304
|
+
writeFileSync(stageReviewMarkerPath(runtimeRoot, stageName, changeName), reviewRunId + '\n')
|
|
305
|
+
} catch {}
|
|
306
|
+
}
|
|
277
307
|
const reviewType = stageName === 'brainstorm' ? 'design'
|
|
278
308
|
: stageName === 'plan' ? 'plan'
|
|
279
|
-
: stageName === 'propose' ? 'proposal'
|
|
280
309
|
: 'acceptance'
|
|
281
310
|
const searchDirs = [effectiveSpecBase, reviewChangeDir, cwd].filter(Boolean)
|
|
282
311
|
const reviewResult = validateStageReview({ stage: stageName, reviewType, runtimeRoot, reviewRunId, searchDirs })
|
|
@@ -302,7 +331,7 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
|
|
|
302
331
|
|
|
303
332
|
if (planPath && existsSync(planPath)) {
|
|
304
333
|
const planContent = readFileSync(planPath, 'utf8')
|
|
305
|
-
const runtimeRoot = platformOpts
|
|
334
|
+
const runtimeRoot = resolveRuntimeRoot(platformOpts, effectiveSpecBase)
|
|
306
335
|
|
|
307
336
|
// execute run id:从变更专属标记文件读取
|
|
308
337
|
const runIdFile = join(runtimeRoot, `current-execute-run-id-${changeName}`)
|
|
@@ -313,10 +342,16 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
|
|
|
313
342
|
}
|
|
314
343
|
} catch {}
|
|
315
344
|
if (!executeRunId) {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
//
|
|
319
|
-
|
|
345
|
+
// marker 缺失:先扫描 execute-runs/ 既有目录找回真实 runId(与 getLatestStageReviewRunId
|
|
346
|
+
// 目录扫描兜底同语义),避免 marker 丢失而 agent 已用旧 runId 落盘时,直接 generate 新 ID
|
|
347
|
+
// 找不到旧 review、误判缺 review.json。仅当确实无既有 run 才 generate 新 ID 并落盘。
|
|
348
|
+
const { generateExecuteRunId, resolveLatestExecuteRunId } = await import('../task-review.js')
|
|
349
|
+
executeRunId = resolveLatestExecuteRunId({ runtimeRoot, changeName }) || ''
|
|
350
|
+
if (!executeRunId) {
|
|
351
|
+
executeRunId = generateExecuteRunId()
|
|
352
|
+
// 落盘(marker 缺失时 fallback 生成后写盘,保证后续 checkbox/gate 读到同一 ID)
|
|
353
|
+
try { mkdirSync(runtimeRoot, { recursive: true }); writeFileSync(runIdFile, executeRunId + '\n') } catch {}
|
|
354
|
+
}
|
|
320
355
|
}
|
|
321
356
|
|
|
322
357
|
// git 真实性校验目录:worktree 存在则用 worktree(base/head commit 在其中),否则主仓库
|
|
@@ -363,3 +398,251 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
|
|
|
363
398
|
return null
|
|
364
399
|
}
|
|
365
400
|
|
|
401
|
+
/**
|
|
402
|
+
* 读取 design.md frontmatter 的 scale 字段(brainstorm 末步写入 'small'|'large')。
|
|
403
|
+
* completeStep 的下一步提示据此分叉:small→quick,large/读不到→plan(fail-safe 走重流程)。
|
|
404
|
+
* 只解析首个 YAML frontmatter 块,避免误读正文里的 "scale:"。
|
|
405
|
+
* 从 complete.js 迁入(completeStageGates 共享收尾管线 + completeStep brainstorm 提示消费)。
|
|
406
|
+
*/
|
|
407
|
+
export function readDesignScale(specBase, changeName) {
|
|
408
|
+
if (!changeName) return null
|
|
409
|
+
const designPath = join(specBase, 'changes', changeName, 'design.md')
|
|
410
|
+
if (!existsSync(designPath)) return null
|
|
411
|
+
const text = readFileSync(designPath, 'utf8')
|
|
412
|
+
const fm = text.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n/)
|
|
413
|
+
if (!fm) return null
|
|
414
|
+
const m = fm[1].match(/^scale:[ \t]*['"]?(\w+)/m)
|
|
415
|
+
return m ? m[1] : null
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* 校验变更目录下近 10 分钟新增的 .md/.yaml/.yml 文件含 author/created_at 元数据(advisory 打印)。
|
|
420
|
+
* 从 complete.js 迁入(completeStageGates 共享收尾管线消费)。
|
|
421
|
+
*/
|
|
422
|
+
export function validateMetadata(cwd, stageName, specBase) {
|
|
423
|
+
const changesDir = join(specBase, 'changes')
|
|
424
|
+
if (!existsSync(changesDir)) return
|
|
425
|
+
|
|
426
|
+
const cutoff = Date.now() - 10 * 60 * 1000
|
|
427
|
+
const missing = []
|
|
428
|
+
|
|
429
|
+
function walk(dir) {
|
|
430
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
431
|
+
const full = join(dir, entry.name)
|
|
432
|
+
try {
|
|
433
|
+
if (entry.isDirectory()) { walk(full); continue }
|
|
434
|
+
if (!/\.(md|yaml|yml)$/.test(entry.name)) continue
|
|
435
|
+
const mtime = statSync(full).mtimeMs
|
|
436
|
+
if (mtime < cutoff) continue
|
|
437
|
+
const content = readFileSync(full, 'utf-8')
|
|
438
|
+
if (!content.includes('author:') && !content.includes('author:')) missing.push(full)
|
|
439
|
+
if (!content.includes('created_at:') && !content.includes('created_at:')) missing.push(full)
|
|
440
|
+
} catch (e) { /* skip unreadable files */ }
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
walk(changesDir)
|
|
445
|
+
const unique = [...new Set(missing)]
|
|
446
|
+
if (unique.length > 0) {
|
|
447
|
+
console.log(`\n⚠️ 以下文件缺少 author 或 created_at 元数据:`)
|
|
448
|
+
unique.forEach(f => console.log(` - ${f.replace(cwd + '/', '')}`))
|
|
449
|
+
console.log('请在文件头部添加 author(git 用户名)和 created_at(精确到秒)')
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* 验证关键文件是否存在于正确的变更目录下(advisory 打印,防 AI 写错路径)。
|
|
455
|
+
* 从 complete.js 迁入(completeStageGates 共享收尾管线消费)。
|
|
456
|
+
*/
|
|
457
|
+
export function validateFileLocations(cwd, stageName, progress, changeName, specBase) {
|
|
458
|
+
const effectiveChange = changeName || progress.currentChange
|
|
459
|
+
if (!effectiveChange) return
|
|
460
|
+
|
|
461
|
+
const changeDir = join(specBase, 'changes', effectiveChange)
|
|
462
|
+
if (!existsSync(changeDir)) return
|
|
463
|
+
|
|
464
|
+
// 每个阶段完成后预期存在的文件
|
|
465
|
+
// brainstorm:scale=small(小变更)只必产 design.md;large/未标 scale → 四件套全。
|
|
466
|
+
// 与 validateBrainstormOutputs 的 BRAINSTORM_RULES condition(scale≠small)同源,避免对合法 small 变更
|
|
467
|
+
// 误报"⬜ proposal/requirements/tasks 未找到"(本检查仅 advisory 打印不 gate,但误导输出仍要消除)。
|
|
468
|
+
const brainstormExpected = readDesignScale(specBase, effectiveChange) === 'small'
|
|
469
|
+
? ['design.md']
|
|
470
|
+
: ['design.md', 'proposal.md', 'requirements.md', 'tasks.md']
|
|
471
|
+
const expectedFiles = {
|
|
472
|
+
brainstorm: brainstormExpected,
|
|
473
|
+
plan: ['plan.md'],
|
|
474
|
+
archive: ['module-impact.md'],
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const expected = expectedFiles[stageName]
|
|
478
|
+
if (!expected) return
|
|
479
|
+
|
|
480
|
+
const missing = []
|
|
481
|
+
for (const file of expected) {
|
|
482
|
+
if (!existsSync(join(changeDir, file))) {
|
|
483
|
+
missing.push(file)
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (missing.length > 0) {
|
|
488
|
+
console.log(`\n⚠️ 文件位置验证:以下文件未在变更目录中找到`)
|
|
489
|
+
console.log(` 变更目录:${changeDir.replace(cwd + '/', '')}/`)
|
|
490
|
+
for (const f of missing) {
|
|
491
|
+
// 检查是否写到了错误的位置
|
|
492
|
+
const wrongPath = join(specBase, 'changes', 'change', effectiveChange, f)
|
|
493
|
+
if (existsSync(wrongPath)) {
|
|
494
|
+
console.log(` ❌ ${f} — 不存在,但发现了错误路径:${wrongPath.replace(cwd + '/', '')}`)
|
|
495
|
+
console.log(` 提示:应该写入 ${changeDir.replace(cwd + '/', '')}/${f}`)
|
|
496
|
+
} else {
|
|
497
|
+
console.log(` ⬜ ${f} — 未找到(该阶段可能未产出此文件)`)
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
} else {
|
|
501
|
+
console.log(`\n✅ 文件位置验证:所有 ${expected.length} 个预期文件均在变更目录中`)
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* 读本变更 design.md §6「文件变更清单」表格,提取声明交付的文件路径(concurrent 预检 ownFiles 源,D-002)。
|
|
507
|
+
*
|
|
508
|
+
* 用途:execute --done 并发预检在 in-place-fallback 模式下,需把本变更交付文件从 foreignFiles 排除
|
|
509
|
+
* (否则自己产出的脏文件会被当他者)。worktree 模式下交付文件不在主仓 git status,ownFiles 用空数组即可。
|
|
510
|
+
*
|
|
511
|
+
* 解析:定位 `## 6.` 段(到下一个 `## N.` 前),逐行扫表格行第 2 列的反引号文件路径,跳过表头/分隔行。
|
|
512
|
+
* 设计容错(design §9 / R-02):design.md 缺失 / 无 §6 / 解析空 → 返回 [],foreignFiles 退化为保守噪音,
|
|
513
|
+
* otherActiveChanges 仍可靠。
|
|
514
|
+
*
|
|
515
|
+
* @param {string} specBase 规范根(.sillyspec/ 所在)
|
|
516
|
+
* @param {string} changeName 当前变更名
|
|
517
|
+
* @returns {string[]} 本变更声明交付的文件路径(git 路径,正斜杠)
|
|
518
|
+
*/
|
|
519
|
+
function readDesignOwnFiles(specBase, changeName) {
|
|
520
|
+
if (!specBase || !changeName) return []
|
|
521
|
+
let content
|
|
522
|
+
try {
|
|
523
|
+
content = readFileSync(join(specBase, 'changes', changeName, 'design.md'), 'utf8')
|
|
524
|
+
} catch {
|
|
525
|
+
return []
|
|
526
|
+
}
|
|
527
|
+
const files = []
|
|
528
|
+
let inSection6 = false
|
|
529
|
+
for (const line of content.split('\n')) {
|
|
530
|
+
const heading = line.match(/^##\s+(\d+)\./)
|
|
531
|
+
if (heading) {
|
|
532
|
+
inSection6 = heading[1] === '6'
|
|
533
|
+
continue
|
|
534
|
+
}
|
|
535
|
+
if (!inSection6 || !line.startsWith('|')) continue
|
|
536
|
+
// 跳过表头分隔行(|---|---|)
|
|
537
|
+
if (/^\|[\s:|-]+\|?$/.test(line.trim())) continue
|
|
538
|
+
// 表格第 2 列反引号文件路径:`| 新增 | \`src/.../x.js\` | 说明 |`
|
|
539
|
+
const m = line.match(/^\|[^|]+\|\s*`([^`]+)`/)
|
|
540
|
+
if (m) files.push(m[1].trim())
|
|
541
|
+
}
|
|
542
|
+
return files
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* 阶段完成收尾共享管线(从 completeStep 抽出,消除 noAI 末步 / continueStep 完成分支绕过 gate 的
|
|
547
|
+
* S1/S2/S3 三处不对称)。调用方已自行标记 stageData.status='completed' 并 pm._write 落盘后调用本函数。
|
|
548
|
+
*
|
|
549
|
+
* 序列:handleScanStageCompleted → validateMetadata → validateFileLocations[completed‖skipped 守卫] →
|
|
550
|
+
* auxiliary 重置 → runStageCompletionGates[同守卫] → handleExecuteWorktreeCleanup。
|
|
551
|
+
*
|
|
552
|
+
* 关键陷阱(design §5.4):auxiliary 重置把 stageData.steps 换成 freshSteps(全 pending);下方
|
|
553
|
+
* runStageCompletionGates 守卫与 rollbackStageCompletion 必须用入参 steps(pre-reset 原数组),
|
|
554
|
+
* 不得重读 stageData.steps(否则计数恒 0 → gate 永跳过)。本函数全程用入参 steps,未重读。
|
|
555
|
+
*
|
|
556
|
+
* @returns {Promise<{stageCompleted:false,currentIdx,nextPendingIdx:number}|null>}
|
|
557
|
+
* null = 全部通过,调用方继续自管收尾(如下一步提示);
|
|
558
|
+
* 非 null = gate/handler 失败已 rollback,调用方直接 return。
|
|
559
|
+
*/
|
|
560
|
+
export async function completeStageGates({ stageName, cwd, changeName, platformOpts, specBase, progress, pm, stageData, steps, currentIdx, outputText }) {
|
|
561
|
+
// 整体 try/catch(task-04 / review-2026-08-09 #2):收尾段(execute 预检 + scan handler + validate* +
|
|
562
|
+
// auxiliary 重置 + runStageCompletionGates)任一段抛非结构化异常 → rollbackCompletionAndReturn
|
|
563
|
+
// (回滚 in-progress + 落盘 + 返回未完成对象),不冒顶 exit 1。正常的 _scanResult/_gateEarlyReturn
|
|
564
|
+
// early-return 是 return 非 throw,不被 catch 拦;下方 handleExecuteWorktreeCleanup 在 try 外(副作用独立,失败不 rollback stage 状态)。
|
|
565
|
+
try {
|
|
566
|
+
// ── execute --done 并发他者改动预检(FR-06/FR-07,非阻断 advisory)──
|
|
567
|
+
// 仅 execute 触发,不影响 scan/plan/verify/archive 等 stage 的 completeStageGates。
|
|
568
|
+
// 纯副作用:console.warn 后照常推进,不改 stageData / 不阻断级联(FR-07)。
|
|
569
|
+
// 整个钩子 try/catch 兜底——任何异常吞掉,绝不影响 gate 级联(FR-07 不阻断铁律)。
|
|
570
|
+
if (stageName === 'execute') {
|
|
571
|
+
try {
|
|
572
|
+
// ownFiles 源(D-002 + B-002,钉死):动态 import WorktreeManager 取 meta.mode(复用上方
|
|
573
|
+
// Task Review Gate 的 ../worktree.js 动态 import 先例)。WorktreeManager 无 appliedFiles
|
|
574
|
+
// 字段(B-002 已证),不用模糊的「worktree applied 文件」。
|
|
575
|
+
// - worktree 模式(meta.mode 非 in-place-fallback):主仓 git status 看不见本变更交付文件
|
|
576
|
+
// → ownFiles=[](无害,dogfood 当前实际模式)
|
|
577
|
+
// - in-place-fallback 模式:交付文件就在主仓 dirty → ownFiles 读 design.md §6 文件清单排除
|
|
578
|
+
let ownFiles = []
|
|
579
|
+
// linkedChanges:execute 通常无(quick 管线概念,存 quick guard 文件);stageData 取不到则 []。
|
|
580
|
+
const linkedChanges = Array.isArray(stageData?.linkedChanges) ? stageData.linkedChanges : []
|
|
581
|
+
try {
|
|
582
|
+
const { WorktreeManager } = await import('../worktree.js')
|
|
583
|
+
const wm = new WorktreeManager({ cwd })
|
|
584
|
+
const meta = wm.getMeta(changeName)
|
|
585
|
+
if (meta?.mode === 'in-place-fallback') {
|
|
586
|
+
ownFiles = readDesignOwnFiles(specBase, changeName)
|
|
587
|
+
}
|
|
588
|
+
} catch {
|
|
589
|
+
// getMeta 抛(meta.json 缺 / worktree 损坏)→ ownFiles=[] 兜底,不崩(FR-07)。
|
|
590
|
+
ownFiles = []
|
|
591
|
+
}
|
|
592
|
+
const detected = detectConcurrentChanges(cwd, { changeName, linkedChanges, ownFiles })
|
|
593
|
+
const warn = formatConcurrentWarning(detected)
|
|
594
|
+
if (warn) console.warn(warn)
|
|
595
|
+
} catch {
|
|
596
|
+
// FR-07 不阻断:钩子任何意外异常静默吞掉,gate 级联照常推进。
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// scan 平台 manifest + post-check(S1 平台受害者:noAI scanPostcheck 末步 / continueStep 现也走这里)
|
|
601
|
+
const _scanResult = await handleScanStageCompleted({ stageName, currentIdx, cwd, progress, pm, stageData, changeName, outputText, platformOpts })
|
|
602
|
+
if (_scanResult) return _scanResult
|
|
603
|
+
|
|
604
|
+
// 守卫计数:completed‖skipped === total(修 S3);用入参 steps(pre-reset 原数组,design §5.4)
|
|
605
|
+
const settledCount = steps.filter(s => s.status === 'completed' || s.status === 'skipped').length
|
|
606
|
+
const total = steps.length
|
|
607
|
+
|
|
608
|
+
validateMetadata(cwd, stageName, specBase)
|
|
609
|
+
|
|
610
|
+
// 验证关键文件位置(仅当所有步骤已结案 completed‖skipped 时才校验)
|
|
611
|
+
if (settledCount === total && total > 0) {
|
|
612
|
+
validateFileLocations(cwd, stageName, progress, changeName, specBase)
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// 辅助阶段完成后重置步骤(scan 等 auxiliary 阶段重置回 pending 可重跑)
|
|
616
|
+
const stageDef = stageRegistry[stageName]
|
|
617
|
+
if (stageDef?.auxiliary) {
|
|
618
|
+
const freshSteps = (stageDef.steps || []).map(s => ({
|
|
619
|
+
name: s.name,
|
|
620
|
+
status: 'pending',
|
|
621
|
+
output: null,
|
|
622
|
+
completedAt: null
|
|
623
|
+
}))
|
|
624
|
+
stageData.steps = freshSteps
|
|
625
|
+
stageData.status = 'pending'
|
|
626
|
+
stageData.completedAt = null
|
|
627
|
+
if (progress.currentStage === stageName) progress.currentStage = ''
|
|
628
|
+
pm._write(cwd, progress, changeName)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// 阶段完成校验 gate 级联(runValidators → verify-test → Plan→Execute → Stage Review → Task Review)
|
|
632
|
+
if (settledCount === total && total > 0) {
|
|
633
|
+
const _gateEarlyReturn = await runStageCompletionGates({ stageName, cwd, changeName, platformOpts, specBase, progress, pm, stageData, steps, currentIdx })
|
|
634
|
+
if (_gateEarlyReturn) return _gateEarlyReturn
|
|
635
|
+
} else if (settledCount < total) {
|
|
636
|
+
console.log(`\n⚠️ 阶段校验跳过:${total} 步中仅 ${settledCount} 步标记为已结案(completed‖skipped),可能存在状态不同步。如确认阶段已完成,请运行 --status 确认。`)
|
|
637
|
+
}
|
|
638
|
+
} catch (e) {
|
|
639
|
+
console.error(`\n❌ ${stageName} 阶段完成收尾异常(已 rollback 为 in-progress,请修复后重新 --done):${e?.message || e}`)
|
|
640
|
+
return await rollbackCompletionAndReturn(pm, progress, stageData, steps, currentIdx, cwd, changeName, platformOpts)
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// execute worktree cleanup(completeStep / continueStep 完成分支统一调用,避免双清理)
|
|
644
|
+
await handleExecuteWorktreeCleanup({ stageName, changeName, cwd })
|
|
645
|
+
|
|
646
|
+
return null
|
|
647
|
+
}
|
|
648
|
+
|