sillyspec 3.28.9 → 3.28.10

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/src/run/prompt.js CHANGED
@@ -316,6 +316,68 @@ export function buildKnowledgeInjection({ knowledgeDir, runtimeDir, change, quer
316
316
 
317
317
  // parseModuleMapSimple 复用 modules.js 的 canonical 实现(合并历史 copy-paste 副本,2026-08-07;
318
318
  // 无循环依赖:modules.js 仅 import fs/path/db.js,prompt.js → modules.js 单向)。
319
+
320
+ /**
321
+ * 等效验证先例库(坑 verify-precedent-only-in-prose,2026-09-15 EHS 生产实证:mvn test 被
322
+ * 公司框架 parent pom pluginManagement 硬编码 surefire skip=true、-D 覆盖无效——等效口径
323
+ * dependency:build-classpath+javac+JUnitCore 第二次复用靠 agent 翻旧 verify-result.md 正文
324
+ * 续命;先例只活在散文里 = 换个 agent/换个变更就断档)。结构化登记进 local.yaml
325
+ * verify_precedents 段,verify prompt 时点注入提示(与 test_strategy 预检提示同挂点)。
326
+ *
327
+ * 对象数组形态(行扫描,同 extractKnownFailures 免 YAML 依赖口径——注释/空行/块界容错):
328
+ * verify_precedents:
329
+ * - id: mvn-test-surefire-skip
330
+ * standard_command: mvn test
331
+ * reason: <标准命令为何不可用>
332
+ * equivalent: <已验证的等效口径>
333
+ * established_by: <确立该口径的变更名>
334
+ * notes: <可选补充>
335
+ * 残项(standard_command 与 equivalent 双缺)过滤——登记不完整的先例没有可执行口径。
336
+ * @param {string|null} yamlText local.yaml 全文
337
+ * @returns {Array<object>} 先例对象数组
338
+ */
339
+ export function parseVerifyPrecedents(yamlText) {
340
+ const lines = String(yamlText || '').split(/\r?\n/)
341
+ const headIdx = lines.findIndex(l => /^verify_precedents:\s*(?:#.*)?$/.test(l))
342
+ if (headIdx < 0) return []
343
+ const items = []
344
+ let cur = null
345
+ const clean = (v) => String(v || '').replace(/\s+#.*$/, '').trim()
346
+ for (let i = headIdx + 1; i < lines.length; i++) {
347
+ const l = lines[i]
348
+ if (/^\S/.test(l)) break // 下一个顶层键 → 块结束
349
+ if (!l.trim() || /^\s*#/.test(l)) continue
350
+ const item = l.match(/^\s*-\s+([A-Za-z_][\w-]*):\s*(.*)$/)
351
+ if (item) {
352
+ cur = {}
353
+ items.push(cur)
354
+ cur[item[1]] = clean(item[2])
355
+ continue
356
+ }
357
+ const kv = l.match(/^\s*([A-Za-z_][\w-]*):\s*(.*)$/)
358
+ if (kv && cur) cur[kv[1]] = clean(kv[2])
359
+ }
360
+ return items.filter(x => x && (x.standard_command || x.equivalent))
361
+ }
362
+
363
+ /**
364
+ * 先例提示渲染(verify prompt 注入用;空输入 → '',调用方零输出)。
365
+ * 行动指引刻意指向「把等效命令配进 commands.test 照常全量实测对账」而非 test_strategy: skip
366
+ * 放行——EHS 实证里被环境阻断 deferred 的集成测试正是 P1 缺陷藏身处,能实测就实测。
367
+ * @param {Array<object>|null} precedents
368
+ * @returns {string}
369
+ */
370
+ export function renderVerifyPrecedentHint(precedents) {
371
+ if (!Array.isArray(precedents) || precedents.length === 0) return ''
372
+ const L = [`【验证先例提示】本仓已登记 ${precedents.length} 条等效验证先例(标准命令不可用时的既有替代口径):`]
373
+ for (const p of precedents.slice(0, 5)) {
374
+ L.push(`- ${p.id || '(无 id)'}:\`${p.standard_command || '?'}\` 不可用(${p.reason || '原因未注'})→ 等效口径 \`${p.equivalent || '?'}\`(确立于 ${p.established_by || '?'}${p.notes ? `;${p.notes}` : ''})`)
375
+ }
376
+ if (precedents.length > 5) L.push(`- …另有 ${precedents.length - 5} 条未展开(读 local.yaml verify_precedents 段看全量)`)
377
+ L.push('适用时把等效命令配进 local.yaml commands.test(或 modules.<m>.test)后照常全量实测对账,而不是只靠 test_strategy: skip 放行;先例不适用(环境已变/口径失效)可忽略并在 verify-result 注记。')
378
+ return L.join('\n')
379
+ }
380
+
319
381
  /**
320
382
  * 提示词路径根的单一解析:specRoot(平台) > specDriftAnchor(worktree 漂移锚定主仓) > cwd/.sillyspec(本地)。
321
383
  * 治 execute review.json 提示路径写 worktree 副本、--done 校验读主仓的分裂(坑 execute-prompt-spec-drift):
@@ -1129,13 +1191,14 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
1129
1191
  // 推荐组合、无从否决——故在 prompt 时点经 {EVIDENCE_AUTO_RECOMMENDATION} 把 task-11
1130
1192
  // resolveTestStrategy 的 evidence_auto_recommendation.summary 渲染进 step prompt(含推荐
1131
1193
  // 理由、降级注记与「可在 verify-result.md 否决并改跑全量」路径),供用户否决(FR-11)。
1132
- // 仅 test_strategy=evidence-auto 且 recommendation 非空时渲染;full/module/skip/未配置
1133
- // (recommendation=null)替换为空串零输出。fail-soft:读取/解析异常降级单行说明不抛,
1194
+ // 仅 test_strategy=evidence-auto 且 recommendation 非空时渲染推荐;full/module/skip/已配置
1195
+ // 替换为空串零输出;**未配置**且 commands.test 已配时渲染 test_strategy 预检提示(2026-09-15
1196
+ // 复盘:环境硬伤的全量实测打回返工,提前在 prompt 时点给配置指引)。fail-soft:读取/解析异常降级单行说明不抛,
1134
1197
  // 绝不阻断 verify prompt 输出(对齐上方 {WORKTREE_BASELINE_INFO} 注入先例)。
1135
1198
  if (stageName === 'verify' && promptText.includes('{EVIDENCE_AUTO_RECOMMENDATION}')) {
1136
1199
  let eaInjected = ''
1137
1200
  try {
1138
- const { resolveTestStrategy } = await import('../verify-postcheck.js')
1201
+ const { resolveTestStrategy, extractTestCommand, extractTestStrategy } = await import('../verify-postcheck.js')
1139
1202
  const eaSpecBase = resolvePromptSpecBase(platformOpts, cwd)
1140
1203
  // 信任边界:local.yaml 只读 specBase(平台 specRoot / 漂移锚定主仓 / cwd 本地),与
1141
1204
  // runVerifyTestCheck 的 specBase 来源同口径,不读 agent 可写的 worktree 副本。
@@ -1144,7 +1207,20 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
1144
1207
  const eaChangeDir = changeName ? join(eaSpecBase, 'changes', changeName) : null
1145
1208
  const resolution = resolveTestStrategy({ yamlText: eaYamlText, changeDir: eaChangeDir })
1146
1209
  const rec = resolution.evidence_auto_recommendation
1147
- if (rec && rec.summary) eaInjected = rec.summary
1210
+ if (rec && rec.summary) {
1211
+ eaInjected = rec.summary
1212
+ } else if (eaYamlText && extractTestCommand(eaYamlText) && !extractTestStrategy(eaYamlText)) {
1213
+ // test_strategy 预检提示(2026-09-15 复盘教训「test_strategy 该在进 verify 前配好,
1214
+ // 而不是被 verify-test gate 打回后才补」):已配 commands.test 但顶层 test_strategy
1215
+ // 未设(缺省=全量实测对账),prompt 时点先给配置指引——环境注定跑不了全量时省一轮
1216
+ // 「--done 实测打回 → 补配置 → 重跑」返工
1217
+ eaInjected = '【test_strategy 预检提示】local.yaml 已配置 commands.test 但未设顶层 test_strategy(缺省=全量实测对账)。若该命令在当前环境无法真实执行(框架/环境硬伤,如测试框架硬编码 skip、依赖外部服务),先在 local.yaml 显式配 `test_strategy: skip`(或 `module` + modules 映射收窄)并注释理由再继续——避免 verify --done 实测对账打回后返工。'
1218
+ }
1219
+ // 等效验证先例注入(verify-precedent-only-in-prose,2026-09-15 EHS 实证):先例与
1220
+ // 预检提示同挂点追加——标准命令不可用但已有验证过的等效口径时,指引优先配进
1221
+ // commands.test 实测对账而非 skip 放行。
1222
+ const _precHint = renderVerifyPrecedentHint(parseVerifyPrecedents(eaYamlText))
1223
+ if (_precHint) eaInjected = eaInjected ? `${eaInjected}\n\n${_precHint}` : _precHint
1148
1224
  } catch (e) {
1149
1225
  eaInjected = `(evidence-auto 推荐注入失败:${e.message}——请读 .sillyspec/local.yaml 确认 test_strategy;--done 时 CLI 仍会按 resolveTestStrategy 实测对账,推荐不可用时可显式设 test_strategy: full/module)`
1150
1226
  }
@@ -1352,10 +1428,15 @@ export async function outputStep(stageName, stepIndex, steps, cwd, changeName, d
1352
1428
  // archive-batch-31-tool-notes ②:通用 --done 模板不带该 flag,agent 照抄执行撞
1353
1429
  // 「请添加 --confirm」确认门,误以为参数没带。
1354
1430
  const confirmFlag = step.requiresConfirm === true ? ' --confirm' : ''
1355
- // auto driver(2026-09-08-auto-driver D-002/P1-③):auto 模式下命令一律 run auto 形态——
1431
+ // auto driver(2026-09-08-auto-driver D-002/P1-Ⅲ):auto 模式下命令一律 run auto 形态——
1356
1432
  // 与 SS-META 的 doneCommand 同源(同一 cmdStage 变量),消灭「正文 run <stage> / 元数据 run auto」双命令
1357
1433
  const cmdStage = autoMeta ? 'auto' : stageName
1358
- const doneCommand = `sillyspec run ${cmdStage} --done${confirmFlag}${changeFlag} --output "你的摘要"`
1434
+ // 意图断言(坑 execute-concurrent-done-skips-next-wave 终解):execute 的 Wave 步骤是动态
1435
+ // 步骤表,并发 --done 会落到错误 Wave 且用旧摘要静默完成——完成命令带 --step "<本步名>"
1436
+ //(CLI 校验一致才放行),照抄命令即得并发防护。仅 execute 注入(quick/静态阶段测试断言
1437
+ // 提示格式,且静态步骤名稳定无漂移风险)。
1438
+ const stepAssertFlag = (stageName === 'execute' && step?.name) ? ` --step "${step.name}"` : ''
1439
+ const doneCommand = `sillyspec run ${cmdStage} --done${confirmFlag}${stepAssertFlag}${changeFlag} --output "你的摘要"`
1359
1440
  if (requiresWait) {
1360
1441
  console.log(`本步骤必须等待用户输入,不能直接 --done:`)
1361
1442
  console.log(`sillyspec run ${cmdStage} --wait --reason "${step.waitReason || '等待用户输入'}" --options "${(step.waitOptions || ['确认']).join(',')}"${changeFlag} --output "你的问题/方案摘要"`)
package/src/run/shared.js CHANGED
@@ -635,6 +635,71 @@ export function checkPlatformManaged(cwd) {
635
635
  }
636
636
  }
637
637
 
638
+ /**
639
+ * 平台模式产物落点指针(坑 platform-docs-dual-location,2026-09-15 EHS 生产实证:
640
+ * 变更 9 文档落 daemon specs 目录,工作树 .sillyspec/ 只剩旧变更残留、worktree 无
641
+ * .sillyspec——人类在工作树里找变更文档扑空,只有平台 changes files API 能读,双位置
642
+ * 造成「文件去哪了」困惑)。阶段 --done 完成时在工作树 .sillyspec/ 落一份人类可读指针
643
+ * PLATFORM-DOCS-POINTER.md:specRoot/runtimeRoot 物理路径 + workspaceId + 本变更文档
644
+ * 清单(.md 文件名+mtime + tasks/ 计数)+ 获取方式提示。
645
+ *
646
+ * 口径与 writePlatformPointer 三写同款豁免:本地模式(无 specRoot/runtimeRoot)零行为;
647
+ * 自指回环(repo-native junction 指回 cwd/.sillyspec)不写;temp 残留形态(specRoot 在
648
+ * 系统 temp 而项目不在)不写。fail-soft:任何写失败只 warn 不抛(对齐 user-inputs.md
649
+ * 追加先例)——指针是便利件,绝不阻断阶段完成。
650
+ *
651
+ * @param {string} cwd 项目根(指针落 cwd/.sillyspec/)
652
+ * @param {string|null} changeName 变更名(可空——无变更上下文时只写根路径头部)
653
+ * @param {{specRoot?: string|null, runtimeRoot?: string|null, workspaceId?: string|null}} [platformOpts]
654
+ * @param {string|null} [stageName] 触发阶段(展示用)
655
+ * @returns {{written: boolean, reason?: string, path?: string, error?: string}}
656
+ */
657
+ export function writePlatformDocsPointer(cwd, changeName, platformOpts = {}, stageName = null) {
658
+ try {
659
+ const specRoot = platformOpts?.specRoot || null
660
+ const runtimeRoot = platformOpts?.runtimeRoot || null
661
+ if (!specRoot && !runtimeRoot) return { written: false, reason: 'local-mode' }
662
+ if (specRoot && isSelfReferentialSpecRoot(cwd, specRoot)) return { written: false, reason: 'self-referential' }
663
+ if (isTempResidueSpecRoot(cwd, specRoot)) return { written: false, reason: 'temp-residue' }
664
+ const localSpecDir = join(cwd, '.sillyspec')
665
+ mkdirSync(localSpecDir, { recursive: true })
666
+ const ts = new Date().toLocaleString('zh-CN', { hour12: false })
667
+ const lines = [
668
+ '<!-- sillyspec 平台模式产物落点指针(CLI 在阶段 --done 完成时自动维护;本地模式不生成本文件) -->',
669
+ '# 变更文档实际落点(工作树 .sillyspec/ 不是本仓权威数据面)',
670
+ '',
671
+ `- 平台 spec 根(proposal/design/verify-result 等变更文档): ${specRoot || '(未指定)'}`,
672
+ `- 运行时根(worktrees/contract-artifacts/verify-logs): ${runtimeRoot || '(未指定)'}`,
673
+ platformOpts?.workspaceId ? `- 工作区 ID: ${platformOpts.workspaceId}` : null,
674
+ `- 最近更新: ${stageName || '?'} 阶段完成 @ ${ts}${changeName ? `(change: ${changeName})` : ''}`,
675
+ ].filter(Boolean)
676
+ const changeDir = (specRoot && changeName) ? join(specRoot, 'changes', changeName) : null
677
+ if (changeDir && existsSync(changeDir)) {
678
+ const docs = []
679
+ for (const f of readdirSync(changeDir)) {
680
+ if (!f.endsWith('.md')) continue
681
+ try {
682
+ const mtime = statSync(join(changeDir, f)).mtime.toLocaleString('zh-CN', { hour12: false })
683
+ docs.push(`- ${f}(mtime ${mtime})`)
684
+ } catch { docs.push(`- ${f}`) }
685
+ }
686
+ const tasksDir = join(changeDir, 'tasks')
687
+ if (existsSync(tasksDir)) {
688
+ const cards = readdirSync(tasksDir).filter(f => f.endsWith('.md'))
689
+ if (cards.length > 0) docs.push(`- tasks/(${cards.length} 张任务卡)`)
690
+ }
691
+ if (docs.length > 0) lines.push('', `## 本变更文档(${changeName})`, ...docs)
692
+ }
693
+ lines.push('', '获取方式:直接读上行物理路径,或经平台 changes files API(/changes/{id}/files/raw?path=...)逐文件下载。')
694
+ const pointerPath = join(localSpecDir, 'PLATFORM-DOCS-POINTER.md')
695
+ writeFileSync(pointerPath, lines.join('\n') + '\n')
696
+ return { written: true, path: pointerPath }
697
+ } catch (e) {
698
+ console.warn('⚠️ 平台产物落点指针写入失败(不阻断):', e && e.message ? e.message : e)
699
+ return { written: false, reason: 'error', error: e && e.message }
700
+ }
701
+ }
702
+
638
703
  /**
639
704
  * 自指 specRoot 判定(repo-native junction 回环检测,变更 2026-08-23-repo-native-spec-backfill)。
640
705
  *
@@ -1035,7 +1100,12 @@ export function isQuickMetadata(p, linkedChanges = []) {
1035
1100
  const file = normalizeGitPath(p)
1036
1101
  if (file.startsWith('.sillyspec/quicklog/')
1037
1102
  || file.startsWith('.sillyspec/.runtime/')
1038
- || file === '.sillyspec/knowledge/uncategorized.md'
1103
+ // 知识库整体白名单(2026-09-15 用户实测反馈:登记知识条目是 quick 收尾常规动作,此前
1104
+ // INDEX/known-issues/patterns/conventions/decisions 非元数据 → 危险门 blocked 只能 --force-baseline,
1105
+ // 且 perFileNotes 强制 --file-notes 覆盖):knowledge/ 是 CLI 自管面(classify append-only +
1106
+ // validate 守门),与 quicklog 同级。可追溯性靠 quicklog 结构化条目 + git diff,不靠文件行。
1107
+ // 前缀带尾斜杠:knowledge-base/ 等相近目录不命中。
1108
+ || file.startsWith('.sillyspec/knowledge/')
1039
1109
  || (/^\.sillyspec\/docs\/[^/]+\/modules\/[^/]+\.md$/.test(file))
1040
1110
  || (/^\.sillyspec\/docs\/[^/]+\/modules\/_module-map\.yaml$/.test(file))) return true
1041
1111
  if (file.startsWith('.sillyspec/changes/')) {
@@ -31,6 +31,9 @@ import { locateQuickSessionGuard, auditQuickCompletion, resolveRuntimeRoot, coll
31
31
  import { computeGateProfile, resolveGateThresholds } from './quick-gate-profile.js'
32
32
  import { safeGit } from './git-helper.js'
33
33
  import { parseFileChangeListDetailed, pathMatches } from './change-list.js'
34
+ import { classifyToolScaffold } from './worktree-apply.js'
35
+ import { parseRepoRegistry } from './stages/plan-postcheck.js'
36
+ import { _readWorktreeMeta } from './contract-matrix.js'
34
37
 
35
38
  /** quick 会话 id 形态(与 run/command.js :652 QUICK_SID_RE 同款——crypto.randomUUID 前 8 hex) */
36
39
  const QUICK_SID_RE = /^quick-[0-9a-f]{8}$/
@@ -211,10 +214,11 @@ function resolveChangeDir(sb, changeName) {
211
214
  function resolveDiffRoot(sb, changeName, form, cwd) {
212
215
  if (form === 'worktree') {
213
216
  try {
214
- const meta = JSON.parse(readFileSync(join(sb, '.runtime', 'worktrees', changeName, 'meta.json'), 'utf8'))
215
- if (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath)) {
216
- return meta.worktreePath
217
- }
217
+ // 双根读取(坑 platform-dual-root-form-misjudge):平台模式 sb=specRoot 时项目侧
218
+ // .sillyspec 的 meta 读不到 → worktree 根错落 cwd。_readWorktreeMeta 双候选(contract-matrix
219
+ // 既有样板,gitDir 已含 worktreePath/mode 判定)。
220
+ const found = _readWorktreeMeta(sb || null, cwd, changeName)
221
+ if (found) return found.gitDir
218
222
  } catch { /* 读失败 → cwd 兜底(advisory) */ }
219
223
  }
220
224
  return cwd
@@ -376,6 +380,22 @@ function findQuickPatchRecord(cwd, sessionId) {
376
380
  /** QUICKLOG 条目号形态(ql-20260914-001-abcd:日期-序号-短 hash,短 hash 4 hex) */
377
381
  const QL_ID_RE = /^ql-\d{8}-\d{3}-[0-9a-f]{4}$/
378
382
 
383
+ /**
384
+ * 注册仓 key 清单(scope-audit-cross-repo-blindness 改进点 2):读 <specBase>/local.yaml 的
385
+ * repos 段(parseRepoRegistry 同源,D-003 不另造解析)——design 清单跨仓子段 / `cross-repo:`
386
+ * 前缀只认注册 key(fail-closed,防普通路径误判)。local.yaml 缺失/坏 → 空清单(全部按主仓
387
+ * 解析,行为同改进前)。
388
+ * @param {string} specBase .sillyspec 根
389
+ * @returns {string[]} repoKey 清单
390
+ */
391
+ function loadRegisteredRepoKeys(specBase) {
392
+ try {
393
+ const p = join(specBase || '', 'local.yaml')
394
+ if (!specBase || !existsSync(p)) return []
395
+ return [...parseRepoRegistry(readFileSync(p, 'utf8')).keys()]
396
+ } catch { /* 读取/解析失败 → 空清单(主仓口径兜底) */ return [] }
397
+ }
398
+
379
399
  /**
380
400
  * ql-xxx → quick-xxx 反查(用户「为什么不直接用 ql-xxx 查」根治,ql-20260910-014 设计:
381
401
  * 映射只在本机 guard,会话结束即清;patches 记录按 qlId 命名且 json 冗余 sessionId——
@@ -704,7 +724,8 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
704
724
  // 审计 tag)全无 = 尚未进入 execute——实际侧不存在(B2 会吞整个工作区脏文件误归属本变更,
705
725
  // 2026-09-11-cross-change-decision-guard 实证 28 个「计划外」全是并行在途)。出计划清单
706
726
  // 视图:rows=design 清单 untouched(待实现),工作区改动归属各自会话不进表。
707
- const hasWorktreeMeta = existsSync(join(sb, '.runtime', 'worktrees', changeName, 'meta.json'))
727
+ // 双根(坑 platform-dual-root-form-misjudge):平台模式 sb=specRoot 时项目侧 meta 单路径读不到
728
+ const hasWorktreeMeta = !!_readWorktreeMeta(sb || null, cwd, changeName)
708
729
  const hasBranch = !!safeGit(cwd, ['rev-parse', '--verify', '--quiet', `sillyspec/${changeName}^{commit}`], { timeout: 15 * 1000 }).value
709
730
  const hasAuditTag = !!safeGit(cwd, ['rev-parse', '--verify', '--quiet', `sillyspec-audit/sillyspec/${changeName}^{commit}`], { timeout: 15 * 1000 }).value
710
731
  // 审查 C-F02:预执行判定必须排除归档(归档=流程走完,58/77 归档变更三信号全缺被误判
@@ -740,11 +761,12 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
740
761
  try {
741
762
  prePlanned = parseFileChangeListDetailed(
742
763
  join(changeDirInfo ? changeDirInfo.dir : join(sb, 'changes', changeName), 'design.md'),
743
- { keepSillyspecDocs: true })
764
+ { keepSillyspecDocs: true, repoKeys: loadRegisteredRepoKeys(sb) })
744
765
  } catch { /* 清单解析失败 → 空清单 + 说明 */ }
745
766
  const preRows = prePlanned.map(e => ({
746
767
  path: e.path, planned: e.operation || null, additions: 0, deletions: 0,
747
768
  kind: 'modified', verdict: 'untouched',
769
+ ...(e.repo ? { crossRepo: e.repo } : {}),
748
770
  }))
749
771
  return {
750
772
  ...emptyBase,
@@ -762,8 +784,9 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
762
784
  let planDegraded = null
763
785
  try {
764
786
  // keepSillyspecDocs=true:与 actual 侧 filterDeliverableFiles 保留 .sillyspec/docs/** 交付物
765
- // 的口径对齐(dogfood 模块文档=交付物)
766
- plannedEntries = parseFileChangeListDetailed(designMdPath, { keepSillyspecDocs: true })
787
+ // 的口径对齐(dogfood 模块文档=交付物);repoKeys=注册仓清单——跨仓子段/`cross-repo:` 前缀
788
+ // 条目带 repo 字段(改进点 2,标注「跨仓(本表不含)」的数据源)
789
+ plannedEntries = parseFileChangeListDetailed(designMdPath, { keepSillyspecDocs: true, repoKeys: loadRegisteredRepoKeys(sb) })
767
790
  } catch (e) {
768
791
  planDegraded = `design.md 清单解析异常: ${e && e.message ? String(e.message).split('\n')[0] : e}`
769
792
  }
@@ -783,13 +806,10 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
783
806
  let actual = null
784
807
  let actualFailure = null
785
808
  try {
786
- const metaPath = join(sb, '.runtime', 'worktrees', changeName, 'meta.json')
787
- if (existsSync(metaPath)) {
788
- const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
789
- const wtRoot = (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath))
790
- ? meta.worktreePath
791
- : cwd
792
- const anchor = meta.baselineCommit || meta.actualBaseHash || meta.baseHash || null
809
+ const found = _readWorktreeMeta(sb || null, cwd, changeName)
810
+ if (found) {
811
+ const wtRoot = found.gitDir
812
+ const anchor = found.meta.baselineCommit || found.meta.actualBaseHash || found.meta.baseHash || null
793
813
  if (anchor) {
794
814
  const diffOut = safeGit(wtRoot, ['diff', '--name-only', anchor], { timeout: 30 * 1000 })
795
815
  if (!diffOut.error && typeof diffOut.value === 'string') {
@@ -811,9 +831,9 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
811
831
  if (wtFilter(p) && !files.includes(p)) files.push(p)
812
832
  }
813
833
  }
814
- actual = { ok: true, form: 'worktree', files, sources: ['worktree:diff-anchor..worktree', 'worktree:status-untracked'], foreignExcluded: 0, degradedReason: null, baseAnchor: anchor }
834
+ actual = { ok: true, form: 'worktree', files, sources: ['worktree:diff-anchor..worktree', 'worktree:status-untracked'], foreignExcluded: 0, foreignExcludedFiles: [], degradedReason: null, baseAnchor: anchor }
815
835
  } else {
816
- actual = { ok: false, form: 'worktree', files: [], sources: [], foreignExcluded: 0, degradedReason: 'worktree 锚定 diff 失败', baseAnchor: null }
836
+ actual = { ok: false, form: 'worktree', files: [], sources: [], foreignExcluded: 0, foreignExcludedFiles: [], degradedReason: 'worktree 锚定 diff 失败', baseAnchor: null }
817
837
  }
818
838
  } else {
819
839
  // 无锚 meta(baseHash 等字段缺失):退 HEAD 未提交窗口口径——文件集=status 全量
@@ -829,7 +849,7 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
829
849
  && !p.startsWith('.sillyspec/quicklog/')) files.push(p)
830
850
  }
831
851
  }
832
- actual = { ok: true, form: 'worktree', files, sources: ['worktree:status-porcelain(HEAD-window)'], foreignExcluded: 0, degradedReason: null, baseAnchor: null }
852
+ actual = { ok: true, form: 'worktree', files, sources: ['worktree:status-porcelain(HEAD-window)'], foreignExcluded: 0, foreignExcludedFiles: [], degradedReason: null, baseAnchor: null }
833
853
  }
834
854
  } else {
835
855
  const mod = await import('./verify-postcheck.js')
@@ -983,10 +1003,20 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
983
1003
  notes.push('baseAnchor 缺失——行数按 HEAD 未提交窗口采集(不含已提交改动,quick 模式同口径;numstat 采集失败时行列为 —)')
984
1004
  }
985
1005
 
986
- // 他者声明排除面可见(R-04 同精神):resolveReconcileActualFiles 只回计数不回名单,退栈的
987
- // 计划内文件会误显「计划未动」——note 点破归属重叠可能性,让 ⚠️ 行可解释。
1006
+ // 他者声明排除面可见(R-04 同精神)+ 退栈名单消费(scope-audit-cross-repo-blindness 改进点 3):
1007
+ // planned 文件被退栈排除但已写盘 → 该行标 suspectedForeignDone「疑似他者已实现·已退栈」,
1008
+ // 与真未动区分(名单来自 resolveReconcileActualFiles 新增的 foreignExcludedFiles)。
1009
+ const foreignExcludedSet = new Set((Array.isArray(actual.foreignExcludedFiles) ? actual.foreignExcludedFiles : []).map(toPosix))
988
1010
  if (actual.foreignExcluded > 0) {
989
- notes.push(`实际侧另有 ${actual.foreignExcluded} 个文件按他者会话声明退栈未进本表——「计划未动」行先怀疑归属重叠(声明即归属,git diff 核实)`)
1011
+ notes.push(`实际侧另有 ${actual.foreignExcluded} 个文件按他者会话声明退栈未进本表——「计划未动」行标注「疑似他者已实现·已退栈」者即此类(声明即归属,git diff 核实)`)
1012
+ }
1013
+
1014
+ // 计划侧跨仓段汇总(改进点 2):跨仓条目不进本仓对账(actual 只采主仓 git),行级标
1015
+ // crossRepo + note 交代去对应仓对账。
1016
+ const crossRepoKeys = [...new Set(plannedEntries.map(e => e.repo).filter(Boolean))]
1017
+ if (crossRepoKeys.length > 0) {
1018
+ const crossCount = plannedEntries.filter(e => e.repo).length
1019
+ notes.push(`计划侧含 ${crossCount} 个跨仓文件(repo:${crossRepoKeys.join('、')})——⊘ 行不在本仓对账面(实际侧只采主仓 git),请到对应仓跑 scope-audit 对账`)
990
1020
  }
991
1021
 
992
1022
  // —— 三态判定 ——
@@ -1006,6 +1036,12 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
1006
1036
  } else {
1007
1037
  row.planned = null
1008
1038
  row.verdict = 'unplanned'
1039
+ // 工具/平台脚手架(改进点 1 软桶):CLI/平台自装文件不占「计划外」——硬排档
1040
+ // (.worktrees//.sillyspec-platform*/knowledge/local.yaml)已被 filterDeliverableFiles
1041
+ // 滤出不进 actual;此处打标的是可能正当交付的软档(.claude/skills/、CLAUDE.md、
1042
+ // attachments/),范围视图归入「工具/平台设施」桶。
1043
+ const facility = classifyToolScaffold(path)
1044
+ if (facility) row.facility = facility
1009
1045
  }
1010
1046
  }
1011
1047
  rows.push(row)
@@ -1013,7 +1049,16 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
1013
1049
  // 计划未动:清单文件无实际改动 → 补行(行数 0/0);计划侧降级时不产(plannedEntries 为空天然不跑)
1014
1050
  for (const e of plannedEntries) {
1015
1051
  if (matchedPlanned.has(e.path)) continue
1016
- rows.push({ path: e.path, planned: e.operation || null, additions: 0, deletions: 0, kind: 'modified', verdict: 'untouched' })
1052
+ const row = { path: e.path, planned: e.operation || null, additions: 0, deletions: 0, kind: 'modified', verdict: 'untouched' }
1053
+ if (e.repo) {
1054
+ // 跨仓条目(改进点 2):不是「未动」是「本表不含」
1055
+ row.crossRepo = e.repo
1056
+ } else {
1057
+ // 退栈排除 + 盘面已存在 → 他者会话已写盘(改进点 3);NEW: 前缀剥除后比对(pathMatches 同款口径)
1058
+ const norm = toPosix(e.path).replace(/^NEW:/, '')
1059
+ if (foreignExcludedSet.has(norm) && existsSync(join(numstatRoot, norm))) row.suspectedForeignDone = true
1060
+ }
1061
+ rows.push(row)
1017
1062
  }
1018
1063
 
1019
1064
  // 冻结 patch(collectPatch,execute --done 落盘方消费):与行数同根同锚(numstatRoot +
@@ -1054,7 +1099,11 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
1054
1099
  * rows: Array<{ path: string, planned?: string|null, declared?: boolean,
1055
1100
  * additions: number|null, deletions: number|null,
1056
1101
  * kind: 'binary'|'new'|'modified'|'deleted',
1057
- * verdict?: 'planned'|'unplanned'|'untouched', attribution?: 'declared'|'soft'|'undeclared' }>,
1102
+ * verdict?: 'planned'|'unplanned'|'untouched', attribution?: 'declared'|'soft'|'undeclared',
1103
+ * crossRepo?: string, suspectedForeignDone?: boolean, facility?: 'tool'|'platform' }>,
1104
+ * (crossRepo/suspectedForeignDone/facility 为 scope-audit-cross-repo-blindness 改进点 1/2/3
1105
+ * 增量字段:跨仓条目标 repoKey / 退栈排除但已写盘的 planned 文件 / 计划外行里的工具平台
1106
+ * 脚手架桶——均为按存在性读取,缺省无字段零回归)
1058
1107
  * excluded: { foreignDeclared: Array<{ file: string, sessions: string[] }> },
1059
1108
  * note?: string|null,
1060
1109
  * gateProfile?: object|null }>}
@@ -1187,7 +1236,7 @@ export async function getFileDiff({ cwd, specBase, changeName, platformOpts, fil
1187
1236
  const located = locateQuickSessionGuard(cwd, changeName)
1188
1237
  if (located) root = dirname(located.specBase)
1189
1238
  } else {
1190
- const form = existsSync(join(sb, '.runtime', 'worktrees', changeName, 'meta.json')) ? 'worktree' : 'post-apply'
1239
+ const form = _readWorktreeMeta(sb || null, cwd, changeName) ? 'worktree' : 'post-apply'
1191
1240
  root = resolveDiffRoot(sb, changeName, form, cwd)
1192
1241
  }
1193
1242
 
@@ -1210,12 +1259,16 @@ export async function getFileDiff({ cwd, specBase, changeName, platformOpts, fil
1210
1259
  }
1211
1260
  }
1212
1261
 
1213
- /** verdict / attribution → 展示标记(人类可读面;BIN 为 binary 行数占位,— 为降级占位) */
1262
+ /** verdict / attribution → 展示标记(人类可读面;BIN 为 binary 行数占位,— 为降级占位)。
1263
+ * 行级标注优先(scope-audit-cross-repo-blindness 改进点 1/2/3):跨仓 > 疑似他者已实现 > 设施桶 */
1214
1264
  const VERDICT_LABEL = {
1215
1265
  planned: '✓ 计划内',
1216
1266
  unplanned: '⚠️ 计划外',
1217
1267
  untouched: '⚠️ 计划未动',
1218
1268
  }
1269
+ const CROSS_REPO_LABEL = '⊘ 跨仓(本表不含)'
1270
+ const SUSPECTED_FOREIGN_LABEL = '⚠️ 计划未动(疑似他者已实现·已退栈)'
1271
+ const FACILITY_LABEL = '⚠️ 计划外(工具/平台设施)'
1219
1272
  const ATTRIBUTION_LABEL = {
1220
1273
  declared: '✓ 已声明',
1221
1274
  soft: '🔍 软归属',
@@ -1278,7 +1331,10 @@ export function renderScopeAuditTable(result, opts = {}) {
1278
1331
  const stat = row.kind === 'binary'
1279
1332
  ? { a: 'BIN', d: 'BIN' }
1280
1333
  : { a: fmtCount(row.additions) ?? '—', d: fmtCount(row.deletions) ?? '—' }
1281
- const label = VERDICT_LABEL[row.verdict] || ATTRIBUTION_LABEL[row.attribution] || ''
1334
+ const label = row.crossRepo ? CROSS_REPO_LABEL
1335
+ : row.suspectedForeignDone ? SUSPECTED_FOREIGN_LABEL
1336
+ : row.facility ? FACILITY_LABEL
1337
+ : VERDICT_LABEL[row.verdict] || ATTRIBUTION_LABEL[row.attribution] || ''
1282
1338
  lines.push(` ${row.path || '(未知路径)'}${' '.repeat(Math.max(1, 40 - String(row.path || '').length))}${label}${' '.repeat(Math.max(1, 12 - label.length))}${String(stat.a).padStart(5)} ${String(stat.d).padStart(5)} ${row.kind || ''}`)
1283
1339
  }
1284
1340
  if (rows.length > shown.length) {
@@ -1287,15 +1343,29 @@ export function renderScopeAuditTable(result, opts = {}) {
1287
1343
 
1288
1344
  lines.push(` 合计:${totals.files ?? rows.length} 文件 +${totals.additions ?? 0} / -${totals.deletions ?? 0}`)
1289
1345
 
1290
- // ⚠️ 出口指引(full-flow 三态面):计划外补声明、计划未动确认遗漏
1291
- const unplanned = rows.filter(x => x && x.verdict === 'unplanned').length
1292
- const untouched = rows.filter(x => x && x.verdict === 'untouched').length
1293
- if (unplanned > 0 || untouched > 0) {
1346
+ // ⚠️ 出口指引(full-flow 三态面):计划外补声明、计划未动确认遗漏。
1347
+ // 行级标注三分(scope-audit-cross-repo-blindness 改进点 1/2/3):跨仓行/疑似他者已实现行/
1348
+ // 工具设施桶各有去向说明,不混入笼统的「计划外/计划未动」计数逼用户排查噪音。
1349
+ const unplanned = rows.filter(x => x && x.verdict === 'unplanned' && !x.facility).length
1350
+ const facility = rows.filter(x => x && x.verdict === 'unplanned' && x.facility).length
1351
+ const untouchedReal = rows.filter(x => x && x.verdict === 'untouched' && !x.suspectedForeignDone && !x.crossRepo).length
1352
+ const suspectedForeign = rows.filter(x => x && x.suspectedForeignDone).length
1353
+ const crossRepo = rows.filter(x => x && x.crossRepo).length
1354
+ if (unplanned > 0 || untouchedReal > 0) {
1294
1355
  const parts = []
1295
1356
  if (unplanned > 0) parts.push(`计划外 ${unplanned} 文件`)
1296
- if (untouched > 0) parts.push(`计划未动 ${untouched} 文件`)
1357
+ if (untouchedReal > 0) parts.push(`计划未动 ${untouchedReal} 文件`)
1297
1358
  lines.push(` ⚠️ ${parts.join('、')}——计划外请补 design.md 声明或 --output 注明原因;计划未动请确认是否遗漏`)
1298
1359
  }
1360
+ if (suspectedForeign > 0) {
1361
+ lines.push(` ℹ️ 疑似他者已实现 ${suspectedForeign} 文件(已写盘但被退栈——他者会话声明归属,本表不计未动;git diff 核实归属)`)
1362
+ }
1363
+ if (facility > 0) {
1364
+ lines.push(` ℹ️ 工具/平台设施 ${facility} 文件(CLI/平台自装脚手架,非本变更改动,不占计划外)`)
1365
+ }
1366
+ if (crossRepo > 0) {
1367
+ lines.push(` ℹ️ 跨仓 ${crossRepo} 文件(⊘ 行——本表不含,请到对应仓跑 scope-audit 对账)`)
1368
+ }
1299
1369
  const undeclared = rows.filter(x => x && x.attribution === 'undeclared').length
1300
1370
  if (undeclared > 0) lines.push(` ⚠️ 未声明 ${undeclared} 文件——超出 allowedFiles 声明面,补 --files 声明或注明原因`)
1301
1371
 
@@ -13,7 +13,7 @@
13
13
  */
14
14
 
15
15
  import { existsSync, readFileSync } from 'fs'
16
- import { join } from 'path'
16
+ import { join, isAbsolute } from 'path'
17
17
 
18
18
  // 提取 `npm|pnpm|yarn run <script>` 命令。
19
19
  // 对齐 scan-postcheck:136 的 `/npm run (\S+)/g`,扩展包管理器 + 容忍多空白。
@@ -50,7 +50,12 @@ function readScripts(pkgPath) {
50
50
  * 为单条命令计算候选 package.json 路径列表。
51
51
  *
52
52
  * 规则(blueprint task-02 + design §5 H2):
53
- * - 有 `cd <subdir> &&` 前缀 → 仅查 `<projectRoot>/<subdir>/package.json`(cd 锁定子目录,不回退根)
53
+ * - 有 `cd <subdir> &&` 前缀 → 仅查 `<subdir>/package.json`(cd 锁定子目录,不回退根)。
54
+ * subdir 为绝对路径(win 盘符 / posix 根)时不再拼 projectRoot——坑
55
+ * verify-cmd-absolute-cd-path(2026-09-15 wp 会话实证):跨仓 task 卡 verify 写
56
+ * `cd E:/PZwangge/sub-grid-security && npm run lint`,join(主仓根, 'E:/...') 拼出
57
+ * 病态路径 → package.json 恒 missing → 真实命令被误判死命令,agent 只能改写成
58
+ * ../ 相对路径绕过
54
59
  * - 无前缀 + modules 块提供 → 查每个 module.path 的子包 + 根 package.json(多候选任一命中即视为存在)
55
60
  * - 无前缀 + 无 modules → 仅查 `<projectRoot>/package.json`
56
61
  *
@@ -65,7 +70,8 @@ function resolveCandidates(text, matchIndex, projectRoot, modules) {
65
70
  const cdMatch = before.match(CD_PREFIX_RE)
66
71
  if (cdMatch) {
67
72
  const subdir = cdMatch[1].replace(/^["']|["']$/g, '')
68
- return [join(projectRoot, subdir, 'package.json')]
73
+ const base = isAbsolute(subdir) ? subdir : join(projectRoot, subdir)
74
+ return [join(base, 'package.json')]
69
75
  }
70
76
  if (modules && typeof modules === 'object') {
71
77
  const candidates = []
@@ -76,10 +76,15 @@ function extractTitle(content) {
76
76
 
77
77
  export async function cmdSearch(dir, args, opts = {}) {
78
78
  const knowledgeDir = resolveKnowledgeDir(dir, opts.specDir)
79
+ // flag-as-value 防护(坑 knowledge-flag-as-value):值以 '--' 开头视为漏传(只读命令,
80
+ // 按缺参处理落入既有 required 校验/默认值,不静默拿 flag 字面量当查询词)
79
81
  const queryIdx = args.indexOf('--query')
80
- const query = queryIdx >= 0 && args[queryIdx + 1] ? args[queryIdx + 1] : ''
82
+ const queryRaw = queryIdx >= 0 ? args[queryIdx + 1] : ''
83
+ const query = queryRaw && !queryRaw.startsWith('--') ? queryRaw : ''
81
84
  const limitIdx = args.indexOf('--limit')
82
- const limit = limitIdx >= 0 && args[limitIdx + 1] ? parseInt(args[limitIdx + 1]) : 10
85
+ const limitRaw = limitIdx >= 0 ? args[limitIdx + 1] : ''
86
+ const parsedLimit = limitRaw && !limitRaw.startsWith('--') ? parseInt(limitRaw, 10) : NaN
87
+ const limit = Number.isInteger(parsedLimit) && parsedLimit > 0 ? parsedLimit : 10
83
88
 
84
89
  if (!query) {
85
90
  output(false, {}, '--query is required')
@@ -130,8 +135,10 @@ export async function cmdSearch(dir, args, opts = {}) {
130
135
 
131
136
  export async function cmdInspect(dir, args, opts = {}) {
132
137
  const knowledgeDir = resolveKnowledgeDir(dir, opts.specDir)
138
+ // flag-as-value 防护同 cmdSearch(坑 knowledge-flag-as-value)
133
139
  const idIdx = args.indexOf('--id')
134
- const id = idIdx >= 0 && args[idIdx + 1] ? args[idIdx + 1] : ''
140
+ const idRaw = idIdx >= 0 ? args[idIdx + 1] : ''
141
+ const id = idRaw && !idRaw.startsWith('--') ? idRaw : ''
135
142
 
136
143
  if (!id) {
137
144
  output(false, {}, '--id is required')
@@ -215,6 +222,20 @@ export async function cmdValidate(dir, args, opts = {}) {
215
222
  })
216
223
  }
217
224
  }
225
+
226
+ // 2b. flag 形态关键词(坑 knowledge-flag-as-value):classify 曾把 flag-as-value 的
227
+ // 字面量 '--file' 写进路由行——目标文件存在、行格式合法,既有检查放行,此处补侦测
228
+ for (const entry of entries) {
229
+ const flagKw = entry.keywords.find(kw => kw.startsWith('--'))
230
+ if (flagKw) {
231
+ warnings.push({
232
+ code: 'flag_like_keyword',
233
+ path: 'knowledge/INDEX.md',
234
+ display: entry.display,
235
+ keyword: flagKw,
236
+ })
237
+ }
238
+ }
218
239
  }
219
240
 
220
241
  // 3. 扫描所有 .md 文件,检查是否在 INDEX 中注册