sillyspec 3.33.9 → 3.34.1

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.
@@ -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 条可解析 = 标题式/扁平列表式两种格式都不匹配——此前静默放行
@@ -641,9 +744,50 @@ export function distillIntoKnowledge(changeDir, knowledgeRoot, headHash, moduleI
641
744
  if (keep && !taken.has(e.number)) { taken.add(e.number); keep.change = changeName; batch.push(keep) }
642
745
  }
643
746
 
747
+ // 文件字段自动填充(2026-11-03-decision-files-autofill):变更交付面 → 无关联条目的「文件:」字段。
748
+ // 策略:① 条目已有文件字段或有有效锚点 → 不动;② 单决策变更 → 全部交付文件填入(语义成立:
749
+ // 该变更只做了一个决策,交付面即影响面);③ 多决策变更 → 仅填条目正文(答案/理由)中实际
750
+ // 提到的交付文件(信号匹配,防过度关联——方法论决策不对应单一代码位置)。
751
+ // 数据源:change-patch.json 的 files 列表(归档时生成,蒸馏在归档后跑——时序正确)。
752
+ const deliverableFiles = (() => {
753
+ try {
754
+ const patchPath = join(changeDir, 'change-patch.json')
755
+ if (!existsSync(patchPath)) return []
756
+ const patch = JSON.parse(readFileSync(patchPath, 'utf8'))
757
+ return (Array.isArray(patch.files) ? patch.files : []).map(f => String(f).replace(/\\/g, '/')).filter(Boolean)
758
+ } catch { return [] }
759
+ })()
760
+ const noAssociation = batch.filter(e =>
761
+ !(e.files && e.files.length > 0) &&
762
+ !(e.anchor && e.anchor.trim() && e.anchor.trim() !== NOT_RECORDED)
763
+ )
764
+ if (deliverableFiles.length > 0 && noAssociation.length > 0) {
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) {
770
+ // 单决策:全部交付文件
771
+ noAssociation[0].files = deliverableFiles.slice(0, 20)
772
+ console.log(` 📎 文件字段自动填充:单决策变更 ← 交付面 ${deliverableFiles.length} 个文件`)
773
+ } else {
774
+ // 多决策:仅填正文实际提到的
775
+ for (const e of noAssociation) {
776
+ const text = [e.answer, e.reason, e.normalizedRequirement, e.question].join('\n')
777
+ const matched = deliverableFiles.filter(f => text.includes(f))
778
+ if (matched.length > 0) e.files = matched.slice(0, 10)
779
+ }
780
+ const filled = noAssociation.filter(e => e.files && e.files.length > 0).length
781
+ if (filled > 0) console.log(` 📎 文件字段自动填充:${filled}/${noAssociation.length} 条从交付面信号匹配`)
782
+ }
783
+ const still = noAssociation.filter(e => !(e.files && e.files.length > 0))
784
+ if (still.length > 0) {
785
+ console.log(` ⚠️ ${still.length} 条决策无代码关联(无锚点无文件,多决策但正文未提及交付文件)——decisions add 时带 --files 可显式指定`)
786
+ }
787
+ }
788
+
644
789
  const decisionsDir = join(knowledgeRoot, 'decisions')
645
790
  mkdirSync(decisionsDir, { recursive: true })
646
- const idx = moduleIndex || discoverModuleIndex(knowledgeRoot)
647
791
 
648
792
  // 域文件 store:域 → { preamble, sections, dirty }(按需加载,批量写盘)
649
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/index.js CHANGED
@@ -153,7 +153,7 @@ 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
159
  | propose --title "..." --category <name>
@@ -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;
@@ -4724,7 +4732,7 @@ SillySpec platform — SillyHub 平台同步
4724
4732
  const decChangeIdx = args.indexOf('--change');
4725
4733
  const decChange = decChangeIdx >= 0 && args[decChangeIdx + 1] && !String(args[decChangeIdx + 1]).startsWith("--") ? args[decChangeIdx + 1] : null;
4726
4734
  if (!decChange || !['add', 'list'].includes(decSub)) {
4727
- console.error('用法: sillyspec decisions add --change <名> --id D-001 --title "…" [--version N] [--status confirmed] [--type architecture] [--domain x(可重复)] [--question/--answer/--anchor/--reject-reason/--revisit-when/--supersedes/--impacts/--note]\n sillyspec decisions list --change <名>');
4735
+ console.error('用法: sillyspec decisions add --change <名> --id D-001 --title "…" [--version N] [--status confirmed] [--type architecture] [--domain x(可重复)] [--question/--answer/--anchor/--files <逗号分隔路径>/--reject-reason/--revisit-when/--supersedes/--impacts/--note]\n sillyspec decisions list --change <名>');
4728
4736
  process.exit(2);
4729
4737
  }
4730
4738
  assertSafeChangeName(decChange, '--change 变更名');
@@ -4765,6 +4773,8 @@ SillySpec platform — SillyHub 平台同步
4765
4773
  question: decFlag('--question') || undefined,
4766
4774
  answer: decFlag('--answer') || undefined,
4767
4775
  anchor: decFlag('--anchor') || undefined,
4776
+ // 文件字段(2026-11-03-decision-files-autofill):逗号分隔路径 → 数组 + POSIX 归一
4777
+ files: decFlag('--files') ? decFlag('--files').split(/[,,、]/).map(s => s.trim().replace(/\\/g, '/')).filter(Boolean) : undefined,
4768
4778
  rejectReason: decFlag('--reject-reason') || undefined,
4769
4779
  revisitWhen: decFlag('--revisit-when') || undefined,
4770
4780
  supersedes: decFlag('--supersedes') || undefined,
@@ -66,6 +66,9 @@ const DECISION_HEADER_RE = /^##\s+(D-(?:[A-Za-z0-9_\-\u4e00-\u9fff]+-)?\d+@v\d+)
66
66
  /** 底稿模板标题特征词(与 4A provenance 分类同源单一口径):harvestSlot4Decision 产的
67
67
  * process 级流程取舍框架词——不是具体方案否决,deathPath 不应赋予(2026-11-02-series-final-cleanup)。 */
68
68
  const TEMPLATE_TITLE_RE = /设计期风险与放弃方案|风险与死路|design\s*槽\s*4\s*收割|底稿/
69
+ // 叙事基线条目识别(2026-11-04-archive-baseline-entry):原编号 D-000@ 是基线保留号
70
+ // (buildBaselineEntry 恒用 D-000@v1)——排序降位/回放幂等预扫共用此约定。
71
+ const BASELINE_ORIGIN_RE = /^D-000@/
69
72
  const DECISION_FIELD_RE = /^(状态|文件|理由|否决理由|复潮条件|supersedes)\s*[::]\s*(.*)$/
70
73
  // 「锚点:」单独标签精确匹配读入(仿 docs-check.js parseDecisionEntries 先例)——绝不并入
71
74
  // DECISION_FIELD_RE:其 else-if 链末尾的 reason 回填(else if (!cur.reason))会把锚点值
@@ -376,7 +379,7 @@ export function fallbackByQueryShingles(indexDir, taskContext) {
376
379
  if (all.length === 0) return null
377
380
  const byFile = new Map()
378
381
  for (const h of all) {
379
- if (isParkingFile(h.file)) continue // 停车场文件不参与词片回退(FR-01)
382
+ if (isParkingFile(h.file)) continue // 停车场文件不参与常规词片回退(FR-01)
380
383
  let st = byFile.get(h.file)
381
384
  if (!st) { st = { entries: [], texts: [] }; byFile.set(h.file, st) }
382
385
  st.entries.push(h)
@@ -569,7 +572,17 @@ export function matchByRouting(indexDir, taskContext, opts = {}) {
569
572
  // 一个 bigram 即得高比率)会压过长理由被稀释的相关条目,实测复现;宁用零分平局先验。
570
573
  const contentChars = (s) => String(s || '').replace(/[\d\p{P}\s_]+/gu, ' ')
571
574
  const relScore = (h) => frTitleOverlap(contentChars(taskContext), contentChars(`${h.id} ${h.title}`))
572
- const byOverlapDesc = (a, b) => (relScore(b) - relScore(a)) || ((b.deathPath ? 1 : 0) - (a.deathPath ? 1 : 0))
575
+ // 基线降位(2026-11-04-archive-baseline-entry,FR-03):同文件内叙事基线(原编号 D-000@)
576
+ // 排在人工条目之后——基线是补充叙事(标题=变更标题+首句,bigram 天然富集,relScore 常
577
+ // 虚高),人工条目才是检索正主;跨文件保持既有得分序(降位仅在同文件双条目竞争时生效)。
578
+ const isBaselineEntry = (h) => BASELINE_ORIGIN_RE.test(h.originId || '')
579
+ const byOverlapDesc = (a, b) => {
580
+ if (a.file === b.file) {
581
+ const ab = isBaselineEntry(a), bb = isBaselineEntry(b)
582
+ if (ab !== bb) return ab ? 1 : -1
583
+ }
584
+ return (relScore(b) - relScore(a)) || ((b.deathPath ? 1 : 0) - (a.deathPath ? 1 : 0))
585
+ }
573
586
  const decisionHits = [
574
587
  ...allDecisionHits.filter((h) => h.status === 'rejected' || h.deathPath).sort(byOverlapDesc),
575
588
  ...allDecisionHits.filter((h) => h.status !== 'rejected' && !h.deathPath).sort(byOverlapDesc),
@@ -594,5 +607,33 @@ export function matchKnowledge(indexDir, taskContext, opts = {}) {
594
607
  const r = matchByRouting(indexDir, taskContext, opts)
595
608
  if (r.matched) return r
596
609
  const fb = fallbackByQueryShingles(indexDir, taskContext)
597
- return fb || r
610
+ if (fb) return fb
611
+ // 停车场兜底通道(4D 解禁,2026-11-03):非停车场全部零命中时逐条匹配停车场 rejected/死路
612
+ // ——cap 3,标记 parking:true(不进 DF 分母、不解禁路由行);底稿资格门已确保模板不获 deathPath。
613
+ // 保留 parkingFiltered 披露(tier-denoise FR-01 契约)。
614
+ const shingles = queryShingles(taskContext)
615
+ if (shingles.length > 0) {
616
+ const all = parseDecisionEntries(indexDir)
617
+ const parkingHits = all.filter(h =>
618
+ isParkingFile(h.file) && (h.status === 'rejected' || h.deathPath) &&
619
+ shingles.some(sh => asciiWordHit(sh, `${h.title} ${h.reason || ''}`))
620
+ ).slice(0, 3)
621
+ if (parkingHits.length > 0) {
622
+ const parkingFiles = [...new Set(all.filter(h => isParkingFile(h.file)).map(h => h.file))]
623
+ return {
624
+ matched: true,
625
+ entries: [{
626
+ category: 'Decisions',
627
+ keywords: ['parking'],
628
+ file: 'decisions/unmapped.md',
629
+ anchor: '',
630
+ display: `decisions/unmapped.md(停车场兜底命中 ${parkingHits.length} 条 rejected/死路)`,
631
+ }],
632
+ report: 'Status: parking-fallback matched',
633
+ json: { matched: true, entry_count: 1, parking_fallback: true, parking: true, ...(parkingFiles.length > 0 ? { parkingFiltered: parkingFiles } : {}) },
634
+ decisionHits: parkingHits.map(h => ({ ...h, parking: true })),
635
+ }
636
+ }
637
+ }
638
+ return r
598
639
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * md-fence.js — Markdown 代码围栏感知扫描(2026-11-04-fence-aware-task-rows)。
3
+ *
4
+ * 定位:给「逐行正则解析 Markdown 结构」的模块(task-tick 等)提供围栏状态——
5
+ * 代码围栏内的示例任务行(如文档示例 `- [ ] task-99:`)不得被当真实结构行。
6
+ * 依据:superpowers #2436(未闭合围栏把 177 行任务吞成 1159 行五任务简报)同型事故。
7
+ *
8
+ * 语法取 CommonMark 实用子集:
9
+ * - 开栏:行首 ≤3 空格缩进的 ``` 或 ~~~(可带信息串);
10
+ * - 闭栏:同字符、长度不小于开栏、行内仅空白的栏线(闭栏行本身计入围栏);
11
+ * - 开栏期间异字符栏线(``` 内的 ~~~ 或反之)视为内容,不互闭;
12
+ * - 未闭合:按「开栏即入栏直到文件尾」处理(CommonMark 语义,fail-safe 方向)。
13
+ *
14
+ * 纯函数无状态;行索引与 String.split('\n') 对齐(CRLF 的 \r 留在行尾,\s*$ 吸收)。
15
+ */
16
+
17
+ /** 开栏行:≤3 空格缩进 + ≥3 个 ` 或 ~ + 任意信息串。 */
18
+ const FENCE_OPEN_RE = /^ {0,3}(`{3,}|~{3,})/
19
+
20
+ /** 闭栏行:≤3 空格缩进 + 同字符 ≥3 个 + 行内仅空白(\s*$ 同时吸收 CRLF 的 \r)。 */
21
+ const FENCE_CLOSE_RE = /^ {0,3}(`{3,}|~{3,})\s*$/
22
+
23
+ /**
24
+ * 扫描 Markdown 文本的围栏状态。
25
+ * @param {string} md 文本(LF 或 CRLF)
26
+ * @returns {{ inFence: boolean[], unclosed: boolean, fenceStartLine: number|null }}
27
+ * inFence 按行(split('\n') 口径)标记是否处于围栏内(含开/闭栏行本身);
28
+ * unclosed=文件尾仍有未闭合开栏;fenceStartLine=该未闭合开栏的 1 基行号(未闭合为 false 时 null)。
29
+ */
30
+ export function fenceScan(md) {
31
+ const lines = String(md || '').split('\n')
32
+ const inFence = new Array(lines.length).fill(false)
33
+ let open = null
34
+ let currentStart = 0
35
+ let unclosed = false
36
+ let fenceStartLine = null
37
+ for (let i = 0; i < lines.length; i++) {
38
+ const line = lines[i]
39
+ if (open) {
40
+ const closeMatch = FENCE_CLOSE_RE.exec(line)
41
+ if (closeMatch && closeMatch[1][0] === open.ch && closeMatch[1].length >= open.len) {
42
+ inFence[i] = true
43
+ open = null
44
+ currentStart = 0
45
+ } else {
46
+ inFence[i] = true
47
+ }
48
+ continue
49
+ }
50
+ const openMatch = FENCE_OPEN_RE.exec(line)
51
+ if (openMatch) {
52
+ open = { ch: openMatch[1][0], len: openMatch[1].length }
53
+ currentStart = i + 1
54
+ inFence[i] = true
55
+ }
56
+ }
57
+ if (open) {
58
+ unclosed = true
59
+ fenceStartLine = currentStart
60
+ }
61
+ return { inFence, unclosed, fenceStartLine }
62
+ }
63
+
64
+ export default { fenceScan }
package/src/progress.js CHANGED
@@ -155,6 +155,36 @@ export function resolveSessionIdentity({ flagSession = null, quickChangeName = n
155
155
  *
156
156
  * 无 pointer = 纯本地项目(从未平台 scan),正常走本地解析,不受影响。
157
157
  */
158
+ /** 向上找 .git 所在目录(无 git 返回 null)——跨仓错绑告警的比对原语。 */
159
+ function gitRootOf(startPath) {
160
+ let cur = resolve(startPath)
161
+ for (;;) {
162
+ if (existsSync(join(cur, '.git'))) return cur
163
+ const parent = dirname(cur)
164
+ if (parent === cur) return null
165
+ cur = parent
166
+ }
167
+ }
168
+
169
+ /** 跨仓错绑告警(2026-11-04-junction-guard-rename-content FR-01):cwd 与解析出的
170
+ * specRoot 分属不同 git 仓 = workspace junction/指针错绑形态(2026-11-04 跨工作区污染
171
+ * 事故实证:平台 workspace junction 指向 sillyspec 仓,CLI 忠实跟随零提示,sillyspec
172
+ * 内容经平台 token 推进平台 workspace)。双侧都有 .git 才比对(裸快照目录不误报);
173
+ * 只告警不阻断(错绑下临时跨仓只读操作仍可用,由人裁决)。 */
174
+ function warnIfCrossRepoSpecRoot(cwd, specRoot, pointerPath) {
175
+ let cwdRoot = null
176
+ let specRootGit = null
177
+ try { cwdRoot = gitRootOf(cwd) } catch { /* 比对失败静默(告警 fail-open) */ }
178
+ try { specRootGit = gitRootOf(specRoot) } catch { /* 同上 */ }
179
+ if (!cwdRoot || !specRootGit || cwdRoot === specRootGit) return
180
+ console.warn(
181
+ `⚠️ 平台 spec 根与当前目录分属不同 git 仓——workspace junction/指针疑似错绑(跨工作区污染事故形态)。\n` +
182
+ ` cwd 仓:${cwdRoot}\n` +
183
+ ` specRoot 仓:${specRootGit}\n` +
184
+ ` 检查 ${pointerPath} 的 specRoot 与 ~/.sillyhub/daemon/specs/ 下对应 workspace 的 junction 绑定;或 sillyspec platform pointer --cleanup 后重扫。命令继续执行,请人工裁决。`
185
+ )
186
+ }
187
+
158
188
  export function resolvePlatformSpecDir(cwd, explicitSpecDir = null) {
159
189
  if (explicitSpecDir) return resolve(explicitSpecDir);
160
190
  const pointerPath = join(resolve(cwd), '.sillyspec-platform.json');
@@ -225,11 +255,13 @@ export function resolvePlatformSpecDir(cwd, explicitSpecDir = null) {
225
255
  // 偶发锚到 daemon junction 副本」):daemon 写入的 pointer specRoot 可能是通向真实 spec 根的
226
256
  // junction/symlink 路径——经 junction 读写与真实路径同文件(无害)但路径呈现与比对漂移、
227
257
  // junction 随 daemon 生命周期可能蒸发。realpath 坍缩为真实路径(失败回退原值,保守不干预)。
258
+ let resolvedSpecRoot = ptr.specRoot
228
259
  try {
229
260
  const canonical = realpathSync(ptr.specRoot)
230
- if (canonical && canonical !== ptr.specRoot) return canonical
261
+ if (canonical && canonical !== ptr.specRoot) resolvedSpecRoot = canonical
231
262
  } catch { /* realpath 失败(权限/竞态删除)→ 原值(上方已过存在性检查) */ }
232
- return ptr.specRoot;
263
+ warnIfCrossRepoSpecRoot(cwd, resolvedSpecRoot, pointerPath)
264
+ return resolvedSpecRoot;
233
265
  }
234
266
 
235
267
  /**
package/src/spec-sync.js CHANGED
@@ -45,6 +45,13 @@ function toPosix(p) {
45
45
  return p.replace(/\\/g, '/');
46
46
  }
47
47
 
48
+ // 全量清单拉取超时(2026-11-04-sync-fail-silent-fix FR-02):全量清单是唯一稳定大响应
49
+ // (生产实测 2.7MB;~1Mbps 入口链路需 ~25s),10s 硬编码使首拉/对账轮/倒退对账在慢链路
50
+ // 恒熔断——缓存漂移后无收敛通道(228 条知识漏推事故的叠加因之二)。对齐 bundle 端点
51
+ // 大超时档(sync.js PULL_BUNDLE_TIMEOUT_MS 同思路:大响应体独立口径);增量(带 since)
52
+ // 小响应维持 10s 噪音闸门不变。
53
+ const MANIFEST_FULL_TIMEOUT_MS = 120_000;
54
+
48
55
  function debugLog(msg) {
49
56
  if (process.env.SILLYSPEC_DEBUG_SYNC) console.warn(msg);
50
57
  }
@@ -262,11 +269,17 @@ export function computeSpecOps(serverManifest, localFiles) {
262
269
  const ops = [];
263
270
 
264
271
  for (const r of renames) {
272
+ const localEntry = localMap.get(r.newPath);
265
273
  ops.push({
266
274
  op: 'rename',
267
275
  path: r.oldPath,
268
276
  new_path: r.newPath,
269
277
  base_version: server[r.oldPath].version,
278
+ // 内容随行(2026-11-04-junction-guard-rename-content FR-02):服务端源行缺失(跨工作区
279
+ // 错绑/对端已清行)时无内容 rename 必被 422「源文件缺失的重命名操作必须携带文件内容」
280
+ // 拒收整批(生产实证文件树同步 5 连败卡死);带 content 后服务端可直接物化目标路径,
281
+ // 源行在则照常移动语义。编码与 add/update 同款 base64。
282
+ content: (localEntry?.buf ?? readFileSync(localEntry?.absPath ?? join('.', r.newPath))).toString('base64'),
270
283
  });
271
284
  }
272
285
 
@@ -755,7 +768,8 @@ async function syncSpecTreeImpl(specRoot, platform, changeName, opts = {}) {
755
768
  try {
756
769
  const res = await fetch(cacheUsable ? `${manifestUrl}?since=${manifestCache.spec_version}` : manifestUrl, {
757
770
  headers: { Authorization: `Bearer ${platform.token}` },
758
- signal: withSignal(10000),
771
+ // 超时分档(FR-02):增量(带 since)小响应 10s;全量大响应 120s(慢链路全量对账可达)
772
+ signal: withSignal(cacheUsable ? 10000 : MANIFEST_FULL_TIMEOUT_MS),
759
773
  });
760
774
  if (!res.ok) {
761
775
  // ql-20260818-008:debugLog → console.warn。树同步失败原本完全静默(要
@@ -787,7 +801,7 @@ async function syncSpecTreeImpl(specRoot, platform, changeName, opts = {}) {
787
801
  debugLog(`[spec-sync] spec_version 倒退(${manifestCache.spec_version} → ${body.spec_version},服务端重建?)——回退全量清单对账`);
788
802
  const full = await fetch(manifestUrl, {
789
803
  headers: { Authorization: `Bearer ${platform.token}` },
790
- signal: withSignal(10000),
804
+ signal: withSignal(MANIFEST_FULL_TIMEOUT_MS), // 倒退对账=全量大响应(FR-02 同档)
791
805
  });
792
806
  if (!full.ok) {
793
807
  const failCount = bumpFailStreak(specRoot, logLabel, `倒退对账全量 HTTP ${full.status}`);
@@ -111,7 +111,7 @@ sillyspec flow start --change <YYYY-MM-DD-名> --input "<一句 10-30 字中文
111
111
  4. 探索顺序(按需):目的 → 约束 → 边界 → 成功标准
112
112
  5. **先挖为什么**:不只问「要什么」,要问出「为什么现在做、不做会怎样」(动机是后续替你补全没提的那一百个决定的依据);到本步出口仍说不清动机 → 不许进入「提出 2-3 种方案」,先把动机问出来
113
113
  6. **实现方式的选择权归谁**:技术栈/平台/语言/库/存储介质这类「怎么做」的选择,问清哪些是用户有强意见要谈的、哪些是交给你选的;你选的按决策记录规则落盘并把 answer 标注「我的判断」(不是用户同意过的)
114
- 7. 多选题优于开放式问题;被问「你觉得呢/先猜一个」时给一个具体方案并标「我的判断」,不要空转追问
114
+ 7. 提问分型(意图问开放、决策给选项):意图/动机类问题(用户想要什么、为什么、成功什么样)必须开放式提出,不附任何示例选项——用户自己的措辞携带选项装不下的意图,给选项会锚定用户;存在可命名取舍的决策类问题才给具名选项,每个选项附一句代价,必带推荐与理由,禁止裸问「选哪个」。被问「你觉得呢/先猜一个」时给一个具体方案并标「我的判断」,不要空转追问
115
115
  8. YAGNI — 砍掉不需要的功能
116
116
 
117
117
  ### B2. 分块回放(Playback——边理解边复述,与追问交替)
@@ -75,13 +75,28 @@ function extractTitle(content) {
75
75
 
76
76
  // ── search ──
77
77
 
78
+ // 位置参数取词(2026-11-04-knowledge-search-positional):--query 缺失时取首个非 flag
79
+ // 且非 flag 值的 token 作查询词(AGENTS.md 模板形态 search "<关键词>")。跳过 '--' 开头
80
+ // token 与 --limit 的值 token——防 ['--limit','2'] 的 '2' 被误拾;flag 字面量永不入查询词
81
+ // (flag-as-value 坑语义保持:--query 后跟 flag 仍按缺参,不从残参里捡词)。
82
+ function positionalQuery(args) {
83
+ for (let i = 0; i < args.length; i++) {
84
+ const a = args[i]
85
+ if (a === '--limit') { i += 1; continue }
86
+ if (a.startsWith('--')) continue
87
+ return a
88
+ }
89
+ return ''
90
+ }
91
+
78
92
  export async function cmdSearch(dir, args, opts = {}) {
79
93
  const knowledgeDir = resolveKnowledgeDir(dir, opts.specDir)
80
94
  // flag-as-value 防护(坑 knowledge-flag-as-value):值以 '--' 开头视为漏传(只读命令,
81
95
  // 按缺参处理落入既有 required 校验/默认值,不静默拿 flag 字面量当查询词)
82
96
  const queryIdx = args.indexOf('--query')
83
97
  const queryRaw = queryIdx >= 0 ? args[queryIdx + 1] : ''
84
- const query = queryRaw && !queryRaw.startsWith('--') ? queryRaw : ''
98
+ // --query 优先(显式压过位置推断);缺失时回退位置参数形态
99
+ const query = (queryRaw && !queryRaw.startsWith('--') ? queryRaw : '') || positionalQuery(args)
85
100
  const limitIdx = args.indexOf('--limit')
86
101
  const limitRaw = limitIdx >= 0 ? args[limitIdx + 1] : ''
87
102
  const parsedLimit = limitRaw && !limitRaw.startsWith('--') ? parseInt(limitRaw, 10) : NaN
@@ -652,7 +667,7 @@ export async function cmdKnowledge(args, dir, opts = {}) {
652
667
  '用法: sillyspec knowledge <子命令> [参数]',
653
668
  '',
654
669
  '子命令:',
655
- ' search "<关键词>" 检索知识库(命中自动注入 prompt 的同源面)',
670
+ ' search "<关键词>" 检索知识库(--query 等效;命中自动注入 prompt 的同源面)',
656
671
  ' inspect <文件> 查看单个知识文件条目结构',
657
672
  ' validate 校验知识库完整性(INDEX 锚点/注册/幽灵条目——纯读)',
658
673
  ' digest 治理信号卡(rot/伪域/坏绑定——纯读)',