sillyspec 3.34.0 → 3.34.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.
@@ -0,0 +1,94 @@
1
+ /**
2
+ * 存量归档叙事基线回放(2026-11-04-archive-baseline-entry)——单机制双执行点的回放侧。
3
+ *
4
+ * 机制侧(decision-distill.distillIntoKnowledge baselineOnly)在归档时为未来变更产出基线;
5
+ * 本模块对存量归档重放同一条代码路径(FR-04:不另立条目体系)。规约 00 纪律:信号词表
6
+ * 一律经 {@link opts.signals} 参数传入——本文件零内置词表常量(缺省空表 = 全量有做法概述
7
+ * 段的变更),调用方(各仓 .runtime 一次性脚本)自持词表。
8
+ *
9
+ * 幂等:写盘前预扫 knowledge/decisions/*.md 已有基线(原编号 D-000@ 行的 变更: 归属),
10
+ * 已含者跳过——中断重入零重复。
11
+ */
12
+
13
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
14
+ import { join } from 'node:path'
15
+ import { distillIntoKnowledge, parseDesignOverview } from './decision-distill.js'
16
+
17
+ const BASELINE_ORIGIN_LINE = /^原编号\s*[::]\s*D-000@/m
18
+ const CHANGE_LINE = /^变更\s*[::]\s*(\S+)/m
19
+
20
+ /** knowledge/decisions 下已有基线的变更名集合(幂等预扫,逐文件一次读取)。 */
21
+ function existingBaselineChanges(knowledgeRoot) {
22
+ const dir = join(knowledgeRoot, 'decisions')
23
+ const names = new Set()
24
+ let files = []
25
+ try { files = readdirSync(dir).filter(f => f.endsWith('.md')) } catch { return names }
26
+ for (const f of files) {
27
+ let content
28
+ try { content = readFileSync(join(dir, f), 'utf8') } catch { continue }
29
+ // 基线段判定:原编号 D-000@ 行 + 段内变更行(逐段扫描——同文件多变更共存常态)
30
+ for (const block of content.split(/^## /m).slice(1)) {
31
+ if (!BASELINE_ORIGIN_LINE.test(block)) continue
32
+ const m = block.match(CHANGE_LINE)
33
+ if (m) names.add(m[1])
34
+ }
35
+ }
36
+ return names
37
+ }
38
+
39
+ /**
40
+ * 纯读规划:归档集 → 回放候选/跳过归因。
41
+ * @param {string} archiveRoot 归档根(.sillyspec/changes/archive)
42
+ * @param {string} knowledgeRoot 知识根(<repo>/.sillyspec/knowledge)
43
+ * @param {{signals?: string[]}} [opts] signals 非空时仅概述段命中任一词者入选
44
+ * @returns {{planned: Array<{change: string, dir: string}>, skipped: {noOverview: number,
45
+ * gated: number, already: number, badDir: number}}
46
+ */
47
+ export function planBaselineReplay(archiveRoot, knowledgeRoot, opts = {}) {
48
+ const signals = (opts.signals || []).map(s => String(s).toLowerCase()).filter(Boolean)
49
+ const already = existingBaselineChanges(knowledgeRoot)
50
+ const planned = []
51
+ const skipped = { noOverview: 0, gated: 0, already: already.size, badDir: 0 }
52
+ let dirs = []
53
+ try { dirs = readdirSync(archiveRoot) } catch { return { planned, skipped } }
54
+ for (const name of dirs.sort()) {
55
+ const dir = join(archiveRoot, name)
56
+ let isDir = false
57
+ try { isDir = existsSync(join(dir, 'design.md')) } catch { /* 不可读按跳过 */ }
58
+ if (!isDir) { skipped.badDir++; continue }
59
+ if (already.has(name)) continue
60
+ const overview = parseDesignOverview(dir)
61
+ if (!overview) { skipped.noOverview++; continue }
62
+ if (signals.length > 0) {
63
+ const lower = overview.toLowerCase()
64
+ if (!signals.some(s => lower.includes(s))) { skipped.gated++; continue }
65
+ }
66
+ planned.push({ change: name, dir })
67
+ }
68
+ return { planned, skipped }
69
+ }
70
+
71
+ /**
72
+ * 执行回放:逐变更 baselineOnly 蒸馏(单变更失败留痕 continue,不阻断批次)。
73
+ * @param {string} archiveRoot 归档根
74
+ * @param {string} knowledgeRoot 知识根
75
+ * @param {{signals?: string[], headHashOf?: (change: string) => string}} [opts]
76
+ * headHashOf 缺省恒 未记录(回放条目的「最近确认」为溯源占位,不冒充归档时 HEAD)
77
+ * @returns {{applied: number, failed: Array<{change: string, error: string}>,
78
+ * planned: number, skipped: object}}
79
+ */
80
+ export function applyBaselineReplay(archiveRoot, knowledgeRoot, opts = {}) {
81
+ const { planned, skipped } = planBaselineReplay(archiveRoot, knowledgeRoot, opts)
82
+ const applied = []
83
+ const failed = []
84
+ for (const { change, dir } of planned) {
85
+ try {
86
+ const headHash = (opts.headHashOf && opts.headHashOf(change)) || '未记录'
87
+ const r = distillIntoKnowledge(dir, knowledgeRoot, headHash, null, { baselineOnly: true })
88
+ if (r.written && r.written.length > 0) applied.push(change)
89
+ } catch (e) {
90
+ failed.push({ change, error: String((e && e.message) || e) })
91
+ }
92
+ }
93
+ return { applied: applied.length, appliedChanges: applied, failed, planned: planned.length, skipped }
94
+ }
@@ -35,7 +35,7 @@ export function isDistillAbnormal(res) {
35
35
  */
36
36
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
37
37
  import { modulePathMatches } from './modules.js'
38
- import { basename, dirname, join } from 'node:path'
38
+ import { basename, dirname, join, relative } from 'node:path'
39
39
  import { KNOWLEDGE_STATUSES, KNOWLEDGE_TYPES } from './decision-values.js'
40
40
 
41
41
  // 入选集=正典单一源派生(2026-10-20-decision-caliber-unify)。历史:五类集漏选 8 条
@@ -444,6 +444,94 @@ function sameDecisionNumber(s, entry) {
444
444
  return (s.change || null) === (entry.change || null) && (s.number === entry.number || s.originNumber === entry.number)
445
445
  }
446
446
 
447
+ // ---------------------------------------------------------------------------
448
+ // 叙事基线条目(2026-11-04-archive-baseline-entry)——事故复盘叙事入知识面
449
+ // ---------------------------------------------------------------------------
450
+
451
+ /** design.md「做法概述」段提取(剥模板问题行与机器草稿注释,空段返回 null)。
452
+ * 段边界 = ##/### 节头(做法概述 起,下一节头止);问题钉死行(「本变更怎么解决问题…」)
453
+ * 剥除——答案才是叙事;HTML 注释(`<!-- MACHINE-DRAFT:… -->` 草稿溯源标记等)整行剥除、
454
+ * 行内片段剥除(平台仓实证:归档 design 概述段首行常为草稿标记,混入即污染标题首句)。
455
+ * 正文折单行(oneLine 同口径,检索面词面命中吃的是字符序列非段落结构)。 */
456
+ export function parseDesignOverview(changeDir) {
457
+ let content
458
+ try { content = readFileSync(join(changeDir, 'design.md'), 'utf8') } catch { return null }
459
+ const body = []
460
+ let inside = false
461
+ for (const line of content.replace(/\r\n/g, '\n').split('\n')) {
462
+ if (/^#{2,3}\s/.test(line)) {
463
+ if (inside) break
464
+ inside = /^#{2,3}\s*做法概述\s*$/.test(line.trim())
465
+ continue
466
+ }
467
+ if (inside) body.push(line.trim())
468
+ }
469
+ const text = body
470
+ .filter(l => l && !/^本变更怎么解决问题/.test(l) && !/^<!--.*-->$/.test(l))
471
+ .map(l => l.replace(/<!--.*?-->/g, '').trim())
472
+ .filter(Boolean)
473
+ .join(' ')
474
+ return text || null
475
+ }
476
+
477
+ /** 基线资格与构造:合格返回可直接进 distill batch 的条目对象,否则 null。
478
+ * 资格四门(FR-01):做法概述段非平凡(≥50 字);交付面(剥 .sillyspec/ 内部路径)×
479
+ * 模块匹配 ≥1 域(docs-only 变更无叙事锚点,跳);decisions.md 未占用 D-000;标题可派生。
480
+ * 幂等键 = D-000@v1 + 变更名(与人工条目同机器:sameDecisionNumber 原编号匹配原位更新)。
481
+ * 锚点 = relative(仓根, changeDir)/design.md——蒸馏时点在归档前则指活跃目录,归档后
482
+ * 重蒸幂等更新收敛到归档路径(自愈,无需双形态)。 */
483
+ export function buildBaselineEntry(changeDir, knowledgeRoot, parsedEntries, moduleIndex) {
484
+ const overview = parseDesignOverview(changeDir)
485
+ if (!overview || overview.length < 50) return null
486
+ if ((parsedEntries || []).some(e => e.number === 'D-000')) return null
487
+ let patchFiles = []
488
+ try {
489
+ const patchPath = join(changeDir, 'change-patch.json')
490
+ if (existsSync(patchPath)) {
491
+ const patch = JSON.parse(readFileSync(patchPath, 'utf8'))
492
+ patchFiles = (Array.isArray(patch.files) ? patch.files : [])
493
+ .map(f => String(f).replace(/\\/g, '/')).filter(f => f && !f.startsWith('.sillyspec/'))
494
+ }
495
+ } catch { /* 坏 patch 按无交付面处理 */ }
496
+ if (patchFiles.length === 0) return null
497
+ // 主域单落(不做多域扇出):交付面逐文件模块计票取 argmax(平票取首遇)。理由:基线是
498
+ // 叙事补充而非域裁决——跨模块交付面扇出会让同一条基线重复占据各域文件(平台仓首放实证
499
+ // 227 条×均值 3 域=703 块),且长叙事灌满多文件后挤占词片回退的「≤5% 条目复现上限」,
500
+ // 反而把症状词查询挤出命中面。主域由多数交付文件的归属决定,语义即「这个变更主要动哪」。
501
+ const tally = new Map()
502
+ for (const f of patchFiles) {
503
+ for (const id of matchImpactsToModules(f, moduleIndex)) tally.set(id, (tally.get(id) || 0) + 1)
504
+ }
505
+ const primary = [...tally.entries()].sort((a, b) => b[1] - a[1])[0]
506
+ if (!primary) return null
507
+ const domains = [sanitizeDomain(primary[0])]
508
+ const changeName = basename(changeDir)
509
+ let changeTitle = ''
510
+ try {
511
+ const fs = readFileSync(join(changeDir, 'flow-state.yaml'), 'utf8')
512
+ changeTitle = ((fs.match(/^title:\s*(.+)$/m) || [])[1] || '').trim().replace(/^['"]|['"]$/g, '')
513
+ } catch { /* 无 flow-state 落变更名 */ }
514
+ const firstSentence = (() => {
515
+ const m = overview.match(/^[^。]*。/)
516
+ const s = (m ? m[0] : overview.slice(0, 40)).trim()
517
+ return s.length > 80 ? s.slice(0, 80) : s
518
+ })()
519
+ let anchor = ''
520
+ try {
521
+ const repoRoot = dirname(dirname(knowledgeRoot))
522
+ anchor = relative(repoRoot, changeDir).replace(/\\/g, '/') + '/design.md'
523
+ } catch { /* 相对化失败按未记录 */ }
524
+ return {
525
+ id: 'D-000@v1', number: 'D-000', version: 1,
526
+ title: `${changeTitle || changeName}——${firstSentence}`,
527
+ type: 'process', status: 'implemented', selected: 'implemented',
528
+ answer: overview,
529
+ domains,
530
+ files: patchFiles.slice(0, 20),
531
+ anchor: anchor || undefined,
532
+ }
533
+ }
534
+
447
535
  function applyEntryToSections(sections, entry, headHash, sectionNumber, originId) {
448
536
  const change = entry.change || null
449
537
  const sameNumber = sections.filter(s => sameDecisionNumber(s, entry))
@@ -590,13 +678,28 @@ export function syncIndexRoutingLines(knowledgeRoot, opts = {}) {
590
678
  * skipped 非空 = 无 decisions.md / 无入选条目(零输出);needsWait 非空 = 有 rejected 条目
591
679
  * 缺否决理由/复潮条件被拦下(该条目未写盘,其余条目照常提炼)。
592
680
  */
593
- export function distillIntoKnowledge(changeDir, knowledgeRoot, headHash, moduleIndex = null) {
681
+ export function distillIntoKnowledge(changeDir, knowledgeRoot, headHash, moduleIndex = null, opts = {}) {
594
682
  // 变更名限定(坑 distill-cross-change-supersede):条目幂等键 = 号+变更。changeDir 末段
595
683
  // 即变更名(changes/<name> 与 archive 后的 changes/archive/<name> 同为 <name>)。
596
684
  const changeName = basename(changeDir)
597
685
  const parsed = parseDecisions(changeDir)
686
+ const idx = moduleIndex || discoverModuleIndex(knowledgeRoot)
687
+ // 叙事基线条目(2026-11-04-archive-baseline-entry):design 做法概述的背景叙事(事故复盘/
688
+ // 实证取舍)此前只活在归档与代码头注释——蒸馏源面不含该段,症状词查询结构性零命中。
689
+ // 合格变更机械产出恰好一条 D-000@v1 基线(资格门/字段构造见 buildBaselineEntry),与人工
690
+ // 条目同机器入库(幂等键 号+原编号+变更)。baselineOnly(存量回放):只写基线、丢弃人工
691
+ // 条目面——同变更人工存量「最近确认」等字段零触碰。
692
+ const baseline = buildBaselineEntry(changeDir, knowledgeRoot, parsed.entries, idx)
693
+ if (opts.baselineOnly) {
694
+ if (!baseline) return { written: [], skipped: 'baselineOnly:变更不合格产基线(无做法概述段/无可匹配模块交付面/D-000 被占用),零输出', needsWait: null }
695
+ parsed.entries = [baseline]
696
+ } else if (baseline) {
697
+ parsed.entries.push(baseline)
698
+ }
598
699
  if (parsed.missing) {
599
- return { written: [], skipped: `decisions.md 不存在(${join(changeDir, 'decisions.md')}),零输出`, needsWait: null }
700
+ if (!baseline) {
701
+ return { written: [], skipped: `decisions.md 不存在(${join(changeDir, 'decisions.md')}),零输出`, needsWait: null }
702
+ }
600
703
  }
601
704
  // 0 条告警(坑 decision-flat-list-silent-zero,2026-09-10 驾驭小结第四批②):文件有内容、
602
705
  // 正文含 D-xxx 形态却 0 条可解析 = 标题式/扁平列表式两种格式都不匹配——此前静默放行
@@ -659,7 +762,11 @@ export function distillIntoKnowledge(changeDir, knowledgeRoot, headHash, moduleI
659
762
  !(e.anchor && e.anchor.trim() && e.anchor.trim() !== NOT_RECORDED)
660
763
  )
661
764
  if (deliverableFiles.length > 0 && noAssociation.length > 0) {
662
- if (batch.length === 1) {
765
+ // 单决策判定按非基线计数(评审 P2 修正,2026-11-04-archive-baseline-entry):基线恒在批内,
766
+ // batch.length===1 会被恰一条人工决策+基线的变更误判成多决策——人工条目文件填充从全量
767
+ // 交付面降级为正文信号匹配。基线自身恒有 files,不进 noAssociation,计数只看人工条目。
768
+ const manualCount = batch.filter(e => e.number !== 'D-000').length
769
+ if (manualCount === 1) {
663
770
  // 单决策:全部交付文件
664
771
  noAssociation[0].files = deliverableFiles.slice(0, 20)
665
772
  console.log(` 📎 文件字段自动填充:单决策变更 ← 交付面 ${deliverableFiles.length} 个文件`)
@@ -681,7 +788,6 @@ export function distillIntoKnowledge(changeDir, knowledgeRoot, headHash, moduleI
681
788
 
682
789
  const decisionsDir = join(knowledgeRoot, 'decisions')
683
790
  mkdirSync(decisionsDir, { recursive: true })
684
- const idx = moduleIndex || discoverModuleIndex(knowledgeRoot)
685
791
 
686
792
  // 域文件 store:域 → { preamble, sections, dirty }(按需加载,批量写盘)
687
793
  const store = new Map()
@@ -813,6 +813,59 @@ export function detectDocBloat(specRoot) {
813
813
  }
814
814
  // ══ 2026-09-09-doctor-noai:净增三类探测器(只读 fail-soft,skipped 带内降级)══
815
815
 
816
+ // ── 常驻注入面体积预算(2026-11-04-doctor-injection-budget)──────────────────
817
+ // Claude Code 系 harness 压缩后将已装载技能截断到 20,000 字符(superpowers #2481 实证:
818
+ // SDD 技能截断丢五轮修复上限,单任务跑 18 轮修复);AGENTS/CLAUDE.md 类常驻指引超线同样
819
+ // 吃全局预算。WARN 制不改收口通过性——阈值先对齐压缩截断线,待真实截断事故校准后再升格。
820
+ const INJECTION_BUDGET_BYTES = 20000;
821
+
822
+ /**
823
+ * 常驻注入面体积预算(WARN 只读):逐文件 statSync 扫描仓根 AGENTS.md / CLAUDE.md /
824
+ * SKILL.md、templates/agents-instruction.md(模板源,本仓开发面)与 .claude/skills 下
825
+ * 各技能的 SKILL.md。
826
+ * 预算内回显最大面(体积可观测);无任何注入面(非 init 仓)跳过不误报。
827
+ */
828
+ export function detectInjectionBudget(cwd) {
829
+ const dim = { name: 'injection_budget', label: '常驻注入面体积预算(压缩生存)', safe_actions: [] };
830
+ const oversized = [];
831
+ let scanned = 0;
832
+ let maxSeen = null;
833
+ const consider = (abs, rel) => {
834
+ try {
835
+ const size = statSync(abs).size;
836
+ scanned += 1;
837
+ if (!maxSeen || size > maxSeen.size) maxSeen = { rel, size };
838
+ if (size > INJECTION_BUDGET_BYTES) oversized.push(`${rel}(${(size / 1024).toFixed(1)}KB)`);
839
+ } catch { /* 单文件 stat 失败跳过(fail-soft 同 detectDocBloat 口径) */ }
840
+ };
841
+ for (const f of ['AGENTS.md', 'CLAUDE.md', 'SKILL.md', join('templates', 'agents-instruction.md')]) {
842
+ const abs = join(cwd, f);
843
+ if (existsSync(abs)) consider(abs, f.split('\\').join('/'));
844
+ }
845
+ try {
846
+ const skillsDir = join(cwd, '.claude', 'skills');
847
+ if (existsSync(skillsDir)) {
848
+ for (const e of readdirSync(skillsDir, { withFileTypes: true })) {
849
+ if (!e.isDirectory()) continue;
850
+ const skillMd = join(skillsDir, e.name, 'SKILL.md');
851
+ if (existsSync(skillMd)) consider(skillMd, `.claude/skills/${e.name}/SKILL.md`);
852
+ }
853
+ }
854
+ } catch { /* skills 目录不可读跳过 */ }
855
+ if (scanned === 0) {
856
+ return { ...dim, pass: true, severity: null, findings: ['无 AGENTS/CLAUDE/SKILL.md 注入面(非 init 仓或未安装,跳过)'] };
857
+ }
858
+ if (oversized.length > 0) {
859
+ dim.safe_actions.push({ action: '按 superpowers references/ 拆分模式瘦身:SKILL.md 只留全程骨架,步骤局部内容拆 references/ 到步现读(压缩后截断线内自洽)', risk: 'low' });
860
+ }
861
+ dim.findings = oversized.length > 0
862
+ ? [`超 ${INJECTION_BUDGET_BYTES} 字节压缩截断线(超线内容压缩后对 agent 不可见,纪律静默丢失):${oversized.join('、')}`]
863
+ : [`常驻注入面在预算内(扫描 ${scanned} 个面,最大 ${maxSeen.rel} ${(maxSeen.size / 1024).toFixed(1)}KB / 预算 20KB)`];
864
+ return oversized.length === 0
865
+ ? { ...dim, pass: true, severity: CHECK_SEVERITY.PASSED }
866
+ : { ...dim, pass: false, severity: CHECK_SEVERITY.WARNING };
867
+ }
868
+
816
869
  /**
817
870
  * worktree 健康(FR-02):复用 WorktreeManager.doctor()(薄适配不重复探测——设计审查附加
818
871
  * gap 修正)+ sillyspec/* 残留分支对账(vs changes/ 活跃目录)。锚定主仓根
@@ -1654,6 +1707,8 @@ export async function runDoctorDiagnostics({ cwd }) {
1654
1707
  const authDb = (multiDb.dbs || []).find((d) => d.role === DB_ROLE.AUTHORITY);
1655
1708
  const executeMismatch = detectExecuteProgressPlanMismatch(authoritySpecDir, authDb);
1656
1709
  const docBloat = detectDocBloat(authoritySpecDir);
1710
+ // 常驻注入面体积预算(2026-11-04-doctor-injection-budget,WARN 只读可观测)
1711
+ const injectionBudget = detectInjectionBudget(cwd);
1657
1712
  const repoNativeChain = detectRepoNativeChain(cwd, pointer);
1658
1713
  // P2-2-②:file-lifecycle 文档欠账(git behind 事实计算,治「改了生命周期代码忘同步文档」)
1659
1714
  const lifecycleDoc = detectLifecycleDocStaleness(cwd);
@@ -1689,7 +1744,7 @@ export async function runDoctorDiagnostics({ cwd }) {
1689
1744
  // FR-07(2026-10-21-p0-p1-hardening / dsh z2 实证):local.yaml 完整性——此前 doctor 对
1690
1745
  // mojibake 坏文件完全失明(agent 手改炸文件后 doctor 报一堆别项零提示,等用户人工恢复 2h13m)
1691
1746
  const localYamlIntegrity = detectLocalYamlIntegrity(cwd);
1692
- const dimensions = [multiDb, pointerHealth, changesSplit, changeDb, executeMismatch, docBloat, repoNativeChain, lifecycleDoc, worktreeHealth, crossWorktreeHealth, buildEnv, mcpEndpoints, applyManifestDrift, selfMaintenanceTax, archiveIntegrity, ceremonyShadow, gateSnapshotLeak, knowledgeGraphIntegrity, localYamlIntegrity];
1747
+ const dimensions = [multiDb, pointerHealth, changesSplit, changeDb, executeMismatch, docBloat, injectionBudget, repoNativeChain, lifecycleDoc, worktreeHealth, crossWorktreeHealth, buildEnv, mcpEndpoints, applyManifestDrift, selfMaintenanceTax, archiveIntegrity, ceremonyShadow, gateSnapshotLeak, knowledgeGraphIntegrity, localYamlIntegrity];
1693
1748
 
1694
1749
  return {
1695
1750
  dimensions,
package/src/flow.js CHANGED
@@ -403,9 +403,13 @@ export async function flowKnowledgeDigest({ specBase, change, changeDir, input,
403
403
  ...(km.routeHits || []).slice(0, 3).map((e) => (e.anchor ? `${e.file}#${e.anchor}` : e.file)),
404
404
  ...scopeMatchedFiles,
405
405
  ])].slice(0, 12)
406
- if (thinMatchedFiles.length > 0 || rejected.length > 0 || scopeMatchedFiles.length > 0) {
407
- try {
408
- const { appendKnowledgeHit } = await import('./knowledge-hits.js')
406
+ // 遥测(FR-04,2026-11-05-knowledge-search-observability):channel 单源记录(调优/画像
407
+ // 数据地基);真零命中也落盘(zeroHit 标记 + 空 matchedFiles——stats 文件矩阵零贡献,
408
+ // 真实零命中率恰是向量层的目标场景,此前「仅命中才落盘」把它定义没了)。fail-soft 不阻断注入。
409
+ try {
410
+ const { appendKnowledgeHit } = await import('./knowledge-hits.js')
411
+ const { resultChannel } = await import('./knowledge-vector.js')
412
+ if (thinMatchedFiles.length > 0 || rejected.length > 0 || scopeMatchedFiles.length > 0) {
409
413
  appendKnowledgeHit(join(specBase, '.runtime'), {
410
414
  type: 'inject',
411
415
  change,
@@ -415,10 +419,24 @@ export async function flowKnowledgeDigest({ specBase, change, changeDir, input,
415
419
  mentionedFiles,
416
420
  ...(scopeMatchedFiles.length > 0 ? { scopeMatchedFiles } : {}),
417
421
  rejectedCount: rejected.length,
422
+ channel: resultChannel(km),
418
423
  source: 'thin-start',
419
424
  })
420
- } catch { /* 遥测 fail-soft(R-04):hits 落盘失败不影响注入本体 */ }
421
- }
425
+ } else {
426
+ appendKnowledgeHit(join(specBase, '.runtime'), {
427
+ type: 'inject',
428
+ change,
429
+ query: kmQuery,
430
+ matchedFiles: [],
431
+ injectedFiles: [],
432
+ mentionedFiles: [],
433
+ rejectedCount: 0,
434
+ channel: resultChannel(km),
435
+ zeroHit: true,
436
+ source: 'thin-start',
437
+ })
438
+ }
439
+ } catch { /* 遥测 fail-soft(R-04):hits 落盘失败不影响注入本体 */ }
422
440
  if (domains.length === 0 && rejected.length === 0 && !km.matched) {
423
441
  lines.length = 0
424
442
  lines.push(`🧠 知识注入(轻量道读取面):语料未命中知识库(无触达域依据、无否决命中、INDEX 无对齐条目)——可自行查 knowledge/INDEX.md。`)
@@ -787,7 +805,7 @@ export async function cmdFlowStart({ change, input, title: titleFlag = null, thi
787
805
  ? `🖼️ 原型在场(${prototypePaths.length} 个 HTML):实现前必看,界面/交互/流程按原型对齐;有出入以 design.md 承诺为准并在回复中说明。`
788
806
  : null,
789
807
  `【你要做的】① spec 阶段先定稿任务面:把 tasks.md 改写为工作分解(每行=可独立完成并当场验证的一步:做什么+怎么验证;全 \`- [ ]\`——代码开动前工作队列先存在);`,
790
- `🛑 断点①(机器门):spec 面(任务面+测试绑定槽)定稿后把摘要给用户看 → flow approve --change ${change}(或用户显式 --autopilot)——批准后才动码。design 四节槽收编豁免(以 brainstorm design 为准)。`,
808
+ `🛑 断点①(机器门):spec 面(任务面+测试绑定槽)定稿后用 AskUserQuestion 把摘要与选项(批准开始实现/需要调整/否决)呈现给用户,不要用纯文字提问等回复 → 用户批准后 flow approve --change ${change}(或用户显式 --autopilot)——批准后才动码。design 四节槽收编豁免(以 brainstorm design 为准)。`,
791
809
  `② 执行走任务循环:对 tasks.md 每个 pending 行——展示「Working on task N/M: <任务>」→ 做 → 测试绿后当场勾一格(\`- [ ]\`→\`- [x]\`:sillyspec task tick --change ${change} --task task-NN 或直接 Edit)→ 下一行;全勾后 flow done(收口硬门拒单拍多格勾选)。design/decisions 是本变更的承诺锚(flow done 豁免 design 四节槽,以其为准)。`,
792
810
  `requirements 测试绑定槽(收编追加)每条 FR 至少一行作答;写码前作答(动码前,与任务面定稿同批)。`,
793
811
  `✅ 任务面归你(工作分解契约):tasks.md 是你的工作队列(机器种子可增删改,保持 \`- [ ] task-NN:\` 行形态)——CLI 以 checkbox 为进度状态机(task tick/flow status 实时读它),TodoWrite 类工具不替代;`,
@@ -1184,7 +1202,7 @@ export async function cmdFlowStart({ change, input, title: titleFlag = null, thi
1184
1202
  // 顺序正典(2026-10-19-thin-order-canon):行动句必须是顺序句——实证(artifact 时间戳
1185
1203
  // 还原+跨仓 14 例抽样)agent 跟从【你要做的】首义行动,旧句「直接干活」把动码提前、
1186
1204
  // 文档与批准沦为收口仪式;正典=先答 spec 面 → 批准 → 才动码(读代码探究不算动码)。
1187
- `【你要做的】先答 spec 面,批准后才动码:① 答 spec——requirements FR 行为句+测试绑定行、design.md 四节作答(动码前;读代码探究不算动码);② 断点①——摘要给用户看 → flow approve --change ${change}(或用户显式 --autopilot);③ 才干活——写测试、改代码。治理工件骨架 CLI 已起草、收口机器做(协议记账单位=change 级)。`,
1205
+ `【你要做的】先答 spec 面,批准后才动码:① 答 spec——requirements FR 行为句+测试绑定行、design.md 四节作答(动码前;读代码探究不算动码);② 断点①——用 AskUserQuestion 呈现摘要+选项(批准开始实现/需要调整/否决),用户批准后 flow approve --change ${change}(或用户显式 --autopilot);③ 才干活——写测试、改代码。治理工件骨架 CLI 已起草、收口机器做(协议记账单位=change 级)。`,
1188
1206
  // 收口拒收红线前置(2026-10-11-flow-start-redline-frontload):钉死/空槽/绑定三面都是文档
1189
1207
  // 先行校验,实证第一次最容易踩(四问文本被改写、区间 task-NN 写法零证据被拒)——横幅
1190
1208
  // 头部先行告知,收口才暴露的规则前置到动笔前。
@@ -1221,16 +1239,16 @@ export async function cmdFlowStart({ change, input, title: titleFlag = null, thi
1221
1239
  `💡 执行期用户插入决策方向(有实现影响、约束后续变更的取舍/否决/定调)→ 当场 sillyspec decisions add --change ${change} --id D-001 --title "…" 落盘(只留会话=收口档案不可见、跨变更复潮无防);改变承诺面的同步 design/tasks 对应节。`,
1222
1240
  ``,
1223
1241
  `🛑 三断点纪律(顺序纪律:①未过不动码;②③为汇报点——用户没说「全跑完」就必须在每个断点向用户汇报并等确认):`,
1224
- ` ① spec 断点【机器门】:填完 FR 区和 design 四节后,把摘要给用户看(FR 条目+盲维作答+方案概述),`,
1242
+ ` ① spec 断点【机器门】:填完 FR 区和 design 四节后,用 AskUserQuestion 把摘要(FR 条目+盲维作答+方案概述)与选项(批准开始实现/需要调整/否决)呈现给用户——不要用纯文字提问等回复;`,
1225
1243
  ` 起草完先自查:sillyspec flow verify-drafts --change ${change}(与批准门同判定——自查通过再请批准,用户一次过);`,
1226
- ` 用户确认后请其运行 sillyspec flow approve --change ${change}(批准留痕)再动手写代码——`,
1244
+ ` 用户在 AskUserQuestion 中确认批准后请其运行 sillyspec flow approve --change ${change}(批准留痕)再动手写代码——`,
1227
1245
  ` agent 代跑时如实声明通道:--user-confirmed(转述用户已确认)/ --self-approved(agent 自批),`,
1228
1246
  ` 无 flag 按 unattested 留痕(审计面无法区分用户确认与自批)——2026-10-12-approve-channel-attest。`,
1229
1247
  ` 方案错了返工最贵;批准是动码前置义务(未批准且未声明 --autopilot 时 flow done 亦拒收——2026-10-04-thin-docs-v2);`,
1230
1248
  ` 用户否掉方案 → flow reject --change ${change} --reason <理由>(拒后批准须显式通道再确认)。`,
1231
- ` ② 执行断点:写完代码跑完测试后,把测试结果(过了几个/挂了什么)给用户看,`,
1232
- ` 等用户确认再跑收口;汇报顺带对账执行期用户决策(decisions 条目列一下,无则说明无)。`,
1233
- ` ③ 归档断点:flow done 跑完后(无论过/拒),把结果(归档成功/被什么拦了)给用户看。`,
1249
+ ` ② 执行断点:写完代码跑完测试后,用 AskUserQuestion 把测试结果(过了几个/挂了什么)与选项(确认收口/继续修/有问题)呈现给用户,`,
1250
+ ` 等用户选择再跑收口;汇报顺带对账执行期用户决策(decisions 条目列一下,无则说明无)。`,
1251
+ ` ③ 归档断点:flow done 跑完后(无论过/拒),用 AskUserQuestion 把结果(归档成功/被什么拦了)呈现给用户(选项:结束/处理遗留问题)。`,
1234
1252
  ` 用户明确说「直接跑完/不用问我」→ 重跑 flow start --change ${change} --autopilot 声明豁免(用户显式授权、留痕——非 agent 自选补救通道),`,
1235
1253
  ` ①②③人审全免(agent 自主跑到底)。`,
1236
1254
  ` 随时可查进度:sillyspec flow status --change ${change}`,
@@ -1283,12 +1301,12 @@ function printRecoveryBriefing({ cwd, specBase, change, changeDir, runtimeRoot,
1283
1301
  `- 六子步标记:${done.length ? `已完成 ${done.join('/')}` : '(无)'}${left.length ? `;待办 ${left.join('/')}` : ';全部完成'}`,
1284
1302
  // 顺序正典(2026-10-19-thin-order-canon):恢复面带批准状态——重入时 agent 只看简报,
1285
1303
  // 未批准时「下一步」必须指向补批而非「继续干活」(防先码后批在恢复路径复发)。
1286
- `- spec 批准:${st.spec_approved ? `✅ 已批准(channel=${approveChannelOf(st)})` : st.autopilot ? '🔑 autopilot 豁免(用户显式授权)' : '⬜ 未批准——动码前先过断点①(摘要给用户 → flow approve)'}`,
1304
+ `- spec 批准:${st.spec_approved ? `✅ 已批准(channel=${approveChannelOf(st)})` : st.autopilot ? '🔑 autopilot 豁免(用户显式授权)' : '⬜ 未批准——动码前先过断点①(用 AskUserQuestion 呈请批准 → flow approve)'}`,
1287
1305
  // 红线重申(2026-10-11-flow-start-redline-frontload):重入不出首进横幅——钉死规则与
1288
1306
  // task-NN token 形态随简报补位,断点续跑生命周期内规则不丢。
1289
1307
  `- 红线重申:design 四问文本/FR 标题锚逐字钉死勿改写(改写即拒收);勾选证据提交消息逐个带 task-NN 或 /、,连写(「task-01..task-05」区间写法不认)。`,
1290
1308
  `- 剩什么:${left.length === 0 && dirty === 0 ? '活已干完' : dirty > 0 ? '活未干完(继续改代码)' : '收尾待裁决'}`,
1291
- `- 下一步:${left.length === 0 ? `sillyspec flow done --change ${change}` : !st.spec_approved && !st.autopilot ? `未批准先补批(摘要给用户 → sillyspec flow approve --change ${change});随后继续干活,干完跑 flow done(断点续)` : `继续干活;干完跑 flow done(断点续)`}`,
1309
+ `- 下一步:${left.length === 0 ? `sillyspec flow done --change ${change}` : !st.spec_approved && !st.autopilot ? `未批准先补批(用 AskUserQuestion 呈请批准 → sillyspec flow approve --change ${change});随后继续干活,干完跑 flow done(断点续)` : `继续干活;干完跑 flow done(断点续)`}`,
1292
1310
  ...(digestLines.length > 0 ? ['', ...digestLines] : []),
1293
1311
  // 尾部原型锚(2026-10-14-guide-anchor-adopt-proto):收编/含原型变更重入时原型指针不隐身
1294
1312
  //(首进 adopt 输出的 🖼️ 行在会话切换后已丢,简报尾部补可发现面;置于 digestLines 后=绝对
@@ -1434,7 +1452,7 @@ export async function cmdFlowDone({ change, cwd, specBase, runtimeRootOpt = null
1434
1452
  // 实证下无牙;v2 起批准是收口硬前提,autopilot 是用户显式豁免通道)
1435
1453
  if (!st.autopilot && !st.spec_approved) {
1436
1454
  console.error(`❌ spec 断点未批准:批准是动码前置义务(不是收口补救动作)——本变更缺方案确认留痕`)
1437
- console.error(` 补批通道(留痕可审计):把 FR 条目+design 四节摘要给用户看,用户确认后由用户运行:`)
1455
+ console.error(` 补批通道(留痕可审计):用 AskUserQuestion 把 FR 条目+design 四节摘要与选项(批准/需要调整/否决)呈现给用户,用户批准后由用户运行:`)
1438
1456
  console.error(` sillyspec flow approve --change ${change} [--user-confirmed|--self-approved]`)
1439
1457
  console.error(` 或用户显式豁免(须用户授权,非 agent 自选):重跑 flow start --change ${change} --autopilot(声明留痕)`)
1440
1458
  reportMidFail('artifacts')
@@ -2888,7 +2906,7 @@ export async function cmdFlowDone({ change, cwd, specBase, runtimeRootOpt = null
2888
2906
  }
2889
2907
  appendTelemetry(reviewOutcome) // 成功收口(失败面已在各失败路径提前落账,appendTelemetry 单点)
2890
2908
 
2891
- console.log(`📦 归档断点:全部子步完成,即将归档——agent 把收口结果(子步清单+评审结论+验证回执)给用户看。`)
2909
+ console.log(`📦 归档断点:全部子步完成,即将归档——agent 用 AskUserQuestion 把收口结果(子步清单+评审结论+验证回执)与选项(结束/处理遗留问题)呈现给用户。`)
2892
2910
  console.log(`✅ flow done 完成(2/2 协议调用收口):${change}——${SUBSTEPS.length} 子步 ${doneList.join('、')};change 已归档注销。`)
2893
2911
  // Git 历史整理指引(2026-09-25-thin-freeze-git-hygiene):多轮中间提交可压扁为单提交——审计
2894
2912
  // 真相在 change.patch(sha256 锚定)与归档产物,不依赖历史形态;baseline 在 change-patch.json。
@@ -2968,7 +2986,7 @@ async function cmdFlowVerifyDrafts({ change, cwd, specBase, runtimeRootOpt = nul
2968
2986
  } else if (passed) {
2969
2987
  for (const a of r.advisories) console.warn(`⚠️ [门柱漂移 advisory] ${a}(不阻断批准)`)
2970
2988
  if (r.degraded) console.log(`ℹ️ 自查降级:draft-ledger 不在此机(.runtime 机本位/存量变更)——空槽兜底校验通过(v2 纯 markdown 无法对锚校验,如实降级)`)
2971
- console.log(`✅ spec 面自查通过(0 violations)——通过≠批准:请把摘要给用户看,用户确认后 sillyspec flow approve --change ${change}(批准留痕后动码)`)
2989
+ console.log(`✅ spec 面自查通过(0 violations)——通过≠批准:用 AskUserQuestion 把摘要与选项(批准开始实现/需要调整/否决)呈现给用户,用户批准后 sillyspec flow approve --change ${change}(批准留痕后动码)`)
2972
2990
  // 重叠路由 advisory(2026-10-28-overlap-route FR-04):批准前最后一个高频触点——当前
2973
2991
  // 变更声明与他人活跃变更相交时一行分流提示。fail-open 静默:检测异常不拦批准不影响退出码。
2974
2992
  try {
@@ -3016,7 +3034,7 @@ async function cmdFlowApprove({ change, cwd, specBase, by = null, channelFlag =
3016
3034
  const st = readFlowState(changeDir) || {}
3017
3035
  if (st.spec_rejected && !st.spec_rejected_superseded_at && !channelFlag) {
3018
3036
  console.error(`❌ 本变更存在未翻越的拒绝记录(spec_rejected,reason=${String(st.spec_rejected_reason || '?').slice(0, 80)})——拒绝后的批准必须显式声明通道:`)
3019
- console.error(` 用户重新确认后:sillyspec flow approve --change ${change} --user-confirmed(agent 转述用户确认)`)
3037
+ console.error(` 用 AskUserQuestion 征得用户重新确认后:sillyspec flow approve --change ${change} --user-confirmed(agent 转述用户确认)`)
3020
3038
  console.error(` agent 自批如实声明:sillyspec flow approve --change ${change} --self-approved`)
3021
3039
  console.error(` (拒绝后派生授权须显式再确认——2026-10-12-approve-channel-attest)`)
3022
3040
  process.exit(2)
@@ -3376,7 +3394,7 @@ export async function cmdFlow(args, cwd, specDir = null, opts = {}) {
3376
3394
  `📋 ${change}`,
3377
3395
  ...(changeTitle ? [` 标题:${changeTitle}`] : []),
3378
3396
  ` 阶段:${phase}`,
3379
- ` spec 批准:${specApproved ? '✅ 已批准' : autopilotOn ? '🔑 autopilot 豁免(用户显式授权)' : '⬜ 未批准(动码前先过断点①)'}`,
3397
+ ` spec 批准:${specApproved ? '✅ 已批准' : autopilotOn ? '🔑 autopilot 豁免(用户显式授权)' : '⬜ 未批准(动码前先过断点①:用 AskUserQuestion 呈请批准)'}`,
3380
3398
  ` design 槽:${designFilled ? '✅ 已填' : '⬜ 未填'}|FR 区:${frFilled ? '✅ 已填' : '⬜ 未填'}|绑定槽:${bindingsFilled}/${bindingsTotal}`,
3381
3399
  hasTasks ? ` 任务勾选:${tasksChecked}/${tasksTotal}` : null,
3382
3400
  ...(heartbeat || []),
@@ -222,8 +222,14 @@ export function parseChangeNamesFromSubject(subject) {
222
222
 
223
223
  /**
224
224
  * git log 文本(--format=%x00%s --name-only,每提交 NUL+标题+文件行)→ 文件提交归属映射。
225
- * 每文件聚合:owners=触碰过它的全部提交携带的变更名;unknown=被无变更名裸提交触碰过
226
- * (归属不明,消费端必须保守保留——fail-closed 不因裸提交剔文件)。
225
+ * 每文件聚合:owners=触碰过它的全部提交携带的变更名;unknown=被无变更名裸提交触碰过;
226
+ * tokened=无名 touch 中存在带 task-NN token 者(`task-\d+`——交付协议证据,thin-tasks-v3
227
+ * 起勾选证据=task token 提交,收口哨兵按它逐格核)。2026-11-08-bare-commit-attribution-narrow
228
+ * 起细分:纯裸 touch(无名无 token——release 生成器/Merge/归档 chore 形态)不再保守保留
229
+ * (判 foreign,见 isForeignByCommit);token 裸 touch 仍保守 own(task token 无变更名限定,
230
+ * 无法区分是谁的 task——fail-closed 不误剔交付)。原「unknown 恒 own」的 fail-closed 前提
231
+ * (自己的交付提交可能不带变更名)已随 token 协议过时——坑②(shared-repo multi-session
232
+ * frictions)10-28 实证:release-record 归档 chore 提交穿透冻结面。
227
233
  */
228
234
  export function buildCommitAttribution(logText) {
229
235
  const map = new Map()
@@ -234,9 +240,11 @@ export function buildCommitAttribution(logText) {
234
240
  for (const f of lines.slice(1)) {
235
241
  const file = f.replace(/\\/g, '/')
236
242
  let e = map.get(file)
237
- if (!e) { e = { owners: new Set(), unknown: false }; map.set(file, e) }
238
- if (names.length === 0) e.unknown = true
239
- else for (const n of names) e.owners.add(n)
243
+ if (!e) { e = { owners: new Set(), unknown: false, tokened: false }; map.set(file, e) }
244
+ if (names.length === 0) {
245
+ e.unknown = true
246
+ if (/task-\d+/i.test(lines[0])) e.tokened = true
247
+ } else for (const n of names) e.owners.add(n)
240
248
  }
241
249
  }
242
250
  return map
@@ -266,9 +274,16 @@ export function commitAttributionForChange(cwd, specBase, currentChangeName, bas
266
274
  } catch { return null }
267
275
  }
268
276
 
269
- /** 纯他侧文件判定:有提交归属且全部提交均属他侧(无本变更名、无裸提交触碰)。 */
277
+ /** 纯他侧文件判定(2026-11-08-bare-commit-attribution-narrow 细分):
278
+ * own 路径——①记录缺位(窗口外文件)②含本变更名 touch ③无名 touch 带 task-NN token
279
+ * (交付协议证据,保守不误剔——token 无变更名限定,区分不了是谁的 task)。
280
+ * foreign 路径——①纯他侧具名(旧语义)②纯裸 touch(无名无 token:release 生成器/
281
+ * Merge/归档 chore 形态——坑②污染源;误剔由收口「提交面夹带嫌疑 advisory」点名兜底)。 */
270
282
  export function isForeignByCommit(entry, currentChangeName) {
271
- return !!entry && !entry.unknown && entry.owners.size > 0 && !entry.owners.has(currentChangeName)
283
+ if (!entry) return false
284
+ if (entry.owners.has(currentChangeName)) return false
285
+ if (entry.tokened) return false
286
+ return entry.owners.size > 0 || entry.unknown === true
272
287
  }
273
288
 
274
289
  /**
@@ -142,8 +142,10 @@ export async function runHunkAttributionGate({ cwd, specBase, changeName, baseli
142
142
  // 补 design 自声明」双误导(F-4 实证:他侧 546720ea 已归档交付被指为在途)。按提交 message
143
143
  // 变更名归属(commitAttributionForChange+isForeignByCommit,与 patch 冻结 filterCommittedFace
144
144
  // 同源):窗口内全部提交均属他侧变更名 → 他侧归因(ℹ️ 信息行、不计 ok 阻断面);
145
- // 裸提交/本变更名提交触碰的文件保持未归因(fail-closed 原口径)。切分不可得(非 git/
146
- // git 失败 → null)保持全量未归因,不放宽。
145
+ // 2026-11-08-bare-commit-attribution-narrow 细分:纯裸提交(无名无 task token——release
146
+ // 生成器/Merge/归档 chore 形态)touch 同判他侧归因(坑②污染源,owners 空数组——渲染走
147
+ // 纯裸分支);token 裸/本变更名提交触碰的文件保持未归因(fail-closed 原口径)。切分不可得
148
+ // (非 git/git 失败 → null)保持全量未归因,不放宽。
147
149
  let attr = null
148
150
  let isForeign = null
149
151
  try {
@@ -195,7 +197,9 @@ export function renderHunkAttributionLines(r) {
195
197
  const attributed = r.faceCount - r.unattributed.length - (r.foreignByCommit || []).length
196
198
  L.push(`- ℹ️ 提交面 ${r.faceCount} 个交付文件对账:声明面归因 ${attributed}、他侧归因(提交事实)${(r.foreignByCommit || []).length}、未归因 ${r.unattributed.length}、跨变更竞争 ${r.contended.length}、在途残留 ${r.residue.length}`)
197
199
  for (const fb of (r.foreignByCommit || [])) {
198
- L.push(`- ℹ️ 他侧归因(提交事实):\`${fb.file}\` 窗口提交全部属 ${fb.owners.join('、')}——已归档他侧交付非本变更未归因,patch 冻结面同口径剔除`)
200
+ L.push((fb.owners || []).length > 0
201
+ ? `- ℹ️ 他侧归因(提交事实):\`${fb.file}\` 窗口提交全部属 ${fb.owners.join('、')}——已归档他侧交付非本变更未归因,patch 冻结面同口径剔除`
202
+ : `- ℹ️ 他侧归因(提交事实):\`${fb.file}\` 窗口内仅纯裸提交(无名无 task token——release 生成器/Merge/归档 chore 形态)触碰——坑②污染源剔除,patch 冻结面同口径剔除;若确属本变更交付,补 design 自声明或带 task token 重新提交`)
199
203
  }
200
204
  for (const u of r.unattributed) {
201
205
  L.push(`- ⚠️ 未归因:\`${u.file}\`(${u.hunks >= 0 ? `${u.hunks} hunk` : 'hunk 数不可得'})不在 design 清单与 requirements 测试绑定——属并行会话在途交付则须显式 pathspec 隔离并协调归属;属本变更则补 design 自声明`)
package/src/index.js CHANGED
@@ -153,10 +153,10 @@ SillySpec CLI — 规范驱动开发工具包
153
153
  影子存档面:列出 task tick 自动落的存档链,预览/按路径读档
154
154
  sillyspec overlap 查询活跃变更 target-file 声明重叠(advisory——检出即附
155
155
  worktree 分流与落地序列指引)
156
- sillyspec knowledge <search --query "..." --limit N [--include-resolved]
156
+ sillyspec knowledge <search "<关键词>" [--limit N] [--include-resolved](--query 等效)
157
157
  | inspect --id "..."
158
158
  | validate | refresh
159
- | propose --title "..." --category <name>
159
+ | propose --title "..." [--body-file <路径>|--body <文本>] --category <name>
160
160
  | inbox [--json] (知识收件箱:uncategorized 待审清单+基线态)>
161
161
  sillyspec platform <connect <url> [--token <t>]
162
162
  | disconnect
@@ -4518,6 +4518,14 @@ SillySpec platform — SillyHub 平台同步
4518
4518
  console.error(`⚠️ 全树同步检测到冲突(${(r.serverVersions && Object.keys(r.serverVersions).length) || 0} 个文件待裁决),处理:sillyspec platform status 查看 / platform resolve <变更名> 处置`);
4519
4519
  process.exit(1);
4520
4520
  }
4521
+ // ok:false 失败可见(2026-11-04-sync-fail-silent-fix FR-01):拉清单超时/POST 422、
4522
+ // 5xx 等失败此前照打成功横幅退出 0——生产实证缓存漂移+慢链路+422 连坐叠加时用户
4523
+ // 只见「0 个文件差异」,228 条知识漏推全程不可见。失败原因链已由 spec-sync 内部
4524
+ // warn 输出,此处补结果语义与退出码。
4525
+ if (r && r.ok === false) {
4526
+ console.error('❌ 全树 spec 同步失败(原因见上方 warn;文件树本次未同步,下次自动重试)');
4527
+ process.exit(1);
4528
+ }
4521
4529
  console.log(`✅ 全树 spec 同步完成(${r ? r.synced : 0} 个文件差异)`);
4522
4530
  }
4523
4531
  break;