@fenglimg/fabric-server 1.6.0 → 1.8.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.
@@ -0,0 +1,2903 @@
1
+ // src/constants.ts
2
+ var AGENTS_MD_RESOURCE_URI = "fabric://bootstrap-readme";
3
+
4
+ // src/cache.ts
5
+ var ContextCache = class {
6
+ constructor(defaultTtlMs = 5e3) {
7
+ this.defaultTtlMs = defaultTtlMs;
8
+ }
9
+ defaultTtlMs;
10
+ // Slot 1: raw AgentsMeta keyed by projectRoot
11
+ metaSlot = /* @__PURE__ */ new Map();
12
+ // Slot 2: GetRulesContext keyed by projectRoot
13
+ contextSlot = /* @__PURE__ */ new Map();
14
+ // Slot 3: audit sliding-window cursor keyed by projectRoot
15
+ auditSlot = /* @__PURE__ */ new Map();
16
+ // ---------------------------------------------------------------------------
17
+ // Generic get / set / invalidate
18
+ // ---------------------------------------------------------------------------
19
+ get(slot, key) {
20
+ const store = this.slotStore(slot);
21
+ const entry = store.get(key);
22
+ if (entry === void 0) {
23
+ return void 0;
24
+ }
25
+ if (entry.expiresAt !== 0 && Date.now() > entry.expiresAt) {
26
+ store.delete(key);
27
+ return void 0;
28
+ }
29
+ return entry.value;
30
+ }
31
+ set(slot, key, value, ttlMs) {
32
+ const store = this.slotStore(slot);
33
+ const resolvedTtl = ttlMs ?? this.defaultTtlMs;
34
+ const expiresAt = resolvedTtl > 0 ? Date.now() + resolvedTtl : 0;
35
+ store.set(key, { value, expiresAt });
36
+ }
37
+ // ---------------------------------------------------------------------------
38
+ // Audit cursor (separate API — not TTL-based)
39
+ // ---------------------------------------------------------------------------
40
+ getAuditCursor(projectRoot) {
41
+ return this.auditSlot.get(projectRoot);
42
+ }
43
+ setAuditCursor(projectRoot, cursor) {
44
+ this.auditSlot.set(projectRoot, cursor);
45
+ }
46
+ resetAuditCursor(projectRoot) {
47
+ this.auditSlot.delete(projectRoot);
48
+ }
49
+ // ---------------------------------------------------------------------------
50
+ // Invalidation
51
+ // ---------------------------------------------------------------------------
52
+ /**
53
+ * Invalidate cache slots based on what changed.
54
+ *
55
+ * @param reason "meta_write" — only the meta slot for this projectRoot
56
+ * "file_watch" — meta + context slots (AGENTS.md may have changed)
57
+ * @param projectRoot Optional; if omitted, clears ALL keys in affected slots.
58
+ */
59
+ invalidate(reason, projectRoot) {
60
+ if (reason === "meta_write") {
61
+ if (projectRoot !== void 0) {
62
+ this.metaSlot.delete(projectRoot);
63
+ } else {
64
+ this.metaSlot.clear();
65
+ }
66
+ return;
67
+ }
68
+ if (projectRoot !== void 0) {
69
+ this.metaSlot.delete(projectRoot);
70
+ this.contextSlot.delete(projectRoot);
71
+ } else {
72
+ this.metaSlot.clear();
73
+ this.contextSlot.clear();
74
+ }
75
+ }
76
+ // ---------------------------------------------------------------------------
77
+ // Helpers
78
+ // ---------------------------------------------------------------------------
79
+ slotStore(slot) {
80
+ return slot === "meta" ? this.metaSlot : this.contextSlot;
81
+ }
82
+ };
83
+ var contextCache = new ContextCache(5e3);
84
+
85
+ // src/meta-reader.ts
86
+ import { readFile } from "fs/promises";
87
+ import { join } from "path";
88
+ import { agentsMetaSchema } from "@fenglimg/fabric-shared";
89
+ import { IOFabricError } from "@fenglimg/fabric-shared/errors";
90
+ import { agentsMetaNodeSchema, agentsMetaSchema as agentsMetaSchema2 } from "@fenglimg/fabric-shared";
91
+ var AgentsMetaFileMissingError = class extends IOFabricError {
92
+ constructor(metaPath, opts) {
93
+ super(`Fabric agents metadata file is missing: ${metaPath}`, {
94
+ actionHint: opts?.actionHint ?? "Run `fab init` to scaffold the .fabric/agents.meta.json file"
95
+ });
96
+ this.metaPath = metaPath;
97
+ }
98
+ metaPath;
99
+ code = "FABRIC_META_MISSING";
100
+ httpStatus = 404;
101
+ };
102
+ var AgentsMetaInvalidError = class extends IOFabricError {
103
+ constructor(metaPath, cause, opts) {
104
+ const detail = cause instanceof Error ? cause.message : String(cause);
105
+ super(`Fabric agents metadata file is invalid: ${metaPath}. ${detail}`, {
106
+ actionHint: opts?.actionHint ?? "Check the agents.meta.json file for schema errors and regenerate if needed"
107
+ });
108
+ this.metaPath = metaPath;
109
+ }
110
+ metaPath;
111
+ code = "FABRIC_META_INVALID";
112
+ httpStatus = 500;
113
+ };
114
+ function getAgentsMetaPath(projectRoot) {
115
+ return join(projectRoot, ".fabric", "agents.meta.json");
116
+ }
117
+ function resolveProjectRoot() {
118
+ return process.env.FABRIC_PROJECT_ROOT ?? process.cwd();
119
+ }
120
+ async function readAgentsMeta(projectRoot) {
121
+ const cached = contextCache.get("meta", projectRoot);
122
+ if (cached !== void 0) {
123
+ return cached;
124
+ }
125
+ const metaPath = getAgentsMetaPath(projectRoot);
126
+ let raw;
127
+ try {
128
+ raw = await readFile(metaPath, "utf8");
129
+ } catch (error) {
130
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
131
+ throw new AgentsMetaFileMissingError(metaPath);
132
+ }
133
+ throw error;
134
+ }
135
+ let parsed;
136
+ try {
137
+ parsed = agentsMetaSchema.parse(JSON.parse(raw));
138
+ } catch (error) {
139
+ throw new AgentsMetaInvalidError(metaPath, error);
140
+ }
141
+ contextCache.set("meta", projectRoot, parsed);
142
+ return parsed;
143
+ }
144
+
145
+ // src/services/_shared.ts
146
+ import { dirname, join as join2, resolve, sep } from "path";
147
+ import { createHash } from "crypto";
148
+ import { mkdir } from "fs/promises";
149
+ import { PathEscapeError } from "@fenglimg/fabric-shared/errors";
150
+ import { atomicWriteText, atomicWriteJson } from "@fenglimg/fabric-shared/node/atomic-write";
151
+ var FABRIC_DIR = ".fabric";
152
+ var LEDGER_FILE = ".intent-ledger.jsonl";
153
+ var LEDGER_PATH = `${FABRIC_DIR}/${LEDGER_FILE}`;
154
+ var LEGACY_LEDGER_PATH = LEDGER_FILE;
155
+ var EVENT_LEDGER_FILE = "events.jsonl";
156
+ var EVENT_LEDGER_PATH = `${FABRIC_DIR}/${EVENT_LEDGER_FILE}`;
157
+ function getLedgerPath(projectRoot) {
158
+ return join2(projectRoot, LEDGER_PATH);
159
+ }
160
+ function getLegacyLedgerPath(projectRoot) {
161
+ return join2(projectRoot, LEGACY_LEDGER_PATH);
162
+ }
163
+ function getEventLedgerPath(projectRoot) {
164
+ return join2(projectRoot, EVENT_LEDGER_PATH);
165
+ }
166
+ async function ensureParentDirectory(path) {
167
+ await mkdir(dirname(path), { recursive: true });
168
+ }
169
+ function sha256(content) {
170
+ return `sha256:${createHash("sha256").update(content).digest("hex")}`;
171
+ }
172
+ function isNodeError(error) {
173
+ return error instanceof Error;
174
+ }
175
+
176
+ // src/services/event-ledger.ts
177
+ import { randomUUID } from "crypto";
178
+ import { existsSync, fsyncSync, openSync, closeSync } from "fs";
179
+ import { readFile as readFile2, truncate, writeFile } from "fs/promises";
180
+ import {
181
+ eventLedgerEventSchema
182
+ } from "@fenglimg/fabric-shared";
183
+ import { createLedgerWriteQueue } from "@fenglimg/fabric-shared/node/atomic-write";
184
+ var ledgerQueue = createLedgerWriteQueue();
185
+ async function appendEventLedgerEvent(projectRoot, event) {
186
+ const eventPath = getEventLedgerPath(projectRoot);
187
+ const nextEvent = eventLedgerEventSchema.parse({
188
+ ...event,
189
+ kind: "fabric-event",
190
+ id: event.id ?? `event:${randomUUID()}`,
191
+ ts: event.ts ?? Date.now(),
192
+ schema_version: 1
193
+ });
194
+ await ensureParentDirectory(eventPath);
195
+ await ledgerQueue.append(eventPath, JSON.stringify(nextEvent));
196
+ return nextEvent;
197
+ }
198
+ async function readEventLedger(projectRoot, options = {}) {
199
+ const eventPath = getEventLedgerPath(projectRoot);
200
+ let raw;
201
+ try {
202
+ raw = await readFile2(eventPath, "utf8");
203
+ } catch (error) {
204
+ if (isNodeError2(error) && error.code === "ENOENT") {
205
+ return { events: [], warnings: [] };
206
+ }
207
+ throw error;
208
+ }
209
+ const warnings = [];
210
+ const lines = raw.split(/\r?\n/);
211
+ const hasTrailingNewline = raw.endsWith("\n");
212
+ let partialLine;
213
+ if (!hasTrailingNewline && lines.length > 0) {
214
+ partialLine = lines.pop();
215
+ }
216
+ if (partialLine !== void 0 && partialLine.trim().length > 0) {
217
+ const fullContentBeforePartial = raw.slice(0, raw.length - partialLine.length);
218
+ const byteOffset = Buffer.byteLength(fullContentBeforePartial, "utf8");
219
+ const byteLength = Buffer.byteLength(partialLine, "utf8");
220
+ warnings.push({
221
+ kind: "partial_write_at_tail",
222
+ byte_offset: byteOffset,
223
+ byte_length: byteLength,
224
+ snippet_first_120: partialLine.slice(0, 120)
225
+ });
226
+ }
227
+ const events = lines.map((line) => line.trim()).filter((line) => line.length > 0).map((line, index) => parseEventLedgerLine(line, index)).filter((entry) => entry !== null).filter((entry) => options.event_type === void 0 || entry.event_type === options.event_type).filter((entry) => options.since === void 0 || entry.ts >= options.since).filter((entry) => options.correlation_id === void 0 || entry.correlation_id === options.correlation_id).filter((entry) => options.session_id === void 0 || entry.session_id === options.session_id);
228
+ return { events, warnings };
229
+ }
230
+ async function truncateLedgerToLastNewline(path) {
231
+ const raw = await readFile2(path);
232
+ const content = raw.toString("utf8");
233
+ if (content.endsWith("\n") || content.length === 0) {
234
+ return { truncated_bytes: 0, corrupted_path: "" };
235
+ }
236
+ const lastNewlineIndex = content.lastIndexOf("\n");
237
+ if (lastNewlineIndex === -1) {
238
+ const corruptedPath2 = `${path}.corrupted.${Date.now()}`;
239
+ await writeFile(corruptedPath2, raw);
240
+ await truncate(path, 0);
241
+ return { truncated_bytes: raw.length, corrupted_path: corruptedPath2 };
242
+ }
243
+ const keepByteLength = Buffer.byteLength(content.slice(0, lastNewlineIndex + 1), "utf8");
244
+ const corruptedBytes = raw.slice(keepByteLength);
245
+ const corruptedPath = `${path}.corrupted.${Date.now()}`;
246
+ await writeFile(corruptedPath, corruptedBytes);
247
+ await truncate(path, keepByteLength);
248
+ return { truncated_bytes: corruptedBytes.length, corrupted_path: corruptedPath };
249
+ }
250
+ function parseEventLedgerLine(line, index) {
251
+ try {
252
+ const parsed = JSON.parse(line);
253
+ const result = eventLedgerEventSchema.safeParse(parsed);
254
+ if (!result.success) {
255
+ return null;
256
+ }
257
+ return {
258
+ ...result.data,
259
+ id: result.data.id || createDerivedId(index, line)
260
+ };
261
+ } catch {
262
+ return null;
263
+ }
264
+ }
265
+ function createDerivedId(index, line) {
266
+ return `event:${index + 1}:${sha256(line).slice("sha256:".length)}`;
267
+ }
268
+ function isNodeError2(error) {
269
+ return error instanceof Error;
270
+ }
271
+ function flushAndSyncEventLedger(projectRoot) {
272
+ const ledgerPath = getEventLedgerPath(projectRoot);
273
+ if (!existsSync(ledgerPath)) return;
274
+ const fd = openSync(ledgerPath, "r+");
275
+ try {
276
+ fsyncSync(fd);
277
+ } finally {
278
+ closeSync(fd);
279
+ }
280
+ }
281
+
282
+ // src/services/rule-meta-builder.ts
283
+ import { readdir, readFile as readFile3 } from "fs/promises";
284
+ import { existsSync as existsSync2, statSync } from "fs";
285
+ import { isAbsolute, join as join3, relative, resolve as resolve2, sep as sep2 } from "path";
286
+ import {
287
+ RULE_TEST_INDEX_SCHEMA_VERSION,
288
+ agentsMetaSchema as agentsMetaSchema3,
289
+ deriveAgentsMetaLayer,
290
+ deriveAgentsMetaStableId,
291
+ deriveAgentsMetaTopologyType,
292
+ ruleTestIndexSchema
293
+ } from "@fenglimg/fabric-shared";
294
+ import { atomicWriteText as atomicWriteText2 } from "@fenglimg/fabric-shared/node/atomic-write";
295
+ async function buildRuleMeta(projectRootInput) {
296
+ const projectRoot = normalizeProjectRoot(projectRootInput);
297
+ assertExistingDirectory(projectRoot);
298
+ const metaPath = join3(projectRoot, ".fabric", "agents.meta.json");
299
+ const ruleTestIndexPath = join3(projectRoot, ".fabric", "rule-test.index.json");
300
+ const existingMeta = await readExistingMeta(metaPath);
301
+ const existingRuleTestIndex = await readExistingRuleTestIndex(ruleTestIndexPath);
302
+ const meta = await computeRulesBasedAgentsMeta(projectRoot, existingMeta);
303
+ const ruleTestIndex = await computeRuleTestIndex(projectRoot, meta, existingRuleTestIndex);
304
+ return {
305
+ meta,
306
+ ruleTestIndex,
307
+ changed: existingMeta === void 0 || stableStringify(existingMeta) !== stableStringify(meta) || existingRuleTestIndex === void 0 || !isSameRuleTestIndex(existingRuleTestIndex, ruleTestIndex)
308
+ };
309
+ }
310
+ async function writeRuleMeta(projectRootInput, options) {
311
+ const projectRoot = normalizeProjectRoot(projectRootInput);
312
+ const metaPath = join3(projectRoot, ".fabric", "agents.meta.json");
313
+ const ruleTestIndexPath = join3(projectRoot, ".fabric", "rule-test.index.json");
314
+ const existingMeta = await readExistingMeta(metaPath);
315
+ const result = await buildRuleMeta(projectRoot);
316
+ if (!result.changed) {
317
+ return result;
318
+ }
319
+ await ensureParentDirectory(metaPath);
320
+ await atomicWriteText2(metaPath, `${JSON.stringify(result.meta, null, 2)}
321
+ `);
322
+ await atomicWriteText2(ruleTestIndexPath, `${JSON.stringify(result.ruleTestIndex, null, 2)}
323
+ `);
324
+ if (existingMeta === void 0 || stableStringify(existingMeta) !== stableStringify(result.meta)) {
325
+ await recordBaselineSynced(projectRoot, {
326
+ previousRevision: existingMeta?.revision,
327
+ revision: result.meta.revision,
328
+ syncedFiles: collectSyncedFiles(existingMeta, result.meta),
329
+ acceptedStableIds: collectStableIds(result.meta),
330
+ driftDetails: collectDriftDetails(existingMeta, result.meta),
331
+ source: options.source
332
+ });
333
+ }
334
+ return result;
335
+ }
336
+ async function computeRulesBasedAgentsMeta(projectRootInput, existingMeta) {
337
+ const projectRoot = normalizeProjectRoot(projectRootInput);
338
+ assertExistingDirectory(projectRoot);
339
+ const previousMeta = existingMeta ?? await readExistingMeta(join3(projectRoot, ".fabric", "agents.meta.json"));
340
+ const existingByContentRef = indexExistingNodesByContentRef(previousMeta);
341
+ const ruleFiles = await findFabricRuleFiles(projectRoot);
342
+ const nodes = {};
343
+ const bootstrapNode = await createBootstrapNode(projectRoot, existingByContentRef.get(".fabric/bootstrap/README.md")?.node);
344
+ if (bootstrapNode !== void 0) {
345
+ nodes.L0 = bootstrapNode;
346
+ }
347
+ for (const contentRef of ruleFiles) {
348
+ const source = await readFile3(join3(projectRoot, contentRef), "utf8");
349
+ const existing = existingByContentRef.get(contentRef);
350
+ const id = deriveNodeId(contentRef);
351
+ const hash = sha256(source);
352
+ const defaults = createDefaultNodeMeta(contentRef);
353
+ const identity = deriveRuleIdentity(contentRef, source, existing?.node);
354
+ nodes[id] = {
355
+ ...defaults,
356
+ ...existing?.node,
357
+ file: contentRef,
358
+ content_ref: contentRef,
359
+ hash,
360
+ stable_id: identity.stableId,
361
+ identity_source: identity.identitySource,
362
+ description: extractRuleDescription(source) ?? existing?.node.description,
363
+ sections: extractRuleSections(source)
364
+ };
365
+ }
366
+ return {
367
+ ...previousMeta ?? {},
368
+ revision: computeRevision(nodes),
369
+ nodes: sortNodes(nodes)
370
+ };
371
+ }
372
+ async function computeRuleTestIndex(projectRootInput, computedMeta, previousIndex) {
373
+ const projectRoot = normalizeProjectRoot(projectRootInput);
374
+ assertExistingDirectory(projectRoot);
375
+ const previousLinks = indexPreviousRuleTestEntries(previousIndex?.links ?? []);
376
+ const previousOrphans = indexPreviousRuleTestEntries(previousIndex?.orphan_annotations ?? []);
377
+ const rulesByStableId = indexRulesByStableId(computedMeta);
378
+ const links = [];
379
+ const orphanAnnotations = [];
380
+ for (const annotation of await findFabricVerifyAnnotations(projectRoot)) {
381
+ const rule = rulesByStableId.get(annotation.stableId);
382
+ const key = createRuleTestEntryKey(annotation.stableId, annotation.testFile, annotation.line);
383
+ if (rule === void 0) {
384
+ const previous2 = previousOrphans.get(key) ?? previousLinks.get(key);
385
+ orphanAnnotations.push({
386
+ rule_stable_id: annotation.stableId,
387
+ test_file: annotation.testFile,
388
+ test_hash: annotation.testHash,
389
+ previous_test_hash: getPreviousTestHash(previous2, annotation.testHash),
390
+ annotation_line: annotation.line
391
+ });
392
+ continue;
393
+ }
394
+ const previous = previousLinks.get(key) ?? previousOrphans.get(key);
395
+ const previousHashes = getPreviousRuleTestHashes(previous, rule.hash, annotation.testHash);
396
+ links.push({
397
+ rule_stable_id: annotation.stableId,
398
+ rule_file: rule.content_ref ?? rule.file,
399
+ rule_hash: rule.hash,
400
+ previous_rule_hash: previousHashes.previousRuleHash,
401
+ test_file: annotation.testFile,
402
+ test_hash: annotation.testHash,
403
+ previous_test_hash: previousHashes.previousTestHash,
404
+ annotation_line: annotation.line
405
+ });
406
+ }
407
+ return {
408
+ schema_version: RULE_TEST_INDEX_SCHEMA_VERSION,
409
+ generated_at: (/* @__PURE__ */ new Date()).toISOString(),
410
+ revision: computedMeta.revision,
411
+ previous_revision: previousIndex?.revision !== void 0 && previousIndex.revision !== computedMeta.revision ? previousIndex.revision : previousIndex?.previous_revision,
412
+ links: links.sort(compareRuleTestEntries),
413
+ orphan_annotations: orphanAnnotations.sort(compareRuleTestEntries)
414
+ };
415
+ }
416
+ function deriveRuleMetaLayer(relativePath) {
417
+ return deriveAgentsMetaLayer(toAgentsCompatiblePath(relativePath));
418
+ }
419
+ function deriveRuleMetaTopologyType(relativePath) {
420
+ return deriveAgentsMetaTopologyType(toAgentsCompatiblePath(relativePath));
421
+ }
422
+ function isSameRuleTestIndex(left, right) {
423
+ return stableStringify(toComparableRuleTestIndex(left)) === stableStringify(toComparableRuleTestIndex(right));
424
+ }
425
+ function stableStringify(value) {
426
+ return JSON.stringify(value, Object.keys(flattenKeys(value)).sort());
427
+ }
428
+ function normalizeProjectRoot(projectRoot) {
429
+ return isAbsolute(projectRoot) ? projectRoot : resolve2(process.cwd(), projectRoot);
430
+ }
431
+ function assertExistingDirectory(projectRoot) {
432
+ if (!existsSync2(projectRoot) || !statSync(projectRoot).isDirectory()) {
433
+ throw new Error(`Target directory does not exist: ${projectRoot}`);
434
+ }
435
+ }
436
+ async function readExistingMeta(metaPath) {
437
+ let raw;
438
+ try {
439
+ raw = await readFile3(metaPath, "utf8");
440
+ } catch (error) {
441
+ if (isNodeError3(error) && error.code === "ENOENT") {
442
+ return void 0;
443
+ }
444
+ throw error;
445
+ }
446
+ try {
447
+ return agentsMetaSchema3.parse(JSON.parse(raw));
448
+ } catch {
449
+ return void 0;
450
+ }
451
+ }
452
+ async function readExistingRuleTestIndex(indexPath) {
453
+ let raw;
454
+ try {
455
+ raw = await readFile3(indexPath, "utf8");
456
+ } catch (error) {
457
+ if (isNodeError3(error) && error.code === "ENOENT") {
458
+ return void 0;
459
+ }
460
+ throw error;
461
+ }
462
+ try {
463
+ return ruleTestIndexSchema.parse(JSON.parse(raw));
464
+ } catch {
465
+ return void 0;
466
+ }
467
+ }
468
+ async function findFabricRuleFiles(projectRoot) {
469
+ const rulesRoot = join3(projectRoot, ".fabric", "rules");
470
+ if (!existsSync2(rulesRoot) || !statSync(rulesRoot).isDirectory()) {
471
+ return [];
472
+ }
473
+ const files = [];
474
+ const stack = [rulesRoot];
475
+ while (stack.length > 0) {
476
+ const current = stack.pop();
477
+ if (current === void 0) {
478
+ continue;
479
+ }
480
+ for (const entry of await readdir(current, { withFileTypes: true })) {
481
+ const absolutePath = join3(current, entry.name);
482
+ const relativePath = toPosixPath(relative(projectRoot, absolutePath));
483
+ if (entry.isDirectory()) {
484
+ stack.push(absolutePath);
485
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
486
+ files.push(relativePath);
487
+ }
488
+ }
489
+ }
490
+ return files.sort();
491
+ }
492
+ async function findFabricVerifyAnnotations(projectRoot) {
493
+ const files = await findTestFiles(projectRoot);
494
+ const annotations = [];
495
+ const annotationPattern = /^\s*\/\/\s*@fabric-verify\s+([A-Za-z0-9][A-Za-z0-9/_-]*)\s*$/u;
496
+ for (const testFile of files) {
497
+ const source = await readFile3(join3(projectRoot, testFile), "utf8");
498
+ const testHash = sha256(source);
499
+ const lines = source.split(/\r?\n/u);
500
+ for (const [index, line] of lines.entries()) {
501
+ const match = annotationPattern.exec(line);
502
+ if (match === null) {
503
+ continue;
504
+ }
505
+ annotations.push({
506
+ stableId: match[1],
507
+ testFile,
508
+ testHash,
509
+ line: index + 1
510
+ });
511
+ }
512
+ }
513
+ return annotations.sort(compareAnnotationEntries);
514
+ }
515
+ async function findTestFiles(projectRoot) {
516
+ const ignoredRootSegments = /* @__PURE__ */ new Set([".git", ".fabric", "node_modules", "dist", "build", "coverage"]);
517
+ const files = [];
518
+ const stack = [projectRoot];
519
+ while (stack.length > 0) {
520
+ const current = stack.pop();
521
+ if (current === void 0) {
522
+ continue;
523
+ }
524
+ for (const entry of await readdir(current, { withFileTypes: true })) {
525
+ const absolutePath = join3(current, entry.name);
526
+ const relativePath = toPosixPath(relative(projectRoot, absolutePath));
527
+ const [rootSegment] = relativePath.split("/");
528
+ if (entry.isDirectory()) {
529
+ if (!ignoredRootSegments.has(rootSegment) && !ignoredRootSegments.has(entry.name)) {
530
+ stack.push(absolutePath);
531
+ }
532
+ continue;
533
+ }
534
+ if (entry.isFile() && isTestFile(relativePath)) {
535
+ files.push(relativePath);
536
+ }
537
+ }
538
+ }
539
+ return files.sort();
540
+ }
541
+ function isTestFile(relativePath) {
542
+ return /\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(relativePath);
543
+ }
544
+ function indexRulesByStableId(meta) {
545
+ const rules = /* @__PURE__ */ new Map();
546
+ for (const node of Object.values(meta.nodes)) {
547
+ if (node.stable_id !== void 0) {
548
+ rules.set(node.stable_id, node);
549
+ }
550
+ }
551
+ return rules;
552
+ }
553
+ function indexPreviousRuleTestEntries(entries) {
554
+ const previous = /* @__PURE__ */ new Map();
555
+ for (const entry of entries) {
556
+ previous.set(createRuleTestEntryKey(entry.rule_stable_id, entry.test_file, entry.annotation_line), {
557
+ rule_hash: entry.rule_hash,
558
+ previous_rule_hash: entry.previous_rule_hash,
559
+ test_hash: entry.test_hash,
560
+ previous_test_hash: entry.previous_test_hash
561
+ });
562
+ }
563
+ return previous;
564
+ }
565
+ function createRuleTestEntryKey(stableId, testFile, line) {
566
+ return `${stableId}\0${testFile}\0${line}`;
567
+ }
568
+ function getPreviousRuleTestHashes(previous, ruleHash, testHash) {
569
+ if (previous === void 0) {
570
+ return {};
571
+ }
572
+ return {
573
+ previousRuleHash: previous.rule_hash !== void 0 && previous.rule_hash !== ruleHash ? previous.rule_hash : previous.previous_rule_hash,
574
+ previousTestHash: previous.test_hash !== testHash ? previous.test_hash : previous.previous_test_hash
575
+ };
576
+ }
577
+ function getPreviousTestHash(previous, testHash) {
578
+ if (previous === void 0) {
579
+ return void 0;
580
+ }
581
+ return previous.test_hash !== testHash ? previous.test_hash : previous.previous_test_hash;
582
+ }
583
+ function compareRuleTestEntries(left, right) {
584
+ return left.rule_stable_id.localeCompare(right.rule_stable_id) || left.test_file.localeCompare(right.test_file) || left.annotation_line - right.annotation_line;
585
+ }
586
+ function compareAnnotationEntries(left, right) {
587
+ return left.stableId.localeCompare(right.stableId) || left.testFile.localeCompare(right.testFile) || left.line - right.line;
588
+ }
589
+ function toComparableRuleTestIndex(index) {
590
+ const { generated_at: _generatedAt, ...comparable } = index;
591
+ return comparable;
592
+ }
593
+ function indexExistingNodesByContentRef(existingMeta) {
594
+ const byContentRef = /* @__PURE__ */ new Map();
595
+ for (const [id, node] of Object.entries(existingMeta?.nodes ?? {})) {
596
+ byContentRef.set(toPosixPath(node.content_ref ?? node.file), { id, node });
597
+ }
598
+ return byContentRef;
599
+ }
600
+ function deriveNodeId(file) {
601
+ if (file === ".fabric/bootstrap/README.md") {
602
+ return "L0";
603
+ }
604
+ const layer = deriveRuleMetaLayer(file);
605
+ const relativeStem = getRuleRelativeStem(file);
606
+ return `${layer}/${relativeStem}`;
607
+ }
608
+ function createDefaultNodeMeta(contentRef) {
609
+ const layer = deriveRuleMetaLayer(contentRef);
610
+ const topologyType = deriveRuleMetaTopologyType(contentRef);
611
+ return {
612
+ file: contentRef,
613
+ content_ref: contentRef,
614
+ scope_glob: deriveScopeGlob(contentRef),
615
+ deps: layer === "L0" ? [] : ["L0"],
616
+ priority: layer === "L0" ? "high" : "medium",
617
+ level: layer,
618
+ layer,
619
+ topology_type: topologyType,
620
+ hash: ""
621
+ };
622
+ }
623
+ async function createBootstrapNode(projectRoot, existing) {
624
+ const contentRef = ".fabric/bootstrap/README.md";
625
+ const bootstrapPath = join3(projectRoot, contentRef);
626
+ if (!existsSync2(bootstrapPath)) {
627
+ return void 0;
628
+ }
629
+ const hash = sha256(await readFile3(bootstrapPath, "utf8"));
630
+ const identity = {
631
+ stableId: existing?.stable_id ?? deriveAgentsMetaStableId(contentRef),
632
+ identitySource: existing?.identity_source ?? "derived"
633
+ };
634
+ return {
635
+ ...createDefaultNodeMeta(contentRef),
636
+ ...existing,
637
+ file: contentRef,
638
+ content_ref: contentRef,
639
+ hash,
640
+ stable_id: identity.stableId,
641
+ identity_source: identity.identitySource
642
+ };
643
+ }
644
+ function deriveScopeGlob(contentRef) {
645
+ if (contentRef === ".fabric/bootstrap/README.md") {
646
+ return "**";
647
+ }
648
+ const stem = getRuleRelativeStem(contentRef);
649
+ const segments = stem.split("/").filter(Boolean);
650
+ if (segments.length === 0 || stem === "root") {
651
+ return "**";
652
+ }
653
+ if (segments[0] === "_cross") {
654
+ return "**";
655
+ }
656
+ if (segments.at(-1) === "rules") {
657
+ segments.pop();
658
+ }
659
+ const scopePath = segments.join("/");
660
+ return scopePath === "" ? "**" : `${scopePath}/**`;
661
+ }
662
+ function getRuleRelativeStem(contentRef) {
663
+ return contentRef.replace(/^\.fabric\/rules\//u, "").replace(/\.md$/u, "");
664
+ }
665
+ function toAgentsCompatiblePath(contentRef) {
666
+ return contentRef.replace(/^\.fabric\/rules\//u, ".fabric/agents/");
667
+ }
668
+ function sortNodes(nodes) {
669
+ return Object.fromEntries(Object.entries(nodes).sort(([left], [right]) => left.localeCompare(right)));
670
+ }
671
+ function computeRevision(nodes) {
672
+ const revisionSource = Object.entries(sortNodes(nodes)).map(([id, node]) => [id, node.hash, node.stable_id ?? "", node.identity_source ?? ""].join("|")).join("\n");
673
+ return sha256(revisionSource);
674
+ }
675
+ function collectSyncedFiles(existingMeta, computedMeta) {
676
+ if (existingMeta === void 0) {
677
+ return Object.values(computedMeta.nodes).map((node) => node.content_ref ?? node.file).sort();
678
+ }
679
+ const existingByContentRef = indexExistingNodesByContentRef(existingMeta);
680
+ return Object.values(computedMeta.nodes).filter((node) => {
681
+ const existing = existingByContentRef.get(node.content_ref ?? node.file)?.node;
682
+ return existing === void 0 || existing.hash !== node.hash || existing.stable_id !== node.stable_id || existing.identity_source !== node.identity_source;
683
+ }).map((node) => node.content_ref ?? node.file).sort();
684
+ }
685
+ function collectStableIds(meta) {
686
+ return Object.values(meta.nodes).map((node) => node.stable_id).filter((stableId) => stableId !== void 0).sort();
687
+ }
688
+ function collectDriftDetails(existingMeta, computedMeta) {
689
+ if (existingMeta === void 0) {
690
+ return [];
691
+ }
692
+ const computedByContentRef = indexExistingNodesByContentRef(computedMeta);
693
+ return Object.values(existingMeta.nodes).map((existingNode) => {
694
+ const contentRef = existingNode.content_ref ?? existingNode.file;
695
+ const computedNode = computedByContentRef.get(contentRef)?.node;
696
+ const stableId = existingNode.stable_id ?? computedNode?.stable_id;
697
+ if (computedNode === void 0 || stableId === void 0 || existingNode.hash === computedNode.hash) {
698
+ return null;
699
+ }
700
+ return {
701
+ file: contentRef,
702
+ stable_id: stableId,
703
+ expected_hash: existingNode.hash,
704
+ actual_hash: computedNode.hash
705
+ };
706
+ }).filter((detail) => detail !== null);
707
+ }
708
+ async function recordBaselineSynced(projectRoot, input) {
709
+ if (input.driftDetails.length > 0) {
710
+ await appendEventLedgerEvent(projectRoot, {
711
+ event_type: "rule_drift_detected",
712
+ revision: input.previousRevision ?? input.revision,
713
+ drifted_stable_ids: input.driftDetails.map((detail) => detail.stable_id),
714
+ missing_files: input.driftDetails.filter((detail) => detail.actual_hash === null).map((detail) => detail.file),
715
+ stale_files: input.driftDetails.filter((detail) => detail.actual_hash !== null).map((detail) => detail.file),
716
+ details: input.driftDetails
717
+ });
718
+ }
719
+ await appendEventLedgerEvent(projectRoot, {
720
+ event_type: "rule_baseline_accepted",
721
+ revision: input.revision,
722
+ previous_revision: input.previousRevision,
723
+ accepted_stable_ids: input.acceptedStableIds,
724
+ source: input.source
725
+ });
726
+ await appendEventLedgerEvent(projectRoot, {
727
+ event_type: "baseline_synced",
728
+ revision: input.revision,
729
+ previous_revision: input.previousRevision,
730
+ synced_files: input.syncedFiles,
731
+ accepted_stable_ids: input.acceptedStableIds,
732
+ source: input.source
733
+ });
734
+ }
735
+ function flattenKeys(value, keys = {}) {
736
+ if (value && typeof value === "object") {
737
+ for (const [key, child] of Object.entries(value)) {
738
+ keys[key] = true;
739
+ flattenKeys(child, keys);
740
+ }
741
+ }
742
+ return keys;
743
+ }
744
+ function toPosixPath(path) {
745
+ return path.split(sep2).join("/");
746
+ }
747
+ function deriveRuleIdentity(file, source, existing) {
748
+ const declaredStableId = extractDeclaredStableId(source);
749
+ const derivedStableId = deriveAgentsMetaStableId(toAgentsCompatiblePath(file));
750
+ if (declaredStableId !== void 0) {
751
+ return {
752
+ stableId: declaredStableId,
753
+ identitySource: "declared"
754
+ };
755
+ }
756
+ if (existing?.identity_source === "declared" && existing.stable_id !== void 0 && existing.stable_id !== derivedStableId) {
757
+ return {
758
+ stableId: existing.stable_id,
759
+ identitySource: "declared"
760
+ };
761
+ }
762
+ return {
763
+ stableId: derivedStableId,
764
+ identitySource: "derived"
765
+ };
766
+ }
767
+ function extractDeclaredStableId(source) {
768
+ 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);
769
+ return match?.[1];
770
+ }
771
+ function extractRuleDescription(source) {
772
+ const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/u.exec(source);
773
+ const description = frontmatter === null ? void 0 : extractDescriptionFromFrontmatter(frontmatter[1]);
774
+ if (description !== void 0) {
775
+ return description;
776
+ }
777
+ const heading = /^#\s+(.+?)\s*$/mu.exec(source);
778
+ const summary = heading?.[1]?.trim();
779
+ if (summary === void 0 || summary.length === 0) {
780
+ return void 0;
781
+ }
782
+ return {
783
+ summary,
784
+ intent_clues: [],
785
+ tech_stack: [],
786
+ impact: [],
787
+ must_read_if: summary
788
+ };
789
+ }
790
+ function extractRuleSections(source) {
791
+ const sections = Array.from(source.matchAll(/^(?:#{2,6})\s+\[([A-Z_]+)\]\s*$/gmu)).map((match) => match[1]).filter((section, index, all) => all.indexOf(section) === index);
792
+ return sections.length > 0 ? sections : void 0;
793
+ }
794
+ function extractDescriptionFromFrontmatter(frontmatter) {
795
+ const summary = extractScalar(frontmatter, "summary") ?? extractScalar(frontmatter, "description");
796
+ if (summary === void 0) {
797
+ return void 0;
798
+ }
799
+ return {
800
+ summary,
801
+ intent_clues: extractInlineArray(frontmatter, "intent_clues"),
802
+ tech_stack: extractInlineArray(frontmatter, "tech_stack"),
803
+ impact: extractInlineArray(frontmatter, "impact"),
804
+ must_read_if: extractScalar(frontmatter, "must_read_if") ?? summary,
805
+ entities: extractInlineArray(frontmatter, "entities")
806
+ };
807
+ }
808
+ function extractScalar(frontmatter, key) {
809
+ const pattern = new RegExp(`^${escapeRegExp(key)}:\\s*(.+?)\\s*$`, "mu");
810
+ const match = pattern.exec(frontmatter);
811
+ if (match === null) {
812
+ return void 0;
813
+ }
814
+ return unquote(match[1].trim());
815
+ }
816
+ function extractInlineArray(frontmatter, key) {
817
+ const pattern = new RegExp(`^${escapeRegExp(key)}:\\s*\\[(.*?)\\]\\s*$`, "mu");
818
+ const match = pattern.exec(frontmatter);
819
+ if (match === null) {
820
+ return [];
821
+ }
822
+ return match[1].split(",").map((item) => unquote(item.trim())).filter((item) => item.length > 0);
823
+ }
824
+ function unquote(value) {
825
+ return value.replace(/^["'](.*)["']$/u, "$1");
826
+ }
827
+ function escapeRegExp(value) {
828
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
829
+ }
830
+ function isNodeError3(error) {
831
+ return error instanceof Error;
832
+ }
833
+
834
+ // src/services/rule-sync.ts
835
+ import { readdir as readdir2, readFile as readFile4, stat } from "fs/promises";
836
+ import { existsSync as existsSync3, statSync as statSync2 } from "fs";
837
+ import { join as join4, relative as relative2, sep as sep3 } from "path";
838
+ import { RuleValidationError } from "@fenglimg/fabric-shared/errors";
839
+ var lastSyncState = /* @__PURE__ */ new Map();
840
+ var freshSyncCooldown = /* @__PURE__ */ new Map();
841
+ var SYNC_COOLDOWN_MS = 500;
842
+ function invalidateRuleSyncCooldown(projectRoot) {
843
+ freshSyncCooldown.delete(projectRoot);
844
+ }
845
+ async function readMetaEntries(projectRoot) {
846
+ const metaPath = join4(projectRoot, ".fabric", "agents.meta.json");
847
+ const map = /* @__PURE__ */ new Map();
848
+ let raw;
849
+ try {
850
+ raw = await readFile4(metaPath, "utf8");
851
+ } catch {
852
+ return map;
853
+ }
854
+ let parsed;
855
+ try {
856
+ parsed = JSON.parse(raw);
857
+ } catch {
858
+ return map;
859
+ }
860
+ for (const node of Object.values(parsed.nodes ?? {})) {
861
+ const path = node.content_ref ?? node.file;
862
+ const stable_id = node.stable_id;
863
+ const content_hash = node.hash;
864
+ if (path !== void 0 && stable_id !== void 0 && content_hash !== void 0) {
865
+ map.set(path, { stable_id, path, content_hash });
866
+ }
867
+ }
868
+ return map;
869
+ }
870
+ async function findRuleFiles(projectRoot) {
871
+ const rulesRoot = join4(projectRoot, ".fabric", "rules");
872
+ if (!existsSync3(rulesRoot) || !statSync2(rulesRoot).isDirectory()) {
873
+ return [];
874
+ }
875
+ const files = [];
876
+ const stack = [rulesRoot];
877
+ while (stack.length > 0) {
878
+ const current = stack.pop();
879
+ if (current === void 0) {
880
+ continue;
881
+ }
882
+ for (const entry of await readdir2(current, { withFileTypes: true })) {
883
+ const absolutePath = join4(current, entry.name);
884
+ if (entry.isDirectory()) {
885
+ stack.push(absolutePath);
886
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
887
+ const rel = toPosixPath2(relative2(projectRoot, absolutePath));
888
+ files.push(rel);
889
+ }
890
+ }
891
+ }
892
+ return files.sort();
893
+ }
894
+ function toPosixPath2(p) {
895
+ return p.split(sep3).join("/");
896
+ }
897
+ function validateFrontmatter(source, filePath, throwOnInvalid) {
898
+ if (!source.startsWith("---")) {
899
+ return null;
900
+ }
901
+ const endIdx = source.indexOf("\n---", 3);
902
+ if (endIdx === -1) {
903
+ const msg = `Unterminated YAML frontmatter in ${filePath}`;
904
+ if (throwOnInvalid) {
905
+ throw new RuleValidationError(msg, {
906
+ actionHint: "Run `fab doctor --fix` to repair frontmatter",
907
+ fixable: true,
908
+ details: { file: filePath }
909
+ });
910
+ }
911
+ return {
912
+ code: "rule_frontmatter_invalid",
913
+ file: filePath,
914
+ action_hint: "Run `fab doctor --fix` to repair frontmatter"
915
+ };
916
+ }
917
+ const frontmatter = source.slice(3, endIdx).trim();
918
+ for (const line of frontmatter.split("\n")) {
919
+ const trimmed = line.trim();
920
+ if (trimmed.length === 0 || trimmed.startsWith("#")) {
921
+ continue;
922
+ }
923
+ if (!trimmed.includes(":") && !trimmed.startsWith("-")) {
924
+ const msg = `Invalid YAML frontmatter line "${trimmed}" in ${filePath}`;
925
+ if (throwOnInvalid) {
926
+ throw new RuleValidationError(msg, {
927
+ actionHint: "Run `fab doctor --fix` to repair frontmatter",
928
+ fixable: true,
929
+ details: { file: filePath, line: trimmed }
930
+ });
931
+ }
932
+ return {
933
+ code: "rule_frontmatter_invalid",
934
+ file: filePath,
935
+ action_hint: "Run `fab doctor --fix` to repair frontmatter"
936
+ };
937
+ }
938
+ }
939
+ return null;
940
+ }
941
+ async function processSingleFile(projectRoot, relPath, metaEntry, source, throwOnInvalidFrontmatter) {
942
+ const absPath = join4(projectRoot, relPath);
943
+ try {
944
+ await stat(absPath);
945
+ } catch {
946
+ if (metaEntry !== void 0) {
947
+ return {
948
+ event: {
949
+ type: "rule_removed",
950
+ stable_id: metaEntry.stable_id,
951
+ path: relPath,
952
+ prev_hash: metaEntry.content_hash,
953
+ new_hash: null,
954
+ changed_fields: ["content"],
955
+ source
956
+ },
957
+ warning: null
958
+ };
959
+ }
960
+ return { event: null, warning: null };
961
+ }
962
+ let content;
963
+ try {
964
+ content = await readFile4(absPath, "utf8");
965
+ } catch {
966
+ return { event: null, warning: null };
967
+ }
968
+ const newHash = sha256(content);
969
+ const now = Date.now();
970
+ const debounce = lastSyncState.get(absPath);
971
+ if (debounce !== void 0 && newHash === debounce.hash && now - debounce.ts < 500) {
972
+ return { event: null, warning: null };
973
+ }
974
+ if (metaEntry !== void 0 && newHash === metaEntry.content_hash) {
975
+ lastSyncState.set(absPath, { ts: now, hash: newHash });
976
+ return { event: null, warning: null };
977
+ }
978
+ const warning = validateFrontmatter(content, relPath, throwOnInvalidFrontmatter);
979
+ if (warning !== null) {
980
+ lastSyncState.set(absPath, { ts: now, hash: newHash });
981
+ return { event: null, warning };
982
+ }
983
+ const prevHash = metaEntry?.content_hash ?? debounce?.hash ?? null;
984
+ const stableId = metaEntry?.stable_id ?? relPath;
985
+ const eventType = metaEntry === void 0 ? "rule_added" : "rule_content_changed";
986
+ lastSyncState.set(absPath, { ts: now, hash: newHash });
987
+ return {
988
+ event: {
989
+ type: eventType,
990
+ stable_id: stableId,
991
+ path: relPath,
992
+ prev_hash: prevHash,
993
+ new_hash: newHash,
994
+ changed_fields: ["content"],
995
+ source
996
+ },
997
+ warning: null
998
+ };
999
+ }
1000
+ async function appendRuleSyncEvents(projectRoot, events) {
1001
+ if (events.length === 0) {
1002
+ return;
1003
+ }
1004
+ const driftedIds = events.map((e) => e.stable_id);
1005
+ const missingFiles = events.filter((e) => e.type === "rule_removed").map((e) => e.path);
1006
+ const staleFiles = events.filter((e) => e.type !== "rule_removed").map((e) => e.path);
1007
+ if (missingFiles.length > 0 || staleFiles.length > 0) {
1008
+ await appendEventLedgerEvent(projectRoot, {
1009
+ event_type: "rule_drift_detected",
1010
+ drifted_stable_ids: driftedIds,
1011
+ missing_files: missingFiles,
1012
+ stale_files: staleFiles
1013
+ });
1014
+ }
1015
+ }
1016
+ async function ensureRulesFresh(projectRoot, opts) {
1017
+ const mode = opts?.mode ?? "incremental";
1018
+ const cooldownExpiry = freshSyncCooldown.get(projectRoot);
1019
+ if (cooldownExpiry !== void 0 && Date.now() < cooldownExpiry && mode !== "full") {
1020
+ return { status: "fresh", events: [], warnings: [] };
1021
+ }
1022
+ const throwOnInvalidFrontmatter = opts?.throwOnInvalidFrontmatter ?? false;
1023
+ const source = "ensureRulesFresh";
1024
+ const events = [];
1025
+ const warnings = [];
1026
+ const metaEntries = await readMetaEntries(projectRoot);
1027
+ const ruleFiles = await findRuleFiles(projectRoot);
1028
+ const filesToCheck = ruleFiles;
1029
+ for (const relPath of filesToCheck) {
1030
+ const metaEntry = metaEntries.get(relPath);
1031
+ const result = await processSingleFile(projectRoot, relPath, metaEntry, source, throwOnInvalidFrontmatter);
1032
+ if (result.event !== null) {
1033
+ events.push(result.event);
1034
+ }
1035
+ if (result.warning !== null) {
1036
+ warnings.push(result.warning);
1037
+ }
1038
+ }
1039
+ for (const [relPath, entry] of metaEntries) {
1040
+ if (!ruleFiles.includes(relPath)) {
1041
+ const absPath = join4(projectRoot, relPath);
1042
+ if (!existsSync3(absPath)) {
1043
+ events.push({
1044
+ type: "rule_removed",
1045
+ stable_id: entry.stable_id,
1046
+ path: relPath,
1047
+ prev_hash: entry.content_hash,
1048
+ new_hash: null,
1049
+ changed_fields: ["content"],
1050
+ source
1051
+ });
1052
+ }
1053
+ }
1054
+ }
1055
+ if (events.length === 0 && warnings.length === 0) {
1056
+ freshSyncCooldown.set(projectRoot, Date.now() + SYNC_COOLDOWN_MS);
1057
+ return { status: "fresh", events: [], warnings: [] };
1058
+ }
1059
+ if (events.length > 0) {
1060
+ await appendRuleSyncEvents(projectRoot, events);
1061
+ contextCache.invalidate("file_watch", projectRoot);
1062
+ }
1063
+ freshSyncCooldown.delete(projectRoot);
1064
+ const status = warnings.length > 0 ? "errors" : "reconciled";
1065
+ return {
1066
+ status,
1067
+ events,
1068
+ warnings,
1069
+ reconciled_files: events.map((e) => e.path)
1070
+ };
1071
+ }
1072
+ async function reconcileRules(projectRoot, opts) {
1073
+ freshSyncCooldown.delete(projectRoot);
1074
+ const trigger = opts?.trigger;
1075
+ const startTime = Date.now();
1076
+ const source = "reconcileRules";
1077
+ const events = [];
1078
+ const warnings = [];
1079
+ const metaEntries = await readMetaEntries(projectRoot);
1080
+ const ruleFiles = await findRuleFiles(projectRoot);
1081
+ for (const relPath of ruleFiles) {
1082
+ const metaEntry = metaEntries.get(relPath);
1083
+ const result = await processSingleFile(projectRoot, relPath, metaEntry, source, false);
1084
+ if (result.event !== null) {
1085
+ events.push(result.event);
1086
+ }
1087
+ if (result.warning !== null) {
1088
+ warnings.push(result.warning);
1089
+ }
1090
+ }
1091
+ for (const [relPath, entry] of metaEntries) {
1092
+ if (!ruleFiles.includes(relPath)) {
1093
+ const absPath = join4(projectRoot, relPath);
1094
+ if (!existsSync3(absPath)) {
1095
+ events.push({
1096
+ type: "rule_removed",
1097
+ stable_id: entry.stable_id,
1098
+ path: relPath,
1099
+ prev_hash: entry.content_hash,
1100
+ new_hash: null,
1101
+ changed_fields: ["content"],
1102
+ source
1103
+ });
1104
+ }
1105
+ }
1106
+ }
1107
+ if (events.length > 0) {
1108
+ await writeRuleMeta(projectRoot, { source: "sync_meta" });
1109
+ await appendRuleSyncEvents(projectRoot, events);
1110
+ contextCache.invalidate("file_watch", projectRoot);
1111
+ }
1112
+ const duration_ms = Date.now() - startTime;
1113
+ const reconciledFiles = events.map((e) => e.path);
1114
+ if (trigger !== void 0 && events.length > 0) {
1115
+ if (trigger === "startup") {
1116
+ await appendEventLedgerEvent(projectRoot, {
1117
+ event_type: "meta_reconciled_on_startup",
1118
+ reconciled_files: reconciledFiles,
1119
+ duration_ms,
1120
+ source: "reconcileRules"
1121
+ });
1122
+ } else {
1123
+ await appendEventLedgerEvent(projectRoot, {
1124
+ event_type: "meta_reconciled",
1125
+ reconciled_files: reconciledFiles,
1126
+ duration_ms,
1127
+ trigger,
1128
+ source: "reconcileRules"
1129
+ });
1130
+ }
1131
+ }
1132
+ if (events.length === 0 && warnings.length === 0) {
1133
+ return { status: "fresh", events: [], warnings: [] };
1134
+ }
1135
+ const status = warnings.length > 0 ? "errors" : "reconciled";
1136
+ return {
1137
+ status,
1138
+ events,
1139
+ warnings,
1140
+ reconciled_files: reconciledFiles
1141
+ };
1142
+ }
1143
+
1144
+ // src/services/doctor.ts
1145
+ import { existsSync as existsSync4, mkdirSync, readdirSync, readFileSync, rmdirSync, renameSync, statSync as statSync3 } from "fs";
1146
+ import { access, readFile as readFile7, writeFile as writeFile2 } from "fs/promises";
1147
+ import { constants } from "fs";
1148
+ import { isAbsolute as isAbsolute3, join as join8, posix as posix2, resolve as resolve4 } from "path";
1149
+ import {
1150
+ agentsMetaSchema as agentsMetaSchema4,
1151
+ forensicReportSchema,
1152
+ ruleTestIndexSchema as ruleTestIndexSchema2
1153
+ } from "@fenglimg/fabric-shared";
1154
+ import { detectFramework } from "@fenglimg/fabric-shared/node";
1155
+
1156
+ // src/services/rule-sections.ts
1157
+ import { readFile as readFile6 } from "fs/promises";
1158
+ import { join as join7 } from "path";
1159
+
1160
+ // src/services/audit-log.ts
1161
+ import { open, stat as stat2 } from "fs/promises";
1162
+ import { isAbsolute as isAbsolute2, join as join5, posix, relative as relative3, resolve as resolve3 } from "path";
1163
+ var AUDIT_LOG_FILE = `${FABRIC_DIR}/audit.jsonl`;
1164
+ var DEFAULT_AUDIT_WINDOW_MS = 5 * 60 * 1e3;
1165
+ async function appendGetRulesAuditEvent(projectRoot, input) {
1166
+ const entry = {
1167
+ kind: "audit-event",
1168
+ event: "get_rules",
1169
+ ts: input.ts ?? Date.now(),
1170
+ path: normalizeAuditPath(projectRoot, input.path),
1171
+ client_hash: input.client_hash
1172
+ };
1173
+ await appendAuditLogEventLedgerEvents(projectRoot, [entry], {
1174
+ rule_context: {
1175
+ required_stable_ids: input.required_stable_ids,
1176
+ ai_selectable_stable_ids: input.ai_selectable_stable_ids,
1177
+ final_stable_ids: input.final_stable_ids
1178
+ },
1179
+ correlation_id: input.correlation_id,
1180
+ session_id: input.session_id
1181
+ });
1182
+ return entry;
1183
+ }
1184
+ async function appendRuleSelectionAuditEvent(projectRoot, input) {
1185
+ const entry = {
1186
+ kind: "audit-event",
1187
+ event: "rule_selection",
1188
+ ts: input.ts ?? Date.now(),
1189
+ path: normalizeAuditPath(projectRoot, input.path),
1190
+ selection_token: input.selection_token,
1191
+ target_paths: input.target_paths.map((path) => normalizeAuditPath(projectRoot, path)),
1192
+ required_stable_ids: input.required_stable_ids,
1193
+ ai_selectable_stable_ids: input.ai_selectable_stable_ids,
1194
+ ai_selected_stable_ids: input.ai_selected_stable_ids,
1195
+ final_stable_ids: input.final_stable_ids,
1196
+ ai_selection_reasons: input.ai_selection_reasons,
1197
+ rejected_stable_ids: input.rejected_stable_ids,
1198
+ ignored_stable_ids: input.ignored_stable_ids
1199
+ };
1200
+ await appendAuditLogEventLedgerEvents(projectRoot, [entry], {
1201
+ correlation_id: input.correlation_id,
1202
+ session_id: input.session_id
1203
+ });
1204
+ return entry;
1205
+ }
1206
+ function normalizeAuditPath(projectRoot, value) {
1207
+ const normalizedProjectRoot = resolve3(projectRoot);
1208
+ const candidate = isAbsolute2(value) ? resolve3(value) : resolve3(normalizedProjectRoot, value);
1209
+ const relativePath = relative3(normalizedProjectRoot, candidate);
1210
+ if (relativePath.length > 0 && relativePath !== "." && !relativePath.startsWith("..") && !isAbsolute2(relativePath)) {
1211
+ return posix.normalize(relativePath.split("\\").join("/"));
1212
+ }
1213
+ return posix.normalize(value.replaceAll("\\", "/"));
1214
+ }
1215
+ async function appendAuditLogEventLedgerEvents(projectRoot, entries, metadata = {}) {
1216
+ for (const entry of entries) {
1217
+ if (entry.event === "get_rules") {
1218
+ await appendEventLedgerEvent(projectRoot, {
1219
+ event_type: "rule_context_planned",
1220
+ ts: entry.ts,
1221
+ target_paths: [entry.path],
1222
+ required_stable_ids: metadata.rule_context?.required_stable_ids ?? [],
1223
+ ai_selectable_stable_ids: metadata.rule_context?.ai_selectable_stable_ids ?? [],
1224
+ final_stable_ids: metadata.rule_context?.final_stable_ids ?? [],
1225
+ client_hash: entry.client_hash,
1226
+ correlation_id: metadata.correlation_id,
1227
+ session_id: metadata.session_id
1228
+ });
1229
+ continue;
1230
+ }
1231
+ if (entry.event === "rule_selection") {
1232
+ await appendEventLedgerEvent(projectRoot, {
1233
+ event_type: "rule_selection",
1234
+ ts: entry.ts,
1235
+ selection_token: entry.selection_token,
1236
+ target_paths: entry.target_paths,
1237
+ required_stable_ids: entry.required_stable_ids,
1238
+ ai_selectable_stable_ids: entry.ai_selectable_stable_ids,
1239
+ ai_selected_stable_ids: entry.ai_selected_stable_ids,
1240
+ final_stable_ids: entry.final_stable_ids,
1241
+ ai_selection_reasons: entry.ai_selection_reasons,
1242
+ rejected_stable_ids: entry.rejected_stable_ids,
1243
+ ignored_stable_ids: entry.ignored_stable_ids,
1244
+ correlation_id: metadata.correlation_id,
1245
+ session_id: metadata.session_id
1246
+ });
1247
+ continue;
1248
+ }
1249
+ await appendEventLedgerEvent(projectRoot, {
1250
+ event_type: "edit_intent_checked",
1251
+ ts: entry.ts,
1252
+ path: entry.path,
1253
+ compliant: entry.compliant,
1254
+ intent: entry.intent,
1255
+ ledger_entry_id: entry.ledger_entry_id,
1256
+ ledger_source: "ai",
1257
+ matched_rule_context_ts: entry.matched_get_rules_ts,
1258
+ window_ms: entry.window_ms,
1259
+ correlation_id: metadata.correlation_id,
1260
+ session_id: metadata.session_id
1261
+ });
1262
+ }
1263
+ }
1264
+
1265
+ // src/services/get-rules.ts
1266
+ import { readFile as readFile5 } from "fs/promises";
1267
+ import { join as join6 } from "path";
1268
+ import { minimatch } from "minimatch";
1269
+ var PRIORITY_ORDER = {
1270
+ high: 0,
1271
+ medium: 1,
1272
+ low: 2
1273
+ };
1274
+ async function getRules(projectRoot, input) {
1275
+ const context = await loadGetRulesContext(projectRoot);
1276
+ const stale = input.client_hash !== void 0 && input.client_hash !== context.meta.revision;
1277
+ const matchedNodes = matchRuleNodes(context.meta, input.path);
1278
+ const requiredStableIds = matchedNodes.filter((node) => node.level === "L2").map((node) => node.stable_id);
1279
+ const aiSelectableStableIds = matchedNodes.filter((node) => node.level === "L1").map((node) => node.stable_id);
1280
+ const rules = await resolveRulesForPath(projectRoot, context, input.path);
1281
+ const result = {
1282
+ revision_hash: context.meta.revision,
1283
+ stale,
1284
+ rules
1285
+ };
1286
+ try {
1287
+ await appendGetRulesAuditEvent(projectRoot, {
1288
+ path: input.path,
1289
+ client_hash: input.client_hash,
1290
+ required_stable_ids: requiredStableIds,
1291
+ ai_selectable_stable_ids: aiSelectableStableIds,
1292
+ final_stable_ids: [...requiredStableIds, ...aiSelectableStableIds],
1293
+ correlation_id: input.correlation_id,
1294
+ session_id: input.session_id
1295
+ });
1296
+ } catch {
1297
+ }
1298
+ return result;
1299
+ }
1300
+ async function loadGetRulesContext(projectRoot) {
1301
+ const cached = contextCache.get("context", projectRoot);
1302
+ if (cached !== void 0) {
1303
+ return cached;
1304
+ }
1305
+ const meta = await readAgentsMeta(projectRoot);
1306
+ const l0Content = await readFile5(join6(projectRoot, ".fabric", "bootstrap", "README.md"), "utf8");
1307
+ const context = {
1308
+ meta,
1309
+ l0Content,
1310
+ humanLockedNearby: []
1311
+ };
1312
+ contextCache.set("context", projectRoot, context);
1313
+ return context;
1314
+ }
1315
+ async function resolveRulesForPath(projectRoot, context, path, options = {}) {
1316
+ const matchedNodes = matchRuleNodes(context.meta, path);
1317
+ const loaded = await loadMatchedRules(projectRoot, matchedNodes);
1318
+ return buildRulesPayload(context, loaded, options);
1319
+ }
1320
+ function normalizeRulesPath(value) {
1321
+ return value.replaceAll("\\", "/");
1322
+ }
1323
+ function matchRuleNodes(meta, path) {
1324
+ const requestedPath = normalizeRulesPath(path);
1325
+ return Object.entries(meta.nodes).filter(([, node]) => shouldLoadNodeForPath(requestedPath, node)).sort((left, right) => {
1326
+ const [leftId, leftNode] = left;
1327
+ const [rightId, rightNode] = right;
1328
+ const priorityDelta = PRIORITY_ORDER[leftNode.priority] - PRIORITY_ORDER[rightNode.priority];
1329
+ return priorityDelta !== 0 ? priorityDelta : leftId.localeCompare(rightId);
1330
+ }).map(([nodeId, node]) => ({
1331
+ node_id: nodeId,
1332
+ level: classifyNode(nodeId, node),
1333
+ stable_id: node.stable_id ?? nodeId,
1334
+ identity_source: node.identity_source ?? "derived",
1335
+ node
1336
+ }));
1337
+ }
1338
+ async function loadMatchedRules(projectRoot, matchedNodes, fileContentCache = /* @__PURE__ */ new Map()) {
1339
+ const rules = [];
1340
+ const stubs = [];
1341
+ for (const matchedNode of matchedNodes) {
1342
+ if (matchedNode.level === null) {
1343
+ continue;
1344
+ }
1345
+ if (matchedNode.node.activation?.tier === "description") {
1346
+ stubs.push({
1347
+ stable_id: matchedNode.stable_id,
1348
+ identity_source: matchedNode.identity_source,
1349
+ level: matchedNode.level,
1350
+ path: matchedNode.node.file,
1351
+ description: matchedNode.node.activation.description ?? ""
1352
+ });
1353
+ continue;
1354
+ }
1355
+ rules.push({
1356
+ level: matchedNode.level,
1357
+ stable_id: matchedNode.stable_id,
1358
+ identity_source: matchedNode.identity_source,
1359
+ entry: {
1360
+ path: matchedNode.node.file,
1361
+ content: await readRuleContent(projectRoot, matchedNode.node.file, fileContentCache)
1362
+ }
1363
+ });
1364
+ }
1365
+ return { rules, stubs };
1366
+ }
1367
+ function buildRulesPayload(context, loaded, options = {}) {
1368
+ const { L1, L2 } = partitionRulesByLevel(loaded.rules, options.dedupeByPath ?? false);
1369
+ return {
1370
+ L0: context.l0Content,
1371
+ L1,
1372
+ L2,
1373
+ human_locked_nearby: context.humanLockedNearby,
1374
+ description_stubs: loaded.stubs.length > 0 ? dedupeDescriptionStubsByPath(loaded.stubs).map(toDescriptionStub) : void 0
1375
+ };
1376
+ }
1377
+ function classifyNode(nodeId, node) {
1378
+ if (nodeId.startsWith("L1/")) {
1379
+ return "L1";
1380
+ }
1381
+ if (nodeId.startsWith("L2/")) {
1382
+ return "L2";
1383
+ }
1384
+ return node.layer === "L0" ? null : node.layer;
1385
+ }
1386
+ function partitionRulesByLevel(loadedRules, dedupeByPath) {
1387
+ const l1 = [];
1388
+ const l2 = [];
1389
+ for (const rule of loadedRules) {
1390
+ if (rule.level === "L1") {
1391
+ l1.push(rule.entry);
1392
+ continue;
1393
+ }
1394
+ if (rule.level === "L2") {
1395
+ l2.push(rule.entry);
1396
+ }
1397
+ }
1398
+ return {
1399
+ L1: dedupeByPath ? dedupeEntriesByPath(l1) : l1,
1400
+ L2: dedupeByPath ? dedupeEntriesByPath(l2) : l2
1401
+ };
1402
+ }
1403
+ function dedupeEntriesByPath(entries) {
1404
+ const seenPaths = /* @__PURE__ */ new Set();
1405
+ return entries.filter((entry) => {
1406
+ if (seenPaths.has(entry.path)) {
1407
+ return false;
1408
+ }
1409
+ seenPaths.add(entry.path);
1410
+ return true;
1411
+ });
1412
+ }
1413
+ function shouldLoadNodeForPath(requestedPath, node) {
1414
+ switch (node.activation?.tier) {
1415
+ case "always":
1416
+ return true;
1417
+ case "description":
1418
+ return true;
1419
+ case "path":
1420
+ case void 0:
1421
+ return minimatch(requestedPath, normalizeRulesPath(node.scope_glob), { dot: true });
1422
+ }
1423
+ }
1424
+ function dedupeDescriptionStubsByPath(stubs) {
1425
+ const seenPaths = /* @__PURE__ */ new Set();
1426
+ return stubs.filter((stub) => {
1427
+ if (seenPaths.has(stub.path)) {
1428
+ return false;
1429
+ }
1430
+ seenPaths.add(stub.path);
1431
+ return true;
1432
+ });
1433
+ }
1434
+ function toDescriptionStub(stub) {
1435
+ return {
1436
+ path: stub.path,
1437
+ description: stub.description
1438
+ };
1439
+ }
1440
+ async function readRuleContent(projectRoot, file, fileContentCache) {
1441
+ const cached = fileContentCache.get(file);
1442
+ if (cached !== void 0) {
1443
+ return await cached;
1444
+ }
1445
+ const pending = readFile5(join6(projectRoot, file), "utf8");
1446
+ fileContentCache.set(file, pending);
1447
+ return await pending;
1448
+ }
1449
+
1450
+ // src/services/plan-context.ts
1451
+ var SELECTION_TOKEN_TTL_MS = 5 * 60 * 1e3;
1452
+ var selectionTokenCache = /* @__PURE__ */ new Map();
1453
+ async function planContext(projectRoot, input) {
1454
+ const meta = await readAgentsMeta(projectRoot);
1455
+ const stale = input.client_hash !== void 0 && input.client_hash !== meta.revision;
1456
+ const uniquePaths = dedupePaths(input.paths);
1457
+ const allDescriptions = buildDescriptionIndex(meta);
1458
+ const entries = uniquePaths.map((path) => {
1459
+ const profile = buildRequirementProfile(path, input);
1460
+ const descriptionIndex = allDescriptions.filter((item) => shouldIncludeIndexItemForPath(item, meta, path));
1461
+ const requiredStableIds2 = descriptionIndex.filter((item) => item.required).map((item) => item.stable_id);
1462
+ const aiSelectableStableIds2 = descriptionIndex.filter((item) => item.selectable).map((item) => item.stable_id);
1463
+ return {
1464
+ path,
1465
+ requirement_profile: profile,
1466
+ description_index: descriptionIndex,
1467
+ required_stable_ids: requiredStableIds2,
1468
+ ai_selectable_stable_ids: aiSelectableStableIds2,
1469
+ initial_selected_stable_ids: requiredStableIds2,
1470
+ selection_policy: {
1471
+ required_levels: ["L0", "L2"],
1472
+ ai_selectable_levels: ["L1"],
1473
+ final_fetch_rule: "required_stable_ids + ai_selected_l1_stable_ids"
1474
+ }
1475
+ };
1476
+ });
1477
+ const requiredStableIds = dedupeStableIds(entries.flatMap((entry) => entry.required_stable_ids));
1478
+ const aiSelectableStableIds = dedupeStableIds(entries.flatMap((entry) => entry.ai_selectable_stable_ids));
1479
+ const sharedDescriptionIndex = dedupeDescriptionIndex(entries.flatMap((entry) => entry.description_index));
1480
+ const selectionToken = createSelectionToken(meta.revision, uniquePaths, requiredStableIds, aiSelectableStableIds);
1481
+ const result = {
1482
+ revision_hash: meta.revision,
1483
+ stale,
1484
+ selection_token: selectionToken,
1485
+ entries,
1486
+ shared: {
1487
+ required_stable_ids: requiredStableIds,
1488
+ ai_selectable_stable_ids: aiSelectableStableIds,
1489
+ description_index: sharedDescriptionIndex,
1490
+ preflight_diagnostics: buildPreflightDiagnostics(meta)
1491
+ }
1492
+ };
1493
+ try {
1494
+ await appendEventLedgerEvent(projectRoot, {
1495
+ event_type: "rule_context_planned",
1496
+ target_paths: uniquePaths,
1497
+ required_stable_ids: requiredStableIds,
1498
+ ai_selectable_stable_ids: aiSelectableStableIds,
1499
+ final_stable_ids: requiredStableIds,
1500
+ selection_token: selectionToken,
1501
+ client_hash: input.client_hash,
1502
+ intent: input.intent,
1503
+ known_tech: input.known_tech,
1504
+ diagnostics: result.shared.preflight_diagnostics,
1505
+ correlation_id: input.correlation_id,
1506
+ session_id: input.session_id
1507
+ });
1508
+ } catch {
1509
+ }
1510
+ return result;
1511
+ }
1512
+ function readSelectionToken(token, now = Date.now()) {
1513
+ const state = selectionTokenCache.get(token);
1514
+ if (state === void 0) {
1515
+ return void 0;
1516
+ }
1517
+ if (state.expires_at <= now) {
1518
+ selectionTokenCache.delete(token);
1519
+ return void 0;
1520
+ }
1521
+ return state;
1522
+ }
1523
+ function createSelectionToken(revisionHash, targetPaths, requiredStableIds, aiSelectableStableIds, now = Date.now()) {
1524
+ const token = `selection:${revisionHash}:${now.toString(36)}:${Math.random().toString(36).slice(2)}`;
1525
+ selectionTokenCache.set(token, {
1526
+ token,
1527
+ revision_hash: revisionHash,
1528
+ target_paths: targetPaths,
1529
+ required_stable_ids: requiredStableIds,
1530
+ ai_selectable_stable_ids: aiSelectableStableIds,
1531
+ created_at: now,
1532
+ expires_at: now + SELECTION_TOKEN_TTL_MS
1533
+ });
1534
+ return token;
1535
+ }
1536
+ function dedupePaths(paths) {
1537
+ const seenPaths = /* @__PURE__ */ new Set();
1538
+ return paths.flatMap((path) => {
1539
+ const normalizedPath = normalizeRulesPath(path);
1540
+ if (seenPaths.has(normalizedPath)) {
1541
+ return [];
1542
+ }
1543
+ seenPaths.add(normalizedPath);
1544
+ return [normalizedPath];
1545
+ });
1546
+ }
1547
+ function buildRequirementProfile(path, input) {
1548
+ const normalizedPath = normalizeRulesPath(path);
1549
+ const extensionMatch = /(\.[^./\\]+)$/u.exec(normalizedPath);
1550
+ const knownTech = dedupeStableIds([
1551
+ ...input.known_tech ?? [],
1552
+ ...extensionMatch?.[1] === ".ts" ? ["TypeScript"] : []
1553
+ ]);
1554
+ return {
1555
+ target_path: normalizedPath,
1556
+ path_segments: normalizedPath.split("/").filter(Boolean),
1557
+ extension: extensionMatch?.[1] ?? "",
1558
+ inferred_domain: inferDomains(normalizedPath),
1559
+ known_tech: knownTech,
1560
+ user_intent: input.intent ?? "",
1561
+ intent_tokens: tokenizeIntent(input.intent ?? ""),
1562
+ impact_hints: inferImpactHints(input.intent ?? ""),
1563
+ detected_entities: input.detected_entities?.[normalizedPath] ?? input.detected_entities?.[path] ?? []
1564
+ };
1565
+ }
1566
+ function buildDescriptionIndex(meta) {
1567
+ return Object.entries(meta.nodes).flatMap(([nodeId, node]) => {
1568
+ const level = node.level ?? node.layer;
1569
+ const description = node.description ?? descriptionFromLegacyActivation(node.activation?.description);
1570
+ if (description === void 0) {
1571
+ return [];
1572
+ }
1573
+ return [{
1574
+ stable_id: node.stable_id ?? nodeId,
1575
+ level,
1576
+ required: level === "L0" || level === "L2",
1577
+ selectable: level === "L1",
1578
+ description
1579
+ }];
1580
+ }).sort(compareDescriptionIndexItems);
1581
+ }
1582
+ function descriptionFromLegacyActivation(summary) {
1583
+ if (summary === void 0) {
1584
+ return void 0;
1585
+ }
1586
+ return {
1587
+ summary,
1588
+ intent_clues: [],
1589
+ tech_stack: [],
1590
+ impact: [],
1591
+ must_read_if: summary
1592
+ };
1593
+ }
1594
+ function shouldIncludeIndexItemForPath(item, meta, path) {
1595
+ if (item.level === "L0" || item.level === "L1") {
1596
+ return true;
1597
+ }
1598
+ const node = Object.values(meta.nodes).find((candidate) => candidate.stable_id === item.stable_id);
1599
+ if (node === void 0) {
1600
+ return false;
1601
+ }
1602
+ return node.scope_glob === path || minimatchSimple(path, node.scope_glob);
1603
+ }
1604
+ function minimatchSimple(path, glob) {
1605
+ if (glob === "**") {
1606
+ return true;
1607
+ }
1608
+ if (glob.endsWith("/**")) {
1609
+ return path.startsWith(glob.slice(0, -3));
1610
+ }
1611
+ return path === glob;
1612
+ }
1613
+ function buildPreflightDiagnostics(meta) {
1614
+ const missingDescriptionStableIds = Object.entries(meta.nodes).filter(([, node]) => node.description === void 0 && node.activation?.description === void 0).map(([nodeId, node]) => node.stable_id ?? nodeId).sort();
1615
+ if (missingDescriptionStableIds.length === 0) {
1616
+ return [];
1617
+ }
1618
+ return [{
1619
+ code: "missing_description",
1620
+ severity: "warn",
1621
+ stable_ids: missingDescriptionStableIds,
1622
+ message: `Resolved registry includes ${missingDescriptionStableIds.length} node(s) without structured descriptions.`
1623
+ }];
1624
+ }
1625
+ function inferDomains(path) {
1626
+ const domains = [];
1627
+ if (path.includes("/ui/") || path.toLowerCase().includes("ui")) {
1628
+ domains.push("UI");
1629
+ }
1630
+ if (path.includes("assets/scripts")) {
1631
+ domains.push("Gameplay");
1632
+ }
1633
+ if (path.includes("resources") || path.includes("assets/resources")) {
1634
+ domains.push("Asset");
1635
+ }
1636
+ return domains;
1637
+ }
1638
+ function tokenizeIntent(intent) {
1639
+ const tokens = ["\u6027\u80FD", "\u4F18\u5316", "drawcall", "\u6E32\u67D3", "\u5361\u987F", "\u95EA\u70C1", "\u754C\u9762", "UI", "\u8D44\u6E90", "\u56FE\u96C6"].filter((token) => intent.toLowerCase().includes(token.toLowerCase()));
1640
+ return dedupeStableIds(tokens);
1641
+ }
1642
+ function inferImpactHints(intent) {
1643
+ return /性能|优化|drawcall|渲染|卡顿|闪烁/iu.test(intent) ? ["Performance"] : [];
1644
+ }
1645
+ function dedupeStableIds(stableIds) {
1646
+ return Array.from(new Set(stableIds));
1647
+ }
1648
+ function dedupeDescriptionIndex(items) {
1649
+ const seenStableIds = /* @__PURE__ */ new Set();
1650
+ return items.filter((item) => {
1651
+ if (seenStableIds.has(item.stable_id)) {
1652
+ return false;
1653
+ }
1654
+ seenStableIds.add(item.stable_id);
1655
+ return true;
1656
+ });
1657
+ }
1658
+ function compareDescriptionIndexItems(left, right) {
1659
+ const levelDelta = levelOrder(left.level) - levelOrder(right.level);
1660
+ return levelDelta !== 0 ? levelDelta : left.stable_id.localeCompare(right.stable_id);
1661
+ }
1662
+ function levelOrder(level) {
1663
+ switch (level) {
1664
+ case "L0":
1665
+ return 0;
1666
+ case "L1":
1667
+ return 1;
1668
+ case "L2":
1669
+ return 2;
1670
+ }
1671
+ }
1672
+
1673
+ // src/services/rule-sections.ts
1674
+ var RULE_SECTION_NAMES = [
1675
+ "MISSION_STATEMENT",
1676
+ "MANDATORY_INJECTION",
1677
+ "BUSINESS_LOGIC_CHUNKS",
1678
+ "CONTEXT_INFO"
1679
+ ];
1680
+ var PRIORITY_ORDER2 = {
1681
+ high: 0,
1682
+ medium: 1,
1683
+ low: 2
1684
+ };
1685
+ function parseRuleSections(content) {
1686
+ const sections = /* @__PURE__ */ new Map();
1687
+ const lines = content.split(/\r?\n/u);
1688
+ let activeSection;
1689
+ let activeSectionDepth = 0;
1690
+ let buffer = [];
1691
+ const flush = () => {
1692
+ if (activeSection === void 0) {
1693
+ return;
1694
+ }
1695
+ const text = buffer.join("\n").trim();
1696
+ if (text.length === 0) {
1697
+ buffer = [];
1698
+ return;
1699
+ }
1700
+ sections.set(activeSection, [...sections.get(activeSection) ?? [], text]);
1701
+ buffer = [];
1702
+ };
1703
+ for (const line of lines) {
1704
+ const heading = /^(#{2,6})\s+\[([A-Z_]+)\]\s*$/u.exec(line.trim());
1705
+ if (heading !== null) {
1706
+ flush();
1707
+ activeSection = isRuleSectionName(heading[2]) ? heading[2] : void 0;
1708
+ activeSectionDepth = activeSection === void 0 ? 0 : heading[1].length;
1709
+ continue;
1710
+ }
1711
+ const ordinaryHeading = /^(#{1,6})\s+/u.exec(line.trim());
1712
+ if (ordinaryHeading !== null) {
1713
+ if (activeSection !== void 0 && ordinaryHeading[1].length > activeSectionDepth) {
1714
+ buffer.push(line);
1715
+ continue;
1716
+ }
1717
+ flush();
1718
+ activeSection = void 0;
1719
+ activeSectionDepth = 0;
1720
+ continue;
1721
+ }
1722
+ if (activeSection !== void 0) {
1723
+ buffer.push(line);
1724
+ }
1725
+ }
1726
+ flush();
1727
+ return new Map(
1728
+ Array.from(sections.entries()).map(([section, values]) => [section, values.join("\n\n")])
1729
+ );
1730
+ }
1731
+ async function getRuleSections(projectRoot, input) {
1732
+ const token = readSelectionToken(input.selection_token);
1733
+ if (token === void 0) {
1734
+ throw new Error("selection_token is missing or expired");
1735
+ }
1736
+ validateAiSelections(token.ai_selectable_stable_ids, input.ai_selected_stable_ids, input.ai_selection_reasons);
1737
+ const meta = await readAgentsMeta(projectRoot);
1738
+ const selectedStableIds = [...token.required_stable_ids, ...input.ai_selected_stable_ids];
1739
+ const selectedRules = sortRuleNodes(selectedStableIds.map((stableId) => findRuleNode(meta, stableId)));
1740
+ const diagnostics = [];
1741
+ const rules = [];
1742
+ for (const rule of selectedRules) {
1743
+ const content = await readFile6(join7(projectRoot, rule.path), "utf8");
1744
+ const parsedSections = parseRuleSections(content);
1745
+ const sections = {};
1746
+ for (const section of input.sections) {
1747
+ const sectionContent = parsedSections.get(section);
1748
+ sections[section] = sectionContent ?? "";
1749
+ if (sectionContent === void 0) {
1750
+ diagnostics.push({
1751
+ code: "missing_section",
1752
+ severity: "warn",
1753
+ stable_id: rule.stable_id,
1754
+ section,
1755
+ message: `Rule ${rule.stable_id} does not define section ${section}.`
1756
+ });
1757
+ }
1758
+ }
1759
+ rules.push({
1760
+ stable_id: rule.stable_id,
1761
+ level: rule.level,
1762
+ path: rule.path,
1763
+ sections
1764
+ });
1765
+ }
1766
+ const result = {
1767
+ revision_hash: meta.revision,
1768
+ precedence: ["L2", "L1", "L0"],
1769
+ selected_stable_ids: rules.map((rule) => rule.stable_id),
1770
+ rules,
1771
+ diagnostics
1772
+ };
1773
+ await appendRuleSelectionAuditEvent(projectRoot, {
1774
+ path: token.target_paths[0] ?? "",
1775
+ selection_token: input.selection_token,
1776
+ target_paths: token.target_paths,
1777
+ required_stable_ids: token.required_stable_ids,
1778
+ ai_selectable_stable_ids: token.ai_selectable_stable_ids,
1779
+ ai_selected_stable_ids: input.ai_selected_stable_ids,
1780
+ final_stable_ids: result.selected_stable_ids,
1781
+ ai_selection_reasons: pickSelectionReasons(input.ai_selected_stable_ids, input.ai_selection_reasons),
1782
+ rejected_stable_ids: [],
1783
+ ignored_stable_ids: [],
1784
+ correlation_id: input.correlation_id,
1785
+ session_id: input.session_id
1786
+ });
1787
+ try {
1788
+ await appendEventLedgerEvent(projectRoot, {
1789
+ event_type: "rule_sections_fetched",
1790
+ selection_token: input.selection_token,
1791
+ target_paths: token.target_paths,
1792
+ requested_sections: input.sections,
1793
+ final_stable_ids: result.selected_stable_ids,
1794
+ ai_selected_stable_ids: input.ai_selected_stable_ids,
1795
+ diagnostics,
1796
+ correlation_id: input.correlation_id,
1797
+ session_id: input.session_id
1798
+ });
1799
+ } catch {
1800
+ }
1801
+ return result;
1802
+ }
1803
+ function validateAiSelections(aiSelectableStableIds, aiSelectedStableIds, aiSelectionReasons) {
1804
+ const selectable = new Set(aiSelectableStableIds);
1805
+ for (const stableId of aiSelectedStableIds) {
1806
+ if (!selectable.has(stableId)) {
1807
+ throw new Error(`Invalid L1 rule selection: ${stableId}`);
1808
+ }
1809
+ if (aiSelectionReasons[stableId]?.trim() === "") {
1810
+ throw new Error(`Missing AI selection reason for ${stableId}`);
1811
+ }
1812
+ if (aiSelectionReasons[stableId] === void 0) {
1813
+ throw new Error(`Missing AI selection reason for ${stableId}`);
1814
+ }
1815
+ }
1816
+ }
1817
+ function findRuleNode(meta, stableId) {
1818
+ for (const [nodeId, node] of Object.entries(meta.nodes)) {
1819
+ const nodeStableId = node.stable_id ?? nodeId;
1820
+ if (nodeStableId !== stableId) {
1821
+ continue;
1822
+ }
1823
+ const level = node.level ?? node.layer;
1824
+ return {
1825
+ stable_id: nodeStableId,
1826
+ level,
1827
+ path: normalizeRulesPath(node.content_ref ?? node.file),
1828
+ priority: node.priority,
1829
+ node
1830
+ };
1831
+ }
1832
+ throw new Error(`Selected rule is not present in agents.meta.json: ${stableId}`);
1833
+ }
1834
+ function sortRuleNodes(rules) {
1835
+ return [...rules].sort((left, right) => {
1836
+ const levelDelta = outputLevelOrder(left.level) - outputLevelOrder(right.level);
1837
+ if (levelDelta !== 0) {
1838
+ return levelDelta;
1839
+ }
1840
+ const priorityDelta = PRIORITY_ORDER2[left.priority] - PRIORITY_ORDER2[right.priority];
1841
+ if (priorityDelta !== 0) {
1842
+ return priorityDelta;
1843
+ }
1844
+ return left.stable_id.localeCompare(right.stable_id);
1845
+ });
1846
+ }
1847
+ function outputLevelOrder(level) {
1848
+ switch (level) {
1849
+ case "L0":
1850
+ return 0;
1851
+ case "L1":
1852
+ return 1;
1853
+ case "L2":
1854
+ return 2;
1855
+ }
1856
+ }
1857
+ function isRuleSectionName(value) {
1858
+ return RULE_SECTION_NAMES.includes(value);
1859
+ }
1860
+ function pickSelectionReasons(selectedStableIds, reasons) {
1861
+ return Object.fromEntries(selectedStableIds.map((stableId) => [stableId, reasons[stableId] ?? ""]));
1862
+ }
1863
+
1864
+ // src/services/doctor.ts
1865
+ import { atomicWriteJson as atomicWriteJson2, atomicWriteText as atomicWriteText3 } from "@fenglimg/fabric-shared/node/atomic-write";
1866
+ import { buildBootstrapContent, FABRIC_BOOTSTRAP_PATH } from "@fenglimg/fabric-shared/node/bootstrap-guide";
1867
+ var LEGACY_CLIENT_PATH_KEYS = ["windsurf", "rooCode", "geminiCLI"];
1868
+ var SCRIPT_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx"]);
1869
+ var IGNORED_DIRECTORIES = /* @__PURE__ */ new Set([
1870
+ ".fabric",
1871
+ ".git",
1872
+ ".next",
1873
+ ".turbo",
1874
+ "Library",
1875
+ "Temp",
1876
+ "build",
1877
+ "coverage",
1878
+ "dist",
1879
+ "node_modules"
1880
+ ]);
1881
+ var TARGET_FILE_PATHS = [
1882
+ ".fabric/bootstrap/README.md",
1883
+ ".fabric/INITIAL_TAXONOMY.md",
1884
+ ".fabric/forensic.json",
1885
+ ".fabric/init-context.json",
1886
+ ".fabric/agents.meta.json",
1887
+ ".fabric/rule-test.index.json",
1888
+ ".fabric/events.jsonl"
1889
+ ];
1890
+ async function runDoctorReport(target) {
1891
+ const projectRoot = normalizeTarget(target);
1892
+ const framework = detectFramework(projectRoot);
1893
+ const entryPoints = collectEntryPoints(projectRoot);
1894
+ const [
1895
+ forensic,
1896
+ initContext,
1897
+ meta,
1898
+ eventLedger,
1899
+ ruleSections,
1900
+ ruleTestIndex
1901
+ ] = await Promise.all([
1902
+ inspectForensic(projectRoot),
1903
+ inspectInitContext(projectRoot),
1904
+ inspectMeta(projectRoot),
1905
+ inspectEventLedger(projectRoot),
1906
+ inspectRuleSections(projectRoot),
1907
+ inspectRuleTestIndex(projectRoot)
1908
+ ]);
1909
+ const mcpConfigInWrongFile = inspectMcpConfigInWrongFile(projectRoot);
1910
+ const metaManuallyDiverged = await inspectMetaManuallyDiverged(projectRoot);
1911
+ const rulesDirUnindexed = inspectRulesDirUnindexed(projectRoot, meta);
1912
+ const stableIdCollision = await inspectStableIdCollisions(projectRoot);
1913
+ const claudeSkillLegacyPath = inspectClaudeSkillLegacyPath(projectRoot);
1914
+ const preexistingRootFiles = inspectPreexistingRootFiles(projectRoot);
1915
+ const legacyClientPaths = inspectLegacyClientPaths(projectRoot);
1916
+ const taxonomyExists = existsSync4(join8(projectRoot, ".fabric", "INITIAL_TAXONOMY.md"));
1917
+ const bootstrapExists = existsSync4(join8(projectRoot, ".fabric", "bootstrap", "README.md"));
1918
+ const checks = [
1919
+ createBootstrapCheck(bootstrapExists),
1920
+ createTaxonomyCheck(taxonomyExists),
1921
+ createForensicCheck(forensic, framework.kind, entryPoints.length),
1922
+ createInitContextCheck(initContext),
1923
+ createMetaCheck(meta),
1924
+ createRuleContentRefCheck(meta),
1925
+ createRuleSectionsCheck(ruleSections),
1926
+ createRuleTestIndexCheck(ruleTestIndex),
1927
+ createEventLedgerCheck(eventLedger),
1928
+ createEventLedgerPartialWriteCheck(eventLedger),
1929
+ createMcpConfigInWrongFileCheck(mcpConfigInWrongFile),
1930
+ createMetaManuallyDivergedCheck(metaManuallyDiverged),
1931
+ createRulesDirUnindexedCheck(rulesDirUnindexed),
1932
+ createStableIdCollisionCheck(stableIdCollision),
1933
+ createClaudeSkillLegacyPathCheck(claudeSkillLegacyPath),
1934
+ createPreexistingRootFilesCheck(preexistingRootFiles),
1935
+ createLegacyClientPathCheck(legacyClientPaths)
1936
+ ];
1937
+ const fixableErrors = collectIssues(checks, "fixable_error");
1938
+ const manualErrors = collectIssues(checks, "manual_error");
1939
+ const warnings = collectIssues(checks, "warning");
1940
+ const infos = collectIssues(checks, "info");
1941
+ return {
1942
+ status: reduceStatus(checks.map((check) => check.status)),
1943
+ checks,
1944
+ fixable_errors: fixableErrors,
1945
+ manual_errors: manualErrors,
1946
+ warnings,
1947
+ infos,
1948
+ summary: {
1949
+ target: projectRoot,
1950
+ framework: {
1951
+ kind: framework.kind,
1952
+ version: framework.version,
1953
+ subkind: framework.subkind
1954
+ },
1955
+ entryPoints,
1956
+ metaRevision: meta.revision,
1957
+ computedMetaRevision: meta.computedRevision,
1958
+ ruleCount: meta.ruleCount,
1959
+ eventLedgerPath: eventLedger.path,
1960
+ fixableErrorCount: fixableErrors.length,
1961
+ manualErrorCount: manualErrors.length,
1962
+ warningCount: warnings.length,
1963
+ infoCount: infos.length,
1964
+ targetFiles: Object.fromEntries(
1965
+ TARGET_FILE_PATHS.map((path) => [path, existsSync4(join8(projectRoot, path))])
1966
+ )
1967
+ }
1968
+ };
1969
+ }
1970
+ async function runDoctorFix(target) {
1971
+ const projectRoot = normalizeTarget(target);
1972
+ const before = await runDoctorReport(projectRoot);
1973
+ const fixed = [];
1974
+ if (before.fixable_errors.some((issue) => issue.code === "bootstrap_missing")) {
1975
+ await writeDefaultBootstrap(projectRoot);
1976
+ fixed.push(findIssue(before.fixable_errors, "bootstrap_missing"));
1977
+ }
1978
+ if (before.fixable_errors.some((issue) => issue.code === "event_ledger_missing")) {
1979
+ await ensureEventLedger(projectRoot);
1980
+ fixed.push(findIssue(before.fixable_errors, "event_ledger_missing"));
1981
+ }
1982
+ if (before.fixable_errors.some(
1983
+ (issue) => [
1984
+ "agents_meta_missing",
1985
+ "agents_meta_stale",
1986
+ "rule_test_index_missing",
1987
+ "rule_test_index_stale",
1988
+ "content_ref_missing",
1989
+ "rules_dir_unindexed"
1990
+ ].includes(issue.code)
1991
+ )) {
1992
+ await reconcileRules(projectRoot, { trigger: "doctor" });
1993
+ for (const issue of before.fixable_errors.filter(
1994
+ (candidate) => [
1995
+ "agents_meta_missing",
1996
+ "agents_meta_stale",
1997
+ "rule_test_index_missing",
1998
+ "rule_test_index_stale",
1999
+ "content_ref_missing",
2000
+ "rules_dir_unindexed"
2001
+ ].includes(candidate.code)
2002
+ )) {
2003
+ fixed.push(issue);
2004
+ }
2005
+ contextCache.invalidate("meta_write", projectRoot);
2006
+ }
2007
+ if (before.fixable_errors.some((issue) => issue.code === "event_ledger_partial_write")) {
2008
+ const ledgerPath = getEventLedgerPath(projectRoot);
2009
+ const truncResult = await truncateLedgerToLastNewline(ledgerPath);
2010
+ await appendEventLedgerEvent(projectRoot, {
2011
+ event_type: "event_ledger_truncated",
2012
+ byte_offset: truncResult.truncated_bytes,
2013
+ byte_length: truncResult.truncated_bytes,
2014
+ corrupted_path: truncResult.corrupted_path
2015
+ });
2016
+ fixed.push(findIssue(before.fixable_errors, "event_ledger_partial_write"));
2017
+ }
2018
+ if (before.fixable_errors.some((issue) => issue.code === "mcp_config_in_wrong_file")) {
2019
+ await fixMcpConfigInWrongFile(projectRoot);
2020
+ fixed.push(findIssue(before.fixable_errors, "mcp_config_in_wrong_file"));
2021
+ }
2022
+ if (before.fixable_errors.some((issue) => issue.code === "claude_skill_legacy_path")) {
2023
+ await fixClaudeSkillLegacyPath(projectRoot);
2024
+ fixed.push(findIssue(before.fixable_errors, "claude_skill_legacy_path"));
2025
+ }
2026
+ if (before.warnings.some((issue) => issue.code === "legacy_client_path_present")) {
2027
+ await fixLegacyClientPaths(projectRoot);
2028
+ fixed.push(findIssue(before.warnings, "legacy_client_path_present"));
2029
+ }
2030
+ const report = await runDoctorReport(projectRoot);
2031
+ return {
2032
+ changed: fixed.length > 0,
2033
+ fixed,
2034
+ remaining_manual_errors: report.manual_errors,
2035
+ warnings: report.warnings,
2036
+ message: createFixMessage(fixed, report),
2037
+ report
2038
+ };
2039
+ }
2040
+ async function inspectForensic(projectRoot) {
2041
+ const path = join8(projectRoot, ".fabric", "forensic.json");
2042
+ try {
2043
+ const parsed = forensicReportSchema.parse(JSON.parse(await readFile7(path, "utf8")));
2044
+ return { present: true, valid: true, report: parsed };
2045
+ } catch (error) {
2046
+ if (isMissingFileError(error)) {
2047
+ return { present: false, valid: false, report: null, error: ".fabric/forensic.json is missing." };
2048
+ }
2049
+ return { present: true, valid: false, report: null, error: error instanceof Error ? error.message : String(error) };
2050
+ }
2051
+ }
2052
+ async function inspectInitContext(projectRoot) {
2053
+ const path = join8(projectRoot, ".fabric", "init-context.json");
2054
+ try {
2055
+ JSON.parse(await readFile7(path, "utf8"));
2056
+ return { exists: true, validJson: true };
2057
+ } catch (error) {
2058
+ if (isMissingFileError(error)) {
2059
+ return { exists: false, validJson: false, error: ".fabric/init-context.json is missing." };
2060
+ }
2061
+ return { exists: true, validJson: false, error: error instanceof Error ? error.message : String(error) };
2062
+ }
2063
+ }
2064
+ function inspectMcpConfigInWrongFile(projectRoot) {
2065
+ const settingsPath = join8(projectRoot, ".claude", "settings.json");
2066
+ if (!existsSync4(settingsPath)) {
2067
+ return { hasWrongEntry: false, settingsPath };
2068
+ }
2069
+ try {
2070
+ const parsed = JSON.parse(readFileSync(settingsPath, "utf8"));
2071
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
2072
+ return { hasWrongEntry: false, settingsPath };
2073
+ }
2074
+ const settings = parsed;
2075
+ const mcpServers = settings.mcpServers;
2076
+ if (mcpServers === null || typeof mcpServers !== "object" || Array.isArray(mcpServers)) {
2077
+ return { hasWrongEntry: false, settingsPath };
2078
+ }
2079
+ const hasWrongEntry = "fabric" in mcpServers;
2080
+ return { hasWrongEntry, settingsPath };
2081
+ } catch {
2082
+ return { hasWrongEntry: false, settingsPath };
2083
+ }
2084
+ }
2085
+ async function inspectMeta(projectRoot) {
2086
+ const metaPath = join8(projectRoot, ".fabric", "agents.meta.json");
2087
+ const built = await tryBuildRuleMeta(projectRoot);
2088
+ try {
2089
+ const raw = await readFile7(metaPath, "utf8");
2090
+ const meta = agentsMetaSchema4.parse(JSON.parse(raw));
2091
+ const contentRefIssues = inspectContentRefs(projectRoot, meta);
2092
+ const changed = built === null ? false : built.changed;
2093
+ return {
2094
+ present: true,
2095
+ valid: true,
2096
+ meta,
2097
+ revision: meta.revision,
2098
+ computedRevision: built?.meta.revision ?? null,
2099
+ ruleCount: Object.values(meta.nodes).filter((node) => (node.content_ref ?? node.file).startsWith(".fabric/rules/")).length,
2100
+ missingContentRefs: contentRefIssues.missing,
2101
+ invalidContentRefs: contentRefIssues.invalid,
2102
+ stale: changed || built !== null && meta.revision !== built.meta.revision,
2103
+ changed
2104
+ };
2105
+ } catch (error) {
2106
+ if (isMissingFileError(error)) {
2107
+ return {
2108
+ present: false,
2109
+ valid: false,
2110
+ meta: null,
2111
+ revision: null,
2112
+ computedRevision: built?.meta.revision ?? null,
2113
+ ruleCount: 0,
2114
+ missingContentRefs: [],
2115
+ invalidContentRefs: [],
2116
+ stale: true,
2117
+ changed: built?.changed ?? true
2118
+ };
2119
+ }
2120
+ return {
2121
+ present: true,
2122
+ valid: false,
2123
+ meta: null,
2124
+ revision: null,
2125
+ computedRevision: built?.meta.revision ?? null,
2126
+ ruleCount: 0,
2127
+ missingContentRefs: [],
2128
+ invalidContentRefs: [],
2129
+ stale: true,
2130
+ changed: built?.changed ?? true,
2131
+ readError: error instanceof Error ? error.message : String(error)
2132
+ };
2133
+ }
2134
+ }
2135
+ async function tryBuildRuleMeta(projectRoot) {
2136
+ try {
2137
+ return await buildRuleMeta(projectRoot);
2138
+ } catch {
2139
+ return null;
2140
+ }
2141
+ }
2142
+ function inspectContentRefs(projectRoot, meta) {
2143
+ const missing = [];
2144
+ const invalid = [];
2145
+ for (const node of Object.values(meta.nodes)) {
2146
+ const contentRef = normalizePath(node.content_ref ?? node.file);
2147
+ if (contentRef === ".fabric/bootstrap/README.md") {
2148
+ if (!existsSync4(join8(projectRoot, contentRef))) {
2149
+ missing.push(contentRef);
2150
+ }
2151
+ continue;
2152
+ }
2153
+ if (!contentRef.startsWith(".fabric/rules/")) {
2154
+ invalid.push(contentRef);
2155
+ continue;
2156
+ }
2157
+ if (!existsSync4(join8(projectRoot, contentRef))) {
2158
+ missing.push(contentRef);
2159
+ }
2160
+ }
2161
+ return { missing, invalid };
2162
+ }
2163
+ async function inspectEventLedger(projectRoot) {
2164
+ const path = getEventLedgerPath(projectRoot);
2165
+ const exists = existsSync4(path);
2166
+ if (!exists) {
2167
+ return { exists: false, writable: false, parseable: false, hasPartialWrite: false, partialWriteByteOffset: 0, partialWriteByteLength: 0, path };
2168
+ }
2169
+ try {
2170
+ await access(path, constants.W_OK);
2171
+ const { warnings } = await readEventLedger(projectRoot);
2172
+ const raw = await readFile7(path, "utf8");
2173
+ const invalidLine = raw.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).find((line) => !isValidJsonLine(line));
2174
+ const partialWarning = warnings.find((w) => w.kind === "partial_write_at_tail");
2175
+ return {
2176
+ exists: true,
2177
+ writable: true,
2178
+ parseable: invalidLine === void 0,
2179
+ hasPartialWrite: partialWarning !== void 0,
2180
+ partialWriteByteOffset: partialWarning?.byte_offset ?? 0,
2181
+ partialWriteByteLength: partialWarning?.byte_length ?? 0,
2182
+ path,
2183
+ error: invalidLine === void 0 ? void 0 : "events.jsonl contains an invalid JSON line."
2184
+ };
2185
+ } catch (error) {
2186
+ return {
2187
+ exists: true,
2188
+ writable: false,
2189
+ parseable: false,
2190
+ hasPartialWrite: false,
2191
+ partialWriteByteOffset: 0,
2192
+ partialWriteByteLength: 0,
2193
+ path,
2194
+ error: error instanceof Error ? error.message : String(error)
2195
+ };
2196
+ }
2197
+ }
2198
+ async function inspectRuleSections(projectRoot) {
2199
+ const invalidFiles = [];
2200
+ const files = findRuleFiles2(projectRoot);
2201
+ for (const file of files) {
2202
+ try {
2203
+ parseRuleSections(await readFile7(join8(projectRoot, file), "utf8"));
2204
+ } catch (error) {
2205
+ invalidFiles.push({
2206
+ file,
2207
+ reason: error instanceof Error ? error.message : String(error)
2208
+ });
2209
+ }
2210
+ }
2211
+ return {
2212
+ checkedCount: files.length,
2213
+ invalidFiles
2214
+ };
2215
+ }
2216
+ async function inspectRuleTestIndex(projectRoot) {
2217
+ const path = join8(projectRoot, ".fabric", "rule-test.index.json");
2218
+ const built = await tryBuildRuleMeta(projectRoot);
2219
+ try {
2220
+ const index = ruleTestIndexSchema2.parse(JSON.parse(await readFile7(path, "utf8")));
2221
+ return {
2222
+ present: true,
2223
+ valid: true,
2224
+ stale: built === null ? false : !isSameRuleTestIndex(index, built.ruleTestIndex),
2225
+ linkCount: index.links.length,
2226
+ orphanCount: index.orphan_annotations.length
2227
+ };
2228
+ } catch (error) {
2229
+ return {
2230
+ present: !isMissingFileError(error),
2231
+ valid: false,
2232
+ stale: true,
2233
+ linkCount: 0,
2234
+ orphanCount: 0,
2235
+ error: isMissingFileError(error) ? ".fabric/rule-test.index.json is missing." : error instanceof Error ? error.message : String(error)
2236
+ };
2237
+ }
2238
+ }
2239
+ function createBootstrapCheck(exists) {
2240
+ if (!exists) {
2241
+ return issueCheck("Bootstrap README", "error", "fixable_error", "bootstrap_missing", ".fabric/bootstrap/README.md is missing.", "Run `fab doctor --fix` to generate the bootstrap guide.");
2242
+ }
2243
+ return okCheck("Bootstrap README", ".fabric/bootstrap/README.md exists.");
2244
+ }
2245
+ function createTaxonomyCheck(exists) {
2246
+ if (!exists) {
2247
+ return issueCheck("Initial taxonomy", "error", "manual_error", "taxonomy_missing", ".fabric/INITIAL_TAXONOMY.md is missing.", "Run `fab init` to regenerate project scaffolding including INITIAL_TAXONOMY.md.");
2248
+ }
2249
+ return okCheck("Initial taxonomy", ".fabric/INITIAL_TAXONOMY.md exists.");
2250
+ }
2251
+ function createForensicCheck(forensic, frameworkKind, entryPointCount) {
2252
+ if (!forensic.present) {
2253
+ return issueCheck(
2254
+ "Scan evidence",
2255
+ "error",
2256
+ "manual_error",
2257
+ "forensic_missing",
2258
+ `${forensic.error ?? ".fabric/forensic.json is missing."} Live scan detects ${frameworkKind} with ${entryPointCount} entry point${entryPointCount === 1 ? "" : "s"}.`,
2259
+ "Run `fab init` to regenerate .fabric/forensic.json."
2260
+ );
2261
+ }
2262
+ if (!forensic.valid) {
2263
+ return issueCheck("Scan evidence", "error", "manual_error", "forensic_invalid", forensic.error ?? ".fabric/forensic.json is invalid.", "Run `fab init` to regenerate .fabric/forensic.json.");
2264
+ }
2265
+ return okCheck("Scan evidence", `.fabric/forensic.json is valid for ${forensic.report?.framework.kind ?? "unknown"}.`);
2266
+ }
2267
+ function createInitContextCheck(initContext) {
2268
+ if (!initContext.exists) {
2269
+ return issueCheck("Init context", "error", "manual_error", "init_context_missing", initContext.error ?? ".fabric/init-context.json is missing.", "Run the fabric-init skill in Claude Code or Codex CLI to complete initialization. See docs/migration-1.8.md FAQ.");
2270
+ }
2271
+ if (!initContext.validJson) {
2272
+ return issueCheck("Init context", "error", "manual_error", "init_context_invalid", initContext.error ?? ".fabric/init-context.json is invalid.", "Delete .fabric/init-context.json and run `fab init` to regenerate it.");
2273
+ }
2274
+ return okCheck("Init context", ".fabric/init-context.json is valid JSON.");
2275
+ }
2276
+ function createMetaCheck(meta) {
2277
+ if (!meta.present) {
2278
+ return issueCheck("Agents metadata", "error", "fixable_error", "agents_meta_missing", ".fabric/agents.meta.json is missing.", "Run `fab doctor --fix` to rebuild agents.meta.json from .fabric/rules/.");
2279
+ }
2280
+ if (!meta.valid) {
2281
+ return issueCheck("Agents metadata", "error", "manual_error", "agents_meta_invalid", meta.readError ?? ".fabric/agents.meta.json is invalid.", "Delete .fabric/agents.meta.json and run `fab doctor --fix` to regenerate it.");
2282
+ }
2283
+ if (meta.stale) {
2284
+ return issueCheck(
2285
+ "Agents metadata",
2286
+ "error",
2287
+ "fixable_error",
2288
+ "agents_meta_stale",
2289
+ `.fabric/agents.meta.json revision ${meta.revision} does not match .fabric/rules derived revision ${meta.computedRevision ?? "<unknown>"}.`,
2290
+ "Run `fab doctor --fix` to reconcile agents.meta.json with the current rule files."
2291
+ );
2292
+ }
2293
+ return okCheck("Agents metadata", `.fabric/agents.meta.json revision ${meta.revision} is aligned with .fabric/rules.`);
2294
+ }
2295
+ function createRuleContentRefCheck(meta) {
2296
+ if (!meta.valid) {
2297
+ return issueCheck("Rule content refs", "error", "manual_error", "content_refs_unavailable", "Cannot inspect content_ref entries until agents.meta.json is valid.", "Fix agents.meta.json first: run `fab doctor --fix`.");
2298
+ }
2299
+ if (meta.invalidContentRefs.length > 0) {
2300
+ return issueCheck(
2301
+ "Rule content refs",
2302
+ "error",
2303
+ "manual_error",
2304
+ "content_ref_outside_rules",
2305
+ `${meta.invalidContentRefs.length} content_ref entr${meta.invalidContentRefs.length === 1 ? "y is" : "ies are"} outside .fabric/rules.`,
2306
+ "Edit agents.meta.json to ensure all content_ref values point inside .fabric/rules/."
2307
+ );
2308
+ }
2309
+ if (meta.missingContentRefs.length > 0) {
2310
+ return issueCheck(
2311
+ "Rule content refs",
2312
+ "error",
2313
+ "fixable_error",
2314
+ "content_ref_missing",
2315
+ `${meta.missingContentRefs.length} content_ref target${meta.missingContentRefs.length === 1 ? "" : "s"} are missing. Run \`fab doctor --fix\` to reconcile.`,
2316
+ "Run `fab doctor --fix` to reconcile agents.meta.json with the files present in .fabric/rules/."
2317
+ );
2318
+ }
2319
+ return okCheck("Rule content refs", "All content_ref entries resolve to .fabric/rules files or bootstrap README.");
2320
+ }
2321
+ function createRuleSectionsCheck(snapshot) {
2322
+ if (snapshot.invalidFiles.length > 0) {
2323
+ return issueCheck(
2324
+ "Rule sections",
2325
+ "error",
2326
+ "manual_error",
2327
+ "rule_sections_invalid",
2328
+ `${snapshot.invalidFiles.length} rule file${snapshot.invalidFiles.length === 1 ? "" : "s"} could not be parsed.`,
2329
+ "Edit the rule file(s) to fix the section structure, then re-run `fab doctor`."
2330
+ );
2331
+ }
2332
+ return okCheck("Rule sections", `${snapshot.checkedCount} .fabric/rules file${snapshot.checkedCount === 1 ? "" : "s"} parsed.`);
2333
+ }
2334
+ function createRuleTestIndexCheck(index) {
2335
+ if (!index.present) {
2336
+ return issueCheck("Rule-test index", "error", "fixable_error", "rule_test_index_missing", index.error, "Run `fab doctor --fix` to rebuild .fabric/rule-test.index.json.");
2337
+ }
2338
+ if (!index.valid) {
2339
+ return issueCheck("Rule-test index", "error", "manual_error", "rule_test_index_invalid", index.error, "Delete .fabric/rule-test.index.json and run `fab doctor --fix` to regenerate it.");
2340
+ }
2341
+ if (index.stale) {
2342
+ return issueCheck("Rule-test index", "error", "fixable_error", "rule_test_index_stale", ".fabric/rule-test.index.json is stale.", "Run `fab doctor --fix` to rebuild the rule-test index.");
2343
+ }
2344
+ return okCheck("Rule-test index", `${index.linkCount} link${index.linkCount === 1 ? "" : "s"} and ${index.orphanCount} orphan annotation${index.orphanCount === 1 ? "" : "s"} indexed.`);
2345
+ }
2346
+ function createEventLedgerCheck(ledger) {
2347
+ if (!ledger.exists) {
2348
+ return issueCheck("Event ledger", "error", "fixable_error", "event_ledger_missing", ".fabric/events.jsonl is missing.", "Run `fab doctor --fix` to create .fabric/events.jsonl.");
2349
+ }
2350
+ if (!ledger.writable) {
2351
+ return issueCheck("Event ledger", "error", "manual_error", "event_ledger_not_writable", ledger.error ?? ".fabric/events.jsonl is not writable.", "Check file permissions on .fabric/events.jsonl and ensure no other process holds a write lock.");
2352
+ }
2353
+ if (!ledger.parseable) {
2354
+ return issueCheck("Event ledger", "error", "manual_error", "event_ledger_invalid", ledger.error ?? ".fabric/events.jsonl is invalid.", "Delete .fabric/events.jsonl and run `fab doctor --fix` to recreate it.");
2355
+ }
2356
+ return okCheck("Event ledger", ".fabric/events.jsonl exists, is writable, and is parseable.");
2357
+ }
2358
+ function createMcpConfigInWrongFileCheck(inspection) {
2359
+ if (inspection.hasWrongEntry) {
2360
+ return issueCheck(
2361
+ "Claude MCP config location",
2362
+ "error",
2363
+ "fixable_error",
2364
+ "mcp_config_in_wrong_file",
2365
+ `.claude/settings.json contains mcpServers.fabric \u2014 this file is for hooks/permissions only. Run --fix to remove it, then re-run fab init to write .mcp.json.`,
2366
+ "Run `fab doctor --fix` to remove mcpServers.fabric from .claude/settings.json, then run `fab init` to write .mcp.json."
2367
+ );
2368
+ }
2369
+ return okCheck("Claude MCP config location", "mcpServers.fabric is not in .claude/settings.json.");
2370
+ }
2371
+ function createEventLedgerPartialWriteCheck(ledger) {
2372
+ if (!ledger.exists || !ledger.writable) {
2373
+ return okCheck("Event ledger partial write", "No partial-write check needed (ledger missing or not writable).");
2374
+ }
2375
+ if (ledger.hasPartialWrite) {
2376
+ return issueCheck(
2377
+ "Event ledger partial write",
2378
+ "error",
2379
+ "fixable_error",
2380
+ "event_ledger_partial_write",
2381
+ `events.jsonl has a partial write at byte offset ${ledger.partialWriteByteOffset} (${ledger.partialWriteByteLength} corrupted bytes). Run --fix to truncate and preserve corrupted bytes.`,
2382
+ "Run `fab doctor --fix` to truncate the partial write and restore events.jsonl to a valid state."
2383
+ );
2384
+ }
2385
+ return okCheck("Event ledger partial write", "events.jsonl has no partial trailing write.");
2386
+ }
2387
+ function okCheck(name, message) {
2388
+ return { name, status: "ok", message };
2389
+ }
2390
+ function issueCheck(name, status, kind, code, message, actionHint) {
2391
+ return {
2392
+ name,
2393
+ status,
2394
+ kind,
2395
+ code,
2396
+ fixable: kind === "fixable_error",
2397
+ message,
2398
+ actionHint
2399
+ };
2400
+ }
2401
+ function collectIssues(checks, kind) {
2402
+ return checks.filter((check) => check.kind === kind).map((check) => ({
2403
+ code: check.code ?? check.name,
2404
+ name: check.name,
2405
+ message: check.message
2406
+ }));
2407
+ }
2408
+ function findIssue(issues, code) {
2409
+ return issues.find((issue) => issue.code === code) ?? {
2410
+ code,
2411
+ name: code,
2412
+ message: code
2413
+ };
2414
+ }
2415
+ async function inspectMetaManuallyDiverged(projectRoot) {
2416
+ const metaPath = join8(projectRoot, ".fabric", "agents.meta.json");
2417
+ if (!existsSync4(metaPath)) {
2418
+ return { extraMetaEntries: [], hashMismatchEntries: [], readable: false };
2419
+ }
2420
+ let meta;
2421
+ try {
2422
+ const raw = await readFile7(metaPath, "utf8");
2423
+ meta = agentsMetaSchema4.parse(JSON.parse(raw));
2424
+ } catch (error) {
2425
+ return {
2426
+ extraMetaEntries: [],
2427
+ hashMismatchEntries: [],
2428
+ readable: false,
2429
+ error: error instanceof Error ? error.message : String(error)
2430
+ };
2431
+ }
2432
+ const extraMetaEntries = [];
2433
+ const hashMismatchEntries = [];
2434
+ for (const node of Object.values(meta.nodes)) {
2435
+ const contentRef = node.content_ref ?? node.file;
2436
+ const absPath = join8(projectRoot, contentRef);
2437
+ if (!existsSync4(absPath)) {
2438
+ extraMetaEntries.push(contentRef);
2439
+ continue;
2440
+ }
2441
+ try {
2442
+ const content = readFileSync(absPath, "utf8");
2443
+ const diskHash = sha256(content);
2444
+ if (node.hash !== "" && node.hash !== diskHash) {
2445
+ hashMismatchEntries.push(contentRef);
2446
+ }
2447
+ } catch {
2448
+ extraMetaEntries.push(contentRef);
2449
+ }
2450
+ }
2451
+ return { extraMetaEntries, hashMismatchEntries, readable: true };
2452
+ }
2453
+ function inspectRulesDirUnindexed(projectRoot, meta) {
2454
+ const rulesDir = join8(projectRoot, ".fabric", "rules");
2455
+ if (!existsSync4(rulesDir)) {
2456
+ return { unindexedFiles: [] };
2457
+ }
2458
+ const physicalMdFiles = /* @__PURE__ */ new Set();
2459
+ const stack = [rulesDir];
2460
+ while (stack.length > 0) {
2461
+ const dir = stack.pop();
2462
+ if (dir === void 0) {
2463
+ continue;
2464
+ }
2465
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
2466
+ const abs = join8(dir, entry.name);
2467
+ if (entry.isDirectory()) {
2468
+ stack.push(abs);
2469
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
2470
+ const rel = posix2.join(".fabric/rules", abs.slice(rulesDir.length + 1).replace(/\\/gu, "/"));
2471
+ physicalMdFiles.add(rel);
2472
+ }
2473
+ }
2474
+ }
2475
+ const indexedRefs = /* @__PURE__ */ new Set();
2476
+ if (meta.valid && meta.meta !== null) {
2477
+ for (const node of Object.values(meta.meta.nodes)) {
2478
+ const ref = normalizePath(node.content_ref ?? node.file);
2479
+ indexedRefs.add(ref);
2480
+ }
2481
+ }
2482
+ const unindexedFiles = [...physicalMdFiles].filter((f) => !indexedRefs.has(f)).sort();
2483
+ return { unindexedFiles };
2484
+ }
2485
+ function createRulesDirUnindexedCheck(inspection) {
2486
+ if (inspection.unindexedFiles.length > 0) {
2487
+ return issueCheck(
2488
+ "Rules dir unindexed",
2489
+ "error",
2490
+ "fixable_error",
2491
+ "rules_dir_unindexed",
2492
+ `${inspection.unindexedFiles.length} .md file${inspection.unindexedFiles.length === 1 ? "" : "s"} in .fabric/rules/ not indexed in agents.meta.json. Run \`fab doctor --fix\` to index the missing rule files.`,
2493
+ "Run `fab doctor --fix` to index the missing rule files."
2494
+ );
2495
+ }
2496
+ return okCheck("Rules dir unindexed", "All .fabric/rules/ .md files are indexed in agents.meta.json.");
2497
+ }
2498
+ async function inspectStableIdCollisions(projectRoot) {
2499
+ const rulesDir = join8(projectRoot, ".fabric", "rules");
2500
+ if (!existsSync4(rulesDir)) {
2501
+ return { collisions: [] };
2502
+ }
2503
+ const mdFiles = [];
2504
+ const stack = [rulesDir];
2505
+ while (stack.length > 0) {
2506
+ const dir = stack.pop();
2507
+ if (dir === void 0) {
2508
+ continue;
2509
+ }
2510
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
2511
+ const abs = join8(dir, entry.name);
2512
+ if (entry.isDirectory()) {
2513
+ stack.push(abs);
2514
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
2515
+ mdFiles.push(abs);
2516
+ }
2517
+ }
2518
+ }
2519
+ const stableIdToFiles = /* @__PURE__ */ new Map();
2520
+ const DECLARED_ID_PATTERN = /^(?:\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;
2521
+ for (const absPath of mdFiles) {
2522
+ let source;
2523
+ try {
2524
+ source = await readFile7(absPath, "utf8");
2525
+ } catch {
2526
+ continue;
2527
+ }
2528
+ const match = DECLARED_ID_PATTERN.exec(source);
2529
+ if (match === null) {
2530
+ continue;
2531
+ }
2532
+ const stableId = match[1];
2533
+ const relPath = posix2.join(".fabric/rules", absPath.slice(rulesDir.length + 1).replace(/\\/gu, "/"));
2534
+ const existing = stableIdToFiles.get(stableId) ?? [];
2535
+ existing.push(relPath);
2536
+ stableIdToFiles.set(stableId, existing);
2537
+ }
2538
+ const collisions = [];
2539
+ for (const [stable_id, files] of stableIdToFiles) {
2540
+ if (files.length > 1) {
2541
+ collisions.push({ stable_id, files: files.sort() });
2542
+ }
2543
+ }
2544
+ return { collisions: collisions.sort((a, b) => a.stable_id.localeCompare(b.stable_id)) };
2545
+ }
2546
+ function createStableIdCollisionCheck(inspection) {
2547
+ if (inspection.collisions.length > 0) {
2548
+ const first = inspection.collisions[0];
2549
+ const detail = inspection.collisions.length === 1 ? `stable_id "${first.stable_id}" is declared in ${first.files.length} files: ${first.files.join(", ")}.` : `${inspection.collisions.length} stable_id collision${inspection.collisions.length === 1 ? "" : "s"} detected. First: "${first.stable_id}" in ${first.files.join(", ")}.`;
2550
+ return issueCheck(
2551
+ "Stable ID collision",
2552
+ "warn",
2553
+ "warning",
2554
+ "stable_id_collision",
2555
+ `${detail} Edit one of the rule files to use a unique stable_id.`,
2556
+ "Edit one of the colliding rule files to declare a different `<!-- fab:rule-id X -->` value."
2557
+ );
2558
+ }
2559
+ return okCheck("Stable ID collision", "No declared stable_id collisions found in .fabric/rules/.");
2560
+ }
2561
+ function createMetaManuallyDivergedCheck(inspection) {
2562
+ if (!inspection.readable) {
2563
+ return okCheck("Meta manual divergence", "agents.meta.json not readable; skipping divergence check.");
2564
+ }
2565
+ if (inspection.extraMetaEntries.length > 0) {
2566
+ return issueCheck(
2567
+ "Meta manual divergence",
2568
+ "warn",
2569
+ "warning",
2570
+ "meta_manually_diverged",
2571
+ `agents.meta.json has ${inspection.extraMetaEntries.length} entr${inspection.extraMetaEntries.length === 1 ? "y" : "ies"} with no backing file on disk. Run --fix to reconcile.`,
2572
+ "Run `fab doctor --fix` to reconcile agents.meta.json with the rule files currently on disk."
2573
+ );
2574
+ }
2575
+ if (inspection.hashMismatchEntries.length > 0) {
2576
+ return issueCheck(
2577
+ "Meta manual divergence",
2578
+ "warn",
2579
+ "warning",
2580
+ "meta_manually_diverged",
2581
+ `agents.meta.json has ${inspection.hashMismatchEntries.length} entr${inspection.hashMismatchEntries.length === 1 ? "y" : "ies"} whose hash does not match the file on disk. Run --fix to reconcile.`,
2582
+ "Run `fab doctor --fix` to reconcile agents.meta.json with the current rule file contents."
2583
+ );
2584
+ }
2585
+ return okCheck("Meta manual divergence", "agents.meta.json is consistent with rule files on disk.");
2586
+ }
2587
+ function inspectPreexistingRootFiles(projectRoot) {
2588
+ const candidates = ["CLAUDE.md", "AGENTS.md"];
2589
+ const detected = candidates.filter((name) => existsSync4(join8(projectRoot, name)));
2590
+ return { detected };
2591
+ }
2592
+ function createPreexistingRootFilesCheck(inspection) {
2593
+ if (inspection.detected.length === 0) {
2594
+ return okCheck("Preexisting root markdown", "No CLAUDE.md or AGENTS.md detected at project root.");
2595
+ }
2596
+ return {
2597
+ name: "Preexisting root markdown",
2598
+ status: "ok",
2599
+ kind: "info",
2600
+ code: "preexisting_root_claude_md",
2601
+ fixable: false,
2602
+ message: `${inspection.detected.join(", ")} detected at project root. These root files are not auto-loaded by Fabric MCP.`,
2603
+ actionHint: "Move rule content to `.fabric/rules/` if you want it available in MCP responses."
2604
+ };
2605
+ }
2606
+ function inspectClaudeSkillLegacyPath(projectRoot) {
2607
+ const legacyPath = join8(projectRoot, ".claude", "skills", "agents-md-init", "SKILL.md");
2608
+ const newPath = join8(projectRoot, ".claude", "skills", "fabric-init", "SKILL.md");
2609
+ const hasLegacy = existsSync4(legacyPath) && !existsSync4(newPath);
2610
+ return { hasLegacy, legacyPath, newPath };
2611
+ }
2612
+ function createClaudeSkillLegacyPathCheck(inspection) {
2613
+ if (inspection.hasLegacy) {
2614
+ return issueCheck(
2615
+ "Claude skill path",
2616
+ "error",
2617
+ "fixable_error",
2618
+ "claude_skill_legacy_path",
2619
+ `.claude/skills/agents-md-init/SKILL.md exists at the legacy path. Run --fix to migrate it to .claude/skills/fabric-init/SKILL.md (user edits preserved).`,
2620
+ "Run `fab doctor --fix` to rename agents-md-init/ to fabric-init/, preserving any user edits to SKILL.md."
2621
+ );
2622
+ }
2623
+ return okCheck("Claude skill path", ".claude/skills/fabric-init/SKILL.md is at the canonical path (or not present).");
2624
+ }
2625
+ async function fixClaudeSkillLegacyPath(projectRoot) {
2626
+ const legacyPath = join8(projectRoot, ".claude", "skills", "agents-md-init", "SKILL.md");
2627
+ const newPath = join8(projectRoot, ".claude", "skills", "fabric-init", "SKILL.md");
2628
+ if (!existsSync4(legacyPath)) {
2629
+ return;
2630
+ }
2631
+ mkdirSync(join8(newPath, ".."), { recursive: true });
2632
+ renameSync(legacyPath, newPath);
2633
+ const legacyDir = join8(legacyPath, "..");
2634
+ try {
2635
+ rmdirSync(legacyDir);
2636
+ } catch {
2637
+ }
2638
+ await appendEventLedgerEvent(projectRoot, {
2639
+ event_type: "claude_skill_path_migrated",
2640
+ from: legacyPath,
2641
+ to: newPath
2642
+ });
2643
+ }
2644
+ function inspectLegacyClientPaths(projectRoot) {
2645
+ const configPath = join8(projectRoot, "fabric.config.json");
2646
+ if (!existsSync4(configPath)) {
2647
+ return { presentKeys: [] };
2648
+ }
2649
+ try {
2650
+ const parsed = JSON.parse(readFileSync(configPath, "utf8"));
2651
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
2652
+ return { presentKeys: [] };
2653
+ }
2654
+ const config = parsed;
2655
+ const clientPaths = config.clientPaths;
2656
+ if (clientPaths === null || typeof clientPaths !== "object" || Array.isArray(clientPaths)) {
2657
+ return { presentKeys: [] };
2658
+ }
2659
+ const cp = clientPaths;
2660
+ const presentKeys = LEGACY_CLIENT_PATH_KEYS.filter((key) => key in cp);
2661
+ return { presentKeys };
2662
+ } catch {
2663
+ return { presentKeys: [] };
2664
+ }
2665
+ }
2666
+ function createLegacyClientPathCheck(inspection) {
2667
+ if (inspection.presentKeys.length > 0) {
2668
+ return issueCheck(
2669
+ "Legacy client paths",
2670
+ "warn",
2671
+ "warning",
2672
+ "legacy_client_path_present",
2673
+ `fabric.config.json contains deprecated clientPaths keys: ${inspection.presentKeys.join(", ")}. These clients are removed in 1.8.0; run --fix to clean now or accept the upcoming removal.`,
2674
+ "Run `fab doctor --fix` to remove deprecated clientPaths keys (windsurf, rooCode, geminiCLI) from fabric.config.json."
2675
+ );
2676
+ }
2677
+ return okCheck("Legacy client paths", "No deprecated clientPaths keys found in fabric.config.json.");
2678
+ }
2679
+ async function fixLegacyClientPaths(projectRoot) {
2680
+ const configPath = join8(projectRoot, "fabric.config.json");
2681
+ if (!existsSync4(configPath)) {
2682
+ return;
2683
+ }
2684
+ let config;
2685
+ try {
2686
+ const parsed = JSON.parse(readFileSync(configPath, "utf8"));
2687
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
2688
+ return;
2689
+ }
2690
+ config = parsed;
2691
+ } catch {
2692
+ return;
2693
+ }
2694
+ const clientPaths = config.clientPaths;
2695
+ if (clientPaths === null || typeof clientPaths !== "object" || Array.isArray(clientPaths)) {
2696
+ return;
2697
+ }
2698
+ const cp = clientPaths;
2699
+ const removed = [];
2700
+ for (const key of LEGACY_CLIENT_PATH_KEYS) {
2701
+ if (key in cp) {
2702
+ delete cp[key];
2703
+ removed.push(key);
2704
+ }
2705
+ }
2706
+ if (removed.length === 0) {
2707
+ return;
2708
+ }
2709
+ const updatedConfig = { ...config, clientPaths: cp };
2710
+ await atomicWriteJson2(configPath, updatedConfig, { indent: 2 });
2711
+ await appendEventLedgerEvent(projectRoot, {
2712
+ event_type: "legacy_client_path_present",
2713
+ removed
2714
+ });
2715
+ }
2716
+ async function fixMcpConfigInWrongFile(projectRoot) {
2717
+ const settingsPath = join8(projectRoot, ".claude", "settings.json");
2718
+ if (!existsSync4(settingsPath)) {
2719
+ return;
2720
+ }
2721
+ let settings;
2722
+ try {
2723
+ const parsed = JSON.parse(readFileSync(settingsPath, "utf8"));
2724
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
2725
+ return;
2726
+ }
2727
+ settings = parsed;
2728
+ } catch {
2729
+ return;
2730
+ }
2731
+ const mcpServers = settings.mcpServers;
2732
+ if (mcpServers === null || typeof mcpServers !== "object" || Array.isArray(mcpServers)) {
2733
+ return;
2734
+ }
2735
+ const { fabric: _removed, ...remainingServers } = mcpServers;
2736
+ const cleaned = { ...settings };
2737
+ if (Object.keys(remainingServers).length === 0) {
2738
+ delete cleaned.mcpServers;
2739
+ } else {
2740
+ cleaned.mcpServers = remainingServers;
2741
+ }
2742
+ await atomicWriteJson2(settingsPath, cleaned, { indent: 2 });
2743
+ await appendEventLedgerEvent(projectRoot, {
2744
+ event_type: "mcp_config_migrated",
2745
+ source: "doctor_fix",
2746
+ removed_from: ".claude/settings.json"
2747
+ });
2748
+ }
2749
+ async function writeDefaultBootstrap(projectRoot) {
2750
+ const path = join8(projectRoot, FABRIC_BOOTSTRAP_PATH);
2751
+ await ensureParentDirectory(path);
2752
+ await atomicWriteText3(path, buildBootstrapContent(projectRoot));
2753
+ }
2754
+ async function ensureEventLedger(projectRoot) {
2755
+ const path = getEventLedgerPath(projectRoot);
2756
+ await ensureParentDirectory(path);
2757
+ await writeFile2(path, "", { encoding: "utf8", flag: "a" });
2758
+ }
2759
+ function createFixMessage(fixed, report) {
2760
+ const fixedText = fixed.length === 0 ? "No deterministic doctor fixes were needed." : `Applied ${fixed.length} deterministic doctor fix${fixed.length === 1 ? "" : "es"}.`;
2761
+ const manualText = report.manual_errors.length === 0 ? "No manual errors remain." : `${report.manual_errors.length} manual error${report.manual_errors.length === 1 ? "" : "s"} remain.`;
2762
+ return `${fixedText} ${manualText}`;
2763
+ }
2764
+ function findRuleFiles2(projectRoot) {
2765
+ const rulesRoot = join8(projectRoot, ".fabric", "rules");
2766
+ if (!existsSync4(rulesRoot) || !statSync3(rulesRoot).isDirectory()) {
2767
+ return [];
2768
+ }
2769
+ const files = [];
2770
+ const stack = [rulesRoot];
2771
+ while (stack.length > 0) {
2772
+ const current = stack.pop();
2773
+ if (current === void 0) {
2774
+ continue;
2775
+ }
2776
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
2777
+ const absolutePath = join8(current, entry.name);
2778
+ const relativePath = normalizePath(absolutePath.slice(projectRoot.length + 1));
2779
+ if (entry.isDirectory()) {
2780
+ stack.push(absolutePath);
2781
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
2782
+ files.push(relativePath);
2783
+ }
2784
+ }
2785
+ }
2786
+ return files.sort();
2787
+ }
2788
+ function isValidJsonLine(line) {
2789
+ try {
2790
+ JSON.parse(line);
2791
+ return true;
2792
+ } catch {
2793
+ return false;
2794
+ }
2795
+ }
2796
+ function normalizeTarget(targetInput) {
2797
+ return isAbsolute3(targetInput) ? targetInput : resolve4(process.cwd(), targetInput);
2798
+ }
2799
+ function normalizePath(path) {
2800
+ return posix2.normalize(path.split("\\").join("/"));
2801
+ }
2802
+ function collectEntryPoints(root) {
2803
+ if (!existsSync4(root) || !statSync3(root).isDirectory()) {
2804
+ return [];
2805
+ }
2806
+ const entries = [];
2807
+ const stack = [root];
2808
+ while (stack.length > 0) {
2809
+ const current = stack.pop();
2810
+ if (current === void 0) {
2811
+ continue;
2812
+ }
2813
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
2814
+ const absolutePath = join8(current, entry.name);
2815
+ const relativePath = normalizePath(absolutePath.slice(root.length + 1));
2816
+ if (relativePath.length === 0) {
2817
+ continue;
2818
+ }
2819
+ if (entry.isDirectory()) {
2820
+ if (!IGNORED_DIRECTORIES.has(entry.name)) {
2821
+ stack.push(absolutePath);
2822
+ }
2823
+ continue;
2824
+ }
2825
+ if (!entry.isFile()) {
2826
+ continue;
2827
+ }
2828
+ const reason = getEntryPointReason(relativePath);
2829
+ if (reason !== null) {
2830
+ entries.push({ path: relativePath, reason });
2831
+ }
2832
+ }
2833
+ }
2834
+ return entries.sort((left, right) => left.path.localeCompare(right.path));
2835
+ }
2836
+ function getEntryPointReason(relativePath) {
2837
+ const extension = relativePath.slice(relativePath.lastIndexOf("."));
2838
+ if (!SCRIPT_EXTENSIONS.has(extension)) {
2839
+ return null;
2840
+ }
2841
+ const directory = posix2.dirname(relativePath);
2842
+ const fileName = posix2.basename(relativePath);
2843
+ const fileBase = fileName.slice(0, Math.max(fileName.lastIndexOf("."), 0));
2844
+ if (directory === "assets/scripts" || directory === "scripts") {
2845
+ return "top-level script";
2846
+ }
2847
+ if (directory === "src" && /^(App|app|index|main)$/.test(fileBase)) {
2848
+ return "application entry";
2849
+ }
2850
+ if ((directory === "app" || directory.startsWith("app/")) && /^(layout|page|route)$/.test(fileBase)) {
2851
+ return "next app route";
2852
+ }
2853
+ if ((directory === "pages" || directory.startsWith("pages/")) && fileName !== "_app.d.ts") {
2854
+ return "next page route";
2855
+ }
2856
+ return null;
2857
+ }
2858
+ function reduceStatus(statuses) {
2859
+ if (statuses.includes("error")) {
2860
+ return "error";
2861
+ }
2862
+ if (statuses.includes("warn")) {
2863
+ return "warn";
2864
+ }
2865
+ return "ok";
2866
+ }
2867
+ function isMissingFileError(error) {
2868
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
2869
+ }
2870
+
2871
+ export {
2872
+ AGENTS_MD_RESOURCE_URI,
2873
+ contextCache,
2874
+ resolveProjectRoot,
2875
+ readAgentsMeta,
2876
+ LEDGER_PATH,
2877
+ LEGACY_LEDGER_PATH,
2878
+ EVENT_LEDGER_PATH,
2879
+ getLedgerPath,
2880
+ getLegacyLedgerPath,
2881
+ getEventLedgerPath,
2882
+ sha256,
2883
+ isNodeError,
2884
+ appendEventLedgerEvent,
2885
+ readEventLedger,
2886
+ flushAndSyncEventLedger,
2887
+ buildRuleMeta,
2888
+ writeRuleMeta,
2889
+ computeRulesBasedAgentsMeta,
2890
+ computeRuleTestIndex,
2891
+ deriveRuleMetaLayer,
2892
+ deriveRuleMetaTopologyType,
2893
+ isSameRuleTestIndex,
2894
+ stableStringify,
2895
+ invalidateRuleSyncCooldown,
2896
+ ensureRulesFresh,
2897
+ reconcileRules,
2898
+ getRules,
2899
+ planContext,
2900
+ getRuleSections,
2901
+ runDoctorReport,
2902
+ runDoctorFix
2903
+ };