@fenglimg/fabric-cli 2.2.0-rc.8 → 2.2.0-rc.9

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.
@@ -22,9 +22,8 @@ var ROOT_AUTHORITATIVE_KEYS = /* @__PURE__ */ new Set([
22
22
  "embed_model",
23
23
  "embed_weight",
24
24
  "plan_context_top_k",
25
+ "recall_relevance_ratio",
25
26
  "mcpPayloadLimits",
26
- "retrieval_budget_profile",
27
- "recall_body_budget_bytes",
28
27
  "selection_token_ttl_ms",
29
28
  "orphan_demote_proven_days",
30
29
  "orphan_demote_verified_days",
@@ -52,6 +52,11 @@ import {
52
52
  BOOTSTRAP_MARKER_END,
53
53
  BOOTSTRAP_REGEX
54
54
  } from "@fenglimg/fabric-shared/templates/bootstrap-canonical";
55
+ var SKILL_ROUTER_TEMPLATE_REL = "skills/fabric/SKILL.md";
56
+ var ROUTER_INTENT_MARKER_BEGIN = "<!-- fabric:router-intent:begin -->";
57
+ var ROUTER_INTENT_MARKER_END = "<!-- fabric:router-intent:end -->";
58
+ var ROUTER_INTENT_GENERATED_NOTE = "<!-- \u672C\u5757\u7531 `fabric install` \u4ECE 7 \u4E2A leaf skill \u7684 description Triggers \u5B50\u53E5\u751F\u6210\u3002\u4E25\u7981\u624B\u7F16;\u6539 leaf description \u540E\u91CD\u8DD1 `fabric install`\u3002 -->";
59
+ var ROUTER_INTENT_REGEX = /<!-- fabric:router-intent:begin -->[\s\S]*?<!-- fabric:router-intent:end -->/u;
55
60
  var SKILL_TEMPLATE_REL = "skills/fabric-archive/SKILL.md";
56
61
  var SKILL_REVIEW_TEMPLATE_REL = "skills/fabric-review/SKILL.md";
57
62
  var SKILL_IMPORT_TEMPLATE_REL = "skills/fabric-import/SKILL.md";
@@ -69,6 +74,12 @@ var HOOK_LIB_TEMPLATE_DIR_REL = "hooks/lib";
69
74
  var CLAUDE_HOOK_CONFIG_TEMPLATE_REL = "hooks/configs/claude-code.json";
70
75
  var CODEX_HOOK_CONFIG_TEMPLATE_REL = "hooks/configs/codex-hooks.json";
71
76
  var SKILL_DESTINATIONS = {
77
+ // B2 skill-router: the fabric/ router skill — single-file (no ref/), installed
78
+ // alongside the 7 leaf skills as the human-facing dispatch entry point.
79
+ fabricRouter: [
80
+ ".claude/skills/fabric/SKILL.md",
81
+ ".codex/skills/fabric/SKILL.md"
82
+ ],
72
83
  fabricArchive: [
73
84
  ".claude/skills/fabric-archive/SKILL.md",
74
85
  ".codex/skills/fabric-archive/SKILL.md"
@@ -106,6 +117,12 @@ var SKILL_DESTINATIONS = {
106
117
  ]
107
118
  };
108
119
  var FABRIC_SKILL_INSTALL_SPECS = {
120
+ fabricRouter: {
121
+ slug: "fabric",
122
+ templateRel: SKILL_ROUTER_TEMPLATE_REL,
123
+ destinations: SKILL_DESTINATIONS.fabricRouter,
124
+ step: "skill-router"
125
+ },
109
126
  fabricArchive: {
110
127
  slug: "fabric-archive",
111
128
  templateRel: SKILL_TEMPLATE_REL,
@@ -306,6 +323,66 @@ async function installFabricAuditSkill(projectRoot, _options = {}) {
306
323
  async function installFabricConnectSkill(projectRoot, _options = {}) {
307
324
  return installFabricSkill(projectRoot, FABRIC_SKILL_INSTALL_SPECS.fabricConnect);
308
325
  }
326
+ function extractSkillMdDescription(skillMd) {
327
+ const fm = skillMd.match(/^---\n([\s\S]*?)\n---/u);
328
+ if (!fm) return "";
329
+ const desc = fm[1].match(/^description:\s*(.+?)\s*$/mu);
330
+ if (!desc) return "";
331
+ return desc[1].replace(/^["'](.+)["']$/u, "$1").trim();
332
+ }
333
+ function extractTriggersClause(description) {
334
+ const m = description.match(/Triggers?\s+([\s\S]+)$/u);
335
+ if (!m) return "";
336
+ return m[1].trim().replace(/[.。]\s*$/u, "").replace(/\|/gu, "\\|");
337
+ }
338
+ function renderRouterIntentBlock(leaves) {
339
+ const rows = leaves.map((l) => `| ${l.triggers} | \`${l.slug}\` |`).join("\n");
340
+ const enumVals = leaves.map((l) => l.slug.replace(/^fabric-/u, "")).join(" | ");
341
+ return [
342
+ ROUTER_INTENT_MARKER_BEGIN,
343
+ ROUTER_INTENT_GENERATED_NOTE,
344
+ "",
345
+ "| \u7528\u6237\u610F\u56FE(leaf description Triggers) | \u4E0B\u6E38 skill |",
346
+ "| --- | --- |",
347
+ rows,
348
+ "",
349
+ `\`S_CLASSIFY\` \u7684 \`task_type\` \u679A\u4E3E:\`${enumVals}\``,
350
+ ROUTER_INTENT_MARKER_END
351
+ ].join("\n");
352
+ }
353
+ async function buildRouterSkillSource() {
354
+ const template = await readTemplate(SKILL_ROUTER_TEMPLATE_REL);
355
+ if (!ROUTER_INTENT_REGEX.test(template)) {
356
+ throw new Error(
357
+ `fabric/SKILL.md is missing the ${ROUTER_INTENT_MARKER_BEGIN} \u2026 ${ROUTER_INTENT_MARKER_END} marker pair \u2014 cannot regenerate the Intent Map. This is a Fabric release bug (router template was hand-edited away from the managed-block contract).`
358
+ );
359
+ }
360
+ const leafSpecs = Object.values(FABRIC_SKILL_INSTALL_SPECS).filter(
361
+ (spec) => spec.slug !== "fabric"
362
+ );
363
+ const leaves = [];
364
+ for (const spec of leafSpecs) {
365
+ const leafMd = await readTemplate(spec.templateRel);
366
+ leaves.push({ slug: spec.slug, triggers: extractTriggersClause(extractSkillMdDescription(leafMd)) });
367
+ }
368
+ return template.replace(ROUTER_INTENT_REGEX, renderRouterIntentBlock(leaves));
369
+ }
370
+ async function installFabricRouterSkill(projectRoot, _options = {}) {
371
+ const source = await buildRouterSkillSource();
372
+ validateSkillCanonicalSize(source, "fabric");
373
+ const spec = FABRIC_SKILL_INSTALL_SPECS.fabricRouter;
374
+ const targets = spec.destinations.map((rel) => join2(projectRoot, rel));
375
+ const results = [];
376
+ for (const target of targets) {
377
+ const staleMsg = inspectStaleInstall(target, source);
378
+ const result = await copyTextIdempotent(spec.step, source, target);
379
+ if (staleMsg && result.status === "written") {
380
+ result.message = result.message ? `${staleMsg}; ${result.message}` : staleMsg;
381
+ }
382
+ results.push(result);
383
+ }
384
+ return results;
385
+ }
309
386
  async function cleanupDeprecatedSkills(projectRoot) {
310
387
  const results = [];
311
388
  for (const rel of DEPRECATED_SKILL_DIRS) {
@@ -914,6 +991,7 @@ export {
914
991
  installFabricStoreSkill,
915
992
  installFabricAuditSkill,
916
993
  installFabricConnectSkill,
994
+ installFabricRouterSkill,
917
995
  cleanupDeprecatedSkills,
918
996
  installSharedSkillLib,
919
997
  installArchiveHintHook,
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  ensureStoreProjectBinding,
4
4
  migrateRootConfig
5
- } from "./chunk-FEOPLBGA.js";
5
+ } from "./chunk-3D7B2UAZ.js";
6
6
  import {
7
7
  resolveDevMode
8
8
  } from "./chunk-WA3DYGSY.js";
@@ -5,8 +5,8 @@ import {
5
5
  parseSinceDuration,
6
6
  renderDoctorFilteredHelp,
7
7
  renderTldrHeader
8
- } from "./chunk-JTHWLUD3.js";
9
- import "./chunk-FEOPLBGA.js";
8
+ } from "./chunk-E7HJUU34.js";
9
+ import "./chunk-3D7B2UAZ.js";
10
10
  import "./chunk-WA3DYGSY.js";
11
11
  import "./chunk-NLNH64A3.js";
12
12
  import "./chunk-PTGQAZEW.js";
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  renderDoctorFilteredHelp
4
- } from "./chunk-JTHWLUD3.js";
5
- import "./chunk-FEOPLBGA.js";
4
+ } from "./chunk-E7HJUU34.js";
5
+ import "./chunk-3D7B2UAZ.js";
6
6
  import "./chunk-WA3DYGSY.js";
7
7
  import "./chunk-NLNH64A3.js";
8
8
  import "./chunk-PTGQAZEW.js";
@@ -23,7 +23,7 @@ import { defineCommand, renderUsage, runCommand, runMain } from "citty";
23
23
  // src/commands/index.ts
24
24
  var allCommands = {
25
25
  // v2.2.0-rc.5: pipeline-based install with TUI renderer (EPIC-005/006/007/008)
26
- install: () => import("./install-v2-2COC3DO3.js").then((module) => module.installCommand),
26
+ install: () => import("./install-v2-I6PJ6IFT.js").then((module) => module.installCommand),
27
27
  // v2.1.0-rc.1 P3: multi-store lifecycle command group (list/add/remove/explain).
28
28
  store: () => import("./store-HOCORVL3.js").then((module) => module.default),
29
29
  // v2.1.0-rc.1 P3 (S9/S17/S37): multi-store pull --rebase + push, conflict resume.
@@ -35,8 +35,8 @@ var allCommands = {
35
35
  whoami: () => import("./whoami-ITGEFWH4.js").then((module) => module.default),
36
36
  status: () => import("./status-4R3TM4FJ.js").then((module) => module.default),
37
37
  "scope-explain": () => import("./scope-explain-HLJZ2M33.js").then((module) => module.default),
38
- doctor: () => import("./doctor-REZDNH4A.js").then((module) => module.default),
39
- uninstall: () => import("./uninstall-62F4LNKI.js").then((module) => module.default),
38
+ doctor: () => import("./doctor-MDTZWKBK.js").then((module) => module.default),
39
+ uninstall: () => import("./uninstall-IFN2KYBK.js").then((module) => module.default),
40
40
  config: () => import("./config-A3LTECAY.js").then((module) => module.default),
41
41
  "plan-context-hint": () => import("./plan-context-hint-G75R4P4J.js").then((module) => module.default),
42
42
  // v2.0.0-rc.23 TASK-014 (F8c): S5 onboard-slot coverage. Used by the
@@ -153,7 +153,7 @@ async function customShowUsageGrouped(cmd, parent, version) {
153
153
  var main = defineCommand({
154
154
  meta: {
155
155
  name: "fabric",
156
- version: "2.2.0-rc.8",
156
+ version: "2.2.0-rc.9",
157
157
  description: t("cli.main.description")
158
158
  },
159
159
  subCommands: allCommands
@@ -165,7 +165,7 @@ async function customShowUsage(cmd, parent) {
165
165
  return;
166
166
  }
167
167
  if (cmdMeta?.name === "fabric" && parent === void 0) {
168
- await customShowUsageGrouped(cmd, parent, "2.2.0-rc.8");
168
+ await customShowUsageGrouped(cmd, parent, "2.2.0-rc.9");
169
169
  return;
170
170
  }
171
171
  console.log(await renderUsage(cmd, parent) + "\n");
@@ -8,6 +8,7 @@ import {
8
8
  installFabricConnectSkill,
9
9
  installFabricImportSkill,
10
10
  installFabricReviewSkill,
11
+ installFabricRouterSkill,
11
12
  installFabricStoreSkill,
12
13
  installFabricSyncSkill,
13
14
  installHookLibs,
@@ -21,13 +22,13 @@ import {
21
22
  writeClaudeBootstrapThinShell,
22
23
  writeCodexBootstrapManagedBlock,
23
24
  writeFabricAgentsSnapshot
24
- } from "./chunk-CMDW3PYK.js";
25
+ } from "./chunk-7ZDXBOOU.js";
25
26
  import {
26
27
  ensureStoreProjectBinding,
27
28
  migrateRootConfig,
28
29
  normalizeStoreProjectId,
29
30
  suggestStoreProjectId
30
- } from "./chunk-FEOPLBGA.js";
31
+ } from "./chunk-3D7B2UAZ.js";
31
32
  import {
32
33
  createDebugLogger,
33
34
  resolveDevMode
@@ -1737,7 +1738,7 @@ function readProjectName(target) {
1737
1738
  return basename(target);
1738
1739
  }
1739
1740
  function getCliVersion() {
1740
- return true ? "2.2.0-rc.8" : "unknown";
1741
+ return true ? "2.2.0-rc.9" : "unknown";
1741
1742
  }
1742
1743
  function sortRecord(record) {
1743
1744
  return Object.fromEntries(Object.entries(record).sort(([left], [right]) => left.localeCompare(right)));
@@ -2356,6 +2357,7 @@ var HooksStage = class {
2356
2357
  const target = context.target;
2357
2358
  const installResults = [];
2358
2359
  installResults.push(...await this.runBestEffort("skill-deprecated-cleanup", () => cleanupDeprecatedSkills(target)));
2360
+ installResults.push(...await this.runBestEffort("skill-router-install", () => installFabricRouterSkill(target)));
2359
2361
  installResults.push(...await this.runBestEffort("skill-install", () => installFabricArchiveSkill(target)));
2360
2362
  installResults.push(...await this.runBestEffort("skill-review-install", () => installFabricReviewSkill(target)));
2361
2363
  installResults.push(...await this.runBestEffort("skill-import-install", () => installFabricImportSkill(target)));
@@ -7,7 +7,7 @@ import {
7
7
  HOOK_SCRIPT_DESTINATIONS,
8
8
  SKILL_DESTINATIONS,
9
9
  fabricAgentsSnapshotPath
10
- } from "./chunk-CMDW3PYK.js";
10
+ } from "./chunk-7ZDXBOOU.js";
11
11
  import {
12
12
  createDebugLogger,
13
13
  resolveDevMode
@@ -37,6 +37,9 @@ import { readdir, readFile, rm, rmdir } from "fs/promises";
37
37
  import { dirname, join } from "path";
38
38
  import { atomicWriteJson, atomicWriteText } from "@fenglimg/fabric-shared/node/atomic-write";
39
39
  import { BOOTSTRAP_REGEX } from "@fenglimg/fabric-shared/templates/bootstrap-canonical";
40
+ async function uninstallFabricRouterSkill(projectRoot) {
41
+ return removeSkill("skill-router", SKILL_DESTINATIONS.fabricRouter, projectRoot);
42
+ }
40
43
  async function uninstallFabricArchiveSkill(projectRoot) {
41
44
  return removeSkill("skill", SKILL_DESTINATIONS.fabricArchive, projectRoot);
42
45
  }
@@ -345,6 +348,12 @@ async function uninstallBootstrapStage(projectRoot, _opts = {}) {
345
348
  projectRoot,
346
349
  () => uninstallFabricArchiveSkill(projectRoot)
347
350
  );
351
+ await runAndCollect(
352
+ results,
353
+ "skill-router",
354
+ projectRoot,
355
+ () => uninstallFabricRouterSkill(projectRoot)
356
+ );
348
357
  return results;
349
358
  }
350
359
  async function runAndCollect(results, step, projectRoot, fn) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fenglimg/fabric-cli",
3
- "version": "2.2.0-rc.8",
3
+ "version": "2.2.0-rc.9",
4
4
  "description": "Fabric CLI — installs the MCP server + skills + hooks for Claude Code and Codex CLI; runs doctor / knowledge maintenance.",
5
5
  "license": "MIT",
6
6
  "author": "wangzhichao <fenglimg90@gmail.com>",
@@ -46,8 +46,8 @@
46
46
  "tree-sitter-javascript": "^0.25.0",
47
47
  "tree-sitter-typescript": "^0.23.2",
48
48
  "web-tree-sitter": "^0.26.8",
49
- "@fenglimg/fabric-shared": "2.2.0-rc.8",
50
- "@fenglimg/fabric-server": "2.2.0-rc.8"
49
+ "@fenglimg/fabric-server": "2.2.0-rc.9",
50
+ "@fenglimg/fabric-shared": "2.2.0-rc.9"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/node": "^22.15.0",
@@ -117,6 +117,10 @@ function readSnapshotKnowledgeStats(projectRoot, now) {
117
117
  }
118
118
  try {
119
119
  const snapshot = bindingsSnapshotReader.readBindingsSnapshot(bindingId);
120
+ // No snapshot file → empty corpus (KT-DEC-0007), preserving prior behavior.
121
+ if (!snapshot) {
122
+ return empty;
123
+ }
120
124
  // LIVE recount off the snapshot's resolved store dirs. The cached
121
125
  // knowledge_stats projection is frozen at snapshot-write time, so once the
122
126
  // pending queue is reviewed (or store content syncs out-of-band) it goes
@@ -124,8 +128,16 @@ function readSnapshotKnowledgeStats(projectRoot, now) {
124
128
  // (KT-PIT-0017). The authoritative count is the live *.md walk under the
125
129
  // resolved store dirs.
126
130
  const live = bindingsSnapshotReader.liveKnowledgeStats(snapshot);
127
- if (!live || typeof live !== "object") {
128
- return empty;
131
+ // #3: a snapshot predating knowledge_store_dirs makes liveKnowledgeStats
132
+ // return null — counts are undeterminable and the cached projection is
133
+ // unreliable. Return `undefined` (a marker distinct from the `null` that
134
+ // lib/binding-absent returns, which readPendingStats uses as its legacy-
135
+ // fallback signal) so countCanonicalNodes maps it to "unknown" and the
136
+ // underseed signal SKIPS rather than false-firing on a stale corpus (snapshot
137
+ // self-heals on the next install/sync). Distinguished from the missing-
138
+ // snapshot case above, which stays `empty` (genuine fresh-project zero).
139
+ if (live === null) {
140
+ return undefined;
129
141
  }
130
142
  const pendingCount =
131
143
  Number.isFinite(live.pendingCount) && live.pendingCount > 0 ? Math.floor(live.pendingCount) : 0;
@@ -412,7 +424,10 @@ function readLedger(projectRoot) {
412
424
  */
413
425
  function readPendingStats(projectRoot, now) {
414
426
  const stats = readSnapshotKnowledgeStats(projectRoot, now);
415
- if (stats !== null) {
427
+ // `!= null` (loose) also catches the `undefined` old-snapshot marker (#3)
428
+ // fall through to the legacy reader (which degrades to 0 → no phantom review
429
+ // nudge), rather than dereferencing pendingCount on a non-object.
430
+ if (stats != null) {
416
431
  return { count: stats.pendingCount, oldestAgeMs: stats.oldestPendingAgeMs };
417
432
  }
418
433
  return readLegacyPendingStats(projectRoot, now);
@@ -425,6 +440,12 @@ function readPendingStats(projectRoot, now) {
425
440
  */
426
441
  function countCanonicalNodes(projectRoot) {
427
442
  const stats = readSnapshotKnowledgeStats(projectRoot);
443
+ // #3: `undefined` = snapshot EXISTS but predates knowledge_store_dirs →
444
+ // undeterminable → return null so decide()'s underseed signal SKIPS rather than
445
+ // false-firing on a stale corpus. `null` = no reader / not bound → degrade to 0
446
+ // (KT-DEC-0007, preserved). The `empty` object (missing snapshot) → canonical 0,
447
+ // still firing correctly for a genuinely fresh corpus.
448
+ if (stats === undefined) return null;
428
449
  return stats === null ? 0 : stats.canonicalCount;
429
450
  }
430
451
 
@@ -1010,6 +1031,10 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
1010
1031
  lastInitScanTs === null ? null : (nowMs - lastInitScanTs) / MS_PER_HOUR;
1011
1032
  const hoursSinceProposed = hoursElapsed; // reuse archive-signal calc above
1012
1033
  const triggerUnderseed =
1034
+ // #3: null = undeterminable canonical count (old snapshot) → skip. Guard
1035
+ // first because `null < threshold` coerces to true in JS and would else
1036
+ // false-fire the underseed nudge on a stale corpus.
1037
+ underseed.nodeCount != null &&
1013
1038
  underseed.nodeCount < underseed.threshold &&
1014
1039
  hoursSinceInit !== null &&
1015
1040
  hoursSinceInit >= UNDERSEED_POST_INIT_QUIET_HOURS &&
@@ -16,9 +16,8 @@
16
16
  *
17
17
  * AI sink (additionalContext) — the dynamically generated "MEMORY.md":
18
18
  * [fabric:SessionStart] <store>
19
- * ALWAYS-ACTIVE RULES (no recall needed): # guideline/model — full BODY
20
- * [guideline] team:KT-GLD-0001
21
- * <full body> # over budget → degrade to index line
19
+ * ALWAYS-ACTIVE RULES (no recall needed): # guideline/model — INDEX line only
20
+ * [guideline] team:KT-GLD-0001 · <summary> # KT-DEC-0036: no eager body
22
21
  * REFERENCE (read on demand / fab_recall): # decision/pitfall/process — title + hook
23
22
  * [decision] team:KT-DEC-0001 — <must_read_if>
24
23
  * … N more folded (broad index > backstop 50; run fabric-audit)
@@ -54,7 +53,6 @@ const { appendLockedLine } = require("./lib/injection-log.cjs");
54
53
  // (TASK-002). Variant is resolved ONCE per main() invocation via
55
54
  // readFabricLanguage(cwd) and threaded into renderBanner — no fs in render path.
56
55
  const { renderBanner, readFabricLanguage } = require("./lib/banner-i18n.cjs");
57
- const { resolveOpaqueSummaries } = require("./lib/summary-fallback.cjs");
58
56
  // v2.0.0-rc.37 NEW-19: shared fabric-config reader + sidecar I/O. Replaces the
59
57
  // five per-key readFileSync+parse config readers (one parse per fire now) and
60
58
  // the bespoke last-emit sidecar helpers. The L78 "refactor into lib/ if a
@@ -112,15 +110,23 @@ function readWorkspaceBindingId(cwd) {
112
110
  }
113
111
 
114
112
  function readSnapshotCanonicalCount(projectRoot) {
113
+ // No reader / not bound → degrade to an empty corpus (0), the documented
114
+ // store-only behavior (KT-DEC-0007). Only the "snapshot EXISTS but predates
115
+ // knowledge_store_dirs" case below is undeterminable → null (skip).
115
116
  if (bindingsSnapshotReader === null) {
116
- return null;
117
+ return 0;
117
118
  }
118
119
  const bindingId = readWorkspaceBindingId(projectRoot);
119
120
  if (bindingId === null) {
120
- return null;
121
+ return 0;
121
122
  }
122
123
  try {
123
124
  const snapshot = bindingsSnapshotReader.readBindingsSnapshot(bindingId);
125
+ // No snapshot file at all → treat as an empty corpus (KT-DEC-0007),
126
+ // preserving the fresh-project underseed nudge.
127
+ if (!snapshot) {
128
+ return 0;
129
+ }
124
130
  // LIVE recount off the snapshot's resolved store dirs. The cached
125
131
  // knowledge_stats.canonical_count is frozen at snapshot-write time and goes
126
132
  // stale when store content syncs in out-of-band (e.g. the store grew from 1
@@ -128,13 +134,21 @@ function readSnapshotCanonicalCount(projectRoot) {
128
134
  // THIS workspace's snapshot), which mis-fired the "knowledge sparse"
129
135
  // underseed nudge (KT-PIT-0017, same stale-projection root cause).
130
136
  const live = bindingsSnapshotReader.liveKnowledgeStats(snapshot);
131
- if (live && Number.isFinite(live.canonicalCount) && live.canonicalCount > 0) {
132
- return Math.floor(live.canonicalCount);
137
+ // #3: a snapshot that predates knowledge_store_dirs makes liveKnowledgeStats
138
+ // return null — the count is undeterminable and the cached projection is
139
+ // unreliable. Return null (not 0) so countCanonicalNodes / shouldRecommendImport
140
+ // SKIP the nudge instead of false-firing on stale data; the snapshot
141
+ // self-heals on the next install/sync. A genuine live 0 (dirs present, no
142
+ // *.md) still returns 0 and fires correctly.
143
+ if (live === null) {
144
+ return null;
133
145
  }
146
+ return Number.isFinite(live.canonicalCount) ? Math.floor(live.canonicalCount) : 0;
134
147
  } catch {
135
- // best-effort hint stats only
148
+ // Read/parse fault degrade to empty (0), preserving prior behavior. The
149
+ // only undeterminable→skip path is the explicit live===null above.
150
+ return 0;
136
151
  }
137
- return 0;
138
152
  }
139
153
 
140
154
 
@@ -205,8 +219,10 @@ const DEFAULT_HINT_REMINDER_TO_CONTEXT = true;
205
219
  * trees — a missing snapshot degrades to zero (KT-DEC-0007).
206
220
  */
207
221
  function countCanonicalNodes(projectRoot) {
208
- const snapshotCount = readSnapshotCanonicalCount(projectRoot);
209
- return snapshotCount === null ? 0 : snapshotCount;
222
+ // #3: null = undeterminable (old snapshot lacking store dirs, or no binding
223
+ // context). Propagate it shouldRecommendImport SKIPS on null rather than
224
+ // treating it as zero and false-firing the underseed nudge on a stale corpus.
225
+ return readSnapshotCanonicalCount(projectRoot);
210
226
  }
211
227
 
212
228
  /**
@@ -338,6 +354,10 @@ function shouldRecommendImport(projectRoot) {
338
354
 
339
355
  const threshold = readUnderseedThreshold(projectRoot);
340
356
  const nodeCount = countCanonicalNodes(projectRoot);
357
+ // #3: undeterminable count (old snapshot predating knowledge_store_dirs) →
358
+ // skip. `null < threshold` coerces to true in JS, so an explicit guard is
359
+ // required — otherwise the stale-snapshot case would still false-fire.
360
+ if (nodeCount === null) return false;
341
361
  if (nodeCount >= threshold) return false;
342
362
 
343
363
  if (isImportTouched(projectRoot) !== "absent") return false;
@@ -374,65 +394,6 @@ const CLI_TIMEOUT_MS = 2000;
374
394
  // `hint_summary_max_len` in fabric-config overrides this default (range 40..240).
375
395
  const DEFAULT_SUMMARY_MAX_LEN = 80;
376
396
 
377
- // v2.2 HK2-degrade (W2-T2): char budget for the rendered broad-menu BODY. The
378
- // hook already degrades by COUNT (hint_broad_top_k slice + TRUNCATION_THRESHOLD
379
- // grouped mode), but nothing bounded the total rendered SIZE — a corpus with
380
- // many types or long (near-maxLen) summaries could still emit a wall of text
381
- // that displaces the agent's working memory. Borrowing the maestro
382
- // context-budget idea, this is the final rung of the degradation ladder: once
383
- // the body exceeds the budget, the tail collapses to a single "N more omitted"
384
- // marker. Default 2000 chars ≈ one screenful. Overridable via
385
- // fabric-config.json#hint_broad_budget_chars (range 200..20000); 0 disables.
386
- const DEFAULT_HINT_BROAD_BUDGET_CHARS = 2000;
387
-
388
- // v2.2 C5-budget (W2-T3): bind the injection char budget to the layered retrieval
389
- // budget profile. Mirrors the injectionChars column of shared/retrieval-budget.ts
390
- // PROFILES (kept in sync — the hook cannot require the TS resolver). The explicit
391
- // `hint_broad_budget_chars` knob still wins; the profile only supplies the
392
- // default. `balanced` (and an absent/unknown profile) keeps the historical 2000.
393
- const RETRIEVAL_BUDGET_INJECTION_CHARS = {
394
- conservative: 1000,
395
- balanced: 2000,
396
- generous: 4000,
397
- };
398
-
399
- function readBroadBudgetChars(projectRoot) {
400
- const profile = readConfigString(projectRoot, "retrieval_budget_profile", "balanced");
401
- const profileDefault =
402
- RETRIEVAL_BUDGET_INJECTION_CHARS[profile] ?? DEFAULT_HINT_BROAD_BUDGET_CHARS;
403
- return readConfigNumber(projectRoot, "hint_broad_budget_chars", profileDefault, {
404
- min: 0,
405
- max: 20000,
406
- floor: true,
407
- });
408
- }
409
-
410
- // v2.2 HK2-degrade (W2-T2): cap the rendered body to `budgetChars`, collapsing
411
- // the overflow tail into one marker line. Structural lines (banner, revision_hash,
412
- // footer) are appended by renderSummary AFTER this pass, so they always survive —
413
- // only entry/group body lines are subject to the budget. `budgetChars` of 0 or
414
- // undefined is a no-op (preserves the pre-HK2 unbounded behavior and all
415
- // existing snapshot tests).
416
- function capBodyToBudget(body, budgetChars) {
417
- if (!budgetChars || budgetChars <= 0) return body;
418
- const kept = [];
419
- let total = 0;
420
- for (let i = 0; i < body.length; i += 1) {
421
- const line = body[i];
422
- // +1 for the newline each line costs once joined.
423
- if (kept.length > 0 && total + line.length + 1 > budgetChars) {
424
- const remaining = body.length - i;
425
- kept.push(
426
- ` … ${remaining} more entr${remaining === 1 ? "y" : "ies"} omitted (injection budget ${budgetChars} chars; raise hint_broad_budget_chars or narrow scope)`,
427
- );
428
- return kept;
429
- }
430
- kept.push(line);
431
- total += line.length + 1;
432
- }
433
- return kept;
434
- }
435
-
436
397
  function readSummaryMaxLen(projectRoot) {
437
398
  return readConfigNumber(projectRoot, "hint_summary_max_len", DEFAULT_SUMMARY_MAX_LEN, {
438
399
  min: 40,
@@ -682,7 +643,7 @@ function renderTruncated(narrow, maxLen) {
682
643
  * after writing exactly one stderr breadcrumb so operators grepping a stuck-
683
644
  * banner report can diagnose the version drift without source-diving.
684
645
  */
685
- function renderSummary(payload, maxLen, budgetChars) {
646
+ function renderSummary(payload, maxLen) {
686
647
  if (!payload || payload.version !== 2) {
687
648
  if (payload && payload.version !== undefined) {
688
649
  try {
@@ -705,9 +666,9 @@ function renderSummary(payload, maxLen, budgetChars) {
705
666
  ? `[fabric] Session start — ${entries.length} broad-scoped knowledge entries available (truncated):`
706
667
  : `[fabric] Session start — ${entries.length} broad-scoped knowledge entries available:`;
707
668
 
708
- const renderedBody = truncated ? renderTruncated(entries, maxLen) : renderFull(entries, maxLen);
709
- // v2.2 HK2-degrade (W2-T2): final budget rung cap the body's rendered size.
710
- const body = capBodyToBudget(renderedBody, budgetChars);
669
+ // KT-DEC-0028 completeness: the rendered census is bounded by the per-line
670
+ // maxLen + TRUNCATION_THRESHOLD grouped mode, not by a body char budget.
671
+ const body = truncated ? renderTruncated(entries, maxLen) : renderFull(entries, maxLen);
711
672
 
712
673
  const lines = [banner, ...body];
713
674
  const revHash = typeof payload.revision_hash === "string" ? payload.revision_hash : null;
@@ -861,10 +822,10 @@ function renderHumanCensus(census, opts) {
861
822
  // generated "MEMORY.md" spine injected into the SessionStart context. Two
862
823
  // type-tiered sections over the BROAD knowledge (narrow stays silent — D0029):
863
824
  //
864
- // ALWAYS-ACTIVE RULES (guideline/model): full BODIES injected, bounded by
865
- // budgetChars. On overflow each entry degrades to an INDEX LINE (title +
866
- // summary), NOT a folded count (D0028) the entry stays individually
867
- // visible and fetchable.
825
+ // ALWAYS-ACTIVE RULES (guideline/model): INDEX LINE only (title + summary) —
826
+ // never the eager body (KT-DEC-0036). The body is one cheap on-demand fetch
827
+ // away, so injecting it on every SessionStart is a permanent context tax
828
+ // (KT-GLD-0005) we no longer pay; each entry stays individually visible.
868
829
  // REFERENCE (decision/pitfall/process): TITLE + must_read_if hook only
869
830
  // (situational; the agent Reads the body on demand) — never the body.
870
831
  //
@@ -876,7 +837,7 @@ function renderHumanCensus(census, opts) {
876
837
  const REFERENCE_TYPES = new Set(["decision", "pitfall", "process"]);
877
838
 
878
839
  function renderAiSink(opts) {
879
- const { entries, alwaysBodies, storeLabel, budgetChars, broadIndexBackstop, summaryMaxLen, lang } =
840
+ const { entries, alwaysBodies, storeLabel, broadIndexBackstop, summaryMaxLen, lang } =
880
841
  opts || {};
881
842
  const zh = lang === "zh-CN";
882
843
  const bodies = Array.isArray(alwaysBodies) ? alwaysBodies : [];
@@ -895,30 +856,19 @@ function renderAiSink(opts) {
895
856
  const lines = [];
896
857
  lines.push(`[fabric:SessionStart] ${storeLabel || "store"}`);
897
858
 
898
- // ALWAYS-ACTIVE RULES — inject bodies up to the budget, degrade to index line.
859
+ // ALWAYS-ACTIVE RULES — index-only (title + summary), never the eager body.
899
860
  lines.push(zh ? "ALWAYS-ACTIVE RULES (无需再 recall):" : "ALWAYS-ACTIVE RULES (no recall needed):");
900
861
  if (bodies.length === 0) {
901
862
  lines.push(zh ? " (无 always-active 条目)" : " (none)");
902
863
  } else {
903
- const budget = typeof budgetChars === "number" && budgetChars > 0 ? budgetChars : 0;
904
- let used = 0;
905
- let degraded = false;
864
+ // KT-DEC-0036: render each always-active entry as a single index line
865
+ // (title + summary). The body is one cheap on-demand fetch away (see footer),
866
+ // so injecting it on every SessionStart is a permanent context tax
867
+ // (KT-GLD-0005) we no longer pay.
906
868
  for (const b of bodies) {
907
869
  const label = `[${TYPE_SINGULAR[b.type] || b.type}] ${b.id}`;
908
- const body = typeof b.body === "string" ? b.body.trim() : "";
909
- const summary = typeof b.summary === "string" ? b.summary : "";
910
- const fullCost = label.length + body.length + 2;
911
- if (!degraded && (budget === 0 || used + fullCost <= budget)) {
912
- lines.push(` ${label}`);
913
- if (body.length > 0) lines.push(body);
914
- used += fullCost;
915
- } else {
916
- // D0028: degrade to an INDEX LINE (title + summary), never a folded count.
917
- degraded = true;
918
- lines.push(
919
- ` ${label} · ${summary}${zh ? " (超预算; fab_recall 取正文)" : " (over budget; fab_recall for body)"}`,
920
- );
921
- }
870
+ const summary = typeof b.summary === "string" ? b.summary.trim() : "";
871
+ lines.push(summary.length > 0 ? ` ${label} · ${summary}` : ` ${label}`);
922
872
  indexCount += 1;
923
873
  }
924
874
  }
@@ -977,29 +927,20 @@ function renderAiSink(opts) {
977
927
  // Returns:
978
928
  // human — gated final human text (null when gated off / empty)
979
929
  // ai — gated final AI text (null when reminder-to-context off / empty)
980
- // resolvedPayload — payload with opaque summaries resolved (for telemetry / --explain)
930
+ // resolvedPayload — the plan-context payload, passed through unchanged (for telemetry / --explain)
981
931
  // hasRenderedContent — true when ANY sink rendered content (main's silent-exit gate)
982
932
  // reminderToContext — readReminderToContext(cwd) (telemetry target-channel)
983
933
  function buildSessionStartSinks(cwd, payload, env) {
984
- // rc.35 TASK-06: opaque-summary substitution (best-effort; failure leaves
985
- // the original summary untouched).
986
- let resolvedPayload = payload;
987
- try {
988
- if (payload && Array.isArray(payload.entries)) {
989
- const resolvedEntries = resolveOpaqueSummaries(
990
- payload.entries,
991
- cwd,
992
- typeof payload.revision_hash === "string" ? payload.revision_hash : "",
993
- );
994
- resolvedPayload = { ...payload, entries: resolvedEntries };
995
- }
996
- } catch {
997
- // resolveOpaqueSummaries swallows its own errors; belt + suspenders.
998
- }
934
+ // KT-GLD-0006: the rc.35 opaque-summary runtime substitution
935
+ // (resolveOpaqueSummaries) is retired. The write-time mechanical floor in
936
+ // extractKnowledge (summary !== stable_id/slug + length floor) prevents
937
+ // degenerate summaries at the source, so SessionStart no longer band-aids them
938
+ // at render time; surviving legacy opaque summaries are fixed by the
939
+ // review-time cold-eval audit pass.
940
+ const resolvedPayload = payload;
999
941
 
1000
942
  const recommendImport = shouldRecommendImport(cwd);
1001
943
  const summaryMaxLen = readSummaryMaxLen(cwd);
1002
- const broadBudgetChars = readBroadBudgetChars(cwd);
1003
944
  const fabricLanguageForEmit = readFabricLanguage(cwd);
1004
945
 
1005
946
  const census =
@@ -1023,7 +964,7 @@ function buildSessionStartSinks(cwd, payload, env) {
1023
964
  // ---- HUMAN sink: §3 grouped census (+ verbose per-entry detail). ----
1024
965
  const humanLines = renderHumanCensus(census, { lang: fabricLanguageForEmit });
1025
966
  if (humanLines.length > 0 && humanGate.verbosity === "verbose") {
1026
- const detail = renderSummary(resolvedPayload, summaryMaxLen, broadBudgetChars);
967
+ const detail = renderSummary(resolvedPayload, summaryMaxLen);
1027
968
  humanLines.push(...detail);
1028
969
  }
1029
970
  if (bindingsSnapshotReader !== null && humanLines.length > 0) {
@@ -1056,12 +997,12 @@ function buildSessionStartSinks(cwd, payload, env) {
1056
997
  );
1057
998
  }
1058
999
 
1059
- // ---- AI sink: spine — always-active bodies + reference, bounded. ----
1000
+ // ---- AI sink: spine — always-active INDEX lines (no eager body, KT-DEC-0036)
1001
+ // + reference, bounded by the broad_index_backstop fold. ----
1060
1002
  const broadIndexBackstop = readBroadIndexBackstop(cwd);
1061
1003
  const aiText = renderAiSink({
1062
1004
  entries: resolvedPayload && Array.isArray(resolvedPayload.entries) ? resolvedPayload.entries : [],
1063
1005
  alwaysBodies,
1064
- budgetChars: broadBudgetChars,
1065
1006
  broadIndexBackstop,
1066
1007
  summaryMaxLen,
1067
1008
  lang: fabricLanguageForEmit,
@@ -75,11 +75,9 @@ const {
75
75
  } = require("node:fs");
76
76
  const { dirname, join } = require("node:path");
77
77
 
78
- // rc.35 TASK-06 (P0-10.b): summary-fallback. Substitutes opaque entries
79
- // (where description.summary === stable_id) with a snippet read from the
80
- // entry's .md `## Summary` section. Caches results in
81
- // `.fabric/.cache/summary-fallback.json` keyed by revision_hash.
82
- const { resolveOpaqueSummaries } = require("./lib/summary-fallback.cjs");
78
+ // KT-GLD-0006: the rc.35 opaque-summary substitution (resolveOpaqueSummaries) is
79
+ // retired the write-time mechanical floor in extractKnowledge prevents
80
+ // degenerate summaries at the source, so the narrow hook no longer band-aids them.
83
81
  // v2.0.0-rc.37 NEW-17: shared sidecar I/O for the plan-context-hint result
84
82
  // cache (skips a redundant CLI cold-start spawn when the same path-set is
85
83
  // re-edited within a session and the knowledge graph hasn't changed).
@@ -1492,21 +1490,10 @@ async function main(env, stdio) {
1492
1490
  }
1493
1491
 
1494
1492
  const summaryMaxLen = readSummaryMaxLen(cwd);
1495
- // rc.35 TASK-06 (P0-10.b): substitute opaque summaries before render.
1496
- // Same lib used by the broad hook — opaque entries seen from both call
1497
- // sites share a single .fabric/.cache/summary-fallback.json file.
1498
- // Best-effort any failure leaves the original opaque summary intact.
1499
- let resolvedEntries = dedupDecision.filtered;
1500
- try {
1501
- resolvedEntries = resolveOpaqueSummaries(
1502
- dedupDecision.filtered,
1503
- cwd,
1504
- currentRevisionHash,
1505
- );
1506
- } catch {
1507
- // resolveOpaqueSummaries swallows its own errors; defensive catch.
1508
- }
1509
- const lines = renderSummary({ ...cliPayload, entries: resolvedEntries }, summaryMaxLen);
1493
+ // KT-GLD-0006: the rc.35 opaque-summary runtime substitution is retired the
1494
+ // write-time mechanical floor in extractKnowledge prevents degenerate summaries
1495
+ // at the source, so the narrow hook renders the description summary as-is.
1496
+ const lines = renderSummary({ ...cliPayload, entries: dedupDecision.filtered }, summaryMaxLen);
1510
1497
  if (lines.length === 0) return;
1511
1498
 
1512
1499
  // v2.1.0-rc.1 P4 (F4/S63): store-aware hint — append the write-target store
@@ -1564,7 +1551,7 @@ async function main(env, stdio) {
1564
1551
  const surfaceClient = detectClient();
1565
1552
  const fabricDir = join(cwd, FABRIC_DIR_REL);
1566
1553
  if (surfaceClient !== undefined && existsSync(fabricDir)) {
1567
- const renderedIds = resolvedEntries
1554
+ const renderedIds = dedupDecision.filtered
1568
1555
  .map((e) => (e && typeof e.id === "string" ? e.id : null))
1569
1556
  .filter((x) => x !== null);
1570
1557
  const realSessionId =
@@ -148,18 +148,19 @@ function liveKnowledgeStats(snapshot) {
148
148
  }
149
149
  return { pendingCount, canonicalCount, oldestPendingMtimeMs };
150
150
  }
151
- // Backward-compatible fallback: snapshot predates knowledge_store_dirs.
152
- const stats = snapshot.knowledge_stats;
153
- if (stats && typeof stats === "object") {
154
- return {
155
- pendingCount: Number.isFinite(stats.pending_count) ? Math.floor(stats.pending_count) : 0,
156
- canonicalCount: Number.isFinite(stats.canonical_count) ? Math.floor(stats.canonical_count) : 0,
157
- oldestPendingMtimeMs:
158
- Number.isFinite(stats.oldest_pending_mtime_ms) && stats.oldest_pending_mtime_ms > 0
159
- ? stats.oldest_pending_mtime_ms
160
- : null,
161
- };
162
- }
151
+ // #3 (GH issue): snapshot predates knowledge_store_dirs. The cached
152
+ // `knowledge_stats` projection is frozen at snapshot-write time and goes stale
153
+ // out-of-band (store grew via git pull / cross-workspace sync), so trusting it
154
+ // re-introduced exactly the false-nudge this whole field cures — observed a
155
+ // store with 61 live canonical entries whose cached count was frozen at 1,
156
+ // mis-firing the "knowledge sparse → /fabric-import" underseed nudge AND
157
+ // defeating the fabric-import `canonical > 50 → SKIP` guard. read_set carries
158
+ // no resolved store root either (alias/uuid only), so a live recount is
159
+ // impossible without re-resolution (which hooks must not do). Return null
160
+ // ("undeterminable") so callers SKIP the nudge rather than act on a stale
161
+ // count — old snapshots self-heal on the next install/sync/store-op (which
162
+ // regenerates the snapshot WITH knowledge_store_dirs). 宁可不弹也别误弹
163
+ // (KT-DEC-0007: hook = nudge, never a false-positive gate).
163
164
  return null;
164
165
  }
165
166
 
@@ -17,15 +17,21 @@ description: Fabric 入口层路由 — 参考 maestro 的顺序协调方式,
17
17
 
18
18
  ## Intent Map
19
19
 
20
- | 用户意图 | 下游 skill |
20
+ <!-- fabric:router-intent:begin -->
21
+ <!-- 本块由 `fabric install` 从 7 个 leaf skill 的 description Triggers 子句生成。严禁手编;改 leaf description 后重跑 `fabric install`。 -->
22
+
23
+ | 用户意图(leaf description Triggers) | 下游 skill |
21
24
  | --- | --- |
22
- | 记录/归档/以后记住/always/never/下次注意 | `fabric-archive` |
23
- | 审批 pending、批量 approve/reject/modify/revisit/defer | `fabric-review` |
24
- | git log、docs 或历史材料冷启动导入知识 | `fabric-import` |
25
- | 创建、挂载、绑定、列出、切换 write store | `fabric-store` |
26
- | store pull --rebase + push、同步冲突处理 | `fabric-sync` |
27
- | 知识库体检、淘汰陈旧条目、deprecate、rescue-before-delete | `fabric-audit` |
28
- | 发现 KB 条目关联、补 `related` 边、知识图谱连通性 | `fabric-connect` |
25
+ | 以后/always/never/下次/记一下;wrong-turn-revert;decision-confirm;dismissal-reason;/fabric-archive | `fabric-archive` |
26
+ | 审批/驳回/复审/重审/approve/reject/review pending | `fabric-review` |
27
+ | 导入历史/bootstrap fabric/mine changelog/挖掘 commit | `fabric-import` |
28
+ | 同步知识库/sync stores/fabric-sync/解决 store 冲突/rebase 冲突 | `fabric-sync` |
29
+ | 创建 store/挂载 store/绑定知识库/store 列表/切换写库/set up knowledge store | `fabric-store` |
30
+ | 审计知识库/清理陈旧知识/知识库体检/deprecate 条目/prune stale knowledge/知识库瘦身/淘汰旧决策 | `fabric-audit` |
31
+ | 连接知识/找关联条目/建知识图谱/link related entries/补 related 边/知识库连通性 | `fabric-connect` |
32
+
33
+ `S_CLASSIFY` 的 `task_type` 枚举:`archive | review | import | sync | store | audit | connect`
34
+ <!-- fabric:router-intent:end -->
29
35
 
30
36
  ## State Machine
31
37
 
@@ -35,7 +41,7 @@ description: Fabric 入口层路由 — 参考 maestro 的顺序协调方式,
35
41
 
36
42
  ```json
37
43
  {
38
- "task_type": "archive|review|import|store|sync|audit|connect",
44
+ "task_type": "<Intent Map task_type 枚举之一>",
39
45
  "scope": "project|store|entry|paths|null",
40
46
  "write_intent": true,
41
47
  "confidence": "high|medium|low"
@@ -1,273 +0,0 @@
1
- /**
2
- * rc.35 TASK-06 (P0-10.b) — summary-fallback library.
3
- *
4
- * Resolves opaque hint entries (where `entry.summary === entry.id` so the
5
- * AI sees no information beyond the id) by reading the entry's markdown file
6
- * from mounted store `knowledge/<type>/<id>--<slug>.md`, extracting the first
7
- * paragraph under `## Summary`, and substituting that text into the entry
8
- * before the hook renders it.
9
- *
10
- * Caching: results are stored in `.fabric/.cache/summary-fallback.json`
11
- * keyed by the current `revision_hash` returned by plan-context-hint. The
12
- * cache is wiped wholesale when the revision changes (cheap invariant —
13
- * any meta rev bump implies entry text MAY have moved). Per-process call
14
- * also benefits from in-memory dedup since the same opaque id may appear
15
- * across narrow + broad paths.
16
- *
17
- * Design contract:
18
- * - Never throw. ANY failure (cache read, fs scan, file read) degrades
19
- * to a no-op — the original opaque summary is left untouched. Hooks
20
- * must remain best-effort.
21
- * - Idempotent over identical inputs. Two calls in succession with the
22
- * same revision_hash + entries set produce zero disk reads on the
23
- * second call.
24
- *
25
- * Public API (module.exports):
26
- * resolveOpaqueSummaries(entries, projectRoot, revisionHash) — returns
27
- * a NEW array of entries with `summary` substituted for opaque cases.
28
- * Original `entry.id` is preserved verbatim.
29
- *
30
- * _extractFirstSummaryParagraph(md) — pure helper, exposed for testing.
31
- *
32
- * _readCache / _writeCache — exposed for testing.
33
- */
34
-
35
- const { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } = require("node:fs");
36
- const { homedir } = require("node:os");
37
- const { join } = require("node:path");
38
-
39
- const CACHE_DIR_REL = ".fabric/.cache";
40
- const CACHE_FILE_REL = ".fabric/.cache/summary-fallback.json";
41
- const GLOBAL_CONFIG_FILE = "fabric-global.json";
42
- const PROJECT_CONFIG_REL = ".fabric/fabric-config.json";
43
- const SUMMARY_MAX_LEN = 80;
44
- const KNOWLEDGE_TYPE_DIRS = ["decisions", "pitfalls", "guidelines", "models", "processes"];
45
-
46
- function _isOpaque(entry) {
47
- if (!entry || typeof entry.id !== "string" || typeof entry.summary !== "string") {
48
- return false;
49
- }
50
- return entry.summary.trim() === entry.id.trim();
51
- }
52
-
53
- /**
54
- * Pure helper: extract the first paragraph under a `## Summary` heading.
55
- *
56
- * - `## Summary` is case-insensitive but level-sensitive (only H2).
57
- * - First paragraph = lines until blank line or next heading.
58
- * - Collapses whitespace + trims; returns `""` if no summary section or
59
- * the section is empty.
60
- */
61
- function _extractFirstSummaryParagraph(md) {
62
- if (typeof md !== "string" || md.length === 0) return "";
63
- const lines = md.split(/\r?\n/);
64
- let i = 0;
65
- while (i < lines.length) {
66
- if (/^##\s+summary\s*$/i.test(lines[i].trim())) {
67
- i += 1;
68
- break;
69
- }
70
- i += 1;
71
- }
72
- if (i >= lines.length) return "";
73
- // Skip blank lines after the heading
74
- while (i < lines.length && lines[i].trim().length === 0) i += 1;
75
- // Collect until the next blank line or next heading
76
- const buf = [];
77
- while (i < lines.length) {
78
- const line = lines[i];
79
- if (line.trim().length === 0) break;
80
- if (/^#{1,6}\s/.test(line.trim())) break;
81
- buf.push(line.trim());
82
- i += 1;
83
- }
84
- const flat = buf.join(" ").replace(/\s+/g, " ").trim();
85
- if (flat.length === 0) return "";
86
- if (flat.length <= SUMMARY_MAX_LEN) return flat;
87
- return `${flat.slice(0, SUMMARY_MAX_LEN - 1)}…`;
88
- }
89
-
90
- function _readCache(projectRoot) {
91
- const cachePath = join(projectRoot, CACHE_FILE_REL);
92
- if (!existsSync(cachePath)) return null;
93
- try {
94
- const raw = readFileSync(cachePath, "utf8");
95
- const parsed = JSON.parse(raw);
96
- if (parsed && typeof parsed === "object" && typeof parsed.revision === "string" && parsed.summaries && typeof parsed.summaries === "object") {
97
- return parsed;
98
- }
99
- } catch {
100
- // ignore — caller treats null as no-cache
101
- }
102
- return null;
103
- }
104
-
105
- function _writeCache(projectRoot, payload) {
106
- try {
107
- const cacheDir = join(projectRoot, CACHE_DIR_REL);
108
- if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
109
- const cachePath = join(projectRoot, CACHE_FILE_REL);
110
- writeFileSync(cachePath, JSON.stringify(payload), "utf8");
111
- } catch {
112
- // Best-effort — failing to persist cache is not an error
113
- }
114
- }
115
-
116
- /**
117
- * Return mounted store directories in the project's read-set
118
- * (`required_stores` plus implicit personal). This hook helper is deliberately
119
- * tiny and best-effort: malformed config degrades to an empty read-set rather
120
- * than throwing during a shell hook.
121
- */
122
- function _readJson(path) {
123
- try {
124
- return JSON.parse(readFileSync(path, "utf8"));
125
- } catch {
126
- return null;
127
- }
128
- }
129
-
130
- function _globalRoot() {
131
- return join(process.env.FABRIC_HOME || homedir(), ".fabric");
132
- }
133
-
134
- function _storeDir(globalRoot, store) {
135
- return join(globalRoot, "stores", store.mount_name || store.store_uuid);
136
- }
137
-
138
- function _readSetStoreDirs(projectRoot) {
139
- const globalRoot = _globalRoot();
140
- const global = _readJson(join(globalRoot, GLOBAL_CONFIG_FILE));
141
- if (!global || !Array.isArray(global.stores)) return [];
142
- const project = _readJson(join(projectRoot, PROJECT_CONFIG_REL)) || {};
143
- const required = Array.isArray(project.required_stores) ? project.required_stores : [];
144
- const stores = [];
145
-
146
- for (const req of required) {
147
- if (!req || typeof req.id !== "string") continue;
148
- const matched = global.stores.find(
149
- (store) => store && !store.personal && (store.alias === req.id || store.store_uuid === req.id),
150
- );
151
- if (matched) stores.push(matched);
152
- }
153
-
154
- const personal = global.stores.find((store) => store && store.personal);
155
- if (personal) stores.push(personal);
156
-
157
- return stores.map((store) => ({
158
- alias: typeof store.alias === "string" ? store.alias : "",
159
- dir: _storeDir(globalRoot, store),
160
- }));
161
- }
162
-
163
- function _splitQualifiedId(id) {
164
- const idx = typeof id === "string" ? id.indexOf(":") : -1;
165
- if (idx <= 0) return { alias: "", stableId: id };
166
- return { alias: id.slice(0, idx), stableId: id.slice(idx + 1) };
167
- }
168
-
169
- /**
170
- * Scan mounted store `knowledge/<type>/` for the canonical `<id>--<slug>.md`
171
- * matching `stableId`. Tries the most likely type-dir first based on the
172
- * entry's `type` hint, then falls back to scanning all canonical type
173
- * directories. Returns the absolute path or null.
174
- *
175
- * The id→file mapping is unique by construction (stable_id is allocated
176
- * once per file), so the first match wins.
177
- */
178
- function _findEntryFile(projectRoot, stableId, typeHint) {
179
- const parsedId = _splitQualifiedId(stableId);
180
- const storeDirs = _readSetStoreDirs(projectRoot).filter(
181
- (store) => parsedId.alias.length === 0 || store.alias === parsedId.alias,
182
- );
183
- if (storeDirs.length === 0) return null;
184
- const tryOrder = [];
185
- if (typeof typeHint === "string" && typeHint.length > 0) {
186
- // Accept both singular and plural hints — find the plural form.
187
- const lower = typeHint.toLowerCase();
188
- const plural = KNOWLEDGE_TYPE_DIRS.find((d) => d === lower || d.startsWith(lower));
189
- if (plural) tryOrder.push(plural);
190
- }
191
- for (const t of KNOWLEDGE_TYPE_DIRS) {
192
- if (!tryOrder.includes(t)) tryOrder.push(t);
193
- }
194
- const prefix = `${parsedId.stableId}--`;
195
- for (const store of storeDirs) {
196
- const baseDir = join(store.dir, "knowledge");
197
- if (!existsSync(baseDir)) continue;
198
- for (const t of tryOrder) {
199
- const typeDir = join(baseDir, t);
200
- if (!existsSync(typeDir)) continue;
201
- let files;
202
- try {
203
- files = readdirSync(typeDir);
204
- } catch {
205
- continue;
206
- }
207
- for (const f of files) {
208
- if (f.startsWith(prefix) && f.endsWith(".md")) {
209
- return join(typeDir, f);
210
- }
211
- }
212
- }
213
- }
214
- return null;
215
- }
216
-
217
- function _resolveOne(projectRoot, entry) {
218
- const filePath = _findEntryFile(projectRoot, entry.id, entry.type);
219
- if (filePath === null) return "";
220
- let md;
221
- try {
222
- md = readFileSync(filePath, "utf8");
223
- } catch {
224
- return "";
225
- }
226
- return _extractFirstSummaryParagraph(md);
227
- }
228
-
229
- /**
230
- * Main API. Returns a new array of entries with `summary` swapped for
231
- * the extracted fallback wherever the original summary was opaque AND
232
- * the fallback extraction yielded a non-empty string. Non-opaque entries
233
- * pass through unchanged.
234
- */
235
- function resolveOpaqueSummaries(entries, projectRoot, revisionHash) {
236
- if (!Array.isArray(entries) || entries.length === 0) return entries;
237
- const cache = _readCache(projectRoot);
238
- const cachedSummaries = cache && cache.revision === revisionHash && cache.summaries ? cache.summaries : {};
239
- const nextCacheSummaries = { ...cachedSummaries };
240
- let cacheChanged = cache === null || cache.revision !== revisionHash;
241
- const result = entries.map((entry) => {
242
- if (!_isOpaque(entry)) return entry;
243
- const id = entry.id;
244
- let fallback;
245
- if (Object.prototype.hasOwnProperty.call(cachedSummaries, id)) {
246
- fallback = cachedSummaries[id];
247
- } else {
248
- fallback = _resolveOne(projectRoot, entry);
249
- nextCacheSummaries[id] = fallback;
250
- cacheChanged = true;
251
- }
252
- if (typeof fallback === "string" && fallback.length > 0) {
253
- return { ...entry, summary: fallback };
254
- }
255
- return entry;
256
- });
257
- if (cacheChanged) {
258
- _writeCache(projectRoot, { revision: revisionHash, summaries: nextCacheSummaries });
259
- }
260
- return result;
261
- }
262
-
263
- module.exports = {
264
- resolveOpaqueSummaries,
265
- _extractFirstSummaryParagraph,
266
- _readCache,
267
- _writeCache,
268
- _findEntryFile,
269
- _readSetStoreDirs,
270
- _isOpaque,
271
- SUMMARY_MAX_LEN,
272
- KNOWLEDGE_TYPE_DIRS,
273
- };