sillyspec 3.27.1 → 3.27.2

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.
@@ -18,6 +18,22 @@ export function normalizePath(raw) {
18
18
  .trim()
19
19
  }
20
20
 
21
+ /**
22
+ * 组合路径单元格拆分(坑 design-combined-cell-mismatch,2026-08-22 实证:"router.py + service.py"
23
+ * 一行两文件整体当单路径,字面匹配对账不过)。拆分符:+ 、 / | ; ;——每 token 独立校验
24
+ * (looksLikePath 不过的丢弃,防自由文本混入);无分隔符返回单元素数组(零变化)。
25
+ * 不含空白要求("a.py+b.py" 与 "a.py + b.py" 都拆)。
26
+ * @param {string} cell 路径单元格(normalizePath 后)
27
+ * @returns {string[]}
28
+ */
29
+ function splitCombinedPaths(cell) {
30
+ const raw = String(cell || '').trim()
31
+ if (!raw) return []
32
+ const tokens = raw.split(/\s*[+|;;、]\s*|//).map(t => t.trim()).filter(Boolean)
33
+ const valid = tokens.filter(t => !isPlaceholder(t) && looksLikePath(t) && !t.startsWith('.sillyspec/'))
34
+ return valid.length > 0 ? valid : (looksLikePath(raw) ? [raw] : [])
35
+ }
36
+
21
37
  /**
22
38
  * glob → 正则:双星(globstar)跨零或多段目录(含零段,bash 语义)、
23
39
  * 单星 → 单段([^/]*)、问号 → 单字符([^/]),其余转义。
@@ -226,8 +242,12 @@ function _parseFileListDetailed(designMdPath, { keepSillyspecDocs = false } = {}
226
242
  // incidental:说明列(非路径列的所有 cell)+ 路径 cell 原始值(剥注释前的括号内容)
227
243
  const incidental = cells.some((c, i) => i !== pathColIdx && INCIDENTAL_RE.test(c))
228
244
  || INCIDENTAL_RE.test(cells[pathColIdx] || '')
229
- if (listMode === 'exclude') { entries.delete(filePath); continue }
230
- entries.set(filePath, { path: filePath, operation, incidental })
245
+ // 组合路径单元格拆分(坑 design-combined-cell-mismatch,2026-08-22 实证:单元格写
246
+ // "router.py + service.py" 一行两文件——整体当单路径字面匹配对账不过,逼文档写法服务
247
+ // 校验器)。拆分符:+ / / / | / 、 / ; / ;——每个 token 独立过校验入表;单路径零变化。
248
+ const pathTokens = splitCombinedPaths(filePath)
249
+ if (listMode === 'exclude') { for (const t of pathTokens) entries.delete(t); continue }
250
+ for (const t of pathTokens) entries.set(t, { path: t, operation, incidental })
231
251
  continue
232
252
  }
233
253
 
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from 'fs'
12
12
  import { join, resolve, basename, relative } from 'path'
13
+ import { gitQuiet } from './git-helper.js'
13
14
  import {
14
15
  scanBackendEndpoints,
15
16
  scanFrontendApiCalls,
@@ -332,6 +333,43 @@ export function buildContractFieldInjection(changeDir, taskName) {
332
333
 
333
334
  // ─── Verify 阶段:parity check ──────────────────────────────────────────
334
335
 
336
+ /**
337
+ * 取本变更 diff 文件集(parity 前端调用收窄用,坑 probe5-fullrepo-frontend-noise)。
338
+ * 优先 worktree meta 锚点 diff(与 verify 同源);无 meta 退主仓 git diff HEAD。
339
+ * @returns {string[]|null}
340
+ */
341
+ function _resolveDiffFilesForParity(scanRoot, changeName) {
342
+ try {
343
+ const metaPath = join(scanRoot, '.sillyspec', '.runtime', 'worktrees', changeName, 'meta.json')
344
+ if (existsSync(metaPath)) {
345
+ const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
346
+ const diffBase = meta.baselineCommit || meta.actualBaseHash || meta.baseHash
347
+ const gitDir = (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath))
348
+ ? meta.worktreePath : scanRoot
349
+ if (diffBase) {
350
+ const out = gitQuiet(gitDir, ['diff', '--name-only', `${diffBase}..HEAD`], { timeout: 30000 })
351
+ if (out !== null) {
352
+ const files = out.split('\n').filter(Boolean)
353
+ // 并入 worktree 未提交(子代理默认不 commit 的形态,与 resolveVerifyChangedFiles 同口径)
354
+ try {
355
+ const wtStatus = gitQuiet(gitDir, ['status', '--porcelain'], { timeout: 30000, trim: false })
356
+ for (const line of String(wtStatus || '').split('\n')) {
357
+ if (!line || line.length < 4) continue
358
+ const p = line.slice(3).trim().split(' -> ').pop() || ''
359
+ const clean = p.replace(/^"|"$/g, '')
360
+ if (clean) files.push(clean)
361
+ }
362
+ } catch {}
363
+ return [...new Set(files)]
364
+ }
365
+ }
366
+ }
367
+ // 无 meta(brownfield/已 cleanup):主仓未提交 diff
368
+ const out = gitQuiet(scanRoot, ['diff', '--name-only', 'HEAD'], { timeout: 30000 })
369
+ return out !== null ? out.split('\n').filter(Boolean) : null
370
+ } catch { return null }
371
+ }
372
+
335
373
  /**
336
374
  * verify 阶段执行 API parity check
337
375
  * @param {string} specBase - .sillyspec 目录
@@ -372,17 +410,63 @@ export function verifyApiParity(specBase, scanRoot, runtimeRoot, changeName = nu
372
410
  return { ok: true, missingBackend: [], unusedBackend: [], summary: 'No scan root for parity check', backendCount: allProviderEndpoints.length, frontendCount: 0 }
373
411
  }
374
412
 
375
- const frontendCalls = scanFrontendApiCalls(scanRoot)
376
- const { missingBackend, unusedBackend } = diffApiParity(frontendCalls, allProviderEndpoints)
413
+ // ── 现算端点并入基线(坑 probe5-endpoint-baseline-stale,2026-08-22 实证:endpoints 基线
414
+ // 是 execute 时落的存量 artifacts,verify 时主仓已被并行会话/部署扰动推进——存量基线与当前
415
+ // 代码失配 → 前端调用(当前代码)对不上旧端点集 → missingBackend 全量误报。现算当前代码的
416
+ // 后端端点并入比对集:missingBackend 判定以「当前真实代码 ∪ 存量基线」为准(现算覆盖大部分、
417
+ // 存量补充跨仓/已删代码);unusedBackend 只对现算端点算(存量过期端点不再误报 unused)。
418
+ let liveEndpoints = []
419
+ try { liveEndpoints = scanBackendEndpoints(scanRoot) } catch { /* 现算失败退回纯存量基线 */ }
420
+ const liveKeys = new Set(liveEndpoints.map(e => `${e.method} ${e.path}`))
421
+ const mergedProviderEndpoints = [...allProviderEndpoints]
422
+ for (const e of liveEndpoints) {
423
+ const key = `${e.method} ${e.path}`
424
+ if (!allProviderEndpoints.some(p => `${p.method} ${p.path}` === key)) {
425
+ mergedProviderEndpoints.push(e)
426
+ }
427
+ }
428
+
429
+ // ── 前端调用收窄到本变更 diff(坑 probe5-fullrepo-frontend-noise,2026-08-23 实证:
430
+ // 「全仓前端调用 × 本变更局部登记」口径下,未受本变更影响的存量调用对不上局部端点集
431
+ // → 143 个 missing 全是误报噪音)。比对口径 = 本变更 diff 文件内的调用 ∪ 全仓(仅当
432
+ // 端点登记源也是全仓时——无 changeName 的 CLI contractScan 场景)。diff 不可得时退回
433
+ // 全仓(不静默跳过对账)。
434
+ let frontendCalls = []
435
+ let frontendScope = 'full-repo'
436
+ if (changeName) {
437
+ try {
438
+ const changed = _resolveDiffFilesForParity(scanRoot, changeName)
439
+ if (changed && changed.length > 0) {
440
+ const changedSet = new Set(changed.map(f => f.replace(/\\/g, '/')))
441
+ frontendCalls = scanFrontendApiCalls(scanRoot).filter(c => {
442
+ const src = String(c.source || '').replace(/\\/g, '/')
443
+ return changedSet.has(src) || [...changedSet].some(cf => src.endsWith(cf) || cf.endsWith(src))
444
+ })
445
+ frontendScope = `change-diff (${changed.length} files)`
446
+ } else {
447
+ frontendCalls = scanFrontendApiCalls(scanRoot)
448
+ }
449
+ } catch { frontendCalls = scanFrontendApiCalls(scanRoot) }
450
+ } else {
451
+ frontendCalls = scanFrontendApiCalls(scanRoot)
452
+ }
453
+
454
+ const { missingBackend, unusedBackend } = diffApiParity(frontendCalls, mergedProviderEndpoints)
455
+ // unusedBackend 收窄到现算端点(存量 artifact 的过期端点不参与——它不在当前代码里,
456
+ // 「前端未调用」可能是端点已被重构掉而非真泄漏)
457
+ const narrowedUnused = unusedBackend.filter(u => {
458
+ const key = `${u.method} ${u.path}`
459
+ return liveKeys.has(key) || liveEndpoints.length === 0
460
+ })
377
461
 
378
462
  const ok = missingBackend.length === 0
379
463
  let summary = ok
380
- ? `✅ API parity check passed: ${allProviderEndpoints.length} backend endpoints, ${frontendCalls.length} frontend calls`
381
- : `❌ API parity check failed: ${missingBackend.length} frontend calls have no matching backend endpoint`
464
+ ? `✅ API parity check passed: ${mergedProviderEndpoints.length} backend endpoints (live ${liveEndpoints.length} + artifact ${allProviderEndpoints.length}), ${frontendCalls.length} frontend calls [scope: ${frontendScope}]`
465
+ : `❌ API parity check failed: ${missingBackend.length} frontend calls have no matching backend endpoint [scope: ${frontendScope}]`
382
466
 
383
- if (unusedBackend.length > 0) {
384
- summary += ` | ${unusedBackend.length} backend endpoints unused by frontend`
467
+ if (narrowedUnused.length > 0) {
468
+ summary += ` | ${narrowedUnused.length} backend endpoints unused by frontend`
385
469
  }
386
470
 
387
- return { ok, missingBackend, unusedBackend, summary, backendCount: allProviderEndpoints.length, frontendCount: frontendCalls.length }
471
+ return { ok, missingBackend, unusedBackend: narrowedUnused, summary, backendCount: mergedProviderEndpoints.length, frontendCount: frontendCalls.length }
388
472
  }
package/src/index.js CHANGED
@@ -85,6 +85,7 @@ SillySpec CLI — 规范驱动开发工具包
85
85
  sillyspec config cat [--json] 读取 local.yaml 实际内容(自动定位真实路径,worktree 内解析到主仓)
86
86
  sillyspec runtime list [--json] 枚举 .sillyspec/.runtime/ 运行时产物(只读,看手上有哪些证据/状态文件)
87
87
  sillyspec dispatch <probe | hint> SillyHub 派发能力探测 + 策略生成(agent 调用桥,仅渲染不执行 tool)
88
+ sillyspec agent-log [--detect] [--json] 本地 agent 会话日志查询/现场探测(run 命令自动上报平台 POST /api/agent-logs 并本地留底)
88
89
 
89
90
  sillyspec workflow check <name> [--project <p>] [--change <c>] [--json] [--save]
90
91
  全局 workflow 校验(--save 归档到 .sillyspec/.runtime/workflow-runs/)
@@ -1843,6 +1844,13 @@ SillySpec worktree — git worktree 隔离管理
1843
1844
  }
1844
1845
  break;
1845
1846
  }
1847
+ case 'agent-log': {
1848
+ // 本地 agent 会话日志路径查询/探测(平台会话解析入口):读 <runtimeRoot>/agent-session-log.json
1849
+ //(run 命令在 agent 环境内自动登记),--detect 现场探测不落盘。协议见 docs/platform-agent-log-protocol.md。
1850
+ const { cmdAgentLog } = await import('./agent-session-log.js');
1851
+ await cmdAgentLog(filteredArgs.slice(1), { json, cwd: dir, specDir });
1852
+ break;
1853
+ }
1846
1854
  case 'dispatch': {
1847
1855
  // SillyHub 派发抽象层的 agent 调用桥(design.md §Phase2 / D-007@v1):
1848
1856
  // 仅做能力探测(probe)与派发策略生成(hint),**不执行任何 tool 调用**——
@@ -2696,6 +2704,7 @@ SillySpec config — local.yaml 配置键速查
2696
2704
  'quick-guard.json': 'quick baseline 守卫(旧位置)',
2697
2705
  'worktrees': 'worktree meta(hooks 侧)',
2698
2706
  'user-inputs.md': '步骤输出/输入历史追加日志',
2707
+ 'agent-session-log.json': '本地 agent 会话日志登记留底(run 命令自动 REST 上报平台 POST /api/agent-logs,见 docs/platform-agent-log-protocol.md)',
2699
2708
  'current-quick-run-id': '当前 quick sessionId 指针(--done 无 --change 时 fallback)',
2700
2709
  'scan-runs': '平台模式 scan/workflow 取证目录',
2701
2710
  'artifacts': '派生产物目录(init 预建)',
@@ -2733,7 +2742,7 @@ SillySpec config — local.yaml 配置键速查
2733
2742
  break;
2734
2743
  }
2735
2744
  default: {
2736
- const topCommands = ['init', 'setup', 'run', 'progress', 'worktree', 'dispatch', 'local', 'workflow', 'gate', 'derive', 'backfill-reviews', 'register-stage-review', 'modules', 'change-rename', 'knowledge', 'platform', 'scan', 'brainstorm', 'plan', 'execute', 'verify', 'archive', 'quick', 'explore', 'status', 'doctor', 'auto', 'runtime'];
2745
+ const topCommands = ['init', 'setup', 'run', 'progress', 'worktree', 'dispatch', 'agent-log', 'local', 'workflow', 'gate', 'derive', 'backfill-reviews', 'register-stage-review', 'modules', 'change-rename', 'knowledge', 'platform', 'scan', 'brainstorm', 'plan', 'execute', 'verify', 'archive', 'quick', 'explore', 'status', 'doctor', 'auto', 'runtime'];
2737
2746
  const suggestion = didYouMean(command, topCommands);
2738
2747
  console.error(`❌ 未知命令: ${command}`);
2739
2748
  if (command === '--status') {
package/src/quicklog.js CHANGED
@@ -265,10 +265,18 @@ export function deriveTitleFromLinkedChange(specBase, change) {
265
265
  try { content = readFileSync(join(specBase, 'changes', change, f), 'utf8') } catch { continue }
266
266
  const m = content.match(/^#\s+(.+?)\s*$/m)
267
267
  if (!m) continue
268
- const raw = m[1].trim()
269
- // 标题惯例「# 提案书(Proposal)— <desc>」「# 设计文档(Design)— <desc>」→ 取破折号后的 desc
268
+ let raw = m[1].trim()
269
+ // 标题惯例「# 提案书(Proposal)— <desc>」「# 设计文档(Design)— <desc>」→ 取破折号后的 desc。
270
+ // 固定前缀显式剥(坑 linked-task-placeholder-title,2026-08-22 实证:标题「# 提案书(Proposal):
271
+ // xxx」或「# 提案书(Proposal)」无破折号时 dash 匹配失败,整串前缀落进 tasks.md 追加行)——
272
+ // 先剥固定模板词(含全/半角冒号分隔形态),再剥破折号;剥完为空(纯模板标题)继续找下一文档
273
+ for (const prefix of [/^提案书(Proposal)\s*[::—\-]?\s*/, /^设计文档(Design)\s*[::—\-]?\s*/, /^Proposal\s*[::—\-]?\s*/i, /^Design\s*[::—\-]?\s*/i]) {
274
+ raw = raw.replace(prefix, '').trim()
275
+ }
276
+ if (!raw) continue
270
277
  const dash = raw.match(/[—-]\s*(.+)$/)
271
- return dash ? dash[1].trim() : raw
278
+ const title = dash ? dash[1].trim() : raw
279
+ if (title) return title
272
280
  }
273
281
  return ''
274
282
  }
@@ -163,6 +163,9 @@ sillyspec run ${stageName}${stage && stage.title ? ` — ${stage.title}` : ''}${
163
163
  * sillyspec run <stage> 主命令
164
164
  */
165
165
  export async function runCommand(args, cwd, specDir = null, opts = {}) {
166
+ // 原始 cwd 快照(下方 cwd 纠正前):agent 在子目录启动时,其会话 transcript 挂在子目录
167
+ // 对应的 harness 项目目录下,agent-log 探测须用原始 cwd(纠正后的是项目根,探测会扑空)
168
+ const invokedCwd = cwd
166
169
  // W1-B: run <stage> 暂不支持 --json。之前 --json 进 knownFlags 白名单却从不读取(静默吞),
167
170
  // agent 按 gate/derive 契约期望 envelope 实则拿到混合人类文本。显式 fail-fast 杜绝歧义;
168
171
  // 完整 run envelope 待 outputStep 重构(W6)后支持(prompt + nextAction 结构化)。
@@ -366,6 +369,29 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
366
369
  // let:下方 worktree 副本漂移守卫命中时锚回 wt.mainSpecBase(task-05/D-03)
367
370
  let specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
368
371
 
372
+ // ── 本地 agent 会话日志登记 + 上报(平台会话展示用,best-effort)──
373
+ // 平台/daemon 模式下 SillyHub 只能看到 CLI 阶段信息,看不到本地 agent 的实际执行日志。
374
+ // 在此探测当前 agent 环境(Claude Code / Codex / ZCode transcript / SILLYSPEC_AGENT_LOG
375
+ // 覆盖),把日志本地路径登记进 <runtimeRoot>/agent-session-log.json(留底)并立即
376
+ // REST 上报平台(POST /api/agent-logs,与进度上报同风格;协议见
377
+ // docs/platform-agent-log-protocol.md)。探测不到不发不写;任何失败只 warn 一行,
378
+ // 绝不阻断 run 主流程。
379
+ try {
380
+ const { recordAgentLogInvocation } = await import('../agent-session-log.js')
381
+ const r = await recordAgentLogInvocation({
382
+ cwd,
383
+ invokedCwd,
384
+ platformOpts,
385
+ specBase,
386
+ // 只记 flag 名不记值(--output 等 flag 值是 agent 工作文本,不进产物)
387
+ command: [stageName, ...flags.filter(t => typeof t === 'string' && t.startsWith('--'))].join(' '),
388
+ })
389
+ if (r && r.isNew && r.latestLogPath) {
390
+ const pushNote = r.pushed === true ? '已上报平台' : r.pushed === false ? '上报失败(本地已留底)' : '本地留底'
391
+ console.log(`📄 本地 agent 日志已登记(${pushNote}): ${r.latestLogPath}`)
392
+ }
393
+ } catch { /* best-effort:登记失败不影响 run 主流程 */ }
394
+
369
395
  // 漂移提醒:cwd 祖先链 ≥2 个 .sillyspec = monorepo 多实例,当前命中的「最近」实例
370
396
  // 可能不是用户意图的项目(如 cd 进被独立 scan 的子项目跑测试后忘回根)。
371
397
  // 平台模式 / 显式 --spec-dir 跳过(已明确指定,无歧义)。仅提醒不阻断。
@@ -1076,6 +1102,20 @@ export async function runCommand(args, cwd, specDir = null, opts = {}) {
1076
1102
  progress = pm.read(cwd, effectiveChange) || progress
1077
1103
  }
1078
1104
 
1105
+ // --file-notes 非末步静默忽略前置 warn(坑 quick-file-notes-nonfinal-ignored,2026-08-22 实证:
1106
+ // step2 --done 传 --file-notes 被静默丢——CLI 短进程,注入随进程结束即丢,白传一轮)。
1107
+ // 放此处:ensureStageSteps 之后 progress 已就绪可判步骤状态;仅 quick + 带了 --file-notes +
1108
+ // 非「唯一 pending 的末步 --done」时提示。末步 --done 是消费点,不提示。
1109
+ if (quickFileNotes !== '' && stageName === 'quick' && !isStatus) {
1110
+ try {
1111
+ const qSteps = progress?.stages?.quick?.steps
1112
+ const pendingCount = Array.isArray(qSteps) ? qSteps.filter(st => ['pending', 'in-progress'].includes(st?.status)).length : 0
1113
+ if (!isDone || pendingCount > 1) {
1114
+ console.warn(`⚠️ --file-notes 本次不会生效:它只在 quick 末步(暂存和更新记录)--done 时随收尾消费——CLI 是短进程,本次注入随进程结束即丢。请在末步 --done 时连同 --output 一起传。`)
1115
+ }
1116
+ } catch { /* 判定失败不提示(fail-open) */ }
1117
+ }
1118
+
1079
1119
  // --status
1080
1120
  if (isStatus) {
1081
1121
  // D9:状态展示末尾附「下一步该跑什么」。pm 持有状态机,按 progress 实际进度算出精确命令;
@@ -167,6 +167,38 @@ export async function completeStep(pm, progress, stageName, cwd, outputText, inp
167
167
  steps[_staleIdx].status = 'pending'
168
168
  currentIdx = _staleIdx
169
169
  console.log(`⚠️ --confirm 收尾:把 stale 步骤 "${steps[_staleIdx].name}" 拉回当前步骤走完成管线,其余 stale 由回填门控一并确认。`)
170
+ } else if (stageData?.status !== 'completed' && steps.length > 0) {
171
+ // ── 矛盾态自愈(坑 execute-status-machine-contradiction,2026-08-23 实证:--done 报
172
+ // 「没有待完成的步骤」而 status 显示 15 步未完成——并发进程写入竞争后 DB 步骤行停在
173
+ // completed 但阶段 status 未推进的矛盾态,用户只能靠 verify 启动碰运气自愈)。
174
+ // 自愈 = 重播种:ensureStageSteps 按 def 步骤名重建步骤行(保留同名完成态),矛盾行
175
+ // (DB 有但 def 无的幽灵行/半写行)被清掉;重读后再走正常管线。自愈失败再报错给精确指引。
176
+ console.warn(`⚠️ 检测到状态机矛盾:阶段 ${stageName} 无 pending/in-progress 步骤但 status=${stageData.status}(并发写入竞争的半写态)。自动重播种修复…`)
177
+ try {
178
+ const { ensureStageSteps } = await import('./command.js')
179
+ const reseeded = await ensureStageSteps(progress, stageName, cwd, platformOpts?.specRoot || null)
180
+ if (reseeded && effectiveChange) {
181
+ pm._write(cwd, progress, effectiveChange)
182
+ progress = pm.read(cwd, effectiveChange) || progress
183
+ }
184
+ const freshSteps = progress?.stages?.[stageName]?.steps || []
185
+ const freshPending = freshSteps.findIndex(s => s.status === 'pending' || s.status === 'in-progress')
186
+ if (freshPending !== -1) {
187
+ console.log(`✅ 重播种成功:${freshSteps.filter(s => s.status === 'pending').length} 个待完成步骤恢复,继续完成管线`)
188
+ currentIdx = freshPending
189
+ steps.length = 0
190
+ steps.push(...freshSteps)
191
+ stageData.steps = freshSteps
192
+ } else {
193
+ console.error(`重播种后仍无待完成步骤(阶段 status=${progress?.stages?.[stageName]?.status ?? '未知'})。`)
194
+ console.error(` 手动修复:sillyspec run ${stageName} --reset${changeName ? ` --change ${changeName}` : ''} 从头重跑,或 sillyspec doctor 诊断。`)
195
+ process.exit(1)
196
+ }
197
+ } catch (e) {
198
+ console.error(`❌ 矛盾态自愈失败: ${e.message}`)
199
+ console.error(` 手动修复:sillyspec run ${stageName} --reset${changeName ? ` --change ${changeName}` : ''},或 sillyspec doctor`)
200
+ process.exit(1)
201
+ }
170
202
  } else {
171
203
  console.error(`没有待完成的步骤(阶段 ${stageName} 已无 pending/in-progress 步骤)。当前阶段状态:${stageData?.status ?? '未知'}。用 \`sillyspec run ${stageName} --status\` 查看进度,或 \`sillyspec progress show\` 看全局下一步。`)
172
204
  process.exit(1)
@@ -679,7 +711,12 @@ async function autoCheckPlanFromReviews({ stageName, changeName, cwd, platformOp
679
711
  return { autoChecked: false, checkedCount: 0, skippedCount: 0 }
680
712
  }
681
713
  try {
682
- const specBaseLc = platformOpts?.specRoot || join(cwd, '.sillyspec')
714
+ // 勾选写入目标漂移锚定(坑 autocheck-worktree-tasks-lost,2026-08-23 实证:agent 在
715
+ // worktree 内跑 execute --done 且 drift 守卫未拦(非平台模式+无 specDriftAnchor 透传的
716
+ // 直调路径)时,tasks.md 勾选写进 worktree 的 .sillyspec/changes/ 副本——worktree 被
717
+ // cleanup 整目录删除后勾选丢失,主仓 tasks.md 停在未勾态需手动补。runtimeRoot 已走
718
+ // resolveRuntimeRoot(drift-aware),changeDir 同步对齐:specDriftAnchor > specRoot > cwd。
719
+ const specBaseLc = platformOpts?.specDriftAnchor || platformOpts?.specRoot || join(cwd, '.sillyspec')
683
720
  const changeDir = join(specBaseLc, 'changes', changeName)
684
721
  // 2026-08-20-task-truth-unify D-001@v1:勾选写入目标从 plan.md 迁 tasks.md(任务唯一真相;
685
722
  // plan.md Wave 引用行无 checkbox,无处可勾)。tasks.md 存在性作前置。
@@ -35,6 +35,13 @@ export function printQuickAuditReview(review) {
35
35
  console.error(` 如确认接受这些变更,重新运行 --done 时带上对应 flag 即可解锁:`)
36
36
  console.error(` sillyspec run quick --done --force-baseline --allow-new --change <id> --output "..."`)
37
37
  console.error(` (--force-baseline 覆盖受保护/危险文件如 src/run.js;--allow-new 允许新增文件;--allow-delete 允许删除文件)`)
38
+ // 两套开关明示(坑 files-flag-not-unlock-protected,2026-08-22 实证:模块文档被判危险
39
+ // 文件,追加 --files 边界不解锁仍被拦——「追加边界」与「解锁拦截」是两套开关,交互上易误解
40
+ // 前者能解决后者)。命中危险文件 reason 时点名:--files 只改归属口径(哪些文件计入本
41
+ // 会话),不改变危险判定;受保护/危险文件的放行开关唯 --force-baseline。
42
+ if (review.reasons.some(r => r.startsWith('危险文件变更'))) {
43
+ console.error(` ⛔ 注意:追加 --files 边界不会解锁受保护/危险文件的拦截(两套开关——--files 只声明哪些文件计入本会话,不改变危险判定)。改这类文件必须 --force-baseline。`)
44
+ }
38
45
  }
39
46
  } else if (review.status === 'warning') {
40
47
  console.warn(`\n⚠️ quick 变更边界审计 — WARNING:`)
@@ -51,6 +58,16 @@ export function printQuickAuditReview(review) {
51
58
  console.log(`\n✅ quick 变更边界审计 — SAFE (本轮新增变更 ${review.changedFiles.length} 个文件)`)
52
59
  }
53
60
  }
61
+ // 关联变更遗留放行提示(坑 linked-change-leftover-false-block,独立于三态都打——放行可见
62
+ // 可审计,不静默):这些文件属关联变更目录但不在本会话归属(启动后出现的他者遗留),
63
+ // 已剔除出本会话文件行,由关联变更自己的流程收尾。
64
+ if (Array.isArray(review.linkedChangeLeftovers) && review.linkedChangeLeftovers.length > 0) {
65
+ console.warn(`
66
+ 🧹 关联变更目录检测到 ${review.linkedChangeLeftovers.length} 个遗留脏文件(他者/上个 session 残留,非本会话改动)——已放行不计入本会话:`)
67
+ for (const f of review.linkedChangeLeftovers) console.warn(` - ${f}`)
68
+ console.warn(` 它们归关联变更自己的流程管;若确系本会话需要改,用 --files 显式声明归属。`)
69
+ }
70
+
54
71
  // D-8 文档欠账显性化(advisory,独立于 status 三态都打):改了源码没动文档 → 一行欠账标记。
55
72
  // 不阻断、不解锁、纯显性化——累积欠账可事后审计 QUICKLOG reasons 追溯。
56
73
  if (review.docSyncHint && review.docSyncHint.touchedSource > 0 && review.docSyncHint.docFiles.length === 0) {
package/src/run/shared.js CHANGED
@@ -902,10 +902,26 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
902
902
 
903
903
  // 检查危险文件(除非 force-baseline)
904
904
  if (file.startsWith('.sillyspec/') && !isQuickMetadata(file, guard.linkedChanges) && !forceBaseline) {
905
+ // 关联变更目录遗留放行(坑 linked-change-leftover-false-block,2026-08-22 实证:并发下
906
+ // 关联变更目录里他者/上个 session 的遗留脏文件——审计时不在本会话 baseline(启动后出现)
907
+ // 且 isQuickMetadata 对关联变更目录不豁免 → 落「危险文件」blocked,只能 --force-baseline。
908
+ // quick 无法区分「他者遗留」与「本次偷改」,按用户裁决放行降 warning:完全剔除出本会话
909
+ // 归属(changedFiles/newFiles/deletedFiles 均退栈),不计文件行、不触发危险/新增/越界
910
+ // 三道门,遗留给关联变更自己的流程收尾。非关联的 .sillyspec/ 仍 blocked。
911
+ const linkedDir = (Array.isArray(guard.linkedChanges) ? guard.linkedChanges : [])
912
+ .find(lc => file.startsWith(`.sillyspec/changes/${String(lc).replace(/\\/g, '/')}/`))
913
+ if (linkedDir) {
914
+ result.changedFiles.pop()
915
+ if (status === '??') result.newFiles.pop()
916
+ if (rawStatus[0] === 'D' || rawStatus[1] === 'D') result.deletedFiles.pop()
917
+ result.linkedChangeLeftovers = result.linkedChangeLeftovers || []
918
+ result.linkedChangeLeftovers.push(file)
919
+ continue
920
+ }
905
921
  result.reasons.push(`危险文件变更: ${file}`)
906
922
  }
907
923
 
908
- if (DANGEROUS_PATTERNS.some(p => file === p || file.startsWith(p)) && !forceBaseline) {
924
+ if (DANGEROUS_PATTERNS.some(p => p === file || file.startsWith(p)) && !forceBaseline) {
909
925
  result.reasons.push(`危险文件变更: ${file}`)
910
926
  }
911
927
  }
@@ -1069,6 +1085,13 @@ export async function auditQuickCompletion(cwd, guard, options = {}) {
1069
1085
  console.warn(` - ${f}`)
1070
1086
  console.warn(` 分离:git add -p ${f}(交互选你的 hunk)或 git diff ${f} > mine.patch(编辑留你的)+ git apply --cached mine.patch`)
1071
1087
  }
1088
+ // 宽严差异明示(坑 concurrent-policy-inconsistency,2026-08-22 实证:quick 同文件并发只
1089
+ // warn 不阻断、plan 阶段同 Wave 同文件却硬拦——两阶段宽严不一致易误判边界)。差异是设计
1090
+ // 使然:quick 是轻量流程无子代理并行,同文件并发最坏后果是 commit 夹带他者 hunk(git 可
1091
+ // 分离,提示分离命令即可);plan 的同 Wave 同文件会让 execute 的并行子代理互相覆盖(不可
1092
+ // 自动恢复),必须设计期硬拦。
1093
+ console.warn(` ℹ️ 边界说明:quick 对同文件并发只提示不阻断(最坏后果=commit 夹带他者 hunk,git 可分离);`)
1094
+ console.warn(` plan 阶段同 Wave 同文件是硬错误(execute 并行子代理会互相覆盖,不可自动恢复)——两阶段宽严不同是设计使然,勿以 quick 的宽松推断 plan 的边界。`)
1072
1095
  }
1073
1096
  }
1074
1097
 
package/src/run/stage.js CHANGED
@@ -162,6 +162,29 @@ export async function runStage(pm, progress, stageName, cwd, changeName, skipApp
162
162
  } catch { /* 预警失败不阻断 execute */ }
163
163
  }
164
164
 
165
+ // ── 共享主仓竞态防护(坑 parallel-shared-main-race,2026-08-23 实证:并行会话共享主仓
166
+ // 工作区/共享部署库的竞态——文件被清、alembic 被推进、staged 混入他者文件)。execute/verify
167
+ // 启动时探测三类信号并 advisory(不阻断——并发是合法工作模式,但要让 agent 知道环境在变):
168
+ // 1. 主仓未提交改动中含非本变更文件(staged 混入:commit 时注意 pathspec)
169
+ // 2. worktree 存在时 base..HEAD 主仓推进(主仓在他者 apply 后已前进,diff 口径注意)
170
+ // best-effort:异常静默。
171
+ if ((stageName === 'execute' || stageName === 'verify') && changeName) {
172
+ try {
173
+ const mainDirty = (safeGit(cwd, ['status', '--porcelain'], { trim: false }).value || '')
174
+ .split('\n').filter(Boolean).map(l => l.slice(3).trim().split(' -> ').pop() || '')
175
+ .map(p => p.replace(/^"|"$/g, '').replace(/\\/g, '/'))
176
+ .filter(p => p && !p.startsWith('.sillyspec/'))
177
+ const myFiles = (await import('../verify-postcheck.js')).resolveVerifyChangedFiles(cwd, changeName, null, { includeWorkingTree: true }) || []
178
+ const mySet = new Set(myFiles.map(f => f.replace(/\\/g, '/')))
179
+ const foreign = mainDirty.filter(p => !mySet.has(p))
180
+ if (foreign.length > 0) {
181
+ console.warn(`⚠️ 主仓含 ${foreign.length} 个非本变更的未提交文件(并行会话工作/部署产物混入)——`)
182
+ console.warn(` 提交时用精确 pathspec(勿 git add -A / 目录级 add 夹带):${foreign.slice(0, 4).join(', ')}${foreign.length > 4 ? ` …` : ''}`)
183
+ console.warn(` 交接物核对(文件被清/迁移被推进类异常)先确认是否他者会话所谓,再判定本变更问题。`)
184
+ }
185
+ } catch { /* 探测失败不阻断 */ }
186
+ }
187
+
165
188
  // 自动探测 currentChange
166
189
  if (autoDetectChange(progress, cwd)) {
167
190
  progress.lastActive = new Date().toLocaleString('zh-CN', { hour12: false })
@@ -1143,6 +1143,7 @@ ${taskList}
1143
1143
  - 最后一个 Wave 完成后做一次全量编译验证
1144
1144
  - 用户明确要求编译时
1145
1145
  4. 每个任务完成后:
1146
+ - **在 worktree 内 git add -A && git commit -m "<task-NN 摘要>"**(坑 subagent-uncommitted-newfile-apply3way,2026-08-22 实证:纯新增文件不 commit 时 apply 的 git apply --3way 报 "does not exist in index" 直接炸——未 commit 的新文件不在 base commit 也不在 index,patch 生成取不到。commit 后 base..HEAD diff 完整、apply 顺畅,review.json 的 head 也有真实锚点)
1146
1147
  - **先写 review.json 再勾选 checkbox**(见下方 Task Review Gate)
1147
1148
  - **既跑 lint check 也跑 formatter**:凡变更涉及的源码跑项目的 lint 检查 **和** 格式化(如 \`ruff format\` / \`prettier --write\`),不要只跑 check——只 check 不 format 会把格式问题留到 commit 时被 pre-commit hook 拦截(worktree 内二进制可能缺失,先 \`which <bin>\` 确认,缺则 \`uv tool install\` / \`uv sync\`)
1148
1149
  - 记录改动文件和测试结果
@@ -448,7 +448,9 @@ export function validateBlueprintConsistency(changeDir) {
448
448
  if (ts.length > 1) {
449
449
  errors.push(
450
450
  `路径 ${p} 被 Wave ${w} 内 ${ts.length} 个 task 修改: ${ts.join(', ')} — 同 Wave 任务 execute 强制并行(execute.js:603),` +
451
- `子代理会互相覆盖该文件。解法:把它们拆到不同 Wave(串行)。`
451
+ `子代理会互相覆盖该文件。解法:把它们拆到不同 Wave(串行)。` +
452
+ `(注:quick 阶段对同文件并发只提示不阻断——那是轻量流程无并行子代理、最坏后果 git 可分离;` +
453
+ `此处硬拦是因并行覆盖不可自动恢复,两阶段宽严不同是设计使然)`
452
454
  )
453
455
  }
454
456
  }
@@ -473,6 +473,9 @@ ${taskList}
473
473
  \`\`\`
474
474
  你是一个专注的 TaskCard 生成器。你的任务是为指定 batch 生成全部 TaskCard。
475
475
 
476
+ ## ⚠️ 格式形态(第一眼必读,坑 taskcard-body-section-rework)
477
+ TaskCard 的契约字段全部在 **frontmatter**(首对 --- 包裹的 YAML 键值对:id/title/title_zh/allowed_paths/goal/...)——**不是 body 章节**(## 标题下的段落)。goal 是 frontmatter 的 \`goal: >\` 多行标量、implementation/acceptance/verify/constraints 是 frontmatter 的列表项;写成 body 章节(\`## goal\` / \`## Goal\` 等)会三组校验全挂返工。先跑骨架命令再 Edit 填充,骨架即正确形态。
478
+
476
479
  ## 输入
477
480
  - 变更目录:${changeDir}
478
481
  - 当前时间:<now-datetime>(frontmatter 的 created_at 使用此值)
package/src/sync.js CHANGED
@@ -783,6 +783,38 @@ export class SyncManager {
783
783
  * @param {{base_ts?: string|null, local_modified_ts?: string|null, platform_last_pushed_at?: string|null, platform_progress?: object|null}} info
784
784
  * @returns {string|null} 冲突文件路径(specDir 不可达返回 null)
785
785
  */
786
+ /**
787
+ * 六表内容比对(坑 pull-deploy-noise-conflict 自愈用):本地 serializeForSync 快照与平台
788
+ * 拉回的 JSON 逐表深度比对,忽略时间戳/同步元数据列(last_active/last_synced_platform_ts/
789
+ * last_local_modified_ts/started_at/completed_at/pushed_at/checked_at/completed_at 等——部署
790
+ * 噪声重推会刷新这些列但内容不变)。任一实质差异 → false。
791
+ * @param {object} local - serializeForSync 输出
792
+ * @param {object} platform - 平台 GET progress JSON(serializeForSync 同构)
793
+ * @returns {boolean}
794
+ */
795
+ _progressContentEquals(local, platform) {
796
+ try {
797
+ const IGNORE_KEYS = new Set([
798
+ 'last_active', 'last_synced_platform_ts', 'last_local_modified_ts',
799
+ 'started_at', 'completed_at', 'pushed_at', 'last_pushed_at', 'created_at',
800
+ 'deps_checked_at', 'checked_at', 'waited_at', 'completedat',
801
+ ]);
802
+ const strip = (v) => {
803
+ if (Array.isArray(v)) return v.map(strip);
804
+ if (v && typeof v === 'object') {
805
+ const out = {};
806
+ for (const [k, val] of Object.entries(v).sort()) {
807
+ if (IGNORE_KEYS.has(k)) continue;
808
+ out[k] = strip(val);
809
+ }
810
+ return out;
811
+ }
812
+ return v;
813
+ };
814
+ return JSON.stringify(strip(local)) === JSON.stringify(strip(platform));
815
+ } catch { return false }
816
+ }
817
+
786
818
  _writeConflictFile(changeName, info = {}) {
787
819
  const specDir = safePlatformSpecDir(this.cwd);
788
820
  if (!specDir) return null;
@@ -965,6 +997,28 @@ export class SyncManager {
965
997
  }
966
998
  } catch { /* 重读失败维持原判(fail-closed 到真冲突分支) */ }
967
999
  }
1000
+ // ── 内容一致自愈(坑 pull-deploy-noise-conflict,2026-08-22 实证:并行会话部署扰动——
1001
+ // 他机 push 了内容相同的进度(如重启/重部署触发重推),本地脏 + 平台 ts 更新 → 拉冲突落
1002
+ // sync-conflict 文件需人工 resolve,但内容其实无分歧。比对平台 JSON 与本地 serializeForSync
1003
+ // 的六表内容(忽略时间戳列),一致 → 跳过 import(本地为准)+ base_ts 推进到平台 ts——
1004
+ // 与 resolve --keep-local 同语义,自动闭环。内容不同 → 真冲突,维持原判。
1005
+ if (localDirty && platformNewer && platformProgress) {
1006
+ try {
1007
+ const { ProgressManager } = await import('./progress.js');
1008
+ const pm = new ProgressManager({ specDir: safePlatformSpecDir(this.cwd) });
1009
+ const localSnapshot = pm.serializeForSync(this.cwd, changeName);
1010
+ if (localSnapshot && this._progressContentEquals(localSnapshot, platformProgress)) {
1011
+ try {
1012
+ const db = pm._ensureDB(this.cwd).getDb();
1013
+ db.prepare(
1014
+ 'UPDATE changes SET last_synced_platform_ts = MAX(?, COALESCE(last_synced_platform_ts, ?)) WHERE name = ?'
1015
+ ).run(platformPushedAt, platformPushedAt, changeName);
1016
+ } catch {}
1017
+ debugLog(`[sync] pull 内容一致自愈: 平台 ts ${platformPushedAt} 为部署噪声重推(六表内容与本地一致),base_ts 已推进,不落冲突文件`);
1018
+ return { ok: true, imported: false, conflict: false, reason: '平台重推内容与本地一致(部署噪声),base_ts 已推进' };
1019
+ }
1020
+ } catch { /* 比对失败维持原判(fail-closed 到真冲突分支) */ }
1021
+ }
968
1022
  if (localDirty && platformNewer) {
969
1023
  console.warn('');
970
1024
  console.warn('⚠️⚠️⚠️════════════════════════════════════════════════════');
@@ -426,6 +426,18 @@ export function provisionDeps(worktreePath, mainCwd, opts = {}) {
426
426
  result.depsMethod = linkedModules[0].method;
427
427
  result.depsSource = 'main-checkout';
428
428
  }
429
+ // 子模块 link 后验证(坑 modules-submodule-link-verify,2026-08-23 实证:Windows 下漏链
430
+ // frontend node_modules——linkOneDir 报 linked 但 junction 实际未建或 main 侧本就没有)。
431
+ // linked 状态的子模块逐一核验 wt/<mp>/node_modules 实存:缺失降 failed 并给兜底指引
432
+ //(不静默放行——跑测试时才发现就晚了)。
433
+ for (const m of moduleResults) {
434
+ if (m.status !== 'linked') continue
435
+ const nmPath = join(worktreePath, m.path, 'node_modules')
436
+ if (!existsSync(nmPath)) {
437
+ m.status = 'failed'
438
+ m.error = `linked 后验证失败:${m.path}/node_modules 不存在(报成功但实际未落盘)。兜底:New-Item -ItemType Junction -Path "<wt>/${m.path}/node_modules" -Target "<主仓>/${m.path}/node_modules"`
439
+ }
440
+ }
429
441
  const failedModules = moduleResults.filter(m => m.status === 'failed');
430
442
  if (failedModules.length > 0) {
431
443
  const fmsg = failedModules.map(m => `${m.path}: ${m.error}`).join('; ');