sillyspec 3.25.6 → 3.25.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/init.js +69 -0
- package/src/progress/consistency-doctor.js +41 -2
- package/src/progress/stage-machine.js +3 -2
- package/src/quicklog.js +92 -5
- package/src/run/complete.js +100 -2
- package/src/run/gates.js +2 -0
- package/src/run/shared.js +14 -1
- package/src/run/stage.js +8 -2
- package/src/stage-review.js +23 -2
- package/src/stages/archive.js +3 -0
- package/src/stages/execute.js +7 -0
- package/src/stages/plan-postcheck.js +5 -0
- package/src/stages/plan.js +1 -0
- package/src/stages/verify.js +10 -13
- package/src/task-review.js +35 -16
- package/src/worktree-apply.js +25 -3
- package/templates/claude-instruction.md +23 -0
- package/templates/prompts/verify-probes.md +1 -0
- package/CLAUDE.md +0 -40
package/package.json
CHANGED
package/src/init.js
CHANGED
|
@@ -123,6 +123,70 @@ function injectInstructions(tool, projectDir) {
|
|
|
123
123
|
writeFileSync(filePath, content.trimEnd() + '\n\n' + INJECTION_CONTENT);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
// ── 注入 CLAUDE.md(claude,版本感知幂等)──
|
|
127
|
+
// 与 codex/gemini/opencode 不同:claude 需要完整 CLAUDE.md 模板 + 版本感知三态。
|
|
128
|
+
// 故 claude 不进 INSTRUCTION_TOOLS,单独走此函数(design: 2026-08-02-init-claude-md)。
|
|
129
|
+
const CLAUDE_TEMPLATE_PATH = join(__dirname, '..', 'templates', 'claude-instruction.md');
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 为 Claude Code 注入 CLAUDE.md(版本感知幂等)。
|
|
133
|
+
* - 不存在:写完整模板(templates/claude-instruction.md)+ 顶部版本注释
|
|
134
|
+
* - 存在无标记:追加受管段(INJECTION_CONTENT)+ 版本块标记
|
|
135
|
+
* - 存在同版本标记:跳过
|
|
136
|
+
* - 存在异版本标记:追加态刷新块 / 完整态仅 stderr 提示
|
|
137
|
+
* @param {string} projectDir - 源码项目根
|
|
138
|
+
*/
|
|
139
|
+
export function injectClaudeInstructions(projectDir) {
|
|
140
|
+
const filePath = join(projectDir, 'CLAUDE.md');
|
|
141
|
+
const version = getVersion();
|
|
142
|
+
// 完整态顶部注释(新文件首行,轻量、不限制后续编辑)
|
|
143
|
+
const fullHeader = `<!-- SillySpec v${version} — 由 sillyspec init 生成,可自由编辑;重跑 init 同版本不更新 -->`;
|
|
144
|
+
// 追加态受管段(包版本块标记,明确"勿手动编辑此段")
|
|
145
|
+
const appendBlock =
|
|
146
|
+
`<!-- SillySpec v${version} START — 由 sillyspec init 注入,勿手动编辑此段 -->\n` +
|
|
147
|
+
INJECTION_CONTENT.trimEnd() +
|
|
148
|
+
`\n<!-- SillySpec END -->`;
|
|
149
|
+
|
|
150
|
+
// 状态 1:文件不存在 → 写完整模板 + 顶部版本注释
|
|
151
|
+
if (!existsSync(filePath)) {
|
|
152
|
+
let template = '';
|
|
153
|
+
try {
|
|
154
|
+
template = readFileSync(CLAUDE_TEMPLATE_PATH, 'utf8');
|
|
155
|
+
} catch {
|
|
156
|
+
console.error(`❌ [sillyspec] 未找到 Claude 模板:${CLAUDE_TEMPLATE_PATH}`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
writeFileSync(filePath, fullHeader + '\n' + template);
|
|
160
|
+
console.log(chalk.green(` ✓ Claude Code CLAUDE.md 已生成(SillySpec v${version})`));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const content = readFileSync(filePath, 'utf8');
|
|
165
|
+
|
|
166
|
+
// 无任何 `<!-- SillySpec v` 标记 → 状态 2:追加受管段(原文字节保留)
|
|
167
|
+
const markMatch = content.match(/<!-- SillySpec v(\S+)/);
|
|
168
|
+
if (!markMatch) {
|
|
169
|
+
writeFileSync(filePath, content.trimEnd() + '\n\n' + appendBlock + '\n');
|
|
170
|
+
console.log(chalk.green(` ✓ Claude Code CLAUDE.md 已追加 SillySpec 受管段(v${version})`));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const existingVersion = markMatch[1];
|
|
175
|
+
// 状态 3:同版本 → 跳过(幂等,不写)
|
|
176
|
+
if (existingVersion === version) return;
|
|
177
|
+
|
|
178
|
+
// 状态 4:异版本(升级)。区分追加态(有 START...END 块)/ 完整态(仅顶部注释)。
|
|
179
|
+
const startBlockRe = /<!-- SillySpec v\S+\s+START[\s\S]*?<!-- SillySpec END -->/;
|
|
180
|
+
if (startBlockRe.test(content)) {
|
|
181
|
+
// 4a:追加态 → 用当前版本受管段替换该块(块外用户内容字节保留)
|
|
182
|
+
writeFileSync(filePath, content.replace(startBlockRe, appendBlock));
|
|
183
|
+
console.log(chalk.green(` ✓ Claude Code CLAUDE.md 受管段已升级(v${existingVersion} → v${version})`));
|
|
184
|
+
} else {
|
|
185
|
+
// 4b:完整态 → 不覆盖(保留用户改动),仅 stderr 打印升级提示
|
|
186
|
+
console.error(`⚠️ [sillyspec] SillySpec 升级 v${existingVersion}→v${version},CLAUDE.md 未自动更新(保留你的改动)。如需采用新模板:备份后删除 CLAUDE.md 再跑 sillyspec init。`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
126
190
|
// ── 检测工具 ──
|
|
127
191
|
|
|
128
192
|
function detectTools(projectDir) {
|
|
@@ -273,6 +337,11 @@ async function doInstall(projectDir, tools, subprojects = [], specDir = null) {
|
|
|
273
337
|
}
|
|
274
338
|
}
|
|
275
339
|
|
|
340
|
+
// 注入 CLAUDE.md(claude,版本感知幂等三态四分支;claude 不进 INSTRUCTION_TOOLS)
|
|
341
|
+
if (tools.includes('claude')) {
|
|
342
|
+
injectClaudeInstructions(projectDir);
|
|
343
|
+
}
|
|
344
|
+
|
|
276
345
|
// 复制 skills 到各工具目录
|
|
277
346
|
const skillToolDirs = {
|
|
278
347
|
claude: '.claude/skills',
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// 从 src/progress.js 单体 ProgressManager 抽出。持有 pm 引用(构造注入),调 pm.read / pm._write /
|
|
3
3
|
// pm._ensureRuntimeDir / pm._runtimePath(persistence-core 留 facade 本体)。
|
|
4
4
|
import { appendFileSync } from 'fs';
|
|
5
|
+
import { existsSync } from 'fs';
|
|
5
6
|
import { STAGE_ORDER } from './shared.js';
|
|
6
7
|
|
|
7
8
|
export class ConsistencyDoctor {
|
|
@@ -193,8 +194,46 @@ export class ConsistencyDoctor {
|
|
|
193
194
|
});
|
|
194
195
|
}
|
|
195
196
|
|
|
196
|
-
//
|
|
197
|
-
|
|
197
|
+
// Fix e: execute 阶段 completed stage 有 pending/stale/in-progress step,但 review.json 客观产出已全通过 → 状态脱钩自动修
|
|
198
|
+
// (坑 verify-archive-flow-pitfalls 坑1+坑5:plan 加 Wave / execute Wave step 未走 --done,但 task 实际有 review.json verdict 且非 fail)
|
|
199
|
+
// 安全边界:仅当 changeName 有效、source=review.json(客观源可用)且 pending=0(所有 task verdict 通过)才自动修,
|
|
200
|
+
// 否则回落到 Manual a(保守不动)。不碰非 execute 阶段。
|
|
201
|
+
let executeAutoFixed = false;
|
|
202
|
+
if (stageName === 'execute' && changeName && sd.status === 'completed' && sd.steps) {
|
|
203
|
+
const badSteps = sd.steps.filter(st => ['pending', 'stale', 'in-progress'].includes(st.status));
|
|
204
|
+
if (badSteps.length > 0) {
|
|
205
|
+
try {
|
|
206
|
+
const changeDir = this.pm._changePath(cwd, changeName);
|
|
207
|
+
if (changeDir && existsSync(changeDir)) {
|
|
208
|
+
const runtimeRoot = this.pm._runtimePath(cwd);
|
|
209
|
+
const { summarizeTaskCompletion } = await import('../task-review.js');
|
|
210
|
+
const summary = summarizeTaskCompletion({ changeDir, runtimeRoot, changeName });
|
|
211
|
+
if (summary.source === 'review.json' && summary.total > 0 && summary.pending.length === 0) {
|
|
212
|
+
const desc = `execute: ${badSteps.length} 个 step 状态脱钩(${badSteps.map(st => st.name).join(', ')})——review.json 客观产出全通过(${summary.completed}/${summary.total}),自动标 completed`;
|
|
213
|
+
fixable.push({
|
|
214
|
+
stage: stageName,
|
|
215
|
+
action: 'align_execute_steps_to_reviews',
|
|
216
|
+
description: desc,
|
|
217
|
+
apply: (d) => {
|
|
218
|
+
for (const st of d.stages[stageName].steps) {
|
|
219
|
+
if (['pending', 'stale', 'in-progress'].includes(st.status)) {
|
|
220
|
+
st.status = 'completed';
|
|
221
|
+
st.completedAt = st.completedAt || now;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
executeAutoFixed = true;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
} catch (e) {
|
|
230
|
+
console.warn(`⚠️ execute 产出核验异常(回落 manual): ${e.message}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Manual a: completed stage 里有 pending/stale/in-progress steps(execute 已由 Fix e 自动修则跳过)
|
|
236
|
+
if (!executeAutoFixed && sd.status === 'completed' && sd.steps) {
|
|
198
237
|
const badSteps = sd.steps.filter(s => ['pending', 'stale', 'in-progress'].includes(s.status));
|
|
199
238
|
for (const step of badSteps) {
|
|
200
239
|
manual.push(`${stageName}/${step.name}: step 状态为 ${step.status},但 stage 状态为 completed(需手动确认)`);
|
|
@@ -97,7 +97,7 @@ export class StageMachine {
|
|
|
97
97
|
if (stageRow && stageRow.length > 0 && stageRow[0].values.length > 0) {
|
|
98
98
|
const stageId = stageRow[0].values[0][0];
|
|
99
99
|
sqlDb.run(
|
|
100
|
-
'UPDATE steps SET status = "completed", completed_at = ? WHERE stage_id = ? AND status
|
|
100
|
+
'UPDATE steps SET status = "completed", completed_at = ? WHERE stage_id = ? AND status IN ("pending", "stale")', // stale 一并回填(reopen --done 场景,坑 brainstorm-reopen-step-state-desync)
|
|
101
101
|
[now, stageId]
|
|
102
102
|
);
|
|
103
103
|
}
|
|
@@ -295,11 +295,12 @@ export class StageMachine {
|
|
|
295
295
|
// 改为:不要求 steps(空=该开始了);upstream 必须全 completed(pending 不算就绪)——
|
|
296
296
|
// archive 只在 scan/brainstorm/plan/execute/verify 全完成后才可能被推荐。
|
|
297
297
|
for (const s of STAGE_ORDER) {
|
|
298
|
+
if (s === 'scan') continue // scan 是 auxiliary(按需显式跑),不作"下一步"推荐——否则未 completed 时恒处 STAGE_ORDER 首位且 upstream 空→恒就绪→误推 scan(回头路,与 complete.js brainstorm/quick 专属分支同类根因)。prompt-control-debt plan-c。
|
|
298
299
|
const sd = data.stages[s];
|
|
299
300
|
if (!sd) continue;
|
|
300
301
|
if (sd.status === 'completed' || sd.status === 'skipped') continue;
|
|
301
302
|
const idx = STAGE_ORDER.indexOf(s);
|
|
302
|
-
const upstream = STAGE_ORDER.slice(0, idx);
|
|
303
|
+
const upstream = STAGE_ORDER.slice(0, idx).filter(us => us !== 'scan'); // scan(auxiliary)不算上游——否则 scan 未 completed 会阻塞所有主流程阶段,与上面跳过 scan 的迭代矛盾
|
|
303
304
|
const upstreamOk = upstream.every(us => data.stages[us]?.status === 'completed');
|
|
304
305
|
if (upstreamOk) {
|
|
305
306
|
return {
|
package/src/quicklog.js
CHANGED
|
@@ -81,6 +81,36 @@ function sanitizeDesc(description) {
|
|
|
81
81
|
return s.length > 120 ? s.slice(0, 120) + '…' : s
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// 从关联变更的 proposal/design 提取首个 # 标题(去「提案书(Proposal)— / 设计文档(Design)—」固定前缀)。
|
|
85
|
+
// 让 quick --linked-changes 启动时(用户常不带 --input)也能拿到语义标题,而非 (quick 任务) 占位。
|
|
86
|
+
// 读不到任何标题返回 ''(调用方再回退占位符,保持向后兼容)。
|
|
87
|
+
export function deriveTitleFromLinkedChange(specBase, change) {
|
|
88
|
+
if (!change) return ''
|
|
89
|
+
for (const f of ['proposal.md', 'design.md']) {
|
|
90
|
+
let content
|
|
91
|
+
try { content = readFileSync(join(specBase, 'changes', change, f), 'utf8') } catch { continue }
|
|
92
|
+
const m = content.match(/^#\s+(.+?)\s*$/m)
|
|
93
|
+
if (!m) continue
|
|
94
|
+
const raw = m[1].trim()
|
|
95
|
+
// 标题惯例「# 提案书(Proposal)— <desc>」「# 设计文档(Design)— <desc>」→ 取破折号后的 desc
|
|
96
|
+
const dash = raw.match(/[—-]\s*(.+)$/)
|
|
97
|
+
return dash ? dash[1].trim() : raw
|
|
98
|
+
}
|
|
99
|
+
return ''
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 从 quick step3 --output 四字段提取「需求:」摘要,用于翻完成时刷新标题行
|
|
103
|
+
// (覆盖启动时的占位/弱标题)。提取失败返回 ''(不刷新)。
|
|
104
|
+
function extractTitleFromResult(result) {
|
|
105
|
+
if (!result) return ''
|
|
106
|
+
const m = String(result).match(/需求:([^\n\r]*?)(?:\s+根因:|$)/)
|
|
107
|
+
if (!m) return ''
|
|
108
|
+
let t = m[1].replace(/[,。;,;].*$/, '').trim() // 截到首个标点,取核心句
|
|
109
|
+
if (!t) return ''
|
|
110
|
+
if (t.length > 80) t = t.slice(0, 80) + '…'
|
|
111
|
+
return t
|
|
112
|
+
}
|
|
113
|
+
|
|
84
114
|
function sanitizeResult(resultText) {
|
|
85
115
|
// 保留换行:结果块支持字段化结构(需求:/根因:/方案:/结果:...)。多行时 flipEntryInContent
|
|
86
116
|
// 写成字段块(追加在「状态:」行下方),不是单条「结果:<长行>」。仅去首尾空白。
|
|
@@ -189,10 +219,13 @@ function tasksPath(specBase, change) {
|
|
|
189
219
|
return join(specBase, 'changes', change, 'tasks.md')
|
|
190
220
|
}
|
|
191
221
|
|
|
192
|
-
// 追加未勾选 task;幂等(已含同 qlId
|
|
222
|
+
// 追加未勾选 task;幂等(已含同 qlId 则跳过)。
|
|
223
|
+
// 关联变更目录不存在(笔误 / 未建 / 仅作标签关联)→ 不 fabricate stub 目录(历史坑 quick-change-phantom:
|
|
224
|
+
// mkdirSync 硬造 changes/<名>/tasks.md,致 quick --done 边界审计自造自拦 BLOCK)。关联仍记入
|
|
225
|
+
// QUICKLOG「关联变更」行(allocateQuicklogEntry 独立写入,不依赖本函数)。
|
|
193
226
|
function appendTaskCheckbox(specBase, change, qlId, desc) {
|
|
194
227
|
const dir = join(specBase, 'changes', change)
|
|
195
|
-
|
|
228
|
+
if (!existsSync(dir)) return
|
|
196
229
|
const p = tasksPath(specBase, change)
|
|
197
230
|
let content = existsSync(p) ? readFileSync(p, 'utf8') : ''
|
|
198
231
|
if (content.includes(qlId)) return
|
|
@@ -215,10 +248,52 @@ async function checkTaskCheckbox(specBase, change, qlId) {
|
|
|
215
248
|
// 可能是 CRLF,split('\n') 后每行带 \r。原代码 `lines[i] === '状态:进行中'` 精确匹配
|
|
216
249
|
// 恒失败 → 走 splice「兜底插入」→ 条目内同时出现「状态:已完成」+「状态:进行中」。
|
|
217
250
|
// 状态行匹配与「已完成」判断统一用行首前缀匹配,容忍行尾 \r;写入保持 CRLF 不扩大改动。
|
|
251
|
+
// 单行四字段(需求:/根因:/方案:/结果:)切段。先按「字段边界」严格扫描:真实标签 = 上一标签之后首次
|
|
252
|
+
// 出现的、前导是串首/空白/句末标点(。;!?)的对应标签——字段正文引用标签字样(根因写「双层「结果:」
|
|
253
|
+
// 前缀」或正则 split(/(?=需求:|根因:|方案:|结果:)/))因前导是「/|( 等非边界字符而跳过。严格失败
|
|
254
|
+
// (如真实标签前导是「,」这类弱标点)退回宽松顺序扫描(上一标签之后首次出现)。缺标签返回 null → 落单行
|
|
255
|
+
// 兜底(--done 契约校验仍会拦缺字段)。残余边界:正文引用标签且前导恰好是空白/句末标点时仍可能错位。
|
|
256
|
+
function isFieldBoundary(body, idx) {
|
|
257
|
+
if (idx <= 0) return true
|
|
258
|
+
const prev = body[idx - 1]
|
|
259
|
+
return /\s/.test(prev) || '。;!?'.includes(prev)
|
|
260
|
+
}
|
|
261
|
+
function findBoundaryLabel(body, label, from) {
|
|
262
|
+
let idx = body.indexOf(label, from)
|
|
263
|
+
while (idx !== -1 && !isFieldBoundary(body, idx)) {
|
|
264
|
+
idx = body.indexOf(label, idx + label.length)
|
|
265
|
+
}
|
|
266
|
+
return idx
|
|
267
|
+
}
|
|
268
|
+
function scanFields(body, findLabel) {
|
|
269
|
+
const labels = ['需求:', '根因:', '方案:', '结果:']
|
|
270
|
+
const positions = []
|
|
271
|
+
let cursor = 0
|
|
272
|
+
for (const label of labels) {
|
|
273
|
+
const idx = findLabel(body, label, cursor)
|
|
274
|
+
if (idx === -1) return null
|
|
275
|
+
positions.push(idx)
|
|
276
|
+
cursor = idx + label.length
|
|
277
|
+
}
|
|
278
|
+
return positions.map((start, i) => {
|
|
279
|
+
const end = i + 1 < positions.length ? positions[i + 1] : body.length
|
|
280
|
+
return body.slice(start, end).trim()
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
function splitSingleLineFields(body) {
|
|
284
|
+
return scanFields(body, findBoundaryLabel) ?? scanFields(body, (b, l, f) => b.indexOf(l, f))
|
|
285
|
+
}
|
|
286
|
+
|
|
218
287
|
function flipEntryInContent(content, qlId, result, changedFiles = []) {
|
|
219
288
|
const lines = content.split('\n')
|
|
220
289
|
const startIdx = lines.findIndex(l => l.startsWith(`## ${qlId} |`))
|
|
221
290
|
if (startIdx === -1) return null
|
|
291
|
+
// 翻完成时若能从结果提取「需求:」摘要,刷新标题行(覆盖启动时的占位/弱标题)
|
|
292
|
+
const newTitle = extractTitleFromResult(result)
|
|
293
|
+
if (newTitle) {
|
|
294
|
+
const headerCore = lines[startIdx].match(/^(## \S+ \| [^|]+ \| )/)
|
|
295
|
+
if (headerCore) lines[startIdx] = headerCore[1] + newTitle
|
|
296
|
+
}
|
|
222
297
|
let endIdx = lines.length
|
|
223
298
|
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
224
299
|
if (lines[i].startsWith('## ')) { endIdx = i; break }
|
|
@@ -241,13 +316,25 @@ function flipEntryInContent(content, qlId, result, changedFiles = []) {
|
|
|
241
316
|
endIdx += 1
|
|
242
317
|
}
|
|
243
318
|
if (result && !hasResult) {
|
|
319
|
+
// quick step3 --output 常被压成单行「需求:…根因:…方案:…结果:…」(agent 未加换行),
|
|
320
|
+
// 直接落盘会得到双层前缀「结果:需求:…结果:…」。把单行四字段归一为多行字段块,省 agent 手工
|
|
321
|
+
// 拆行精修(prompt-control-debt quick-①)。多行 / 单句结果不受影响。
|
|
322
|
+
let body = result
|
|
323
|
+
if (!/\r?\n/.test(body) && /^需求:/.test(body)) {
|
|
324
|
+
// 单行四字段归一为多行。不能 split(/(?=需求:|根因:|方案:|结果:)/)——正文引用字段标签字样
|
|
325
|
+
// (如根因里写「双层「结果:」前缀」)会被任意位置误切。改按序扫描:真实标签 = 上一标签之后
|
|
326
|
+
// 首次出现,字段正文引用更靠后的标签不误断(quick-① 2026-08-04 实证补丁)。缺标签返回 null
|
|
327
|
+
// → 落单行兜底(--done 契约校验仍会拦缺字段)。
|
|
328
|
+
const segs = splitSingleLineFields(body)
|
|
329
|
+
if (segs) body = segs.join('\n')
|
|
330
|
+
}
|
|
244
331
|
// 多行结果(结构化 需求/根因/方案/结果 字段块):逐行插入为独立字段行。
|
|
245
332
|
// 单行结果:保持「结果:<一句话>」一行,向后兼容简单用例。
|
|
246
|
-
if (/\r?\n/.test(
|
|
247
|
-
const resultLines =
|
|
333
|
+
if (/\r?\n/.test(body)) {
|
|
334
|
+
const resultLines = body.split(/\r?\n/).filter(l => l.trim() !== '')
|
|
248
335
|
lines.splice(endIdx, 0, ...resultLines)
|
|
249
336
|
} else {
|
|
250
|
-
lines.splice(endIdx, 0, `结果:${
|
|
337
|
+
lines.splice(endIdx, 0, `结果:${body}`)
|
|
251
338
|
}
|
|
252
339
|
}
|
|
253
340
|
return lines.join('\n')
|
package/src/run/complete.js
CHANGED
|
@@ -54,6 +54,22 @@ function validateMetadata(cwd, stageName, specBase) {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* 读取 design.md frontmatter 的 scale 字段(brainstorm 末步写入 'small'|'large')。
|
|
59
|
+
* completeStep 的下一步提示据此分叉:small→quick,large/读不到→plan(fail-safe 走重流程)。
|
|
60
|
+
* 只解析首个 YAML frontmatter 块,避免误读正文里的 "scale:"。
|
|
61
|
+
*/
|
|
62
|
+
function readDesignScale(specBase, changeName) {
|
|
63
|
+
if (!changeName) return null
|
|
64
|
+
const designPath = join(specBase, 'changes', changeName, 'design.md')
|
|
65
|
+
if (!existsSync(designPath)) return null
|
|
66
|
+
const text = readFileSync(designPath, 'utf8')
|
|
67
|
+
const fm = text.match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n/)
|
|
68
|
+
if (!fm) return null
|
|
69
|
+
const m = fm[1].match(/^scale:[ \t]*['"]?(\w+)/m)
|
|
70
|
+
return m ? m[1] : null
|
|
71
|
+
}
|
|
72
|
+
|
|
57
73
|
/**
|
|
58
74
|
* 验证关键文件是否存在于正确的变更目录下
|
|
59
75
|
* 防止 AI 将文件写到错误的路径
|
|
@@ -100,6 +116,36 @@ function validateFileLocations(cwd, stageName, progress, changeName, specBase) {
|
|
|
100
116
|
}
|
|
101
117
|
}
|
|
102
118
|
|
|
119
|
+
/**
|
|
120
|
+
* 坑1:用 --done --answer 解掉「已 waiting 的步骤」。
|
|
121
|
+
*
|
|
122
|
+
* completeStep 的 currentIdx 选择(findIndex pending||in-progress)排除 waiting,导致
|
|
123
|
+
* --done --answer 落到已 --wait 暂停的 requiresWait 步骤时跳过它、--answer 静默丢失、步骤永久
|
|
124
|
+
* 卡 WAITING、末步报「Step N 等待用户输入」无法 finish。本函数把首个 waiting 步骤拉回 pending
|
|
125
|
+
* + 补 waitAnswer(对齐 continueStep 对 requiresWait 的 shouldReturnToCurrentStep=回 pending 语义),
|
|
126
|
+
* 让主流程 requiresWait 门控见 waitAnswer 已置→不阻断→正常 completed。
|
|
127
|
+
*
|
|
128
|
+
* @param {object[]} steps - stageData.steps(原地修改)
|
|
129
|
+
* @param {string|null|undefined} doneAnswer - --answer 值;空则不触发(返回 -1)
|
|
130
|
+
* @param {string} nowStr - 时间戳字符串(用于 waitAnswers[].answeredAt)
|
|
131
|
+
* @returns {number} 被解的 waiting 步骤 idx;无 waiting 或无 answer 返回 -1
|
|
132
|
+
*/
|
|
133
|
+
export function resolveWaitingStepWithAnswer(steps, doneAnswer, nowStr) {
|
|
134
|
+
if (!doneAnswer) return -1
|
|
135
|
+
const waitIdx = steps.findIndex(s => s && s.status === 'waiting')
|
|
136
|
+
if (waitIdx === -1) return -1
|
|
137
|
+
const ws = steps[waitIdx]
|
|
138
|
+
ws.waitAnswer = doneAnswer
|
|
139
|
+
ws.waitAnswers = Array.isArray(ws.waitAnswers) ? ws.waitAnswers : []
|
|
140
|
+
ws.waitAnswers.push({ round: (ws.waitRound || 0) + 1, answer: doneAnswer, answeredAt: nowStr })
|
|
141
|
+
delete ws.waitReason
|
|
142
|
+
delete ws.waitOptions
|
|
143
|
+
delete ws.waitedAt
|
|
144
|
+
ws.status = 'pending'
|
|
145
|
+
ws.completedAt = null
|
|
146
|
+
return waitIdx
|
|
147
|
+
}
|
|
148
|
+
|
|
103
149
|
export async function completeStep(pm, progress, stageName, cwd, outputText, inputText = null, options = {}) {
|
|
104
150
|
const { printNext = true, confirm = false, changeName, platformOpts = {}, nonInteractive = false, confirmMode = null, isForceBaseline = false, isAllowNew = false } = options
|
|
105
151
|
const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
|
|
@@ -129,7 +175,17 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
|
|
|
129
175
|
}
|
|
130
176
|
|
|
131
177
|
const steps = stageData.steps
|
|
132
|
-
|
|
178
|
+
let currentIdx = steps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
|
|
179
|
+
// ── 坑1:--done --answer 解「已 waiting 的步骤」──
|
|
180
|
+
// findIndex 仅查 pending/in-progress,排除 waiting;故 --done --answer 落到已 --wait 暂停的
|
|
181
|
+
// requiresWait 步骤时会跳过它、把 --answer 静默丢弃,步骤永久卡 WAITING、末步报「等待用户输入」。
|
|
182
|
+
// 修复:带 doneAnswer 且存在 waiting 步骤时,把首个 waiting 拉回 pending + 补 waitAnswer,主流程
|
|
183
|
+
// requiresWait 门控见 waitAnswer 已置→不阻断→正常 completed。仅 --answer 触发,普通 --done 零变化。
|
|
184
|
+
const _resolvedWaitIdx = resolveWaitingStepWithAnswer(steps, options && options.doneAnswer, new Date().toLocaleString('zh-CN', { hour12: false }))
|
|
185
|
+
if (_resolvedWaitIdx !== -1) {
|
|
186
|
+
currentIdx = _resolvedWaitIdx
|
|
187
|
+
console.log(`⚠️ Step "${steps[_resolvedWaitIdx].name}" 此前处于 waiting,--done --answer 已补回答并拉回待完成。`)
|
|
188
|
+
}
|
|
133
189
|
if (currentIdx === -1) {
|
|
134
190
|
console.error('没有待完成的步骤')
|
|
135
191
|
process.exit(1)
|
|
@@ -264,6 +320,23 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
|
|
|
264
320
|
// quick 收尾(W6 Step6b 抽至 complete-handlers.js handleQuickStageCompletion)
|
|
265
321
|
await handleQuickStageCompletion({ stageName, steps, currentIdx, cwd, progress, changeName, specBase, outputText, confirm, isForceBaseline, isAllowNew, platformOpts })
|
|
266
322
|
|
|
323
|
+
// ── reopen --done 回填(坑 brainstorm-reopen-step-state-desync)──
|
|
324
|
+
// nextPendingIdx === -1 且无 waiting,说明要进阶段完成分支。此时若存在 stale 步骤
|
|
325
|
+
// (reopen --from-step N 把 N+1..end 置 stale,但方案未变、无需重跑),需同步回填为
|
|
326
|
+
// completed,否则进度数字与实际步骤明细矛盾(阶段已完成 + 6/8)。
|
|
327
|
+
// 安全边界:仅进入阶段完成分支时才回填(stale 本就被跳过、不回填也照常标 completed),
|
|
328
|
+
// 不改变单步推进行为;stale 若确需重跑,运行流(stage.js:141-148)会先于本分支转 pending 执行。
|
|
329
|
+
const staleSteps = steps.filter(s => s.status === 'stale')
|
|
330
|
+
if (staleSteps.length > 0) {
|
|
331
|
+
const nowStr = new Date().toLocaleString('zh-CN',{hour12:false})
|
|
332
|
+
for (const st of staleSteps) {
|
|
333
|
+
st.status = 'completed'
|
|
334
|
+
st.completedAt = st.completedAt || nowStr
|
|
335
|
+
}
|
|
336
|
+
await pm._write(cwd, progress, changeName)
|
|
337
|
+
console.log(` ⚠️ 同步回填 ${staleSteps.length} 个 stale 步骤为 completed(reopen --from-step N 后 --done,方案未变)`)
|
|
338
|
+
}
|
|
339
|
+
|
|
267
340
|
stageData.status = 'completed'
|
|
268
341
|
stageData.completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
|
|
269
342
|
progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
|
|
@@ -332,6 +405,22 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
|
|
|
332
405
|
} else if (stageName === 'verify') {
|
|
333
406
|
// verify 的"验证通过"提示延后到下方 validator 通过后才打印,
|
|
334
407
|
// 避免校验失败(FAIL / 缺 verify-result.md)时仍声称"验证通过可以归档"。
|
|
408
|
+
} else if (stageName === 'brainstorm') {
|
|
409
|
+
// brainstorm 下一步按 design.md 的 scale 分叉(与末步 prompt 的 large→plan / small→quick 对齐),
|
|
410
|
+
// 不走下方 _getNextSuggestion —— 后者按全局状态机「第一个未完成且上游就绪的阶段」推荐,
|
|
411
|
+
// 当 scan 未完成时会误推 scan(回头路),与 brainstorm 已完成、应进入 plan/quick 矛盾。
|
|
412
|
+
// 历史教训:曾因此让 agent 在 brainstorm 完成后被误导去跑 scan。
|
|
413
|
+
const _bscale = readDesignScale(specBase, changeName)
|
|
414
|
+
if (_bscale === 'small') {
|
|
415
|
+
console.log(`\n👉 brainstorm 已完成(small)。下一步:sillyspec run quick --linked-changes ${changeName}`)
|
|
416
|
+
} else {
|
|
417
|
+
console.log(`\n👉 brainstorm 已完成。下一步:sillyspec run plan${changeName ? ` --change ${changeName}` : ''}(scale=large 或未标 small 走完整 plan)`)
|
|
418
|
+
}
|
|
419
|
+
} else if (stageName === 'quick') {
|
|
420
|
+
// quick 是收尾阶段(辅助流程,不走主链 scan→archive),完成后该提交,而非推 scan。
|
|
421
|
+
// 走下方 _getNextSuggestion 会因 scan 是 STAGE_ORDER 首位、永未完成而误推 scan(回头路),
|
|
422
|
+
// 与 brainstorm 同类问题(见上注释),故给 quick 专属分支。
|
|
423
|
+
console.log('\n👉 quick 已完成。下一步:提交本次改动(git commit / sillyspec-commit),或 sillyspec run <stage> 继续其他阶段。')
|
|
335
424
|
} else {
|
|
336
425
|
// D1 暗衔接修:阶段刚完成,CLI 本就知道状态机下一步是哪个阶段(_getNextSuggestion),
|
|
337
426
|
// 却只丢一句「下一步由你决定」让 agent 自己猜命令。改为按 progress 实际状态给出精确命令。
|
|
@@ -738,7 +827,16 @@ export async function continueStep(pm, progress, stageName, cwd, answer, options
|
|
|
738
827
|
// 阶段完成后明确下一步(agent 常卡:stageData completed 但不知要 run <下一阶段> 推进 currentStage)
|
|
739
828
|
const nextStageHint = { brainstorm: 'plan', plan: 'execute', execute: 'verify', verify: 'archive' }[stageName]
|
|
740
829
|
if (nextStageHint) {
|
|
741
|
-
|
|
830
|
+
// brainstorm 按 design.md frontmatter 的 scale 分叉(与末步 prompt 对齐):
|
|
831
|
+
// small → quick --linked-changes(小变更免走完整 plan);large / 读不到 scale → plan(保守默认)。
|
|
832
|
+
// 修历史 bug:此处曾硬编码 brainstorm→plan 不读 scale,small 档与末步 prompt(quick)矛盾、误导小变更进 plan。
|
|
833
|
+
let hintStage = nextStageHint
|
|
834
|
+
let hintChangeFlag = changeName ? ` --change ${changeName}` : ''
|
|
835
|
+
if (stageName === 'brainstorm' && changeName && readDesignScale(specBase, changeName) === 'small') {
|
|
836
|
+
hintStage = 'quick'
|
|
837
|
+
hintChangeFlag = ` --linked-changes ${changeName}`
|
|
838
|
+
}
|
|
839
|
+
console.log(`\n👉 ${stageName} 已完成。下一步:sillyspec run ${hintStage}${hintChangeFlag}`)
|
|
742
840
|
if (stageName === 'execute') {
|
|
743
841
|
console.log(` ⚠️ 若 worktree 改动还没 apply 到主工作区,先:sillyspec worktree apply ${changeName}`)
|
|
744
842
|
console.log(` (apply 不需要先 commit,支持 working tree 未提交改动)`)
|
package/src/run/gates.js
CHANGED
|
@@ -204,6 +204,8 @@ export async function runStageCompletionGates({ stageName, cwd, changeName, plat
|
|
|
204
204
|
// 自报告 PASS 但实测失败 → 阻断(防止"文案通过"绕过验证)。
|
|
205
205
|
if (stageName === 'verify') {
|
|
206
206
|
const { runVerifyTestCheck, printVerifyTestCheck } = await import('../verify-postcheck.js')
|
|
207
|
+
// 测试实测是同步 execSync,长套件可跑 2~10min 且中途无输出——先预告避免 agent 误判卡死
|
|
208
|
+
console.log(`\n⏳ Verify 测试对账:CLI 亲自执行 local.yaml 的 commands.test(同步,耗时可能较长,请等待…)`)
|
|
207
209
|
const testCheck = runVerifyTestCheck({ cwd, specBase, changeName })
|
|
208
210
|
printVerifyTestCheck(testCheck)
|
|
209
211
|
if (testCheck.status === 'failed') {
|
package/src/run/shared.js
CHANGED
|
@@ -278,6 +278,11 @@ export function resolveChangeDir(cwd, progress, specDir = null) {
|
|
|
278
278
|
/**
|
|
279
279
|
* 触发 sync(平台模式走自己的链路,跳过;否则 await import sync.js)。
|
|
280
280
|
*/
|
|
281
|
+
// sync 总超时熔断:sync.js 是 best-effort 后台回传(每请求已有 10s 超时),但 sync() 可能串行多次
|
|
282
|
+
// fetchJson 累积等待、阻塞 --done。给 8s 总超时,超时放弃(best-effort,失败不影响正确性,下次 --done 重试)。
|
|
283
|
+
// 历史痛点:--done 在 sync 慢时体感 hang,用户被迫用外部 timeout 兜底。
|
|
284
|
+
const SYNC_TOTAL_TIMEOUT_MS = 8_000
|
|
285
|
+
|
|
281
286
|
export async function triggerSync(cwd, changeName, platformOpts = {}) {
|
|
282
287
|
// 平台模式(SillyHub)走自己的回传链路,不走 CLI 内置 sync
|
|
283
288
|
if (platformOpts?.specRoot || platformOpts?.runtimeRoot) return
|
|
@@ -285,7 +290,15 @@ export async function triggerSync(cwd, changeName, platformOpts = {}) {
|
|
|
285
290
|
if (changeName && !existsSync(join(cwd, '.sillyspec', 'changes', changeName))) return
|
|
286
291
|
// shared.js 在 src/run/,sync.js 在 src/ → 退一层
|
|
287
292
|
const syncMod = await import('../sync.js')
|
|
288
|
-
|
|
293
|
+
let timer
|
|
294
|
+
try {
|
|
295
|
+
await Promise.race([
|
|
296
|
+
syncMod.sync(changeName, cwd),
|
|
297
|
+
new Promise((resolve) => { timer = setTimeout(resolve, SYNC_TOTAL_TIMEOUT_MS) }),
|
|
298
|
+
])
|
|
299
|
+
} finally {
|
|
300
|
+
clearTimeout(timer) // sync 先完成则清掉未触发的 timer,避免泄漏
|
|
301
|
+
}
|
|
289
302
|
} catch (e) {
|
|
290
303
|
// sync.js 不存在或同步失败,静默跳过
|
|
291
304
|
console.warn('⚠️ 同步失败:', e.message)
|
package/src/run/stage.js
CHANGED
|
@@ -22,7 +22,7 @@ import { writeAtomicSync } from '../fs-atomic.js'
|
|
|
22
22
|
import { resolveSpecDir, resolveChangeDir, triggerSync, safeGit, parsePorcelainPath, formatWaitOptions, checkApproval, getStageSteps } from './shared.js'
|
|
23
23
|
import { computeScanProfile, applyScanProfileSteps, executeScanPreflight, executeScanPostcheck } from './scan-profile.js'
|
|
24
24
|
import { outputStep } from './prompt.js'
|
|
25
|
-
import { allocateQuicklogEntry } from '../quicklog.js'
|
|
25
|
+
import { allocateQuicklogEntry, deriveTitleFromLinkedChange } from '../quicklog.js'
|
|
26
26
|
import { checkTransition } from '../stage-contract.js'
|
|
27
27
|
|
|
28
28
|
export async function runStage(pm, progress, stageName, cwd, changeName, skipApproval = false, platformOpts = {}, quickOpts = {}) {
|
|
@@ -239,8 +239,14 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
|
|
|
239
239
|
const linkedChanges = Array.isArray(quickOpts?.linkedChanges) ? quickOpts.linkedChanges : []
|
|
240
240
|
// CLI 接管:分配 ql-ID + 写 QUICKLOG「进行中」条目 + 关联 tasks.md(持锁、当天唯一)
|
|
241
241
|
const gitUser = safeGit(cwd, ['config', 'user.name']).value || 'unknown'
|
|
242
|
+
// 标题回退:启动 quick 不带 --input 时,从关联变更的 proposal/design 提取语义标题,
|
|
243
|
+
// 避免 QUICKLOG/tasks.md 落 (quick 任务) 占位(deriveTitleFromLinkedChange 读不到则空→sanitizeDesc 回退占位)。
|
|
244
|
+
let quickDesc = quickOpts?.taskDescription || ''
|
|
245
|
+
if (!quickDesc && linkedChanges.length > 0) {
|
|
246
|
+
quickDesc = deriveTitleFromLinkedChange(specBase, linkedChanges[0])
|
|
247
|
+
}
|
|
242
248
|
const { qlId } = await allocateQuicklogEntry(specBase, gitUser, {
|
|
243
|
-
description:
|
|
249
|
+
description: quickDesc,
|
|
244
250
|
linkedChanges,
|
|
245
251
|
allowedFiles,
|
|
246
252
|
})
|
package/src/stage-review.js
CHANGED
|
@@ -41,7 +41,7 @@ const STAGE_REVIEW_TYPE = { brainstorm: 'design', plan: 'plan', propose: 'propos
|
|
|
41
41
|
*/
|
|
42
42
|
export function renderReviewJsonContract({ stage, changeDir, reviewRunId, tier } = {}) {
|
|
43
43
|
if (tier === 'self') {
|
|
44
|
-
return '> review.json 契约:tier=self(
|
|
44
|
+
return '> review.json 契约:tier=self(此刻 design.md 变更文件数 ≤3,当前 agent 自审即可)。⚠️ 注意:本判定基于此刻 design.md 快照——Stage Review Gate 会以 --done 时刻的 design.md 重新判定,若 design 后续扩大到 >3 文件,tier 将升级为 independent 并硬要求独立审查子代理产出 review.json。以 gate 实际校验结果为准(FAILED 即 independent 需补 review.json),勿据本提示提前断言无需 review.json。'
|
|
45
45
|
}
|
|
46
46
|
const reviewType = STAGE_REVIEW_TYPE[stage] || 'design'
|
|
47
47
|
const mainDoc = STAGE_MAIN_DOC[stage] || 'design.md'
|
|
@@ -270,7 +270,13 @@ export function getLatestStageReviewRunId(runtimeRoot, stage, changeName) {
|
|
|
270
270
|
const markerPath = stageReviewMarkerPath(runtimeRoot, stage, changeName)
|
|
271
271
|
if (existsSync(markerPath)) {
|
|
272
272
|
const content = readFileSync(markerPath, 'utf8').trim()
|
|
273
|
-
|
|
273
|
+
// marker 内容格式校验(execute 复盘 b2):必须 review- 前缀(generateStageReviewRunId 格式)。
|
|
274
|
+
// agent 手写 marker 可能误填 execute 的 exec- 前缀 → 按 stage-reviews/<stage>-<runId> 拼目录必找不到,
|
|
275
|
+
// 与其读坏 ID 报误导错误,不如显式 warn 并退回目录扫描。
|
|
276
|
+
if (content) {
|
|
277
|
+
if (/^review-/.test(content)) return content
|
|
278
|
+
console.warn(`⚠️ stage-review marker ${markerPath} 内容 "${content.slice(0, 40)}" 不是 review- 前缀(应为 generateStageReviewRunId 格式,勿填 execute 的 exec- ID),忽略并退回目录扫描`)
|
|
279
|
+
}
|
|
274
280
|
}
|
|
275
281
|
} catch {}
|
|
276
282
|
|
|
@@ -284,6 +290,21 @@ export function getLatestStageReviewRunId(runtimeRoot, stage, changeName) {
|
|
|
284
290
|
.sort()
|
|
285
291
|
.reverse()
|
|
286
292
|
if (!entries[0]) return null
|
|
293
|
+
// cross-change 防护(execute 复盘 b1):changeName 提供时按 review.json 的 reviewedFiles[0] 归属过滤。
|
|
294
|
+
// 契约 reviewedFiles[0] = `changes/<change>/<mainDoc>`(renderReviewJsonContract),据此排除他变更的 review,
|
|
295
|
+
// 否则批量完成 marker 缺失时可能读到 proxy 等其他变更的 acceptance review 报错误导。
|
|
296
|
+
if (changeName) {
|
|
297
|
+
const matching = entries.find(e => {
|
|
298
|
+
try {
|
|
299
|
+
const rj = JSON.parse(readFileSync(join(dir, e, 'review.json'), 'utf8'))
|
|
300
|
+
const r0 = Array.isArray(rj.reviewedFiles) ? String(rj.reviewedFiles[0]) : ''
|
|
301
|
+
return r0.includes(`changes/${changeName}/`)
|
|
302
|
+
} catch { return false }
|
|
303
|
+
})
|
|
304
|
+
if (matching) return matching.slice(stage.length + 1)
|
|
305
|
+
console.warn(`⚠️ 无 ${stage} 的 review marker,且 stage-reviews/ 下无归属变更 ${changeName} 的 review(reviewedFiles 应含 changes/${changeName}/);为避免跨变更串台不再取最新,返回 null(fail-closed,由调用方报缺 review.json)`)
|
|
306
|
+
return null
|
|
307
|
+
}
|
|
287
308
|
// entries[0] = "plan-review-2026-...",剥掉 `${stage}-` 前缀得到 runId "review-2026-..."
|
|
288
309
|
return entries[0].slice(stage.length + 1)
|
|
289
310
|
} catch {
|
package/src/stages/archive.js
CHANGED
|
@@ -59,6 +59,9 @@ module-impact.md 第一行标题必须用中文:# 模块影响分析(Module
|
|
|
59
59
|
},
|
|
60
60
|
{
|
|
61
61
|
name: 'sync-module-docs',
|
|
62
|
+
// 坑 verify-archive-flow-pitfalls 坑4:缺 requiresWait 时 --continue --answer 会直接标 completed、
|
|
63
|
+
// agent 无机会按 module-impact.md 写入模块卡片。加 requiresWait 使 --continue 确认后回到本步执行写入
|
|
64
|
+
requiresWait: true,
|
|
62
65
|
prompt: `根据 module-impact.md 同步更新模块索引和卡片文档。
|
|
63
66
|
|
|
64
67
|
### ⚠️ 核心原则:结构化事实改 _module-map.yaml,语义解释改模块卡片
|
package/src/stages/execute.js
CHANGED
|
@@ -596,6 +596,13 @@ ${taskSummary}
|
|
|
596
596
|
- 🔥 热上下文:design.md 非目标/兼容策略 + 当前 Wave 任务(必须加载)
|
|
597
597
|
- 🌡️ 温上下文:CONVENTIONS.md + ARCHITECTURE.md(需要时加载)
|
|
598
598
|
- ❄️ 冷上下文:其他变更的 design.md、历史 plan.md(不要主动加载,除非明确需要)
|
|
599
|
+
|
|
600
|
+
### 中断续跑(如曾中断恢复)
|
|
601
|
+
execute 按 Wave 持久化进度,task 级进度靠 plan.md checkbox 勾选。若本 Wave 曾因 429/API 配额/崩溃中断:
|
|
602
|
+
- plan.md 中**已勾选 \`- [x]\` 的 task 已完成,跳过不重跑**(子代理也可能在完成前中断,重跑前先确认该 task 产出文件是否完整)
|
|
603
|
+
- 用 \`sillyspec status\` 查当前进度,重新 \`sillyspec run execute\` 会回到当前 Wave step 继续,**不要从零重置或重跑已完成 Wave**
|
|
604
|
+
- 本 Wave 已完成但不完整(产出缺文件)的 task 补做,不牵连其他 task
|
|
605
|
+
|
|
599
606
|
${contractInjection}${prototypeInjection}
|
|
600
607
|
### 本 Wave 任务
|
|
601
608
|
${taskList}
|
|
@@ -532,6 +532,11 @@ export function validatePlanFeasibility(changeDir, projectRoot = null) {
|
|
|
532
532
|
|
|
533
533
|
if (!taskId) errors.push(fsRule.data.messageId.replaceAll('${id}', file))
|
|
534
534
|
if (!title) errors.push(fsRule.data.messageTitle.replaceAll('${id}', file))
|
|
535
|
+
// title_zh(中文标题)完整性:子代理常为压 plan.js 的 20~40 行硬上限合并 title/title_zh(静默丢失),
|
|
536
|
+
// postcheck 不校验行数(那是 prompt 劝说)但强制 frontmatter 字段齐全(enforcement 优于放宽劝说)。
|
|
537
|
+
// 无对应 manifest 文案,内联(其余字段文案走 fsRule.data.messageXxx)。prompt-control-debt plan-b。
|
|
538
|
+
const titleZh = (fm.match(/^title_zh:\s*(.+)/m)?.[1] || '').trim()
|
|
539
|
+
if (!titleZh) errors.push(`${taskId || file}: frontmatter 缺少 title_zh(中文标题)——不得为压行数合并/删除字段`)
|
|
535
540
|
if (taskId) allTaskIds.push(taskId)
|
|
536
541
|
|
|
537
542
|
// 2. allowed_paths 不为空
|
package/src/stages/plan.js
CHANGED
package/src/stages/verify.js
CHANGED
|
@@ -38,7 +38,7 @@ export const definition = {
|
|
|
38
38
|
{
|
|
39
39
|
name: '进度确认',
|
|
40
40
|
migratedFrom: ['状态检查'],
|
|
41
|
-
prompt: `> 💡 先说清楚:\`sillyspec run verify\`(不带 --done
|
|
41
|
+
prompt: `> 💡 先说清楚:\`sillyspec run verify\`(不带 --done)**只下发执行指令,不会替你跑测试/构建**——真正的测试由 CLI 在最后 --done 时统一执行(local.yaml 的 commands.test,同步对账可能耗时较长);「运行测试和质量扫描」那步**不需要你重复手动跑全量**(避免与 CLI 对账重复耗时),只做 lint/静态检查 + 可选快速冒烟。别以为敲了 run verify 就自动验证了。
|
|
42
42
|
|
|
43
43
|
检查当前进度,确认可以执行 verify。
|
|
44
44
|
|
|
@@ -156,28 +156,25 @@ export const definition = {
|
|
|
156
156
|
},
|
|
157
157
|
{
|
|
158
158
|
name: '运行测试和质量扫描',
|
|
159
|
-
prompt:
|
|
159
|
+
prompt: `运行代码质量扫描(测试实测统一由 CLI 对账执行,本步不重复手动跑全量)。
|
|
160
160
|
|
|
161
161
|
### 操作
|
|
162
|
-
1. 读取 \`.sillyspec/local.yaml\`
|
|
163
|
-
2.
|
|
164
|
-
3. 如果 local.yaml 无 test 命令,根据项目类型选择:
|
|
162
|
+
1. 读取 \`.sillyspec/local.yaml\` 获取构建、测试和 lint 命令
|
|
163
|
+
2. **不要手动重复跑 commands.test**——CLI 会在最终 --done 时统一执行一次(按变更命中模块子集),本步再跑 = 与 CLI 对账重复耗时(实测 198s×2)。如为提前发现实现问题,可对变更模块做**针对性快速冒烟**(可选,非必需):
|
|
165
164
|
- Maven:\`mvn test -pl <变更模块> -am\`(仅编译变更模块及其依赖)
|
|
166
165
|
- Gradle:\`./gradlew :<模块>:test\`
|
|
167
166
|
- npm/pnpm:\`pnpm test --filter=<包名>\` 或 \`npm test -- --testPathPattern=<相关文件>\`
|
|
168
167
|
- Python:\`pytest <变更模块路径>/\`
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
6. 如果 local.yaml 有 lint 命令,运行 lint 检查
|
|
168
|
+
3. 如果 local.yaml 有 lint 命令,运行 lint 检查
|
|
169
|
+
4. 搜索技术债务:grep TODO/FIXME/HACK/XXX(仅限变更文件)
|
|
172
170
|
|
|
173
171
|
### 注意
|
|
174
|
-
-
|
|
175
|
-
-
|
|
176
|
-
- **CLI 对账机制**:verify 阶段最终 --done 时,CLI 会亲自执行 local.yaml 的 commands.test 并与你的报告对账;实测失败会直接阻断 verify 完成,谎报测试结果没有意义
|
|
172
|
+
- **CLI 对账机制**:verify 阶段最终 --done 时,CLI 会亲自执行 local.yaml 的 commands.test(同步,耗时可能较长);实测失败会直接阻断 verify 完成,谎报测试结果没有意义
|
|
173
|
+
- 冒烟测试非必需:全量/模块实测结果以 CLI 对账为准,本步跑的结果仅供你提前发现问题并写入验证报告
|
|
177
174
|
|
|
178
175
|
### 输出
|
|
179
|
-
|
|
180
|
-
outputHint: '
|
|
176
|
+
质量扫描结果 + 技术债务标记`,
|
|
177
|
+
outputHint: '质量扫描结果 + 技术债务',
|
|
181
178
|
optional: false
|
|
182
179
|
},
|
|
183
180
|
{
|
package/src/task-review.js
CHANGED
|
@@ -460,6 +460,21 @@ function runGit(gitDir, args) {
|
|
|
460
460
|
}).trim()
|
|
461
461
|
}
|
|
462
462
|
|
|
463
|
+
// porcelain 状态行 → 变更文件路径(对齐 shared.js:343 parsePorcelainPath:`XY path`、引号包裹、
|
|
464
|
+
// rename `R old -> new` 取箭头后)。未 commit 的 working-tree 改动对账用。
|
|
465
|
+
function parsePorcelainFiles(statusOut) {
|
|
466
|
+
const files = []
|
|
467
|
+
for (const line of String(statusOut).split('\n')) {
|
|
468
|
+
if (line.trim() === '') continue
|
|
469
|
+
let p = line.slice(3).trim()
|
|
470
|
+
if (p.length >= 2 && p.startsWith('"') && p.endsWith('"')) p = p.slice(1, -1).replace(/\\(.)/g, (_, c) => c)
|
|
471
|
+
const arrow = p.indexOf(' -> ')
|
|
472
|
+
if (arrow !== -1) p = p.slice(arrow + 4)
|
|
473
|
+
if (p) files.push(p.replace(/\\/g, '/'))
|
|
474
|
+
}
|
|
475
|
+
return files
|
|
476
|
+
}
|
|
477
|
+
|
|
463
478
|
/**
|
|
464
479
|
* 校验 review.json 的 base/head 是否指向真实 git 提交,且 diff 非空。
|
|
465
480
|
*
|
|
@@ -514,23 +529,27 @@ export function verifyReviewGitEvidence(review, gitDir) {
|
|
|
514
529
|
return { ok: true, emptyDiff: false, errors, warnings, unavailable: false }
|
|
515
530
|
}
|
|
516
531
|
|
|
517
|
-
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
532
|
+
// 并入 working-tree 未提交改动(execute 复盘 a):子代理默认不 commit,base..head 无 commit diff 时
|
|
533
|
+
// diffFiles 留空 → 下方交叉比对拿空集对非空 changedFiles 必判「完全不相交」伪造,逼 agent 强制 commit
|
|
534
|
+
// + 改 7 个 review head。对齐 checkExecuteCodeEvidence(stage-contract.js 同时查 working-tree)语义,
|
|
535
|
+
// 未提交改动也算有效对账源。working-tree 有改动时无条件并入(覆盖「部分 commit + 未提交」)。
|
|
536
|
+
const commitDiffFiles = diffFiles.slice()
|
|
537
|
+
try {
|
|
538
|
+
const wtStatus = runGit(gitDir, ['status', '--porcelain'])
|
|
539
|
+
if (wtStatus && wtStatus.trim().length > 0) {
|
|
540
|
+
const wtFiles = parsePorcelainFiles(wtStatus)
|
|
541
|
+
if (wtFiles.length > 0) diffFiles = diffFiles.concat(wtFiles)
|
|
542
|
+
if (commitDiffFiles.length === 0) {
|
|
543
|
+
warnings.push(`base..head 无 commit diff(${review.base.slice(0, 8)}..${review.head.slice(0, 8)}),交叉对账并入 working-tree 未提交改动 ${wtFiles.length} 个文件 —— 按有效改动处理,不判零改动伪造`)
|
|
544
|
+
}
|
|
528
545
|
}
|
|
529
|
-
}
|
|
530
|
-
|
|
546
|
+
} catch {}
|
|
547
|
+
diffFiles = [...new Set(diffFiles)].filter(Boolean)
|
|
548
|
+
|
|
549
|
+
const emptyDiff = diffFiles.length === 0
|
|
531
550
|
|
|
532
|
-
// changedFiles 交叉比对:完全不相交 = review 描述的改动与实际 diff
|
|
533
|
-
if (!
|
|
551
|
+
// changedFiles 交叉比对:完全不相交 = review 描述的改动与实际 diff 无关(diffFiles 已含 working-tree)
|
|
552
|
+
if (!emptyDiff && Array.isArray(review.changedFiles) && review.changedFiles.length > 0) {
|
|
534
553
|
const normalize = (p) => String(p).replace(/^\.\//, '')
|
|
535
554
|
const diffSet = new Set(diffFiles.map(normalize))
|
|
536
555
|
const hasOverlap = review.changedFiles.some(f => {
|
|
@@ -543,7 +562,7 @@ export function verifyReviewGitEvidence(review, gitDir) {
|
|
|
543
562
|
}
|
|
544
563
|
}
|
|
545
564
|
|
|
546
|
-
return { ok: errors.length === 0, emptyDiff
|
|
565
|
+
return { ok: errors.length === 0, emptyDiff, errors, warnings, unavailable: false }
|
|
547
566
|
}
|
|
548
567
|
|
|
549
568
|
/**
|
package/src/worktree-apply.js
CHANGED
|
@@ -94,6 +94,29 @@ function getBlobHashMap(cwd, treeish, files) {
|
|
|
94
94
|
return map;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* apply 的文件清单 = design §6 清单 ∪ plan TaskCard allowed_paths(execute 复盘 c)。
|
|
99
|
+
*
|
|
100
|
+
* design §6 常只列源码、漏测试/产物文件,而 task allowed_paths(plan 阶段产出)已含——apply 若只认
|
|
101
|
+
* design 清单会在测试/产物文件上误拦(assess 用 task allowed_paths 已放行、apply 用 design 清单又拦,
|
|
102
|
+
* 两 gate 口径不一致)。union 后两源并集为准;plan 已过 validateDesignFileCoverage 单向校验(design ⊆
|
|
103
|
+
* plan),union 不会放开 design/plan 之外的任意文件(仍拦完全越界文件)。
|
|
104
|
+
*
|
|
105
|
+
* @param {string} projectRoot - 主仓库根
|
|
106
|
+
* @param {string} changeName - 变更名
|
|
107
|
+
* @returns {Set<string>} 并集清单(无 design 清单且无 task 卡片时为空集)
|
|
108
|
+
*/
|
|
109
|
+
export function resolveApplyAllowSet(projectRoot, changeName) {
|
|
110
|
+
const allowSet = parseFileChangeList(join(projectRoot, CHANGES_REL, changeName, 'design.md'));
|
|
111
|
+
const tasksDir = join(projectRoot, CHANGES_REL, changeName, 'tasks');
|
|
112
|
+
if (existsSync(tasksDir)) {
|
|
113
|
+
for (const tf of readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f))) {
|
|
114
|
+
for (const p of parseAllowedPaths(readFileSync(join(tasksDir, tf), 'utf8'))) allowSet.add(p);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return allowSet;
|
|
118
|
+
}
|
|
119
|
+
|
|
97
120
|
/**
|
|
98
121
|
* apply worktree 变更到主工作区
|
|
99
122
|
*
|
|
@@ -178,9 +201,8 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
178
201
|
return result;
|
|
179
202
|
}
|
|
180
203
|
|
|
181
|
-
// --- 3. 解析 design
|
|
182
|
-
const
|
|
183
|
-
const allowSet = parseFileChangeList(designPath);
|
|
204
|
+
// --- 3. 解析 apply 文件清单(design §6 清单 ∪ plan TaskCard allowed_paths,execute 复盘 c) ---
|
|
205
|
+
const allowSet = resolveApplyAllowSet(projectRoot, changeName);
|
|
184
206
|
const hasAllowList = allowSet.size > 0;
|
|
185
207
|
|
|
186
208
|
// --- 4. 校验:变更文件 ⊆ 清单(无清单则跳过)---
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Claude Code 指引
|
|
2
|
+
|
|
3
|
+
## 项目说明
|
|
4
|
+
本项目使用 **SillySpec** 管理变更,采用文档驱动开发。SillySpec 是给 Agent 调用的 CLI 流程控制器,不是给人类直接使用的产品,也不是处理业务逻辑的工具——你(Agent)通过 CLI 告诉它"我在哪",它告诉你"下一步做什么";你执行步骤,它校验产出、推进状态,人类只在关键决策点介入审批。要考虑多 agent 同时操作代码,代码随时可能变化。所有变更以稳定、可用、可维护为目标,按生产级标准处理。
|
|
5
|
+
|
|
6
|
+
## 核心规则
|
|
7
|
+
1. **禁止绕过本文件规则和 SillySpec 流程**。所有变更走 sillyspec 流程,不裸改裸提交。
|
|
8
|
+
2. **改代码前必须先说明依据**——依据的文档路径(design.md / 模块文档 / file-lifecycle.md)或现有代码依据,无依据不改。
|
|
9
|
+
3. **新功能 / 大改动走完整流程**:`brainstorm → plan → execute → verify → archive`。
|
|
10
|
+
4. **小修复 / 小调整走 quick**:`sillyspec run quick`。
|
|
11
|
+
5. **执行顺序**:文档 → 读代码 → 写测试 → 写实现 → 跑测试 → 验收 → 更新文档。
|
|
12
|
+
6. **判规模选档**:≤3 文件、范围明确走 `quick`;多阶段 / 架构级走完整流程。
|
|
13
|
+
7. **代码先行不补流程(倒推 B 模式)**:代码若已先写好,**不回头补 brainstorm/plan 装样子**——用 `quick --done` 收尾 + 补 quicklog 条目,把已落盘改动如实登记进进度库。
|
|
14
|
+
8. **实证核验再 `--done`**:`quick --done` 前,触及 `src`/`test` 的改动先 `npm test` + `npm run lint`;纯 doc/配置改动可跳过(lint 不扫 docs/,空转无信息),以落盘文件与测试结果为准,不信口头"已完成"。
|
|
15
|
+
9. **中途停下用 `sillyspec status` / `sillyspec resume` 存进度**,不直接 commit 半成品。
|
|
16
|
+
10. **实现完成后对照文档验收**(design.md / 模块文档),并检查是否影响已有测试。
|
|
17
|
+
11. **非测试逻辑本身有误时,禁止改测试来"通过"**——修逻辑,不修测试。
|
|
18
|
+
12. **hook 拦截提交时禁止跳过**(`.husky/pre-push`),修复问题后再提交。
|
|
19
|
+
13. **代码必须兼容 Windows / Linux / macOS**(路径 / 换行 / 并发都要顾)。
|
|
20
|
+
14. **任务记录隔离**:永不重置 / reset / 清零已存在的 change;多个活跃 change 各自 `--change <名>` 隔离不重叠;quick 同一 QUICKLOG 按 ql-ID 条目追加,不冲突。
|
|
21
|
+
15. **quicklog 手动精修**:CLI 只写骨架,`--done` 后手动补语义化标题 / 文件多行带括注 / 结果四段。
|
|
22
|
+
16. **代码可能随时在修改**(多 agent 并行),Edit 前重跑 + 查最新态;破坏性 git op 前先备份。
|
|
23
|
+
17. **不奉承用户**,禁止"你说得对"类话术,直接给结论、依据、方案。
|
|
@@ -20,6 +20,7 @@ grep -rl "<关键词>" <源码目录>/ --include="*.java" --include="*.js" --inc
|
|
|
20
20
|
1. 读取变更目录下的 tasks.md,提取所有 checkbox 任务
|
|
21
21
|
2. 对每个 task,**递归**检查对应模块目录及其**所有子目录**下是否存在测试文件(*test*、*spec*、*Test*、*Spec*)——包括模块内 co-located 的 tests/ 目录(如 `backend/app/modules/*/tests/`),**不要只看顶层 `tests/`**,否则会把测试就在模块内部的项目误判为“无测试”
|
|
22
22
|
3. 没有测试文件的 task 标记为 ⚠️ 缺少测试
|
|
23
|
+
4. **集成盲区提示**:测试文件存在 ≠ 集成正确——路由/layout 守卫重定向、跨模块装配这类集成 bug 组件单测覆盖不到,只有集成/冒烟/E2E 才暴露。对路由/layout/跨进程装配敏感的 task,额外检查是否有集成冒烟覆盖;无则标 ⚠️ 集成层未验证(需人工或部署确认)
|
|
23
24
|
|
|
24
25
|
**探针 4:决策追踪覆盖探针(如存在 decisions.md)**
|
|
25
26
|
1. 从 decisions.md 提取所有当前版本 D-xxx@vN
|
package/CLAUDE.md
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
# Claude Code 指引
|
|
2
|
-
|
|
3
|
-
# SillySpec工具定位
|
|
4
|
-
1. 是给Agent调用的CLI工具,非人类直接使用
|
|
5
|
-
2. 管理Agent工作流的CLI工具,非业务工具
|
|
6
|
-
|
|
7
|
-
## 文件生命周期文档同步
|
|
8
|
-
每次修改 `src/stages/` 下的阶段定义(prompt、步骤、输出文件名等)或 `src/run.js`、`src/progress.js` 等影响文件生命周期的代码后,**必须同步更新** `docs/sillyspec/file-lifecycle.md`,确保文档与代码一致。
|
|
9
|
-
|
|
10
|
-
### 触发更新的典型改动
|
|
11
|
-
- 新增/删除/重命名阶段步骤
|
|
12
|
-
- 修改步骤 prompt 中的输出文件名(如 verify-result.md)
|
|
13
|
-
- 修改阶段间的流转逻辑(如 archive 归档方式)
|
|
14
|
-
- 新增/删除运行时文件类型(如 gate-status.json)
|
|
15
|
-
- 修改 ProgressManager 的数据存储方式(如 SQLite 表结构变更)
|
|
16
|
-
|
|
17
|
-
### 更新检查清单
|
|
18
|
-
- [ ] 文件名引用一致(prompt 输出的文件名 == validateFileLocations 期望的文件名)
|
|
19
|
-
- [ ] 阶段步骤描述与 `src/stages/*.js` 一致
|
|
20
|
-
- [ ] 归档/清理流程描述与实际代码逻辑一致
|
|
21
|
-
- [ ] 数据库 Schema 描述与 `src/db.js` 一致
|
|
22
|
-
- [ ] 更新文档头部 `updated_at` 时间戳
|
|
23
|
-
|
|
24
|
-
## 提示词文档同步(docs/prompt/)
|
|
25
|
-
`docs/prompt/` 收录各阶段 CLI→Agent 的**逐步提示词原文**(人类可读 md 参考)。详见 `docs/prompt/README.md`。
|
|
26
|
-
|
|
27
|
-
### 数据源关系(重要 — 避免双写漂移)
|
|
28
|
-
- **唯一数据源 = `src/stages/*.js`**(`definition.steps[].prompt`,CLI 运行时读这里)+ `src/run/prompt.js`(注入框架:persona / 铁律 / 占位符替换 / 完成后执行命令模板)。
|
|
29
|
-
- `docs/prompt/*.md` 是源码的**机械提取人类可读镜像**,由 `node docs/prompt/_extract.mjs` 一键再生(源码 → `_extracted.json` → md)。提取脚本对静态阶段直接读 `definition.steps`,对动态阶段(plan/execute)用示例输入跑 `buildPlanSteps`/`buildExecuteSteps` 得到真实 prompt。
|
|
30
|
-
- **禁止手改 md 里的 prompt 原文**(下次提取会被覆盖,且与源码不一致)。要改提示词,改源码后重跑脚本。
|
|
31
|
-
|
|
32
|
-
### 触发同步的改动
|
|
33
|
-
- 修改 `src/stages/*.js`:`definition.steps[].prompt`、步骤名、步骤增删/重排、wait 配置、`_globalGuardrails`、title/description
|
|
34
|
-
- 修改 `src/run/prompt.js`:persona / 铁律 / 占位符替换 / 完成后执行命令模板
|
|
35
|
-
- 修改 `stage-review.js` / `review-tier.js` / `knowledge-match.js`:影响 `{REVIEW_JSON_CONTRACT}` / `{REVIEW_TIER}` / `{KNOWLEDGE_HIT_REPORT}` 等动态块
|
|
36
|
-
|
|
37
|
-
### 同步步骤
|
|
38
|
-
1. 改源码
|
|
39
|
-
2. 运行 `node docs/prompt/_extract.mjs`(刷新 `docs/prompt/_extracted.json`)
|
|
40
|
-
3. 按变动同步对应 `docs/prompt/<stage>.md`(prompt 正文以 `_extracted.json` 为准逐字替换)+ 必要时更新 `docs/prompt/README.md`(占位符总表 / persona 表 / CLI 注入框架)
|