@fenglimg/fabric-server 2.3.0-rc.1 → 2.3.0-rc.11

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.
Files changed (3) hide show
  1. package/dist/index.d.ts +242 -6
  2. package/dist/index.js +1666 -368
  3. package/package.json +5 -2
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // src/index.ts
2
- import { existsSync as existsSync9 } from "fs";
3
- import { readFile as readFile19 } from "fs/promises";
4
- import { join as join23, resolve as resolve4 } from "path";
2
+ import { existsSync as existsSync10 } from "fs";
3
+ import { readFile as readFile22 } from "fs/promises";
4
+ import { join as join27, resolve as resolve4 } from "path";
5
5
  import { fileURLToPath } from "url";
6
6
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
7
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -742,7 +742,7 @@ var SUPPORTED_EMBED_MODELS = /* @__PURE__ */ new Set([
742
742
  function readEmbedConfig(projectRoot) {
743
743
  try {
744
744
  const config = readFabricConfig(projectRoot);
745
- const enabled = config.embed_enabled === true;
745
+ const enabled = config.embed_enabled !== false;
746
746
  const rawWeight = config.embed_weight;
747
747
  const weight = typeof rawWeight === "number" && Number.isInteger(rawWeight) && rawWeight >= 0 && rawWeight <= 49 ? rawWeight : 30;
748
748
  const rawModel = config.embed_model;
@@ -817,6 +817,73 @@ function readOrphanDemoteThresholdDays(projectRoot) {
817
817
  return {};
818
818
  }
819
819
  }
820
+ function readCredibilityHalfLives(projectRoot) {
821
+ const defaults = {
822
+ decisions: 180,
823
+ guidelines: 150,
824
+ models: 150,
825
+ pitfalls: 120,
826
+ processes: 120
827
+ };
828
+ try {
829
+ const cfg = readFabricConfig(projectRoot);
830
+ const validate = (v) => {
831
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 1 || v > 3650 || !Number.isInteger(v)) {
832
+ return void 0;
833
+ }
834
+ return v;
835
+ };
836
+ const out = { ...defaults };
837
+ const dec = validate(cfg.credibility_half_life_decisions_days);
838
+ if (dec !== void 0) out.decisions = dec;
839
+ const gui = validate(cfg.credibility_half_life_guidelines_days);
840
+ if (gui !== void 0) out.guidelines = gui;
841
+ const mod = validate(cfg.credibility_half_life_models_days);
842
+ if (mod !== void 0) out.models = mod;
843
+ const pit = validate(cfg.credibility_half_life_pitfalls_days);
844
+ if (pit !== void 0) out.pitfalls = pit;
845
+ const pro = validate(cfg.credibility_half_life_processes_days);
846
+ if (pro !== void 0) out.processes = pro;
847
+ return out;
848
+ } catch {
849
+ return { ...defaults };
850
+ }
851
+ }
852
+ function readCredibilityFloors(projectRoot) {
853
+ const defaults = {
854
+ draft: 0.4,
855
+ verified: 0.55,
856
+ proven: 0.7
857
+ };
858
+ try {
859
+ const cfg = readFabricConfig(projectRoot);
860
+ const validate = (v) => {
861
+ if (typeof v !== "number" || !Number.isFinite(v) || v < 0 || v > 1) {
862
+ return void 0;
863
+ }
864
+ return v;
865
+ };
866
+ const out = { ...defaults };
867
+ const draft = validate(cfg.credibility_floor_draft);
868
+ if (draft !== void 0) out.draft = draft;
869
+ const verified = validate(cfg.credibility_floor_verified);
870
+ if (verified !== void 0) out.verified = verified;
871
+ const proven = validate(cfg.credibility_floor_proven);
872
+ if (proven !== void 0) out.proven = proven;
873
+ return out;
874
+ } catch {
875
+ return { ...defaults };
876
+ }
877
+ }
878
+ function readFusion(projectRoot) {
879
+ try {
880
+ const raw = readFabricConfig(projectRoot).fusion;
881
+ if (raw === "rrf" || raw === "additive") return raw;
882
+ return "auto";
883
+ } catch {
884
+ return "auto";
885
+ }
886
+ }
820
887
  function readConflictLintThreshold(projectRoot) {
821
888
  try {
822
889
  const cfgPath = join4(projectRoot, ".fabric", "fabric-config.json");
@@ -884,7 +951,7 @@ async function awaitFirstReconcileGate(timeoutMs = 5e3) {
884
951
 
885
952
  // src/services/extract-knowledge.ts
886
953
  import { existsSync as existsSync3 } from "fs";
887
- import { readFile as readFile4 } from "fs/promises";
954
+ import { readFile as readFile5 } from "fs/promises";
888
955
  import { join as join7 } from "path";
889
956
  import {
890
957
  PROPOSED_REASON_DESCRIPTIONS_BY_LOCALE
@@ -892,10 +959,14 @@ import {
892
959
  import { hasSecrets, isPersonalScope, redactPii, resolveGlobalLocale } from "@fenglimg/fabric-shared";
893
960
 
894
961
  // src/services/cross-store-write.ts
962
+ import { createHash as createHash2 } from "crypto";
963
+ import { readFile as readFile3 } from "fs/promises";
895
964
  import { join as join5 } from "path";
896
965
  import {
966
+ STORE_KNOWLEDGE_TYPE_DIRS,
897
967
  STORE_LAYOUT,
898
968
  STORE_PENDING_DIR,
969
+ STORE_PROJECT_ID_PATTERN,
899
970
  buildStoreResolveInput,
900
971
  createStoreResolver,
901
972
  isPersonalLeakIntoSharedStore,
@@ -907,6 +978,7 @@ import {
907
978
  PersonalScopeLeakError,
908
979
  StoreWriteTargetUnresolvedError
909
980
  } from "@fenglimg/fabric-shared/errors";
981
+ import { withFileLock as withFileLock2 } from "@fenglimg/fabric-shared/node/atomic-write";
910
982
  function writeTargetUnresolved(scope, layer) {
911
983
  const actionHint = layer === "personal" ? "run `fabric install --global` to mint your personal store, then retry" : `mount + bind a shared store, then set an explicit route: \`fabric store switch-write <alias> --scope ${scope}\``;
912
984
  return new StoreWriteTargetUnresolvedError(
@@ -943,8 +1015,25 @@ function resolveWriteTargetStoreDir(layer, projectRoot, semanticScope) {
943
1015
  function resolveStorePendingBase(layer, projectRoot, semanticScope) {
944
1016
  return join5(resolveWriteTargetStoreDir(layer, projectRoot, semanticScope), STORE_LAYOUT.knowledgeDir, STORE_PENDING_DIR);
945
1017
  }
946
- function resolveStoreCanonicalBase(layer, projectRoot) {
947
- return join5(resolveWriteTargetStoreDir(layer, projectRoot), STORE_LAYOUT.knowledgeDir);
1018
+ var STORE_PROJECTS_DIR = "projects";
1019
+ function isValidWriteProjectSegment(project) {
1020
+ if (project.length === 0) {
1021
+ return false;
1022
+ }
1023
+ if (project === STORE_PROJECTS_DIR) {
1024
+ return false;
1025
+ }
1026
+ if (STORE_KNOWLEDGE_TYPE_DIRS.includes(project)) {
1027
+ return false;
1028
+ }
1029
+ return STORE_PROJECT_ID_PATTERN.test(project);
1030
+ }
1031
+ function resolveStoreCanonicalBase(layer, projectRoot, project) {
1032
+ const base = join5(resolveWriteTargetStoreDir(layer, projectRoot), STORE_LAYOUT.knowledgeDir);
1033
+ if (layer === "team" && project !== void 0 && isValidWriteProjectSegment(project)) {
1034
+ return join5(base, STORE_PROJECTS_DIR, project);
1035
+ }
1036
+ return base;
948
1037
  }
949
1038
  function resolveWriteScopeMeta(layer, projectRoot, semanticScope) {
950
1039
  const input = buildStoreResolveInput(projectRoot);
@@ -969,7 +1058,7 @@ function resolveWriteScopeMeta(layer, projectRoot, semanticScope) {
969
1058
  }
970
1059
 
971
1060
  // src/services/cross-store-recall.ts
972
- import { readFile as readFile3, stat } from "fs/promises";
1061
+ import { readFile as readFile4, stat } from "fs/promises";
973
1062
  import { join as join6 } from "path";
974
1063
  import {
975
1064
  buildStoreResolveInput as buildStoreResolveInput2,
@@ -986,7 +1075,6 @@ import {
986
1075
  deriveAgentsMetaStableId,
987
1076
  isKnowledgeStableId,
988
1077
  KnowledgeTypeSchema,
989
- LayerSchema,
990
1078
  MaturitySchema,
991
1079
  parseKnowledgeId,
992
1080
  StableIdSchema
@@ -1069,8 +1157,7 @@ function extractRuleDescription(source) {
1069
1157
  id: knowledge?.id,
1070
1158
  knowledge_type: knowledge?.knowledge_type,
1071
1159
  maturity: knowledge?.maturity,
1072
- knowledge_layer: knowledge?.knowledge_layer,
1073
- layer_reason: knowledge?.layer_reason,
1160
+ // W4/Track1 (D1): no `knowledge_layer` — layer derives from the id prefix.
1074
1161
  created_at: knowledge?.created_at,
1075
1162
  tags: knowledge?.tags,
1076
1163
  // v2.0-rc.5 (C1): default-safe values when there is no frontmatter at all;
@@ -1080,7 +1167,9 @@ function extractRuleDescription(source) {
1080
1167
  relevance_scope: knowledge?.relevance_scope ?? "broad",
1081
1168
  relevance_paths: knowledge?.relevance_paths ?? [],
1082
1169
  // v2.2 H2-related (W1-T7): graph edges, undefined when absent.
1083
- related: knowledge?.related
1170
+ related: knowledge?.related,
1171
+ // v2.2 glossary aliases FIELD (C-002): synonym terms, undefined when absent.
1172
+ aliases: knowledge?.aliases
1084
1173
  };
1085
1174
  }
1086
1175
  function extractDescriptionFromFrontmatter(frontmatter) {
@@ -1095,18 +1184,18 @@ function extractDescriptionFromFrontmatter(frontmatter) {
1095
1184
  tech_stack: extractInlineArray(frontmatter, "tech_stack"),
1096
1185
  impact: extractInlineArray(frontmatter, "impact"),
1097
1186
  must_read_if: extractScalar(frontmatter, "must_read_if") ?? summary,
1098
- entities: extractInlineArray(frontmatter, "entities"),
1099
1187
  id: knowledge.id,
1100
1188
  knowledge_type: knowledge.knowledge_type,
1101
1189
  maturity: knowledge.maturity,
1102
- knowledge_layer: knowledge.knowledge_layer,
1103
- layer_reason: knowledge.layer_reason,
1190
+ // W4/Track1 (D1): no `knowledge_layer` — layer derives from the id prefix.
1104
1191
  created_at: knowledge.created_at,
1105
1192
  tags: knowledge.tags,
1106
1193
  relevance_scope: knowledge.relevance_scope,
1107
1194
  relevance_paths: knowledge.relevance_paths,
1108
1195
  // v2.2 H2-related (W1-T7): graph edges parsed from frontmatter.
1109
- related: knowledge.related
1196
+ related: knowledge.related,
1197
+ // v2.2 glossary aliases FIELD (C-002): synonym terms for the BM25 body.
1198
+ aliases: knowledge.aliases
1110
1199
  };
1111
1200
  }
1112
1201
  function isForbiddenCrossLayerEdge(sourceLayer, targetId) {
@@ -1131,8 +1220,6 @@ function extractKnowledgeFieldsFromFrontmatter(frontmatter) {
1131
1220
  const rawId = extractScalar(frontmatter, "id");
1132
1221
  const rawType = extractScalar(frontmatter, "type");
1133
1222
  const rawMaturity = extractScalar(frontmatter, "maturity");
1134
- const rawLayer = extractScalar(frontmatter, "layer");
1135
- const rawLayerReason = extractScalar(frontmatter, "layer_reason");
1136
1223
  const rawCreatedAt = extractScalar(frontmatter, "created_at");
1137
1224
  let id;
1138
1225
  if (rawId !== void 0) {
@@ -1169,16 +1256,6 @@ function extractKnowledgeFieldsFromFrontmatter(frontmatter) {
1169
1256
  maturity = parsed.data;
1170
1257
  } else {
1171
1258
  process.stderr.write(`[fabric] frontmatter: unknown maturity ${JSON.stringify(rawMaturity)}; skipping
1172
- `);
1173
- }
1174
- }
1175
- let knowledge_layer;
1176
- if (rawLayer !== void 0) {
1177
- const parsed = LayerSchema.safeParse(rawLayer);
1178
- if (parsed.success) {
1179
- knowledge_layer = parsed.data;
1180
- } else {
1181
- process.stderr.write(`[fabric] frontmatter: unknown layer ${JSON.stringify(rawLayer)}; skipping
1182
1259
  `);
1183
1260
  }
1184
1261
  }
@@ -1191,23 +1268,12 @@ function extractKnowledgeFieldsFromFrontmatter(frontmatter) {
1191
1268
  `);
1192
1269
  }
1193
1270
  }
1194
- if (id !== void 0 && knowledge_layer !== void 0) {
1195
- const decoded = parseKnowledgeId(id);
1196
- if (decoded !== null && decoded.layer !== knowledge_layer) {
1197
- process.stderr.write(
1198
- `[fabric] frontmatter: id ${id} encodes layer ${decoded.layer} but layer field says ${knowledge_layer}; dropping both
1199
- `
1200
- );
1201
- id = void 0;
1202
- knowledge_layer = void 0;
1203
- }
1204
- }
1205
1271
  const tags = extractInlineArray(frontmatter, "tags");
1206
1272
  const rawRelevanceScope = extractScalar(frontmatter, "relevance_scope");
1207
1273
  const relevance_scope = rawRelevanceScope === "narrow" || rawRelevanceScope === "broad" ? rawRelevanceScope : "broad";
1208
1274
  const relevance_paths = extractInlineArray(frontmatter, "relevance_paths");
1209
1275
  const rawRelated = extractInlineArray(frontmatter, "related");
1210
- const sourceLayer = knowledge_layer ?? (id !== void 0 ? parseKnowledgeId(id)?.layer ?? "team" : "team");
1276
+ const sourceLayer = id !== void 0 ? parseKnowledgeId(id)?.layer ?? "team" : "team";
1211
1277
  const related = rawRelated.filter((targetId) => {
1212
1278
  if (isForbiddenCrossLayerEdge(sourceLayer, targetId)) {
1213
1279
  process.stderr.write(
@@ -1218,17 +1284,17 @@ function extractKnowledgeFieldsFromFrontmatter(frontmatter) {
1218
1284
  }
1219
1285
  return true;
1220
1286
  });
1287
+ const aliases = extractInlineArray(frontmatter, "aliases");
1221
1288
  return {
1222
1289
  id,
1223
1290
  knowledge_type,
1224
1291
  maturity,
1225
- knowledge_layer,
1226
- layer_reason: rawLayerReason,
1227
1292
  created_at,
1228
1293
  tags: tags.length > 0 ? tags : void 0,
1229
1294
  relevance_scope,
1230
1295
  relevance_paths,
1231
- related: related.length > 0 ? related : void 0
1296
+ related: related.length > 0 ? related : void 0,
1297
+ aliases: aliases.length > 0 ? aliases : void 0
1232
1298
  };
1233
1299
  }
1234
1300
  function extractScalar(frontmatter, key) {
@@ -1274,7 +1340,13 @@ async function readSetFingerprint(refs) {
1274
1340
  return parts.sort().join("\n");
1275
1341
  }
1276
1342
  var SEMANTIC_SCOPE_LINE = /^semantic_scope:\s*"?([^"\n]+?)"?\s*$/mu;
1277
- function readSemanticScope(source, layer) {
1343
+ function readSemanticScope(source, layer, project) {
1344
+ if (layer === "personal") {
1345
+ return "personal";
1346
+ }
1347
+ if (typeof project === "string" && project.length > 0) {
1348
+ return `project:${project}`;
1349
+ }
1278
1350
  const match = SEMANTIC_SCOPE_LINE.exec(source);
1279
1351
  return match?.[1] ?? layer;
1280
1352
  }
@@ -1341,7 +1413,7 @@ async function walkReadSetStoresUncached(snapshot) {
1341
1413
  const entries = await Promise.all(snapshot.refs.map(async (ref) => {
1342
1414
  let source;
1343
1415
  try {
1344
- source = await readFile3(ref.file, "utf8");
1416
+ source = await readFile4(ref.file, "utf8");
1345
1417
  } catch {
1346
1418
  return null;
1347
1419
  }
@@ -1353,7 +1425,7 @@ async function walkReadSetStoresUncached(snapshot) {
1353
1425
  type: ref.type,
1354
1426
  alias: ref.alias,
1355
1427
  layer,
1356
- semanticScope: readSemanticScope(source, layer),
1428
+ semanticScope: readSemanticScope(source, layer, ref.project),
1357
1429
  source
1358
1430
  };
1359
1431
  }));
@@ -1371,7 +1443,9 @@ async function buildCrossStoreRawItems(projectRoot) {
1371
1443
  stable_id: entry.qualifiedId,
1372
1444
  description: {
1373
1445
  ...baseDescription,
1374
- knowledge_layer: entry.layer,
1446
+ // W4/Track1 (D1): no `knowledge_layer` backfill — a candidate's layer is
1447
+ // derived from its stable_id prefix (layerFromStableId in plan-context),
1448
+ // the single source of truth (KT-DEC-0004).
1375
1449
  semantic_scope: entry.semanticScope
1376
1450
  }
1377
1451
  });
@@ -1534,11 +1608,44 @@ function buildBm25Model(docs) {
1534
1608
  for (const field of BM25_FIELDS) {
1535
1609
  avgFieldLength[field] = totalDocs > 0 ? totalFieldLength[field] / totalDocs : 0;
1536
1610
  }
1611
+ const serialized = {
1612
+ version: 1,
1613
+ totalDocs,
1614
+ documentFrequency: [...documentFrequency],
1615
+ avgFieldLength,
1616
+ perDoc: [...perDoc].map(([id, stats]) => ({
1617
+ id,
1618
+ fieldTermFreq: emptyFieldRecord(() => []),
1619
+ fieldLength: { ...stats.fieldLength }
1620
+ }))
1621
+ };
1622
+ for (const entry of serialized.perDoc) {
1623
+ const stats = perDoc.get(entry.id);
1624
+ if (stats === void 0) continue;
1625
+ for (const field of BM25_FIELDS) {
1626
+ entry.fieldTermFreq[field] = [...stats.fieldTermFreq[field]];
1627
+ }
1628
+ }
1629
+ return modelFromStats(serialized);
1630
+ }
1631
+ function modelFromStats(serialized) {
1632
+ const totalDocs = serialized.totalDocs;
1633
+ const documentFrequency = new Map(serialized.documentFrequency);
1634
+ const avgFieldLength = serialized.avgFieldLength;
1635
+ const perDoc = /* @__PURE__ */ new Map();
1636
+ for (const entry of serialized.perDoc) {
1637
+ const fieldTermFreq = emptyFieldRecord(() => /* @__PURE__ */ new Map());
1638
+ for (const field of BM25_FIELDS) {
1639
+ fieldTermFreq[field] = new Map(entry.fieldTermFreq[field]);
1640
+ }
1641
+ perDoc.set(entry.id, { fieldTermFreq, fieldLength: entry.fieldLength });
1642
+ }
1537
1643
  const idf = (term) => {
1538
1644
  const n = documentFrequency.get(term) ?? 0;
1539
1645
  return Math.log(1 + (totalDocs - n + 0.5) / (n + 0.5));
1540
1646
  };
1541
1647
  return {
1648
+ __serialized: serialized,
1542
1649
  scoreDoc(id, queryTerms) {
1543
1650
  const data = perDoc.get(id);
1544
1651
  if (data === void 0 || queryTerms.length === 0) {
@@ -1574,9 +1681,186 @@ function buildBm25Model(docs) {
1574
1681
  }
1575
1682
  };
1576
1683
  }
1684
+ function serializeBm25Model(model) {
1685
+ return model.__serialized;
1686
+ }
1687
+ function rehydrateBm25Model(serialized) {
1688
+ return modelFromStats(serialized);
1689
+ }
1690
+ var SYNONYM_PAIRS = {
1691
+ // Code / engineering actions
1692
+ refactor: ["restructure", "rewrite", "redesign", "reorganize", "clean", "rework"],
1693
+ optimize: ["improve", "speed-up", "tune", "accelerate", "perf", "performance"],
1694
+ debug: ["fix", "troubleshoot", "diagnose", "investigate", "resolve", "correct"],
1695
+ migrate: ["port", "move", "transfer", "upgrade", "transition", "convert"],
1696
+ "set up": ["init", "initialize", "configure", "bootstrap", "install", "onboard"],
1697
+ implement: ["add", "build", "create", "develop", "write", "introduce", "introduce"],
1698
+ // Architecture / design
1699
+ architecture: ["design", "structure", "layout", "organization", "pattern", "system"],
1700
+ "data model": ["schema", "entity", "type", "structure", "contract", "shape"],
1701
+ // Quality / correctness
1702
+ test: ["verify", "validate", "assert", "check", "spec", "coverage"],
1703
+ lint: ["check", "validate", "audit", "inspect", "analyze"],
1704
+ // Communication / impact
1705
+ documentation: ["docs", "readme", "guide", "spec", "explanation", "reference"],
1706
+ decision: ["adr", "rationale", "why", "motivation", "reason", "trade-off"],
1707
+ // Change management
1708
+ release: ["deploy", "ship", "publish", "cut", "version", "tag"],
1709
+ rollback: ["revert", "undo", "back-out", "backout", "restore"],
1710
+ // Containers / infra
1711
+ container: ["docker", "image", "oci", "cri-o"],
1712
+ deploy: ["release", "rollout", "ship", "publish", "promote"],
1713
+ // Project / process
1714
+ on_boarding: ["getting-started", "quickstart", "newcomer", "new-hire", "first-time"],
1715
+ best_practice: ["convention", "guideline", "standard", "rule", "recommendation"],
1716
+ // Tech stacks
1717
+ api: ["endpoint", "route", "service", "interface", "rpc", "rest"],
1718
+ typescript: ["ts", "types", "type-safe"],
1719
+ react: ["jsx", "tsx", "component", "ui"],
1720
+ node: ["nodejs", "runtime", "backend", "server"],
1721
+ database: ["db", "sql", "nosql", "storage", "persistence"]
1722
+ };
1723
+ var STEMMING_PATTERNS = [
1724
+ { suffix: "e", alternatives: ["es", "ed", "ing", "ation"] },
1725
+ { suffix: "y", alternatives: ["ies", "ied", "ying"] }
1726
+ // Catch-all for non-verb / already-stemmed query terms: no-op by default.
1727
+ ];
1728
+ var DEFAULT_IDF_WEIGHT = 1;
1729
+ var COMMON_TERMS = /* @__PURE__ */ new Set([
1730
+ "a",
1731
+ "an",
1732
+ "the",
1733
+ "and",
1734
+ "or",
1735
+ "but",
1736
+ "in",
1737
+ "on",
1738
+ "at",
1739
+ "to",
1740
+ "for",
1741
+ "of",
1742
+ "with",
1743
+ "by",
1744
+ "from",
1745
+ "as",
1746
+ "is",
1747
+ "it",
1748
+ "at",
1749
+ "be",
1750
+ "do",
1751
+ "has",
1752
+ "have",
1753
+ "was",
1754
+ "are",
1755
+ "been",
1756
+ "this",
1757
+ "that",
1758
+ "these",
1759
+ "those",
1760
+ "will",
1761
+ "can",
1762
+ "may",
1763
+ "would",
1764
+ "could",
1765
+ "should",
1766
+ "does",
1767
+ "not",
1768
+ "no",
1769
+ "if",
1770
+ "so",
1771
+ "up",
1772
+ "out",
1773
+ "all",
1774
+ "each",
1775
+ "every",
1776
+ "both",
1777
+ "some",
1778
+ "any",
1779
+ "such",
1780
+ "only",
1781
+ "own",
1782
+ "same",
1783
+ "too",
1784
+ "very",
1785
+ "just",
1786
+ "about",
1787
+ "over",
1788
+ "than",
1789
+ "then",
1790
+ "also"
1791
+ ]);
1792
+ function idfWeight(term) {
1793
+ return COMMON_TERMS.has(term) ? 0.3 : DEFAULT_IDF_WEIGHT;
1794
+ }
1795
+ function expandQueryTerms(text) {
1796
+ const baseTerms = tokenize(text);
1797
+ const weighted = /* @__PURE__ */ new Map();
1798
+ for (const term of baseTerms) {
1799
+ const lower = term.toLowerCase();
1800
+ const aliases = /* @__PURE__ */ new Set();
1801
+ const syns = SYNONYM_PAIRS[lower];
1802
+ if (syns !== void 0) {
1803
+ for (const s of syns) aliases.add(s);
1804
+ }
1805
+ for (const [key, values] of Object.entries(SYNONYM_PAIRS)) {
1806
+ if (values.includes(lower)) {
1807
+ aliases.add(key);
1808
+ }
1809
+ }
1810
+ for (const { suffix, alternatives } of STEMMING_PATTERNS) {
1811
+ if (lower.endsWith(suffix) && lower.length > suffix.length) {
1812
+ const stem = lower.slice(0, -suffix.length);
1813
+ for (const alt of alternatives) {
1814
+ aliases.add(stem + alt);
1815
+ }
1816
+ }
1817
+ }
1818
+ const originalWeight = idfWeight(lower);
1819
+ weighted.set(term, originalWeight);
1820
+ for (const alias of aliases) {
1821
+ if (!weighted.has(alias)) {
1822
+ weighted.set(alias, originalWeight * 0.5);
1823
+ }
1824
+ }
1825
+ }
1826
+ return weighted;
1827
+ }
1577
1828
  function buildQueryTerms(text) {
1578
1829
  return tokenize(text);
1579
1830
  }
1831
+ function rankDocuments(model, ids, queryTerms) {
1832
+ if (queryTerms.length === 0) {
1833
+ return /* @__PURE__ */ new Map();
1834
+ }
1835
+ const scored = [];
1836
+ ids.forEach((id, order) => {
1837
+ const score = model.scoreDoc(id, queryTerms);
1838
+ if (score > 0) {
1839
+ scored.push({ id, score, order });
1840
+ }
1841
+ });
1842
+ scored.sort((a, b) => a.score !== b.score ? b.score - a.score : a.order - b.order);
1843
+ const ranks = /* @__PURE__ */ new Map();
1844
+ scored.forEach((entry, index) => {
1845
+ ranks.set(entry.id, index + 1);
1846
+ });
1847
+ return ranks;
1848
+ }
1849
+ function rankByScore(ids, scores) {
1850
+ const scored = [];
1851
+ ids.forEach((id, order) => {
1852
+ const score = scores.get(id) ?? 0;
1853
+ if (score > 0) {
1854
+ scored.push({ id, score, order });
1855
+ }
1856
+ });
1857
+ scored.sort((a, b) => a.score !== b.score ? b.score - a.score : a.order - b.order);
1858
+ const ranks = /* @__PURE__ */ new Map();
1859
+ scored.forEach((entry, index) => {
1860
+ ranks.set(entry.id, index + 1);
1861
+ });
1862
+ return ranks;
1863
+ }
1580
1864
 
1581
1865
  // src/services/conflict-lint.ts
1582
1866
  function buildSimilarityModel(docs) {
@@ -1940,7 +2224,7 @@ async function extractKnowledge(projectRoot, input) {
1940
2224
  const writeScopeMeta = resolveWriteScopeMeta(layer, projectRoot, semanticScope);
1941
2225
  await ensureParentDirectory(absolutePath);
1942
2226
  if (existsSync3(absolutePath)) {
1943
- const existing = await readFile4(absolutePath, "utf8");
2227
+ const existing = await readFile5(absolutePath, "utf8");
1944
2228
  const existingKey = readFrontmatterKey(existing, "x-fabric-idempotency-key");
1945
2229
  if (existingKey === effectiveIdempotencyKey) {
1946
2230
  const fresh2 = renderFreshEntry({
@@ -2073,7 +2357,7 @@ async function resolveDisambiguatedSlugPath(args) {
2073
2357
  idempotencyKey: candidateKey
2074
2358
  };
2075
2359
  }
2076
- const existing = await readFile4(candidatePath, "utf8");
2360
+ const existing = await readFile5(candidatePath, "utf8");
2077
2361
  const existingKey = readFrontmatterKey(existing, "x-fabric-idempotency-key");
2078
2362
  if (existingKey === candidateKey) {
2079
2363
  return {
@@ -2670,13 +2954,47 @@ var LEDGER_DUAL_WRITE_METRIC_NAMES = {
2670
2954
  edit_intent_checked: METRIC_COUNTER_NAMES.edit_intent_checked
2671
2955
  };
2672
2956
 
2957
+ // src/services/plan-context.ts
2958
+ import { mkdir as mkdir5, readFile as readFile7, writeFile as writeFile3 } from "fs/promises";
2959
+ import { join as join10 } from "path";
2960
+
2673
2961
  // src/services/vector-retrieval.ts
2962
+ import { mkdirSync } from "fs";
2963
+ import { mkdir as mkdir4, readFile as readFile6, writeFile as writeFile2 } from "fs/promises";
2964
+ import { createRequire } from "module";
2965
+ import { join as join9 } from "path";
2966
+ import { resolveGlobalRoot as resolveGlobalRoot3 } from "@fenglimg/fabric-shared";
2674
2967
  var embedderLoad;
2675
2968
  var OPTIONAL_EMBED_PACKAGE = "fastembed";
2969
+ function isEmbedderResolvable() {
2970
+ try {
2971
+ createRequire(import.meta.url).resolve(OPTIONAL_EMBED_PACKAGE);
2972
+ return true;
2973
+ } catch {
2974
+ return false;
2975
+ }
2976
+ }
2977
+ var embedderModuleLoader = (name) => import(name);
2978
+ var MISSING_EMBEDDER_HINT = "[fabric] vector semantic recall is enabled but the optional 'fastembed' package is unavailable \u2014 falling back to text-only ranking. Install it where the server resolves modules (e.g. `npm i -g fastembed`) to enable embeddings, or set embed_enabled:false to silence this.\n";
2979
+ var defaultMissingEmbedderHint = () => {
2980
+ process.stderr.write(MISSING_EMBEDDER_HINT);
2981
+ };
2982
+ var missingEmbedderHinted = false;
2983
+ var emitMissingEmbedderHint = defaultMissingEmbedderHint;
2984
+ function hintMissingEmbedderOnce() {
2985
+ if (missingEmbedderHinted) {
2986
+ return;
2987
+ }
2988
+ missingEmbedderHinted = true;
2989
+ emitMissingEmbedderHint();
2990
+ }
2991
+ function defaultEmbedCacheDir() {
2992
+ return join9(resolveGlobalRoot3(), "cache", "embed");
2993
+ }
2676
2994
  function buildEmbedInitOptions(modelName) {
2677
2995
  return {
2678
2996
  maxLength: 512,
2679
- cacheDir: process.env.FABRIC_EMBED_CACHE_DIR,
2997
+ cacheDir: process.env.FABRIC_EMBED_CACHE_DIR ?? defaultEmbedCacheDir(),
2680
2998
  ...typeof modelName === "string" && modelName.length > 0 ? { model: modelName } : {}
2681
2999
  };
2682
3000
  }
@@ -2685,11 +3003,14 @@ async function loadEmbedder(modelName) {
2685
3003
  embedderLoad = (async () => {
2686
3004
  try {
2687
3005
  const moduleName = OPTIONAL_EMBED_PACKAGE;
2688
- const mod = await import(moduleName);
3006
+ const mod = await embedderModuleLoader(moduleName);
2689
3007
  if (mod?.FlagEmbedding?.init === void 0) {
3008
+ hintMissingEmbedderOnce();
2690
3009
  return null;
2691
3010
  }
2692
- const model = await mod.FlagEmbedding.init(buildEmbedInitOptions(modelName));
3011
+ const initOpts = buildEmbedInitOptions(modelName);
3012
+ mkdirSync(initOpts.cacheDir, { recursive: true });
3013
+ const model = await mod.FlagEmbedding.init(initOpts);
2693
3014
  return {
2694
3015
  async embed(texts) {
2695
3016
  const out = [];
@@ -2702,6 +3023,7 @@ async function loadEmbedder(modelName) {
2702
3023
  }
2703
3024
  };
2704
3025
  } catch {
3026
+ hintMissingEmbedderOnce();
2705
3027
  return null;
2706
3028
  }
2707
3029
  })();
@@ -2732,7 +3054,7 @@ function cosineSimilarity(a, b) {
2732
3054
  if (!Number.isFinite(sim)) {
2733
3055
  return 0;
2734
3056
  }
2735
- return Math.max(-1, Math.min(1, sim));
3057
+ return Math.max(0, Math.min(1, sim));
2736
3058
  }
2737
3059
  var docVectorCache = /* @__PURE__ */ new Map();
2738
3060
  var DOC_VECTOR_CACHE_MAX = 1e4;
@@ -2749,11 +3071,63 @@ function cacheDocVector(text, vector) {
2749
3071
  docVectorCache.delete(lru);
2750
3072
  }
2751
3073
  }
2752
- async function buildVectorScores(embedder, queryText, items) {
3074
+ var VECTOR_CACHE_VERSION = 1;
3075
+ var VECTOR_CACHE_DIR = ".fabric/cache/vectors";
3076
+ function vectorCachePath(projectRoot, revision) {
3077
+ const safe = revision.replace(/[^A-Za-z0-9_-]/g, "_");
3078
+ return join9(projectRoot, VECTOR_CACHE_DIR, `${safe}.json`);
3079
+ }
3080
+ async function loadVectorCacheFromDisk(ctx) {
3081
+ try {
3082
+ const raw = await readFile6(vectorCachePath(ctx.projectRoot, ctx.corpusRevision), "utf8");
3083
+ const parsed = JSON.parse(raw);
3084
+ if (parsed.version !== VECTOR_CACHE_VERSION) return null;
3085
+ if (parsed.embedding_model !== ctx.embeddingModel) return null;
3086
+ if (parsed.corpus_revision !== ctx.corpusRevision) return null;
3087
+ if (!Array.isArray(parsed.vectors) || parsed.vectors.length === 0) return null;
3088
+ const dimension = parsed.dimension;
3089
+ if (!Number.isInteger(dimension) || dimension <= 0) return null;
3090
+ const out = /* @__PURE__ */ new Map();
3091
+ for (const entry of parsed.vectors) {
3092
+ if (!Array.isArray(entry) || entry.length !== 2) return null;
3093
+ const [text, vector] = entry;
3094
+ if (typeof text !== "string" || !Array.isArray(vector)) return null;
3095
+ if (vector.length !== dimension) return null;
3096
+ out.set(text, vector);
3097
+ }
3098
+ return out;
3099
+ } catch {
3100
+ return null;
3101
+ }
3102
+ }
3103
+ async function saveVectorCacheToDisk(ctx, vectors, dimension) {
3104
+ try {
3105
+ const payload = {
3106
+ version: VECTOR_CACHE_VERSION,
3107
+ embedding_model: ctx.embeddingModel,
3108
+ dimension,
3109
+ corpus_revision: ctx.corpusRevision,
3110
+ vectors
3111
+ };
3112
+ const path = vectorCachePath(ctx.projectRoot, ctx.corpusRevision);
3113
+ await mkdir4(join9(ctx.projectRoot, VECTOR_CACHE_DIR), { recursive: true });
3114
+ await writeFile2(path, JSON.stringify(payload), "utf8");
3115
+ } catch {
3116
+ }
3117
+ }
3118
+ async function buildVectorScores(embedder, queryText, items, cache2) {
2753
3119
  if (embedder === null || queryText.trim().length === 0 || items.length === 0) {
2754
3120
  return null;
2755
3121
  }
2756
3122
  try {
3123
+ if (cache2 !== void 0) {
3124
+ const fromDisk = await loadVectorCacheFromDisk(cache2);
3125
+ if (fromDisk !== null) {
3126
+ for (const [text, vector] of fromDisk) {
3127
+ cacheDocVector(text, vector);
3128
+ }
3129
+ }
3130
+ }
2757
3131
  const missTexts = [];
2758
3132
  for (const item of items) {
2759
3133
  if (!docVectorCache.has(item.text)) {
@@ -2769,6 +3143,24 @@ async function buildVectorScores(embedder, queryText, items) {
2769
3143
  for (let m = 0; m < missTexts.length; m += 1) {
2770
3144
  cacheDocVector(missTexts[m], embedded[m + 1]);
2771
3145
  }
3146
+ const staleTexts = [];
3147
+ for (const item of items) {
3148
+ const cached = docVectorCache.get(item.text);
3149
+ if (cached !== void 0 && cached.length !== queryVec.length) {
3150
+ docVectorCache.delete(item.text);
3151
+ staleTexts.push(item.text);
3152
+ }
3153
+ }
3154
+ if (staleTexts.length > 0) {
3155
+ const reEmbedded = await embedder.embed(staleTexts);
3156
+ if (reEmbedded.length !== staleTexts.length) {
3157
+ return null;
3158
+ }
3159
+ for (let s = 0; s < staleTexts.length; s += 1) {
3160
+ cacheDocVector(staleTexts[s], reEmbedded[s]);
3161
+ }
3162
+ }
3163
+ const embeddedNewDoc = missTexts.length > 0 || staleTexts.length > 0;
2772
3164
  const scores = /* @__PURE__ */ new Map();
2773
3165
  for (const item of items) {
2774
3166
  const docVec = docVectorCache.get(item.text);
@@ -2777,6 +3169,20 @@ async function buildVectorScores(embedder, queryText, items) {
2777
3169
  }
2778
3170
  scores.set(item.stable_id, cosineSimilarity(queryVec, docVec));
2779
3171
  }
3172
+ if (cache2 !== void 0 && embeddedNewDoc) {
3173
+ const snapshot = [];
3174
+ for (const item of items) {
3175
+ const vec = docVectorCache.get(item.text);
3176
+ if (vec !== void 0) {
3177
+ snapshot.push([item.text, vec]);
3178
+ }
3179
+ }
3180
+ const dimension = snapshot.length > 0 ? snapshot[0][1].length : 0;
3181
+ const uniform = dimension > 0 && snapshot.every(([, v]) => v.length === dimension);
3182
+ if (uniform) {
3183
+ await saveVectorCacheToDisk(cache2, snapshot, dimension);
3184
+ }
3185
+ }
2780
3186
  return scores;
2781
3187
  } catch {
2782
3188
  return null;
@@ -2784,6 +3190,11 @@ async function buildVectorScores(embedder, queryText, items) {
2784
3190
  }
2785
3191
 
2786
3192
  // src/services/plan-context.ts
3193
+ function layerFromStableId(qualifiedId) {
3194
+ const colon = qualifiedId.lastIndexOf(":");
3195
+ const localId = colon === -1 ? qualifiedId : qualifiedId.slice(colon + 1);
3196
+ return localId.startsWith("KP-") ? "personal" : "team";
3197
+ }
2787
3198
  var SELECTION_TOKEN_TTL_DEFAULT_MS = 30 * 60 * 1e3;
2788
3199
  var selectionTokenCache = /* @__PURE__ */ new Map();
2789
3200
  var SELECTION_TOKEN_CACHE_MAX = 1e3;
@@ -2837,56 +3248,28 @@ async function planContext(projectRoot, input) {
2837
3248
  ...input.known_tech ?? [],
2838
3249
  ...Object.values(input.detected_entities ?? {}).flat()
2839
3250
  ].join(" ");
2840
- const scoringContext = {
2841
- nowMs: Date.now(),
2842
- targetPaths: input.target_paths ?? dedupePaths(input.paths),
2843
- queryTerms: buildQueryTerms(queryText)
2844
- };
2845
3251
  const storeRawItems = await buildCrossStoreRawItems(projectRoot).catch(() => []);
2846
3252
  const { rawItems: allRawItems, suppressedStableIds } = partitionEmptyShells(storeRawItems);
2847
3253
  const effectiveLayerFilter = input.layer_filter ?? readDefaultLayerFilter(projectRoot);
2848
- const rawItems = effectiveLayerFilter === "both" ? allRawItems : allRawItems.filter((item) => item.description.knowledge_layer === effectiveLayerFilter);
2849
- const docTexts = /* @__PURE__ */ new Map();
2850
- for (const item of rawItems) {
2851
- docTexts.set(item.stable_id, documentTextForItem(item.description));
2852
- }
2853
- scoringContext.docTexts = docTexts;
2854
- if (scoringContext.queryTerms.length > 0 && rawItems.length > 0) {
2855
- scoringContext.bm25 = getOrBuildBm25Model(revision, rawItems, docTexts);
2856
- }
2857
- scoringContext.scopeRank = buildScopeRankMap(rawItems, projectRoot);
2858
- const embedConfig = readEmbedConfig(projectRoot);
2859
- if (embedConfig.enabled && queryText.trim().length > 0 && rawItems.length > 0) {
2860
- const embedder = await loadEmbedder(embedConfig.model);
2861
- const vectorScores = await buildVectorScores(
2862
- embedder,
2863
- queryText,
2864
- rawItems.map((item) => ({
2865
- stable_id: item.stable_id,
2866
- text: docTexts.get(item.stable_id) ?? documentTextForItem(item.description)
2867
- }))
2868
- );
2869
- if (vectorScores !== null) {
2870
- scoringContext.vectorScores = vectorScores;
2871
- scoringContext.vectorWeight = embedConfig.weight;
2872
- }
2873
- }
2874
- const scoredSorted = sortDescriptionItems(rawItems, scoringContext);
2875
- const seenStableIds = /* @__PURE__ */ new Set();
2876
- const rankedScored = scoredSorted.filter(({ item }) => {
2877
- if (seenStableIds.has(item.stable_id)) return false;
2878
- seenStableIds.add(item.stable_id);
2879
- return true;
3254
+ const rawItems = effectiveLayerFilter === "both" ? allRawItems : allRawItems.filter((item) => layerFromStableId(item.stable_id) === effectiveLayerFilter);
3255
+ const scoringContext = await buildScoringContext(projectRoot, revision, rawItems, {
3256
+ queryText,
3257
+ targetPaths: input.target_paths ?? dedupePaths(input.paths)
2880
3258
  });
3259
+ const rankedScored = rankDescriptionItems(rawItems, scoringContext, "triage");
2881
3260
  const rankedCandidates = rankedScored.map((entry) => entry.item);
2882
- const topK = readPlanContextTopK(projectRoot);
2883
- const cappedScored = rankedScored.slice(0, topK);
2884
- const relevanceRatio = readRecallRelevanceRatio(projectRoot);
2885
- const hasQuery = scoringContext.queryTerms.length > 0;
2886
- const maxScore = rankedScored.length > 0 ? rankedScored[0].score : 0;
2887
- const relevanceFloor = maxScore * relevanceRatio;
2888
- const survivingScored = hasQuery && maxScore > 0 && relevanceRatio > 0 ? cappedScored.filter((entry) => entry.score >= relevanceFloor) : cappedScored;
3261
+ const survivingScored = rankDescriptionItems(rawItems, scoringContext, "recall", {
3262
+ topK: readPlanContextTopK(projectRoot),
3263
+ relevanceRatio: readRecallRelevanceRatio(projectRoot)
3264
+ });
2889
3265
  const topKCandidates = survivingScored.map((entry) => entry.item);
3266
+ const candidateScores = /* @__PURE__ */ new Map();
3267
+ for (const entry of survivingScored) {
3268
+ candidateScores.set(entry.item.stable_id, {
3269
+ score: entry.score,
3270
+ score_breakdown: scoreBreakdownForItem(entry.item, scoringContext)
3271
+ });
3272
+ }
2890
3273
  const topKIds = new Set(topKCandidates.map((item) => item.stable_id));
2891
3274
  const retrievalDropped = rankedCandidates.filter((item) => !topKIds.has(item.stable_id)).map((item) => ({ id: item.stable_id, reason: "retrieval_budget" }));
2892
3275
  const omittedCandidateCount = retrievalDropped.length;
@@ -2992,6 +3375,8 @@ async function planContext(projectRoot, input) {
2992
3375
  // ONLY when at least one neighbour was actually appended. Empty (graph-empty
2993
3376
  // no-op) → field omitted, steady-state wire shape unchanged.
2994
3377
  ...Object.keys(relatedAppended).length > 0 ? { related_appended: relatedAppended } : {},
3378
+ // P1 recall-observability: runtime-only score channel (Map → {} on the wire).
3379
+ ...candidateScores.size > 0 ? { candidate_scores: candidateScores } : {},
2995
3380
  ...payloadTrimDropped > 0 ? { payload_trimmed: true } : {},
2996
3381
  ...payloadOverBudget ? { payload_over_budget: true } : {}
2997
3382
  };
@@ -3080,10 +3465,38 @@ function partitionEmptyShells(items) {
3080
3465
  }
3081
3466
  var bm25ModelCache = null;
3082
3467
  var bm25BuildCount = 0;
3083
- function getOrBuildBm25Model(revision, rawItems, docTexts) {
3468
+ var BM25_CACHE_DIR = ".fabric/cache/bm25";
3469
+ function bm25CachePath(projectRoot, revision) {
3470
+ const safe = revision.replace(/[^A-Za-z0-9_-]/g, "_");
3471
+ return join10(projectRoot, BM25_CACHE_DIR, `${safe}.json`);
3472
+ }
3473
+ async function loadBm25ModelFromDisk(projectRoot, revision) {
3474
+ try {
3475
+ const raw = await readFile7(bm25CachePath(projectRoot, revision), "utf8");
3476
+ const parsed = JSON.parse(raw);
3477
+ if (parsed.version !== 1) return null;
3478
+ return rehydrateBm25Model(parsed);
3479
+ } catch {
3480
+ return null;
3481
+ }
3482
+ }
3483
+ async function saveBm25ModelToDisk(projectRoot, revision, model) {
3484
+ try {
3485
+ const path = bm25CachePath(projectRoot, revision);
3486
+ await mkdir5(join10(projectRoot, BM25_CACHE_DIR), { recursive: true });
3487
+ await writeFile3(path, JSON.stringify(serializeBm25Model(model)), "utf8");
3488
+ } catch {
3489
+ }
3490
+ }
3491
+ async function getOrBuildBm25Model(projectRoot, revision, rawItems, docTexts) {
3084
3492
  if (bm25ModelCache !== null && bm25ModelCache.revision === revision) {
3085
3493
  return bm25ModelCache.model;
3086
3494
  }
3495
+ const fromDisk = await loadBm25ModelFromDisk(projectRoot, revision);
3496
+ if (fromDisk !== null) {
3497
+ bm25ModelCache = { revision, model: fromDisk };
3498
+ return fromDisk;
3499
+ }
3087
3500
  bm25BuildCount += 1;
3088
3501
  const model = buildBm25Model(
3089
3502
  rawItems.map((item) => ({
@@ -3092,6 +3505,7 @@ function getOrBuildBm25Model(revision, rawItems, docTexts) {
3092
3505
  }))
3093
3506
  );
3094
3507
  bm25ModelCache = { revision, model };
3508
+ await saveBm25ModelToDisk(projectRoot, revision, model);
3095
3509
  return model;
3096
3510
  }
3097
3511
  function compareStableIds(a, b) {
@@ -3111,7 +3525,7 @@ function buildScopeRankMap(items, projectRoot) {
3111
3525
  const colon = it.stable_id.indexOf(":");
3112
3526
  const alias = colon === -1 ? "" : it.stable_id.slice(0, colon);
3113
3527
  const localId = colon === -1 ? it.stable_id : it.stable_id.slice(colon + 1);
3114
- const semanticScope = it.description.semantic_scope ?? it.description.knowledge_layer ?? "team";
3528
+ const semanticScope = it.description.semantic_scope ?? layerFromStableId(it.stable_id);
3115
3529
  return {
3116
3530
  global_ref: it.stable_id,
3117
3531
  store_uuid: aliasToUuid.get(alias) ?? alias,
@@ -3137,6 +3551,103 @@ function compareScopeThenId(left, right, scopeRank) {
3137
3551
  }
3138
3552
  return compareStableIds(left.stable_id, right.stable_id);
3139
3553
  }
3554
+ async function buildScoringContext(projectRoot, revision, rawItems, opts) {
3555
+ const scoringContext = {
3556
+ nowMs: Date.now(),
3557
+ targetPaths: opts.targetPaths,
3558
+ queryTerms: buildQueryTerms(opts.queryText),
3559
+ // PLN-004 F1: resolve the credibility half-lives + floors ONCE here (never per
3560
+ // candidate) so credibilityFactor is a pure lookup on the ranking hot path.
3561
+ credibilityHalfLives: readCredibilityHalfLives(projectRoot),
3562
+ credibilityFloors: readCredibilityFloors(projectRoot)
3563
+ };
3564
+ const docTexts = /* @__PURE__ */ new Map();
3565
+ for (const item of rawItems) {
3566
+ docTexts.set(item.stable_id, documentTextForItem(item.description));
3567
+ }
3568
+ scoringContext.docTexts = docTexts;
3569
+ if (scoringContext.queryTerms.length > 0 && rawItems.length > 0) {
3570
+ scoringContext.bm25 = await getOrBuildBm25Model(projectRoot, revision, rawItems, docTexts);
3571
+ }
3572
+ scoringContext.scopeRank = buildScopeRankMap(rawItems, projectRoot);
3573
+ const embedConfig = readEmbedConfig(projectRoot);
3574
+ if (embedConfig.enabled && opts.queryText.trim().length > 0 && rawItems.length > 0) {
3575
+ const embedder = await loadEmbedder(embedConfig.model);
3576
+ const vectorScores = await buildVectorScores(
3577
+ embedder,
3578
+ opts.queryText,
3579
+ rawItems.map((item) => ({
3580
+ stable_id: item.stable_id,
3581
+ text: docTexts.get(item.stable_id) ?? documentTextForItem(item.description)
3582
+ })),
3583
+ { projectRoot, corpusRevision: revision, embeddingModel: embedConfig.model }
3584
+ );
3585
+ if (vectorScores !== null) {
3586
+ scoringContext.vectorScores = vectorScores;
3587
+ scoringContext.vectorWeight = embedConfig.weight;
3588
+ }
3589
+ }
3590
+ const configuredFusion = readFusion(projectRoot);
3591
+ const vectorActive = scoringContext.vectorScores !== void 0 && scoringContext.vectorScores.size > 0;
3592
+ scoringContext.fusion = configuredFusion === "auto" ? vectorActive ? "rrf" : "additive" : configuredFusion;
3593
+ let queryTermWeights;
3594
+ if (scoringContext.queryTerms.length > 0) {
3595
+ queryTermWeights = expandQueryTerms(opts.queryText);
3596
+ }
3597
+ if (scoringContext.fusion === "rrf" && scoringContext.queryTerms.length > 0 && rawItems.length > 0) {
3598
+ const rankIds = rawItems.map((item) => item.stable_id).sort((a, b) => compareStableIds(a, b));
3599
+ if (scoringContext.bm25 !== void 0) {
3600
+ scoringContext.bm25Ranks = rankDocuments(scoringContext.bm25, rankIds, scoringContext.queryTerms);
3601
+ }
3602
+ if (scoringContext.vectorScores !== void 0) {
3603
+ scoringContext.vectorRanks = rankByScore(rankIds, scoringContext.vectorScores);
3604
+ }
3605
+ }
3606
+ return scoringContext;
3607
+ }
3608
+ var PROXIMITY_WINDOW = 6;
3609
+ var PROXIMITY_BOOST_CAP = 0.15;
3610
+ function proximityBoost(item, context, contentScore2) {
3611
+ if (contentScore2 <= 0) return 0;
3612
+ const text = context.docTexts?.get(item.stable_id);
3613
+ if (text === void 0 || text.length === 0) return 0;
3614
+ const tokens = text.toLowerCase().split(/[^a-z0-9_$#]+/u).filter(Boolean);
3615
+ const queryTerms = context.queryTerms;
3616
+ if (queryTerms.length < 2) return 0;
3617
+ const positions = /* @__PURE__ */ new Map();
3618
+ for (const qt of queryTerms) {
3619
+ const qtLower = qt.toLowerCase();
3620
+ const pos = [];
3621
+ for (let i = 0; i < tokens.length; i++) {
3622
+ if (tokens[i] === qtLower) {
3623
+ pos.push(i);
3624
+ }
3625
+ }
3626
+ if (pos.length > 0) {
3627
+ positions.set(qtLower, pos);
3628
+ }
3629
+ }
3630
+ if (positions.size < 2) return 0;
3631
+ const termList = [...positions.entries()];
3632
+ let minDist = Infinity;
3633
+ for (let i = 0; i < termList.length; i++) {
3634
+ const [, posI] = termList[i];
3635
+ for (let j = i + 1; j < termList.length; j++) {
3636
+ const [, posJ] = termList[j];
3637
+ for (const pi of posI) {
3638
+ for (const pj of posJ) {
3639
+ const dist = Math.abs(pi - pj);
3640
+ if (dist < minDist) minDist = dist;
3641
+ }
3642
+ }
3643
+ }
3644
+ }
3645
+ if (!Number.isFinite(minDist)) return 0;
3646
+ if (minDist >= PROXIMITY_WINDOW) return 0;
3647
+ const ratio = 1 - minDist / PROXIMITY_WINDOW;
3648
+ const boost = contentScore2 * PROXIMITY_BOOST_CAP * ratio;
3649
+ return boost;
3650
+ }
3140
3651
  function sortDescriptionItems(rawItems, scoringContext) {
3141
3652
  if (scoringContext === void 0) {
3142
3653
  return [...rawItems].sort((left, right) => compareStableIds(left.stable_id, right.stable_id)).map((item) => ({ item, score: 0 }));
@@ -3151,6 +3662,25 @@ function sortDescriptionItems(rawItems, scoringContext) {
3151
3662
  );
3152
3663
  return scored;
3153
3664
  }
3665
+ function rankDescriptionItems(items, scoringContext, mode, options = {}) {
3666
+ const sorted = sortDescriptionItems(items, scoringContext);
3667
+ const seen = /* @__PURE__ */ new Set();
3668
+ const rankedScored = sorted.filter(({ item }) => {
3669
+ if (seen.has(item.stable_id)) return false;
3670
+ seen.add(item.stable_id);
3671
+ return true;
3672
+ });
3673
+ if (mode === "triage") {
3674
+ return rankedScored;
3675
+ }
3676
+ const topK = options.topK ?? rankedScored.length;
3677
+ const cappedScored = rankedScored.slice(0, topK);
3678
+ const relevanceRatio = options.relevanceRatio ?? 0;
3679
+ const hasQuery = scoringContext.queryTerms.length > 0;
3680
+ const maxScore = rankedScored.length > 0 ? rankedScored[0].score : 0;
3681
+ const relevanceFloor = maxScore * relevanceRatio;
3682
+ return hasQuery && maxScore > 0 && relevanceRatio > 0 ? cappedScored.filter((entry) => entry.score >= relevanceFloor) : cappedScored;
3683
+ }
3154
3684
  function documentTextForItem(description) {
3155
3685
  return [
3156
3686
  description.summary,
@@ -3158,17 +3688,21 @@ function documentTextForItem(description) {
3158
3688
  ...description.intent_clues,
3159
3689
  ...description.tech_stack,
3160
3690
  ...description.impact,
3161
- ...description.entities ?? [],
3162
- ...description.tags ?? []
3691
+ ...description.tags ?? [],
3692
+ // v2.2 glossary aliases (C-002): long-tail synonym terms feed the flat
3693
+ // vector-embedding document verbatim, same as the BM25F summary slot below.
3694
+ ...description.aliases ?? []
3163
3695
  ].join(" ");
3164
3696
  }
3165
3697
  function documentFieldsForItem(description) {
3166
3698
  return {
3167
3699
  title: tokenize2(description.summary),
3168
- tags: tokenize2(
3169
- [...description.tags ?? [], ...description.tech_stack, ...description.entities ?? []].join(" ")
3700
+ tags: tokenize2([...description.tags ?? [], ...description.tech_stack].join(" ")),
3701
+ summary: tokenize2(
3702
+ [description.must_read_if, ...description.intent_clues, ...description.aliases ?? []].join(
3703
+ " "
3704
+ )
3170
3705
  ),
3171
- summary: tokenize2([description.must_read_if, ...description.intent_clues].join(" ")),
3172
3706
  body: tokenize2(description.impact.join(" "))
3173
3707
  };
3174
3708
  }
@@ -3199,6 +3733,9 @@ var BM25_WEIGHT = 50;
3199
3733
  var SALIENCE_PROVEN = 15;
3200
3734
  var SALIENCE_VERIFIED = 8;
3201
3735
  var SALIENCE_DRAFT = 0;
3736
+ var RRF_K = 10;
3737
+ var RRF_NORMALIZATION = 2e3;
3738
+ var RRF_STRUCTURAL_SCALE = 0.2;
3202
3739
  function salienceScore(item) {
3203
3740
  switch (item.description?.maturity) {
3204
3741
  case "proven":
@@ -3209,35 +3746,109 @@ function salienceScore(item) {
3209
3746
  return SALIENCE_DRAFT;
3210
3747
  }
3211
3748
  }
3212
- function scoreDescriptionItem(item, context) {
3213
- let score = 0;
3214
- if (context.bm25 !== void 0 && context.queryTerms.length > 0) {
3215
- score += BM25_WEIGHT * context.bm25.scoreDoc(item.stable_id, context.queryTerms);
3749
+ function contentScore(item, context) {
3750
+ const hasQuery = context.queryTerms.length > 0;
3751
+ if (context.fusion === "rrf" && hasQuery) {
3752
+ let rrf = 0;
3753
+ const bm25Rank = context.bm25Ranks?.get(item.stable_id);
3754
+ if (bm25Rank !== void 0) rrf += 1 / (RRF_K + bm25Rank);
3755
+ const vectorRank = context.vectorRanks?.get(item.stable_id);
3756
+ if (vectorRank !== void 0) rrf += 1 / (RRF_K + vectorRank);
3757
+ return RRF_NORMALIZATION * rrf;
3758
+ }
3759
+ let content = 0;
3760
+ if (context.bm25 !== void 0 && hasQuery) {
3761
+ content += BM25_WEIGHT * context.bm25.scoreDoc(item.stable_id, context.queryTerms);
3216
3762
  }
3217
3763
  if (context.vectorScores !== void 0) {
3218
- score += (context.vectorWeight ?? 0) * (context.vectorScores.get(item.stable_id) ?? 0);
3764
+ content += (context.vectorWeight ?? 0) * (context.vectorScores.get(item.stable_id) ?? 0);
3219
3765
  }
3220
- score += salienceScore(item);
3766
+ return content;
3767
+ }
3768
+ function recencyBoost(item, context) {
3221
3769
  const createdAtRaw = item.description?.created_at;
3222
3770
  if (typeof createdAtRaw === "string" && createdAtRaw.length > 0) {
3223
3771
  const createdMs = Date.parse(createdAtRaw);
3224
3772
  if (Number.isFinite(createdMs) && context.nowMs - createdMs < RECENCY_WINDOW_MS) {
3225
- score += RECENCY_BOOST;
3773
+ return RECENCY_BOOST;
3226
3774
  }
3227
3775
  }
3228
- if (context.targetPaths.length > 0) {
3229
- const relevancePaths = item.description?.relevance_paths ?? [];
3230
- let best = 0;
3231
- outer: for (const rp of relevancePaths) {
3232
- for (const tp of context.targetPaths) {
3233
- const tier = localityTier(rp, tp);
3234
- if (tier > best) best = tier;
3235
- if (best === LOCALITY_SAME_FILE) break outer;
3236
- }
3776
+ return 0;
3777
+ }
3778
+ function localityBoost(item, context) {
3779
+ if (context.targetPaths.length === 0) return 0;
3780
+ const relevancePaths = item.description?.relevance_paths ?? [];
3781
+ let best = 0;
3782
+ outer: for (const rp of relevancePaths) {
3783
+ for (const tp of context.targetPaths) {
3784
+ const tier = localityTier(rp, tp);
3785
+ if (tier > best) best = tier;
3786
+ if (best === LOCALITY_SAME_FILE) break outer;
3237
3787
  }
3238
- score += best;
3239
3788
  }
3240
- return score;
3789
+ return best;
3790
+ }
3791
+ function structuralScaleFor(context) {
3792
+ return context.fusion === "rrf" && context.queryTerms.length > 0 ? RRF_STRUCTURAL_SCALE : 1;
3793
+ }
3794
+ function credibilityFactor(item, context) {
3795
+ const halfLives = context.credibilityHalfLives;
3796
+ const floors = context.credibilityFloors;
3797
+ if (halfLives === void 0 || floors === void 0) return 1;
3798
+ const createdAtRaw = item.description?.created_at;
3799
+ if (typeof createdAtRaw !== "string" || createdAtRaw.length === 0) return 1;
3800
+ const createdMs = Date.parse(createdAtRaw);
3801
+ if (!Number.isFinite(createdMs)) return 1;
3802
+ const ageDays = (context.nowMs - createdMs) / (24 * 60 * 60 * 1e3);
3803
+ if (ageDays <= 0) return 1;
3804
+ const type = item.description?.knowledge_type;
3805
+ const halfLife = type !== void 0 ? halfLives[type] : halfLives.decisions;
3806
+ const factor = Math.pow(2, -ageDays / halfLife);
3807
+ const maturity = item.description?.maturity;
3808
+ const floor = maturity !== void 0 ? floors[maturity] : floors.draft;
3809
+ return Math.max(floor, Math.min(1, factor));
3810
+ }
3811
+ function scoreDescriptionItem(item, context) {
3812
+ const content = contentScore(item, context);
3813
+ const structural = salienceScore(item) + recencyBoost(item, context) + localityBoost(item, context);
3814
+ const proximity = proximityBoost(item, context, content);
3815
+ return (content + structuralScaleFor(context) * structural + proximity) * credibilityFactor(item, context);
3816
+ }
3817
+ function scoreBreakdownForItem(item, context) {
3818
+ const hasQuery = context.queryTerms.length > 0;
3819
+ const rrfMode = context.fusion === "rrf" && hasQuery;
3820
+ let bm25 = 0;
3821
+ let vector = 0;
3822
+ let bm25Rank;
3823
+ let vectorRank;
3824
+ if (rrfMode) {
3825
+ bm25Rank = context.bm25Ranks?.get(item.stable_id);
3826
+ if (bm25Rank !== void 0) bm25 = RRF_NORMALIZATION * (1 / (RRF_K + bm25Rank));
3827
+ vectorRank = context.vectorRanks?.get(item.stable_id);
3828
+ if (vectorRank !== void 0) vector = RRF_NORMALIZATION * (1 / (RRF_K + vectorRank));
3829
+ } else {
3830
+ bm25 = context.bm25 !== void 0 && hasQuery ? BM25_WEIGHT * context.bm25.scoreDoc(item.stable_id, context.queryTerms) : 0;
3831
+ vector = context.vectorScores !== void 0 ? (context.vectorWeight ?? 0) * (context.vectorScores.get(item.stable_id) ?? 0) : 0;
3832
+ }
3833
+ const scale = structuralScaleFor(context);
3834
+ const salience = salienceScore(item) * scale;
3835
+ const recency = recencyBoost(item, context) * scale;
3836
+ const locality = localityBoost(item, context) * scale;
3837
+ const proximity = proximityBoost(item, context, bm25 + vector);
3838
+ const credibility = credibilityFactor(item, context);
3839
+ const final = (bm25 + vector + salience + recency + locality + proximity) * credibility;
3840
+ return {
3841
+ final,
3842
+ ...bm25 !== 0 ? { bm25 } : {},
3843
+ ...bm25Rank !== void 0 ? { bm25_rank: bm25Rank } : {},
3844
+ ...vector !== 0 ? { vector } : {},
3845
+ ...vectorRank !== void 0 ? { vector_rank: vectorRank } : {},
3846
+ salience,
3847
+ recency,
3848
+ locality,
3849
+ proximity,
3850
+ credibility
3851
+ };
3241
3852
  }
3242
3853
  function localityTier(relevancePath, targetPath) {
3243
3854
  if (relevancePath === targetPath) return LOCALITY_SAME_FILE;
@@ -3274,7 +3885,13 @@ function relatedLookupKeys(stableId) {
3274
3885
  var RECALL_DIRECTIVE = "These entries are auto-accounted as citations for edits whose paths overlap this recall \u2014 no first-line cite needed. Speak up only to dismiss one you judge inapplicable: `dismissed: <id> (<reason>)`.";
3275
3886
  async function recall(projectRoot, input) {
3276
3887
  const planResult = await planContext(projectRoot, input);
3277
- const { selection_token: _token, payload_trimmed: _pt, payload_over_budget: _pob, ...planRest } = planResult;
3888
+ const {
3889
+ selection_token: _token,
3890
+ payload_trimmed: _pt,
3891
+ payload_over_budget: _pob,
3892
+ candidate_scores: candidateScores,
3893
+ ...planRest
3894
+ } = planResult;
3278
3895
  let bodyIndex;
3279
3896
  try {
3280
3897
  bodyIndex = await buildCrossStoreBodyIndex(projectRoot);
@@ -3323,13 +3940,15 @@ async function recall(projectRoot, input) {
3323
3940
  const pathByStableId = new Map(paths.map((p) => [p.stable_id, p]));
3324
3941
  const entries = planRest.candidates.map((c, index) => {
3325
3942
  const readPath = pathByStableId.get(c.stable_id);
3943
+ const scored = candidateScores?.get(c.stable_id);
3326
3944
  return {
3327
3945
  stable_id: c.stable_id,
3328
3946
  rank: index + 1,
3329
- description: c.description,
3947
+ description: slimDescription(c.description),
3330
3948
  ...readPath ? { read_path: readPath.path } : {},
3331
3949
  ...readPath?.store ? { store: readPath.store } : {},
3332
- ...isAlwaysActive(c) ? { body_in_context: true } : {}
3950
+ ...isAlwaysActive(c) ? { body_in_context: true } : {},
3951
+ ...scored ? { score: scored.score, score_breakdown: scored.score_breakdown } : {}
3333
3952
  };
3334
3953
  });
3335
3954
  const { entries: _reqProfiles, candidates: _candidates, ...planRestNoLists } = planRest;
@@ -3345,6 +3964,14 @@ function isAlwaysActive(candidate) {
3345
3964
  const { relevance_scope, knowledge_type } = candidate.description;
3346
3965
  return (relevance_scope ?? "broad") !== "narrow" && ALWAYS_ACTIVE_TYPES2.has(knowledge_type ?? "");
3347
3966
  }
3967
+ function slimDescription(d) {
3968
+ return {
3969
+ summary: d.summary,
3970
+ must_read_if: d.must_read_if,
3971
+ intent_clues: d.intent_clues,
3972
+ ...d.knowledge_type !== void 0 ? { knowledge_type: d.knowledge_type } : {}
3973
+ };
3974
+ }
3348
3975
  function buildNextSteps(planResult, paths, candidateById, candidateLookup) {
3349
3976
  const nextSteps = [];
3350
3977
  const omitted = (planResult.dropped ?? []).filter((d) => d.reason === "retrieval_budget").length;
@@ -3489,22 +4116,8 @@ import {
3489
4116
  import { enforcePayloadLimit as enforcePayloadLimit3 } from "@fenglimg/fabric-shared/node/mcp-payload-guard";
3490
4117
 
3491
4118
  // src/services/archive-scan.ts
4119
+ import { isHighValueArchiveCandidate } from "@fenglimg/fabric-shared";
3492
4120
  var ANTI_LOOP_HOURS = 12;
3493
- var HIGH_VALUE_EVENT_TYPES = /* @__PURE__ */ new Set([
3494
- "knowledge_context_planned",
3495
- "edit_paths_recorded",
3496
- "edit_intent_checked"
3497
- // the real high-freq edit signal (rc.37 NEW-14/B3)
3498
- ]);
3499
- var NORMATIVE_KEYWORDS = [
3500
- "\u4EE5\u540E",
3501
- "always",
3502
- "never",
3503
- "from now on",
3504
- "\u4E0B\u6B21",
3505
- "\u8BB0\u4E00\u4E0B",
3506
- "\u6C38\u8FDC\u4E0D\u8981"
3507
- ];
3508
4121
  var PROPOSED_KEYS_WINDOW_MS = 30 * 24 * 60 * 60 * 1e3;
3509
4122
  async function collectArchiveScan(projectRoot, input = {}) {
3510
4123
  const nowMs = typeof input.now_ms === "number" ? input.now_ms : Date.now();
@@ -3572,7 +4185,7 @@ async function collectArchiveScan(projectRoot, input = {}) {
3572
4185
  continue;
3573
4186
  }
3574
4187
  if (typeof attempt.covered_through_ts === "number") {
3575
- if (hasHighValueSignal(events, sid, attempt.covered_through_ts)) {
4188
+ if (isHighValueArchiveCandidate(events, sid, attempt.covered_through_ts)) {
3576
4189
  kept.push(sid);
3577
4190
  } else {
3578
4191
  dropped.push({ session_id: sid, reason: "no_new_signal" });
@@ -3598,28 +4211,6 @@ async function collectArchiveScan(projectRoot, input = {}) {
3598
4211
  already_proposed_keys: [...proposedKeys]
3599
4212
  };
3600
4213
  }
3601
- function hasHighValueSignal(events, sessionId, watermarkTs) {
3602
- let latestTurn = null;
3603
- for (const e of events) {
3604
- if (e.session_id !== sessionId) continue;
3605
- if (typeof e.ts !== "number" || e.ts <= watermarkTs) continue;
3606
- if (typeof e.event_type === "string" && HIGH_VALUE_EVENT_TYPES.has(e.event_type)) {
3607
- return true;
3608
- }
3609
- if (e.event_type === "assistant_turn_observed") {
3610
- if (!latestTurn || typeof latestTurn.ts === "number" && e.ts > latestTurn.ts) {
3611
- latestTurn = e;
3612
- }
3613
- }
3614
- }
3615
- if (latestTurn) {
3616
- const haystack = JSON.stringify(latestTurn).toLowerCase();
3617
- for (const kw of NORMATIVE_KEYWORDS) {
3618
- if (haystack.includes(kw.toLowerCase())) return true;
3619
- }
3620
- }
3621
- return false;
3622
- }
3623
4214
 
3624
4215
  // src/tools/archive-scan.ts
3625
4216
  function registerArchiveScan(server, tracker) {
@@ -3679,9 +4270,9 @@ import { enforcePayloadLimit as enforcePayloadLimit4 } from "@fenglimg/fabric-sh
3679
4270
  // src/services/review.ts
3680
4271
  import { execFileSync } from "child_process";
3681
4272
  import { existsSync as existsSync5 } from "fs";
3682
- import { readFile as readFile6, readdir as readdir2, stat as stat2, unlink as unlink2 } from "fs/promises";
4273
+ import { readFile as readFile9, readdir as readdir2, stat as stat2, unlink as unlink2 } from "fs/promises";
3683
4274
  import { homedir } from "os";
3684
- import { basename, isAbsolute, join as join10, relative, resolve as resolve2, sep as sep2 } from "path";
4275
+ import { basename, isAbsolute, join as join12, relative, resolve as resolve2, sep as sep2 } from "path";
3685
4276
 
3686
4277
  // src/services/promotion-gate.ts
3687
4278
  function toLocalId(id) {
@@ -3717,12 +4308,12 @@ async function hasUnresolvedDismissal(projectRoot, id) {
3717
4308
  }
3718
4309
 
3719
4310
  // src/services/review.ts
3720
- import { allocateStoreKnowledgeId, isPersonalScope as isPersonalScope2 } from "@fenglimg/fabric-shared";
4311
+ import { allocateStoreKnowledgeId, isPersonalScope as isPersonalScope2, loadProjectConfig as loadProjectConfig3 } from "@fenglimg/fabric-shared";
3721
4312
 
3722
4313
  // src/services/pending-dedupe.ts
3723
4314
  import { existsSync as existsSync4 } from "fs";
3724
- import { readdir, readFile as readFile5, unlink } from "fs/promises";
3725
- import { join as join9 } from "path";
4315
+ import { readdir, readFile as readFile8, unlink } from "fs/promises";
4316
+ import { join as join11 } from "path";
3726
4317
  var PENDING_TYPES = ["decisions", "pitfalls", "guidelines", "models", "processes"];
3727
4318
  var DISAMBIGUATION_SUFFIX = /^(.+)-([2-9])\.md$/u;
3728
4319
  var SOURCE_SESSIONS_LINE = /^source_sessions:\s*\[(.*)\]\s*$/mu;
@@ -3795,7 +4386,7 @@ async function mergePendingTwins(projectRoot) {
3795
4386
  continue;
3796
4387
  }
3797
4388
  for (const type of PENDING_TYPES) {
3798
- const dir = join9(pendingBase2, type);
4389
+ const dir = join11(pendingBase2, type);
3799
4390
  if (!existsSync4(dir)) continue;
3800
4391
  let names;
3801
4392
  try {
@@ -3816,10 +4407,10 @@ async function mergePendingTwins(projectRoot) {
3816
4407
  if (groupNames.length < 2) continue;
3817
4408
  const parsed = [];
3818
4409
  for (const name of groupNames) {
3819
- const abs = join9(dir, name);
4410
+ const abs = join11(dir, name);
3820
4411
  let content;
3821
4412
  try {
3822
- content = await readFile5(abs, "utf8");
4413
+ content = await readFile8(abs, "utf8");
3823
4414
  } catch {
3824
4415
  continue;
3825
4416
  }
@@ -3919,7 +4510,7 @@ async function reviewPending(projectRoot, input) {
3919
4510
  case "search":
3920
4511
  return {
3921
4512
  action: "search",
3922
- items: await searchEntries(projectRoot, input.query, input.filters)
4513
+ items: await triageSearch(projectRoot, input.query, input.filters)
3923
4514
  };
3924
4515
  default: {
3925
4516
  const exhaustive = input;
@@ -3999,7 +4590,7 @@ async function listPending(projectRoot, filters) {
3999
4590
  }
4000
4591
  for (const source of sources) {
4001
4592
  for (const type of typesToScan) {
4002
- const dir = join10(source.root, type);
4593
+ const dir = join12(source.root, type);
4003
4594
  if (!existsSync5(dir)) {
4004
4595
  continue;
4005
4596
  }
@@ -4011,10 +4602,10 @@ async function listPending(projectRoot, filters) {
4011
4602
  }
4012
4603
  for (const name of entries) {
4013
4604
  if (!name.endsWith(".md")) continue;
4014
- const absolutePath = join10(dir, name);
4605
+ const absolutePath = join12(dir, name);
4015
4606
  let content;
4016
4607
  try {
4017
- content = await readFile6(absolutePath, "utf8");
4608
+ content = await readFile9(absolutePath, "utf8");
4018
4609
  } catch {
4019
4610
  continue;
4020
4611
  }
@@ -4124,7 +4715,7 @@ async function approveOne(projectRoot, pendingPath) {
4124
4715
  let targetAbs;
4125
4716
  let writtenTarget = false;
4126
4717
  try {
4127
- const content = await readFile6(sourceAbs, "utf8");
4718
+ const content = await readFile9(sourceAbs, "utf8");
4128
4719
  const fm = parseFrontmatter(content);
4129
4720
  const pluralType = fm.type;
4130
4721
  if (pluralType === void 0 || !PLURAL_TYPES.includes(pluralType)) {
@@ -4138,7 +4729,12 @@ async function approveOne(projectRoot, pendingPath) {
4138
4729
  );
4139
4730
  allocatedId = stableId;
4140
4731
  const newFilename = `${stableId}--${slug}.md`;
4141
- targetAbs = join10(resolveStoreCanonicalBase(layer, projectRoot), pluralType, newFilename);
4732
+ const promoteProject = layer === "team" ? loadProjectConfig3(projectRoot)?.active_project : void 0;
4733
+ targetAbs = join12(
4734
+ resolveStoreCanonicalBase(layer, projectRoot, promoteProject),
4735
+ pluralType,
4736
+ newFilename
4737
+ );
4142
4738
  await ensureParentDirectory(targetAbs);
4143
4739
  const rewritten = rewriteFrontmatterMerge(
4144
4740
  rewriteFrontmatterForPromote(content, stableId),
@@ -4196,7 +4792,7 @@ async function rejectAll(projectRoot, pendingPaths, reason) {
4196
4792
  try {
4197
4793
  const sandboxed = resolveSandboxedPath(projectRoot, pendingPath, { allowPersonal: true });
4198
4794
  if (existsSync5(sandboxed.abs)) {
4199
- const content = await readFile6(sandboxed.abs, "utf8");
4795
+ const content = await readFile9(sandboxed.abs, "utf8");
4200
4796
  const merged = rewriteFrontmatterMerge(content, { status: "rejected" });
4201
4797
  const rejectedAbs = sandboxed.abs.includes(`${sep2}pending${sep2}`) ? sandboxed.abs.replace(`${sep2}pending${sep2}`, `${sep2}rejected${sep2}`) : null;
4202
4798
  if (rejectedAbs !== null) {
@@ -4223,7 +4819,7 @@ async function modifyEntry(projectRoot, pendingPath, changes) {
4223
4819
  if (target === null) {
4224
4820
  throw new Error(`modify target not found: ${pendingPath}`);
4225
4821
  }
4226
- const content = await readFile6(target.absPath, "utf8");
4822
+ const content = await readFile9(target.absPath, "utf8");
4227
4823
  const fm = parseFrontmatter(content);
4228
4824
  const currentLayer = fm.layer ?? "team";
4229
4825
  if (fm.maturity === "verified" && changes.maturity === "proven" && fm.id !== void 0) {
@@ -4325,8 +4921,9 @@ async function modifyLayerFlip(projectRoot, target, content, fm, changes) {
4325
4921
  pluralType,
4326
4922
  resolveWriteTargetStoreDir(toLayer, projectRoot)
4327
4923
  );
4328
- const toAbs = join10(
4329
- resolveStoreCanonicalBase(toLayer, projectRoot),
4924
+ const flipProject = toLayer === "team" ? loadProjectConfig3(projectRoot)?.active_project : void 0;
4925
+ const toAbs = join12(
4926
+ resolveStoreCanonicalBase(toLayer, projectRoot, flipProject),
4330
4927
  pluralType,
4331
4928
  `${newStableId}--${slug}.md`
4332
4929
  );
@@ -4348,20 +4945,22 @@ async function modifyLayerFlip(projectRoot, target, content, fm, changes) {
4348
4945
  { ...effectivePatch, last_review_confirmed_at: (/* @__PURE__ */ new Date()).toISOString() },
4349
4946
  { id: newStableId }
4350
4947
  );
4351
- await atomicWriteText(toAbs, rewritten);
4948
+ let moved = false;
4352
4949
  if (target.isInProjectTree) {
4353
4950
  const relSource = relative(projectRoot, target.absPath);
4951
+ const relDest = relative(projectRoot, toAbs);
4354
4952
  try {
4355
- execFileSync("git", ["rm", "--quiet", "-f", relSource], {
4953
+ execFileSync("git", ["mv", "-f", relSource, relDest], {
4356
4954
  cwd: projectRoot,
4357
4955
  stdio: ["ignore", "pipe", "pipe"]
4358
4956
  });
4957
+ moved = true;
4359
4958
  } catch {
4360
- if (existsSync5(target.absPath)) {
4361
- await unlink2(target.absPath);
4362
- }
4959
+ moved = false;
4363
4960
  }
4364
- } else if (existsSync5(target.absPath)) {
4961
+ }
4962
+ await atomicWriteText(toAbs, rewritten);
4963
+ if (!moved && existsSync5(target.absPath) && target.absPath !== toAbs) {
4365
4964
  await unlink2(target.absPath);
4366
4965
  }
4367
4966
  const flipReason = `layer_flip:${priorStableId ?? "<unassigned>"}->${newStableId}`;
@@ -4425,7 +5024,7 @@ function getSearchDirectoryCache(cacheKey) {
4425
5024
  return created;
4426
5025
  }
4427
5026
  async function listIndexedSearchEntries(source, type) {
4428
- const dir = join10(source.root, type);
5027
+ const dir = join12(source.root, type);
4429
5028
  let entries;
4430
5029
  try {
4431
5030
  entries = await readdir2(dir);
@@ -4438,7 +5037,7 @@ async function listIndexedSearchEntries(source, type) {
4438
5037
  const indexed = [];
4439
5038
  for (const name of entries) {
4440
5039
  if (!name.endsWith(".md")) continue;
4441
- const absolutePath = join10(dir, name);
5040
+ const absolutePath = join12(dir, name);
4442
5041
  let fingerprint;
4443
5042
  try {
4444
5043
  const st = await stat2(absolutePath);
@@ -4455,7 +5054,7 @@ async function listIndexedSearchEntries(source, type) {
4455
5054
  }
4456
5055
  let content;
4457
5056
  try {
4458
- content = await readFile6(absolutePath, "utf8");
5057
+ content = await readFile9(absolutePath, "utf8");
4459
5058
  searchEntryIndexContentReads += 1;
4460
5059
  } catch {
4461
5060
  directoryCache.files.delete(absolutePath);
@@ -4482,9 +5081,42 @@ async function listIndexedSearchEntries(source, type) {
4482
5081
  }
4483
5082
  return indexed;
4484
5083
  }
4485
- async function searchEntries(projectRoot, query, filters) {
5084
+ function matchesTriageQuery(indexed, lowerQuery, includeBody) {
5085
+ const haystacks = [
5086
+ indexed.fm.title ?? "",
5087
+ indexed.fm.summary ?? "",
5088
+ ...indexed.fm.tags ?? [],
5089
+ indexed.name,
5090
+ includeBody ? indexed.body : ""
5091
+ ].map((s) => s.toLowerCase());
5092
+ return haystacks.some((h) => h.includes(lowerQuery));
5093
+ }
5094
+ function pendingEntryToRankerItem(indexed) {
5095
+ const { fm, name } = indexed;
5096
+ const slug = name.replace(/\.md$/u, "");
5097
+ const summary = fm.summary ?? fm.title ?? slug;
5098
+ const description = {
5099
+ summary,
5100
+ intent_clues: [],
5101
+ tech_stack: [],
5102
+ impact: [],
5103
+ must_read_if: fm.title ?? summary,
5104
+ ...fm.id !== void 0 ? { id: fm.id } : {},
5105
+ ...fm.type !== void 0 ? { knowledge_type: fm.type } : {},
5106
+ maturity: fm.maturity ?? "draft",
5107
+ // W4/Track1 (D1): no `knowledge_layer` field — layer is derived from the
5108
+ // stable_id prefix (KT-DEC-0004), never carried on the description.
5109
+ ...fm.semantic_scope !== void 0 ? { semantic_scope: fm.semantic_scope } : {},
5110
+ ...fm.created_at !== void 0 ? { created_at: fm.created_at } : {},
5111
+ tags: fm.tags ?? [],
5112
+ relevance_scope: fm.relevance_scope ?? "broad",
5113
+ relevance_paths: fm.relevance_paths ?? []
5114
+ };
5115
+ return { stable_id: indexed.absolutePath, description };
5116
+ }
5117
+ async function triageSearch(projectRoot, query, filters) {
4486
5118
  const lowerQuery = query.toLowerCase();
4487
- const items = [];
5119
+ const includeBody = filters?.include_body === true;
4488
5120
  const sources = [];
4489
5121
  for (const layer of ["team", "personal"]) {
4490
5122
  const isPersonal = layer === "personal";
@@ -4507,10 +5139,12 @@ async function searchEntries(projectRoot, query, filters) {
4507
5139
  }
4508
5140
  }
4509
5141
  const typesToScan = filters?.type !== void 0 ? [filters.type] : PLURAL_TYPES;
5142
+ const matchedByKey = /* @__PURE__ */ new Map();
5143
+ const rankerItems = [];
4510
5144
  for (const source of sources) {
4511
5145
  for (const type of typesToScan) {
4512
5146
  for (const indexed of await listIndexedSearchEntries(source, type)) {
4513
- const { absolutePath, fm, layer, maturity, name } = indexed;
5147
+ const { fm, layer, maturity } = indexed;
4514
5148
  if (filters?.layer !== void 0 && filters.layer !== "both" && filters.layer !== layer) {
4515
5149
  continue;
4516
5150
  }
@@ -4531,42 +5165,56 @@ async function searchEntries(projectRoot, query, filters) {
4531
5165
  if (!isVisibleByLifecycle(fm, filters)) {
4532
5166
  continue;
4533
5167
  }
4534
- const bodyForSearch = filters?.include_body === true ? indexed.body : "";
4535
- const haystacks = [
4536
- fm.title ?? "",
4537
- fm.summary ?? "",
4538
- ...fm.tags ?? [],
4539
- name,
4540
- bodyForSearch
4541
- ].map((s) => s.toLowerCase());
4542
- const matches = haystacks.some((h) => h.includes(lowerQuery));
4543
- if (!matches) continue;
4544
- const reportedPath = source.isStore ? absolutePath : source.isPersonal ? `~/${relative(resolvePersonalRoot(), absolutePath)}` : relative(projectRoot, absolutePath);
4545
- items.push({
4546
- area: source.isPending ? "pending" : "canonical",
4547
- path: reportedPath,
4548
- ...source.isPersonal ? { path_absolute: absolutePath } : {},
4549
- type,
4550
- layer,
4551
- maturity,
4552
- // Only pending entries carry an origin tag (canonical hits live
4553
- // outside the dual-pending-root convention).
4554
- ...source.isPending ? { origin: source.isPersonal ? "personal" : "team" } : {},
4555
- ...fm.tags !== void 0 && fm.tags.length > 0 ? { tags: fm.tags } : {},
4556
- ...fm.title !== void 0 ? { title: fm.title } : {},
4557
- ...fm.summary !== void 0 ? { summary: fm.summary } : {},
4558
- ...fm.status !== void 0 ? { status: fm.status } : {},
4559
- ...fm.deferred_until !== void 0 ? { deferred_until: fm.deferred_until } : {},
4560
- // v2.0.0-rc.27 TASK-006 (audit §2.23): body emission when opted in.
4561
- ...filters?.include_body === true ? { body: bodyForSearch } : {},
4562
- // Canonical hits always have an id; pending hits typically don't
4563
- // yet — surface the frontmatter id when present so consumers can
4564
- // dedupe across runs.
4565
- ...fm.id !== void 0 ? { stable_id: fm.id } : {}
4566
- });
5168
+ if (!matchesTriageQuery(indexed, lowerQuery, includeBody)) continue;
5169
+ const item = pendingEntryToRankerItem(indexed);
5170
+ matchedByKey.set(item.stable_id, { indexed, source, type });
5171
+ rankerItems.push(item);
4567
5172
  }
4568
5173
  }
4569
5174
  }
5175
+ if (rankerItems.length === 0) {
5176
+ return [];
5177
+ }
5178
+ let revision;
5179
+ try {
5180
+ revision = await computeReadSetRevision(projectRoot);
5181
+ } catch {
5182
+ revision = "triage-search";
5183
+ }
5184
+ const scoringContext = await buildScoringContext(projectRoot, revision, rankerItems, {
5185
+ queryText: query,
5186
+ targetPaths: []
5187
+ });
5188
+ const ranked = rankDescriptionItems(rankerItems, scoringContext, "triage");
5189
+ const items = [];
5190
+ for (const { item } of ranked) {
5191
+ const match = matchedByKey.get(item.stable_id);
5192
+ if (match === void 0) continue;
5193
+ const { indexed, source, type } = match;
5194
+ const { absolutePath, fm, layer, maturity } = indexed;
5195
+ const reportedPath = source.isStore ? absolutePath : source.isPersonal ? `~/${relative(resolvePersonalRoot(), absolutePath)}` : relative(projectRoot, absolutePath);
5196
+ items.push({
5197
+ area: source.isPending ? "pending" : "canonical",
5198
+ path: reportedPath,
5199
+ ...source.isPersonal ? { path_absolute: absolutePath } : {},
5200
+ type,
5201
+ layer,
5202
+ maturity,
5203
+ // Only pending entries carry an origin tag (canonical hits live
5204
+ // outside the dual-pending-root convention).
5205
+ ...source.isPending ? { origin: source.isPersonal ? "personal" : "team" } : {},
5206
+ ...fm.tags !== void 0 && fm.tags.length > 0 ? { tags: fm.tags } : {},
5207
+ ...fm.title !== void 0 ? { title: fm.title } : {},
5208
+ ...fm.summary !== void 0 ? { summary: fm.summary } : {},
5209
+ ...fm.status !== void 0 ? { status: fm.status } : {},
5210
+ ...fm.deferred_until !== void 0 ? { deferred_until: fm.deferred_until } : {},
5211
+ // v2.0.0-rc.27 TASK-006 (audit §2.23): body emission when opted in.
5212
+ ...includeBody ? { body: indexed.body } : {},
5213
+ // Canonical hits always have an id; pending hits typically don't yet —
5214
+ // surface the frontmatter id when present so consumers can dedupe.
5215
+ ...fm.id !== void 0 ? { stable_id: fm.id } : {}
5216
+ });
5217
+ }
4570
5218
  return items;
4571
5219
  }
4572
5220
  async function deferAll(projectRoot, pendingPaths, until, reason) {
@@ -4576,7 +5224,7 @@ async function deferAll(projectRoot, pendingPaths, until, reason) {
4576
5224
  try {
4577
5225
  const sandboxed = resolveSandboxedPath(projectRoot, pendingPath, { allowPersonal: true });
4578
5226
  if (existsSync5(sandboxed.abs)) {
4579
- const content = await readFile6(sandboxed.abs, "utf8");
5227
+ const content = await readFile9(sandboxed.abs, "utf8");
4580
5228
  stableId = parseFrontmatter(content).id;
4581
5229
  const patch = {
4582
5230
  status: "deferred",
@@ -4737,6 +5385,9 @@ ${content}`;
4737
5385
  if (patch.relevance_paths !== void 0) updates.relevance_paths = `relevance_paths: ${flowArray(patch.relevance_paths)}`;
4738
5386
  if (patch.semantic_scope !== void 0) updates.semantic_scope = `semantic_scope: ${patch.semantic_scope}`;
4739
5387
  if (patch.related !== void 0) updates.related = `related: ${flowArray(patch.related)}`;
5388
+ if (patch.must_read_if !== void 0) updates.must_read_if = `must_read_if: ${quoteIfNeeded(patch.must_read_if)}`;
5389
+ if (patch.intent_clues !== void 0) updates.intent_clues = `intent_clues: ${flowArray(patch.intent_clues)}`;
5390
+ if (patch.impact !== void 0) updates.impact = `impact: ${flowArray(patch.impact)}`;
4740
5391
  if (patch.status !== void 0) updates.status = `status: ${patch.status}`;
4741
5392
  if (patch.deferred_until !== void 0) updates.deferred_until = `deferred_until: ${quoteIfNeeded(patch.deferred_until)}`;
4742
5393
  if (patch.last_review_confirmed_at !== void 0) updates.last_review_confirmed_at = `last_review_confirmed_at: ${quoteIfNeeded(patch.last_review_confirmed_at)}`;
@@ -4774,6 +5425,9 @@ function appendPatchLines(lines, patch) {
4774
5425
  if (patch.relevance_scope !== void 0) lines.push(`relevance_scope: ${patch.relevance_scope}`);
4775
5426
  if (patch.relevance_paths !== void 0) lines.push(`relevance_paths: ${flowArray(patch.relevance_paths)}`);
4776
5427
  if (patch.related !== void 0) lines.push(`related: ${flowArray(patch.related)}`);
5428
+ if (patch.must_read_if !== void 0) lines.push(`must_read_if: ${quoteIfNeeded(patch.must_read_if)}`);
5429
+ if (patch.intent_clues !== void 0) lines.push(`intent_clues: ${flowArray(patch.intent_clues)}`);
5430
+ if (patch.impact !== void 0) lines.push(`impact: ${flowArray(patch.impact)}`);
4777
5431
  if (patch.status !== void 0) lines.push(`status: ${patch.status}`);
4778
5432
  if (patch.deferred_until !== void 0) lines.push(`deferred_until: ${quoteIfNeeded(patch.deferred_until)}`);
4779
5433
  if (patch.last_review_confirmed_at !== void 0) lines.push(`last_review_confirmed_at: ${quoteIfNeeded(patch.last_review_confirmed_at)}`);
@@ -4917,9 +5571,9 @@ function registerPending(server, tracker) {
4917
5571
  }
4918
5572
 
4919
5573
  // src/services/doctor.ts
4920
- import { access as access4, readFile as readFile16, readdir as readdirAsync, stat as statAsync, unlink as unlink4, writeFile as writeFile3 } from "fs/promises";
5574
+ import { access as access4, readFile as readFile19, readdir as readdirAsync, stat as statAsync, unlink as unlink4, writeFile as writeFile5 } from "fs/promises";
4921
5575
  import { constants as constants2 } from "fs";
4922
- import { isAbsolute as isAbsolute2, join as join21, posix as posix5, resolve as resolve3 } from "path";
5576
+ import { isAbsolute as isAbsolute2, join as join24, posix as posix5, resolve as resolve3 } from "path";
4923
5577
  import {
4924
5578
  createTranslator,
4925
5579
  forensicReportSchema,
@@ -5027,6 +5681,7 @@ function createGlobalCliVersionCheck(t, inspection) {
5027
5681
  }
5028
5682
 
5029
5683
  // src/services/doctor-health.ts
5684
+ import { isHighValueArchiveCandidate as isHighValueArchiveCandidate2 } from "@fenglimg/fabric-shared";
5030
5685
  var DOCTOR_HEALTH_PENALTY_MANUAL_ERROR = 15;
5031
5686
  var DOCTOR_HEALTH_PENALTY_FIXABLE_ERROR = 8;
5032
5687
  var DOCTOR_HEALTH_PENALTY_WARNING = 3;
@@ -5046,6 +5701,64 @@ function computeDoctorHealth(manualErrorCount, fixableErrorCount, warningCount)
5046
5701
  }
5047
5702
  };
5048
5703
  }
5704
+ async function checkBacklogAge(projectRoot, nowMs = Date.now()) {
5705
+ const empty = {
5706
+ count: 0,
5707
+ oldest_days: null,
5708
+ median_age_days: 0,
5709
+ ages_days: []
5710
+ };
5711
+ let events = [];
5712
+ try {
5713
+ const result = await readEventLedger(projectRoot);
5714
+ events = result.events;
5715
+ } catch {
5716
+ return empty;
5717
+ }
5718
+ if (!Array.isArray(events) || events.length === 0) return empty;
5719
+ const sids = /* @__PURE__ */ new Set();
5720
+ const lastAttemptCoveredThrough = /* @__PURE__ */ new Map();
5721
+ const firstEventTs = /* @__PURE__ */ new Map();
5722
+ for (const e of events) {
5723
+ if (typeof e.session_id !== "string" || e.session_id.length === 0) continue;
5724
+ if (typeof e.ts !== "number") continue;
5725
+ sids.add(e.session_id);
5726
+ const priorFirst = firstEventTs.get(e.session_id);
5727
+ if (priorFirst === void 0 || e.ts < priorFirst) {
5728
+ firstEventTs.set(e.session_id, e.ts);
5729
+ }
5730
+ if (e.event_type === "session_archive_attempted" && typeof e.covered_through_ts === "number") {
5731
+ const prior = lastAttemptCoveredThrough.get(e.session_id);
5732
+ if (prior === void 0 || e.ts > prior) {
5733
+ lastAttemptCoveredThrough.set(e.session_id, e.covered_through_ts);
5734
+ }
5735
+ }
5736
+ }
5737
+ const agesMs = [];
5738
+ for (const sid of sids) {
5739
+ const watermark = lastAttemptCoveredThrough.get(sid) ?? null;
5740
+ if (!isHighValueArchiveCandidate2(events, sid, watermark)) continue;
5741
+ const first = firstEventTs.get(sid);
5742
+ if (typeof first !== "number") continue;
5743
+ const ageMs = Math.max(0, nowMs - first);
5744
+ agesMs.push(ageMs);
5745
+ }
5746
+ if (agesMs.length === 0) return empty;
5747
+ const agesDays = agesMs.map((ms) => Math.floor(ms / 864e5));
5748
+ agesDays.sort((a, b) => a - b);
5749
+ const oldestDays = agesDays[agesDays.length - 1];
5750
+ const medianAgeDays = agesDays.length % 2 === 1 ? agesDays[Math.floor(agesDays.length / 2)] : Math.floor((agesDays[agesDays.length / 2 - 1] + agesDays[agesDays.length / 2]) / 2);
5751
+ return {
5752
+ count: agesDays.length,
5753
+ oldest_days: oldestDays,
5754
+ median_age_days: medianAgeDays,
5755
+ ages_days: agesDays
5756
+ };
5757
+ }
5758
+ function renderBacklogAgeLine(metric) {
5759
+ if (metric.count === 0) return " backlog: 0 high-value";
5760
+ return ` backlog: ${metric.count} high-value, oldest ${metric.oldest_days}d`;
5761
+ }
5049
5762
 
5050
5763
  // src/services/doctor-summary-opaque.ts
5051
5764
  var KNOWLEDGE_SUMMARY_OPAQUE_THRESHOLD = 0.3;
@@ -5133,13 +5846,13 @@ function createKnowledgeSummaryOpaqueCheck(t, inspection) {
5133
5846
  }
5134
5847
 
5135
5848
  // src/services/doctor-stable-id-collision.ts
5136
- import { readFile as readFile7 } from "fs/promises";
5137
- import { basename as basename2, join as join11 } from "path";
5849
+ import { readFile as readFile10 } from "fs/promises";
5850
+ import { basename as basename2, join as join13 } from "path";
5138
5851
  import {
5139
5852
  buildStoreResolveInput as buildStoreResolveInput4,
5140
5853
  createStoreResolver as createStoreResolver4,
5141
5854
  readKnowledgeAcrossStores as readKnowledgeAcrossStores2,
5142
- resolveGlobalRoot as resolveGlobalRoot3,
5855
+ resolveGlobalRoot as resolveGlobalRoot4,
5143
5856
  storeRelativePathForMount as storeRelativePathForMount3
5144
5857
  } from "@fenglimg/fabric-shared";
5145
5858
  var ID_LINE = /^id:\s*"?([^"\n]+?)"?\s*$/mu;
@@ -5155,13 +5868,13 @@ function resolveIntegrityStores(projectRoot) {
5155
5868
  const personalUuids = new Set(
5156
5869
  input.mountedStores.filter((s) => s.personal).map((s) => s.store_uuid)
5157
5870
  );
5158
- const globalRoot = resolveGlobalRoot3();
5871
+ const globalRoot = resolveGlobalRoot4();
5159
5872
  const dirs = readSet.stores.map((entry) => {
5160
5873
  const mounted = input.mountedStores.find((s) => s.store_uuid === entry.store_uuid);
5161
5874
  return {
5162
5875
  store_uuid: entry.store_uuid,
5163
5876
  alias: entry.alias,
5164
- dir: join11(globalRoot, storeRelativePathForMount3(mounted ?? { store_uuid: entry.store_uuid }))
5877
+ dir: join13(globalRoot, storeRelativePathForMount3(mounted ?? { store_uuid: entry.store_uuid }))
5165
5878
  };
5166
5879
  });
5167
5880
  return { dirs, personalUuids };
@@ -5180,7 +5893,7 @@ async function inspectStoreStableIdIntegrity(projectRoot) {
5180
5893
  for (const ref of await readKnowledgeAcrossStores2(resolved.dirs)) {
5181
5894
  let source;
5182
5895
  try {
5183
- source = await readFile7(ref.file, "utf8");
5896
+ source = await readFile10(ref.file, "utf8");
5184
5897
  } catch {
5185
5898
  continue;
5186
5899
  }
@@ -5265,7 +5978,7 @@ function createLayerMismatchCheck(t, inspection) {
5265
5978
  // src/services/doctor-relevance-paths.ts
5266
5979
  import { execFileSync as execFileSync2 } from "child_process";
5267
5980
  import { existsSync as existsSync6, readdirSync, statSync as statSync3 } from "fs";
5268
- import { join as join12, sep as sep3 } from "path";
5981
+ import { join as join14, sep as sep3 } from "path";
5269
5982
  import { minimatch } from "minimatch";
5270
5983
  var MS_PER_DAY = 24 * 60 * 60 * 1e3;
5271
5984
  var RELEVANCE_PATHS_DRIFT_WINDOW_DAYS = 90;
@@ -5308,7 +6021,7 @@ function collectWorkspacePaths(projectRoot) {
5308
6021
  continue;
5309
6022
  }
5310
6023
  for (const entry of entries) {
5311
- const abs = join12(current, entry.name);
6024
+ const abs = join14(current, entry.name);
5312
6025
  const rel = toPosix(abs.slice(projectRoot.length + 1));
5313
6026
  if (rel.length === 0) continue;
5314
6027
  if (entry.isDirectory()) {
@@ -5501,16 +6214,16 @@ function createNarrowNoPathsCheck(t, inspection) {
5501
6214
  }
5502
6215
 
5503
6216
  // src/services/doctor-broad-index.ts
5504
- import { readFile as readFile8 } from "fs/promises";
5505
- import { join as join13 } from "path";
6217
+ import { readFile as readFile11 } from "fs/promises";
6218
+ import { join as join15 } from "path";
5506
6219
  var DEFAULT_BROAD_INDEX_BACKSTOP = 50;
5507
6220
  var BROAD_INDEX_BACKSTOP_MIN = 20;
5508
6221
  var BROAD_INDEX_BACKSTOP_MAX = 500;
5509
6222
  var BROAD_INDEX_DRIFT_RATIO = 0.8;
5510
6223
  async function readBroadIndexBackstop(projectRoot) {
5511
- const configPath = join13(projectRoot, ".fabric", "fabric-config.json");
6224
+ const configPath = join15(projectRoot, ".fabric", "fabric-config.json");
5512
6225
  try {
5513
- const raw = await readFile8(configPath, "utf8");
6226
+ const raw = await readFile11(configPath, "utf8");
5514
6227
  const parsed = JSON.parse(raw);
5515
6228
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
5516
6229
  const v = parsed.broad_index_backstop;
@@ -5827,15 +6540,15 @@ function createBroadReviewRecheckCheck(t, inspection) {
5827
6540
  }
5828
6541
 
5829
6542
  // src/services/doctor-scope-lint.ts
5830
- import { readFile as readFile9 } from "fs/promises";
5831
- import { join as join14 } from "path";
6543
+ import { readFile as readFile12 } from "fs/promises";
6544
+ import { join as join16 } from "path";
5832
6545
  import {
5833
6546
  buildStoreResolveInput as buildStoreResolveInput5,
5834
6547
  createStoreResolver as createStoreResolver5,
5835
6548
  isPersonalScope as isPersonalScope3,
5836
6549
  readKnowledgeAcrossStores as readKnowledgeAcrossStores3,
5837
6550
  readStoreProjects,
5838
- resolveGlobalRoot as resolveGlobalRoot4,
6551
+ resolveGlobalRoot as resolveGlobalRoot5,
5839
6552
  scopeRoot as scopeRoot2,
5840
6553
  storeRelativePathForMount as storeRelativePathForMount4
5841
6554
  } from "@fenglimg/fabric-shared";
@@ -5860,10 +6573,10 @@ async function resolveLintStores(projectRoot) {
5860
6573
  const personalUuids = new Set(
5861
6574
  input.mountedStores.filter((s) => s.personal).map((s) => s.store_uuid)
5862
6575
  );
5863
- const globalRoot = resolveGlobalRoot4();
6576
+ const globalRoot = resolveGlobalRoot5();
5864
6577
  return Promise.all(readSet.stores.map(async (entry) => {
5865
6578
  const mounted = input.mountedStores.find((s) => s.store_uuid === entry.store_uuid);
5866
- const dir = join14(
6579
+ const dir = join16(
5867
6580
  globalRoot,
5868
6581
  storeRelativePathForMount4(mounted ?? { store_uuid: entry.store_uuid })
5869
6582
  );
@@ -5895,7 +6608,7 @@ async function lintStoreScopes(projectRoot) {
5895
6608
  }
5896
6609
  let source;
5897
6610
  try {
5898
- source = await readFile9(ref.file, "utf8");
6611
+ source = await readFile12(ref.file, "utf8");
5899
6612
  } catch {
5900
6613
  continue;
5901
6614
  }
@@ -5978,9 +6691,9 @@ function createScopeLintCheck(t, violations) {
5978
6691
  }
5979
6692
 
5980
6693
  // src/services/doctor-unbound-project.ts
5981
- import { loadProjectConfig as loadProjectConfig3 } from "@fenglimg/fabric-shared";
6694
+ import { loadProjectConfig as loadProjectConfig4 } from "@fenglimg/fabric-shared";
5982
6695
  function detectUnboundProject(projectRoot) {
5983
- const config = loadProjectConfig3(projectRoot);
6696
+ const config = loadProjectConfig4(projectRoot);
5984
6697
  const alias = config?.active_write_store;
5985
6698
  if (typeof alias !== "string" || alias.length === 0) {
5986
6699
  return null;
@@ -6016,9 +6729,56 @@ function createUnboundProjectCheck(t, violation) {
6016
6729
  };
6017
6730
  }
6018
6731
 
6732
+ // src/services/doctor-write-route-lint.ts
6733
+ import { loadProjectConfig as loadProjectConfig5 } from "@fenglimg/fabric-shared";
6734
+ function detectWriteRouteUnbound(projectRoot) {
6735
+ const config = loadProjectConfig5(projectRoot);
6736
+ const routes = config?.write_routes ?? [];
6737
+ if (!Array.isArray(routes) || routes.length === 0) {
6738
+ return [];
6739
+ }
6740
+ const boundIds = new Set(
6741
+ (config?.required_stores ?? []).map((r) => typeof r?.id === "string" ? r.id : "").filter((id) => id.length > 0)
6742
+ );
6743
+ const violations = [];
6744
+ for (const route of routes) {
6745
+ const scope = typeof route?.scope === "string" ? route.scope : "";
6746
+ const store = typeof route?.store === "string" ? route.store : "";
6747
+ if (scope.length === 0 || store.length === 0) {
6748
+ continue;
6749
+ }
6750
+ if (!boundIds.has(store)) {
6751
+ violations.push({ scope, store });
6752
+ }
6753
+ }
6754
+ return violations;
6755
+ }
6756
+ function createWriteRouteUnboundCheck(t, violations) {
6757
+ if (violations.length === 0) {
6758
+ return {
6759
+ name: t("doctor.check.write_route_target_unbound.name"),
6760
+ status: "ok",
6761
+ message: t("doctor.check.write_route_target_unbound.ok")
6762
+ };
6763
+ }
6764
+ const summary = violations.map((v) => `${v.scope} \u2192 ${v.store}`).join(", ");
6765
+ return {
6766
+ name: t("doctor.check.write_route_target_unbound.name"),
6767
+ status: "warn",
6768
+ kind: "warning",
6769
+ code: "write_route_target_unbound",
6770
+ fixable: false,
6771
+ message: t("doctor.check.write_route_target_unbound.message", {
6772
+ count: String(violations.length),
6773
+ routes: summary
6774
+ }),
6775
+ actionHint: t("doctor.check.write_route_target_unbound.remediation")
6776
+ };
6777
+ }
6778
+
6019
6779
  // src/services/doctor-store-counters.ts
6020
6780
  import { existsSync as existsSync7, readdirSync as readdirSync2, readFileSync as readFileSync3 } from "fs";
6021
- import { join as join15 } from "path";
6781
+ import { join as join17 } from "path";
6022
6782
  import {
6023
6783
  buildStoreResolveInput as buildStoreResolveInput6,
6024
6784
  createStoreResolver as createStoreResolver6,
@@ -6027,8 +6787,8 @@ import {
6027
6787
  parseKnowledgeId as parseKnowledgeId2,
6028
6788
  readStoreCounters,
6029
6789
  reconcileStoreCounters,
6030
- resolveGlobalRoot as resolveGlobalRoot5,
6031
- STORE_KNOWLEDGE_TYPE_DIRS,
6790
+ resolveGlobalRoot as resolveGlobalRoot6,
6791
+ STORE_KNOWLEDGE_TYPE_DIRS as STORE_KNOWLEDGE_TYPE_DIRS2,
6032
6792
  STORE_LAYOUT as STORE_LAYOUT2,
6033
6793
  storeRelativePathForMount as storeRelativePathForMount5
6034
6794
  } from "@fenglimg/fabric-shared";
@@ -6041,11 +6801,11 @@ function resolveCounterStores(projectRoot) {
6041
6801
  if (readSet.stores.length === 0) {
6042
6802
  return [];
6043
6803
  }
6044
- const globalRoot = resolveGlobalRoot5();
6804
+ const globalRoot = resolveGlobalRoot6();
6045
6805
  return readSet.stores.map((entry) => ({
6046
6806
  uuid: entry.store_uuid,
6047
6807
  alias: entry.alias,
6048
- dir: join15(
6808
+ dir: join17(
6049
6809
  globalRoot,
6050
6810
  storeRelativePathForMount5(
6051
6811
  input.mountedStores.find((s) => s.store_uuid === entry.store_uuid) ?? {
@@ -6072,8 +6832,8 @@ function readEntryId(file) {
6072
6832
  }
6073
6833
  function computeStoreDiskMax(storeDir) {
6074
6834
  const max = defaultAgentsMetaCounters();
6075
- for (const type of STORE_KNOWLEDGE_TYPE_DIRS) {
6076
- const dir = join15(storeDir, STORE_LAYOUT2.knowledgeDir, type);
6835
+ for (const type of STORE_KNOWLEDGE_TYPE_DIRS2) {
6836
+ const dir = join17(storeDir, STORE_LAYOUT2.knowledgeDir, type);
6077
6837
  if (!existsSync7(dir)) {
6078
6838
  continue;
6079
6839
  }
@@ -6087,7 +6847,7 @@ function computeStoreDiskMax(storeDir) {
6087
6847
  if (!name.endsWith(".md")) {
6088
6848
  continue;
6089
6849
  }
6090
- const parsed = parseKnowledgeId2(readEntryId(join15(dir, name)) ?? "");
6850
+ const parsed = parseKnowledgeId2(readEntryId(join17(dir, name)) ?? "");
6091
6851
  if (parsed === null) {
6092
6852
  continue;
6093
6853
  }
@@ -6170,14 +6930,14 @@ function createStoreCounterCheck(t, drifts) {
6170
6930
 
6171
6931
  // src/services/doctor-store-orphan.ts
6172
6932
  import { readdirSync as readdirSync3 } from "fs";
6173
- import { join as join16 } from "path";
6933
+ import { join as join18 } from "path";
6174
6934
  import {
6175
6935
  STORES_ROOT_DIR,
6176
6936
  addMountedStore,
6177
6937
  disambiguateAlias,
6178
6938
  loadGlobalConfig,
6179
6939
  readStoreIdentity,
6180
- resolveGlobalRoot as resolveGlobalRoot6,
6940
+ resolveGlobalRoot as resolveGlobalRoot7,
6181
6941
  saveGlobalConfig
6182
6942
  } from "@fenglimg/fabric-shared";
6183
6943
  var STORE_BY_ALIAS_DIR = "by-alias";
@@ -6188,21 +6948,21 @@ function listDir(dir) {
6188
6948
  return [];
6189
6949
  }
6190
6950
  }
6191
- function inspectStoreOrphans(globalRoot = resolveGlobalRoot6()) {
6951
+ function inspectStoreOrphans(globalRoot = resolveGlobalRoot7()) {
6192
6952
  const config = loadGlobalConfig(globalRoot);
6193
6953
  if (config === null) {
6194
6954
  return [];
6195
6955
  }
6196
6956
  const registered = new Set(config.stores.map((s) => s.store_uuid));
6197
- const storesRoot = join16(globalRoot, STORES_ROOT_DIR);
6957
+ const storesRoot = join18(globalRoot, STORES_ROOT_DIR);
6198
6958
  const orphans = [];
6199
6959
  for (const group of listDir(storesRoot)) {
6200
6960
  if (group === STORE_BY_ALIAS_DIR) {
6201
6961
  continue;
6202
6962
  }
6203
- const groupDir = join16(storesRoot, group);
6963
+ const groupDir = join18(storesRoot, group);
6204
6964
  for (const mount of listDir(groupDir)) {
6205
- const dir = join16(groupDir, mount);
6965
+ const dir = join18(groupDir, mount);
6206
6966
  const identity = readStoreIdentity(dir);
6207
6967
  if (identity === null || registered.has(identity.store_uuid)) {
6208
6968
  continue;
@@ -6212,7 +6972,7 @@ function inspectStoreOrphans(globalRoot = resolveGlobalRoot6()) {
6212
6972
  }
6213
6973
  return orphans;
6214
6974
  }
6215
- function fixStoreOrphans(globalRoot = resolveGlobalRoot6()) {
6975
+ function fixStoreOrphans(globalRoot = resolveGlobalRoot7()) {
6216
6976
  let config = loadGlobalConfig(globalRoot);
6217
6977
  if (config === null) {
6218
6978
  return [];
@@ -6267,6 +7027,195 @@ function createStoreOrphanCheck(t, orphans) {
6267
7027
  };
6268
7028
  }
6269
7029
 
7030
+ // src/services/doctor-project-registry-drift.ts
7031
+ import { readdir as readdir3, rmdir } from "fs/promises";
7032
+ import { join as join19 } from "path";
7033
+ import {
7034
+ addStoreProject,
7035
+ buildStoreResolveInput as buildStoreResolveInput7,
7036
+ createStoreResolver as createStoreResolver7,
7037
+ readStoreProjects as readStoreProjects2,
7038
+ resolveGlobalRoot as resolveGlobalRoot8,
7039
+ STORE_KNOWLEDGE_TYPE_DIRS as STORE_KNOWLEDGE_TYPE_DIRS3,
7040
+ STORE_LAYOUT as STORE_LAYOUT3,
7041
+ STORE_PROJECT_ID_PATTERN as STORE_PROJECT_ID_PATTERN2,
7042
+ storeRelativePathForMount as storeRelativePathForMount6
7043
+ } from "@fenglimg/fabric-shared";
7044
+ var PROJECTS_DIR = "projects";
7045
+ var EMPTY_INSPECTION = { findings: [] };
7046
+ function resolveDriftStores(projectRoot) {
7047
+ const input = buildStoreResolveInput7(projectRoot);
7048
+ if (input === null) {
7049
+ return [];
7050
+ }
7051
+ const readSet = createStoreResolver7().resolveReadSet(input);
7052
+ if (readSet.stores.length === 0) {
7053
+ return [];
7054
+ }
7055
+ const globalRoot = resolveGlobalRoot8();
7056
+ return readSet.stores.map((entry) => {
7057
+ const mounted = input.mountedStores.find((s) => s.store_uuid === entry.store_uuid);
7058
+ return {
7059
+ uuid: entry.store_uuid,
7060
+ alias: entry.alias,
7061
+ dir: join19(globalRoot, storeRelativePathForMount6(mounted ?? { store_uuid: entry.store_uuid }))
7062
+ };
7063
+ });
7064
+ }
7065
+ async function listDir2(dir) {
7066
+ try {
7067
+ return await readdir3(dir);
7068
+ } catch {
7069
+ return [];
7070
+ }
7071
+ }
7072
+ async function listProjectFolders(storeDir) {
7073
+ const projectsRoot = join19(storeDir, STORE_LAYOUT3.knowledgeDir, PROJECTS_DIR);
7074
+ const reserved = /* @__PURE__ */ new Set([PROJECTS_DIR, ...STORE_KNOWLEDGE_TYPE_DIRS3]);
7075
+ return (await listDir2(projectsRoot)).filter((id) => STORE_PROJECT_ID_PATTERN2.test(id) && !reserved.has(id)).sort();
7076
+ }
7077
+ async function folderHasEntries(storeDir, projectId) {
7078
+ const base = join19(storeDir, STORE_LAYOUT3.knowledgeDir, PROJECTS_DIR, projectId);
7079
+ for (const type of STORE_KNOWLEDGE_TYPE_DIRS3) {
7080
+ const names = await listDir2(join19(base, type));
7081
+ if (names.some((name) => name.endsWith(".md"))) {
7082
+ return true;
7083
+ }
7084
+ }
7085
+ return false;
7086
+ }
7087
+ async function inspectProjectRegistryDrift(projectRoot) {
7088
+ const stores = resolveDriftStores(projectRoot);
7089
+ if (stores.length === 0) {
7090
+ return EMPTY_INSPECTION;
7091
+ }
7092
+ const findings = [];
7093
+ for (const store of stores) {
7094
+ const registered = new Set((await readStoreProjects2(store.dir)).map((p) => p.id));
7095
+ const folders = await listProjectFolders(store.dir);
7096
+ const folderSet = new Set(folders);
7097
+ const base = { store_alias: store.alias, store_uuid: store.uuid, store_dir: store.dir };
7098
+ for (const projectId of folders) {
7099
+ if (registered.has(projectId)) {
7100
+ if (!await folderHasEntries(store.dir, projectId)) {
7101
+ findings.push({ ...base, project_id: projectId, kind: "empty_folder" });
7102
+ }
7103
+ continue;
7104
+ }
7105
+ const kind = await folderHasEntries(store.dir, projectId) ? "unregistered_write" : "orphan_folder";
7106
+ findings.push({ ...base, project_id: projectId, kind });
7107
+ }
7108
+ void folderSet;
7109
+ }
7110
+ findings.sort(
7111
+ (a, b) => a.store_alias.localeCompare(b.store_alias) || a.project_id.localeCompare(b.project_id)
7112
+ );
7113
+ return { findings };
7114
+ }
7115
+ async function fixProjectRegistryDrift(projectRoot) {
7116
+ const registered = [];
7117
+ const pruned = [];
7118
+ const { findings } = await inspectProjectRegistryDrift(projectRoot);
7119
+ for (const finding of findings) {
7120
+ if (finding.kind === "empty_folder") {
7121
+ if (await folderHasEntries(finding.store_dir, finding.project_id)) {
7122
+ continue;
7123
+ }
7124
+ try {
7125
+ const base = join19(
7126
+ finding.store_dir,
7127
+ STORE_LAYOUT3.knowledgeDir,
7128
+ PROJECTS_DIR,
7129
+ finding.project_id
7130
+ );
7131
+ for (const type of STORE_KNOWLEDGE_TYPE_DIRS3) {
7132
+ await rmdir(join19(base, type)).catch(() => void 0);
7133
+ }
7134
+ await rmdir(base);
7135
+ pruned.push(finding);
7136
+ } catch {
7137
+ }
7138
+ continue;
7139
+ }
7140
+ try {
7141
+ await addStoreProject(finding.store_dir, {
7142
+ id: finding.project_id,
7143
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
7144
+ });
7145
+ registered.push(finding);
7146
+ } catch {
7147
+ }
7148
+ }
7149
+ return { registered, pruned };
7150
+ }
7151
+ function createProjectRegistryDriftCheck(t, inspection) {
7152
+ const { findings } = inspection;
7153
+ if (findings.length === 0) {
7154
+ return {
7155
+ name: t("doctor.check.project_registry_drift.name"),
7156
+ status: "ok",
7157
+ message: t("doctor.check.project_registry_drift.ok")
7158
+ };
7159
+ }
7160
+ const unregistered = findings.filter((f) => f.kind === "unregistered_write").length;
7161
+ const orphans = findings.filter((f) => f.kind === "orphan_folder").length;
7162
+ const empties = findings.filter((f) => f.kind === "empty_folder").length;
7163
+ const breakdown = [
7164
+ unregistered > 0 ? `${unregistered} unregistered-write` : null,
7165
+ orphans > 0 ? `${orphans} orphan-folder` : null,
7166
+ empties > 0 ? `${empties} empty-folder` : null
7167
+ ].filter((part) => part !== null).join(", ");
7168
+ if (unregistered > 0) {
7169
+ const first2 = findings.find((f) => f.kind === "unregistered_write");
7170
+ return {
7171
+ name: t("doctor.check.project_registry_drift.name"),
7172
+ status: "error",
7173
+ kind: "manual_error",
7174
+ code: "project_registry_drift",
7175
+ fixable: true,
7176
+ message: t("doctor.check.project_registry_drift.message.unregistered", {
7177
+ total: String(findings.length),
7178
+ breakdown,
7179
+ projectId: first2.project_id,
7180
+ storeAlias: first2.store_alias
7181
+ }),
7182
+ actionHint: t("doctor.check.project_registry_drift.remediation")
7183
+ };
7184
+ }
7185
+ if (orphans > 0) {
7186
+ const first2 = findings.find((f) => f.kind === "orphan_folder");
7187
+ return {
7188
+ name: t("doctor.check.project_registry_drift.name"),
7189
+ status: "warn",
7190
+ kind: "warning",
7191
+ code: "project_registry_drift",
7192
+ fixable: true,
7193
+ message: t("doctor.check.project_registry_drift.message.orphan", {
7194
+ total: String(findings.length),
7195
+ breakdown,
7196
+ projectId: first2.project_id,
7197
+ storeAlias: first2.store_alias
7198
+ }),
7199
+ actionHint: t("doctor.check.project_registry_drift.remediation")
7200
+ };
7201
+ }
7202
+ const first = findings.find((f) => f.kind === "empty_folder");
7203
+ return {
7204
+ name: t("doctor.check.project_registry_drift.name"),
7205
+ status: "warn",
7206
+ kind: "info",
7207
+ code: "project_registry_drift",
7208
+ fixable: true,
7209
+ message: t("doctor.check.project_registry_drift.message.empty", {
7210
+ total: String(findings.length),
7211
+ breakdown,
7212
+ projectId: first.project_id,
7213
+ storeAlias: first.store_alias
7214
+ }),
7215
+ actionHint: t("doctor.check.project_registry_drift.remediation")
7216
+ };
7217
+ }
7218
+
6270
7219
  // src/services/legacy-serve-lock-probe.ts
6271
7220
  import {
6272
7221
  isAlive,
@@ -6339,12 +7288,37 @@ async function inspectEventsJsonlGates(projectRoot, options = {}) {
6339
7288
  }
6340
7289
 
6341
7290
  // src/services/doctor-skill-lints.ts
6342
- import { readdir as readdir3, readFile as readFile10 } from "fs/promises";
6343
- import { join as join17, posix as posix2 } from "path";
7291
+ import { readdir as readdir4, readFile as readFile13 } from "fs/promises";
7292
+ import { join as join20, posix as posix2 } from "path";
6344
7293
  var FABRIC_SKILL_SLUGS = ["fabric-archive", "fabric-review"];
7294
+ var FABRIC_CONTRACT_SKILL_SLUGS = ["fabric-archive", "fabric-review", "fabric-store", "fabric-sync"];
6345
7295
  var SKILL_MD_FRONTMATTER_ROOTS = [".claude/skills", ".codex/skills"];
6346
7296
  var SKILL_FRONTMATTER_KEY_PATTERN = /^([A-Za-z_][A-Za-z0-9_-]*):[ \t]+(.+?)[ \t]*$/u;
6347
7297
  var SKILL_QUOTED_VALUE_LEADS = /* @__PURE__ */ new Set(['"', "'", "[", "{", ">", "|"]);
7298
+ var SKILL_CONTRACT_TOKENS = {
7299
+ "fabric-archive": [
7300
+ "## Hard Rules",
7301
+ "### DISPLAY Rules",
7302
+ "### WRITE Rules",
7303
+ "mcp__fabric__fab_propose",
7304
+ "only legal write path",
7305
+ "reached-but-inert"
7306
+ ],
7307
+ "fabric-review": [
7308
+ "## Hard Rules",
7309
+ "### DISPLAY Rules",
7310
+ "### WRITE Rules",
7311
+ "mcp__fabric__fab_review",
7312
+ "only legal mutation path",
7313
+ "reached-but-inert",
7314
+ "changes next action",
7315
+ "must_read_if",
7316
+ "intent_clues",
7317
+ "impact"
7318
+ ],
7319
+ "fabric-store": ["thin shim", "CLI", "\u672C skill \u53EA", "NEVER"],
7320
+ "fabric-sync": ["thin shim", "CLI", "\u672C skill \u53EA", "NEVER"]
7321
+ };
6348
7322
  function okCheck(name, message) {
6349
7323
  return { name, status: "ok", message };
6350
7324
  }
@@ -6362,7 +7336,7 @@ function issueCheck(name, status, kind, code, message, actionHint, audience) {
6362
7336
  }
6363
7337
  async function listMarkdownFiles(dir) {
6364
7338
  try {
6365
- return (await readdir3(dir)).filter((name) => name.endsWith(".md"));
7339
+ return (await readdir4(dir)).filter((name) => name.endsWith(".md"));
6366
7340
  } catch {
6367
7341
  return null;
6368
7342
  }
@@ -6370,8 +7344,8 @@ async function listMarkdownFiles(dir) {
6370
7344
  async function inspectSkillRefMirror(projectRoot) {
6371
7345
  const driftedPaths = [];
6372
7346
  for (const slug of FABRIC_SKILL_SLUGS) {
6373
- const claudeRef = join17(projectRoot, ".claude", "skills", slug, "ref");
6374
- const codexRef = join17(projectRoot, ".codex", "skills", slug, "ref");
7347
+ const claudeRef = join20(projectRoot, ".claude", "skills", slug, "ref");
7348
+ const codexRef = join20(projectRoot, ".codex", "skills", slug, "ref");
6375
7349
  const [claudeFiles, codexFiles] = await Promise.all([listMarkdownFiles(claudeRef), listMarkdownFiles(codexRef)]);
6376
7350
  if (claudeFiles === null || codexFiles === null) continue;
6377
7351
  const claudeSet = new Set(claudeFiles);
@@ -6388,8 +7362,8 @@ async function inspectSkillRefMirror(projectRoot) {
6388
7362
  let codexBody;
6389
7363
  try {
6390
7364
  [claudeBody, codexBody] = await Promise.all([
6391
- readFile10(join17(claudeRef, fname), "utf8"),
6392
- readFile10(join17(codexRef, fname), "utf8")
7365
+ readFile13(join20(claudeRef, fname), "utf8"),
7366
+ readFile13(join20(codexRef, fname), "utf8")
6393
7367
  ]);
6394
7368
  } catch {
6395
7369
  continue;
@@ -6408,10 +7382,10 @@ async function inspectSkillTokenBudget(projectRoot) {
6408
7382
  const overSize = [];
6409
7383
  let highestSeverity = "ok";
6410
7384
  for (const slug of FABRIC_SKILL_SLUGS) {
6411
- const skillMdPath = join17(projectRoot, ".claude", "skills", slug, "SKILL.md");
7385
+ const skillMdPath = join20(projectRoot, ".claude", "skills", slug, "SKILL.md");
6412
7386
  let body;
6413
7387
  try {
6414
- body = await readFile10(skillMdPath, "utf8");
7388
+ body = await readFile13(skillMdPath, "utf8");
6415
7389
  } catch {
6416
7390
  continue;
6417
7391
  }
@@ -6431,11 +7405,12 @@ async function inspectSkillDescription(projectRoot) {
6431
7405
  const issues = [];
6432
7406
  const CJK_PATTERN = /[\u3400-\u4dbf\u4e00-\u9fff]/u;
6433
7407
  const ASCII_PATTERN = /[a-zA-Z]{2,}/u;
7408
+ const ANTI_TRIGGER_PATTERN = /\bNOT\b|ONLY when|do not|不要|不是|非/u;
6434
7409
  for (const slug of FABRIC_SKILL_SLUGS) {
6435
- const skillMdPath = join17(projectRoot, ".claude", "skills", slug, "SKILL.md");
7410
+ const skillMdPath = join20(projectRoot, ".claude", "skills", slug, "SKILL.md");
6436
7411
  let body;
6437
7412
  try {
6438
- body = await readFile10(skillMdPath, "utf8");
7413
+ body = await readFile13(skillMdPath, "utf8");
6439
7414
  } catch {
6440
7415
  continue;
6441
7416
  }
@@ -6460,25 +7435,75 @@ async function inspectSkillDescription(projectRoot) {
6460
7435
  if (!ASCII_PATTERN.test(description)) {
6461
7436
  issues.push({ slug, problem: "no_ascii", detail: "no English trigger phrase" });
6462
7437
  }
7438
+ if (!ANTI_TRIGGER_PATTERN.test(description)) {
7439
+ issues.push({
7440
+ slug,
7441
+ problem: "missing_anti_trigger",
7442
+ detail: "no explicit non-trigger phrase such as NOT/\u4E0D\u662F/\u4E0D\u8981"
7443
+ });
7444
+ }
7445
+ }
7446
+ return { status: issues.length === 0 ? "ok" : "warn", issues };
7447
+ }
7448
+ async function inspectSkillContract(projectRoot) {
7449
+ const issues = [];
7450
+ for (const rootRel of SKILL_MD_FRONTMATTER_ROOTS) {
7451
+ const client = rootRel.startsWith(".claude") ? ".claude" : ".codex";
7452
+ for (const slug of FABRIC_CONTRACT_SKILL_SLUGS) {
7453
+ const skillDir = join20(projectRoot, rootRel, slug);
7454
+ const skillMdPath = join20(skillDir, "SKILL.md");
7455
+ let body;
7456
+ try {
7457
+ body = await readFile13(skillMdPath, "utf8");
7458
+ } catch {
7459
+ continue;
7460
+ }
7461
+ for (const token of SKILL_CONTRACT_TOKENS[slug]) {
7462
+ if (body.includes(token)) continue;
7463
+ issues.push({
7464
+ slug,
7465
+ client,
7466
+ problem: slug === "fabric-store" || slug === "fabric-sync" ? "missing_thin_shim_token" : "missing_contract_token",
7467
+ detail: token
7468
+ });
7469
+ }
7470
+ const refDir = join20(skillDir, "ref");
7471
+ let refFiles;
7472
+ try {
7473
+ refFiles = (await readdir4(refDir)).filter((name) => name.endsWith(".md"));
7474
+ } catch {
7475
+ refFiles = null;
7476
+ }
7477
+ if (refFiles === null) continue;
7478
+ for (const fname of refFiles) {
7479
+ if (body.includes(fname) || body.includes(`ref/${fname}`)) continue;
7480
+ issues.push({
7481
+ slug,
7482
+ client,
7483
+ problem: "missing_ref_entry",
7484
+ detail: fname
7485
+ });
7486
+ }
7487
+ }
6463
7488
  }
6464
7489
  return { status: issues.length === 0 ? "ok" : "warn", issues };
6465
7490
  }
6466
7491
  async function inspectSkillMdYamlInvalid(projectRoot) {
6467
7492
  const candidates = [];
6468
7493
  for (const rootRel of SKILL_MD_FRONTMATTER_ROOTS) {
6469
- const rootAbs = join17(projectRoot, rootRel);
7494
+ const rootAbs = join20(projectRoot, rootRel);
6470
7495
  let dirEntries;
6471
7496
  try {
6472
- dirEntries = await readdir3(rootAbs, { withFileTypes: true });
7497
+ dirEntries = await readdir4(rootAbs, { withFileTypes: true });
6473
7498
  } catch {
6474
7499
  continue;
6475
7500
  }
6476
7501
  for (const dirEntry of dirEntries) {
6477
7502
  if (!dirEntry.isDirectory()) continue;
6478
- const skillFile = join17(rootAbs, dirEntry.name, "SKILL.md");
7503
+ const skillFile = join20(rootAbs, dirEntry.name, "SKILL.md");
6479
7504
  let raw;
6480
7505
  try {
6481
- raw = await readFile10(skillFile, "utf8");
7506
+ raw = await readFile13(skillFile, "utf8");
6482
7507
  } catch {
6483
7508
  continue;
6484
7509
  }
@@ -6580,6 +7605,25 @@ function createSkillDescriptionCheck(t, inspection) {
6580
7605
  "maintainer"
6581
7606
  );
6582
7607
  }
7608
+ function createSkillContractCheck(t, inspection) {
7609
+ if (inspection.status === "ok") {
7610
+ return okCheck(t("doctor.check.skill_contract.name"), t("doctor.check.skill_contract.ok"));
7611
+ }
7612
+ const list = inspection.issues.map((issue) => `${issue.client}/${issue.slug}: ${issue.problem} (${issue.detail})`).join("; ");
7613
+ const count = inspection.issues.length;
7614
+ return issueCheck(
7615
+ t("doctor.check.skill_contract.name"),
7616
+ "warn",
7617
+ "warning",
7618
+ "skill_contract_integrity",
7619
+ t(`doctor.check.skill_contract.message.${count === 1 ? "singular" : "plural"}`, {
7620
+ count: String(count),
7621
+ list
7622
+ }),
7623
+ t("doctor.check.skill_contract.remediation"),
7624
+ "maintainer"
7625
+ );
7626
+ }
6583
7627
  function createSkillMdYamlInvalidCheck(t, inspection) {
6584
7628
  if (inspection.candidates.length === 0) {
6585
7629
  return okCheck(t("doctor.check.skill_md_yaml_invalid.name"), t("doctor.check.skill_md_yaml_invalid.ok"));
@@ -6601,8 +7645,8 @@ function createSkillMdYamlInvalidCheck(t, inspection) {
6601
7645
  }
6602
7646
 
6603
7647
  // src/services/doctor-retired-references-lint.ts
6604
- import { readdir as readdir4, readFile as readFile11, stat as stat3 } from "fs/promises";
6605
- import { join as join18, posix as posix3, relative as relative2 } from "path";
7648
+ import { readdir as readdir5, readFile as readFile14, stat as stat3 } from "fs/promises";
7649
+ import { join as join21, posix as posix3, relative as relative2 } from "path";
6606
7650
  var RETIRED_TOKENS = [
6607
7651
  { token: "fab_plan_context", replacement: "fab_recall", reason: "retrieval collapsed to one lean fab_recall (KT-DEC-0026)" },
6608
7652
  { token: "fab_get_knowledge_sections", replacement: "fab_recall", reason: "two-step fetch retired (KT-DEC-0026)" },
@@ -6625,7 +7669,7 @@ var RETIRED_TOKENS = [
6625
7669
  ];
6626
7670
  var HOOK_DIRS = [".claude/hooks", ".codex/hooks"];
6627
7671
  var SKILL_DIRS = [".claude/skills", ".codex/skills"];
6628
- var BOOTSTRAP_FILES = ["AGENTS.md", "CLAUDE.md", join18(".fabric", "AGENTS.md")];
7672
+ var BOOTSTRAP_FILES = ["AGENTS.md", "CLAUDE.md", join21(".fabric", "AGENTS.md")];
6629
7673
  function isCommentLine(line) {
6630
7674
  const t = line.trim();
6631
7675
  return t.startsWith("//") || t.startsWith("*") || t.startsWith("/*");
@@ -6633,13 +7677,13 @@ function isCommentLine(line) {
6633
7677
  async function walkFiles(dir, exts) {
6634
7678
  let entries;
6635
7679
  try {
6636
- entries = await readdir4(dir, { withFileTypes: true });
7680
+ entries = await readdir5(dir, { withFileTypes: true });
6637
7681
  } catch {
6638
7682
  return [];
6639
7683
  }
6640
7684
  const out = [];
6641
7685
  for (const e of entries) {
6642
- const full = join18(dir, e.name);
7686
+ const full = join21(dir, e.name);
6643
7687
  if (e.isDirectory()) {
6644
7688
  out.push(...await walkFiles(full, exts));
6645
7689
  } else if (exts.some((ext) => e.name.endsWith(ext))) {
@@ -6665,29 +7709,29 @@ async function inspectRetiredReferences(projectRoot) {
6665
7709
  let scannedFiles = 0;
6666
7710
  const toRel = (p) => posix3.normalize(relative2(projectRoot, p).split("\\").join("/"));
6667
7711
  for (const rel of BOOTSTRAP_FILES) {
6668
- const abs = join18(projectRoot, rel);
7712
+ const abs = join21(projectRoot, rel);
6669
7713
  try {
6670
7714
  if ((await stat3(abs)).isFile()) {
6671
7715
  scannedFiles += 1;
6672
- scanText(toRel(abs), await readFile11(abs, "utf8"), false, hits);
7716
+ scanText(toRel(abs), await readFile14(abs, "utf8"), false, hits);
6673
7717
  }
6674
7718
  } catch {
6675
7719
  }
6676
7720
  }
6677
7721
  for (const dir of SKILL_DIRS) {
6678
- for (const file of await walkFiles(join18(projectRoot, dir), [".md"])) {
7722
+ for (const file of await walkFiles(join21(projectRoot, dir), [".md"])) {
6679
7723
  scannedFiles += 1;
6680
7724
  try {
6681
- scanText(toRel(file), await readFile11(file, "utf8"), false, hits);
7725
+ scanText(toRel(file), await readFile14(file, "utf8"), false, hits);
6682
7726
  } catch {
6683
7727
  }
6684
7728
  }
6685
7729
  }
6686
7730
  for (const dir of HOOK_DIRS) {
6687
- for (const file of await walkFiles(join18(projectRoot, dir), [".cjs"])) {
7731
+ for (const file of await walkFiles(join21(projectRoot, dir), [".cjs"])) {
6688
7732
  scannedFiles += 1;
6689
7733
  try {
6690
- scanText(toRel(file), await readFile11(file, "utf8"), true, hits);
7734
+ scanText(toRel(file), await readFile14(file, "utf8"), true, hits);
6691
7735
  } catch {
6692
7736
  }
6693
7737
  }
@@ -6722,8 +7766,8 @@ function createRetiredReferenceCheck(t, inspection) {
6722
7766
 
6723
7767
  // src/services/doctor-hooks-lints.ts
6724
7768
  import { constants } from "fs";
6725
- import { access, readdir as readdir5, readFile as readFile12, stat as stat4 } from "fs/promises";
6726
- import { join as join19, posix as posix4 } from "path";
7769
+ import { access, readdir as readdir6, readFile as readFile15, stat as stat4 } from "fs/promises";
7770
+ import { join as join22, posix as posix4 } from "path";
6727
7771
  import { Script } from "vm";
6728
7772
  var HOOKS_RUNTIME_CLIENT_DIRS = [
6729
7773
  { client: "claude", dir: ".claude/hooks" },
@@ -6772,7 +7816,7 @@ function isHookWiredForEvent(hooks, event, hookFile) {
6772
7816
  }
6773
7817
  async function readDirectoryFileNames(dir) {
6774
7818
  try {
6775
- return await readdir5(dir);
7819
+ return await readdir6(dir);
6776
7820
  } catch {
6777
7821
  return null;
6778
7822
  }
@@ -6785,21 +7829,27 @@ async function isFile(absPath) {
6785
7829
  }
6786
7830
  }
6787
7831
  async function inspectHooksWired(projectRoot) {
6788
- const claudeEntries = await readDirectoryFileNames(join19(projectRoot, ".claude"));
7832
+ const claudeEntries = await readDirectoryFileNames(join22(projectRoot, ".claude"));
6789
7833
  if (claudeEntries === null) {
6790
7834
  return { status: "skipped", missingHooks: [] };
6791
7835
  }
6792
- const settingsPath = join19(projectRoot, ".claude", "settings.json");
7836
+ const settingsPath = join22(projectRoot, ".claude", "settings.json");
6793
7837
  let parsed;
6794
7838
  try {
6795
- parsed = JSON.parse(await readFile12(settingsPath, "utf8"));
7839
+ parsed = JSON.parse(await readFile15(settingsPath, "utf8"));
6796
7840
  } catch {
6797
7841
  return { status: "missing-settings", missingHooks: [] };
6798
7842
  }
6799
7843
  const required = [
6800
7844
  { event: "Stop", hookFile: "fabric-hint.cjs" },
6801
7845
  { event: "SessionStart", hookFile: "knowledge-hint-broad.cjs" },
6802
- { event: "PreToolUse", hookFile: "knowledge-hint-narrow.cjs" }
7846
+ // ux-w2-6 (rc.9): PreToolUse is the SINGLE merged orchestrator
7847
+ // knowledge-pretooluse.cjs which internally requires knowledge-hint-narrow.cjs
7848
+ // (and cite-policy-evict.cjs) so a single edit produces ONE additionalContext
7849
+ // envelope instead of two. The stale reference to narrow.cjs here was the
7850
+ // rc.30 BUG-M3 false-negative source — narrow.cjs still exists on disk as a
7851
+ // lib but is NOT registered as a PreToolUse hook.
7852
+ { event: "PreToolUse", hookFile: "knowledge-pretooluse.cjs" }
6803
7853
  ];
6804
7854
  const missing = [];
6805
7855
  const hooksSection = isRecord(parsed) ? parsed.hooks : void 0;
@@ -6815,8 +7865,8 @@ async function inspectHooksWired(projectRoot) {
6815
7865
  }
6816
7866
  async function inspectHookCacheWritability(projectRoot) {
6817
7867
  const relPath = posix4.join(".fabric", ".cache");
6818
- const fabricDir = join19(projectRoot, ".fabric");
6819
- const cacheDir = join19(projectRoot, ".fabric", ".cache");
7868
+ const fabricDir = join22(projectRoot, ".fabric");
7869
+ const cacheDir = join22(projectRoot, ".fabric", ".cache");
6820
7870
  try {
6821
7871
  try {
6822
7872
  const cacheStats = await stat4(cacheDir);
@@ -6865,12 +7915,12 @@ async function inspectHookCacheWritability(projectRoot) {
6865
7915
  async function inspectHooksContentDrift(projectRoot) {
6866
7916
  const hookFilesByBasename = /* @__PURE__ */ new Map();
6867
7917
  for (const { client, dir } of HOOKS_RUNTIME_CLIENT_DIRS) {
6868
- const absDir = join19(projectRoot, dir);
7918
+ const absDir = join22(projectRoot, dir);
6869
7919
  const entries = await readDirectoryFileNames(absDir);
6870
7920
  if (entries === null) continue;
6871
7921
  for (const name of entries) {
6872
7922
  if (!name.endsWith(".cjs")) continue;
6873
- const abs = join19(absDir, name);
7923
+ const abs = join22(absDir, name);
6874
7924
  if (!await isFile(abs)) continue;
6875
7925
  const arr = hookFilesByBasename.get(name) ?? [];
6876
7926
  arr.push({ client, abs });
@@ -6885,7 +7935,7 @@ async function inspectHooksContentDrift(projectRoot) {
6885
7935
  const hashes = [];
6886
7936
  for (const { client, abs } of copies) {
6887
7937
  try {
6888
- const body = await readFile12(abs, "utf8");
7938
+ const body = await readFile15(abs, "utf8");
6889
7939
  hashes.push({ client, sha: sha256(body) });
6890
7940
  } catch {
6891
7941
  }
@@ -6907,18 +7957,18 @@ async function inspectHooksRuntime(projectRoot) {
6907
7957
  const issues = [];
6908
7958
  let scanned = 0;
6909
7959
  for (const { client, dir } of HOOKS_RUNTIME_CLIENT_DIRS) {
6910
- const absDir = join19(projectRoot, dir);
7960
+ const absDir = join22(projectRoot, dir);
6911
7961
  const entries = await readDirectoryFileNames(absDir);
6912
7962
  if (entries === null) continue;
6913
7963
  for (const name of entries) {
6914
7964
  if (!name.endsWith(".cjs")) continue;
6915
- const abs = join19(absDir, name);
7965
+ const abs = join22(absDir, name);
6916
7966
  const displayPath = `${dir}/${name}`;
6917
7967
  if (!await isFile(abs)) continue;
6918
7968
  scanned += 1;
6919
7969
  let body;
6920
7970
  try {
6921
- body = await readFile12(abs, "utf8");
7971
+ body = await readFile15(abs, "utf8");
6922
7972
  } catch (err) {
6923
7973
  issues.push({
6924
7974
  path: displayPath,
@@ -7055,8 +8105,8 @@ function createHookCacheWritabilityCheck(t, inspection) {
7055
8105
  }
7056
8106
 
7057
8107
  // src/services/doctor-bootstrap-lints.ts
7058
- import { access as access2, readFile as readFile13 } from "fs/promises";
7059
- import { join as join20 } from "path";
8108
+ import { access as access2, readFile as readFile16 } from "fs/promises";
8109
+ import { join as join23 } from "path";
7060
8110
  import {
7061
8111
  BOOTSTRAP_MARKER_BEGIN,
7062
8112
  BOOTSTRAP_MARKER_END,
@@ -7088,17 +8138,17 @@ async function fileExists(path) {
7088
8138
  }
7089
8139
  async function inspectBootstrapAnchor(projectRoot) {
7090
8140
  const [hasAgentsMd, hasClaudeMd] = await Promise.all([
7091
- fileExists(join20(projectRoot, "AGENTS.md")),
7092
- fileExists(join20(projectRoot, "CLAUDE.md"))
8141
+ fileExists(join23(projectRoot, "AGENTS.md")),
8142
+ fileExists(join23(projectRoot, "CLAUDE.md"))
7093
8143
  ]);
7094
8144
  return { hasAgentsMd, hasClaudeMd };
7095
8145
  }
7096
8146
  async function inspectL1BootstrapSnapshotDrift(target) {
7097
- const abs = join20(target, ".fabric", "AGENTS.md");
8147
+ const abs = join23(target, ".fabric", "AGENTS.md");
7098
8148
  const canonical = resolveBootstrapCanonical();
7099
8149
  let onDisk;
7100
8150
  try {
7101
- onDisk = await readFile13(abs, "utf8");
8151
+ onDisk = await readFile16(abs, "utf8");
7102
8152
  } catch {
7103
8153
  return { status: "missing", canonical, onDisk: null };
7104
8154
  }
@@ -7124,17 +8174,17 @@ function createL1BootstrapSnapshotDriftCheck(t, inspection) {
7124
8174
  );
7125
8175
  }
7126
8176
  async function inspectL2ManagedBlockDrift(target) {
7127
- const snapshotPath = join20(target, ".fabric", "AGENTS.md");
8177
+ const snapshotPath = join23(target, ".fabric", "AGENTS.md");
7128
8178
  let snapshot;
7129
8179
  try {
7130
- snapshot = await readFile13(snapshotPath, "utf8");
8180
+ snapshot = await readFile16(snapshotPath, "utf8");
7131
8181
  } catch {
7132
8182
  return { status: "ok", drifted: [] };
7133
8183
  }
7134
- const projectRulesPath = join20(target, ".fabric", "project-rules.md");
8184
+ const projectRulesPath = join23(target, ".fabric", "project-rules.md");
7135
8185
  let expectedBody = snapshot;
7136
8186
  try {
7137
- const projectRules = await readFile13(projectRulesPath, "utf8");
8187
+ const projectRules = await readFile16(projectRulesPath, "utf8");
7138
8188
  expectedBody = `${snapshot}
7139
8189
  ---
7140
8190
  ${projectRules}`;
@@ -7143,12 +8193,12 @@ ${projectRules}`;
7143
8193
  const drifted = [];
7144
8194
  let anyManagedBlockFound = false;
7145
8195
  const blockTargets = [
7146
- join20(target, "AGENTS.md")
8196
+ join23(target, "AGENTS.md")
7147
8197
  ];
7148
8198
  for (const abs of blockTargets) {
7149
8199
  let content;
7150
8200
  try {
7151
- content = await readFile13(abs, "utf8");
8201
+ content = await readFile16(abs, "utf8");
7152
8202
  } catch {
7153
8203
  continue;
7154
8204
  }
@@ -7171,9 +8221,9 @@ ${projectRules}`;
7171
8221
  drifted.push({ path: abs, expected: expectedBody, actual: body });
7172
8222
  }
7173
8223
  }
7174
- const claudeMdPath = join20(target, "CLAUDE.md");
8224
+ const claudeMdPath = join23(target, "CLAUDE.md");
7175
8225
  try {
7176
- const claudeContent = await readFile13(claudeMdPath, "utf8");
8226
+ const claudeContent = await readFile16(claudeMdPath, "utf8");
7177
8227
  anyManagedBlockFound = true;
7178
8228
  const lines = claudeContent.split(/\r?\n/u);
7179
8229
  const hasAtImport = lines.some((line) => line.trim() === "@.fabric/AGENTS.md");
@@ -7241,7 +8291,7 @@ import { appendFile as appendFile3 } from "fs/promises";
7241
8291
  import { minimatch as minimatch2 } from "minimatch";
7242
8292
 
7243
8293
  // src/services/cite-rollup.ts
7244
- import { readFile as readFile14 } from "fs/promises";
8294
+ import { readFile as readFile17 } from "fs/promises";
7245
8295
  import { createLedgerWriteQueue as createLedgerWriteQueue3 } from "@fenglimg/fabric-shared/node/atomic-write";
7246
8296
  var citeRollupQueue = createLedgerWriteQueue3();
7247
8297
  async function appendCiteRollupRow(projectRoot, row) {
@@ -7253,7 +8303,7 @@ async function readCiteRollup(projectRoot) {
7253
8303
  const path = getCiteRollupPath(projectRoot);
7254
8304
  let raw;
7255
8305
  try {
7256
- raw = await readFile14(path, "utf8");
8306
+ raw = await readFile17(path, "utf8");
7257
8307
  } catch (error) {
7258
8308
  if (isNodeError(error) && error.code === "ENOENT") return [];
7259
8309
  throw error;
@@ -7844,6 +8894,18 @@ async function runDoctorCiteCoverage(projectRoot, options) {
7844
8894
  }
7845
8895
  }
7846
8896
  }
8897
+ const editSessionIds = /* @__PURE__ */ new Set();
8898
+ for (const edit of editEvents) {
8899
+ if (typeof edit.session_id === "string" && edit.session_id.length > 0) {
8900
+ editSessionIds.add(edit.session_id);
8901
+ }
8902
+ }
8903
+ let recallsInWindow = 0;
8904
+ for (const list of plannedBySession.values()) recallsInWindow += list.length;
8905
+ let recallSessionsCorrelated = 0;
8906
+ for (const sid of plannedBySession.keys()) {
8907
+ if (editSessionIds.has(sid)) recallSessionsCorrelated += 1;
8908
+ }
7847
8909
  const noneTotal = Object.values(noneHistogram).reduce((a, b) => a + b, 0);
7848
8910
  const compliantCites = qualifyingCites + noneTotal;
7849
8911
  const noncompliantCites = expectedButMissed;
@@ -7911,6 +8973,11 @@ async function runDoctorCiteCoverage(projectRoot, options) {
7911
8973
  uncorrelatable_edits: uncorrelatableEdits,
7912
8974
  recall_backed_edits: recallBackedEdits,
7913
8975
  recall_coverage_rate: recallCoverageRate,
8976
+ recall_diagnostics: {
8977
+ recalls_in_window: recallsInWindow,
8978
+ recall_sessions: plannedBySession.size,
8979
+ recall_sessions_correlated: recallSessionsCorrelated
8980
+ },
7914
8981
  exposed_and_mutated: {
7915
8982
  count: exposedAndMutated.count,
7916
8983
  ...exposedAndMutated.ids.length > 0 ? { ids: exposedAndMutated.ids } : {}
@@ -8316,6 +9383,7 @@ async function runDoctorReport(target) {
8316
9383
  skillRefMirror,
8317
9384
  skillTokenBudget,
8318
9385
  skillDescription,
9386
+ skillContract,
8319
9387
  retiredReferences
8320
9388
  ] = await Promise.all([
8321
9389
  inspectForensic(projectRoot),
@@ -8337,6 +9405,7 @@ async function runDoctorReport(target) {
8337
9405
  inspectSkillRefMirror(projectRoot),
8338
9406
  inspectSkillTokenBudget(projectRoot),
8339
9407
  inspectSkillDescription(projectRoot),
9408
+ inspectSkillContract(projectRoot),
8340
9409
  // ux-w2-2: registry-driven stale-pointer scan over the agent-consumed
8341
9410
  // surface (bootstrap + SKILL.md + installed hooks).
8342
9411
  inspectRetiredReferences(projectRoot)
@@ -8354,6 +9423,7 @@ async function runDoctorReport(target) {
8354
9423
  const driftUnconsumed = await inspectDriftUnconsumed(projectRoot);
8355
9424
  const storeCounterDrift = inspectStoreCounters(projectRoot);
8356
9425
  const storeOrphans = inspectStoreOrphans();
9426
+ const projectRegistryDrift = await inspectProjectRegistryDrift(projectRoot);
8357
9427
  const stableIdIntegrity = await inspectStoreStableIdIntegrity(projectRoot);
8358
9428
  const relevancePaths = await inspectStoreRelevancePaths(projectRoot);
8359
9429
  const broadIndexDrift = await inspectBroadIndexDrift(projectRoot);
@@ -8383,7 +9453,7 @@ async function runDoctorReport(target) {
8383
9453
  const globalCliVersion = process.env.VITEST === "true" ? { status: "ok", version: "test-skipped" } : inspectGlobalCliVersion();
8384
9454
  const targetFiles = Object.fromEntries(
8385
9455
  await Promise.all(
8386
- TARGET_FILE_PATHS.map(async (path) => [path, await pathExists(join21(projectRoot, path))])
9456
+ TARGET_FILE_PATHS.map(async (path) => [path, await pathExists(join24(projectRoot, path))])
8387
9457
  )
8388
9458
  );
8389
9459
  const checks = [
@@ -8422,6 +9492,7 @@ async function runDoctorReport(target) {
8422
9492
  createSkillRefMirrorCheck(t, skillRefMirror),
8423
9493
  createSkillTokenBudgetCheck(t, skillTokenBudget),
8424
9494
  createSkillDescriptionCheck(t, skillDescription),
9495
+ createSkillContractCheck(t, skillContract),
8425
9496
  // ux-w2-2: retired-reference (stale-pointer) lint — registry-driven.
8426
9497
  createRetiredReferenceCheck(t, retiredReferences),
8427
9498
  createCiteGoodhartCheck(t, citeGoodhart),
@@ -8437,6 +9508,12 @@ async function runDoctorReport(target) {
8437
9508
  // (warning). `--fix` adopts it (rescue-before-delete — re-register, never
8438
9509
  // an on-disk delete).
8439
9510
  createStoreOrphanCheck(t, storeOrphans),
9511
+ // W2 (F-003): project-registry drift over projects.json ↔ projects/ folder
9512
+ // tree. unregistered-write (data unrouted) → manual_error; orphan-folder →
9513
+ // warning; empty-folder → info; ghost-registration emits nothing (DA-05).
9514
+ // `--fix` rescue-registers orphans/unregistered (addStoreProject — never a
9515
+ // non-empty delete) and prunes only genuinely-empty registered folders.
9516
+ createProjectRegistryDriftCheck(t, projectRegistryDrift),
8440
9517
  // rc.5 TASK-010: read-side underseeded-corpus check (#22). Info kind —
8441
9518
  // does not bump report status. Recommends running the fabric-import skill
8442
9519
  // to backfill knowledge when the corpus is below the threshold floor.
@@ -8512,6 +9589,14 @@ async function runDoctorReport(target) {
8512
9589
  // fresh-install hole is sealed in store.stage.ts; this covers existing repos
8513
9590
  // (CLI `--fix` backfills via ensureStoreProjectBinding).
8514
9591
  createUnboundProjectCheck(t, detectUnboundProject(projectRoot)),
9592
+ // write_route_target_unbound — every write_routes[i].store must resolve
9593
+ // against required_stores. Catches "route survived migration to single
9594
+ // team slot" (werewolf-minigame case): route says `team → cocos` but
9595
+ // required_stores only holds `team`, so fab_propose(audience:"team")
9596
+ // would report "no write-target store resolved" at runtime. Static
9597
+ // config-level check, no resolver, aligned with KT-DEC-0048 read-tolerant
9598
+ // doctrine.
9599
+ createWriteRouteUnboundCheck(t, detectWriteRouteUnbound(projectRoot)),
8515
9600
  // rc.31 BUG-G2/G5: promote-ledger invariant. Sits adjacent to onboard
8516
9601
  // coverage — both are observability advisories built off events.jsonl.
8517
9602
  ...promoteLedgerInvariant === null ? [] : [createPromoteLedgerInvariantCheck(t, promoteLedgerInvariant)],
@@ -8587,7 +9672,7 @@ async function runDoctorFix(target) {
8587
9672
  const fixed = [];
8588
9673
  const ledgerWarnings = [];
8589
9674
  if (before.fixable_errors.some((issue) => issue.code === "bootstrap_snapshot_drift")) {
8590
- const snapshotPath = join21(projectRoot, ".fabric", "AGENTS.md");
9675
+ const snapshotPath = join24(projectRoot, ".fabric", "AGENTS.md");
8591
9676
  await ensureParentDirectory(snapshotPath);
8592
9677
  await atomicWriteText4(snapshotPath, resolveBootstrapCanonical2());
8593
9678
  fixed.push(findIssue(before.fixable_errors, "bootstrap_snapshot_drift"));
@@ -8611,6 +9696,18 @@ async function runDoctorFix(target) {
8611
9696
  fixed.push(findIssue(before.warnings, "store_orphan"));
8612
9697
  }
8613
9698
  }
9699
+ const registryDriftFound = before.manual_errors.some((issue) => issue.code === "project_registry_drift") || before.warnings.some((issue) => issue.code === "project_registry_drift") || before.infos.some((issue) => issue.code === "project_registry_drift");
9700
+ if (registryDriftFound) {
9701
+ const result = await fixProjectRegistryDrift(projectRoot);
9702
+ if (result.registered.length > 0 || result.pruned.length > 0) {
9703
+ fixed.push(
9704
+ findIssue(
9705
+ [...before.manual_errors, ...before.warnings, ...before.infos],
9706
+ "project_registry_drift"
9707
+ )
9708
+ );
9709
+ }
9710
+ }
8614
9711
  if (before.fixable_errors.some((issue) => issue.code === "event_ledger_partial_write")) {
8615
9712
  const ledgerPath = getEventLedgerPath(projectRoot);
8616
9713
  const truncResult = await truncateLedgerToLastNewline(ledgerPath);
@@ -8660,7 +9757,7 @@ async function runDoctorFix(target) {
8660
9757
  if (before.infos.some((issue) => issue.code === "stale_serve_lock")) {
8661
9758
  const lockInspection = inspectStaleServeLock(projectRoot, Date.now());
8662
9759
  if (lockInspection.present && !lockInspection.pidAlive) {
8663
- const lockFilePath = join21(projectRoot, ".fabric", ".serve.lock");
9760
+ const lockFilePath = join24(projectRoot, ".fabric", ".serve.lock");
8664
9761
  try {
8665
9762
  await unlink4(lockFilePath);
8666
9763
  } catch (err) {
@@ -8777,7 +9874,7 @@ function createApplyLintMessage(succeeded, failed, manualErrorCount) {
8777
9874
  }
8778
9875
  async function applySessionHintsStaleCleanup(projectRoot, candidate) {
8779
9876
  const detail = `deleted (${candidate.age_days}d old)`;
8780
- const absPath = join21(projectRoot, candidate.path);
9877
+ const absPath = join24(projectRoot, candidate.path);
8781
9878
  try {
8782
9879
  const { unlink: unlink5 } = await import("fs/promises");
8783
9880
  await unlink5(absPath);
@@ -8802,9 +9899,9 @@ function truncateErrorMessage(error) {
8802
9899
  return raw.length > 240 ? `${raw.slice(0, 237)}...` : raw;
8803
9900
  }
8804
9901
  async function inspectForensic(projectRoot) {
8805
- const path = join21(projectRoot, ".fabric", "forensic.json");
9902
+ const path = join24(projectRoot, ".fabric", "forensic.json");
8806
9903
  try {
8807
- const parsed = forensicReportSchema.parse(JSON.parse(await readFile16(path, "utf8")));
9904
+ const parsed = forensicReportSchema.parse(JSON.parse(await readFile19(path, "utf8")));
8808
9905
  return { present: true, valid: true, report: parsed };
8809
9906
  } catch (error) {
8810
9907
  if (isMissingFileError(error)) {
@@ -8834,7 +9931,7 @@ async function inspectEventLedger(projectRoot) {
8834
9931
  try {
8835
9932
  await access4(path, constants2.W_OK);
8836
9933
  const { warnings } = await readEventLedger(projectRoot);
8837
- const raw = await readFile16(path, "utf8");
9934
+ const raw = await readFile19(path, "utf8");
8838
9935
  const invalidLine = raw.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).find((line) => !isValidJsonLine(line));
8839
9936
  const partialWarning = warnings.find((w) => w.kind === "partial_write_at_tail");
8840
9937
  const schemaVersionSamples = [];
@@ -9379,7 +10476,7 @@ async function inspectPreexistingRootFiles(projectRoot) {
9379
10476
  const candidates = ["CLAUDE.md", "AGENTS.md"];
9380
10477
  const detected = [];
9381
10478
  for (const name of candidates) {
9382
- if (await pathExists(join21(projectRoot, name))) {
10479
+ if (await pathExists(join24(projectRoot, name))) {
9383
10480
  detected.push(name);
9384
10481
  }
9385
10482
  }
@@ -9464,7 +10561,7 @@ async function buildLastActiveIndex(projectRoot) {
9464
10561
  return map;
9465
10562
  }
9466
10563
  async function inspectSessionHintsStale(projectRoot, now) {
9467
- const cacheDir = join21(projectRoot, ".fabric", ".cache");
10564
+ const cacheDir = join24(projectRoot, ".fabric", ".cache");
9468
10565
  let entries;
9469
10566
  try {
9470
10567
  entries = await readdirAsync(cacheDir, { withFileTypes: true });
@@ -9476,7 +10573,7 @@ async function inspectSessionHintsStale(projectRoot, now) {
9476
10573
  if (!entry.isFile()) continue;
9477
10574
  if (!entry.name.startsWith(SESSION_HINTS_FILE_PREFIX)) continue;
9478
10575
  if (!entry.name.endsWith(SESSION_HINTS_FILE_SUFFIX)) continue;
9479
- const absPath = join21(cacheDir, entry.name);
10576
+ const absPath = join24(cacheDir, entry.name);
9480
10577
  let mtimeMs = 0;
9481
10578
  try {
9482
10579
  mtimeMs = (await statAsync(absPath)).mtimeMs;
@@ -9508,9 +10605,9 @@ function inspectStaleServeLock(projectRoot, now) {
9508
10605
  };
9509
10606
  }
9510
10607
  async function readUnderseedThresholdFromConfig(projectRoot) {
9511
- const configPath = join21(projectRoot, ".fabric", "fabric-config.json");
10608
+ const configPath = join24(projectRoot, ".fabric", "fabric-config.json");
9512
10609
  try {
9513
- const raw = await readFile16(configPath, "utf8");
10610
+ const raw = await readFile19(configPath, "utf8");
9514
10611
  const parsed = JSON.parse(raw);
9515
10612
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
9516
10613
  const v = parsed.underseed_node_threshold;
@@ -9626,10 +10723,10 @@ async function inspectOnboardCoverage(projectRoot) {
9626
10723
  return { filled, missing, opted_out: optedOut };
9627
10724
  }
9628
10725
  async function readOnboardOptedOut(projectRoot) {
9629
- const path = join21(projectRoot, ".fabric", "fabric-config.json");
10726
+ const path = join24(projectRoot, ".fabric", "fabric-config.json");
9630
10727
  let raw;
9631
10728
  try {
9632
- raw = await readFile16(path, "utf8");
10729
+ raw = await readFile19(path, "utf8");
9633
10730
  } catch {
9634
10731
  return [];
9635
10732
  }
@@ -9731,22 +10828,22 @@ async function* iterateCanonicalFilenames(projectRoot) {
9731
10828
  }
9732
10829
  }
9733
10830
  async function rewriteThreeEndManagedBlocks(projectRoot) {
9734
- const snapshotPath = join21(projectRoot, ".fabric", "AGENTS.md");
10831
+ const snapshotPath = join24(projectRoot, ".fabric", "AGENTS.md");
9735
10832
  if (!await pathExists(snapshotPath)) {
9736
10833
  return;
9737
10834
  }
9738
10835
  let snapshot;
9739
10836
  try {
9740
- snapshot = await readFile16(snapshotPath, "utf8");
10837
+ snapshot = await readFile19(snapshotPath, "utf8");
9741
10838
  } catch {
9742
10839
  return;
9743
10840
  }
9744
- const projectRulesPath = join21(projectRoot, ".fabric", "project-rules.md");
10841
+ const projectRulesPath = join24(projectRoot, ".fabric", "project-rules.md");
9745
10842
  const hasProjectRules = await pathExists(projectRulesPath);
9746
10843
  let expectedBody = snapshot;
9747
10844
  if (hasProjectRules) {
9748
10845
  try {
9749
- const projectRules = await readFile16(projectRulesPath, "utf8");
10846
+ const projectRules = await readFile19(projectRulesPath, "utf8");
9750
10847
  expectedBody = `${snapshot}
9751
10848
  ---
9752
10849
  ${projectRules}`;
@@ -9757,7 +10854,7 @@ ${projectRules}`;
9757
10854
  ${expectedBody}
9758
10855
  ${BOOTSTRAP_MARKER_END2}`;
9759
10856
  const blockTargets = [
9760
- join21(projectRoot, "AGENTS.md")
10857
+ join24(projectRoot, "AGENTS.md")
9761
10858
  ];
9762
10859
  for (const abs of blockTargets) {
9763
10860
  if (!await pathExists(abs)) {
@@ -9765,7 +10862,7 @@ ${BOOTSTRAP_MARKER_END2}`;
9765
10862
  }
9766
10863
  let existing;
9767
10864
  try {
9768
- existing = await readFile16(abs, "utf8");
10865
+ existing = await readFile19(abs, "utf8");
9769
10866
  } catch {
9770
10867
  continue;
9771
10868
  }
@@ -9790,11 +10887,11 @@ ${managedBlock}
9790
10887
  }
9791
10888
  await atomicWriteText4(abs, next);
9792
10889
  }
9793
- const claudeMdPath = join21(projectRoot, "CLAUDE.md");
10890
+ const claudeMdPath = join24(projectRoot, "CLAUDE.md");
9794
10891
  if (await pathExists(claudeMdPath)) {
9795
10892
  let claudeContent;
9796
10893
  try {
9797
- claudeContent = await readFile16(claudeMdPath, "utf8");
10894
+ claudeContent = await readFile19(claudeMdPath, "utf8");
9798
10895
  } catch {
9799
10896
  return;
9800
10897
  }
@@ -9821,7 +10918,7 @@ ${managedBlock}
9821
10918
  async function ensureEventLedger(projectRoot) {
9822
10919
  const path = getEventLedgerPath(projectRoot);
9823
10920
  await ensureParentDirectory(path);
9824
- await writeFile3(path, "", { encoding: "utf8", flag: "a" });
10921
+ await writeFile5(path, "", { encoding: "utf8", flag: "a" });
9825
10922
  }
9826
10923
  function createFixMessage(fixed, report) {
9827
10924
  const fixedText = fixed.length === 0 ? "No deterministic doctor fixes were needed." : `Applied ${fixed.length} deterministic doctor fix${fixed.length === 1 ? "" : "es"}.`;
@@ -9860,7 +10957,7 @@ async function collectEntryPoints(root) {
9860
10957
  continue;
9861
10958
  }
9862
10959
  for (const entry of await readdirAsync(current, { withFileTypes: true })) {
9863
- const absolutePath = join21(current, entry.name);
10960
+ const absolutePath = join24(current, entry.name);
9864
10961
  const relativePath = normalizePath2(absolutePath.slice(root.length + 1));
9865
10962
  if (relativePath.length === 0) {
9866
10963
  continue;
@@ -9936,7 +11033,7 @@ async function enrichDescriptions(projectRoot, opts = {}) {
9936
11033
  scanned += 1;
9937
11034
  let source;
9938
11035
  try {
9939
- source = await readFile16(absPath, "utf8");
11036
+ source = await readFile19(absPath, "utf8");
9940
11037
  } catch {
9941
11038
  continue;
9942
11039
  }
@@ -10074,16 +11171,16 @@ async function runDoctorConflictLint(projectRoot, opts = {}) {
10074
11171
  }
10075
11172
 
10076
11173
  // src/services/why-not-surfaced.ts
10077
- import { readFile as readFile17 } from "fs/promises";
10078
- import { basename as basename3, join as join22 } from "path";
11174
+ import { readFile as readFile20 } from "fs/promises";
11175
+ import { basename as basename3, join as join25 } from "path";
10079
11176
  import {
10080
- buildStoreResolveInput as buildStoreResolveInput7,
10081
- createStoreResolver as createStoreResolver7,
10082
- loadProjectConfig as loadProjectConfig4,
11177
+ buildStoreResolveInput as buildStoreResolveInput8,
11178
+ createStoreResolver as createStoreResolver8,
11179
+ loadProjectConfig as loadProjectConfig6,
10083
11180
  readKnowledgeAcrossStores as readKnowledgeAcrossStores4,
10084
- resolveGlobalRoot as resolveGlobalRoot7,
11181
+ resolveGlobalRoot as resolveGlobalRoot9,
10085
11182
  scopeRoot as scopeRoot3,
10086
- storeRelativePathForMount as storeRelativePathForMount6
11183
+ storeRelativePathForMount as storeRelativePathForMount7
10087
11184
  } from "@fenglimg/fabric-shared";
10088
11185
  var SEMANTIC_SCOPE_LINE3 = /^semantic_scope:\s*"?([^"\n]+?)"?\s*$/mu;
10089
11186
  var RELEVANCE_SCOPE_LINE = /^relevance_scope:\s*"?(broad|narrow)"?\s*$/mu;
@@ -10107,15 +11204,15 @@ async function explainWhyNotSurfaced(projectRoot, query) {
10107
11204
  activeProject: null,
10108
11205
  relevanceScope: null
10109
11206
  };
10110
- const input = buildStoreResolveInput7(projectRoot);
11207
+ const input = buildStoreResolveInput8(projectRoot);
10111
11208
  if (input === null) {
10112
11209
  return base;
10113
11210
  }
10114
- const globalRoot = resolveGlobalRoot7();
11211
+ const globalRoot = resolveGlobalRoot9();
10115
11212
  const allStores = input.mountedStores.map((s) => ({
10116
11213
  store_uuid: s.store_uuid,
10117
11214
  alias: s.alias,
10118
- dir: join22(globalRoot, storeRelativePathForMount6(s))
11215
+ dir: join25(globalRoot, storeRelativePathForMount7(s))
10119
11216
  }));
10120
11217
  const refs = await readKnowledgeAcrossStores4(allStores);
10121
11218
  const candidate = refs.find((ref) => fileMatchesId(ref.file, localId));
@@ -10124,7 +11221,7 @@ async function explainWhyNotSurfaced(projectRoot, query) {
10124
11221
  }
10125
11222
  let source;
10126
11223
  try {
10127
- source = await readFile17(candidate.file, "utf8");
11224
+ source = await readFile20(candidate.file, "utf8");
10128
11225
  } catch {
10129
11226
  return base;
10130
11227
  }
@@ -10133,9 +11230,9 @@ async function explainWhyNotSurfaced(projectRoot, query) {
10133
11230
  }
10134
11231
  const semanticScope = SEMANTIC_SCOPE_LINE3.exec(source)?.[1] ?? null;
10135
11232
  const relevanceScope = RELEVANCE_SCOPE_LINE.exec(source)?.[1] ?? "broad";
10136
- const activeProject = loadProjectConfig4(projectRoot)?.active_project ?? null;
11233
+ const activeProject = loadProjectConfig6(projectRoot)?.active_project ?? null;
10137
11234
  const boundUuids = new Set(
10138
- createStoreResolver7().resolveReadSet(input).stores.map((s) => s.store_uuid)
11235
+ createStoreResolver8().resolveReadSet(input).stores.map((s) => s.store_uuid)
10139
11236
  );
10140
11237
  const storeBound = boundUuids.has(candidate.store_uuid);
10141
11238
  const found = {
@@ -10177,6 +11274,191 @@ function buildColdEvalBatch(candidates) {
10177
11274
  return { rubric: COLD_EVAL_RUBRIC, candidates: judgeable };
10178
11275
  }
10179
11276
 
11277
+ // src/services/doctor-related-graph.ts
11278
+ function buildRelatedGraph(nodes) {
11279
+ const brokenLinks = [];
11280
+ const inDegree = /* @__PURE__ */ new Map();
11281
+ const allIds = /* @__PURE__ */ new Set();
11282
+ const edgeSources = [];
11283
+ for (const node of nodes) {
11284
+ const bareId = extractBareStableId(node.qualifiedId);
11285
+ allIds.add(node.qualifiedId);
11286
+ if (bareId !== null) {
11287
+ allIds.add(bareId);
11288
+ }
11289
+ const related = node.related;
11290
+ if (related !== void 0 && related.length > 0) {
11291
+ edgeSources.push({ source: node.qualifiedId, related });
11292
+ for (const rel of related) {
11293
+ inDegree.set(rel, (inDegree.get(rel) ?? 0) + 1);
11294
+ }
11295
+ }
11296
+ }
11297
+ for (const { source, related } of edgeSources) {
11298
+ for (const rel of related) {
11299
+ if (!allIds.has(rel)) {
11300
+ brokenLinks.push({ source, target: rel });
11301
+ }
11302
+ }
11303
+ }
11304
+ const hubEntries = [...inDegree.entries()].map(([stableId, degree]) => ({ stableId, inDegree: degree })).sort((a, b) => b.inDegree - a.inDegree || a.stableId.localeCompare(b.stableId));
11305
+ return {
11306
+ brokenLinks,
11307
+ hubEntries,
11308
+ totalEntries: nodes.length
11309
+ };
11310
+ }
11311
+ function extractBareStableId(qualifiedId) {
11312
+ const colonIdx = qualifiedId.indexOf(":");
11313
+ if (colonIdx > 0 && colonIdx < qualifiedId.length - 1) {
11314
+ return qualifiedId.slice(colonIdx + 1);
11315
+ }
11316
+ return null;
11317
+ }
11318
+ async function inspectRelatedGraph(projectRoot) {
11319
+ const entries = await collectStoreCanonicalEntries(projectRoot);
11320
+ return buildRelatedGraph(
11321
+ entries.map((entry) => ({
11322
+ qualifiedId: entry.qualifiedId,
11323
+ related: entry.description.related
11324
+ }))
11325
+ );
11326
+ }
11327
+
11328
+ // src/services/store-precheck.ts
11329
+ import { existsSync as existsSync9, readFileSync as readFileSync4 } from "fs";
11330
+ import { join as join26 } from "path";
11331
+ import {
11332
+ buildStoreResolveInput as buildStoreResolveInput9,
11333
+ createStoreResolver as createStoreResolver9,
11334
+ resolveGlobalRoot as resolveGlobalRoot10,
11335
+ storeRelativePathForMount as storeRelativePathForMount8
11336
+ } from "@fenglimg/fabric-shared";
11337
+ var CACHE_TTL_MS = 6e4;
11338
+ var cache = /* @__PURE__ */ new Map();
11339
+ function clearPrecheckCache() {
11340
+ cache.clear();
11341
+ }
11342
+ function evaluateStoreDir(storeDir, identity) {
11343
+ if (!existsSync9(storeDir)) {
11344
+ return {
11345
+ uuid: identity.uuid,
11346
+ alias: identity.alias,
11347
+ reachable: false,
11348
+ reason: `directory not found at ${storeDir}`
11349
+ };
11350
+ }
11351
+ const storeJsonPath = join26(storeDir, "store.json");
11352
+ const gitDirPath = join26(storeDir, ".git");
11353
+ const hasStoreJson = existsSync9(storeJsonPath) && isValidStoreJson(storeJsonPath);
11354
+ const hasGitDir = existsSync9(gitDirPath);
11355
+ if (!hasStoreJson && !hasGitDir) {
11356
+ return {
11357
+ uuid: identity.uuid,
11358
+ alias: identity.alias,
11359
+ reachable: false,
11360
+ reason: `no store.json or .git found in ${storeDir}`
11361
+ };
11362
+ }
11363
+ return { uuid: identity.uuid, alias: identity.alias, reachable: true };
11364
+ }
11365
+ function isValidStoreJson(path) {
11366
+ try {
11367
+ JSON.parse(readFileSync4(path, "utf8"));
11368
+ return true;
11369
+ } catch {
11370
+ return false;
11371
+ }
11372
+ }
11373
+ async function precheckStoreReachability(projectRoot, globalRoot = resolveGlobalRoot10(), now = Date.now()) {
11374
+ const cached = cache.get(projectRoot);
11375
+ if (cached !== void 0 && cached.expiresAt > now) {
11376
+ return cached.result;
11377
+ }
11378
+ const input = buildStoreResolveInput9(projectRoot, globalRoot);
11379
+ if (input === null) {
11380
+ const result2 = { stores: [], allReachable: true };
11381
+ cache.set(projectRoot, { result: result2, expiresAt: now + CACHE_TTL_MS });
11382
+ return result2;
11383
+ }
11384
+ const readSet = createStoreResolver9().resolveReadSet(input);
11385
+ const stores = readSet.stores.map((entry) => {
11386
+ const mounted = input.mountedStores.find((s) => s.store_uuid === entry.store_uuid);
11387
+ const storeDir = join26(
11388
+ globalRoot,
11389
+ storeRelativePathForMount8(mounted ?? { store_uuid: entry.store_uuid })
11390
+ );
11391
+ return evaluateStoreDir(storeDir, { uuid: entry.store_uuid, alias: entry.alias });
11392
+ });
11393
+ const result = {
11394
+ stores,
11395
+ allReachable: stores.every((s) => s.reachable)
11396
+ };
11397
+ cache.set(projectRoot, { result, expiresAt: now + CACHE_TTL_MS });
11398
+ return result;
11399
+ }
11400
+
11401
+ // src/services/doctor-consumption-lint.ts
11402
+ var DEFAULT_WINDOW_DAYS = 30;
11403
+ var DEFAULT_TOP_N = 10;
11404
+ var DEFAULT_MIN_TOTAL_ENTRIES = 20;
11405
+ var DEFAULT_MIN_CONSUMED_WINDOWS = 30;
11406
+ var DEFAULT_MIN_CONSUMED_EVENTS = 50;
11407
+ var CONSUMED_PREFIX = "knowledge_consumed:";
11408
+ var MS_PER_DAY5 = 24 * 60 * 60 * 1e3;
11409
+ function resolveConfig(config) {
11410
+ return {
11411
+ windowDays: config?.windowDays ?? DEFAULT_WINDOW_DAYS,
11412
+ topN: config?.topN ?? DEFAULT_TOP_N,
11413
+ minTotalEntries: config?.minTotalEntries ?? DEFAULT_MIN_TOTAL_ENTRIES,
11414
+ minConsumedWindows: config?.minConsumedWindows ?? DEFAULT_MIN_CONSUMED_WINDOWS,
11415
+ minConsumedEvents: config?.minConsumedEvents ?? DEFAULT_MIN_CONSUMED_EVENTS
11416
+ };
11417
+ }
11418
+ function aggregateConsumption(rows, allQualifiedIds, now, config) {
11419
+ const cfg = resolveConfig(config);
11420
+ const cutoffMs = now - cfg.windowDays * MS_PER_DAY5;
11421
+ const consumed = /* @__PURE__ */ new Map();
11422
+ let consumedWindows = 0;
11423
+ let totalConsumedEvents = 0;
11424
+ for (const row of rows) {
11425
+ const rowTs = Date.parse(row.timestamp);
11426
+ if (!Number.isFinite(rowTs) || rowTs < cutoffMs) continue;
11427
+ if (row.counters === null || typeof row.counters !== "object") continue;
11428
+ let windowHadConsumption = false;
11429
+ for (const [counterName, rawCount] of Object.entries(row.counters)) {
11430
+ if (!counterName.startsWith(CONSUMED_PREFIX)) continue;
11431
+ const count = typeof rawCount === "number" ? rawCount : 0;
11432
+ if (count <= 0) continue;
11433
+ const id = counterName.slice(CONSUMED_PREFIX.length);
11434
+ if (id.length === 0) continue;
11435
+ consumed.set(id, (consumed.get(id) ?? 0) + count);
11436
+ totalConsumedEvents += count;
11437
+ windowHadConsumption = true;
11438
+ }
11439
+ if (windowHadConsumption) consumedWindows += 1;
11440
+ }
11441
+ const topConsumed = [...consumed.entries()].map(([stableId, count]) => ({ stableId, count })).sort((a, b) => b.count - a.count || a.stableId.localeCompare(b.stableId)).slice(0, cfg.topN);
11442
+ const dataMature = consumedWindows >= cfg.minConsumedWindows && totalConsumedEvents >= cfg.minConsumedEvents && allQualifiedIds.length >= cfg.minTotalEntries;
11443
+ const zeroConsumed = dataMature ? allQualifiedIds.filter((id) => !consumed.has(id)).sort((a, b) => a.localeCompare(b)) : [];
11444
+ return {
11445
+ topConsumed,
11446
+ zeroConsumed,
11447
+ totalEntries: allQualifiedIds.length,
11448
+ consumedEntries: consumed.size,
11449
+ consumedWindows,
11450
+ totalConsumedEvents,
11451
+ dataMature,
11452
+ windowDays: cfg.windowDays
11453
+ };
11454
+ }
11455
+ async function inspectConsumption(projectRoot, config, now = Date.now()) {
11456
+ const rows = await readMetrics(projectRoot);
11457
+ const entries = await collectStoreCanonicalEntries(projectRoot);
11458
+ const allQualifiedIds = entries.map((entry) => entry.qualifiedId);
11459
+ return aggregateConsumption(rows, allQualifiedIds, now, config);
11460
+ }
11461
+
10180
11462
  // src/services/rotation-tick.ts
10181
11463
  var DEFAULT_TICK_INTERVAL_MS = 6 * 60 * 60 * 1e3;
10182
11464
  var tickTimers = /* @__PURE__ */ new Map();
@@ -10212,7 +11494,7 @@ import { IOFabricError as IOFabricError2, RuleError } from "@fenglimg/fabric-sha
10212
11494
 
10213
11495
  // src/services/read-ledger.ts
10214
11496
  import { randomUUID as randomUUID7 } from "crypto";
10215
- import { access as access5, copyFile, readFile as readFile18, rm } from "fs/promises";
11497
+ import { access as access5, copyFile, readFile as readFile21, rm } from "fs/promises";
10216
11498
  import { ledgerEntrySchema } from "@fenglimg/fabric-shared";
10217
11499
  async function resolveLedgerPaths(projectRoot) {
10218
11500
  const primaryPath = getLedgerPath(projectRoot);
@@ -10240,7 +11522,7 @@ async function readLegacyLedger(projectRoot) {
10240
11522
  const { readPath } = await resolveLedgerPaths(projectRoot);
10241
11523
  let raw;
10242
11524
  try {
10243
- raw = await readFile18(readPath, "utf8");
11525
+ raw = await readFile21(readPath, "utf8");
10244
11526
  } catch (error) {
10245
11527
  if (isNodeError(error) && error.code === "ENOENT") {
10246
11528
  return [];
@@ -10495,8 +11777,8 @@ function formatError(error) {
10495
11777
  }
10496
11778
  function formatPreexistingRootMessage(projectRoot) {
10497
11779
  const preexisting = [];
10498
- if (existsSync9(join23(projectRoot, "CLAUDE.md"))) preexisting.push("CLAUDE.md");
10499
- if (existsSync9(join23(projectRoot, "AGENTS.md"))) preexisting.push("AGENTS.md");
11780
+ if (existsSync10(join27(projectRoot, "CLAUDE.md"))) preexisting.push("CLAUDE.md");
11781
+ if (existsSync10(join27(projectRoot, "AGENTS.md"))) preexisting.push("AGENTS.md");
10500
11782
  if (preexisting.length === 0) return null;
10501
11783
  return `[startup] info: detected ${preexisting.join(", ")} at project root. Note: Fabric serves knowledge from mounted stores via MCP \u2014 root markdown files are not auto-loaded into the AI context.`;
10502
11784
  }
@@ -10523,7 +11805,7 @@ function createFabricServer(tracker) {
10523
11805
  const server = new McpServer(
10524
11806
  {
10525
11807
  name: "fabric-knowledge-server",
10526
- version: "2.3.0-rc.1"
11808
+ version: "2.3.0-rc.11"
10527
11809
  },
10528
11810
  {
10529
11811
  instructions: FABRIC_SERVER_INSTRUCTIONS
@@ -10543,10 +11825,10 @@ function createFabricServer(tracker) {
10543
11825
  },
10544
11826
  async (_uri) => {
10545
11827
  const projectRoot = process.env.FABRIC_PROJECT_ROOT ?? process.cwd();
10546
- const path = join23(projectRoot, ".fabric", "bootstrap", "README.md");
11828
+ const path = join27(projectRoot, ".fabric", "bootstrap", "README.md");
10547
11829
  let text = "";
10548
- if (existsSync9(path)) {
10549
- text = await readFile19(path, "utf8");
11830
+ if (existsSync10(path)) {
11831
+ text = await readFile22(path, "utf8");
10550
11832
  }
10551
11833
  return {
10552
11834
  contents: [
@@ -10644,19 +11926,27 @@ export {
10644
11926
  LEGACY_LEDGER_PATH,
10645
11927
  METRICS_LEDGER_PATH,
10646
11928
  METRIC_COUNTER_NAMES,
11929
+ OPTIONAL_EMBED_PACKAGE,
10647
11930
  RETIRED_TOKENS,
11931
+ aggregateConsumption,
10648
11932
  appendEventLedgerEvent,
10649
11933
  buildAlwaysActiveBodies,
10650
11934
  buildColdEvalBatch,
10651
11935
  buildKnowledgeCensus,
11936
+ buildRelatedGraph,
10652
11937
  bumpCounter,
11938
+ checkBacklogAge,
11939
+ clearPrecheckCache,
11940
+ collectStoreCanonicalEntries,
10653
11941
  contextCache,
10654
11942
  createFabricServer,
10655
11943
  createInFlightTracker,
10656
11944
  createShutdownHandler,
11945
+ defaultEmbedCacheDir,
10657
11946
  detectUnboundProject,
10658
11947
  drainCounters,
10659
11948
  enrichDescriptions,
11949
+ evaluateStoreDir,
10660
11950
  explainWhyNotSurfaced,
10661
11951
  extractKnowledge,
10662
11952
  findConflictCandidates,
@@ -10667,17 +11957,25 @@ export {
10667
11957
  getLedgerPath,
10668
11958
  getLegacyLedgerPath,
10669
11959
  getMetricsLedgerPath,
11960
+ inspectConsumption,
11961
+ inspectRelatedGraph,
10670
11962
  inspectRetiredReferences,
11963
+ isEmbedderResolvable,
10671
11964
  lintConflicts,
10672
11965
  loadConflictEntries,
11966
+ loadEmbedder,
10673
11967
  pairSimilarity,
10674
11968
  planContext,
11969
+ precheckStoreReachability,
11970
+ readEmbedConfig,
10675
11971
  readEventLedger,
11972
+ readFusion,
10676
11973
  readLedger,
10677
11974
  readMetrics,
10678
11975
  readSelectionToken,
10679
11976
  recall,
10680
11977
  rehydrateAgentsMetaAt,
11978
+ renderBacklogAgeLine,
10681
11979
  resolveLedgerPaths,
10682
11980
  reviewKnowledge,
10683
11981
  reviewPending,