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

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 existsSync10 } from "fs";
2
+ import { existsSync as existsSync11 } from "fs";
3
3
  import { readFile as readFile22 } from "fs/promises";
4
- import { join as join27, resolve as resolve4 } from "path";
4
+ import { join as join28, 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";
@@ -10,8 +10,9 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
10
10
  var AGENTS_MD_RESOURCE_URI = "fabric://bootstrap-readme";
11
11
 
12
12
  // src/meta-reader.ts
13
+ import { existsSync } from "fs";
13
14
  import { readFile } from "fs/promises";
14
- import { join } from "path";
15
+ import { dirname, join } from "path";
15
16
  import { agentsMetaSchema } from "@fenglimg/fabric-shared";
16
17
  import { IOFabricError } from "@fenglimg/fabric-shared/errors";
17
18
 
@@ -98,8 +99,22 @@ var contextCache = new ContextCache(5e3);
98
99
 
99
100
  // src/meta-reader.ts
100
101
  import { agentsMetaSchema as agentsMetaSchema2 } from "@fenglimg/fabric-shared";
101
- function resolveProjectRoot() {
102
- return process.env.FABRIC_PROJECT_ROOT ?? process.cwd();
102
+ function resolveProjectRoot(startCwd) {
103
+ const envOverride = process.env.FABRIC_PROJECT_ROOT;
104
+ if (typeof envOverride === "string" && envOverride.length > 0) return envOverride;
105
+ const claudeRoot = process.env.CLAUDE_PROJECT_DIR;
106
+ if (typeof claudeRoot === "string" && claudeRoot.length > 0) return claudeRoot;
107
+ const start = typeof startCwd === "string" && startCwd.length > 0 ? startCwd : process.cwd();
108
+ let dir = start;
109
+ let firstFabric = null;
110
+ for (let i = 0; i < 64; i++) {
111
+ if (existsSync(join(dir, ".git"))) return dir;
112
+ if (firstFabric === null && existsSync(join(dir, ".fabric"))) firstFabric = dir;
113
+ const parent = dirname(dir);
114
+ if (parent === dir) break;
115
+ dir = parent;
116
+ }
117
+ return firstFabric ?? start;
103
118
  }
104
119
 
105
120
  // src/services/event-ledger.ts
@@ -107,7 +122,7 @@ import { randomUUID } from "crypto";
107
122
  import {
108
123
  closeSync,
109
124
  createReadStream,
110
- existsSync,
125
+ existsSync as existsSync2,
111
126
  fsyncSync,
112
127
  openSync,
113
128
  readFileSync,
@@ -127,7 +142,7 @@ import {
127
142
  } from "@fenglimg/fabric-shared/node/atomic-write";
128
143
 
129
144
  // src/services/_shared.ts
130
- import { dirname, join as join2, resolve, sep } from "path";
145
+ import { dirname as dirname2, join as join2, resolve, sep } from "path";
131
146
  import { createHash } from "crypto";
132
147
  import { mkdir } from "fs/promises";
133
148
  import { PathEscapeError } from "@fenglimg/fabric-shared/errors";
@@ -165,7 +180,7 @@ function extractBody(content) {
165
180
  return content.slice(match[0].length);
166
181
  }
167
182
  async function ensureParentDirectory(path) {
168
- await mkdir(dirname(path), { recursive: true });
183
+ await mkdir(dirname2(path), { recursive: true });
169
184
  }
170
185
  function sha256(content) {
171
186
  return `sha256:${createHash("sha256").update(content).digest("hex")}`;
@@ -606,7 +621,7 @@ function resolveRetentionDays(projectRoot, override) {
606
621
  }
607
622
  const configPath = join3(projectRoot, ".fabric", "fabric-config.json");
608
623
  try {
609
- if (existsSync(configPath)) {
624
+ if (existsSync2(configPath)) {
610
625
  const raw = readFileSync(configPath, "utf8");
611
626
  const parsed = JSON.parse(raw);
612
627
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
@@ -628,7 +643,7 @@ function formatUtcDate(date) {
628
643
  }
629
644
  function flushAndSyncEventLedger(projectRoot) {
630
645
  const ledgerPath = getEventLedgerPath(projectRoot);
631
- if (!existsSync(ledgerPath)) return;
646
+ if (!existsSync2(ledgerPath)) return;
632
647
  const fd = openSync(ledgerPath, "r+");
633
648
  try {
634
649
  fsyncSync(fd);
@@ -700,14 +715,14 @@ function appendPayloadWarning(warnings, guardResult, actionHint) {
700
715
  }
701
716
 
702
717
  // src/config-loader.ts
703
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
718
+ import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
704
719
  import { join as join4 } from "path";
705
720
  import { selectionTokenTtlMsSchema, planContextTopKSchema } from "@fenglimg/fabric-shared";
706
721
  var PLAN_CONTEXT_TOP_K_DEFAULT = 24;
707
722
  var RECALL_RELEVANCE_RATIO_DEFAULT = 0.25;
708
723
  function readFabricConfig(projectRoot) {
709
724
  const configPath = join4(projectRoot, ".fabric", "fabric-config.json");
710
- if (!existsSync2(configPath)) {
725
+ if (!existsSync3(configPath)) {
711
726
  return {};
712
727
  }
713
728
  const parsed = JSON.parse(readFileSync2(configPath, "utf8"));
@@ -887,7 +902,7 @@ function readFusion(projectRoot) {
887
902
  function readConflictLintThreshold(projectRoot) {
888
903
  try {
889
904
  const cfgPath = join4(projectRoot, ".fabric", "fabric-config.json");
890
- if (!existsSync2(cfgPath)) return void 0;
905
+ if (!existsSync3(cfgPath)) return void 0;
891
906
  const parsed = JSON.parse(readFileSync2(cfgPath, "utf8"));
892
907
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
893
908
  const v = parsed.conflict_lint_similarity_threshold;
@@ -950,7 +965,7 @@ async function awaitFirstReconcileGate(timeoutMs = 5e3) {
950
965
  }
951
966
 
952
967
  // src/services/extract-knowledge.ts
953
- import { existsSync as existsSync3 } from "fs";
968
+ import { existsSync as existsSync4 } from "fs";
954
969
  import { readFile as readFile5 } from "fs/promises";
955
970
  import { join as join7 } from "path";
956
971
  import {
@@ -1340,6 +1355,7 @@ async function readSetFingerprint(refs) {
1340
1355
  return parts.sort().join("\n");
1341
1356
  }
1342
1357
  var SEMANTIC_SCOPE_LINE = /^semantic_scope:\s*"?([^"\n]+?)"?\s*$/mu;
1358
+ var DEPRECATED_LINE = /^deprecated:\s*true\s*$/mu;
1343
1359
  function readSemanticScope(source, layer, project) {
1344
1360
  if (layer === "personal") {
1345
1361
  return "personal";
@@ -1359,6 +1375,9 @@ function filterByActiveProject(entries, activeProject) {
1359
1375
  (e) => scopeRoot(e.semanticScope) !== "project" || e.semanticScope === current
1360
1376
  );
1361
1377
  }
1378
+ function filterOutDeprecated(entries) {
1379
+ return entries.filter((entry) => !entry.deprecated);
1380
+ }
1362
1381
  function activeProjectOf(projectRoot) {
1363
1382
  const ap = loadProjectConfig2(projectRoot)?.active_project;
1364
1383
  return ap !== void 0 && ap.length > 0 ? ap : void 0;
@@ -1426,6 +1445,7 @@ async function walkReadSetStoresUncached(snapshot) {
1426
1445
  alias: ref.alias,
1427
1446
  layer,
1428
1447
  semanticScope: readSemanticScope(source, layer, ref.project),
1448
+ deprecated: DEPRECATED_LINE.test(source),
1429
1449
  source
1430
1450
  };
1431
1451
  }));
@@ -1434,7 +1454,9 @@ async function walkReadSetStoresUncached(snapshot) {
1434
1454
  async function buildCrossStoreRawItems(projectRoot) {
1435
1455
  const items = [];
1436
1456
  const activeProject = activeProjectOf(projectRoot);
1437
- for (const entry of filterByActiveProject(await walkReadSetStores(projectRoot), activeProject)) {
1457
+ for (const entry of filterOutDeprecated(
1458
+ filterByActiveProject(await walkReadSetStores(projectRoot), activeProject)
1459
+ )) {
1438
1460
  const baseDescription = extractRuleDescription(entry.source);
1439
1461
  if (baseDescription === void 0) {
1440
1462
  continue;
@@ -1474,7 +1496,7 @@ async function buildKnowledgeCensus(projectRoot) {
1474
1496
  };
1475
1497
  try {
1476
1498
  const activeProject = activeProjectOf(projectRoot);
1477
- const all = await walkReadSetStores(projectRoot);
1499
+ const all = filterOutDeprecated(await walkReadSetStores(projectRoot));
1478
1500
  const kept = filterByActiveProject(all, activeProject);
1479
1501
  census.dropped_other_project = all.length - kept.length;
1480
1502
  for (const entry of kept) {
@@ -1503,9 +1525,8 @@ async function buildAlwaysActiveBodies(projectRoot) {
1503
1525
  const out = [];
1504
1526
  try {
1505
1527
  const activeProject = activeProjectOf(projectRoot);
1506
- for (const entry of filterByActiveProject(
1507
- await walkReadSetStores(projectRoot),
1508
- activeProject
1528
+ for (const entry of filterOutDeprecated(
1529
+ filterByActiveProject(await walkReadSetStores(projectRoot), activeProject)
1509
1530
  )) {
1510
1531
  const desc = extractRuleDescription(entry.source);
1511
1532
  if (desc === void 0) continue;
@@ -2223,7 +2244,7 @@ async function extractKnowledge(projectRoot, input) {
2223
2244
  const effectiveIdempotencyKey = chosenKey;
2224
2245
  const writeScopeMeta = resolveWriteScopeMeta(layer, projectRoot, semanticScope);
2225
2246
  await ensureParentDirectory(absolutePath);
2226
- if (existsSync3(absolutePath)) {
2247
+ if (existsSync4(absolutePath)) {
2227
2248
  const existing = await readFile5(absolutePath, "utf8");
2228
2249
  const existingKey = readFrontmatterKey(existing, "x-fabric-idempotency-key");
2229
2250
  if (existingKey === effectiveIdempotencyKey) {
@@ -2350,7 +2371,7 @@ async function resolveDisambiguatedSlugPath(args) {
2350
2371
  slug: candidateSlug
2351
2372
  })
2352
2373
  );
2353
- if (!existsSync3(candidatePath)) {
2374
+ if (!existsSync4(candidatePath)) {
2354
2375
  return {
2355
2376
  absolutePath: candidatePath,
2356
2377
  sanitizedSlug: candidateSlug,
@@ -2589,11 +2610,11 @@ function readFrontmatterKey(content, key) {
2589
2610
  }
2590
2611
  for (const rawLine of block.split(/\r?\n/u)) {
2591
2612
  const line = rawLine.trim();
2592
- const sep5 = line.indexOf(":");
2593
- if (sep5 === -1) continue;
2594
- const k = line.slice(0, sep5).trim();
2613
+ const sep6 = line.indexOf(":");
2614
+ if (sep6 === -1) continue;
2615
+ const k = line.slice(0, sep6).trim();
2595
2616
  if (k === key) {
2596
- return line.slice(sep5 + 1).trim();
2617
+ return line.slice(sep6 + 1).trim();
2597
2618
  }
2598
2619
  }
2599
2620
  return void 0;
@@ -2605,6 +2626,58 @@ async function emitEventBestEffort(projectRoot, event) {
2605
2626
  }
2606
2627
  }
2607
2628
 
2629
+ // src/services/doctor-unbound-project.ts
2630
+ import { loadProjectConfig as loadProjectConfig3 } from "@fenglimg/fabric-shared";
2631
+ function detectUnboundProject(projectRoot) {
2632
+ const config = loadProjectConfig3(projectRoot);
2633
+ const alias = config?.active_write_store;
2634
+ if (typeof alias !== "string" || alias.length === 0) {
2635
+ return null;
2636
+ }
2637
+ const missing = [];
2638
+ if (typeof config?.project_id !== "string" || config.project_id.length === 0) {
2639
+ missing.push("project_id");
2640
+ }
2641
+ if (typeof config?.active_project !== "string" || config.active_project.length === 0) {
2642
+ missing.push("active_project");
2643
+ }
2644
+ return missing.length > 0 ? { alias, missing } : null;
2645
+ }
2646
+ function createUnboundProjectCheck(t, violation) {
2647
+ if (violation === null) {
2648
+ return {
2649
+ name: t("doctor.check.unbound_project.name"),
2650
+ status: "ok",
2651
+ message: t("doctor.check.unbound_project.ok")
2652
+ };
2653
+ }
2654
+ return {
2655
+ name: t("doctor.check.unbound_project.name"),
2656
+ status: "warn",
2657
+ kind: "warning",
2658
+ code: "unbound_project",
2659
+ fixable: false,
2660
+ message: t("doctor.check.unbound_project.message", {
2661
+ alias: violation.alias,
2662
+ missing: violation.missing.join(" + ")
2663
+ }),
2664
+ actionHint: t("doctor.check.unbound_project.remediation")
2665
+ };
2666
+ }
2667
+
2668
+ // src/services/write-scope-warning.ts
2669
+ function unsealedProjectScopeWarning(projectRoot) {
2670
+ const violation = detectUnboundProject(projectRoot);
2671
+ if (violation === null) {
2672
+ return null;
2673
+ }
2674
+ return {
2675
+ code: "project_scope_unsealed",
2676
+ file: "<response>",
2677
+ action_hint: `Write store '${violation.alias}' is bound but this repo is missing ${violation.missing.join(", ")} \u2014 team-layer entries will land flat under knowledge/<type>/ (semantic_scope: team) instead of the project-partitioned knowledge/projects/<id>/<type>/. Run \`fabric doctor --fix\` to seal the project coordinate, or ignore if this is genuinely cross-project team knowledge.`
2678
+ };
2679
+ }
2680
+
2608
2681
  // src/tools/extract-knowledge.ts
2609
2682
  function registerExtractKnowledge(server, tracker) {
2610
2683
  server.registerTool(
@@ -2642,6 +2715,10 @@ function registerExtractKnowledge(server, tracker) {
2642
2715
  if (gateWarn) {
2643
2716
  response.warnings = [gateWarn];
2644
2717
  }
2718
+ const scopeWarn = unsealedProjectScopeWarning(projectRoot);
2719
+ if (scopeWarn) {
2720
+ response.warnings = [...response.warnings ?? [], scopeWarn];
2721
+ }
2645
2722
  const payloadLimits = readPayloadLimits(projectRoot);
2646
2723
  const serialized = JSON.stringify(response);
2647
2724
  const guardResult = enforcePayloadLimit(serialized, payloadLimits);
@@ -3271,10 +3348,10 @@ async function planContext(projectRoot, input) {
3271
3348
  });
3272
3349
  }
3273
3350
  const topKIds = new Set(topKCandidates.map((item) => item.stable_id));
3274
- const retrievalDropped = rankedCandidates.filter((item) => !topKIds.has(item.stable_id)).map((item) => ({ id: item.stable_id, reason: "retrieval_budget" }));
3275
- const omittedCandidateCount = retrievalDropped.length;
3351
+ const retrievalDroppedInitial = rankedCandidates.filter((item) => !topKIds.has(item.stable_id)).map((item) => ({ id: item.stable_id, reason: "retrieval_budget" }));
3276
3352
  let candidates = topKCandidates;
3277
3353
  const relatedAppended = {};
3354
+ const appendedIds = /* @__PURE__ */ new Set();
3278
3355
  if (input.include_related === true) {
3279
3356
  const inTopK = new Set(topKCandidates.flatMap((item) => relatedLookupKeys(item.stable_id)));
3280
3357
  const rankedById = /* @__PURE__ */ new Map();
@@ -3286,7 +3363,6 @@ async function planContext(projectRoot, input) {
3286
3363
  }
3287
3364
  }
3288
3365
  const appended = [];
3289
- const appendedIds = /* @__PURE__ */ new Set();
3290
3366
  for (const surfaced of topKCandidates) {
3291
3367
  for (const rel of surfaced.description.related ?? []) {
3292
3368
  if (inTopK.has(rel)) continue;
@@ -3302,6 +3378,8 @@ async function planContext(projectRoot, input) {
3302
3378
  candidates = [...topKCandidates, ...appended];
3303
3379
  }
3304
3380
  }
3381
+ const retrievalDropped = retrievalDroppedInitial.filter((d) => !appendedIds.has(d.id));
3382
+ const omittedCandidateCount = retrievalDropped.length;
3305
3383
  const entries = uniquePaths.map((path) => ({
3306
3384
  path,
3307
3385
  requirement_profile: buildRequirementProfile(path, input)
@@ -3882,7 +3960,6 @@ function relatedLookupKeys(stableId) {
3882
3960
  }
3883
3961
 
3884
3962
  // src/services/recall.ts
3885
- var RECALL_DIRECTIVE = "These entries are auto-accounted as citations for edits whose paths overlap this recall \u2014 no first-line cite needed. Speak up only to dismiss one you judge inapplicable: `dismissed: <id> (<reason>)`.";
3886
3963
  async function recall(projectRoot, input) {
3887
3964
  const planResult = await planContext(projectRoot, input);
3888
3965
  const {
@@ -3938,25 +4015,51 @@ async function recall(projectRoot, input) {
3938
4015
  }
3939
4016
  const nextSteps = buildNextSteps(planResult, paths, candidateById, candidateLookup);
3940
4017
  const pathByStableId = new Map(paths.map((p) => [p.stable_id, p]));
3941
- const entries = planRest.candidates.map((c, index) => {
4018
+ const entries = planRest.candidates.map((c) => {
3942
4019
  const readPath = pathByStableId.get(c.stable_id);
3943
4020
  const scored = candidateScores?.get(c.stable_id);
3944
4021
  return {
3945
4022
  stable_id: c.stable_id,
3946
- rank: index + 1,
3947
4023
  description: slimDescription(c.description),
3948
4024
  ...readPath ? { read_path: readPath.path } : {},
3949
- ...readPath?.store ? { store: readPath.store } : {},
4025
+ // TASK-004: flatten { alias } alias string on the wire.
4026
+ ...readPath?.store ? { store_alias: readPath.store.alias } : {},
3950
4027
  ...isAlwaysActive(c) ? { body_in_context: true } : {},
3951
- ...scored ? { score: scored.score, score_breakdown: scored.score_breakdown } : {}
4028
+ // TASK-004: entry.score dropped (final===score invariant, KT-PIT-0036);
4029
+ // consumers read score_breakdown.final when opt-in enabled.
4030
+ // TASK-006: score_breakdown is now opt-in via include_score_breakdown —
4031
+ // steady-state recall omits it (~4.8KB saved on a 24-entry sample). The
4032
+ // plan-context service layer still writes candidate_scores unconditionally,
4033
+ // so the debug surface is available on demand without perturbing ranking.
4034
+ ...scored && input.include_score_breakdown === true ? { score_breakdown: scored.score_breakdown } : {}
3952
4035
  };
3953
4036
  });
3954
- const { entries: _reqProfiles, candidates: _candidates, ...planRestNoLists } = planRest;
4037
+ const {
4038
+ entries: _reqProfiles,
4039
+ candidates: _candidates,
4040
+ stale: _stale,
4041
+ intent: _intent,
4042
+ dropped: droppedList,
4043
+ preflight_diagnostics: preflightList,
4044
+ ...planRestNoLists
4045
+ } = planRest;
4046
+ const retrievalDroppedCount = (droppedList ?? []).filter(
4047
+ (d) => d.reason === "retrieval_budget"
4048
+ ).length;
4049
+ const payloadDroppedCount = (droppedList ?? []).filter((d) => d.reason === "payload_budget").length;
4050
+ const droppedIds = (droppedList ?? []).map((d) => d.id);
3955
4051
  return {
3956
4052
  ...planRestNoLists,
3957
4053
  entries,
3958
- directive: RECALL_DIRECTIVE,
3959
- ...nextSteps.length > 0 ? { next_steps: nextSteps } : {}
4054
+ ...nextSteps.length > 0 ? { next_steps: nextSteps } : {},
4055
+ ...droppedIds.length > 0 ? { dropped_ids: droppedIds } : {},
4056
+ ...retrievalDroppedCount > 0 || payloadDroppedCount > 0 ? {
4057
+ dropped_reasons: {
4058
+ ...retrievalDroppedCount > 0 ? { retrieval_budget: retrievalDroppedCount } : {},
4059
+ ...payloadDroppedCount > 0 ? { payload_budget: payloadDroppedCount } : {}
4060
+ }
4061
+ } : {},
4062
+ ...preflightList !== void 0 && preflightList.length > 0 ? { preflight_diagnostics: preflightList } : {}
3960
4063
  };
3961
4064
  }
3962
4065
  var ALWAYS_ACTIVE_TYPES2 = /* @__PURE__ */ new Set(["models", "guidelines"]);
@@ -3967,8 +4070,8 @@ function isAlwaysActive(candidate) {
3967
4070
  function slimDescription(d) {
3968
4071
  return {
3969
4072
  summary: d.summary,
3970
- must_read_if: d.must_read_if,
3971
- intent_clues: d.intent_clues,
4073
+ ...d.must_read_if !== d.summary ? { must_read_if: d.must_read_if } : {},
4074
+ ...Array.isArray(d.impact) && d.impact.length > 0 ? { impact: d.impact } : {},
3972
4075
  ...d.knowledge_type !== void 0 ? { knowledge_type: d.knowledge_type } : {}
3973
4076
  };
3974
4077
  }
@@ -4010,7 +4113,7 @@ function registerRecall(server, tracker) {
4010
4113
  server.registerTool(
4011
4114
  "fab_recall",
4012
4115
  {
4013
- 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.",
4116
+ 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 (best-first \u2014 array index IS the rank). Each entry carries `description.summary` (always) plus optional `description.must_read_if` (omitted when identical to summary), `description.impact` (\u26A0\uFE0F consequence hints), `description.knowledge_type`, `read_path` (on-disk file for the body), `store_alias`, and `body_in_context:true` when the body is already injected at SessionStart. Full frontmatter fields (intent_clues / tech_stack / related / tags / relevance_paths) are NOT on the wire \u2014 Read the `read_path` to load them. Pass `ids` to scope surfaced read_paths, `include_related:true` to append one-hop neighbours, `include_score_breakdown:true` for numeric ranking diagnostics.",
4014
4117
  inputSchema: recallInputSchema,
4015
4118
  outputSchema: recallOutputSchema,
4016
4119
  annotations: recallAnnotations
@@ -4026,7 +4129,8 @@ function registerRecall(server, tracker) {
4026
4129
  target_paths,
4027
4130
  layer_filter,
4028
4131
  ids,
4029
- include_related
4132
+ include_related,
4133
+ include_score_breakdown
4030
4134
  }) => {
4031
4135
  const requestId = randomUUID3();
4032
4136
  const startedAt = process.hrtime.bigint();
@@ -4050,7 +4154,8 @@ function registerRecall(server, tracker) {
4050
4154
  // declared layer restriction instead of silently discarding it.
4051
4155
  layer_filter,
4052
4156
  ids,
4053
- include_related
4157
+ include_related,
4158
+ include_score_breakdown
4054
4159
  };
4055
4160
  const result = await recall(projectRoot, input);
4056
4161
  const response = {
@@ -4269,15 +4374,15 @@ import { enforcePayloadLimit as enforcePayloadLimit4 } from "@fenglimg/fabric-sh
4269
4374
 
4270
4375
  // src/services/review.ts
4271
4376
  import { execFileSync } from "child_process";
4272
- import { existsSync as existsSync5 } from "fs";
4377
+ import { existsSync as existsSync6 } from "fs";
4273
4378
  import { readFile as readFile9, readdir as readdir2, stat as stat2, unlink as unlink2 } from "fs/promises";
4274
4379
  import { homedir } from "os";
4275
4380
  import { basename, isAbsolute, join as join12, relative, resolve as resolve2, sep as sep2 } from "path";
4276
4381
 
4277
4382
  // src/services/promotion-gate.ts
4278
4383
  function toLocalId(id) {
4279
- const sep5 = id.indexOf(":");
4280
- return sep5 === -1 ? id : id.slice(sep5 + 1);
4384
+ const sep6 = id.indexOf(":");
4385
+ return sep6 === -1 ? id : id.slice(sep6 + 1);
4281
4386
  }
4282
4387
  async function hasUnresolvedDismissal(projectRoot, id) {
4283
4388
  const target = toLocalId(id);
@@ -4308,10 +4413,10 @@ async function hasUnresolvedDismissal(projectRoot, id) {
4308
4413
  }
4309
4414
 
4310
4415
  // src/services/review.ts
4311
- import { allocateStoreKnowledgeId, isPersonalScope as isPersonalScope2, loadProjectConfig as loadProjectConfig3 } from "@fenglimg/fabric-shared";
4416
+ import { allocateStoreKnowledgeId, isPersonalScope as isPersonalScope2, loadProjectConfig as loadProjectConfig4 } from "@fenglimg/fabric-shared";
4312
4417
 
4313
4418
  // src/services/pending-dedupe.ts
4314
- import { existsSync as existsSync4 } from "fs";
4419
+ import { existsSync as existsSync5 } from "fs";
4315
4420
  import { readdir, readFile as readFile8, unlink } from "fs/promises";
4316
4421
  import { join as join11 } from "path";
4317
4422
  var PENDING_TYPES = ["decisions", "pitfalls", "guidelines", "models", "processes"];
@@ -4387,7 +4492,7 @@ async function mergePendingTwins(projectRoot) {
4387
4492
  }
4388
4493
  for (const type of PENDING_TYPES) {
4389
4494
  const dir = join11(pendingBase2, type);
4390
- if (!existsSync4(dir)) continue;
4495
+ if (!existsSync5(dir)) continue;
4391
4496
  let names;
4392
4497
  try {
4393
4498
  names = (await readdir(dir)).filter((n) => n.endsWith(".md"));
@@ -4484,6 +4589,14 @@ async function reviewKnowledge(projectRoot, input) {
4484
4589
  }
4485
4590
  case "modify-layer":
4486
4591
  return await modifyEntry(projectRoot, input.pending_path, input.changes);
4592
+ // v2.3 batch content-modify: the array-native flush path for the
4593
+ // fabric-review maintain loop. Collapses N per-item modify round-trips
4594
+ // (each paying a first-reconcile gate wait) into one call.
4595
+ case "modify-content-batch":
4596
+ return {
4597
+ action: "modify-content-batch",
4598
+ modified: await modifyContentBatch(projectRoot, input.items)
4599
+ };
4487
4600
  case "defer":
4488
4601
  return {
4489
4602
  action: "defer",
@@ -4494,6 +4607,19 @@ async function reviewKnowledge(projectRoot, input) {
4494
4607
  input.reason
4495
4608
  )
4496
4609
  };
4610
+ // retire (W3-C): mark canonical entries deprecated in place. Same in-place
4611
+ // frontmatter-merge path as modify (resolveModifyTarget + rewriteFrontmatterMerge),
4612
+ // batched over pending_paths like reject/defer. Never deletes a file.
4613
+ case "retire":
4614
+ return {
4615
+ action: "retire",
4616
+ retired: await retireAll(
4617
+ projectRoot,
4618
+ input.pending_paths,
4619
+ input.superseded_by,
4620
+ input.reason
4621
+ )
4622
+ };
4497
4623
  default: {
4498
4624
  const exhaustive = input;
4499
4625
  throw new Error(`unsupported action: ${JSON.stringify(exhaustive)}`);
@@ -4591,7 +4717,7 @@ async function listPending(projectRoot, filters) {
4591
4717
  for (const source of sources) {
4592
4718
  for (const type of typesToScan) {
4593
4719
  const dir = join12(source.root, type);
4594
- if (!existsSync5(dir)) {
4720
+ if (!existsSync6(dir)) {
4595
4721
  continue;
4596
4722
  }
4597
4723
  let entries;
@@ -4729,7 +4855,7 @@ async function approveOne(projectRoot, pendingPath) {
4729
4855
  );
4730
4856
  allocatedId = stableId;
4731
4857
  const newFilename = `${stableId}--${slug}.md`;
4732
- const promoteProject = layer === "team" ? loadProjectConfig3(projectRoot)?.active_project : void 0;
4858
+ const promoteProject = layer === "team" ? loadProjectConfig4(projectRoot)?.active_project : void 0;
4733
4859
  targetAbs = join12(
4734
4860
  resolveStoreCanonicalBase(layer, projectRoot, promoteProject),
4735
4861
  pluralType,
@@ -4743,7 +4869,7 @@ async function approveOne(projectRoot, pendingPath) {
4743
4869
  await atomicWriteText(targetAbs, rewritten);
4744
4870
  writtenTarget = true;
4745
4871
  if (sourceIsStore) {
4746
- if (existsSync5(sourceAbs)) {
4872
+ if (existsSync6(sourceAbs)) {
4747
4873
  await unlink2(sourceAbs);
4748
4874
  }
4749
4875
  } else if (sourceOrigin === "team") {
@@ -4753,12 +4879,12 @@ async function approveOne(projectRoot, pendingPath) {
4753
4879
  stdio: ["ignore", "pipe", "pipe"]
4754
4880
  });
4755
4881
  } catch {
4756
- if (existsSync5(sourceAbs)) {
4882
+ if (existsSync6(sourceAbs)) {
4757
4883
  await unlink2(sourceAbs);
4758
4884
  }
4759
4885
  }
4760
4886
  } else {
4761
- if (existsSync5(sourceAbs)) {
4887
+ if (existsSync6(sourceAbs)) {
4762
4888
  await unlink2(sourceAbs);
4763
4889
  }
4764
4890
  }
@@ -4770,7 +4896,7 @@ async function approveOne(projectRoot, pendingPath) {
4770
4896
  });
4771
4897
  return { pending_path: pendingPath, stable_id: stableId };
4772
4898
  } catch (err) {
4773
- if (writtenTarget && targetAbs !== void 0 && existsSync5(targetAbs)) {
4899
+ if (writtenTarget && targetAbs !== void 0 && existsSync6(targetAbs)) {
4774
4900
  try {
4775
4901
  await unlink2(targetAbs);
4776
4902
  } catch {
@@ -4791,7 +4917,7 @@ async function rejectAll(projectRoot, pendingPaths, reason) {
4791
4917
  for (const pendingPath of pendingPaths) {
4792
4918
  try {
4793
4919
  const sandboxed = resolveSandboxedPath(projectRoot, pendingPath, { allowPersonal: true });
4794
- if (existsSync5(sandboxed.abs)) {
4920
+ if (existsSync6(sandboxed.abs)) {
4795
4921
  const content = await readFile9(sandboxed.abs, "utf8");
4796
4922
  const merged = rewriteFrontmatterMerge(content, { status: "rejected" });
4797
4923
  const rejectedAbs = sandboxed.abs.includes(`${sep2}pending${sep2}`) ? sandboxed.abs.replace(`${sep2}pending${sep2}`, `${sep2}rejected${sep2}`) : null;
@@ -4860,6 +4986,23 @@ async function modifyEntry(projectRoot, pendingPath, changes) {
4860
4986
  pending_path: pendingPath
4861
4987
  };
4862
4988
  }
4989
+ async function modifyContentBatch(projectRoot, items) {
4990
+ const results = [];
4991
+ for (const item of items) {
4992
+ const { layer: _droppedLayer, ...contentChanges } = item.changes;
4993
+ try {
4994
+ await modifyEntry(projectRoot, item.pending_path, contentChanges);
4995
+ results.push({ pending_path: item.pending_path, ok: true });
4996
+ } catch (error) {
4997
+ results.push({
4998
+ pending_path: item.pending_path,
4999
+ ok: false,
5000
+ error: error instanceof Error ? error.message : String(error)
5001
+ });
5002
+ }
5003
+ }
5004
+ return results;
5005
+ }
4863
5006
  function pickModifyEventValues(source, fields) {
4864
5007
  const out = {};
4865
5008
  for (const field of fields) {
@@ -4874,7 +5017,7 @@ function resolveModifyTarget(projectRoot, pendingPath) {
4874
5017
  } catch {
4875
5018
  return null;
4876
5019
  }
4877
- if (existsSync5(sandboxed.abs)) {
5020
+ if (existsSync6(sandboxed.abs)) {
4878
5021
  return {
4879
5022
  absPath: sandboxed.abs,
4880
5023
  isInProjectTree: sandboxed.isInProjectTree,
@@ -4921,7 +5064,7 @@ async function modifyLayerFlip(projectRoot, target, content, fm, changes) {
4921
5064
  pluralType,
4922
5065
  resolveWriteTargetStoreDir(toLayer, projectRoot)
4923
5066
  );
4924
- const flipProject = toLayer === "team" ? loadProjectConfig3(projectRoot)?.active_project : void 0;
5067
+ const flipProject = toLayer === "team" ? loadProjectConfig4(projectRoot)?.active_project : void 0;
4925
5068
  const toAbs = join12(
4926
5069
  resolveStoreCanonicalBase(toLayer, projectRoot, flipProject),
4927
5070
  pluralType,
@@ -4960,7 +5103,7 @@ async function modifyLayerFlip(projectRoot, target, content, fm, changes) {
4960
5103
  }
4961
5104
  }
4962
5105
  await atomicWriteText(toAbs, rewritten);
4963
- if (!moved && existsSync5(target.absPath) && target.absPath !== toAbs) {
5106
+ if (!moved && existsSync6(target.absPath) && target.absPath !== toAbs) {
4964
5107
  await unlink2(target.absPath);
4965
5108
  }
4966
5109
  const flipReason = `layer_flip:${priorStableId ?? "<unassigned>"}->${newStableId}`;
@@ -5223,7 +5366,7 @@ async function deferAll(projectRoot, pendingPaths, until, reason) {
5223
5366
  let stableId;
5224
5367
  try {
5225
5368
  const sandboxed = resolveSandboxedPath(projectRoot, pendingPath, { allowPersonal: true });
5226
- if (existsSync5(sandboxed.abs)) {
5369
+ if (existsSync6(sandboxed.abs)) {
5227
5370
  const content = await readFile9(sandboxed.abs, "utf8");
5228
5371
  stableId = parseFrontmatter(content).id;
5229
5372
  const patch = {
@@ -5249,6 +5392,52 @@ async function deferAll(projectRoot, pendingPaths, until, reason) {
5249
5392
  }
5250
5393
  return deferred;
5251
5394
  }
5395
+ async function retireAll(projectRoot, pendingPaths, supersededBy, reason) {
5396
+ const retired = [];
5397
+ for (const pendingPath of pendingPaths) {
5398
+ const result = await retireOne(projectRoot, pendingPath, supersededBy, reason);
5399
+ if (result !== null) {
5400
+ retired.push(result);
5401
+ }
5402
+ }
5403
+ return retired;
5404
+ }
5405
+ async function retireOne(projectRoot, pendingPath, supersededBy, reason) {
5406
+ const target = resolveModifyTarget(projectRoot, pendingPath);
5407
+ if (target === null) {
5408
+ return null;
5409
+ }
5410
+ const content = await readFile9(target.absPath, "utf8");
5411
+ const fm = parseFrontmatter(content);
5412
+ const patch = {
5413
+ deprecated: true,
5414
+ ...supersededBy !== void 0 ? { superseded_by: supersededBy } : {}
5415
+ };
5416
+ const merged = rewriteFrontmatterMerge(content, patch);
5417
+ await atomicWriteText(target.absPath, merged);
5418
+ const changedFields = supersededBy !== void 0 ? ["deprecated", "superseded_by"] : ["deprecated"];
5419
+ const before = { deprecated: fm.deprecated ?? null };
5420
+ const after = { deprecated: true };
5421
+ if (supersededBy !== void 0) {
5422
+ before.superseded_by = fm.superseded_by ?? null;
5423
+ after.superseded_by = supersededBy;
5424
+ }
5425
+ await emitEventBestEffort2(projectRoot, {
5426
+ event_type: "knowledge_modified",
5427
+ ...fm.id !== void 0 ? { stable_id: fm.id } : {},
5428
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
5429
+ path: pendingPath,
5430
+ changed_fields: changedFields,
5431
+ before,
5432
+ after,
5433
+ reason: reason !== void 0 ? `retire:${pendingPath}: ${reason}` : `retire:${pendingPath}`
5434
+ });
5435
+ return {
5436
+ path: pendingPath,
5437
+ ...fm.id !== void 0 ? { stable_id: fm.id } : {},
5438
+ ...supersededBy !== void 0 ? { superseded_by: supersededBy } : {}
5439
+ };
5440
+ }
5252
5441
  function parseFrontmatter(content) {
5253
5442
  const match = /^(?:\uFEFF)?---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/u.exec(content);
5254
5443
  if (match === null) {
@@ -5262,10 +5451,10 @@ function parseFrontmatter(content) {
5262
5451
  for (const rawLine of block.split(/\r?\n/u)) {
5263
5452
  const line = rawLine.trim();
5264
5453
  if (line.length === 0) continue;
5265
- const sep5 = line.indexOf(":");
5266
- if (sep5 === -1) continue;
5267
- const key = line.slice(0, sep5).trim();
5268
- const value = line.slice(sep5 + 1).trim();
5454
+ const sep6 = line.indexOf(":");
5455
+ if (sep6 === -1) continue;
5456
+ const key = line.slice(0, sep6).trim();
5457
+ const value = line.slice(sep6 + 1).trim();
5269
5458
  switch (key) {
5270
5459
  case "id":
5271
5460
  out.id = stripQuotes(value);
@@ -5322,6 +5511,14 @@ function parseFrontmatter(content) {
5322
5511
  case "last_review_confirmed_at":
5323
5512
  out.last_review_confirmed_at = stripQuotes(value);
5324
5513
  break;
5514
+ case "deprecated":
5515
+ if (value === "true" || value === "false") {
5516
+ out.deprecated = value === "true";
5517
+ }
5518
+ break;
5519
+ case "superseded_by":
5520
+ out.superseded_by = stripQuotes(value);
5521
+ break;
5325
5522
  default:
5326
5523
  break;
5327
5524
  }
@@ -5391,12 +5588,14 @@ ${content}`;
5391
5588
  if (patch.status !== void 0) updates.status = `status: ${patch.status}`;
5392
5589
  if (patch.deferred_until !== void 0) updates.deferred_until = `deferred_until: ${quoteIfNeeded(patch.deferred_until)}`;
5393
5590
  if (patch.last_review_confirmed_at !== void 0) updates.last_review_confirmed_at = `last_review_confirmed_at: ${quoteIfNeeded(patch.last_review_confirmed_at)}`;
5591
+ if (patch.deprecated !== void 0) updates.deprecated = `deprecated: ${patch.deprecated}`;
5592
+ if (patch.superseded_by !== void 0) updates.superseded_by = `superseded_by: ${quoteIfNeeded(patch.superseded_by)}`;
5394
5593
  const lines = block.split(/\r?\n/u);
5395
5594
  const seen = /* @__PURE__ */ new Set();
5396
5595
  const newLines = [];
5397
5596
  for (const line of lines) {
5398
- const sep5 = line.indexOf(":");
5399
- const key = sep5 === -1 ? "" : line.slice(0, sep5).trim();
5597
+ const sep6 = line.indexOf(":");
5598
+ const key = sep6 === -1 ? "" : line.slice(0, sep6).trim();
5400
5599
  if (key in updates) {
5401
5600
  newLines.push(updates[key]);
5402
5601
  seen.add(key);
@@ -5431,6 +5630,8 @@ function appendPatchLines(lines, patch) {
5431
5630
  if (patch.status !== void 0) lines.push(`status: ${patch.status}`);
5432
5631
  if (patch.deferred_until !== void 0) lines.push(`deferred_until: ${quoteIfNeeded(patch.deferred_until)}`);
5433
5632
  if (patch.last_review_confirmed_at !== void 0) lines.push(`last_review_confirmed_at: ${quoteIfNeeded(patch.last_review_confirmed_at)}`);
5633
+ if (patch.deprecated !== void 0) lines.push(`deprecated: ${patch.deprecated}`);
5634
+ if (patch.superseded_by !== void 0) lines.push(`superseded_by: ${quoteIfNeeded(patch.superseded_by)}`);
5434
5635
  }
5435
5636
  function flowArrayElement(value) {
5436
5637
  if (/[\n\r,\[\]{}"#:]/u.test(value) || /^\s|\s$/u.test(value)) {
@@ -5465,7 +5666,7 @@ function registerReview(server, tracker) {
5465
5666
  server.registerTool(
5466
5667
  "fab_review",
5467
5668
  {
5468
- 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.",
5669
+ 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); modify-content-batch \u2192 items[{pending_path, changes}][\u22651] (batch content edits; per-item partial-failure reported in modified[]); search \u2192 query; defer \u2192 pending_paths[\u22651] (until/reason optional); retire \u2192 pending_paths[\u22651] (superseded_by/reason optional; marks canonical entries deprecated in place \u2014 deprecate-over-delete \u2014 so they stop surfacing in recall/broad indexes). approve allocates a stable_id and promotes to the canonical store knowledge path. Skill-side tool \u2014 invoked by fabric-review.",
5469
5670
  // Flat ZodRawShape required by MCP SDK 1.29.0 registerTool. The
5470
5671
  // authoritative cross-field contract still lives in FabReviewInputSchema
5471
5672
  // (discriminatedUnion) and is enforced inside the handler via
@@ -5487,6 +5688,12 @@ function registerReview(server, tracker) {
5487
5688
  if (gateWarn) {
5488
5689
  response.warnings = [gateWarn];
5489
5690
  }
5691
+ if (narrowed.action !== "retire") {
5692
+ const scopeWarn = unsealedProjectScopeWarning(projectRoot);
5693
+ if (scopeWarn) {
5694
+ response.warnings = [...response.warnings ?? [], scopeWarn];
5695
+ }
5696
+ }
5490
5697
  const payloadLimits = readPayloadLimits(projectRoot);
5491
5698
  const serialized = JSON.stringify(response);
5492
5699
  const guardResult = enforcePayloadLimit4(serialized, payloadLimits);
@@ -5573,7 +5780,7 @@ function registerPending(server, tracker) {
5573
5780
  // src/services/doctor.ts
5574
5781
  import { access as access4, readFile as readFile19, readdir as readdirAsync, stat as statAsync, unlink as unlink4, writeFile as writeFile5 } from "fs/promises";
5575
5782
  import { constants as constants2 } from "fs";
5576
- import { isAbsolute as isAbsolute2, join as join24, posix as posix5, resolve as resolve3 } from "path";
5783
+ import { isAbsolute as isAbsolute2, join as join25, posix as posix5, resolve as resolve3 } from "path";
5577
5784
  import {
5578
5785
  createTranslator,
5579
5786
  forensicReportSchema,
@@ -5977,7 +6184,7 @@ function createLayerMismatchCheck(t, inspection) {
5977
6184
 
5978
6185
  // src/services/doctor-relevance-paths.ts
5979
6186
  import { execFileSync as execFileSync2 } from "child_process";
5980
- import { existsSync as existsSync6, readdirSync, statSync as statSync3 } from "fs";
6187
+ import { existsSync as existsSync7, readdirSync, statSync as statSync3 } from "fs";
5981
6188
  import { join as join14, sep as sep3 } from "path";
5982
6189
  import { minimatch } from "minimatch";
5983
6190
  var MS_PER_DAY = 24 * 60 * 60 * 1e3;
@@ -5999,7 +6206,7 @@ function expandGlob(rawGlob) {
5999
6206
  return rawGlob.endsWith("/") ? `${rawGlob}**` : rawGlob;
6000
6207
  }
6001
6208
  function collectWorkspacePaths(projectRoot) {
6002
- if (!existsSync6(projectRoot)) {
6209
+ if (!existsSync7(projectRoot)) {
6003
6210
  return [];
6004
6211
  }
6005
6212
  try {
@@ -6409,8 +6616,8 @@ function createStaleArchiveCheck(t, inspection) {
6409
6616
  // src/services/doctor-knowledge-promotion.ts
6410
6617
  var PROVEN_RELATED_INDEGREE_THRESHOLD = 3;
6411
6618
  function toLocalId2(id) {
6412
- const sep5 = id.indexOf(":");
6413
- return sep5 === -1 ? id : id.slice(sep5 + 1);
6619
+ const sep6 = id.indexOf(":");
6620
+ return sep6 === -1 ? id : id.slice(sep6 + 1);
6414
6621
  }
6415
6622
  function computeRelatedInDegree(entries) {
6416
6623
  const indegree = /* @__PURE__ */ new Map();
@@ -6690,45 +6897,6 @@ function createScopeLintCheck(t, violations) {
6690
6897
  };
6691
6898
  }
6692
6899
 
6693
- // src/services/doctor-unbound-project.ts
6694
- import { loadProjectConfig as loadProjectConfig4 } from "@fenglimg/fabric-shared";
6695
- function detectUnboundProject(projectRoot) {
6696
- const config = loadProjectConfig4(projectRoot);
6697
- const alias = config?.active_write_store;
6698
- if (typeof alias !== "string" || alias.length === 0) {
6699
- return null;
6700
- }
6701
- const missing = [];
6702
- if (typeof config?.project_id !== "string" || config.project_id.length === 0) {
6703
- missing.push("project_id");
6704
- }
6705
- if (typeof config?.active_project !== "string" || config.active_project.length === 0) {
6706
- missing.push("active_project");
6707
- }
6708
- return missing.length > 0 ? { alias, missing } : null;
6709
- }
6710
- function createUnboundProjectCheck(t, violation) {
6711
- if (violation === null) {
6712
- return {
6713
- name: t("doctor.check.unbound_project.name"),
6714
- status: "ok",
6715
- message: t("doctor.check.unbound_project.ok")
6716
- };
6717
- }
6718
- return {
6719
- name: t("doctor.check.unbound_project.name"),
6720
- status: "warn",
6721
- kind: "warning",
6722
- code: "unbound_project",
6723
- fixable: false,
6724
- message: t("doctor.check.unbound_project.message", {
6725
- alias: violation.alias,
6726
- missing: violation.missing.join(" + ")
6727
- }),
6728
- actionHint: t("doctor.check.unbound_project.remediation")
6729
- };
6730
- }
6731
-
6732
6900
  // src/services/doctor-write-route-lint.ts
6733
6901
  import { loadProjectConfig as loadProjectConfig5 } from "@fenglimg/fabric-shared";
6734
6902
  function detectWriteRouteUnbound(projectRoot) {
@@ -6776,9 +6944,100 @@ function createWriteRouteUnboundCheck(t, violations) {
6776
6944
  };
6777
6945
  }
6778
6946
 
6947
+ // src/services/doctor-stray-fabric-dir.ts
6948
+ import { readdirSync as readdirSync2, rename as renameCb } from "fs";
6949
+ import { promisify } from "util";
6950
+ import { join as join17, sep as sep4 } from "path";
6951
+ var rename = promisify(renameCb);
6952
+ var IGNORED_DIRS = /* @__PURE__ */ new Set([
6953
+ "node_modules",
6954
+ ".git",
6955
+ "dist",
6956
+ "build",
6957
+ "coverage",
6958
+ ".next",
6959
+ ".turbo",
6960
+ "Library",
6961
+ "Temp",
6962
+ ".claude",
6963
+ ".codex",
6964
+ ".workflow"
6965
+ ]);
6966
+ var MAX_DEPTH = 8;
6967
+ function detectStrayFabricDirs(projectRoot) {
6968
+ const strays = [];
6969
+ walk(projectRoot, projectRoot, 0, strays);
6970
+ return strays;
6971
+ }
6972
+ function walk(root, current, depth, out) {
6973
+ if (depth > MAX_DEPTH) return;
6974
+ let entries;
6975
+ try {
6976
+ entries = readdirSync2(current, { withFileTypes: true });
6977
+ } catch {
6978
+ return;
6979
+ }
6980
+ for (const entry of entries) {
6981
+ if (!entry.isDirectory()) continue;
6982
+ if (entry.name === ".fabric") {
6983
+ if (current !== root) {
6984
+ out.push(join17(current, entry.name));
6985
+ }
6986
+ continue;
6987
+ }
6988
+ if (IGNORED_DIRS.has(entry.name)) continue;
6989
+ walk(root, join17(current, entry.name), depth + 1, out);
6990
+ }
6991
+ }
6992
+ function relative2(root, abs) {
6993
+ const prefix = root.endsWith(sep4) ? root : root + sep4;
6994
+ return abs.startsWith(prefix) ? abs.slice(prefix.length) : abs;
6995
+ }
6996
+ function createStrayFabricDirCheck(t, strays, projectRoot) {
6997
+ if (strays.length === 0) {
6998
+ return {
6999
+ name: t("doctor.check.stray_fabric_dir_detected.name"),
7000
+ status: "ok",
7001
+ message: t("doctor.check.stray_fabric_dir_detected.ok")
7002
+ };
7003
+ }
7004
+ const summary = strays.slice(0, 5).map((abs) => relative2(projectRoot, abs)).join(", ");
7005
+ return {
7006
+ name: t("doctor.check.stray_fabric_dir_detected.name"),
7007
+ status: "warn",
7008
+ kind: "warning",
7009
+ code: "stray_fabric_dir_detected",
7010
+ fixable: true,
7011
+ message: t("doctor.check.stray_fabric_dir_detected.message", {
7012
+ count: String(strays.length),
7013
+ dirs: summary
7014
+ }),
7015
+ actionHint: t("doctor.check.stray_fabric_dir_detected.remediation")
7016
+ };
7017
+ }
7018
+ async function fixStrayFabricDirs(strays, nowIso = (/* @__PURE__ */ new Date()).toISOString()) {
7019
+ const stamp = nowIso.replace(/[:.]/g, "-");
7020
+ const results = [];
7021
+ for (const from of strays) {
7022
+ const to = `${from}.stale-${stamp}`;
7023
+ try {
7024
+ await rename(from, to);
7025
+ results.push({ from, to, ok: true });
7026
+ } catch (err) {
7027
+ results.push({
7028
+ from,
7029
+ to,
7030
+ ok: false,
7031
+ error: err instanceof Error ? err.message : String(err)
7032
+ });
7033
+ }
7034
+ }
7035
+ return results;
7036
+ }
7037
+
6779
7038
  // src/services/doctor-store-counters.ts
6780
- import { existsSync as existsSync7, readdirSync as readdirSync2, readFileSync as readFileSync3 } from "fs";
6781
- import { join as join17 } from "path";
7039
+ import { existsSync as existsSync8, readdirSync as readdirSync3, readFileSync as readFileSync3 } from "fs";
7040
+ import { join as join18 } from "path";
6782
7041
  import {
6783
7042
  buildStoreResolveInput as buildStoreResolveInput6,
6784
7043
  createStoreResolver as createStoreResolver6,
@@ -6805,7 +7064,7 @@ function resolveCounterStores(projectRoot) {
6805
7064
  return readSet.stores.map((entry) => ({
6806
7065
  uuid: entry.store_uuid,
6807
7066
  alias: entry.alias,
6808
- dir: join17(
7067
+ dir: join18(
6809
7068
  globalRoot,
6810
7069
  storeRelativePathForMount5(
6811
7070
  input.mountedStores.find((s) => s.store_uuid === entry.store_uuid) ?? {
@@ -6833,13 +7092,13 @@ function readEntryId(file) {
6833
7092
  function computeStoreDiskMax(storeDir) {
6834
7093
  const max = defaultAgentsMetaCounters();
6835
7094
  for (const type of STORE_KNOWLEDGE_TYPE_DIRS2) {
6836
- const dir = join17(storeDir, STORE_LAYOUT2.knowledgeDir, type);
6837
- if (!existsSync7(dir)) {
7095
+ const dir = join18(storeDir, STORE_LAYOUT2.knowledgeDir, type);
7096
+ if (!existsSync8(dir)) {
6838
7097
  continue;
6839
7098
  }
6840
7099
  let names;
6841
7100
  try {
6842
- names = readdirSync2(dir);
7101
+ names = readdirSync3(dir);
6843
7102
  } catch {
6844
7103
  continue;
6845
7104
  }
@@ -6847,7 +7106,7 @@ function computeStoreDiskMax(storeDir) {
6847
7106
  if (!name.endsWith(".md")) {
6848
7107
  continue;
6849
7108
  }
6850
- const parsed = parseKnowledgeId2(readEntryId(join17(dir, name)) ?? "");
7109
+ const parsed = parseKnowledgeId2(readEntryId(join18(dir, name)) ?? "");
6851
7110
  if (parsed === null) {
6852
7111
  continue;
6853
7112
  }
@@ -6929,8 +7188,8 @@ function createStoreCounterCheck(t, drifts) {
6929
7188
  }
6930
7189
 
6931
7190
  // src/services/doctor-store-orphan.ts
6932
- import { readdirSync as readdirSync3 } from "fs";
6933
- import { join as join18 } from "path";
7191
+ import { readdirSync as readdirSync4 } from "fs";
7192
+ import { join as join19 } from "path";
6934
7193
  import {
6935
7194
  STORES_ROOT_DIR,
6936
7195
  addMountedStore,
@@ -6943,7 +7202,7 @@ import {
6943
7202
  var STORE_BY_ALIAS_DIR = "by-alias";
6944
7203
  function listDir(dir) {
6945
7204
  try {
6946
- return readdirSync3(dir);
7205
+ return readdirSync4(dir);
6947
7206
  } catch {
6948
7207
  return [];
6949
7208
  }
@@ -6954,15 +7213,15 @@ function inspectStoreOrphans(globalRoot = resolveGlobalRoot7()) {
6954
7213
  return [];
6955
7214
  }
6956
7215
  const registered = new Set(config.stores.map((s) => s.store_uuid));
6957
- const storesRoot = join18(globalRoot, STORES_ROOT_DIR);
7216
+ const storesRoot = join19(globalRoot, STORES_ROOT_DIR);
6958
7217
  const orphans = [];
6959
7218
  for (const group of listDir(storesRoot)) {
6960
7219
  if (group === STORE_BY_ALIAS_DIR) {
6961
7220
  continue;
6962
7221
  }
6963
- const groupDir = join18(storesRoot, group);
7222
+ const groupDir = join19(storesRoot, group);
6964
7223
  for (const mount of listDir(groupDir)) {
6965
- const dir = join18(groupDir, mount);
7224
+ const dir = join19(groupDir, mount);
6966
7225
  const identity = readStoreIdentity(dir);
6967
7226
  if (identity === null || registered.has(identity.store_uuid)) {
6968
7227
  continue;
@@ -7029,7 +7288,7 @@ function createStoreOrphanCheck(t, orphans) {
7029
7288
 
7030
7289
  // src/services/doctor-project-registry-drift.ts
7031
7290
  import { readdir as readdir3, rmdir } from "fs/promises";
7032
- import { join as join19 } from "path";
7291
+ import { join as join20 } from "path";
7033
7292
  import {
7034
7293
  addStoreProject,
7035
7294
  buildStoreResolveInput as buildStoreResolveInput7,
@@ -7058,7 +7317,7 @@ function resolveDriftStores(projectRoot) {
7058
7317
  return {
7059
7318
  uuid: entry.store_uuid,
7060
7319
  alias: entry.alias,
7061
- dir: join19(globalRoot, storeRelativePathForMount6(mounted ?? { store_uuid: entry.store_uuid }))
7320
+ dir: join20(globalRoot, storeRelativePathForMount6(mounted ?? { store_uuid: entry.store_uuid }))
7062
7321
  };
7063
7322
  });
7064
7323
  }
@@ -7070,14 +7329,14 @@ async function listDir2(dir) {
7070
7329
  }
7071
7330
  }
7072
7331
  async function listProjectFolders(storeDir) {
7073
- const projectsRoot = join19(storeDir, STORE_LAYOUT3.knowledgeDir, PROJECTS_DIR);
7332
+ const projectsRoot = join20(storeDir, STORE_LAYOUT3.knowledgeDir, PROJECTS_DIR);
7074
7333
  const reserved = /* @__PURE__ */ new Set([PROJECTS_DIR, ...STORE_KNOWLEDGE_TYPE_DIRS3]);
7075
7334
  return (await listDir2(projectsRoot)).filter((id) => STORE_PROJECT_ID_PATTERN2.test(id) && !reserved.has(id)).sort();
7076
7335
  }
7077
7336
  async function folderHasEntries(storeDir, projectId) {
7078
- const base = join19(storeDir, STORE_LAYOUT3.knowledgeDir, PROJECTS_DIR, projectId);
7337
+ const base = join20(storeDir, STORE_LAYOUT3.knowledgeDir, PROJECTS_DIR, projectId);
7079
7338
  for (const type of STORE_KNOWLEDGE_TYPE_DIRS3) {
7080
- const names = await listDir2(join19(base, type));
7339
+ const names = await listDir2(join20(base, type));
7081
7340
  if (names.some((name) => name.endsWith(".md"))) {
7082
7341
  return true;
7083
7342
  }
@@ -7122,14 +7381,14 @@ async function fixProjectRegistryDrift(projectRoot) {
7122
7381
  continue;
7123
7382
  }
7124
7383
  try {
7125
- const base = join19(
7384
+ const base = join20(
7126
7385
  finding.store_dir,
7127
7386
  STORE_LAYOUT3.knowledgeDir,
7128
7387
  PROJECTS_DIR,
7129
7388
  finding.project_id
7130
7389
  );
7131
7390
  for (const type of STORE_KNOWLEDGE_TYPE_DIRS3) {
7132
- await rmdir(join19(base, type)).catch(() => void 0);
7391
+ await rmdir(join20(base, type)).catch(() => void 0);
7133
7392
  }
7134
7393
  await rmdir(base);
7135
7394
  pruned.push(finding);
@@ -7224,7 +7483,7 @@ import {
7224
7483
 
7225
7484
  // src/services/events-jsonl-gates.ts
7226
7485
  import { promises as fs } from "fs";
7227
- import { existsSync as existsSync8 } from "fs";
7486
+ import { existsSync as existsSync9 } from "fs";
7228
7487
  var EVENTS_JSONL_SIZE_WARN_BYTES = 10 * 1024 * 1024;
7229
7488
  var METRICS_STALE_WARN_MS = 10 * 60 * 1e3;
7230
7489
  var ROTATION_OVERDUE_WARN_MS = 90 * 24 * 60 * 60 * 1e3;
@@ -7246,7 +7505,7 @@ async function inspectEventsJsonlGates(projectRoot, options = {}) {
7246
7505
  if (!(isNodeError(error) && error.code === "ENOENT")) throw error;
7247
7506
  }
7248
7507
  let metricsStalenessMs = null;
7249
- if (existsSync8(metricsPath)) {
7508
+ if (existsSync9(metricsPath)) {
7250
7509
  try {
7251
7510
  const stat5 = await fs.stat(metricsPath);
7252
7511
  metricsStalenessMs = Math.max(0, now.getTime() - stat5.mtimeMs);
@@ -7289,7 +7548,7 @@ async function inspectEventsJsonlGates(projectRoot, options = {}) {
7289
7548
 
7290
7549
  // src/services/doctor-skill-lints.ts
7291
7550
  import { readdir as readdir4, readFile as readFile13 } from "fs/promises";
7292
- import { join as join20, posix as posix2 } from "path";
7551
+ import { join as join21, posix as posix2 } from "path";
7293
7552
  var FABRIC_SKILL_SLUGS = ["fabric-archive", "fabric-review"];
7294
7553
  var FABRIC_CONTRACT_SKILL_SLUGS = ["fabric-archive", "fabric-review", "fabric-store", "fabric-sync"];
7295
7554
  var SKILL_MD_FRONTMATTER_ROOTS = [".claude/skills", ".codex/skills"];
@@ -7344,8 +7603,8 @@ async function listMarkdownFiles(dir) {
7344
7603
  async function inspectSkillRefMirror(projectRoot) {
7345
7604
  const driftedPaths = [];
7346
7605
  for (const slug of FABRIC_SKILL_SLUGS) {
7347
- const claudeRef = join20(projectRoot, ".claude", "skills", slug, "ref");
7348
- const codexRef = join20(projectRoot, ".codex", "skills", slug, "ref");
7606
+ const claudeRef = join21(projectRoot, ".claude", "skills", slug, "ref");
7607
+ const codexRef = join21(projectRoot, ".codex", "skills", slug, "ref");
7349
7608
  const [claudeFiles, codexFiles] = await Promise.all([listMarkdownFiles(claudeRef), listMarkdownFiles(codexRef)]);
7350
7609
  if (claudeFiles === null || codexFiles === null) continue;
7351
7610
  const claudeSet = new Set(claudeFiles);
@@ -7362,8 +7621,8 @@ async function inspectSkillRefMirror(projectRoot) {
7362
7621
  let codexBody;
7363
7622
  try {
7364
7623
  [claudeBody, codexBody] = await Promise.all([
7365
- readFile13(join20(claudeRef, fname), "utf8"),
7366
- readFile13(join20(codexRef, fname), "utf8")
7624
+ readFile13(join21(claudeRef, fname), "utf8"),
7625
+ readFile13(join21(codexRef, fname), "utf8")
7367
7626
  ]);
7368
7627
  } catch {
7369
7628
  continue;
@@ -7382,7 +7641,7 @@ async function inspectSkillTokenBudget(projectRoot) {
7382
7641
  const overSize = [];
7383
7642
  let highestSeverity = "ok";
7384
7643
  for (const slug of FABRIC_SKILL_SLUGS) {
7385
- const skillMdPath = join20(projectRoot, ".claude", "skills", slug, "SKILL.md");
7644
+ const skillMdPath = join21(projectRoot, ".claude", "skills", slug, "SKILL.md");
7386
7645
  let body;
7387
7646
  try {
7388
7647
  body = await readFile13(skillMdPath, "utf8");
@@ -7407,7 +7666,7 @@ async function inspectSkillDescription(projectRoot) {
7407
7666
  const ASCII_PATTERN = /[a-zA-Z]{2,}/u;
7408
7667
  const ANTI_TRIGGER_PATTERN = /\bNOT\b|ONLY when|do not|不要|不是|非/u;
7409
7668
  for (const slug of FABRIC_SKILL_SLUGS) {
7410
- const skillMdPath = join20(projectRoot, ".claude", "skills", slug, "SKILL.md");
7669
+ const skillMdPath = join21(projectRoot, ".claude", "skills", slug, "SKILL.md");
7411
7670
  let body;
7412
7671
  try {
7413
7672
  body = await readFile13(skillMdPath, "utf8");
@@ -7450,8 +7709,8 @@ async function inspectSkillContract(projectRoot) {
7450
7709
  for (const rootRel of SKILL_MD_FRONTMATTER_ROOTS) {
7451
7710
  const client = rootRel.startsWith(".claude") ? ".claude" : ".codex";
7452
7711
  for (const slug of FABRIC_CONTRACT_SKILL_SLUGS) {
7453
- const skillDir = join20(projectRoot, rootRel, slug);
7454
- const skillMdPath = join20(skillDir, "SKILL.md");
7712
+ const skillDir = join21(projectRoot, rootRel, slug);
7713
+ const skillMdPath = join21(skillDir, "SKILL.md");
7455
7714
  let body;
7456
7715
  try {
7457
7716
  body = await readFile13(skillMdPath, "utf8");
@@ -7467,7 +7726,7 @@ async function inspectSkillContract(projectRoot) {
7467
7726
  detail: token
7468
7727
  });
7469
7728
  }
7470
- const refDir = join20(skillDir, "ref");
7729
+ const refDir = join21(skillDir, "ref");
7471
7730
  let refFiles;
7472
7731
  try {
7473
7732
  refFiles = (await readdir4(refDir)).filter((name) => name.endsWith(".md"));
@@ -7491,7 +7750,7 @@ async function inspectSkillContract(projectRoot) {
7491
7750
  async function inspectSkillMdYamlInvalid(projectRoot) {
7492
7751
  const candidates = [];
7493
7752
  for (const rootRel of SKILL_MD_FRONTMATTER_ROOTS) {
7494
- const rootAbs = join20(projectRoot, rootRel);
7753
+ const rootAbs = join21(projectRoot, rootRel);
7495
7754
  let dirEntries;
7496
7755
  try {
7497
7756
  dirEntries = await readdir4(rootAbs, { withFileTypes: true });
@@ -7500,7 +7759,7 @@ async function inspectSkillMdYamlInvalid(projectRoot) {
7500
7759
  }
7501
7760
  for (const dirEntry of dirEntries) {
7502
7761
  if (!dirEntry.isDirectory()) continue;
7503
- const skillFile = join20(rootAbs, dirEntry.name, "SKILL.md");
7762
+ const skillFile = join21(rootAbs, dirEntry.name, "SKILL.md");
7504
7763
  let raw;
7505
7764
  try {
7506
7765
  raw = await readFile13(skillFile, "utf8");
@@ -7646,7 +7905,7 @@ function createSkillMdYamlInvalidCheck(t, inspection) {
7646
7905
 
7647
7906
  // src/services/doctor-retired-references-lint.ts
7648
7907
  import { readdir as readdir5, readFile as readFile14, stat as stat3 } from "fs/promises";
7649
- import { join as join21, posix as posix3, relative as relative2 } from "path";
7908
+ import { join as join22, posix as posix3, relative as relative3 } from "path";
7650
7909
  var RETIRED_TOKENS = [
7651
7910
  { token: "fab_plan_context", replacement: "fab_recall", reason: "retrieval collapsed to one lean fab_recall (KT-DEC-0026)" },
7652
7911
  { token: "fab_get_knowledge_sections", replacement: "fab_recall", reason: "two-step fetch retired (KT-DEC-0026)" },
@@ -7669,7 +7928,7 @@ var RETIRED_TOKENS = [
7669
7928
  ];
7670
7929
  var HOOK_DIRS = [".claude/hooks", ".codex/hooks"];
7671
7930
  var SKILL_DIRS = [".claude/skills", ".codex/skills"];
7672
- var BOOTSTRAP_FILES = ["AGENTS.md", "CLAUDE.md", join21(".fabric", "AGENTS.md")];
7931
+ var BOOTSTRAP_FILES = ["AGENTS.md", "CLAUDE.md", join22(".fabric", "AGENTS.md")];
7673
7932
  function isCommentLine(line) {
7674
7933
  const t = line.trim();
7675
7934
  return t.startsWith("//") || t.startsWith("*") || t.startsWith("/*");
@@ -7683,7 +7942,7 @@ async function walkFiles(dir, exts) {
7683
7942
  }
7684
7943
  const out = [];
7685
7944
  for (const e of entries) {
7686
- const full = join21(dir, e.name);
7945
+ const full = join22(dir, e.name);
7687
7946
  if (e.isDirectory()) {
7688
7947
  out.push(...await walkFiles(full, exts));
7689
7948
  } else if (exts.some((ext) => e.name.endsWith(ext))) {
@@ -7707,9 +7966,9 @@ function scanText(rel, text, skipComments, hits) {
7707
7966
  async function inspectRetiredReferences(projectRoot) {
7708
7967
  const hits = [];
7709
7968
  let scannedFiles = 0;
7710
- const toRel = (p) => posix3.normalize(relative2(projectRoot, p).split("\\").join("/"));
7969
+ const toRel = (p) => posix3.normalize(relative3(projectRoot, p).split("\\").join("/"));
7711
7970
  for (const rel of BOOTSTRAP_FILES) {
7712
- const abs = join21(projectRoot, rel);
7971
+ const abs = join22(projectRoot, rel);
7713
7972
  try {
7714
7973
  if ((await stat3(abs)).isFile()) {
7715
7974
  scannedFiles += 1;
@@ -7719,7 +7978,7 @@ async function inspectRetiredReferences(projectRoot) {
7719
7978
  }
7720
7979
  }
7721
7980
  for (const dir of SKILL_DIRS) {
7722
- for (const file of await walkFiles(join21(projectRoot, dir), [".md"])) {
7981
+ for (const file of await walkFiles(join22(projectRoot, dir), [".md"])) {
7723
7982
  scannedFiles += 1;
7724
7983
  try {
7725
7984
  scanText(toRel(file), await readFile14(file, "utf8"), false, hits);
@@ -7728,7 +7987,7 @@ async function inspectRetiredReferences(projectRoot) {
7728
7987
  }
7729
7988
  }
7730
7989
  for (const dir of HOOK_DIRS) {
7731
- for (const file of await walkFiles(join21(projectRoot, dir), [".cjs"])) {
7990
+ for (const file of await walkFiles(join22(projectRoot, dir), [".cjs"])) {
7732
7991
  scannedFiles += 1;
7733
7992
  try {
7734
7993
  scanText(toRel(file), await readFile14(file, "utf8"), true, hits);
@@ -7767,7 +8026,7 @@ function createRetiredReferenceCheck(t, inspection) {
7767
8026
  // src/services/doctor-hooks-lints.ts
7768
8027
  import { constants } from "fs";
7769
8028
  import { access, readdir as readdir6, readFile as readFile15, stat as stat4 } from "fs/promises";
7770
- import { join as join22, posix as posix4 } from "path";
8029
+ import { join as join23, posix as posix4 } from "path";
7771
8030
  import { Script } from "vm";
7772
8031
  var HOOKS_RUNTIME_CLIENT_DIRS = [
7773
8032
  { client: "claude", dir: ".claude/hooks" },
@@ -7829,11 +8088,11 @@ async function isFile(absPath) {
7829
8088
  }
7830
8089
  }
7831
8090
  async function inspectHooksWired(projectRoot) {
7832
- const claudeEntries = await readDirectoryFileNames(join22(projectRoot, ".claude"));
8091
+ const claudeEntries = await readDirectoryFileNames(join23(projectRoot, ".claude"));
7833
8092
  if (claudeEntries === null) {
7834
8093
  return { status: "skipped", missingHooks: [] };
7835
8094
  }
7836
- const settingsPath = join22(projectRoot, ".claude", "settings.json");
8095
+ const settingsPath = join23(projectRoot, ".claude", "settings.json");
7837
8096
  let parsed;
7838
8097
  try {
7839
8098
  parsed = JSON.parse(await readFile15(settingsPath, "utf8"));
@@ -7865,8 +8124,8 @@ async function inspectHooksWired(projectRoot) {
7865
8124
  }
7866
8125
  async function inspectHookCacheWritability(projectRoot) {
7867
8126
  const relPath = posix4.join(".fabric", ".cache");
7868
- const fabricDir = join22(projectRoot, ".fabric");
7869
- const cacheDir = join22(projectRoot, ".fabric", ".cache");
8127
+ const fabricDir = join23(projectRoot, ".fabric");
8128
+ const cacheDir = join23(projectRoot, ".fabric", ".cache");
7870
8129
  try {
7871
8130
  try {
7872
8131
  const cacheStats = await stat4(cacheDir);
@@ -7915,12 +8174,12 @@ async function inspectHookCacheWritability(projectRoot) {
7915
8174
  async function inspectHooksContentDrift(projectRoot) {
7916
8175
  const hookFilesByBasename = /* @__PURE__ */ new Map();
7917
8176
  for (const { client, dir } of HOOKS_RUNTIME_CLIENT_DIRS) {
7918
- const absDir = join22(projectRoot, dir);
8177
+ const absDir = join23(projectRoot, dir);
7919
8178
  const entries = await readDirectoryFileNames(absDir);
7920
8179
  if (entries === null) continue;
7921
8180
  for (const name of entries) {
7922
8181
  if (!name.endsWith(".cjs")) continue;
7923
- const abs = join22(absDir, name);
8182
+ const abs = join23(absDir, name);
7924
8183
  if (!await isFile(abs)) continue;
7925
8184
  const arr = hookFilesByBasename.get(name) ?? [];
7926
8185
  arr.push({ client, abs });
@@ -7957,12 +8216,12 @@ async function inspectHooksRuntime(projectRoot) {
7957
8216
  const issues = [];
7958
8217
  let scanned = 0;
7959
8218
  for (const { client, dir } of HOOKS_RUNTIME_CLIENT_DIRS) {
7960
- const absDir = join22(projectRoot, dir);
8219
+ const absDir = join23(projectRoot, dir);
7961
8220
  const entries = await readDirectoryFileNames(absDir);
7962
8221
  if (entries === null) continue;
7963
8222
  for (const name of entries) {
7964
8223
  if (!name.endsWith(".cjs")) continue;
7965
- const abs = join22(absDir, name);
8224
+ const abs = join23(absDir, name);
7966
8225
  const displayPath = `${dir}/${name}`;
7967
8226
  if (!await isFile(abs)) continue;
7968
8227
  scanned += 1;
@@ -8106,7 +8365,7 @@ function createHookCacheWritabilityCheck(t, inspection) {
8106
8365
 
8107
8366
  // src/services/doctor-bootstrap-lints.ts
8108
8367
  import { access as access2, readFile as readFile16 } from "fs/promises";
8109
- import { join as join23 } from "path";
8368
+ import { join as join24 } from "path";
8110
8369
  import {
8111
8370
  BOOTSTRAP_MARKER_BEGIN,
8112
8371
  BOOTSTRAP_MARKER_END,
@@ -8138,13 +8397,13 @@ async function fileExists(path) {
8138
8397
  }
8139
8398
  async function inspectBootstrapAnchor(projectRoot) {
8140
8399
  const [hasAgentsMd, hasClaudeMd] = await Promise.all([
8141
- fileExists(join23(projectRoot, "AGENTS.md")),
8142
- fileExists(join23(projectRoot, "CLAUDE.md"))
8400
+ fileExists(join24(projectRoot, "AGENTS.md")),
8401
+ fileExists(join24(projectRoot, "CLAUDE.md"))
8143
8402
  ]);
8144
8403
  return { hasAgentsMd, hasClaudeMd };
8145
8404
  }
8146
8405
  async function inspectL1BootstrapSnapshotDrift(target) {
8147
- const abs = join23(target, ".fabric", "AGENTS.md");
8406
+ const abs = join24(target, ".fabric", "AGENTS.md");
8148
8407
  const canonical = resolveBootstrapCanonical();
8149
8408
  let onDisk;
8150
8409
  try {
@@ -8174,14 +8433,14 @@ function createL1BootstrapSnapshotDriftCheck(t, inspection) {
8174
8433
  );
8175
8434
  }
8176
8435
  async function inspectL2ManagedBlockDrift(target) {
8177
- const snapshotPath = join23(target, ".fabric", "AGENTS.md");
8436
+ const snapshotPath = join24(target, ".fabric", "AGENTS.md");
8178
8437
  let snapshot;
8179
8438
  try {
8180
8439
  snapshot = await readFile16(snapshotPath, "utf8");
8181
8440
  } catch {
8182
8441
  return { status: "ok", drifted: [] };
8183
8442
  }
8184
- const projectRulesPath = join23(target, ".fabric", "project-rules.md");
8443
+ const projectRulesPath = join24(target, ".fabric", "project-rules.md");
8185
8444
  let expectedBody = snapshot;
8186
8445
  try {
8187
8446
  const projectRules = await readFile16(projectRulesPath, "utf8");
@@ -8193,7 +8452,7 @@ ${projectRules}`;
8193
8452
  const drifted = [];
8194
8453
  let anyManagedBlockFound = false;
8195
8454
  const blockTargets = [
8196
- join23(target, "AGENTS.md")
8455
+ join24(target, "AGENTS.md")
8197
8456
  ];
8198
8457
  for (const abs of blockTargets) {
8199
8458
  let content;
@@ -8221,7 +8480,7 @@ ${projectRules}`;
8221
8480
  drifted.push({ path: abs, expected: expectedBody, actual: body });
8222
8481
  }
8223
8482
  }
8224
- const claudeMdPath = join23(target, "CLAUDE.md");
8483
+ const claudeMdPath = join24(target, "CLAUDE.md");
8225
8484
  try {
8226
8485
  const claudeContent = await readFile16(claudeMdPath, "utf8");
8227
8486
  anyManagedBlockFound = true;
@@ -9453,7 +9712,7 @@ async function runDoctorReport(target) {
9453
9712
  const globalCliVersion = process.env.VITEST === "true" ? { status: "ok", version: "test-skipped" } : inspectGlobalCliVersion();
9454
9713
  const targetFiles = Object.fromEntries(
9455
9714
  await Promise.all(
9456
- TARGET_FILE_PATHS.map(async (path) => [path, await pathExists(join24(projectRoot, path))])
9715
+ TARGET_FILE_PATHS.map(async (path) => [path, await pathExists(join25(projectRoot, path))])
9457
9716
  )
9458
9717
  );
9459
9718
  const checks = [
@@ -9597,6 +9856,14 @@ async function runDoctorReport(target) {
9597
9856
  // config-level check, no resolver, aligned with KT-DEC-0048 read-tolerant
9598
9857
  // doctrine.
9599
9858
  createWriteRouteUnboundCheck(t, detectWriteRouteUnbound(projectRoot)),
9859
+ // rc.11 stray_fabric_dir_detected — walks the project tree for any
9860
+ // `.fabric/` directory other than `<projectRoot>/.fabric`. Historical
9861
+ // residue from the pre-rc.10 hook / pre-rc.11 server-side resolveProjectRoot
9862
+ // fault mode where a subprocess whose cwd was a subdirectory of the repo
9863
+ // created a stray `<subdir>/.fabric/` (scattering events.jsonl / metrics.jsonl
9864
+ // / .cache). Adjacent to write_route_target_unbound — both catch config /
9865
+ // filesystem cross-references that drifted through migrations.
9866
+ createStrayFabricDirCheck(t, detectStrayFabricDirs(projectRoot), projectRoot),
9600
9867
  // rc.31 BUG-G2/G5: promote-ledger invariant. Sits adjacent to onboard
9601
9868
  // coverage — both are observability advisories built off events.jsonl.
9602
9869
  ...promoteLedgerInvariant === null ? [] : [createPromoteLedgerInvariantCheck(t, promoteLedgerInvariant)],
@@ -9672,7 +9939,7 @@ async function runDoctorFix(target) {
9672
9939
  const fixed = [];
9673
9940
  const ledgerWarnings = [];
9674
9941
  if (before.fixable_errors.some((issue) => issue.code === "bootstrap_snapshot_drift")) {
9675
- const snapshotPath = join24(projectRoot, ".fabric", "AGENTS.md");
9942
+ const snapshotPath = join25(projectRoot, ".fabric", "AGENTS.md");
9676
9943
  await ensureParentDirectory(snapshotPath);
9677
9944
  await atomicWriteText4(snapshotPath, resolveBootstrapCanonical2());
9678
9945
  fixed.push(findIssue(before.fixable_errors, "bootstrap_snapshot_drift"));
@@ -9696,6 +9963,15 @@ async function runDoctorFix(target) {
9696
9963
  fixed.push(findIssue(before.warnings, "store_orphan"));
9697
9964
  }
9698
9965
  }
9966
+ if (before.warnings.some((issue) => issue.code === "stray_fabric_dir_detected")) {
9967
+ const strays = detectStrayFabricDirs(projectRoot);
9968
+ if (strays.length > 0) {
9969
+ const results = await fixStrayFabricDirs(strays);
9970
+ if (results.some((r) => r.ok)) {
9971
+ fixed.push(findIssue(before.warnings, "stray_fabric_dir_detected"));
9972
+ }
9973
+ }
9974
+ }
9699
9975
  const registryDriftFound = before.manual_errors.some((issue) => issue.code === "project_registry_drift") || before.warnings.some((issue) => issue.code === "project_registry_drift") || before.infos.some((issue) => issue.code === "project_registry_drift");
9700
9976
  if (registryDriftFound) {
9701
9977
  const result = await fixProjectRegistryDrift(projectRoot);
@@ -9757,7 +10033,7 @@ async function runDoctorFix(target) {
9757
10033
  if (before.infos.some((issue) => issue.code === "stale_serve_lock")) {
9758
10034
  const lockInspection = inspectStaleServeLock(projectRoot, Date.now());
9759
10035
  if (lockInspection.present && !lockInspection.pidAlive) {
9760
- const lockFilePath = join24(projectRoot, ".fabric", ".serve.lock");
10036
+ const lockFilePath = join25(projectRoot, ".fabric", ".serve.lock");
9761
10037
  try {
9762
10038
  await unlink4(lockFilePath);
9763
10039
  } catch (err) {
@@ -9874,7 +10150,7 @@ function createApplyLintMessage(succeeded, failed, manualErrorCount) {
9874
10150
  }
9875
10151
  async function applySessionHintsStaleCleanup(projectRoot, candidate) {
9876
10152
  const detail = `deleted (${candidate.age_days}d old)`;
9877
- const absPath = join24(projectRoot, candidate.path);
10153
+ const absPath = join25(projectRoot, candidate.path);
9878
10154
  try {
9879
10155
  const { unlink: unlink5 } = await import("fs/promises");
9880
10156
  await unlink5(absPath);
@@ -9899,7 +10175,7 @@ function truncateErrorMessage(error) {
9899
10175
  return raw.length > 240 ? `${raw.slice(0, 237)}...` : raw;
9900
10176
  }
9901
10177
  async function inspectForensic(projectRoot) {
9902
- const path = join24(projectRoot, ".fabric", "forensic.json");
10178
+ const path = join25(projectRoot, ".fabric", "forensic.json");
9903
10179
  try {
9904
10180
  const parsed = forensicReportSchema.parse(JSON.parse(await readFile19(path, "utf8")));
9905
10181
  return { present: true, valid: true, report: parsed };
@@ -10476,7 +10752,7 @@ async function inspectPreexistingRootFiles(projectRoot) {
10476
10752
  const candidates = ["CLAUDE.md", "AGENTS.md"];
10477
10753
  const detected = [];
10478
10754
  for (const name of candidates) {
10479
- if (await pathExists(join24(projectRoot, name))) {
10755
+ if (await pathExists(join25(projectRoot, name))) {
10480
10756
  detected.push(name);
10481
10757
  }
10482
10758
  }
@@ -10561,7 +10837,7 @@ async function buildLastActiveIndex(projectRoot) {
10561
10837
  return map;
10562
10838
  }
10563
10839
  async function inspectSessionHintsStale(projectRoot, now) {
10564
- const cacheDir = join24(projectRoot, ".fabric", ".cache");
10840
+ const cacheDir = join25(projectRoot, ".fabric", ".cache");
10565
10841
  let entries;
10566
10842
  try {
10567
10843
  entries = await readdirAsync(cacheDir, { withFileTypes: true });
@@ -10573,7 +10849,7 @@ async function inspectSessionHintsStale(projectRoot, now) {
10573
10849
  if (!entry.isFile()) continue;
10574
10850
  if (!entry.name.startsWith(SESSION_HINTS_FILE_PREFIX)) continue;
10575
10851
  if (!entry.name.endsWith(SESSION_HINTS_FILE_SUFFIX)) continue;
10576
- const absPath = join24(cacheDir, entry.name);
10852
+ const absPath = join25(cacheDir, entry.name);
10577
10853
  let mtimeMs = 0;
10578
10854
  try {
10579
10855
  mtimeMs = (await statAsync(absPath)).mtimeMs;
@@ -10605,7 +10881,7 @@ function inspectStaleServeLock(projectRoot, now) {
10605
10881
  };
10606
10882
  }
10607
10883
  async function readUnderseedThresholdFromConfig(projectRoot) {
10608
- const configPath = join24(projectRoot, ".fabric", "fabric-config.json");
10884
+ const configPath = join25(projectRoot, ".fabric", "fabric-config.json");
10609
10885
  try {
10610
10886
  const raw = await readFile19(configPath, "utf8");
10611
10887
  const parsed = JSON.parse(raw);
@@ -10723,7 +10999,7 @@ async function inspectOnboardCoverage(projectRoot) {
10723
10999
  return { filled, missing, opted_out: optedOut };
10724
11000
  }
10725
11001
  async function readOnboardOptedOut(projectRoot) {
10726
- const path = join24(projectRoot, ".fabric", "fabric-config.json");
11002
+ const path = join25(projectRoot, ".fabric", "fabric-config.json");
10727
11003
  let raw;
10728
11004
  try {
10729
11005
  raw = await readFile19(path, "utf8");
@@ -10750,10 +11026,10 @@ function readFrontmatterScalar(content, key) {
10750
11026
  if (block === void 0) return void 0;
10751
11027
  for (const rawLine of block.split(/\r?\n/u)) {
10752
11028
  const line = rawLine.trim();
10753
- const sep5 = line.indexOf(":");
10754
- if (sep5 === -1) continue;
10755
- if (line.slice(0, sep5).trim() !== key) continue;
10756
- let value = line.slice(sep5 + 1).trim();
11029
+ const sep6 = line.indexOf(":");
11030
+ if (sep6 === -1) continue;
11031
+ if (line.slice(0, sep6).trim() !== key) continue;
11032
+ let value = line.slice(sep6 + 1).trim();
10757
11033
  if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
10758
11034
  value = value.slice(1, -1);
10759
11035
  }
@@ -10828,7 +11104,7 @@ async function* iterateCanonicalFilenames(projectRoot) {
10828
11104
  }
10829
11105
  }
10830
11106
  async function rewriteThreeEndManagedBlocks(projectRoot) {
10831
- const snapshotPath = join24(projectRoot, ".fabric", "AGENTS.md");
11107
+ const snapshotPath = join25(projectRoot, ".fabric", "AGENTS.md");
10832
11108
  if (!await pathExists(snapshotPath)) {
10833
11109
  return;
10834
11110
  }
@@ -10838,7 +11114,7 @@ async function rewriteThreeEndManagedBlocks(projectRoot) {
10838
11114
  } catch {
10839
11115
  return;
10840
11116
  }
10841
- const projectRulesPath = join24(projectRoot, ".fabric", "project-rules.md");
11117
+ const projectRulesPath = join25(projectRoot, ".fabric", "project-rules.md");
10842
11118
  const hasProjectRules = await pathExists(projectRulesPath);
10843
11119
  let expectedBody = snapshot;
10844
11120
  if (hasProjectRules) {
@@ -10854,7 +11130,7 @@ ${projectRules}`;
10854
11130
  ${expectedBody}
10855
11131
  ${BOOTSTRAP_MARKER_END2}`;
10856
11132
  const blockTargets = [
10857
- join24(projectRoot, "AGENTS.md")
11133
+ join25(projectRoot, "AGENTS.md")
10858
11134
  ];
10859
11135
  for (const abs of blockTargets) {
10860
11136
  if (!await pathExists(abs)) {
@@ -10887,7 +11163,7 @@ ${managedBlock}
10887
11163
  }
10888
11164
  await atomicWriteText4(abs, next);
10889
11165
  }
10890
- const claudeMdPath = join24(projectRoot, "CLAUDE.md");
11166
+ const claudeMdPath = join25(projectRoot, "CLAUDE.md");
10891
11167
  if (await pathExists(claudeMdPath)) {
10892
11168
  let claudeContent;
10893
11169
  try {
@@ -10957,7 +11233,7 @@ async function collectEntryPoints(root) {
10957
11233
  continue;
10958
11234
  }
10959
11235
  for (const entry of await readdirAsync(current, { withFileTypes: true })) {
10960
- const absolutePath = join24(current, entry.name);
11236
+ const absolutePath = join25(current, entry.name);
10961
11237
  const relativePath = normalizePath2(absolutePath.slice(root.length + 1));
10962
11238
  if (relativePath.length === 0) {
10963
11239
  continue;
@@ -11172,7 +11448,7 @@ async function runDoctorConflictLint(projectRoot, opts = {}) {
11172
11448
 
11173
11449
  // src/services/why-not-surfaced.ts
11174
11450
  import { readFile as readFile20 } from "fs/promises";
11175
- import { basename as basename3, join as join25 } from "path";
11451
+ import { basename as basename3, join as join26 } from "path";
11176
11452
  import {
11177
11453
  buildStoreResolveInput as buildStoreResolveInput8,
11178
11454
  createStoreResolver as createStoreResolver8,
@@ -11212,7 +11488,7 @@ async function explainWhyNotSurfaced(projectRoot, query) {
11212
11488
  const allStores = input.mountedStores.map((s) => ({
11213
11489
  store_uuid: s.store_uuid,
11214
11490
  alias: s.alias,
11215
- dir: join25(globalRoot, storeRelativePathForMount7(s))
11491
+ dir: join26(globalRoot, storeRelativePathForMount7(s))
11216
11492
  }));
11217
11493
  const refs = await readKnowledgeAcrossStores4(allStores);
11218
11494
  const candidate = refs.find((ref) => fileMatchesId(ref.file, localId));
@@ -11326,8 +11602,8 @@ async function inspectRelatedGraph(projectRoot) {
11326
11602
  }
11327
11603
 
11328
11604
  // src/services/store-precheck.ts
11329
- import { existsSync as existsSync9, readFileSync as readFileSync4 } from "fs";
11330
- import { join as join26 } from "path";
11605
+ import { existsSync as existsSync10, readFileSync as readFileSync4 } from "fs";
11606
+ import { join as join27 } from "path";
11331
11607
  import {
11332
11608
  buildStoreResolveInput as buildStoreResolveInput9,
11333
11609
  createStoreResolver as createStoreResolver9,
@@ -11340,7 +11616,7 @@ function clearPrecheckCache() {
11340
11616
  cache.clear();
11341
11617
  }
11342
11618
  function evaluateStoreDir(storeDir, identity) {
11343
- if (!existsSync9(storeDir)) {
11619
+ if (!existsSync10(storeDir)) {
11344
11620
  return {
11345
11621
  uuid: identity.uuid,
11346
11622
  alias: identity.alias,
@@ -11348,10 +11624,10 @@ function evaluateStoreDir(storeDir, identity) {
11348
11624
  reason: `directory not found at ${storeDir}`
11349
11625
  };
11350
11626
  }
11351
- const storeJsonPath = join26(storeDir, "store.json");
11352
- const gitDirPath = join26(storeDir, ".git");
11353
- const hasStoreJson = existsSync9(storeJsonPath) && isValidStoreJson(storeJsonPath);
11354
- const hasGitDir = existsSync9(gitDirPath);
11627
+ const storeJsonPath = join27(storeDir, "store.json");
11628
+ const gitDirPath = join27(storeDir, ".git");
11629
+ const hasStoreJson = existsSync10(storeJsonPath) && isValidStoreJson(storeJsonPath);
11630
+ const hasGitDir = existsSync10(gitDirPath);
11355
11631
  if (!hasStoreJson && !hasGitDir) {
11356
11632
  return {
11357
11633
  uuid: identity.uuid,
@@ -11384,7 +11660,7 @@ async function precheckStoreReachability(projectRoot, globalRoot = resolveGlobal
11384
11660
  const readSet = createStoreResolver9().resolveReadSet(input);
11385
11661
  const stores = readSet.stores.map((entry) => {
11386
11662
  const mounted = input.mountedStores.find((s) => s.store_uuid === entry.store_uuid);
11387
- const storeDir = join26(
11663
+ const storeDir = join27(
11388
11664
  globalRoot,
11389
11665
  storeRelativePathForMount8(mounted ?? { store_uuid: entry.store_uuid })
11390
11666
  );
@@ -11488,7 +11764,7 @@ function stopRotationTick(projectRoot) {
11488
11764
 
11489
11765
  // src/services/rehydrate-state.ts
11490
11766
  import { execFile } from "child_process";
11491
- import { promisify } from "util";
11767
+ import { promisify as promisify2 } from "util";
11492
11768
  import { agentsMetaSchema as agentsMetaSchema3 } from "@fenglimg/fabric-shared";
11493
11769
  import { IOFabricError as IOFabricError2, RuleError } from "@fenglimg/fabric-shared/errors";
11494
11770
 
@@ -11624,7 +11900,7 @@ async function pathExists2(path) {
11624
11900
  }
11625
11901
 
11626
11902
  // src/services/rehydrate-state.ts
11627
- var execFileAsync = promisify(execFile);
11903
+ var execFileAsync = promisify2(execFile);
11628
11904
  var AGENTS_META_GIT_PATH = ".fabric/agents.meta.json";
11629
11905
  var HistoryStateNotFoundError = class extends IOFabricError2 {
11630
11906
  code = "HISTORY_STATE_NOT_FOUND";
@@ -11777,8 +12053,8 @@ function formatError(error) {
11777
12053
  }
11778
12054
  function formatPreexistingRootMessage(projectRoot) {
11779
12055
  const preexisting = [];
11780
- if (existsSync10(join27(projectRoot, "CLAUDE.md"))) preexisting.push("CLAUDE.md");
11781
- if (existsSync10(join27(projectRoot, "AGENTS.md"))) preexisting.push("AGENTS.md");
12056
+ if (existsSync11(join28(projectRoot, "CLAUDE.md"))) preexisting.push("CLAUDE.md");
12057
+ if (existsSync11(join28(projectRoot, "AGENTS.md"))) preexisting.push("AGENTS.md");
11782
12058
  if (preexisting.length === 0) return null;
11783
12059
  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.`;
11784
12060
  }
@@ -11805,7 +12081,7 @@ function createFabricServer(tracker) {
11805
12081
  const server = new McpServer(
11806
12082
  {
11807
12083
  name: "fabric-knowledge-server",
11808
- version: "2.3.0-rc.11"
12084
+ version: "2.3.0-rc.12"
11809
12085
  },
11810
12086
  {
11811
12087
  instructions: FABRIC_SERVER_INSTRUCTIONS
@@ -11825,9 +12101,9 @@ function createFabricServer(tracker) {
11825
12101
  },
11826
12102
  async (_uri) => {
11827
12103
  const projectRoot = process.env.FABRIC_PROJECT_ROOT ?? process.cwd();
11828
- const path = join27(projectRoot, ".fabric", "bootstrap", "README.md");
12104
+ const path = join28(projectRoot, ".fabric", "bootstrap", "README.md");
11829
12105
  let text = "";
11830
- if (existsSync10(path)) {
12106
+ if (existsSync11(path)) {
11831
12107
  text = await readFile22(path, "utf8");
11832
12108
  }
11833
12109
  return {