@fenglimg/fabric-server 2.0.0 → 2.1.0-rc.2

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.
@@ -1,4061 +0,0 @@
1
- // src/cache.ts
2
- var ContextCache = class {
3
- constructor(defaultTtlMs = 5e3) {
4
- this.defaultTtlMs = defaultTtlMs;
5
- }
6
- defaultTtlMs;
7
- // Slot 1: raw AgentsMeta keyed by projectRoot
8
- metaSlot = /* @__PURE__ */ new Map();
9
- // Slot 2: GetKnowledgeContext keyed by projectRoot
10
- contextSlot = /* @__PURE__ */ new Map();
11
- // Slot 3: audit sliding-window cursor keyed by projectRoot
12
- auditSlot = /* @__PURE__ */ new Map();
13
- // ---------------------------------------------------------------------------
14
- // Generic get / set / invalidate
15
- // ---------------------------------------------------------------------------
16
- get(slot, key) {
17
- const store = this.slotStore(slot);
18
- const entry = store.get(key);
19
- if (entry === void 0) {
20
- return void 0;
21
- }
22
- if (entry.expiresAt !== 0 && Date.now() > entry.expiresAt) {
23
- store.delete(key);
24
- return void 0;
25
- }
26
- return entry.value;
27
- }
28
- set(slot, key, value, ttlMs) {
29
- const store = this.slotStore(slot);
30
- const resolvedTtl = ttlMs ?? this.defaultTtlMs;
31
- const expiresAt = resolvedTtl > 0 ? Date.now() + resolvedTtl : 0;
32
- store.set(key, { value, expiresAt });
33
- }
34
- // ---------------------------------------------------------------------------
35
- // Audit cursor (separate API — not TTL-based)
36
- // ---------------------------------------------------------------------------
37
- getAuditCursor(projectRoot) {
38
- return this.auditSlot.get(projectRoot);
39
- }
40
- setAuditCursor(projectRoot, cursor) {
41
- this.auditSlot.set(projectRoot, cursor);
42
- }
43
- resetAuditCursor(projectRoot) {
44
- this.auditSlot.delete(projectRoot);
45
- }
46
- // ---------------------------------------------------------------------------
47
- // Invalidation
48
- // ---------------------------------------------------------------------------
49
- /**
50
- * Invalidate cache slots based on what changed.
51
- *
52
- * @param reason "meta_write" — only the meta slot for this projectRoot
53
- * "file_watch" — meta + context slots (AGENTS.md may have changed)
54
- * @param projectRoot Optional; if omitted, clears ALL keys in affected slots.
55
- */
56
- invalidate(reason, projectRoot) {
57
- if (reason === "meta_write") {
58
- if (projectRoot !== void 0) {
59
- this.metaSlot.delete(projectRoot);
60
- } else {
61
- this.metaSlot.clear();
62
- }
63
- return;
64
- }
65
- if (projectRoot !== void 0) {
66
- this.metaSlot.delete(projectRoot);
67
- this.contextSlot.delete(projectRoot);
68
- } else {
69
- this.metaSlot.clear();
70
- this.contextSlot.clear();
71
- }
72
- }
73
- // ---------------------------------------------------------------------------
74
- // Helpers
75
- // ---------------------------------------------------------------------------
76
- slotStore(slot) {
77
- return slot === "meta" ? this.metaSlot : this.contextSlot;
78
- }
79
- };
80
- var contextCache = new ContextCache(5e3);
81
-
82
- // src/meta-reader.ts
83
- import { readFile } from "fs/promises";
84
- import { join } from "path";
85
- import { agentsMetaSchema } from "@fenglimg/fabric-shared";
86
- import { IOFabricError } from "@fenglimg/fabric-shared/errors";
87
- import { agentsMetaNodeSchema, agentsMetaSchema as agentsMetaSchema2 } from "@fenglimg/fabric-shared";
88
- var AgentsMetaFileMissingError = class extends IOFabricError {
89
- constructor(metaPath, opts) {
90
- super(`Fabric agents metadata file is missing: ${metaPath}`, {
91
- actionHint: opts?.actionHint ?? "Run `fab init` to scaffold the .fabric/agents.meta.json file"
92
- });
93
- this.metaPath = metaPath;
94
- }
95
- metaPath;
96
- code = "FABRIC_META_MISSING";
97
- httpStatus = 404;
98
- };
99
- var AgentsMetaInvalidError = class extends IOFabricError {
100
- constructor(metaPath, cause, opts) {
101
- const detail = cause instanceof Error ? cause.message : String(cause);
102
- super(`Fabric agents metadata file is invalid: ${metaPath}. ${detail}`, {
103
- actionHint: opts?.actionHint ?? "Check the agents.meta.json file for schema errors and regenerate if needed"
104
- });
105
- this.metaPath = metaPath;
106
- }
107
- metaPath;
108
- code = "FABRIC_META_INVALID";
109
- httpStatus = 500;
110
- };
111
- function getAgentsMetaPath(projectRoot) {
112
- return join(projectRoot, ".fabric", "agents.meta.json");
113
- }
114
- function resolveProjectRoot() {
115
- return process.env.FABRIC_PROJECT_ROOT ?? process.cwd();
116
- }
117
- async function readAgentsMeta(projectRoot) {
118
- const cached = contextCache.get("meta", projectRoot);
119
- if (cached !== void 0) {
120
- return cached;
121
- }
122
- const metaPath = getAgentsMetaPath(projectRoot);
123
- let raw;
124
- try {
125
- raw = await readFile(metaPath, "utf8");
126
- } catch (error) {
127
- if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
128
- throw new AgentsMetaFileMissingError(metaPath);
129
- }
130
- throw error;
131
- }
132
- let parsed;
133
- try {
134
- parsed = agentsMetaSchema.parse(JSON.parse(raw));
135
- } catch (error) {
136
- throw new AgentsMetaInvalidError(metaPath, error);
137
- }
138
- contextCache.set("meta", projectRoot, parsed);
139
- return parsed;
140
- }
141
-
142
- // src/services/_shared.ts
143
- import { dirname, join as join2, resolve, sep } from "path";
144
- import { createHash } from "crypto";
145
- import { mkdir } from "fs/promises";
146
- import { PathEscapeError } from "@fenglimg/fabric-shared/errors";
147
- import { atomicWriteText, atomicWriteJson } from "@fenglimg/fabric-shared/node/atomic-write";
148
- var FABRIC_DIR = ".fabric";
149
- var LEDGER_FILE = ".intent-ledger.jsonl";
150
- var LEDGER_PATH = `${FABRIC_DIR}/${LEDGER_FILE}`;
151
- var LEGACY_LEDGER_PATH = LEDGER_FILE;
152
- var EVENT_LEDGER_FILE = "events.jsonl";
153
- var EVENT_LEDGER_PATH = `${FABRIC_DIR}/${EVENT_LEDGER_FILE}`;
154
- function getLedgerPath(projectRoot) {
155
- return join2(projectRoot, LEDGER_PATH);
156
- }
157
- function getLegacyLedgerPath(projectRoot) {
158
- return join2(projectRoot, LEGACY_LEDGER_PATH);
159
- }
160
- function getEventLedgerPath(projectRoot) {
161
- return join2(projectRoot, EVENT_LEDGER_PATH);
162
- }
163
- async function ensureParentDirectory(path) {
164
- await mkdir(dirname(path), { recursive: true });
165
- }
166
- function sha256(content) {
167
- return `sha256:${createHash("sha256").update(content).digest("hex")}`;
168
- }
169
- function isNodeError(error) {
170
- return error instanceof Error;
171
- }
172
-
173
- // src/services/event-ledger.ts
174
- import { randomUUID } from "crypto";
175
- import { existsSync, fsyncSync, openSync, closeSync } from "fs";
176
- import { readFile as readFile2, truncate, writeFile } from "fs/promises";
177
- import {
178
- eventLedgerEventSchema
179
- } from "@fenglimg/fabric-shared";
180
- import { createLedgerWriteQueue } from "@fenglimg/fabric-shared/node/atomic-write";
181
- var ledgerQueue = createLedgerWriteQueue();
182
- async function appendEventLedgerEvent(projectRoot, event) {
183
- const eventPath = getEventLedgerPath(projectRoot);
184
- const nextEvent = eventLedgerEventSchema.parse({
185
- ...event,
186
- kind: "fabric-event",
187
- id: event.id ?? `event:${randomUUID()}`,
188
- ts: event.ts ?? Date.now(),
189
- schema_version: 1
190
- });
191
- await ensureParentDirectory(eventPath);
192
- await ledgerQueue.append(eventPath, JSON.stringify(nextEvent));
193
- return nextEvent;
194
- }
195
- async function readEventLedger(projectRoot, options = {}) {
196
- const eventPath = getEventLedgerPath(projectRoot);
197
- let raw;
198
- try {
199
- raw = await readFile2(eventPath, "utf8");
200
- } catch (error) {
201
- if (isNodeError2(error) && error.code === "ENOENT") {
202
- return { events: [], warnings: [] };
203
- }
204
- throw error;
205
- }
206
- const warnings = [];
207
- const lines = raw.split(/\r?\n/);
208
- const hasTrailingNewline = raw.endsWith("\n");
209
- let partialLine;
210
- if (!hasTrailingNewline && lines.length > 0) {
211
- partialLine = lines.pop();
212
- }
213
- if (partialLine !== void 0 && partialLine.trim().length > 0) {
214
- const fullContentBeforePartial = raw.slice(0, raw.length - partialLine.length);
215
- const byteOffset = Buffer.byteLength(fullContentBeforePartial, "utf8");
216
- const byteLength = Buffer.byteLength(partialLine, "utf8");
217
- warnings.push({
218
- kind: "partial_write_at_tail",
219
- byte_offset: byteOffset,
220
- byte_length: byteLength,
221
- snippet_first_120: partialLine.slice(0, 120)
222
- });
223
- }
224
- 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);
225
- return { events, warnings };
226
- }
227
- async function truncateLedgerToLastNewline(path) {
228
- const raw = await readFile2(path);
229
- const content = raw.toString("utf8");
230
- if (content.endsWith("\n") || content.length === 0) {
231
- return { truncated_bytes: 0, corrupted_path: "" };
232
- }
233
- const lastNewlineIndex = content.lastIndexOf("\n");
234
- if (lastNewlineIndex === -1) {
235
- const corruptedPath2 = `${path}.corrupted.${Date.now()}`;
236
- await writeFile(corruptedPath2, raw);
237
- await truncate(path, 0);
238
- return { truncated_bytes: raw.length, corrupted_path: corruptedPath2 };
239
- }
240
- const keepByteLength = Buffer.byteLength(content.slice(0, lastNewlineIndex + 1), "utf8");
241
- const corruptedBytes = raw.slice(keepByteLength);
242
- const corruptedPath = `${path}.corrupted.${Date.now()}`;
243
- await writeFile(corruptedPath, corruptedBytes);
244
- await truncate(path, keepByteLength);
245
- return { truncated_bytes: corruptedBytes.length, corrupted_path: corruptedPath };
246
- }
247
- function parseEventLedgerLine(line, index) {
248
- try {
249
- const parsed = JSON.parse(line);
250
- const result = eventLedgerEventSchema.safeParse(parsed);
251
- if (!result.success) {
252
- return null;
253
- }
254
- return {
255
- ...result.data,
256
- id: result.data.id || createDerivedId(index, line)
257
- };
258
- } catch {
259
- return null;
260
- }
261
- }
262
- function createDerivedId(index, line) {
263
- return `event:${index + 1}:${sha256(line).slice("sha256:".length)}`;
264
- }
265
- function isNodeError2(error) {
266
- return error instanceof Error;
267
- }
268
- function flushAndSyncEventLedger(projectRoot) {
269
- const ledgerPath = getEventLedgerPath(projectRoot);
270
- if (!existsSync(ledgerPath)) return;
271
- const fd = openSync(ledgerPath, "r+");
272
- try {
273
- fsyncSync(fd);
274
- } finally {
275
- closeSync(fd);
276
- }
277
- }
278
-
279
- // src/services/knowledge-meta-builder.ts
280
- import { mkdir as mkdir2, readdir, readFile as readFile3 } from "fs/promises";
281
- import { existsSync as existsSync2, statSync } from "fs";
282
- import { homedir } from "os";
283
- import { isAbsolute, join as join3, relative, resolve as resolve2, sep as sep2 } from "path";
284
- import {
285
- KNOWLEDGE_TEST_INDEX_SCHEMA_VERSION,
286
- agentsMetaSchema as agentsMetaSchema3,
287
- defaultAgentsMetaCounters,
288
- deriveAgentsMetaLayer,
289
- deriveAgentsMetaStableId,
290
- deriveAgentsMetaTopologyType,
291
- isKnowledgeStableId,
292
- knowledgeTestIndexSchema,
293
- KnowledgeTypeSchema,
294
- MaturitySchema,
295
- LayerSchema,
296
- StableIdSchema,
297
- parseKnowledgeId
298
- } from "@fenglimg/fabric-shared";
299
- import { atomicWriteText as atomicWriteText2 } from "@fenglimg/fabric-shared/node/atomic-write";
300
- async function buildKnowledgeMeta(projectRootInput) {
301
- const projectRoot = normalizeProjectRoot(projectRootInput);
302
- assertExistingDirectory(projectRoot);
303
- const metaPath = join3(projectRoot, ".fabric", "agents.meta.json");
304
- const knowledgeTestIndexPath = join3(projectRoot, ".fabric", ".cache", "knowledge-test.index.json");
305
- const existingMeta = await readExistingMeta(metaPath);
306
- const existingKnowledgeTestIndex = await readExistingKnowledgeTestIndex(knowledgeTestIndexPath);
307
- const meta = await computeKnowledgeBasedAgentsMeta(projectRoot, existingMeta);
308
- const knowledgeTestIndex = await computeKnowledgeTestIndex(projectRoot, meta, existingKnowledgeTestIndex);
309
- return {
310
- meta,
311
- knowledgeTestIndex,
312
- changed: existingMeta === void 0 || stableStringify(existingMeta) !== stableStringify(meta) || existingKnowledgeTestIndex === void 0 || !isSameKnowledgeTestIndex(existingKnowledgeTestIndex, knowledgeTestIndex)
313
- };
314
- }
315
- async function writeKnowledgeMeta(projectRootInput, options) {
316
- const projectRoot = normalizeProjectRoot(projectRootInput);
317
- const metaPath = join3(projectRoot, ".fabric", "agents.meta.json");
318
- const knowledgeTestIndexPath = join3(projectRoot, ".fabric", ".cache", "knowledge-test.index.json");
319
- const existingMeta = await readExistingMeta(metaPath);
320
- const result = await buildKnowledgeMeta(projectRoot);
321
- if (!result.changed) {
322
- return result;
323
- }
324
- await ensureParentDirectory(metaPath);
325
- await atomicWriteText2(metaPath, `${JSON.stringify(result.meta, null, 2)}
326
- `);
327
- await ensureParentDirectory(knowledgeTestIndexPath);
328
- await atomicWriteText2(knowledgeTestIndexPath, `${JSON.stringify(result.knowledgeTestIndex, null, 2)}
329
- `);
330
- if (existingMeta === void 0 || stableStringify(existingMeta) !== stableStringify(result.meta)) {
331
- await recordBaselineSynced(projectRoot, {
332
- previousRevision: existingMeta?.revision,
333
- revision: result.meta.revision,
334
- syncedFiles: collectSyncedFiles(existingMeta, result.meta),
335
- acceptedStableIds: collectStableIds(result.meta),
336
- driftDetails: collectDriftDetails(existingMeta, result.meta),
337
- source: options.source
338
- });
339
- }
340
- return result;
341
- }
342
- async function computeKnowledgeBasedAgentsMeta(projectRootInput, existingMeta) {
343
- const projectRoot = normalizeProjectRoot(projectRootInput);
344
- assertExistingDirectory(projectRoot);
345
- const previousMeta = existingMeta ?? await readExistingMeta(join3(projectRoot, ".fabric", "agents.meta.json"));
346
- const existingByContentRef = indexExistingNodesByContentRef(previousMeta);
347
- const ruleFiles = await findKnowledgeFiles(projectRoot);
348
- const nodes = {};
349
- for (const contentRef of ruleFiles) {
350
- const source = await readFile3(resolveContentRefPath(projectRoot, contentRef), "utf8");
351
- const existing = existingByContentRef.get(contentRef);
352
- const hash = sha256(source);
353
- const defaults = createDefaultNodeMeta(contentRef);
354
- const identity = deriveRuleIdentity(contentRef, source, existing?.node);
355
- const id = isKnowledgeStableId(identity.stableId) ? identity.stableId : deriveNodeId(contentRef);
356
- nodes[id] = {
357
- ...defaults,
358
- ...existing?.node,
359
- file: contentRef,
360
- content_ref: contentRef,
361
- hash,
362
- stable_id: identity.stableId,
363
- identity_source: identity.identitySource,
364
- description: extractRuleDescription(source) ?? existing?.node.description,
365
- sections: extractRuleSections(source)
366
- };
367
- }
368
- const counters = previousMeta?.counters ?? defaultAgentsMetaCounters();
369
- return {
370
- ...previousMeta ?? {},
371
- revision: computeRevision(nodes),
372
- nodes: sortNodes(nodes),
373
- counters
374
- };
375
- }
376
- async function computeKnowledgeTestIndex(projectRootInput, computedMeta, previousIndex) {
377
- const projectRoot = normalizeProjectRoot(projectRootInput);
378
- assertExistingDirectory(projectRoot);
379
- const previousLinks = indexPreviousRuleTestEntries(previousIndex?.links ?? []);
380
- const previousOrphans = indexPreviousRuleTestEntries(previousIndex?.orphan_annotations ?? []);
381
- const rulesByStableId = indexRulesByStableId(computedMeta);
382
- const links = [];
383
- const orphanAnnotations = [];
384
- for (const annotation of await findFabricVerifyAnnotations(projectRoot)) {
385
- const rule = rulesByStableId.get(annotation.stableId);
386
- const key = createRuleTestEntryKey(annotation.stableId, annotation.testFile, annotation.line);
387
- if (rule === void 0) {
388
- const previous2 = previousOrphans.get(key) ?? previousLinks.get(key);
389
- orphanAnnotations.push({
390
- rule_stable_id: annotation.stableId,
391
- test_file: annotation.testFile,
392
- test_hash: annotation.testHash,
393
- previous_test_hash: getPreviousTestHash(previous2, annotation.testHash),
394
- annotation_line: annotation.line
395
- });
396
- continue;
397
- }
398
- const previous = previousLinks.get(key) ?? previousOrphans.get(key);
399
- const previousHashes = getPreviousRuleTestHashes(previous, rule.hash, annotation.testHash);
400
- links.push({
401
- rule_stable_id: annotation.stableId,
402
- rule_file: rule.content_ref ?? rule.file,
403
- rule_hash: rule.hash,
404
- previous_rule_hash: previousHashes.previousRuleHash,
405
- test_file: annotation.testFile,
406
- test_hash: annotation.testHash,
407
- previous_test_hash: previousHashes.previousTestHash,
408
- annotation_line: annotation.line
409
- });
410
- }
411
- return {
412
- schema_version: KNOWLEDGE_TEST_INDEX_SCHEMA_VERSION,
413
- generated_at: (/* @__PURE__ */ new Date()).toISOString(),
414
- revision: computedMeta.revision,
415
- previous_revision: previousIndex?.revision !== void 0 && previousIndex.revision !== computedMeta.revision ? previousIndex.revision : previousIndex?.previous_revision,
416
- links: links.sort(compareRuleTestEntries),
417
- orphan_annotations: orphanAnnotations.sort(compareRuleTestEntries)
418
- };
419
- }
420
- function deriveKnowledgeMetaLayer(relativePath) {
421
- return deriveAgentsMetaLayer(toAgentsCompatiblePath(relativePath));
422
- }
423
- function deriveKnowledgeMetaTopologyType(relativePath) {
424
- return deriveAgentsMetaTopologyType(toAgentsCompatiblePath(relativePath));
425
- }
426
- function isSameKnowledgeTestIndex(left, right) {
427
- return stableStringify(toComparableKnowledgeTestIndex(left)) === stableStringify(toComparableKnowledgeTestIndex(right));
428
- }
429
- function stableStringify(value) {
430
- return JSON.stringify(value, Object.keys(flattenKeys(value)).sort());
431
- }
432
- function normalizeProjectRoot(projectRoot) {
433
- return isAbsolute(projectRoot) ? projectRoot : resolve2(process.cwd(), projectRoot);
434
- }
435
- function assertExistingDirectory(projectRoot) {
436
- if (!existsSync2(projectRoot) || !statSync(projectRoot).isDirectory()) {
437
- throw new Error(`Target directory does not exist: ${projectRoot}`);
438
- }
439
- }
440
- async function readExistingMeta(metaPath) {
441
- let raw;
442
- try {
443
- raw = await readFile3(metaPath, "utf8");
444
- } catch (error) {
445
- if (isNodeError3(error) && error.code === "ENOENT") {
446
- return void 0;
447
- }
448
- throw error;
449
- }
450
- try {
451
- return agentsMetaSchema3.parse(JSON.parse(raw));
452
- } catch {
453
- return void 0;
454
- }
455
- }
456
- async function readExistingKnowledgeTestIndex(indexPath) {
457
- let raw;
458
- try {
459
- raw = await readFile3(indexPath, "utf8");
460
- } catch (error) {
461
- if (isNodeError3(error) && error.code === "ENOENT") {
462
- return void 0;
463
- }
464
- throw error;
465
- }
466
- try {
467
- return knowledgeTestIndexSchema.parse(JSON.parse(raw));
468
- } catch {
469
- return void 0;
470
- }
471
- }
472
- var KNOWLEDGE_SUBDIRS = ["decisions", "pitfalls", "guidelines", "models", "processes", "pending"];
473
- var PERSONAL_CONTENT_REF_PREFIX = "~/.fabric/knowledge/";
474
- var TEAM_CONTENT_REF_PREFIX = ".fabric/knowledge/";
475
- function resolvePersonalRoot() {
476
- return process.env.FABRIC_HOME ?? homedir();
477
- }
478
- function resolveContentRefPath(projectRoot, contentRef) {
479
- if (contentRef.startsWith(PERSONAL_CONTENT_REF_PREFIX)) {
480
- return join3(resolvePersonalRoot(), ".fabric", "knowledge", contentRef.slice(PERSONAL_CONTENT_REF_PREFIX.length));
481
- }
482
- return join3(projectRoot, contentRef);
483
- }
484
- async function findKnowledgeFiles(projectRoot) {
485
- const teamRoot = join3(projectRoot, ".fabric", "knowledge");
486
- const personalRoot = join3(resolvePersonalRoot(), ".fabric", "knowledge");
487
- try {
488
- await mkdir2(personalRoot, { recursive: true });
489
- for (const sub of KNOWLEDGE_SUBDIRS) {
490
- await mkdir2(join3(personalRoot, sub), { recursive: true });
491
- }
492
- } catch {
493
- }
494
- const files = [];
495
- for (const [root, prefix] of [
496
- [teamRoot, TEAM_CONTENT_REF_PREFIX],
497
- [personalRoot, PERSONAL_CONTENT_REF_PREFIX]
498
- ]) {
499
- if (!existsSync2(root) || !statSync(root).isDirectory()) {
500
- continue;
501
- }
502
- for (const subdir of KNOWLEDGE_SUBDIRS) {
503
- const dir = join3(root, subdir);
504
- let entries;
505
- try {
506
- entries = await readdir(dir, { withFileTypes: true });
507
- } catch (error) {
508
- if (isNodeError3(error) && error.code === "ENOENT") {
509
- continue;
510
- }
511
- throw error;
512
- }
513
- for (const entry of entries) {
514
- if (entry.isFile() && entry.name.endsWith(".md")) {
515
- files.push(`${prefix}${subdir}/${entry.name}`);
516
- }
517
- }
518
- }
519
- }
520
- return files.sort();
521
- }
522
- async function findFabricVerifyAnnotations(projectRoot) {
523
- const files = await findTestFiles(projectRoot);
524
- const annotations = [];
525
- const annotationPattern = /^\s*\/\/\s*@fabric-verify\s+([A-Za-z0-9][A-Za-z0-9/_-]*)\s*$/u;
526
- for (const testFile of files) {
527
- const source = await readFile3(join3(projectRoot, testFile), "utf8");
528
- const testHash = sha256(source);
529
- const lines = source.split(/\r?\n/u);
530
- for (const [index, line] of lines.entries()) {
531
- const match = annotationPattern.exec(line);
532
- if (match === null) {
533
- continue;
534
- }
535
- annotations.push({
536
- stableId: match[1],
537
- testFile,
538
- testHash,
539
- line: index + 1
540
- });
541
- }
542
- }
543
- return annotations.sort(compareAnnotationEntries);
544
- }
545
- async function findTestFiles(projectRoot) {
546
- const ignoredRootSegments = /* @__PURE__ */ new Set([".git", ".fabric", "node_modules", "dist", "build", "coverage"]);
547
- const files = [];
548
- const stack = [projectRoot];
549
- while (stack.length > 0) {
550
- const current = stack.pop();
551
- if (current === void 0) {
552
- continue;
553
- }
554
- for (const entry of await readdir(current, { withFileTypes: true })) {
555
- const absolutePath = join3(current, entry.name);
556
- const relativePath = toPosixPath(relative(projectRoot, absolutePath));
557
- const [rootSegment] = relativePath.split("/");
558
- if (entry.isDirectory()) {
559
- if (!ignoredRootSegments.has(rootSegment) && !ignoredRootSegments.has(entry.name)) {
560
- stack.push(absolutePath);
561
- }
562
- continue;
563
- }
564
- if (entry.isFile() && isTestFile(relativePath)) {
565
- files.push(relativePath);
566
- }
567
- }
568
- }
569
- return files.sort();
570
- }
571
- function isTestFile(relativePath) {
572
- return /\.(?:test|spec)\.[cm]?[jt]sx?$/u.test(relativePath);
573
- }
574
- function indexRulesByStableId(meta) {
575
- const rules = /* @__PURE__ */ new Map();
576
- for (const node of Object.values(meta.nodes)) {
577
- if (node.stable_id !== void 0) {
578
- rules.set(node.stable_id, node);
579
- }
580
- }
581
- return rules;
582
- }
583
- function indexPreviousRuleTestEntries(entries) {
584
- const previous = /* @__PURE__ */ new Map();
585
- for (const entry of entries) {
586
- previous.set(createRuleTestEntryKey(entry.rule_stable_id, entry.test_file, entry.annotation_line), {
587
- rule_hash: entry.rule_hash,
588
- previous_rule_hash: entry.previous_rule_hash,
589
- test_hash: entry.test_hash,
590
- previous_test_hash: entry.previous_test_hash
591
- });
592
- }
593
- return previous;
594
- }
595
- function createRuleTestEntryKey(stableId, testFile, line) {
596
- return `${stableId}\0${testFile}\0${line}`;
597
- }
598
- function getPreviousRuleTestHashes(previous, ruleHash, testHash) {
599
- if (previous === void 0) {
600
- return {};
601
- }
602
- return {
603
- previousRuleHash: previous.rule_hash !== void 0 && previous.rule_hash !== ruleHash ? previous.rule_hash : previous.previous_rule_hash,
604
- previousTestHash: previous.test_hash !== testHash ? previous.test_hash : previous.previous_test_hash
605
- };
606
- }
607
- function getPreviousTestHash(previous, testHash) {
608
- if (previous === void 0) {
609
- return void 0;
610
- }
611
- return previous.test_hash !== testHash ? previous.test_hash : previous.previous_test_hash;
612
- }
613
- function compareRuleTestEntries(left, right) {
614
- return left.rule_stable_id.localeCompare(right.rule_stable_id) || left.test_file.localeCompare(right.test_file) || left.annotation_line - right.annotation_line;
615
- }
616
- function compareAnnotationEntries(left, right) {
617
- return left.stableId.localeCompare(right.stableId) || left.testFile.localeCompare(right.testFile) || left.line - right.line;
618
- }
619
- function toComparableKnowledgeTestIndex(index) {
620
- const { generated_at: _generatedAt, ...comparable } = index;
621
- return comparable;
622
- }
623
- function indexExistingNodesByContentRef(existingMeta) {
624
- const byContentRef = /* @__PURE__ */ new Map();
625
- for (const [id, node] of Object.entries(existingMeta?.nodes ?? {})) {
626
- byContentRef.set(toPosixPath(node.content_ref ?? node.file), { id, node });
627
- }
628
- return byContentRef;
629
- }
630
- function deriveNodeId(file) {
631
- const layer = deriveKnowledgeMetaLayer(file);
632
- const relativeStem = getRuleRelativeStem(file);
633
- if (file.startsWith(PERSONAL_CONTENT_REF_PREFIX)) {
634
- return `${layer}/personal/${relativeStem}`;
635
- }
636
- if (file.startsWith(TEAM_CONTENT_REF_PREFIX)) {
637
- return `${layer}/team/${relativeStem}`;
638
- }
639
- return `${layer}/${relativeStem}`;
640
- }
641
- function createDefaultNodeMeta(contentRef) {
642
- const layer = deriveKnowledgeMetaLayer(contentRef);
643
- const topologyType = deriveKnowledgeMetaTopologyType(contentRef);
644
- return {
645
- file: contentRef,
646
- content_ref: contentRef,
647
- scope_glob: deriveScopeGlob(contentRef),
648
- deps: layer === "L0" ? [] : ["L0"],
649
- priority: layer === "L0" ? "high" : "medium",
650
- level: layer,
651
- layer,
652
- topology_type: topologyType,
653
- hash: ""
654
- };
655
- }
656
- function deriveScopeGlob(contentRef) {
657
- const stem = getRuleRelativeStem(contentRef);
658
- const segments = stem.split("/").filter(Boolean);
659
- if (segments.length === 0 || stem === "root") {
660
- return "**";
661
- }
662
- if (segments[0] === "_cross") {
663
- return "**";
664
- }
665
- if (segments.at(-1) === "rules") {
666
- segments.pop();
667
- }
668
- const scopePath = segments.join("/");
669
- return scopePath === "" ? "**" : `${scopePath}/**`;
670
- }
671
- function getRuleRelativeStem(contentRef) {
672
- return contentRef.replace(/^~\/\.fabric\/knowledge\//u, "").replace(/^\.fabric\/knowledge\//u, "").replace(/\.md$/u, "");
673
- }
674
- function toAgentsCompatiblePath(contentRef) {
675
- return contentRef.replace(/^~\/\.fabric\/knowledge\//u, ".fabric/agents/").replace(/^\.fabric\/knowledge\//u, ".fabric/agents/");
676
- }
677
- function sortNodes(nodes) {
678
- return Object.fromEntries(Object.entries(nodes).sort(([left], [right]) => left.localeCompare(right)));
679
- }
680
- function isPendingNode(node) {
681
- const ref = node.content_ref ?? node.file ?? "";
682
- return ref.startsWith(".fabric/knowledge/pending/") || ref.startsWith("~/.fabric/knowledge/pending/");
683
- }
684
- function computeRevision(nodes) {
685
- const revisionSource = Object.entries(sortNodes(nodes)).filter(([, node]) => !isPendingNode(node)).map(([id, node]) => [id, node.hash, node.stable_id ?? "", node.identity_source ?? ""].join("|")).join("\n");
686
- return sha256(revisionSource);
687
- }
688
- function collectSyncedFiles(existingMeta, computedMeta) {
689
- if (existingMeta === void 0) {
690
- return Object.values(computedMeta.nodes).map((node) => node.content_ref ?? node.file).sort();
691
- }
692
- const existingByContentRef = indexExistingNodesByContentRef(existingMeta);
693
- return Object.values(computedMeta.nodes).filter((node) => {
694
- const existing = existingByContentRef.get(node.content_ref ?? node.file)?.node;
695
- return existing === void 0 || existing.hash !== node.hash || existing.stable_id !== node.stable_id || existing.identity_source !== node.identity_source;
696
- }).map((node) => node.content_ref ?? node.file).sort();
697
- }
698
- function collectStableIds(meta) {
699
- return Object.values(meta.nodes).map((node) => node.stable_id).filter((stableId) => stableId !== void 0).sort();
700
- }
701
- function collectDriftDetails(existingMeta, computedMeta) {
702
- if (existingMeta === void 0) {
703
- return [];
704
- }
705
- const computedByContentRef = indexExistingNodesByContentRef(computedMeta);
706
- return Object.values(existingMeta.nodes).map((existingNode) => {
707
- const contentRef = existingNode.content_ref ?? existingNode.file;
708
- const computedNode = computedByContentRef.get(contentRef)?.node;
709
- const stableId = existingNode.stable_id ?? computedNode?.stable_id;
710
- if (computedNode === void 0 || stableId === void 0 || existingNode.hash === computedNode.hash) {
711
- return null;
712
- }
713
- return {
714
- file: contentRef,
715
- stable_id: stableId,
716
- expected_hash: existingNode.hash,
717
- actual_hash: computedNode.hash
718
- };
719
- }).filter((detail) => detail !== null);
720
- }
721
- async function recordBaselineSynced(projectRoot, input) {
722
- if (input.driftDetails.length > 0) {
723
- await appendEventLedgerEvent(projectRoot, {
724
- event_type: "knowledge_drift_detected",
725
- revision: input.previousRevision ?? input.revision,
726
- drifted_stable_ids: input.driftDetails.map((detail) => detail.stable_id),
727
- missing_files: input.driftDetails.filter((detail) => detail.actual_hash === null).map((detail) => detail.file),
728
- stale_files: input.driftDetails.filter((detail) => detail.actual_hash !== null).map((detail) => detail.file),
729
- details: input.driftDetails
730
- });
731
- }
732
- }
733
- function flattenKeys(value, keys = {}) {
734
- if (value && typeof value === "object") {
735
- for (const [key, child] of Object.entries(value)) {
736
- keys[key] = true;
737
- flattenKeys(child, keys);
738
- }
739
- }
740
- return keys;
741
- }
742
- function toPosixPath(path) {
743
- return path.split(sep2).join("/");
744
- }
745
- function deriveRuleIdentity(file, source, existing) {
746
- const declaredKnowledgeId = extractDeclaredKnowledgeId(source);
747
- if (declaredKnowledgeId !== void 0) {
748
- return {
749
- stableId: declaredKnowledgeId,
750
- identitySource: "declared"
751
- };
752
- }
753
- if (existing?.stable_id !== void 0 && isKnowledgeStableId(existing.stable_id)) {
754
- return {
755
- stableId: existing.stable_id,
756
- identitySource: "declared"
757
- };
758
- }
759
- const declaredStableId = extractDeclaredStableId(source);
760
- const derivedStableId = deriveAgentsMetaStableId(toAgentsCompatiblePath(file));
761
- if (declaredStableId !== void 0) {
762
- return {
763
- stableId: declaredStableId,
764
- identitySource: "declared"
765
- };
766
- }
767
- if (existing?.identity_source === "declared" && existing.stable_id !== void 0 && existing.stable_id !== derivedStableId) {
768
- return {
769
- stableId: existing.stable_id,
770
- identitySource: "declared"
771
- };
772
- }
773
- return {
774
- stableId: derivedStableId,
775
- identitySource: "derived"
776
- };
777
- }
778
- function extractDeclaredStableId(source) {
779
- 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);
780
- return match?.[1];
781
- }
782
- function extractDeclaredKnowledgeId(source) {
783
- const frontmatter = /^(?:\uFEFF)?---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/u.exec(source);
784
- if (frontmatter === null) {
785
- return void 0;
786
- }
787
- const idMatch = /^id:\s*(.+?)\s*$/mu.exec(frontmatter[1]);
788
- if (idMatch === null) {
789
- return void 0;
790
- }
791
- const candidate = idMatch[1].replace(/^["'](.*)["']$/u, "$1").trim();
792
- return isKnowledgeStableId(candidate) ? candidate : void 0;
793
- }
794
- function extractRuleDescription(source) {
795
- const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n|$)/u.exec(source);
796
- const description = frontmatter === null ? void 0 : extractDescriptionFromFrontmatter(frontmatter[1]);
797
- if (description !== void 0) {
798
- return description;
799
- }
800
- const heading = /^#\s+(.+?)\s*$/mu.exec(source);
801
- const summary = heading?.[1]?.trim();
802
- if (summary === void 0 || summary.length === 0) {
803
- return void 0;
804
- }
805
- return {
806
- summary,
807
- intent_clues: [],
808
- tech_stack: [],
809
- impact: [],
810
- must_read_if: summary,
811
- // v2.0 knowledge fields are absent in heading-only fallback.
812
- id: void 0,
813
- knowledge_type: void 0,
814
- maturity: void 0,
815
- knowledge_layer: void 0,
816
- layer_reason: void 0,
817
- created_at: void 0,
818
- tags: void 0,
819
- // v2.0-rc.5 (C1): default-safe values when there is no frontmatter at all.
820
- relevance_scope: "broad",
821
- relevance_paths: []
822
- };
823
- }
824
- function extractRuleSections(source) {
825
- 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);
826
- return sections.length > 0 ? sections : void 0;
827
- }
828
- function extractDescriptionFromFrontmatter(frontmatter) {
829
- const summary = extractScalar(frontmatter, "summary") ?? extractScalar(frontmatter, "description");
830
- if (summary === void 0) {
831
- return void 0;
832
- }
833
- const knowledge = extractKnowledgeFieldsFromFrontmatter(frontmatter);
834
- return {
835
- summary,
836
- intent_clues: extractInlineArray(frontmatter, "intent_clues"),
837
- tech_stack: extractInlineArray(frontmatter, "tech_stack"),
838
- impact: extractInlineArray(frontmatter, "impact"),
839
- must_read_if: extractScalar(frontmatter, "must_read_if") ?? summary,
840
- entities: extractInlineArray(frontmatter, "entities"),
841
- id: knowledge.id,
842
- knowledge_type: knowledge.knowledge_type,
843
- maturity: knowledge.maturity,
844
- knowledge_layer: knowledge.knowledge_layer,
845
- layer_reason: knowledge.layer_reason,
846
- created_at: knowledge.created_at,
847
- tags: knowledge.tags,
848
- relevance_scope: knowledge.relevance_scope,
849
- relevance_paths: knowledge.relevance_paths
850
- };
851
- }
852
- function extractKnowledgeFieldsFromFrontmatter(frontmatter) {
853
- const rawId = extractScalar(frontmatter, "id");
854
- const rawType = extractScalar(frontmatter, "type");
855
- const rawMaturity = extractScalar(frontmatter, "maturity");
856
- const rawLayer = extractScalar(frontmatter, "layer");
857
- const rawLayerReason = extractScalar(frontmatter, "layer_reason");
858
- const rawCreatedAt = extractScalar(frontmatter, "created_at");
859
- let id;
860
- if (rawId !== void 0) {
861
- const parsed = StableIdSchema.safeParse(rawId);
862
- if (parsed.success) {
863
- id = parsed.data;
864
- } else {
865
- process.stderr.write(`[fabric] frontmatter: invalid knowledge id format ${JSON.stringify(rawId)}; skipping
866
- `);
867
- }
868
- }
869
- let knowledge_type;
870
- if (rawType !== void 0) {
871
- const parsed = KnowledgeTypeSchema.safeParse(rawType);
872
- if (parsed.success) {
873
- knowledge_type = parsed.data;
874
- } else {
875
- process.stderr.write(`[fabric] frontmatter: unknown knowledge type ${JSON.stringify(rawType)}; skipping
876
- `);
877
- }
878
- }
879
- let maturity;
880
- if (rawMaturity !== void 0) {
881
- const parsed = MaturitySchema.safeParse(rawMaturity);
882
- if (parsed.success) {
883
- maturity = parsed.data;
884
- } else {
885
- process.stderr.write(`[fabric] frontmatter: unknown maturity ${JSON.stringify(rawMaturity)}; skipping
886
- `);
887
- }
888
- }
889
- let knowledge_layer;
890
- if (rawLayer !== void 0) {
891
- const parsed = LayerSchema.safeParse(rawLayer);
892
- if (parsed.success) {
893
- knowledge_layer = parsed.data;
894
- } else {
895
- process.stderr.write(`[fabric] frontmatter: unknown layer ${JSON.stringify(rawLayer)}; skipping
896
- `);
897
- }
898
- }
899
- let created_at;
900
- if (rawCreatedAt !== void 0) {
901
- if (!Number.isNaN(Date.parse(rawCreatedAt))) {
902
- created_at = rawCreatedAt;
903
- } else {
904
- process.stderr.write(`[fabric] frontmatter: malformed created_at ${JSON.stringify(rawCreatedAt)}; skipping
905
- `);
906
- }
907
- }
908
- if (id !== void 0 && knowledge_layer !== void 0) {
909
- const decoded = parseKnowledgeId(id);
910
- if (decoded !== null && decoded.layer !== knowledge_layer) {
911
- process.stderr.write(
912
- `[fabric] frontmatter: id ${id} encodes layer ${decoded.layer} but layer field says ${knowledge_layer}; dropping both
913
- `
914
- );
915
- id = void 0;
916
- knowledge_layer = void 0;
917
- }
918
- }
919
- const tags = extractInlineArray(frontmatter, "tags");
920
- const rawRelevanceScope = extractScalar(frontmatter, "relevance_scope");
921
- const relevance_scope = rawRelevanceScope === "narrow" || rawRelevanceScope === "broad" ? rawRelevanceScope : "broad";
922
- const relevance_paths = extractInlineArray(frontmatter, "relevance_paths");
923
- return {
924
- id,
925
- knowledge_type,
926
- maturity,
927
- knowledge_layer,
928
- layer_reason: rawLayerReason,
929
- created_at,
930
- tags: tags.length > 0 ? tags : void 0,
931
- relevance_scope,
932
- relevance_paths
933
- };
934
- }
935
- function extractScalar(frontmatter, key) {
936
- const pattern = new RegExp(`^${escapeRegExp(key)}:\\s*(.+?)\\s*$`, "mu");
937
- const match = pattern.exec(frontmatter);
938
- if (match === null) {
939
- return void 0;
940
- }
941
- return unquote(match[1].trim());
942
- }
943
- function extractInlineArray(frontmatter, key) {
944
- const pattern = new RegExp(`^${escapeRegExp(key)}:\\s*\\[(.*?)\\]\\s*$`, "mu");
945
- const match = pattern.exec(frontmatter);
946
- if (match === null) {
947
- return [];
948
- }
949
- return match[1].split(",").map((item) => unquote(item.trim())).filter((item) => item.length > 0);
950
- }
951
- function unquote(value) {
952
- return value.replace(/^["'](.*)["']$/u, "$1");
953
- }
954
- function escapeRegExp(value) {
955
- return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
956
- }
957
- function isNodeError3(error) {
958
- return error instanceof Error;
959
- }
960
-
961
- // src/services/knowledge-sync.ts
962
- import { readdir as readdir2, readFile as readFile4, stat } from "fs/promises";
963
- import { existsSync as existsSync3, statSync as statSync2 } from "fs";
964
- import { join as join4, relative as relative2, sep as sep3 } from "path";
965
- import { RuleValidationError } from "@fenglimg/fabric-shared/errors";
966
- var lastSyncState = /* @__PURE__ */ new Map();
967
- var freshSyncCooldown = /* @__PURE__ */ new Map();
968
- var SYNC_COOLDOWN_MS = 500;
969
- function invalidateKnowledgeSyncCooldown(projectRoot) {
970
- freshSyncCooldown.delete(projectRoot);
971
- }
972
- async function readMetaEntries(projectRoot) {
973
- const metaPath = join4(projectRoot, ".fabric", "agents.meta.json");
974
- const map = /* @__PURE__ */ new Map();
975
- let raw;
976
- try {
977
- raw = await readFile4(metaPath, "utf8");
978
- } catch {
979
- return map;
980
- }
981
- let parsed;
982
- try {
983
- parsed = JSON.parse(raw);
984
- } catch {
985
- return map;
986
- }
987
- for (const node of Object.values(parsed.nodes ?? {})) {
988
- const path = node.content_ref ?? node.file;
989
- const stable_id = node.stable_id;
990
- const content_hash = node.hash;
991
- if (path !== void 0 && stable_id !== void 0 && content_hash !== void 0) {
992
- map.set(path, { stable_id, path, content_hash });
993
- }
994
- }
995
- return map;
996
- }
997
- async function findRuleFiles(projectRoot) {
998
- const knowledgeRoot = join4(projectRoot, ".fabric", "knowledge");
999
- if (!existsSync3(knowledgeRoot) || !statSync2(knowledgeRoot).isDirectory()) {
1000
- return [];
1001
- }
1002
- const files = [];
1003
- const stack = [knowledgeRoot];
1004
- while (stack.length > 0) {
1005
- const current = stack.pop();
1006
- if (current === void 0) {
1007
- continue;
1008
- }
1009
- for (const entry of await readdir2(current, { withFileTypes: true })) {
1010
- const absolutePath = join4(current, entry.name);
1011
- if (entry.isDirectory()) {
1012
- stack.push(absolutePath);
1013
- } else if (entry.isFile() && entry.name.endsWith(".md")) {
1014
- const rel = toPosixPath2(relative2(projectRoot, absolutePath));
1015
- files.push(rel);
1016
- }
1017
- }
1018
- }
1019
- return files.sort();
1020
- }
1021
- function toPosixPath2(p) {
1022
- return p.split(sep3).join("/");
1023
- }
1024
- function validateFrontmatter(source, filePath, throwOnInvalid) {
1025
- if (!source.startsWith("---")) {
1026
- return null;
1027
- }
1028
- const endIdx = source.indexOf("\n---", 3);
1029
- if (endIdx === -1) {
1030
- const msg = `Unterminated YAML frontmatter in ${filePath}`;
1031
- if (throwOnInvalid) {
1032
- throw new RuleValidationError(msg, {
1033
- actionHint: "Run `fab doctor --fix` to repair frontmatter",
1034
- fixable: true,
1035
- details: { file: filePath }
1036
- });
1037
- }
1038
- return {
1039
- code: "rule_frontmatter_invalid",
1040
- file: filePath,
1041
- action_hint: "Run `fab doctor --fix` to repair frontmatter"
1042
- };
1043
- }
1044
- const frontmatter = source.slice(3, endIdx).trim();
1045
- for (const line of frontmatter.split("\n")) {
1046
- const trimmed = line.trim();
1047
- if (trimmed.length === 0 || trimmed.startsWith("#")) {
1048
- continue;
1049
- }
1050
- if (!trimmed.includes(":") && !trimmed.startsWith("-")) {
1051
- const msg = `Invalid YAML frontmatter line "${trimmed}" in ${filePath}`;
1052
- if (throwOnInvalid) {
1053
- throw new RuleValidationError(msg, {
1054
- actionHint: "Run `fab doctor --fix` to repair frontmatter",
1055
- fixable: true,
1056
- details: { file: filePath, line: trimmed }
1057
- });
1058
- }
1059
- return {
1060
- code: "rule_frontmatter_invalid",
1061
- file: filePath,
1062
- action_hint: "Run `fab doctor --fix` to repair frontmatter"
1063
- };
1064
- }
1065
- }
1066
- return null;
1067
- }
1068
- async function processSingleFile(projectRoot, relPath, metaEntry, source, throwOnInvalidFrontmatter) {
1069
- const absPath = join4(projectRoot, relPath);
1070
- try {
1071
- await stat(absPath);
1072
- } catch {
1073
- if (metaEntry !== void 0) {
1074
- return {
1075
- event: {
1076
- type: "rule_removed",
1077
- stable_id: metaEntry.stable_id,
1078
- path: relPath,
1079
- prev_hash: metaEntry.content_hash,
1080
- new_hash: null,
1081
- changed_fields: ["content"],
1082
- source
1083
- },
1084
- warning: null
1085
- };
1086
- }
1087
- return { event: null, warning: null };
1088
- }
1089
- let content;
1090
- try {
1091
- content = await readFile4(absPath, "utf8");
1092
- } catch {
1093
- return { event: null, warning: null };
1094
- }
1095
- const newHash = sha256(content);
1096
- const now = Date.now();
1097
- const debounce = lastSyncState.get(absPath);
1098
- if (debounce !== void 0 && newHash === debounce.hash && now - debounce.ts < 500) {
1099
- return { event: null, warning: null };
1100
- }
1101
- if (metaEntry !== void 0 && newHash === metaEntry.content_hash) {
1102
- lastSyncState.set(absPath, { ts: now, hash: newHash });
1103
- return { event: null, warning: null };
1104
- }
1105
- const warning = validateFrontmatter(content, relPath, throwOnInvalidFrontmatter);
1106
- if (warning !== null) {
1107
- lastSyncState.set(absPath, { ts: now, hash: newHash });
1108
- return { event: null, warning };
1109
- }
1110
- const prevHash = metaEntry?.content_hash ?? debounce?.hash ?? null;
1111
- const stableId = metaEntry?.stable_id ?? relPath;
1112
- const eventType = metaEntry === void 0 ? "rule_added" : "rule_content_changed";
1113
- lastSyncState.set(absPath, { ts: now, hash: newHash });
1114
- return {
1115
- event: {
1116
- type: eventType,
1117
- stable_id: stableId,
1118
- path: relPath,
1119
- prev_hash: prevHash,
1120
- new_hash: newHash,
1121
- changed_fields: ["content"],
1122
- source
1123
- },
1124
- warning: null
1125
- };
1126
- }
1127
- async function appendRuleSyncEvents(projectRoot, events) {
1128
- if (events.length === 0) {
1129
- return;
1130
- }
1131
- const driftedIds = events.map((e) => e.stable_id);
1132
- const missingFiles = events.filter((e) => e.type === "rule_removed").map((e) => e.path);
1133
- const staleFiles = events.filter((e) => e.type !== "rule_removed").map((e) => e.path);
1134
- if (missingFiles.length > 0 || staleFiles.length > 0) {
1135
- await appendEventLedgerEvent(projectRoot, {
1136
- event_type: "knowledge_drift_detected",
1137
- drifted_stable_ids: driftedIds,
1138
- missing_files: missingFiles,
1139
- stale_files: staleFiles
1140
- });
1141
- }
1142
- }
1143
- async function ensureKnowledgeFresh(projectRoot, opts) {
1144
- const mode = opts?.mode ?? "incremental";
1145
- const cooldownExpiry = freshSyncCooldown.get(projectRoot);
1146
- if (cooldownExpiry !== void 0 && Date.now() < cooldownExpiry && mode !== "full") {
1147
- return { status: "fresh", events: [], warnings: [] };
1148
- }
1149
- const throwOnInvalidFrontmatter = opts?.throwOnInvalidFrontmatter ?? false;
1150
- const source = "ensureKnowledgeFresh";
1151
- const events = [];
1152
- const warnings = [];
1153
- const metaEntries = await readMetaEntries(projectRoot);
1154
- const ruleFiles = await findRuleFiles(projectRoot);
1155
- const filesToCheck = ruleFiles;
1156
- for (const relPath of filesToCheck) {
1157
- const metaEntry = metaEntries.get(relPath);
1158
- const result = await processSingleFile(projectRoot, relPath, metaEntry, source, throwOnInvalidFrontmatter);
1159
- if (result.event !== null) {
1160
- events.push(result.event);
1161
- }
1162
- if (result.warning !== null) {
1163
- warnings.push(result.warning);
1164
- }
1165
- }
1166
- for (const [relPath, entry] of metaEntries) {
1167
- if (!ruleFiles.includes(relPath)) {
1168
- const absPath = join4(projectRoot, relPath);
1169
- if (!existsSync3(absPath)) {
1170
- events.push({
1171
- type: "rule_removed",
1172
- stable_id: entry.stable_id,
1173
- path: relPath,
1174
- prev_hash: entry.content_hash,
1175
- new_hash: null,
1176
- changed_fields: ["content"],
1177
- source
1178
- });
1179
- }
1180
- }
1181
- }
1182
- if (events.length === 0 && warnings.length === 0) {
1183
- freshSyncCooldown.set(projectRoot, Date.now() + SYNC_COOLDOWN_MS);
1184
- return { status: "fresh", events: [], warnings: [] };
1185
- }
1186
- if (events.length > 0) {
1187
- await appendRuleSyncEvents(projectRoot, events);
1188
- contextCache.invalidate("file_watch", projectRoot);
1189
- }
1190
- freshSyncCooldown.delete(projectRoot);
1191
- const status = warnings.length > 0 ? "errors" : "reconciled";
1192
- return {
1193
- status,
1194
- events,
1195
- warnings,
1196
- reconciled_files: events.map((e) => e.path)
1197
- };
1198
- }
1199
- async function reconcileKnowledge(projectRoot, opts) {
1200
- freshSyncCooldown.delete(projectRoot);
1201
- const trigger = opts?.trigger;
1202
- const startTime = Date.now();
1203
- const source = "reconcileKnowledge";
1204
- const events = [];
1205
- const warnings = [];
1206
- const metaEntries = await readMetaEntries(projectRoot);
1207
- const ruleFiles = await findRuleFiles(projectRoot);
1208
- for (const relPath of ruleFiles) {
1209
- const metaEntry = metaEntries.get(relPath);
1210
- const result = await processSingleFile(projectRoot, relPath, metaEntry, source, false);
1211
- if (result.event !== null) {
1212
- events.push(result.event);
1213
- }
1214
- if (result.warning !== null) {
1215
- warnings.push(result.warning);
1216
- }
1217
- }
1218
- for (const [relPath, entry] of metaEntries) {
1219
- if (!ruleFiles.includes(relPath)) {
1220
- const absPath = join4(projectRoot, relPath);
1221
- if (!existsSync3(absPath)) {
1222
- events.push({
1223
- type: "rule_removed",
1224
- stable_id: entry.stable_id,
1225
- path: relPath,
1226
- prev_hash: entry.content_hash,
1227
- new_hash: null,
1228
- changed_fields: ["content"],
1229
- source
1230
- });
1231
- }
1232
- }
1233
- }
1234
- if (events.length > 0) {
1235
- await writeKnowledgeMeta(projectRoot, { source: "sync_meta" });
1236
- await appendRuleSyncEvents(projectRoot, events);
1237
- contextCache.invalidate("file_watch", projectRoot);
1238
- }
1239
- const duration_ms = Date.now() - startTime;
1240
- const reconciledFiles = events.map((e) => e.path);
1241
- if (trigger !== void 0 && events.length > 0) {
1242
- if (trigger === "startup") {
1243
- await appendEventLedgerEvent(projectRoot, {
1244
- event_type: "meta_reconciled_on_startup",
1245
- reconciled_files: reconciledFiles,
1246
- duration_ms,
1247
- source: "reconcileKnowledge"
1248
- });
1249
- } else {
1250
- await appendEventLedgerEvent(projectRoot, {
1251
- event_type: "meta_reconciled",
1252
- reconciled_files: reconciledFiles,
1253
- duration_ms,
1254
- trigger,
1255
- source: "reconcileKnowledge"
1256
- });
1257
- }
1258
- }
1259
- if (events.length === 0 && warnings.length === 0) {
1260
- return { status: "fresh", events: [], warnings: [] };
1261
- }
1262
- const status = warnings.length > 0 ? "errors" : "reconciled";
1263
- return {
1264
- status,
1265
- events,
1266
- warnings,
1267
- reconciled_files: reconciledFiles
1268
- };
1269
- }
1270
-
1271
- // src/services/doctor.ts
1272
- import { execFileSync } from "child_process";
1273
- import { existsSync as existsSync4, readdirSync, readFileSync, statSync as statSync3 } from "fs";
1274
- import { access, mkdir as mkdir3, readFile as readFile5, rename, writeFile as writeFile2 } from "fs/promises";
1275
- import { constants } from "fs";
1276
- import { homedir as homedir2 } from "os";
1277
- import { isAbsolute as isAbsolute2, join as join5, posix, relative as nodeRelative, resolve as resolve3, sep as sep4 } from "path";
1278
- import { minimatch } from "minimatch";
1279
- import {
1280
- agentsMetaSchema as agentsMetaSchema4,
1281
- AgentsMetaCountersSchema,
1282
- forensicReportSchema,
1283
- parseKnowledgeId as parseKnowledgeId2,
1284
- knowledgeTestIndexSchema as knowledgeTestIndexSchema2
1285
- } from "@fenglimg/fabric-shared";
1286
- import { detectFramework } from "@fenglimg/fabric-shared/node";
1287
- import { atomicWriteJson as atomicWriteJson2, atomicWriteText as atomicWriteText3 } from "@fenglimg/fabric-shared/node/atomic-write";
1288
- var ORPHAN_DEMOTE_THRESHOLD_DAYS = {
1289
- stable: 90,
1290
- endorsed: 30,
1291
- draft: 14
1292
- };
1293
- var STALE_ARCHIVE_ADDITIONAL_DAYS = 90;
1294
- var PENDING_OVERDUE_THRESHOLD_DAYS = 14;
1295
- var PENDING_AUTO_ARCHIVE_THRESHOLD_DAYS = 30;
1296
- var DEFAULT_UNDERSEED_NODE_THRESHOLD = 10;
1297
- var SESSION_HINTS_STALE_DAYS = 7;
1298
- var SESSION_HINTS_FILE_PREFIX = "session-hints-";
1299
- var SESSION_HINTS_FILE_SUFFIX = ".json";
1300
- var NARROW_RATIO_THRESHOLD = 0.2;
1301
- var NARROW_MIN_TOTAL = 10;
1302
- var SILENCE_RATE_THRESHOLD = 0.95;
1303
- var SILENCE_WINDOW_DAYS = 30;
1304
- var EDIT_COUNTER_FILE_REL = posix.join(".fabric", ".cache", "edit-counter");
1305
- var HINT_SILENCE_COUNTER_FILE_REL = posix.join(
1306
- ".fabric",
1307
- ".cache",
1308
- "hint-silence-counter"
1309
- );
1310
- var MS_PER_DAY = 24 * 60 * 60 * 1e3;
1311
- var MATURITY_LINE_PATTERN = /^maturity:\s*("?)(stable|endorsed|draft)\1\s*$/mu;
1312
- var CREATED_AT_LINE_PATTERN = /^created_at:\s*("?)([^"\n]+)\1\s*$/mu;
1313
- var RELEVANCE_SCOPE_LINE_PATTERN = /^relevance_scope:\s*("?)(narrow|broad)\1\s*$/mu;
1314
- var RELEVANCE_PATHS_LINE_PATTERN = /^relevance_paths:\s*\[([^\]]*)\]\s*$/mu;
1315
- var RELEVANCE_PATHS_DRIFT_WINDOW_DAYS = 90;
1316
- var SYNTHESIZED_PROMOTED_REASON = "[synthesized] filesystem-edit-fallback";
1317
- var KNOWLEDGE_CANONICAL_TYPE_DIRS = [
1318
- "decisions",
1319
- "pitfalls",
1320
- "guidelines",
1321
- "models",
1322
- "processes"
1323
- ];
1324
- var CANONICAL_KNOWLEDGE_FILENAME_PATTERN = /^(K[PT]-(?:MOD|DEC|GLD|PIT|PRO)-\d{4,})--[a-z0-9][a-z0-9-]*\.md$/u;
1325
- var KNOWLEDGE_SUBDIRS2 = ["decisions", "pitfalls", "guidelines", "models", "processes", "pending"];
1326
- var COUNTER_TYPE_CODES = ["MOD", "DEC", "GLD", "PIT", "PRO"];
1327
- var SCRIPT_EXTENSIONS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx"]);
1328
- var IGNORED_DIRECTORIES = /* @__PURE__ */ new Set([
1329
- ".fabric",
1330
- ".git",
1331
- ".next",
1332
- ".turbo",
1333
- "Library",
1334
- "Temp",
1335
- "build",
1336
- "coverage",
1337
- "dist",
1338
- "node_modules"
1339
- ]);
1340
- var TARGET_FILE_PATHS = [
1341
- ".fabric/forensic.json",
1342
- ".fabric/agents.meta.json",
1343
- ".fabric/.cache/knowledge-test.index.json",
1344
- ".fabric/events.jsonl",
1345
- ".fabric/knowledge"
1346
- ];
1347
- async function runDoctorReport(target) {
1348
- const projectRoot = normalizeTarget(target);
1349
- const framework = detectFramework(projectRoot);
1350
- const entryPoints = collectEntryPoints(projectRoot);
1351
- const [
1352
- forensic,
1353
- meta,
1354
- eventLedger,
1355
- knowledgeTestIndex
1356
- ] = await Promise.all([
1357
- inspectForensic(projectRoot),
1358
- inspectMeta(projectRoot),
1359
- inspectEventLedger(projectRoot),
1360
- inspectKnowledgeTestIndex(projectRoot)
1361
- ]);
1362
- const mcpConfigInWrongFile = inspectMcpConfigInWrongFile(projectRoot);
1363
- const metaManuallyDiverged = await inspectMetaManuallyDiverged(projectRoot);
1364
- const knowledgeDirUnindexed = inspectKnowledgeDirUnindexed(projectRoot, meta);
1365
- const knowledgeDirMissing = inspectKnowledgeDirMissing(projectRoot);
1366
- const stableIdCollision = await inspectStableIdCollisions(projectRoot);
1367
- const counterDesync = inspectCounterDesync(meta);
1368
- const preexistingRootFiles = inspectPreexistingRootFiles(projectRoot);
1369
- const bootstrapAnchor = inspectBootstrapAnchor(projectRoot);
1370
- const filesystemEditFallback = eventLedger.exists && eventLedger.writable && eventLedger.parseable ? await inspectFilesystemEditFallback(projectRoot) : { synthesized: 0, synthesizedStableIds: [] };
1371
- const lintNow = Date.now();
1372
- const orphanDemote = await inspectOrphanDemote(projectRoot, lintNow);
1373
- const staleArchive = await inspectStaleArchive(projectRoot, lintNow);
1374
- const pendingOverdue = inspectPendingOverdue(projectRoot, lintNow);
1375
- const stableIdDuplicate = inspectStableIdDuplicate(projectRoot);
1376
- const layerMismatch = inspectLayerMismatch(projectRoot);
1377
- const indexDrift = inspectIndexDrift(projectRoot, meta);
1378
- const underseeded = inspectUnderseeded(projectRoot);
1379
- const narrowNoPaths = inspectNarrowNoPaths(projectRoot);
1380
- const relevancePathsDangling = inspectRelevancePathsDangling(projectRoot);
1381
- const relevancePathsDrift = inspectRelevancePathsDrift(projectRoot);
1382
- const narrowTooFew = inspectNarrowTooFew(projectRoot, lintNow);
1383
- const sessionHintsStale = inspectSessionHintsStale(projectRoot, lintNow);
1384
- const checks = [
1385
- createBootstrapAnchorCheck(bootstrapAnchor),
1386
- createKnowledgeDirMissingCheck(knowledgeDirMissing),
1387
- createForensicCheck(forensic, framework.kind, entryPoints.length),
1388
- // v2.0: removed `createInitContextCheck` — `.fabric/init-context.json`
1389
- // is owned by the AI-side client init skill, not by `fabric init` CLI.
1390
- // The file's absence is a legitimate post-init state when the skill has
1391
- // not yet run, so flagging it as a doctor manual_error misrepresents
1392
- // ownership.
1393
- createMetaCheck(meta),
1394
- createRuleContentRefCheck(meta),
1395
- // v2.0 / rc.2: `createRuleSectionsCheck` removed — it parsed v1.x
1396
- // [MANDATORY_INJECTION] sections out of legacy rule files, a structural
1397
- // concept that has no v2 equivalent. rc.4 will introduce a dedicated v2
1398
- // lint suite for the new knowledge frontmatter contract.
1399
- createKnowledgeTestIndexCheck(knowledgeTestIndex),
1400
- createEventLedgerCheck(eventLedger),
1401
- createEventLedgerPartialWriteCheck(eventLedger),
1402
- createMcpConfigInWrongFileCheck(mcpConfigInWrongFile),
1403
- createMetaManuallyDivergedCheck(metaManuallyDiverged),
1404
- createKnowledgeDirUnindexedCheck(knowledgeDirUnindexed),
1405
- createStableIdCollisionCheck(stableIdCollision),
1406
- createCounterDesyncCheck(counterDesync),
1407
- createFilesystemEditFallbackCheck(filesystemEditFallback),
1408
- // rc.4 TASK-001: read-side lint checks #16-18. Findings only — mutation
1409
- // + event emission lands in TASK-003 behind --apply-lint.
1410
- createOrphanDemoteCheck(orphanDemote),
1411
- createStaleArchiveCheck(staleArchive),
1412
- createPendingOverdueCheck(pendingOverdue),
1413
- // rc.4 TASK-002: read-side integrity checks #19-21. Stable_id duplicate
1414
- // runs first in this trio — it is the most critical integrity break and
1415
- // surfaces ahead of layer-mismatch / index-drift in the report so a
1416
- // human operator triages the collision before reasoning about counter
1417
- // state. Index drift is the only fixable_error of the three; stable_id
1418
- // duplicate and layer mismatch require manual triage (rename / move).
1419
- createStableIdDuplicateCheck(stableIdDuplicate),
1420
- createLayerMismatchCheck(layerMismatch),
1421
- createIndexDriftCheck(indexDrift),
1422
- // rc.5 TASK-010: read-side underseeded-corpus check (#22). Info kind —
1423
- // does not bump report status. Recommends running the fabric-import skill
1424
- // to backfill knowledge when the corpus is below the threshold floor.
1425
- createUnderseededCheck(underseeded),
1426
- // rc.5 TASK-013 (C4): relevance_paths hygiene checks #23/#24/#25.
1427
- // All three are flag-only in rc.5 (no apply-lint mutations).
1428
- // #23 narrow_no_paths — warning kind (silent recall risk)
1429
- // #24 relevance_paths_dangling — warning kind (glob → zero matches)
1430
- // #25 relevance_paths_drift — info kind (git-log heuristic; noisy)
1431
- createNarrowNoPathsCheck(narrowNoPaths),
1432
- createRelevancePathsDanglingCheck(relevancePathsDangling),
1433
- createRelevancePathsDriftCheck(relevancePathsDrift),
1434
- // rc.6 TASK-023 (E6): narrow_too_few (lint #26). Info kind; both arms
1435
- // (structural + telemetry) recommend the same fabric-import action.
1436
- createNarrowTooFewCheck(narrowTooFew),
1437
- // rc.6 TASK-021 (E3): session-hints cache hygiene (lint #27). Info kind.
1438
- createSessionHintsStaleCheck(sessionHintsStale),
1439
- createPreexistingRootFilesCheck(preexistingRootFiles)
1440
- // v2.0 / rc.2: `createLegacyClientPathCheck` removed. The schema now
1441
- // rejects retired clientPaths keys (windsurf/rooCode/geminiCLI) at Zod
1442
- // parse time, so the soft-deprecation warn-and-fix path no longer has a
1443
- // reachable input — fabric.config.json with a retired key fails before
1444
- // doctor ever inspects it.
1445
- // v2.0 / rc.2: `createLegacyV1ArtifactsCheck` removed alongside its
1446
- // path-list constant. The visibility-only warning referenced v1.x
1447
- // artifacts that are now archaeology. rc.4 owns v2 lint coverage; on a
1448
- // clean v2 install nothing is lost since the check fired only when v1
1449
- // artifacts remained.
1450
- ];
1451
- const fixableErrors = collectIssues(checks, "fixable_error");
1452
- const manualErrors = collectIssues(checks, "manual_error");
1453
- const warnings = collectIssues(checks, "warning");
1454
- const infos = collectIssues(checks, "info");
1455
- return {
1456
- status: reduceStatus(checks.map((check) => check.status)),
1457
- checks,
1458
- fixable_errors: fixableErrors,
1459
- manual_errors: manualErrors,
1460
- warnings,
1461
- infos,
1462
- summary: {
1463
- target: projectRoot,
1464
- framework: {
1465
- kind: framework.kind,
1466
- version: framework.version,
1467
- subkind: framework.subkind
1468
- },
1469
- entryPoints,
1470
- metaRevision: meta.revision,
1471
- computedMetaRevision: meta.computedRevision,
1472
- ruleCount: meta.ruleCount,
1473
- eventLedgerPath: eventLedger.path,
1474
- fixableErrorCount: fixableErrors.length,
1475
- manualErrorCount: manualErrors.length,
1476
- warningCount: warnings.length,
1477
- infoCount: infos.length,
1478
- targetFiles: Object.fromEntries(
1479
- TARGET_FILE_PATHS.map((path) => [path, existsSync4(join5(projectRoot, path))])
1480
- )
1481
- }
1482
- };
1483
- }
1484
- async function runDoctorFix(target) {
1485
- const projectRoot = normalizeTarget(target);
1486
- const before = await runDoctorReport(projectRoot);
1487
- const fixed = [];
1488
- if (before.fixable_errors.some((issue) => issue.code === "knowledge_dir_missing")) {
1489
- await ensureKnowledgeSubdirs(projectRoot);
1490
- fixed.push(findIssue(before.fixable_errors, "knowledge_dir_missing"));
1491
- }
1492
- if (before.fixable_errors.some((issue) => issue.code === "event_ledger_missing")) {
1493
- await ensureEventLedger(projectRoot);
1494
- fixed.push(findIssue(before.fixable_errors, "event_ledger_missing"));
1495
- }
1496
- if (before.fixable_errors.some((issue) => issue.code === "counter_desync")) {
1497
- await fixCounterDesync(projectRoot);
1498
- fixed.push(findIssue(before.fixable_errors, "counter_desync"));
1499
- contextCache.invalidate("meta_write", projectRoot);
1500
- }
1501
- if (before.fixable_errors.some(
1502
- (issue) => [
1503
- "agents_meta_missing",
1504
- "agents_meta_stale",
1505
- "knowledge_test_index_missing",
1506
- "knowledge_test_index_stale",
1507
- "content_ref_missing",
1508
- "knowledge_dir_unindexed"
1509
- ].includes(issue.code)
1510
- )) {
1511
- await reconcileKnowledge(projectRoot, { trigger: "doctor" });
1512
- for (const issue of before.fixable_errors.filter(
1513
- (candidate) => [
1514
- "agents_meta_missing",
1515
- "agents_meta_stale",
1516
- "knowledge_test_index_missing",
1517
- "knowledge_test_index_stale",
1518
- "content_ref_missing",
1519
- "knowledge_dir_unindexed"
1520
- ].includes(candidate.code)
1521
- )) {
1522
- fixed.push(issue);
1523
- }
1524
- contextCache.invalidate("meta_write", projectRoot);
1525
- await fixCounterDesync(projectRoot);
1526
- contextCache.invalidate("meta_write", projectRoot);
1527
- }
1528
- if (before.fixable_errors.some((issue) => issue.code === "event_ledger_partial_write")) {
1529
- const ledgerPath = getEventLedgerPath(projectRoot);
1530
- const truncResult = await truncateLedgerToLastNewline(ledgerPath);
1531
- await appendEventLedgerEvent(projectRoot, {
1532
- event_type: "event_ledger_truncated",
1533
- byte_offset: truncResult.truncated_bytes,
1534
- byte_length: truncResult.truncated_bytes,
1535
- corrupted_path: truncResult.corrupted_path
1536
- });
1537
- fixed.push(findIssue(before.fixable_errors, "event_ledger_partial_write"));
1538
- }
1539
- if (before.fixable_errors.some((issue) => issue.code === "mcp_config_in_wrong_file")) {
1540
- await fixMcpConfigInWrongFile(projectRoot);
1541
- fixed.push(findIssue(before.fixable_errors, "mcp_config_in_wrong_file"));
1542
- }
1543
- const report = await runDoctorReport(projectRoot);
1544
- return {
1545
- changed: fixed.length > 0,
1546
- fixed,
1547
- remaining_manual_errors: report.manual_errors,
1548
- warnings: report.warnings,
1549
- message: createFixMessage(fixed, report),
1550
- report
1551
- };
1552
- }
1553
- var MANUAL_LINT_ERROR_CODES = /* @__PURE__ */ new Set([
1554
- "knowledge_stable_id_duplicate",
1555
- "knowledge_layer_mismatch"
1556
- ]);
1557
- async function runDoctorApplyLint(target) {
1558
- const projectRoot = normalizeTarget(target);
1559
- const before = await runDoctorReport(projectRoot);
1560
- const mutations = [];
1561
- const blockingManual = before.manual_errors.find(
1562
- (issue) => MANUAL_LINT_ERROR_CODES.has(issue.code)
1563
- );
1564
- if (blockingManual !== void 0) {
1565
- return {
1566
- changed: false,
1567
- mutations: [],
1568
- manual_errors: before.manual_errors,
1569
- aborted: true,
1570
- abort_reason: `Manual repair required for ${blockingManual.code}: ${blockingManual.message} - apply-lint cannot resolve this safely; triage by hand.`,
1571
- message: `apply-lint aborted: ${blockingManual.code} requires manual repair.`,
1572
- report: before
1573
- };
1574
- }
1575
- const now = Date.now();
1576
- const orphanDemote = await inspectOrphanDemote(projectRoot, now);
1577
- for (const candidate of orphanDemote.candidates) {
1578
- if (candidate.next_maturity === null) {
1579
- continue;
1580
- }
1581
- mutations.push(await applyOrphanDemote(projectRoot, candidate, now));
1582
- }
1583
- const staleArchive = await inspectStaleArchive(projectRoot, now);
1584
- for (const candidate of staleArchive.candidates) {
1585
- mutations.push(await applyStaleArchive(projectRoot, candidate, now));
1586
- }
1587
- const pendingAutoArchive = inspectPendingAutoArchive(projectRoot, now);
1588
- for (const candidate of pendingAutoArchive.candidates) {
1589
- mutations.push(await applyPendingAutoArchive(projectRoot, candidate, now));
1590
- }
1591
- const sessionHintsStale = inspectSessionHintsStale(projectRoot, now);
1592
- for (const candidate of sessionHintsStale.candidates) {
1593
- mutations.push(await applySessionHintsStaleCleanup(projectRoot, candidate));
1594
- }
1595
- const meta = await inspectMeta(projectRoot);
1596
- const indexDrift = inspectIndexDrift(projectRoot, meta);
1597
- if (indexDrift.drifts.length > 0) {
1598
- mutations.push(await applyIndexDriftFix(projectRoot, indexDrift));
1599
- }
1600
- contextCache.invalidate("meta_write", projectRoot);
1601
- const after = await runDoctorReport(projectRoot);
1602
- const successCount = mutations.filter((m) => m.applied).length;
1603
- const failureCount = mutations.length - successCount;
1604
- return {
1605
- changed: successCount > 0,
1606
- mutations,
1607
- manual_errors: after.manual_errors,
1608
- aborted: false,
1609
- message: createApplyLintMessage(successCount, failureCount, after.manual_errors.length),
1610
- report: after
1611
- };
1612
- }
1613
- function createApplyLintMessage(succeeded, failed, manualErrorCount) {
1614
- const parts = [];
1615
- if (succeeded === 0 && failed === 0) {
1616
- parts.push("No apply-lint mutations were needed.");
1617
- } else {
1618
- parts.push(`Applied ${succeeded} apply-lint mutation${succeeded === 1 ? "" : "s"}.`);
1619
- if (failed > 0) {
1620
- parts.push(`${failed} mutation${failed === 1 ? "" : "s"} failed.`);
1621
- }
1622
- }
1623
- parts.push(
1624
- manualErrorCount === 0 ? "No manual errors remain." : `${manualErrorCount} manual error${manualErrorCount === 1 ? "" : "s"} remain.`
1625
- );
1626
- return parts.join(" ");
1627
- }
1628
- function rewriteFrontmatterMaturity(source, newMaturity) {
1629
- const FM_PATTERN = /^(?:\uFEFF)?---\r?\n([\s\S]*?)\r?\n---/u;
1630
- const fm = FM_PATTERN.exec(source);
1631
- if (fm === null) {
1632
- return null;
1633
- }
1634
- const block = fm[1];
1635
- if (!MATURITY_LINE_PATTERN.test(block)) {
1636
- return null;
1637
- }
1638
- const replacedBlock = block.replace(
1639
- MATURITY_LINE_PATTERN,
1640
- (line) => line.replace(/(stable|endorsed|draft)/u, newMaturity)
1641
- );
1642
- const blockStart = source.indexOf(block);
1643
- if (blockStart < 0) {
1644
- return null;
1645
- }
1646
- return source.slice(0, blockStart) + replacedBlock + source.slice(blockStart + block.length);
1647
- }
1648
- async function applyOrphanDemote(projectRoot, candidate, now) {
1649
- const next = candidate.next_maturity;
1650
- if (next === null) {
1651
- return {
1652
- kind: "knowledge_orphan_demote_required",
1653
- path: candidate.path,
1654
- detail: `${candidate.maturity} -> (none, already at terminal tier)`,
1655
- applied: false,
1656
- error: "next_maturity is null; orphan-demote not applicable"
1657
- };
1658
- }
1659
- const detail = `${candidate.maturity} -> ${next}`;
1660
- const absPath = join5(projectRoot, candidate.path);
1661
- try {
1662
- const source = await readFile5(absPath, "utf8");
1663
- const rewritten = rewriteFrontmatterMaturity(source, next);
1664
- if (rewritten === null) {
1665
- return {
1666
- kind: "knowledge_orphan_demote_required",
1667
- path: candidate.path,
1668
- detail,
1669
- applied: false,
1670
- error: "frontmatter missing maturity field; cannot rewrite"
1671
- };
1672
- }
1673
- if (rewritten === source) {
1674
- return {
1675
- kind: "knowledge_orphan_demote_required",
1676
- path: candidate.path,
1677
- detail,
1678
- applied: false,
1679
- error: "rewrite produced byte-identical output"
1680
- };
1681
- }
1682
- await atomicWriteText3(absPath, rewritten);
1683
- try {
1684
- await appendEventLedgerEvent(projectRoot, {
1685
- event_type: "knowledge_demoted",
1686
- stable_id: candidate.stable_id,
1687
- timestamp: new Date(now).toISOString(),
1688
- reason: `lint:orphan_demote ${candidate.maturity}->${next} after ${candidate.age_days}d inactive`
1689
- });
1690
- } catch (ledgerError) {
1691
- try {
1692
- await atomicWriteText3(absPath, source);
1693
- } catch (rollbackError) {
1694
- return {
1695
- kind: "knowledge_orphan_demote_required",
1696
- path: candidate.path,
1697
- detail,
1698
- applied: false,
1699
- error: `ledger append failed (${truncateErrorMessage(ledgerError)}); rollback also failed (${truncateErrorMessage(rollbackError)}); disk may be in inconsistent state`
1700
- };
1701
- }
1702
- return {
1703
- kind: "knowledge_orphan_demote_required",
1704
- path: candidate.path,
1705
- detail,
1706
- applied: false,
1707
- error: `ledger append failed (${truncateErrorMessage(ledgerError)}); frontmatter rolled back`
1708
- };
1709
- }
1710
- return {
1711
- kind: "knowledge_orphan_demote_required",
1712
- path: candidate.path,
1713
- detail,
1714
- applied: true
1715
- };
1716
- } catch (error) {
1717
- return {
1718
- kind: "knowledge_orphan_demote_required",
1719
- path: candidate.path,
1720
- detail,
1721
- applied: false,
1722
- error: truncateErrorMessage(error)
1723
- };
1724
- }
1725
- }
1726
- async function applyStaleArchive(projectRoot, candidate, now) {
1727
- const sourceAbs = join5(projectRoot, candidate.path);
1728
- const destAbs = join5(projectRoot, candidate.archive_path);
1729
- const detail = `${candidate.path} -> ${candidate.archive_path}`;
1730
- try {
1731
- await mkdir3(join5(destAbs, ".."), { recursive: true });
1732
- try {
1733
- await rename(sourceAbs, destAbs);
1734
- } catch (renameError) {
1735
- if (renameError instanceof Error && "code" in renameError && renameError.code === "EXDEV") {
1736
- const data = await readFile5(sourceAbs);
1737
- await writeFile2(destAbs, data);
1738
- const { unlink } = await import("fs/promises");
1739
- await unlink(sourceAbs);
1740
- } else {
1741
- throw renameError;
1742
- }
1743
- }
1744
- try {
1745
- await appendEventLedgerEvent(projectRoot, {
1746
- event_type: "knowledge_archived",
1747
- stable_id: candidate.stable_id,
1748
- timestamp: new Date(now).toISOString(),
1749
- reason: `lint:stale_archive ${candidate.path} -> ${candidate.archive_path} after ${candidate.age_days}d inactive`
1750
- });
1751
- } catch (ledgerError) {
1752
- try {
1753
- await rename(destAbs, sourceAbs);
1754
- } catch (rollbackError) {
1755
- return {
1756
- kind: "knowledge_stale_archive_required",
1757
- path: candidate.path,
1758
- detail,
1759
- applied: false,
1760
- error: `ledger append failed (${truncateErrorMessage(ledgerError)}); rollback also failed (${truncateErrorMessage(rollbackError)}); file may be stranded at ${candidate.archive_path}`
1761
- };
1762
- }
1763
- return {
1764
- kind: "knowledge_stale_archive_required",
1765
- path: candidate.path,
1766
- detail,
1767
- applied: false,
1768
- error: `ledger append failed (${truncateErrorMessage(ledgerError)}); archive rolled back`
1769
- };
1770
- }
1771
- return {
1772
- kind: "knowledge_stale_archive_required",
1773
- path: candidate.path,
1774
- detail,
1775
- applied: true
1776
- };
1777
- } catch (error) {
1778
- return {
1779
- kind: "knowledge_stale_archive_required",
1780
- path: candidate.path,
1781
- detail,
1782
- applied: false,
1783
- error: truncateErrorMessage(error)
1784
- };
1785
- }
1786
- }
1787
- async function applyPendingAutoArchive(projectRoot, candidate, now) {
1788
- const detail = `${candidate.pending_path} -> ${candidate.archived_to}`;
1789
- try {
1790
- await mkdir3(join5(candidate.archived_to_abs, ".."), { recursive: true });
1791
- let moved = false;
1792
- if (candidate.layer === "team") {
1793
- try {
1794
- const relSource = relativePosix(projectRoot, candidate.pending_path_abs);
1795
- const relDest = relativePosix(projectRoot, candidate.archived_to_abs);
1796
- execFileSync("git", ["mv", "-f", relSource, relDest], {
1797
- cwd: projectRoot,
1798
- stdio: ["ignore", "pipe", "pipe"]
1799
- });
1800
- moved = true;
1801
- } catch {
1802
- }
1803
- }
1804
- if (!moved) {
1805
- try {
1806
- await rename(candidate.pending_path_abs, candidate.archived_to_abs);
1807
- } catch (renameError) {
1808
- if (renameError instanceof Error && "code" in renameError && renameError.code === "EXDEV") {
1809
- const data = await readFile5(candidate.pending_path_abs);
1810
- await writeFile2(candidate.archived_to_abs, data);
1811
- const { unlink } = await import("fs/promises");
1812
- await unlink(candidate.pending_path_abs);
1813
- } else {
1814
- throw renameError;
1815
- }
1816
- }
1817
- }
1818
- try {
1819
- await appendEventLedgerEvent(projectRoot, {
1820
- event_type: "pending_auto_archived",
1821
- pending_path: candidate.pending_path,
1822
- archived_to: candidate.archived_to,
1823
- reason: "auto_archive_30d"
1824
- });
1825
- } catch (ledgerError) {
1826
- try {
1827
- await rename(candidate.archived_to_abs, candidate.pending_path_abs);
1828
- } catch (rollbackError) {
1829
- return {
1830
- kind: "knowledge_pending_auto_archive",
1831
- path: candidate.pending_path,
1832
- detail,
1833
- applied: false,
1834
- error: `ledger append failed (${truncateErrorMessage(ledgerError)}); rollback also failed (${truncateErrorMessage(rollbackError)}); file may be stranded at ${candidate.archived_to}`
1835
- };
1836
- }
1837
- return {
1838
- kind: "knowledge_pending_auto_archive",
1839
- path: candidate.pending_path,
1840
- detail,
1841
- applied: false,
1842
- error: `ledger append failed (${truncateErrorMessage(ledgerError)}); archive rolled back`
1843
- };
1844
- }
1845
- return {
1846
- kind: "knowledge_pending_auto_archive",
1847
- path: candidate.pending_path,
1848
- detail,
1849
- applied: true
1850
- };
1851
- } catch (error) {
1852
- return {
1853
- kind: "knowledge_pending_auto_archive",
1854
- path: candidate.pending_path,
1855
- detail,
1856
- applied: false,
1857
- error: truncateErrorMessage(error)
1858
- };
1859
- }
1860
- }
1861
- function relativePosix(projectRoot, absolutePath) {
1862
- const rel = nodeRelative(projectRoot, absolutePath);
1863
- return rel.split(sep4).join("/");
1864
- }
1865
- async function applySessionHintsStaleCleanup(projectRoot, candidate) {
1866
- const detail = `deleted (${candidate.age_days}d old)`;
1867
- const absPath = join5(projectRoot, candidate.path);
1868
- try {
1869
- const { unlink } = await import("fs/promises");
1870
- await unlink(absPath);
1871
- return {
1872
- kind: "knowledge_session_hints_stale_cleanup",
1873
- path: candidate.path,
1874
- detail,
1875
- applied: true
1876
- };
1877
- } catch (error) {
1878
- return {
1879
- kind: "knowledge_session_hints_stale_cleanup",
1880
- path: candidate.path,
1881
- detail,
1882
- applied: false,
1883
- error: truncateErrorMessage(error)
1884
- };
1885
- }
1886
- }
1887
- async function applyIndexDriftFix(projectRoot, inspection) {
1888
- const metaPath = join5(projectRoot, ".fabric", "agents.meta.json");
1889
- const detailParts = [];
1890
- try {
1891
- const meta = agentsMetaSchema4.parse(JSON.parse(await readFile5(metaPath, "utf8")));
1892
- const baseCounters = AgentsMetaCountersSchema.parse(meta.counters ?? void 0);
1893
- const updatedCounters = {
1894
- KP: { ...baseCounters.KP },
1895
- KT: { ...baseCounters.KT }
1896
- };
1897
- for (const drift of inspection.drifts) {
1898
- updatedCounters[drift.layer][drift.type] = drift.proposed_after;
1899
- detailParts.push(`${drift.layer}.${drift.type}: ${drift.counter} -> ${drift.proposed_after}`);
1900
- }
1901
- const updated = { ...meta, counters: updatedCounters };
1902
- await atomicWriteJson2(metaPath, updated, { indent: 2 });
1903
- return {
1904
- kind: "knowledge_index_drift",
1905
- path: "agents.meta.json#counters",
1906
- detail: detailParts.join("; "),
1907
- applied: true
1908
- };
1909
- } catch (error) {
1910
- return {
1911
- kind: "knowledge_index_drift",
1912
- path: "agents.meta.json#counters",
1913
- detail: detailParts.join("; ") || "(no counters processed)",
1914
- applied: false,
1915
- error: truncateErrorMessage(error)
1916
- };
1917
- }
1918
- }
1919
- function truncateErrorMessage(error) {
1920
- const raw = error instanceof Error ? error.message : String(error);
1921
- return raw.length > 240 ? `${raw.slice(0, 237)}...` : raw;
1922
- }
1923
- async function inspectForensic(projectRoot) {
1924
- const path = join5(projectRoot, ".fabric", "forensic.json");
1925
- try {
1926
- const parsed = forensicReportSchema.parse(JSON.parse(await readFile5(path, "utf8")));
1927
- return { present: true, valid: true, report: parsed };
1928
- } catch (error) {
1929
- if (isMissingFileError(error)) {
1930
- return { present: false, valid: false, report: null, error: ".fabric/forensic.json is missing." };
1931
- }
1932
- return { present: true, valid: false, report: null, error: error instanceof Error ? error.message : String(error) };
1933
- }
1934
- }
1935
- function inspectMcpConfigInWrongFile(projectRoot) {
1936
- const settingsPath = join5(projectRoot, ".claude", "settings.json");
1937
- if (!existsSync4(settingsPath)) {
1938
- return { hasWrongEntry: false, settingsPath };
1939
- }
1940
- try {
1941
- const parsed = JSON.parse(readFileSync(settingsPath, "utf8"));
1942
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1943
- return { hasWrongEntry: false, settingsPath };
1944
- }
1945
- const settings = parsed;
1946
- const mcpServers = settings.mcpServers;
1947
- if (mcpServers === null || typeof mcpServers !== "object" || Array.isArray(mcpServers)) {
1948
- return { hasWrongEntry: false, settingsPath };
1949
- }
1950
- const hasWrongEntry = "fabric" in mcpServers;
1951
- return { hasWrongEntry, settingsPath };
1952
- } catch {
1953
- return { hasWrongEntry: false, settingsPath };
1954
- }
1955
- }
1956
- async function inspectMeta(projectRoot) {
1957
- const metaPath = join5(projectRoot, ".fabric", "agents.meta.json");
1958
- const built = await tryBuildRuleMeta(projectRoot);
1959
- try {
1960
- const raw = await readFile5(metaPath, "utf8");
1961
- const meta = agentsMetaSchema4.parse(JSON.parse(raw));
1962
- const contentRefIssues = inspectContentRefs(projectRoot, meta);
1963
- const changed = built === null ? false : built.changed;
1964
- return {
1965
- present: true,
1966
- valid: true,
1967
- meta,
1968
- revision: meta.revision,
1969
- computedRevision: built?.meta.revision ?? null,
1970
- ruleCount: Object.values(meta.nodes).filter((node) => {
1971
- const ref = node.content_ref ?? node.file;
1972
- return ref.startsWith(".fabric/knowledge/") || ref.startsWith("~/.fabric/knowledge/");
1973
- }).length,
1974
- missingContentRefs: contentRefIssues.missing,
1975
- invalidContentRefs: contentRefIssues.invalid,
1976
- stale: changed || built !== null && meta.revision !== built.meta.revision,
1977
- changed
1978
- };
1979
- } catch (error) {
1980
- if (isMissingFileError(error)) {
1981
- return {
1982
- present: false,
1983
- valid: false,
1984
- meta: null,
1985
- revision: null,
1986
- computedRevision: built?.meta.revision ?? null,
1987
- ruleCount: 0,
1988
- missingContentRefs: [],
1989
- invalidContentRefs: [],
1990
- stale: true,
1991
- changed: built?.changed ?? true
1992
- };
1993
- }
1994
- return {
1995
- present: true,
1996
- valid: false,
1997
- meta: null,
1998
- revision: null,
1999
- computedRevision: built?.meta.revision ?? null,
2000
- ruleCount: 0,
2001
- missingContentRefs: [],
2002
- invalidContentRefs: [],
2003
- stale: true,
2004
- changed: built?.changed ?? true,
2005
- readError: error instanceof Error ? error.message : String(error)
2006
- };
2007
- }
2008
- }
2009
- async function tryBuildRuleMeta(projectRoot) {
2010
- try {
2011
- return await buildKnowledgeMeta(projectRoot);
2012
- } catch {
2013
- return null;
2014
- }
2015
- }
2016
- function inspectContentRefs(projectRoot, meta) {
2017
- const missing = [];
2018
- const invalid = [];
2019
- for (const node of Object.values(meta.nodes)) {
2020
- const contentRef = normalizePath(node.content_ref ?? node.file);
2021
- const isPersonalKnowledge = contentRef.startsWith("~/.fabric/knowledge/");
2022
- const isTeamKnowledge = contentRef.startsWith(".fabric/knowledge/");
2023
- if (!isPersonalKnowledge && !isTeamKnowledge) {
2024
- invalid.push(contentRef);
2025
- continue;
2026
- }
2027
- if (isPersonalKnowledge) {
2028
- continue;
2029
- }
2030
- if (!existsSync4(join5(projectRoot, contentRef))) {
2031
- missing.push(contentRef);
2032
- }
2033
- }
2034
- return { missing, invalid };
2035
- }
2036
- async function inspectEventLedger(projectRoot) {
2037
- const path = getEventLedgerPath(projectRoot);
2038
- const exists = existsSync4(path);
2039
- if (!exists) {
2040
- return { exists: false, writable: false, parseable: false, hasPartialWrite: false, partialWriteByteOffset: 0, partialWriteByteLength: 0, path };
2041
- }
2042
- try {
2043
- await access(path, constants.W_OK);
2044
- const { warnings } = await readEventLedger(projectRoot);
2045
- const raw = await readFile5(path, "utf8");
2046
- const invalidLine = raw.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).find((line) => !isValidJsonLine(line));
2047
- const partialWarning = warnings.find((w) => w.kind === "partial_write_at_tail");
2048
- return {
2049
- exists: true,
2050
- writable: true,
2051
- parseable: invalidLine === void 0,
2052
- hasPartialWrite: partialWarning !== void 0,
2053
- partialWriteByteOffset: partialWarning?.byte_offset ?? 0,
2054
- partialWriteByteLength: partialWarning?.byte_length ?? 0,
2055
- path,
2056
- error: invalidLine === void 0 ? void 0 : "events.jsonl contains an invalid JSON line."
2057
- };
2058
- } catch (error) {
2059
- return {
2060
- exists: true,
2061
- writable: false,
2062
- parseable: false,
2063
- hasPartialWrite: false,
2064
- partialWriteByteOffset: 0,
2065
- partialWriteByteLength: 0,
2066
- path,
2067
- error: error instanceof Error ? error.message : String(error)
2068
- };
2069
- }
2070
- }
2071
- async function inspectKnowledgeTestIndex(projectRoot) {
2072
- const path = join5(projectRoot, ".fabric", ".cache", "knowledge-test.index.json");
2073
- const built = await tryBuildRuleMeta(projectRoot);
2074
- try {
2075
- const index = knowledgeTestIndexSchema2.parse(JSON.parse(await readFile5(path, "utf8")));
2076
- return {
2077
- present: true,
2078
- valid: true,
2079
- stale: built === null ? false : !isSameKnowledgeTestIndex(index, built.knowledgeTestIndex),
2080
- linkCount: index.links.length,
2081
- orphanCount: index.orphan_annotations.length
2082
- };
2083
- } catch (error) {
2084
- return {
2085
- present: !isMissingFileError(error),
2086
- valid: false,
2087
- stale: true,
2088
- linkCount: 0,
2089
- orphanCount: 0,
2090
- error: isMissingFileError(error) ? ".fabric/.cache/knowledge-test.index.json is missing." : error instanceof Error ? error.message : String(error)
2091
- };
2092
- }
2093
- }
2094
- function inspectBootstrapAnchor(projectRoot) {
2095
- return {
2096
- hasAgentsMd: existsSync4(join5(projectRoot, "AGENTS.md")),
2097
- hasClaudeMd: existsSync4(join5(projectRoot, "CLAUDE.md"))
2098
- };
2099
- }
2100
- function createBootstrapAnchorCheck(inspection) {
2101
- if (!inspection.hasAgentsMd && !inspection.hasClaudeMd) {
2102
- return issueCheck(
2103
- "Bootstrap anchor",
2104
- "error",
2105
- "fixable_error",
2106
- "bootstrap_anchor_missing",
2107
- "Neither AGENTS.md nor CLAUDE.md exists at the repo root. Fabric requires a bootstrap anchor file at the project root.",
2108
- "Run `fabric init` to generate the AGENTS.md / CLAUDE.md bootstrap anchor at the repo root."
2109
- );
2110
- }
2111
- const present = [
2112
- inspection.hasAgentsMd ? "AGENTS.md" : null,
2113
- inspection.hasClaudeMd ? "CLAUDE.md" : null
2114
- ].filter((entry) => entry !== null).join(", ");
2115
- return okCheck("Bootstrap anchor", `Bootstrap anchor present at repo root: ${present}.`);
2116
- }
2117
- function inspectKnowledgeDirMissing(projectRoot) {
2118
- const knowledgeRoot = join5(projectRoot, ".fabric", "knowledge");
2119
- const missingSubdirs = [];
2120
- for (const sub of KNOWLEDGE_SUBDIRS2) {
2121
- const path = join5(knowledgeRoot, sub);
2122
- if (!existsSync4(path)) {
2123
- missingSubdirs.push(`.fabric/knowledge/${sub}`);
2124
- }
2125
- }
2126
- return { missingSubdirs };
2127
- }
2128
- function createKnowledgeDirMissingCheck(inspection) {
2129
- if (inspection.missingSubdirs.length > 0) {
2130
- const list = inspection.missingSubdirs.join(", ");
2131
- return issueCheck(
2132
- "Knowledge layout",
2133
- "error",
2134
- "fixable_error",
2135
- "knowledge_dir_missing",
2136
- `${inspection.missingSubdirs.length} required knowledge subdir${inspection.missingSubdirs.length === 1 ? " is" : "s are"} missing: ${list}.`,
2137
- "Run `fab doctor --fix` to create the missing .fabric/knowledge/* subdirectories."
2138
- );
2139
- }
2140
- return okCheck(
2141
- "Knowledge layout",
2142
- `All ${KNOWLEDGE_SUBDIRS2.length} required .fabric/knowledge/* subdirectories exist.`
2143
- );
2144
- }
2145
- function createForensicCheck(forensic, frameworkKind, entryPointCount) {
2146
- if (!forensic.present) {
2147
- return issueCheck(
2148
- "Scan evidence",
2149
- "error",
2150
- "manual_error",
2151
- "forensic_missing",
2152
- `${forensic.error ?? ".fabric/forensic.json is missing."} Live scan detects ${frameworkKind} with ${entryPointCount} entry point${entryPointCount === 1 ? "" : "s"}.`,
2153
- "Run `fab init` to regenerate .fabric/forensic.json."
2154
- );
2155
- }
2156
- if (!forensic.valid) {
2157
- return issueCheck("Scan evidence", "error", "manual_error", "forensic_invalid", forensic.error ?? ".fabric/forensic.json is invalid.", "Run `fab init` to regenerate .fabric/forensic.json.");
2158
- }
2159
- return okCheck("Scan evidence", `.fabric/forensic.json is valid for ${forensic.report?.framework.kind ?? "unknown"}.`);
2160
- }
2161
- function createMetaCheck(meta) {
2162
- if (!meta.present) {
2163
- 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/knowledge/.");
2164
- }
2165
- if (!meta.valid) {
2166
- 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.");
2167
- }
2168
- if (meta.stale) {
2169
- return issueCheck(
2170
- "Agents metadata",
2171
- "error",
2172
- "fixable_error",
2173
- "agents_meta_stale",
2174
- `.fabric/agents.meta.json revision ${meta.revision} does not match .fabric/knowledge derived revision ${meta.computedRevision ?? "<unknown>"}.`,
2175
- "Run `fab doctor --fix` to reconcile agents.meta.json with the current knowledge files."
2176
- );
2177
- }
2178
- return okCheck("Agents metadata", `.fabric/agents.meta.json revision ${meta.revision} is aligned with .fabric/knowledge.`);
2179
- }
2180
- function createRuleContentRefCheck(meta) {
2181
- if (!meta.valid) {
2182
- 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`.");
2183
- }
2184
- if (meta.invalidContentRefs.length > 0) {
2185
- return issueCheck(
2186
- "Rule content refs",
2187
- "error",
2188
- "manual_error",
2189
- "content_ref_outside_rules",
2190
- `${meta.invalidContentRefs.length} content_ref entr${meta.invalidContentRefs.length === 1 ? "y is" : "ies are"} outside .fabric/knowledge.`,
2191
- "Edit agents.meta.json to ensure all content_ref values point inside .fabric/knowledge/{type}/ (team) or ~/.fabric/knowledge/{type}/ (personal)."
2192
- );
2193
- }
2194
- if (meta.missingContentRefs.length > 0) {
2195
- return issueCheck(
2196
- "Rule content refs",
2197
- "error",
2198
- "fixable_error",
2199
- "content_ref_missing",
2200
- `${meta.missingContentRefs.length} content_ref target${meta.missingContentRefs.length === 1 ? "" : "s"} are missing. Run \`fab doctor --fix\` to reconcile.`,
2201
- "Run `fab doctor --fix` to reconcile agents.meta.json with the files present in .fabric/knowledge/."
2202
- );
2203
- }
2204
- return okCheck("Rule content refs", "All content_ref entries resolve to .fabric/knowledge files.");
2205
- }
2206
- function createKnowledgeTestIndexCheck(index) {
2207
- if (!index.present) {
2208
- return issueCheck("Knowledge-test index", "error", "fixable_error", "knowledge_test_index_missing", index.error, "Run `fab doctor --fix` to rebuild .fabric/.cache/knowledge-test.index.json.");
2209
- }
2210
- if (!index.valid) {
2211
- return issueCheck("Knowledge-test index", "error", "manual_error", "knowledge_test_index_invalid", index.error, "Delete .fabric/.cache/knowledge-test.index.json and run `fab doctor --fix` to regenerate it.");
2212
- }
2213
- if (index.stale) {
2214
- return issueCheck("Knowledge-test index", "error", "fixable_error", "knowledge_test_index_stale", ".fabric/.cache/knowledge-test.index.json is stale.", "Run `fab doctor --fix` to rebuild the knowledge-test index.");
2215
- }
2216
- return okCheck("Knowledge-test index", `${index.linkCount} link${index.linkCount === 1 ? "" : "s"} and ${index.orphanCount} orphan annotation${index.orphanCount === 1 ? "" : "s"} indexed.`);
2217
- }
2218
- function createEventLedgerCheck(ledger) {
2219
- if (!ledger.exists) {
2220
- return issueCheck("Event ledger", "error", "fixable_error", "event_ledger_missing", ".fabric/events.jsonl is missing.", "Run `fab doctor --fix` to create .fabric/events.jsonl.");
2221
- }
2222
- if (!ledger.writable) {
2223
- 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.");
2224
- }
2225
- if (!ledger.parseable) {
2226
- 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.");
2227
- }
2228
- return okCheck("Event ledger", ".fabric/events.jsonl exists, is writable, and is parseable.");
2229
- }
2230
- function createMcpConfigInWrongFileCheck(inspection) {
2231
- if (inspection.hasWrongEntry) {
2232
- return issueCheck(
2233
- "Claude MCP config location",
2234
- "error",
2235
- "fixable_error",
2236
- "mcp_config_in_wrong_file",
2237
- `.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.`,
2238
- "Run `fab doctor --fix` to remove mcpServers.fabric from .claude/settings.json, then run `fab init` to write .mcp.json."
2239
- );
2240
- }
2241
- return okCheck("Claude MCP config location", "mcpServers.fabric is not in .claude/settings.json.");
2242
- }
2243
- function createEventLedgerPartialWriteCheck(ledger) {
2244
- if (!ledger.exists || !ledger.writable) {
2245
- return okCheck("Event ledger partial write", "No partial-write check needed (ledger missing or not writable).");
2246
- }
2247
- if (ledger.hasPartialWrite) {
2248
- return issueCheck(
2249
- "Event ledger partial write",
2250
- "error",
2251
- "fixable_error",
2252
- "event_ledger_partial_write",
2253
- `events.jsonl has a partial write at byte offset ${ledger.partialWriteByteOffset} (${ledger.partialWriteByteLength} corrupted bytes). Run --fix to truncate and preserve corrupted bytes.`,
2254
- "Run `fab doctor --fix` to truncate the partial write and restore events.jsonl to a valid state."
2255
- );
2256
- }
2257
- return okCheck("Event ledger partial write", "events.jsonl has no partial trailing write.");
2258
- }
2259
- function okCheck(name, message) {
2260
- return { name, status: "ok", message };
2261
- }
2262
- function issueCheck(name, status, kind, code, message, actionHint) {
2263
- return {
2264
- name,
2265
- status,
2266
- kind,
2267
- code,
2268
- fixable: kind === "fixable_error",
2269
- message,
2270
- actionHint
2271
- };
2272
- }
2273
- function collectIssues(checks, kind) {
2274
- return checks.filter((check) => check.kind === kind).map((check) => ({
2275
- code: check.code ?? check.name,
2276
- name: check.name,
2277
- message: check.message
2278
- }));
2279
- }
2280
- function findIssue(issues, code) {
2281
- return issues.find((issue) => issue.code === code) ?? {
2282
- code,
2283
- name: code,
2284
- message: code
2285
- };
2286
- }
2287
- async function inspectMetaManuallyDiverged(projectRoot) {
2288
- const metaPath = join5(projectRoot, ".fabric", "agents.meta.json");
2289
- if (!existsSync4(metaPath)) {
2290
- return { extraMetaEntries: [], hashMismatchEntries: [], readable: false };
2291
- }
2292
- let meta;
2293
- try {
2294
- const raw = await readFile5(metaPath, "utf8");
2295
- meta = agentsMetaSchema4.parse(JSON.parse(raw));
2296
- } catch (error) {
2297
- return {
2298
- extraMetaEntries: [],
2299
- hashMismatchEntries: [],
2300
- readable: false,
2301
- error: error instanceof Error ? error.message : String(error)
2302
- };
2303
- }
2304
- const extraMetaEntries = [];
2305
- const hashMismatchEntries = [];
2306
- for (const node of Object.values(meta.nodes)) {
2307
- const contentRef = node.content_ref ?? node.file;
2308
- const absPath = join5(projectRoot, contentRef);
2309
- if (!existsSync4(absPath)) {
2310
- extraMetaEntries.push(contentRef);
2311
- continue;
2312
- }
2313
- try {
2314
- const content = readFileSync(absPath, "utf8");
2315
- const diskHash = sha256(content);
2316
- if (node.hash !== "" && node.hash !== diskHash) {
2317
- hashMismatchEntries.push(contentRef);
2318
- }
2319
- } catch {
2320
- extraMetaEntries.push(contentRef);
2321
- }
2322
- }
2323
- return { extraMetaEntries, hashMismatchEntries, readable: true };
2324
- }
2325
- function inspectKnowledgeDirUnindexed(projectRoot, meta) {
2326
- const physicalMdFiles = /* @__PURE__ */ new Set();
2327
- collectMdFilesUnder(physicalMdFiles, projectRoot, join5(projectRoot, ".fabric", "knowledge"), ".fabric/knowledge");
2328
- if (physicalMdFiles.size === 0) {
2329
- return { unindexedFiles: [] };
2330
- }
2331
- const indexedRefs = /* @__PURE__ */ new Set();
2332
- if (meta.valid && meta.meta !== null) {
2333
- for (const node of Object.values(meta.meta.nodes)) {
2334
- const ref = normalizePath(node.content_ref ?? node.file);
2335
- indexedRefs.add(ref);
2336
- }
2337
- }
2338
- const unindexedFiles = [...physicalMdFiles].filter((f) => !indexedRefs.has(f)).sort();
2339
- return { unindexedFiles };
2340
- }
2341
- function collectMdFilesUnder(out, projectRoot, rootDir, relPrefix) {
2342
- if (!existsSync4(rootDir)) {
2343
- return;
2344
- }
2345
- const stack = [rootDir];
2346
- while (stack.length > 0) {
2347
- const dir = stack.pop();
2348
- if (dir === void 0) {
2349
- continue;
2350
- }
2351
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
2352
- const abs = join5(dir, entry.name);
2353
- if (entry.isDirectory()) {
2354
- stack.push(abs);
2355
- } else if (entry.isFile() && entry.name.endsWith(".md")) {
2356
- const rel = posix.join(relPrefix, abs.slice(rootDir.length + 1).replace(/\\/gu, "/"));
2357
- out.add(rel);
2358
- }
2359
- }
2360
- }
2361
- }
2362
- function createKnowledgeDirUnindexedCheck(inspection) {
2363
- if (inspection.unindexedFiles.length > 0) {
2364
- return issueCheck(
2365
- "Knowledge dir unindexed",
2366
- "error",
2367
- "fixable_error",
2368
- "knowledge_dir_unindexed",
2369
- `${inspection.unindexedFiles.length} .md file${inspection.unindexedFiles.length === 1 ? "" : "s"} in .fabric/knowledge/ not indexed in agents.meta.json. Run \`fab doctor --fix\` to index the missing knowledge files.`,
2370
- "Run `fab doctor --fix` to index the missing knowledge files."
2371
- );
2372
- }
2373
- return okCheck("Knowledge dir unindexed", "All .fabric/knowledge/ .md files are indexed in agents.meta.json.");
2374
- }
2375
- async function inspectStableIdCollisions(projectRoot) {
2376
- const found = [];
2377
- const knowledgeDir = join5(projectRoot, ".fabric", "knowledge");
2378
- if (existsSync4(knowledgeDir)) {
2379
- const stack = [knowledgeDir];
2380
- while (stack.length > 0) {
2381
- const dir = stack.pop();
2382
- if (dir === void 0) {
2383
- continue;
2384
- }
2385
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
2386
- const abs = join5(dir, entry.name);
2387
- if (entry.isDirectory()) {
2388
- stack.push(abs);
2389
- } else if (entry.isFile() && entry.name.endsWith(".md")) {
2390
- let source;
2391
- try {
2392
- source = await readFile5(abs, "utf8");
2393
- } catch {
2394
- continue;
2395
- }
2396
- const id = extractKnowledgeFrontmatterId(source);
2397
- if (id === null) {
2398
- continue;
2399
- }
2400
- const relPath = posix.join(".fabric/knowledge", abs.slice(knowledgeDir.length + 1).replace(/\\/gu, "/"));
2401
- found.push({ stableId: id, relPath });
2402
- }
2403
- }
2404
- }
2405
- }
2406
- const stableIdToFiles = /* @__PURE__ */ new Map();
2407
- for (const { stableId, relPath } of found) {
2408
- const existing = stableIdToFiles.get(stableId) ?? [];
2409
- existing.push(relPath);
2410
- stableIdToFiles.set(stableId, existing);
2411
- }
2412
- const collisions = [];
2413
- for (const [stable_id, files] of stableIdToFiles) {
2414
- if (files.length > 1) {
2415
- collisions.push({ stable_id, files: files.sort() });
2416
- }
2417
- }
2418
- return { collisions: collisions.sort((a, b) => a.stable_id.localeCompare(b.stable_id)) };
2419
- }
2420
- function extractKnowledgeFrontmatterId(source) {
2421
- const FM_PATTERN = /^(?:\uFEFF)?---\r?\n([\s\S]*?)\r?\n---/u;
2422
- const fm = FM_PATTERN.exec(source);
2423
- if (fm === null) {
2424
- return null;
2425
- }
2426
- const block = fm[1];
2427
- const ID_LINE = /^id:\s*("?)(K[PT]-(?:MOD|DEC|GLD|PIT|PRO)-\d{4,})\1\s*$/mu;
2428
- const idMatch = ID_LINE.exec(block);
2429
- return idMatch === null ? null : idMatch[2];
2430
- }
2431
- function inspectCounterDesync(meta) {
2432
- if (!meta.valid || meta.meta === null) {
2433
- return { desyncs: [], correctedCounters: null };
2434
- }
2435
- const current = AgentsMetaCountersSchema.parse(meta.meta.counters ?? void 0);
2436
- const observed = {
2437
- KP: { MOD: 0, DEC: 0, GLD: 0, PIT: 0, PRO: 0 },
2438
- KT: { MOD: 0, DEC: 0, GLD: 0, PIT: 0, PRO: 0 }
2439
- };
2440
- for (const node of Object.values(meta.meta.nodes)) {
2441
- const id = node.stable_id;
2442
- if (id === void 0) {
2443
- continue;
2444
- }
2445
- const parsed = parseKnowledgeId2(id);
2446
- if (parsed === null) {
2447
- continue;
2448
- }
2449
- const layer = parsed.layer === "personal" ? "KP" : "KT";
2450
- const typeCode = [
2451
- ["model", "MOD"],
2452
- ["decision", "DEC"],
2453
- ["guideline", "GLD"],
2454
- ["pitfall", "PIT"],
2455
- ["process", "PRO"]
2456
- ].find(([t]) => t === parsed.type)?.[1];
2457
- if (typeCode === void 0) {
2458
- continue;
2459
- }
2460
- if (parsed.counter > observed[layer][typeCode]) {
2461
- observed[layer][typeCode] = parsed.counter;
2462
- }
2463
- }
2464
- const desyncs = [];
2465
- const corrected = {
2466
- KP: { ...current.KP },
2467
- KT: { ...current.KT }
2468
- };
2469
- for (const layer of ["KP", "KT"]) {
2470
- for (const code of COUNTER_TYPE_CODES) {
2471
- const obs = observed[layer][code];
2472
- const cur = current[layer][code];
2473
- if (obs > cur) {
2474
- desyncs.push({ layer, type: code, observed: obs, current: cur });
2475
- corrected[layer][code] = obs;
2476
- }
2477
- }
2478
- }
2479
- return {
2480
- desyncs,
2481
- correctedCounters: desyncs.length === 0 ? null : corrected
2482
- };
2483
- }
2484
- function createCounterDesyncCheck(inspection) {
2485
- if (inspection.desyncs.length > 0) {
2486
- const first = inspection.desyncs[0];
2487
- const detail = `counters.${first.layer}.${first.type} = ${first.current} but observed K${first.layer === "KP" ? "P" : "T"}-${first.type}-${String(first.observed).padStart(4, "0")}`;
2488
- return issueCheck(
2489
- "Knowledge counter desync",
2490
- "error",
2491
- "fixable_error",
2492
- "counter_desync",
2493
- `${inspection.desyncs.length} knowledge counter${inspection.desyncs.length === 1 ? "" : "s"} desynced from observed stable_ids. ${detail}. Run \`fab doctor --fix\` to bump counters.`,
2494
- "Run `fab doctor --fix` to bump agents.meta.json counters to the maximum observed counter value."
2495
- );
2496
- }
2497
- return okCheck("Knowledge counter desync", "agents.meta.json counters envelope is consistent with observed stable_ids.");
2498
- }
2499
- function createStableIdCollisionCheck(inspection) {
2500
- if (inspection.collisions.length > 0) {
2501
- const first = inspection.collisions[0];
2502
- 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(", ")}.`;
2503
- return issueCheck(
2504
- "Stable ID collision",
2505
- "warn",
2506
- "warning",
2507
- "stable_id_collision",
2508
- `${detail} Edit one of the knowledge files to use a unique stable_id.`,
2509
- "Edit one of the colliding knowledge files to declare a different `id: K[PT]-XXX-NNNN` frontmatter value."
2510
- );
2511
- }
2512
- return okCheck("Stable ID collision", "No declared stable_id collisions found in .fabric/knowledge/.");
2513
- }
2514
- function createMetaManuallyDivergedCheck(inspection) {
2515
- if (!inspection.readable) {
2516
- return okCheck("Meta manual divergence", "agents.meta.json not readable; skipping divergence check.");
2517
- }
2518
- if (inspection.extraMetaEntries.length > 0) {
2519
- return issueCheck(
2520
- "Meta manual divergence",
2521
- "warn",
2522
- "warning",
2523
- "meta_manually_diverged",
2524
- `agents.meta.json has ${inspection.extraMetaEntries.length} entr${inspection.extraMetaEntries.length === 1 ? "y" : "ies"} with no backing file on disk. Run --fix to reconcile.`,
2525
- "Run `fab doctor --fix` to reconcile agents.meta.json with the rule files currently on disk."
2526
- );
2527
- }
2528
- if (inspection.hashMismatchEntries.length > 0) {
2529
- return issueCheck(
2530
- "Meta manual divergence",
2531
- "warn",
2532
- "warning",
2533
- "meta_manually_diverged",
2534
- `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.`,
2535
- "Run `fab doctor --fix` to reconcile agents.meta.json with the current rule file contents."
2536
- );
2537
- }
2538
- return okCheck("Meta manual divergence", "agents.meta.json is consistent with rule files on disk.");
2539
- }
2540
- function inspectPreexistingRootFiles(projectRoot) {
2541
- const candidates = ["CLAUDE.md", "AGENTS.md"];
2542
- const detected = candidates.filter((name) => existsSync4(join5(projectRoot, name)));
2543
- return { detected };
2544
- }
2545
- async function inspectFilesystemEditFallback(projectRoot) {
2546
- const knowledgeRoot = join5(projectRoot, ".fabric", "knowledge");
2547
- if (!existsSync4(knowledgeRoot)) {
2548
- return { synthesized: 0, synthesizedStableIds: [] };
2549
- }
2550
- const canonicalIds = /* @__PURE__ */ new Set();
2551
- for (const typeDir of KNOWLEDGE_CANONICAL_TYPE_DIRS) {
2552
- const dir = join5(knowledgeRoot, typeDir);
2553
- if (!existsSync4(dir)) {
2554
- continue;
2555
- }
2556
- let entries;
2557
- try {
2558
- entries = readdirSync(dir, { withFileTypes: true });
2559
- } catch {
2560
- continue;
2561
- }
2562
- for (const entry of entries) {
2563
- if (!entry.isFile()) {
2564
- continue;
2565
- }
2566
- const match = CANONICAL_KNOWLEDGE_FILENAME_PATTERN.exec(entry.name);
2567
- if (match === null) {
2568
- continue;
2569
- }
2570
- canonicalIds.add(match[1]);
2571
- }
2572
- }
2573
- if (canonicalIds.size === 0) {
2574
- return { synthesized: 0, synthesizedStableIds: [] };
2575
- }
2576
- let promotedIds = /* @__PURE__ */ new Set();
2577
- try {
2578
- const { events } = await readEventLedger(projectRoot, { event_type: "knowledge_promoted" });
2579
- promotedIds = new Set(
2580
- events.map((event) => event.event_type === "knowledge_promoted" ? event.stable_id : void 0).filter((id) => typeof id === "string")
2581
- );
2582
- } catch {
2583
- promotedIds = /* @__PURE__ */ new Set();
2584
- }
2585
- const orphanIds = [];
2586
- for (const id of canonicalIds) {
2587
- if (!promotedIds.has(id)) {
2588
- orphanIds.push(id);
2589
- }
2590
- }
2591
- orphanIds.sort();
2592
- for (const stable_id of orphanIds) {
2593
- await appendEventLedgerEvent(projectRoot, {
2594
- event_type: "knowledge_promoted",
2595
- stable_id,
2596
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2597
- reason: SYNTHESIZED_PROMOTED_REASON,
2598
- correlation_id: "doctor-synthesized",
2599
- session_id: "doctor-synthesized"
2600
- });
2601
- }
2602
- return { synthesized: orphanIds.length, synthesizedStableIds: orphanIds };
2603
- }
2604
- function createFilesystemEditFallbackCheck(inspection) {
2605
- if (inspection.synthesized === 0) {
2606
- return okCheck(
2607
- "Filesystem-edit fallback",
2608
- "No orphan canonical knowledge entries detected; events.jsonl promotion trail is complete."
2609
- );
2610
- }
2611
- const sample = inspection.synthesizedStableIds.slice(0, 3).join(", ");
2612
- return {
2613
- name: "Filesystem-edit fallback",
2614
- status: "ok",
2615
- kind: "info",
2616
- code: "knowledge_promoted_synthesized",
2617
- fixable: false,
2618
- message: `Synthesized ${inspection.synthesized} knowledge_promoted event${inspection.synthesized === 1 ? "" : "s"} for orphan canonical entries (${sample}${inspection.synthesizedStableIds.length > 3 ? ", ..." : ""}). Reason='${SYNTHESIZED_PROMOTED_REASON}'.`,
2619
- actionHint: "These entries were moved into .fabric/knowledge/<type>/ outside fab_review.approve. The synthesized events restore audit-trail completeness."
2620
- };
2621
- }
2622
- function createPreexistingRootFilesCheck(inspection) {
2623
- if (inspection.detected.length === 0) {
2624
- return okCheck("Preexisting root markdown", "No CLAUDE.md or AGENTS.md detected at project root.");
2625
- }
2626
- return {
2627
- name: "Preexisting root markdown",
2628
- status: "ok",
2629
- kind: "info",
2630
- code: "preexisting_root_claude_md",
2631
- fixable: false,
2632
- message: `${inspection.detected.join(", ")} detected at project root. These root files are not auto-loaded by Fabric MCP.`,
2633
- actionHint: "Move knowledge content to `.fabric/knowledge/{type}/` if you want it available in MCP responses."
2634
- };
2635
- }
2636
- async function buildLastConsumedIndex(projectRoot) {
2637
- const map = /* @__PURE__ */ new Map();
2638
- let events;
2639
- try {
2640
- ({ events } = await readEventLedger(projectRoot));
2641
- } catch {
2642
- return map;
2643
- }
2644
- for (const event of events) {
2645
- if (event.event_type !== "knowledge_consumed" && event.event_type !== "knowledge_demoted" && event.event_type !== "knowledge_archived") {
2646
- continue;
2647
- }
2648
- const ts = event.ts;
2649
- if (typeof ts !== "number" || !Number.isFinite(ts)) {
2650
- continue;
2651
- }
2652
- const stableId = event.stable_id;
2653
- if (typeof stableId !== "string" || stableId.length === 0) {
2654
- continue;
2655
- }
2656
- const prev = map.get(stableId);
2657
- if (prev === void 0 || ts > prev) {
2658
- map.set(stableId, ts);
2659
- }
2660
- }
2661
- return map;
2662
- }
2663
- async function buildLastActiveIndex(projectRoot) {
2664
- const map = /* @__PURE__ */ new Map();
2665
- let events;
2666
- try {
2667
- ({ events } = await readEventLedger(projectRoot));
2668
- } catch {
2669
- return map;
2670
- }
2671
- for (const event of events) {
2672
- const ts = event.ts;
2673
- if (typeof ts !== "number" || !Number.isFinite(ts)) {
2674
- continue;
2675
- }
2676
- const ids = [];
2677
- switch (event.event_type) {
2678
- case "knowledge_proposed":
2679
- case "knowledge_promote_started":
2680
- case "knowledge_promoted":
2681
- case "knowledge_promote_failed":
2682
- case "knowledge_layer_changed":
2683
- case "knowledge_slug_renamed":
2684
- case "knowledge_demoted":
2685
- case "knowledge_archived":
2686
- case "knowledge_archive_attempted":
2687
- case "knowledge_deferred":
2688
- case "knowledge_rejected": {
2689
- if (typeof event.stable_id === "string" && event.stable_id.length > 0) {
2690
- ids.push(event.stable_id);
2691
- }
2692
- break;
2693
- }
2694
- case "knowledge_context_planned": {
2695
- ids.push(...event.required_stable_ids, ...event.ai_selectable_stable_ids, ...event.final_stable_ids);
2696
- break;
2697
- }
2698
- case "knowledge_selection": {
2699
- ids.push(
2700
- ...event.required_stable_ids,
2701
- ...event.ai_selectable_stable_ids,
2702
- ...event.ai_selected_stable_ids,
2703
- ...event.final_stable_ids
2704
- );
2705
- break;
2706
- }
2707
- case "knowledge_sections_fetched": {
2708
- ids.push(...event.final_stable_ids, ...event.ai_selected_stable_ids);
2709
- break;
2710
- }
2711
- default:
2712
- break;
2713
- }
2714
- for (const id of ids) {
2715
- const prev = map.get(id);
2716
- if (prev === void 0 || ts > prev) {
2717
- map.set(id, ts);
2718
- }
2719
- }
2720
- }
2721
- return map;
2722
- }
2723
- function maturityThresholdDays(maturity) {
2724
- return ORPHAN_DEMOTE_THRESHOLD_DAYS[maturity];
2725
- }
2726
- function nextLowerMaturity(current) {
2727
- if (current === "stable") return "endorsed";
2728
- if (current === "endorsed") return "draft";
2729
- return null;
2730
- }
2731
- function extractKnowledgeFrontmatterMaturity(source) {
2732
- const FM_PATTERN = /^(?:\uFEFF)?---\r?\n([\s\S]*?)\r?\n---/u;
2733
- const fm = FM_PATTERN.exec(source);
2734
- if (fm === null) {
2735
- return null;
2736
- }
2737
- const match = MATURITY_LINE_PATTERN.exec(fm[1]);
2738
- return match === null ? null : match[2];
2739
- }
2740
- function extractKnowledgeFrontmatterCreatedAt(source) {
2741
- const FM_PATTERN = /^(?:\uFEFF)?---\r?\n([\s\S]*?)\r?\n---/u;
2742
- const fm = FM_PATTERN.exec(source);
2743
- if (fm === null) {
2744
- return null;
2745
- }
2746
- const match = CREATED_AT_LINE_PATTERN.exec(fm[1]);
2747
- if (match === null) {
2748
- return null;
2749
- }
2750
- const parsed = Date.parse(match[2]);
2751
- return Number.isFinite(parsed) ? parsed : null;
2752
- }
2753
- function* iterateCanonicalEntries(projectRoot, lastActiveIndex) {
2754
- const knowledgeRoot = join5(projectRoot, ".fabric", "knowledge");
2755
- if (!existsSync4(knowledgeRoot)) {
2756
- return;
2757
- }
2758
- for (const typeDir of KNOWLEDGE_CANONICAL_TYPE_DIRS) {
2759
- const dir = join5(knowledgeRoot, typeDir);
2760
- if (!existsSync4(dir)) {
2761
- continue;
2762
- }
2763
- let entries;
2764
- try {
2765
- entries = readdirSync(dir, { withFileTypes: true });
2766
- } catch {
2767
- continue;
2768
- }
2769
- for (const entry of entries) {
2770
- if (!entry.isFile()) {
2771
- continue;
2772
- }
2773
- const match = CANONICAL_KNOWLEDGE_FILENAME_PATTERN.exec(entry.name);
2774
- if (match === null) {
2775
- continue;
2776
- }
2777
- const stableId = match[1];
2778
- const absPath = join5(dir, entry.name);
2779
- let source;
2780
- try {
2781
- source = readFileSync(absPath, "utf8");
2782
- } catch {
2783
- continue;
2784
- }
2785
- const maturity = extractKnowledgeFrontmatterMaturity(source);
2786
- if (maturity === null) {
2787
- continue;
2788
- }
2789
- const createdAt = extractKnowledgeFrontmatterCreatedAt(source);
2790
- const eventTs = lastActiveIndex.get(stableId) ?? 0;
2791
- let lastReferenceMs = Math.max(createdAt ?? 0, eventTs);
2792
- if (lastReferenceMs === 0) {
2793
- try {
2794
- lastReferenceMs = statSync3(absPath).mtimeMs;
2795
- } catch {
2796
- lastReferenceMs = 0;
2797
- }
2798
- }
2799
- const relPath = posix.join(
2800
- ".fabric/knowledge",
2801
- typeDir,
2802
- entry.name
2803
- );
2804
- yield { stable_id: stableId, maturity, type: typeDir, absPath, relPath, lastReferenceMs };
2805
- }
2806
- }
2807
- }
2808
- async function inspectOrphanDemote(projectRoot, now) {
2809
- const lastConsumedIndex = await buildLastConsumedIndex(projectRoot);
2810
- const candidates = [];
2811
- for (const entry of iterateCanonicalEntries(projectRoot, lastConsumedIndex)) {
2812
- const ageMs = entry.lastReferenceMs > 0 ? now - entry.lastReferenceMs : now;
2813
- const ageDays = Math.floor(ageMs / MS_PER_DAY);
2814
- const threshold = maturityThresholdDays(entry.maturity);
2815
- if (ageDays <= threshold) {
2816
- continue;
2817
- }
2818
- candidates.push({
2819
- stable_id: entry.stable_id,
2820
- path: entry.relPath,
2821
- age_days: ageDays,
2822
- maturity: entry.maturity,
2823
- next_maturity: nextLowerMaturity(entry.maturity)
2824
- });
2825
- }
2826
- candidates.sort((a, b) => a.path.localeCompare(b.path));
2827
- return { candidates };
2828
- }
2829
- async function inspectStaleArchive(projectRoot, now) {
2830
- const lastActiveIndex = await buildLastActiveIndex(projectRoot);
2831
- const candidates = [];
2832
- for (const entry of iterateCanonicalEntries(projectRoot, lastActiveIndex)) {
2833
- if (entry.maturity !== "draft") {
2834
- continue;
2835
- }
2836
- const ageMs = entry.lastReferenceMs > 0 ? now - entry.lastReferenceMs : now;
2837
- const ageDays = Math.floor(ageMs / MS_PER_DAY);
2838
- const requiredQuiet = ORPHAN_DEMOTE_THRESHOLD_DAYS.draft + STALE_ARCHIVE_ADDITIONAL_DAYS;
2839
- if (ageDays <= requiredQuiet) {
2840
- continue;
2841
- }
2842
- const filename = posix.basename(entry.relPath);
2843
- candidates.push({
2844
- stable_id: entry.stable_id,
2845
- path: entry.relPath,
2846
- age_days: ageDays,
2847
- archive_path: posix.join(".fabric/.archive", entry.type, filename)
2848
- });
2849
- }
2850
- candidates.sort((a, b) => a.path.localeCompare(b.path));
2851
- return { candidates };
2852
- }
2853
- function* iteratePendingFiles(projectRoot, now) {
2854
- const teamRoot = join5(projectRoot, ".fabric", "knowledge", "pending");
2855
- const personalRoot = join5(resolvePersonalRootForPending(), ".fabric", "knowledge", "pending");
2856
- for (const [layer, root, displayPrefix] of [
2857
- ["team", teamRoot, ".fabric/knowledge/pending"],
2858
- ["personal", personalRoot, "~/.fabric/knowledge/pending"]
2859
- ]) {
2860
- if (!existsSync4(root)) {
2861
- continue;
2862
- }
2863
- let typeDirs = [];
2864
- try {
2865
- typeDirs = readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
2866
- } catch {
2867
- continue;
2868
- }
2869
- for (const typeDir of typeDirs) {
2870
- const dir = join5(root, typeDir);
2871
- let entries;
2872
- try {
2873
- entries = readdirSync(dir, { withFileTypes: true });
2874
- } catch {
2875
- continue;
2876
- }
2877
- for (const entry of entries) {
2878
- if (!entry.isFile() || !entry.name.endsWith(".md")) {
2879
- continue;
2880
- }
2881
- const absPath = join5(dir, entry.name);
2882
- let source = "";
2883
- try {
2884
- source = readFileSync(absPath, "utf8");
2885
- } catch {
2886
- continue;
2887
- }
2888
- const createdAt = extractKnowledgeFrontmatterCreatedAt(source);
2889
- let mtimeMs = 0;
2890
- try {
2891
- mtimeMs = statSync3(absPath).mtimeMs;
2892
- } catch {
2893
- mtimeMs = 0;
2894
- }
2895
- const referenceMs = createdAt ?? mtimeMs;
2896
- const displayPath = posix.join(displayPrefix, typeDir, entry.name);
2897
- if (referenceMs === 0) {
2898
- yield {
2899
- layer,
2900
- type: typeDir,
2901
- filename: entry.name,
2902
- pending_path: displayPath,
2903
- pending_path_abs: absPath,
2904
- stable_id: void 0,
2905
- age_days: PENDING_OVERDUE_THRESHOLD_DAYS + 1
2906
- };
2907
- continue;
2908
- }
2909
- const ageDays = Math.floor((now - referenceMs) / MS_PER_DAY);
2910
- const stableId = extractKnowledgeFrontmatterId(source) ?? void 0;
2911
- yield {
2912
- layer,
2913
- type: typeDir,
2914
- filename: entry.name,
2915
- pending_path: displayPath,
2916
- pending_path_abs: absPath,
2917
- stable_id: stableId,
2918
- age_days: ageDays
2919
- };
2920
- }
2921
- }
2922
- }
2923
- }
2924
- function resolvePersonalRootForPending() {
2925
- return process.env.FABRIC_HOME ?? homedir2();
2926
- }
2927
- function inspectPendingOverdue(projectRoot, now) {
2928
- const candidates = [];
2929
- for (const visit of iteratePendingFiles(projectRoot, now)) {
2930
- if (visit.age_days <= PENDING_OVERDUE_THRESHOLD_DAYS) {
2931
- continue;
2932
- }
2933
- candidates.push({
2934
- stable_id: visit.stable_id,
2935
- path: visit.pending_path,
2936
- age_days: visit.age_days
2937
- });
2938
- }
2939
- candidates.sort((a, b) => a.path.localeCompare(b.path));
2940
- return { candidates };
2941
- }
2942
- function inspectPendingAutoArchive(projectRoot, now) {
2943
- const candidates = [];
2944
- for (const visit of iteratePendingFiles(projectRoot, now)) {
2945
- if (visit.age_days <= PENDING_AUTO_ARCHIVE_THRESHOLD_DAYS) {
2946
- continue;
2947
- }
2948
- if (visit.layer === "team") {
2949
- const archivedToRel = posix.join(".fabric/.archive/pending", visit.type, visit.filename);
2950
- candidates.push({
2951
- layer: "team",
2952
- type: visit.type,
2953
- pending_path: visit.pending_path,
2954
- pending_path_abs: visit.pending_path_abs,
2955
- archived_to: archivedToRel,
2956
- archived_to_abs: join5(projectRoot, archivedToRel),
2957
- age_days: visit.age_days
2958
- });
2959
- } else {
2960
- const archivedToDisplay = posix.join(
2961
- "~/.fabric/.archive/pending",
2962
- visit.type,
2963
- visit.filename
2964
- );
2965
- const archivedToAbs = join5(
2966
- resolvePersonalRootForPending(),
2967
- ".fabric",
2968
- ".archive",
2969
- "pending",
2970
- visit.type,
2971
- visit.filename
2972
- );
2973
- candidates.push({
2974
- layer: "personal",
2975
- type: visit.type,
2976
- pending_path: visit.pending_path,
2977
- pending_path_abs: visit.pending_path_abs,
2978
- archived_to: archivedToDisplay,
2979
- archived_to_abs: archivedToAbs,
2980
- age_days: visit.age_days
2981
- });
2982
- }
2983
- }
2984
- candidates.sort((a, b) => a.pending_path.localeCompare(b.pending_path));
2985
- return { candidates };
2986
- }
2987
- function inspectUnderseeded(projectRoot) {
2988
- const threshold = readUnderseedThresholdFromConfig(projectRoot);
2989
- const knowledgeRoot = join5(projectRoot, ".fabric", "knowledge");
2990
- let nodeCount = 0;
2991
- if (existsSync4(knowledgeRoot)) {
2992
- for (const typeDir of KNOWLEDGE_CANONICAL_TYPE_DIRS) {
2993
- const dir = join5(knowledgeRoot, typeDir);
2994
- if (!existsSync4(dir)) continue;
2995
- let entries;
2996
- try {
2997
- entries = readdirSync(dir, { withFileTypes: true });
2998
- } catch {
2999
- continue;
3000
- }
3001
- for (const entry of entries) {
3002
- if (entry.isFile() && entry.name.endsWith(".md")) {
3003
- nodeCount += 1;
3004
- }
3005
- }
3006
- }
3007
- }
3008
- return {
3009
- node_count: nodeCount,
3010
- threshold,
3011
- underseeded: nodeCount < threshold
3012
- };
3013
- }
3014
- function inspectSessionHintsStale(projectRoot, now) {
3015
- const cacheDir = join5(projectRoot, ".fabric", ".cache");
3016
- if (!existsSync4(cacheDir)) {
3017
- return { candidates: [] };
3018
- }
3019
- let entries;
3020
- try {
3021
- entries = readdirSync(cacheDir, { withFileTypes: true });
3022
- } catch {
3023
- return { candidates: [] };
3024
- }
3025
- const candidates = [];
3026
- for (const entry of entries) {
3027
- if (!entry.isFile()) continue;
3028
- if (!entry.name.startsWith(SESSION_HINTS_FILE_PREFIX)) continue;
3029
- if (!entry.name.endsWith(SESSION_HINTS_FILE_SUFFIX)) continue;
3030
- const absPath = join5(cacheDir, entry.name);
3031
- let mtimeMs = 0;
3032
- try {
3033
- mtimeMs = statSync3(absPath).mtimeMs;
3034
- } catch {
3035
- continue;
3036
- }
3037
- const ageDays = Math.floor((now - mtimeMs) / MS_PER_DAY);
3038
- if (ageDays < SESSION_HINTS_STALE_DAYS) continue;
3039
- candidates.push({
3040
- path: posix.join(".fabric", ".cache", entry.name),
3041
- age_days: ageDays
3042
- });
3043
- }
3044
- candidates.sort((a, b) => a.path.localeCompare(b.path));
3045
- return { candidates };
3046
- }
3047
- function inspectNarrowTooFew(projectRoot, now) {
3048
- let total = 0;
3049
- let narrowWithPaths = 0;
3050
- for (const { scope, paths } of iterateRelevanceFrontmatter(projectRoot)) {
3051
- total += 1;
3052
- if (scope === "narrow" && paths.length > 0) {
3053
- narrowWithPaths += 1;
3054
- }
3055
- }
3056
- const narrowRatio = total === 0 ? 0 : narrowWithPaths / total;
3057
- const structuralFlagged = total >= NARROW_MIN_TOTAL && narrowRatio < NARROW_RATIO_THRESHOLD;
3058
- const windowStartMs = now - SILENCE_WINDOW_DAYS * MS_PER_DAY;
3059
- const editFires = readCounterTimestamps(
3060
- join5(projectRoot, EDIT_COUNTER_FILE_REL),
3061
- windowStartMs
3062
- );
3063
- const silenceFires = readCounterTimestamps(
3064
- join5(projectRoot, HINT_SILENCE_COUNTER_FILE_REL),
3065
- windowStartMs
3066
- );
3067
- const telemetrySkipped = editFires === 0;
3068
- const silenceRate = editFires === 0 ? 0 : silenceFires / editFires;
3069
- const telemetryFlagged = !telemetrySkipped && silenceRate > SILENCE_RATE_THRESHOLD;
3070
- return {
3071
- total_canonical_entries: total,
3072
- narrow_with_paths_count: narrowWithPaths,
3073
- narrow_ratio: narrowRatio,
3074
- structural_flagged: structuralFlagged,
3075
- total_edit_fires_in_window: editFires,
3076
- silence_fires_in_window: silenceFires,
3077
- silence_rate: silenceRate,
3078
- telemetry_skipped: telemetrySkipped,
3079
- telemetry_flagged: telemetryFlagged
3080
- };
3081
- }
3082
- function readCounterTimestamps(absPath, windowStartMs) {
3083
- if (!existsSync4(absPath)) return 0;
3084
- let raw;
3085
- try {
3086
- raw = readFileSync(absPath, "utf8");
3087
- } catch {
3088
- return 0;
3089
- }
3090
- let count = 0;
3091
- for (const line of raw.split(/\r?\n/u)) {
3092
- const trimmed = line.trim();
3093
- if (trimmed.length === 0) continue;
3094
- const ts = Date.parse(trimmed);
3095
- if (!Number.isFinite(ts)) continue;
3096
- if (ts < windowStartMs) continue;
3097
- count += 1;
3098
- }
3099
- return count;
3100
- }
3101
- function readUnderseedThresholdFromConfig(projectRoot) {
3102
- const configPath = join5(projectRoot, ".fabric", "fabric-config.json");
3103
- if (!existsSync4(configPath)) return DEFAULT_UNDERSEED_NODE_THRESHOLD;
3104
- try {
3105
- const raw = readFileSync(configPath, "utf8");
3106
- const parsed = JSON.parse(raw);
3107
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
3108
- const v = parsed.underseed_node_threshold;
3109
- if (typeof v === "number" && Number.isFinite(v) && v > 0) {
3110
- return v;
3111
- }
3112
- }
3113
- } catch {
3114
- }
3115
- return DEFAULT_UNDERSEED_NODE_THRESHOLD;
3116
- }
3117
- function createOrphanDemoteCheck(inspection) {
3118
- if (inspection.candidates.length === 0) {
3119
- return okCheck(
3120
- "Knowledge orphan demote",
3121
- "No canonical knowledge entries exceed their maturity-keyed inactivity threshold."
3122
- );
3123
- }
3124
- const first = inspection.candidates[0];
3125
- const detail = `${first.stable_id} (${first.maturity}, ${first.age_days}d inactive at ${first.path})`;
3126
- return issueCheck(
3127
- "Knowledge orphan demote",
3128
- "warn",
3129
- "warning",
3130
- "knowledge_orphan_demote_required",
3131
- `${inspection.candidates.length} canonical knowledge entr${inspection.candidates.length === 1 ? "y exceeds" : "ies exceed"} their maturity-keyed inactivity threshold (stable=${ORPHAN_DEMOTE_THRESHOLD_DAYS.stable}d / endorsed=${ORPHAN_DEMOTE_THRESHOLD_DAYS.endorsed}d / draft=${ORPHAN_DEMOTE_THRESHOLD_DAYS.draft}d). First: ${detail}.`,
3132
- "Run `fab doctor --apply-lint` (rc.4 TASK-003) to demote orphan entries one maturity tier."
3133
- );
3134
- }
3135
- function createStaleArchiveCheck(inspection) {
3136
- if (inspection.candidates.length === 0) {
3137
- return okCheck(
3138
- "Knowledge stale archive",
3139
- "No draft knowledge entries exceed the additional stale-archive quiet window."
3140
- );
3141
- }
3142
- const first = inspection.candidates[0];
3143
- const detail = `${first.stable_id} (${first.age_days}d inactive at ${first.path}) \u2192 ${first.archive_path}`;
3144
- return issueCheck(
3145
- "Knowledge stale archive",
3146
- "warn",
3147
- "warning",
3148
- "knowledge_stale_archive_required",
3149
- `${inspection.candidates.length} draft knowledge entr${inspection.candidates.length === 1 ? "y is" : "ies are"} stale beyond the demote+${STALE_ARCHIVE_ADDITIONAL_DAYS}d additional quiet window. First: ${detail}.`,
3150
- "Run `fab doctor --apply-lint` (rc.4 TASK-003) to move stale entries into `.fabric/.archive/<type>/`."
3151
- );
3152
- }
3153
- function createPendingOverdueCheck(inspection) {
3154
- if (inspection.candidates.length === 0) {
3155
- return okCheck(
3156
- "Knowledge pending overdue",
3157
- "No pending knowledge entries exceed the 14-day review threshold."
3158
- );
3159
- }
3160
- const first = inspection.candidates[0];
3161
- const detail = `${first.path} (${first.age_days}d old)`;
3162
- return issueCheck(
3163
- "Knowledge pending overdue",
3164
- "warn",
3165
- "warning",
3166
- "knowledge_pending_overdue",
3167
- `${inspection.candidates.length} pending knowledge entr${inspection.candidates.length === 1 ? "y has" : "ies have"} been awaiting review for more than ${PENDING_OVERDUE_THRESHOLD_DAYS} days. First: ${detail}.`,
3168
- "Review pending entries via the fabric-review Skill (`/fabric-review`) and approve, reject, defer, or modify."
3169
- );
3170
- }
3171
- function createUnderseededCheck(inspection) {
3172
- if (!inspection.underseeded) {
3173
- return okCheck(
3174
- "Knowledge underseeded",
3175
- `Knowledge corpus has ${inspection.node_count} canonical entries (>= ${inspection.threshold}).`
3176
- );
3177
- }
3178
- return issueCheck(
3179
- "Knowledge underseeded",
3180
- "ok",
3181
- "info",
3182
- "knowledge_underseeded",
3183
- `Knowledge corpus has only ${inspection.node_count} canonical entr${inspection.node_count === 1 ? "y" : "ies"} (< ${inspection.threshold} threshold). The plan_context retrieval surface is below its useful floor.`,
3184
- "Run the fabric-import Skill (`/fabric-import`) to backfill knowledge from git history and existing docs."
3185
- );
3186
- }
3187
- function createSessionHintsStaleCheck(inspection) {
3188
- if (inspection.candidates.length === 0) {
3189
- return okCheck(
3190
- "Knowledge session-hints stale",
3191
- `No session-hints cache files older than ${SESSION_HINTS_STALE_DAYS} days under .fabric/.cache/.`
3192
- );
3193
- }
3194
- const first = inspection.candidates[0];
3195
- const detail = `${first.path} (${first.age_days}d old)`;
3196
- return issueCheck(
3197
- "Knowledge session-hints stale",
3198
- "ok",
3199
- "info",
3200
- "knowledge_session_hints_stale",
3201
- `${inspection.candidates.length} session-hints cache file${inspection.candidates.length === 1 ? "" : "s"} under .fabric/.cache/ ${inspection.candidates.length === 1 ? "is" : "are"} older than ${SESSION_HINTS_STALE_DAYS} days. First: ${detail}.`,
3202
- "Run `fab doctor --apply-lint` to delete stale session-hints cache files."
3203
- );
3204
- }
3205
- function extractKnowledgeFrontmatterRelevanceScope(source) {
3206
- const FM_PATTERN = /^(?:\uFEFF)?---\r?\n([\s\S]*?)\r?\n---/u;
3207
- const fm = FM_PATTERN.exec(source);
3208
- if (fm === null) {
3209
- return "broad";
3210
- }
3211
- const match = RELEVANCE_SCOPE_LINE_PATTERN.exec(fm[1]);
3212
- if (match === null) {
3213
- return "broad";
3214
- }
3215
- return match[2];
3216
- }
3217
- function extractKnowledgeFrontmatterRelevancePaths(source) {
3218
- const FM_PATTERN = /^(?:\uFEFF)?---\r?\n([\s\S]*?)\r?\n---/u;
3219
- const fm = FM_PATTERN.exec(source);
3220
- if (fm === null) {
3221
- return [];
3222
- }
3223
- const match = RELEVANCE_PATHS_LINE_PATTERN.exec(fm[1]);
3224
- if (match === null) {
3225
- return [];
3226
- }
3227
- const inner = match[1].trim();
3228
- if (inner.length === 0) {
3229
- return [];
3230
- }
3231
- return inner.split(",").map((token) => token.trim().replace(/^"(.*)"$/u, "$1")).filter((token) => token.length > 0);
3232
- }
3233
- function* iterateRelevanceFrontmatter(projectRoot) {
3234
- for (const visit of iterateCanonicalFilenames(projectRoot)) {
3235
- const layerRoot = visit.layer === "team" ? join5(projectRoot, ".fabric", "knowledge") : resolvePersonalKnowledgeRoot();
3236
- const absPath = join5(layerRoot, visit.type, visit.filename);
3237
- let source;
3238
- try {
3239
- source = readFileSync(absPath, "utf8");
3240
- } catch {
3241
- continue;
3242
- }
3243
- const scope = extractKnowledgeFrontmatterRelevanceScope(source);
3244
- const paths = extractKnowledgeFrontmatterRelevancePaths(source);
3245
- yield { visit, scope, paths, absPath };
3246
- }
3247
- }
3248
- function inspectNarrowNoPaths(projectRoot) {
3249
- const candidates = [];
3250
- for (const { visit, scope, paths } of iterateRelevanceFrontmatter(projectRoot)) {
3251
- if (scope !== "narrow") {
3252
- continue;
3253
- }
3254
- if (paths.length > 0) {
3255
- continue;
3256
- }
3257
- candidates.push({
3258
- stable_id: visit.parsed.stable_id,
3259
- path: visit.displayPath
3260
- });
3261
- }
3262
- candidates.sort((a, b) => a.path.localeCompare(b.path));
3263
- return { candidates };
3264
- }
3265
- function inspectRelevancePathsDangling(projectRoot) {
3266
- const entries = [];
3267
- const workspacePaths = collectWorkspacePathsForGlobMatch(projectRoot);
3268
- if (workspacePaths.length === 0) {
3269
- return { entries };
3270
- }
3271
- for (const { visit, paths } of iterateRelevanceFrontmatter(projectRoot)) {
3272
- if (paths.length === 0) {
3273
- continue;
3274
- }
3275
- for (const rawGlob of paths) {
3276
- const glob = rawGlob.endsWith("/") ? `${rawGlob}**` : rawGlob;
3277
- let matched = false;
3278
- for (const target of workspacePaths) {
3279
- if (minimatch(target, glob, { dot: true, matchBase: false })) {
3280
- matched = true;
3281
- break;
3282
- }
3283
- }
3284
- if (matched) {
3285
- continue;
3286
- }
3287
- entries.push({
3288
- stable_id: visit.parsed.stable_id,
3289
- path: visit.displayPath,
3290
- dangling_glob: rawGlob
3291
- });
3292
- }
3293
- }
3294
- entries.sort((a, b) => {
3295
- const byPath = a.path.localeCompare(b.path);
3296
- return byPath !== 0 ? byPath : a.dangling_glob.localeCompare(b.dangling_glob);
3297
- });
3298
- return { entries };
3299
- }
3300
- function collectWorkspacePathsForGlobMatch(projectRoot) {
3301
- if (!existsSync4(projectRoot)) {
3302
- return [];
3303
- }
3304
- let rootStat;
3305
- try {
3306
- rootStat = statSync3(projectRoot);
3307
- } catch {
3308
- return [];
3309
- }
3310
- if (!rootStat.isDirectory()) {
3311
- return [];
3312
- }
3313
- const paths = [];
3314
- const stack = [projectRoot];
3315
- while (stack.length > 0) {
3316
- const current = stack.pop();
3317
- if (current === void 0) continue;
3318
- let entries;
3319
- try {
3320
- entries = readdirSync(current, { withFileTypes: true });
3321
- } catch {
3322
- continue;
3323
- }
3324
- for (const entry of entries) {
3325
- const abs = join5(current, entry.name);
3326
- const rel = normalizePath(abs.slice(projectRoot.length + 1));
3327
- if (rel.length === 0) continue;
3328
- if (entry.isDirectory()) {
3329
- if (IGNORED_DIRECTORIES.has(entry.name)) {
3330
- continue;
3331
- }
3332
- paths.push(rel);
3333
- stack.push(abs);
3334
- } else if (entry.isFile()) {
3335
- paths.push(rel);
3336
- }
3337
- }
3338
- }
3339
- return paths;
3340
- }
3341
- function inspectRelevancePathsDrift(projectRoot) {
3342
- let recentPaths = null;
3343
- try {
3344
- recentPaths = readRecentGitTouchedPaths(projectRoot, RELEVANCE_PATHS_DRIFT_WINDOW_DAYS);
3345
- } catch {
3346
- recentPaths = null;
3347
- }
3348
- if (recentPaths === null) {
3349
- return { candidates: [], git_available: false };
3350
- }
3351
- const candidates = [];
3352
- for (const { visit, scope, paths } of iterateRelevanceFrontmatter(projectRoot)) {
3353
- if (scope !== "narrow") {
3354
- continue;
3355
- }
3356
- if (paths.length === 0) {
3357
- continue;
3358
- }
3359
- let anyMatch = false;
3360
- for (const rawGlob of paths) {
3361
- const glob = rawGlob.endsWith("/") ? `${rawGlob}**` : rawGlob;
3362
- for (const target of recentPaths) {
3363
- if (minimatch(target, glob, { dot: true, matchBase: false })) {
3364
- anyMatch = true;
3365
- break;
3366
- }
3367
- }
3368
- if (anyMatch) break;
3369
- }
3370
- if (anyMatch) {
3371
- continue;
3372
- }
3373
- candidates.push({
3374
- stable_id: visit.parsed.stable_id,
3375
- path: visit.displayPath,
3376
- globs: paths.slice()
3377
- });
3378
- }
3379
- candidates.sort((a, b) => a.path.localeCompare(b.path));
3380
- return { candidates, git_available: true };
3381
- }
3382
- function readRecentGitTouchedPaths(projectRoot, windowDays) {
3383
- const since = new Date(Date.now() - windowDays * MS_PER_DAY).toISOString();
3384
- const stdout = execFileSync(
3385
- "git",
3386
- ["log", `--since=${since}`, "--name-only", "--pretty=format:"],
3387
- {
3388
- cwd: projectRoot,
3389
- stdio: ["ignore", "pipe", "pipe"],
3390
- encoding: "utf8"
3391
- }
3392
- );
3393
- const set = /* @__PURE__ */ new Set();
3394
- for (const line of stdout.split(/\r?\n/u)) {
3395
- const trimmed = line.trim();
3396
- if (trimmed.length === 0) continue;
3397
- set.add(normalizePath(trimmed));
3398
- }
3399
- return Array.from(set);
3400
- }
3401
- function createNarrowNoPathsCheck(inspection) {
3402
- if (inspection.candidates.length === 0) {
3403
- return okCheck(
3404
- "Knowledge narrow without paths",
3405
- "No narrow-scope canonical entries have an empty relevance_paths array."
3406
- );
3407
- }
3408
- const first = inspection.candidates[0];
3409
- const detail = `${first.stable_id} (${first.path})`;
3410
- return issueCheck(
3411
- "Knowledge narrow without paths",
3412
- "warn",
3413
- "warning",
3414
- "knowledge_narrow_no_paths",
3415
- `${inspection.candidates.length} narrow-scope canonical entr${inspection.candidates.length === 1 ? "y has" : "ies have"} an empty relevance_paths array (silent recall risk \u2014 narrow without anchors can never match a target path). First: ${detail}.`,
3416
- "Either add path anchors to relevance_paths or widen the entry's relevance_scope to broad."
3417
- );
3418
- }
3419
- function createRelevancePathsDanglingCheck(inspection) {
3420
- if (inspection.entries.length === 0) {
3421
- return okCheck(
3422
- "Knowledge relevance_paths dangling",
3423
- "All relevance_paths globs resolve to at least one file under the workspace root."
3424
- );
3425
- }
3426
- const first = inspection.entries[0];
3427
- const detail = `${first.stable_id} at ${first.path} \u2192 \`${first.dangling_glob}\` (0 matches)`;
3428
- return issueCheck(
3429
- "Knowledge relevance_paths dangling",
3430
- "warn",
3431
- "warning",
3432
- "knowledge_relevance_paths_dangling",
3433
- `${inspection.entries.length} relevance_paths glob${inspection.entries.length === 1 ? " resolves" : "s resolve"} to zero files in the current workspace. First: ${detail}.`,
3434
- "Update the entry's relevance_paths to remove globs that no longer match any files, or use `fab_review.modify` to rewrite the anchor set."
3435
- );
3436
- }
3437
- function createRelevancePathsDriftCheck(inspection) {
3438
- if (!inspection.git_available) {
3439
- return okCheck(
3440
- "Knowledge relevance_paths drift",
3441
- `Skipped (git history unavailable; cannot evaluate ${RELEVANCE_PATHS_DRIFT_WINDOW_DAYS}d drift window).`
3442
- );
3443
- }
3444
- if (inspection.candidates.length === 0) {
3445
- return okCheck(
3446
- "Knowledge relevance_paths drift",
3447
- `All narrow-scope canonical entries have at least one relevance_path touched in the last ${RELEVANCE_PATHS_DRIFT_WINDOW_DAYS}d.`
3448
- );
3449
- }
3450
- const first = inspection.candidates[0];
3451
- const detail = `${first.stable_id} at ${first.path} (globs: ${first.globs.join(", ")})`;
3452
- return issueCheck(
3453
- "Knowledge relevance_paths drift",
3454
- "ok",
3455
- "info",
3456
- "knowledge_relevance_paths_drift",
3457
- `${inspection.candidates.length} narrow-scope canonical entr${inspection.candidates.length === 1 ? "y has" : "ies have"} relevance_paths whose globs match no file touched in the last ${RELEVANCE_PATHS_DRIFT_WINDOW_DAYS}d of git history. First: ${detail}.`,
3458
- "Review whether the entry is still relevant \u2014 use `fab_review.modify` to refresh the anchors or `fab_review.reject` to archive."
3459
- );
3460
- }
3461
- function createNarrowTooFewCheck(inspection) {
3462
- const { structural_flagged, telemetry_flagged } = inspection;
3463
- if (!structural_flagged && !telemetry_flagged) {
3464
- const ratioPct = (inspection.narrow_ratio * 100).toFixed(0);
3465
- const teleNote = inspection.telemetry_skipped ? "telemetry skipped (no edit-counter fires in window)" : `silence rate ${(inspection.silence_rate * 100).toFixed(0)}% over ${SILENCE_WINDOW_DAYS}d`;
3466
- return okCheck(
3467
- "Knowledge narrow too few",
3468
- `Narrow-with-paths ratio ${ratioPct}% (${inspection.narrow_with_paths_count}/${inspection.total_canonical_entries}); ${teleNote}.`
3469
- );
3470
- }
3471
- const parts = [];
3472
- if (structural_flagged) {
3473
- const ratioPct = (inspection.narrow_ratio * 100).toFixed(0);
3474
- parts.push(
3475
- `narrow-with-paths share ${ratioPct}% (${inspection.narrow_with_paths_count}/${inspection.total_canonical_entries}) below ${(NARROW_RATIO_THRESHOLD * 100).toFixed(0)}% threshold`
3476
- );
3477
- }
3478
- if (telemetry_flagged) {
3479
- const silencePct = (inspection.silence_rate * 100).toFixed(0);
3480
- parts.push(
3481
- `narrow-hook silence rate ${silencePct}% (${inspection.silence_fires_in_window}/${inspection.total_edit_fires_in_window}) over ${SILENCE_WINDOW_DAYS}d above ${(SILENCE_RATE_THRESHOLD * 100).toFixed(0)}% threshold`
3482
- );
3483
- }
3484
- return issueCheck(
3485
- "Knowledge narrow too few",
3486
- "ok",
3487
- "info",
3488
- "knowledge_narrow_too_few",
3489
- `Narrow-scope KB coverage is below the useful floor: ${parts.join("; ")}.`,
3490
- "Run the fabric-import Skill (`/fabric-import`) to re-seed narrow anchors against the current codebase."
3491
- );
3492
- }
3493
- function resolvePersonalKnowledgeRoot() {
3494
- const home = process.env.FABRIC_HOME ?? homedir2();
3495
- return join5(home, ".fabric", "knowledge");
3496
- }
3497
- function parseStableIdFromCanonicalFilename(filename) {
3498
- const match = CANONICAL_KNOWLEDGE_FILENAME_PATTERN.exec(filename);
3499
- if (match === null) {
3500
- return null;
3501
- }
3502
- const stableId = match[1];
3503
- const inner = /^(K[PT])-(MOD|DEC|GLD|PIT|PRO)-(\d{4,})$/u.exec(stableId);
3504
- if (inner === null) {
3505
- return null;
3506
- }
3507
- return {
3508
- prefix: inner[1],
3509
- typeCode: inner[2],
3510
- counter: Number.parseInt(inner[3], 10),
3511
- stable_id: stableId
3512
- };
3513
- }
3514
- function* iterateCanonicalFilenames(projectRoot) {
3515
- const teamRoot = join5(projectRoot, ".fabric", "knowledge");
3516
- const personalRoot = resolvePersonalKnowledgeRoot();
3517
- for (const [layer, root, displayPrefix] of [
3518
- ["team", teamRoot, ".fabric/knowledge"],
3519
- ["personal", personalRoot, "~/.fabric/knowledge"]
3520
- ]) {
3521
- if (!existsSync4(root)) {
3522
- continue;
3523
- }
3524
- for (const typeDir of KNOWLEDGE_CANONICAL_TYPE_DIRS) {
3525
- const dir = join5(root, typeDir);
3526
- if (!existsSync4(dir)) {
3527
- continue;
3528
- }
3529
- let entries;
3530
- try {
3531
- entries = readdirSync(dir, { withFileTypes: true });
3532
- } catch {
3533
- continue;
3534
- }
3535
- for (const entry of entries) {
3536
- if (!entry.isFile()) {
3537
- continue;
3538
- }
3539
- const parsed = parseStableIdFromCanonicalFilename(entry.name);
3540
- if (parsed === null) {
3541
- continue;
3542
- }
3543
- const displayPath = posix.join(displayPrefix, typeDir, entry.name);
3544
- yield {
3545
- layer,
3546
- type: typeDir,
3547
- filename: entry.name,
3548
- displayPath,
3549
- parsed
3550
- };
3551
- }
3552
- }
3553
- }
3554
- }
3555
- function inspectStableIdDuplicate(projectRoot) {
3556
- const idToPaths = /* @__PURE__ */ new Map();
3557
- for (const visit of iterateCanonicalFilenames(projectRoot)) {
3558
- const existing = idToPaths.get(visit.parsed.stable_id) ?? [];
3559
- existing.push(visit.displayPath);
3560
- idToPaths.set(visit.parsed.stable_id, existing);
3561
- }
3562
- const duplicates = [];
3563
- for (const [stable_id, paths] of idToPaths) {
3564
- if (paths.length > 1) {
3565
- duplicates.push({ stable_id, paths: paths.slice().sort() });
3566
- }
3567
- }
3568
- duplicates.sort((a, b) => a.stable_id.localeCompare(b.stable_id));
3569
- return { duplicates };
3570
- }
3571
- function inspectLayerMismatch(projectRoot) {
3572
- const mismatches = [];
3573
- for (const visit of iterateCanonicalFilenames(projectRoot)) {
3574
- const expected_layer = visit.parsed.prefix === "KT" ? "team" : "personal";
3575
- if (expected_layer === visit.layer) {
3576
- continue;
3577
- }
3578
- mismatches.push({
3579
- path: visit.displayPath,
3580
- located_in: visit.layer,
3581
- expected_layer,
3582
- stable_id: visit.parsed.stable_id
3583
- });
3584
- }
3585
- mismatches.sort((a, b) => a.path.localeCompare(b.path));
3586
- return { mismatches };
3587
- }
3588
- function inspectIndexDrift(projectRoot, meta) {
3589
- if (!meta.valid || meta.meta === null) {
3590
- return { drifts: [] };
3591
- }
3592
- const counters = AgentsMetaCountersSchema.parse(meta.meta.counters ?? void 0);
3593
- const observed = {
3594
- KP: { MOD: 0, DEC: 0, GLD: 0, PIT: 0, PRO: 0 },
3595
- KT: { MOD: 0, DEC: 0, GLD: 0, PIT: 0, PRO: 0 }
3596
- };
3597
- for (const visit of iterateCanonicalFilenames(projectRoot)) {
3598
- const { prefix, typeCode, counter } = visit.parsed;
3599
- if (counter > observed[prefix][typeCode]) {
3600
- observed[prefix][typeCode] = counter;
3601
- }
3602
- }
3603
- const drifts = [];
3604
- for (const layer of ["KP", "KT"]) {
3605
- for (const code of COUNTER_TYPE_CODES) {
3606
- const max = observed[layer][code];
3607
- if (max === 0) {
3608
- continue;
3609
- }
3610
- const current = counters[layer][code];
3611
- if (current < max) {
3612
- drifts.push({
3613
- layer,
3614
- type: code,
3615
- counter: current,
3616
- max_observed: max,
3617
- proposed_after: max + 1
3618
- });
3619
- }
3620
- }
3621
- }
3622
- drifts.sort(
3623
- (a, b) => a.layer === b.layer ? a.type.localeCompare(b.type) : a.layer.localeCompare(b.layer)
3624
- );
3625
- return { drifts };
3626
- }
3627
- function createStableIdDuplicateCheck(inspection) {
3628
- if (inspection.duplicates.length === 0) {
3629
- return okCheck(
3630
- "Knowledge stable_id duplicate",
3631
- "No canonical knowledge files share a stable_id across team / personal trees."
3632
- );
3633
- }
3634
- const first = inspection.duplicates[0];
3635
- const detail = `${first.stable_id} appears in ${first.paths.length} files: ${first.paths.join(", ")}`;
3636
- return issueCheck(
3637
- "Knowledge stable_id duplicate",
3638
- "error",
3639
- "manual_error",
3640
- "knowledge_stable_id_duplicate",
3641
- `${inspection.duplicates.length} stable_id${inspection.duplicates.length === 1 ? "" : "s"} duplicated across canonical knowledge files (path-decoupled identity invariant). First: ${detail}.`,
3642
- "Manually rename one of the colliding files to a fresh `<prefix>-<type>-<counter>--<slug>.md` allocated via the canonical id allocator; do not edit by hand."
3643
- );
3644
- }
3645
- function createLayerMismatchCheck(inspection) {
3646
- if (inspection.mismatches.length === 0) {
3647
- return okCheck(
3648
- "Knowledge layer mismatch",
3649
- "All canonical knowledge files are physically located under the layer their stable_id prefix declares."
3650
- );
3651
- }
3652
- const first = inspection.mismatches[0];
3653
- const detail = `${first.stable_id} at ${first.path} (located in ${first.located_in}, expected ${first.expected_layer})`;
3654
- return issueCheck(
3655
- "Knowledge layer mismatch",
3656
- "error",
3657
- "manual_error",
3658
- "knowledge_layer_mismatch",
3659
- `${inspection.mismatches.length} canonical knowledge file${inspection.mismatches.length === 1 ? "" : "s"} are physically misaligned with their stable_id layer prefix (KT-* must live under team/, KP-* under personal/). First: ${detail}.`,
3660
- "Move the file to the correct layer root, or use the fabric-review modify flow to flip its layer (which renames the stable_id prefix accordingly)."
3661
- );
3662
- }
3663
- function createIndexDriftCheck(inspection) {
3664
- if (inspection.drifts.length === 0) {
3665
- return okCheck(
3666
- "Knowledge index drift",
3667
- "agents.meta.json counters envelope is at or above the highest existing canonical counter for every (layer, type) pair."
3668
- );
3669
- }
3670
- const first = inspection.drifts[0];
3671
- const detail = `${first.layer}.${first.type} counter=${first.counter} but max_observed=${first.max_observed} (would propose counters.${first.layer}.${first.type}=${first.proposed_after})`;
3672
- return issueCheck(
3673
- "Knowledge index drift",
3674
- "error",
3675
- "fixable_error",
3676
- "knowledge_index_drift",
3677
- `${inspection.drifts.length} (layer, type) counter slot${inspection.drifts.length === 1 ? "" : "s"} have drifted below the observed canonical maximum (next allocate would collide). First: ${detail}.`,
3678
- "Run `fab doctor --apply-lint` (rc.4 TASK-003) to bump agents.meta.json counters to max_observed + 1."
3679
- );
3680
- }
3681
- async function fixMcpConfigInWrongFile(projectRoot) {
3682
- const settingsPath = join5(projectRoot, ".claude", "settings.json");
3683
- if (!existsSync4(settingsPath)) {
3684
- return;
3685
- }
3686
- let settings;
3687
- try {
3688
- const parsed = JSON.parse(readFileSync(settingsPath, "utf8"));
3689
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
3690
- return;
3691
- }
3692
- settings = parsed;
3693
- } catch {
3694
- return;
3695
- }
3696
- const mcpServers = settings.mcpServers;
3697
- if (mcpServers === null || typeof mcpServers !== "object" || Array.isArray(mcpServers)) {
3698
- return;
3699
- }
3700
- const { fabric: _removed, ...remainingServers } = mcpServers;
3701
- const cleaned = { ...settings };
3702
- if (Object.keys(remainingServers).length === 0) {
3703
- delete cleaned.mcpServers;
3704
- } else {
3705
- cleaned.mcpServers = remainingServers;
3706
- }
3707
- await atomicWriteJson2(settingsPath, cleaned, { indent: 2 });
3708
- await appendEventLedgerEvent(projectRoot, {
3709
- event_type: "mcp_config_migrated",
3710
- source: "doctor_fix",
3711
- removed_from: ".claude/settings.json"
3712
- });
3713
- }
3714
- async function ensureKnowledgeSubdirs(projectRoot) {
3715
- for (const sub of KNOWLEDGE_SUBDIRS2) {
3716
- await mkdir3(join5(projectRoot, ".fabric", "knowledge", sub), { recursive: true });
3717
- }
3718
- }
3719
- async function fixCounterDesync(projectRoot) {
3720
- const metaPath = join5(projectRoot, ".fabric", "agents.meta.json");
3721
- if (!existsSync4(metaPath)) {
3722
- return;
3723
- }
3724
- let meta;
3725
- try {
3726
- meta = agentsMetaSchema4.parse(JSON.parse(await readFile5(metaPath, "utf8")));
3727
- } catch {
3728
- return;
3729
- }
3730
- const synthetic = {
3731
- present: true,
3732
- valid: true,
3733
- meta,
3734
- revision: meta.revision,
3735
- computedRevision: null,
3736
- ruleCount: 0,
3737
- missingContentRefs: [],
3738
- invalidContentRefs: [],
3739
- stale: false,
3740
- changed: false
3741
- };
3742
- const desync = inspectCounterDesync(synthetic);
3743
- if (desync.desyncs.length === 0 || desync.correctedCounters === null) {
3744
- return;
3745
- }
3746
- const updated = { ...meta, counters: desync.correctedCounters };
3747
- await atomicWriteJson2(metaPath, updated, { indent: 2 });
3748
- }
3749
- async function ensureEventLedger(projectRoot) {
3750
- const path = getEventLedgerPath(projectRoot);
3751
- await ensureParentDirectory(path);
3752
- await writeFile2(path, "", { encoding: "utf8", flag: "a" });
3753
- }
3754
- function createFixMessage(fixed, report) {
3755
- const fixedText = fixed.length === 0 ? "No deterministic doctor fixes were needed." : `Applied ${fixed.length} deterministic doctor fix${fixed.length === 1 ? "" : "es"}.`;
3756
- const manualText = report.manual_errors.length === 0 ? "No manual errors remain." : `${report.manual_errors.length} manual error${report.manual_errors.length === 1 ? "" : "s"} remain.`;
3757
- return `${fixedText} ${manualText}`;
3758
- }
3759
- function isValidJsonLine(line) {
3760
- try {
3761
- JSON.parse(line);
3762
- return true;
3763
- } catch {
3764
- return false;
3765
- }
3766
- }
3767
- function normalizeTarget(targetInput) {
3768
- return isAbsolute2(targetInput) ? targetInput : resolve3(process.cwd(), targetInput);
3769
- }
3770
- function normalizePath(path) {
3771
- return posix.normalize(path.split("\\").join("/"));
3772
- }
3773
- function collectEntryPoints(root) {
3774
- if (!existsSync4(root) || !statSync3(root).isDirectory()) {
3775
- return [];
3776
- }
3777
- const entries = [];
3778
- const stack = [root];
3779
- while (stack.length > 0) {
3780
- const current = stack.pop();
3781
- if (current === void 0) {
3782
- continue;
3783
- }
3784
- for (const entry of readdirSync(current, { withFileTypes: true })) {
3785
- const absolutePath = join5(current, entry.name);
3786
- const relativePath = normalizePath(absolutePath.slice(root.length + 1));
3787
- if (relativePath.length === 0) {
3788
- continue;
3789
- }
3790
- if (entry.isDirectory()) {
3791
- if (!IGNORED_DIRECTORIES.has(entry.name)) {
3792
- stack.push(absolutePath);
3793
- }
3794
- continue;
3795
- }
3796
- if (!entry.isFile()) {
3797
- continue;
3798
- }
3799
- const reason = getEntryPointReason(relativePath);
3800
- if (reason !== null) {
3801
- entries.push({ path: relativePath, reason });
3802
- }
3803
- }
3804
- }
3805
- return entries.sort((left, right) => left.path.localeCompare(right.path));
3806
- }
3807
- function getEntryPointReason(relativePath) {
3808
- const extension = relativePath.slice(relativePath.lastIndexOf("."));
3809
- if (!SCRIPT_EXTENSIONS.has(extension)) {
3810
- return null;
3811
- }
3812
- const directory = posix.dirname(relativePath);
3813
- const fileName = posix.basename(relativePath);
3814
- const fileBase = fileName.slice(0, Math.max(fileName.lastIndexOf("."), 0));
3815
- if (directory === "assets/scripts" || directory === "scripts") {
3816
- return "top-level script";
3817
- }
3818
- if (directory === "src" && /^(App|app|index|main)$/.test(fileBase)) {
3819
- return "application entry";
3820
- }
3821
- if ((directory === "app" || directory.startsWith("app/")) && /^(layout|page|route)$/.test(fileBase)) {
3822
- return "next app route";
3823
- }
3824
- if ((directory === "pages" || directory.startsWith("pages/")) && fileName !== "_app.d.ts") {
3825
- return "next page route";
3826
- }
3827
- return null;
3828
- }
3829
- function reduceStatus(statuses) {
3830
- if (statuses.includes("error")) {
3831
- return "error";
3832
- }
3833
- if (statuses.includes("warn")) {
3834
- return "warn";
3835
- }
3836
- return "ok";
3837
- }
3838
- function isMissingFileError(error) {
3839
- return error instanceof Error && "code" in error && error.code === "ENOENT";
3840
- }
3841
-
3842
- // src/services/get-knowledge.ts
3843
- import { readFile as readFile6 } from "fs/promises";
3844
- import { join as join6 } from "path";
3845
- import { minimatch as minimatch2 } from "minimatch";
3846
- var PRIORITY_ORDER = {
3847
- high: 0,
3848
- medium: 1,
3849
- low: 2
3850
- };
3851
- async function getKnowledge(projectRoot, input) {
3852
- const context = await loadGetKnowledgeContext(projectRoot);
3853
- const stale = input.client_hash !== void 0 && input.client_hash !== context.meta.revision;
3854
- const matchedNodes = matchRuleNodes(context.meta, input.path);
3855
- const requiredStableIds = matchedNodes.filter((node) => node.level === "L2").map((node) => node.stable_id);
3856
- const aiSelectableStableIds = matchedNodes.filter((node) => node.level === "L1").map((node) => node.stable_id);
3857
- const rules = await resolveKnowledgeForPath(projectRoot, context, input.path);
3858
- const result = {
3859
- revision_hash: context.meta.revision,
3860
- stale,
3861
- rules
3862
- };
3863
- try {
3864
- await appendEventLedgerEvent(projectRoot, {
3865
- event_type: "knowledge_context_planned",
3866
- target_paths: [input.path],
3867
- required_stable_ids: requiredStableIds,
3868
- ai_selectable_stable_ids: aiSelectableStableIds,
3869
- final_stable_ids: [...requiredStableIds, ...aiSelectableStableIds],
3870
- client_hash: input.client_hash,
3871
- correlation_id: input.correlation_id,
3872
- session_id: input.session_id
3873
- });
3874
- } catch {
3875
- }
3876
- return result;
3877
- }
3878
- async function loadGetKnowledgeContext(projectRoot) {
3879
- const cached = contextCache.get("context", projectRoot);
3880
- if (cached !== void 0) {
3881
- return cached;
3882
- }
3883
- const meta = await readAgentsMeta(projectRoot);
3884
- const l0Content = await readFile6(join6(projectRoot, ".fabric", "bootstrap", "README.md"), "utf8");
3885
- const context = {
3886
- meta,
3887
- l0Content,
3888
- humanLockedNearby: []
3889
- };
3890
- contextCache.set("context", projectRoot, context);
3891
- return context;
3892
- }
3893
- async function resolveKnowledgeForPath(projectRoot, context, path, options = {}) {
3894
- const matchedNodes = matchRuleNodes(context.meta, path);
3895
- const loaded = await loadMatchedRules(projectRoot, matchedNodes);
3896
- return buildKnowledgePayload(context, loaded, options);
3897
- }
3898
- function normalizeKnowledgePath(value) {
3899
- return value.replaceAll("\\", "/");
3900
- }
3901
- function matchRuleNodes(meta, path) {
3902
- const requestedPath = normalizeKnowledgePath(path);
3903
- return Object.entries(meta.nodes).filter(([, node]) => shouldLoadNodeForPath(requestedPath, node)).sort((left, right) => {
3904
- const [leftId, leftNode] = left;
3905
- const [rightId, rightNode] = right;
3906
- const priorityDelta = PRIORITY_ORDER[leftNode.priority ?? "medium"] - PRIORITY_ORDER[rightNode.priority ?? "medium"];
3907
- return priorityDelta !== 0 ? priorityDelta : leftId.localeCompare(rightId);
3908
- }).map(([nodeId, node]) => ({
3909
- node_id: nodeId,
3910
- level: classifyNode(nodeId, node),
3911
- stable_id: node.stable_id ?? nodeId,
3912
- identity_source: node.identity_source ?? "derived",
3913
- node
3914
- }));
3915
- }
3916
- async function loadMatchedRules(projectRoot, matchedNodes, fileContentCache = /* @__PURE__ */ new Map()) {
3917
- const rules = [];
3918
- const stubs = [];
3919
- for (const matchedNode of matchedNodes) {
3920
- if (matchedNode.level === null) {
3921
- continue;
3922
- }
3923
- if (matchedNode.node.activation?.tier === "description") {
3924
- stubs.push({
3925
- stable_id: matchedNode.stable_id,
3926
- identity_source: matchedNode.identity_source,
3927
- level: matchedNode.level,
3928
- path: matchedNode.node.file,
3929
- description: matchedNode.node.activation.description ?? ""
3930
- });
3931
- continue;
3932
- }
3933
- rules.push({
3934
- level: matchedNode.level,
3935
- stable_id: matchedNode.stable_id,
3936
- identity_source: matchedNode.identity_source,
3937
- entry: {
3938
- path: matchedNode.node.file,
3939
- content: await readRuleContent(projectRoot, matchedNode.node.file, fileContentCache)
3940
- }
3941
- });
3942
- }
3943
- return { rules, stubs };
3944
- }
3945
- function buildKnowledgePayload(context, loaded, options = {}) {
3946
- const { L1, L2 } = partitionRulesByLevel(loaded.rules, options.dedupeByPath ?? false);
3947
- return {
3948
- L0: context.l0Content,
3949
- L1,
3950
- L2,
3951
- human_locked_nearby: context.humanLockedNearby,
3952
- description_stubs: loaded.stubs.length > 0 ? dedupeDescriptionStubsByPath(loaded.stubs).map(toDescriptionStub) : void 0
3953
- };
3954
- }
3955
- function classifyNode(nodeId, node) {
3956
- if (nodeId.startsWith("L1/")) {
3957
- return "L1";
3958
- }
3959
- if (nodeId.startsWith("L2/")) {
3960
- return "L2";
3961
- }
3962
- return node.layer === "L0" ? null : node.layer ?? null;
3963
- }
3964
- function partitionRulesByLevel(loadedRules, dedupeByPath) {
3965
- const l1 = [];
3966
- const l2 = [];
3967
- for (const rule of loadedRules) {
3968
- if (rule.level === "L1") {
3969
- l1.push(rule.entry);
3970
- continue;
3971
- }
3972
- if (rule.level === "L2") {
3973
- l2.push(rule.entry);
3974
- }
3975
- }
3976
- return {
3977
- L1: dedupeByPath ? dedupeEntriesByPath(l1) : l1,
3978
- L2: dedupeByPath ? dedupeEntriesByPath(l2) : l2
3979
- };
3980
- }
3981
- function dedupeEntriesByPath(entries) {
3982
- const seenPaths = /* @__PURE__ */ new Set();
3983
- return entries.filter((entry) => {
3984
- if (seenPaths.has(entry.path)) {
3985
- return false;
3986
- }
3987
- seenPaths.add(entry.path);
3988
- return true;
3989
- });
3990
- }
3991
- function shouldLoadNodeForPath(requestedPath, node) {
3992
- switch (node.activation?.tier) {
3993
- case "always":
3994
- return true;
3995
- case "description":
3996
- return true;
3997
- case "path":
3998
- case void 0:
3999
- return minimatch2(requestedPath, normalizeKnowledgePath(node.scope_glob), { dot: true });
4000
- }
4001
- }
4002
- function dedupeDescriptionStubsByPath(stubs) {
4003
- const seenPaths = /* @__PURE__ */ new Set();
4004
- return stubs.filter((stub) => {
4005
- if (seenPaths.has(stub.path)) {
4006
- return false;
4007
- }
4008
- seenPaths.add(stub.path);
4009
- return true;
4010
- });
4011
- }
4012
- function toDescriptionStub(stub) {
4013
- return {
4014
- path: stub.path,
4015
- description: stub.description
4016
- };
4017
- }
4018
- async function readRuleContent(projectRoot, file, fileContentCache) {
4019
- const cached = fileContentCache.get(file);
4020
- if (cached !== void 0) {
4021
- return await cached;
4022
- }
4023
- const pending = readFile6(join6(projectRoot, file), "utf8");
4024
- fileContentCache.set(file, pending);
4025
- return await pending;
4026
- }
4027
-
4028
- export {
4029
- contextCache,
4030
- resolveProjectRoot,
4031
- readAgentsMeta,
4032
- LEDGER_PATH,
4033
- LEGACY_LEDGER_PATH,
4034
- EVENT_LEDGER_PATH,
4035
- getLedgerPath,
4036
- getLegacyLedgerPath,
4037
- getEventLedgerPath,
4038
- ensureParentDirectory,
4039
- sha256,
4040
- isNodeError,
4041
- atomicWriteText,
4042
- appendEventLedgerEvent,
4043
- readEventLedger,
4044
- flushAndSyncEventLedger,
4045
- buildKnowledgeMeta,
4046
- writeKnowledgeMeta,
4047
- computeKnowledgeBasedAgentsMeta,
4048
- computeKnowledgeTestIndex,
4049
- deriveKnowledgeMetaLayer,
4050
- deriveKnowledgeMetaTopologyType,
4051
- isSameKnowledgeTestIndex,
4052
- stableStringify,
4053
- invalidateKnowledgeSyncCooldown,
4054
- ensureKnowledgeFresh,
4055
- reconcileKnowledge,
4056
- getKnowledge,
4057
- normalizeKnowledgePath,
4058
- runDoctorReport,
4059
- runDoctorFix,
4060
- runDoctorApplyLint
4061
- };