sillyspec 3.25.7 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sillyspec",
3
- "version": "3.25.7",
3
+ "version": "3.25.8",
4
4
  "description": "SillySpec CLI — 流程状态机,让 AI 严格按步骤来",
5
5
  "icon": "logo.jpg",
6
6
  "homepage": "https://sillyspec.ppdmq.top/",
@@ -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
- // Manual a: completed stage 里有 pending/stale/in-progress steps
197
- if (sd.status === 'completed' && sd.steps) {
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 = "pending"',
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
@@ -248,6 +248,42 @@ async function checkTaskCheckbox(specBase, change, qlId) {
248
248
  // 可能是 CRLF,split('\n') 后每行带 \r。原代码 `lines[i] === '状态:进行中'` 精确匹配
249
249
  // 恒失败 → 走 splice「兜底插入」→ 条目内同时出现「状态:已完成」+「状态:进行中」。
250
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
+
251
287
  function flipEntryInContent(content, qlId, result, changedFiles = []) {
252
288
  const lines = content.split('\n')
253
289
  const startIdx = lines.findIndex(l => l.startsWith(`## ${qlId} |`))
@@ -280,13 +316,25 @@ function flipEntryInContent(content, qlId, result, changedFiles = []) {
280
316
  endIdx += 1
281
317
  }
282
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
+ }
283
331
  // 多行结果(结构化 需求/根因/方案/结果 字段块):逐行插入为独立字段行。
284
332
  // 单行结果:保持「结果:<一句话>」一行,向后兼容简单用例。
285
- if (/\r?\n/.test(result)) {
286
- const resultLines = result.split(/\r?\n/).filter(l => l.trim() !== '')
333
+ if (/\r?\n/.test(body)) {
334
+ const resultLines = body.split(/\r?\n/).filter(l => l.trim() !== '')
287
335
  lines.splice(endIdx, 0, ...resultLines)
288
336
  } else {
289
- lines.splice(endIdx, 0, `结果:${result}`)
337
+ lines.splice(endIdx, 0, `结果:${body}`)
290
338
  }
291
339
  }
292
340
  return lines.join('\n')
@@ -320,6 +320,23 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
320
320
  // quick 收尾(W6 Step6b 抽至 complete-handlers.js handleQuickStageCompletion)
321
321
  await handleQuickStageCompletion({ stageName, steps, currentIdx, cwd, progress, changeName, specBase, outputText, confirm, isForceBaseline, isAllowNew, platformOpts })
322
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
+
323
340
  stageData.status = 'completed'
324
341
  stageData.completedAt = new Date().toLocaleString('zh-CN',{hour12:false})
325
342
  progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
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') {
@@ -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
- if (content) return content
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 {
@@ -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,语义解释改模块卡片
@@ -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 不为空
@@ -241,6 +241,7 @@ task-01 → task-03(最长路径,决定最短交付周期)
241
241
 
242
242
  ## 全局验收标准
243
243
  - [ ] 所有单元测试通过
244
+ - [ ] 集成敏感 task(路由/layout/跨进程装配)建议加集成冒烟验收——组件单测全绿 ≠ 集成正确
244
245
  - [ ] (brownfield)未配置新功能时行为不变
245
246
 
246
247
  ## 覆盖矩阵(如存在 decisions.md)
@@ -38,7 +38,7 @@ export const definition = {
38
38
  {
39
39
  name: '进度确认',
40
40
  migratedFrom: ['状态检查'],
41
- prompt: `> 💡 先说清楚:\`sillyspec run verify\`(不带 --done)**只下发执行指令,不会替你跑测试/构建**——真正的测试由你(Agent)在「运行测试和质量扫描」那步自己执行;CLI 只在最后 --done 时亲自跑 local.yaml 的 test 对账。别以为敲了 run verify 就自动验证了。
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. 如果 local.yaml 有 test 命令,使用它(仅测试变更涉及的模块,非全量)
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
- 4. 记录通过/失败数量,分析失败原因
170
- 5. 搜索技术债务:grep TODO/FIXME/HACK/XXX(仅限变更文件)
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
- - 如果变更模块不确定,优先使用 local.yaml 中的命令
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
  {
@@ -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
- const emptyDiff = diffFiles.length === 0
518
- // emptyDiff 回退:子代理可能未 commit,base..head 无 commit diff 但 working-tree 有改动。
519
- // 对齐 checkExecuteCodeEvidence(stage-contract.js 同时查 working-tree)语义,避免误判零改动伪造。
520
- let workingTreeChanged = false
521
- if (emptyDiff) {
522
- try {
523
- const wtStatus = runGit(gitDir, ['status', '--porcelain'])
524
- workingTreeChanged = !!wtStatus && wtStatus.trim().length > 0
525
- } catch {}
526
- if (workingTreeChanged) {
527
- warnings.push(`base..head 无 commit diff(${review.base.slice(0, 8)}..${review.head.slice(0, 8)}),但 working-tree 有未提交改动 —— 按有效改动处理,不判零改动伪造`)
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
- const effectiveEmptyDiff = emptyDiff && !workingTreeChanged
546
+ } catch {}
547
+ diffFiles = [...new Set(diffFiles)].filter(Boolean)
548
+
549
+ const emptyDiff = diffFiles.length === 0
531
550
 
532
- // changedFiles 交叉比对:完全不相交 = review 描述的改动与实际 diff 无关
533
- if (!effectiveEmptyDiff && Array.isArray(review.changedFiles) && review.changedFiles.length > 0) {
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: effectiveEmptyDiff, errors, warnings, unavailable: false }
565
+ return { ok: errors.length === 0, emptyDiff, errors, warnings, unavailable: false }
547
566
  }
548
567
 
549
568
  /**
@@ -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.md 文件变更清单 ---
182
- const designPath = join(projectRoot, CHANGES_REL, changeName, 'design.md');
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. 校验:变更文件 ⊆ 清单(无清单则跳过)---
@@ -11,7 +11,7 @@
11
11
  5. **执行顺序**:文档 → 读代码 → 写测试 → 写实现 → 跑测试 → 验收 → 更新文档。
12
12
  6. **判规模选档**:≤3 文件、范围明确走 `quick`;多阶段 / 架构级走完整流程。
13
13
  7. **代码先行不补流程(倒推 B 模式)**:代码若已先写好,**不回头补 brainstorm/plan 装样子**——用 `quick --done` 收尾 + 补 quicklog 条目,把已落盘改动如实登记进进度库。
14
- 8. **实证核验再 `--done`**:`quick --done` 前先 `npm test` + `npm run lint`,以落盘文件与测试结果为准,不信口头"已完成"。
14
+ 8. **实证核验再 `--done`**:`quick --done` 前,触及 `src`/`test` 的改动先 `npm test` + `npm run lint`;纯 doc/配置改动可跳过(lint 不扫 docs/,空转无信息),以落盘文件与测试结果为准,不信口头"已完成"。
15
15
  9. **中途停下用 `sillyspec status` / `sillyspec resume` 存进度**,不直接 commit 半成品。
16
16
  10. **实现完成后对照文档验收**(design.md / 模块文档),并检查是否影响已有测试。
17
17
  11. **非测试逻辑本身有误时,禁止改测试来"通过"**——修逻辑,不修测试。
@@ -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