@fenglimg/fabric-server 2.2.0-rc.9 → 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
- import { existsSync as existsSync8 } from "fs";
3
- import { readFile as readFile16 } from "fs/promises";
4
- import { join as join20, resolve as resolve4 } from "path";
2
+ import { existsSync as existsSync9 } from "fs";
3
+ import { readFile as readFile19 } from "fs/promises";
4
+ import { join as join23, resolve as resolve4 } from "path";
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,1169 +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
- };
1091
+ const knowledge = extractKnowledgeFieldsFromFrontmatter(frontmatter);
1092
+ return {
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
1110
+ };
1111
+ }
1112
+ function isForbiddenCrossLayerEdge(sourceLayer, targetId) {
1113
+ if (sourceLayer !== "team") {
1114
+ return false;
1158
1115
  }
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
- );
1116
+ const decoded = parseKnowledgeId(localKnowledgeIdFromReference(targetId));
1117
+ if (decoded === null) {
1118
+ return false;
1165
1119
  }
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
- });
1120
+ return decoded.layer === "personal";
1121
+ }
1122
+ function localKnowledgeIdFromReference(ref) {
1123
+ const direct = parseKnowledgeId(ref);
1124
+ if (direct !== null) {
1125
+ return ref;
1181
1126
  }
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
- };
1127
+ const tail = ref.split(":").at(-1);
1128
+ return tail ?? ref;
1129
+ }
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
+ `);
1235
1145
  }
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
1146
  }
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
- });
1270
- return {
1271
- pending_path: reportedPath,
1272
- idempotency_key: effectiveIdempotencyKey
1147
+ const SINGULAR_TO_PLURAL = {
1148
+ model: "models",
1149
+ decision: "decisions",
1150
+ guideline: "guidelines",
1151
+ pitfall: "pitfalls",
1152
+ process: "processes"
1273
1153
  };
1274
- }
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
- };
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
+ `);
1293
1163
  }
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
- };
1164
+ }
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
+ `);
1302
1173
  }
1303
1174
  }
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
- );
1307
- }
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 "";
1314
- }
1315
- return trimmed.slice(0, SLUG_MAX_LENGTH).replace(/-+$/g, "");
1316
- }
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}`);
1351
- }
1352
- if (args.relevancePaths !== void 0) {
1353
- const pathsBody = args.relevancePaths.map((p) => quoteRelevancePath(p)).join(", ");
1354
- frontmatterLines.push(`relevance_paths: [${pathsBody}]`);
1355
- }
1356
- if (args.intentClues !== void 0) {
1357
- const body2 = args.intentClues.map((s) => quoteRelevancePath(s)).join(", ");
1358
- frontmatterLines.push(`intent_clues: [${body2}]`);
1359
- }
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
- "---"
1381
- );
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}`;
1248
+ return match[1].split(",").map((item) => unquote(item.trim())).filter((item) => item.length > 0);
1405
1249
  }
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}"`;
1250
+ function unquote(value) {
1251
+ return value.replace(/^["'](.*)["']$/u, "$1");
1409
1252
  }
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");
1253
+ function escapeRegExp(value) {
1254
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
1421
1255
  }
1422
- function mergeEvidenceNotes(existing, fresh) {
1423
- const freshSplit = splitAtEvidence(fresh);
1424
- if (freshSplit === null) {
1425
- return fresh.endsWith("\n") ? fresh : `${fresh}
1426
- `;
1427
- }
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);
1439
- }
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);
1448
- }
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
1256
 
1463
- ${evidenceBody}
1464
- `;
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 ?? ""}`;
1465
1262
  }
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] ?? "" };
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
+ })
1273
+ );
1274
+ return parts.sort().join("\n");
1472
1275
  }
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());
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;
1280
+ }
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
+ );
1289
+ }
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;
1298
+ }
1299
+ const readSet = createStoreResolver2().resolveReadSet(resolveInput);
1300
+ if (readSet.stores.length === 0) {
1301
+ return null;
1302
+ }
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
1487
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 [];
1328
+ }
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();
1338
+ }
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);
1361
+ }
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;
1369
+ }
1370
+ items.push({
1371
+ stable_id: entry.qualifiedId,
1372
+ description: {
1373
+ ...baseDescription,
1374
+ knowledge_layer: entry.layer,
1375
+ semantic_scope: entry.semanticScope
1488
1376
  }
1377
+ });
1378
+ }
1379
+ return items;
1380
+ }
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 });
1489
1387
  }
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 = [];
1388
+ }
1389
+ return index;
1390
+ }
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
+ };
1401
+ try {
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;
1501
1414
  }
1502
- bulletLines.push(t.slice(2).trim());
1415
+ }
1416
+ if (isNarrow) census.narrow_total += 1;
1417
+ if (scopeRoot(entry.semanticScope) === "project") {
1418
+ census.by_layer.project += 1;
1503
1419
  } else {
1504
- prose.push(t);
1420
+ census.by_layer[entry.layer] += 1;
1505
1421
  }
1422
+ census.total += 1;
1506
1423
  }
1507
- if (prose.length > 0) notes.push(prose.join(" ").trim());
1508
- for (const n of bulletLines) notes.push(n);
1424
+ } catch {
1509
1425
  }
1510
- return { notes, paths };
1426
+ return census;
1511
1427
  }
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;
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 [];
1520
1451
  }
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();
1452
+ return out;
1453
+ }
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);
1457
+ }
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;
1528
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
+ });
1529
1477
  }
1530
- return void 0;
1478
+ return out;
1531
1479
  }
1532
- async function emitEventBestEffort(projectRoot, event) {
1533
- try {
1534
- await appendEventLedgerEvent(projectRoot, event);
1535
- } catch {
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
+ });
1536
1492
  }
1493
+ return out;
1537
1494
  }
1538
1495
 
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];
1561
- }
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);
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);
1576
1526
  }
1577
1527
  }
1578
- );
1528
+ for (const term of docTerms) {
1529
+ documentFrequency.set(term, (documentFrequency.get(term) ?? 0) + 1);
1530
+ }
1531
+ perDoc.set(doc.id, { fieldTermFreq, fieldLength });
1532
+ }
1533
+ const avgFieldLength = emptyFieldRecord(() => 0);
1534
+ for (const field of BM25_FIELDS) {
1535
+ avgFieldLength[field] = totalDocs > 0 ? totalFieldLength[field] / totalDocs : 0;
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
+ };
1541
+ return {
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
+ }
1575
+ };
1579
1576
  }
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("\\", "/");
1577
+ function buildQueryTerms(text) {
1578
+ return tokenize(text);
1604
1579
  }
1605
1580
 
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/");
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 };
1631
1589
  }
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
- };
1639
- }
1640
- if (existing?.stable_id !== void 0 && isKnowledgeStableId(existing.stable_id)) {
1641
- return {
1642
- stableId: existing.stable_id,
1643
- identitySource: "declared"
1644
- };
1645
- }
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
- };
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);
1653
1611
  }
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
- };
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
+ }
1659
1639
  }
1660
- return {
1661
- stableId: derivedStableId,
1662
- identitySource: "derived"
1663
- };
1664
- }
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];
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;
1668
1642
  }
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;
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;
1673
1647
  }
1674
- const idMatch = /^id:\s*(.+?)\s*$/mu.exec(frontmatter[1]);
1675
- if (idMatch === null) {
1676
- 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
+ }
1677
1667
  }
1678
- const candidate = idMatch[1].replace(/^["'](.*)["']$/u, "$1").trim();
1679
- return isKnowledgeStableId(candidate) ? candidate : void 0;
1668
+ return judged;
1680
1669
  }
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;
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: [] };
1686
1683
  }
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) {
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) {
1692
1703
  return void 0;
1693
1704
  }
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
- };
1705
+ const top = result.matches[0];
1706
+ return `${result.verdict} of ${top.stable_id} (${top.similarity.toFixed(2)})`;
1720
1707
  }
1721
- function extractDescriptionFromFrontmatter(frontmatter) {
1722
- const summary = extractScalar(frontmatter, "summary") ?? extractScalar(frontmatter, "description");
1723
- if (summary === void 0) {
1724
- return void 0;
1725
- }
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
- };
1746
- }
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);
1754
1750
  }
1755
- return decoded.layer === "personal";
1751
+ return { sanitized, redactions };
1756
1752
  }
1757
- function localKnowledgeIdFromReference(ref) {
1758
- const direct = parseKnowledgeId(ref);
1759
- if (direct !== null) {
1760
- return ref;
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
+ }
1761
1774
  }
1762
- const tail = ref.split(":").at(-1);
1763
- return tail ?? ref;
1775
+ return { sanitized: out, allRedactions };
1764
1776
  }
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
- `);
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
+ }
1780
1791
  }
1781
1792
  }
1782
- const SINGULAR_TO_PLURAL = {
1783
- model: "models",
1784
- decision: "decisions",
1785
- guideline: "guidelines",
1786
- pitfall: "pitfalls",
1787
- process: "processes"
1793
+ return { redacted: out, redactedFields: [...redactedFields].sort() };
1794
+ }
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
1788
1816
  };
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
- }
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
+ });
1799
1827
  }
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
- }
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
1846
+ };
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
+ });
1809
1856
  }
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
- }
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
+ };
1819
1888
  }
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
- }
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
+ };
1828
1910
  }
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;
1838
- }
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
+ });
1839
1928
  }
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;
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
+ };
1853
1982
  }
1854
- return true;
1983
+ throw new Error(
1984
+ `slug collision (unreachable after rc.37 NEW-6): pending file ${reportedPath} already exists with key ${existingKey ?? "<missing>"} != ${effectiveIdempotencyKey}`
1985
+ );
1986
+ }
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, "\\$&");
1890
- }
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 ?? ""}`;
1897
- }
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
- })
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`
1908
2088
  );
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;
1915
2089
  }
1916
- function filterByActiveProject(entries, activeProject) {
1917
- if (activeProject === void 0 || activeProject.length === 0) {
1918
- return entries;
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 "";
1919
2096
  }
1920
- const current = `project:${activeProject}`;
1921
- return entries.filter(
1922
- (e) => scopeRoot(e.semanticScope) !== "project" || e.semanticScope === current
1923
- );
1924
- }
1925
- function activeProjectOf(projectRoot) {
1926
- const ap = loadProjectConfig2(projectRoot)?.active_project;
1927
- return ap !== void 0 && ap.length > 0 ? ap : void 0;
2097
+ return trimmed.slice(0, SLUG_MAX_LENGTH).replace(/-+$/g, "");
1928
2098
  }
1929
- async function resolveReadSetSnapshot(projectRoot) {
1930
- const resolveInput = buildStoreResolveInput2(projectRoot);
1931
- if (resolveInput === null) {
1932
- return null;
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}`);
1933
2133
  }
1934
- const readSet = createStoreResolver2().resolveReadSet(resolveInput);
1935
- if (readSet.stores.length === 0) {
1936
- return null;
2134
+ if (args.relevancePaths !== void 0) {
2135
+ const pathsBody = args.relevancePaths.map((p) => quoteRelevancePath(p)).join(", ");
2136
+ frontmatterLines.push(`relevance_paths: [${pathsBody}]`);
1937
2137
  }
1938
- const personalUuids = new Set(
1939
- resolveInput.mountedStores.filter((s) => s.personal).map((s) => s.store_uuid)
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
+ "---"
1940
2166
  );
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
- };
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}`;
1958
2190
  }
1959
- async function walkReadSetStores(projectRoot) {
1960
- const snapshot = await resolveReadSetSnapshot(projectRoot);
1961
- if (snapshot === null) {
1962
- return [];
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}"`;
2194
+ }
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");
2206
+ }
2207
+ function mergeEvidenceNotes(existing, fresh) {
2208
+ const freshSplit = splitAtEvidence(fresh);
2209
+ if (freshSplit === null) {
2210
+ return fresh.endsWith("\n") ? fresh : `${fresh}
2211
+ `;
1963
2212
  }
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();
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);
1969
2224
  }
1970
- const entries = await walkReadSetStoresUncached(snapshot);
1971
- readSetWalkCache.set(key, { fingerprint, entries });
1972
- return 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);
2233
+ }
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;
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
+ }
2273
+ }
2004
2274
  }
2005
- items.push({
2006
- stable_id: entry.qualifiedId,
2007
- description: {
2008
- ...baseDescription,
2009
- knowledge_layer: entry.layer,
2010
- semantic_scope: entry.semanticScope
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 = [];
2286
+ }
2287
+ bulletLines.push(t.slice(2).trim());
2288
+ } else {
2289
+ prose.push(t);
2011
2290
  }
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
2291
  }
2292
+ if (prose.length > 0) notes.push(prose.join(" ").trim());
2293
+ for (const n of bulletLines) notes.push(n);
2023
2294
  }
2024
- return index;
2295
+ return { notes, paths };
2025
2296
  }
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
- dropped_other_project: 0,
2032
- total: 0
2033
- };
2034
- try {
2035
- const activeProject = activeProjectOf(projectRoot);
2036
- const all = await walkReadSetStores(projectRoot);
2037
- const kept = filterByActiveProject(all, activeProject);
2038
- census.dropped_other_project = all.length - kept.length;
2039
- for (const entry of kept) {
2040
- const type = extractRuleDescription(entry.source)?.knowledge_type;
2041
- if (typeof type === "string") {
2042
- census.by_type[type] = (census.by_type[type] ?? 0) + 1;
2043
- }
2044
- if (scopeRoot(entry.semanticScope) === "project") {
2045
- census.by_layer.project += 1;
2046
- } else {
2047
- census.by_layer[entry.layer] += 1;
2048
- }
2049
- census.total += 1;
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();
2050
2313
  }
2051
- } catch {
2052
2314
  }
2053
- return census;
2315
+ return void 0;
2054
2316
  }
2055
- async function buildAlwaysActiveBodies(projectRoot) {
2056
- const out = [];
2317
+ async function emitEventBestEffort(projectRoot, event) {
2057
2318
  try {
2058
- const activeProject = activeProjectOf(projectRoot);
2059
- for (const entry of filterByActiveProject(
2060
- await walkReadSetStores(projectRoot),
2061
- activeProject
2062
- )) {
2063
- const desc = extractRuleDescription(entry.source);
2064
- if (desc === void 0) continue;
2065
- const type = desc.knowledge_type;
2066
- if (typeof type !== "string" || !ALWAYS_ACTIVE_TYPES.has(type)) continue;
2067
- out.push({
2068
- stable_id: entry.qualifiedId,
2069
- type,
2070
- layer: entry.layer,
2071
- summary: typeof desc.summary === "string" ? desc.summary : "",
2072
- body: extractBody(entry.source)
2073
- });
2074
- }
2319
+ await appendEventLedgerEvent(projectRoot, event);
2075
2320
  } catch {
2076
- return [];
2077
2321
  }
2078
- return out;
2079
- }
2080
- async function computeReadSetRevision(projectRoot) {
2081
- const revisionSource = (await walkReadSetStores(projectRoot)).filter((entry) => !entry.file.includes("/knowledge/pending/")).map((entry) => `${entry.qualifiedId}|${sha256(entry.source)}`).sort().join("\n");
2082
- return sha256(revisionSource);
2083
2322
  }
2084
- async function collectStoreCanonicalEntries(projectRoot) {
2085
- const out = [];
2086
- for (const entry of await walkReadSetStores(projectRoot)) {
2087
- if (entry.file.includes("/knowledge/pending/")) {
2088
- continue;
2089
- }
2090
- const description = extractRuleDescription(entry.source);
2091
- if (description === void 0) {
2092
- 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
+ }
2093
2381
  }
2094
- out.push({
2095
- stableId: entry.qualifiedId.slice(entry.alias.length + 1),
2096
- qualifiedId: entry.qualifiedId,
2097
- file: entry.file,
2098
- type: entry.type,
2099
- layer: entry.layer,
2100
- body: entry.source,
2101
- description
2102
- });
2103
- }
2104
- return out;
2382
+ );
2105
2383
  }
2106
- async function collectStoreKnowledgeSummaries(projectRoot) {
2107
- const out = [];
2108
- for (const entry of await walkReadSetStores(projectRoot)) {
2109
- const description = extractRuleDescription(entry.source);
2110
- if (description === void 0) {
2111
- continue;
2112
- }
2113
- out.push({
2114
- stableId: entry.qualifiedId,
2115
- summary: description.summary ?? "",
2116
- layer: entry.layer
2117
- });
2118
- }
2119
- return out;
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("\\", "/");
2120
2408
  }
2121
2409
 
2122
2410
  // src/services/id-redirect.ts
@@ -2382,61 +2670,6 @@ var LEDGER_DUAL_WRITE_METRIC_NAMES = {
2382
2670
  edit_intent_checked: METRIC_COUNTER_NAMES.edit_intent_checked
2383
2671
  };
2384
2672
 
2385
- // src/services/bm25.ts
2386
- import { tokenize } from "@fenglimg/fabric-shared";
2387
- var K1 = 1.5;
2388
- var B = 0.75;
2389
- function buildBm25Model(docs) {
2390
- const totalDocs = docs.length;
2391
- const documentFrequency = /* @__PURE__ */ new Map();
2392
- const perDoc = /* @__PURE__ */ new Map();
2393
- let totalLength = 0;
2394
- for (const doc of docs) {
2395
- const termFreq = /* @__PURE__ */ new Map();
2396
- for (const term of doc.tokens) {
2397
- termFreq.set(term, (termFreq.get(term) ?? 0) + 1);
2398
- }
2399
- for (const term of termFreq.keys()) {
2400
- documentFrequency.set(term, (documentFrequency.get(term) ?? 0) + 1);
2401
- }
2402
- totalLength += doc.tokens.length;
2403
- perDoc.set(doc.id, { termFreq, length: doc.tokens.length });
2404
- }
2405
- const avgDocLength = totalDocs > 0 ? totalLength / totalDocs : 0;
2406
- const idf = (term) => {
2407
- const n = documentFrequency.get(term) ?? 0;
2408
- return Math.log(1 + (totalDocs - n + 0.5) / (n + 0.5));
2409
- };
2410
- return {
2411
- scoreDoc(id, queryTerms) {
2412
- const data = perDoc.get(id);
2413
- if (data === void 0 || data.length === 0 || queryTerms.length === 0) {
2414
- return 0;
2415
- }
2416
- const normalizer = avgDocLength > 0 ? data.length / avgDocLength : 1;
2417
- let score = 0;
2418
- const scoredTerms = /* @__PURE__ */ new Set();
2419
- for (const term of queryTerms) {
2420
- if (scoredTerms.has(term)) {
2421
- continue;
2422
- }
2423
- scoredTerms.add(term);
2424
- const freq = data.termFreq.get(term);
2425
- if (freq === void 0) {
2426
- continue;
2427
- }
2428
- const numerator = freq * (K1 + 1);
2429
- const denominator = freq + K1 * (1 - B + B * normalizer);
2430
- score += idf(term) * (numerator / denominator);
2431
- }
2432
- return score;
2433
- }
2434
- };
2435
- }
2436
- function buildQueryTerms(text) {
2437
- return tokenize(text);
2438
- }
2439
-
2440
2673
  // src/services/vector-retrieval.ts
2441
2674
  var embedderLoad;
2442
2675
  var OPTIONAL_EMBED_PACKAGE = "fastembed";
@@ -2654,7 +2887,9 @@ async function planContext(projectRoot, input) {
2654
2887
  const relevanceFloor = maxScore * relevanceRatio;
2655
2888
  const survivingScored = hasQuery && maxScore > 0 && relevanceRatio > 0 ? cappedScored.filter((entry) => entry.score >= relevanceFloor) : cappedScored;
2656
2889
  const topKCandidates = survivingScored.map((entry) => entry.item);
2657
- 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;
2658
2893
  let candidates = topKCandidates;
2659
2894
  const relatedAppended = {};
2660
2895
  if (input.include_related === true) {
@@ -2694,8 +2929,10 @@ async function planContext(projectRoot, input) {
2694
2929
  const basePreflightDiagnostics = buildPreflightDiagnostics(suppressedStableIds);
2695
2930
  let payloadTrimDropped = 0;
2696
2931
  let payloadOverBudget = false;
2932
+ let payloadDropped = [];
2697
2933
  if (input.payload_budget !== void 0) {
2698
2934
  const fullCandidateCount = candidates.length;
2935
+ const preTrimIds = candidates.map((item) => item.stable_id);
2699
2936
  const serialize = (candidateSlice) => {
2700
2937
  const dropped = fullCandidateCount - candidateSlice.length;
2701
2938
  const totalOmitted = omittedCandidateCount + dropped;
@@ -2706,7 +2943,7 @@ async function planContext(projectRoot, input) {
2706
2943
  entries,
2707
2944
  ...input.intent !== void 0 ? { intent: input.intent } : {},
2708
2945
  candidates: candidateSlice,
2709
- ...totalOmitted > 0 ? { omitted_candidate_count: totalOmitted } : {},
2946
+ ...totalOmitted > 0 ? { dropped_count: totalOmitted } : {},
2710
2947
  preflight_diagnostics: basePreflightDiagnostics,
2711
2948
  warnings: dropped > 0 && input.payload_budget?.trim_warning !== void 0 ? [...input.payload_budget.warnings ?? [], input.payload_budget.trim_warning] : input.payload_budget?.warnings,
2712
2949
  ...Object.keys(relatedAppended).length > 0 ? { related_appended: relatedAppended } : {}
@@ -2714,6 +2951,8 @@ async function planContext(projectRoot, input) {
2714
2951
  };
2715
2952
  const trim = trimToPayloadBudget(candidates, serialize, input.payload_budget.limits);
2716
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" }));
2717
2956
  candidates = trim.items;
2718
2957
  payloadTrimDropped = trim.dropped;
2719
2958
  }
@@ -2737,7 +2976,11 @@ async function planContext(projectRoot, input) {
2737
2976
  entries,
2738
2977
  ...input.intent !== void 0 ? { intent: input.intent } : {},
2739
2978
  candidates,
2740
- ...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] } : {},
2741
2984
  preflight_diagnostics: basePreflightDiagnostics,
2742
2985
  // v2.2 W5 R1: the auto_healed / previous_revision_hash pair was tied to the
2743
2986
  // co-location loadActiveMetaOrStale read-path auto-heal, which is retired.
@@ -2845,7 +3088,7 @@ function getOrBuildBm25Model(revision, rawItems, docTexts) {
2845
3088
  const model = buildBm25Model(
2846
3089
  rawItems.map((item) => ({
2847
3090
  id: item.stable_id,
2848
- tokens: tokenize2(docTexts.get(item.stable_id) ?? documentTextForItem(item.description))
3091
+ fields: documentFieldsForItem(item.description)
2849
3092
  }))
2850
3093
  );
2851
3094
  bm25ModelCache = { revision, model };
@@ -2919,6 +3162,16 @@ function documentTextForItem(description) {
2919
3162
  ...description.tags ?? []
2920
3163
  ].join(" ");
2921
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
+ }
2922
3175
  function isEmptyShellDescription(description, stableId) {
2923
3176
  return description.summary === stableId && description.intent_clues.length === 0 && description.tech_stack.length === 0 && description.impact.length === 0;
2924
3177
  }
@@ -3018,7 +3271,7 @@ function relatedLookupKeys(stableId) {
3018
3271
  }
3019
3272
 
3020
3273
  // src/services/recall.ts
3021
- 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>)`.";
3022
3275
  async function recall(projectRoot, input) {
3023
3276
  const planResult = await planContext(projectRoot, input);
3024
3277
  const { selection_token: _token, payload_trimmed: _pt, payload_over_budget: _pob, ...planRest } = planResult;
@@ -3067,13 +3320,22 @@ async function recall(projectRoot, input) {
3067
3320
  paths.push(attachPathStore({ stable_id: candidate.stable_id, path: ref.file }));
3068
3321
  }
3069
3322
  const nextSteps = buildNextSteps(planResult, paths, candidateById, candidateLookup);
3070
- const markedCandidates = planRest.candidates.map(
3071
- (c) => isAlwaysActive(c) ? { ...c, always_active: true } : c
3072
- );
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;
3073
3336
  return {
3074
- ...planRest,
3075
- candidates: markedCandidates,
3076
- paths,
3337
+ ...planRestNoLists,
3338
+ entries,
3077
3339
  directive: RECALL_DIRECTIVE,
3078
3340
  ...nextSteps.length > 0 ? { next_steps: nextSteps } : {}
3079
3341
  };
@@ -3085,7 +3347,7 @@ function isAlwaysActive(candidate) {
3085
3347
  }
3086
3348
  function buildNextSteps(planResult, paths, candidateById, candidateLookup) {
3087
3349
  const nextSteps = [];
3088
- const omitted = planResult.omitted_candidate_count ?? 0;
3350
+ const omitted = (planResult.dropped ?? []).filter((d) => d.reason === "retrieval_budget").length;
3089
3351
  if (omitted > 0) {
3090
3352
  nextSteps.push(
3091
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.`
@@ -3121,7 +3383,7 @@ function registerRecall(server, tracker) {
3121
3383
  server.registerTool(
3122
3384
  "fab_recall",
3123
3385
  {
3124
- 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.",
3125
3387
  inputSchema: recallInputSchema,
3126
3388
  outputSchema: recallOutputSchema,
3127
3389
  annotations: recallAnnotations
@@ -3176,11 +3438,16 @@ function registerRecall(server, tracker) {
3176
3438
  response.warnings = appendPayloadWarning(
3177
3439
  response.warnings,
3178
3440
  guardResult,
3179
- "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."
3180
3442
  );
3181
3443
  payloadBytesOut = Buffer.byteLength(serialized, "utf8");
3182
3444
  return {
3183
- 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
+ ],
3184
3451
  structuredContent: response
3185
3452
  };
3186
3453
  } catch (error) {
@@ -3384,7 +3651,12 @@ function registerArchiveScan(server, tracker) {
3384
3651
  "fab_archive_scan returned a large candidate set \u2014 pass an explicit `range` of session_ids to narrow the scan."
3385
3652
  );
3386
3653
  return {
3387
- 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
+ ],
3388
3660
  structuredContent: result
3389
3661
  };
3390
3662
  } finally {
@@ -3406,11 +3678,190 @@ import { enforcePayloadLimit as enforcePayloadLimit4 } from "@fenglimg/fabric-sh
3406
3678
 
3407
3679
  // src/services/review.ts
3408
3680
  import { execFileSync } from "child_process";
3409
- import { existsSync as existsSync4 } from "fs";
3410
- import { readFile as readFile5, readdir, stat as stat2, unlink } from "fs/promises";
3681
+ import { existsSync as existsSync5 } from "fs";
3682
+ import { readFile as readFile6, readdir as readdir2, stat as stat2, unlink as unlink2 } from "fs/promises";
3411
3683
  import { homedir } from "os";
3412
- import { basename, isAbsolute, join as join9, relative, resolve as resolve2, sep as sep2 } from "path";
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
3413
3720
  import { allocateStoreKnowledgeId, isPersonalScope as isPersonalScope2 } from "@fenglimg/fabric-shared";
3721
+
3722
+ // src/services/pending-dedupe.ts
3723
+ import { existsSync as existsSync4 } from "fs";
3724
+ import { readdir, readFile as readFile5, unlink } from "fs/promises";
3725
+ import { join as join9 } from "path";
3726
+ var PENDING_TYPES = ["decisions", "pitfalls", "guidelines", "models", "processes"];
3727
+ var DISAMBIGUATION_SUFFIX = /^(.+)-([2-9])\.md$/u;
3728
+ var SOURCE_SESSIONS_LINE = /^source_sessions:\s*\[(.*)\]\s*$/mu;
3729
+ var CREATED_AT_LINE = /^created_at:\s*(.+)$/mu;
3730
+ function parseSourceSessions(content) {
3731
+ const m = SOURCE_SESSIONS_LINE.exec(content);
3732
+ if (!m) return [];
3733
+ try {
3734
+ const arr = JSON.parse(`[${m[1]}]`);
3735
+ return Array.isArray(arr) ? arr.filter((s) => typeof s === "string") : [];
3736
+ } catch {
3737
+ return [];
3738
+ }
3739
+ }
3740
+ function parseCreatedAt(content) {
3741
+ const m = CREATED_AT_LINE.exec(content);
3742
+ return m ? m[1].trim() : "";
3743
+ }
3744
+ function resolveBaseSlug(name, present) {
3745
+ const m = DISAMBIGUATION_SUFFIX.exec(name);
3746
+ if (m && present.has(`${m[1]}.md`)) return m[1];
3747
+ return name.replace(/\.md$/u, "");
3748
+ }
3749
+ function unionSessions(survivor, twins) {
3750
+ const seen = /* @__PURE__ */ new Set();
3751
+ const out = [];
3752
+ for (const s of [survivor, ...twins]) {
3753
+ for (const sid of s.sourceSessions) {
3754
+ if (sid.length > 0 && !seen.has(sid)) {
3755
+ seen.add(sid);
3756
+ out.push(sid);
3757
+ }
3758
+ }
3759
+ }
3760
+ return out;
3761
+ }
3762
+ function buildMergedContent(survivor, twins) {
3763
+ const union = unionSessions(survivor, twins);
3764
+ let merged = survivor.content.replace(
3765
+ SOURCE_SESSIONS_LINE,
3766
+ `source_sessions: [${union.map((s) => JSON.stringify(s)).join(", ")}]`
3767
+ );
3768
+ if (!merged.endsWith("\n")) merged += "\n";
3769
+ for (const twin of twins) {
3770
+ const body = extractBody(twin.content).trim();
3771
+ if (body.length === 0) continue;
3772
+ merged += `
3773
+ ## Evidence (merged from session ${twin.primarySession || "unknown"})
3774
+
3775
+ ${body}
3776
+ `;
3777
+ }
3778
+ return merged;
3779
+ }
3780
+ function chooseSurvivor(baseSlug, group) {
3781
+ const exact = group.find((p) => p.name === `${baseSlug}.md`);
3782
+ if (exact) return exact;
3783
+ return [...group].sort((a, b) => {
3784
+ if (a.createdAt !== b.createdAt) return a.createdAt < b.createdAt ? -1 : 1;
3785
+ return a.name < b.name ? -1 : 1;
3786
+ })[0];
3787
+ }
3788
+ async function mergePendingTwins(projectRoot) {
3789
+ const merged = [];
3790
+ for (const layer of ["team", "personal"]) {
3791
+ let pendingBase2;
3792
+ try {
3793
+ pendingBase2 = resolveStorePendingBase(layer, projectRoot);
3794
+ } catch {
3795
+ continue;
3796
+ }
3797
+ for (const type of PENDING_TYPES) {
3798
+ const dir = join9(pendingBase2, type);
3799
+ if (!existsSync4(dir)) continue;
3800
+ let names;
3801
+ try {
3802
+ names = (await readdir(dir)).filter((n) => n.endsWith(".md"));
3803
+ } catch {
3804
+ continue;
3805
+ }
3806
+ if (names.length < 2) continue;
3807
+ const present = new Set(names);
3808
+ const groups = /* @__PURE__ */ new Map();
3809
+ for (const name of names) {
3810
+ const base = resolveBaseSlug(name, present);
3811
+ const arr = groups.get(base);
3812
+ if (arr) arr.push(name);
3813
+ else groups.set(base, [name]);
3814
+ }
3815
+ for (const [baseSlug, groupNames] of groups) {
3816
+ if (groupNames.length < 2) continue;
3817
+ const parsed = [];
3818
+ for (const name of groupNames) {
3819
+ const abs = join9(dir, name);
3820
+ let content;
3821
+ try {
3822
+ content = await readFile5(abs, "utf8");
3823
+ } catch {
3824
+ continue;
3825
+ }
3826
+ const sourceSessions = parseSourceSessions(content);
3827
+ parsed.push({
3828
+ name,
3829
+ abs,
3830
+ content,
3831
+ sourceSessions,
3832
+ primarySession: sourceSessions[0] ?? "",
3833
+ createdAt: parseCreatedAt(content)
3834
+ });
3835
+ }
3836
+ if (parsed.length < 2) continue;
3837
+ const distinctPrimaries = new Set(parsed.map((p) => p.primarySession).filter((s) => s.length > 0));
3838
+ if (distinctPrimaries.size < 2) continue;
3839
+ const survivor = chooseSurvivor(baseSlug, parsed);
3840
+ const twins = parsed.filter((p) => p.abs !== survivor.abs);
3841
+ const mergedContent = buildMergedContent(survivor, twins);
3842
+ try {
3843
+ await atomicWriteText(survivor.abs, mergedContent);
3844
+ for (const twin of twins) {
3845
+ await unlink(twin.abs);
3846
+ }
3847
+ } catch {
3848
+ continue;
3849
+ }
3850
+ merged.push({
3851
+ layer,
3852
+ type,
3853
+ base_slug: baseSlug,
3854
+ survivor: survivor.abs,
3855
+ removed: twins.map((t) => t.abs),
3856
+ source_sessions: unionSessions(survivor, twins)
3857
+ });
3858
+ }
3859
+ }
3860
+ }
3861
+ return { merged };
3862
+ }
3863
+
3864
+ // src/services/review.ts
3414
3865
  var PLURAL_TYPES = [
3415
3866
  "decisions",
3416
3867
  "pitfalls",
@@ -3420,11 +3871,6 @@ var PLURAL_TYPES = [
3420
3871
  ];
3421
3872
  async function reviewKnowledge(projectRoot, input) {
3422
3873
  switch (input.action) {
3423
- case "list":
3424
- return {
3425
- action: "list",
3426
- items: await listPending(projectRoot, input.filters)
3427
- };
3428
3874
  case "approve":
3429
3875
  return {
3430
3876
  action: "approve",
@@ -3447,11 +3893,6 @@ async function reviewKnowledge(projectRoot, input) {
3447
3893
  }
3448
3894
  case "modify-layer":
3449
3895
  return await modifyEntry(projectRoot, input.pending_path, input.changes);
3450
- case "search":
3451
- return {
3452
- action: "search",
3453
- items: await searchEntries(projectRoot, input.query, input.filters)
3454
- };
3455
3896
  case "defer":
3456
3897
  return {
3457
3898
  action: "defer",
@@ -3468,6 +3909,24 @@ async function reviewKnowledge(projectRoot, input) {
3468
3909
  }
3469
3910
  }
3470
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
+ }
3471
3930
  function storeKnowledgeRoots(projectRoot) {
3472
3931
  const roots = [];
3473
3932
  for (const layer of ["team", "personal"]) {
@@ -3517,6 +3976,10 @@ function isVisibleByLifecycle(fm, filters) {
3517
3976
  return true;
3518
3977
  }
3519
3978
  async function listPending(projectRoot, filters) {
3979
+ try {
3980
+ await mergePendingTwins(projectRoot);
3981
+ } catch {
3982
+ }
3520
3983
  const items = [];
3521
3984
  const typesToScan = filters?.type !== void 0 ? [filters.type] : PLURAL_TYPES;
3522
3985
  const sources = [];
@@ -3536,22 +3999,22 @@ async function listPending(projectRoot, filters) {
3536
3999
  }
3537
4000
  for (const source of sources) {
3538
4001
  for (const type of typesToScan) {
3539
- const dir = join9(source.root, type);
3540
- if (!existsSync4(dir)) {
4002
+ const dir = join10(source.root, type);
4003
+ if (!existsSync5(dir)) {
3541
4004
  continue;
3542
4005
  }
3543
4006
  let entries;
3544
4007
  try {
3545
- entries = await readdir(dir);
4008
+ entries = await readdir2(dir);
3546
4009
  } catch {
3547
4010
  continue;
3548
4011
  }
3549
4012
  for (const name of entries) {
3550
4013
  if (!name.endsWith(".md")) continue;
3551
- const absolutePath = join9(dir, name);
4014
+ const absolutePath = join10(dir, name);
3552
4015
  let content;
3553
4016
  try {
3554
- content = await readFile5(absolutePath, "utf8");
4017
+ content = await readFile6(absolutePath, "utf8");
3555
4018
  } catch {
3556
4019
  continue;
3557
4020
  }
@@ -3661,7 +4124,7 @@ async function approveOne(projectRoot, pendingPath) {
3661
4124
  let targetAbs;
3662
4125
  let writtenTarget = false;
3663
4126
  try {
3664
- const content = await readFile5(sourceAbs, "utf8");
4127
+ const content = await readFile6(sourceAbs, "utf8");
3665
4128
  const fm = parseFrontmatter(content);
3666
4129
  const pluralType = fm.type;
3667
4130
  if (pluralType === void 0 || !PLURAL_TYPES.includes(pluralType)) {
@@ -3675,14 +4138,17 @@ async function approveOne(projectRoot, pendingPath) {
3675
4138
  );
3676
4139
  allocatedId = stableId;
3677
4140
  const newFilename = `${stableId}--${slug}.md`;
3678
- targetAbs = join9(resolveStoreCanonicalBase(layer, projectRoot), pluralType, newFilename);
4141
+ targetAbs = join10(resolveStoreCanonicalBase(layer, projectRoot), pluralType, newFilename);
3679
4142
  await ensureParentDirectory(targetAbs);
3680
- const rewritten = rewriteFrontmatterForPromote(content, stableId);
4143
+ const rewritten = rewriteFrontmatterMerge(
4144
+ rewriteFrontmatterForPromote(content, stableId),
4145
+ { last_review_confirmed_at: (/* @__PURE__ */ new Date()).toISOString() }
4146
+ );
3681
4147
  await atomicWriteText(targetAbs, rewritten);
3682
4148
  writtenTarget = true;
3683
4149
  if (sourceIsStore) {
3684
- if (existsSync4(sourceAbs)) {
3685
- await unlink(sourceAbs);
4150
+ if (existsSync5(sourceAbs)) {
4151
+ await unlink2(sourceAbs);
3686
4152
  }
3687
4153
  } else if (sourceOrigin === "team") {
3688
4154
  try {
@@ -3691,13 +4157,13 @@ async function approveOne(projectRoot, pendingPath) {
3691
4157
  stdio: ["ignore", "pipe", "pipe"]
3692
4158
  });
3693
4159
  } catch {
3694
- if (existsSync4(sourceAbs)) {
3695
- await unlink(sourceAbs);
4160
+ if (existsSync5(sourceAbs)) {
4161
+ await unlink2(sourceAbs);
3696
4162
  }
3697
4163
  }
3698
4164
  } else {
3699
- if (existsSync4(sourceAbs)) {
3700
- await unlink(sourceAbs);
4165
+ if (existsSync5(sourceAbs)) {
4166
+ await unlink2(sourceAbs);
3701
4167
  }
3702
4168
  }
3703
4169
  await emitEventBestEffort2(projectRoot, {
@@ -3708,9 +4174,9 @@ async function approveOne(projectRoot, pendingPath) {
3708
4174
  });
3709
4175
  return { pending_path: pendingPath, stable_id: stableId };
3710
4176
  } catch (err) {
3711
- if (writtenTarget && targetAbs !== void 0 && existsSync4(targetAbs)) {
4177
+ if (writtenTarget && targetAbs !== void 0 && existsSync5(targetAbs)) {
3712
4178
  try {
3713
- await unlink(targetAbs);
4179
+ await unlink2(targetAbs);
3714
4180
  } catch {
3715
4181
  }
3716
4182
  }
@@ -3729,14 +4195,14 @@ async function rejectAll(projectRoot, pendingPaths, reason) {
3729
4195
  for (const pendingPath of pendingPaths) {
3730
4196
  try {
3731
4197
  const sandboxed = resolveSandboxedPath(projectRoot, pendingPath, { allowPersonal: true });
3732
- if (existsSync4(sandboxed.abs)) {
3733
- const content = await readFile5(sandboxed.abs, "utf8");
4198
+ if (existsSync5(sandboxed.abs)) {
4199
+ const content = await readFile6(sandboxed.abs, "utf8");
3734
4200
  const merged = rewriteFrontmatterMerge(content, { status: "rejected" });
3735
4201
  const rejectedAbs = sandboxed.abs.includes(`${sep2}pending${sep2}`) ? sandboxed.abs.replace(`${sep2}pending${sep2}`, `${sep2}rejected${sep2}`) : null;
3736
4202
  if (rejectedAbs !== null) {
3737
4203
  await ensureParentDirectory(rejectedAbs);
3738
4204
  await atomicWriteText(rejectedAbs, merged);
3739
- await unlink(sandboxed.abs);
4205
+ await unlink2(sandboxed.abs);
3740
4206
  } else if (merged !== content) {
3741
4207
  await atomicWriteText(sandboxed.abs, merged);
3742
4208
  }
@@ -3757,9 +4223,16 @@ async function modifyEntry(projectRoot, pendingPath, changes) {
3757
4223
  if (target === null) {
3758
4224
  throw new Error(`modify target not found: ${pendingPath}`);
3759
4225
  }
3760
- const content = await readFile5(target.absPath, "utf8");
4226
+ const content = await readFile6(target.absPath, "utf8");
3761
4227
  const fm = parseFrontmatter(content);
3762
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
+ }
3763
4236
  if (changes.semantic_scope !== void 0 && isPersonalScope2(changes.semantic_scope)) {
3764
4237
  throw new Error(
3765
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)`
@@ -3768,7 +4241,10 @@ async function modifyEntry(projectRoot, pendingPath, changes) {
3768
4241
  if (changes.layer !== void 0 && changes.layer !== currentLayer) {
3769
4242
  return await modifyLayerFlip(projectRoot, target, content, fm, changes);
3770
4243
  }
3771
- const merged = rewriteFrontmatterMerge(content, changes);
4244
+ const merged = rewriteFrontmatterMerge(content, {
4245
+ ...changes,
4246
+ last_review_confirmed_at: (/* @__PURE__ */ new Date()).toISOString()
4247
+ });
3772
4248
  await atomicWriteText(target.absPath, merged);
3773
4249
  const changedFields = Object.keys(changes).filter(
3774
4250
  (field) => changes[field] !== void 0
@@ -3802,7 +4278,7 @@ function resolveModifyTarget(projectRoot, pendingPath) {
3802
4278
  } catch {
3803
4279
  return null;
3804
4280
  }
3805
- if (existsSync4(sandboxed.abs)) {
4281
+ if (existsSync5(sandboxed.abs)) {
3806
4282
  return {
3807
4283
  absPath: sandboxed.abs,
3808
4284
  isInProjectTree: sandboxed.isInProjectTree,
@@ -3849,7 +4325,7 @@ async function modifyLayerFlip(projectRoot, target, content, fm, changes) {
3849
4325
  pluralType,
3850
4326
  resolveWriteTargetStoreDir(toLayer, projectRoot)
3851
4327
  );
3852
- const toAbs = join9(
4328
+ const toAbs = join10(
3853
4329
  resolveStoreCanonicalBase(toLayer, projectRoot),
3854
4330
  pluralType,
3855
4331
  `${newStableId}--${slug}.md`
@@ -3867,7 +4343,11 @@ async function modifyLayerFlip(projectRoot, target, content, fm, changes) {
3867
4343
  relevance_scope: "broad",
3868
4344
  relevance_paths: []
3869
4345
  } : { ...changes, layer: toLayer };
3870
- 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
+ );
3871
4351
  await atomicWriteText(toAbs, rewritten);
3872
4352
  if (target.isInProjectTree) {
3873
4353
  const relSource = relative(projectRoot, target.absPath);
@@ -3877,12 +4357,12 @@ async function modifyLayerFlip(projectRoot, target, content, fm, changes) {
3877
4357
  stdio: ["ignore", "pipe", "pipe"]
3878
4358
  });
3879
4359
  } catch {
3880
- if (existsSync4(target.absPath)) {
3881
- await unlink(target.absPath);
4360
+ if (existsSync5(target.absPath)) {
4361
+ await unlink2(target.absPath);
3882
4362
  }
3883
4363
  }
3884
- } else if (existsSync4(target.absPath)) {
3885
- await unlink(target.absPath);
4364
+ } else if (existsSync5(target.absPath)) {
4365
+ await unlink2(target.absPath);
3886
4366
  }
3887
4367
  const flipReason = `layer_flip:${priorStableId ?? "<unassigned>"}->${newStableId}`;
3888
4368
  const flipTimestamp = (/* @__PURE__ */ new Date()).toISOString();
@@ -3945,10 +4425,10 @@ function getSearchDirectoryCache(cacheKey) {
3945
4425
  return created;
3946
4426
  }
3947
4427
  async function listIndexedSearchEntries(source, type) {
3948
- const dir = join9(source.root, type);
4428
+ const dir = join10(source.root, type);
3949
4429
  let entries;
3950
4430
  try {
3951
- entries = await readdir(dir);
4431
+ entries = await readdir2(dir);
3952
4432
  } catch {
3953
4433
  return [];
3954
4434
  }
@@ -3958,7 +4438,7 @@ async function listIndexedSearchEntries(source, type) {
3958
4438
  const indexed = [];
3959
4439
  for (const name of entries) {
3960
4440
  if (!name.endsWith(".md")) continue;
3961
- const absolutePath = join9(dir, name);
4441
+ const absolutePath = join10(dir, name);
3962
4442
  let fingerprint;
3963
4443
  try {
3964
4444
  const st = await stat2(absolutePath);
@@ -3975,7 +4455,7 @@ async function listIndexedSearchEntries(source, type) {
3975
4455
  }
3976
4456
  let content;
3977
4457
  try {
3978
- content = await readFile5(absolutePath, "utf8");
4458
+ content = await readFile6(absolutePath, "utf8");
3979
4459
  searchEntryIndexContentReads += 1;
3980
4460
  } catch {
3981
4461
  directoryCache.files.delete(absolutePath);
@@ -4095,8 +4575,8 @@ async function deferAll(projectRoot, pendingPaths, until, reason) {
4095
4575
  let stableId;
4096
4576
  try {
4097
4577
  const sandboxed = resolveSandboxedPath(projectRoot, pendingPath, { allowPersonal: true });
4098
- if (existsSync4(sandboxed.abs)) {
4099
- const content = await readFile5(sandboxed.abs, "utf8");
4578
+ if (existsSync5(sandboxed.abs)) {
4579
+ const content = await readFile6(sandboxed.abs, "utf8");
4100
4580
  stableId = parseFrontmatter(content).id;
4101
4581
  const patch = {
4102
4582
  status: "deferred",
@@ -4191,6 +4671,9 @@ function parseFrontmatter(content) {
4191
4671
  case "deferred_until":
4192
4672
  out.deferred_until = stripQuotes(value);
4193
4673
  break;
4674
+ case "last_review_confirmed_at":
4675
+ out.last_review_confirmed_at = stripQuotes(value);
4676
+ break;
4194
4677
  default:
4195
4678
  break;
4196
4679
  }
@@ -4256,6 +4739,7 @@ ${content}`;
4256
4739
  if (patch.related !== void 0) updates.related = `related: ${flowArray(patch.related)}`;
4257
4740
  if (patch.status !== void 0) updates.status = `status: ${patch.status}`;
4258
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)}`;
4259
4743
  const lines = block.split(/\r?\n/u);
4260
4744
  const seen = /* @__PURE__ */ new Set();
4261
4745
  const newLines = [];
@@ -4292,6 +4776,7 @@ function appendPatchLines(lines, patch) {
4292
4776
  if (patch.related !== void 0) lines.push(`related: ${flowArray(patch.related)}`);
4293
4777
  if (patch.status !== void 0) lines.push(`status: ${patch.status}`);
4294
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)}`);
4295
4780
  }
4296
4781
  function flowArrayElement(value) {
4297
4782
  if (/[\n\r,\[\]{}"#:]/u.test(value) || /^\s|\s$/u.test(value)) {
@@ -4326,7 +4811,7 @@ function registerReview(server, tracker) {
4326
4811
  server.registerTool(
4327
4812
  "fab_review",
4328
4813
  {
4329
- 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.",
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.",
4330
4815
  // Flat ZodRawShape required by MCP SDK 1.29.0 registerTool. The
4331
4816
  // authoritative cross-field contract still lives in FabReviewInputSchema
4332
4817
  // (discriminatedUnion) and is enforced inside the handler via
@@ -4357,7 +4842,71 @@ function registerReview(server, tracker) {
4357
4842
  "fab_review returned a large result set \u2014 pass a narrower filter (topic / status / id) to reduce response size."
4358
4843
  );
4359
4844
  return {
4360
- content: [{ type: "text", text: JSON.stringify(response) }],
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",
4872
+ {
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.",
4874
+ // Flat ZodRawShape required by MCP SDK 1.29.0 registerTool. The
4875
+ // authoritative cross-field contract still lives in FabPendingInputSchema
4876
+ // (discriminatedUnion) and is enforced inside the handler via
4877
+ // `FabPendingInputSchema.parse(input)`.
4878
+ inputSchema: FabPendingInputShape,
4879
+ outputSchema: FabPendingOutputShape,
4880
+ annotations: fabPendingAnnotations
4881
+ },
4882
+ async (input) => {
4883
+ const requestId = randomUUID6();
4884
+ tracker?.enter(requestId);
4885
+ try {
4886
+ const gateResult = await awaitFirstReconcileGate();
4887
+ const gateWarn = gateWarning(gateResult);
4888
+ const narrowed = FabPendingInputSchema.parse(input);
4889
+ const projectRoot = resolveProjectRoot();
4890
+ const result = await reviewPending(projectRoot, narrowed);
4891
+ const response = { ...result };
4892
+ if (gateWarn) {
4893
+ response.warnings = [gateWarn];
4894
+ }
4895
+ const payloadLimits = readPayloadLimits(projectRoot);
4896
+ const serialized = JSON.stringify(response);
4897
+ const guardResult = enforcePayloadLimit5(serialized, payloadLimits);
4898
+ response.warnings = appendPayloadWarning(
4899
+ response.warnings,
4900
+ guardResult,
4901
+ "fab_pending returned a large result set \u2014 pass a narrower filter (topic / status / id) to reduce response size."
4902
+ );
4903
+ return {
4904
+ content: [
4905
+ {
4906
+ type: "text",
4907
+ text: `Fabric pending: ${response.action} (see structuredContent)`
4908
+ }
4909
+ ],
4361
4910
  structuredContent: response
4362
4911
  };
4363
4912
  } finally {
@@ -4368,9 +4917,9 @@ function registerReview(server, tracker) {
4368
4917
  }
4369
4918
 
4370
4919
  // src/services/doctor.ts
4371
- import { access as access4, readFile as readFile14, readdir as readdirAsync, stat as statAsync, unlink as unlink3, 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";
4372
4921
  import { constants as constants2 } from "fs";
4373
- import { isAbsolute as isAbsolute2, join as join19, posix as posix4, resolve as resolve3 } from "path";
4922
+ import { isAbsolute as isAbsolute2, join as join21, posix as posix5, resolve as resolve3 } from "path";
4374
4923
  import {
4375
4924
  createTranslator,
4376
4925
  forensicReportSchema,
@@ -4584,8 +5133,8 @@ function createKnowledgeSummaryOpaqueCheck(t, inspection) {
4584
5133
  }
4585
5134
 
4586
5135
  // src/services/doctor-stable-id-collision.ts
4587
- import { readFile as readFile6 } from "fs/promises";
4588
- import { basename as basename2, join as join10 } from "path";
5136
+ import { readFile as readFile7 } from "fs/promises";
5137
+ import { basename as basename2, join as join11 } from "path";
4589
5138
  import {
4590
5139
  buildStoreResolveInput as buildStoreResolveInput4,
4591
5140
  createStoreResolver as createStoreResolver4,
@@ -4612,7 +5161,7 @@ function resolveIntegrityStores(projectRoot) {
4612
5161
  return {
4613
5162
  store_uuid: entry.store_uuid,
4614
5163
  alias: entry.alias,
4615
- dir: join10(globalRoot, storeRelativePathForMount3(mounted ?? { store_uuid: entry.store_uuid }))
5164
+ dir: join11(globalRoot, storeRelativePathForMount3(mounted ?? { store_uuid: entry.store_uuid }))
4616
5165
  };
4617
5166
  });
4618
5167
  return { dirs, personalUuids };
@@ -4631,7 +5180,7 @@ async function inspectStoreStableIdIntegrity(projectRoot) {
4631
5180
  for (const ref of await readKnowledgeAcrossStores2(resolved.dirs)) {
4632
5181
  let source;
4633
5182
  try {
4634
- source = await readFile6(ref.file, "utf8");
5183
+ source = await readFile7(ref.file, "utf8");
4635
5184
  } catch {
4636
5185
  continue;
4637
5186
  }
@@ -4715,8 +5264,8 @@ function createLayerMismatchCheck(t, inspection) {
4715
5264
 
4716
5265
  // src/services/doctor-relevance-paths.ts
4717
5266
  import { execFileSync as execFileSync2 } from "child_process";
4718
- import { existsSync as existsSync5, readdirSync, statSync as statSync3 } from "fs";
4719
- import { join as join11, sep as sep3 } from "path";
5267
+ import { existsSync as existsSync6, readdirSync, statSync as statSync3 } from "fs";
5268
+ import { join as join12, sep as sep3 } from "path";
4720
5269
  import { minimatch } from "minimatch";
4721
5270
  var MS_PER_DAY = 24 * 60 * 60 * 1e3;
4722
5271
  var RELEVANCE_PATHS_DRIFT_WINDOW_DAYS = 90;
@@ -4737,7 +5286,7 @@ function expandGlob(rawGlob) {
4737
5286
  return rawGlob.endsWith("/") ? `${rawGlob}**` : rawGlob;
4738
5287
  }
4739
5288
  function collectWorkspacePaths(projectRoot) {
4740
- if (!existsSync5(projectRoot)) {
5289
+ if (!existsSync6(projectRoot)) {
4741
5290
  return [];
4742
5291
  }
4743
5292
  try {
@@ -4759,7 +5308,7 @@ function collectWorkspacePaths(projectRoot) {
4759
5308
  continue;
4760
5309
  }
4761
5310
  for (const entry of entries) {
4762
- const abs = join11(current, entry.name);
5311
+ const abs = join12(current, entry.name);
4763
5312
  const rel = toPosix(abs.slice(projectRoot.length + 1));
4764
5313
  if (rel.length === 0) continue;
4765
5314
  if (entry.isDirectory()) {
@@ -4952,16 +5501,16 @@ function createNarrowNoPathsCheck(t, inspection) {
4952
5501
  }
4953
5502
 
4954
5503
  // src/services/doctor-broad-index.ts
4955
- import { readFile as readFile7 } from "fs/promises";
4956
- import { join as join12 } from "path";
5504
+ import { readFile as readFile8 } from "fs/promises";
5505
+ import { join as join13 } from "path";
4957
5506
  var DEFAULT_BROAD_INDEX_BACKSTOP = 50;
4958
5507
  var BROAD_INDEX_BACKSTOP_MIN = 20;
4959
5508
  var BROAD_INDEX_BACKSTOP_MAX = 500;
4960
5509
  var BROAD_INDEX_DRIFT_RATIO = 0.8;
4961
5510
  async function readBroadIndexBackstop(projectRoot) {
4962
- const configPath = join12(projectRoot, ".fabric", "fabric-config.json");
5511
+ const configPath = join13(projectRoot, ".fabric", "fabric-config.json");
4963
5512
  try {
4964
- const raw = await readFile7(configPath, "utf8");
5513
+ const raw = await readFile8(configPath, "utf8");
4965
5514
  const parsed = JSON.parse(raw);
4966
5515
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
4967
5516
  const v = parsed.broad_index_backstop;
@@ -5057,6 +5606,9 @@ async function inspectStoreKnowledgeAge(projectRoot, now, lastActiveIndex) {
5057
5606
  if (maturity === void 0) {
5058
5607
  continue;
5059
5608
  }
5609
+ if (entry.description.relevance_scope === "broad") {
5610
+ continue;
5611
+ }
5060
5612
  const lastActive = lastActiveIndex.get(entry.stableId);
5061
5613
  if (lastActive === void 0) {
5062
5614
  continue;
@@ -5141,9 +5693,142 @@ function createStaleArchiveCheck(t, inspection) {
5141
5693
  };
5142
5694
  }
5143
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
+
5144
5829
  // src/services/doctor-scope-lint.ts
5145
- import { readFile as readFile8 } from "fs/promises";
5146
- import { join as join13 } from "path";
5830
+ import { readFile as readFile9 } from "fs/promises";
5831
+ import { join as join14 } from "path";
5147
5832
  import {
5148
5833
  buildStoreResolveInput as buildStoreResolveInput5,
5149
5834
  createStoreResolver as createStoreResolver5,
@@ -5178,7 +5863,7 @@ async function resolveLintStores(projectRoot) {
5178
5863
  const globalRoot = resolveGlobalRoot4();
5179
5864
  return Promise.all(readSet.stores.map(async (entry) => {
5180
5865
  const mounted = input.mountedStores.find((s) => s.store_uuid === entry.store_uuid);
5181
- const dir = join13(
5866
+ const dir = join14(
5182
5867
  globalRoot,
5183
5868
  storeRelativePathForMount4(mounted ?? { store_uuid: entry.store_uuid })
5184
5869
  );
@@ -5210,7 +5895,7 @@ async function lintStoreScopes(projectRoot) {
5210
5895
  }
5211
5896
  let source;
5212
5897
  try {
5213
- source = await readFile8(ref.file, "utf8");
5898
+ source = await readFile9(ref.file, "utf8");
5214
5899
  } catch {
5215
5900
  continue;
5216
5901
  }
@@ -5332,8 +6017,8 @@ function createUnboundProjectCheck(t, violation) {
5332
6017
  }
5333
6018
 
5334
6019
  // src/services/doctor-store-counters.ts
5335
- import { existsSync as existsSync6, readdirSync as readdirSync2, readFileSync as readFileSync3 } from "fs";
5336
- import { join as join14 } from "path";
6020
+ import { existsSync as existsSync7, readdirSync as readdirSync2, readFileSync as readFileSync3 } from "fs";
6021
+ import { join as join15 } from "path";
5337
6022
  import {
5338
6023
  buildStoreResolveInput as buildStoreResolveInput6,
5339
6024
  createStoreResolver as createStoreResolver6,
@@ -5360,7 +6045,7 @@ function resolveCounterStores(projectRoot) {
5360
6045
  return readSet.stores.map((entry) => ({
5361
6046
  uuid: entry.store_uuid,
5362
6047
  alias: entry.alias,
5363
- dir: join14(
6048
+ dir: join15(
5364
6049
  globalRoot,
5365
6050
  storeRelativePathForMount5(
5366
6051
  input.mountedStores.find((s) => s.store_uuid === entry.store_uuid) ?? {
@@ -5388,8 +6073,8 @@ function readEntryId(file) {
5388
6073
  function computeStoreDiskMax(storeDir) {
5389
6074
  const max = defaultAgentsMetaCounters();
5390
6075
  for (const type of STORE_KNOWLEDGE_TYPE_DIRS) {
5391
- const dir = join14(storeDir, STORE_LAYOUT2.knowledgeDir, type);
5392
- if (!existsSync6(dir)) {
6076
+ const dir = join15(storeDir, STORE_LAYOUT2.knowledgeDir, type);
6077
+ if (!existsSync7(dir)) {
5393
6078
  continue;
5394
6079
  }
5395
6080
  let names;
@@ -5402,7 +6087,7 @@ function computeStoreDiskMax(storeDir) {
5402
6087
  if (!name.endsWith(".md")) {
5403
6088
  continue;
5404
6089
  }
5405
- const parsed = parseKnowledgeId2(readEntryId(join14(dir, name)) ?? "");
6090
+ const parsed = parseKnowledgeId2(readEntryId(join15(dir, name)) ?? "");
5406
6091
  if (parsed === null) {
5407
6092
  continue;
5408
6093
  }
@@ -5485,7 +6170,7 @@ function createStoreCounterCheck(t, drifts) {
5485
6170
 
5486
6171
  // src/services/doctor-store-orphan.ts
5487
6172
  import { readdirSync as readdirSync3 } from "fs";
5488
- import { join as join15 } from "path";
6173
+ import { join as join16 } from "path";
5489
6174
  import {
5490
6175
  STORES_ROOT_DIR,
5491
6176
  addMountedStore,
@@ -5509,15 +6194,15 @@ function inspectStoreOrphans(globalRoot = resolveGlobalRoot6()) {
5509
6194
  return [];
5510
6195
  }
5511
6196
  const registered = new Set(config.stores.map((s) => s.store_uuid));
5512
- const storesRoot = join15(globalRoot, STORES_ROOT_DIR);
6197
+ const storesRoot = join16(globalRoot, STORES_ROOT_DIR);
5513
6198
  const orphans = [];
5514
6199
  for (const group of listDir(storesRoot)) {
5515
6200
  if (group === STORE_BY_ALIAS_DIR) {
5516
6201
  continue;
5517
6202
  }
5518
- const groupDir = join15(storesRoot, group);
6203
+ const groupDir = join16(storesRoot, group);
5519
6204
  for (const mount of listDir(groupDir)) {
5520
- const dir = join15(groupDir, mount);
6205
+ const dir = join16(groupDir, mount);
5521
6206
  const identity = readStoreIdentity(dir);
5522
6207
  if (identity === null || registered.has(identity.store_uuid)) {
5523
6208
  continue;
@@ -5590,7 +6275,7 @@ import {
5590
6275
 
5591
6276
  // src/services/events-jsonl-gates.ts
5592
6277
  import { promises as fs } from "fs";
5593
- import { existsSync as existsSync7 } from "fs";
6278
+ import { existsSync as existsSync8 } from "fs";
5594
6279
  var EVENTS_JSONL_SIZE_WARN_BYTES = 10 * 1024 * 1024;
5595
6280
  var METRICS_STALE_WARN_MS = 10 * 60 * 1e3;
5596
6281
  var ROTATION_OVERDUE_WARN_MS = 90 * 24 * 60 * 60 * 1e3;
@@ -5605,17 +6290,17 @@ async function inspectEventsJsonlGates(projectRoot, options = {}) {
5605
6290
  let ledgerSizeBytes = 0;
5606
6291
  let ledgerStalenessMs = null;
5607
6292
  try {
5608
- const stat4 = await fs.stat(eventsPath);
5609
- ledgerSizeBytes = stat4.size;
5610
- 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);
5611
6296
  } catch (error) {
5612
6297
  if (!(isNodeError(error) && error.code === "ENOENT")) throw error;
5613
6298
  }
5614
6299
  let metricsStalenessMs = null;
5615
- if (existsSync7(metricsPath)) {
6300
+ if (existsSync8(metricsPath)) {
5616
6301
  try {
5617
- const stat4 = await fs.stat(metricsPath);
5618
- metricsStalenessMs = Math.max(0, now.getTime() - stat4.mtimeMs);
6302
+ const stat5 = await fs.stat(metricsPath);
6303
+ metricsStalenessMs = Math.max(0, now.getTime() - stat5.mtimeMs);
5619
6304
  } catch {
5620
6305
  }
5621
6306
  }
@@ -5654,18 +6339,9 @@ async function inspectEventsJsonlGates(projectRoot, options = {}) {
5654
6339
  }
5655
6340
 
5656
6341
  // src/services/doctor-skill-lints.ts
5657
- import { readdir as readdir2, readFile as readFile9 } from "fs/promises";
5658
- import { join as join16, posix as posix2 } from "path";
5659
- var FABRIC_SKILL_SLUGS = ["fabric-archive", "fabric-review", "fabric-import"];
5660
- var ROUTER_VALID_LEAF_SLUGS = /* @__PURE__ */ new Set([
5661
- "fabric-archive",
5662
- "fabric-review",
5663
- "fabric-import",
5664
- "fabric-sync",
5665
- "fabric-store",
5666
- "fabric-audit",
5667
- "fabric-connect"
5668
- ]);
6342
+ import { readdir as readdir3, readFile as readFile10 } from "fs/promises";
6343
+ import { join as join17, posix as posix2 } from "path";
6344
+ var FABRIC_SKILL_SLUGS = ["fabric-archive", "fabric-review"];
5669
6345
  var SKILL_MD_FRONTMATTER_ROOTS = [".claude/skills", ".codex/skills"];
5670
6346
  var SKILL_FRONTMATTER_KEY_PATTERN = /^([A-Za-z_][A-Za-z0-9_-]*):[ \t]+(.+?)[ \t]*$/u;
5671
6347
  var SKILL_QUOTED_VALUE_LEADS = /* @__PURE__ */ new Set(['"', "'", "[", "{", ">", "|"]);
@@ -5686,7 +6362,7 @@ function issueCheck(name, status, kind, code, message, actionHint, audience) {
5686
6362
  }
5687
6363
  async function listMarkdownFiles(dir) {
5688
6364
  try {
5689
- return (await readdir2(dir)).filter((name) => name.endsWith(".md"));
6365
+ return (await readdir3(dir)).filter((name) => name.endsWith(".md"));
5690
6366
  } catch {
5691
6367
  return null;
5692
6368
  }
@@ -5694,8 +6370,8 @@ async function listMarkdownFiles(dir) {
5694
6370
  async function inspectSkillRefMirror(projectRoot) {
5695
6371
  const driftedPaths = [];
5696
6372
  for (const slug of FABRIC_SKILL_SLUGS) {
5697
- const claudeRef = join16(projectRoot, ".claude", "skills", slug, "ref");
5698
- const codexRef = join16(projectRoot, ".codex", "skills", slug, "ref");
6373
+ const claudeRef = join17(projectRoot, ".claude", "skills", slug, "ref");
6374
+ const codexRef = join17(projectRoot, ".codex", "skills", slug, "ref");
5699
6375
  const [claudeFiles, codexFiles] = await Promise.all([listMarkdownFiles(claudeRef), listMarkdownFiles(codexRef)]);
5700
6376
  if (claudeFiles === null || codexFiles === null) continue;
5701
6377
  const claudeSet = new Set(claudeFiles);
@@ -5712,8 +6388,8 @@ async function inspectSkillRefMirror(projectRoot) {
5712
6388
  let codexBody;
5713
6389
  try {
5714
6390
  [claudeBody, codexBody] = await Promise.all([
5715
- readFile9(join16(claudeRef, fname), "utf8"),
5716
- readFile9(join16(codexRef, fname), "utf8")
6391
+ readFile10(join17(claudeRef, fname), "utf8"),
6392
+ readFile10(join17(codexRef, fname), "utf8")
5717
6393
  ]);
5718
6394
  } catch {
5719
6395
  continue;
@@ -5732,10 +6408,10 @@ async function inspectSkillTokenBudget(projectRoot) {
5732
6408
  const overSize = [];
5733
6409
  let highestSeverity = "ok";
5734
6410
  for (const slug of FABRIC_SKILL_SLUGS) {
5735
- const skillMdPath = join16(projectRoot, ".claude", "skills", slug, "SKILL.md");
6411
+ const skillMdPath = join17(projectRoot, ".claude", "skills", slug, "SKILL.md");
5736
6412
  let body;
5737
6413
  try {
5738
- body = await readFile9(skillMdPath, "utf8");
6414
+ body = await readFile10(skillMdPath, "utf8");
5739
6415
  } catch {
5740
6416
  continue;
5741
6417
  }
@@ -5756,10 +6432,10 @@ async function inspectSkillDescription(projectRoot) {
5756
6432
  const CJK_PATTERN = /[\u3400-\u4dbf\u4e00-\u9fff]/u;
5757
6433
  const ASCII_PATTERN = /[a-zA-Z]{2,}/u;
5758
6434
  for (const slug of FABRIC_SKILL_SLUGS) {
5759
- const skillMdPath = join16(projectRoot, ".claude", "skills", slug, "SKILL.md");
6435
+ const skillMdPath = join17(projectRoot, ".claude", "skills", slug, "SKILL.md");
5760
6436
  let body;
5761
6437
  try {
5762
- body = await readFile9(skillMdPath, "utf8");
6438
+ body = await readFile10(skillMdPath, "utf8");
5763
6439
  } catch {
5764
6440
  continue;
5765
6441
  }
@@ -5790,19 +6466,19 @@ async function inspectSkillDescription(projectRoot) {
5790
6466
  async function inspectSkillMdYamlInvalid(projectRoot) {
5791
6467
  const candidates = [];
5792
6468
  for (const rootRel of SKILL_MD_FRONTMATTER_ROOTS) {
5793
- const rootAbs = join16(projectRoot, rootRel);
6469
+ const rootAbs = join17(projectRoot, rootRel);
5794
6470
  let dirEntries;
5795
6471
  try {
5796
- dirEntries = await readdir2(rootAbs, { withFileTypes: true });
6472
+ dirEntries = await readdir3(rootAbs, { withFileTypes: true });
5797
6473
  } catch {
5798
6474
  continue;
5799
6475
  }
5800
6476
  for (const dirEntry of dirEntries) {
5801
6477
  if (!dirEntry.isDirectory()) continue;
5802
- const skillFile = join16(rootAbs, dirEntry.name, "SKILL.md");
6478
+ const skillFile = join17(rootAbs, dirEntry.name, "SKILL.md");
5803
6479
  let raw;
5804
6480
  try {
5805
- raw = await readFile9(skillFile, "utf8");
6481
+ raw = await readFile10(skillFile, "utf8");
5806
6482
  } catch {
5807
6483
  continue;
5808
6484
  }
@@ -5850,68 +6526,6 @@ function extractSkillFrontmatterLines(raw) {
5850
6526
  }
5851
6527
  return null;
5852
6528
  }
5853
- function extractMarkdownSectionBody(markdown, sectionName) {
5854
- const lines = markdown.split(/\r?\n/u);
5855
- const headingRe = /^(#{2,3})\s+(.+?)\s*$/u;
5856
- let start = -1;
5857
- for (let i = 0; i < lines.length; i++) {
5858
- const h = headingRe.exec(lines[i]);
5859
- if (h && h[2] === sectionName) {
5860
- start = i + 1;
5861
- break;
5862
- }
5863
- }
5864
- if (start === -1) return null;
5865
- const out = [];
5866
- for (let i = start; i < lines.length; i++) {
5867
- if (headingRe.test(lines[i])) break;
5868
- out.push(lines[i]);
5869
- }
5870
- return out.join("\n");
5871
- }
5872
- async function inspectRouterChainRef(projectRoot) {
5873
- const candidatePaths = [
5874
- join16(projectRoot, ".claude", "skills", "fabric", "SKILL.md"),
5875
- join16(projectRoot, ".codex", "skills", "fabric", "SKILL.md")
5876
- ];
5877
- let body = null;
5878
- for (const candidate of candidatePaths) {
5879
- try {
5880
- body = await readFile9(candidate, "utf8");
5881
- break;
5882
- } catch {
5883
- }
5884
- }
5885
- if (body === null) return { status: "ok", unknownRefs: [] };
5886
- const chainSection = extractMarkdownSectionBody(body, "S_CHAIN");
5887
- if (chainSection === null) return { status: "ok", unknownRefs: [] };
5888
- const refs = /* @__PURE__ */ new Set();
5889
- const tokenRe = /`(fabric-[a-z]+)`/gu;
5890
- let match;
5891
- while ((match = tokenRe.exec(chainSection)) !== null) {
5892
- refs.add(match[1]);
5893
- }
5894
- const unknownRefs = [...refs].filter((slug) => !ROUTER_VALID_LEAF_SLUGS.has(slug)).sort();
5895
- return unknownRefs.length === 0 ? { status: "ok", unknownRefs: [] } : { status: "drift", unknownRefs };
5896
- }
5897
- function createRouterChainRefCheck(t, inspection) {
5898
- if (inspection.status === "ok") {
5899
- return okCheck(t("doctor.check.router_chain_ref.name"), t("doctor.check.router_chain_ref.ok"));
5900
- }
5901
- const count = inspection.unknownRefs.length;
5902
- return issueCheck(
5903
- t("doctor.check.router_chain_ref.name"),
5904
- "warn",
5905
- "warning",
5906
- "router_chain_ref_drift",
5907
- t(`doctor.check.router_chain_ref.message.${count === 1 ? "singular" : "plural"}`, {
5908
- count: String(count),
5909
- list: inspection.unknownRefs.join(", ")
5910
- }),
5911
- t("doctor.check.router_chain_ref.remediation"),
5912
- "maintainer"
5913
- );
5914
- }
5915
6529
  function createSkillRefMirrorCheck(t, inspection) {
5916
6530
  if (inspection.status === "ok") {
5917
6531
  return okCheck(t("doctor.check.skill_ref_mirror.name"), t("doctor.check.skill_ref_mirror.ok"));
@@ -5986,10 +6600,130 @@ function createSkillMdYamlInvalidCheck(t, inspection) {
5986
6600
  );
5987
6601
  }
5988
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
+
5989
6723
  // src/services/doctor-hooks-lints.ts
5990
6724
  import { constants } from "fs";
5991
- import { access, readdir as readdir3, readFile as readFile10, stat as stat3 } from "fs/promises";
5992
- import { join as join17, 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";
5993
6727
  import { Script } from "vm";
5994
6728
  var HOOKS_RUNTIME_CLIENT_DIRS = [
5995
6729
  { client: "claude", dir: ".claude/hooks" },
@@ -6013,7 +6747,7 @@ function isRecord(value) {
6013
6747
  return typeof value === "object" && value !== null && !Array.isArray(value);
6014
6748
  }
6015
6749
  function normalizePath(path) {
6016
- return posix3.normalize(path.split("\\").join("/"));
6750
+ return posix4.normalize(path.split("\\").join("/"));
6017
6751
  }
6018
6752
  function isNodeMissingPathError(error) {
6019
6753
  return error instanceof Error && "code" in error && error.code === "ENOENT";
@@ -6038,27 +6772,27 @@ function isHookWiredForEvent(hooks, event, hookFile) {
6038
6772
  }
6039
6773
  async function readDirectoryFileNames(dir) {
6040
6774
  try {
6041
- return await readdir3(dir);
6775
+ return await readdir5(dir);
6042
6776
  } catch {
6043
6777
  return null;
6044
6778
  }
6045
6779
  }
6046
6780
  async function isFile(absPath) {
6047
6781
  try {
6048
- return (await stat3(absPath)).isFile();
6782
+ return (await stat4(absPath)).isFile();
6049
6783
  } catch {
6050
6784
  return false;
6051
6785
  }
6052
6786
  }
6053
6787
  async function inspectHooksWired(projectRoot) {
6054
- const claudeEntries = await readDirectoryFileNames(join17(projectRoot, ".claude"));
6788
+ const claudeEntries = await readDirectoryFileNames(join19(projectRoot, ".claude"));
6055
6789
  if (claudeEntries === null) {
6056
6790
  return { status: "skipped", missingHooks: [] };
6057
6791
  }
6058
- const settingsPath = join17(projectRoot, ".claude", "settings.json");
6792
+ const settingsPath = join19(projectRoot, ".claude", "settings.json");
6059
6793
  let parsed;
6060
6794
  try {
6061
- parsed = JSON.parse(await readFile10(settingsPath, "utf8"));
6795
+ parsed = JSON.parse(await readFile12(settingsPath, "utf8"));
6062
6796
  } catch {
6063
6797
  return { status: "missing-settings", missingHooks: [] };
6064
6798
  }
@@ -6080,12 +6814,12 @@ async function inspectHooksWired(projectRoot) {
6080
6814
  return { status: "incomplete", missingHooks: missing };
6081
6815
  }
6082
6816
  async function inspectHookCacheWritability(projectRoot) {
6083
- const relPath = posix3.join(".fabric", ".cache");
6084
- const fabricDir = join17(projectRoot, ".fabric");
6085
- const cacheDir = join17(projectRoot, ".fabric", ".cache");
6817
+ const relPath = posix4.join(".fabric", ".cache");
6818
+ const fabricDir = join19(projectRoot, ".fabric");
6819
+ const cacheDir = join19(projectRoot, ".fabric", ".cache");
6086
6820
  try {
6087
6821
  try {
6088
- const cacheStats = await stat3(cacheDir);
6822
+ const cacheStats = await stat4(cacheDir);
6089
6823
  if (!cacheStats.isDirectory()) {
6090
6824
  return {
6091
6825
  writable: false,
@@ -6102,14 +6836,14 @@ async function inspectHookCacheWritability(projectRoot) {
6102
6836
  }
6103
6837
  let parent = fabricDir;
6104
6838
  try {
6105
- await stat3(fabricDir);
6839
+ await stat4(fabricDir);
6106
6840
  } catch (error) {
6107
6841
  if (!isNodeMissingPathError(error)) {
6108
6842
  throw error;
6109
6843
  }
6110
6844
  parent = projectRoot;
6111
6845
  }
6112
- const parentStats = await stat3(parent);
6846
+ const parentStats = await stat4(parent);
6113
6847
  if (!parentStats.isDirectory()) {
6114
6848
  return {
6115
6849
  writable: false,
@@ -6131,12 +6865,12 @@ async function inspectHookCacheWritability(projectRoot) {
6131
6865
  async function inspectHooksContentDrift(projectRoot) {
6132
6866
  const hookFilesByBasename = /* @__PURE__ */ new Map();
6133
6867
  for (const { client, dir } of HOOKS_RUNTIME_CLIENT_DIRS) {
6134
- const absDir = join17(projectRoot, dir);
6868
+ const absDir = join19(projectRoot, dir);
6135
6869
  const entries = await readDirectoryFileNames(absDir);
6136
6870
  if (entries === null) continue;
6137
6871
  for (const name of entries) {
6138
6872
  if (!name.endsWith(".cjs")) continue;
6139
- const abs = join17(absDir, name);
6873
+ const abs = join19(absDir, name);
6140
6874
  if (!await isFile(abs)) continue;
6141
6875
  const arr = hookFilesByBasename.get(name) ?? [];
6142
6876
  arr.push({ client, abs });
@@ -6145,13 +6879,13 @@ async function inspectHooksContentDrift(projectRoot) {
6145
6879
  }
6146
6880
  const drifts = [];
6147
6881
  let scanned = 0;
6148
- for (const [basename3, copies] of hookFilesByBasename) {
6882
+ for (const [basename4, copies] of hookFilesByBasename) {
6149
6883
  if (copies.length < 2) continue;
6150
6884
  scanned += copies.length;
6151
6885
  const hashes = [];
6152
6886
  for (const { client, abs } of copies) {
6153
6887
  try {
6154
- const body = await readFile10(abs, "utf8");
6888
+ const body = await readFile12(abs, "utf8");
6155
6889
  hashes.push({ client, sha: sha256(body) });
6156
6890
  } catch {
6157
6891
  }
@@ -6160,7 +6894,7 @@ async function inspectHooksContentDrift(projectRoot) {
6160
6894
  const first = hashes[0].sha;
6161
6895
  if (hashes.some((h) => h.sha !== first)) {
6162
6896
  drifts.push({
6163
- basename: basename3,
6897
+ basename: basename4,
6164
6898
  clients: copies.map((copy) => copy.client),
6165
6899
  hashes
6166
6900
  });
@@ -6173,18 +6907,18 @@ async function inspectHooksRuntime(projectRoot) {
6173
6907
  const issues = [];
6174
6908
  let scanned = 0;
6175
6909
  for (const { client, dir } of HOOKS_RUNTIME_CLIENT_DIRS) {
6176
- const absDir = join17(projectRoot, dir);
6910
+ const absDir = join19(projectRoot, dir);
6177
6911
  const entries = await readDirectoryFileNames(absDir);
6178
6912
  if (entries === null) continue;
6179
6913
  for (const name of entries) {
6180
6914
  if (!name.endsWith(".cjs")) continue;
6181
- const abs = join17(absDir, name);
6915
+ const abs = join19(absDir, name);
6182
6916
  const displayPath = `${dir}/${name}`;
6183
6917
  if (!await isFile(abs)) continue;
6184
6918
  scanned += 1;
6185
6919
  let body;
6186
6920
  try {
6187
- body = await readFile10(abs, "utf8");
6921
+ body = await readFile12(abs, "utf8");
6188
6922
  } catch (err) {
6189
6923
  issues.push({
6190
6924
  path: displayPath,
@@ -6321,8 +7055,8 @@ function createHookCacheWritabilityCheck(t, inspection) {
6321
7055
  }
6322
7056
 
6323
7057
  // src/services/doctor-bootstrap-lints.ts
6324
- import { access as access2, readFile as readFile11 } from "fs/promises";
6325
- import { join as join18 } from "path";
7058
+ import { access as access2, readFile as readFile13 } from "fs/promises";
7059
+ import { join as join20 } from "path";
6326
7060
  import {
6327
7061
  BOOTSTRAP_MARKER_BEGIN,
6328
7062
  BOOTSTRAP_MARKER_END,
@@ -6354,17 +7088,17 @@ async function fileExists(path) {
6354
7088
  }
6355
7089
  async function inspectBootstrapAnchor(projectRoot) {
6356
7090
  const [hasAgentsMd, hasClaudeMd] = await Promise.all([
6357
- fileExists(join18(projectRoot, "AGENTS.md")),
6358
- fileExists(join18(projectRoot, "CLAUDE.md"))
7091
+ fileExists(join20(projectRoot, "AGENTS.md")),
7092
+ fileExists(join20(projectRoot, "CLAUDE.md"))
6359
7093
  ]);
6360
7094
  return { hasAgentsMd, hasClaudeMd };
6361
7095
  }
6362
7096
  async function inspectL1BootstrapSnapshotDrift(target) {
6363
- const abs = join18(target, ".fabric", "AGENTS.md");
7097
+ const abs = join20(target, ".fabric", "AGENTS.md");
6364
7098
  const canonical = resolveBootstrapCanonical();
6365
7099
  let onDisk;
6366
7100
  try {
6367
- onDisk = await readFile11(abs, "utf8");
7101
+ onDisk = await readFile13(abs, "utf8");
6368
7102
  } catch {
6369
7103
  return { status: "missing", canonical, onDisk: null };
6370
7104
  }
@@ -6390,17 +7124,17 @@ function createL1BootstrapSnapshotDriftCheck(t, inspection) {
6390
7124
  );
6391
7125
  }
6392
7126
  async function inspectL2ManagedBlockDrift(target) {
6393
- const snapshotPath = join18(target, ".fabric", "AGENTS.md");
7127
+ const snapshotPath = join20(target, ".fabric", "AGENTS.md");
6394
7128
  let snapshot;
6395
7129
  try {
6396
- snapshot = await readFile11(snapshotPath, "utf8");
7130
+ snapshot = await readFile13(snapshotPath, "utf8");
6397
7131
  } catch {
6398
7132
  return { status: "ok", drifted: [] };
6399
7133
  }
6400
- const projectRulesPath = join18(target, ".fabric", "project-rules.md");
7134
+ const projectRulesPath = join20(target, ".fabric", "project-rules.md");
6401
7135
  let expectedBody = snapshot;
6402
7136
  try {
6403
- const projectRules = await readFile11(projectRulesPath, "utf8");
7137
+ const projectRules = await readFile13(projectRulesPath, "utf8");
6404
7138
  expectedBody = `${snapshot}
6405
7139
  ---
6406
7140
  ${projectRules}`;
@@ -6409,12 +7143,12 @@ ${projectRules}`;
6409
7143
  const drifted = [];
6410
7144
  let anyManagedBlockFound = false;
6411
7145
  const blockTargets = [
6412
- join18(target, "AGENTS.md")
7146
+ join20(target, "AGENTS.md")
6413
7147
  ];
6414
7148
  for (const abs of blockTargets) {
6415
7149
  let content;
6416
7150
  try {
6417
- content = await readFile11(abs, "utf8");
7151
+ content = await readFile13(abs, "utf8");
6418
7152
  } catch {
6419
7153
  continue;
6420
7154
  }
@@ -6437,9 +7171,9 @@ ${projectRules}`;
6437
7171
  drifted.push({ path: abs, expected: expectedBody, actual: body });
6438
7172
  }
6439
7173
  }
6440
- const claudeMdPath = join18(target, "CLAUDE.md");
7174
+ const claudeMdPath = join20(target, "CLAUDE.md");
6441
7175
  try {
6442
- const claudeContent = await readFile11(claudeMdPath, "utf8");
7176
+ const claudeContent = await readFile13(claudeMdPath, "utf8");
6443
7177
  anyManagedBlockFound = true;
6444
7178
  const lines = claudeContent.split(/\r?\n/u);
6445
7179
  const hasAtImport = lines.some((line) => line.trim() === "@.fabric/AGENTS.md");
@@ -6507,7 +7241,7 @@ import { appendFile as appendFile3 } from "fs/promises";
6507
7241
  import { minimatch as minimatch2 } from "minimatch";
6508
7242
 
6509
7243
  // src/services/cite-rollup.ts
6510
- import { readFile as readFile12 } from "fs/promises";
7244
+ import { readFile as readFile14 } from "fs/promises";
6511
7245
  import { createLedgerWriteQueue as createLedgerWriteQueue3 } from "@fenglimg/fabric-shared/node/atomic-write";
6512
7246
  var citeRollupQueue = createLedgerWriteQueue3();
6513
7247
  async function appendCiteRollupRow(projectRoot, row) {
@@ -6519,7 +7253,7 @@ async function readCiteRollup(projectRoot) {
6519
7253
  const path = getCiteRollupPath(projectRoot);
6520
7254
  let raw;
6521
7255
  try {
6522
- raw = await readFile12(path, "utf8");
7256
+ raw = await readFile14(path, "utf8");
6523
7257
  } catch (error) {
6524
7258
  if (isNodeError(error) && error.code === "ENOENT") return [];
6525
7259
  throw error;
@@ -7527,13 +8261,13 @@ var DEFAULT_UNDERSEED_NODE_THRESHOLD = 10;
7527
8261
  var SESSION_HINTS_STALE_DAYS = 7;
7528
8262
  var SESSION_HINTS_FILE_PREFIX = "session-hints-";
7529
8263
  var SESSION_HINTS_FILE_SUFFIX = ".json";
7530
- var EDIT_COUNTER_FILE_REL = posix4.join(".fabric", ".cache", "edit-counter");
7531
- 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(
7532
8266
  ".fabric",
7533
8267
  ".cache",
7534
8268
  "hint-silence-counter"
7535
8269
  );
7536
- var MS_PER_DAY3 = 24 * 60 * 60 * 1e3;
8270
+ var MS_PER_DAY4 = 24 * 60 * 60 * 1e3;
7537
8271
  var KNOWLEDGE_CANONICAL_TYPE_DIRS = [
7538
8272
  "decisions",
7539
8273
  "pitfalls",
@@ -7581,7 +8315,8 @@ async function runDoctorReport(target) {
7581
8315
  l2ManagedBlockDrift,
7582
8316
  skillRefMirror,
7583
8317
  skillTokenBudget,
7584
- skillDescription
8318
+ skillDescription,
8319
+ retiredReferences
7585
8320
  ] = await Promise.all([
7586
8321
  inspectForensic(projectRoot),
7587
8322
  // v2.2 W5 R4 (agents.meta decolo): `inspectMeta` (read co-location
@@ -7601,7 +8336,10 @@ async function runDoctorReport(target) {
7601
8336
  inspectL2ManagedBlockDrift(projectRoot),
7602
8337
  inspectSkillRefMirror(projectRoot),
7603
8338
  inspectSkillTokenBudget(projectRoot),
7604
- 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)
7605
8343
  ]);
7606
8344
  const citeGoodhart = await inspectCiteGoodhart(projectRoot);
7607
8345
  const storeKnowledgeSummaries = await collectStoreKnowledgeSummaries(projectRoot);
@@ -7622,6 +8360,8 @@ async function runDoctorReport(target) {
7622
8360
  const lintNow = Date.now();
7623
8361
  const lastActiveIndex = await buildLastActiveIndex(projectRoot);
7624
8362
  const knowledgeAge = await inspectStoreKnowledgeAge(projectRoot, lintNow, lastActiveIndex);
8363
+ const knowledgePromotion = await inspectStoreKnowledgePromotion(projectRoot);
8364
+ const broadReviewRecheck = await inspectStoreBroadReviewRecheck(projectRoot, lintNow);
7625
8365
  const preexistingRootFiles = await inspectPreexistingRootFiles(projectRoot);
7626
8366
  const underseedThreshold = await readUnderseedThresholdFromConfig(projectRoot);
7627
8367
  const underseeded = {
@@ -7633,7 +8373,6 @@ async function runDoctorReport(target) {
7633
8373
  const hookCacheWritability = await inspectHookCacheWritability(projectRoot);
7634
8374
  const staleServeLock = inspectStaleServeLock(projectRoot, lintNow);
7635
8375
  const skillMdYamlInvalid = await inspectSkillMdYamlInvalid(projectRoot);
7636
- const routerChainRef = await inspectRouterChainRef(projectRoot);
7637
8376
  const onboardCoverage = await inspectOnboardCoverage(projectRoot);
7638
8377
  const [hooksWired, hooksRuntime, hooksContentDrift] = await Promise.all([
7639
8378
  inspectHooksWired(projectRoot),
@@ -7644,7 +8383,7 @@ async function runDoctorReport(target) {
7644
8383
  const globalCliVersion = process.env.VITEST === "true" ? { status: "ok", version: "test-skipped" } : inspectGlobalCliVersion();
7645
8384
  const targetFiles = Object.fromEntries(
7646
8385
  await Promise.all(
7647
- TARGET_FILE_PATHS.map(async (path) => [path, await pathExists(join19(projectRoot, path))])
8386
+ TARGET_FILE_PATHS.map(async (path) => [path, await pathExists(join21(projectRoot, path))])
7648
8387
  )
7649
8388
  );
7650
8389
  const checks = [
@@ -7683,6 +8422,8 @@ async function runDoctorReport(target) {
7683
8422
  createSkillRefMirrorCheck(t, skillRefMirror),
7684
8423
  createSkillTokenBudgetCheck(t, skillTokenBudget),
7685
8424
  createSkillDescriptionCheck(t, skillDescription),
8425
+ // ux-w2-2: retired-reference (stale-pointer) lint — registry-driven.
8426
+ createRetiredReferenceCheck(t, retiredReferences),
7686
8427
  createCiteGoodhartCheck(t, citeGoodhart),
7687
8428
  createDraftBacklogCheck(t, draftBacklog),
7688
8429
  createKnowledgeTagsEmptyCheck(t, knowledgeTagsEmpty),
@@ -7710,9 +8451,6 @@ async function runDoctorReport(target) {
7710
8451
  // rc.12 lint #29: skill_md_yaml_invalid. Warning kind — surfaces
7711
8452
  // SKILL.md frontmatter that Codex CLI silently drops at load.
7712
8453
  createSkillMdYamlInvalidCheck(t, skillMdYamlInvalid),
7713
- // B2 skill-router (A4): S_CHAIN reference backstop. Warning kind — flags an
7714
- // S_CHAIN `fabric-*` reference to a leaf no longer in the install set.
7715
- createRouterChainRefCheck(t, routerChainRef),
7716
8454
  // v2.0.0-rc.23 TASK-014 (F8c): Onboard coverage advisory. Info kind.
7717
8455
  // Surfaces uncovered S5 onboard slots and recommends /fabric-archive
7718
8456
  // first-run phase. Sits adjacent to Skill markdown YAML — both are
@@ -7764,6 +8502,11 @@ async function runDoctorReport(target) {
7764
8502
  // thresholds 90/30/14d per maturity tier (KT-DEC-0008).
7765
8503
  createOrphanDemoteCheck(t, knowledgeAge.orphanDemote),
7766
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),
7767
8510
  // project-scope binding backfill lint — a store bound as the write target
7768
8511
  // but with no project_id / active_project parks the project axis. The
7769
8512
  // fresh-install hole is sealed in store.stage.ts; this covers existing repos
@@ -7844,7 +8587,7 @@ async function runDoctorFix(target) {
7844
8587
  const fixed = [];
7845
8588
  const ledgerWarnings = [];
7846
8589
  if (before.fixable_errors.some((issue) => issue.code === "bootstrap_snapshot_drift")) {
7847
- const snapshotPath = join19(projectRoot, ".fabric", "AGENTS.md");
8590
+ const snapshotPath = join21(projectRoot, ".fabric", "AGENTS.md");
7848
8591
  await ensureParentDirectory(snapshotPath);
7849
8592
  await atomicWriteText4(snapshotPath, resolveBootstrapCanonical2());
7850
8593
  fixed.push(findIssue(before.fixable_errors, "bootstrap_snapshot_drift"));
@@ -7917,9 +8660,9 @@ async function runDoctorFix(target) {
7917
8660
  if (before.infos.some((issue) => issue.code === "stale_serve_lock")) {
7918
8661
  const lockInspection = inspectStaleServeLock(projectRoot, Date.now());
7919
8662
  if (lockInspection.present && !lockInspection.pidAlive) {
7920
- const lockFilePath = join19(projectRoot, ".fabric", ".serve.lock");
8663
+ const lockFilePath = join21(projectRoot, ".fabric", ".serve.lock");
7921
8664
  try {
7922
- await unlink3(lockFilePath);
8665
+ await unlink4(lockFilePath);
7923
8666
  } catch (err) {
7924
8667
  const errno = err;
7925
8668
  if (errno.code !== "ENOENT") throw err;
@@ -8034,10 +8777,10 @@ function createApplyLintMessage(succeeded, failed, manualErrorCount) {
8034
8777
  }
8035
8778
  async function applySessionHintsStaleCleanup(projectRoot, candidate) {
8036
8779
  const detail = `deleted (${candidate.age_days}d old)`;
8037
- const absPath = join19(projectRoot, candidate.path);
8780
+ const absPath = join21(projectRoot, candidate.path);
8038
8781
  try {
8039
- const { unlink: unlink4 } = await import("fs/promises");
8040
- await unlink4(absPath);
8782
+ const { unlink: unlink5 } = await import("fs/promises");
8783
+ await unlink5(absPath);
8041
8784
  return {
8042
8785
  kind: "knowledge_session_hints_stale_cleanup",
8043
8786
  path: candidate.path,
@@ -8059,9 +8802,9 @@ function truncateErrorMessage(error) {
8059
8802
  return raw.length > 240 ? `${raw.slice(0, 237)}...` : raw;
8060
8803
  }
8061
8804
  async function inspectForensic(projectRoot) {
8062
- const path = join19(projectRoot, ".fabric", "forensic.json");
8805
+ const path = join21(projectRoot, ".fabric", "forensic.json");
8063
8806
  try {
8064
- const parsed = forensicReportSchema.parse(JSON.parse(await readFile14(path, "utf8")));
8807
+ const parsed = forensicReportSchema.parse(JSON.parse(await readFile16(path, "utf8")));
8065
8808
  return { present: true, valid: true, report: parsed };
8066
8809
  } catch (error) {
8067
8810
  if (isMissingFileError(error)) {
@@ -8091,7 +8834,7 @@ async function inspectEventLedger(projectRoot) {
8091
8834
  try {
8092
8835
  await access4(path, constants2.W_OK);
8093
8836
  const { warnings } = await readEventLedger(projectRoot);
8094
- const raw = await readFile14(path, "utf8");
8837
+ const raw = await readFile16(path, "utf8");
8095
8838
  const invalidLine = raw.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).find((line) => !isValidJsonLine(line));
8096
8839
  const partialWarning = warnings.find((w) => w.kind === "partial_write_at_tail");
8097
8840
  const schemaVersionSamples = [];
@@ -8636,7 +9379,7 @@ async function inspectPreexistingRootFiles(projectRoot) {
8636
9379
  const candidates = ["CLAUDE.md", "AGENTS.md"];
8637
9380
  const detected = [];
8638
9381
  for (const name of candidates) {
8639
- if (await pathExists(join19(projectRoot, name))) {
9382
+ if (await pathExists(join21(projectRoot, name))) {
8640
9383
  detected.push(name);
8641
9384
  }
8642
9385
  }
@@ -8721,7 +9464,7 @@ async function buildLastActiveIndex(projectRoot) {
8721
9464
  return map;
8722
9465
  }
8723
9466
  async function inspectSessionHintsStale(projectRoot, now) {
8724
- const cacheDir = join19(projectRoot, ".fabric", ".cache");
9467
+ const cacheDir = join21(projectRoot, ".fabric", ".cache");
8725
9468
  let entries;
8726
9469
  try {
8727
9470
  entries = await readdirAsync(cacheDir, { withFileTypes: true });
@@ -8733,17 +9476,17 @@ async function inspectSessionHintsStale(projectRoot, now) {
8733
9476
  if (!entry.isFile()) continue;
8734
9477
  if (!entry.name.startsWith(SESSION_HINTS_FILE_PREFIX)) continue;
8735
9478
  if (!entry.name.endsWith(SESSION_HINTS_FILE_SUFFIX)) continue;
8736
- const absPath = join19(cacheDir, entry.name);
9479
+ const absPath = join21(cacheDir, entry.name);
8737
9480
  let mtimeMs = 0;
8738
9481
  try {
8739
9482
  mtimeMs = (await statAsync(absPath)).mtimeMs;
8740
9483
  } catch {
8741
9484
  continue;
8742
9485
  }
8743
- const ageDays = Math.floor((now - mtimeMs) / MS_PER_DAY3);
9486
+ const ageDays = Math.floor((now - mtimeMs) / MS_PER_DAY4);
8744
9487
  if (ageDays < SESSION_HINTS_STALE_DAYS) continue;
8745
9488
  candidates.push({
8746
- path: posix4.join(".fabric", ".cache", entry.name),
9489
+ path: posix5.join(".fabric", ".cache", entry.name),
8747
9490
  age_days: ageDays
8748
9491
  });
8749
9492
  }
@@ -8765,9 +9508,9 @@ function inspectStaleServeLock(projectRoot, now) {
8765
9508
  };
8766
9509
  }
8767
9510
  async function readUnderseedThresholdFromConfig(projectRoot) {
8768
- const configPath = join19(projectRoot, ".fabric", "fabric-config.json");
9511
+ const configPath = join21(projectRoot, ".fabric", "fabric-config.json");
8769
9512
  try {
8770
- const raw = await readFile14(configPath, "utf8");
9513
+ const raw = await readFile16(configPath, "utf8");
8771
9514
  const parsed = JSON.parse(raw);
8772
9515
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
8773
9516
  const v = parsed.underseed_node_threshold;
@@ -8841,7 +9584,7 @@ function createStaleServeLockCheck(t, inspection) {
8841
9584
  })
8842
9585
  );
8843
9586
  }
8844
- const days = Math.floor(inspection.ageMs / MS_PER_DAY3);
9587
+ const days = Math.floor(inspection.ageMs / MS_PER_DAY4);
8845
9588
  const hours = Math.floor(inspection.ageMs / (60 * 60 * 1e3));
8846
9589
  const acquiredAgo = days >= 1 ? t(`doctor.check.stale_serve_lock.age.day.${days === 1 ? "singular" : "plural"}`, {
8847
9590
  count: String(days)
@@ -8883,10 +9626,10 @@ async function inspectOnboardCoverage(projectRoot) {
8883
9626
  return { filled, missing, opted_out: optedOut };
8884
9627
  }
8885
9628
  async function readOnboardOptedOut(projectRoot) {
8886
- const path = join19(projectRoot, ".fabric", "fabric-config.json");
9629
+ const path = join21(projectRoot, ".fabric", "fabric-config.json");
8887
9630
  let raw;
8888
9631
  try {
8889
- raw = await readFile14(path, "utf8");
9632
+ raw = await readFile16(path, "utf8");
8890
9633
  } catch {
8891
9634
  return [];
8892
9635
  }
@@ -8971,7 +9714,7 @@ async function* iterateCanonicalFilenames(projectRoot) {
8971
9714
  if (!KNOWLEDGE_CANONICAL_TYPE_DIRS.includes(entry.type)) {
8972
9715
  continue;
8973
9716
  }
8974
- const filename = posix4.basename(normalizePath2(entry.file));
9717
+ const filename = posix5.basename(normalizePath2(entry.file));
8975
9718
  const parsed = parseStableIdFromCanonicalFilename(filename);
8976
9719
  if (parsed === null) {
8977
9720
  continue;
@@ -8988,22 +9731,22 @@ async function* iterateCanonicalFilenames(projectRoot) {
8988
9731
  }
8989
9732
  }
8990
9733
  async function rewriteThreeEndManagedBlocks(projectRoot) {
8991
- const snapshotPath = join19(projectRoot, ".fabric", "AGENTS.md");
9734
+ const snapshotPath = join21(projectRoot, ".fabric", "AGENTS.md");
8992
9735
  if (!await pathExists(snapshotPath)) {
8993
9736
  return;
8994
9737
  }
8995
9738
  let snapshot;
8996
9739
  try {
8997
- snapshot = await readFile14(snapshotPath, "utf8");
9740
+ snapshot = await readFile16(snapshotPath, "utf8");
8998
9741
  } catch {
8999
9742
  return;
9000
9743
  }
9001
- const projectRulesPath = join19(projectRoot, ".fabric", "project-rules.md");
9744
+ const projectRulesPath = join21(projectRoot, ".fabric", "project-rules.md");
9002
9745
  const hasProjectRules = await pathExists(projectRulesPath);
9003
9746
  let expectedBody = snapshot;
9004
9747
  if (hasProjectRules) {
9005
9748
  try {
9006
- const projectRules = await readFile14(projectRulesPath, "utf8");
9749
+ const projectRules = await readFile16(projectRulesPath, "utf8");
9007
9750
  expectedBody = `${snapshot}
9008
9751
  ---
9009
9752
  ${projectRules}`;
@@ -9014,7 +9757,7 @@ ${projectRules}`;
9014
9757
  ${expectedBody}
9015
9758
  ${BOOTSTRAP_MARKER_END2}`;
9016
9759
  const blockTargets = [
9017
- join19(projectRoot, "AGENTS.md")
9760
+ join21(projectRoot, "AGENTS.md")
9018
9761
  ];
9019
9762
  for (const abs of blockTargets) {
9020
9763
  if (!await pathExists(abs)) {
@@ -9022,7 +9765,7 @@ ${BOOTSTRAP_MARKER_END2}`;
9022
9765
  }
9023
9766
  let existing;
9024
9767
  try {
9025
- existing = await readFile14(abs, "utf8");
9768
+ existing = await readFile16(abs, "utf8");
9026
9769
  } catch {
9027
9770
  continue;
9028
9771
  }
@@ -9047,11 +9790,11 @@ ${managedBlock}
9047
9790
  }
9048
9791
  await atomicWriteText4(abs, next);
9049
9792
  }
9050
- const claudeMdPath = join19(projectRoot, "CLAUDE.md");
9793
+ const claudeMdPath = join21(projectRoot, "CLAUDE.md");
9051
9794
  if (await pathExists(claudeMdPath)) {
9052
9795
  let claudeContent;
9053
9796
  try {
9054
- claudeContent = await readFile14(claudeMdPath, "utf8");
9797
+ claudeContent = await readFile16(claudeMdPath, "utf8");
9055
9798
  } catch {
9056
9799
  return;
9057
9800
  }
@@ -9097,7 +9840,7 @@ function normalizeTarget(targetInput) {
9097
9840
  return isAbsolute2(targetInput) ? targetInput : resolve3(process.cwd(), targetInput);
9098
9841
  }
9099
9842
  function normalizePath2(path) {
9100
- return posix4.normalize(path.split("\\").join("/"));
9843
+ return posix5.normalize(path.split("\\").join("/"));
9101
9844
  }
9102
9845
  async function collectEntryPoints(root) {
9103
9846
  let rootStat;
@@ -9117,7 +9860,7 @@ async function collectEntryPoints(root) {
9117
9860
  continue;
9118
9861
  }
9119
9862
  for (const entry of await readdirAsync(current, { withFileTypes: true })) {
9120
- const absolutePath = join19(current, entry.name);
9863
+ const absolutePath = join21(current, entry.name);
9121
9864
  const relativePath = normalizePath2(absolutePath.slice(root.length + 1));
9122
9865
  if (relativePath.length === 0) {
9123
9866
  continue;
@@ -9144,8 +9887,8 @@ function getEntryPointReason(relativePath) {
9144
9887
  if (!SCRIPT_EXTENSIONS.has(extension)) {
9145
9888
  return null;
9146
9889
  }
9147
- const directory = posix4.dirname(relativePath);
9148
- const fileName = posix4.basename(relativePath);
9890
+ const directory = posix5.dirname(relativePath);
9891
+ const fileName = posix5.basename(relativePath);
9149
9892
  const fileBase = fileName.slice(0, Math.max(fileName.lastIndexOf("."), 0));
9150
9893
  if (directory === "assets/scripts" || directory === "scripts") {
9151
9894
  return "top-level script";
@@ -9193,7 +9936,7 @@ async function enrichDescriptions(projectRoot, opts = {}) {
9193
9936
  scanned += 1;
9194
9937
  let source;
9195
9938
  try {
9196
- source = await readFile14(absPath, "utf8");
9939
+ source = await readFile16(absPath, "utf8");
9197
9940
  } catch {
9198
9941
  continue;
9199
9942
  }
@@ -9290,88 +10033,6 @@ function yamlQuoteIfNeeded(value) {
9290
10033
  return value;
9291
10034
  }
9292
10035
 
9293
- // src/services/conflict-lint.ts
9294
- var DEFAULT_CONFLICT_SIMILARITY_THRESHOLD = 0.5;
9295
- function groupKey(entry) {
9296
- return `${entry.layer}\0${entry.knowledge_type}`;
9297
- }
9298
- function pairSimilarity(model, a, b) {
9299
- const selfA = model.scoreDoc(a.id, a.tokens);
9300
- const selfB = model.scoreDoc(b.id, b.tokens);
9301
- if (selfA <= 0 || selfB <= 0) return 0;
9302
- const aToB = model.scoreDoc(b.id, a.tokens) / selfB;
9303
- const bToA = model.scoreDoc(a.id, b.tokens) / selfA;
9304
- const sim = Math.min(aToB, bToA);
9305
- return sim < 0 ? 0 : sim > 1 ? 1 : sim;
9306
- }
9307
- function findConflictCandidates(entries, opts = {}) {
9308
- const threshold = typeof opts.threshold === "number" && opts.threshold >= 0 && opts.threshold <= 1 ? opts.threshold : DEFAULT_CONFLICT_SIMILARITY_THRESHOLD;
9309
- const groups = /* @__PURE__ */ new Map();
9310
- for (const entry of entries) {
9311
- if (typeof entry.stable_id !== "string" || entry.stable_id.length === 0) continue;
9312
- const list = groups.get(groupKey(entry)) ?? [];
9313
- list.push(entry);
9314
- groups.set(groupKey(entry), list);
9315
- }
9316
- const pairs = [];
9317
- for (const group of groups.values()) {
9318
- if (group.length < 2) continue;
9319
- const docs = group.map((e) => ({ id: e.stable_id, tokens: buildQueryTerms(e.text) }));
9320
- const tokensById = new Map(docs.map((d) => [d.id, d.tokens]));
9321
- const model = buildBm25Model(docs);
9322
- for (let i = 0; i < group.length; i += 1) {
9323
- for (let j = i + 1; j < group.length; j += 1) {
9324
- const ea = group[i];
9325
- const eb = group[j];
9326
- const sim = pairSimilarity(
9327
- model,
9328
- { id: ea.stable_id, tokens: tokensById.get(ea.stable_id) ?? [] },
9329
- { id: eb.stable_id, tokens: tokensById.get(eb.stable_id) ?? [] }
9330
- );
9331
- if (sim < threshold) continue;
9332
- const [a, b] = ea.stable_id <= eb.stable_id ? [ea.stable_id, eb.stable_id] : [eb.stable_id, ea.stable_id];
9333
- pairs.push({
9334
- a,
9335
- b,
9336
- knowledge_type: ea.knowledge_type,
9337
- layer: ea.layer,
9338
- similarity: sim,
9339
- verdict: "unknown"
9340
- });
9341
- }
9342
- }
9343
- }
9344
- pairs.sort((x, y) => y.similarity - x.similarity || (x.a < y.a ? -1 : x.a > y.a ? 1 : x.b < y.b ? -1 : 1));
9345
- return pairs;
9346
- }
9347
- async function lintConflicts(entries, opts = {}) {
9348
- const candidates = findConflictCandidates(entries, { threshold: opts.threshold });
9349
- if (opts.judge === void 0 || candidates.length === 0) {
9350
- return candidates;
9351
- }
9352
- const byId = new Map(entries.map((e) => [e.stable_id, e]));
9353
- const judged = [];
9354
- for (const pair of candidates) {
9355
- const ea = byId.get(pair.a);
9356
- const eb = byId.get(pair.b);
9357
- if (ea === void 0 || eb === void 0) {
9358
- judged.push(pair);
9359
- continue;
9360
- }
9361
- try {
9362
- const verdict = await opts.judge(ea, eb);
9363
- judged.push({
9364
- ...pair,
9365
- verdict: verdict.isConflict ? "conflict" : "similar",
9366
- rationale: verdict.rationale
9367
- });
9368
- } catch {
9369
- judged.push(pair);
9370
- }
9371
- }
9372
- return judged;
9373
- }
9374
-
9375
10036
  // src/services/doctor-conflict.ts
9376
10037
  function stripFrontmatter(content) {
9377
10038
  if (!content.startsWith("---")) return content;
@@ -9412,6 +10073,91 @@ async function runDoctorConflictLint(projectRoot, opts = {}) {
9412
10073
  };
9413
10074
  }
9414
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
+
9415
10161
  // src/services/summary-cold-eval.ts
9416
10162
  var COLD_EVAL_RUBRIC = [
9417
10163
  "You are a ZERO-CONTEXT judge. You are shown ONLY a one-line knowledge summary \u2014",
@@ -9465,8 +10211,8 @@ import { agentsMetaSchema as agentsMetaSchema3 } from "@fenglimg/fabric-shared";
9465
10211
  import { IOFabricError as IOFabricError2, RuleError } from "@fenglimg/fabric-shared/errors";
9466
10212
 
9467
10213
  // src/services/read-ledger.ts
9468
- import { randomUUID as randomUUID6 } from "crypto";
9469
- import { access as access5, copyFile, readFile as readFile15, rm } from "fs/promises";
10214
+ import { randomUUID as randomUUID7 } from "crypto";
10215
+ import { access as access5, copyFile, readFile as readFile18, rm } from "fs/promises";
9470
10216
  import { ledgerEntrySchema } from "@fenglimg/fabric-shared";
9471
10217
  async function resolveLedgerPaths(projectRoot) {
9472
10218
  const primaryPath = getLedgerPath(projectRoot);
@@ -9494,7 +10240,7 @@ async function readLegacyLedger(projectRoot) {
9494
10240
  const { readPath } = await resolveLedgerPaths(projectRoot);
9495
10241
  let raw;
9496
10242
  try {
9497
- raw = await readFile15(readPath, "utf8");
10243
+ raw = await readFile18(readPath, "utf8");
9498
10244
  } catch (error) {
9499
10245
  if (isNodeError(error) && error.code === "ENOENT") {
9500
10246
  return [];
@@ -9749,26 +10495,27 @@ function formatError(error) {
9749
10495
  }
9750
10496
  function formatPreexistingRootMessage(projectRoot) {
9751
10497
  const preexisting = [];
9752
- if (existsSync8(join20(projectRoot, "CLAUDE.md"))) preexisting.push("CLAUDE.md");
9753
- if (existsSync8(join20(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");
9754
10500
  if (preexisting.length === 0) return null;
9755
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.`;
9756
10502
  }
9757
10503
  var FABRIC_SERVER_INSTRUCTIONS = [
9758
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.",
9759
10505
  "",
10506
+ "AGENT-DIRECT tools \u2014 call these yourself, inline, as you work:",
9760
10507
  "Retrieval \u2014 do this BEFORE you edit code or commit to a decision:",
9761
- "- 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).",
9762
- "- 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.",
9763
10510
  "",
9764
- "Tools:",
9765
- "- `fab_recall` \u2014 one-shot KB recall: descriptions + read paths for the given files.",
9766
- "- `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.",
9767
10513
  "- `fab_archive_scan` \u2014 scan recent work for archive-worthy knowledge candidates.",
9768
- "- `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).",
9769
10516
  "",
9770
10517
  "Conventions:",
9771
- "- 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.",
9772
10519
  "- Pass the client `session_id` to `fab_recall` so cross-session knowledge-debt tracking stays accurate.",
9773
10520
  "- Cite the KB id you applied or dismissed before edits, per the project's cite policy."
9774
10521
  ].join("\n");
@@ -9776,7 +10523,7 @@ function createFabricServer(tracker) {
9776
10523
  const server = new McpServer(
9777
10524
  {
9778
10525
  name: "fabric-knowledge-server",
9779
- version: "2.2.0-rc.9"
10526
+ version: "2.3.0-rc.1"
9780
10527
  },
9781
10528
  {
9782
10529
  instructions: FABRIC_SERVER_INSTRUCTIONS
@@ -9786,6 +10533,7 @@ function createFabricServer(tracker) {
9786
10533
  registerArchiveScan(server, tracker);
9787
10534
  registerExtractKnowledge(server, tracker);
9788
10535
  registerReview(server, tracker);
10536
+ registerPending(server, tracker);
9789
10537
  server.registerResource(
9790
10538
  "bootstrap README",
9791
10539
  AGENTS_MD_RESOURCE_URI,
@@ -9795,10 +10543,10 @@ function createFabricServer(tracker) {
9795
10543
  },
9796
10544
  async (_uri) => {
9797
10545
  const projectRoot = process.env.FABRIC_PROJECT_ROOT ?? process.cwd();
9798
- const path = join20(projectRoot, ".fabric", "bootstrap", "README.md");
10546
+ const path = join23(projectRoot, ".fabric", "bootstrap", "README.md");
9799
10547
  let text = "";
9800
- if (existsSync8(path)) {
9801
- text = await readFile16(path, "utf8");
10548
+ if (existsSync9(path)) {
10549
+ text = await readFile19(path, "utf8");
9802
10550
  }
9803
10551
  return {
9804
10552
  contents: [
@@ -9896,6 +10644,7 @@ export {
9896
10644
  LEGACY_LEDGER_PATH,
9897
10645
  METRICS_LEDGER_PATH,
9898
10646
  METRIC_COUNTER_NAMES,
10647
+ RETIRED_TOKENS,
9899
10648
  appendEventLedgerEvent,
9900
10649
  buildAlwaysActiveBodies,
9901
10650
  buildColdEvalBatch,
@@ -9908,6 +10657,7 @@ export {
9908
10657
  detectUnboundProject,
9909
10658
  drainCounters,
9910
10659
  enrichDescriptions,
10660
+ explainWhyNotSurfaced,
9911
10661
  extractKnowledge,
9912
10662
  findConflictCandidates,
9913
10663
  flushAndSyncEventLedger,
@@ -9917,6 +10667,7 @@ export {
9917
10667
  getLedgerPath,
9918
10668
  getLegacyLedgerPath,
9919
10669
  getMetricsLedgerPath,
10670
+ inspectRetiredReferences,
9920
10671
  lintConflicts,
9921
10672
  loadConflictEntries,
9922
10673
  pairSimilarity,
@@ -9929,6 +10680,7 @@ export {
9929
10680
  rehydrateAgentsMetaAt,
9930
10681
  resolveLedgerPaths,
9931
10682
  reviewKnowledge,
10683
+ reviewPending,
9932
10684
  runDoctorApplyLint,
9933
10685
  runDoctorArchiveHistory,
9934
10686
  runDoctorCiteCoverage,