@fenglimg/fabric-server 2.0.0-rc.22 → 2.0.0-rc.25

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.
@@ -160,8 +160,8 @@ function getLegacyLedgerPath(projectRoot) {
160
160
  function getEventLedgerPath(projectRoot) {
161
161
  return join2(projectRoot, EVENT_LEDGER_PATH);
162
162
  }
163
- async function ensureParentDirectory(path) {
164
- await mkdir(dirname(path), { recursive: true });
163
+ async function ensureParentDirectory(path2) {
164
+ await mkdir(dirname(path2), { recursive: true });
165
165
  }
166
166
  function sha256(content) {
167
167
  return `sha256:${createHash("sha256").update(content).digest("hex")}`;
@@ -241,24 +241,24 @@ async function readEventLedger(projectRoot, options = {}) {
241
241
  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);
242
242
  return { events, warnings };
243
243
  }
244
- async function truncateLedgerToLastNewline(path) {
245
- const raw = await readFile2(path);
244
+ async function truncateLedgerToLastNewline(path2) {
245
+ const raw = await readFile2(path2);
246
246
  const content = raw.toString("utf8");
247
247
  if (content.endsWith("\n") || content.length === 0) {
248
248
  return { truncated_bytes: 0, corrupted_path: "" };
249
249
  }
250
250
  const lastNewlineIndex = content.lastIndexOf("\n");
251
251
  if (lastNewlineIndex === -1) {
252
- const corruptedPath2 = `${path}.corrupted.${Date.now()}`;
252
+ const corruptedPath2 = `${path2}.corrupted.${Date.now()}`;
253
253
  await writeFile(corruptedPath2, raw);
254
- await truncate(path, 0);
254
+ await truncate(path2, 0);
255
255
  return { truncated_bytes: raw.length, corrupted_path: corruptedPath2 };
256
256
  }
257
257
  const keepByteLength = Buffer.byteLength(content.slice(0, lastNewlineIndex + 1), "utf8");
258
258
  const corruptedBytes = raw.slice(keepByteLength);
259
- const corruptedPath = `${path}.corrupted.${Date.now()}`;
259
+ const corruptedPath = `${path2}.corrupted.${Date.now()}`;
260
260
  await writeFile(corruptedPath, corruptedBytes);
261
- await truncate(path, keepByteLength);
261
+ await truncate(path2, keepByteLength);
262
262
  return { truncated_bytes: corruptedBytes.length, corrupted_path: corruptedPath };
263
263
  }
264
264
  function parseEventLedgerLine(line, index) {
@@ -428,6 +428,27 @@ import {
428
428
  parseKnowledgeId
429
429
  } from "@fenglimg/fabric-shared";
430
430
  import { atomicWriteText as atomicWriteText3 } from "@fenglimg/fabric-shared/node/atomic-write";
431
+ async function loadKbIdTypeMap(projectRootInput) {
432
+ const projectRoot = normalizeProjectRoot(projectRootInput);
433
+ const metaPath = join4(projectRoot, ".fabric", "agents.meta.json");
434
+ const meta = await readExistingMeta(metaPath);
435
+ const map = /* @__PURE__ */ new Map();
436
+ if (meta === void 0) {
437
+ return map;
438
+ }
439
+ for (const node of Object.values(meta.nodes)) {
440
+ const stableId = node.stable_id;
441
+ if (stableId === void 0 || !isKnowledgeStableId(stableId)) {
442
+ continue;
443
+ }
444
+ const knowledgeType = node.description?.knowledge_type;
445
+ if (knowledgeType === void 0) {
446
+ continue;
447
+ }
448
+ map.set(stableId, knowledgeType);
449
+ }
450
+ return map;
451
+ }
431
452
  async function buildKnowledgeMeta(projectRootInput) {
432
453
  const projectRoot = normalizeProjectRoot(projectRootInput);
433
454
  assertExistingDirectory(projectRoot);
@@ -870,8 +891,8 @@ function flattenKeys(value, keys = {}) {
870
891
  }
871
892
  return keys;
872
893
  }
873
- function toPosixPath(path) {
874
- return path.split(sep2).join("/");
894
+ function toPosixPath(path2) {
895
+ return path2.split(sep2).join("/");
875
896
  }
876
897
  function deriveRuleIdentity(file, source, existing) {
877
898
  const declaredKnowledgeId = extractDeclaredKnowledgeId(source);
@@ -959,7 +980,7 @@ function extractRuleDescription(source) {
959
980
  };
960
981
  }
961
982
  function extractRuleSections(source) {
962
- 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);
983
+ const sections = Array.from(source.matchAll(/^#{2,6}\s+(.+?)\s*$/gmu)).map((match) => match[1].trim()).filter((section, index, all) => section.length > 0 && all.indexOf(section) === index);
963
984
  return sections.length > 0 ? sections : void 0;
964
985
  }
965
986
  function extractDescriptionFromFrontmatter(frontmatter) {
@@ -1147,11 +1168,11 @@ async function readMetaEntries(projectRoot) {
1147
1168
  return map;
1148
1169
  }
1149
1170
  for (const node of Object.values(parsed.nodes ?? {})) {
1150
- const path = node.content_ref ?? node.file;
1171
+ const path2 = node.content_ref ?? node.file;
1151
1172
  const stable_id = node.stable_id;
1152
1173
  const content_hash = node.hash;
1153
- if (path !== void 0 && stable_id !== void 0 && content_hash !== void 0) {
1154
- map.set(path, { stable_id, path, content_hash });
1174
+ if (path2 !== void 0 && stable_id !== void 0 && content_hash !== void 0) {
1175
+ map.set(path2, { stable_id, path: path2, content_hash });
1155
1176
  }
1156
1177
  }
1157
1178
  return map;
@@ -1407,7 +1428,8 @@ async function reconcileKnowledge(projectRoot, opts) {
1407
1428
  throw error;
1408
1429
  }
1409
1430
  }
1410
- if (events.length > 0 || revisionDrift) {
1431
+ const forceWriteForDescriptionHeal = trigger === "auto-heal-description";
1432
+ if (events.length > 0 || revisionDrift || forceWriteForDescriptionHeal) {
1411
1433
  await writeKnowledgeMeta(projectRoot, { source: "sync_meta" });
1412
1434
  if (events.length > 0) {
1413
1435
  await appendRuleSyncEvents(projectRoot, events);
@@ -1417,7 +1439,7 @@ async function reconcileKnowledge(projectRoot, opts) {
1417
1439
  }
1418
1440
  const duration_ms = Date.now() - startTime;
1419
1441
  const reconciledFiles = events.map((e) => e.path);
1420
- if (trigger !== void 0 && (events.length > 0 || revisionDrift)) {
1442
+ if (trigger !== void 0 && (events.length > 0 || revisionDrift || forceWriteForDescriptionHeal)) {
1421
1443
  if (trigger === "startup") {
1422
1444
  await appendEventLedgerEvent(projectRoot, {
1423
1445
  event_type: "meta_reconciled_on_startup",
@@ -1448,10 +1470,103 @@ async function reconcileKnowledge(projectRoot, opts) {
1448
1470
  };
1449
1471
  }
1450
1472
 
1473
+ // src/services/serve-lock.ts
1474
+ import fs from "fs";
1475
+ import path from "path";
1476
+ import { createTranslator, detectNodeLocale } from "@fenglimg/fabric-shared";
1477
+ import { IOFabricError as IOFabricError2 } from "@fenglimg/fabric-shared/errors";
1478
+ var LOCK_FILENAME = ".serve.lock";
1479
+ var t = createTranslator(detectNodeLocale());
1480
+ var ServeLockHeldError = class extends IOFabricError2 {
1481
+ code = "SERVE_LOCK_HELD";
1482
+ httpStatus = 423;
1483
+ };
1484
+ function lockPath(projectRoot) {
1485
+ return path.join(projectRoot, ".fabric", LOCK_FILENAME);
1486
+ }
1487
+ function isAlive(pid) {
1488
+ try {
1489
+ process.kill(pid, 0);
1490
+ return true;
1491
+ } catch (e) {
1492
+ const err = e;
1493
+ if (err.code === "ESRCH") return false;
1494
+ if (err.code === "EPERM") return true;
1495
+ throw e;
1496
+ }
1497
+ }
1498
+ function acquireLock(projectRoot, opts) {
1499
+ const p = lockPath(projectRoot);
1500
+ if (fs.existsSync(p)) {
1501
+ let state = null;
1502
+ try {
1503
+ state = JSON.parse(fs.readFileSync(p, "utf8"));
1504
+ } catch {
1505
+ }
1506
+ if (state && state.pid && state.pid !== process.pid && isAlive(state.pid) && !opts?.force) {
1507
+ throw new ServeLockHeldError(
1508
+ `serve lock held by live PID ${state.pid}`,
1509
+ {
1510
+ actionHint: t("cli.serve.lock-held.action-hint", { pid: String(state.pid) }),
1511
+ details: state
1512
+ }
1513
+ );
1514
+ }
1515
+ if (state && state.pid && !isAlive(state.pid)) {
1516
+ process.stderr.write(`[serve-lock] stale lock from PID ${state.pid} \u2014 overwriting
1517
+ `);
1518
+ }
1519
+ }
1520
+ fs.mkdirSync(path.dirname(p), { recursive: true });
1521
+ fs.writeFileSync(
1522
+ p,
1523
+ JSON.stringify({ pid: process.pid, acquiredAt: Date.now(), host: process.env.HOSTNAME })
1524
+ );
1525
+ }
1526
+ function releaseLock(projectRoot) {
1527
+ const p = lockPath(projectRoot);
1528
+ try {
1529
+ if (fs.existsSync(p)) {
1530
+ const state = JSON.parse(fs.readFileSync(p, "utf8"));
1531
+ if (state.pid === process.pid) {
1532
+ fs.unlinkSync(p);
1533
+ }
1534
+ }
1535
+ } catch {
1536
+ }
1537
+ }
1538
+ function readLockState(projectRoot) {
1539
+ const p = lockPath(projectRoot);
1540
+ if (!fs.existsSync(p)) return null;
1541
+ try {
1542
+ return JSON.parse(fs.readFileSync(p, "utf8"));
1543
+ } catch {
1544
+ return null;
1545
+ }
1546
+ }
1547
+ function checkLockOrThrow(projectRoot, opts) {
1548
+ const state = readLockState(projectRoot);
1549
+ if (state === null) return;
1550
+ if (state.pid === process.pid) return;
1551
+ if (!isAlive(state.pid)) {
1552
+ process.stderr.write(`[serve-lock] stale lock from PID ${state.pid} \u2014 ignoring
1553
+ `);
1554
+ return;
1555
+ }
1556
+ if (opts?.force) return;
1557
+ throw new ServeLockHeldError(
1558
+ `serve lock held by live PID ${state.pid}`,
1559
+ {
1560
+ actionHint: t("cli.serve.lock-held.action-hint", { pid: String(state.pid) }),
1561
+ details: state
1562
+ }
1563
+ );
1564
+ }
1565
+
1451
1566
  // src/services/doctor.ts
1452
1567
  import { execFileSync } from "child_process";
1453
1568
  import { existsSync as existsSync4, readdirSync, readFileSync as readFileSync2, statSync as statSync4 } from "fs";
1454
- import { access, mkdir as mkdir4, readFile as readFile5, rename, writeFile as writeFile2 } from "fs/promises";
1569
+ import { access, mkdir as mkdir4, readFile as readFile5, rename, unlink, writeFile as writeFile2 } from "fs/promises";
1455
1570
  import { constants } from "fs";
1456
1571
  import { homedir as homedir3 } from "os";
1457
1572
  import { isAbsolute as isAbsolute2, join as join6, posix, relative as nodeRelative, resolve as resolve3, sep as sep3 } from "path";
@@ -1466,7 +1581,9 @@ import {
1466
1581
  BOOTSTRAP_CANONICAL,
1467
1582
  BOOTSTRAP_MARKER_BEGIN,
1468
1583
  BOOTSTRAP_MARKER_END,
1469
- BOOTSTRAP_REGEX
1584
+ BOOTSTRAP_REGEX,
1585
+ ONBOARD_SLOT_NAMES,
1586
+ ONBOARD_SLOT_TOTAL
1470
1587
  } from "@fenglimg/fabric-shared";
1471
1588
  import { detectFramework } from "@fenglimg/fabric-shared/node";
1472
1589
  import { atomicWriteJson as atomicWriteJson2, atomicWriteText as atomicWriteText4 } from "@fenglimg/fabric-shared/node/atomic-write";
@@ -1600,8 +1717,10 @@ async function runDoctorReport(target) {
1600
1717
  const relevancePathsDrift = inspectRelevancePathsDrift(projectRoot);
1601
1718
  const narrowTooFew = inspectNarrowTooFew(projectRoot, lintNow);
1602
1719
  const sessionHintsStale = inspectSessionHintsStale(projectRoot, lintNow);
1720
+ const staleServeLock = inspectStaleServeLock(projectRoot, lintNow);
1603
1721
  const relevanceFieldsMissing = inspectRelevanceFieldsMissing(projectRoot);
1604
1722
  const skillMdYamlInvalid = inspectSkillMdYamlInvalid(projectRoot);
1723
+ const onboardCoverage = inspectOnboardCoverage(projectRoot);
1605
1724
  const checks = [
1606
1725
  createBootstrapAnchorCheck(bootstrapAnchor),
1607
1726
  // v2.0.0-rc.19 TASK-004: bootstrap marker migration check sits adjacent to
@@ -1616,7 +1735,8 @@ async function runDoctorReport(target) {
1616
1735
  createKnowledgeDirMissingCheck(knowledgeDirMissing),
1617
1736
  // v2.0.0-rc.22 TASK-006: baseline filename format. Sits adjacent to
1618
1737
  // knowledge_dir_missing — both are knowledge-layout invariants. manual_error
1619
- // kind; resolution delegates to `fab scan` (no --fix path).
1738
+ // kind; resolution is manual file deletion (rc.23 TASK-012 (F8a) removed
1739
+ // the baseline-emit pipeline, so no auto-fix exists).
1620
1740
  createBaselineFilenameFormatCheck(baselineFilenameFormat),
1621
1741
  createForensicCheck(forensic, framework.kind, entryPoints.length),
1622
1742
  // v2.0: removed `createInitContextCheck` — `.fabric/init-context.json`
@@ -1670,6 +1790,10 @@ async function runDoctorReport(target) {
1670
1790
  createNarrowTooFewCheck(narrowTooFew),
1671
1791
  // rc.6 TASK-021 (E3): session-hints cache hygiene (lint #27). Info kind.
1672
1792
  createSessionHintsStaleCheck(sessionHintsStale),
1793
+ // rc.23 TASK-010 (e): stale .fabric/.serve.lock advisory. Info kind —
1794
+ // does not bump report status. `--fix` unlinks the corpse and emits
1795
+ // `serve_lock_cleared`.
1796
+ createStaleServeLockCheck(staleServeLock),
1673
1797
  // v2.0.0-rc.9 TASK-003 (A3): relevance fields back-fill (lint #28).
1674
1798
  // Info kind — applies to pending entries only; canonical entries get
1675
1799
  // the fields written verbatim by fab_review.approve/modify.
@@ -1677,6 +1801,11 @@ async function runDoctorReport(target) {
1677
1801
  // rc.12 lint #29: skill_md_yaml_invalid. Warning kind — surfaces
1678
1802
  // SKILL.md frontmatter that Codex CLI silently drops at load.
1679
1803
  createSkillMdYamlInvalidCheck(skillMdYamlInvalid),
1804
+ // v2.0.0-rc.23 TASK-014 (F8c): Onboard coverage advisory. Info kind.
1805
+ // Surfaces uncovered S5 onboard slots and recommends /fabric-archive
1806
+ // first-run phase. Sits adjacent to Skill markdown YAML — both are
1807
+ // Skill-adjacent advisories. --fix never mutates onboard state.
1808
+ createOnboardCoverageCheck(onboardCoverage),
1680
1809
  createPreexistingRootFilesCheck(preexistingRootFiles)
1681
1810
  // v2.0 / rc.2: `createLegacyClientPathCheck` removed. The schema now
1682
1811
  // rejects retired clientPaths keys (windsurf/rooCode/geminiCLI) at Zod
@@ -1717,7 +1846,7 @@ async function runDoctorReport(target) {
1717
1846
  warningCount: warnings.length,
1718
1847
  infoCount: infos.length,
1719
1848
  targetFiles: Object.fromEntries(
1720
- TARGET_FILE_PATHS.map((path) => [path, existsSync4(join6(projectRoot, path))])
1849
+ TARGET_FILE_PATHS.map((path2) => [path2, existsSync4(join6(projectRoot, path2))])
1721
1850
  )
1722
1851
  }
1723
1852
  };
@@ -1731,11 +1860,11 @@ async function runDoctorFix(target) {
1731
1860
  )) {
1732
1861
  const migrated = await migrateBootstrapMarkers(projectRoot);
1733
1862
  fixed.push(findIssue(before.fixable_errors, "bootstrap_marker_migration_required"));
1734
- for (const path of migrated.paths) {
1863
+ for (const path2 of migrated.paths) {
1735
1864
  await appendEventLedgerEvent(projectRoot, {
1736
1865
  event_type: "bootstrap_marker_migrated",
1737
- path,
1738
- migrated_count: migrated.countPerPath[path] ?? 1,
1866
+ path: path2,
1867
+ migrated_count: migrated.countPerPath[path2] ?? 1,
1739
1868
  legacy_marker: "fabric:knowledge-base",
1740
1869
  new_marker: "fabric:bootstrap",
1741
1870
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
@@ -1814,6 +1943,31 @@ async function runDoctorFix(target) {
1814
1943
  await fixMcpConfigInWrongFile(projectRoot);
1815
1944
  fixed.push(findIssue(before.fixable_errors, "mcp_config_in_wrong_file"));
1816
1945
  }
1946
+ if (before.infos.some((issue) => issue.code === "stale_serve_lock")) {
1947
+ const lockInspection = inspectStaleServeLock(projectRoot, Date.now());
1948
+ if (lockInspection.present && !lockInspection.pidAlive) {
1949
+ const lockFilePath = join6(projectRoot, ".fabric", ".serve.lock");
1950
+ try {
1951
+ await unlink(lockFilePath);
1952
+ } catch (err) {
1953
+ const errno = err;
1954
+ if (errno.code !== "ENOENT") throw err;
1955
+ }
1956
+ await appendEventLedgerEvent(projectRoot, {
1957
+ event_type: "serve_lock_cleared",
1958
+ pid: lockInspection.pid,
1959
+ age_ms: lockInspection.ageMs,
1960
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1961
+ }).catch(() => {
1962
+ });
1963
+ fixed.push({
1964
+ code: "stale_serve_lock",
1965
+ name: "Serve lock",
1966
+ message: `Removed stale .fabric/.serve.lock (dead PID ${lockInspection.pid}).`,
1967
+ path: ".fabric/.serve.lock"
1968
+ });
1969
+ }
1970
+ }
1817
1971
  const report = await runDoctorReport(projectRoot);
1818
1972
  return {
1819
1973
  changed: fixed.length > 0,
@@ -2027,8 +2181,8 @@ async function applyStaleArchive(projectRoot, candidate, now) {
2027
2181
  if (renameError instanceof Error && "code" in renameError && renameError.code === "EXDEV") {
2028
2182
  const data = await readFile5(sourceAbs);
2029
2183
  await writeFile2(destAbs, data);
2030
- const { unlink } = await import("fs/promises");
2031
- await unlink(sourceAbs);
2184
+ const { unlink: unlink2 } = await import("fs/promises");
2185
+ await unlink2(sourceAbs);
2032
2186
  } else {
2033
2187
  throw renameError;
2034
2188
  }
@@ -2100,8 +2254,8 @@ async function applyPendingAutoArchive(projectRoot, candidate, now) {
2100
2254
  if (renameError instanceof Error && "code" in renameError && renameError.code === "EXDEV") {
2101
2255
  const data = await readFile5(candidate.pending_path_abs);
2102
2256
  await writeFile2(candidate.archived_to_abs, data);
2103
- const { unlink } = await import("fs/promises");
2104
- await unlink(candidate.pending_path_abs);
2257
+ const { unlink: unlink2 } = await import("fs/promises");
2258
+ await unlink2(candidate.pending_path_abs);
2105
2259
  } else {
2106
2260
  throw renameError;
2107
2261
  }
@@ -2158,8 +2312,8 @@ async function applySessionHintsStaleCleanup(projectRoot, candidate) {
2158
2312
  const detail = `deleted (${candidate.age_days}d old)`;
2159
2313
  const absPath = join6(projectRoot, candidate.path);
2160
2314
  try {
2161
- const { unlink } = await import("fs/promises");
2162
- await unlink(absPath);
2315
+ const { unlink: unlink2 } = await import("fs/promises");
2316
+ await unlink2(absPath);
2163
2317
  return {
2164
2318
  kind: "knowledge_session_hints_stale_cleanup",
2165
2319
  path: candidate.path,
@@ -2213,9 +2367,9 @@ function truncateErrorMessage(error) {
2213
2367
  return raw.length > 240 ? `${raw.slice(0, 237)}...` : raw;
2214
2368
  }
2215
2369
  async function inspectForensic(projectRoot) {
2216
- const path = join6(projectRoot, ".fabric", "forensic.json");
2370
+ const path2 = join6(projectRoot, ".fabric", "forensic.json");
2217
2371
  try {
2218
- const parsed = forensicReportSchema.parse(JSON.parse(await readFile5(path, "utf8")));
2372
+ const parsed = forensicReportSchema.parse(JSON.parse(await readFile5(path2, "utf8")));
2219
2373
  return { present: true, valid: true, report: parsed };
2220
2374
  } catch (error) {
2221
2375
  if (isMissingFileError(error)) {
@@ -2326,15 +2480,15 @@ function inspectContentRefs(projectRoot, meta) {
2326
2480
  return { missing, invalid };
2327
2481
  }
2328
2482
  async function inspectEventLedger(projectRoot) {
2329
- const path = getEventLedgerPath(projectRoot);
2330
- const exists = existsSync4(path);
2483
+ const path2 = getEventLedgerPath(projectRoot);
2484
+ const exists = existsSync4(path2);
2331
2485
  if (!exists) {
2332
- return { exists: false, writable: false, parseable: false, hasPartialWrite: false, partialWriteByteOffset: 0, partialWriteByteLength: 0, path };
2486
+ return { exists: false, writable: false, parseable: false, hasPartialWrite: false, partialWriteByteOffset: 0, partialWriteByteLength: 0, path: path2 };
2333
2487
  }
2334
2488
  try {
2335
- await access(path, constants.W_OK);
2489
+ await access(path2, constants.W_OK);
2336
2490
  const { warnings } = await readEventLedger(projectRoot);
2337
- const raw = await readFile5(path, "utf8");
2491
+ const raw = await readFile5(path2, "utf8");
2338
2492
  const invalidLine = raw.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).find((line) => !isValidJsonLine(line));
2339
2493
  const partialWarning = warnings.find((w) => w.kind === "partial_write_at_tail");
2340
2494
  return {
@@ -2344,7 +2498,7 @@ async function inspectEventLedger(projectRoot) {
2344
2498
  hasPartialWrite: partialWarning !== void 0,
2345
2499
  partialWriteByteOffset: partialWarning?.byte_offset ?? 0,
2346
2500
  partialWriteByteLength: partialWarning?.byte_length ?? 0,
2347
- path,
2501
+ path: path2,
2348
2502
  error: invalidLine === void 0 ? void 0 : "events.jsonl contains an invalid JSON line."
2349
2503
  };
2350
2504
  } catch (error) {
@@ -2355,16 +2509,16 @@ async function inspectEventLedger(projectRoot) {
2355
2509
  hasPartialWrite: false,
2356
2510
  partialWriteByteOffset: 0,
2357
2511
  partialWriteByteLength: 0,
2358
- path,
2512
+ path: path2,
2359
2513
  error: error instanceof Error ? error.message : String(error)
2360
2514
  };
2361
2515
  }
2362
2516
  }
2363
2517
  async function inspectKnowledgeTestIndex(projectRoot) {
2364
- const path = join6(projectRoot, ".fabric", ".cache", "knowledge-test.index.json");
2518
+ const path2 = join6(projectRoot, ".fabric", ".cache", "knowledge-test.index.json");
2365
2519
  const built = await tryBuildRuleMeta(projectRoot);
2366
2520
  try {
2367
- const index = knowledgeTestIndexSchema2.parse(JSON.parse(await readFile5(path, "utf8")));
2521
+ const index = knowledgeTestIndexSchema2.parse(JSON.parse(await readFile5(path2, "utf8")));
2368
2522
  return {
2369
2523
  present: true,
2370
2524
  valid: true,
@@ -2590,8 +2744,8 @@ function inspectKnowledgeDirMissing(projectRoot) {
2590
2744
  const knowledgeRoot = join6(projectRoot, ".fabric", "knowledge");
2591
2745
  const missingSubdirs = [];
2592
2746
  for (const sub of KNOWLEDGE_SUBDIRS3) {
2593
- const path = join6(knowledgeRoot, sub);
2594
- if (!existsSync4(path)) {
2747
+ const path2 = join6(knowledgeRoot, sub);
2748
+ if (!existsSync4(path2)) {
2595
2749
  missingSubdirs.push(`.fabric/knowledge/${sub}`);
2596
2750
  }
2597
2751
  }
@@ -2660,7 +2814,7 @@ function createBaselineFilenameFormatCheck(inspection) {
2660
2814
  "manual_error",
2661
2815
  "lint-baseline-filename-format",
2662
2816
  `${inspection.offenders.length} baseline knowledge file${inspection.offenders.length === 1 ? "" : "s"} use${inspection.offenders.length === 1 ? "s" : ""} the deprecated bare-slug filename format and must be migrated to \`\${id}--\${slug}.md\`. First: ${detail}.`,
2663
- "Run `fab scan` to auto-migrate baseline filenames to the canonical `${id}--${slug}.md` format."
2817
+ "Delete the legacy bare-slug baseline file(s) manually \u2014 the baseline pipeline was removed in rc.23 and is no longer an auto-fix path."
2664
2818
  );
2665
2819
  }
2666
2820
  function createKnowledgeDirMissingCheck(inspection) {
@@ -2889,7 +3043,9 @@ function collectMdFilesUnder(out, projectRoot, rootDir, relPrefix) {
2889
3043
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
2890
3044
  const abs = join6(dir, entry.name);
2891
3045
  if (entry.isDirectory()) {
2892
- stack.push(abs);
3046
+ if (entry.name !== "pending" && entry.name !== "archive") {
3047
+ stack.push(abs);
3048
+ }
2893
3049
  } else if (entry.isFile() && entry.name.endsWith(".md")) {
2894
3050
  const rel = posix.join(relPrefix, abs.slice(rootDir.length + 1).replace(/\\/gu, "/"));
2895
3051
  out.add(rel);
@@ -2991,7 +3147,7 @@ function inspectCounterDesync(meta) {
2991
3147
  ["guideline", "GLD"],
2992
3148
  ["pitfall", "PIT"],
2993
3149
  ["process", "PRO"]
2994
- ].find(([t]) => t === parsed.type)?.[1];
3150
+ ].find(([t2]) => t2 === parsed.type)?.[1];
2995
3151
  if (typeCode === void 0) {
2996
3152
  continue;
2997
3153
  }
@@ -3582,6 +3738,20 @@ function inspectSessionHintsStale(projectRoot, now) {
3582
3738
  candidates.sort((a, b) => a.path.localeCompare(b.path));
3583
3739
  return { candidates };
3584
3740
  }
3741
+ function inspectStaleServeLock(projectRoot, now) {
3742
+ const state = readLockState(projectRoot);
3743
+ if (state === null) {
3744
+ return { present: false };
3745
+ }
3746
+ const ageMs = Math.max(0, now - state.acquiredAt);
3747
+ return {
3748
+ present: true,
3749
+ pid: state.pid,
3750
+ acquiredAt: state.acquiredAt,
3751
+ ageMs,
3752
+ pidAlive: isAlive(state.pid)
3753
+ };
3754
+ }
3585
3755
  function inspectNarrowTooFew(projectRoot, now) {
3586
3756
  let total = 0;
3587
3757
  let narrowWithPaths = 0;
@@ -3740,6 +3910,28 @@ function createSessionHintsStaleCheck(inspection) {
3740
3910
  "Run `fab doctor --apply-lint` to delete stale session-hints cache files."
3741
3911
  );
3742
3912
  }
3913
+ function createStaleServeLockCheck(inspection) {
3914
+ if (!inspection.present) {
3915
+ return okCheck("Serve lock", "No .fabric/.serve.lock present.");
3916
+ }
3917
+ if (inspection.pidAlive) {
3918
+ return okCheck(
3919
+ "Serve lock",
3920
+ `.fabric/.serve.lock held by live PID ${inspection.pid}.`
3921
+ );
3922
+ }
3923
+ const days = Math.floor(inspection.ageMs / MS_PER_DAY);
3924
+ const hours = Math.floor(inspection.ageMs / (60 * 60 * 1e3));
3925
+ const acquiredAgo = days >= 1 ? `${days} day${days === 1 ? "" : "s"} ago` : `${hours} hour${hours === 1 ? "" : "s"} ago`;
3926
+ return issueCheck(
3927
+ "Serve lock",
3928
+ "ok",
3929
+ "info",
3930
+ "stale_serve_lock",
3931
+ `[advisory] .fabric/.serve.lock holds dead PID ${inspection.pid} (acquired ${acquiredAgo}). Run \`fab doctor --fix\` to remove.`,
3932
+ "Run `fab doctor --fix` to remove the stale .fabric/.serve.lock."
3933
+ );
3934
+ }
3743
3935
  function extractKnowledgeFrontmatterRelevanceScope(source) {
3744
3936
  const FM_PATTERN = /^(?:\uFEFF)?---\r?\n([\s\S]*?)\r?\n---/u;
3745
3937
  const fm = FM_PATTERN.exec(source);
@@ -4244,6 +4436,117 @@ function createSkillMdYamlInvalidCheck(inspection) {
4244
4436
  'Quote the value with double quotes (`description: "\u2026"`) or rewrite the inner `key: value` token to `key=value`.'
4245
4437
  );
4246
4438
  }
4439
+ var KNOWLEDGE_CANONICAL_TYPE_DIRS_FOR_ONBOARD = [
4440
+ "decisions",
4441
+ "pitfalls",
4442
+ "guidelines",
4443
+ "models",
4444
+ "processes"
4445
+ ];
4446
+ function inspectOnboardCoverage(projectRoot) {
4447
+ const filled = {};
4448
+ for (const slot of ONBOARD_SLOT_NAMES) {
4449
+ filled[slot] = [];
4450
+ }
4451
+ const knowledgeRoot = join6(projectRoot, ".fabric", "knowledge");
4452
+ if (existsSync4(knowledgeRoot)) {
4453
+ for (const typeDir of KNOWLEDGE_CANONICAL_TYPE_DIRS_FOR_ONBOARD) {
4454
+ const dir = join6(knowledgeRoot, typeDir);
4455
+ if (!existsSync4(dir)) continue;
4456
+ let entries;
4457
+ try {
4458
+ entries = readdirSync(dir, { withFileTypes: true });
4459
+ } catch {
4460
+ continue;
4461
+ }
4462
+ for (const entry of entries) {
4463
+ if (!entry.isFile()) continue;
4464
+ if (!entry.name.endsWith(".md")) continue;
4465
+ const filePath = join6(dir, entry.name);
4466
+ let content;
4467
+ try {
4468
+ content = readFileSync2(filePath, "utf8");
4469
+ } catch {
4470
+ continue;
4471
+ }
4472
+ const slot = readFrontmatterScalar(content, "onboard_slot");
4473
+ if (slot === void 0) continue;
4474
+ if (!ONBOARD_SLOT_NAMES.includes(slot)) continue;
4475
+ const stableId = readFrontmatterScalar(content, "id") ?? entry.name.replace(/\.md$/u, "");
4476
+ filled[slot].push(stableId);
4477
+ }
4478
+ }
4479
+ }
4480
+ for (const slot of ONBOARD_SLOT_NAMES) {
4481
+ filled[slot].sort();
4482
+ }
4483
+ const optedOut = readOnboardOptedOut(projectRoot);
4484
+ const missing = ONBOARD_SLOT_NAMES.filter((slot) => {
4485
+ if (filled[slot].length > 0) return false;
4486
+ if (optedOut.includes(slot)) return false;
4487
+ return true;
4488
+ });
4489
+ return { filled, missing, opted_out: optedOut };
4490
+ }
4491
+ function readOnboardOptedOut(projectRoot) {
4492
+ const path2 = join6(projectRoot, ".fabric", "fabric-config.json");
4493
+ if (!existsSync4(path2)) return [];
4494
+ let raw;
4495
+ try {
4496
+ raw = readFileSync2(path2, "utf8");
4497
+ } catch {
4498
+ return [];
4499
+ }
4500
+ let parsed;
4501
+ try {
4502
+ parsed = JSON.parse(raw);
4503
+ } catch {
4504
+ return [];
4505
+ }
4506
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
4507
+ return [];
4508
+ }
4509
+ const list = parsed.onboard_slots_opted_out;
4510
+ if (!Array.isArray(list)) return [];
4511
+ return list.filter((v) => typeof v === "string");
4512
+ }
4513
+ function readFrontmatterScalar(content, key) {
4514
+ const match = /^---\n([\s\S]*?)\n---/u.exec(content);
4515
+ if (match === null) return void 0;
4516
+ const block = match[1];
4517
+ if (block === void 0) return void 0;
4518
+ for (const rawLine of block.split(/\r?\n/u)) {
4519
+ const line = rawLine.trim();
4520
+ const sep4 = line.indexOf(":");
4521
+ if (sep4 === -1) continue;
4522
+ if (line.slice(0, sep4).trim() !== key) continue;
4523
+ let value = line.slice(sep4 + 1).trim();
4524
+ if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
4525
+ value = value.slice(1, -1);
4526
+ }
4527
+ return value;
4528
+ }
4529
+ return void 0;
4530
+ }
4531
+ function createOnboardCoverageCheck(inspection) {
4532
+ const filledCount = ONBOARD_SLOT_NAMES.filter(
4533
+ (slot) => inspection.filled[slot].length > 0
4534
+ ).length;
4535
+ if (inspection.missing.length === 0) {
4536
+ return okCheck(
4537
+ "Onboard coverage",
4538
+ `Onboard coverage: ${filledCount}/${ONBOARD_SLOT_TOTAL} \u2713 (opted-out: ${inspection.opted_out.length}).`
4539
+ );
4540
+ }
4541
+ return issueCheck(
4542
+ "Onboard coverage",
4543
+ "ok",
4544
+ "info",
4545
+ "onboard_coverage_incomplete",
4546
+ `Onboard slots not yet covered: [${inspection.missing.join(", ")}]. ${filledCount}/${ONBOARD_SLOT_TOTAL} filled; ${inspection.opted_out.length} opted-out.`,
4547
+ "Run /fabric-archive to onboard \u2014 the Skill's first-run phase will tour the project and propose pending entries for each unclaimed slot."
4548
+ );
4549
+ }
4247
4550
  function createNarrowTooFewCheck(inspection) {
4248
4551
  const { structural_flagged, telemetry_flagged } = inspection;
4249
4552
  if (!structural_flagged && !telemetry_flagged) {
@@ -4652,9 +4955,9 @@ async function fixCounterDesync(projectRoot) {
4652
4955
  await atomicWriteJson2(metaPath, updated, { indent: 2 });
4653
4956
  }
4654
4957
  async function ensureEventLedger(projectRoot) {
4655
- const path = getEventLedgerPath(projectRoot);
4656
- await ensureParentDirectory(path);
4657
- await writeFile2(path, "", { encoding: "utf8", flag: "a" });
4958
+ const path2 = getEventLedgerPath(projectRoot);
4959
+ await ensureParentDirectory(path2);
4960
+ await writeFile2(path2, "", { encoding: "utf8", flag: "a" });
4658
4961
  }
4659
4962
  var CITE_POLICY_VERSION = "2.0.0-rc.20";
4660
4963
  async function ensureCitePolicyActivatedMarker(projectRoot) {
@@ -4681,6 +4984,48 @@ async function ensureCitePolicyActivatedMarker(projectRoot) {
4681
4984
  return { marker_ts: 0, emitted_now: false };
4682
4985
  }
4683
4986
  }
4987
+ async function ensureCiteContractPolicyActivatedMarker(projectRoot) {
4988
+ let driftStatus;
4989
+ try {
4990
+ const inspection = await inspectL1BootstrapSnapshotDrift(projectRoot);
4991
+ driftStatus = inspection.status;
4992
+ } catch {
4993
+ driftStatus = "drift";
4994
+ }
4995
+ if (driftStatus !== "ok") {
4996
+ return { marker_ts: 0, emitted_now: false, blocked_by: "bootstrap_drift" };
4997
+ }
4998
+ let existing;
4999
+ try {
5000
+ const { events } = await readEventLedger(projectRoot, {
5001
+ event_type: "cite_contract_policy_activated"
5002
+ });
5003
+ if (events.length > 0) {
5004
+ existing = events[0];
5005
+ }
5006
+ } catch {
5007
+ return { marker_ts: 0, emitted_now: false, blocked_by: null };
5008
+ }
5009
+ if (existing !== void 0) {
5010
+ return { marker_ts: existing.ts, emitted_now: false, blocked_by: null };
5011
+ }
5012
+ try {
5013
+ const stored = await appendEventLedgerEvent(projectRoot, {
5014
+ event_type: "cite_contract_policy_activated"
5015
+ });
5016
+ return { marker_ts: stored.ts, emitted_now: true, blocked_by: null };
5017
+ } catch {
5018
+ return { marker_ts: 0, emitted_now: false, blocked_by: null };
5019
+ }
5020
+ }
5021
+ function parseNoneSentinel(kbLineRaw) {
5022
+ if (typeof kbLineRaw !== "string" || kbLineRaw.length === 0) return "unspecified";
5023
+ const m = kbLineRaw.match(/^KB:\s*none\b\s*(?:\[([^\]]*)\])?\s*$/i);
5024
+ if (m === null) return "unspecified";
5025
+ const inner = (m[1] ?? "").trim().toLowerCase();
5026
+ if (inner === "no-relevant" || inner === "not-applicable") return inner;
5027
+ return "unspecified";
5028
+ }
4684
5029
  function categorizeCiteTag(tag) {
4685
5030
  if (tag === "planned" || tag === "recalled" || tag === "chained-from" || tag === "none") {
4686
5031
  return { category: tag };
@@ -4710,7 +5055,10 @@ function matchesRelevancePath(editPath, relevancePaths) {
4710
5055
  return false;
4711
5056
  }
4712
5057
  async function runDoctorCiteCoverage(projectRoot, options) {
5058
+ const layerFilter = options.layer ?? "all";
4713
5059
  const marker = await ensureCitePolicyActivatedMarker(projectRoot);
5060
+ const contractMarker = await ensureCiteContractPolicyActivatedMarker(projectRoot);
5061
+ const idTypeMap = await loadKbIdTypeMap(projectRoot);
4714
5062
  const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
4715
5063
  const zeroMetrics = {
4716
5064
  edits_touched: 0,
@@ -4719,6 +5067,20 @@ async function runDoctorCiteCoverage(projectRoot, options) {
4719
5067
  expected_but_missed: 0,
4720
5068
  total_turns: 0
4721
5069
  };
5070
+ const contractStatus = contractMarker.blocked_by === "bootstrap_drift" ? "skipped:bootstrap_drift" : contractMarker.marker_ts === 0 ? "awaiting_marker" : "ok";
5071
+ const zeroContractMetrics = {
5072
+ decisions_cited: 0,
5073
+ pitfalls_cited: 0,
5074
+ contract_with: 0,
5075
+ contract_missing: 0,
5076
+ hard_violated: 0,
5077
+ cite_id_unresolved: 0,
5078
+ skip_count: {}
5079
+ };
5080
+ const zeroLayerType = {
5081
+ team: {},
5082
+ personal: {}
5083
+ };
4722
5084
  if (marker.marker_ts === 0) {
4723
5085
  return {
4724
5086
  status: "skipped",
@@ -4726,11 +5088,17 @@ async function runDoctorCiteCoverage(projectRoot, options) {
4726
5088
  marker_emitted_now: false,
4727
5089
  since_ts: options.since,
4728
5090
  client_filter: options.client,
5091
+ layer_filter: layerFilter,
4729
5092
  metrics: zeroMetrics,
5093
+ contract_metrics_status: contractStatus,
5094
+ contract_metrics: zeroContractMetrics,
5095
+ per_layer_type: zeroLayerType,
5096
+ contract_marker_ts: contractMarker.marker_ts,
4730
5097
  generated_at: generatedAt
4731
5098
  };
4732
5099
  }
4733
5100
  const effectiveSince = Math.max(marker.marker_ts, options.since);
5101
+ const contractEffectiveSince = contractStatus === "ok" ? Math.max(contractMarker.marker_ts, options.since) : Number.POSITIVE_INFINITY;
4734
5102
  let ledgerEvents = [];
4735
5103
  try {
4736
5104
  const result = await readEventLedger(projectRoot, { since: effectiveSince });
@@ -4742,7 +5110,12 @@ async function runDoctorCiteCoverage(projectRoot, options) {
4742
5110
  marker_emitted_now: marker.emitted_now,
4743
5111
  since_ts: effectiveSince,
4744
5112
  client_filter: options.client,
5113
+ layer_filter: layerFilter,
4745
5114
  metrics: zeroMetrics,
5115
+ contract_metrics_status: contractStatus,
5116
+ contract_metrics: zeroContractMetrics,
5117
+ per_layer_type: zeroLayerType,
5118
+ contract_marker_ts: contractMarker.marker_ts,
4746
5119
  generated_at: generatedAt
4747
5120
  };
4748
5121
  }
@@ -4764,7 +5137,7 @@ async function runDoctorCiteCoverage(projectRoot, options) {
4764
5137
  break;
4765
5138
  }
4766
5139
  }
4767
- const filteredTurns = options.client === "all" ? assistantTurns : assistantTurns.filter((t) => t.client === options.client);
5140
+ const filteredTurns = options.client === "all" ? assistantTurns : assistantTurns.filter((t2) => t2.client === options.client);
4768
5141
  let clientSessionIds = null;
4769
5142
  if (options.client !== "all") {
4770
5143
  clientSessionIds = /* @__PURE__ */ new Set();
@@ -4819,6 +5192,7 @@ async function runDoctorCiteCoverage(projectRoot, options) {
4819
5192
  return false;
4820
5193
  };
4821
5194
  const dismissedHistogram = {};
5195
+ const noneHistogram = {};
4822
5196
  const perClientAccum = /* @__PURE__ */ new Map();
4823
5197
  const emptyMetrics = () => ({
4824
5198
  edits_touched: 0,
@@ -4834,6 +5208,80 @@ async function runDoctorCiteCoverage(projectRoot, options) {
4834
5208
  perClientAccum.set(client, existing);
4835
5209
  };
4836
5210
  const sessionCitedKbs = /* @__PURE__ */ new Map();
5211
+ const sessionEditPaths = /* @__PURE__ */ new Map();
5212
+ for (const edit of editEvents) {
5213
+ const sid = edit.session_id;
5214
+ if (typeof sid !== "string" || sid.length === 0) continue;
5215
+ const list = sessionEditPaths.get(sid) ?? [];
5216
+ list.push(normalizePath(edit.path));
5217
+ sessionEditPaths.set(sid, list);
5218
+ }
5219
+ let decisionsCited = 0;
5220
+ let pitfallsCited = 0;
5221
+ let contractWith = 0;
5222
+ let contractMissing = 0;
5223
+ let hardViolated = 0;
5224
+ let citeIdUnresolved = 0;
5225
+ const skipCount = {};
5226
+ const layerTypeAccum = { team: {}, personal: {} };
5227
+ const bumpLayerType = (citeId, type) => {
5228
+ const layer = citeId.startsWith("KP-") ? "personal" : citeId.startsWith("KT-") ? "team" : null;
5229
+ if (layer === null) return;
5230
+ layerTypeAccum[layer][type] = (layerTypeAccum[layer][type] ?? 0) + 1;
5231
+ };
5232
+ const passesLayerFilter = (citeId) => {
5233
+ if (layerFilter === "all") return true;
5234
+ if (layerFilter === "team") return citeId.startsWith("KT-");
5235
+ return citeId.startsWith("KP-");
5236
+ };
5237
+ const evaluateOperatorViolation = (sessionId, operators) => {
5238
+ const editPaths = typeof sessionId === "string" && sessionId.length > 0 ? sessionEditPaths.get(sessionId) ?? [] : [];
5239
+ for (const op of operators) {
5240
+ switch (op.kind) {
5241
+ case "edit": {
5242
+ let matched = false;
5243
+ for (const p of editPaths) {
5244
+ if (minimatch(p, op.target, { dot: true, matchBase: false })) {
5245
+ matched = true;
5246
+ break;
5247
+ }
5248
+ }
5249
+ if (!matched) return true;
5250
+ break;
5251
+ }
5252
+ case "not_edit": {
5253
+ for (const p of editPaths) {
5254
+ if (minimatch(p, op.target, { dot: true, matchBase: false })) {
5255
+ return true;
5256
+ }
5257
+ }
5258
+ break;
5259
+ }
5260
+ case "require": {
5261
+ let found = false;
5262
+ for (const p of editPaths) {
5263
+ if (p.includes(op.target)) {
5264
+ found = true;
5265
+ break;
5266
+ }
5267
+ }
5268
+ if (!found) return true;
5269
+ break;
5270
+ }
5271
+ case "forbid": {
5272
+ for (const p of editPaths) {
5273
+ if (p.includes(op.target)) {
5274
+ return true;
5275
+ }
5276
+ }
5277
+ break;
5278
+ }
5279
+ default:
5280
+ break;
5281
+ }
5282
+ }
5283
+ return false;
5284
+ };
4837
5285
  let totalTurns = 0;
4838
5286
  let qualifyingCites = 0;
4839
5287
  let recalledUnverified = 0;
@@ -4868,7 +5316,11 @@ async function runDoctorCiteCoverage(projectRoot, options) {
4868
5316
  dismissedHistogram[key] = (dismissedHistogram[key] ?? 0) + 1;
4869
5317
  break;
4870
5318
  }
4871
- case "none":
5319
+ case "none": {
5320
+ const sentinel = parseNoneSentinel(turn.kb_line_raw);
5321
+ noneHistogram[sentinel] = (noneHistogram[sentinel] ?? 0) + 1;
5322
+ break;
5323
+ }
4872
5324
  default:
4873
5325
  break;
4874
5326
  }
@@ -4879,6 +5331,40 @@ async function runDoctorCiteCoverage(projectRoot, options) {
4879
5331
  m.recalled_unverified += 1;
4880
5332
  });
4881
5333
  }
5334
+ if (contractStatus === "ok" && turn.ts >= contractEffectiveSince) {
5335
+ const commitments = turn.cite_commitments ?? [];
5336
+ for (let i = 0; i < turn.cite_ids.length; i += 1) {
5337
+ const citeId = turn.cite_ids[i];
5338
+ if (typeof citeId !== "string" || citeId.length === 0) continue;
5339
+ if (!passesLayerFilter(citeId)) continue;
5340
+ const kbType = idTypeMap.get(citeId);
5341
+ if (kbType === void 0) {
5342
+ citeIdUnresolved += 1;
5343
+ bumpLayerType(citeId, "unresolved");
5344
+ continue;
5345
+ }
5346
+ bumpLayerType(citeId, kbType);
5347
+ if (kbType === "decision" || kbType === "pitfall") {
5348
+ if (kbType === "decision") decisionsCited += 1;
5349
+ else pitfallsCited += 1;
5350
+ const commitment = commitments[i];
5351
+ const operators = commitment?.operators ?? [];
5352
+ const skipReason = commitment?.skip_reason ?? null;
5353
+ if (skipReason !== null) {
5354
+ skipCount[skipReason] = (skipCount[skipReason] ?? 0) + 1;
5355
+ continue;
5356
+ }
5357
+ if (operators.length === 0) {
5358
+ contractMissing += 1;
5359
+ continue;
5360
+ }
5361
+ contractWith += 1;
5362
+ if (evaluateOperatorViolation(sid, operators)) {
5363
+ hardViolated += 1;
5364
+ }
5365
+ }
5366
+ }
5367
+ }
4882
5368
  }
4883
5369
  let editsTouched = 0;
4884
5370
  let expectedButMissed = 0;
@@ -4913,18 +5399,96 @@ async function runDoctorCiteCoverage(projectRoot, options) {
4913
5399
  perClient[client] = m;
4914
5400
  }
4915
5401
  }
5402
+ const contractMetrics = {
5403
+ decisions_cited: decisionsCited,
5404
+ pitfalls_cited: pitfallsCited,
5405
+ contract_with: contractWith,
5406
+ contract_missing: contractMissing,
5407
+ hard_violated: hardViolated,
5408
+ cite_id_unresolved: citeIdUnresolved,
5409
+ skip_count: skipCount
5410
+ };
4916
5411
  return {
4917
5412
  status: "ok",
4918
5413
  marker_ts: marker.marker_ts,
4919
5414
  marker_emitted_now: marker.emitted_now,
4920
5415
  since_ts: effectiveSince,
4921
5416
  client_filter: options.client,
5417
+ layer_filter: layerFilter,
4922
5418
  metrics,
4923
5419
  ...perClient !== void 0 ? { per_client: perClient } : {},
4924
5420
  ...Object.keys(dismissedHistogram).length > 0 ? { dismissed_reason_histogram: dismissedHistogram } : {},
5421
+ ...Object.keys(noneHistogram).length > 0 ? { none_reason_histogram: noneHistogram } : {},
5422
+ contract_metrics_status: contractStatus,
5423
+ contract_metrics: contractMetrics,
5424
+ per_layer_type: layerTypeAccum,
5425
+ contract_marker_ts: contractMarker.marker_ts,
4925
5426
  generated_at: generatedAt
4926
5427
  };
4927
5428
  }
5429
+ async function runDoctorArchiveHistory(projectRoot, options) {
5430
+ const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
5431
+ const nowMs = Date.now();
5432
+ let events = [];
5433
+ try {
5434
+ const result = await readEventLedger(projectRoot, {
5435
+ event_type: "session_archive_attempted",
5436
+ since: options.since
5437
+ });
5438
+ events = result.events;
5439
+ } catch {
5440
+ return {
5441
+ entries: [],
5442
+ total: 0,
5443
+ since_ms: options.since,
5444
+ generated_at: generatedAt
5445
+ };
5446
+ }
5447
+ const mostRecentBySession = /* @__PURE__ */ new Map();
5448
+ for (const event of events) {
5449
+ if (event.event_type !== "session_archive_attempted") {
5450
+ continue;
5451
+ }
5452
+ const sessionId = event.session_id;
5453
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
5454
+ continue;
5455
+ }
5456
+ const prior = mostRecentBySession.get(sessionId);
5457
+ if (prior === void 0 || event.ts > prior.ts) {
5458
+ mostRecentBySession.set(sessionId, event);
5459
+ }
5460
+ }
5461
+ const entries = [];
5462
+ for (const [sessionId, event] of mostRecentBySession.entries()) {
5463
+ const ageHours = Math.max(
5464
+ 0,
5465
+ Math.floor((nowMs - event.covered_through_ts) / 36e5)
5466
+ );
5467
+ entries.push({
5468
+ session_id_short: truncateSessionId(sessionId),
5469
+ last_attempted_at: new Date(event.ts).toISOString(),
5470
+ outcome: event.outcome,
5471
+ candidates_proposed: event.candidates_proposed,
5472
+ covered_through_ts: event.covered_through_ts,
5473
+ age_since_covered_hours: ageHours
5474
+ });
5475
+ }
5476
+ entries.sort(
5477
+ (a, b) => a.last_attempted_at < b.last_attempted_at ? 1 : a.last_attempted_at > b.last_attempted_at ? -1 : 0
5478
+ );
5479
+ return {
5480
+ entries,
5481
+ total: entries.length,
5482
+ since_ms: options.since,
5483
+ generated_at: generatedAt
5484
+ };
5485
+ }
5486
+ function truncateSessionId(sessionId) {
5487
+ if (sessionId.length <= 8) {
5488
+ return sessionId;
5489
+ }
5490
+ return `${sessionId.slice(0, 8)}...`;
5491
+ }
4928
5492
  function createFixMessage(fixed, report) {
4929
5493
  const fixedText = fixed.length === 0 ? "No deterministic doctor fixes were needed." : `Applied ${fixed.length} deterministic doctor fix${fixed.length === 1 ? "" : "es"}.`;
4930
5494
  const manualText = report.manual_errors.length === 0 ? "No manual errors remain." : `${report.manual_errors.length} manual error${report.manual_errors.length === 1 ? "" : "s"} remain.`;
@@ -4941,8 +5505,8 @@ function isValidJsonLine(line) {
4941
5505
  function normalizeTarget(targetInput) {
4942
5506
  return isAbsolute2(targetInput) ? targetInput : resolve3(process.cwd(), targetInput);
4943
5507
  }
4944
- function normalizePath(path) {
4945
- return posix.normalize(path.split("\\").join("/"));
5508
+ function normalizePath(path2) {
5509
+ return posix.normalize(path2.split("\\").join("/"));
4946
5510
  }
4947
5511
  function collectEntryPoints(root) {
4948
5512
  if (!existsSync4(root) || !statSync4(root).isDirectory()) {
@@ -5012,6 +5576,123 @@ function reduceStatus(statuses) {
5012
5576
  function isMissingFileError(error) {
5013
5577
  return error instanceof Error && "code" in error && error.code === "ENOENT";
5014
5578
  }
5579
+ var ENRICH_DESC_FIELDS = ["intent_clues", "tech_stack", "impact", "must_read_if"];
5580
+ var ENRICH_DESC_FIELD_PATTERNS = {
5581
+ intent_clues: /^intent_clues\s*:/mu,
5582
+ tech_stack: /^tech_stack\s*:/mu,
5583
+ impact: /^impact\s*:/mu,
5584
+ must_read_if: /^must_read_if\s*:/mu
5585
+ };
5586
+ async function enrichDescriptions(projectRoot, opts = {}) {
5587
+ const auto = opts.auto === true;
5588
+ const dryRun = opts.dryRun === true;
5589
+ const mode = auto ? "auto" : "interactive";
5590
+ const candidates = [];
5591
+ let scanned = 0;
5592
+ let modified = 0;
5593
+ let skipped = 0;
5594
+ for (const visit of iterateCanonicalFilenames(projectRoot)) {
5595
+ const layerRoot = visit.layer === "team" ? join6(projectRoot, ".fabric", "knowledge") : resolvePersonalKnowledgeRoot();
5596
+ const absPath = join6(layerRoot, visit.type, visit.filename);
5597
+ scanned += 1;
5598
+ let source;
5599
+ try {
5600
+ source = await readFile5(absPath, "utf8");
5601
+ } catch {
5602
+ continue;
5603
+ }
5604
+ const fmMatch = /^(?:\uFEFF)?---\r?\n([\s\S]*?)\r?\n---/u.exec(source);
5605
+ if (fmMatch === null) {
5606
+ candidates.push({
5607
+ path: visit.displayPath,
5608
+ missing: [...ENRICH_DESC_FIELDS],
5609
+ modified: false,
5610
+ added_fields: [],
5611
+ error: "frontmatter not parseable"
5612
+ });
5613
+ continue;
5614
+ }
5615
+ const block = fmMatch[1];
5616
+ const missing = ENRICH_DESC_FIELDS.filter(
5617
+ (field) => !ENRICH_DESC_FIELD_PATTERNS[field].test(block)
5618
+ );
5619
+ if (missing.length === 0) {
5620
+ skipped += 1;
5621
+ continue;
5622
+ }
5623
+ if (!auto || dryRun) {
5624
+ candidates.push({
5625
+ path: visit.displayPath,
5626
+ missing,
5627
+ modified: false,
5628
+ added_fields: []
5629
+ });
5630
+ continue;
5631
+ }
5632
+ const mustReadIf = synthesizeMustReadIfStub(source, visit.filename);
5633
+ const additions = [];
5634
+ for (const field of missing) {
5635
+ if (field === "must_read_if") {
5636
+ additions.push({ field, line: `must_read_if: ${yamlQuoteIfNeeded(mustReadIf)}` });
5637
+ } else {
5638
+ additions.push({ field, line: `${field}: []` });
5639
+ }
5640
+ }
5641
+ const trailing = block.endsWith("\n") ? "" : "\n";
5642
+ const replacedBlock = `${block}${trailing}${additions.map((a) => a.line).join("\n")}`;
5643
+ const blockStart = source.indexOf(block);
5644
+ if (blockStart < 0) {
5645
+ candidates.push({
5646
+ path: visit.displayPath,
5647
+ missing,
5648
+ modified: false,
5649
+ added_fields: [],
5650
+ error: "frontmatter block not located after match"
5651
+ });
5652
+ continue;
5653
+ }
5654
+ const rewritten = source.slice(0, blockStart) + replacedBlock + source.slice(blockStart + block.length);
5655
+ await atomicWriteText4(absPath, rewritten);
5656
+ modified += 1;
5657
+ candidates.push({
5658
+ path: visit.displayPath,
5659
+ missing,
5660
+ modified: true,
5661
+ added_fields: additions.map((a) => a.field)
5662
+ });
5663
+ await appendEventLedgerEvent(projectRoot, {
5664
+ event_type: "knowledge_enriched",
5665
+ path: visit.displayPath,
5666
+ added_fields: additions.map((a) => a.field),
5667
+ mode,
5668
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
5669
+ }).catch(() => {
5670
+ });
5671
+ }
5672
+ candidates.sort((a, b) => a.path.localeCompare(b.path));
5673
+ return { mode, dryRun, scanned, modified, skipped, candidates };
5674
+ }
5675
+ function synthesizeMustReadIfStub(source, filename) {
5676
+ const h1Match = /^#\s+(.+?)\s*$/mu.exec(source);
5677
+ let raw = h1Match !== null ? h1Match[1] : filename.replace(/^K[PT]-[A-Z]+-\d+--/, "").replace(/\.md$/u, "").replace(/-/g, " ");
5678
+ raw = raw.trim();
5679
+ if (raw.length === 0) {
5680
+ raw = "describes a knowledge invariant for this project";
5681
+ }
5682
+ if (raw.length > 120) {
5683
+ raw = `${raw.slice(0, 117)}...`;
5684
+ }
5685
+ return raw;
5686
+ }
5687
+ function yamlQuoteIfNeeded(value) {
5688
+ if (value.length === 0) {
5689
+ return '""';
5690
+ }
5691
+ if (/[:#"'\\[\]{},&*!|>%@`]/.test(value) || /^[\s-?]/.test(value) || /\s$/.test(value)) {
5692
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
5693
+ }
5694
+ return value;
5695
+ }
5015
5696
 
5016
5697
  // src/services/load-active-meta.ts
5017
5698
  async function loadActiveMeta(projectRoot, opts = {}) {
@@ -5168,16 +5849,16 @@ async function loadGetKnowledgeContext(projectRoot) {
5168
5849
  contextCache.set("context", projectRoot, context);
5169
5850
  return context;
5170
5851
  }
5171
- async function resolveKnowledgeForPath(projectRoot, context, path, options = {}) {
5172
- const matchedNodes = matchRuleNodes(context.meta, path);
5852
+ async function resolveKnowledgeForPath(projectRoot, context, path2, options = {}) {
5853
+ const matchedNodes = matchRuleNodes(context.meta, path2);
5173
5854
  const loaded = await loadMatchedRules(projectRoot, matchedNodes);
5174
5855
  return buildKnowledgePayload(context, loaded, options);
5175
5856
  }
5176
5857
  function normalizeKnowledgePath(value) {
5177
5858
  return value.replaceAll("\\", "/");
5178
5859
  }
5179
- function matchRuleNodes(meta, path) {
5180
- const requestedPath = normalizeKnowledgePath(path);
5860
+ function matchRuleNodes(meta, path2) {
5861
+ const requestedPath = normalizeKnowledgePath(path2);
5181
5862
  return Object.entries(meta.nodes).filter(([, node]) => shouldLoadNodeForPath(requestedPath, node)).sort((left, right) => {
5182
5863
  const [leftId, leftNode] = left;
5183
5864
  const [rightId, rightNode] = right;
@@ -5321,6 +6002,7 @@ export {
5321
6002
  appendEventLedgerEvent,
5322
6003
  readEventLedger,
5323
6004
  flushAndSyncEventLedger,
6005
+ loadKbIdTypeMap,
5324
6006
  buildKnowledgeMeta,
5325
6007
  writeKnowledgeMeta,
5326
6008
  computeKnowledgeBasedAgentsMeta,
@@ -5336,8 +6018,15 @@ export {
5336
6018
  loadActiveMetaOrStale,
5337
6019
  getKnowledge,
5338
6020
  normalizeKnowledgePath,
6021
+ ServeLockHeldError,
6022
+ acquireLock,
6023
+ releaseLock,
6024
+ readLockState,
6025
+ checkLockOrThrow,
5339
6026
  runDoctorReport,
5340
6027
  runDoctorFix,
5341
6028
  runDoctorApplyLint,
5342
- runDoctorCiteCoverage
6029
+ runDoctorCiteCoverage,
6030
+ runDoctorArchiveHistory,
6031
+ enrichDescriptions
5343
6032
  };