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

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.
@@ -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);
@@ -1452,10 +1473,9 @@ async function reconcileKnowledge(projectRoot, opts) {
1452
1473
  // src/services/serve-lock.ts
1453
1474
  import fs from "fs";
1454
1475
  import path from "path";
1455
- import { createTranslator, detectNodeLocale } from "@fenglimg/fabric-shared";
1476
+ import { createTranslator, resolveFabricLocale } from "@fenglimg/fabric-shared";
1456
1477
  import { IOFabricError as IOFabricError2 } from "@fenglimg/fabric-shared/errors";
1457
1478
  var LOCK_FILENAME = ".serve.lock";
1458
- var t = createTranslator(detectNodeLocale());
1459
1479
  var ServeLockHeldError = class extends IOFabricError2 {
1460
1480
  code = "SERVE_LOCK_HELD";
1461
1481
  httpStatus = 423;
@@ -1483,6 +1503,7 @@ function acquireLock(projectRoot, opts) {
1483
1503
  } catch {
1484
1504
  }
1485
1505
  if (state && state.pid && state.pid !== process.pid && isAlive(state.pid) && !opts?.force) {
1506
+ const t = createTranslator(resolveFabricLocale(projectRoot));
1486
1507
  throw new ServeLockHeldError(
1487
1508
  `serve lock held by live PID ${state.pid}`,
1488
1509
  {
@@ -1533,6 +1554,7 @@ function checkLockOrThrow(projectRoot, opts) {
1533
1554
  return;
1534
1555
  }
1535
1556
  if (opts?.force) return;
1557
+ const t = createTranslator(resolveFabricLocale(projectRoot));
1536
1558
  throw new ServeLockHeldError(
1537
1559
  `serve lock held by live PID ${state.pid}`,
1538
1560
  {
@@ -1553,6 +1575,7 @@ import { minimatch } from "minimatch";
1553
1575
  import {
1554
1576
  agentsMetaSchema as agentsMetaSchema4,
1555
1577
  AgentsMetaCountersSchema,
1578
+ createTranslator as createTranslator2,
1556
1579
  forensicReportSchema,
1557
1580
  parseKnowledgeId as parseKnowledgeId2,
1558
1581
  knowledgeTestIndexSchema as knowledgeTestIndexSchema2,
@@ -1562,7 +1585,8 @@ import {
1562
1585
  BOOTSTRAP_MARKER_END,
1563
1586
  BOOTSTRAP_REGEX,
1564
1587
  ONBOARD_SLOT_NAMES,
1565
- ONBOARD_SLOT_TOTAL
1588
+ ONBOARD_SLOT_TOTAL,
1589
+ resolveFabricLocale as resolveFabricLocale2
1566
1590
  } from "@fenglimg/fabric-shared";
1567
1591
  import { detectFramework } from "@fenglimg/fabric-shared/node";
1568
1592
  import { atomicWriteJson as atomicWriteJson2, atomicWriteText as atomicWriteText4 } from "@fenglimg/fabric-shared/node/atomic-write";
@@ -1648,6 +1672,7 @@ var TARGET_FILE_PATHS = [
1648
1672
  ];
1649
1673
  async function runDoctorReport(target) {
1650
1674
  const projectRoot = normalizeTarget(target);
1675
+ const t = createTranslator2(resolveFabricLocale2(projectRoot));
1651
1676
  const framework = detectFramework(projectRoot);
1652
1677
  const entryPoints = collectEntryPoints(projectRoot);
1653
1678
  const [
@@ -1701,91 +1726,91 @@ async function runDoctorReport(target) {
1701
1726
  const skillMdYamlInvalid = inspectSkillMdYamlInvalid(projectRoot);
1702
1727
  const onboardCoverage = inspectOnboardCoverage(projectRoot);
1703
1728
  const checks = [
1704
- createBootstrapAnchorCheck(bootstrapAnchor),
1729
+ createBootstrapAnchorCheck(t, bootstrapAnchor),
1705
1730
  // v2.0.0-rc.19 TASK-004: bootstrap marker migration check sits adjacent to
1706
1731
  // the anchor check — both are bootstrap-file invariants. fixable_error
1707
1732
  // when any of the four target paths still carries the legacy marker.
1708
- createBootstrapMarkerMigrationCheck(bootstrapMarkerMigration),
1733
+ createBootstrapMarkerMigrationCheck(t, bootstrapMarkerMigration),
1709
1734
  // v2.0.0-rc.19 TASK-005: L1 + L2 byte-level drift detection sit immediately
1710
1735
  // after the marker migration check. Order: anchor existence → migration →
1711
1736
  // L1 (canonical ↔ snapshot) → L2 (snapshot+rules ↔ three-end blocks).
1712
- createL1BootstrapSnapshotDriftCheck(l1BootstrapSnapshotDrift),
1713
- createL2ManagedBlockDriftCheck(l2ManagedBlockDrift),
1714
- createKnowledgeDirMissingCheck(knowledgeDirMissing),
1737
+ createL1BootstrapSnapshotDriftCheck(t, l1BootstrapSnapshotDrift),
1738
+ createL2ManagedBlockDriftCheck(t, l2ManagedBlockDrift),
1739
+ createKnowledgeDirMissingCheck(t, knowledgeDirMissing),
1715
1740
  // v2.0.0-rc.22 TASK-006: baseline filename format. Sits adjacent to
1716
1741
  // knowledge_dir_missing — both are knowledge-layout invariants. manual_error
1717
1742
  // kind; resolution is manual file deletion (rc.23 TASK-012 (F8a) removed
1718
1743
  // the baseline-emit pipeline, so no auto-fix exists).
1719
- createBaselineFilenameFormatCheck(baselineFilenameFormat),
1720
- createForensicCheck(forensic, framework.kind, entryPoints.length),
1744
+ createBaselineFilenameFormatCheck(t, baselineFilenameFormat),
1745
+ createForensicCheck(t, forensic, framework.kind, entryPoints.length),
1721
1746
  // v2.0: removed `createInitContextCheck` — `.fabric/init-context.json`
1722
1747
  // is owned by the AI-side client init skill, not by `fabric install` CLI.
1723
1748
  // The file's absence is a legitimate post-init state when the skill has
1724
1749
  // not yet run, so flagging it as a doctor manual_error misrepresents
1725
1750
  // ownership.
1726
- createMetaCheck(meta),
1727
- createRuleContentRefCheck(meta),
1751
+ createMetaCheck(t, meta),
1752
+ createRuleContentRefCheck(t, meta),
1728
1753
  // v2.0 / rc.2: `createRuleSectionsCheck` removed — it parsed v1.x
1729
1754
  // [MANDATORY_INJECTION] sections out of legacy rule files, a structural
1730
1755
  // concept that has no v2 equivalent. rc.4 will introduce a dedicated v2
1731
1756
  // lint suite for the new knowledge frontmatter contract.
1732
- createKnowledgeTestIndexCheck(knowledgeTestIndex),
1733
- createEventLedgerCheck(eventLedger),
1734
- createEventLedgerPartialWriteCheck(eventLedger),
1735
- createMcpConfigInWrongFileCheck(mcpConfigInWrongFile),
1736
- createMetaManuallyDivergedCheck(metaManuallyDiverged),
1737
- createKnowledgeDirUnindexedCheck(knowledgeDirUnindexed),
1738
- createStableIdCollisionCheck(stableIdCollision),
1739
- createCounterDesyncCheck(counterDesync),
1740
- createFilesystemEditFallbackCheck(filesystemEditFallback),
1757
+ createKnowledgeTestIndexCheck(t, knowledgeTestIndex),
1758
+ createEventLedgerCheck(t, eventLedger),
1759
+ createEventLedgerPartialWriteCheck(t, eventLedger),
1760
+ createMcpConfigInWrongFileCheck(t, mcpConfigInWrongFile),
1761
+ createMetaManuallyDivergedCheck(t, metaManuallyDiverged),
1762
+ createKnowledgeDirUnindexedCheck(t, knowledgeDirUnindexed),
1763
+ createStableIdCollisionCheck(t, stableIdCollision),
1764
+ createCounterDesyncCheck(t, counterDesync),
1765
+ createFilesystemEditFallbackCheck(t, filesystemEditFallback),
1741
1766
  // rc.4 TASK-001: read-side lint checks #16-18. Findings only — mutation
1742
1767
  // + event emission lands in TASK-003 behind --apply-lint.
1743
- createOrphanDemoteCheck(orphanDemote),
1744
- createStaleArchiveCheck(staleArchive),
1745
- createPendingOverdueCheck(pendingOverdue),
1768
+ createOrphanDemoteCheck(t, orphanDemote),
1769
+ createStaleArchiveCheck(t, staleArchive),
1770
+ createPendingOverdueCheck(t, pendingOverdue),
1746
1771
  // rc.4 TASK-002: read-side integrity checks #19-21. Stable_id duplicate
1747
1772
  // runs first in this trio — it is the most critical integrity break and
1748
1773
  // surfaces ahead of layer-mismatch / index-drift in the report so a
1749
1774
  // human operator triages the collision before reasoning about counter
1750
1775
  // state. Index drift is the only fixable_error of the three; stable_id
1751
1776
  // duplicate and layer mismatch require manual triage (rename / move).
1752
- createStableIdDuplicateCheck(stableIdDuplicate),
1753
- createLayerMismatchCheck(layerMismatch),
1754
- createIndexDriftCheck(indexDrift),
1777
+ createStableIdDuplicateCheck(t, stableIdDuplicate),
1778
+ createLayerMismatchCheck(t, layerMismatch),
1779
+ createIndexDriftCheck(t, indexDrift),
1755
1780
  // rc.5 TASK-010: read-side underseeded-corpus check (#22). Info kind —
1756
1781
  // does not bump report status. Recommends running the fabric-import skill
1757
1782
  // to backfill knowledge when the corpus is below the threshold floor.
1758
- createUnderseededCheck(underseeded),
1783
+ createUnderseededCheck(t, underseeded),
1759
1784
  // rc.5 TASK-013 (C4): relevance_paths hygiene checks #23/#24/#25.
1760
1785
  // All three are flag-only in rc.5 (no apply-lint mutations).
1761
1786
  // #23 narrow_no_paths — warning kind (silent recall risk)
1762
1787
  // #24 relevance_paths_dangling — warning kind (glob → zero matches)
1763
1788
  // #25 relevance_paths_drift — info kind (git-log heuristic; noisy)
1764
- createNarrowNoPathsCheck(narrowNoPaths),
1765
- createRelevancePathsDanglingCheck(relevancePathsDangling),
1766
- createRelevancePathsDriftCheck(relevancePathsDrift),
1789
+ createNarrowNoPathsCheck(t, narrowNoPaths),
1790
+ createRelevancePathsDanglingCheck(t, relevancePathsDangling),
1791
+ createRelevancePathsDriftCheck(t, relevancePathsDrift),
1767
1792
  // rc.6 TASK-023 (E6): narrow_too_few (lint #26). Info kind; both arms
1768
1793
  // (structural + telemetry) recommend the same fabric-import action.
1769
- createNarrowTooFewCheck(narrowTooFew),
1794
+ createNarrowTooFewCheck(t, narrowTooFew),
1770
1795
  // rc.6 TASK-021 (E3): session-hints cache hygiene (lint #27). Info kind.
1771
- createSessionHintsStaleCheck(sessionHintsStale),
1796
+ createSessionHintsStaleCheck(t, sessionHintsStale),
1772
1797
  // rc.23 TASK-010 (e): stale .fabric/.serve.lock advisory. Info kind —
1773
1798
  // does not bump report status. `--fix` unlinks the corpse and emits
1774
1799
  // `serve_lock_cleared`.
1775
- createStaleServeLockCheck(staleServeLock),
1800
+ createStaleServeLockCheck(t, staleServeLock),
1776
1801
  // v2.0.0-rc.9 TASK-003 (A3): relevance fields back-fill (lint #28).
1777
1802
  // Info kind — applies to pending entries only; canonical entries get
1778
1803
  // the fields written verbatim by fab_review.approve/modify.
1779
- createRelevanceFieldsMissingCheck(relevanceFieldsMissing),
1804
+ createRelevanceFieldsMissingCheck(t, relevanceFieldsMissing),
1780
1805
  // rc.12 lint #29: skill_md_yaml_invalid. Warning kind — surfaces
1781
1806
  // SKILL.md frontmatter that Codex CLI silently drops at load.
1782
- createSkillMdYamlInvalidCheck(skillMdYamlInvalid),
1807
+ createSkillMdYamlInvalidCheck(t, skillMdYamlInvalid),
1783
1808
  // v2.0.0-rc.23 TASK-014 (F8c): Onboard coverage advisory. Info kind.
1784
1809
  // Surfaces uncovered S5 onboard slots and recommends /fabric-archive
1785
1810
  // first-run phase. Sits adjacent to Skill markdown YAML — both are
1786
1811
  // Skill-adjacent advisories. --fix never mutates onboard state.
1787
- createOnboardCoverageCheck(onboardCoverage),
1788
- createPreexistingRootFilesCheck(preexistingRootFiles)
1812
+ createOnboardCoverageCheck(t, onboardCoverage),
1813
+ createPreexistingRootFilesCheck(t, preexistingRootFiles)
1789
1814
  // v2.0 / rc.2: `createLegacyClientPathCheck` removed. The schema now
1790
1815
  // rejects retired clientPaths keys (windsurf/rooCode/geminiCLI) at Zod
1791
1816
  // parse time, so the soft-deprecation warn-and-fix path no longer has a
@@ -2547,21 +2572,25 @@ async function inspectBootstrapMarkerMigration(target) {
2547
2572
  }
2548
2573
  return { filesNeedingMigration };
2549
2574
  }
2550
- function createBootstrapMarkerMigrationCheck(inspection) {
2575
+ function createBootstrapMarkerMigrationCheck(t, inspection) {
2551
2576
  if (inspection.filesNeedingMigration.length === 0) {
2552
2577
  return okCheck(
2553
- "Bootstrap marker migration",
2554
- "No legacy fabric:knowledge-base markers detected in bootstrap target files."
2578
+ t("doctor.check.bootstrap_marker_migration.name"),
2579
+ t("doctor.check.bootstrap_marker_migration.ok")
2555
2580
  );
2556
2581
  }
2557
2582
  const list = inspection.filesNeedingMigration.join(", ");
2583
+ const count = inspection.filesNeedingMigration.length;
2558
2584
  return issueCheck(
2559
- "Bootstrap marker migration",
2585
+ t("doctor.check.bootstrap_marker_migration.name"),
2560
2586
  "error",
2561
2587
  "fixable_error",
2562
2588
  "bootstrap_marker_migration_required",
2563
- `${inspection.filesNeedingMigration.length} file${inspection.filesNeedingMigration.length === 1 ? "" : "s"} still carry the legacy fabric:knowledge-base bootstrap marker: ${list}.`,
2564
- "Run `fab doctor --fix` to migrate to fabric:bootstrap marker"
2589
+ t(`doctor.check.bootstrap_marker_migration.message.${count === 1 ? "singular" : "plural"}`, {
2590
+ count: String(count),
2591
+ list
2592
+ }),
2593
+ t("doctor.check.bootstrap_marker_migration.remediation")
2565
2594
  );
2566
2595
  }
2567
2596
  async function inspectL1BootstrapSnapshotDrift(target) {
@@ -2580,20 +2609,20 @@ async function inspectL1BootstrapSnapshotDrift(target) {
2580
2609
  }
2581
2610
  return { status: "drift", canonical: BOOTSTRAP_CANONICAL, onDisk };
2582
2611
  }
2583
- function createL1BootstrapSnapshotDriftCheck(inspection) {
2612
+ function createL1BootstrapSnapshotDriftCheck(t, inspection) {
2584
2613
  if (inspection.status === "drift") {
2585
2614
  return issueCheck(
2586
- "Bootstrap snapshot drift",
2615
+ t("doctor.check.bootstrap_snapshot_drift.name"),
2587
2616
  "error",
2588
2617
  "fixable_error",
2589
2618
  "bootstrap_snapshot_drift",
2590
- ".fabric/AGENTS.md content diverges byte-for-byte from BOOTSTRAP_CANONICAL.",
2591
- "Run `fab doctor --fix` to restore canonical bootstrap snapshot"
2619
+ t("doctor.check.bootstrap_snapshot_drift.message.drift"),
2620
+ t("doctor.check.bootstrap_snapshot_drift.remediation.drift")
2592
2621
  );
2593
2622
  }
2594
2623
  return okCheck(
2595
- "Bootstrap snapshot drift",
2596
- inspection.status === "ok" ? ".fabric/AGENTS.md byte-equals BOOTSTRAP_CANONICAL." : ".fabric/AGENTS.md absent \u2014 delegated to bootstrap_anchor_missing."
2624
+ t("doctor.check.bootstrap_snapshot_drift.name"),
2625
+ inspection.status === "ok" ? t("doctor.check.bootstrap_snapshot_drift.ok.ok") : t("doctor.check.bootstrap_snapshot_drift.ok.missing_delegated")
2597
2626
  );
2598
2627
  }
2599
2628
  async function inspectL2ManagedBlockDrift(target) {
@@ -2685,39 +2714,46 @@ ${projectRules}`;
2685
2714
  }
2686
2715
  return { status: "drift", drifted };
2687
2716
  }
2688
- function createL2ManagedBlockDriftCheck(inspection) {
2717
+ function createL2ManagedBlockDriftCheck(t, inspection) {
2689
2718
  if (inspection.status === "drift") {
2690
2719
  const list = inspection.drifted.map((d) => d.path).join(", ");
2720
+ const count = inspection.drifted.length;
2691
2721
  return issueCheck(
2692
- "Managed block drift",
2722
+ t("doctor.check.managed_block_drift.name"),
2693
2723
  "error",
2694
2724
  "fixable_error",
2695
2725
  "managed_block_drift",
2696
- `${inspection.drifted.length} three-end managed block${inspection.drifted.length === 1 ? "" : "s"} diverge from expected body (snapshot + optional project-rules concat): ${list}.`,
2697
- "Run `fab doctor --fix` to restore three-end managed blocks from canonical"
2726
+ t(`doctor.check.managed_block_drift.message.${count === 1 ? "singular" : "plural"}`, {
2727
+ count: String(count),
2728
+ list
2729
+ }),
2730
+ t("doctor.check.managed_block_drift.remediation")
2698
2731
  );
2699
2732
  }
2700
2733
  return okCheck(
2701
- "Managed block drift",
2702
- inspection.status === "ok" ? "Three-end managed blocks byte-equal expectedBody." : "No three-end managed blocks detected \u2014 propagation pending or legacy-marker state."
2734
+ t("doctor.check.managed_block_drift.name"),
2735
+ inspection.status === "ok" ? t("doctor.check.managed_block_drift.ok.ok") : t("doctor.check.managed_block_drift.ok.no_managed_block")
2703
2736
  );
2704
2737
  }
2705
- function createBootstrapAnchorCheck(inspection) {
2738
+ function createBootstrapAnchorCheck(t, inspection) {
2706
2739
  if (!inspection.hasAgentsMd && !inspection.hasClaudeMd) {
2707
2740
  return issueCheck(
2708
- "Bootstrap anchor",
2741
+ t("doctor.check.bootstrap_anchor.name"),
2709
2742
  "error",
2710
2743
  "fixable_error",
2711
2744
  "bootstrap_anchor_missing",
2712
- "Neither AGENTS.md nor CLAUDE.md exists at the repo root. Fabric requires a bootstrap anchor file at the project root.",
2713
- "Run `fabric install` to generate the AGENTS.md / CLAUDE.md bootstrap anchor at the repo root."
2745
+ t("doctor.check.bootstrap_anchor.message.missing"),
2746
+ t("doctor.check.bootstrap_anchor.remediation.missing")
2714
2747
  );
2715
2748
  }
2716
2749
  const present = [
2717
2750
  inspection.hasAgentsMd ? "AGENTS.md" : null,
2718
2751
  inspection.hasClaudeMd ? "CLAUDE.md" : null
2719
2752
  ].filter((entry) => entry !== null).join(", ");
2720
- return okCheck("Bootstrap anchor", `Bootstrap anchor present at repo root: ${present}.`);
2753
+ return okCheck(
2754
+ t("doctor.check.bootstrap_anchor.name"),
2755
+ t("doctor.check.bootstrap_anchor.ok", { present })
2756
+ );
2721
2757
  }
2722
2758
  function inspectKnowledgeDirMissing(projectRoot) {
2723
2759
  const knowledgeRoot = join6(projectRoot, ".fabric", "knowledge");
@@ -2778,154 +2814,269 @@ function inspectBaselineFilenameFormat(projectRoot) {
2778
2814
  offenders.sort((a, b) => a.path.localeCompare(b.path));
2779
2815
  return { offenders };
2780
2816
  }
2781
- function createBaselineFilenameFormatCheck(inspection) {
2817
+ function createBaselineFilenameFormatCheck(t, inspection) {
2782
2818
  if (inspection.offenders.length === 0) {
2783
2819
  return okCheck(
2784
- "Baseline filename format",
2785
- "All baseline knowledge files use the canonical `${id}--${slug}.md` filename format."
2820
+ t("doctor.check.baseline_filename_format.name"),
2821
+ t("doctor.check.baseline_filename_format.ok")
2786
2822
  );
2787
2823
  }
2788
2824
  const first = inspection.offenders[0];
2789
2825
  const detail = `${first.stable_id} at ${first.path}`;
2826
+ const count = inspection.offenders.length;
2790
2827
  return issueCheck(
2791
- "Baseline filename format",
2828
+ t("doctor.check.baseline_filename_format.name"),
2792
2829
  "error",
2793
2830
  "manual_error",
2794
2831
  "lint-baseline-filename-format",
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}.`,
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."
2832
+ t(`doctor.check.baseline_filename_format.message.${count === 1 ? "singular" : "plural"}`, {
2833
+ count: String(count),
2834
+ detail
2835
+ }),
2836
+ t("doctor.check.baseline_filename_format.remediation")
2797
2837
  );
2798
2838
  }
2799
- function createKnowledgeDirMissingCheck(inspection) {
2839
+ function createKnowledgeDirMissingCheck(t, inspection) {
2800
2840
  if (inspection.missingSubdirs.length > 0) {
2801
2841
  const list = inspection.missingSubdirs.join(", ");
2842
+ const count = inspection.missingSubdirs.length;
2802
2843
  return issueCheck(
2803
- "Knowledge layout",
2844
+ t("doctor.check.knowledge_dir_missing.name"),
2804
2845
  "error",
2805
2846
  "fixable_error",
2806
2847
  "knowledge_dir_missing",
2807
- `${inspection.missingSubdirs.length} required knowledge subdir${inspection.missingSubdirs.length === 1 ? " is" : "s are"} missing: ${list}.`,
2808
- "Run `fab doctor --fix` to create the missing .fabric/knowledge/* subdirectories."
2848
+ t(`doctor.check.knowledge_dir_missing.message.${count === 1 ? "singular" : "plural"}`, {
2849
+ count: String(count),
2850
+ list
2851
+ }),
2852
+ t("doctor.check.knowledge_dir_missing.remediation")
2809
2853
  );
2810
2854
  }
2811
2855
  return okCheck(
2812
- "Knowledge layout",
2813
- `All ${KNOWLEDGE_SUBDIRS3.length} required .fabric/knowledge/* subdirectories exist.`
2856
+ t("doctor.check.knowledge_dir_missing.name"),
2857
+ t("doctor.check.knowledge_dir_missing.ok", { count: String(KNOWLEDGE_SUBDIRS3.length) })
2814
2858
  );
2815
2859
  }
2816
- function createForensicCheck(forensic, frameworkKind, entryPointCount) {
2860
+ function createForensicCheck(t, forensic, frameworkKind, entryPointCount) {
2817
2861
  if (!forensic.present) {
2818
2862
  return issueCheck(
2819
- "Scan evidence",
2863
+ t("doctor.check.forensic.name"),
2820
2864
  "error",
2821
2865
  "manual_error",
2822
2866
  "forensic_missing",
2823
- `${forensic.error ?? ".fabric/forensic.json is missing."} Live scan detects ${frameworkKind} with ${entryPointCount} entry point${entryPointCount === 1 ? "" : "s"}.`,
2824
- "Run `fab install` to regenerate .fabric/forensic.json."
2867
+ t(`doctor.check.forensic.message.missing.${entryPointCount === 1 ? "singular" : "plural"}`, {
2868
+ error: forensic.error ?? t("doctor.check.forensic.message.missing-default"),
2869
+ frameworkKind,
2870
+ count: String(entryPointCount)
2871
+ }),
2872
+ t("doctor.check.forensic.remediation")
2825
2873
  );
2826
2874
  }
2827
2875
  if (!forensic.valid) {
2828
- return issueCheck("Scan evidence", "error", "manual_error", "forensic_invalid", forensic.error ?? ".fabric/forensic.json is invalid.", "Run `fab install` to regenerate .fabric/forensic.json.");
2876
+ return issueCheck(
2877
+ t("doctor.check.forensic.name"),
2878
+ "error",
2879
+ "manual_error",
2880
+ "forensic_invalid",
2881
+ forensic.error ?? t("doctor.check.forensic.message.invalid-default"),
2882
+ t("doctor.check.forensic.remediation")
2883
+ );
2829
2884
  }
2830
- return okCheck("Scan evidence", `.fabric/forensic.json is valid for ${forensic.report?.framework.kind ?? "unknown"}.`);
2885
+ return okCheck(
2886
+ t("doctor.check.forensic.name"),
2887
+ t("doctor.check.forensic.ok", { frameworkKind: forensic.report?.framework.kind ?? "unknown" })
2888
+ );
2831
2889
  }
2832
- function createMetaCheck(meta) {
2890
+ function createMetaCheck(t, meta) {
2833
2891
  if (!meta.present) {
2834
- 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/.");
2892
+ return issueCheck(
2893
+ t("doctor.check.agents_meta.name"),
2894
+ "error",
2895
+ "fixable_error",
2896
+ "agents_meta_missing",
2897
+ t("doctor.check.agents_meta.message.missing"),
2898
+ t("doctor.check.agents_meta.remediation.missing")
2899
+ );
2835
2900
  }
2836
2901
  if (!meta.valid) {
2837
- 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.");
2902
+ return issueCheck(
2903
+ t("doctor.check.agents_meta.name"),
2904
+ "error",
2905
+ "manual_error",
2906
+ "agents_meta_invalid",
2907
+ meta.readError ?? t("doctor.check.agents_meta.message.invalid-default"),
2908
+ t("doctor.check.agents_meta.remediation.invalid")
2909
+ );
2838
2910
  }
2839
2911
  if (meta.stale) {
2840
2912
  return issueCheck(
2841
- "Agents metadata",
2913
+ t("doctor.check.agents_meta.name"),
2842
2914
  "warn",
2843
2915
  "warning",
2844
2916
  "agents_meta_stale",
2845
- `.fabric/agents.meta.json revision ${meta.revision} does not match .fabric/knowledge derived revision ${meta.computedRevision ?? "<unknown>"}.`,
2846
- "Benign \u2014 engine auto-heals on next plan-context/get-sections call. Run `fab doctor --fix` for explicit reconciliation."
2917
+ t("doctor.check.agents_meta.message.stale", {
2918
+ revision: meta.revision,
2919
+ computedRevision: meta.computedRevision ?? "<unknown>"
2920
+ }),
2921
+ t("doctor.check.agents_meta.remediation.stale")
2847
2922
  );
2848
2923
  }
2849
- return okCheck("Agents metadata", `.fabric/agents.meta.json revision ${meta.revision} is aligned with .fabric/knowledge.`);
2924
+ return okCheck(
2925
+ t("doctor.check.agents_meta.name"),
2926
+ t("doctor.check.agents_meta.ok", { revision: meta.revision })
2927
+ );
2850
2928
  }
2851
- function createRuleContentRefCheck(meta) {
2929
+ function createRuleContentRefCheck(t, meta) {
2852
2930
  if (!meta.valid) {
2853
- 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`.");
2931
+ return issueCheck(
2932
+ t("doctor.check.rule_content_refs.name"),
2933
+ "error",
2934
+ "manual_error",
2935
+ "content_refs_unavailable",
2936
+ t("doctor.check.rule_content_refs.message.unavailable"),
2937
+ t("doctor.check.rule_content_refs.remediation.unavailable")
2938
+ );
2854
2939
  }
2855
2940
  if (meta.invalidContentRefs.length > 0) {
2941
+ const count = meta.invalidContentRefs.length;
2856
2942
  return issueCheck(
2857
- "Rule content refs",
2943
+ t("doctor.check.rule_content_refs.name"),
2858
2944
  "error",
2859
2945
  "manual_error",
2860
2946
  "content_ref_outside_rules",
2861
- `${meta.invalidContentRefs.length} content_ref entr${meta.invalidContentRefs.length === 1 ? "y is" : "ies are"} outside .fabric/knowledge.`,
2862
- "Edit agents.meta.json to ensure all content_ref values point inside .fabric/knowledge/{type}/ (team) or ~/.fabric/knowledge/{type}/ (personal)."
2947
+ t(`doctor.check.rule_content_refs.message.outside.${count === 1 ? "singular" : "plural"}`, {
2948
+ count: String(count)
2949
+ }),
2950
+ t("doctor.check.rule_content_refs.remediation.outside")
2863
2951
  );
2864
2952
  }
2865
2953
  if (meta.missingContentRefs.length > 0) {
2954
+ const count = meta.missingContentRefs.length;
2866
2955
  return issueCheck(
2867
- "Rule content refs",
2956
+ t("doctor.check.rule_content_refs.name"),
2868
2957
  "error",
2869
2958
  "fixable_error",
2870
2959
  "content_ref_missing",
2871
- `${meta.missingContentRefs.length} content_ref target${meta.missingContentRefs.length === 1 ? "" : "s"} are missing. Run \`fab doctor --fix\` to reconcile.`,
2872
- "Run `fab doctor --fix` to reconcile agents.meta.json with the files present in .fabric/knowledge/."
2960
+ t(`doctor.check.rule_content_refs.message.missing.${count === 1 ? "singular" : "plural"}`, {
2961
+ count: String(count)
2962
+ }),
2963
+ t("doctor.check.rule_content_refs.remediation.missing")
2873
2964
  );
2874
2965
  }
2875
- return okCheck("Rule content refs", "All content_ref entries resolve to .fabric/knowledge files.");
2966
+ return okCheck(t("doctor.check.rule_content_refs.name"), t("doctor.check.rule_content_refs.ok"));
2876
2967
  }
2877
- function createKnowledgeTestIndexCheck(index) {
2968
+ function createKnowledgeTestIndexCheck(t, index) {
2878
2969
  if (!index.present) {
2879
- 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.");
2970
+ return issueCheck(
2971
+ t("doctor.check.knowledge_test_index.name"),
2972
+ "error",
2973
+ "fixable_error",
2974
+ "knowledge_test_index_missing",
2975
+ index.error,
2976
+ t("doctor.check.knowledge_test_index.remediation.missing")
2977
+ );
2880
2978
  }
2881
2979
  if (!index.valid) {
2882
- 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.");
2980
+ return issueCheck(
2981
+ t("doctor.check.knowledge_test_index.name"),
2982
+ "error",
2983
+ "manual_error",
2984
+ "knowledge_test_index_invalid",
2985
+ index.error,
2986
+ t("doctor.check.knowledge_test_index.remediation.invalid")
2987
+ );
2883
2988
  }
2884
2989
  if (index.stale) {
2885
- 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.");
2990
+ return issueCheck(
2991
+ t("doctor.check.knowledge_test_index.name"),
2992
+ "error",
2993
+ "fixable_error",
2994
+ "knowledge_test_index_stale",
2995
+ t("doctor.check.knowledge_test_index.message.stale"),
2996
+ t("doctor.check.knowledge_test_index.remediation.stale")
2997
+ );
2886
2998
  }
2887
- return okCheck("Knowledge-test index", `${index.linkCount} link${index.linkCount === 1 ? "" : "s"} and ${index.orphanCount} orphan annotation${index.orphanCount === 1 ? "" : "s"} indexed.`);
2999
+ return okCheck(
3000
+ t("doctor.check.knowledge_test_index.name"),
3001
+ t(
3002
+ `doctor.check.knowledge_test_index.ok.${index.linkCount === 1 ? "link_singular" : "link_plural"}.${index.orphanCount === 1 ? "orphan_singular" : "orphan_plural"}`,
3003
+ { linkCount: String(index.linkCount), orphanCount: String(index.orphanCount) }
3004
+ )
3005
+ );
2888
3006
  }
2889
- function createEventLedgerCheck(ledger) {
3007
+ function createEventLedgerCheck(t, ledger) {
2890
3008
  if (!ledger.exists) {
2891
- return issueCheck("Event ledger", "error", "fixable_error", "event_ledger_missing", ".fabric/events.jsonl is missing.", "Run `fab doctor --fix` to create .fabric/events.jsonl.");
3009
+ return issueCheck(
3010
+ t("doctor.check.event_ledger.name"),
3011
+ "error",
3012
+ "fixable_error",
3013
+ "event_ledger_missing",
3014
+ t("doctor.check.event_ledger.message.missing"),
3015
+ t("doctor.check.event_ledger.remediation.missing")
3016
+ );
2892
3017
  }
2893
3018
  if (!ledger.writable) {
2894
- 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.");
3019
+ return issueCheck(
3020
+ t("doctor.check.event_ledger.name"),
3021
+ "error",
3022
+ "manual_error",
3023
+ "event_ledger_not_writable",
3024
+ ledger.error ?? t("doctor.check.event_ledger.message.not_writable-default"),
3025
+ t("doctor.check.event_ledger.remediation.not_writable")
3026
+ );
2895
3027
  }
2896
3028
  if (!ledger.parseable) {
2897
- 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.");
3029
+ return issueCheck(
3030
+ t("doctor.check.event_ledger.name"),
3031
+ "error",
3032
+ "manual_error",
3033
+ "event_ledger_invalid",
3034
+ ledger.error ?? t("doctor.check.event_ledger.message.invalid-default"),
3035
+ t("doctor.check.event_ledger.remediation.invalid")
3036
+ );
2898
3037
  }
2899
- return okCheck("Event ledger", ".fabric/events.jsonl exists, is writable, and is parseable.");
3038
+ return okCheck(t("doctor.check.event_ledger.name"), t("doctor.check.event_ledger.ok"));
2900
3039
  }
2901
- function createMcpConfigInWrongFileCheck(inspection) {
3040
+ function createMcpConfigInWrongFileCheck(t, inspection) {
2902
3041
  if (inspection.hasWrongEntry) {
2903
3042
  return issueCheck(
2904
- "Claude MCP config location",
3043
+ t("doctor.check.mcp_config_in_wrong_file.name"),
2905
3044
  "error",
2906
3045
  "fixable_error",
2907
3046
  "mcp_config_in_wrong_file",
2908
- `.claude/settings.json contains mcpServers.fabric \u2014 this file is for hooks/permissions only. Run --fix to remove it, then re-run fab install to write .mcp.json.`,
2909
- "Run `fab doctor --fix` to remove mcpServers.fabric from .claude/settings.json, then run `fab install` to write .mcp.json."
3047
+ t("doctor.check.mcp_config_in_wrong_file.message"),
3048
+ t("doctor.check.mcp_config_in_wrong_file.remediation")
2910
3049
  );
2911
3050
  }
2912
- return okCheck("Claude MCP config location", "mcpServers.fabric is not in .claude/settings.json.");
3051
+ return okCheck(
3052
+ t("doctor.check.mcp_config_in_wrong_file.name"),
3053
+ t("doctor.check.mcp_config_in_wrong_file.ok")
3054
+ );
2913
3055
  }
2914
- function createEventLedgerPartialWriteCheck(ledger) {
3056
+ function createEventLedgerPartialWriteCheck(t, ledger) {
2915
3057
  if (!ledger.exists || !ledger.writable) {
2916
- return okCheck("Event ledger partial write", "No partial-write check needed (ledger missing or not writable).");
3058
+ return okCheck(
3059
+ t("doctor.check.event_ledger_partial_write.name"),
3060
+ t("doctor.check.event_ledger_partial_write.ok.skipped")
3061
+ );
2917
3062
  }
2918
3063
  if (ledger.hasPartialWrite) {
2919
3064
  return issueCheck(
2920
- "Event ledger partial write",
3065
+ t("doctor.check.event_ledger_partial_write.name"),
2921
3066
  "error",
2922
3067
  "fixable_error",
2923
3068
  "event_ledger_partial_write",
2924
- `events.jsonl has a partial write at byte offset ${ledger.partialWriteByteOffset} (${ledger.partialWriteByteLength} corrupted bytes). Run --fix to truncate and preserve corrupted bytes.`,
2925
- "Run `fab doctor --fix` to truncate the partial write and restore events.jsonl to a valid state."
3069
+ t("doctor.check.event_ledger_partial_write.message", {
3070
+ byteOffset: String(ledger.partialWriteByteOffset),
3071
+ byteLength: String(ledger.partialWriteByteLength)
3072
+ }),
3073
+ t("doctor.check.event_ledger_partial_write.remediation")
2926
3074
  );
2927
3075
  }
2928
- return okCheck("Event ledger partial write", "events.jsonl has no partial trailing write.");
3076
+ return okCheck(
3077
+ t("doctor.check.event_ledger_partial_write.name"),
3078
+ t("doctor.check.event_ledger_partial_write.ok.clean")
3079
+ );
2929
3080
  }
2930
3081
  function okCheck(name, message) {
2931
3082
  return { name, status: "ok", message };
@@ -2945,7 +3096,8 @@ function collectIssues(checks, kind) {
2945
3096
  return checks.filter((check) => check.kind === kind).map((check) => ({
2946
3097
  code: check.code ?? check.name,
2947
3098
  name: check.name,
2948
- message: check.message
3099
+ message: check.message,
3100
+ actionHint: check.actionHint
2949
3101
  }));
2950
3102
  }
2951
3103
  function findIssue(issues, code) {
@@ -3032,18 +3184,24 @@ function collectMdFilesUnder(out, projectRoot, rootDir, relPrefix) {
3032
3184
  }
3033
3185
  }
3034
3186
  }
3035
- function createKnowledgeDirUnindexedCheck(inspection) {
3187
+ function createKnowledgeDirUnindexedCheck(t, inspection) {
3036
3188
  if (inspection.unindexedFiles.length > 0) {
3189
+ const count = inspection.unindexedFiles.length;
3037
3190
  return issueCheck(
3038
- "Knowledge dir unindexed",
3191
+ t("doctor.check.knowledge_dir_unindexed.name"),
3039
3192
  "error",
3040
3193
  "fixable_error",
3041
3194
  "knowledge_dir_unindexed",
3042
- `${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.`,
3043
- "Run `fab doctor --fix` to index the missing knowledge files."
3195
+ t(`doctor.check.knowledge_dir_unindexed.message.${count === 1 ? "singular" : "plural"}`, {
3196
+ count: String(count)
3197
+ }),
3198
+ t("doctor.check.knowledge_dir_unindexed.remediation")
3044
3199
  );
3045
3200
  }
3046
- return okCheck("Knowledge dir unindexed", "All .fabric/knowledge/ .md files are indexed in agents.meta.json.");
3201
+ return okCheck(
3202
+ t("doctor.check.knowledge_dir_unindexed.name"),
3203
+ t("doctor.check.knowledge_dir_unindexed.ok")
3204
+ );
3047
3205
  }
3048
3206
  async function inspectStableIdCollisions(projectRoot) {
3049
3207
  const found = [];
@@ -3126,7 +3284,7 @@ function inspectCounterDesync(meta) {
3126
3284
  ["guideline", "GLD"],
3127
3285
  ["pitfall", "PIT"],
3128
3286
  ["process", "PRO"]
3129
- ].find(([t2]) => t2 === parsed.type)?.[1];
3287
+ ].find(([t]) => t === parsed.type)?.[1];
3130
3288
  if (typeCode === void 0) {
3131
3289
  continue;
3132
3290
  }
@@ -3154,61 +3312,84 @@ function inspectCounterDesync(meta) {
3154
3312
  correctedCounters: desyncs.length === 0 ? null : corrected
3155
3313
  };
3156
3314
  }
3157
- function createCounterDesyncCheck(inspection) {
3315
+ function createCounterDesyncCheck(t, inspection) {
3158
3316
  if (inspection.desyncs.length > 0) {
3159
3317
  const first = inspection.desyncs[0];
3160
- 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")}`;
3318
+ const observedId = `K${first.layer === "KP" ? "P" : "T"}-${first.type}-${String(first.observed).padStart(4, "0")}`;
3319
+ const count = inspection.desyncs.length;
3161
3320
  return issueCheck(
3162
- "Knowledge counter desync",
3321
+ t("doctor.check.counter_desync.name"),
3163
3322
  "error",
3164
3323
  "fixable_error",
3165
3324
  "counter_desync",
3166
- `${inspection.desyncs.length} knowledge counter${inspection.desyncs.length === 1 ? "" : "s"} desynced from observed stable_ids. ${detail}. Run \`fab doctor --fix\` to bump counters.`,
3167
- "Run `fab doctor --fix` to bump agents.meta.json counters to the maximum observed counter value."
3325
+ t(`doctor.check.counter_desync.message.${count === 1 ? "singular" : "plural"}`, {
3326
+ count: String(count),
3327
+ counterPath: `counters.${first.layer}.${first.type}`,
3328
+ current: String(first.current),
3329
+ observedId
3330
+ }),
3331
+ t("doctor.check.counter_desync.remediation")
3168
3332
  );
3169
3333
  }
3170
- return okCheck("Knowledge counter desync", "agents.meta.json counters envelope is consistent with observed stable_ids.");
3334
+ return okCheck(t("doctor.check.counter_desync.name"), t("doctor.check.counter_desync.ok"));
3171
3335
  }
3172
- function createStableIdCollisionCheck(inspection) {
3336
+ function createStableIdCollisionCheck(t, inspection) {
3173
3337
  if (inspection.collisions.length > 0) {
3174
3338
  const first = inspection.collisions[0];
3175
- 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(", ")}.`;
3339
+ const count = inspection.collisions.length;
3176
3340
  return issueCheck(
3177
- "Stable ID collision",
3341
+ t("doctor.check.stable_id_collision.name"),
3178
3342
  "warn",
3179
3343
  "warning",
3180
3344
  "stable_id_collision",
3181
- `${detail} Edit one of the knowledge files to use a unique stable_id.`,
3182
- "Edit one of the colliding knowledge files to declare a different `id: K[PT]-XXX-NNNN` frontmatter value."
3345
+ t(`doctor.check.stable_id_collision.message.${count === 1 ? "singular" : "plural"}`, {
3346
+ count: String(count),
3347
+ stableId: first.stable_id,
3348
+ fileCount: String(first.files.length),
3349
+ files: first.files.join(", ")
3350
+ }),
3351
+ t("doctor.check.stable_id_collision.remediation")
3183
3352
  );
3184
3353
  }
3185
- return okCheck("Stable ID collision", "No declared stable_id collisions found in .fabric/knowledge/.");
3354
+ return okCheck(t("doctor.check.stable_id_collision.name"), t("doctor.check.stable_id_collision.ok"));
3186
3355
  }
3187
- function createMetaManuallyDivergedCheck(inspection) {
3356
+ function createMetaManuallyDivergedCheck(t, inspection) {
3188
3357
  if (!inspection.readable) {
3189
- return okCheck("Meta manual divergence", "agents.meta.json not readable; skipping divergence check.");
3358
+ return okCheck(
3359
+ t("doctor.check.meta_manually_diverged.name"),
3360
+ t("doctor.check.meta_manually_diverged.ok.unreadable")
3361
+ );
3190
3362
  }
3191
3363
  if (inspection.extraMetaEntries.length > 0) {
3364
+ const count = inspection.extraMetaEntries.length;
3192
3365
  return issueCheck(
3193
- "Meta manual divergence",
3366
+ t("doctor.check.meta_manually_diverged.name"),
3194
3367
  "warn",
3195
3368
  "warning",
3196
3369
  "meta_manually_diverged",
3197
- `agents.meta.json has ${inspection.extraMetaEntries.length} entr${inspection.extraMetaEntries.length === 1 ? "y" : "ies"} with no backing file on disk. Run --fix to reconcile.`,
3198
- "Run `fab doctor --fix` to reconcile agents.meta.json with the rule files currently on disk."
3370
+ t(`doctor.check.meta_manually_diverged.message.extra.${count === 1 ? "singular" : "plural"}`, {
3371
+ count: String(count)
3372
+ }),
3373
+ t("doctor.check.meta_manually_diverged.remediation.extra")
3199
3374
  );
3200
3375
  }
3201
3376
  if (inspection.hashMismatchEntries.length > 0) {
3377
+ const count = inspection.hashMismatchEntries.length;
3202
3378
  return issueCheck(
3203
- "Meta manual divergence",
3379
+ t("doctor.check.meta_manually_diverged.name"),
3204
3380
  "warn",
3205
3381
  "warning",
3206
3382
  "meta_manually_diverged",
3207
- `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.`,
3208
- "Run `fab doctor --fix` to reconcile agents.meta.json with the current rule file contents."
3383
+ t(`doctor.check.meta_manually_diverged.message.hash.${count === 1 ? "singular" : "plural"}`, {
3384
+ count: String(count)
3385
+ }),
3386
+ t("doctor.check.meta_manually_diverged.remediation.hash")
3209
3387
  );
3210
3388
  }
3211
- return okCheck("Meta manual divergence", "agents.meta.json is consistent with rule files on disk.");
3389
+ return okCheck(
3390
+ t("doctor.check.meta_manually_diverged.name"),
3391
+ t("doctor.check.meta_manually_diverged.ok.consistent")
3392
+ );
3212
3393
  }
3213
3394
  function inspectPreexistingRootFiles(projectRoot) {
3214
3395
  const candidates = ["CLAUDE.md", "AGENTS.md"];
@@ -3274,36 +3455,44 @@ async function inspectFilesystemEditFallback(projectRoot) {
3274
3455
  }
3275
3456
  return { synthesized: orphanIds.length, synthesizedStableIds: orphanIds };
3276
3457
  }
3277
- function createFilesystemEditFallbackCheck(inspection) {
3458
+ function createFilesystemEditFallbackCheck(t, inspection) {
3278
3459
  if (inspection.synthesized === 0) {
3279
3460
  return okCheck(
3280
- "Filesystem-edit fallback",
3281
- "No orphan canonical knowledge entries detected; events.jsonl promotion trail is complete."
3461
+ t("doctor.check.filesystem_edit_fallback.name"),
3462
+ t("doctor.check.filesystem_edit_fallback.ok")
3282
3463
  );
3283
3464
  }
3284
3465
  const sample = inspection.synthesizedStableIds.slice(0, 3).join(", ");
3285
3466
  return {
3286
- name: "Filesystem-edit fallback",
3467
+ name: t("doctor.check.filesystem_edit_fallback.name"),
3287
3468
  status: "ok",
3288
3469
  kind: "info",
3289
3470
  code: "knowledge_promoted_synthesized",
3290
3471
  fixable: false,
3291
- message: `Synthesized ${inspection.synthesized} knowledge_promoted event${inspection.synthesized === 1 ? "" : "s"} for orphan canonical entries (${sample}${inspection.synthesizedStableIds.length > 3 ? ", ..." : ""}). Reason='${SYNTHESIZED_PROMOTED_REASON}'.`,
3292
- actionHint: "These entries were moved into .fabric/knowledge/<type>/ outside fab_review.approve. The synthesized events restore audit-trail completeness."
3472
+ message: t(
3473
+ `doctor.check.filesystem_edit_fallback.message.synthesized.${inspection.synthesized === 1 ? "singular" : "plural"}`,
3474
+ {
3475
+ count: String(inspection.synthesized),
3476
+ sample,
3477
+ suffix: inspection.synthesizedStableIds.length > 3 ? ", ..." : "",
3478
+ reason: SYNTHESIZED_PROMOTED_REASON
3479
+ }
3480
+ ),
3481
+ actionHint: t("doctor.check.filesystem_edit_fallback.remediation.synthesized")
3293
3482
  };
3294
3483
  }
3295
- function createPreexistingRootFilesCheck(inspection) {
3484
+ function createPreexistingRootFilesCheck(t, inspection) {
3296
3485
  if (inspection.detected.length === 0) {
3297
- return okCheck("Preexisting root markdown", "No CLAUDE.md or AGENTS.md detected at project root.");
3486
+ return okCheck(t("doctor.check.preexisting_root_files.name"), t("doctor.check.preexisting_root_files.ok"));
3298
3487
  }
3299
3488
  return {
3300
- name: "Preexisting root markdown",
3489
+ name: t("doctor.check.preexisting_root_files.name"),
3301
3490
  status: "ok",
3302
3491
  kind: "info",
3303
3492
  code: "preexisting_root_claude_md",
3304
3493
  fixable: false,
3305
- message: `${inspection.detected.join(", ")} detected at project root. These root files are not auto-loaded by Fabric MCP.`,
3306
- actionHint: "Move knowledge content to `.fabric/knowledge/{type}/` if you want it available in MCP responses."
3494
+ message: t("doctor.check.preexisting_root_files.message", { files: inspection.detected.join(", ") }),
3495
+ actionHint: t("doctor.check.preexisting_root_files.remediation")
3307
3496
  };
3308
3497
  }
3309
3498
  async function buildLastConsumedIndex(projectRoot) {
@@ -3801,114 +3990,156 @@ function readUnderseedThresholdFromConfig(projectRoot) {
3801
3990
  }
3802
3991
  return DEFAULT_UNDERSEED_NODE_THRESHOLD;
3803
3992
  }
3804
- function createOrphanDemoteCheck(inspection) {
3993
+ function createOrphanDemoteCheck(t, inspection) {
3805
3994
  if (inspection.candidates.length === 0) {
3806
3995
  return okCheck(
3807
- "Knowledge orphan demote",
3808
- "No canonical knowledge entries exceed their maturity-keyed inactivity threshold."
3996
+ t("doctor.check.orphan_demote.name"),
3997
+ t("doctor.check.orphan_demote.ok")
3809
3998
  );
3810
3999
  }
3811
4000
  const first = inspection.candidates[0];
3812
4001
  const detail = `${first.stable_id} (${first.maturity}, ${first.age_days}d inactive at ${first.path})`;
4002
+ const count = inspection.candidates.length;
3813
4003
  return issueCheck(
3814
- "Knowledge orphan demote",
4004
+ t("doctor.check.orphan_demote.name"),
3815
4005
  "warn",
3816
4006
  "warning",
3817
4007
  "knowledge_orphan_demote_required",
3818
- `${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}.`,
3819
- "Run `fab doctor --apply-lint` (rc.4 TASK-003) to demote orphan entries one maturity tier."
4008
+ t(`doctor.check.orphan_demote.message.${count === 1 ? "singular" : "plural"}`, {
4009
+ count: String(count),
4010
+ stableDays: String(ORPHAN_DEMOTE_THRESHOLD_DAYS.stable),
4011
+ endorsedDays: String(ORPHAN_DEMOTE_THRESHOLD_DAYS.endorsed),
4012
+ draftDays: String(ORPHAN_DEMOTE_THRESHOLD_DAYS.draft),
4013
+ detail
4014
+ }),
4015
+ t("doctor.check.orphan_demote.remediation")
3820
4016
  );
3821
4017
  }
3822
- function createStaleArchiveCheck(inspection) {
4018
+ function createStaleArchiveCheck(t, inspection) {
3823
4019
  if (inspection.candidates.length === 0) {
3824
4020
  return okCheck(
3825
- "Knowledge stale archive",
3826
- "No draft knowledge entries exceed the additional stale-archive quiet window."
4021
+ t("doctor.check.stale_archive.name"),
4022
+ t("doctor.check.stale_archive.ok")
3827
4023
  );
3828
4024
  }
3829
4025
  const first = inspection.candidates[0];
3830
4026
  const detail = `${first.stable_id} (${first.age_days}d inactive at ${first.path}) \u2192 ${first.archive_path}`;
4027
+ const count = inspection.candidates.length;
3831
4028
  return issueCheck(
3832
- "Knowledge stale archive",
4029
+ t("doctor.check.stale_archive.name"),
3833
4030
  "warn",
3834
4031
  "warning",
3835
4032
  "knowledge_stale_archive_required",
3836
- `${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}.`,
3837
- "Run `fab doctor --apply-lint` (rc.4 TASK-003) to move stale entries into `.fabric/.archive/<type>/`."
4033
+ t(`doctor.check.stale_archive.message.${count === 1 ? "singular" : "plural"}`, {
4034
+ count: String(count),
4035
+ additionalDays: String(STALE_ARCHIVE_ADDITIONAL_DAYS),
4036
+ detail
4037
+ }),
4038
+ t("doctor.check.stale_archive.remediation")
3838
4039
  );
3839
4040
  }
3840
- function createPendingOverdueCheck(inspection) {
4041
+ function createPendingOverdueCheck(t, inspection) {
3841
4042
  if (inspection.candidates.length === 0) {
3842
4043
  return okCheck(
3843
- "Knowledge pending overdue",
3844
- "No pending knowledge entries exceed the 14-day review threshold."
4044
+ t("doctor.check.pending_overdue.name"),
4045
+ t("doctor.check.pending_overdue.ok")
3845
4046
  );
3846
4047
  }
3847
4048
  const first = inspection.candidates[0];
3848
4049
  const detail = `${first.path} (${first.age_days}d old)`;
4050
+ const count = inspection.candidates.length;
3849
4051
  return issueCheck(
3850
- "Knowledge pending overdue",
4052
+ t("doctor.check.pending_overdue.name"),
3851
4053
  "warn",
3852
4054
  "warning",
3853
4055
  "knowledge_pending_overdue",
3854
- `${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}.`,
3855
- "Review pending entries via the fabric-review Skill (`/fabric-review`) and approve, reject, defer, or modify."
4056
+ t(`doctor.check.pending_overdue.message.${count === 1 ? "singular" : "plural"}`, {
4057
+ count: String(count),
4058
+ thresholdDays: String(PENDING_OVERDUE_THRESHOLD_DAYS),
4059
+ detail
4060
+ }),
4061
+ t("doctor.check.pending_overdue.remediation")
3856
4062
  );
3857
4063
  }
3858
- function createUnderseededCheck(inspection) {
4064
+ function createUnderseededCheck(t, inspection) {
3859
4065
  if (!inspection.underseeded) {
3860
4066
  return okCheck(
3861
- "Knowledge underseeded",
3862
- `Knowledge corpus has ${inspection.node_count} canonical entries (>= ${inspection.threshold}).`
4067
+ t("doctor.check.underseeded.name"),
4068
+ t("doctor.check.underseeded.ok", {
4069
+ count: String(inspection.node_count),
4070
+ threshold: String(inspection.threshold)
4071
+ })
3863
4072
  );
3864
4073
  }
3865
4074
  return issueCheck(
3866
- "Knowledge underseeded",
4075
+ t("doctor.check.underseeded.name"),
3867
4076
  "ok",
3868
4077
  "info",
3869
4078
  "knowledge_underseeded",
3870
- `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.`,
3871
- "Run the fabric-import Skill (`/fabric-import`) to backfill knowledge from git history and existing docs."
4079
+ t(`doctor.check.underseeded.message.${inspection.node_count === 1 ? "singular" : "plural"}`, {
4080
+ count: String(inspection.node_count),
4081
+ threshold: String(inspection.threshold)
4082
+ }),
4083
+ t("doctor.check.underseeded.remediation")
3872
4084
  );
3873
4085
  }
3874
- function createSessionHintsStaleCheck(inspection) {
4086
+ function createSessionHintsStaleCheck(t, inspection) {
3875
4087
  if (inspection.candidates.length === 0) {
3876
4088
  return okCheck(
3877
- "Knowledge session-hints stale",
3878
- `No session-hints cache files older than ${SESSION_HINTS_STALE_DAYS} days under .fabric/.cache/.`
4089
+ t("doctor.check.session_hints_stale.name"),
4090
+ t("doctor.check.session_hints_stale.ok", {
4091
+ days: String(SESSION_HINTS_STALE_DAYS)
4092
+ })
3879
4093
  );
3880
4094
  }
3881
4095
  const first = inspection.candidates[0];
3882
4096
  const detail = `${first.path} (${first.age_days}d old)`;
4097
+ const count = inspection.candidates.length;
3883
4098
  return issueCheck(
3884
- "Knowledge session-hints stale",
4099
+ t("doctor.check.session_hints_stale.name"),
3885
4100
  "ok",
3886
4101
  "info",
3887
4102
  "knowledge_session_hints_stale",
3888
- `${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}.`,
3889
- "Run `fab doctor --apply-lint` to delete stale session-hints cache files."
4103
+ t(`doctor.check.session_hints_stale.message.${count === 1 ? "singular" : "plural"}`, {
4104
+ count: String(count),
4105
+ days: String(SESSION_HINTS_STALE_DAYS),
4106
+ detail
4107
+ }),
4108
+ t("doctor.check.session_hints_stale.remediation")
3890
4109
  );
3891
4110
  }
3892
- function createStaleServeLockCheck(inspection) {
4111
+ function createStaleServeLockCheck(t, inspection) {
3893
4112
  if (!inspection.present) {
3894
- return okCheck("Serve lock", "No .fabric/.serve.lock present.");
4113
+ return okCheck(
4114
+ t("doctor.check.stale_serve_lock.name"),
4115
+ t("doctor.check.stale_serve_lock.ok.no_lock")
4116
+ );
3895
4117
  }
3896
4118
  if (inspection.pidAlive) {
3897
4119
  return okCheck(
3898
- "Serve lock",
3899
- `.fabric/.serve.lock held by live PID ${inspection.pid}.`
4120
+ t("doctor.check.stale_serve_lock.name"),
4121
+ t("doctor.check.stale_serve_lock.ok.live_pid", {
4122
+ pid: String(inspection.pid)
4123
+ })
3900
4124
  );
3901
4125
  }
3902
4126
  const days = Math.floor(inspection.ageMs / MS_PER_DAY);
3903
4127
  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`;
4128
+ const acquiredAgo = days >= 1 ? t(`doctor.check.stale_serve_lock.age.day.${days === 1 ? "singular" : "plural"}`, {
4129
+ count: String(days)
4130
+ }) : t(`doctor.check.stale_serve_lock.age.hour.${hours === 1 ? "singular" : "plural"}`, {
4131
+ count: String(hours)
4132
+ });
3905
4133
  return issueCheck(
3906
- "Serve lock",
4134
+ t("doctor.check.stale_serve_lock.name"),
3907
4135
  "ok",
3908
4136
  "info",
3909
4137
  "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."
4138
+ t("doctor.check.stale_serve_lock.message.dead_pid", {
4139
+ pid: String(inspection.pid),
4140
+ acquiredAgo
4141
+ }),
4142
+ t("doctor.check.stale_serve_lock.remediation.dead_pid")
3912
4143
  );
3913
4144
  }
3914
4145
  function extractKnowledgeFrontmatterRelevanceScope(source) {
@@ -4107,64 +4338,81 @@ function readRecentGitTouchedPaths(projectRoot, windowDays) {
4107
4338
  }
4108
4339
  return Array.from(set);
4109
4340
  }
4110
- function createNarrowNoPathsCheck(inspection) {
4341
+ function createNarrowNoPathsCheck(t, inspection) {
4111
4342
  if (inspection.candidates.length === 0) {
4112
4343
  return okCheck(
4113
- "Knowledge narrow without paths",
4114
- "No narrow-scope canonical entries have an empty relevance_paths array."
4344
+ t("doctor.check.narrow_no_paths.name"),
4345
+ t("doctor.check.narrow_no_paths.ok")
4115
4346
  );
4116
4347
  }
4117
4348
  const first = inspection.candidates[0];
4118
4349
  const detail = `${first.stable_id} (${first.path})`;
4350
+ const count = inspection.candidates.length;
4119
4351
  return issueCheck(
4120
- "Knowledge narrow without paths",
4352
+ t("doctor.check.narrow_no_paths.name"),
4121
4353
  "warn",
4122
4354
  "warning",
4123
4355
  "knowledge_narrow_no_paths",
4124
- `${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}.`,
4125
- "Either add path anchors to relevance_paths or widen the entry's relevance_scope to broad."
4356
+ t(`doctor.check.narrow_no_paths.message.${count === 1 ? "singular" : "plural"}`, {
4357
+ count: String(count),
4358
+ detail
4359
+ }),
4360
+ t("doctor.check.narrow_no_paths.remediation")
4126
4361
  );
4127
4362
  }
4128
- function createRelevancePathsDanglingCheck(inspection) {
4363
+ function createRelevancePathsDanglingCheck(t, inspection) {
4129
4364
  if (inspection.entries.length === 0) {
4130
4365
  return okCheck(
4131
- "Knowledge relevance_paths dangling",
4132
- "All relevance_paths globs resolve to at least one file under the workspace root."
4366
+ t("doctor.check.relevance_paths_dangling.name"),
4367
+ t("doctor.check.relevance_paths_dangling.ok")
4133
4368
  );
4134
4369
  }
4135
4370
  const first = inspection.entries[0];
4136
4371
  const detail = `${first.stable_id} at ${first.path} \u2192 \`${first.dangling_glob}\` (0 matches)`;
4372
+ const count = inspection.entries.length;
4137
4373
  return issueCheck(
4138
- "Knowledge relevance_paths dangling",
4374
+ t("doctor.check.relevance_paths_dangling.name"),
4139
4375
  "warn",
4140
4376
  "warning",
4141
4377
  "knowledge_relevance_paths_dangling",
4142
- `${inspection.entries.length} relevance_paths glob${inspection.entries.length === 1 ? " resolves" : "s resolve"} to zero files in the current workspace. First: ${detail}.`,
4143
- "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."
4378
+ t(`doctor.check.relevance_paths_dangling.message.${count === 1 ? "singular" : "plural"}`, {
4379
+ count: String(count),
4380
+ detail
4381
+ }),
4382
+ t("doctor.check.relevance_paths_dangling.remediation")
4144
4383
  );
4145
4384
  }
4146
- function createRelevancePathsDriftCheck(inspection) {
4385
+ function createRelevancePathsDriftCheck(t, inspection) {
4147
4386
  if (!inspection.git_available) {
4148
4387
  return okCheck(
4149
- "Knowledge relevance_paths drift",
4150
- `Skipped (git history unavailable; cannot evaluate ${RELEVANCE_PATHS_DRIFT_WINDOW_DAYS}d drift window).`
4388
+ t("doctor.check.relevance_paths_drift.name"),
4389
+ t("doctor.check.relevance_paths_drift.ok.skipped", {
4390
+ windowDays: String(RELEVANCE_PATHS_DRIFT_WINDOW_DAYS)
4391
+ })
4151
4392
  );
4152
4393
  }
4153
4394
  if (inspection.candidates.length === 0) {
4154
4395
  return okCheck(
4155
- "Knowledge relevance_paths drift",
4156
- `All narrow-scope canonical entries have at least one relevance_path touched in the last ${RELEVANCE_PATHS_DRIFT_WINDOW_DAYS}d.`
4396
+ t("doctor.check.relevance_paths_drift.name"),
4397
+ t("doctor.check.relevance_paths_drift.ok.fresh", {
4398
+ windowDays: String(RELEVANCE_PATHS_DRIFT_WINDOW_DAYS)
4399
+ })
4157
4400
  );
4158
4401
  }
4159
4402
  const first = inspection.candidates[0];
4160
4403
  const detail = `${first.stable_id} at ${first.path} (globs: ${first.globs.join(", ")})`;
4404
+ const count = inspection.candidates.length;
4161
4405
  return issueCheck(
4162
- "Knowledge relevance_paths drift",
4406
+ t("doctor.check.relevance_paths_drift.name"),
4163
4407
  "ok",
4164
4408
  "info",
4165
4409
  "knowledge_relevance_paths_drift",
4166
- `${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}.`,
4167
- "Review whether the entry is still relevant \u2014 use `fab_review.modify` to refresh the anchors or `fab_review.reject` to archive."
4410
+ t(`doctor.check.relevance_paths_drift.message.${count === 1 ? "singular" : "plural"}`, {
4411
+ count: String(count),
4412
+ windowDays: String(RELEVANCE_PATHS_DRIFT_WINDOW_DAYS),
4413
+ detail
4414
+ }),
4415
+ t("doctor.check.relevance_paths_drift.remediation")
4168
4416
  );
4169
4417
  }
4170
4418
  function inspectRelevanceFieldsMissing(projectRoot) {
@@ -4307,11 +4555,11 @@ async function applyRelevanceFieldsMissing(candidate) {
4307
4555
  };
4308
4556
  }
4309
4557
  }
4310
- function createRelevanceFieldsMissingCheck(inspection) {
4558
+ function createRelevanceFieldsMissingCheck(t, inspection) {
4311
4559
  if (inspection.candidates.length === 0) {
4312
4560
  return okCheck(
4313
- "Knowledge relevance fields missing",
4314
- "All pending entries declare both relevance_scope and relevance_paths."
4561
+ t("doctor.check.relevance_fields_missing.name"),
4562
+ t("doctor.check.relevance_fields_missing.ok")
4315
4563
  );
4316
4564
  }
4317
4565
  const first = inspection.candidates[0];
@@ -4319,13 +4567,17 @@ function createRelevanceFieldsMissingCheck(inspection) {
4319
4567
  if (first.missing_scope) missingParts.push("relevance_scope");
4320
4568
  if (first.missing_paths) missingParts.push("relevance_paths");
4321
4569
  const detail = `${first.pending_path} (missing: ${missingParts.join(", ")})`;
4570
+ const count = inspection.candidates.length;
4322
4571
  return issueCheck(
4323
- "Knowledge relevance fields missing",
4572
+ t("doctor.check.relevance_fields_missing.name"),
4324
4573
  "ok",
4325
4574
  "info",
4326
4575
  "knowledge_relevance_fields_missing",
4327
- `${inspection.candidates.length} pending entr${inspection.candidates.length === 1 ? "y is" : "ies are"} missing relevance_scope and/or relevance_paths in frontmatter. First: ${detail}.`,
4328
- "Run `fab doctor --apply-lint` to back-fill the schema defaults (relevance_scope: broad, relevance_paths: [])."
4576
+ t(`doctor.check.relevance_fields_missing.message.${count === 1 ? "singular" : "plural"}`, {
4577
+ count: String(count),
4578
+ detail
4579
+ }),
4580
+ t("doctor.check.relevance_fields_missing.remediation")
4329
4581
  );
4330
4582
  }
4331
4583
  var SKILL_MD_FRONTMATTER_ROOTS = [".claude/skills", ".codex/skills"];
@@ -4396,23 +4648,26 @@ function extractSkillFrontmatterLines(raw) {
4396
4648
  }
4397
4649
  return null;
4398
4650
  }
4399
- function createSkillMdYamlInvalidCheck(inspection) {
4651
+ function createSkillMdYamlInvalidCheck(t, inspection) {
4400
4652
  if (inspection.candidates.length === 0) {
4401
4653
  return okCheck(
4402
- "Skill markdown YAML",
4403
- "All .claude/.codex SKILL.md frontmatter values parse as strict YAML."
4654
+ t("doctor.check.skill_md_yaml_invalid.name"),
4655
+ t("doctor.check.skill_md_yaml_invalid.ok")
4404
4656
  );
4405
4657
  }
4406
4658
  const first = inspection.candidates[0];
4407
4659
  const detail = `${first.path}:${first.line} (key \`${first.key}\` value contains an unquoted ': ' \u2014 preview: \`${first.preview}\`)`;
4408
4660
  const plural = inspection.candidates.length === 1;
4409
4661
  return issueCheck(
4410
- "Skill markdown YAML",
4662
+ t("doctor.check.skill_md_yaml_invalid.name"),
4411
4663
  "warn",
4412
4664
  "warning",
4413
4665
  "skill_md_yaml_invalid",
4414
- `${inspection.candidates.length} SKILL.md frontmatter ${plural ? "value contains" : "values contain"} an unquoted ': ' that strict YAML parsers reject (Claude Code tolerates it; Codex CLI drops the skill at load). First: ${detail}.`,
4415
- 'Quote the value with double quotes (`description: "\u2026"`) or rewrite the inner `key: value` token to `key=value`.'
4666
+ t(`doctor.check.skill_md_yaml_invalid.message.${plural ? "singular" : "plural"}`, {
4667
+ count: String(inspection.candidates.length),
4668
+ detail
4669
+ }),
4670
+ t("doctor.check.skill_md_yaml_invalid.remediation")
4416
4671
  );
4417
4672
  }
4418
4673
  var KNOWLEDGE_CANONICAL_TYPE_DIRS_FOR_ONBOARD = [
@@ -4507,55 +4762,83 @@ function readFrontmatterScalar(content, key) {
4507
4762
  }
4508
4763
  return void 0;
4509
4764
  }
4510
- function createOnboardCoverageCheck(inspection) {
4765
+ function createOnboardCoverageCheck(t, inspection) {
4511
4766
  const filledCount = ONBOARD_SLOT_NAMES.filter(
4512
4767
  (slot) => inspection.filled[slot].length > 0
4513
4768
  ).length;
4514
4769
  if (inspection.missing.length === 0) {
4515
4770
  return okCheck(
4516
- "Onboard coverage",
4517
- `Onboard coverage: ${filledCount}/${ONBOARD_SLOT_TOTAL} \u2713 (opted-out: ${inspection.opted_out.length}).`
4771
+ t("doctor.check.onboard_coverage.name"),
4772
+ t("doctor.check.onboard_coverage.ok.complete", {
4773
+ filledCount: String(filledCount),
4774
+ total: String(ONBOARD_SLOT_TOTAL),
4775
+ optedOutCount: String(inspection.opted_out.length)
4776
+ })
4518
4777
  );
4519
4778
  }
4520
4779
  return issueCheck(
4521
- "Onboard coverage",
4780
+ t("doctor.check.onboard_coverage.name"),
4522
4781
  "ok",
4523
4782
  "info",
4524
4783
  "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."
4784
+ t("doctor.check.onboard_coverage.message.incomplete", {
4785
+ missingSlots: inspection.missing.join(", "),
4786
+ filledCount: String(filledCount),
4787
+ total: String(ONBOARD_SLOT_TOTAL),
4788
+ optedOutCount: String(inspection.opted_out.length)
4789
+ }),
4790
+ t("doctor.check.onboard_coverage.remediation.incomplete")
4527
4791
  );
4528
4792
  }
4529
- function createNarrowTooFewCheck(inspection) {
4793
+ function createNarrowTooFewCheck(t, inspection) {
4530
4794
  const { structural_flagged, telemetry_flagged } = inspection;
4531
4795
  if (!structural_flagged && !telemetry_flagged) {
4532
4796
  const ratioPct = (inspection.narrow_ratio * 100).toFixed(0);
4533
- 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`;
4797
+ const teleNote = inspection.telemetry_skipped ? t("doctor.check.narrow_too_few.message.telemetry_skipped") : t("doctor.check.narrow_too_few.message.telemetry_window", {
4798
+ silencePct: (inspection.silence_rate * 100).toFixed(0),
4799
+ windowDays: String(SILENCE_WINDOW_DAYS)
4800
+ });
4534
4801
  return okCheck(
4535
- "Knowledge narrow too few",
4536
- `Narrow-with-paths ratio ${ratioPct}% (${inspection.narrow_with_paths_count}/${inspection.total_canonical_entries}); ${teleNote}.`
4802
+ t("doctor.check.narrow_too_few.name"),
4803
+ t("doctor.check.narrow_too_few.ok", {
4804
+ ratioPct,
4805
+ narrowCount: String(inspection.narrow_with_paths_count),
4806
+ totalCount: String(inspection.total_canonical_entries),
4807
+ teleNote
4808
+ })
4537
4809
  );
4538
4810
  }
4539
4811
  const parts = [];
4540
4812
  if (structural_flagged) {
4541
4813
  const ratioPct = (inspection.narrow_ratio * 100).toFixed(0);
4542
4814
  parts.push(
4543
- `narrow-with-paths share ${ratioPct}% (${inspection.narrow_with_paths_count}/${inspection.total_canonical_entries}) below ${(NARROW_RATIO_THRESHOLD * 100).toFixed(0)}% threshold`
4815
+ t("doctor.check.narrow_too_few.message.structural", {
4816
+ ratioPct,
4817
+ narrowCount: String(inspection.narrow_with_paths_count),
4818
+ totalCount: String(inspection.total_canonical_entries),
4819
+ thresholdPct: (NARROW_RATIO_THRESHOLD * 100).toFixed(0)
4820
+ })
4544
4821
  );
4545
4822
  }
4546
4823
  if (telemetry_flagged) {
4547
4824
  const silencePct = (inspection.silence_rate * 100).toFixed(0);
4548
4825
  parts.push(
4549
- `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`
4826
+ t("doctor.check.narrow_too_few.message.telemetry", {
4827
+ silencePct,
4828
+ silenceFires: String(inspection.silence_fires_in_window),
4829
+ totalFires: String(inspection.total_edit_fires_in_window),
4830
+ windowDays: String(SILENCE_WINDOW_DAYS),
4831
+ thresholdPct: (SILENCE_RATE_THRESHOLD * 100).toFixed(0)
4832
+ })
4550
4833
  );
4551
4834
  }
4552
4835
  return issueCheck(
4553
- "Knowledge narrow too few",
4836
+ t("doctor.check.narrow_too_few.name"),
4554
4837
  "ok",
4555
4838
  "info",
4556
4839
  "knowledge_narrow_too_few",
4557
- `Narrow-scope KB coverage is below the useful floor: ${parts.join("; ")}.`,
4558
- "Run the fabric-import Skill (`/fabric-import`) to re-seed narrow anchors against the current codebase."
4840
+ t("doctor.check.narrow_too_few.message.summary", { parts: parts.join("; ") }),
4841
+ t("doctor.check.narrow_too_few.remediation")
4559
4842
  );
4560
4843
  }
4561
4844
  function resolvePersonalKnowledgeRoot() {
@@ -4692,58 +4975,70 @@ function inspectIndexDrift(projectRoot, meta) {
4692
4975
  );
4693
4976
  return { drifts };
4694
4977
  }
4695
- function createStableIdDuplicateCheck(inspection) {
4978
+ function createStableIdDuplicateCheck(t, inspection) {
4696
4979
  if (inspection.duplicates.length === 0) {
4697
4980
  return okCheck(
4698
- "Knowledge stable_id duplicate",
4699
- "No canonical knowledge files share a stable_id across team / personal trees."
4981
+ t("doctor.check.stable_id_duplicate.name"),
4982
+ t("doctor.check.stable_id_duplicate.ok")
4700
4983
  );
4701
4984
  }
4702
4985
  const first = inspection.duplicates[0];
4703
4986
  const detail = `${first.stable_id} appears in ${first.paths.length} files: ${first.paths.join(", ")}`;
4987
+ const count = inspection.duplicates.length;
4704
4988
  return issueCheck(
4705
- "Knowledge stable_id duplicate",
4989
+ t("doctor.check.stable_id_duplicate.name"),
4706
4990
  "error",
4707
4991
  "manual_error",
4708
4992
  "knowledge_stable_id_duplicate",
4709
- `${inspection.duplicates.length} stable_id${inspection.duplicates.length === 1 ? "" : "s"} duplicated across canonical knowledge files (path-decoupled identity invariant). First: ${detail}.`,
4710
- "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."
4993
+ t(`doctor.check.stable_id_duplicate.message.${count === 1 ? "singular" : "plural"}`, {
4994
+ count: String(count),
4995
+ detail
4996
+ }),
4997
+ t("doctor.check.stable_id_duplicate.remediation")
4711
4998
  );
4712
4999
  }
4713
- function createLayerMismatchCheck(inspection) {
5000
+ function createLayerMismatchCheck(t, inspection) {
4714
5001
  if (inspection.mismatches.length === 0) {
4715
5002
  return okCheck(
4716
- "Knowledge layer mismatch",
4717
- "All canonical knowledge files are physically located under the layer their stable_id prefix declares."
5003
+ t("doctor.check.layer_mismatch.name"),
5004
+ t("doctor.check.layer_mismatch.ok")
4718
5005
  );
4719
5006
  }
4720
5007
  const first = inspection.mismatches[0];
4721
5008
  const detail = `${first.stable_id} at ${first.path} (located in ${first.located_in}, expected ${first.expected_layer})`;
5009
+ const count = inspection.mismatches.length;
4722
5010
  return issueCheck(
4723
- "Knowledge layer mismatch",
5011
+ t("doctor.check.layer_mismatch.name"),
4724
5012
  "error",
4725
5013
  "manual_error",
4726
5014
  "knowledge_layer_mismatch",
4727
- `${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}.`,
4728
- "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)."
5015
+ t(`doctor.check.layer_mismatch.message.${count === 1 ? "singular" : "plural"}`, {
5016
+ count: String(count),
5017
+ detail
5018
+ }),
5019
+ t("doctor.check.layer_mismatch.remediation")
4729
5020
  );
4730
5021
  }
4731
- function createIndexDriftCheck(inspection) {
5022
+ function createIndexDriftCheck(t, inspection) {
4732
5023
  if (inspection.drifts.length === 0) {
4733
5024
  return okCheck(
4734
- "Knowledge index drift",
4735
- "agents.meta.json counters envelope is at or above the highest existing canonical counter for every (layer, type) pair."
5025
+ t("doctor.check.index_drift.name"),
5026
+ t("doctor.check.index_drift.ok")
4736
5027
  );
4737
5028
  }
4738
5029
  const first = inspection.drifts[0];
4739
5030
  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})`;
5031
+ const count = inspection.drifts.length;
4740
5032
  return issueCheck(
4741
- "Knowledge index drift",
5033
+ t("doctor.check.index_drift.name"),
4742
5034
  "error",
4743
5035
  "fixable_error",
4744
5036
  "knowledge_index_drift",
4745
- `${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}.`,
4746
- "Run `fab doctor --apply-lint` (rc.4 TASK-003) to bump agents.meta.json counters to max_observed + 1."
5037
+ t(`doctor.check.index_drift.message.${count === 1 ? "singular" : "plural"}`, {
5038
+ count: String(count),
5039
+ detail
5040
+ }),
5041
+ t("doctor.check.index_drift.remediation")
4747
5042
  );
4748
5043
  }
4749
5044
  async function migrateBootstrapMarkers(projectRoot) {
@@ -4963,6 +5258,40 @@ async function ensureCitePolicyActivatedMarker(projectRoot) {
4963
5258
  return { marker_ts: 0, emitted_now: false };
4964
5259
  }
4965
5260
  }
5261
+ async function ensureCiteContractPolicyActivatedMarker(projectRoot) {
5262
+ let driftStatus;
5263
+ try {
5264
+ const inspection = await inspectL1BootstrapSnapshotDrift(projectRoot);
5265
+ driftStatus = inspection.status;
5266
+ } catch {
5267
+ driftStatus = "drift";
5268
+ }
5269
+ if (driftStatus !== "ok") {
5270
+ return { marker_ts: 0, emitted_now: false, blocked_by: "bootstrap_drift" };
5271
+ }
5272
+ let existing;
5273
+ try {
5274
+ const { events } = await readEventLedger(projectRoot, {
5275
+ event_type: "cite_contract_policy_activated"
5276
+ });
5277
+ if (events.length > 0) {
5278
+ existing = events[0];
5279
+ }
5280
+ } catch {
5281
+ return { marker_ts: 0, emitted_now: false, blocked_by: null };
5282
+ }
5283
+ if (existing !== void 0) {
5284
+ return { marker_ts: existing.ts, emitted_now: false, blocked_by: null };
5285
+ }
5286
+ try {
5287
+ const stored = await appendEventLedgerEvent(projectRoot, {
5288
+ event_type: "cite_contract_policy_activated"
5289
+ });
5290
+ return { marker_ts: stored.ts, emitted_now: true, blocked_by: null };
5291
+ } catch {
5292
+ return { marker_ts: 0, emitted_now: false, blocked_by: null };
5293
+ }
5294
+ }
4966
5295
  function parseNoneSentinel(kbLineRaw) {
4967
5296
  if (typeof kbLineRaw !== "string" || kbLineRaw.length === 0) return "unspecified";
4968
5297
  const m = kbLineRaw.match(/^KB:\s*none\b\s*(?:\[([^\]]*)\])?\s*$/i);
@@ -5000,7 +5329,10 @@ function matchesRelevancePath(editPath, relevancePaths) {
5000
5329
  return false;
5001
5330
  }
5002
5331
  async function runDoctorCiteCoverage(projectRoot, options) {
5332
+ const layerFilter = options.layer ?? "all";
5003
5333
  const marker = await ensureCitePolicyActivatedMarker(projectRoot);
5334
+ const contractMarker = await ensureCiteContractPolicyActivatedMarker(projectRoot);
5335
+ const idTypeMap = await loadKbIdTypeMap(projectRoot);
5004
5336
  const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
5005
5337
  const zeroMetrics = {
5006
5338
  edits_touched: 0,
@@ -5009,6 +5341,20 @@ async function runDoctorCiteCoverage(projectRoot, options) {
5009
5341
  expected_but_missed: 0,
5010
5342
  total_turns: 0
5011
5343
  };
5344
+ const contractStatus = contractMarker.blocked_by === "bootstrap_drift" ? "skipped:bootstrap_drift" : contractMarker.marker_ts === 0 ? "awaiting_marker" : "ok";
5345
+ const zeroContractMetrics = {
5346
+ decisions_cited: 0,
5347
+ pitfalls_cited: 0,
5348
+ contract_with: 0,
5349
+ contract_missing: 0,
5350
+ hard_violated: 0,
5351
+ cite_id_unresolved: 0,
5352
+ skip_count: {}
5353
+ };
5354
+ const zeroLayerType = {
5355
+ team: {},
5356
+ personal: {}
5357
+ };
5012
5358
  if (marker.marker_ts === 0) {
5013
5359
  return {
5014
5360
  status: "skipped",
@@ -5016,11 +5362,17 @@ async function runDoctorCiteCoverage(projectRoot, options) {
5016
5362
  marker_emitted_now: false,
5017
5363
  since_ts: options.since,
5018
5364
  client_filter: options.client,
5365
+ layer_filter: layerFilter,
5019
5366
  metrics: zeroMetrics,
5367
+ contract_metrics_status: contractStatus,
5368
+ contract_metrics: zeroContractMetrics,
5369
+ per_layer_type: zeroLayerType,
5370
+ contract_marker_ts: contractMarker.marker_ts,
5020
5371
  generated_at: generatedAt
5021
5372
  };
5022
5373
  }
5023
5374
  const effectiveSince = Math.max(marker.marker_ts, options.since);
5375
+ const contractEffectiveSince = contractStatus === "ok" ? Math.max(contractMarker.marker_ts, options.since) : Number.POSITIVE_INFINITY;
5024
5376
  let ledgerEvents = [];
5025
5377
  try {
5026
5378
  const result = await readEventLedger(projectRoot, { since: effectiveSince });
@@ -5032,7 +5384,12 @@ async function runDoctorCiteCoverage(projectRoot, options) {
5032
5384
  marker_emitted_now: marker.emitted_now,
5033
5385
  since_ts: effectiveSince,
5034
5386
  client_filter: options.client,
5387
+ layer_filter: layerFilter,
5035
5388
  metrics: zeroMetrics,
5389
+ contract_metrics_status: contractStatus,
5390
+ contract_metrics: zeroContractMetrics,
5391
+ per_layer_type: zeroLayerType,
5392
+ contract_marker_ts: contractMarker.marker_ts,
5036
5393
  generated_at: generatedAt
5037
5394
  };
5038
5395
  }
@@ -5054,7 +5411,7 @@ async function runDoctorCiteCoverage(projectRoot, options) {
5054
5411
  break;
5055
5412
  }
5056
5413
  }
5057
- const filteredTurns = options.client === "all" ? assistantTurns : assistantTurns.filter((t2) => t2.client === options.client);
5414
+ const filteredTurns = options.client === "all" ? assistantTurns : assistantTurns.filter((t) => t.client === options.client);
5058
5415
  let clientSessionIds = null;
5059
5416
  if (options.client !== "all") {
5060
5417
  clientSessionIds = /* @__PURE__ */ new Set();
@@ -5125,6 +5482,80 @@ async function runDoctorCiteCoverage(projectRoot, options) {
5125
5482
  perClientAccum.set(client, existing);
5126
5483
  };
5127
5484
  const sessionCitedKbs = /* @__PURE__ */ new Map();
5485
+ const sessionEditPaths = /* @__PURE__ */ new Map();
5486
+ for (const edit of editEvents) {
5487
+ const sid = edit.session_id;
5488
+ if (typeof sid !== "string" || sid.length === 0) continue;
5489
+ const list = sessionEditPaths.get(sid) ?? [];
5490
+ list.push(normalizePath(edit.path));
5491
+ sessionEditPaths.set(sid, list);
5492
+ }
5493
+ let decisionsCited = 0;
5494
+ let pitfallsCited = 0;
5495
+ let contractWith = 0;
5496
+ let contractMissing = 0;
5497
+ let hardViolated = 0;
5498
+ let citeIdUnresolved = 0;
5499
+ const skipCount = {};
5500
+ const layerTypeAccum = { team: {}, personal: {} };
5501
+ const bumpLayerType = (citeId, type) => {
5502
+ const layer = citeId.startsWith("KP-") ? "personal" : citeId.startsWith("KT-") ? "team" : null;
5503
+ if (layer === null) return;
5504
+ layerTypeAccum[layer][type] = (layerTypeAccum[layer][type] ?? 0) + 1;
5505
+ };
5506
+ const passesLayerFilter = (citeId) => {
5507
+ if (layerFilter === "all") return true;
5508
+ if (layerFilter === "team") return citeId.startsWith("KT-");
5509
+ return citeId.startsWith("KP-");
5510
+ };
5511
+ const evaluateOperatorViolation = (sessionId, operators) => {
5512
+ const editPaths = typeof sessionId === "string" && sessionId.length > 0 ? sessionEditPaths.get(sessionId) ?? [] : [];
5513
+ for (const op of operators) {
5514
+ switch (op.kind) {
5515
+ case "edit": {
5516
+ let matched = false;
5517
+ for (const p of editPaths) {
5518
+ if (minimatch(p, op.target, { dot: true, matchBase: false })) {
5519
+ matched = true;
5520
+ break;
5521
+ }
5522
+ }
5523
+ if (!matched) return true;
5524
+ break;
5525
+ }
5526
+ case "not_edit": {
5527
+ for (const p of editPaths) {
5528
+ if (minimatch(p, op.target, { dot: true, matchBase: false })) {
5529
+ return true;
5530
+ }
5531
+ }
5532
+ break;
5533
+ }
5534
+ case "require": {
5535
+ let found = false;
5536
+ for (const p of editPaths) {
5537
+ if (p.includes(op.target)) {
5538
+ found = true;
5539
+ break;
5540
+ }
5541
+ }
5542
+ if (!found) return true;
5543
+ break;
5544
+ }
5545
+ case "forbid": {
5546
+ for (const p of editPaths) {
5547
+ if (p.includes(op.target)) {
5548
+ return true;
5549
+ }
5550
+ }
5551
+ break;
5552
+ }
5553
+ default:
5554
+ break;
5555
+ }
5556
+ }
5557
+ return false;
5558
+ };
5128
5559
  let totalTurns = 0;
5129
5560
  let qualifyingCites = 0;
5130
5561
  let recalledUnverified = 0;
@@ -5174,6 +5605,40 @@ async function runDoctorCiteCoverage(projectRoot, options) {
5174
5605
  m.recalled_unverified += 1;
5175
5606
  });
5176
5607
  }
5608
+ if (contractStatus === "ok" && turn.ts >= contractEffectiveSince) {
5609
+ const commitments = turn.cite_commitments ?? [];
5610
+ for (let i = 0; i < turn.cite_ids.length; i += 1) {
5611
+ const citeId = turn.cite_ids[i];
5612
+ if (typeof citeId !== "string" || citeId.length === 0) continue;
5613
+ if (!passesLayerFilter(citeId)) continue;
5614
+ const kbType = idTypeMap.get(citeId);
5615
+ if (kbType === void 0) {
5616
+ citeIdUnresolved += 1;
5617
+ bumpLayerType(citeId, "unresolved");
5618
+ continue;
5619
+ }
5620
+ bumpLayerType(citeId, kbType);
5621
+ if (kbType === "decision" || kbType === "pitfall") {
5622
+ if (kbType === "decision") decisionsCited += 1;
5623
+ else pitfallsCited += 1;
5624
+ const commitment = commitments[i];
5625
+ const operators = commitment?.operators ?? [];
5626
+ const skipReason = commitment?.skip_reason ?? null;
5627
+ if (skipReason !== null) {
5628
+ skipCount[skipReason] = (skipCount[skipReason] ?? 0) + 1;
5629
+ continue;
5630
+ }
5631
+ if (operators.length === 0) {
5632
+ contractMissing += 1;
5633
+ continue;
5634
+ }
5635
+ contractWith += 1;
5636
+ if (evaluateOperatorViolation(sid, operators)) {
5637
+ hardViolated += 1;
5638
+ }
5639
+ }
5640
+ }
5641
+ }
5177
5642
  }
5178
5643
  let editsTouched = 0;
5179
5644
  let expectedButMissed = 0;
@@ -5208,19 +5673,96 @@ async function runDoctorCiteCoverage(projectRoot, options) {
5208
5673
  perClient[client] = m;
5209
5674
  }
5210
5675
  }
5676
+ const contractMetrics = {
5677
+ decisions_cited: decisionsCited,
5678
+ pitfalls_cited: pitfallsCited,
5679
+ contract_with: contractWith,
5680
+ contract_missing: contractMissing,
5681
+ hard_violated: hardViolated,
5682
+ cite_id_unresolved: citeIdUnresolved,
5683
+ skip_count: skipCount
5684
+ };
5211
5685
  return {
5212
5686
  status: "ok",
5213
5687
  marker_ts: marker.marker_ts,
5214
5688
  marker_emitted_now: marker.emitted_now,
5215
5689
  since_ts: effectiveSince,
5216
5690
  client_filter: options.client,
5691
+ layer_filter: layerFilter,
5217
5692
  metrics,
5218
5693
  ...perClient !== void 0 ? { per_client: perClient } : {},
5219
5694
  ...Object.keys(dismissedHistogram).length > 0 ? { dismissed_reason_histogram: dismissedHistogram } : {},
5220
5695
  ...Object.keys(noneHistogram).length > 0 ? { none_reason_histogram: noneHistogram } : {},
5696
+ contract_metrics_status: contractStatus,
5697
+ contract_metrics: contractMetrics,
5698
+ per_layer_type: layerTypeAccum,
5699
+ contract_marker_ts: contractMarker.marker_ts,
5700
+ generated_at: generatedAt
5701
+ };
5702
+ }
5703
+ async function runDoctorArchiveHistory(projectRoot, options) {
5704
+ const generatedAt = (/* @__PURE__ */ new Date()).toISOString();
5705
+ const nowMs = Date.now();
5706
+ let events = [];
5707
+ try {
5708
+ const result = await readEventLedger(projectRoot, {
5709
+ event_type: "session_archive_attempted",
5710
+ since: options.since
5711
+ });
5712
+ events = result.events;
5713
+ } catch {
5714
+ return {
5715
+ entries: [],
5716
+ total: 0,
5717
+ since_ms: options.since,
5718
+ generated_at: generatedAt
5719
+ };
5720
+ }
5721
+ const mostRecentBySession = /* @__PURE__ */ new Map();
5722
+ for (const event of events) {
5723
+ if (event.event_type !== "session_archive_attempted") {
5724
+ continue;
5725
+ }
5726
+ const sessionId = event.session_id;
5727
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
5728
+ continue;
5729
+ }
5730
+ const prior = mostRecentBySession.get(sessionId);
5731
+ if (prior === void 0 || event.ts > prior.ts) {
5732
+ mostRecentBySession.set(sessionId, event);
5733
+ }
5734
+ }
5735
+ const entries = [];
5736
+ for (const [sessionId, event] of mostRecentBySession.entries()) {
5737
+ const ageHours = Math.max(
5738
+ 0,
5739
+ Math.floor((nowMs - event.covered_through_ts) / 36e5)
5740
+ );
5741
+ entries.push({
5742
+ session_id_short: truncateSessionId(sessionId),
5743
+ last_attempted_at: new Date(event.ts).toISOString(),
5744
+ outcome: event.outcome,
5745
+ candidates_proposed: event.candidates_proposed,
5746
+ covered_through_ts: event.covered_through_ts,
5747
+ age_since_covered_hours: ageHours
5748
+ });
5749
+ }
5750
+ entries.sort(
5751
+ (a, b) => a.last_attempted_at < b.last_attempted_at ? 1 : a.last_attempted_at > b.last_attempted_at ? -1 : 0
5752
+ );
5753
+ return {
5754
+ entries,
5755
+ total: entries.length,
5756
+ since_ms: options.since,
5221
5757
  generated_at: generatedAt
5222
5758
  };
5223
5759
  }
5760
+ function truncateSessionId(sessionId) {
5761
+ if (sessionId.length <= 8) {
5762
+ return sessionId;
5763
+ }
5764
+ return `${sessionId.slice(0, 8)}...`;
5765
+ }
5224
5766
  function createFixMessage(fixed, report) {
5225
5767
  const fixedText = fixed.length === 0 ? "No deterministic doctor fixes were needed." : `Applied ${fixed.length} deterministic doctor fix${fixed.length === 1 ? "" : "es"}.`;
5226
5768
  const manualText = report.manual_errors.length === 0 ? "No manual errors remain." : `${report.manual_errors.length} manual error${report.manual_errors.length === 1 ? "" : "s"} remain.`;
@@ -5734,6 +6276,7 @@ export {
5734
6276
  appendEventLedgerEvent,
5735
6277
  readEventLedger,
5736
6278
  flushAndSyncEventLedger,
6279
+ loadKbIdTypeMap,
5737
6280
  buildKnowledgeMeta,
5738
6281
  writeKnowledgeMeta,
5739
6282
  computeKnowledgeBasedAgentsMeta,
@@ -5758,5 +6301,6 @@ export {
5758
6301
  runDoctorFix,
5759
6302
  runDoctorApplyLint,
5760
6303
  runDoctorCiteCoverage,
6304
+ runDoctorArchiveHistory,
5761
6305
  enrichDescriptions
5762
6306
  };