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

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) {
@@ -870,8 +870,8 @@ function flattenKeys(value, keys = {}) {
870
870
  }
871
871
  return keys;
872
872
  }
873
- function toPosixPath(path) {
874
- return path.split(sep2).join("/");
873
+ function toPosixPath(path2) {
874
+ return path2.split(sep2).join("/");
875
875
  }
876
876
  function deriveRuleIdentity(file, source, existing) {
877
877
  const declaredKnowledgeId = extractDeclaredKnowledgeId(source);
@@ -959,7 +959,7 @@ function extractRuleDescription(source) {
959
959
  };
960
960
  }
961
961
  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);
962
+ 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
963
  return sections.length > 0 ? sections : void 0;
964
964
  }
965
965
  function extractDescriptionFromFrontmatter(frontmatter) {
@@ -1147,11 +1147,11 @@ async function readMetaEntries(projectRoot) {
1147
1147
  return map;
1148
1148
  }
1149
1149
  for (const node of Object.values(parsed.nodes ?? {})) {
1150
- const path = node.content_ref ?? node.file;
1150
+ const path2 = node.content_ref ?? node.file;
1151
1151
  const stable_id = node.stable_id;
1152
1152
  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 });
1153
+ if (path2 !== void 0 && stable_id !== void 0 && content_hash !== void 0) {
1154
+ map.set(path2, { stable_id, path: path2, content_hash });
1155
1155
  }
1156
1156
  }
1157
1157
  return map;
@@ -1407,7 +1407,8 @@ async function reconcileKnowledge(projectRoot, opts) {
1407
1407
  throw error;
1408
1408
  }
1409
1409
  }
1410
- if (events.length > 0 || revisionDrift) {
1410
+ const forceWriteForDescriptionHeal = trigger === "auto-heal-description";
1411
+ if (events.length > 0 || revisionDrift || forceWriteForDescriptionHeal) {
1411
1412
  await writeKnowledgeMeta(projectRoot, { source: "sync_meta" });
1412
1413
  if (events.length > 0) {
1413
1414
  await appendRuleSyncEvents(projectRoot, events);
@@ -1417,7 +1418,7 @@ async function reconcileKnowledge(projectRoot, opts) {
1417
1418
  }
1418
1419
  const duration_ms = Date.now() - startTime;
1419
1420
  const reconciledFiles = events.map((e) => e.path);
1420
- if (trigger !== void 0 && (events.length > 0 || revisionDrift)) {
1421
+ if (trigger !== void 0 && (events.length > 0 || revisionDrift || forceWriteForDescriptionHeal)) {
1421
1422
  if (trigger === "startup") {
1422
1423
  await appendEventLedgerEvent(projectRoot, {
1423
1424
  event_type: "meta_reconciled_on_startup",
@@ -1448,10 +1449,103 @@ async function reconcileKnowledge(projectRoot, opts) {
1448
1449
  };
1449
1450
  }
1450
1451
 
1452
+ // src/services/serve-lock.ts
1453
+ import fs from "fs";
1454
+ import path from "path";
1455
+ import { createTranslator, detectNodeLocale } from "@fenglimg/fabric-shared";
1456
+ import { IOFabricError as IOFabricError2 } from "@fenglimg/fabric-shared/errors";
1457
+ var LOCK_FILENAME = ".serve.lock";
1458
+ var t = createTranslator(detectNodeLocale());
1459
+ var ServeLockHeldError = class extends IOFabricError2 {
1460
+ code = "SERVE_LOCK_HELD";
1461
+ httpStatus = 423;
1462
+ };
1463
+ function lockPath(projectRoot) {
1464
+ return path.join(projectRoot, ".fabric", LOCK_FILENAME);
1465
+ }
1466
+ function isAlive(pid) {
1467
+ try {
1468
+ process.kill(pid, 0);
1469
+ return true;
1470
+ } catch (e) {
1471
+ const err = e;
1472
+ if (err.code === "ESRCH") return false;
1473
+ if (err.code === "EPERM") return true;
1474
+ throw e;
1475
+ }
1476
+ }
1477
+ function acquireLock(projectRoot, opts) {
1478
+ const p = lockPath(projectRoot);
1479
+ if (fs.existsSync(p)) {
1480
+ let state = null;
1481
+ try {
1482
+ state = JSON.parse(fs.readFileSync(p, "utf8"));
1483
+ } catch {
1484
+ }
1485
+ if (state && state.pid && state.pid !== process.pid && isAlive(state.pid) && !opts?.force) {
1486
+ throw new ServeLockHeldError(
1487
+ `serve lock held by live PID ${state.pid}`,
1488
+ {
1489
+ actionHint: t("cli.serve.lock-held.action-hint", { pid: String(state.pid) }),
1490
+ details: state
1491
+ }
1492
+ );
1493
+ }
1494
+ if (state && state.pid && !isAlive(state.pid)) {
1495
+ process.stderr.write(`[serve-lock] stale lock from PID ${state.pid} \u2014 overwriting
1496
+ `);
1497
+ }
1498
+ }
1499
+ fs.mkdirSync(path.dirname(p), { recursive: true });
1500
+ fs.writeFileSync(
1501
+ p,
1502
+ JSON.stringify({ pid: process.pid, acquiredAt: Date.now(), host: process.env.HOSTNAME })
1503
+ );
1504
+ }
1505
+ function releaseLock(projectRoot) {
1506
+ const p = lockPath(projectRoot);
1507
+ try {
1508
+ if (fs.existsSync(p)) {
1509
+ const state = JSON.parse(fs.readFileSync(p, "utf8"));
1510
+ if (state.pid === process.pid) {
1511
+ fs.unlinkSync(p);
1512
+ }
1513
+ }
1514
+ } catch {
1515
+ }
1516
+ }
1517
+ function readLockState(projectRoot) {
1518
+ const p = lockPath(projectRoot);
1519
+ if (!fs.existsSync(p)) return null;
1520
+ try {
1521
+ return JSON.parse(fs.readFileSync(p, "utf8"));
1522
+ } catch {
1523
+ return null;
1524
+ }
1525
+ }
1526
+ function checkLockOrThrow(projectRoot, opts) {
1527
+ const state = readLockState(projectRoot);
1528
+ if (state === null) return;
1529
+ if (state.pid === process.pid) return;
1530
+ if (!isAlive(state.pid)) {
1531
+ process.stderr.write(`[serve-lock] stale lock from PID ${state.pid} \u2014 ignoring
1532
+ `);
1533
+ return;
1534
+ }
1535
+ if (opts?.force) return;
1536
+ throw new ServeLockHeldError(
1537
+ `serve lock held by live PID ${state.pid}`,
1538
+ {
1539
+ actionHint: t("cli.serve.lock-held.action-hint", { pid: String(state.pid) }),
1540
+ details: state
1541
+ }
1542
+ );
1543
+ }
1544
+
1451
1545
  // src/services/doctor.ts
1452
1546
  import { execFileSync } from "child_process";
1453
1547
  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";
1548
+ import { access, mkdir as mkdir4, readFile as readFile5, rename, unlink, writeFile as writeFile2 } from "fs/promises";
1455
1549
  import { constants } from "fs";
1456
1550
  import { homedir as homedir3 } from "os";
1457
1551
  import { isAbsolute as isAbsolute2, join as join6, posix, relative as nodeRelative, resolve as resolve3, sep as sep3 } from "path";
@@ -1466,7 +1560,9 @@ import {
1466
1560
  BOOTSTRAP_CANONICAL,
1467
1561
  BOOTSTRAP_MARKER_BEGIN,
1468
1562
  BOOTSTRAP_MARKER_END,
1469
- BOOTSTRAP_REGEX
1563
+ BOOTSTRAP_REGEX,
1564
+ ONBOARD_SLOT_NAMES,
1565
+ ONBOARD_SLOT_TOTAL
1470
1566
  } from "@fenglimg/fabric-shared";
1471
1567
  import { detectFramework } from "@fenglimg/fabric-shared/node";
1472
1568
  import { atomicWriteJson as atomicWriteJson2, atomicWriteText as atomicWriteText4 } from "@fenglimg/fabric-shared/node/atomic-write";
@@ -1600,8 +1696,10 @@ async function runDoctorReport(target) {
1600
1696
  const relevancePathsDrift = inspectRelevancePathsDrift(projectRoot);
1601
1697
  const narrowTooFew = inspectNarrowTooFew(projectRoot, lintNow);
1602
1698
  const sessionHintsStale = inspectSessionHintsStale(projectRoot, lintNow);
1699
+ const staleServeLock = inspectStaleServeLock(projectRoot, lintNow);
1603
1700
  const relevanceFieldsMissing = inspectRelevanceFieldsMissing(projectRoot);
1604
1701
  const skillMdYamlInvalid = inspectSkillMdYamlInvalid(projectRoot);
1702
+ const onboardCoverage = inspectOnboardCoverage(projectRoot);
1605
1703
  const checks = [
1606
1704
  createBootstrapAnchorCheck(bootstrapAnchor),
1607
1705
  // v2.0.0-rc.19 TASK-004: bootstrap marker migration check sits adjacent to
@@ -1616,7 +1714,8 @@ async function runDoctorReport(target) {
1616
1714
  createKnowledgeDirMissingCheck(knowledgeDirMissing),
1617
1715
  // v2.0.0-rc.22 TASK-006: baseline filename format. Sits adjacent to
1618
1716
  // knowledge_dir_missing — both are knowledge-layout invariants. manual_error
1619
- // kind; resolution delegates to `fab scan` (no --fix path).
1717
+ // kind; resolution is manual file deletion (rc.23 TASK-012 (F8a) removed
1718
+ // the baseline-emit pipeline, so no auto-fix exists).
1620
1719
  createBaselineFilenameFormatCheck(baselineFilenameFormat),
1621
1720
  createForensicCheck(forensic, framework.kind, entryPoints.length),
1622
1721
  // v2.0: removed `createInitContextCheck` — `.fabric/init-context.json`
@@ -1670,6 +1769,10 @@ async function runDoctorReport(target) {
1670
1769
  createNarrowTooFewCheck(narrowTooFew),
1671
1770
  // rc.6 TASK-021 (E3): session-hints cache hygiene (lint #27). Info kind.
1672
1771
  createSessionHintsStaleCheck(sessionHintsStale),
1772
+ // rc.23 TASK-010 (e): stale .fabric/.serve.lock advisory. Info kind —
1773
+ // does not bump report status. `--fix` unlinks the corpse and emits
1774
+ // `serve_lock_cleared`.
1775
+ createStaleServeLockCheck(staleServeLock),
1673
1776
  // v2.0.0-rc.9 TASK-003 (A3): relevance fields back-fill (lint #28).
1674
1777
  // Info kind — applies to pending entries only; canonical entries get
1675
1778
  // the fields written verbatim by fab_review.approve/modify.
@@ -1677,6 +1780,11 @@ async function runDoctorReport(target) {
1677
1780
  // rc.12 lint #29: skill_md_yaml_invalid. Warning kind — surfaces
1678
1781
  // SKILL.md frontmatter that Codex CLI silently drops at load.
1679
1782
  createSkillMdYamlInvalidCheck(skillMdYamlInvalid),
1783
+ // v2.0.0-rc.23 TASK-014 (F8c): Onboard coverage advisory. Info kind.
1784
+ // Surfaces uncovered S5 onboard slots and recommends /fabric-archive
1785
+ // first-run phase. Sits adjacent to Skill markdown YAML — both are
1786
+ // Skill-adjacent advisories. --fix never mutates onboard state.
1787
+ createOnboardCoverageCheck(onboardCoverage),
1680
1788
  createPreexistingRootFilesCheck(preexistingRootFiles)
1681
1789
  // v2.0 / rc.2: `createLegacyClientPathCheck` removed. The schema now
1682
1790
  // rejects retired clientPaths keys (windsurf/rooCode/geminiCLI) at Zod
@@ -1717,7 +1825,7 @@ async function runDoctorReport(target) {
1717
1825
  warningCount: warnings.length,
1718
1826
  infoCount: infos.length,
1719
1827
  targetFiles: Object.fromEntries(
1720
- TARGET_FILE_PATHS.map((path) => [path, existsSync4(join6(projectRoot, path))])
1828
+ TARGET_FILE_PATHS.map((path2) => [path2, existsSync4(join6(projectRoot, path2))])
1721
1829
  )
1722
1830
  }
1723
1831
  };
@@ -1731,11 +1839,11 @@ async function runDoctorFix(target) {
1731
1839
  )) {
1732
1840
  const migrated = await migrateBootstrapMarkers(projectRoot);
1733
1841
  fixed.push(findIssue(before.fixable_errors, "bootstrap_marker_migration_required"));
1734
- for (const path of migrated.paths) {
1842
+ for (const path2 of migrated.paths) {
1735
1843
  await appendEventLedgerEvent(projectRoot, {
1736
1844
  event_type: "bootstrap_marker_migrated",
1737
- path,
1738
- migrated_count: migrated.countPerPath[path] ?? 1,
1845
+ path: path2,
1846
+ migrated_count: migrated.countPerPath[path2] ?? 1,
1739
1847
  legacy_marker: "fabric:knowledge-base",
1740
1848
  new_marker: "fabric:bootstrap",
1741
1849
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
@@ -1814,6 +1922,31 @@ async function runDoctorFix(target) {
1814
1922
  await fixMcpConfigInWrongFile(projectRoot);
1815
1923
  fixed.push(findIssue(before.fixable_errors, "mcp_config_in_wrong_file"));
1816
1924
  }
1925
+ if (before.infos.some((issue) => issue.code === "stale_serve_lock")) {
1926
+ const lockInspection = inspectStaleServeLock(projectRoot, Date.now());
1927
+ if (lockInspection.present && !lockInspection.pidAlive) {
1928
+ const lockFilePath = join6(projectRoot, ".fabric", ".serve.lock");
1929
+ try {
1930
+ await unlink(lockFilePath);
1931
+ } catch (err) {
1932
+ const errno = err;
1933
+ if (errno.code !== "ENOENT") throw err;
1934
+ }
1935
+ await appendEventLedgerEvent(projectRoot, {
1936
+ event_type: "serve_lock_cleared",
1937
+ pid: lockInspection.pid,
1938
+ age_ms: lockInspection.ageMs,
1939
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1940
+ }).catch(() => {
1941
+ });
1942
+ fixed.push({
1943
+ code: "stale_serve_lock",
1944
+ name: "Serve lock",
1945
+ message: `Removed stale .fabric/.serve.lock (dead PID ${lockInspection.pid}).`,
1946
+ path: ".fabric/.serve.lock"
1947
+ });
1948
+ }
1949
+ }
1817
1950
  const report = await runDoctorReport(projectRoot);
1818
1951
  return {
1819
1952
  changed: fixed.length > 0,
@@ -2027,8 +2160,8 @@ async function applyStaleArchive(projectRoot, candidate, now) {
2027
2160
  if (renameError instanceof Error && "code" in renameError && renameError.code === "EXDEV") {
2028
2161
  const data = await readFile5(sourceAbs);
2029
2162
  await writeFile2(destAbs, data);
2030
- const { unlink } = await import("fs/promises");
2031
- await unlink(sourceAbs);
2163
+ const { unlink: unlink2 } = await import("fs/promises");
2164
+ await unlink2(sourceAbs);
2032
2165
  } else {
2033
2166
  throw renameError;
2034
2167
  }
@@ -2100,8 +2233,8 @@ async function applyPendingAutoArchive(projectRoot, candidate, now) {
2100
2233
  if (renameError instanceof Error && "code" in renameError && renameError.code === "EXDEV") {
2101
2234
  const data = await readFile5(candidate.pending_path_abs);
2102
2235
  await writeFile2(candidate.archived_to_abs, data);
2103
- const { unlink } = await import("fs/promises");
2104
- await unlink(candidate.pending_path_abs);
2236
+ const { unlink: unlink2 } = await import("fs/promises");
2237
+ await unlink2(candidate.pending_path_abs);
2105
2238
  } else {
2106
2239
  throw renameError;
2107
2240
  }
@@ -2158,8 +2291,8 @@ async function applySessionHintsStaleCleanup(projectRoot, candidate) {
2158
2291
  const detail = `deleted (${candidate.age_days}d old)`;
2159
2292
  const absPath = join6(projectRoot, candidate.path);
2160
2293
  try {
2161
- const { unlink } = await import("fs/promises");
2162
- await unlink(absPath);
2294
+ const { unlink: unlink2 } = await import("fs/promises");
2295
+ await unlink2(absPath);
2163
2296
  return {
2164
2297
  kind: "knowledge_session_hints_stale_cleanup",
2165
2298
  path: candidate.path,
@@ -2213,9 +2346,9 @@ function truncateErrorMessage(error) {
2213
2346
  return raw.length > 240 ? `${raw.slice(0, 237)}...` : raw;
2214
2347
  }
2215
2348
  async function inspectForensic(projectRoot) {
2216
- const path = join6(projectRoot, ".fabric", "forensic.json");
2349
+ const path2 = join6(projectRoot, ".fabric", "forensic.json");
2217
2350
  try {
2218
- const parsed = forensicReportSchema.parse(JSON.parse(await readFile5(path, "utf8")));
2351
+ const parsed = forensicReportSchema.parse(JSON.parse(await readFile5(path2, "utf8")));
2219
2352
  return { present: true, valid: true, report: parsed };
2220
2353
  } catch (error) {
2221
2354
  if (isMissingFileError(error)) {
@@ -2326,15 +2459,15 @@ function inspectContentRefs(projectRoot, meta) {
2326
2459
  return { missing, invalid };
2327
2460
  }
2328
2461
  async function inspectEventLedger(projectRoot) {
2329
- const path = getEventLedgerPath(projectRoot);
2330
- const exists = existsSync4(path);
2462
+ const path2 = getEventLedgerPath(projectRoot);
2463
+ const exists = existsSync4(path2);
2331
2464
  if (!exists) {
2332
- return { exists: false, writable: false, parseable: false, hasPartialWrite: false, partialWriteByteOffset: 0, partialWriteByteLength: 0, path };
2465
+ return { exists: false, writable: false, parseable: false, hasPartialWrite: false, partialWriteByteOffset: 0, partialWriteByteLength: 0, path: path2 };
2333
2466
  }
2334
2467
  try {
2335
- await access(path, constants.W_OK);
2468
+ await access(path2, constants.W_OK);
2336
2469
  const { warnings } = await readEventLedger(projectRoot);
2337
- const raw = await readFile5(path, "utf8");
2470
+ const raw = await readFile5(path2, "utf8");
2338
2471
  const invalidLine = raw.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).find((line) => !isValidJsonLine(line));
2339
2472
  const partialWarning = warnings.find((w) => w.kind === "partial_write_at_tail");
2340
2473
  return {
@@ -2344,7 +2477,7 @@ async function inspectEventLedger(projectRoot) {
2344
2477
  hasPartialWrite: partialWarning !== void 0,
2345
2478
  partialWriteByteOffset: partialWarning?.byte_offset ?? 0,
2346
2479
  partialWriteByteLength: partialWarning?.byte_length ?? 0,
2347
- path,
2480
+ path: path2,
2348
2481
  error: invalidLine === void 0 ? void 0 : "events.jsonl contains an invalid JSON line."
2349
2482
  };
2350
2483
  } catch (error) {
@@ -2355,16 +2488,16 @@ async function inspectEventLedger(projectRoot) {
2355
2488
  hasPartialWrite: false,
2356
2489
  partialWriteByteOffset: 0,
2357
2490
  partialWriteByteLength: 0,
2358
- path,
2491
+ path: path2,
2359
2492
  error: error instanceof Error ? error.message : String(error)
2360
2493
  };
2361
2494
  }
2362
2495
  }
2363
2496
  async function inspectKnowledgeTestIndex(projectRoot) {
2364
- const path = join6(projectRoot, ".fabric", ".cache", "knowledge-test.index.json");
2497
+ const path2 = join6(projectRoot, ".fabric", ".cache", "knowledge-test.index.json");
2365
2498
  const built = await tryBuildRuleMeta(projectRoot);
2366
2499
  try {
2367
- const index = knowledgeTestIndexSchema2.parse(JSON.parse(await readFile5(path, "utf8")));
2500
+ const index = knowledgeTestIndexSchema2.parse(JSON.parse(await readFile5(path2, "utf8")));
2368
2501
  return {
2369
2502
  present: true,
2370
2503
  valid: true,
@@ -2590,8 +2723,8 @@ function inspectKnowledgeDirMissing(projectRoot) {
2590
2723
  const knowledgeRoot = join6(projectRoot, ".fabric", "knowledge");
2591
2724
  const missingSubdirs = [];
2592
2725
  for (const sub of KNOWLEDGE_SUBDIRS3) {
2593
- const path = join6(knowledgeRoot, sub);
2594
- if (!existsSync4(path)) {
2726
+ const path2 = join6(knowledgeRoot, sub);
2727
+ if (!existsSync4(path2)) {
2595
2728
  missingSubdirs.push(`.fabric/knowledge/${sub}`);
2596
2729
  }
2597
2730
  }
@@ -2660,7 +2793,7 @@ function createBaselineFilenameFormatCheck(inspection) {
2660
2793
  "manual_error",
2661
2794
  "lint-baseline-filename-format",
2662
2795
  `${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."
2796
+ "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
2797
  );
2665
2798
  }
2666
2799
  function createKnowledgeDirMissingCheck(inspection) {
@@ -2889,7 +3022,9 @@ function collectMdFilesUnder(out, projectRoot, rootDir, relPrefix) {
2889
3022
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
2890
3023
  const abs = join6(dir, entry.name);
2891
3024
  if (entry.isDirectory()) {
2892
- stack.push(abs);
3025
+ if (entry.name !== "pending" && entry.name !== "archive") {
3026
+ stack.push(abs);
3027
+ }
2893
3028
  } else if (entry.isFile() && entry.name.endsWith(".md")) {
2894
3029
  const rel = posix.join(relPrefix, abs.slice(rootDir.length + 1).replace(/\\/gu, "/"));
2895
3030
  out.add(rel);
@@ -2991,7 +3126,7 @@ function inspectCounterDesync(meta) {
2991
3126
  ["guideline", "GLD"],
2992
3127
  ["pitfall", "PIT"],
2993
3128
  ["process", "PRO"]
2994
- ].find(([t]) => t === parsed.type)?.[1];
3129
+ ].find(([t2]) => t2 === parsed.type)?.[1];
2995
3130
  if (typeCode === void 0) {
2996
3131
  continue;
2997
3132
  }
@@ -3582,6 +3717,20 @@ function inspectSessionHintsStale(projectRoot, now) {
3582
3717
  candidates.sort((a, b) => a.path.localeCompare(b.path));
3583
3718
  return { candidates };
3584
3719
  }
3720
+ function inspectStaleServeLock(projectRoot, now) {
3721
+ const state = readLockState(projectRoot);
3722
+ if (state === null) {
3723
+ return { present: false };
3724
+ }
3725
+ const ageMs = Math.max(0, now - state.acquiredAt);
3726
+ return {
3727
+ present: true,
3728
+ pid: state.pid,
3729
+ acquiredAt: state.acquiredAt,
3730
+ ageMs,
3731
+ pidAlive: isAlive(state.pid)
3732
+ };
3733
+ }
3585
3734
  function inspectNarrowTooFew(projectRoot, now) {
3586
3735
  let total = 0;
3587
3736
  let narrowWithPaths = 0;
@@ -3740,6 +3889,28 @@ function createSessionHintsStaleCheck(inspection) {
3740
3889
  "Run `fab doctor --apply-lint` to delete stale session-hints cache files."
3741
3890
  );
3742
3891
  }
3892
+ function createStaleServeLockCheck(inspection) {
3893
+ if (!inspection.present) {
3894
+ return okCheck("Serve lock", "No .fabric/.serve.lock present.");
3895
+ }
3896
+ if (inspection.pidAlive) {
3897
+ return okCheck(
3898
+ "Serve lock",
3899
+ `.fabric/.serve.lock held by live PID ${inspection.pid}.`
3900
+ );
3901
+ }
3902
+ const days = Math.floor(inspection.ageMs / MS_PER_DAY);
3903
+ const hours = Math.floor(inspection.ageMs / (60 * 60 * 1e3));
3904
+ const acquiredAgo = days >= 1 ? `${days} day${days === 1 ? "" : "s"} ago` : `${hours} hour${hours === 1 ? "" : "s"} ago`;
3905
+ return issueCheck(
3906
+ "Serve lock",
3907
+ "ok",
3908
+ "info",
3909
+ "stale_serve_lock",
3910
+ `[advisory] .fabric/.serve.lock holds dead PID ${inspection.pid} (acquired ${acquiredAgo}). Run \`fab doctor --fix\` to remove.`,
3911
+ "Run `fab doctor --fix` to remove the stale .fabric/.serve.lock."
3912
+ );
3913
+ }
3743
3914
  function extractKnowledgeFrontmatterRelevanceScope(source) {
3744
3915
  const FM_PATTERN = /^(?:\uFEFF)?---\r?\n([\s\S]*?)\r?\n---/u;
3745
3916
  const fm = FM_PATTERN.exec(source);
@@ -4244,6 +4415,117 @@ function createSkillMdYamlInvalidCheck(inspection) {
4244
4415
  'Quote the value with double quotes (`description: "\u2026"`) or rewrite the inner `key: value` token to `key=value`.'
4245
4416
  );
4246
4417
  }
4418
+ var KNOWLEDGE_CANONICAL_TYPE_DIRS_FOR_ONBOARD = [
4419
+ "decisions",
4420
+ "pitfalls",
4421
+ "guidelines",
4422
+ "models",
4423
+ "processes"
4424
+ ];
4425
+ function inspectOnboardCoverage(projectRoot) {
4426
+ const filled = {};
4427
+ for (const slot of ONBOARD_SLOT_NAMES) {
4428
+ filled[slot] = [];
4429
+ }
4430
+ const knowledgeRoot = join6(projectRoot, ".fabric", "knowledge");
4431
+ if (existsSync4(knowledgeRoot)) {
4432
+ for (const typeDir of KNOWLEDGE_CANONICAL_TYPE_DIRS_FOR_ONBOARD) {
4433
+ const dir = join6(knowledgeRoot, typeDir);
4434
+ if (!existsSync4(dir)) continue;
4435
+ let entries;
4436
+ try {
4437
+ entries = readdirSync(dir, { withFileTypes: true });
4438
+ } catch {
4439
+ continue;
4440
+ }
4441
+ for (const entry of entries) {
4442
+ if (!entry.isFile()) continue;
4443
+ if (!entry.name.endsWith(".md")) continue;
4444
+ const filePath = join6(dir, entry.name);
4445
+ let content;
4446
+ try {
4447
+ content = readFileSync2(filePath, "utf8");
4448
+ } catch {
4449
+ continue;
4450
+ }
4451
+ const slot = readFrontmatterScalar(content, "onboard_slot");
4452
+ if (slot === void 0) continue;
4453
+ if (!ONBOARD_SLOT_NAMES.includes(slot)) continue;
4454
+ const stableId = readFrontmatterScalar(content, "id") ?? entry.name.replace(/\.md$/u, "");
4455
+ filled[slot].push(stableId);
4456
+ }
4457
+ }
4458
+ }
4459
+ for (const slot of ONBOARD_SLOT_NAMES) {
4460
+ filled[slot].sort();
4461
+ }
4462
+ const optedOut = readOnboardOptedOut(projectRoot);
4463
+ const missing = ONBOARD_SLOT_NAMES.filter((slot) => {
4464
+ if (filled[slot].length > 0) return false;
4465
+ if (optedOut.includes(slot)) return false;
4466
+ return true;
4467
+ });
4468
+ return { filled, missing, opted_out: optedOut };
4469
+ }
4470
+ function readOnboardOptedOut(projectRoot) {
4471
+ const path2 = join6(projectRoot, ".fabric", "fabric-config.json");
4472
+ if (!existsSync4(path2)) return [];
4473
+ let raw;
4474
+ try {
4475
+ raw = readFileSync2(path2, "utf8");
4476
+ } catch {
4477
+ return [];
4478
+ }
4479
+ let parsed;
4480
+ try {
4481
+ parsed = JSON.parse(raw);
4482
+ } catch {
4483
+ return [];
4484
+ }
4485
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
4486
+ return [];
4487
+ }
4488
+ const list = parsed.onboard_slots_opted_out;
4489
+ if (!Array.isArray(list)) return [];
4490
+ return list.filter((v) => typeof v === "string");
4491
+ }
4492
+ function readFrontmatterScalar(content, key) {
4493
+ const match = /^---\n([\s\S]*?)\n---/u.exec(content);
4494
+ if (match === null) return void 0;
4495
+ const block = match[1];
4496
+ if (block === void 0) return void 0;
4497
+ for (const rawLine of block.split(/\r?\n/u)) {
4498
+ const line = rawLine.trim();
4499
+ const sep4 = line.indexOf(":");
4500
+ if (sep4 === -1) continue;
4501
+ if (line.slice(0, sep4).trim() !== key) continue;
4502
+ let value = line.slice(sep4 + 1).trim();
4503
+ if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
4504
+ value = value.slice(1, -1);
4505
+ }
4506
+ return value;
4507
+ }
4508
+ return void 0;
4509
+ }
4510
+ function createOnboardCoverageCheck(inspection) {
4511
+ const filledCount = ONBOARD_SLOT_NAMES.filter(
4512
+ (slot) => inspection.filled[slot].length > 0
4513
+ ).length;
4514
+ if (inspection.missing.length === 0) {
4515
+ return okCheck(
4516
+ "Onboard coverage",
4517
+ `Onboard coverage: ${filledCount}/${ONBOARD_SLOT_TOTAL} \u2713 (opted-out: ${inspection.opted_out.length}).`
4518
+ );
4519
+ }
4520
+ return issueCheck(
4521
+ "Onboard coverage",
4522
+ "ok",
4523
+ "info",
4524
+ "onboard_coverage_incomplete",
4525
+ `Onboard slots not yet covered: [${inspection.missing.join(", ")}]. ${filledCount}/${ONBOARD_SLOT_TOTAL} filled; ${inspection.opted_out.length} opted-out.`,
4526
+ "Run /fabric-archive to onboard \u2014 the Skill's first-run phase will tour the project and propose pending entries for each unclaimed slot."
4527
+ );
4528
+ }
4247
4529
  function createNarrowTooFewCheck(inspection) {
4248
4530
  const { structural_flagged, telemetry_flagged } = inspection;
4249
4531
  if (!structural_flagged && !telemetry_flagged) {
@@ -4652,9 +4934,9 @@ async function fixCounterDesync(projectRoot) {
4652
4934
  await atomicWriteJson2(metaPath, updated, { indent: 2 });
4653
4935
  }
4654
4936
  async function ensureEventLedger(projectRoot) {
4655
- const path = getEventLedgerPath(projectRoot);
4656
- await ensureParentDirectory(path);
4657
- await writeFile2(path, "", { encoding: "utf8", flag: "a" });
4937
+ const path2 = getEventLedgerPath(projectRoot);
4938
+ await ensureParentDirectory(path2);
4939
+ await writeFile2(path2, "", { encoding: "utf8", flag: "a" });
4658
4940
  }
4659
4941
  var CITE_POLICY_VERSION = "2.0.0-rc.20";
4660
4942
  async function ensureCitePolicyActivatedMarker(projectRoot) {
@@ -4681,6 +4963,14 @@ async function ensureCitePolicyActivatedMarker(projectRoot) {
4681
4963
  return { marker_ts: 0, emitted_now: false };
4682
4964
  }
4683
4965
  }
4966
+ function parseNoneSentinel(kbLineRaw) {
4967
+ if (typeof kbLineRaw !== "string" || kbLineRaw.length === 0) return "unspecified";
4968
+ const m = kbLineRaw.match(/^KB:\s*none\b\s*(?:\[([^\]]*)\])?\s*$/i);
4969
+ if (m === null) return "unspecified";
4970
+ const inner = (m[1] ?? "").trim().toLowerCase();
4971
+ if (inner === "no-relevant" || inner === "not-applicable") return inner;
4972
+ return "unspecified";
4973
+ }
4684
4974
  function categorizeCiteTag(tag) {
4685
4975
  if (tag === "planned" || tag === "recalled" || tag === "chained-from" || tag === "none") {
4686
4976
  return { category: tag };
@@ -4764,7 +5054,7 @@ async function runDoctorCiteCoverage(projectRoot, options) {
4764
5054
  break;
4765
5055
  }
4766
5056
  }
4767
- const filteredTurns = options.client === "all" ? assistantTurns : assistantTurns.filter((t) => t.client === options.client);
5057
+ const filteredTurns = options.client === "all" ? assistantTurns : assistantTurns.filter((t2) => t2.client === options.client);
4768
5058
  let clientSessionIds = null;
4769
5059
  if (options.client !== "all") {
4770
5060
  clientSessionIds = /* @__PURE__ */ new Set();
@@ -4819,6 +5109,7 @@ async function runDoctorCiteCoverage(projectRoot, options) {
4819
5109
  return false;
4820
5110
  };
4821
5111
  const dismissedHistogram = {};
5112
+ const noneHistogram = {};
4822
5113
  const perClientAccum = /* @__PURE__ */ new Map();
4823
5114
  const emptyMetrics = () => ({
4824
5115
  edits_touched: 0,
@@ -4868,7 +5159,11 @@ async function runDoctorCiteCoverage(projectRoot, options) {
4868
5159
  dismissedHistogram[key] = (dismissedHistogram[key] ?? 0) + 1;
4869
5160
  break;
4870
5161
  }
4871
- case "none":
5162
+ case "none": {
5163
+ const sentinel = parseNoneSentinel(turn.kb_line_raw);
5164
+ noneHistogram[sentinel] = (noneHistogram[sentinel] ?? 0) + 1;
5165
+ break;
5166
+ }
4872
5167
  default:
4873
5168
  break;
4874
5169
  }
@@ -4922,6 +5217,7 @@ async function runDoctorCiteCoverage(projectRoot, options) {
4922
5217
  metrics,
4923
5218
  ...perClient !== void 0 ? { per_client: perClient } : {},
4924
5219
  ...Object.keys(dismissedHistogram).length > 0 ? { dismissed_reason_histogram: dismissedHistogram } : {},
5220
+ ...Object.keys(noneHistogram).length > 0 ? { none_reason_histogram: noneHistogram } : {},
4925
5221
  generated_at: generatedAt
4926
5222
  };
4927
5223
  }
@@ -4941,8 +5237,8 @@ function isValidJsonLine(line) {
4941
5237
  function normalizeTarget(targetInput) {
4942
5238
  return isAbsolute2(targetInput) ? targetInput : resolve3(process.cwd(), targetInput);
4943
5239
  }
4944
- function normalizePath(path) {
4945
- return posix.normalize(path.split("\\").join("/"));
5240
+ function normalizePath(path2) {
5241
+ return posix.normalize(path2.split("\\").join("/"));
4946
5242
  }
4947
5243
  function collectEntryPoints(root) {
4948
5244
  if (!existsSync4(root) || !statSync4(root).isDirectory()) {
@@ -5012,6 +5308,123 @@ function reduceStatus(statuses) {
5012
5308
  function isMissingFileError(error) {
5013
5309
  return error instanceof Error && "code" in error && error.code === "ENOENT";
5014
5310
  }
5311
+ var ENRICH_DESC_FIELDS = ["intent_clues", "tech_stack", "impact", "must_read_if"];
5312
+ var ENRICH_DESC_FIELD_PATTERNS = {
5313
+ intent_clues: /^intent_clues\s*:/mu,
5314
+ tech_stack: /^tech_stack\s*:/mu,
5315
+ impact: /^impact\s*:/mu,
5316
+ must_read_if: /^must_read_if\s*:/mu
5317
+ };
5318
+ async function enrichDescriptions(projectRoot, opts = {}) {
5319
+ const auto = opts.auto === true;
5320
+ const dryRun = opts.dryRun === true;
5321
+ const mode = auto ? "auto" : "interactive";
5322
+ const candidates = [];
5323
+ let scanned = 0;
5324
+ let modified = 0;
5325
+ let skipped = 0;
5326
+ for (const visit of iterateCanonicalFilenames(projectRoot)) {
5327
+ const layerRoot = visit.layer === "team" ? join6(projectRoot, ".fabric", "knowledge") : resolvePersonalKnowledgeRoot();
5328
+ const absPath = join6(layerRoot, visit.type, visit.filename);
5329
+ scanned += 1;
5330
+ let source;
5331
+ try {
5332
+ source = await readFile5(absPath, "utf8");
5333
+ } catch {
5334
+ continue;
5335
+ }
5336
+ const fmMatch = /^(?:\uFEFF)?---\r?\n([\s\S]*?)\r?\n---/u.exec(source);
5337
+ if (fmMatch === null) {
5338
+ candidates.push({
5339
+ path: visit.displayPath,
5340
+ missing: [...ENRICH_DESC_FIELDS],
5341
+ modified: false,
5342
+ added_fields: [],
5343
+ error: "frontmatter not parseable"
5344
+ });
5345
+ continue;
5346
+ }
5347
+ const block = fmMatch[1];
5348
+ const missing = ENRICH_DESC_FIELDS.filter(
5349
+ (field) => !ENRICH_DESC_FIELD_PATTERNS[field].test(block)
5350
+ );
5351
+ if (missing.length === 0) {
5352
+ skipped += 1;
5353
+ continue;
5354
+ }
5355
+ if (!auto || dryRun) {
5356
+ candidates.push({
5357
+ path: visit.displayPath,
5358
+ missing,
5359
+ modified: false,
5360
+ added_fields: []
5361
+ });
5362
+ continue;
5363
+ }
5364
+ const mustReadIf = synthesizeMustReadIfStub(source, visit.filename);
5365
+ const additions = [];
5366
+ for (const field of missing) {
5367
+ if (field === "must_read_if") {
5368
+ additions.push({ field, line: `must_read_if: ${yamlQuoteIfNeeded(mustReadIf)}` });
5369
+ } else {
5370
+ additions.push({ field, line: `${field}: []` });
5371
+ }
5372
+ }
5373
+ const trailing = block.endsWith("\n") ? "" : "\n";
5374
+ const replacedBlock = `${block}${trailing}${additions.map((a) => a.line).join("\n")}`;
5375
+ const blockStart = source.indexOf(block);
5376
+ if (blockStart < 0) {
5377
+ candidates.push({
5378
+ path: visit.displayPath,
5379
+ missing,
5380
+ modified: false,
5381
+ added_fields: [],
5382
+ error: "frontmatter block not located after match"
5383
+ });
5384
+ continue;
5385
+ }
5386
+ const rewritten = source.slice(0, blockStart) + replacedBlock + source.slice(blockStart + block.length);
5387
+ await atomicWriteText4(absPath, rewritten);
5388
+ modified += 1;
5389
+ candidates.push({
5390
+ path: visit.displayPath,
5391
+ missing,
5392
+ modified: true,
5393
+ added_fields: additions.map((a) => a.field)
5394
+ });
5395
+ await appendEventLedgerEvent(projectRoot, {
5396
+ event_type: "knowledge_enriched",
5397
+ path: visit.displayPath,
5398
+ added_fields: additions.map((a) => a.field),
5399
+ mode,
5400
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
5401
+ }).catch(() => {
5402
+ });
5403
+ }
5404
+ candidates.sort((a, b) => a.path.localeCompare(b.path));
5405
+ return { mode, dryRun, scanned, modified, skipped, candidates };
5406
+ }
5407
+ function synthesizeMustReadIfStub(source, filename) {
5408
+ const h1Match = /^#\s+(.+?)\s*$/mu.exec(source);
5409
+ let raw = h1Match !== null ? h1Match[1] : filename.replace(/^K[PT]-[A-Z]+-\d+--/, "").replace(/\.md$/u, "").replace(/-/g, " ");
5410
+ raw = raw.trim();
5411
+ if (raw.length === 0) {
5412
+ raw = "describes a knowledge invariant for this project";
5413
+ }
5414
+ if (raw.length > 120) {
5415
+ raw = `${raw.slice(0, 117)}...`;
5416
+ }
5417
+ return raw;
5418
+ }
5419
+ function yamlQuoteIfNeeded(value) {
5420
+ if (value.length === 0) {
5421
+ return '""';
5422
+ }
5423
+ if (/[:#"'\\[\]{},&*!|>%@`]/.test(value) || /^[\s-?]/.test(value) || /\s$/.test(value)) {
5424
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
5425
+ }
5426
+ return value;
5427
+ }
5015
5428
 
5016
5429
  // src/services/load-active-meta.ts
5017
5430
  async function loadActiveMeta(projectRoot, opts = {}) {
@@ -5168,16 +5581,16 @@ async function loadGetKnowledgeContext(projectRoot) {
5168
5581
  contextCache.set("context", projectRoot, context);
5169
5582
  return context;
5170
5583
  }
5171
- async function resolveKnowledgeForPath(projectRoot, context, path, options = {}) {
5172
- const matchedNodes = matchRuleNodes(context.meta, path);
5584
+ async function resolveKnowledgeForPath(projectRoot, context, path2, options = {}) {
5585
+ const matchedNodes = matchRuleNodes(context.meta, path2);
5173
5586
  const loaded = await loadMatchedRules(projectRoot, matchedNodes);
5174
5587
  return buildKnowledgePayload(context, loaded, options);
5175
5588
  }
5176
5589
  function normalizeKnowledgePath(value) {
5177
5590
  return value.replaceAll("\\", "/");
5178
5591
  }
5179
- function matchRuleNodes(meta, path) {
5180
- const requestedPath = normalizeKnowledgePath(path);
5592
+ function matchRuleNodes(meta, path2) {
5593
+ const requestedPath = normalizeKnowledgePath(path2);
5181
5594
  return Object.entries(meta.nodes).filter(([, node]) => shouldLoadNodeForPath(requestedPath, node)).sort((left, right) => {
5182
5595
  const [leftId, leftNode] = left;
5183
5596
  const [rightId, rightNode] = right;
@@ -5336,8 +5749,14 @@ export {
5336
5749
  loadActiveMetaOrStale,
5337
5750
  getKnowledge,
5338
5751
  normalizeKnowledgePath,
5752
+ ServeLockHeldError,
5753
+ acquireLock,
5754
+ releaseLock,
5755
+ readLockState,
5756
+ checkLockOrThrow,
5339
5757
  runDoctorReport,
5340
5758
  runDoctorFix,
5341
5759
  runDoctorApplyLint,
5342
- runDoctorCiteCoverage
5760
+ runDoctorCiteCoverage,
5761
+ enrichDescriptions
5343
5762
  };