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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // src/index.ts
2
2
  import { existsSync as existsSync9 } from "fs";
3
- import { readFile as readFile17 } from "fs/promises";
4
- import { join as join21, resolve as resolve4 } from "path";
3
+ import { readFile as readFile19 } from "fs/promises";
4
+ import { join as join23, 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";
@@ -675,12 +675,14 @@ function createInFlightTracker() {
675
675
 
676
676
  // src/tools/extract-knowledge.ts
677
677
  import { randomUUID as randomUUID2 } from "crypto";
678
+ import { ZodError } from "zod";
678
679
  import {
679
680
  FabExtractKnowledgeInputShape,
680
681
  FabExtractKnowledgeInputSchema,
681
682
  FabExtractKnowledgeOutputSchema,
682
683
  fabExtractKnowledgeAnnotations
683
684
  } from "@fenglimg/fabric-shared/schemas/api-contracts";
685
+ import { SCOPE_COORDINATE_HINT } from "@fenglimg/fabric-shared";
684
686
  import { enforcePayloadLimit } from "@fenglimg/fabric-shared/node/mcp-payload-guard";
685
687
 
686
688
  // src/tools/payload-warning.ts
@@ -782,6 +784,18 @@ function readRecallRelevanceRatio(projectRoot) {
782
784
  return RECALL_RELEVANCE_RATIO_DEFAULT;
783
785
  }
784
786
  }
787
+ var BROAD_REVIEW_RECHECK_DAYS_DEFAULT = 180;
788
+ function readBroadReviewRecheckThresholdDays(projectRoot) {
789
+ try {
790
+ const raw = readFabricConfig(projectRoot).broad_review_recheck_days;
791
+ if (typeof raw === "number" && Number.isFinite(raw) && Number.isInteger(raw) && raw >= 1 && raw <= 3650) {
792
+ return raw;
793
+ }
794
+ return BROAD_REVIEW_RECHECK_DAYS_DEFAULT;
795
+ } catch {
796
+ return BROAD_REVIEW_RECHECK_DAYS_DEFAULT;
797
+ }
798
+ }
785
799
  function readOrphanDemoteThresholdDays(projectRoot) {
786
800
  try {
787
801
  const cfg = readFabricConfig(projectRoot);
@@ -870,8 +884,8 @@ async function awaitFirstReconcileGate(timeoutMs = 5e3) {
870
884
 
871
885
  // src/services/extract-knowledge.ts
872
886
  import { existsSync as existsSync3 } from "fs";
873
- import { readFile as readFile3 } from "fs/promises";
874
- import { join as join6 } from "path";
887
+ import { readFile as readFile4 } from "fs/promises";
888
+ import { join as join7 } from "path";
875
889
  import {
876
890
  PROPOSED_REASON_DESCRIPTIONS_BY_LOCALE
877
891
  } from "@fenglimg/fabric-shared/schemas/api-contracts";
@@ -894,7 +908,7 @@ import {
894
908
  StoreWriteTargetUnresolvedError
895
909
  } from "@fenglimg/fabric-shared/errors";
896
910
  function writeTargetUnresolved(scope, layer) {
897
- 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 route-write ${scope} <alias>\``;
911
+ 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}\``;
898
912
  return new StoreWriteTargetUnresolvedError(
899
913
  `no write-target store resolved for scope '${scope}' \u2014 knowledge writes are store-only (dual-root co-location removed)`,
900
914
  { actionHint, fixable: true, details: { layer, scope } }
@@ -954,1178 +968,1443 @@ function resolveWriteScopeMeta(layer, projectRoot, semanticScope) {
954
968
  return { semantic_scope, visibility_store: target.alias };
955
969
  }
956
970
 
957
- // src/services/extract-knowledge.ts
958
- var SLUG_MAX_LENGTH = 40;
959
- var INJECTION_PATTERNS = [
960
- {
961
- name: "ignore-prior-instructions",
962
- pattern: /\b(?:ignore|forget|disregard)\s+(?:all\s+)?(?:previous|prior|above|earlier)\s+(?:instructions?|messages?|prompts?|rules?)\b/giu
963
- },
964
- {
965
- name: "forget-your-role",
966
- pattern: /\b(?:forget|disregard|ignore)\s+(?:your|the)\s+(?:role|identity|system\s+prompt)\b/giu
967
- },
968
- {
969
- name: "you-are-now",
970
- pattern: /\byou\s+are\s+now\s+(?:a|an)\s+\w+\s+(?:assistant|agent|model|bot|persona)\b/giu
971
- },
972
- {
973
- name: "rm-rf-root",
974
- pattern: /\brm\s+-rf?\s+(?:--no-preserve-root\s+)?[/~][^\s`'")>}]*?\s*(?:\/[*]?|;|$|\n|\|)/giu
975
- },
976
- {
977
- name: "shell-eval-curl",
978
- pattern: /\b(?:eval|sh|bash|zsh)\s+(?:-\w+\s+)?["'`]?\$\(\s*curl\s+[^)]+\)/giu
979
- },
980
- {
981
- name: "chatml-envelope",
982
- pattern: /<\|(?:im_start|im_end|system|user|assistant|endoftext|fim_prefix|fim_suffix|fim_middle)\|>/giu
983
- },
984
- {
985
- name: "claude-envelope",
986
- pattern: /\b(?:Human:|Assistant:)\s*<.*?>/giu
971
+ // src/services/cross-store-recall.ts
972
+ import { readFile as readFile3, stat } from "fs/promises";
973
+ import { join as join6 } from "path";
974
+ import {
975
+ buildStoreResolveInput as buildStoreResolveInput2,
976
+ createStoreResolver as createStoreResolver2,
977
+ loadProjectConfig as loadProjectConfig2,
978
+ readKnowledgeAcrossStores,
979
+ resolveGlobalRoot as resolveGlobalRoot2,
980
+ scopeRoot,
981
+ storeRelativePathForMount as storeRelativePathForMount2
982
+ } from "@fenglimg/fabric-shared";
983
+
984
+ // src/services/knowledge-meta-builder.ts
985
+ import {
986
+ deriveAgentsMetaStableId,
987
+ isKnowledgeStableId,
988
+ KnowledgeTypeSchema,
989
+ LayerSchema,
990
+ MaturitySchema,
991
+ parseKnowledgeId,
992
+ StableIdSchema
993
+ } from "@fenglimg/fabric-shared";
994
+ function toAgentsCompatiblePath(contentRef) {
995
+ return contentRef.replace(/^~\/\.fabric\/knowledge\//u, ".fabric/agents/").replace(/^\.fabric\/knowledge\//u, ".fabric/agents/");
996
+ }
997
+ function deriveRuleIdentity(file, source, existing) {
998
+ const declaredKnowledgeId = extractDeclaredKnowledgeId(source);
999
+ if (declaredKnowledgeId !== void 0) {
1000
+ return {
1001
+ stableId: declaredKnowledgeId,
1002
+ identitySource: "declared"
1003
+ };
987
1004
  }
988
- ];
989
- var INJECTION_REDACTION_MARKER = "[REDACTED: prompt-injection pattern stripped by fab_extract_knowledge \u2014 NEW-31]";
990
- function sanitizeInjectionPatterns(input) {
991
- let sanitized = input;
992
- const redactions = [];
993
- for (const { name, pattern } of INJECTION_PATTERNS) {
994
- const matches = sanitized.match(pattern);
995
- if (matches === null || matches.length === 0) continue;
996
- redactions.push({ name, matches: matches.length });
997
- sanitized = sanitized.replace(pattern, INJECTION_REDACTION_MARKER);
1005
+ if (existing?.stable_id !== void 0 && isKnowledgeStableId(existing.stable_id)) {
1006
+ return {
1007
+ stableId: existing.stable_id,
1008
+ identitySource: "declared"
1009
+ };
998
1010
  }
999
- return { sanitized, redactions };
1000
- }
1001
- function sanitizeInjectionFields(fields) {
1002
- const out = { ...fields };
1003
- const allRedactions = [];
1004
- for (const [key, value] of Object.entries(fields)) {
1005
- if (typeof value === "string") {
1006
- const { sanitized, redactions } = sanitizeInjectionPatterns(value);
1007
- out[key] = sanitized;
1008
- for (const r of redactions) {
1009
- allRedactions.push({ field: key, ...r });
1010
- }
1011
- } else if (Array.isArray(value) && value.every((v) => typeof v === "string")) {
1012
- const cleaned = [];
1013
- for (const entry of value) {
1014
- const { sanitized, redactions } = sanitizeInjectionPatterns(entry);
1015
- cleaned.push(sanitized);
1016
- for (const r of redactions) {
1017
- allRedactions.push({ field: key, ...r });
1018
- }
1019
- }
1020
- out[key] = cleaned;
1021
- }
1011
+ const declaredStableId = extractDeclaredStableId(source);
1012
+ const derivedStableId = deriveAgentsMetaStableId(toAgentsCompatiblePath(file));
1013
+ if (declaredStableId !== void 0) {
1014
+ return {
1015
+ stableId: declaredStableId,
1016
+ identitySource: "declared"
1017
+ };
1022
1018
  }
1023
- return { sanitized: out, allRedactions };
1024
- }
1025
- function redactPiiFields(fields) {
1026
- const out = { ...fields };
1027
- const redactedFields = /* @__PURE__ */ new Set();
1028
- for (const [key, value] of Object.entries(fields)) {
1029
- if (typeof value === "string") {
1030
- const cleaned = redactPii(value);
1031
- out[key] = cleaned;
1032
- if (cleaned !== value) redactedFields.add(key);
1033
- } else if (Array.isArray(value) && value.every((v) => typeof v === "string")) {
1034
- const cleaned = value.map((entry) => redactPii(entry));
1035
- out[key] = cleaned;
1036
- if (cleaned.some((entry, index) => entry !== value[index])) {
1037
- redactedFields.add(key);
1038
- }
1039
- }
1019
+ if (existing?.identity_source === "declared" && existing.stable_id !== void 0 && existing.stable_id !== derivedStableId) {
1020
+ return {
1021
+ stableId: existing.stable_id,
1022
+ identitySource: "declared"
1023
+ };
1040
1024
  }
1041
- return { redacted: out, redactedFields: [...redactedFields].sort() };
1025
+ return {
1026
+ stableId: derivedStableId,
1027
+ identitySource: "derived"
1028
+ };
1042
1029
  }
1043
- function pendingBase(layer, projectRoot, semanticScope) {
1044
- return resolveStorePendingBase(layer, projectRoot, semanticScope);
1030
+ function extractDeclaredStableId(source) {
1031
+ const match = /^(?:\uFEFF)?(?:---\r?\n[\s\S]*?\r?\n---\s*(?:\r?\n|$))?<!--\s*fab:rule-id\s+([A-Za-z0-9][A-Za-z0-9/_-]*)\s*-->\s*(?:\r?\n|$)/u.exec(source);
1032
+ return match?.[1];
1045
1033
  }
1046
- function toPosixPath(path) {
1047
- return path.replace(/\\/gu, "/");
1034
+ function extractDeclaredKnowledgeId(source) {
1035
+ const frontmatter = /^(?:\uFEFF)?---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/u.exec(source);
1036
+ if (frontmatter === null) {
1037
+ return void 0;
1038
+ }
1039
+ const idMatch = /^id:\s*(.+?)\s*$/mu.exec(frontmatter[1]);
1040
+ if (idMatch === null) {
1041
+ return void 0;
1042
+ }
1043
+ const candidate = idMatch[1].replace(/^["'](.*)["']$/u, "$1").trim();
1044
+ return isKnowledgeStableId(candidate) ? candidate : void 0;
1048
1045
  }
1049
- async function extractKnowledge(projectRoot, input) {
1050
- const sanitizedInputFields = sanitizeInjectionFields({
1051
- slug: input.slug ?? "",
1052
- user_messages_summary: input.user_messages_summary ?? "",
1053
- session_context: input.session_context ?? "",
1054
- must_read_if: input.must_read_if ?? "",
1055
- intent_clues: input.intent_clues ?? []
1056
- });
1057
- input = {
1058
- ...input,
1059
- slug: sanitizedInputFields.sanitized.slug,
1060
- user_messages_summary: sanitizedInputFields.sanitized.user_messages_summary || void 0,
1061
- session_context: sanitizedInputFields.sanitized.session_context || void 0,
1062
- must_read_if: sanitizedInputFields.sanitized.must_read_if || void 0,
1063
- intent_clues: sanitizedInputFields.sanitized.intent_clues.length > 0 ? sanitizedInputFields.sanitized.intent_clues : void 0
1064
- };
1065
- if (sanitizedInputFields.allRedactions.length > 0) {
1066
- const summary2 = sanitizedInputFields.allRedactions.map((r) => `${r.field}:${r.name}x${r.matches}`).join(",");
1067
- const primarySessionForLog = (input.source_sessions ?? [])[0] ?? "";
1068
- await emitEventBestEffort(projectRoot, {
1069
- event_type: "knowledge_archive_attempted",
1070
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1071
- correlation_id: primarySessionForLog,
1072
- session_id: primarySessionForLog,
1073
- reason: `extract_knowledge:injection-redacted:${summary2}`
1074
- });
1046
+ function extractRuleDescription(source) {
1047
+ const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/u.exec(source);
1048
+ const description = frontmatter === null ? void 0 : extractDescriptionFromFrontmatter(frontmatter[1]);
1049
+ if (description !== void 0) {
1050
+ return description;
1075
1051
  }
1076
- const piiRedactedInputFields = redactPiiFields({
1077
- slug: input.slug ?? "",
1078
- user_messages_summary: input.user_messages_summary ?? "",
1079
- session_context: input.session_context ?? "",
1080
- must_read_if: input.must_read_if ?? "",
1081
- intent_clues: input.intent_clues ?? [],
1082
- tags: input.tags ?? [],
1083
- evidence_paths: input.evidence_paths ?? []
1084
- });
1085
- input = {
1086
- ...input,
1087
- slug: piiRedactedInputFields.redacted.slug,
1088
- user_messages_summary: piiRedactedInputFields.redacted.user_messages_summary || void 0,
1089
- session_context: piiRedactedInputFields.redacted.session_context || void 0,
1090
- must_read_if: piiRedactedInputFields.redacted.must_read_if || void 0,
1091
- intent_clues: piiRedactedInputFields.redacted.intent_clues.length > 0 ? piiRedactedInputFields.redacted.intent_clues : void 0,
1092
- tags: piiRedactedInputFields.redacted.tags.length > 0 ? piiRedactedInputFields.redacted.tags : void 0,
1093
- evidence_paths: piiRedactedInputFields.redacted.evidence_paths.length > 0 ? piiRedactedInputFields.redacted.evidence_paths : void 0
1052
+ const heading = /^#\s+(.+?)\s*$/mu.exec(source);
1053
+ const summary = heading?.[1]?.trim();
1054
+ const knowledge = frontmatter !== null ? extractKnowledgeFieldsFromFrontmatter(frontmatter[1]) : void 0;
1055
+ const isStructurallyAKnowledgeEntry = summary !== void 0 && summary.length > 0 ? true : knowledge !== void 0 && (knowledge.id !== void 0 || knowledge.knowledge_type !== void 0 || knowledge.tags !== void 0 && knowledge.tags.length > 0);
1056
+ if (!isStructurallyAKnowledgeEntry) {
1057
+ return void 0;
1058
+ }
1059
+ const synthesizedSummary = summary !== void 0 && summary.length > 0 ? summary : knowledge?.id ?? (knowledge?.tags !== void 0 && knowledge.tags.length > 0 ? `(unnamed; tags: ${knowledge.tags.join(", ")})` : "(unnamed knowledge entry)");
1060
+ return {
1061
+ summary: synthesizedSummary,
1062
+ intent_clues: [],
1063
+ tech_stack: [],
1064
+ impact: [],
1065
+ must_read_if: synthesizedSummary,
1066
+ // v2.0-rc.22: when frontmatter is present, merge its knowledge fields;
1067
+ // when fully absent (no `---` block), all knowledge fields stay
1068
+ // undefined, matching the original heading-only fallback contract.
1069
+ id: knowledge?.id,
1070
+ knowledge_type: knowledge?.knowledge_type,
1071
+ maturity: knowledge?.maturity,
1072
+ knowledge_layer: knowledge?.knowledge_layer,
1073
+ layer_reason: knowledge?.layer_reason,
1074
+ created_at: knowledge?.created_at,
1075
+ tags: knowledge?.tags,
1076
+ // v2.0-rc.5 (C1): default-safe values when there is no frontmatter at all;
1077
+ // when frontmatter exists, honor its declared values (extractKnowledge
1078
+ // FieldsFromFrontmatter already applies the broad-default for missing
1079
+ // or malformed scopes).
1080
+ relevance_scope: knowledge?.relevance_scope ?? "broad",
1081
+ relevance_paths: knowledge?.relevance_paths ?? [],
1082
+ // v2.2 H2-related (W1-T7): graph edges, undefined when absent.
1083
+ related: knowledge?.related
1094
1084
  };
1095
- if (piiRedactedInputFields.redactedFields.length > 0) {
1096
- const primarySessionForLog = (input.source_sessions ?? [])[0] ?? "";
1097
- await emitEventBestEffort(projectRoot, {
1098
- event_type: "knowledge_archive_attempted",
1099
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1100
- correlation_id: primarySessionForLog,
1101
- session_id: primarySessionForLog,
1102
- reason: `extract_knowledge:pii-redacted:${piiRedactedInputFields.redactedFields.join(",")}`
1103
- });
1085
+ }
1086
+ function extractDescriptionFromFrontmatter(frontmatter) {
1087
+ const summary = extractScalar(frontmatter, "summary") ?? extractScalar(frontmatter, "description");
1088
+ if (summary === void 0) {
1089
+ return void 0;
1104
1090
  }
1105
- const sanitizedSlug = sanitizeSlug(input.slug);
1106
- const sourceSessions = input.source_sessions ?? [];
1107
- const primarySession = sourceSessions[0] ?? "";
1108
- const idempotencyKey = sha256(
1109
- JSON.stringify({
1110
- source_session: primarySession,
1111
- type: input.type,
1112
- slug: sanitizedSlug
1113
- })
1114
- );
1115
- const summary = input.user_messages_summary ?? "";
1116
- const summaryTrimmed = summary.trim();
1117
- const summaryIsEmpty = summaryTrimmed.length === 0;
1118
- const slugIsEmpty = sanitizedSlug.length === 0;
1119
- const summaryTooShort = !summaryIsEmpty && summaryTrimmed.length < 15;
1120
- const summaryEqualsSlug = !summaryIsEmpty && summaryTrimmed.toLowerCase() === sanitizedSlug.toLowerCase();
1121
- const summaryLooksLikeStableId = !summaryIsEmpty && /^K[TP]-[A-Z]{3}-\d{4}$/.test(summaryTrimmed);
1122
- const summaryIsOpaque = summaryTooShort || summaryEqualsSlug || summaryLooksLikeStableId;
1123
- if (summaryIsEmpty || slugIsEmpty || summaryIsOpaque) {
1124
- const reason = summaryIsEmpty ? "empty_summary" : slugIsEmpty ? "empty_slug" : summaryTooShort ? "summary_too_short" : summaryEqualsSlug ? "summary_equals_slug" : "summary_looks_like_stable_id";
1125
- await emitEventBestEffort(projectRoot, {
1126
- event_type: "knowledge_archive_attempted",
1127
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1128
- correlation_id: primarySession,
1129
- session_id: primarySession,
1130
- reason: `extract_knowledge:${sanitizedSlug || input.slug}:${reason}`
1131
- });
1132
- return {
1133
- pending_path: "",
1134
- idempotency_key: idempotencyKey
1135
- };
1136
- }
1137
- const secretScanTarget = [
1138
- input.slug ?? "",
1139
- input.user_messages_summary ?? "",
1140
- input.session_context ?? "",
1141
- input.must_read_if ?? "",
1142
- ...input.intent_clues ?? [],
1143
- ...input.tags ?? [],
1144
- ...input.evidence_paths ?? []
1145
- ].join("\n");
1146
- if (hasSecrets(secretScanTarget)) {
1147
- await emitEventBestEffort(projectRoot, {
1148
- event_type: "knowledge_archive_attempted",
1149
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1150
- correlation_id: primarySession,
1151
- session_id: primarySession,
1152
- reason: `extract_knowledge:${sanitizedSlug || input.slug}:secret_detected`
1153
- });
1154
- return {
1155
- pending_path: "",
1156
- idempotency_key: idempotencyKey
1157
- };
1158
- }
1159
- const semanticScope = input.semantic_scope;
1160
- const scopeIsPersonal = semanticScope !== void 0 && isPersonalScope(semanticScope);
1161
- if (semanticScope !== void 0 && input.layer !== void 0 && scopeIsPersonal !== (input.layer === "personal")) {
1162
- throw new Error(
1163
- `semantic_scope '${semanticScope}' conflicts with compatibility layer '${input.layer}'`
1164
- );
1165
- }
1166
- const layer = scopeIsPersonal ? "personal" : input.layer ?? "team";
1167
- let relevanceScope = input.relevance_scope;
1168
- let relevancePaths = input.relevance_paths;
1169
- const shouldAutoDegrade = layer === "personal" && relevanceScope === "narrow";
1170
- if (shouldAutoDegrade) {
1171
- relevanceScope = "broad";
1172
- relevancePaths = [];
1173
- await emitEventBestEffort(projectRoot, {
1174
- event_type: "knowledge_scope_degraded",
1175
- stable_id: `pending:${idempotencyKey}`,
1176
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1177
- from_scope: "narrow",
1178
- to_scope: "broad",
1179
- reason: "personal-implies-broad"
1180
- });
1181
- }
1182
- const baseDir = pendingBase(layer, projectRoot, semanticScope);
1183
- const { absolutePath, sanitizedSlug: chosenSlug, idempotencyKey: chosenKey } = await resolveDisambiguatedSlugPath({
1184
- baseDir,
1185
- type: input.type,
1186
- slug: sanitizedSlug,
1187
- primarySession,
1188
- baseIdempotencyKey: idempotencyKey
1189
- });
1190
- const reportedPath = toPosixPath(absolutePath);
1191
- const effectiveSanitizedSlug = chosenSlug;
1192
- const effectiveIdempotencyKey = chosenKey;
1193
- const writeScopeMeta = resolveWriteScopeMeta(layer, projectRoot, semanticScope);
1194
- await ensureParentDirectory(absolutePath);
1195
- if (existsSync3(absolutePath)) {
1196
- const existing = await readFile3(absolutePath, "utf8");
1197
- const existingKey = readFrontmatterKey(existing, "x-fabric-idempotency-key");
1198
- if (existingKey === effectiveIdempotencyKey) {
1199
- const fresh2 = renderFreshEntry({
1200
- type: input.type,
1201
- sourceSessions,
1202
- idempotencyKey: effectiveIdempotencyKey,
1203
- summary,
1204
- recentPaths: input.recent_paths,
1205
- layer,
1206
- semanticScope: writeScopeMeta.semantic_scope,
1207
- visibilityStore: writeScopeMeta.visibility_store,
1208
- proposedReason: input.proposed_reason,
1209
- sessionContext: input.session_context,
1210
- relevanceScope,
1211
- relevancePaths,
1212
- intentClues: input.intent_clues,
1213
- techStack: input.tech_stack,
1214
- impact: input.impact,
1215
- mustReadIf: input.must_read_if,
1216
- onboardSlot: input.onboard_slot,
1217
- // v2.0.0-rc.37 NEW-37: pass-through topic tags.
1218
- tags: input.tags,
1219
- // v2.0.0-rc.37 NEW-7: pass-through evidence_paths to frontmatter.
1220
- evidencePaths: input.evidence_paths
1221
- });
1222
- const augmented = mergeEvidenceNotes(existing, fresh2);
1223
- await atomicWriteText(absolutePath, augmented);
1224
- await emitEventBestEffort(projectRoot, {
1225
- event_type: "knowledge_proposed",
1226
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1227
- correlation_id: primarySession,
1228
- session_id: primarySession,
1229
- reason: `extract_knowledge:${effectiveSanitizedSlug}`
1230
- });
1231
- return {
1232
- pending_path: reportedPath,
1233
- idempotency_key: effectiveIdempotencyKey
1234
- };
1235
- }
1236
- throw new Error(
1237
- `slug collision (unreachable after rc.37 NEW-6): pending file ${reportedPath} already exists with key ${existingKey ?? "<missing>"} != ${effectiveIdempotencyKey}`
1238
- );
1239
- }
1240
- const fresh = renderFreshEntry({
1241
- type: input.type,
1242
- sourceSessions,
1243
- idempotencyKey: effectiveIdempotencyKey,
1244
- summary,
1245
- recentPaths: input.recent_paths,
1246
- layer,
1247
- semanticScope: writeScopeMeta.semantic_scope,
1248
- visibilityStore: writeScopeMeta.visibility_store,
1249
- proposedReason: input.proposed_reason,
1250
- sessionContext: input.session_context,
1251
- relevanceScope,
1252
- relevancePaths,
1253
- intentClues: input.intent_clues,
1254
- techStack: input.tech_stack,
1255
- impact: input.impact,
1256
- mustReadIf: input.must_read_if,
1257
- onboardSlot: input.onboard_slot,
1258
- tags: input.tags,
1259
- // v2.0.0-rc.37 NEW-7: pass-through evidence_paths to frontmatter.
1260
- evidencePaths: input.evidence_paths
1261
- });
1262
- await atomicWriteText(absolutePath, fresh);
1263
- await emitEventBestEffort(projectRoot, {
1264
- event_type: "knowledge_proposed",
1265
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1266
- correlation_id: primarySession,
1267
- session_id: primarySession,
1268
- reason: `extract_knowledge:${effectiveSanitizedSlug}`
1269
- });
1091
+ const knowledge = extractKnowledgeFieldsFromFrontmatter(frontmatter);
1270
1092
  return {
1271
- pending_path: reportedPath,
1272
- idempotency_key: effectiveIdempotencyKey
1093
+ summary,
1094
+ intent_clues: extractInlineArray(frontmatter, "intent_clues"),
1095
+ tech_stack: extractInlineArray(frontmatter, "tech_stack"),
1096
+ impact: extractInlineArray(frontmatter, "impact"),
1097
+ must_read_if: extractScalar(frontmatter, "must_read_if") ?? summary,
1098
+ entities: extractInlineArray(frontmatter, "entities"),
1099
+ id: knowledge.id,
1100
+ knowledge_type: knowledge.knowledge_type,
1101
+ maturity: knowledge.maturity,
1102
+ knowledge_layer: knowledge.knowledge_layer,
1103
+ layer_reason: knowledge.layer_reason,
1104
+ created_at: knowledge.created_at,
1105
+ tags: knowledge.tags,
1106
+ relevance_scope: knowledge.relevance_scope,
1107
+ relevance_paths: knowledge.relevance_paths,
1108
+ // v2.2 H2-related (W1-T7): graph edges parsed from frontmatter.
1109
+ related: knowledge.related
1273
1110
  };
1274
1111
  }
1275
- var SLUG_DISAMBIGUATE_MAX_VARIANTS = 9;
1276
- async function resolveDisambiguatedSlugPath(args) {
1277
- for (let n = 1; n <= SLUG_DISAMBIGUATE_MAX_VARIANTS; n += 1) {
1278
- const candidateSlug = n === 1 ? args.slug : `${args.slug}-${n}`;
1279
- const candidatePath = join6(args.baseDir, args.type, `${candidateSlug}.md`);
1280
- const candidateKey = n === 1 ? args.baseIdempotencyKey : sha256(
1281
- JSON.stringify({
1282
- source_session: args.primarySession,
1283
- type: args.type,
1284
- slug: candidateSlug
1285
- })
1286
- );
1287
- if (!existsSync3(candidatePath)) {
1288
- return {
1289
- absolutePath: candidatePath,
1290
- sanitizedSlug: candidateSlug,
1291
- idempotencyKey: candidateKey
1292
- };
1293
- }
1294
- const existing = await readFile3(candidatePath, "utf8");
1295
- const existingKey = readFrontmatterKey(existing, "x-fabric-idempotency-key");
1296
- if (existingKey === candidateKey) {
1297
- return {
1298
- absolutePath: candidatePath,
1299
- sanitizedSlug: candidateSlug,
1300
- idempotencyKey: candidateKey
1301
- };
1302
- }
1112
+ function isForbiddenCrossLayerEdge(sourceLayer, targetId) {
1113
+ if (sourceLayer !== "team") {
1114
+ return false;
1303
1115
  }
1304
- throw new Error(
1305
- `slug exhaustion: tried ${args.slug}.md plus -2..-${SLUG_DISAMBIGUATE_MAX_VARIANTS} suffix variants and all slots are taken by entries with different idempotency_keys; rename slug at the caller and retry`
1306
- );
1116
+ const decoded = parseKnowledgeId(localKnowledgeIdFromReference(targetId));
1117
+ if (decoded === null) {
1118
+ return false;
1119
+ }
1120
+ return decoded.layer === "personal";
1307
1121
  }
1308
- function sanitizeSlug(raw) {
1309
- const lower = raw.toLowerCase();
1310
- const collapsed = lower.replace(/[^a-z0-9]+/g, "-");
1311
- const trimmed = collapsed.replace(/^-+|-+$/g, "");
1312
- if (trimmed.length === 0) {
1313
- return "";
1122
+ function localKnowledgeIdFromReference(ref) {
1123
+ const direct = parseKnowledgeId(ref);
1124
+ if (direct !== null) {
1125
+ return ref;
1314
1126
  }
1315
- return trimmed.slice(0, SLUG_MAX_LENGTH).replace(/-+$/g, "");
1127
+ const tail = ref.split(":").at(-1);
1128
+ return tail ?? ref;
1316
1129
  }
1317
- function renderFreshEntry(args) {
1318
- const createdAt = (/* @__PURE__ */ new Date()).toISOString();
1319
- const frontmatterLines = [
1320
- "---",
1321
- `type: ${args.type}`,
1322
- "maturity: draft",
1323
- `layer: ${args.layer}`,
1324
- // v2.1 global-refactor (W1/A1): scope coordinate (resolution axis) + the
1325
- // physical store this entry lives in. `layer` is retained for back-compat
1326
- // during the co-location retirement; `semantic_scope`/`visibility_store` are
1327
- // the v2.1 source of truth (scope ⊥ store).
1328
- `semantic_scope: ${args.semanticScope}`,
1329
- `visibility_store: ${quoteRelevancePath(args.visibilityStore)}`,
1330
- `created_at: ${createdAt}`,
1331
- `source_sessions: [${args.sourceSessions.map((s) => JSON.stringify(s)).join(", ")}]`,
1332
- `proposed_reason: ${args.proposedReason}`,
1333
- // rc.31 BUG-2.9/2.1: persist the caller-supplied summary in frontmatter so
1334
- // knowledge-meta-builder.extractDescriptionFromFrontmatter picks it up
1335
- // directly. Without this, the meta-builder fell back to extractRule
1336
- // Description's h1-or-stable-id-or-placeholder synthesis (line ~944),
1337
- // which made user-visible description.summary == stable_id for any
1338
- // pending file whose body started with h2-only sections (`## Summary` is
1339
- // the canonical pending shape). The frontmatter `summary:` line is the
1340
- // canonical source-of-truth: `extractDescriptionFromFrontmatter` reads it
1341
- // before extractRuleDescription's fallback kicks in.
1342
- `summary: ${quoteRelevancePath(args.summary)}`,
1343
- // v2.0.0-rc.37 NEW-37: render caller-supplied tags or fall back to empty
1344
- // array. Empty array still legal but doctor's knowledge_tags_empty_ratio
1345
- // lint will warn at the corpus level. Encourages 2-4 kebab-case topic
1346
- // strings per entry for cross-entry retrieval signal.
1347
- args.tags !== void 0 && args.tags.length > 0 ? `tags: [${args.tags.map((t) => quoteRelevancePath(t)).join(", ")}]` : "tags: []"
1348
- ];
1349
- if (args.relevanceScope !== void 0) {
1350
- frontmatterLines.push(`relevance_scope: ${args.relevanceScope}`);
1130
+ function extractKnowledgeFieldsFromFrontmatter(frontmatter) {
1131
+ const rawId = extractScalar(frontmatter, "id");
1132
+ const rawType = extractScalar(frontmatter, "type");
1133
+ const rawMaturity = extractScalar(frontmatter, "maturity");
1134
+ const rawLayer = extractScalar(frontmatter, "layer");
1135
+ const rawLayerReason = extractScalar(frontmatter, "layer_reason");
1136
+ const rawCreatedAt = extractScalar(frontmatter, "created_at");
1137
+ let id;
1138
+ if (rawId !== void 0) {
1139
+ const parsed = StableIdSchema.safeParse(rawId);
1140
+ if (parsed.success) {
1141
+ id = parsed.data;
1142
+ } else {
1143
+ process.stderr.write(`[fabric] frontmatter: invalid knowledge id format ${JSON.stringify(rawId)}; skipping
1144
+ `);
1145
+ }
1351
1146
  }
1352
- if (args.relevancePaths !== void 0) {
1353
- const pathsBody = args.relevancePaths.map((p) => quoteRelevancePath(p)).join(", ");
1354
- frontmatterLines.push(`relevance_paths: [${pathsBody}]`);
1147
+ const SINGULAR_TO_PLURAL = {
1148
+ model: "models",
1149
+ decision: "decisions",
1150
+ guideline: "guidelines",
1151
+ pitfall: "pitfalls",
1152
+ process: "processes"
1153
+ };
1154
+ let knowledge_type;
1155
+ if (rawType !== void 0) {
1156
+ const normalized = SINGULAR_TO_PLURAL[rawType] ?? rawType;
1157
+ const parsed = KnowledgeTypeSchema.safeParse(normalized);
1158
+ if (parsed.success) {
1159
+ knowledge_type = parsed.data;
1160
+ } else {
1161
+ process.stderr.write(`[fabric] frontmatter: unknown knowledge type ${JSON.stringify(rawType)}; skipping
1162
+ `);
1163
+ }
1355
1164
  }
1356
- if (args.intentClues !== void 0) {
1357
- const body2 = args.intentClues.map((s) => quoteRelevancePath(s)).join(", ");
1358
- frontmatterLines.push(`intent_clues: [${body2}]`);
1165
+ let maturity;
1166
+ if (rawMaturity !== void 0) {
1167
+ const parsed = MaturitySchema.safeParse(rawMaturity);
1168
+ if (parsed.success) {
1169
+ maturity = parsed.data;
1170
+ } else {
1171
+ process.stderr.write(`[fabric] frontmatter: unknown maturity ${JSON.stringify(rawMaturity)}; skipping
1172
+ `);
1173
+ }
1359
1174
  }
1360
- if (args.techStack !== void 0) {
1361
- const body2 = args.techStack.map((s) => quoteRelevancePath(s)).join(", ");
1362
- frontmatterLines.push(`tech_stack: [${body2}]`);
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
+ `);
1183
+ }
1363
1184
  }
1364
- if (args.impact !== void 0) {
1365
- const body2 = args.impact.map((s) => quoteRelevancePath(s)).join(", ");
1366
- frontmatterLines.push(`impact: [${body2}]`);
1185
+ let created_at;
1186
+ if (rawCreatedAt !== void 0) {
1187
+ if (!Number.isNaN(Date.parse(rawCreatedAt))) {
1188
+ created_at = rawCreatedAt;
1189
+ } else {
1190
+ process.stderr.write(`[fabric] frontmatter: malformed created_at ${JSON.stringify(rawCreatedAt)}; skipping
1191
+ `);
1192
+ }
1367
1193
  }
1368
- if (args.mustReadIf !== void 0) {
1369
- frontmatterLines.push(`must_read_if: ${quoteRelevancePath(args.mustReadIf)}`);
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
+ }
1370
1204
  }
1371
- if (args.onboardSlot !== void 0) {
1372
- frontmatterLines.push(`onboard_slot: ${args.onboardSlot}`);
1205
+ const tags = extractInlineArray(frontmatter, "tags");
1206
+ const rawRelevanceScope = extractScalar(frontmatter, "relevance_scope");
1207
+ const relevance_scope = rawRelevanceScope === "narrow" || rawRelevanceScope === "broad" ? rawRelevanceScope : "broad";
1208
+ const relevance_paths = extractInlineArray(frontmatter, "relevance_paths");
1209
+ const rawRelated = extractInlineArray(frontmatter, "related");
1210
+ const sourceLayer = knowledge_layer ?? (id !== void 0 ? parseKnowledgeId(id)?.layer ?? "team" : "team");
1211
+ const related = rawRelated.filter((targetId) => {
1212
+ if (isForbiddenCrossLayerEdge(sourceLayer, targetId)) {
1213
+ process.stderr.write(
1214
+ `[fabric] frontmatter: stripping forbidden cross-layer related edge ${id ?? "(team entry)"} \u2192 ${targetId} (KT\u2192KP topology leak; \xA74 privacy iron law)
1215
+ `
1216
+ );
1217
+ return false;
1218
+ }
1219
+ return true;
1220
+ });
1221
+ return {
1222
+ id,
1223
+ knowledge_type,
1224
+ maturity,
1225
+ knowledge_layer,
1226
+ layer_reason: rawLayerReason,
1227
+ created_at,
1228
+ tags: tags.length > 0 ? tags : void 0,
1229
+ relevance_scope,
1230
+ relevance_paths,
1231
+ related: related.length > 0 ? related : void 0
1232
+ };
1233
+ }
1234
+ function extractScalar(frontmatter, key) {
1235
+ const pattern = new RegExp(`^${escapeRegExp(key)}:\\s*(.+?)\\s*$`, "mu");
1236
+ const match = pattern.exec(frontmatter);
1237
+ if (match === null) {
1238
+ return void 0;
1373
1239
  }
1374
- if (args.evidencePaths !== void 0 && args.evidencePaths.length > 0) {
1375
- const body2 = args.evidencePaths.map((p) => quoteRelevancePath(p)).join(", ");
1376
- frontmatterLines.push(`evidence_paths: [${body2}]`);
1240
+ return unquote(match[1].trim());
1241
+ }
1242
+ function extractInlineArray(frontmatter, key) {
1243
+ const pattern = new RegExp(`^${escapeRegExp(key)}:\\s*\\[(.*?)\\]\\s*$`, "mu");
1244
+ const match = pattern.exec(frontmatter);
1245
+ if (match === null) {
1246
+ return [];
1377
1247
  }
1378
- frontmatterLines.push(
1379
- `x-fabric-idempotency-key: ${args.idempotencyKey}`,
1380
- "---"
1248
+ return match[1].split(",").map((item) => unquote(item.trim())).filter((item) => item.length > 0);
1249
+ }
1250
+ function unquote(value) {
1251
+ return value.replace(/^["'](.*)["']$/u, "$1");
1252
+ }
1253
+ function escapeRegExp(value) {
1254
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
1255
+ }
1256
+
1257
+ // src/services/cross-store-recall.ts
1258
+ var readSetWalkCache = /* @__PURE__ */ new Map();
1259
+ var readSetWalkCount = 0;
1260
+ function readSetWalkCacheKey(projectRoot) {
1261
+ return `${projectRoot}\0${process.env.FABRIC_HOME ?? ""}`;
1262
+ }
1263
+ async function readSetFingerprint(refs) {
1264
+ const parts = await Promise.all(
1265
+ refs.map(async (ref) => {
1266
+ try {
1267
+ const fileStat = await stat(ref.file);
1268
+ return `${ref.store_uuid}|${ref.alias}|${ref.file}|${fileStat.size}|${fileStat.mtimeMs}`;
1269
+ } catch {
1270
+ return `${ref.store_uuid}|${ref.alias}|${ref.file}|missing`;
1271
+ }
1272
+ })
1381
1273
  );
1382
- const frontmatter = frontmatterLines.join("\n");
1383
- const reasonExplanation = PROPOSED_REASON_DESCRIPTIONS_BY_LOCALE[resolveGlobalLocale()][args.proposedReason];
1384
- const body = [
1385
- "",
1386
- "## Summary",
1387
- "",
1388
- args.summary,
1389
- "",
1390
- "## Why proposed",
1391
- "",
1392
- `${args.proposedReason} \u2014 ${reasonExplanation}`,
1393
- "",
1394
- "## Session context",
1395
- "",
1396
- args.sessionContext,
1397
- "",
1398
- "## Evidence",
1399
- "",
1400
- renderEvidenceBlock(args.summary, args.recentPaths),
1401
- ""
1402
- ].join("\n");
1403
- return `${frontmatter}
1404
- ${body}`;
1274
+ return parts.sort().join("\n");
1405
1275
  }
1406
- function quoteRelevancePath(value) {
1407
- const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
1408
- return `"${escaped}"`;
1276
+ var SEMANTIC_SCOPE_LINE = /^semantic_scope:\s*"?([^"\n]+?)"?\s*$/mu;
1277
+ function readSemanticScope(source, layer) {
1278
+ const match = SEMANTIC_SCOPE_LINE.exec(source);
1279
+ return match?.[1] ?? layer;
1409
1280
  }
1410
- function renderEvidenceBlock(summary, recentPaths) {
1411
- const pathLines = recentPaths.length === 0 ? "_(no recent paths reported)_" : recentPaths.map((p) => `- ${p}`).join("\n");
1412
- return [
1413
- "Recent paths:",
1414
- "",
1415
- pathLines,
1416
- "",
1417
- "Notes:",
1418
- "",
1419
- `- ${summary.trim()}`
1420
- ].join("\n");
1281
+ function filterByActiveProject(entries, activeProject) {
1282
+ if (activeProject === void 0 || activeProject.length === 0) {
1283
+ return entries;
1284
+ }
1285
+ const current = `project:${activeProject}`;
1286
+ return entries.filter(
1287
+ (e) => scopeRoot(e.semanticScope) !== "project" || e.semanticScope === current
1288
+ );
1421
1289
  }
1422
- function mergeEvidenceNotes(existing, fresh) {
1423
- const freshSplit = splitAtEvidence(fresh);
1424
- if (freshSplit === null) {
1425
- return fresh.endsWith("\n") ? fresh : `${fresh}
1426
- `;
1290
+ function activeProjectOf(projectRoot) {
1291
+ const ap = loadProjectConfig2(projectRoot)?.active_project;
1292
+ return ap !== void 0 && ap.length > 0 ? ap : void 0;
1293
+ }
1294
+ async function resolveReadSetSnapshot(projectRoot) {
1295
+ const resolveInput = buildStoreResolveInput2(projectRoot);
1296
+ if (resolveInput === null) {
1297
+ return null;
1427
1298
  }
1428
- const freshHead = freshSplit.head;
1429
- const oldEvidence = collectEvidenceItems(existing);
1430
- const freshEvidence = collectEvidenceItems(fresh);
1431
- const mergedNotes = [];
1432
- const seenNotes = /* @__PURE__ */ new Set();
1433
- for (const note of [...oldEvidence.notes, ...freshEvidence.notes]) {
1434
- const key = note.replace(/\s+/gu, " ").trim();
1435
- if (key.length === 0) continue;
1436
- if (seenNotes.has(key)) continue;
1437
- seenNotes.add(key);
1438
- mergedNotes.push(note);
1299
+ const readSet = createStoreResolver2().resolveReadSet(resolveInput);
1300
+ if (readSet.stores.length === 0) {
1301
+ return null;
1439
1302
  }
1440
- const mergedPaths = [];
1441
- const seenPaths = /* @__PURE__ */ new Set();
1442
- for (const p of [...oldEvidence.paths, ...freshEvidence.paths]) {
1443
- const key = p.trim();
1444
- if (key.length === 0) continue;
1445
- if (seenPaths.has(key)) continue;
1446
- seenPaths.add(key);
1447
- mergedPaths.push(key);
1303
+ const personalUuids = new Set(
1304
+ resolveInput.mountedStores.filter((s) => s.personal).map((s) => s.store_uuid)
1305
+ );
1306
+ const globalRoot = resolveGlobalRoot2();
1307
+ const dirs = readSet.stores.map((entry) => ({
1308
+ store_uuid: entry.store_uuid,
1309
+ alias: entry.alias,
1310
+ dir: join6(
1311
+ globalRoot,
1312
+ storeRelativePathForMount2(
1313
+ resolveInput.mountedStores.find((s) => s.store_uuid === entry.store_uuid) ?? {
1314
+ store_uuid: entry.store_uuid
1315
+ }
1316
+ )
1317
+ )
1318
+ }));
1319
+ return {
1320
+ refs: await readKnowledgeAcrossStores(dirs),
1321
+ personalUuids
1322
+ };
1323
+ }
1324
+ async function walkReadSetStores(projectRoot) {
1325
+ const snapshot = await resolveReadSetSnapshot(projectRoot);
1326
+ if (snapshot === null) {
1327
+ return [];
1448
1328
  }
1449
- const pathLines = mergedPaths.length === 0 ? "_(no recent paths reported)_" : mergedPaths.map((p) => `- ${p}`).join("\n");
1450
- const noteLines = mergedNotes.length === 0 ? "_(no notes recorded)_" : mergedNotes.map((n) => `- ${n}`).join("\n");
1451
- const evidenceBody = [
1452
- "Recent paths:",
1453
- "",
1454
- pathLines,
1455
- "",
1456
- "Notes:",
1457
- "",
1458
- noteLines
1459
- ].join("\n");
1460
- return `${freshHead}
1461
- ## Evidence
1462
-
1463
- ${evidenceBody}
1464
- `;
1329
+ const key = readSetWalkCacheKey(projectRoot);
1330
+ const fingerprint = await readSetFingerprint(snapshot.refs);
1331
+ const cached = readSetWalkCache.get(key);
1332
+ if (cached !== void 0 && cached.fingerprint === fingerprint) {
1333
+ return cached.entries.slice();
1334
+ }
1335
+ const entries = await walkReadSetStoresUncached(snapshot);
1336
+ readSetWalkCache.set(key, { fingerprint, entries });
1337
+ return entries.slice();
1465
1338
  }
1466
- function splitAtEvidence(content) {
1467
- const tail = content.endsWith("\n") ? content : `${content}
1468
- `;
1469
- const match = /^([\s\S]*?)(\n## Evidence(?:\s*\(call \d+\))?\s*\n)/u.exec(tail);
1470
- if (match === null) return null;
1471
- return { head: match[1] ?? "" };
1339
+ async function walkReadSetStoresUncached(snapshot) {
1340
+ readSetWalkCount += 1;
1341
+ const entries = await Promise.all(snapshot.refs.map(async (ref) => {
1342
+ let source;
1343
+ try {
1344
+ source = await readFile3(ref.file, "utf8");
1345
+ } catch {
1346
+ return null;
1347
+ }
1348
+ const stableId = deriveRuleIdentity(ref.file, source, void 0).stableId;
1349
+ const layer = snapshot.personalUuids.has(ref.store_uuid) ? "personal" : "team";
1350
+ return {
1351
+ qualifiedId: `${ref.alias}:${stableId}`,
1352
+ file: ref.file,
1353
+ type: ref.type,
1354
+ alias: ref.alias,
1355
+ layer,
1356
+ semanticScope: readSemanticScope(source, layer),
1357
+ source
1358
+ };
1359
+ }));
1360
+ return entries.filter((entry) => entry !== null);
1472
1361
  }
1473
- function collectEvidenceItems(content) {
1474
- const notes = [];
1475
- const paths = [];
1476
- const evidenceBlockRe = /\n## Evidence(?:\s*\(call \d+\))?\s*\n([\s\S]*?)(?=\n## |$)/gu;
1477
- let m;
1478
- while ((m = evidenceBlockRe.exec(`${content}
1479
- `)) !== null) {
1480
- const block = m[1] ?? "";
1481
- const pathSection = /Recent paths:\s*\n([\s\S]*?)(?:\n\s*Notes:|$)/u.exec(block);
1482
- if (pathSection !== null) {
1483
- for (const rawLine of (pathSection[1] ?? "").split(/\r?\n/u)) {
1484
- const t = rawLine.trim();
1485
- if (t.startsWith("- ")) {
1486
- paths.push(t.slice(2).trim());
1487
- }
1488
- }
1362
+ async function buildCrossStoreRawItems(projectRoot) {
1363
+ const items = [];
1364
+ const activeProject = activeProjectOf(projectRoot);
1365
+ for (const entry of filterByActiveProject(await walkReadSetStores(projectRoot), activeProject)) {
1366
+ const baseDescription = extractRuleDescription(entry.source);
1367
+ if (baseDescription === void 0) {
1368
+ continue;
1489
1369
  }
1490
- const notesSection = /Notes:\s*\n([\s\S]*?)$/u.exec(block);
1491
- const noteBody = (notesSection !== null ? notesSection[1] : block) ?? "";
1492
- const bulletLines = [];
1493
- let prose = [];
1494
- for (const rawLine of noteBody.split(/\r?\n/u)) {
1495
- const t = rawLine.trim();
1496
- if (t.length === 0) continue;
1497
- if (t.startsWith("- ")) {
1498
- if (prose.length > 0) {
1499
- notes.push(prose.join(" ").trim());
1500
- prose = [];
1501
- }
1502
- bulletLines.push(t.slice(2).trim());
1503
- } else {
1504
- prose.push(t);
1370
+ items.push({
1371
+ stable_id: entry.qualifiedId,
1372
+ description: {
1373
+ ...baseDescription,
1374
+ knowledge_layer: entry.layer,
1375
+ semantic_scope: entry.semanticScope
1505
1376
  }
1506
- }
1507
- if (prose.length > 0) notes.push(prose.join(" ").trim());
1508
- for (const n of bulletLines) notes.push(n);
1377
+ });
1509
1378
  }
1510
- return { notes, paths };
1379
+ return items;
1511
1380
  }
1512
- function readFrontmatterKey(content, key) {
1513
- const match = /^---\n([\s\S]*?)\n---/u.exec(content);
1514
- if (match === null) {
1515
- return void 0;
1516
- }
1517
- const block = match[1];
1518
- if (block === void 0) {
1519
- return void 0;
1520
- }
1521
- for (const rawLine of block.split(/\r?\n/u)) {
1522
- const line = rawLine.trim();
1523
- const sep5 = line.indexOf(":");
1524
- if (sep5 === -1) continue;
1525
- const k = line.slice(0, sep5).trim();
1526
- if (k === key) {
1527
- return line.slice(sep5 + 1).trim();
1381
+ async function buildCrossStoreBodyIndex(projectRoot) {
1382
+ const index = /* @__PURE__ */ new Map();
1383
+ const activeProject = activeProjectOf(projectRoot);
1384
+ for (const entry of filterByActiveProject(await walkReadSetStores(projectRoot), activeProject)) {
1385
+ if (!index.has(entry.qualifiedId)) {
1386
+ index.set(entry.qualifiedId, { file: entry.file, layer: entry.layer });
1528
1387
  }
1529
1388
  }
1530
- return void 0;
1389
+ return index;
1531
1390
  }
1532
- async function emitEventBestEffort(projectRoot, event) {
1391
+ var ALWAYS_ACTIVE_TYPES = /* @__PURE__ */ new Set(["guidelines", "models"]);
1392
+ async function buildKnowledgeCensus(projectRoot) {
1393
+ const census = {
1394
+ by_type: {},
1395
+ by_layer: { team: 0, personal: 0, project: 0 },
1396
+ broad_by_type: {},
1397
+ narrow_total: 0,
1398
+ dropped_other_project: 0,
1399
+ total: 0
1400
+ };
1533
1401
  try {
1534
- await appendEventLedgerEvent(projectRoot, event);
1535
- } catch {
1536
- }
1537
- }
1538
-
1539
- // src/tools/extract-knowledge.ts
1540
- function registerExtractKnowledge(server, tracker) {
1541
- server.registerTool(
1542
- "fab_extract_knowledge",
1543
- {
1544
- description: "Persist a proposed pending knowledge entry into the resolved write-target store under knowledge/pending/<type>/<slug>.md. Idempotent on (source_sessions[0], type, slug); repeat calls append evidence rather than overwrite. Skill-side tool \u2014 invoked at session-stop.",
1545
- inputSchema: FabExtractKnowledgeInputShape,
1546
- outputSchema: FabExtractKnowledgeOutputSchema.shape,
1547
- annotations: fabExtractKnowledgeAnnotations
1548
- },
1549
- async (input) => {
1550
- const requestId = randomUUID2();
1551
- tracker?.enter(requestId);
1552
- try {
1553
- const gateResult = await awaitFirstReconcileGate();
1554
- const gateWarn = gateWarning(gateResult);
1555
- const validated = FabExtractKnowledgeInputSchema.parse(input);
1556
- const projectRoot = resolveProjectRoot();
1557
- const result = await extractKnowledge(projectRoot, validated);
1558
- const response = { ...result };
1559
- if (gateWarn) {
1560
- response.warnings = [gateWarn];
1402
+ const activeProject = activeProjectOf(projectRoot);
1403
+ const all = await walkReadSetStores(projectRoot);
1404
+ const kept = filterByActiveProject(all, activeProject);
1405
+ census.dropped_other_project = all.length - kept.length;
1406
+ for (const entry of kept) {
1407
+ const desc = extractRuleDescription(entry.source);
1408
+ const type = desc?.knowledge_type;
1409
+ const isNarrow = desc?.relevance_scope === "narrow";
1410
+ if (typeof type === "string") {
1411
+ census.by_type[type] = (census.by_type[type] ?? 0) + 1;
1412
+ if (!isNarrow) {
1413
+ census.broad_by_type[type] = (census.broad_by_type[type] ?? 0) + 1;
1561
1414
  }
1562
- const payloadLimits = readPayloadLimits(projectRoot);
1563
- const serialized = JSON.stringify(response);
1564
- const guardResult = enforcePayloadLimit(serialized, payloadLimits);
1565
- response.warnings = appendPayloadWarning(
1566
- response.warnings,
1567
- guardResult,
1568
- "fab_extract_knowledge produced an unexpectedly large response \u2014 extract from a smaller span of text."
1569
- );
1570
- return {
1571
- content: [{ type: "text", text: JSON.stringify(response) }],
1572
- structuredContent: response
1573
- };
1574
- } finally {
1575
- tracker?.exit(requestId);
1576
1415
  }
1416
+ if (isNarrow) census.narrow_total += 1;
1417
+ if (scopeRoot(entry.semanticScope) === "project") {
1418
+ census.by_layer.project += 1;
1419
+ } else {
1420
+ census.by_layer[entry.layer] += 1;
1421
+ }
1422
+ census.total += 1;
1577
1423
  }
1578
- );
1424
+ } catch {
1425
+ }
1426
+ return census;
1579
1427
  }
1580
-
1581
- // src/tools/recall.ts
1582
- import { randomUUID as randomUUID3 } from "crypto";
1583
- import {
1584
- recallAnnotations,
1585
- recallInputSchema,
1586
- recallOutputSchema
1587
- } from "@fenglimg/fabric-shared/schemas/api-contracts";
1588
- import { enforcePayloadLimit as enforcePayloadLimit2 } from "@fenglimg/fabric-shared/node/mcp-payload-guard";
1589
-
1590
- // src/services/plan-context.ts
1591
- import {
1592
- buildStoreResolveInput as buildStoreResolveInput3,
1593
- createStoreResolver as createStoreResolver3,
1594
- resolveCandidates,
1595
- tokenize as tokenize2
1596
- } from "@fenglimg/fabric-shared";
1597
- import {
1598
- trimToPayloadBudget
1599
- } from "@fenglimg/fabric-shared/node/mcp-payload-guard";
1600
-
1601
- // src/services/get-knowledge.ts
1602
- function normalizeKnowledgePath(value) {
1603
- return value.replaceAll("\\", "/");
1428
+ async function buildAlwaysActiveBodies(projectRoot) {
1429
+ const out = [];
1430
+ try {
1431
+ const activeProject = activeProjectOf(projectRoot);
1432
+ for (const entry of filterByActiveProject(
1433
+ await walkReadSetStores(projectRoot),
1434
+ activeProject
1435
+ )) {
1436
+ const desc = extractRuleDescription(entry.source);
1437
+ if (desc === void 0) continue;
1438
+ const type = desc.knowledge_type;
1439
+ if (typeof type !== "string" || !ALWAYS_ACTIVE_TYPES.has(type)) continue;
1440
+ if (desc.relevance_scope === "narrow") continue;
1441
+ out.push({
1442
+ stable_id: entry.qualifiedId,
1443
+ type,
1444
+ layer: entry.layer,
1445
+ summary: typeof desc.summary === "string" ? desc.summary : "",
1446
+ body: extractBody(entry.source)
1447
+ });
1448
+ }
1449
+ } catch {
1450
+ return [];
1451
+ }
1452
+ return out;
1604
1453
  }
1605
-
1606
- // src/services/cross-store-recall.ts
1607
- import { readFile as readFile4, stat } from "fs/promises";
1608
- import { join as join7 } from "path";
1609
- import {
1610
- buildStoreResolveInput as buildStoreResolveInput2,
1611
- createStoreResolver as createStoreResolver2,
1612
- loadProjectConfig as loadProjectConfig2,
1613
- readKnowledgeAcrossStores,
1614
- resolveGlobalRoot as resolveGlobalRoot2,
1615
- scopeRoot,
1616
- storeRelativePathForMount as storeRelativePathForMount2
1617
- } from "@fenglimg/fabric-shared";
1618
-
1619
- // src/services/knowledge-meta-builder.ts
1620
- import {
1621
- deriveAgentsMetaStableId,
1622
- isKnowledgeStableId,
1623
- KnowledgeTypeSchema,
1624
- LayerSchema,
1625
- MaturitySchema,
1626
- parseKnowledgeId,
1627
- StableIdSchema
1628
- } from "@fenglimg/fabric-shared";
1629
- function toAgentsCompatiblePath(contentRef) {
1630
- return contentRef.replace(/^~\/\.fabric\/knowledge\//u, ".fabric/agents/").replace(/^\.fabric\/knowledge\//u, ".fabric/agents/");
1454
+ async function computeReadSetRevision(projectRoot) {
1455
+ const revisionSource = (await walkReadSetStores(projectRoot)).filter((entry) => !entry.file.includes("/knowledge/pending/")).map((entry) => `${entry.qualifiedId}|${sha256(entry.source)}`).sort().join("\n");
1456
+ return sha256(revisionSource);
1631
1457
  }
1632
- function deriveRuleIdentity(file, source, existing) {
1633
- const declaredKnowledgeId = extractDeclaredKnowledgeId(source);
1634
- if (declaredKnowledgeId !== void 0) {
1635
- return {
1636
- stableId: declaredKnowledgeId,
1637
- identitySource: "declared"
1638
- };
1458
+ async function collectStoreCanonicalEntries(projectRoot) {
1459
+ const out = [];
1460
+ for (const entry of await walkReadSetStores(projectRoot)) {
1461
+ if (entry.file.includes("/knowledge/pending/")) {
1462
+ continue;
1463
+ }
1464
+ const description = extractRuleDescription(entry.source);
1465
+ if (description === void 0) {
1466
+ continue;
1467
+ }
1468
+ out.push({
1469
+ stableId: entry.qualifiedId.slice(entry.alias.length + 1),
1470
+ qualifiedId: entry.qualifiedId,
1471
+ file: entry.file,
1472
+ type: entry.type,
1473
+ layer: entry.layer,
1474
+ body: entry.source,
1475
+ description
1476
+ });
1639
1477
  }
1640
- if (existing?.stable_id !== void 0 && isKnowledgeStableId(existing.stable_id)) {
1641
- return {
1642
- stableId: existing.stable_id,
1643
- identitySource: "declared"
1644
- };
1478
+ return out;
1479
+ }
1480
+ async function collectStoreKnowledgeSummaries(projectRoot) {
1481
+ const out = [];
1482
+ for (const entry of await walkReadSetStores(projectRoot)) {
1483
+ const description = extractRuleDescription(entry.source);
1484
+ if (description === void 0) {
1485
+ continue;
1486
+ }
1487
+ out.push({
1488
+ stableId: entry.qualifiedId,
1489
+ summary: description.summary ?? "",
1490
+ layer: entry.layer
1491
+ });
1645
1492
  }
1646
- const declaredStableId = extractDeclaredStableId(source);
1647
- const derivedStableId = deriveAgentsMetaStableId(toAgentsCompatiblePath(file));
1648
- if (declaredStableId !== void 0) {
1649
- return {
1650
- stableId: declaredStableId,
1651
- identitySource: "declared"
1652
- };
1493
+ return out;
1494
+ }
1495
+
1496
+ // src/services/bm25.ts
1497
+ import { tokenize } from "@fenglimg/fabric-shared";
1498
+ var K1 = 1.5;
1499
+ var BM25_FIELDS = ["title", "summary", "tags", "body"];
1500
+ var FIELD_CONFIGS = {
1501
+ title: { boost: 3, b: 0.3 },
1502
+ tags: { boost: 2, b: 0 },
1503
+ summary: { boost: 1.5, b: 0.75 },
1504
+ body: { boost: 1, b: 0.75 }
1505
+ };
1506
+ function emptyFieldRecord(make) {
1507
+ return { title: make(), summary: make(), tags: make(), body: make() };
1508
+ }
1509
+ function buildBm25Model(docs) {
1510
+ const totalDocs = docs.length;
1511
+ const documentFrequency = /* @__PURE__ */ new Map();
1512
+ const perDoc = /* @__PURE__ */ new Map();
1513
+ const totalFieldLength = emptyFieldRecord(() => 0);
1514
+ for (const doc of docs) {
1515
+ const fieldTermFreq = emptyFieldRecord(() => /* @__PURE__ */ new Map());
1516
+ const fieldLength = emptyFieldRecord(() => 0);
1517
+ const docTerms = /* @__PURE__ */ new Set();
1518
+ for (const field of BM25_FIELDS) {
1519
+ const tokens = doc.fields[field] ?? [];
1520
+ fieldLength[field] = tokens.length;
1521
+ totalFieldLength[field] += tokens.length;
1522
+ const tf = fieldTermFreq[field];
1523
+ for (const term of tokens) {
1524
+ tf.set(term, (tf.get(term) ?? 0) + 1);
1525
+ docTerms.add(term);
1526
+ }
1527
+ }
1528
+ for (const term of docTerms) {
1529
+ documentFrequency.set(term, (documentFrequency.get(term) ?? 0) + 1);
1530
+ }
1531
+ perDoc.set(doc.id, { fieldTermFreq, fieldLength });
1653
1532
  }
1654
- if (existing?.identity_source === "declared" && existing.stable_id !== void 0 && existing.stable_id !== derivedStableId) {
1655
- return {
1656
- stableId: existing.stable_id,
1657
- identitySource: "declared"
1658
- };
1533
+ const avgFieldLength = emptyFieldRecord(() => 0);
1534
+ for (const field of BM25_FIELDS) {
1535
+ avgFieldLength[field] = totalDocs > 0 ? totalFieldLength[field] / totalDocs : 0;
1659
1536
  }
1537
+ const idf = (term) => {
1538
+ const n = documentFrequency.get(term) ?? 0;
1539
+ return Math.log(1 + (totalDocs - n + 0.5) / (n + 0.5));
1540
+ };
1660
1541
  return {
1661
- stableId: derivedStableId,
1662
- identitySource: "derived"
1542
+ scoreDoc(id, queryTerms) {
1543
+ const data = perDoc.get(id);
1544
+ if (data === void 0 || queryTerms.length === 0) {
1545
+ return 0;
1546
+ }
1547
+ let score = 0;
1548
+ const scoredTerms = /* @__PURE__ */ new Set();
1549
+ for (const term of queryTerms) {
1550
+ if (scoredTerms.has(term)) {
1551
+ continue;
1552
+ }
1553
+ scoredTerms.add(term);
1554
+ let pseudoTermFreq = 0;
1555
+ for (const field of BM25_FIELDS) {
1556
+ const config = FIELD_CONFIGS[field];
1557
+ if (config.boost === 0) {
1558
+ continue;
1559
+ }
1560
+ const freq = data.fieldTermFreq[field].get(term);
1561
+ if (freq === void 0 || freq === 0) {
1562
+ continue;
1563
+ }
1564
+ const avg = avgFieldLength[field] || 1;
1565
+ const norm = 1 - config.b + config.b * (data.fieldLength[field] / avg);
1566
+ pseudoTermFreq += config.boost * (freq / (norm || 1));
1567
+ }
1568
+ if (pseudoTermFreq === 0) {
1569
+ continue;
1570
+ }
1571
+ score += idf(term) * (pseudoTermFreq * (K1 + 1) / (pseudoTermFreq + K1));
1572
+ }
1573
+ return score;
1574
+ }
1663
1575
  };
1664
1576
  }
1665
- function extractDeclaredStableId(source) {
1666
- const match = /^(?:\uFEFF)?(?:---\r?\n[\s\S]*?\r?\n---\s*(?:\r?\n|$))?<!--\s*fab:rule-id\s+([A-Za-z0-9][A-Za-z0-9/_-]*)\s*-->\s*(?:\r?\n|$)/u.exec(source);
1667
- return match?.[1];
1577
+ function buildQueryTerms(text) {
1578
+ return tokenize(text);
1668
1579
  }
1669
- function extractDeclaredKnowledgeId(source) {
1670
- const frontmatter = /^(?:\uFEFF)?---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/u.exec(source);
1671
- if (frontmatter === null) {
1672
- return void 0;
1580
+
1581
+ // src/services/conflict-lint.ts
1582
+ function buildSimilarityModel(docs) {
1583
+ const tokenized = docs.map((d) => ({ id: d.id, tokens: buildQueryTerms(d.text) }));
1584
+ const tokensById = new Map(tokenized.map((d) => [d.id, d.tokens]));
1585
+ const model = buildBm25Model(
1586
+ tokenized.map((d) => ({ id: d.id, fields: { title: [], summary: [], tags: [], body: d.tokens } }))
1587
+ );
1588
+ return { model, tokensById };
1589
+ }
1590
+ var DEFAULT_CONFLICT_SIMILARITY_THRESHOLD = 0.5;
1591
+ function groupKey(entry) {
1592
+ return `${entry.layer}\0${entry.knowledge_type}`;
1593
+ }
1594
+ function pairSimilarity(model, a, b) {
1595
+ const selfA = model.scoreDoc(a.id, a.tokens);
1596
+ const selfB = model.scoreDoc(b.id, b.tokens);
1597
+ if (selfA <= 0 || selfB <= 0) return 0;
1598
+ const aToB = model.scoreDoc(b.id, a.tokens) / selfB;
1599
+ const bToA = model.scoreDoc(a.id, b.tokens) / selfA;
1600
+ const sim = Math.min(aToB, bToA);
1601
+ return sim < 0 ? 0 : sim > 1 ? 1 : sim;
1602
+ }
1603
+ function findConflictCandidates(entries, opts = {}) {
1604
+ const threshold = typeof opts.threshold === "number" && opts.threshold >= 0 && opts.threshold <= 1 ? opts.threshold : DEFAULT_CONFLICT_SIMILARITY_THRESHOLD;
1605
+ const groups = /* @__PURE__ */ new Map();
1606
+ for (const entry of entries) {
1607
+ if (typeof entry.stable_id !== "string" || entry.stable_id.length === 0) continue;
1608
+ const list = groups.get(groupKey(entry)) ?? [];
1609
+ list.push(entry);
1610
+ groups.set(groupKey(entry), list);
1673
1611
  }
1674
- const idMatch = /^id:\s*(.+?)\s*$/mu.exec(frontmatter[1]);
1675
- if (idMatch === null) {
1676
- return void 0;
1612
+ const pairs = [];
1613
+ for (const group of groups.values()) {
1614
+ if (group.length < 2) continue;
1615
+ const { model, tokensById } = buildSimilarityModel(
1616
+ group.map((e) => ({ id: e.stable_id, text: e.text }))
1617
+ );
1618
+ for (let i = 0; i < group.length; i += 1) {
1619
+ for (let j = i + 1; j < group.length; j += 1) {
1620
+ const ea = group[i];
1621
+ const eb = group[j];
1622
+ const sim = pairSimilarity(
1623
+ model,
1624
+ { id: ea.stable_id, tokens: tokensById.get(ea.stable_id) ?? [] },
1625
+ { id: eb.stable_id, tokens: tokensById.get(eb.stable_id) ?? [] }
1626
+ );
1627
+ if (sim < threshold) continue;
1628
+ const [a, b] = ea.stable_id <= eb.stable_id ? [ea.stable_id, eb.stable_id] : [eb.stable_id, ea.stable_id];
1629
+ pairs.push({
1630
+ a,
1631
+ b,
1632
+ knowledge_type: ea.knowledge_type,
1633
+ layer: ea.layer,
1634
+ similarity: sim,
1635
+ verdict: "unknown"
1636
+ });
1637
+ }
1638
+ }
1677
1639
  }
1678
- const candidate = idMatch[1].replace(/^["'](.*)["']$/u, "$1").trim();
1679
- return isKnowledgeStableId(candidate) ? candidate : void 0;
1640
+ pairs.sort((x, y) => y.similarity - x.similarity || (x.a < y.a ? -1 : x.a > y.a ? 1 : x.b < y.b ? -1 : 1));
1641
+ return pairs;
1680
1642
  }
1681
- function extractRuleDescription(source) {
1682
- const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/u.exec(source);
1683
- const description = frontmatter === null ? void 0 : extractDescriptionFromFrontmatter(frontmatter[1]);
1684
- if (description !== void 0) {
1685
- return description;
1643
+ async function lintConflicts(entries, opts = {}) {
1644
+ const candidates = findConflictCandidates(entries, { threshold: opts.threshold });
1645
+ if (opts.judge === void 0 || candidates.length === 0) {
1646
+ return candidates;
1686
1647
  }
1687
- const heading = /^#\s+(.+?)\s*$/mu.exec(source);
1688
- const summary = heading?.[1]?.trim();
1689
- const knowledge = frontmatter !== null ? extractKnowledgeFieldsFromFrontmatter(frontmatter[1]) : void 0;
1690
- const isStructurallyAKnowledgeEntry = summary !== void 0 && summary.length > 0 ? true : knowledge !== void 0 && (knowledge.id !== void 0 || knowledge.knowledge_type !== void 0 || knowledge.tags !== void 0 && knowledge.tags.length > 0);
1691
- if (!isStructurallyAKnowledgeEntry) {
1692
- return void 0;
1648
+ const byId = new Map(entries.map((e) => [e.stable_id, e]));
1649
+ const judged = [];
1650
+ for (const pair of candidates) {
1651
+ const ea = byId.get(pair.a);
1652
+ const eb = byId.get(pair.b);
1653
+ if (ea === void 0 || eb === void 0) {
1654
+ judged.push(pair);
1655
+ continue;
1656
+ }
1657
+ try {
1658
+ const verdict = await opts.judge(ea, eb);
1659
+ judged.push({
1660
+ ...pair,
1661
+ verdict: verdict.isConflict ? "conflict" : "similar",
1662
+ rationale: verdict.rationale
1663
+ });
1664
+ } catch {
1665
+ judged.push(pair);
1666
+ }
1693
1667
  }
1694
- const synthesizedSummary = summary !== void 0 && summary.length > 0 ? summary : knowledge?.id ?? (knowledge?.tags !== void 0 && knowledge.tags.length > 0 ? `(unnamed; tags: ${knowledge.tags.join(", ")})` : "(unnamed knowledge entry)");
1695
- return {
1696
- summary: synthesizedSummary,
1697
- intent_clues: [],
1698
- tech_stack: [],
1699
- impact: [],
1700
- must_read_if: synthesizedSummary,
1701
- // v2.0-rc.22: when frontmatter is present, merge its knowledge fields;
1702
- // when fully absent (no `---` block), all knowledge fields stay
1703
- // undefined, matching the original heading-only fallback contract.
1704
- id: knowledge?.id,
1705
- knowledge_type: knowledge?.knowledge_type,
1706
- maturity: knowledge?.maturity,
1707
- knowledge_layer: knowledge?.knowledge_layer,
1708
- layer_reason: knowledge?.layer_reason,
1709
- created_at: knowledge?.created_at,
1710
- tags: knowledge?.tags,
1711
- // v2.0-rc.5 (C1): default-safe values when there is no frontmatter at all;
1712
- // when frontmatter exists, honor its declared values (extractKnowledge
1713
- // FieldsFromFrontmatter already applies the broad-default for missing
1714
- // or malformed scopes).
1715
- relevance_scope: knowledge?.relevance_scope ?? "broad",
1716
- relevance_paths: knowledge?.relevance_paths ?? [],
1717
- // v2.2 H2-related (W1-T7): graph edges, undefined when absent.
1718
- related: knowledge?.related
1719
- };
1668
+ return judged;
1720
1669
  }
1721
- function extractDescriptionFromFrontmatter(frontmatter) {
1722
- const summary = extractScalar(frontmatter, "summary") ?? extractScalar(frontmatter, "description");
1723
- if (summary === void 0) {
1670
+
1671
+ // src/services/archive-dedup-gate.ts
1672
+ var DEDUP_NEAR_DUPLICATE_THRESHOLD = 0.85;
1673
+ var DEDUP_CONFLICT_THRESHOLD = 0.5;
1674
+ var CANDIDATE_ID = "__candidate__";
1675
+ function classifyArchiveCandidate(candidate, corpus, opts = {}) {
1676
+ const nearDuplicate = opts.nearDuplicateThreshold ?? DEDUP_NEAR_DUPLICATE_THRESHOLD;
1677
+ const conflict = opts.conflictThreshold ?? DEDUP_CONFLICT_THRESHOLD;
1678
+ const bucket = corpus.filter(
1679
+ (e) => e.knowledge_type === candidate.knowledge_type && e.layer === candidate.layer
1680
+ );
1681
+ if (bucket.length === 0) {
1682
+ return { verdict: "unique", matches: [] };
1683
+ }
1684
+ const { model, tokensById } = buildSimilarityModel([
1685
+ { id: CANDIDATE_ID, text: candidate.text },
1686
+ ...bucket.map((e) => ({ id: e.stable_id, text: e.text }))
1687
+ ]);
1688
+ const candidateTokens = tokensById.get(CANDIDATE_ID) ?? [];
1689
+ const matches = bucket.map((e) => ({
1690
+ stable_id: e.stable_id,
1691
+ similarity: pairSimilarity(
1692
+ model,
1693
+ { id: CANDIDATE_ID, tokens: candidateTokens },
1694
+ { id: e.stable_id, tokens: tokensById.get(e.stable_id) ?? [] }
1695
+ )
1696
+ })).filter((m) => m.similarity >= conflict).sort((a, b) => b.similarity - a.similarity || a.stable_id.localeCompare(b.stable_id));
1697
+ const top = matches[0]?.similarity ?? 0;
1698
+ const verdict = top >= nearDuplicate ? "near-duplicate" : top >= conflict ? "conflict" : "unique";
1699
+ return { verdict, matches };
1700
+ }
1701
+ function formatDedupMarker(result) {
1702
+ if (result.verdict === "unique" || result.matches.length === 0) {
1724
1703
  return void 0;
1725
1704
  }
1726
- const knowledge = extractKnowledgeFieldsFromFrontmatter(frontmatter);
1727
- return {
1728
- summary,
1729
- intent_clues: extractInlineArray(frontmatter, "intent_clues"),
1730
- tech_stack: extractInlineArray(frontmatter, "tech_stack"),
1731
- impact: extractInlineArray(frontmatter, "impact"),
1732
- must_read_if: extractScalar(frontmatter, "must_read_if") ?? summary,
1733
- entities: extractInlineArray(frontmatter, "entities"),
1734
- id: knowledge.id,
1735
- knowledge_type: knowledge.knowledge_type,
1736
- maturity: knowledge.maturity,
1737
- knowledge_layer: knowledge.knowledge_layer,
1738
- layer_reason: knowledge.layer_reason,
1739
- created_at: knowledge.created_at,
1740
- tags: knowledge.tags,
1741
- relevance_scope: knowledge.relevance_scope,
1742
- relevance_paths: knowledge.relevance_paths,
1743
- // v2.2 H2-related (W1-T7): graph edges parsed from frontmatter.
1744
- related: knowledge.related
1745
- };
1705
+ const top = result.matches[0];
1706
+ return `${result.verdict} of ${top.stable_id} (${top.similarity.toFixed(2)})`;
1746
1707
  }
1747
- function isForbiddenCrossLayerEdge(sourceLayer, targetId) {
1748
- if (sourceLayer !== "team") {
1749
- return false;
1708
+
1709
+ // src/services/extract-knowledge.ts
1710
+ var SLUG_MAX_LENGTH = 40;
1711
+ var INJECTION_PATTERNS = [
1712
+ {
1713
+ name: "ignore-prior-instructions",
1714
+ pattern: /\b(?:ignore|forget|disregard)\s+(?:all\s+)?(?:previous|prior|above|earlier)\s+(?:instructions?|messages?|prompts?|rules?)\b/giu
1715
+ },
1716
+ {
1717
+ name: "forget-your-role",
1718
+ pattern: /\b(?:forget|disregard|ignore)\s+(?:your|the)\s+(?:role|identity|system\s+prompt)\b/giu
1719
+ },
1720
+ {
1721
+ name: "you-are-now",
1722
+ pattern: /\byou\s+are\s+now\s+(?:a|an)\s+\w+\s+(?:assistant|agent|model|bot|persona)\b/giu
1723
+ },
1724
+ {
1725
+ name: "rm-rf-root",
1726
+ pattern: /\brm\s+-rf?\s+(?:--no-preserve-root\s+)?[/~][^\s`'")>}]*?\s*(?:\/[*]?|;|$|\n|\|)/giu
1727
+ },
1728
+ {
1729
+ name: "shell-eval-curl",
1730
+ pattern: /\b(?:eval|sh|bash|zsh)\s+(?:-\w+\s+)?["'`]?\$\(\s*curl\s+[^)]+\)/giu
1731
+ },
1732
+ {
1733
+ name: "chatml-envelope",
1734
+ pattern: /<\|(?:im_start|im_end|system|user|assistant|endoftext|fim_prefix|fim_suffix|fim_middle)\|>/giu
1735
+ },
1736
+ {
1737
+ name: "claude-envelope",
1738
+ pattern: /\b(?:Human:|Assistant:)\s*<.*?>/giu
1750
1739
  }
1751
- const decoded = parseKnowledgeId(localKnowledgeIdFromReference(targetId));
1752
- if (decoded === null) {
1753
- return false;
1740
+ ];
1741
+ var INJECTION_REDACTION_MARKER = "[REDACTED: prompt-injection pattern stripped by fab_propose \u2014 NEW-31]";
1742
+ function sanitizeInjectionPatterns(input) {
1743
+ let sanitized = input;
1744
+ const redactions = [];
1745
+ for (const { name, pattern } of INJECTION_PATTERNS) {
1746
+ const matches = sanitized.match(pattern);
1747
+ if (matches === null || matches.length === 0) continue;
1748
+ redactions.push({ name, matches: matches.length });
1749
+ sanitized = sanitized.replace(pattern, INJECTION_REDACTION_MARKER);
1750
+ }
1751
+ return { sanitized, redactions };
1752
+ }
1753
+ function sanitizeInjectionFields(fields) {
1754
+ const out = { ...fields };
1755
+ const allRedactions = [];
1756
+ for (const [key, value] of Object.entries(fields)) {
1757
+ if (typeof value === "string") {
1758
+ const { sanitized, redactions } = sanitizeInjectionPatterns(value);
1759
+ out[key] = sanitized;
1760
+ for (const r of redactions) {
1761
+ allRedactions.push({ field: key, ...r });
1762
+ }
1763
+ } else if (Array.isArray(value) && value.every((v) => typeof v === "string")) {
1764
+ const cleaned = [];
1765
+ for (const entry of value) {
1766
+ const { sanitized, redactions } = sanitizeInjectionPatterns(entry);
1767
+ cleaned.push(sanitized);
1768
+ for (const r of redactions) {
1769
+ allRedactions.push({ field: key, ...r });
1770
+ }
1771
+ }
1772
+ out[key] = cleaned;
1773
+ }
1754
1774
  }
1755
- return decoded.layer === "personal";
1775
+ return { sanitized: out, allRedactions };
1756
1776
  }
1757
- function localKnowledgeIdFromReference(ref) {
1758
- const direct = parseKnowledgeId(ref);
1759
- if (direct !== null) {
1760
- return ref;
1777
+ function redactPiiFields(fields) {
1778
+ const out = { ...fields };
1779
+ const redactedFields = /* @__PURE__ */ new Set();
1780
+ for (const [key, value] of Object.entries(fields)) {
1781
+ if (typeof value === "string") {
1782
+ const cleaned = redactPii(value);
1783
+ out[key] = cleaned;
1784
+ if (cleaned !== value) redactedFields.add(key);
1785
+ } else if (Array.isArray(value) && value.every((v) => typeof v === "string")) {
1786
+ const cleaned = value.map((entry) => redactPii(entry));
1787
+ out[key] = cleaned;
1788
+ if (cleaned.some((entry, index) => entry !== value[index])) {
1789
+ redactedFields.add(key);
1790
+ }
1791
+ }
1761
1792
  }
1762
- const tail = ref.split(":").at(-1);
1763
- return tail ?? ref;
1793
+ return { redacted: out, redactedFields: [...redactedFields].sort() };
1764
1794
  }
1765
- function extractKnowledgeFieldsFromFrontmatter(frontmatter) {
1766
- const rawId = extractScalar(frontmatter, "id");
1767
- const rawType = extractScalar(frontmatter, "type");
1768
- const rawMaturity = extractScalar(frontmatter, "maturity");
1769
- const rawLayer = extractScalar(frontmatter, "layer");
1770
- const rawLayerReason = extractScalar(frontmatter, "layer_reason");
1771
- const rawCreatedAt = extractScalar(frontmatter, "created_at");
1772
- let id;
1773
- if (rawId !== void 0) {
1774
- const parsed = StableIdSchema.safeParse(rawId);
1775
- if (parsed.success) {
1776
- id = parsed.data;
1777
- } else {
1778
- process.stderr.write(`[fabric] frontmatter: invalid knowledge id format ${JSON.stringify(rawId)}; skipping
1779
- `);
1780
- }
1795
+ function pendingBase(layer, projectRoot, semanticScope) {
1796
+ return resolveStorePendingBase(layer, projectRoot, semanticScope);
1797
+ }
1798
+ function toPosixPath(path) {
1799
+ return path.replace(/\\/gu, "/");
1800
+ }
1801
+ async function extractKnowledge(projectRoot, input) {
1802
+ const sanitizedInputFields = sanitizeInjectionFields({
1803
+ slug: input.slug ?? "",
1804
+ user_messages_summary: input.user_messages_summary ?? "",
1805
+ session_context: input.session_context ?? "",
1806
+ must_read_if: input.must_read_if ?? "",
1807
+ intent_clues: input.intent_clues ?? []
1808
+ });
1809
+ input = {
1810
+ ...input,
1811
+ slug: sanitizedInputFields.sanitized.slug,
1812
+ user_messages_summary: sanitizedInputFields.sanitized.user_messages_summary || void 0,
1813
+ session_context: sanitizedInputFields.sanitized.session_context || void 0,
1814
+ must_read_if: sanitizedInputFields.sanitized.must_read_if || void 0,
1815
+ intent_clues: sanitizedInputFields.sanitized.intent_clues.length > 0 ? sanitizedInputFields.sanitized.intent_clues : void 0
1816
+ };
1817
+ if (sanitizedInputFields.allRedactions.length > 0) {
1818
+ const summary2 = sanitizedInputFields.allRedactions.map((r) => `${r.field}:${r.name}x${r.matches}`).join(",");
1819
+ const primarySessionForLog = (input.source_sessions ?? [])[0] ?? "";
1820
+ await emitEventBestEffort(projectRoot, {
1821
+ event_type: "knowledge_archive_attempted",
1822
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1823
+ correlation_id: primarySessionForLog,
1824
+ session_id: primarySessionForLog,
1825
+ reason: `extract_knowledge:injection-redacted:${summary2}`
1826
+ });
1781
1827
  }
1782
- const SINGULAR_TO_PLURAL = {
1783
- model: "models",
1784
- decision: "decisions",
1785
- guideline: "guidelines",
1786
- pitfall: "pitfalls",
1787
- process: "processes"
1828
+ const piiRedactedInputFields = redactPiiFields({
1829
+ slug: input.slug ?? "",
1830
+ user_messages_summary: input.user_messages_summary ?? "",
1831
+ session_context: input.session_context ?? "",
1832
+ must_read_if: input.must_read_if ?? "",
1833
+ intent_clues: input.intent_clues ?? [],
1834
+ tags: input.tags ?? [],
1835
+ evidence_paths: input.evidence_paths ?? []
1836
+ });
1837
+ input = {
1838
+ ...input,
1839
+ slug: piiRedactedInputFields.redacted.slug,
1840
+ user_messages_summary: piiRedactedInputFields.redacted.user_messages_summary || void 0,
1841
+ session_context: piiRedactedInputFields.redacted.session_context || void 0,
1842
+ must_read_if: piiRedactedInputFields.redacted.must_read_if || void 0,
1843
+ intent_clues: piiRedactedInputFields.redacted.intent_clues.length > 0 ? piiRedactedInputFields.redacted.intent_clues : void 0,
1844
+ tags: piiRedactedInputFields.redacted.tags.length > 0 ? piiRedactedInputFields.redacted.tags : void 0,
1845
+ evidence_paths: piiRedactedInputFields.redacted.evidence_paths.length > 0 ? piiRedactedInputFields.redacted.evidence_paths : void 0
1788
1846
  };
1789
- let knowledge_type;
1790
- if (rawType !== void 0) {
1791
- const normalized = SINGULAR_TO_PLURAL[rawType] ?? rawType;
1792
- const parsed = KnowledgeTypeSchema.safeParse(normalized);
1793
- if (parsed.success) {
1794
- knowledge_type = parsed.data;
1795
- } else {
1796
- process.stderr.write(`[fabric] frontmatter: unknown knowledge type ${JSON.stringify(rawType)}; skipping
1797
- `);
1798
- }
1847
+ if (piiRedactedInputFields.redactedFields.length > 0) {
1848
+ const primarySessionForLog = (input.source_sessions ?? [])[0] ?? "";
1849
+ await emitEventBestEffort(projectRoot, {
1850
+ event_type: "knowledge_archive_attempted",
1851
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1852
+ correlation_id: primarySessionForLog,
1853
+ session_id: primarySessionForLog,
1854
+ reason: `extract_knowledge:pii-redacted:${piiRedactedInputFields.redactedFields.join(",")}`
1855
+ });
1799
1856
  }
1800
- let maturity;
1801
- if (rawMaturity !== void 0) {
1802
- const parsed = MaturitySchema.safeParse(rawMaturity);
1803
- if (parsed.success) {
1804
- maturity = parsed.data;
1805
- } else {
1806
- process.stderr.write(`[fabric] frontmatter: unknown maturity ${JSON.stringify(rawMaturity)}; skipping
1807
- `);
1808
- }
1857
+ const sanitizedSlug = sanitizeSlug(input.slug);
1858
+ const sourceSessions = input.source_sessions ?? [];
1859
+ const primarySession = sourceSessions[0] ?? "";
1860
+ const idempotencyKey = sha256(
1861
+ JSON.stringify({
1862
+ source_session: primarySession,
1863
+ type: input.type,
1864
+ slug: sanitizedSlug
1865
+ })
1866
+ );
1867
+ const summary = input.user_messages_summary ?? "";
1868
+ const summaryTrimmed = summary.trim();
1869
+ const summaryIsEmpty = summaryTrimmed.length === 0;
1870
+ const slugIsEmpty = sanitizedSlug.length === 0;
1871
+ const summaryTooShort = !summaryIsEmpty && summaryTrimmed.length < 15;
1872
+ const summaryEqualsSlug = !summaryIsEmpty && summaryTrimmed.toLowerCase() === sanitizedSlug.toLowerCase();
1873
+ const summaryLooksLikeStableId = !summaryIsEmpty && /^K[TP]-[A-Z]{3}-\d{4}$/.test(summaryTrimmed);
1874
+ const summaryIsOpaque = summaryTooShort || summaryEqualsSlug || summaryLooksLikeStableId;
1875
+ if (summaryIsEmpty || slugIsEmpty || summaryIsOpaque) {
1876
+ const reason = summaryIsEmpty ? "empty_summary" : slugIsEmpty ? "empty_slug" : summaryTooShort ? "summary_too_short" : summaryEqualsSlug ? "summary_equals_slug" : "summary_looks_like_stable_id";
1877
+ await emitEventBestEffort(projectRoot, {
1878
+ event_type: "knowledge_archive_attempted",
1879
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1880
+ correlation_id: primarySession,
1881
+ session_id: primarySession,
1882
+ reason: `extract_knowledge:${sanitizedSlug || input.slug}:${reason}`
1883
+ });
1884
+ return {
1885
+ pending_path: "",
1886
+ idempotency_key: idempotencyKey
1887
+ };
1809
1888
  }
1810
- let knowledge_layer;
1811
- if (rawLayer !== void 0) {
1812
- const parsed = LayerSchema.safeParse(rawLayer);
1813
- if (parsed.success) {
1814
- knowledge_layer = parsed.data;
1815
- } else {
1816
- process.stderr.write(`[fabric] frontmatter: unknown layer ${JSON.stringify(rawLayer)}; skipping
1817
- `);
1818
- }
1889
+ const secretScanTarget = [
1890
+ input.slug ?? "",
1891
+ input.user_messages_summary ?? "",
1892
+ input.session_context ?? "",
1893
+ input.must_read_if ?? "",
1894
+ ...input.intent_clues ?? [],
1895
+ ...input.tags ?? [],
1896
+ ...input.evidence_paths ?? []
1897
+ ].join("\n");
1898
+ if (hasSecrets(secretScanTarget)) {
1899
+ await emitEventBestEffort(projectRoot, {
1900
+ event_type: "knowledge_archive_attempted",
1901
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1902
+ correlation_id: primarySession,
1903
+ session_id: primarySession,
1904
+ reason: `extract_knowledge:${sanitizedSlug || input.slug}:secret_detected`
1905
+ });
1906
+ return {
1907
+ pending_path: "",
1908
+ idempotency_key: idempotencyKey
1909
+ };
1819
1910
  }
1820
- let created_at;
1821
- if (rawCreatedAt !== void 0) {
1822
- if (!Number.isNaN(Date.parse(rawCreatedAt))) {
1823
- created_at = rawCreatedAt;
1824
- } else {
1825
- process.stderr.write(`[fabric] frontmatter: malformed created_at ${JSON.stringify(rawCreatedAt)}; skipping
1826
- `);
1827
- }
1911
+ const semanticScope = input.audience;
1912
+ const scopeIsPersonal = semanticScope !== void 0 && isPersonalScope(semanticScope);
1913
+ const layer = scopeIsPersonal ? "personal" : "team";
1914
+ let relevancePaths = input.paths;
1915
+ let relevanceScope = relevancePaths === void 0 ? void 0 : relevancePaths.length > 0 ? "narrow" : "broad";
1916
+ const shouldAutoDegrade = layer === "personal" && relevanceScope === "narrow";
1917
+ if (shouldAutoDegrade) {
1918
+ relevanceScope = "broad";
1919
+ relevancePaths = [];
1920
+ await emitEventBestEffort(projectRoot, {
1921
+ event_type: "knowledge_scope_degraded",
1922
+ stable_id: `pending:${idempotencyKey}`,
1923
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1924
+ from_scope: "narrow",
1925
+ to_scope: "broad",
1926
+ reason: "personal-implies-broad"
1927
+ });
1828
1928
  }
1829
- if (id !== void 0 && knowledge_layer !== void 0) {
1830
- const decoded = parseKnowledgeId(id);
1831
- if (decoded !== null && decoded.layer !== knowledge_layer) {
1832
- process.stderr.write(
1833
- `[fabric] frontmatter: id ${id} encodes layer ${decoded.layer} but layer field says ${knowledge_layer}; dropping both
1834
- `
1835
- );
1836
- id = void 0;
1837
- knowledge_layer = void 0;
1929
+ const baseDir = pendingBase(layer, projectRoot, semanticScope);
1930
+ const { absolutePath, sanitizedSlug: chosenSlug, idempotencyKey: chosenKey } = await resolveDisambiguatedSlugPath({
1931
+ baseDir,
1932
+ type: input.type,
1933
+ slug: sanitizedSlug,
1934
+ primarySession,
1935
+ baseIdempotencyKey: idempotencyKey
1936
+ });
1937
+ const reportedPath = toPosixPath(absolutePath);
1938
+ const effectiveSanitizedSlug = chosenSlug;
1939
+ const effectiveIdempotencyKey = chosenKey;
1940
+ const writeScopeMeta = resolveWriteScopeMeta(layer, projectRoot, semanticScope);
1941
+ await ensureParentDirectory(absolutePath);
1942
+ if (existsSync3(absolutePath)) {
1943
+ const existing = await readFile4(absolutePath, "utf8");
1944
+ const existingKey = readFrontmatterKey(existing, "x-fabric-idempotency-key");
1945
+ if (existingKey === effectiveIdempotencyKey) {
1946
+ const fresh2 = renderFreshEntry({
1947
+ type: input.type,
1948
+ sourceSessions,
1949
+ idempotencyKey: effectiveIdempotencyKey,
1950
+ summary,
1951
+ recentPaths: input.recent_paths,
1952
+ layer,
1953
+ semanticScope: writeScopeMeta.semantic_scope,
1954
+ visibilityStore: writeScopeMeta.visibility_store,
1955
+ proposedReason: input.proposed_reason,
1956
+ sessionContext: input.session_context,
1957
+ relevanceScope,
1958
+ relevancePaths,
1959
+ intentClues: input.intent_clues,
1960
+ techStack: input.tech_stack,
1961
+ impact: input.impact,
1962
+ mustReadIf: input.must_read_if,
1963
+ onboardSlot: input.onboard_slot,
1964
+ // v2.0.0-rc.37 NEW-37: pass-through topic tags.
1965
+ tags: input.tags,
1966
+ // v2.0.0-rc.37 NEW-7: pass-through evidence_paths to frontmatter.
1967
+ evidencePaths: input.evidence_paths
1968
+ });
1969
+ const augmented = mergeEvidenceNotes(existing, fresh2);
1970
+ await atomicWriteText(absolutePath, augmented);
1971
+ await emitEventBestEffort(projectRoot, {
1972
+ event_type: "knowledge_proposed",
1973
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1974
+ correlation_id: primarySession,
1975
+ session_id: primarySession,
1976
+ reason: `extract_knowledge:${effectiveSanitizedSlug}`
1977
+ });
1978
+ return {
1979
+ pending_path: reportedPath,
1980
+ idempotency_key: effectiveIdempotencyKey
1981
+ };
1838
1982
  }
1983
+ throw new Error(
1984
+ `slug collision (unreachable after rc.37 NEW-6): pending file ${reportedPath} already exists with key ${existingKey ?? "<missing>"} != ${effectiveIdempotencyKey}`
1985
+ );
1839
1986
  }
1840
- const tags = extractInlineArray(frontmatter, "tags");
1841
- const rawRelevanceScope = extractScalar(frontmatter, "relevance_scope");
1842
- const relevance_scope = rawRelevanceScope === "narrow" || rawRelevanceScope === "broad" ? rawRelevanceScope : "broad";
1843
- const relevance_paths = extractInlineArray(frontmatter, "relevance_paths");
1844
- const rawRelated = extractInlineArray(frontmatter, "related");
1845
- const sourceLayer = knowledge_layer ?? (id !== void 0 ? parseKnowledgeId(id)?.layer ?? "team" : "team");
1846
- const related = rawRelated.filter((targetId) => {
1847
- if (isForbiddenCrossLayerEdge(sourceLayer, targetId)) {
1848
- process.stderr.write(
1849
- `[fabric] frontmatter: stripping forbidden cross-layer related edge ${id ?? "(team entry)"} \u2192 ${targetId} (KT\u2192KP topology leak; \xA74 privacy iron law)
1850
- `
1851
- );
1852
- return false;
1853
- }
1854
- return true;
1987
+ const dedupMarker = await computeDedupMarker(projectRoot, {
1988
+ text: [
1989
+ summary,
1990
+ input.must_read_if ?? "",
1991
+ ...input.intent_clues ?? [],
1992
+ ...input.impact ?? [],
1993
+ ...input.tags ?? []
1994
+ ].join(" "),
1995
+ knowledge_type: input.type,
1996
+ layer
1997
+ });
1998
+ const fresh = renderFreshEntry({
1999
+ type: input.type,
2000
+ sourceSessions,
2001
+ idempotencyKey: effectiveIdempotencyKey,
2002
+ summary,
2003
+ recentPaths: input.recent_paths,
2004
+ layer,
2005
+ semanticScope: writeScopeMeta.semantic_scope,
2006
+ visibilityStore: writeScopeMeta.visibility_store,
2007
+ proposedReason: input.proposed_reason,
2008
+ sessionContext: input.session_context,
2009
+ relevanceScope,
2010
+ relevancePaths,
2011
+ intentClues: input.intent_clues,
2012
+ techStack: input.tech_stack,
2013
+ impact: input.impact,
2014
+ mustReadIf: input.must_read_if,
2015
+ onboardSlot: input.onboard_slot,
2016
+ tags: input.tags,
2017
+ // v2.0.0-rc.37 NEW-7: pass-through evidence_paths to frontmatter.
2018
+ evidencePaths: input.evidence_paths,
2019
+ dedupMarker
2020
+ });
2021
+ await atomicWriteText(absolutePath, fresh);
2022
+ await emitEventBestEffort(projectRoot, {
2023
+ event_type: "knowledge_proposed",
2024
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2025
+ correlation_id: primarySession,
2026
+ session_id: primarySession,
2027
+ reason: `extract_knowledge:${effectiveSanitizedSlug}`
1855
2028
  });
1856
2029
  return {
1857
- id,
1858
- knowledge_type,
1859
- maturity,
1860
- knowledge_layer,
1861
- layer_reason: rawLayerReason,
1862
- created_at,
1863
- tags: tags.length > 0 ? tags : void 0,
1864
- relevance_scope,
1865
- relevance_paths,
1866
- related: related.length > 0 ? related : void 0
2030
+ pending_path: reportedPath,
2031
+ idempotency_key: effectiveIdempotencyKey
1867
2032
  };
1868
2033
  }
1869
- function extractScalar(frontmatter, key) {
1870
- const pattern = new RegExp(`^${escapeRegExp(key)}:\\s*(.+?)\\s*$`, "mu");
1871
- const match = pattern.exec(frontmatter);
1872
- if (match === null) {
2034
+ async function computeDedupMarker(projectRoot, candidate) {
2035
+ try {
2036
+ const corpus = await collectStoreCanonicalEntries(projectRoot);
2037
+ const result = classifyArchiveCandidate(
2038
+ candidate,
2039
+ corpus.map((e) => ({
2040
+ stable_id: e.qualifiedId,
2041
+ knowledge_type: typeof e.description.knowledge_type === "string" && e.description.knowledge_type.length > 0 ? e.description.knowledge_type : e.type,
2042
+ layer: e.layer,
2043
+ text: [
2044
+ e.description.summary ?? "",
2045
+ e.description.must_read_if ?? "",
2046
+ ...e.description.intent_clues ?? [],
2047
+ ...e.description.impact ?? [],
2048
+ ...e.description.tags ?? []
2049
+ ].join(" ")
2050
+ }))
2051
+ );
2052
+ return formatDedupMarker(result);
2053
+ } catch {
1873
2054
  return void 0;
1874
2055
  }
1875
- return unquote(match[1].trim());
1876
2056
  }
1877
- function extractInlineArray(frontmatter, key) {
1878
- const pattern = new RegExp(`^${escapeRegExp(key)}:\\s*\\[(.*?)\\]\\s*$`, "mu");
1879
- const match = pattern.exec(frontmatter);
1880
- if (match === null) {
1881
- return [];
2057
+ var SLUG_DISAMBIGUATE_MAX_VARIANTS = 9;
2058
+ async function resolveDisambiguatedSlugPath(args) {
2059
+ for (let n = 1; n <= SLUG_DISAMBIGUATE_MAX_VARIANTS; n += 1) {
2060
+ const candidateSlug = n === 1 ? args.slug : `${args.slug}-${n}`;
2061
+ const candidatePath = join7(args.baseDir, args.type, `${candidateSlug}.md`);
2062
+ const candidateKey = n === 1 ? args.baseIdempotencyKey : sha256(
2063
+ JSON.stringify({
2064
+ source_session: args.primarySession,
2065
+ type: args.type,
2066
+ slug: candidateSlug
2067
+ })
2068
+ );
2069
+ if (!existsSync3(candidatePath)) {
2070
+ return {
2071
+ absolutePath: candidatePath,
2072
+ sanitizedSlug: candidateSlug,
2073
+ idempotencyKey: candidateKey
2074
+ };
2075
+ }
2076
+ const existing = await readFile4(candidatePath, "utf8");
2077
+ const existingKey = readFrontmatterKey(existing, "x-fabric-idempotency-key");
2078
+ if (existingKey === candidateKey) {
2079
+ return {
2080
+ absolutePath: candidatePath,
2081
+ sanitizedSlug: candidateSlug,
2082
+ idempotencyKey: candidateKey
2083
+ };
2084
+ }
1882
2085
  }
1883
- return match[1].split(",").map((item) => unquote(item.trim())).filter((item) => item.length > 0);
1884
- }
1885
- function unquote(value) {
1886
- return value.replace(/^["'](.*)["']$/u, "$1");
1887
- }
1888
- function escapeRegExp(value) {
1889
- return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
2086
+ throw new Error(
2087
+ `slug exhaustion: tried ${args.slug}.md plus -2..-${SLUG_DISAMBIGUATE_MAX_VARIANTS} suffix variants and all slots are taken by entries with different idempotency_keys; rename slug at the caller and retry`
2088
+ );
1890
2089
  }
1891
-
1892
- // src/services/cross-store-recall.ts
1893
- var readSetWalkCache = /* @__PURE__ */ new Map();
1894
- var readSetWalkCount = 0;
1895
- function readSetWalkCacheKey(projectRoot) {
1896
- return `${projectRoot}\0${process.env.FABRIC_HOME ?? ""}`;
2090
+ function sanitizeSlug(raw) {
2091
+ const lower = raw.toLowerCase();
2092
+ const collapsed = lower.replace(/[^a-z0-9]+/g, "-");
2093
+ const trimmed = collapsed.replace(/^-+|-+$/g, "");
2094
+ if (trimmed.length === 0) {
2095
+ return "";
2096
+ }
2097
+ return trimmed.slice(0, SLUG_MAX_LENGTH).replace(/-+$/g, "");
1897
2098
  }
1898
- async function readSetFingerprint(refs) {
1899
- const parts = await Promise.all(
1900
- refs.map(async (ref) => {
1901
- try {
1902
- const fileStat = await stat(ref.file);
1903
- return `${ref.store_uuid}|${ref.alias}|${ref.file}|${fileStat.size}|${fileStat.mtimeMs}`;
1904
- } catch {
1905
- return `${ref.store_uuid}|${ref.alias}|${ref.file}|missing`;
1906
- }
1907
- })
2099
+ function renderFreshEntry(args) {
2100
+ const createdAt = (/* @__PURE__ */ new Date()).toISOString();
2101
+ const frontmatterLines = [
2102
+ "---",
2103
+ `type: ${args.type}`,
2104
+ "maturity: draft",
2105
+ `layer: ${args.layer}`,
2106
+ // v2.1 global-refactor (W1/A1): scope coordinate (resolution axis) + the
2107
+ // physical store this entry lives in. `layer` is retained for back-compat
2108
+ // during the co-location retirement; `semantic_scope`/`visibility_store` are
2109
+ // the v2.1 source of truth (scope ⊥ store).
2110
+ `semantic_scope: ${args.semanticScope}`,
2111
+ `visibility_store: ${quoteRelevancePath(args.visibilityStore)}`,
2112
+ `created_at: ${createdAt}`,
2113
+ `source_sessions: [${args.sourceSessions.map((s) => JSON.stringify(s)).join(", ")}]`,
2114
+ `proposed_reason: ${args.proposedReason}`,
2115
+ // rc.31 BUG-2.9/2.1: persist the caller-supplied summary in frontmatter so
2116
+ // knowledge-meta-builder.extractDescriptionFromFrontmatter picks it up
2117
+ // directly. Without this, the meta-builder fell back to extractRule
2118
+ // Description's h1-or-stable-id-or-placeholder synthesis (line ~944),
2119
+ // which made user-visible description.summary == stable_id for any
2120
+ // pending file whose body started with h2-only sections (`## Summary` is
2121
+ // the canonical pending shape). The frontmatter `summary:` line is the
2122
+ // canonical source-of-truth: `extractDescriptionFromFrontmatter` reads it
2123
+ // before extractRuleDescription's fallback kicks in.
2124
+ `summary: ${quoteRelevancePath(args.summary)}`,
2125
+ // v2.0.0-rc.37 NEW-37: render caller-supplied tags or fall back to empty
2126
+ // array. Empty array still legal but doctor's knowledge_tags_empty_ratio
2127
+ // lint will warn at the corpus level. Encourages 2-4 kebab-case topic
2128
+ // strings per entry for cross-entry retrieval signal.
2129
+ args.tags !== void 0 && args.tags.length > 0 ? `tags: [${args.tags.map((t) => quoteRelevancePath(t)).join(", ")}]` : "tags: []"
2130
+ ];
2131
+ if (args.relevanceScope !== void 0) {
2132
+ frontmatterLines.push(`relevance_scope: ${args.relevanceScope}`);
2133
+ }
2134
+ if (args.relevancePaths !== void 0) {
2135
+ const pathsBody = args.relevancePaths.map((p) => quoteRelevancePath(p)).join(", ");
2136
+ frontmatterLines.push(`relevance_paths: [${pathsBody}]`);
2137
+ }
2138
+ if (args.intentClues !== void 0) {
2139
+ const body2 = args.intentClues.map((s) => quoteRelevancePath(s)).join(", ");
2140
+ frontmatterLines.push(`intent_clues: [${body2}]`);
2141
+ }
2142
+ if (args.techStack !== void 0) {
2143
+ const body2 = args.techStack.map((s) => quoteRelevancePath(s)).join(", ");
2144
+ frontmatterLines.push(`tech_stack: [${body2}]`);
2145
+ }
2146
+ if (args.impact !== void 0) {
2147
+ const body2 = args.impact.map((s) => quoteRelevancePath(s)).join(", ");
2148
+ frontmatterLines.push(`impact: [${body2}]`);
2149
+ }
2150
+ if (args.mustReadIf !== void 0) {
2151
+ frontmatterLines.push(`must_read_if: ${quoteRelevancePath(args.mustReadIf)}`);
2152
+ }
2153
+ if (args.onboardSlot !== void 0) {
2154
+ frontmatterLines.push(`onboard_slot: ${args.onboardSlot}`);
2155
+ }
2156
+ if (args.evidencePaths !== void 0 && args.evidencePaths.length > 0) {
2157
+ const body2 = args.evidencePaths.map((p) => quoteRelevancePath(p)).join(", ");
2158
+ frontmatterLines.push(`evidence_paths: [${body2}]`);
2159
+ }
2160
+ if (args.dedupMarker !== void 0) {
2161
+ frontmatterLines.push(`x-fabric-dedup: ${quoteRelevancePath(args.dedupMarker)}`);
2162
+ }
2163
+ frontmatterLines.push(
2164
+ `x-fabric-idempotency-key: ${args.idempotencyKey}`,
2165
+ "---"
1908
2166
  );
1909
- return parts.sort().join("\n");
1910
- }
1911
- var SEMANTIC_SCOPE_LINE = /^semantic_scope:\s*"?([^"\n]+?)"?\s*$/mu;
1912
- function readSemanticScope(source, layer) {
1913
- const match = SEMANTIC_SCOPE_LINE.exec(source);
1914
- return match?.[1] ?? layer;
2167
+ const frontmatter = frontmatterLines.join("\n");
2168
+ const reasonExplanation = PROPOSED_REASON_DESCRIPTIONS_BY_LOCALE[resolveGlobalLocale()][args.proposedReason];
2169
+ const body = [
2170
+ "",
2171
+ "## Summary",
2172
+ "",
2173
+ args.summary,
2174
+ "",
2175
+ "## Why proposed",
2176
+ "",
2177
+ `${args.proposedReason} \u2014 ${reasonExplanation}`,
2178
+ "",
2179
+ "## Session context",
2180
+ "",
2181
+ args.sessionContext,
2182
+ "",
2183
+ "## Evidence",
2184
+ "",
2185
+ renderEvidenceBlock(args.summary, args.recentPaths),
2186
+ ""
2187
+ ].join("\n");
2188
+ return `${frontmatter}
2189
+ ${body}`;
1915
2190
  }
1916
- function filterByActiveProject(entries, activeProject) {
1917
- if (activeProject === void 0 || activeProject.length === 0) {
1918
- return entries;
1919
- }
1920
- const current = `project:${activeProject}`;
1921
- return entries.filter(
1922
- (e) => scopeRoot(e.semanticScope) !== "project" || e.semanticScope === current
1923
- );
2191
+ function quoteRelevancePath(value) {
2192
+ const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
2193
+ return `"${escaped}"`;
1924
2194
  }
1925
- function activeProjectOf(projectRoot) {
1926
- const ap = loadProjectConfig2(projectRoot)?.active_project;
1927
- return ap !== void 0 && ap.length > 0 ? ap : void 0;
2195
+ function renderEvidenceBlock(summary, recentPaths) {
2196
+ const pathLines = recentPaths.length === 0 ? "_(no recent paths reported)_" : recentPaths.map((p) => `- ${p}`).join("\n");
2197
+ return [
2198
+ "Recent paths:",
2199
+ "",
2200
+ pathLines,
2201
+ "",
2202
+ "Notes:",
2203
+ "",
2204
+ `- ${summary.trim()}`
2205
+ ].join("\n");
1928
2206
  }
1929
- async function resolveReadSetSnapshot(projectRoot) {
1930
- const resolveInput = buildStoreResolveInput2(projectRoot);
1931
- if (resolveInput === null) {
1932
- return null;
1933
- }
1934
- const readSet = createStoreResolver2().resolveReadSet(resolveInput);
1935
- if (readSet.stores.length === 0) {
1936
- return null;
2207
+ function mergeEvidenceNotes(existing, fresh) {
2208
+ const freshSplit = splitAtEvidence(fresh);
2209
+ if (freshSplit === null) {
2210
+ return fresh.endsWith("\n") ? fresh : `${fresh}
2211
+ `;
1937
2212
  }
1938
- const personalUuids = new Set(
1939
- resolveInput.mountedStores.filter((s) => s.personal).map((s) => s.store_uuid)
1940
- );
1941
- const globalRoot = resolveGlobalRoot2();
1942
- const dirs = readSet.stores.map((entry) => ({
1943
- store_uuid: entry.store_uuid,
1944
- alias: entry.alias,
1945
- dir: join7(
1946
- globalRoot,
1947
- storeRelativePathForMount2(
1948
- resolveInput.mountedStores.find((s) => s.store_uuid === entry.store_uuid) ?? {
1949
- store_uuid: entry.store_uuid
1950
- }
1951
- )
1952
- )
1953
- }));
1954
- return {
1955
- refs: await readKnowledgeAcrossStores(dirs),
1956
- personalUuids
1957
- };
1958
- }
1959
- async function walkReadSetStores(projectRoot) {
1960
- const snapshot = await resolveReadSetSnapshot(projectRoot);
1961
- if (snapshot === null) {
1962
- return [];
2213
+ const freshHead = freshSplit.head;
2214
+ const oldEvidence = collectEvidenceItems(existing);
2215
+ const freshEvidence = collectEvidenceItems(fresh);
2216
+ const mergedNotes = [];
2217
+ const seenNotes = /* @__PURE__ */ new Set();
2218
+ for (const note of [...oldEvidence.notes, ...freshEvidence.notes]) {
2219
+ const key = note.replace(/\s+/gu, " ").trim();
2220
+ if (key.length === 0) continue;
2221
+ if (seenNotes.has(key)) continue;
2222
+ seenNotes.add(key);
2223
+ mergedNotes.push(note);
1963
2224
  }
1964
- const key = readSetWalkCacheKey(projectRoot);
1965
- const fingerprint = await readSetFingerprint(snapshot.refs);
1966
- const cached = readSetWalkCache.get(key);
1967
- if (cached !== void 0 && cached.fingerprint === fingerprint) {
1968
- return cached.entries.slice();
2225
+ const mergedPaths = [];
2226
+ const seenPaths = /* @__PURE__ */ new Set();
2227
+ for (const p of [...oldEvidence.paths, ...freshEvidence.paths]) {
2228
+ const key = p.trim();
2229
+ if (key.length === 0) continue;
2230
+ if (seenPaths.has(key)) continue;
2231
+ seenPaths.add(key);
2232
+ mergedPaths.push(key);
1969
2233
  }
1970
- const entries = await walkReadSetStoresUncached(snapshot);
1971
- readSetWalkCache.set(key, { fingerprint, entries });
1972
- return entries.slice();
2234
+ const pathLines = mergedPaths.length === 0 ? "_(no recent paths reported)_" : mergedPaths.map((p) => `- ${p}`).join("\n");
2235
+ const noteLines = mergedNotes.length === 0 ? "_(no notes recorded)_" : mergedNotes.map((n) => `- ${n}`).join("\n");
2236
+ const evidenceBody = [
2237
+ "Recent paths:",
2238
+ "",
2239
+ pathLines,
2240
+ "",
2241
+ "Notes:",
2242
+ "",
2243
+ noteLines
2244
+ ].join("\n");
2245
+ return `${freshHead}
2246
+ ## Evidence
2247
+
2248
+ ${evidenceBody}
2249
+ `;
1973
2250
  }
1974
- async function walkReadSetStoresUncached(snapshot) {
1975
- readSetWalkCount += 1;
1976
- const entries = await Promise.all(snapshot.refs.map(async (ref) => {
1977
- let source;
1978
- try {
1979
- source = await readFile4(ref.file, "utf8");
1980
- } catch {
1981
- return null;
1982
- }
1983
- const stableId = deriveRuleIdentity(ref.file, source, void 0).stableId;
1984
- const layer = snapshot.personalUuids.has(ref.store_uuid) ? "personal" : "team";
1985
- return {
1986
- qualifiedId: `${ref.alias}:${stableId}`,
1987
- file: ref.file,
1988
- type: ref.type,
1989
- alias: ref.alias,
1990
- layer,
1991
- semanticScope: readSemanticScope(source, layer),
1992
- source
1993
- };
1994
- }));
1995
- return entries.filter((entry) => entry !== null);
2251
+ function splitAtEvidence(content) {
2252
+ const tail = content.endsWith("\n") ? content : `${content}
2253
+ `;
2254
+ const match = /^([\s\S]*?)(\n## Evidence(?:\s*\(call \d+\))?\s*\n)/u.exec(tail);
2255
+ if (match === null) return null;
2256
+ return { head: match[1] ?? "" };
1996
2257
  }
1997
- async function buildCrossStoreRawItems(projectRoot) {
1998
- const items = [];
1999
- const activeProject = activeProjectOf(projectRoot);
2000
- for (const entry of filterByActiveProject(await walkReadSetStores(projectRoot), activeProject)) {
2001
- const baseDescription = extractRuleDescription(entry.source);
2002
- if (baseDescription === void 0) {
2003
- continue;
2004
- }
2005
- items.push({
2006
- stable_id: entry.qualifiedId,
2007
- description: {
2008
- ...baseDescription,
2009
- knowledge_layer: entry.layer,
2010
- semantic_scope: entry.semanticScope
2258
+ function collectEvidenceItems(content) {
2259
+ const notes = [];
2260
+ const paths = [];
2261
+ const evidenceBlockRe = /\n## Evidence(?:\s*\(call \d+\))?\s*\n([\s\S]*?)(?=\n## |$)/gu;
2262
+ let m;
2263
+ while ((m = evidenceBlockRe.exec(`${content}
2264
+ `)) !== null) {
2265
+ const block = m[1] ?? "";
2266
+ const pathSection = /Recent paths:\s*\n([\s\S]*?)(?:\n\s*Notes:|$)/u.exec(block);
2267
+ if (pathSection !== null) {
2268
+ for (const rawLine of (pathSection[1] ?? "").split(/\r?\n/u)) {
2269
+ const t = rawLine.trim();
2270
+ if (t.startsWith("- ")) {
2271
+ paths.push(t.slice(2).trim());
2272
+ }
2011
2273
  }
2012
- });
2013
- }
2014
- return items;
2015
- }
2016
- async function buildCrossStoreBodyIndex(projectRoot) {
2017
- const index = /* @__PURE__ */ new Map();
2018
- const activeProject = activeProjectOf(projectRoot);
2019
- for (const entry of filterByActiveProject(await walkReadSetStores(projectRoot), activeProject)) {
2020
- if (!index.has(entry.qualifiedId)) {
2021
- index.set(entry.qualifiedId, { file: entry.file, layer: entry.layer });
2022
2274
  }
2023
- }
2024
- return index;
2025
- }
2026
- var ALWAYS_ACTIVE_TYPES = /* @__PURE__ */ new Set(["guidelines", "models"]);
2027
- async function buildKnowledgeCensus(projectRoot) {
2028
- const census = {
2029
- by_type: {},
2030
- by_layer: { team: 0, personal: 0, project: 0 },
2031
- broad_by_type: {},
2032
- narrow_total: 0,
2033
- dropped_other_project: 0,
2034
- total: 0
2035
- };
2036
- try {
2037
- const activeProject = activeProjectOf(projectRoot);
2038
- const all = await walkReadSetStores(projectRoot);
2039
- const kept = filterByActiveProject(all, activeProject);
2040
- census.dropped_other_project = all.length - kept.length;
2041
- for (const entry of kept) {
2042
- const desc = extractRuleDescription(entry.source);
2043
- const type = desc?.knowledge_type;
2044
- const isNarrow = desc?.relevance_scope === "narrow";
2045
- if (typeof type === "string") {
2046
- census.by_type[type] = (census.by_type[type] ?? 0) + 1;
2047
- if (!isNarrow) {
2048
- census.broad_by_type[type] = (census.broad_by_type[type] ?? 0) + 1;
2275
+ const notesSection = /Notes:\s*\n([\s\S]*?)$/u.exec(block);
2276
+ const noteBody = (notesSection !== null ? notesSection[1] : block) ?? "";
2277
+ const bulletLines = [];
2278
+ let prose = [];
2279
+ for (const rawLine of noteBody.split(/\r?\n/u)) {
2280
+ const t = rawLine.trim();
2281
+ if (t.length === 0) continue;
2282
+ if (t.startsWith("- ")) {
2283
+ if (prose.length > 0) {
2284
+ notes.push(prose.join(" ").trim());
2285
+ prose = [];
2049
2286
  }
2050
- }
2051
- if (isNarrow) census.narrow_total += 1;
2052
- if (scopeRoot(entry.semanticScope) === "project") {
2053
- census.by_layer.project += 1;
2287
+ bulletLines.push(t.slice(2).trim());
2054
2288
  } else {
2055
- census.by_layer[entry.layer] += 1;
2289
+ prose.push(t);
2056
2290
  }
2057
- census.total += 1;
2058
2291
  }
2059
- } catch {
2292
+ if (prose.length > 0) notes.push(prose.join(" ").trim());
2293
+ for (const n of bulletLines) notes.push(n);
2060
2294
  }
2061
- return census;
2295
+ return { notes, paths };
2062
2296
  }
2063
- async function buildAlwaysActiveBodies(projectRoot) {
2064
- const out = [];
2065
- try {
2066
- const activeProject = activeProjectOf(projectRoot);
2067
- for (const entry of filterByActiveProject(
2068
- await walkReadSetStores(projectRoot),
2069
- activeProject
2070
- )) {
2071
- const desc = extractRuleDescription(entry.source);
2072
- if (desc === void 0) continue;
2073
- const type = desc.knowledge_type;
2074
- if (typeof type !== "string" || !ALWAYS_ACTIVE_TYPES.has(type)) continue;
2075
- if (desc.relevance_scope === "narrow") continue;
2076
- out.push({
2077
- stable_id: entry.qualifiedId,
2078
- type,
2079
- layer: entry.layer,
2080
- summary: typeof desc.summary === "string" ? desc.summary : "",
2081
- body: extractBody(entry.source)
2082
- });
2297
+ function readFrontmatterKey(content, key) {
2298
+ const match = /^---\n([\s\S]*?)\n---/u.exec(content);
2299
+ if (match === null) {
2300
+ return void 0;
2301
+ }
2302
+ const block = match[1];
2303
+ if (block === void 0) {
2304
+ return void 0;
2305
+ }
2306
+ for (const rawLine of block.split(/\r?\n/u)) {
2307
+ const line = rawLine.trim();
2308
+ const sep5 = line.indexOf(":");
2309
+ if (sep5 === -1) continue;
2310
+ const k = line.slice(0, sep5).trim();
2311
+ if (k === key) {
2312
+ return line.slice(sep5 + 1).trim();
2083
2313
  }
2084
- } catch {
2085
- return [];
2086
2314
  }
2087
- return out;
2088
- }
2089
- async function computeReadSetRevision(projectRoot) {
2090
- const revisionSource = (await walkReadSetStores(projectRoot)).filter((entry) => !entry.file.includes("/knowledge/pending/")).map((entry) => `${entry.qualifiedId}|${sha256(entry.source)}`).sort().join("\n");
2091
- return sha256(revisionSource);
2315
+ return void 0;
2092
2316
  }
2093
- async function collectStoreCanonicalEntries(projectRoot) {
2094
- const out = [];
2095
- for (const entry of await walkReadSetStores(projectRoot)) {
2096
- if (entry.file.includes("/knowledge/pending/")) {
2097
- continue;
2098
- }
2099
- const description = extractRuleDescription(entry.source);
2100
- if (description === void 0) {
2101
- continue;
2102
- }
2103
- out.push({
2104
- stableId: entry.qualifiedId.slice(entry.alias.length + 1),
2105
- qualifiedId: entry.qualifiedId,
2106
- file: entry.file,
2107
- type: entry.type,
2108
- layer: entry.layer,
2109
- body: entry.source,
2110
- description
2111
- });
2317
+ async function emitEventBestEffort(projectRoot, event) {
2318
+ try {
2319
+ await appendEventLedgerEvent(projectRoot, event);
2320
+ } catch {
2112
2321
  }
2113
- return out;
2114
2322
  }
2115
- async function collectStoreKnowledgeSummaries(projectRoot) {
2116
- const out = [];
2117
- for (const entry of await walkReadSetStores(projectRoot)) {
2118
- const description = extractRuleDescription(entry.source);
2119
- if (description === void 0) {
2120
- continue;
2323
+
2324
+ // src/tools/extract-knowledge.ts
2325
+ function registerExtractKnowledge(server, tracker) {
2326
+ server.registerTool(
2327
+ "fab_propose",
2328
+ {
2329
+ description: "Persist a proposed pending knowledge entry into the resolved write-target store under knowledge/pending/<type>/<slug>.md. Idempotent on (source_sessions[0], type, slug); repeat calls append evidence rather than overwrite. Skill-side tool \u2014 invoked at session-stop.",
2330
+ inputSchema: FabExtractKnowledgeInputShape,
2331
+ outputSchema: FabExtractKnowledgeOutputSchema.shape,
2332
+ annotations: fabExtractKnowledgeAnnotations
2333
+ },
2334
+ async (input) => {
2335
+ const requestId = randomUUID2();
2336
+ tracker?.enter(requestId);
2337
+ try {
2338
+ const gateResult = await awaitFirstReconcileGate();
2339
+ const gateWarn = gateWarning(gateResult);
2340
+ let validated;
2341
+ try {
2342
+ validated = FabExtractKnowledgeInputSchema.parse(input);
2343
+ } catch (parseErr) {
2344
+ if (parseErr instanceof ZodError) {
2345
+ const audienceIssue = parseErr.issues.find((issue) => issue.path.includes("audience"));
2346
+ if (audienceIssue !== void 0) {
2347
+ const hinted = new Error(audienceIssue.message);
2348
+ hinted.code = "scope_coordinate_invalid";
2349
+ hinted.action_hint = SCOPE_COORDINATE_HINT;
2350
+ throw hinted;
2351
+ }
2352
+ }
2353
+ throw parseErr;
2354
+ }
2355
+ const projectRoot = resolveProjectRoot();
2356
+ const result = await extractKnowledge(projectRoot, validated);
2357
+ const response = { ...result };
2358
+ if (gateWarn) {
2359
+ response.warnings = [gateWarn];
2360
+ }
2361
+ const payloadLimits = readPayloadLimits(projectRoot);
2362
+ const serialized = JSON.stringify(response);
2363
+ const guardResult = enforcePayloadLimit(serialized, payloadLimits);
2364
+ response.warnings = appendPayloadWarning(
2365
+ response.warnings,
2366
+ guardResult,
2367
+ "fab_propose produced an unexpectedly large response \u2014 extract from a smaller span of text."
2368
+ );
2369
+ return {
2370
+ content: [
2371
+ {
2372
+ type: "text",
2373
+ text: `Fabric propose: ${response.pending_path} (see structuredContent)`
2374
+ }
2375
+ ],
2376
+ structuredContent: response
2377
+ };
2378
+ } finally {
2379
+ tracker?.exit(requestId);
2380
+ }
2121
2381
  }
2122
- out.push({
2123
- stableId: entry.qualifiedId,
2124
- summary: description.summary ?? "",
2125
- layer: entry.layer
2126
- });
2127
- }
2128
- return out;
2382
+ );
2383
+ }
2384
+
2385
+ // src/tools/recall.ts
2386
+ import { randomUUID as randomUUID3 } from "crypto";
2387
+ import {
2388
+ recallAnnotations,
2389
+ recallInputSchema,
2390
+ recallOutputSchema
2391
+ } from "@fenglimg/fabric-shared/schemas/api-contracts";
2392
+ import { enforcePayloadLimit as enforcePayloadLimit2 } from "@fenglimg/fabric-shared/node/mcp-payload-guard";
2393
+
2394
+ // src/services/plan-context.ts
2395
+ import {
2396
+ buildStoreResolveInput as buildStoreResolveInput3,
2397
+ createStoreResolver as createStoreResolver3,
2398
+ resolveCandidates,
2399
+ tokenize as tokenize2
2400
+ } from "@fenglimg/fabric-shared";
2401
+ import {
2402
+ trimToPayloadBudget
2403
+ } from "@fenglimg/fabric-shared/node/mcp-payload-guard";
2404
+
2405
+ // src/services/get-knowledge.ts
2406
+ function normalizeKnowledgePath(value) {
2407
+ return value.replaceAll("\\", "/");
2129
2408
  }
2130
2409
 
2131
2410
  // src/services/id-redirect.ts
@@ -2391,61 +2670,6 @@ var LEDGER_DUAL_WRITE_METRIC_NAMES = {
2391
2670
  edit_intent_checked: METRIC_COUNTER_NAMES.edit_intent_checked
2392
2671
  };
2393
2672
 
2394
- // src/services/bm25.ts
2395
- import { tokenize } from "@fenglimg/fabric-shared";
2396
- var K1 = 1.5;
2397
- var B = 0.75;
2398
- function buildBm25Model(docs) {
2399
- const totalDocs = docs.length;
2400
- const documentFrequency = /* @__PURE__ */ new Map();
2401
- const perDoc = /* @__PURE__ */ new Map();
2402
- let totalLength = 0;
2403
- for (const doc of docs) {
2404
- const termFreq = /* @__PURE__ */ new Map();
2405
- for (const term of doc.tokens) {
2406
- termFreq.set(term, (termFreq.get(term) ?? 0) + 1);
2407
- }
2408
- for (const term of termFreq.keys()) {
2409
- documentFrequency.set(term, (documentFrequency.get(term) ?? 0) + 1);
2410
- }
2411
- totalLength += doc.tokens.length;
2412
- perDoc.set(doc.id, { termFreq, length: doc.tokens.length });
2413
- }
2414
- const avgDocLength = totalDocs > 0 ? totalLength / totalDocs : 0;
2415
- const idf = (term) => {
2416
- const n = documentFrequency.get(term) ?? 0;
2417
- return Math.log(1 + (totalDocs - n + 0.5) / (n + 0.5));
2418
- };
2419
- return {
2420
- scoreDoc(id, queryTerms) {
2421
- const data = perDoc.get(id);
2422
- if (data === void 0 || data.length === 0 || queryTerms.length === 0) {
2423
- return 0;
2424
- }
2425
- const normalizer = avgDocLength > 0 ? data.length / avgDocLength : 1;
2426
- let score = 0;
2427
- const scoredTerms = /* @__PURE__ */ new Set();
2428
- for (const term of queryTerms) {
2429
- if (scoredTerms.has(term)) {
2430
- continue;
2431
- }
2432
- scoredTerms.add(term);
2433
- const freq = data.termFreq.get(term);
2434
- if (freq === void 0) {
2435
- continue;
2436
- }
2437
- const numerator = freq * (K1 + 1);
2438
- const denominator = freq + K1 * (1 - B + B * normalizer);
2439
- score += idf(term) * (numerator / denominator);
2440
- }
2441
- return score;
2442
- }
2443
- };
2444
- }
2445
- function buildQueryTerms(text) {
2446
- return tokenize(text);
2447
- }
2448
-
2449
2673
  // src/services/vector-retrieval.ts
2450
2674
  var embedderLoad;
2451
2675
  var OPTIONAL_EMBED_PACKAGE = "fastembed";
@@ -2663,7 +2887,9 @@ async function planContext(projectRoot, input) {
2663
2887
  const relevanceFloor = maxScore * relevanceRatio;
2664
2888
  const survivingScored = hasQuery && maxScore > 0 && relevanceRatio > 0 ? cappedScored.filter((entry) => entry.score >= relevanceFloor) : cappedScored;
2665
2889
  const topKCandidates = survivingScored.map((entry) => entry.item);
2666
- const omittedCandidateCount = Math.max(0, rankedCandidates.length - topKCandidates.length);
2890
+ const topKIds = new Set(topKCandidates.map((item) => item.stable_id));
2891
+ const retrievalDropped = rankedCandidates.filter((item) => !topKIds.has(item.stable_id)).map((item) => ({ id: item.stable_id, reason: "retrieval_budget" }));
2892
+ const omittedCandidateCount = retrievalDropped.length;
2667
2893
  let candidates = topKCandidates;
2668
2894
  const relatedAppended = {};
2669
2895
  if (input.include_related === true) {
@@ -2703,8 +2929,10 @@ async function planContext(projectRoot, input) {
2703
2929
  const basePreflightDiagnostics = buildPreflightDiagnostics(suppressedStableIds);
2704
2930
  let payloadTrimDropped = 0;
2705
2931
  let payloadOverBudget = false;
2932
+ let payloadDropped = [];
2706
2933
  if (input.payload_budget !== void 0) {
2707
2934
  const fullCandidateCount = candidates.length;
2935
+ const preTrimIds = candidates.map((item) => item.stable_id);
2708
2936
  const serialize = (candidateSlice) => {
2709
2937
  const dropped = fullCandidateCount - candidateSlice.length;
2710
2938
  const totalOmitted = omittedCandidateCount + dropped;
@@ -2715,7 +2943,7 @@ async function planContext(projectRoot, input) {
2715
2943
  entries,
2716
2944
  ...input.intent !== void 0 ? { intent: input.intent } : {},
2717
2945
  candidates: candidateSlice,
2718
- ...totalOmitted > 0 ? { omitted_candidate_count: totalOmitted } : {},
2946
+ ...totalOmitted > 0 ? { dropped_count: totalOmitted } : {},
2719
2947
  preflight_diagnostics: basePreflightDiagnostics,
2720
2948
  warnings: dropped > 0 && input.payload_budget?.trim_warning !== void 0 ? [...input.payload_budget.warnings ?? [], input.payload_budget.trim_warning] : input.payload_budget?.warnings,
2721
2949
  ...Object.keys(relatedAppended).length > 0 ? { related_appended: relatedAppended } : {}
@@ -2723,6 +2951,8 @@ async function planContext(projectRoot, input) {
2723
2951
  };
2724
2952
  const trim = trimToPayloadBudget(candidates, serialize, input.payload_budget.limits);
2725
2953
  if (trim.dropped > 0) {
2954
+ const survivingIds = new Set(trim.items.map((item) => item.stable_id));
2955
+ payloadDropped = preTrimIds.filter((id) => !survivingIds.has(id)).map((id) => ({ id, reason: "payload_budget" }));
2726
2956
  candidates = trim.items;
2727
2957
  payloadTrimDropped = trim.dropped;
2728
2958
  }
@@ -2746,7 +2976,11 @@ async function planContext(projectRoot, input) {
2746
2976
  entries,
2747
2977
  ...input.intent !== void 0 ? { intent: input.intent } : {},
2748
2978
  candidates,
2749
- ...omittedCandidateCount + payloadTrimDropped > 0 ? { omitted_candidate_count: omittedCandidateCount + payloadTrimDropped } : {},
2979
+ // K6 (W3-K): assemble the structured dropped[] ONCE, here at final result
2980
+ // assembly — the CONSTANT retrieval_budget set (computed pre-trim) followed
2981
+ // by the payload_budget set (computed after the trim settled). Omitted when
2982
+ // nothing was dropped, keeping the steady-state wire shape unchanged.
2983
+ ...retrievalDropped.length + payloadDropped.length > 0 ? { dropped: [...retrievalDropped, ...payloadDropped] } : {},
2750
2984
  preflight_diagnostics: basePreflightDiagnostics,
2751
2985
  // v2.2 W5 R1: the auto_healed / previous_revision_hash pair was tied to the
2752
2986
  // co-location loadActiveMetaOrStale read-path auto-heal, which is retired.
@@ -2854,7 +3088,7 @@ function getOrBuildBm25Model(revision, rawItems, docTexts) {
2854
3088
  const model = buildBm25Model(
2855
3089
  rawItems.map((item) => ({
2856
3090
  id: item.stable_id,
2857
- tokens: tokenize2(docTexts.get(item.stable_id) ?? documentTextForItem(item.description))
3091
+ fields: documentFieldsForItem(item.description)
2858
3092
  }))
2859
3093
  );
2860
3094
  bm25ModelCache = { revision, model };
@@ -2928,6 +3162,16 @@ function documentTextForItem(description) {
2928
3162
  ...description.tags ?? []
2929
3163
  ].join(" ");
2930
3164
  }
3165
+ function documentFieldsForItem(description) {
3166
+ return {
3167
+ title: tokenize2(description.summary),
3168
+ tags: tokenize2(
3169
+ [...description.tags ?? [], ...description.tech_stack, ...description.entities ?? []].join(" ")
3170
+ ),
3171
+ summary: tokenize2([description.must_read_if, ...description.intent_clues].join(" ")),
3172
+ body: tokenize2(description.impact.join(" "))
3173
+ };
3174
+ }
2931
3175
  function isEmptyShellDescription(description, stableId) {
2932
3176
  return description.summary === stableId && description.intent_clues.length === 0 && description.tech_stack.length === 0 && description.impact.length === 0;
2933
3177
  }
@@ -3027,7 +3271,7 @@ function relatedLookupKeys(stableId) {
3027
3271
  }
3028
3272
 
3029
3273
  // src/services/recall.ts
3030
- var RECALL_DIRECTIVE = "Before you edit or commit to a decision, cite the KB id you apply or dismiss (first reply line: `KB: <id> [applied|dismissed:<reason>]`).";
3274
+ 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>)`.";
3031
3275
  async function recall(projectRoot, input) {
3032
3276
  const planResult = await planContext(projectRoot, input);
3033
3277
  const { selection_token: _token, payload_trimmed: _pt, payload_over_budget: _pob, ...planRest } = planResult;
@@ -3076,13 +3320,22 @@ async function recall(projectRoot, input) {
3076
3320
  paths.push(attachPathStore({ stable_id: candidate.stable_id, path: ref.file }));
3077
3321
  }
3078
3322
  const nextSteps = buildNextSteps(planResult, paths, candidateById, candidateLookup);
3079
- const markedCandidates = planRest.candidates.map(
3080
- (c) => isAlwaysActive(c) ? { ...c, always_active: true } : c
3081
- );
3323
+ const pathByStableId = new Map(paths.map((p) => [p.stable_id, p]));
3324
+ const entries = planRest.candidates.map((c, index) => {
3325
+ const readPath = pathByStableId.get(c.stable_id);
3326
+ return {
3327
+ stable_id: c.stable_id,
3328
+ rank: index + 1,
3329
+ description: c.description,
3330
+ ...readPath ? { read_path: readPath.path } : {},
3331
+ ...readPath?.store ? { store: readPath.store } : {},
3332
+ ...isAlwaysActive(c) ? { body_in_context: true } : {}
3333
+ };
3334
+ });
3335
+ const { entries: _reqProfiles, candidates: _candidates, ...planRestNoLists } = planRest;
3082
3336
  return {
3083
- ...planRest,
3084
- candidates: markedCandidates,
3085
- paths,
3337
+ ...planRestNoLists,
3338
+ entries,
3086
3339
  directive: RECALL_DIRECTIVE,
3087
3340
  ...nextSteps.length > 0 ? { next_steps: nextSteps } : {}
3088
3341
  };
@@ -3094,7 +3347,7 @@ function isAlwaysActive(candidate) {
3094
3347
  }
3095
3348
  function buildNextSteps(planResult, paths, candidateById, candidateLookup) {
3096
3349
  const nextSteps = [];
3097
- const omitted = planResult.omitted_candidate_count ?? 0;
3350
+ const omitted = (planResult.dropped ?? []).filter((d) => d.reason === "retrieval_budget").length;
3098
3351
  if (omitted > 0) {
3099
3352
  nextSteps.push(
3100
3353
  `${omitted} lower-ranked candidate(s) were omitted by the retrieval budget \u2014 pass a narrower intent (or raise plan_context_top_k) to surface them.`
@@ -3130,7 +3383,7 @@ function registerRecall(server, tracker) {
3130
3383
  server.registerTool(
3131
3384
  "fab_recall",
3132
3385
  {
3133
- description: "Recall the Fabric knowledge relevant to the files you are about to touch. Pass candidate `paths` (+ optional `intent`) and receive a DESCRIPTION index (`candidates[]`: summary / intent_clues / must_read_if / related) plus a READ-PATH index (`paths[]`: one on-disk knowledge file per surfaced candidate). It does NOT return bodies \u2014 Read a `paths[].path` to load the full entry on demand (cheap, and recoverable), instead of paying a permanent per-recall context tax for bodies you may not use. Pass `ids` to scope which read paths are surfaced when you already know which entries you want; `include_related:true` to also surface one-hop `related` neighbours' descriptions + read paths.",
3386
+ description: "Recall the Fabric knowledge relevant to the files you are about to touch. Pass candidate `paths` (+ optional `intent`) and receive a single ranked `entries[]` list \u2014 each entry carries its DESCRIPTION (summary / intent_clues / must_read_if / related), a `read_path` (the on-disk knowledge file to Read for the body), `rank` (1 = most relevant), and `body_in_context:true` when the body is already injected at SessionStart. It does NOT return bodies \u2014 Read an entry's `read_path` on demand (cheap, recoverable) instead of paying a permanent per-recall context tax for bodies you may not use. Pass `ids` to scope which entries surface a read_path when you already know which you want; `include_related:true` to also surface one-hop `related` neighbours.",
3134
3387
  inputSchema: recallInputSchema,
3135
3388
  outputSchema: recallOutputSchema,
3136
3389
  annotations: recallAnnotations
@@ -3185,11 +3438,16 @@ function registerRecall(server, tracker) {
3185
3438
  response.warnings = appendPayloadWarning(
3186
3439
  response.warnings,
3187
3440
  guardResult,
3188
- "Pass an explicit `ids` array (or a narrower `intent`) to scope fab_recall's read-path index \u2014 recall returns descriptions + read paths, so Read a `paths[].path` to load any body on demand."
3441
+ "Pass an explicit `ids` array (or a narrower `intent`) to scope fab_recall's entries \u2014 each entry carries a `read_path`, so Read it to load any body on demand."
3189
3442
  );
3190
3443
  payloadBytesOut = Buffer.byteLength(serialized, "utf8");
3191
3444
  return {
3192
- content: [{ type: "text", text: JSON.stringify(response) }],
3445
+ content: [
3446
+ {
3447
+ type: "text",
3448
+ text: `Fabric recall: ${result.entries.length} entries (see structuredContent)`
3449
+ }
3450
+ ],
3193
3451
  structuredContent: response
3194
3452
  };
3195
3453
  } catch (error) {
@@ -3393,7 +3651,12 @@ function registerArchiveScan(server, tracker) {
3393
3651
  "fab_archive_scan returned a large candidate set \u2014 pass an explicit `range` of session_ids to narrow the scan."
3394
3652
  );
3395
3653
  return {
3396
- content: [{ type: "text", text: JSON.stringify(result) }],
3654
+ content: [
3655
+ {
3656
+ type: "text",
3657
+ text: `Fabric archive scan: ${result.session_ids.length} sessions, ${result.dropped.length} dropped (see structuredContent)`
3658
+ }
3659
+ ],
3397
3660
  structuredContent: result
3398
3661
  };
3399
3662
  } finally {
@@ -3419,6 +3682,41 @@ import { existsSync as existsSync5 } from "fs";
3419
3682
  import { readFile as readFile6, readdir as readdir2, stat as stat2, unlink as unlink2 } from "fs/promises";
3420
3683
  import { homedir } from "os";
3421
3684
  import { basename, isAbsolute, join as join10, relative, resolve as resolve2, sep as sep2 } from "path";
3685
+
3686
+ // src/services/promotion-gate.ts
3687
+ function toLocalId(id) {
3688
+ const sep5 = id.indexOf(":");
3689
+ return sep5 === -1 ? id : id.slice(sep5 + 1);
3690
+ }
3691
+ async function hasUnresolvedDismissal(projectRoot, id) {
3692
+ const target = toLocalId(id);
3693
+ let events;
3694
+ try {
3695
+ ({ events } = await readEventLedger(projectRoot, { event_type: "assistant_turn_observed" }));
3696
+ } catch {
3697
+ return false;
3698
+ }
3699
+ let latestTs = -1;
3700
+ let latestTag;
3701
+ for (const event of events) {
3702
+ if (event.event_type !== "assistant_turn_observed") continue;
3703
+ const ids = event.cite_ids;
3704
+ const tags = event.cite_tags;
3705
+ for (let i = 0; i < ids.length; i += 1) {
3706
+ const cited = ids[i];
3707
+ if (cited === void 0 || toLocalId(cited) !== target) continue;
3708
+ const tag = tags[i];
3709
+ if (tag === void 0) continue;
3710
+ if (event.ts >= latestTs) {
3711
+ latestTs = event.ts;
3712
+ latestTag = tag;
3713
+ }
3714
+ }
3715
+ }
3716
+ return latestTag === "dismissed";
3717
+ }
3718
+
3719
+ // src/services/review.ts
3422
3720
  import { allocateStoreKnowledgeId, isPersonalScope as isPersonalScope2 } from "@fenglimg/fabric-shared";
3423
3721
 
3424
3722
  // src/services/pending-dedupe.ts
@@ -3573,11 +3871,6 @@ var PLURAL_TYPES = [
3573
3871
  ];
3574
3872
  async function reviewKnowledge(projectRoot, input) {
3575
3873
  switch (input.action) {
3576
- case "list":
3577
- return {
3578
- action: "list",
3579
- items: await listPending(projectRoot, input.filters)
3580
- };
3581
3874
  case "approve":
3582
3875
  return {
3583
3876
  action: "approve",
@@ -3600,11 +3893,6 @@ async function reviewKnowledge(projectRoot, input) {
3600
3893
  }
3601
3894
  case "modify-layer":
3602
3895
  return await modifyEntry(projectRoot, input.pending_path, input.changes);
3603
- case "search":
3604
- return {
3605
- action: "search",
3606
- items: await searchEntries(projectRoot, input.query, input.filters)
3607
- };
3608
3896
  case "defer":
3609
3897
  return {
3610
3898
  action: "defer",
@@ -3621,6 +3909,24 @@ async function reviewKnowledge(projectRoot, input) {
3621
3909
  }
3622
3910
  }
3623
3911
  }
3912
+ async function reviewPending(projectRoot, input) {
3913
+ switch (input.action) {
3914
+ case "list":
3915
+ return {
3916
+ action: "list",
3917
+ items: await listPending(projectRoot, input.filters)
3918
+ };
3919
+ case "search":
3920
+ return {
3921
+ action: "search",
3922
+ items: await searchEntries(projectRoot, input.query, input.filters)
3923
+ };
3924
+ default: {
3925
+ const exhaustive = input;
3926
+ throw new Error(`unsupported action: ${JSON.stringify(exhaustive)}`);
3927
+ }
3928
+ }
3929
+ }
3624
3930
  function storeKnowledgeRoots(projectRoot) {
3625
3931
  const roots = [];
3626
3932
  for (const layer of ["team", "personal"]) {
@@ -3834,7 +4140,10 @@ async function approveOne(projectRoot, pendingPath) {
3834
4140
  const newFilename = `${stableId}--${slug}.md`;
3835
4141
  targetAbs = join10(resolveStoreCanonicalBase(layer, projectRoot), pluralType, newFilename);
3836
4142
  await ensureParentDirectory(targetAbs);
3837
- const rewritten = rewriteFrontmatterForPromote(content, stableId);
4143
+ const rewritten = rewriteFrontmatterMerge(
4144
+ rewriteFrontmatterForPromote(content, stableId),
4145
+ { last_review_confirmed_at: (/* @__PURE__ */ new Date()).toISOString() }
4146
+ );
3838
4147
  await atomicWriteText(targetAbs, rewritten);
3839
4148
  writtenTarget = true;
3840
4149
  if (sourceIsStore) {
@@ -3917,6 +4226,13 @@ async function modifyEntry(projectRoot, pendingPath, changes) {
3917
4226
  const content = await readFile6(target.absPath, "utf8");
3918
4227
  const fm = parseFrontmatter(content);
3919
4228
  const currentLayer = fm.layer ?? "team";
4229
+ if (fm.maturity === "verified" && changes.maturity === "proven" && fm.id !== void 0) {
4230
+ if (await hasUnresolvedDismissal(projectRoot, fm.id)) {
4231
+ throw new Error(
4232
+ `verified\u2192proven promotion blocked for ${fm.id}: an unresolved dismissed cite is on record (rubric necessary gate "0 dismiss"). Re-affirm the entry with an applied cite or address the objection, then retry.`
4233
+ );
4234
+ }
4235
+ }
3920
4236
  if (changes.semantic_scope !== void 0 && isPersonalScope2(changes.semantic_scope)) {
3921
4237
  throw new Error(
3922
4238
  `cannot re-scope to personal coordinate '${changes.semantic_scope}' via modify; use action 'modify-layer' with layer 'personal' to move the entry into the personal store (R5#3)`
@@ -3925,7 +4241,10 @@ async function modifyEntry(projectRoot, pendingPath, changes) {
3925
4241
  if (changes.layer !== void 0 && changes.layer !== currentLayer) {
3926
4242
  return await modifyLayerFlip(projectRoot, target, content, fm, changes);
3927
4243
  }
3928
- const merged = rewriteFrontmatterMerge(content, changes);
4244
+ const merged = rewriteFrontmatterMerge(content, {
4245
+ ...changes,
4246
+ last_review_confirmed_at: (/* @__PURE__ */ new Date()).toISOString()
4247
+ });
3929
4248
  await atomicWriteText(target.absPath, merged);
3930
4249
  const changedFields = Object.keys(changes).filter(
3931
4250
  (field) => changes[field] !== void 0
@@ -4024,7 +4343,11 @@ async function modifyLayerFlip(projectRoot, target, content, fm, changes) {
4024
4343
  relevance_scope: "broad",
4025
4344
  relevance_paths: []
4026
4345
  } : { ...changes, layer: toLayer };
4027
- const rewritten = rewriteFrontmatterMerge(content, effectivePatch, { id: newStableId });
4346
+ const rewritten = rewriteFrontmatterMerge(
4347
+ content,
4348
+ { ...effectivePatch, last_review_confirmed_at: (/* @__PURE__ */ new Date()).toISOString() },
4349
+ { id: newStableId }
4350
+ );
4028
4351
  await atomicWriteText(toAbs, rewritten);
4029
4352
  if (target.isInProjectTree) {
4030
4353
  const relSource = relative(projectRoot, target.absPath);
@@ -4348,6 +4671,9 @@ function parseFrontmatter(content) {
4348
4671
  case "deferred_until":
4349
4672
  out.deferred_until = stripQuotes(value);
4350
4673
  break;
4674
+ case "last_review_confirmed_at":
4675
+ out.last_review_confirmed_at = stripQuotes(value);
4676
+ break;
4351
4677
  default:
4352
4678
  break;
4353
4679
  }
@@ -4413,6 +4739,7 @@ ${content}`;
4413
4739
  if (patch.related !== void 0) updates.related = `related: ${flowArray(patch.related)}`;
4414
4740
  if (patch.status !== void 0) updates.status = `status: ${patch.status}`;
4415
4741
  if (patch.deferred_until !== void 0) updates.deferred_until = `deferred_until: ${quoteIfNeeded(patch.deferred_until)}`;
4742
+ if (patch.last_review_confirmed_at !== void 0) updates.last_review_confirmed_at = `last_review_confirmed_at: ${quoteIfNeeded(patch.last_review_confirmed_at)}`;
4416
4743
  const lines = block.split(/\r?\n/u);
4417
4744
  const seen = /* @__PURE__ */ new Set();
4418
4745
  const newLines = [];
@@ -4449,6 +4776,7 @@ function appendPatchLines(lines, patch) {
4449
4776
  if (patch.related !== void 0) lines.push(`related: ${flowArray(patch.related)}`);
4450
4777
  if (patch.status !== void 0) lines.push(`status: ${patch.status}`);
4451
4778
  if (patch.deferred_until !== void 0) lines.push(`deferred_until: ${quoteIfNeeded(patch.deferred_until)}`);
4779
+ if (patch.last_review_confirmed_at !== void 0) lines.push(`last_review_confirmed_at: ${quoteIfNeeded(patch.last_review_confirmed_at)}`);
4452
4780
  }
4453
4781
  function flowArrayElement(value) {
4454
4782
  if (/[\n\r,\[\]{}"#:]/u.test(value) || /^\s|\s$/u.test(value)) {
@@ -4481,40 +4809,104 @@ async function emitEventBestEffort2(projectRoot, event) {
4481
4809
  // src/tools/review.ts
4482
4810
  function registerReview(server, tracker) {
4483
4811
  server.registerTool(
4484
- "fab_review",
4812
+ "fab_review",
4813
+ {
4814
+ description: "Review pending knowledge entries in resolved store-backed knowledge/pending/. Discriminated by `action`; required fields per action: list \u2192 (filters optional); approve \u2192 pending_paths[\u22651]; reject \u2192 pending_paths[\u22651] + reason; modify / modify-content \u2192 pending_path + changes; modify-layer \u2192 pending_path + changes.layer(team|personal); search \u2192 query; defer \u2192 pending_paths[\u22651] (until/reason optional). approve allocates a stable_id and promotes to the canonical store knowledge path. Skill-side tool \u2014 invoked by fabric-review.",
4815
+ // Flat ZodRawShape required by MCP SDK 1.29.0 registerTool. The
4816
+ // authoritative cross-field contract still lives in FabReviewInputSchema
4817
+ // (discriminatedUnion) and is enforced inside the handler via
4818
+ // `FabReviewInputSchema.parse(input)`.
4819
+ inputSchema: FabReviewInputShape,
4820
+ outputSchema: FabReviewOutputShape,
4821
+ annotations: fabReviewAnnotations
4822
+ },
4823
+ async (input) => {
4824
+ const requestId = randomUUID5();
4825
+ tracker?.enter(requestId);
4826
+ try {
4827
+ const gateResult = await awaitFirstReconcileGate();
4828
+ const gateWarn = gateWarning(gateResult);
4829
+ const narrowed = FabReviewInputSchema.parse(input);
4830
+ const projectRoot = resolveProjectRoot();
4831
+ const result = await reviewKnowledge(projectRoot, narrowed);
4832
+ const response = { ...result };
4833
+ if (gateWarn) {
4834
+ response.warnings = [gateWarn];
4835
+ }
4836
+ const payloadLimits = readPayloadLimits(projectRoot);
4837
+ const serialized = JSON.stringify(response);
4838
+ const guardResult = enforcePayloadLimit4(serialized, payloadLimits);
4839
+ response.warnings = appendPayloadWarning(
4840
+ response.warnings,
4841
+ guardResult,
4842
+ "fab_review returned a large result set \u2014 pass a narrower filter (topic / status / id) to reduce response size."
4843
+ );
4844
+ return {
4845
+ content: [
4846
+ {
4847
+ type: "text",
4848
+ text: `Fabric review: ${response.action} (see structuredContent)`
4849
+ }
4850
+ ],
4851
+ structuredContent: response
4852
+ };
4853
+ } finally {
4854
+ tracker?.exit(requestId);
4855
+ }
4856
+ }
4857
+ );
4858
+ }
4859
+
4860
+ // src/tools/pending.ts
4861
+ import { randomUUID as randomUUID6 } from "crypto";
4862
+ import {
4863
+ FabPendingInputSchema,
4864
+ FabPendingInputShape,
4865
+ FabPendingOutputShape,
4866
+ fabPendingAnnotations
4867
+ } from "@fenglimg/fabric-shared/schemas/api-contracts";
4868
+ import { enforcePayloadLimit as enforcePayloadLimit5 } from "@fenglimg/fabric-shared/node/mcp-payload-guard";
4869
+ function registerPending(server, tracker) {
4870
+ server.registerTool(
4871
+ "fab_pending",
4485
4872
  {
4486
- description: "Review pending knowledge entries in resolved store-backed knowledge/pending/. Discriminated by `action`: list (enumerate), approve (allocate stable_id and promote to canonical store knowledge path), reject/modify/search/defer (TASK-002). Skill-side tool \u2014 invoked by fabric-review.",
4873
+ description: "Browse and search store-backed pending + canonical knowledge (read-only). Discriminated by `action`; required fields per action: list \u2192 (filters optional, returns pending entries with `pending_path`); search \u2192 query (filters optional, ranges over pending + canonical with `area`+`path`). Never mutates state \u2014 pair with the write-only fab_review tool for approve/reject/modify/defer. Skill-side read tool \u2014 invoked by fabric-review / fabric-archive.",
4487
4874
  // Flat ZodRawShape required by MCP SDK 1.29.0 registerTool. The
4488
- // authoritative cross-field contract still lives in FabReviewInputSchema
4875
+ // authoritative cross-field contract still lives in FabPendingInputSchema
4489
4876
  // (discriminatedUnion) and is enforced inside the handler via
4490
- // `FabReviewInputSchema.parse(input)`.
4491
- inputSchema: FabReviewInputShape,
4492
- outputSchema: FabReviewOutputShape,
4493
- annotations: fabReviewAnnotations
4877
+ // `FabPendingInputSchema.parse(input)`.
4878
+ inputSchema: FabPendingInputShape,
4879
+ outputSchema: FabPendingOutputShape,
4880
+ annotations: fabPendingAnnotations
4494
4881
  },
4495
4882
  async (input) => {
4496
- const requestId = randomUUID5();
4883
+ const requestId = randomUUID6();
4497
4884
  tracker?.enter(requestId);
4498
4885
  try {
4499
4886
  const gateResult = await awaitFirstReconcileGate();
4500
4887
  const gateWarn = gateWarning(gateResult);
4501
- const narrowed = FabReviewInputSchema.parse(input);
4888
+ const narrowed = FabPendingInputSchema.parse(input);
4502
4889
  const projectRoot = resolveProjectRoot();
4503
- const result = await reviewKnowledge(projectRoot, narrowed);
4890
+ const result = await reviewPending(projectRoot, narrowed);
4504
4891
  const response = { ...result };
4505
4892
  if (gateWarn) {
4506
4893
  response.warnings = [gateWarn];
4507
4894
  }
4508
4895
  const payloadLimits = readPayloadLimits(projectRoot);
4509
4896
  const serialized = JSON.stringify(response);
4510
- const guardResult = enforcePayloadLimit4(serialized, payloadLimits);
4897
+ const guardResult = enforcePayloadLimit5(serialized, payloadLimits);
4511
4898
  response.warnings = appendPayloadWarning(
4512
4899
  response.warnings,
4513
4900
  guardResult,
4514
- "fab_review returned a large result set \u2014 pass a narrower filter (topic / status / id) to reduce response size."
4901
+ "fab_pending returned a large result set \u2014 pass a narrower filter (topic / status / id) to reduce response size."
4515
4902
  );
4516
4903
  return {
4517
- content: [{ type: "text", text: JSON.stringify(response) }],
4904
+ content: [
4905
+ {
4906
+ type: "text",
4907
+ text: `Fabric pending: ${response.action} (see structuredContent)`
4908
+ }
4909
+ ],
4518
4910
  structuredContent: response
4519
4911
  };
4520
4912
  } finally {
@@ -4525,9 +4917,9 @@ function registerReview(server, tracker) {
4525
4917
  }
4526
4918
 
4527
4919
  // src/services/doctor.ts
4528
- import { access as access4, readFile as readFile15, readdir as readdirAsync, stat as statAsync, unlink as unlink4, writeFile as writeFile3 } from "fs/promises";
4920
+ import { access as access4, readFile as readFile16, readdir as readdirAsync, stat as statAsync, unlink as unlink4, writeFile as writeFile3 } from "fs/promises";
4529
4921
  import { constants as constants2 } from "fs";
4530
- import { isAbsolute as isAbsolute2, join as join20, posix as posix4, resolve as resolve3 } from "path";
4922
+ import { isAbsolute as isAbsolute2, join as join21, posix as posix5, resolve as resolve3 } from "path";
4531
4923
  import {
4532
4924
  createTranslator,
4533
4925
  forensicReportSchema,
@@ -5214,6 +5606,9 @@ async function inspectStoreKnowledgeAge(projectRoot, now, lastActiveIndex) {
5214
5606
  if (maturity === void 0) {
5215
5607
  continue;
5216
5608
  }
5609
+ if (entry.description.relevance_scope === "broad") {
5610
+ continue;
5611
+ }
5217
5612
  const lastActive = lastActiveIndex.get(entry.stableId);
5218
5613
  if (lastActive === void 0) {
5219
5614
  continue;
@@ -5298,6 +5693,139 @@ function createStaleArchiveCheck(t, inspection) {
5298
5693
  };
5299
5694
  }
5300
5695
 
5696
+ // src/services/doctor-knowledge-promotion.ts
5697
+ var PROVEN_RELATED_INDEGREE_THRESHOLD = 3;
5698
+ function toLocalId2(id) {
5699
+ const sep5 = id.indexOf(":");
5700
+ return sep5 === -1 ? id : id.slice(sep5 + 1);
5701
+ }
5702
+ function computeRelatedInDegree(entries) {
5703
+ const indegree = /* @__PURE__ */ new Map();
5704
+ for (const entry of entries) {
5705
+ for (const target of entry.description.related ?? []) {
5706
+ const key = toLocalId2(target);
5707
+ indegree.set(key, (indegree.get(key) ?? 0) + 1);
5708
+ }
5709
+ }
5710
+ return indegree;
5711
+ }
5712
+ async function inspectStoreKnowledgePromotion(projectRoot, indegreeThreshold = PROVEN_RELATED_INDEGREE_THRESHOLD) {
5713
+ const entries = await collectStoreCanonicalEntries(projectRoot);
5714
+ const indegree = computeRelatedInDegree(entries);
5715
+ const candidates = [];
5716
+ for (const entry of entries) {
5717
+ if (entry.description.maturity !== "verified") {
5718
+ continue;
5719
+ }
5720
+ const deg = indegree.get(entry.stableId) ?? 0;
5721
+ if (deg >= indegreeThreshold) {
5722
+ candidates.push({
5723
+ stable_id: entry.qualifiedId,
5724
+ path: `store:${entry.qualifiedId}`,
5725
+ related_indegree: deg
5726
+ });
5727
+ }
5728
+ }
5729
+ candidates.sort((a, b) => a.path.localeCompare(b.path));
5730
+ return { candidates, indegree_threshold: indegreeThreshold };
5731
+ }
5732
+ function createPromotionCandidateCheck(t, inspection) {
5733
+ if (inspection.candidates.length === 0) {
5734
+ return {
5735
+ name: t("doctor.check.promotion_candidate.name"),
5736
+ status: "ok",
5737
+ message: t("doctor.check.promotion_candidate.ok")
5738
+ };
5739
+ }
5740
+ const first = inspection.candidates[0];
5741
+ const detail = `${first.stable_id} (verified, ${first.related_indegree} inbound related \u2192 proven candidate)`;
5742
+ const count = inspection.candidates.length;
5743
+ return {
5744
+ name: t("doctor.check.promotion_candidate.name"),
5745
+ // An opportunity, not a defect — info kind keeps doctor health "ok".
5746
+ status: "ok",
5747
+ kind: "info",
5748
+ code: "knowledge_promotion_candidate",
5749
+ fixable: false,
5750
+ message: t(`doctor.check.promotion_candidate.message.${count === 1 ? "singular" : "plural"}`, {
5751
+ count: String(count),
5752
+ threshold: String(inspection.indegree_threshold),
5753
+ detail
5754
+ }),
5755
+ actionHint: t("doctor.check.promotion_candidate.remediation")
5756
+ };
5757
+ }
5758
+
5759
+ // src/services/doctor-knowledge-review-recheck.ts
5760
+ var MS_PER_DAY3 = 24 * 60 * 60 * 1e3;
5761
+ var LAST_REVIEW_CONFIRMED_LINE = /^last_review_confirmed_at:\s*"?([^"\n]+?)"?\s*$/mu;
5762
+ var CREATED_AT_LINE2 = /^created_at:\s*"?([^"\n]+?)"?\s*$/mu;
5763
+ function resolveClock(source) {
5764
+ const review = LAST_REVIEW_CONFIRMED_LINE.exec(source)?.[1];
5765
+ if (review !== void 0) {
5766
+ const ms = Date.parse(review);
5767
+ if (!Number.isNaN(ms)) return { ms, source: "review" };
5768
+ }
5769
+ const created = CREATED_AT_LINE2.exec(source)?.[1];
5770
+ if (created !== void 0) {
5771
+ const ms = Date.parse(created);
5772
+ if (!Number.isNaN(ms)) return { ms, source: "created" };
5773
+ }
5774
+ return void 0;
5775
+ }
5776
+ async function inspectStoreBroadReviewRecheck(projectRoot, now, thresholdDays = readBroadReviewRecheckThresholdDays(projectRoot)) {
5777
+ const entries = await collectStoreCanonicalEntries(projectRoot);
5778
+ const candidates = [];
5779
+ for (const entry of entries) {
5780
+ if (entry.description.relevance_scope !== "broad") {
5781
+ continue;
5782
+ }
5783
+ const clock = resolveClock(entry.body);
5784
+ if (clock === void 0) {
5785
+ continue;
5786
+ }
5787
+ const ageDays = Math.floor((now - clock.ms) / MS_PER_DAY3);
5788
+ if (ageDays > thresholdDays) {
5789
+ candidates.push({
5790
+ stable_id: entry.qualifiedId,
5791
+ path: `store:${entry.qualifiedId}`,
5792
+ age_days: ageDays,
5793
+ clock_source: clock.source
5794
+ });
5795
+ }
5796
+ }
5797
+ candidates.sort((a, b) => a.path.localeCompare(b.path));
5798
+ return { candidates, threshold_days: thresholdDays };
5799
+ }
5800
+ function createBroadReviewRecheckCheck(t, inspection) {
5801
+ if (inspection.candidates.length === 0) {
5802
+ return {
5803
+ name: t("doctor.check.broad_review_recheck.name"),
5804
+ status: "ok",
5805
+ message: t("doctor.check.broad_review_recheck.ok")
5806
+ };
5807
+ }
5808
+ const first = inspection.candidates[0];
5809
+ const clockLabel = first.clock_source === "review" ? "last reviewed" : "never re-confirmed since creation,";
5810
+ const detail = `${first.stable_id} (broad, ${clockLabel} ${first.age_days}d ago \u2192 recheck)`;
5811
+ const count = inspection.candidates.length;
5812
+ return {
5813
+ name: t("doctor.check.broad_review_recheck.name"),
5814
+ // A re-confirm nudge, not a defect — info kind keeps doctor health "ok"
5815
+ // (broad is exempt from decay warnings; this is the gentler review clock).
5816
+ status: "ok",
5817
+ kind: "info",
5818
+ code: "knowledge_broad_review_recheck",
5819
+ fixable: false,
5820
+ message: t(`doctor.check.broad_review_recheck.message.${count === 1 ? "singular" : "plural"}`, {
5821
+ count: String(count),
5822
+ thresholdDays: String(inspection.threshold_days),
5823
+ detail
5824
+ }),
5825
+ actionHint: t("doctor.check.broad_review_recheck.remediation")
5826
+ };
5827
+ }
5828
+
5301
5829
  // src/services/doctor-scope-lint.ts
5302
5830
  import { readFile as readFile9 } from "fs/promises";
5303
5831
  import { join as join14 } from "path";
@@ -5762,17 +6290,17 @@ async function inspectEventsJsonlGates(projectRoot, options = {}) {
5762
6290
  let ledgerSizeBytes = 0;
5763
6291
  let ledgerStalenessMs = null;
5764
6292
  try {
5765
- const stat4 = await fs.stat(eventsPath);
5766
- ledgerSizeBytes = stat4.size;
5767
- ledgerStalenessMs = Math.max(0, now.getTime() - stat4.mtimeMs);
6293
+ const stat5 = await fs.stat(eventsPath);
6294
+ ledgerSizeBytes = stat5.size;
6295
+ ledgerStalenessMs = Math.max(0, now.getTime() - stat5.mtimeMs);
5768
6296
  } catch (error) {
5769
6297
  if (!(isNodeError(error) && error.code === "ENOENT")) throw error;
5770
6298
  }
5771
6299
  let metricsStalenessMs = null;
5772
6300
  if (existsSync8(metricsPath)) {
5773
6301
  try {
5774
- const stat4 = await fs.stat(metricsPath);
5775
- metricsStalenessMs = Math.max(0, now.getTime() - stat4.mtimeMs);
6302
+ const stat5 = await fs.stat(metricsPath);
6303
+ metricsStalenessMs = Math.max(0, now.getTime() - stat5.mtimeMs);
5776
6304
  } catch {
5777
6305
  }
5778
6306
  }
@@ -5813,16 +6341,7 @@ async function inspectEventsJsonlGates(projectRoot, options = {}) {
5813
6341
  // src/services/doctor-skill-lints.ts
5814
6342
  import { readdir as readdir3, readFile as readFile10 } from "fs/promises";
5815
6343
  import { join as join17, posix as posix2 } from "path";
5816
- var FABRIC_SKILL_SLUGS = ["fabric-archive", "fabric-review", "fabric-import"];
5817
- var ROUTER_VALID_LEAF_SLUGS = /* @__PURE__ */ new Set([
5818
- "fabric-archive",
5819
- "fabric-review",
5820
- "fabric-import",
5821
- "fabric-sync",
5822
- "fabric-store",
5823
- "fabric-audit",
5824
- "fabric-connect"
5825
- ]);
6344
+ var FABRIC_SKILL_SLUGS = ["fabric-archive", "fabric-review"];
5826
6345
  var SKILL_MD_FRONTMATTER_ROOTS = [".claude/skills", ".codex/skills"];
5827
6346
  var SKILL_FRONTMATTER_KEY_PATTERN = /^([A-Za-z_][A-Za-z0-9_-]*):[ \t]+(.+?)[ \t]*$/u;
5828
6347
  var SKILL_QUOTED_VALUE_LEADS = /* @__PURE__ */ new Set(['"', "'", "[", "{", ">", "|"]);
@@ -6007,68 +6526,6 @@ function extractSkillFrontmatterLines(raw) {
6007
6526
  }
6008
6527
  return null;
6009
6528
  }
6010
- function extractMarkdownSectionBody(markdown, sectionName) {
6011
- const lines = markdown.split(/\r?\n/u);
6012
- const headingRe = /^(#{2,3})\s+(.+?)\s*$/u;
6013
- let start = -1;
6014
- for (let i = 0; i < lines.length; i++) {
6015
- const h = headingRe.exec(lines[i]);
6016
- if (h && h[2] === sectionName) {
6017
- start = i + 1;
6018
- break;
6019
- }
6020
- }
6021
- if (start === -1) return null;
6022
- const out = [];
6023
- for (let i = start; i < lines.length; i++) {
6024
- if (headingRe.test(lines[i])) break;
6025
- out.push(lines[i]);
6026
- }
6027
- return out.join("\n");
6028
- }
6029
- async function inspectRouterChainRef(projectRoot) {
6030
- const candidatePaths = [
6031
- join17(projectRoot, ".claude", "skills", "fabric", "SKILL.md"),
6032
- join17(projectRoot, ".codex", "skills", "fabric", "SKILL.md")
6033
- ];
6034
- let body = null;
6035
- for (const candidate of candidatePaths) {
6036
- try {
6037
- body = await readFile10(candidate, "utf8");
6038
- break;
6039
- } catch {
6040
- }
6041
- }
6042
- if (body === null) return { status: "ok", unknownRefs: [] };
6043
- const chainSection = extractMarkdownSectionBody(body, "S_CHAIN");
6044
- if (chainSection === null) return { status: "ok", unknownRefs: [] };
6045
- const refs = /* @__PURE__ */ new Set();
6046
- const tokenRe = /`(fabric-[a-z]+)`/gu;
6047
- let match;
6048
- while ((match = tokenRe.exec(chainSection)) !== null) {
6049
- refs.add(match[1]);
6050
- }
6051
- const unknownRefs = [...refs].filter((slug) => !ROUTER_VALID_LEAF_SLUGS.has(slug)).sort();
6052
- return unknownRefs.length === 0 ? { status: "ok", unknownRefs: [] } : { status: "drift", unknownRefs };
6053
- }
6054
- function createRouterChainRefCheck(t, inspection) {
6055
- if (inspection.status === "ok") {
6056
- return okCheck(t("doctor.check.router_chain_ref.name"), t("doctor.check.router_chain_ref.ok"));
6057
- }
6058
- const count = inspection.unknownRefs.length;
6059
- return issueCheck(
6060
- t("doctor.check.router_chain_ref.name"),
6061
- "warn",
6062
- "warning",
6063
- "router_chain_ref_drift",
6064
- t(`doctor.check.router_chain_ref.message.${count === 1 ? "singular" : "plural"}`, {
6065
- count: String(count),
6066
- list: inspection.unknownRefs.join(", ")
6067
- }),
6068
- t("doctor.check.router_chain_ref.remediation"),
6069
- "maintainer"
6070
- );
6071
- }
6072
6529
  function createSkillRefMirrorCheck(t, inspection) {
6073
6530
  if (inspection.status === "ok") {
6074
6531
  return okCheck(t("doctor.check.skill_ref_mirror.name"), t("doctor.check.skill_ref_mirror.ok"));
@@ -6143,10 +6600,130 @@ function createSkillMdYamlInvalidCheck(t, inspection) {
6143
6600
  );
6144
6601
  }
6145
6602
 
6603
+ // 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";
6606
+ var RETIRED_TOKENS = [
6607
+ { token: "fab_plan_context", replacement: "fab_recall", reason: "retrieval collapsed to one lean fab_recall (KT-DEC-0026)" },
6608
+ { token: "fab_get_knowledge_sections", replacement: "fab_recall", reason: "two-step fetch retired (KT-DEC-0026)" },
6609
+ { token: "fab_extract_knowledge", replacement: "fab_propose", reason: "tool renamed to match propose/write semantics (ux-w1-1)" },
6610
+ { token: "hint_broad_budget_chars", replacement: null, reason: "index-only SessionStart sink has no body budget (ux-w1-5)" },
6611
+ { token: "cite_evict_interval", replacement: "cite_recall_nudge", reason: "turn-counter superseded by recall-aware nudge (ux-w1-5)" },
6612
+ { token: "reverse_unarchive_enabled", replacement: null, reason: "never-wired opt-in flag deleted (ux-w1-5)" },
6613
+ { token: "reverse_unarchive_dry_run", replacement: null, reason: "unarchive dryRun comes from the caller, not config (ux-w1-5)" },
6614
+ { token: "doctor --cite-coverage", replacement: "audit cite", reason: "cite audit moved to the audit group (W3-D)" },
6615
+ { token: "doctor --fix-knowledge", replacement: "doctor --fix", reason: "fix-knowledge merged into a single --fix (W3-D)" },
6616
+ { token: "store add", replacement: "store mount", reason: "de-synonymised: add \u2192 mount (W3-E)" },
6617
+ { token: "store route-write", replacement: "store switch-write --scope", reason: "route-write folded into switch-write --scope (W3-E)" },
6618
+ { token: "store re-scope", replacement: "store migrate scope", reason: "scope-rewrite ops grouped under `store migrate` (W3-E)" },
6619
+ { token: "store backfill-scope", replacement: "store migrate backfill", reason: "scope-rewrite ops grouped under `store migrate` (W3-E)" },
6620
+ { token: "store promote", replacement: "store migrate promote", reason: "scope-rewrite ops grouped under `store migrate` (W3-E)" },
6621
+ { token: "fabric scope-explain", replacement: "fabric info scope", reason: "scope-explain command merged into the `info scope` subcommand (W3-F)" },
6622
+ { token: "fabric context", replacement: "fabric inspect", reason: "renamed: `context` of what? \u2192 `inspect` the injection (W3-F / NS-01 \xA71)" },
6623
+ { token: "fabric metrics", replacement: "fabric audit metrics", reason: "top-level metrics retired; reachable as `audit metrics` (W3-F)" },
6624
+ { token: "hint_broad_top_k", replacement: null, reason: "W2-1 retired the broad hard cap; broad_index_backstop is the sole guard (W3-J)" }
6625
+ ];
6626
+ var HOOK_DIRS = [".claude/hooks", ".codex/hooks"];
6627
+ var SKILL_DIRS = [".claude/skills", ".codex/skills"];
6628
+ var BOOTSTRAP_FILES = ["AGENTS.md", "CLAUDE.md", join18(".fabric", "AGENTS.md")];
6629
+ function isCommentLine(line) {
6630
+ const t = line.trim();
6631
+ return t.startsWith("//") || t.startsWith("*") || t.startsWith("/*");
6632
+ }
6633
+ async function walkFiles(dir, exts) {
6634
+ let entries;
6635
+ try {
6636
+ entries = await readdir4(dir, { withFileTypes: true });
6637
+ } catch {
6638
+ return [];
6639
+ }
6640
+ const out = [];
6641
+ for (const e of entries) {
6642
+ const full = join18(dir, e.name);
6643
+ if (e.isDirectory()) {
6644
+ out.push(...await walkFiles(full, exts));
6645
+ } else if (exts.some((ext) => e.name.endsWith(ext))) {
6646
+ out.push(full);
6647
+ }
6648
+ }
6649
+ return out;
6650
+ }
6651
+ function scanText(rel, text, skipComments, hits) {
6652
+ const lines = text.split("\n");
6653
+ for (let i = 0; i < lines.length; i += 1) {
6654
+ const line = lines[i];
6655
+ if (skipComments && isCommentLine(line)) continue;
6656
+ for (const { token, replacement } of RETIRED_TOKENS) {
6657
+ if (line.includes(token)) {
6658
+ hits.push({ path: rel, token, line: i + 1, replacement });
6659
+ }
6660
+ }
6661
+ }
6662
+ }
6663
+ async function inspectRetiredReferences(projectRoot) {
6664
+ const hits = [];
6665
+ let scannedFiles = 0;
6666
+ const toRel = (p) => posix3.normalize(relative2(projectRoot, p).split("\\").join("/"));
6667
+ for (const rel of BOOTSTRAP_FILES) {
6668
+ const abs = join18(projectRoot, rel);
6669
+ try {
6670
+ if ((await stat3(abs)).isFile()) {
6671
+ scannedFiles += 1;
6672
+ scanText(toRel(abs), await readFile11(abs, "utf8"), false, hits);
6673
+ }
6674
+ } catch {
6675
+ }
6676
+ }
6677
+ for (const dir of SKILL_DIRS) {
6678
+ for (const file of await walkFiles(join18(projectRoot, dir), [".md"])) {
6679
+ scannedFiles += 1;
6680
+ try {
6681
+ scanText(toRel(file), await readFile11(file, "utf8"), false, hits);
6682
+ } catch {
6683
+ }
6684
+ }
6685
+ }
6686
+ for (const dir of HOOK_DIRS) {
6687
+ for (const file of await walkFiles(join18(projectRoot, dir), [".cjs"])) {
6688
+ scannedFiles += 1;
6689
+ try {
6690
+ scanText(toRel(file), await readFile11(file, "utf8"), true, hits);
6691
+ } catch {
6692
+ }
6693
+ }
6694
+ }
6695
+ if (scannedFiles === 0) {
6696
+ return { status: "skipped", scannedFiles, hits: [] };
6697
+ }
6698
+ hits.sort((a, b) => a.path.localeCompare(b.path) || a.line - b.line);
6699
+ return { status: hits.length > 0 ? "warn" : "ok", scannedFiles, hits };
6700
+ }
6701
+ function createRetiredReferenceCheck(t, inspection) {
6702
+ const name = t("doctor.check.retired_reference.name");
6703
+ if (inspection.status !== "warn") {
6704
+ return { name, status: "ok", message: t("doctor.check.retired_reference.ok") };
6705
+ }
6706
+ const sample = inspection.hits.slice(0, 5).map(
6707
+ (h) => h.replacement ? `${h.path}:${h.line} \`${h.token}\` \u2192 \`${h.replacement}\`` : `${h.path}:${h.line} \`${h.token}\` (removed)`
6708
+ ).join("; ");
6709
+ return {
6710
+ name,
6711
+ status: "warn",
6712
+ kind: "warning",
6713
+ code: "retired_reference",
6714
+ audience: "maintainer",
6715
+ message: t("doctor.check.retired_reference.message", {
6716
+ count: String(inspection.hits.length),
6717
+ sample
6718
+ }),
6719
+ actionHint: t("doctor.check.retired_reference.remediation")
6720
+ };
6721
+ }
6722
+
6146
6723
  // src/services/doctor-hooks-lints.ts
6147
6724
  import { constants } from "fs";
6148
- import { access, readdir as readdir4, readFile as readFile11, stat as stat3 } from "fs/promises";
6149
- import { join as join18, posix as posix3 } from "path";
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";
6150
6727
  import { Script } from "vm";
6151
6728
  var HOOKS_RUNTIME_CLIENT_DIRS = [
6152
6729
  { client: "claude", dir: ".claude/hooks" },
@@ -6170,7 +6747,7 @@ function isRecord(value) {
6170
6747
  return typeof value === "object" && value !== null && !Array.isArray(value);
6171
6748
  }
6172
6749
  function normalizePath(path) {
6173
- return posix3.normalize(path.split("\\").join("/"));
6750
+ return posix4.normalize(path.split("\\").join("/"));
6174
6751
  }
6175
6752
  function isNodeMissingPathError(error) {
6176
6753
  return error instanceof Error && "code" in error && error.code === "ENOENT";
@@ -6195,27 +6772,27 @@ function isHookWiredForEvent(hooks, event, hookFile) {
6195
6772
  }
6196
6773
  async function readDirectoryFileNames(dir) {
6197
6774
  try {
6198
- return await readdir4(dir);
6775
+ return await readdir5(dir);
6199
6776
  } catch {
6200
6777
  return null;
6201
6778
  }
6202
6779
  }
6203
6780
  async function isFile(absPath) {
6204
6781
  try {
6205
- return (await stat3(absPath)).isFile();
6782
+ return (await stat4(absPath)).isFile();
6206
6783
  } catch {
6207
6784
  return false;
6208
6785
  }
6209
6786
  }
6210
6787
  async function inspectHooksWired(projectRoot) {
6211
- const claudeEntries = await readDirectoryFileNames(join18(projectRoot, ".claude"));
6788
+ const claudeEntries = await readDirectoryFileNames(join19(projectRoot, ".claude"));
6212
6789
  if (claudeEntries === null) {
6213
6790
  return { status: "skipped", missingHooks: [] };
6214
6791
  }
6215
- const settingsPath = join18(projectRoot, ".claude", "settings.json");
6792
+ const settingsPath = join19(projectRoot, ".claude", "settings.json");
6216
6793
  let parsed;
6217
6794
  try {
6218
- parsed = JSON.parse(await readFile11(settingsPath, "utf8"));
6795
+ parsed = JSON.parse(await readFile12(settingsPath, "utf8"));
6219
6796
  } catch {
6220
6797
  return { status: "missing-settings", missingHooks: [] };
6221
6798
  }
@@ -6237,12 +6814,12 @@ async function inspectHooksWired(projectRoot) {
6237
6814
  return { status: "incomplete", missingHooks: missing };
6238
6815
  }
6239
6816
  async function inspectHookCacheWritability(projectRoot) {
6240
- const relPath = posix3.join(".fabric", ".cache");
6241
- const fabricDir = join18(projectRoot, ".fabric");
6242
- const cacheDir = join18(projectRoot, ".fabric", ".cache");
6817
+ const relPath = posix4.join(".fabric", ".cache");
6818
+ const fabricDir = join19(projectRoot, ".fabric");
6819
+ const cacheDir = join19(projectRoot, ".fabric", ".cache");
6243
6820
  try {
6244
6821
  try {
6245
- const cacheStats = await stat3(cacheDir);
6822
+ const cacheStats = await stat4(cacheDir);
6246
6823
  if (!cacheStats.isDirectory()) {
6247
6824
  return {
6248
6825
  writable: false,
@@ -6259,14 +6836,14 @@ async function inspectHookCacheWritability(projectRoot) {
6259
6836
  }
6260
6837
  let parent = fabricDir;
6261
6838
  try {
6262
- await stat3(fabricDir);
6839
+ await stat4(fabricDir);
6263
6840
  } catch (error) {
6264
6841
  if (!isNodeMissingPathError(error)) {
6265
6842
  throw error;
6266
6843
  }
6267
6844
  parent = projectRoot;
6268
6845
  }
6269
- const parentStats = await stat3(parent);
6846
+ const parentStats = await stat4(parent);
6270
6847
  if (!parentStats.isDirectory()) {
6271
6848
  return {
6272
6849
  writable: false,
@@ -6288,12 +6865,12 @@ async function inspectHookCacheWritability(projectRoot) {
6288
6865
  async function inspectHooksContentDrift(projectRoot) {
6289
6866
  const hookFilesByBasename = /* @__PURE__ */ new Map();
6290
6867
  for (const { client, dir } of HOOKS_RUNTIME_CLIENT_DIRS) {
6291
- const absDir = join18(projectRoot, dir);
6868
+ const absDir = join19(projectRoot, dir);
6292
6869
  const entries = await readDirectoryFileNames(absDir);
6293
6870
  if (entries === null) continue;
6294
6871
  for (const name of entries) {
6295
6872
  if (!name.endsWith(".cjs")) continue;
6296
- const abs = join18(absDir, name);
6873
+ const abs = join19(absDir, name);
6297
6874
  if (!await isFile(abs)) continue;
6298
6875
  const arr = hookFilesByBasename.get(name) ?? [];
6299
6876
  arr.push({ client, abs });
@@ -6302,13 +6879,13 @@ async function inspectHooksContentDrift(projectRoot) {
6302
6879
  }
6303
6880
  const drifts = [];
6304
6881
  let scanned = 0;
6305
- for (const [basename3, copies] of hookFilesByBasename) {
6882
+ for (const [basename4, copies] of hookFilesByBasename) {
6306
6883
  if (copies.length < 2) continue;
6307
6884
  scanned += copies.length;
6308
6885
  const hashes = [];
6309
6886
  for (const { client, abs } of copies) {
6310
6887
  try {
6311
- const body = await readFile11(abs, "utf8");
6888
+ const body = await readFile12(abs, "utf8");
6312
6889
  hashes.push({ client, sha: sha256(body) });
6313
6890
  } catch {
6314
6891
  }
@@ -6317,7 +6894,7 @@ async function inspectHooksContentDrift(projectRoot) {
6317
6894
  const first = hashes[0].sha;
6318
6895
  if (hashes.some((h) => h.sha !== first)) {
6319
6896
  drifts.push({
6320
- basename: basename3,
6897
+ basename: basename4,
6321
6898
  clients: copies.map((copy) => copy.client),
6322
6899
  hashes
6323
6900
  });
@@ -6330,18 +6907,18 @@ async function inspectHooksRuntime(projectRoot) {
6330
6907
  const issues = [];
6331
6908
  let scanned = 0;
6332
6909
  for (const { client, dir } of HOOKS_RUNTIME_CLIENT_DIRS) {
6333
- const absDir = join18(projectRoot, dir);
6910
+ const absDir = join19(projectRoot, dir);
6334
6911
  const entries = await readDirectoryFileNames(absDir);
6335
6912
  if (entries === null) continue;
6336
6913
  for (const name of entries) {
6337
6914
  if (!name.endsWith(".cjs")) continue;
6338
- const abs = join18(absDir, name);
6915
+ const abs = join19(absDir, name);
6339
6916
  const displayPath = `${dir}/${name}`;
6340
6917
  if (!await isFile(abs)) continue;
6341
6918
  scanned += 1;
6342
6919
  let body;
6343
6920
  try {
6344
- body = await readFile11(abs, "utf8");
6921
+ body = await readFile12(abs, "utf8");
6345
6922
  } catch (err) {
6346
6923
  issues.push({
6347
6924
  path: displayPath,
@@ -6478,8 +7055,8 @@ function createHookCacheWritabilityCheck(t, inspection) {
6478
7055
  }
6479
7056
 
6480
7057
  // src/services/doctor-bootstrap-lints.ts
6481
- import { access as access2, readFile as readFile12 } from "fs/promises";
6482
- import { join as join19 } from "path";
7058
+ import { access as access2, readFile as readFile13 } from "fs/promises";
7059
+ import { join as join20 } from "path";
6483
7060
  import {
6484
7061
  BOOTSTRAP_MARKER_BEGIN,
6485
7062
  BOOTSTRAP_MARKER_END,
@@ -6511,17 +7088,17 @@ async function fileExists(path) {
6511
7088
  }
6512
7089
  async function inspectBootstrapAnchor(projectRoot) {
6513
7090
  const [hasAgentsMd, hasClaudeMd] = await Promise.all([
6514
- fileExists(join19(projectRoot, "AGENTS.md")),
6515
- fileExists(join19(projectRoot, "CLAUDE.md"))
7091
+ fileExists(join20(projectRoot, "AGENTS.md")),
7092
+ fileExists(join20(projectRoot, "CLAUDE.md"))
6516
7093
  ]);
6517
7094
  return { hasAgentsMd, hasClaudeMd };
6518
7095
  }
6519
7096
  async function inspectL1BootstrapSnapshotDrift(target) {
6520
- const abs = join19(target, ".fabric", "AGENTS.md");
7097
+ const abs = join20(target, ".fabric", "AGENTS.md");
6521
7098
  const canonical = resolveBootstrapCanonical();
6522
7099
  let onDisk;
6523
7100
  try {
6524
- onDisk = await readFile12(abs, "utf8");
7101
+ onDisk = await readFile13(abs, "utf8");
6525
7102
  } catch {
6526
7103
  return { status: "missing", canonical, onDisk: null };
6527
7104
  }
@@ -6547,17 +7124,17 @@ function createL1BootstrapSnapshotDriftCheck(t, inspection) {
6547
7124
  );
6548
7125
  }
6549
7126
  async function inspectL2ManagedBlockDrift(target) {
6550
- const snapshotPath = join19(target, ".fabric", "AGENTS.md");
7127
+ const snapshotPath = join20(target, ".fabric", "AGENTS.md");
6551
7128
  let snapshot;
6552
7129
  try {
6553
- snapshot = await readFile12(snapshotPath, "utf8");
7130
+ snapshot = await readFile13(snapshotPath, "utf8");
6554
7131
  } catch {
6555
7132
  return { status: "ok", drifted: [] };
6556
7133
  }
6557
- const projectRulesPath = join19(target, ".fabric", "project-rules.md");
7134
+ const projectRulesPath = join20(target, ".fabric", "project-rules.md");
6558
7135
  let expectedBody = snapshot;
6559
7136
  try {
6560
- const projectRules = await readFile12(projectRulesPath, "utf8");
7137
+ const projectRules = await readFile13(projectRulesPath, "utf8");
6561
7138
  expectedBody = `${snapshot}
6562
7139
  ---
6563
7140
  ${projectRules}`;
@@ -6566,12 +7143,12 @@ ${projectRules}`;
6566
7143
  const drifted = [];
6567
7144
  let anyManagedBlockFound = false;
6568
7145
  const blockTargets = [
6569
- join19(target, "AGENTS.md")
7146
+ join20(target, "AGENTS.md")
6570
7147
  ];
6571
7148
  for (const abs of blockTargets) {
6572
7149
  let content;
6573
7150
  try {
6574
- content = await readFile12(abs, "utf8");
7151
+ content = await readFile13(abs, "utf8");
6575
7152
  } catch {
6576
7153
  continue;
6577
7154
  }
@@ -6594,9 +7171,9 @@ ${projectRules}`;
6594
7171
  drifted.push({ path: abs, expected: expectedBody, actual: body });
6595
7172
  }
6596
7173
  }
6597
- const claudeMdPath = join19(target, "CLAUDE.md");
7174
+ const claudeMdPath = join20(target, "CLAUDE.md");
6598
7175
  try {
6599
- const claudeContent = await readFile12(claudeMdPath, "utf8");
7176
+ const claudeContent = await readFile13(claudeMdPath, "utf8");
6600
7177
  anyManagedBlockFound = true;
6601
7178
  const lines = claudeContent.split(/\r?\n/u);
6602
7179
  const hasAtImport = lines.some((line) => line.trim() === "@.fabric/AGENTS.md");
@@ -6664,7 +7241,7 @@ import { appendFile as appendFile3 } from "fs/promises";
6664
7241
  import { minimatch as minimatch2 } from "minimatch";
6665
7242
 
6666
7243
  // src/services/cite-rollup.ts
6667
- import { readFile as readFile13 } from "fs/promises";
7244
+ import { readFile as readFile14 } from "fs/promises";
6668
7245
  import { createLedgerWriteQueue as createLedgerWriteQueue3 } from "@fenglimg/fabric-shared/node/atomic-write";
6669
7246
  var citeRollupQueue = createLedgerWriteQueue3();
6670
7247
  async function appendCiteRollupRow(projectRoot, row) {
@@ -6676,7 +7253,7 @@ async function readCiteRollup(projectRoot) {
6676
7253
  const path = getCiteRollupPath(projectRoot);
6677
7254
  let raw;
6678
7255
  try {
6679
- raw = await readFile13(path, "utf8");
7256
+ raw = await readFile14(path, "utf8");
6680
7257
  } catch (error) {
6681
7258
  if (isNodeError(error) && error.code === "ENOENT") return [];
6682
7259
  throw error;
@@ -7684,13 +8261,13 @@ var DEFAULT_UNDERSEED_NODE_THRESHOLD = 10;
7684
8261
  var SESSION_HINTS_STALE_DAYS = 7;
7685
8262
  var SESSION_HINTS_FILE_PREFIX = "session-hints-";
7686
8263
  var SESSION_HINTS_FILE_SUFFIX = ".json";
7687
- var EDIT_COUNTER_FILE_REL = posix4.join(".fabric", ".cache", "edit-counter");
7688
- var HINT_SILENCE_COUNTER_FILE_REL = posix4.join(
8264
+ var EDIT_COUNTER_FILE_REL = posix5.join(".fabric", ".cache", "edit-counter");
8265
+ var HINT_SILENCE_COUNTER_FILE_REL = posix5.join(
7689
8266
  ".fabric",
7690
8267
  ".cache",
7691
8268
  "hint-silence-counter"
7692
8269
  );
7693
- var MS_PER_DAY3 = 24 * 60 * 60 * 1e3;
8270
+ var MS_PER_DAY4 = 24 * 60 * 60 * 1e3;
7694
8271
  var KNOWLEDGE_CANONICAL_TYPE_DIRS = [
7695
8272
  "decisions",
7696
8273
  "pitfalls",
@@ -7738,7 +8315,8 @@ async function runDoctorReport(target) {
7738
8315
  l2ManagedBlockDrift,
7739
8316
  skillRefMirror,
7740
8317
  skillTokenBudget,
7741
- skillDescription
8318
+ skillDescription,
8319
+ retiredReferences
7742
8320
  ] = await Promise.all([
7743
8321
  inspectForensic(projectRoot),
7744
8322
  // v2.2 W5 R4 (agents.meta decolo): `inspectMeta` (read co-location
@@ -7758,7 +8336,10 @@ async function runDoctorReport(target) {
7758
8336
  inspectL2ManagedBlockDrift(projectRoot),
7759
8337
  inspectSkillRefMirror(projectRoot),
7760
8338
  inspectSkillTokenBudget(projectRoot),
7761
- inspectSkillDescription(projectRoot)
8339
+ inspectSkillDescription(projectRoot),
8340
+ // ux-w2-2: registry-driven stale-pointer scan over the agent-consumed
8341
+ // surface (bootstrap + SKILL.md + installed hooks).
8342
+ inspectRetiredReferences(projectRoot)
7762
8343
  ]);
7763
8344
  const citeGoodhart = await inspectCiteGoodhart(projectRoot);
7764
8345
  const storeKnowledgeSummaries = await collectStoreKnowledgeSummaries(projectRoot);
@@ -7779,6 +8360,8 @@ async function runDoctorReport(target) {
7779
8360
  const lintNow = Date.now();
7780
8361
  const lastActiveIndex = await buildLastActiveIndex(projectRoot);
7781
8362
  const knowledgeAge = await inspectStoreKnowledgeAge(projectRoot, lintNow, lastActiveIndex);
8363
+ const knowledgePromotion = await inspectStoreKnowledgePromotion(projectRoot);
8364
+ const broadReviewRecheck = await inspectStoreBroadReviewRecheck(projectRoot, lintNow);
7782
8365
  const preexistingRootFiles = await inspectPreexistingRootFiles(projectRoot);
7783
8366
  const underseedThreshold = await readUnderseedThresholdFromConfig(projectRoot);
7784
8367
  const underseeded = {
@@ -7790,7 +8373,6 @@ async function runDoctorReport(target) {
7790
8373
  const hookCacheWritability = await inspectHookCacheWritability(projectRoot);
7791
8374
  const staleServeLock = inspectStaleServeLock(projectRoot, lintNow);
7792
8375
  const skillMdYamlInvalid = await inspectSkillMdYamlInvalid(projectRoot);
7793
- const routerChainRef = await inspectRouterChainRef(projectRoot);
7794
8376
  const onboardCoverage = await inspectOnboardCoverage(projectRoot);
7795
8377
  const [hooksWired, hooksRuntime, hooksContentDrift] = await Promise.all([
7796
8378
  inspectHooksWired(projectRoot),
@@ -7801,7 +8383,7 @@ async function runDoctorReport(target) {
7801
8383
  const globalCliVersion = process.env.VITEST === "true" ? { status: "ok", version: "test-skipped" } : inspectGlobalCliVersion();
7802
8384
  const targetFiles = Object.fromEntries(
7803
8385
  await Promise.all(
7804
- TARGET_FILE_PATHS.map(async (path) => [path, await pathExists(join20(projectRoot, path))])
8386
+ TARGET_FILE_PATHS.map(async (path) => [path, await pathExists(join21(projectRoot, path))])
7805
8387
  )
7806
8388
  );
7807
8389
  const checks = [
@@ -7840,6 +8422,8 @@ async function runDoctorReport(target) {
7840
8422
  createSkillRefMirrorCheck(t, skillRefMirror),
7841
8423
  createSkillTokenBudgetCheck(t, skillTokenBudget),
7842
8424
  createSkillDescriptionCheck(t, skillDescription),
8425
+ // ux-w2-2: retired-reference (stale-pointer) lint — registry-driven.
8426
+ createRetiredReferenceCheck(t, retiredReferences),
7843
8427
  createCiteGoodhartCheck(t, citeGoodhart),
7844
8428
  createDraftBacklogCheck(t, draftBacklog),
7845
8429
  createKnowledgeTagsEmptyCheck(t, knowledgeTagsEmpty),
@@ -7867,9 +8451,6 @@ async function runDoctorReport(target) {
7867
8451
  // rc.12 lint #29: skill_md_yaml_invalid. Warning kind — surfaces
7868
8452
  // SKILL.md frontmatter that Codex CLI silently drops at load.
7869
8453
  createSkillMdYamlInvalidCheck(t, skillMdYamlInvalid),
7870
- // B2 skill-router (A4): S_CHAIN reference backstop. Warning kind — flags an
7871
- // S_CHAIN `fabric-*` reference to a leaf no longer in the install set.
7872
- createRouterChainRefCheck(t, routerChainRef),
7873
8454
  // v2.0.0-rc.23 TASK-014 (F8c): Onboard coverage advisory. Info kind.
7874
8455
  // Surfaces uncovered S5 onboard slots and recommends /fabric-archive
7875
8456
  // first-run phase. Sits adjacent to Skill markdown YAML — both are
@@ -7921,6 +8502,11 @@ async function runDoctorReport(target) {
7921
8502
  // thresholds 90/30/14d per maturity tier (KT-DEC-0008).
7922
8503
  createOrphanDemoteCheck(t, knowledgeAge.orphanDemote),
7923
8504
  createStaleArchiveCheck(t, knowledgeAge.staleArchive),
8505
+ // v2.2 C1: knowledge promotion candidate (info kind — opportunity, not defect).
8506
+ createPromotionCandidateCheck(t, knowledgePromotion),
8507
+ // v2.2 C1: broad review-recheck nudge (info kind — broad's review-clock
8508
+ // counterpart to the usage-age decay it is exempt from).
8509
+ createBroadReviewRecheckCheck(t, broadReviewRecheck),
7924
8510
  // project-scope binding backfill lint — a store bound as the write target
7925
8511
  // but with no project_id / active_project parks the project axis. The
7926
8512
  // fresh-install hole is sealed in store.stage.ts; this covers existing repos
@@ -8001,7 +8587,7 @@ async function runDoctorFix(target) {
8001
8587
  const fixed = [];
8002
8588
  const ledgerWarnings = [];
8003
8589
  if (before.fixable_errors.some((issue) => issue.code === "bootstrap_snapshot_drift")) {
8004
- const snapshotPath = join20(projectRoot, ".fabric", "AGENTS.md");
8590
+ const snapshotPath = join21(projectRoot, ".fabric", "AGENTS.md");
8005
8591
  await ensureParentDirectory(snapshotPath);
8006
8592
  await atomicWriteText4(snapshotPath, resolveBootstrapCanonical2());
8007
8593
  fixed.push(findIssue(before.fixable_errors, "bootstrap_snapshot_drift"));
@@ -8074,7 +8660,7 @@ async function runDoctorFix(target) {
8074
8660
  if (before.infos.some((issue) => issue.code === "stale_serve_lock")) {
8075
8661
  const lockInspection = inspectStaleServeLock(projectRoot, Date.now());
8076
8662
  if (lockInspection.present && !lockInspection.pidAlive) {
8077
- const lockFilePath = join20(projectRoot, ".fabric", ".serve.lock");
8663
+ const lockFilePath = join21(projectRoot, ".fabric", ".serve.lock");
8078
8664
  try {
8079
8665
  await unlink4(lockFilePath);
8080
8666
  } catch (err) {
@@ -8191,7 +8777,7 @@ function createApplyLintMessage(succeeded, failed, manualErrorCount) {
8191
8777
  }
8192
8778
  async function applySessionHintsStaleCleanup(projectRoot, candidate) {
8193
8779
  const detail = `deleted (${candidate.age_days}d old)`;
8194
- const absPath = join20(projectRoot, candidate.path);
8780
+ const absPath = join21(projectRoot, candidate.path);
8195
8781
  try {
8196
8782
  const { unlink: unlink5 } = await import("fs/promises");
8197
8783
  await unlink5(absPath);
@@ -8216,9 +8802,9 @@ function truncateErrorMessage(error) {
8216
8802
  return raw.length > 240 ? `${raw.slice(0, 237)}...` : raw;
8217
8803
  }
8218
8804
  async function inspectForensic(projectRoot) {
8219
- const path = join20(projectRoot, ".fabric", "forensic.json");
8805
+ const path = join21(projectRoot, ".fabric", "forensic.json");
8220
8806
  try {
8221
- const parsed = forensicReportSchema.parse(JSON.parse(await readFile15(path, "utf8")));
8807
+ const parsed = forensicReportSchema.parse(JSON.parse(await readFile16(path, "utf8")));
8222
8808
  return { present: true, valid: true, report: parsed };
8223
8809
  } catch (error) {
8224
8810
  if (isMissingFileError(error)) {
@@ -8248,7 +8834,7 @@ async function inspectEventLedger(projectRoot) {
8248
8834
  try {
8249
8835
  await access4(path, constants2.W_OK);
8250
8836
  const { warnings } = await readEventLedger(projectRoot);
8251
- const raw = await readFile15(path, "utf8");
8837
+ const raw = await readFile16(path, "utf8");
8252
8838
  const invalidLine = raw.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).find((line) => !isValidJsonLine(line));
8253
8839
  const partialWarning = warnings.find((w) => w.kind === "partial_write_at_tail");
8254
8840
  const schemaVersionSamples = [];
@@ -8793,7 +9379,7 @@ async function inspectPreexistingRootFiles(projectRoot) {
8793
9379
  const candidates = ["CLAUDE.md", "AGENTS.md"];
8794
9380
  const detected = [];
8795
9381
  for (const name of candidates) {
8796
- if (await pathExists(join20(projectRoot, name))) {
9382
+ if (await pathExists(join21(projectRoot, name))) {
8797
9383
  detected.push(name);
8798
9384
  }
8799
9385
  }
@@ -8878,7 +9464,7 @@ async function buildLastActiveIndex(projectRoot) {
8878
9464
  return map;
8879
9465
  }
8880
9466
  async function inspectSessionHintsStale(projectRoot, now) {
8881
- const cacheDir = join20(projectRoot, ".fabric", ".cache");
9467
+ const cacheDir = join21(projectRoot, ".fabric", ".cache");
8882
9468
  let entries;
8883
9469
  try {
8884
9470
  entries = await readdirAsync(cacheDir, { withFileTypes: true });
@@ -8890,17 +9476,17 @@ async function inspectSessionHintsStale(projectRoot, now) {
8890
9476
  if (!entry.isFile()) continue;
8891
9477
  if (!entry.name.startsWith(SESSION_HINTS_FILE_PREFIX)) continue;
8892
9478
  if (!entry.name.endsWith(SESSION_HINTS_FILE_SUFFIX)) continue;
8893
- const absPath = join20(cacheDir, entry.name);
9479
+ const absPath = join21(cacheDir, entry.name);
8894
9480
  let mtimeMs = 0;
8895
9481
  try {
8896
9482
  mtimeMs = (await statAsync(absPath)).mtimeMs;
8897
9483
  } catch {
8898
9484
  continue;
8899
9485
  }
8900
- const ageDays = Math.floor((now - mtimeMs) / MS_PER_DAY3);
9486
+ const ageDays = Math.floor((now - mtimeMs) / MS_PER_DAY4);
8901
9487
  if (ageDays < SESSION_HINTS_STALE_DAYS) continue;
8902
9488
  candidates.push({
8903
- path: posix4.join(".fabric", ".cache", entry.name),
9489
+ path: posix5.join(".fabric", ".cache", entry.name),
8904
9490
  age_days: ageDays
8905
9491
  });
8906
9492
  }
@@ -8922,9 +9508,9 @@ function inspectStaleServeLock(projectRoot, now) {
8922
9508
  };
8923
9509
  }
8924
9510
  async function readUnderseedThresholdFromConfig(projectRoot) {
8925
- const configPath = join20(projectRoot, ".fabric", "fabric-config.json");
9511
+ const configPath = join21(projectRoot, ".fabric", "fabric-config.json");
8926
9512
  try {
8927
- const raw = await readFile15(configPath, "utf8");
9513
+ const raw = await readFile16(configPath, "utf8");
8928
9514
  const parsed = JSON.parse(raw);
8929
9515
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
8930
9516
  const v = parsed.underseed_node_threshold;
@@ -8998,7 +9584,7 @@ function createStaleServeLockCheck(t, inspection) {
8998
9584
  })
8999
9585
  );
9000
9586
  }
9001
- const days = Math.floor(inspection.ageMs / MS_PER_DAY3);
9587
+ const days = Math.floor(inspection.ageMs / MS_PER_DAY4);
9002
9588
  const hours = Math.floor(inspection.ageMs / (60 * 60 * 1e3));
9003
9589
  const acquiredAgo = days >= 1 ? t(`doctor.check.stale_serve_lock.age.day.${days === 1 ? "singular" : "plural"}`, {
9004
9590
  count: String(days)
@@ -9040,10 +9626,10 @@ async function inspectOnboardCoverage(projectRoot) {
9040
9626
  return { filled, missing, opted_out: optedOut };
9041
9627
  }
9042
9628
  async function readOnboardOptedOut(projectRoot) {
9043
- const path = join20(projectRoot, ".fabric", "fabric-config.json");
9629
+ const path = join21(projectRoot, ".fabric", "fabric-config.json");
9044
9630
  let raw;
9045
9631
  try {
9046
- raw = await readFile15(path, "utf8");
9632
+ raw = await readFile16(path, "utf8");
9047
9633
  } catch {
9048
9634
  return [];
9049
9635
  }
@@ -9128,7 +9714,7 @@ async function* iterateCanonicalFilenames(projectRoot) {
9128
9714
  if (!KNOWLEDGE_CANONICAL_TYPE_DIRS.includes(entry.type)) {
9129
9715
  continue;
9130
9716
  }
9131
- const filename = posix4.basename(normalizePath2(entry.file));
9717
+ const filename = posix5.basename(normalizePath2(entry.file));
9132
9718
  const parsed = parseStableIdFromCanonicalFilename(filename);
9133
9719
  if (parsed === null) {
9134
9720
  continue;
@@ -9145,22 +9731,22 @@ async function* iterateCanonicalFilenames(projectRoot) {
9145
9731
  }
9146
9732
  }
9147
9733
  async function rewriteThreeEndManagedBlocks(projectRoot) {
9148
- const snapshotPath = join20(projectRoot, ".fabric", "AGENTS.md");
9734
+ const snapshotPath = join21(projectRoot, ".fabric", "AGENTS.md");
9149
9735
  if (!await pathExists(snapshotPath)) {
9150
9736
  return;
9151
9737
  }
9152
9738
  let snapshot;
9153
9739
  try {
9154
- snapshot = await readFile15(snapshotPath, "utf8");
9740
+ snapshot = await readFile16(snapshotPath, "utf8");
9155
9741
  } catch {
9156
9742
  return;
9157
9743
  }
9158
- const projectRulesPath = join20(projectRoot, ".fabric", "project-rules.md");
9744
+ const projectRulesPath = join21(projectRoot, ".fabric", "project-rules.md");
9159
9745
  const hasProjectRules = await pathExists(projectRulesPath);
9160
9746
  let expectedBody = snapshot;
9161
9747
  if (hasProjectRules) {
9162
9748
  try {
9163
- const projectRules = await readFile15(projectRulesPath, "utf8");
9749
+ const projectRules = await readFile16(projectRulesPath, "utf8");
9164
9750
  expectedBody = `${snapshot}
9165
9751
  ---
9166
9752
  ${projectRules}`;
@@ -9171,7 +9757,7 @@ ${projectRules}`;
9171
9757
  ${expectedBody}
9172
9758
  ${BOOTSTRAP_MARKER_END2}`;
9173
9759
  const blockTargets = [
9174
- join20(projectRoot, "AGENTS.md")
9760
+ join21(projectRoot, "AGENTS.md")
9175
9761
  ];
9176
9762
  for (const abs of blockTargets) {
9177
9763
  if (!await pathExists(abs)) {
@@ -9179,7 +9765,7 @@ ${BOOTSTRAP_MARKER_END2}`;
9179
9765
  }
9180
9766
  let existing;
9181
9767
  try {
9182
- existing = await readFile15(abs, "utf8");
9768
+ existing = await readFile16(abs, "utf8");
9183
9769
  } catch {
9184
9770
  continue;
9185
9771
  }
@@ -9204,11 +9790,11 @@ ${managedBlock}
9204
9790
  }
9205
9791
  await atomicWriteText4(abs, next);
9206
9792
  }
9207
- const claudeMdPath = join20(projectRoot, "CLAUDE.md");
9793
+ const claudeMdPath = join21(projectRoot, "CLAUDE.md");
9208
9794
  if (await pathExists(claudeMdPath)) {
9209
9795
  let claudeContent;
9210
9796
  try {
9211
- claudeContent = await readFile15(claudeMdPath, "utf8");
9797
+ claudeContent = await readFile16(claudeMdPath, "utf8");
9212
9798
  } catch {
9213
9799
  return;
9214
9800
  }
@@ -9254,7 +9840,7 @@ function normalizeTarget(targetInput) {
9254
9840
  return isAbsolute2(targetInput) ? targetInput : resolve3(process.cwd(), targetInput);
9255
9841
  }
9256
9842
  function normalizePath2(path) {
9257
- return posix4.normalize(path.split("\\").join("/"));
9843
+ return posix5.normalize(path.split("\\").join("/"));
9258
9844
  }
9259
9845
  async function collectEntryPoints(root) {
9260
9846
  let rootStat;
@@ -9274,7 +9860,7 @@ async function collectEntryPoints(root) {
9274
9860
  continue;
9275
9861
  }
9276
9862
  for (const entry of await readdirAsync(current, { withFileTypes: true })) {
9277
- const absolutePath = join20(current, entry.name);
9863
+ const absolutePath = join21(current, entry.name);
9278
9864
  const relativePath = normalizePath2(absolutePath.slice(root.length + 1));
9279
9865
  if (relativePath.length === 0) {
9280
9866
  continue;
@@ -9301,8 +9887,8 @@ function getEntryPointReason(relativePath) {
9301
9887
  if (!SCRIPT_EXTENSIONS.has(extension)) {
9302
9888
  return null;
9303
9889
  }
9304
- const directory = posix4.dirname(relativePath);
9305
- const fileName = posix4.basename(relativePath);
9890
+ const directory = posix5.dirname(relativePath);
9891
+ const fileName = posix5.basename(relativePath);
9306
9892
  const fileBase = fileName.slice(0, Math.max(fileName.lastIndexOf("."), 0));
9307
9893
  if (directory === "assets/scripts" || directory === "scripts") {
9308
9894
  return "top-level script";
@@ -9350,7 +9936,7 @@ async function enrichDescriptions(projectRoot, opts = {}) {
9350
9936
  scanned += 1;
9351
9937
  let source;
9352
9938
  try {
9353
- source = await readFile15(absPath, "utf8");
9939
+ source = await readFile16(absPath, "utf8");
9354
9940
  } catch {
9355
9941
  continue;
9356
9942
  }
@@ -9447,88 +10033,6 @@ function yamlQuoteIfNeeded(value) {
9447
10033
  return value;
9448
10034
  }
9449
10035
 
9450
- // src/services/conflict-lint.ts
9451
- var DEFAULT_CONFLICT_SIMILARITY_THRESHOLD = 0.5;
9452
- function groupKey(entry) {
9453
- return `${entry.layer}\0${entry.knowledge_type}`;
9454
- }
9455
- function pairSimilarity(model, a, b) {
9456
- const selfA = model.scoreDoc(a.id, a.tokens);
9457
- const selfB = model.scoreDoc(b.id, b.tokens);
9458
- if (selfA <= 0 || selfB <= 0) return 0;
9459
- const aToB = model.scoreDoc(b.id, a.tokens) / selfB;
9460
- const bToA = model.scoreDoc(a.id, b.tokens) / selfA;
9461
- const sim = Math.min(aToB, bToA);
9462
- return sim < 0 ? 0 : sim > 1 ? 1 : sim;
9463
- }
9464
- function findConflictCandidates(entries, opts = {}) {
9465
- const threshold = typeof opts.threshold === "number" && opts.threshold >= 0 && opts.threshold <= 1 ? opts.threshold : DEFAULT_CONFLICT_SIMILARITY_THRESHOLD;
9466
- const groups = /* @__PURE__ */ new Map();
9467
- for (const entry of entries) {
9468
- if (typeof entry.stable_id !== "string" || entry.stable_id.length === 0) continue;
9469
- const list = groups.get(groupKey(entry)) ?? [];
9470
- list.push(entry);
9471
- groups.set(groupKey(entry), list);
9472
- }
9473
- const pairs = [];
9474
- for (const group of groups.values()) {
9475
- if (group.length < 2) continue;
9476
- const docs = group.map((e) => ({ id: e.stable_id, tokens: buildQueryTerms(e.text) }));
9477
- const tokensById = new Map(docs.map((d) => [d.id, d.tokens]));
9478
- const model = buildBm25Model(docs);
9479
- for (let i = 0; i < group.length; i += 1) {
9480
- for (let j = i + 1; j < group.length; j += 1) {
9481
- const ea = group[i];
9482
- const eb = group[j];
9483
- const sim = pairSimilarity(
9484
- model,
9485
- { id: ea.stable_id, tokens: tokensById.get(ea.stable_id) ?? [] },
9486
- { id: eb.stable_id, tokens: tokensById.get(eb.stable_id) ?? [] }
9487
- );
9488
- if (sim < threshold) continue;
9489
- const [a, b] = ea.stable_id <= eb.stable_id ? [ea.stable_id, eb.stable_id] : [eb.stable_id, ea.stable_id];
9490
- pairs.push({
9491
- a,
9492
- b,
9493
- knowledge_type: ea.knowledge_type,
9494
- layer: ea.layer,
9495
- similarity: sim,
9496
- verdict: "unknown"
9497
- });
9498
- }
9499
- }
9500
- }
9501
- pairs.sort((x, y) => y.similarity - x.similarity || (x.a < y.a ? -1 : x.a > y.a ? 1 : x.b < y.b ? -1 : 1));
9502
- return pairs;
9503
- }
9504
- async function lintConflicts(entries, opts = {}) {
9505
- const candidates = findConflictCandidates(entries, { threshold: opts.threshold });
9506
- if (opts.judge === void 0 || candidates.length === 0) {
9507
- return candidates;
9508
- }
9509
- const byId = new Map(entries.map((e) => [e.stable_id, e]));
9510
- const judged = [];
9511
- for (const pair of candidates) {
9512
- const ea = byId.get(pair.a);
9513
- const eb = byId.get(pair.b);
9514
- if (ea === void 0 || eb === void 0) {
9515
- judged.push(pair);
9516
- continue;
9517
- }
9518
- try {
9519
- const verdict = await opts.judge(ea, eb);
9520
- judged.push({
9521
- ...pair,
9522
- verdict: verdict.isConflict ? "conflict" : "similar",
9523
- rationale: verdict.rationale
9524
- });
9525
- } catch {
9526
- judged.push(pair);
9527
- }
9528
- }
9529
- return judged;
9530
- }
9531
-
9532
10036
  // src/services/doctor-conflict.ts
9533
10037
  function stripFrontmatter(content) {
9534
10038
  if (!content.startsWith("---")) return content;
@@ -9569,6 +10073,91 @@ async function runDoctorConflictLint(projectRoot, opts = {}) {
9569
10073
  };
9570
10074
  }
9571
10075
 
10076
+ // src/services/why-not-surfaced.ts
10077
+ import { readFile as readFile17 } from "fs/promises";
10078
+ import { basename as basename3, join as join22 } from "path";
10079
+ import {
10080
+ buildStoreResolveInput as buildStoreResolveInput7,
10081
+ createStoreResolver as createStoreResolver7,
10082
+ loadProjectConfig as loadProjectConfig4,
10083
+ readKnowledgeAcrossStores as readKnowledgeAcrossStores4,
10084
+ resolveGlobalRoot as resolveGlobalRoot7,
10085
+ scopeRoot as scopeRoot3,
10086
+ storeRelativePathForMount as storeRelativePathForMount6
10087
+ } from "@fenglimg/fabric-shared";
10088
+ var SEMANTIC_SCOPE_LINE3 = /^semantic_scope:\s*"?([^"\n]+?)"?\s*$/mu;
10089
+ var RELEVANCE_SCOPE_LINE = /^relevance_scope:\s*"?(broad|narrow)"?\s*$/mu;
10090
+ function toLocalId3(query) {
10091
+ const i = query.indexOf(":");
10092
+ return i === -1 ? query : query.slice(i + 1);
10093
+ }
10094
+ function fileMatchesId(file, localId) {
10095
+ const base = basename3(file);
10096
+ return base === `${localId}.md` || base.startsWith(`${localId}--`);
10097
+ }
10098
+ async function explainWhyNotSurfaced(projectRoot, query) {
10099
+ const localId = toLocalId3(query.trim());
10100
+ const base = {
10101
+ query,
10102
+ localId,
10103
+ verdict: "not_found",
10104
+ storeAlias: null,
10105
+ storeBound: null,
10106
+ semanticScope: null,
10107
+ activeProject: null,
10108
+ relevanceScope: null
10109
+ };
10110
+ const input = buildStoreResolveInput7(projectRoot);
10111
+ if (input === null) {
10112
+ return base;
10113
+ }
10114
+ const globalRoot = resolveGlobalRoot7();
10115
+ const allStores = input.mountedStores.map((s) => ({
10116
+ store_uuid: s.store_uuid,
10117
+ alias: s.alias,
10118
+ dir: join22(globalRoot, storeRelativePathForMount6(s))
10119
+ }));
10120
+ const refs = await readKnowledgeAcrossStores4(allStores);
10121
+ const candidate = refs.find((ref) => fileMatchesId(ref.file, localId));
10122
+ if (candidate === void 0) {
10123
+ return base;
10124
+ }
10125
+ let source;
10126
+ try {
10127
+ source = await readFile17(candidate.file, "utf8");
10128
+ } catch {
10129
+ return base;
10130
+ }
10131
+ if (deriveRuleIdentity(candidate.file, source, void 0).stableId !== localId) {
10132
+ return base;
10133
+ }
10134
+ const semanticScope = SEMANTIC_SCOPE_LINE3.exec(source)?.[1] ?? null;
10135
+ const relevanceScope = RELEVANCE_SCOPE_LINE.exec(source)?.[1] ?? "broad";
10136
+ const activeProject = loadProjectConfig4(projectRoot)?.active_project ?? null;
10137
+ const boundUuids = new Set(
10138
+ createStoreResolver7().resolveReadSet(input).stores.map((s) => s.store_uuid)
10139
+ );
10140
+ const storeBound = boundUuids.has(candidate.store_uuid);
10141
+ const found = {
10142
+ ...base,
10143
+ storeAlias: candidate.alias,
10144
+ storeBound,
10145
+ semanticScope,
10146
+ activeProject,
10147
+ relevanceScope
10148
+ };
10149
+ if (!storeBound) {
10150
+ return { ...found, verdict: "store_unbound" };
10151
+ }
10152
+ if (semanticScope !== null && scopeRoot3(semanticScope) === "project" && activeProject !== null && semanticScope !== `project:${activeProject}`) {
10153
+ return { ...found, verdict: "project_mismatch" };
10154
+ }
10155
+ if (relevanceScope === "narrow") {
10156
+ return { ...found, verdict: "narrow_timing" };
10157
+ }
10158
+ return { ...found, verdict: "should_surface" };
10159
+ }
10160
+
9572
10161
  // src/services/summary-cold-eval.ts
9573
10162
  var COLD_EVAL_RUBRIC = [
9574
10163
  "You are a ZERO-CONTEXT judge. You are shown ONLY a one-line knowledge summary \u2014",
@@ -9622,8 +10211,8 @@ import { agentsMetaSchema as agentsMetaSchema3 } from "@fenglimg/fabric-shared";
9622
10211
  import { IOFabricError as IOFabricError2, RuleError } from "@fenglimg/fabric-shared/errors";
9623
10212
 
9624
10213
  // src/services/read-ledger.ts
9625
- import { randomUUID as randomUUID6 } from "crypto";
9626
- import { access as access5, copyFile, readFile as readFile16, rm } from "fs/promises";
10214
+ import { randomUUID as randomUUID7 } from "crypto";
10215
+ import { access as access5, copyFile, readFile as readFile18, rm } from "fs/promises";
9627
10216
  import { ledgerEntrySchema } from "@fenglimg/fabric-shared";
9628
10217
  async function resolveLedgerPaths(projectRoot) {
9629
10218
  const primaryPath = getLedgerPath(projectRoot);
@@ -9651,7 +10240,7 @@ async function readLegacyLedger(projectRoot) {
9651
10240
  const { readPath } = await resolveLedgerPaths(projectRoot);
9652
10241
  let raw;
9653
10242
  try {
9654
- raw = await readFile16(readPath, "utf8");
10243
+ raw = await readFile18(readPath, "utf8");
9655
10244
  } catch (error) {
9656
10245
  if (isNodeError(error) && error.code === "ENOENT") {
9657
10246
  return [];
@@ -9906,26 +10495,27 @@ function formatError(error) {
9906
10495
  }
9907
10496
  function formatPreexistingRootMessage(projectRoot) {
9908
10497
  const preexisting = [];
9909
- if (existsSync9(join21(projectRoot, "CLAUDE.md"))) preexisting.push("CLAUDE.md");
9910
- if (existsSync9(join21(projectRoot, "AGENTS.md"))) preexisting.push("AGENTS.md");
10498
+ if (existsSync9(join23(projectRoot, "CLAUDE.md"))) preexisting.push("CLAUDE.md");
10499
+ if (existsSync9(join23(projectRoot, "AGENTS.md"))) preexisting.push("AGENTS.md");
9911
10500
  if (preexisting.length === 0) return null;
9912
10501
  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.`;
9913
10502
  }
9914
10503
  var FABRIC_SERVER_INSTRUCTIONS = [
9915
10504
  "Fabric is a cross-client knowledge layer: durable team/personal decisions, pitfalls, guidelines, models, and processes this server surfaces so you do not re-learn them each session.",
9916
10505
  "",
10506
+ "AGENT-DIRECT tools \u2014 call these yourself, inline, as you work:",
9917
10507
  "Retrieval \u2014 do this BEFORE you edit code or commit to a decision:",
9918
- "- Call `fab_recall(paths)` with the files you are about to touch. It returns the relevant KB DESCRIPTIONS (`candidates[]`) plus a READ-PATH index (`paths[]`: one on-disk knowledge file per surfaced candidate).",
9919
- "- It does NOT return bodies. To load an entry's full content, Read the file at its `paths[].path` (native file read) \u2014 that is observed as a `knowledge_body_read`. Reading on demand keeps the description index lean; a needed body is one cheap Read away.",
10508
+ "- `fab_recall(paths)` \u2014 one-shot KB recall for the files you are about to touch. Returns a single ranked `entries[]` \u2014 each entry carries its DESCRIPTION, a `read_path` (the on-disk knowledge file), `rank` (1 = most relevant), and `body_in_context:true` when the body is already in context from SessionStart.",
10509
+ "- It does NOT return bodies. To load an entry's full content, Read its `read_path` (native file read) \u2014 that is observed as a `knowledge_body_read`. Reading on demand keeps the index lean; a needed body is one cheap Read away.",
9920
10510
  "",
9921
- "Tools:",
9922
- "- `fab_recall` \u2014 one-shot KB recall: descriptions + read paths for the given files.",
9923
- "- `fab_extract_knowledge` \u2014 extract structured knowledge from text you supply.",
10511
+ "SKILL-DRIVEN tools \u2014 invoked by the Fabric skills (fabric-archive / fabric-review) at the right lifecycle moment, not called ad-hoc:",
10512
+ "- `fab_propose` \u2014 propose/persist a pending knowledge entry into the write-target store for later review.",
9924
10513
  "- `fab_archive_scan` \u2014 scan recent work for archive-worthy knowledge candidates.",
9925
- "- `fab_review` \u2014 review and triage pending knowledge entries.",
10514
+ "- `fab_pending` \u2014 read-only browse/search of pending + canonical knowledge (list / search).",
10515
+ "- `fab_review` \u2014 write-only triage of pending knowledge entries (approve / reject / modify / defer).",
9926
10516
  "",
9927
10517
  "Conventions:",
9928
- "- Candidate lists are ranked best-first (content relevance) and bounded; `omitted_candidate_count > 0` means more exist \u2014 narrow your intent to surface them.",
10518
+ "- Candidate lists are ranked best-first (content relevance) and bounded; a non-empty `dropped[]` (each entry `{ id, reason }`, reason `retrieval_budget` | `payload_budget`) means more exist \u2014 narrow your intent to surface them.",
9929
10519
  "- Pass the client `session_id` to `fab_recall` so cross-session knowledge-debt tracking stays accurate.",
9930
10520
  "- Cite the KB id you applied or dismissed before edits, per the project's cite policy."
9931
10521
  ].join("\n");
@@ -9933,7 +10523,7 @@ function createFabricServer(tracker) {
9933
10523
  const server = new McpServer(
9934
10524
  {
9935
10525
  name: "fabric-knowledge-server",
9936
- version: "2.2.0"
10526
+ version: "2.3.0-rc.1"
9937
10527
  },
9938
10528
  {
9939
10529
  instructions: FABRIC_SERVER_INSTRUCTIONS
@@ -9943,6 +10533,7 @@ function createFabricServer(tracker) {
9943
10533
  registerArchiveScan(server, tracker);
9944
10534
  registerExtractKnowledge(server, tracker);
9945
10535
  registerReview(server, tracker);
10536
+ registerPending(server, tracker);
9946
10537
  server.registerResource(
9947
10538
  "bootstrap README",
9948
10539
  AGENTS_MD_RESOURCE_URI,
@@ -9952,10 +10543,10 @@ function createFabricServer(tracker) {
9952
10543
  },
9953
10544
  async (_uri) => {
9954
10545
  const projectRoot = process.env.FABRIC_PROJECT_ROOT ?? process.cwd();
9955
- const path = join21(projectRoot, ".fabric", "bootstrap", "README.md");
10546
+ const path = join23(projectRoot, ".fabric", "bootstrap", "README.md");
9956
10547
  let text = "";
9957
10548
  if (existsSync9(path)) {
9958
- text = await readFile17(path, "utf8");
10549
+ text = await readFile19(path, "utf8");
9959
10550
  }
9960
10551
  return {
9961
10552
  contents: [
@@ -10053,6 +10644,7 @@ export {
10053
10644
  LEGACY_LEDGER_PATH,
10054
10645
  METRICS_LEDGER_PATH,
10055
10646
  METRIC_COUNTER_NAMES,
10647
+ RETIRED_TOKENS,
10056
10648
  appendEventLedgerEvent,
10057
10649
  buildAlwaysActiveBodies,
10058
10650
  buildColdEvalBatch,
@@ -10065,6 +10657,7 @@ export {
10065
10657
  detectUnboundProject,
10066
10658
  drainCounters,
10067
10659
  enrichDescriptions,
10660
+ explainWhyNotSurfaced,
10068
10661
  extractKnowledge,
10069
10662
  findConflictCandidates,
10070
10663
  flushAndSyncEventLedger,
@@ -10074,6 +10667,7 @@ export {
10074
10667
  getLedgerPath,
10075
10668
  getLegacyLedgerPath,
10076
10669
  getMetricsLedgerPath,
10670
+ inspectRetiredReferences,
10077
10671
  lintConflicts,
10078
10672
  loadConflictEntries,
10079
10673
  pairSimilarity,
@@ -10086,6 +10680,7 @@ export {
10086
10680
  rehydrateAgentsMetaAt,
10087
10681
  resolveLedgerPaths,
10088
10682
  reviewKnowledge,
10683
+ reviewPending,
10089
10684
  runDoctorApplyLint,
10090
10685
  runDoctorArchiveHistory,
10091
10686
  runDoctorCiteCoverage,