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

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.
@@ -184,6 +184,46 @@ var EVENT_LEDGER_DEFAULT_RETENTION_DAYS = 30;
184
184
  var EVENT_LEDGER_SIZE_WARN_BYTES = 50 * 1024 * 1024;
185
185
  var EVENT_LEDGER_ARCHIVE_DIR = ".fabric/events.archive";
186
186
  var warnedOversize = false;
187
+ var knownEventTypesCache = null;
188
+ function getKnownEventTypes() {
189
+ if (knownEventTypesCache !== null) return knownEventTypesCache;
190
+ const set = /* @__PURE__ */ new Set();
191
+ for (const opt of eventLedgerEventSchema.options) {
192
+ const shape = opt.shape;
193
+ if (shape && typeof shape.event_type?.value === "string") {
194
+ set.add(shape.event_type.value);
195
+ }
196
+ }
197
+ knownEventTypesCache = set;
198
+ return set;
199
+ }
200
+ function classifyRejection(line, index) {
201
+ let parsed;
202
+ try {
203
+ parsed = JSON.parse(line);
204
+ } catch {
205
+ return null;
206
+ }
207
+ if (parsed === null || typeof parsed !== "object") return null;
208
+ if ("schema_version" in parsed && parsed.schema_version !== 1 && (typeof parsed.schema_version === "number" || parsed.schema_version === null)) {
209
+ return {
210
+ kind: "schema_version_unsupported",
211
+ line_index: index,
212
+ schema_version: parsed.schema_version,
213
+ snippet_first_120: line.slice(0, 120)
214
+ };
215
+ }
216
+ const known = getKnownEventTypes();
217
+ if (typeof parsed.event_type === "string" && !known.has(parsed.event_type)) {
218
+ return {
219
+ kind: "event_type_unknown",
220
+ line_index: index,
221
+ event_type: parsed.event_type,
222
+ snippet_first_120: line.slice(0, 120)
223
+ };
224
+ }
225
+ return null;
226
+ }
187
227
  async function appendEventLedgerEvent(projectRoot, event) {
188
228
  const eventPath = getEventLedgerPath(projectRoot);
189
229
  const nextEvent = eventLedgerEventSchema.parse({
@@ -238,8 +278,20 @@ async function readEventLedger(projectRoot, options = {}) {
238
278
  snippet_first_120: partialLine.slice(0, 120)
239
279
  });
240
280
  }
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
- return { events, warnings };
281
+ const trimmed = lines.map((line) => line.trim()).filter((line) => line.length > 0);
282
+ const events = [];
283
+ for (let i = 0; i < trimmed.length; i++) {
284
+ const line = trimmed[i];
285
+ const parsed = parseEventLedgerLine(line, i);
286
+ if (parsed !== null) {
287
+ events.push(parsed);
288
+ continue;
289
+ }
290
+ const rejection = classifyRejection(line, i);
291
+ if (rejection !== null) warnings.push(rejection);
292
+ }
293
+ const filtered = events.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);
294
+ return { events: filtered, warnings };
243
295
  }
244
296
  async function truncateLedgerToLastNewline(path2) {
245
297
  const raw = await readFile2(path2);
@@ -951,16 +1003,18 @@ function extractRuleDescription(source) {
951
1003
  }
952
1004
  const heading = /^#\s+(.+?)\s*$/mu.exec(source);
953
1005
  const summary = heading?.[1]?.trim();
954
- if (summary === void 0 || summary.length === 0) {
1006
+ const knowledge = frontmatter !== null ? extractKnowledgeFieldsFromFrontmatter(frontmatter[1]) : void 0;
1007
+ const isStructurallyAKnowledgeEntry = summary !== void 0 && summary.length > 0 ? true : knowledge !== void 0 && (knowledge.id !== void 0 || knowledge.knowledge_type !== void 0 || knowledge.tags !== void 0 && knowledge.tags.length > 0);
1008
+ if (!isStructurallyAKnowledgeEntry) {
955
1009
  return void 0;
956
1010
  }
957
- const knowledge = frontmatter !== null ? extractKnowledgeFieldsFromFrontmatter(frontmatter[1]) : void 0;
1011
+ const synthesizedSummary = summary !== void 0 && summary.length > 0 ? summary : knowledge?.id ?? (knowledge?.tags !== void 0 && knowledge.tags.length > 0 ? `(unnamed; tags: ${knowledge.tags.join(", ")})` : "(unnamed knowledge entry)");
958
1012
  return {
959
- summary,
1013
+ summary: synthesizedSummary,
960
1014
  intent_clues: [],
961
1015
  tech_stack: [],
962
1016
  impact: [],
963
- must_read_if: summary,
1017
+ must_read_if: synthesizedSummary,
964
1018
  // v2.0-rc.22: when frontmatter is present, merge its knowledge fields;
965
1019
  // when fully absent (no `---` block), all knowledge fields stay
966
1020
  // undefined, matching the original heading-only fallback contract.
@@ -1473,10 +1527,9 @@ async function reconcileKnowledge(projectRoot, opts) {
1473
1527
  // src/services/serve-lock.ts
1474
1528
  import fs from "fs";
1475
1529
  import path from "path";
1476
- import { createTranslator, detectNodeLocale } from "@fenglimg/fabric-shared";
1530
+ import { createTranslator, resolveFabricLocale } from "@fenglimg/fabric-shared";
1477
1531
  import { IOFabricError as IOFabricError2 } from "@fenglimg/fabric-shared/errors";
1478
1532
  var LOCK_FILENAME = ".serve.lock";
1479
- var t = createTranslator(detectNodeLocale());
1480
1533
  var ServeLockHeldError = class extends IOFabricError2 {
1481
1534
  code = "SERVE_LOCK_HELD";
1482
1535
  httpStatus = 423;
@@ -1504,6 +1557,7 @@ function acquireLock(projectRoot, opts) {
1504
1557
  } catch {
1505
1558
  }
1506
1559
  if (state && state.pid && state.pid !== process.pid && isAlive(state.pid) && !opts?.force) {
1560
+ const t = createTranslator(resolveFabricLocale(projectRoot));
1507
1561
  throw new ServeLockHeldError(
1508
1562
  `serve lock held by live PID ${state.pid}`,
1509
1563
  {
@@ -1554,6 +1608,7 @@ function checkLockOrThrow(projectRoot, opts) {
1554
1608
  return;
1555
1609
  }
1556
1610
  if (opts?.force) return;
1611
+ const t = createTranslator(resolveFabricLocale(projectRoot));
1557
1612
  throw new ServeLockHeldError(
1558
1613
  `serve lock held by live PID ${state.pid}`,
1559
1614
  {
@@ -1574,6 +1629,7 @@ import { minimatch } from "minimatch";
1574
1629
  import {
1575
1630
  agentsMetaSchema as agentsMetaSchema4,
1576
1631
  AgentsMetaCountersSchema,
1632
+ createTranslator as createTranslator2,
1577
1633
  forensicReportSchema,
1578
1634
  parseKnowledgeId as parseKnowledgeId2,
1579
1635
  knowledgeTestIndexSchema as knowledgeTestIndexSchema2,
@@ -1583,7 +1639,8 @@ import {
1583
1639
  BOOTSTRAP_MARKER_END,
1584
1640
  BOOTSTRAP_REGEX,
1585
1641
  ONBOARD_SLOT_NAMES,
1586
- ONBOARD_SLOT_TOTAL
1642
+ ONBOARD_SLOT_TOTAL,
1643
+ resolveFabricLocale as resolveFabricLocale2
1587
1644
  } from "@fenglimg/fabric-shared";
1588
1645
  import { detectFramework } from "@fenglimg/fabric-shared/node";
1589
1646
  import { atomicWriteJson as atomicWriteJson2, atomicWriteText as atomicWriteText4 } from "@fenglimg/fabric-shared/node/atomic-write";
@@ -1669,6 +1726,7 @@ var TARGET_FILE_PATHS = [
1669
1726
  ];
1670
1727
  async function runDoctorReport(target) {
1671
1728
  const projectRoot = normalizeTarget(target);
1729
+ const t = createTranslator2(resolveFabricLocale2(projectRoot));
1672
1730
  const framework = detectFramework(projectRoot);
1673
1731
  const entryPoints = collectEntryPoints(projectRoot);
1674
1732
  const [
@@ -1722,91 +1780,95 @@ async function runDoctorReport(target) {
1722
1780
  const skillMdYamlInvalid = inspectSkillMdYamlInvalid(projectRoot);
1723
1781
  const onboardCoverage = inspectOnboardCoverage(projectRoot);
1724
1782
  const checks = [
1725
- createBootstrapAnchorCheck(bootstrapAnchor),
1783
+ createBootstrapAnchorCheck(t, bootstrapAnchor),
1726
1784
  // v2.0.0-rc.19 TASK-004: bootstrap marker migration check sits adjacent to
1727
1785
  // the anchor check — both are bootstrap-file invariants. fixable_error
1728
1786
  // when any of the four target paths still carries the legacy marker.
1729
- createBootstrapMarkerMigrationCheck(bootstrapMarkerMigration),
1787
+ createBootstrapMarkerMigrationCheck(t, bootstrapMarkerMigration),
1730
1788
  // v2.0.0-rc.19 TASK-005: L1 + L2 byte-level drift detection sit immediately
1731
1789
  // after the marker migration check. Order: anchor existence → migration →
1732
1790
  // L1 (canonical ↔ snapshot) → L2 (snapshot+rules ↔ three-end blocks).
1733
- createL1BootstrapSnapshotDriftCheck(l1BootstrapSnapshotDrift),
1734
- createL2ManagedBlockDriftCheck(l2ManagedBlockDrift),
1735
- createKnowledgeDirMissingCheck(knowledgeDirMissing),
1791
+ createL1BootstrapSnapshotDriftCheck(t, l1BootstrapSnapshotDrift),
1792
+ createL2ManagedBlockDriftCheck(t, l2ManagedBlockDrift),
1793
+ createKnowledgeDirMissingCheck(t, knowledgeDirMissing),
1736
1794
  // v2.0.0-rc.22 TASK-006: baseline filename format. Sits adjacent to
1737
1795
  // knowledge_dir_missing — both are knowledge-layout invariants. manual_error
1738
1796
  // kind; resolution is manual file deletion (rc.23 TASK-012 (F8a) removed
1739
1797
  // the baseline-emit pipeline, so no auto-fix exists).
1740
- createBaselineFilenameFormatCheck(baselineFilenameFormat),
1741
- createForensicCheck(forensic, framework.kind, entryPoints.length),
1798
+ createBaselineFilenameFormatCheck(t, baselineFilenameFormat),
1799
+ createForensicCheck(t, forensic, framework.kind, entryPoints.length),
1742
1800
  // v2.0: removed `createInitContextCheck` — `.fabric/init-context.json`
1743
1801
  // is owned by the AI-side client init skill, not by `fabric install` CLI.
1744
1802
  // The file's absence is a legitimate post-init state when the skill has
1745
1803
  // not yet run, so flagging it as a doctor manual_error misrepresents
1746
1804
  // ownership.
1747
- createMetaCheck(meta),
1748
- createRuleContentRefCheck(meta),
1805
+ createMetaCheck(t, meta),
1806
+ createRuleContentRefCheck(t, meta),
1749
1807
  // v2.0 / rc.2: `createRuleSectionsCheck` removed — it parsed v1.x
1750
1808
  // [MANDATORY_INJECTION] sections out of legacy rule files, a structural
1751
1809
  // concept that has no v2 equivalent. rc.4 will introduce a dedicated v2
1752
1810
  // lint suite for the new knowledge frontmatter contract.
1753
- createKnowledgeTestIndexCheck(knowledgeTestIndex),
1754
- createEventLedgerCheck(eventLedger),
1755
- createEventLedgerPartialWriteCheck(eventLedger),
1756
- createMcpConfigInWrongFileCheck(mcpConfigInWrongFile),
1757
- createMetaManuallyDivergedCheck(metaManuallyDiverged),
1758
- createKnowledgeDirUnindexedCheck(knowledgeDirUnindexed),
1759
- createStableIdCollisionCheck(stableIdCollision),
1760
- createCounterDesyncCheck(counterDesync),
1761
- createFilesystemEditFallbackCheck(filesystemEditFallback),
1811
+ createKnowledgeTestIndexCheck(t, knowledgeTestIndex),
1812
+ createEventLedgerCheck(t, eventLedger),
1813
+ createEventLedgerPartialWriteCheck(t, eventLedger),
1814
+ // v2.0.0-rc.27 TASK-010 (audit §2.24): forward-compat warning surface for
1815
+ // events.jsonl rows that fail Zod validation because of unknown
1816
+ // schema_version or event_type tokens. Previously silently dropped.
1817
+ createEventLedgerSchemaCompatCheck(t, eventLedger),
1818
+ createMcpConfigInWrongFileCheck(t, mcpConfigInWrongFile),
1819
+ createMetaManuallyDivergedCheck(t, metaManuallyDiverged),
1820
+ createKnowledgeDirUnindexedCheck(t, knowledgeDirUnindexed),
1821
+ createStableIdCollisionCheck(t, stableIdCollision),
1822
+ createCounterDesyncCheck(t, counterDesync),
1823
+ createFilesystemEditFallbackCheck(t, filesystemEditFallback),
1762
1824
  // rc.4 TASK-001: read-side lint checks #16-18. Findings only — mutation
1763
1825
  // + event emission lands in TASK-003 behind --apply-lint.
1764
- createOrphanDemoteCheck(orphanDemote),
1765
- createStaleArchiveCheck(staleArchive),
1766
- createPendingOverdueCheck(pendingOverdue),
1826
+ createOrphanDemoteCheck(t, orphanDemote),
1827
+ createStaleArchiveCheck(t, staleArchive),
1828
+ createPendingOverdueCheck(t, pendingOverdue),
1767
1829
  // rc.4 TASK-002: read-side integrity checks #19-21. Stable_id duplicate
1768
1830
  // runs first in this trio — it is the most critical integrity break and
1769
1831
  // surfaces ahead of layer-mismatch / index-drift in the report so a
1770
1832
  // human operator triages the collision before reasoning about counter
1771
1833
  // state. Index drift is the only fixable_error of the three; stable_id
1772
1834
  // duplicate and layer mismatch require manual triage (rename / move).
1773
- createStableIdDuplicateCheck(stableIdDuplicate),
1774
- createLayerMismatchCheck(layerMismatch),
1775
- createIndexDriftCheck(indexDrift),
1835
+ createStableIdDuplicateCheck(t, stableIdDuplicate),
1836
+ createLayerMismatchCheck(t, layerMismatch),
1837
+ createIndexDriftCheck(t, indexDrift),
1776
1838
  // rc.5 TASK-010: read-side underseeded-corpus check (#22). Info kind —
1777
1839
  // does not bump report status. Recommends running the fabric-import skill
1778
1840
  // to backfill knowledge when the corpus is below the threshold floor.
1779
- createUnderseededCheck(underseeded),
1841
+ createUnderseededCheck(t, underseeded),
1780
1842
  // rc.5 TASK-013 (C4): relevance_paths hygiene checks #23/#24/#25.
1781
1843
  // All three are flag-only in rc.5 (no apply-lint mutations).
1782
1844
  // #23 narrow_no_paths — warning kind (silent recall risk)
1783
1845
  // #24 relevance_paths_dangling — warning kind (glob → zero matches)
1784
1846
  // #25 relevance_paths_drift — info kind (git-log heuristic; noisy)
1785
- createNarrowNoPathsCheck(narrowNoPaths),
1786
- createRelevancePathsDanglingCheck(relevancePathsDangling),
1787
- createRelevancePathsDriftCheck(relevancePathsDrift),
1847
+ createNarrowNoPathsCheck(t, narrowNoPaths),
1848
+ createRelevancePathsDanglingCheck(t, relevancePathsDangling),
1849
+ createRelevancePathsDriftCheck(t, relevancePathsDrift),
1788
1850
  // rc.6 TASK-023 (E6): narrow_too_few (lint #26). Info kind; both arms
1789
1851
  // (structural + telemetry) recommend the same fabric-import action.
1790
- createNarrowTooFewCheck(narrowTooFew),
1852
+ createNarrowTooFewCheck(t, narrowTooFew),
1791
1853
  // rc.6 TASK-021 (E3): session-hints cache hygiene (lint #27). Info kind.
1792
- createSessionHintsStaleCheck(sessionHintsStale),
1854
+ createSessionHintsStaleCheck(t, sessionHintsStale),
1793
1855
  // rc.23 TASK-010 (e): stale .fabric/.serve.lock advisory. Info kind —
1794
1856
  // does not bump report status. `--fix` unlinks the corpse and emits
1795
1857
  // `serve_lock_cleared`.
1796
- createStaleServeLockCheck(staleServeLock),
1858
+ createStaleServeLockCheck(t, staleServeLock),
1797
1859
  // v2.0.0-rc.9 TASK-003 (A3): relevance fields back-fill (lint #28).
1798
1860
  // Info kind — applies to pending entries only; canonical entries get
1799
1861
  // the fields written verbatim by fab_review.approve/modify.
1800
- createRelevanceFieldsMissingCheck(relevanceFieldsMissing),
1862
+ createRelevanceFieldsMissingCheck(t, relevanceFieldsMissing),
1801
1863
  // rc.12 lint #29: skill_md_yaml_invalid. Warning kind — surfaces
1802
1864
  // SKILL.md frontmatter that Codex CLI silently drops at load.
1803
- createSkillMdYamlInvalidCheck(skillMdYamlInvalid),
1865
+ createSkillMdYamlInvalidCheck(t, skillMdYamlInvalid),
1804
1866
  // v2.0.0-rc.23 TASK-014 (F8c): Onboard coverage advisory. Info kind.
1805
1867
  // Surfaces uncovered S5 onboard slots and recommends /fabric-archive
1806
1868
  // first-run phase. Sits adjacent to Skill markdown YAML — both are
1807
1869
  // Skill-adjacent advisories. --fix never mutates onboard state.
1808
- createOnboardCoverageCheck(onboardCoverage),
1809
- createPreexistingRootFilesCheck(preexistingRootFiles)
1870
+ createOnboardCoverageCheck(t, onboardCoverage),
1871
+ createPreexistingRootFilesCheck(t, preexistingRootFiles)
1810
1872
  // v2.0 / rc.2: `createLegacyClientPathCheck` removed. The schema now
1811
1873
  // rejects retired clientPaths keys (windsurf/rooCode/geminiCLI) at Zod
1812
1874
  // parse time, so the soft-deprecation warn-and-fix path no longer has a
@@ -1901,7 +1963,8 @@ async function runDoctorFix(target) {
1901
1963
  "knowledge_test_index_missing",
1902
1964
  "knowledge_test_index_stale",
1903
1965
  "content_ref_missing",
1904
- "knowledge_dir_unindexed"
1966
+ "knowledge_dir_unindexed",
1967
+ "meta_manually_diverged"
1905
1968
  ];
1906
1969
  if (before.fixable_errors.some((issue) => reconcileCodes.includes(issue.code)) || before.warnings.some((issue) => reconcileCodes.includes(issue.code))) {
1907
1970
  await reconcileKnowledge(projectRoot, { trigger: "doctor" });
@@ -2483,7 +2546,19 @@ async function inspectEventLedger(projectRoot) {
2483
2546
  const path2 = getEventLedgerPath(projectRoot);
2484
2547
  const exists = existsSync4(path2);
2485
2548
  if (!exists) {
2486
- return { exists: false, writable: false, parseable: false, hasPartialWrite: false, partialWriteByteOffset: 0, partialWriteByteLength: 0, path: path2 };
2549
+ return {
2550
+ exists: false,
2551
+ writable: false,
2552
+ parseable: false,
2553
+ hasPartialWrite: false,
2554
+ partialWriteByteOffset: 0,
2555
+ partialWriteByteLength: 0,
2556
+ schemaVersionUnsupportedCount: 0,
2557
+ eventTypeUnknownCount: 0,
2558
+ schemaVersionSamples: [],
2559
+ eventTypeSamples: [],
2560
+ path: path2
2561
+ };
2487
2562
  }
2488
2563
  try {
2489
2564
  await access(path2, constants.W_OK);
@@ -2491,6 +2566,25 @@ async function inspectEventLedger(projectRoot) {
2491
2566
  const raw = await readFile5(path2, "utf8");
2492
2567
  const invalidLine = raw.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).find((line) => !isValidJsonLine(line));
2493
2568
  const partialWarning = warnings.find((w) => w.kind === "partial_write_at_tail");
2569
+ const schemaVersionSamples = [];
2570
+ const eventTypeSamples = [];
2571
+ let schemaVersionUnsupportedCount = 0;
2572
+ let eventTypeUnknownCount = 0;
2573
+ for (const w of warnings) {
2574
+ if (w.kind === "schema_version_unsupported") {
2575
+ schemaVersionUnsupportedCount += 1;
2576
+ const token = String(w.schema_version);
2577
+ if (!schemaVersionSamples.includes(token) && schemaVersionSamples.length < 3) {
2578
+ schemaVersionSamples.push(token);
2579
+ }
2580
+ } else if (w.kind === "event_type_unknown") {
2581
+ eventTypeUnknownCount += 1;
2582
+ const token = String(w.event_type);
2583
+ if (!eventTypeSamples.includes(token) && eventTypeSamples.length < 3) {
2584
+ eventTypeSamples.push(token);
2585
+ }
2586
+ }
2587
+ }
2494
2588
  return {
2495
2589
  exists: true,
2496
2590
  writable: true,
@@ -2498,6 +2592,10 @@ async function inspectEventLedger(projectRoot) {
2498
2592
  hasPartialWrite: partialWarning !== void 0,
2499
2593
  partialWriteByteOffset: partialWarning?.byte_offset ?? 0,
2500
2594
  partialWriteByteLength: partialWarning?.byte_length ?? 0,
2595
+ schemaVersionUnsupportedCount,
2596
+ eventTypeUnknownCount,
2597
+ schemaVersionSamples,
2598
+ eventTypeSamples,
2501
2599
  path: path2,
2502
2600
  error: invalidLine === void 0 ? void 0 : "events.jsonl contains an invalid JSON line."
2503
2601
  };
@@ -2509,6 +2607,10 @@ async function inspectEventLedger(projectRoot) {
2509
2607
  hasPartialWrite: false,
2510
2608
  partialWriteByteOffset: 0,
2511
2609
  partialWriteByteLength: 0,
2610
+ schemaVersionUnsupportedCount: 0,
2611
+ eventTypeUnknownCount: 0,
2612
+ schemaVersionSamples: [],
2613
+ eventTypeSamples: [],
2512
2614
  path: path2,
2513
2615
  error: error instanceof Error ? error.message : String(error)
2514
2616
  };
@@ -2568,21 +2670,25 @@ async function inspectBootstrapMarkerMigration(target) {
2568
2670
  }
2569
2671
  return { filesNeedingMigration };
2570
2672
  }
2571
- function createBootstrapMarkerMigrationCheck(inspection) {
2673
+ function createBootstrapMarkerMigrationCheck(t, inspection) {
2572
2674
  if (inspection.filesNeedingMigration.length === 0) {
2573
2675
  return okCheck(
2574
- "Bootstrap marker migration",
2575
- "No legacy fabric:knowledge-base markers detected in bootstrap target files."
2676
+ t("doctor.check.bootstrap_marker_migration.name"),
2677
+ t("doctor.check.bootstrap_marker_migration.ok")
2576
2678
  );
2577
2679
  }
2578
2680
  const list = inspection.filesNeedingMigration.join(", ");
2681
+ const count = inspection.filesNeedingMigration.length;
2579
2682
  return issueCheck(
2580
- "Bootstrap marker migration",
2683
+ t("doctor.check.bootstrap_marker_migration.name"),
2581
2684
  "error",
2582
2685
  "fixable_error",
2583
2686
  "bootstrap_marker_migration_required",
2584
- `${inspection.filesNeedingMigration.length} file${inspection.filesNeedingMigration.length === 1 ? "" : "s"} still carry the legacy fabric:knowledge-base bootstrap marker: ${list}.`,
2585
- "Run `fab doctor --fix` to migrate to fabric:bootstrap marker"
2687
+ t(`doctor.check.bootstrap_marker_migration.message.${count === 1 ? "singular" : "plural"}`, {
2688
+ count: String(count),
2689
+ list
2690
+ }),
2691
+ t("doctor.check.bootstrap_marker_migration.remediation")
2586
2692
  );
2587
2693
  }
2588
2694
  async function inspectL1BootstrapSnapshotDrift(target) {
@@ -2601,20 +2707,20 @@ async function inspectL1BootstrapSnapshotDrift(target) {
2601
2707
  }
2602
2708
  return { status: "drift", canonical: BOOTSTRAP_CANONICAL, onDisk };
2603
2709
  }
2604
- function createL1BootstrapSnapshotDriftCheck(inspection) {
2710
+ function createL1BootstrapSnapshotDriftCheck(t, inspection) {
2605
2711
  if (inspection.status === "drift") {
2606
2712
  return issueCheck(
2607
- "Bootstrap snapshot drift",
2713
+ t("doctor.check.bootstrap_snapshot_drift.name"),
2608
2714
  "error",
2609
2715
  "fixable_error",
2610
2716
  "bootstrap_snapshot_drift",
2611
- ".fabric/AGENTS.md content diverges byte-for-byte from BOOTSTRAP_CANONICAL.",
2612
- "Run `fab doctor --fix` to restore canonical bootstrap snapshot"
2717
+ t("doctor.check.bootstrap_snapshot_drift.message.drift"),
2718
+ t("doctor.check.bootstrap_snapshot_drift.remediation.drift")
2613
2719
  );
2614
2720
  }
2615
2721
  return okCheck(
2616
- "Bootstrap snapshot drift",
2617
- inspection.status === "ok" ? ".fabric/AGENTS.md byte-equals BOOTSTRAP_CANONICAL." : ".fabric/AGENTS.md absent \u2014 delegated to bootstrap_anchor_missing."
2722
+ t("doctor.check.bootstrap_snapshot_drift.name"),
2723
+ inspection.status === "ok" ? t("doctor.check.bootstrap_snapshot_drift.ok.ok") : t("doctor.check.bootstrap_snapshot_drift.ok.missing_delegated")
2618
2724
  );
2619
2725
  }
2620
2726
  async function inspectL2ManagedBlockDrift(target) {
@@ -2706,39 +2812,46 @@ ${projectRules}`;
2706
2812
  }
2707
2813
  return { status: "drift", drifted };
2708
2814
  }
2709
- function createL2ManagedBlockDriftCheck(inspection) {
2815
+ function createL2ManagedBlockDriftCheck(t, inspection) {
2710
2816
  if (inspection.status === "drift") {
2711
2817
  const list = inspection.drifted.map((d) => d.path).join(", ");
2818
+ const count = inspection.drifted.length;
2712
2819
  return issueCheck(
2713
- "Managed block drift",
2820
+ t("doctor.check.managed_block_drift.name"),
2714
2821
  "error",
2715
2822
  "fixable_error",
2716
2823
  "managed_block_drift",
2717
- `${inspection.drifted.length} three-end managed block${inspection.drifted.length === 1 ? "" : "s"} diverge from expected body (snapshot + optional project-rules concat): ${list}.`,
2718
- "Run `fab doctor --fix` to restore three-end managed blocks from canonical"
2824
+ t(`doctor.check.managed_block_drift.message.${count === 1 ? "singular" : "plural"}`, {
2825
+ count: String(count),
2826
+ list
2827
+ }),
2828
+ t("doctor.check.managed_block_drift.remediation")
2719
2829
  );
2720
2830
  }
2721
2831
  return okCheck(
2722
- "Managed block drift",
2723
- inspection.status === "ok" ? "Three-end managed blocks byte-equal expectedBody." : "No three-end managed blocks detected \u2014 propagation pending or legacy-marker state."
2832
+ t("doctor.check.managed_block_drift.name"),
2833
+ inspection.status === "ok" ? t("doctor.check.managed_block_drift.ok.ok") : t("doctor.check.managed_block_drift.ok.no_managed_block")
2724
2834
  );
2725
2835
  }
2726
- function createBootstrapAnchorCheck(inspection) {
2836
+ function createBootstrapAnchorCheck(t, inspection) {
2727
2837
  if (!inspection.hasAgentsMd && !inspection.hasClaudeMd) {
2728
2838
  return issueCheck(
2729
- "Bootstrap anchor",
2839
+ t("doctor.check.bootstrap_anchor.name"),
2730
2840
  "error",
2731
2841
  "fixable_error",
2732
2842
  "bootstrap_anchor_missing",
2733
- "Neither AGENTS.md nor CLAUDE.md exists at the repo root. Fabric requires a bootstrap anchor file at the project root.",
2734
- "Run `fabric install` to generate the AGENTS.md / CLAUDE.md bootstrap anchor at the repo root."
2843
+ t("doctor.check.bootstrap_anchor.message.missing"),
2844
+ t("doctor.check.bootstrap_anchor.remediation.missing")
2735
2845
  );
2736
2846
  }
2737
2847
  const present = [
2738
2848
  inspection.hasAgentsMd ? "AGENTS.md" : null,
2739
2849
  inspection.hasClaudeMd ? "CLAUDE.md" : null
2740
2850
  ].filter((entry) => entry !== null).join(", ");
2741
- return okCheck("Bootstrap anchor", `Bootstrap anchor present at repo root: ${present}.`);
2851
+ return okCheck(
2852
+ t("doctor.check.bootstrap_anchor.name"),
2853
+ t("doctor.check.bootstrap_anchor.ok", { present })
2854
+ );
2742
2855
  }
2743
2856
  function inspectKnowledgeDirMissing(projectRoot) {
2744
2857
  const knowledgeRoot = join6(projectRoot, ".fabric", "knowledge");
@@ -2799,154 +2912,310 @@ function inspectBaselineFilenameFormat(projectRoot) {
2799
2912
  offenders.sort((a, b) => a.path.localeCompare(b.path));
2800
2913
  return { offenders };
2801
2914
  }
2802
- function createBaselineFilenameFormatCheck(inspection) {
2915
+ function createBaselineFilenameFormatCheck(t, inspection) {
2803
2916
  if (inspection.offenders.length === 0) {
2804
2917
  return okCheck(
2805
- "Baseline filename format",
2806
- "All baseline knowledge files use the canonical `${id}--${slug}.md` filename format."
2918
+ t("doctor.check.baseline_filename_format.name"),
2919
+ t("doctor.check.baseline_filename_format.ok")
2807
2920
  );
2808
2921
  }
2809
2922
  const first = inspection.offenders[0];
2810
2923
  const detail = `${first.stable_id} at ${first.path}`;
2924
+ const count = inspection.offenders.length;
2811
2925
  return issueCheck(
2812
- "Baseline filename format",
2926
+ t("doctor.check.baseline_filename_format.name"),
2813
2927
  "error",
2814
2928
  "manual_error",
2815
2929
  "lint-baseline-filename-format",
2816
- `${inspection.offenders.length} baseline knowledge file${inspection.offenders.length === 1 ? "" : "s"} use${inspection.offenders.length === 1 ? "s" : ""} the deprecated bare-slug filename format and must be migrated to \`\${id}--\${slug}.md\`. First: ${detail}.`,
2817
- "Delete the legacy bare-slug baseline file(s) manually \u2014 the baseline pipeline was removed in rc.23 and is no longer an auto-fix path."
2930
+ t(`doctor.check.baseline_filename_format.message.${count === 1 ? "singular" : "plural"}`, {
2931
+ count: String(count),
2932
+ detail
2933
+ }),
2934
+ t("doctor.check.baseline_filename_format.remediation")
2818
2935
  );
2819
2936
  }
2820
- function createKnowledgeDirMissingCheck(inspection) {
2937
+ function createKnowledgeDirMissingCheck(t, inspection) {
2821
2938
  if (inspection.missingSubdirs.length > 0) {
2822
2939
  const list = inspection.missingSubdirs.join(", ");
2940
+ const count = inspection.missingSubdirs.length;
2823
2941
  return issueCheck(
2824
- "Knowledge layout",
2942
+ t("doctor.check.knowledge_dir_missing.name"),
2825
2943
  "error",
2826
2944
  "fixable_error",
2827
2945
  "knowledge_dir_missing",
2828
- `${inspection.missingSubdirs.length} required knowledge subdir${inspection.missingSubdirs.length === 1 ? " is" : "s are"} missing: ${list}.`,
2829
- "Run `fab doctor --fix` to create the missing .fabric/knowledge/* subdirectories."
2946
+ t(`doctor.check.knowledge_dir_missing.message.${count === 1 ? "singular" : "plural"}`, {
2947
+ count: String(count),
2948
+ list
2949
+ }),
2950
+ t("doctor.check.knowledge_dir_missing.remediation")
2830
2951
  );
2831
2952
  }
2832
2953
  return okCheck(
2833
- "Knowledge layout",
2834
- `All ${KNOWLEDGE_SUBDIRS3.length} required .fabric/knowledge/* subdirectories exist.`
2954
+ t("doctor.check.knowledge_dir_missing.name"),
2955
+ t("doctor.check.knowledge_dir_missing.ok", { count: String(KNOWLEDGE_SUBDIRS3.length) })
2835
2956
  );
2836
2957
  }
2837
- function createForensicCheck(forensic, frameworkKind, entryPointCount) {
2958
+ function createForensicCheck(t, forensic, frameworkKind, entryPointCount) {
2838
2959
  if (!forensic.present) {
2839
2960
  return issueCheck(
2840
- "Scan evidence",
2961
+ t("doctor.check.forensic.name"),
2841
2962
  "error",
2842
2963
  "manual_error",
2843
2964
  "forensic_missing",
2844
- `${forensic.error ?? ".fabric/forensic.json is missing."} Live scan detects ${frameworkKind} with ${entryPointCount} entry point${entryPointCount === 1 ? "" : "s"}.`,
2845
- "Run `fab install` to regenerate .fabric/forensic.json."
2965
+ t(`doctor.check.forensic.message.missing.${entryPointCount === 1 ? "singular" : "plural"}`, {
2966
+ error: forensic.error ?? t("doctor.check.forensic.message.missing-default"),
2967
+ frameworkKind,
2968
+ count: String(entryPointCount)
2969
+ }),
2970
+ t("doctor.check.forensic.remediation")
2846
2971
  );
2847
2972
  }
2848
2973
  if (!forensic.valid) {
2849
- return issueCheck("Scan evidence", "error", "manual_error", "forensic_invalid", forensic.error ?? ".fabric/forensic.json is invalid.", "Run `fab install` to regenerate .fabric/forensic.json.");
2974
+ return issueCheck(
2975
+ t("doctor.check.forensic.name"),
2976
+ "error",
2977
+ "manual_error",
2978
+ "forensic_invalid",
2979
+ forensic.error ?? t("doctor.check.forensic.message.invalid-default"),
2980
+ t("doctor.check.forensic.remediation")
2981
+ );
2850
2982
  }
2851
- return okCheck("Scan evidence", `.fabric/forensic.json is valid for ${forensic.report?.framework.kind ?? "unknown"}.`);
2983
+ return okCheck(
2984
+ t("doctor.check.forensic.name"),
2985
+ t("doctor.check.forensic.ok", { frameworkKind: forensic.report?.framework.kind ?? "unknown" })
2986
+ );
2852
2987
  }
2853
- function createMetaCheck(meta) {
2988
+ function createMetaCheck(t, meta) {
2854
2989
  if (!meta.present) {
2855
- 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/.");
2990
+ return issueCheck(
2991
+ t("doctor.check.agents_meta.name"),
2992
+ "error",
2993
+ "fixable_error",
2994
+ "agents_meta_missing",
2995
+ t("doctor.check.agents_meta.message.missing"),
2996
+ t("doctor.check.agents_meta.remediation.missing")
2997
+ );
2856
2998
  }
2857
2999
  if (!meta.valid) {
2858
- 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.");
3000
+ return issueCheck(
3001
+ t("doctor.check.agents_meta.name"),
3002
+ "error",
3003
+ "manual_error",
3004
+ "agents_meta_invalid",
3005
+ meta.readError ?? t("doctor.check.agents_meta.message.invalid-default"),
3006
+ t("doctor.check.agents_meta.remediation.invalid")
3007
+ );
2859
3008
  }
2860
3009
  if (meta.stale) {
2861
3010
  return issueCheck(
2862
- "Agents metadata",
3011
+ t("doctor.check.agents_meta.name"),
2863
3012
  "warn",
2864
3013
  "warning",
2865
3014
  "agents_meta_stale",
2866
- `.fabric/agents.meta.json revision ${meta.revision} does not match .fabric/knowledge derived revision ${meta.computedRevision ?? "<unknown>"}.`,
2867
- "Benign \u2014 engine auto-heals on next plan-context/get-sections call. Run `fab doctor --fix` for explicit reconciliation."
3015
+ t("doctor.check.agents_meta.message.stale", {
3016
+ revision: meta.revision,
3017
+ computedRevision: meta.computedRevision ?? "<unknown>"
3018
+ }),
3019
+ t("doctor.check.agents_meta.remediation.stale")
2868
3020
  );
2869
3021
  }
2870
- return okCheck("Agents metadata", `.fabric/agents.meta.json revision ${meta.revision} is aligned with .fabric/knowledge.`);
3022
+ return okCheck(
3023
+ t("doctor.check.agents_meta.name"),
3024
+ t("doctor.check.agents_meta.ok", { revision: meta.revision })
3025
+ );
2871
3026
  }
2872
- function createRuleContentRefCheck(meta) {
3027
+ function createRuleContentRefCheck(t, meta) {
2873
3028
  if (!meta.valid) {
2874
- 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`.");
3029
+ return issueCheck(
3030
+ t("doctor.check.rule_content_refs.name"),
3031
+ "error",
3032
+ "manual_error",
3033
+ "content_refs_unavailable",
3034
+ t("doctor.check.rule_content_refs.message.unavailable"),
3035
+ t("doctor.check.rule_content_refs.remediation.unavailable")
3036
+ );
2875
3037
  }
2876
3038
  if (meta.invalidContentRefs.length > 0) {
3039
+ const count = meta.invalidContentRefs.length;
2877
3040
  return issueCheck(
2878
- "Rule content refs",
3041
+ t("doctor.check.rule_content_refs.name"),
2879
3042
  "error",
2880
3043
  "manual_error",
2881
3044
  "content_ref_outside_rules",
2882
- `${meta.invalidContentRefs.length} content_ref entr${meta.invalidContentRefs.length === 1 ? "y is" : "ies are"} outside .fabric/knowledge.`,
2883
- "Edit agents.meta.json to ensure all content_ref values point inside .fabric/knowledge/{type}/ (team) or ~/.fabric/knowledge/{type}/ (personal)."
3045
+ t(`doctor.check.rule_content_refs.message.outside.${count === 1 ? "singular" : "plural"}`, {
3046
+ count: String(count)
3047
+ }),
3048
+ t("doctor.check.rule_content_refs.remediation.outside")
2884
3049
  );
2885
3050
  }
2886
3051
  if (meta.missingContentRefs.length > 0) {
3052
+ const count = meta.missingContentRefs.length;
2887
3053
  return issueCheck(
2888
- "Rule content refs",
3054
+ t("doctor.check.rule_content_refs.name"),
2889
3055
  "error",
2890
3056
  "fixable_error",
2891
3057
  "content_ref_missing",
2892
- `${meta.missingContentRefs.length} content_ref target${meta.missingContentRefs.length === 1 ? "" : "s"} are missing. Run \`fab doctor --fix\` to reconcile.`,
2893
- "Run `fab doctor --fix` to reconcile agents.meta.json with the files present in .fabric/knowledge/."
3058
+ t(`doctor.check.rule_content_refs.message.missing.${count === 1 ? "singular" : "plural"}`, {
3059
+ count: String(count)
3060
+ }),
3061
+ t("doctor.check.rule_content_refs.remediation.missing")
2894
3062
  );
2895
3063
  }
2896
- return okCheck("Rule content refs", "All content_ref entries resolve to .fabric/knowledge files.");
3064
+ return okCheck(t("doctor.check.rule_content_refs.name"), t("doctor.check.rule_content_refs.ok"));
2897
3065
  }
2898
- function createKnowledgeTestIndexCheck(index) {
3066
+ function createKnowledgeTestIndexCheck(t, index) {
2899
3067
  if (!index.present) {
2900
- 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.");
3068
+ return issueCheck(
3069
+ t("doctor.check.knowledge_test_index.name"),
3070
+ "error",
3071
+ "fixable_error",
3072
+ "knowledge_test_index_missing",
3073
+ index.error,
3074
+ t("doctor.check.knowledge_test_index.remediation.missing")
3075
+ );
2901
3076
  }
2902
3077
  if (!index.valid) {
2903
- 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.");
3078
+ return issueCheck(
3079
+ t("doctor.check.knowledge_test_index.name"),
3080
+ "error",
3081
+ "manual_error",
3082
+ "knowledge_test_index_invalid",
3083
+ index.error,
3084
+ t("doctor.check.knowledge_test_index.remediation.invalid")
3085
+ );
2904
3086
  }
2905
3087
  if (index.stale) {
2906
- 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.");
3088
+ return issueCheck(
3089
+ t("doctor.check.knowledge_test_index.name"),
3090
+ "error",
3091
+ "fixable_error",
3092
+ "knowledge_test_index_stale",
3093
+ t("doctor.check.knowledge_test_index.message.stale"),
3094
+ t("doctor.check.knowledge_test_index.remediation.stale")
3095
+ );
2907
3096
  }
2908
- return okCheck("Knowledge-test index", `${index.linkCount} link${index.linkCount === 1 ? "" : "s"} and ${index.orphanCount} orphan annotation${index.orphanCount === 1 ? "" : "s"} indexed.`);
3097
+ return okCheck(
3098
+ t("doctor.check.knowledge_test_index.name"),
3099
+ t(
3100
+ `doctor.check.knowledge_test_index.ok.${index.linkCount === 1 ? "link_singular" : "link_plural"}.${index.orphanCount === 1 ? "orphan_singular" : "orphan_plural"}`,
3101
+ { linkCount: String(index.linkCount), orphanCount: String(index.orphanCount) }
3102
+ )
3103
+ );
2909
3104
  }
2910
- function createEventLedgerCheck(ledger) {
3105
+ function createEventLedgerCheck(t, ledger) {
2911
3106
  if (!ledger.exists) {
2912
- return issueCheck("Event ledger", "error", "fixable_error", "event_ledger_missing", ".fabric/events.jsonl is missing.", "Run `fab doctor --fix` to create .fabric/events.jsonl.");
3107
+ return issueCheck(
3108
+ t("doctor.check.event_ledger.name"),
3109
+ "error",
3110
+ "fixable_error",
3111
+ "event_ledger_missing",
3112
+ t("doctor.check.event_ledger.message.missing"),
3113
+ t("doctor.check.event_ledger.remediation.missing")
3114
+ );
2913
3115
  }
2914
3116
  if (!ledger.writable) {
2915
- 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.");
3117
+ return issueCheck(
3118
+ t("doctor.check.event_ledger.name"),
3119
+ "error",
3120
+ "manual_error",
3121
+ "event_ledger_not_writable",
3122
+ ledger.error ?? t("doctor.check.event_ledger.message.not_writable-default"),
3123
+ t("doctor.check.event_ledger.remediation.not_writable")
3124
+ );
2916
3125
  }
2917
3126
  if (!ledger.parseable) {
2918
- 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.");
3127
+ return issueCheck(
3128
+ t("doctor.check.event_ledger.name"),
3129
+ "error",
3130
+ "manual_error",
3131
+ "event_ledger_invalid",
3132
+ ledger.error ?? t("doctor.check.event_ledger.message.invalid-default"),
3133
+ t("doctor.check.event_ledger.remediation.invalid")
3134
+ );
2919
3135
  }
2920
- return okCheck("Event ledger", ".fabric/events.jsonl exists, is writable, and is parseable.");
3136
+ return okCheck(t("doctor.check.event_ledger.name"), t("doctor.check.event_ledger.ok"));
2921
3137
  }
2922
- function createMcpConfigInWrongFileCheck(inspection) {
3138
+ function createMcpConfigInWrongFileCheck(t, inspection) {
2923
3139
  if (inspection.hasWrongEntry) {
2924
3140
  return issueCheck(
2925
- "Claude MCP config location",
3141
+ t("doctor.check.mcp_config_in_wrong_file.name"),
2926
3142
  "error",
2927
3143
  "fixable_error",
2928
3144
  "mcp_config_in_wrong_file",
2929
- `.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.`,
2930
- "Run `fab doctor --fix` to remove mcpServers.fabric from .claude/settings.json, then run `fab install` to write .mcp.json."
3145
+ t("doctor.check.mcp_config_in_wrong_file.message"),
3146
+ t("doctor.check.mcp_config_in_wrong_file.remediation")
2931
3147
  );
2932
3148
  }
2933
- return okCheck("Claude MCP config location", "mcpServers.fabric is not in .claude/settings.json.");
3149
+ return okCheck(
3150
+ t("doctor.check.mcp_config_in_wrong_file.name"),
3151
+ t("doctor.check.mcp_config_in_wrong_file.ok")
3152
+ );
2934
3153
  }
2935
- function createEventLedgerPartialWriteCheck(ledger) {
3154
+ function createEventLedgerSchemaCompatCheck(t, ledger) {
2936
3155
  if (!ledger.exists || !ledger.writable) {
2937
- return okCheck("Event ledger partial write", "No partial-write check needed (ledger missing or not writable).");
3156
+ return okCheck(
3157
+ t("doctor.check.event_ledger_schema_compat.name"),
3158
+ t("doctor.check.event_ledger_schema_compat.ok.skipped")
3159
+ );
3160
+ }
3161
+ const hasUnsupportedVersion = ledger.schemaVersionUnsupportedCount > 0;
3162
+ const hasUnknownEventType = ledger.eventTypeUnknownCount > 0;
3163
+ if (!hasUnsupportedVersion && !hasUnknownEventType) {
3164
+ return okCheck(
3165
+ t("doctor.check.event_ledger_schema_compat.name"),
3166
+ t("doctor.check.event_ledger_schema_compat.ok.clean")
3167
+ );
3168
+ }
3169
+ const parts = [];
3170
+ if (hasUnsupportedVersion) {
3171
+ parts.push(
3172
+ t("doctor.check.event_ledger_schema_compat.message.schema_version", {
3173
+ count: String(ledger.schemaVersionUnsupportedCount),
3174
+ samples: ledger.schemaVersionSamples.join(", ")
3175
+ })
3176
+ );
3177
+ }
3178
+ if (hasUnknownEventType) {
3179
+ parts.push(
3180
+ t("doctor.check.event_ledger_schema_compat.message.event_type", {
3181
+ count: String(ledger.eventTypeUnknownCount),
3182
+ samples: ledger.eventTypeSamples.join(", ")
3183
+ })
3184
+ );
3185
+ }
3186
+ return issueCheck(
3187
+ t("doctor.check.event_ledger_schema_compat.name"),
3188
+ "warn",
3189
+ "warning",
3190
+ "event_ledger_schema_compat",
3191
+ parts.join(" "),
3192
+ t("doctor.check.event_ledger_schema_compat.remediation")
3193
+ );
3194
+ }
3195
+ function createEventLedgerPartialWriteCheck(t, ledger) {
3196
+ if (!ledger.exists || !ledger.writable) {
3197
+ return okCheck(
3198
+ t("doctor.check.event_ledger_partial_write.name"),
3199
+ t("doctor.check.event_ledger_partial_write.ok.skipped")
3200
+ );
2938
3201
  }
2939
3202
  if (ledger.hasPartialWrite) {
2940
3203
  return issueCheck(
2941
- "Event ledger partial write",
3204
+ t("doctor.check.event_ledger_partial_write.name"),
2942
3205
  "error",
2943
3206
  "fixable_error",
2944
3207
  "event_ledger_partial_write",
2945
- `events.jsonl has a partial write at byte offset ${ledger.partialWriteByteOffset} (${ledger.partialWriteByteLength} corrupted bytes). Run --fix to truncate and preserve corrupted bytes.`,
2946
- "Run `fab doctor --fix` to truncate the partial write and restore events.jsonl to a valid state."
3208
+ t("doctor.check.event_ledger_partial_write.message", {
3209
+ byteOffset: String(ledger.partialWriteByteOffset),
3210
+ byteLength: String(ledger.partialWriteByteLength)
3211
+ }),
3212
+ t("doctor.check.event_ledger_partial_write.remediation")
2947
3213
  );
2948
3214
  }
2949
- return okCheck("Event ledger partial write", "events.jsonl has no partial trailing write.");
3215
+ return okCheck(
3216
+ t("doctor.check.event_ledger_partial_write.name"),
3217
+ t("doctor.check.event_ledger_partial_write.ok.clean")
3218
+ );
2950
3219
  }
2951
3220
  function okCheck(name, message) {
2952
3221
  return { name, status: "ok", message };
@@ -2966,7 +3235,8 @@ function collectIssues(checks, kind) {
2966
3235
  return checks.filter((check) => check.kind === kind).map((check) => ({
2967
3236
  code: check.code ?? check.name,
2968
3237
  name: check.name,
2969
- message: check.message
3238
+ message: check.message,
3239
+ actionHint: check.actionHint
2970
3240
  }));
2971
3241
  }
2972
3242
  function findIssue(issues, code) {
@@ -3053,18 +3323,24 @@ function collectMdFilesUnder(out, projectRoot, rootDir, relPrefix) {
3053
3323
  }
3054
3324
  }
3055
3325
  }
3056
- function createKnowledgeDirUnindexedCheck(inspection) {
3326
+ function createKnowledgeDirUnindexedCheck(t, inspection) {
3057
3327
  if (inspection.unindexedFiles.length > 0) {
3328
+ const count = inspection.unindexedFiles.length;
3058
3329
  return issueCheck(
3059
- "Knowledge dir unindexed",
3330
+ t("doctor.check.knowledge_dir_unindexed.name"),
3060
3331
  "error",
3061
3332
  "fixable_error",
3062
3333
  "knowledge_dir_unindexed",
3063
- `${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.`,
3064
- "Run `fab doctor --fix` to index the missing knowledge files."
3334
+ t(`doctor.check.knowledge_dir_unindexed.message.${count === 1 ? "singular" : "plural"}`, {
3335
+ count: String(count)
3336
+ }),
3337
+ t("doctor.check.knowledge_dir_unindexed.remediation")
3065
3338
  );
3066
3339
  }
3067
- return okCheck("Knowledge dir unindexed", "All .fabric/knowledge/ .md files are indexed in agents.meta.json.");
3340
+ return okCheck(
3341
+ t("doctor.check.knowledge_dir_unindexed.name"),
3342
+ t("doctor.check.knowledge_dir_unindexed.ok")
3343
+ );
3068
3344
  }
3069
3345
  async function inspectStableIdCollisions(projectRoot) {
3070
3346
  const found = [];
@@ -3147,7 +3423,7 @@ function inspectCounterDesync(meta) {
3147
3423
  ["guideline", "GLD"],
3148
3424
  ["pitfall", "PIT"],
3149
3425
  ["process", "PRO"]
3150
- ].find(([t2]) => t2 === parsed.type)?.[1];
3426
+ ].find(([t]) => t === parsed.type)?.[1];
3151
3427
  if (typeCode === void 0) {
3152
3428
  continue;
3153
3429
  }
@@ -3175,61 +3451,84 @@ function inspectCounterDesync(meta) {
3175
3451
  correctedCounters: desyncs.length === 0 ? null : corrected
3176
3452
  };
3177
3453
  }
3178
- function createCounterDesyncCheck(inspection) {
3454
+ function createCounterDesyncCheck(t, inspection) {
3179
3455
  if (inspection.desyncs.length > 0) {
3180
3456
  const first = inspection.desyncs[0];
3181
- 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")}`;
3457
+ const observedId = `K${first.layer === "KP" ? "P" : "T"}-${first.type}-${String(first.observed).padStart(4, "0")}`;
3458
+ const count = inspection.desyncs.length;
3182
3459
  return issueCheck(
3183
- "Knowledge counter desync",
3460
+ t("doctor.check.counter_desync.name"),
3184
3461
  "error",
3185
3462
  "fixable_error",
3186
3463
  "counter_desync",
3187
- `${inspection.desyncs.length} knowledge counter${inspection.desyncs.length === 1 ? "" : "s"} desynced from observed stable_ids. ${detail}. Run \`fab doctor --fix\` to bump counters.`,
3188
- "Run `fab doctor --fix` to bump agents.meta.json counters to the maximum observed counter value."
3464
+ t(`doctor.check.counter_desync.message.${count === 1 ? "singular" : "plural"}`, {
3465
+ count: String(count),
3466
+ counterPath: `counters.${first.layer}.${first.type}`,
3467
+ current: String(first.current),
3468
+ observedId
3469
+ }),
3470
+ t("doctor.check.counter_desync.remediation")
3189
3471
  );
3190
3472
  }
3191
- return okCheck("Knowledge counter desync", "agents.meta.json counters envelope is consistent with observed stable_ids.");
3473
+ return okCheck(t("doctor.check.counter_desync.name"), t("doctor.check.counter_desync.ok"));
3192
3474
  }
3193
- function createStableIdCollisionCheck(inspection) {
3475
+ function createStableIdCollisionCheck(t, inspection) {
3194
3476
  if (inspection.collisions.length > 0) {
3195
3477
  const first = inspection.collisions[0];
3196
- 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(", ")}.`;
3478
+ const count = inspection.collisions.length;
3197
3479
  return issueCheck(
3198
- "Stable ID collision",
3480
+ t("doctor.check.stable_id_collision.name"),
3199
3481
  "warn",
3200
3482
  "warning",
3201
3483
  "stable_id_collision",
3202
- `${detail} Edit one of the knowledge files to use a unique stable_id.`,
3203
- "Edit one of the colliding knowledge files to declare a different `id: K[PT]-XXX-NNNN` frontmatter value."
3484
+ t(`doctor.check.stable_id_collision.message.${count === 1 ? "singular" : "plural"}`, {
3485
+ count: String(count),
3486
+ stableId: first.stable_id,
3487
+ fileCount: String(first.files.length),
3488
+ files: first.files.join(", ")
3489
+ }),
3490
+ t("doctor.check.stable_id_collision.remediation")
3204
3491
  );
3205
3492
  }
3206
- return okCheck("Stable ID collision", "No declared stable_id collisions found in .fabric/knowledge/.");
3493
+ return okCheck(t("doctor.check.stable_id_collision.name"), t("doctor.check.stable_id_collision.ok"));
3207
3494
  }
3208
- function createMetaManuallyDivergedCheck(inspection) {
3495
+ function createMetaManuallyDivergedCheck(t, inspection) {
3209
3496
  if (!inspection.readable) {
3210
- return okCheck("Meta manual divergence", "agents.meta.json not readable; skipping divergence check.");
3497
+ return okCheck(
3498
+ t("doctor.check.meta_manually_diverged.name"),
3499
+ t("doctor.check.meta_manually_diverged.ok.unreadable")
3500
+ );
3211
3501
  }
3212
3502
  if (inspection.extraMetaEntries.length > 0) {
3503
+ const count = inspection.extraMetaEntries.length;
3213
3504
  return issueCheck(
3214
- "Meta manual divergence",
3505
+ t("doctor.check.meta_manually_diverged.name"),
3215
3506
  "warn",
3216
3507
  "warning",
3217
3508
  "meta_manually_diverged",
3218
- `agents.meta.json has ${inspection.extraMetaEntries.length} entr${inspection.extraMetaEntries.length === 1 ? "y" : "ies"} with no backing file on disk. Run --fix to reconcile.`,
3219
- "Run `fab doctor --fix` to reconcile agents.meta.json with the rule files currently on disk."
3509
+ t(`doctor.check.meta_manually_diverged.message.extra.${count === 1 ? "singular" : "plural"}`, {
3510
+ count: String(count)
3511
+ }),
3512
+ t("doctor.check.meta_manually_diverged.remediation.extra")
3220
3513
  );
3221
3514
  }
3222
3515
  if (inspection.hashMismatchEntries.length > 0) {
3516
+ const count = inspection.hashMismatchEntries.length;
3223
3517
  return issueCheck(
3224
- "Meta manual divergence",
3518
+ t("doctor.check.meta_manually_diverged.name"),
3225
3519
  "warn",
3226
3520
  "warning",
3227
3521
  "meta_manually_diverged",
3228
- `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.`,
3229
- "Run `fab doctor --fix` to reconcile agents.meta.json with the current rule file contents."
3522
+ t(`doctor.check.meta_manually_diverged.message.hash.${count === 1 ? "singular" : "plural"}`, {
3523
+ count: String(count)
3524
+ }),
3525
+ t("doctor.check.meta_manually_diverged.remediation.hash")
3230
3526
  );
3231
3527
  }
3232
- return okCheck("Meta manual divergence", "agents.meta.json is consistent with rule files on disk.");
3528
+ return okCheck(
3529
+ t("doctor.check.meta_manually_diverged.name"),
3530
+ t("doctor.check.meta_manually_diverged.ok.consistent")
3531
+ );
3233
3532
  }
3234
3533
  function inspectPreexistingRootFiles(projectRoot) {
3235
3534
  const candidates = ["CLAUDE.md", "AGENTS.md"];
@@ -3295,36 +3594,44 @@ async function inspectFilesystemEditFallback(projectRoot) {
3295
3594
  }
3296
3595
  return { synthesized: orphanIds.length, synthesizedStableIds: orphanIds };
3297
3596
  }
3298
- function createFilesystemEditFallbackCheck(inspection) {
3597
+ function createFilesystemEditFallbackCheck(t, inspection) {
3299
3598
  if (inspection.synthesized === 0) {
3300
3599
  return okCheck(
3301
- "Filesystem-edit fallback",
3302
- "No orphan canonical knowledge entries detected; events.jsonl promotion trail is complete."
3600
+ t("doctor.check.filesystem_edit_fallback.name"),
3601
+ t("doctor.check.filesystem_edit_fallback.ok")
3303
3602
  );
3304
3603
  }
3305
3604
  const sample = inspection.synthesizedStableIds.slice(0, 3).join(", ");
3306
3605
  return {
3307
- name: "Filesystem-edit fallback",
3606
+ name: t("doctor.check.filesystem_edit_fallback.name"),
3308
3607
  status: "ok",
3309
3608
  kind: "info",
3310
3609
  code: "knowledge_promoted_synthesized",
3311
3610
  fixable: false,
3312
- message: `Synthesized ${inspection.synthesized} knowledge_promoted event${inspection.synthesized === 1 ? "" : "s"} for orphan canonical entries (${sample}${inspection.synthesizedStableIds.length > 3 ? ", ..." : ""}). Reason='${SYNTHESIZED_PROMOTED_REASON}'.`,
3313
- actionHint: "These entries were moved into .fabric/knowledge/<type>/ outside fab_review.approve. The synthesized events restore audit-trail completeness."
3611
+ message: t(
3612
+ `doctor.check.filesystem_edit_fallback.message.synthesized.${inspection.synthesized === 1 ? "singular" : "plural"}`,
3613
+ {
3614
+ count: String(inspection.synthesized),
3615
+ sample,
3616
+ suffix: inspection.synthesizedStableIds.length > 3 ? ", ..." : "",
3617
+ reason: SYNTHESIZED_PROMOTED_REASON
3618
+ }
3619
+ ),
3620
+ actionHint: t("doctor.check.filesystem_edit_fallback.remediation.synthesized")
3314
3621
  };
3315
3622
  }
3316
- function createPreexistingRootFilesCheck(inspection) {
3623
+ function createPreexistingRootFilesCheck(t, inspection) {
3317
3624
  if (inspection.detected.length === 0) {
3318
- return okCheck("Preexisting root markdown", "No CLAUDE.md or AGENTS.md detected at project root.");
3625
+ return okCheck(t("doctor.check.preexisting_root_files.name"), t("doctor.check.preexisting_root_files.ok"));
3319
3626
  }
3320
3627
  return {
3321
- name: "Preexisting root markdown",
3628
+ name: t("doctor.check.preexisting_root_files.name"),
3322
3629
  status: "ok",
3323
3630
  kind: "info",
3324
3631
  code: "preexisting_root_claude_md",
3325
3632
  fixable: false,
3326
- message: `${inspection.detected.join(", ")} detected at project root. These root files are not auto-loaded by Fabric MCP.`,
3327
- actionHint: "Move knowledge content to `.fabric/knowledge/{type}/` if you want it available in MCP responses."
3633
+ message: t("doctor.check.preexisting_root_files.message", { files: inspection.detected.join(", ") }),
3634
+ actionHint: t("doctor.check.preexisting_root_files.remediation")
3328
3635
  };
3329
3636
  }
3330
3637
  async function buildLastConsumedIndex(projectRoot) {
@@ -3822,114 +4129,156 @@ function readUnderseedThresholdFromConfig(projectRoot) {
3822
4129
  }
3823
4130
  return DEFAULT_UNDERSEED_NODE_THRESHOLD;
3824
4131
  }
3825
- function createOrphanDemoteCheck(inspection) {
4132
+ function createOrphanDemoteCheck(t, inspection) {
3826
4133
  if (inspection.candidates.length === 0) {
3827
4134
  return okCheck(
3828
- "Knowledge orphan demote",
3829
- "No canonical knowledge entries exceed their maturity-keyed inactivity threshold."
4135
+ t("doctor.check.orphan_demote.name"),
4136
+ t("doctor.check.orphan_demote.ok")
3830
4137
  );
3831
4138
  }
3832
4139
  const first = inspection.candidates[0];
3833
4140
  const detail = `${first.stable_id} (${first.maturity}, ${first.age_days}d inactive at ${first.path})`;
4141
+ const count = inspection.candidates.length;
3834
4142
  return issueCheck(
3835
- "Knowledge orphan demote",
4143
+ t("doctor.check.orphan_demote.name"),
3836
4144
  "warn",
3837
4145
  "warning",
3838
4146
  "knowledge_orphan_demote_required",
3839
- `${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}.`,
3840
- "Run `fab doctor --apply-lint` (rc.4 TASK-003) to demote orphan entries one maturity tier."
4147
+ t(`doctor.check.orphan_demote.message.${count === 1 ? "singular" : "plural"}`, {
4148
+ count: String(count),
4149
+ stableDays: String(ORPHAN_DEMOTE_THRESHOLD_DAYS.stable),
4150
+ endorsedDays: String(ORPHAN_DEMOTE_THRESHOLD_DAYS.endorsed),
4151
+ draftDays: String(ORPHAN_DEMOTE_THRESHOLD_DAYS.draft),
4152
+ detail
4153
+ }),
4154
+ t("doctor.check.orphan_demote.remediation")
3841
4155
  );
3842
4156
  }
3843
- function createStaleArchiveCheck(inspection) {
4157
+ function createStaleArchiveCheck(t, inspection) {
3844
4158
  if (inspection.candidates.length === 0) {
3845
4159
  return okCheck(
3846
- "Knowledge stale archive",
3847
- "No draft knowledge entries exceed the additional stale-archive quiet window."
4160
+ t("doctor.check.stale_archive.name"),
4161
+ t("doctor.check.stale_archive.ok")
3848
4162
  );
3849
4163
  }
3850
4164
  const first = inspection.candidates[0];
3851
4165
  const detail = `${first.stable_id} (${first.age_days}d inactive at ${first.path}) \u2192 ${first.archive_path}`;
4166
+ const count = inspection.candidates.length;
3852
4167
  return issueCheck(
3853
- "Knowledge stale archive",
4168
+ t("doctor.check.stale_archive.name"),
3854
4169
  "warn",
3855
4170
  "warning",
3856
4171
  "knowledge_stale_archive_required",
3857
- `${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}.`,
3858
- "Run `fab doctor --apply-lint` (rc.4 TASK-003) to move stale entries into `.fabric/.archive/<type>/`."
4172
+ t(`doctor.check.stale_archive.message.${count === 1 ? "singular" : "plural"}`, {
4173
+ count: String(count),
4174
+ additionalDays: String(STALE_ARCHIVE_ADDITIONAL_DAYS),
4175
+ detail
4176
+ }),
4177
+ t("doctor.check.stale_archive.remediation")
3859
4178
  );
3860
4179
  }
3861
- function createPendingOverdueCheck(inspection) {
4180
+ function createPendingOverdueCheck(t, inspection) {
3862
4181
  if (inspection.candidates.length === 0) {
3863
4182
  return okCheck(
3864
- "Knowledge pending overdue",
3865
- "No pending knowledge entries exceed the 14-day review threshold."
4183
+ t("doctor.check.pending_overdue.name"),
4184
+ t("doctor.check.pending_overdue.ok")
3866
4185
  );
3867
4186
  }
3868
4187
  const first = inspection.candidates[0];
3869
4188
  const detail = `${first.path} (${first.age_days}d old)`;
4189
+ const count = inspection.candidates.length;
3870
4190
  return issueCheck(
3871
- "Knowledge pending overdue",
4191
+ t("doctor.check.pending_overdue.name"),
3872
4192
  "warn",
3873
4193
  "warning",
3874
4194
  "knowledge_pending_overdue",
3875
- `${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}.`,
3876
- "Review pending entries via the fabric-review Skill (`/fabric-review`) and approve, reject, defer, or modify."
4195
+ t(`doctor.check.pending_overdue.message.${count === 1 ? "singular" : "plural"}`, {
4196
+ count: String(count),
4197
+ thresholdDays: String(PENDING_OVERDUE_THRESHOLD_DAYS),
4198
+ detail
4199
+ }),
4200
+ t("doctor.check.pending_overdue.remediation")
3877
4201
  );
3878
4202
  }
3879
- function createUnderseededCheck(inspection) {
4203
+ function createUnderseededCheck(t, inspection) {
3880
4204
  if (!inspection.underseeded) {
3881
4205
  return okCheck(
3882
- "Knowledge underseeded",
3883
- `Knowledge corpus has ${inspection.node_count} canonical entries (>= ${inspection.threshold}).`
4206
+ t("doctor.check.underseeded.name"),
4207
+ t("doctor.check.underseeded.ok", {
4208
+ count: String(inspection.node_count),
4209
+ threshold: String(inspection.threshold)
4210
+ })
3884
4211
  );
3885
4212
  }
3886
4213
  return issueCheck(
3887
- "Knowledge underseeded",
4214
+ t("doctor.check.underseeded.name"),
3888
4215
  "ok",
3889
4216
  "info",
3890
4217
  "knowledge_underseeded",
3891
- `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.`,
3892
- "Run the fabric-import Skill (`/fabric-import`) to backfill knowledge from git history and existing docs."
4218
+ t(`doctor.check.underseeded.message.${inspection.node_count === 1 ? "singular" : "plural"}`, {
4219
+ count: String(inspection.node_count),
4220
+ threshold: String(inspection.threshold)
4221
+ }),
4222
+ t("doctor.check.underseeded.remediation")
3893
4223
  );
3894
4224
  }
3895
- function createSessionHintsStaleCheck(inspection) {
4225
+ function createSessionHintsStaleCheck(t, inspection) {
3896
4226
  if (inspection.candidates.length === 0) {
3897
4227
  return okCheck(
3898
- "Knowledge session-hints stale",
3899
- `No session-hints cache files older than ${SESSION_HINTS_STALE_DAYS} days under .fabric/.cache/.`
4228
+ t("doctor.check.session_hints_stale.name"),
4229
+ t("doctor.check.session_hints_stale.ok", {
4230
+ days: String(SESSION_HINTS_STALE_DAYS)
4231
+ })
3900
4232
  );
3901
4233
  }
3902
4234
  const first = inspection.candidates[0];
3903
4235
  const detail = `${first.path} (${first.age_days}d old)`;
4236
+ const count = inspection.candidates.length;
3904
4237
  return issueCheck(
3905
- "Knowledge session-hints stale",
4238
+ t("doctor.check.session_hints_stale.name"),
3906
4239
  "ok",
3907
4240
  "info",
3908
4241
  "knowledge_session_hints_stale",
3909
- `${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}.`,
3910
- "Run `fab doctor --apply-lint` to delete stale session-hints cache files."
4242
+ t(`doctor.check.session_hints_stale.message.${count === 1 ? "singular" : "plural"}`, {
4243
+ count: String(count),
4244
+ days: String(SESSION_HINTS_STALE_DAYS),
4245
+ detail
4246
+ }),
4247
+ t("doctor.check.session_hints_stale.remediation")
3911
4248
  );
3912
4249
  }
3913
- function createStaleServeLockCheck(inspection) {
4250
+ function createStaleServeLockCheck(t, inspection) {
3914
4251
  if (!inspection.present) {
3915
- return okCheck("Serve lock", "No .fabric/.serve.lock present.");
4252
+ return okCheck(
4253
+ t("doctor.check.stale_serve_lock.name"),
4254
+ t("doctor.check.stale_serve_lock.ok.no_lock")
4255
+ );
3916
4256
  }
3917
4257
  if (inspection.pidAlive) {
3918
4258
  return okCheck(
3919
- "Serve lock",
3920
- `.fabric/.serve.lock held by live PID ${inspection.pid}.`
4259
+ t("doctor.check.stale_serve_lock.name"),
4260
+ t("doctor.check.stale_serve_lock.ok.live_pid", {
4261
+ pid: String(inspection.pid)
4262
+ })
3921
4263
  );
3922
4264
  }
3923
4265
  const days = Math.floor(inspection.ageMs / MS_PER_DAY);
3924
4266
  const hours = Math.floor(inspection.ageMs / (60 * 60 * 1e3));
3925
- const acquiredAgo = days >= 1 ? `${days} day${days === 1 ? "" : "s"} ago` : `${hours} hour${hours === 1 ? "" : "s"} ago`;
4267
+ const acquiredAgo = days >= 1 ? t(`doctor.check.stale_serve_lock.age.day.${days === 1 ? "singular" : "plural"}`, {
4268
+ count: String(days)
4269
+ }) : t(`doctor.check.stale_serve_lock.age.hour.${hours === 1 ? "singular" : "plural"}`, {
4270
+ count: String(hours)
4271
+ });
3926
4272
  return issueCheck(
3927
- "Serve lock",
4273
+ t("doctor.check.stale_serve_lock.name"),
3928
4274
  "ok",
3929
4275
  "info",
3930
4276
  "stale_serve_lock",
3931
- `[advisory] .fabric/.serve.lock holds dead PID ${inspection.pid} (acquired ${acquiredAgo}). Run \`fab doctor --fix\` to remove.`,
3932
- "Run `fab doctor --fix` to remove the stale .fabric/.serve.lock."
4277
+ t("doctor.check.stale_serve_lock.message.dead_pid", {
4278
+ pid: String(inspection.pid),
4279
+ acquiredAgo
4280
+ }),
4281
+ t("doctor.check.stale_serve_lock.remediation.dead_pid")
3933
4282
  );
3934
4283
  }
3935
4284
  function extractKnowledgeFrontmatterRelevanceScope(source) {
@@ -4128,64 +4477,81 @@ function readRecentGitTouchedPaths(projectRoot, windowDays) {
4128
4477
  }
4129
4478
  return Array.from(set);
4130
4479
  }
4131
- function createNarrowNoPathsCheck(inspection) {
4480
+ function createNarrowNoPathsCheck(t, inspection) {
4132
4481
  if (inspection.candidates.length === 0) {
4133
4482
  return okCheck(
4134
- "Knowledge narrow without paths",
4135
- "No narrow-scope canonical entries have an empty relevance_paths array."
4483
+ t("doctor.check.narrow_no_paths.name"),
4484
+ t("doctor.check.narrow_no_paths.ok")
4136
4485
  );
4137
4486
  }
4138
4487
  const first = inspection.candidates[0];
4139
4488
  const detail = `${first.stable_id} (${first.path})`;
4489
+ const count = inspection.candidates.length;
4140
4490
  return issueCheck(
4141
- "Knowledge narrow without paths",
4491
+ t("doctor.check.narrow_no_paths.name"),
4142
4492
  "warn",
4143
4493
  "warning",
4144
4494
  "knowledge_narrow_no_paths",
4145
- `${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}.`,
4146
- "Either add path anchors to relevance_paths or widen the entry's relevance_scope to broad."
4495
+ t(`doctor.check.narrow_no_paths.message.${count === 1 ? "singular" : "plural"}`, {
4496
+ count: String(count),
4497
+ detail
4498
+ }),
4499
+ t("doctor.check.narrow_no_paths.remediation")
4147
4500
  );
4148
4501
  }
4149
- function createRelevancePathsDanglingCheck(inspection) {
4502
+ function createRelevancePathsDanglingCheck(t, inspection) {
4150
4503
  if (inspection.entries.length === 0) {
4151
4504
  return okCheck(
4152
- "Knowledge relevance_paths dangling",
4153
- "All relevance_paths globs resolve to at least one file under the workspace root."
4505
+ t("doctor.check.relevance_paths_dangling.name"),
4506
+ t("doctor.check.relevance_paths_dangling.ok")
4154
4507
  );
4155
4508
  }
4156
4509
  const first = inspection.entries[0];
4157
4510
  const detail = `${first.stable_id} at ${first.path} \u2192 \`${first.dangling_glob}\` (0 matches)`;
4511
+ const count = inspection.entries.length;
4158
4512
  return issueCheck(
4159
- "Knowledge relevance_paths dangling",
4513
+ t("doctor.check.relevance_paths_dangling.name"),
4160
4514
  "warn",
4161
4515
  "warning",
4162
4516
  "knowledge_relevance_paths_dangling",
4163
- `${inspection.entries.length} relevance_paths glob${inspection.entries.length === 1 ? " resolves" : "s resolve"} to zero files in the current workspace. First: ${detail}.`,
4164
- "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."
4517
+ t(`doctor.check.relevance_paths_dangling.message.${count === 1 ? "singular" : "plural"}`, {
4518
+ count: String(count),
4519
+ detail
4520
+ }),
4521
+ t("doctor.check.relevance_paths_dangling.remediation")
4165
4522
  );
4166
4523
  }
4167
- function createRelevancePathsDriftCheck(inspection) {
4524
+ function createRelevancePathsDriftCheck(t, inspection) {
4168
4525
  if (!inspection.git_available) {
4169
4526
  return okCheck(
4170
- "Knowledge relevance_paths drift",
4171
- `Skipped (git history unavailable; cannot evaluate ${RELEVANCE_PATHS_DRIFT_WINDOW_DAYS}d drift window).`
4527
+ t("doctor.check.relevance_paths_drift.name"),
4528
+ t("doctor.check.relevance_paths_drift.ok.skipped", {
4529
+ windowDays: String(RELEVANCE_PATHS_DRIFT_WINDOW_DAYS)
4530
+ })
4172
4531
  );
4173
4532
  }
4174
4533
  if (inspection.candidates.length === 0) {
4175
4534
  return okCheck(
4176
- "Knowledge relevance_paths drift",
4177
- `All narrow-scope canonical entries have at least one relevance_path touched in the last ${RELEVANCE_PATHS_DRIFT_WINDOW_DAYS}d.`
4535
+ t("doctor.check.relevance_paths_drift.name"),
4536
+ t("doctor.check.relevance_paths_drift.ok.fresh", {
4537
+ windowDays: String(RELEVANCE_PATHS_DRIFT_WINDOW_DAYS)
4538
+ })
4178
4539
  );
4179
4540
  }
4180
4541
  const first = inspection.candidates[0];
4181
4542
  const detail = `${first.stable_id} at ${first.path} (globs: ${first.globs.join(", ")})`;
4543
+ const count = inspection.candidates.length;
4182
4544
  return issueCheck(
4183
- "Knowledge relevance_paths drift",
4545
+ t("doctor.check.relevance_paths_drift.name"),
4184
4546
  "ok",
4185
4547
  "info",
4186
4548
  "knowledge_relevance_paths_drift",
4187
- `${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}.`,
4188
- "Review whether the entry is still relevant \u2014 use `fab_review.modify` to refresh the anchors or `fab_review.reject` to archive."
4549
+ t(`doctor.check.relevance_paths_drift.message.${count === 1 ? "singular" : "plural"}`, {
4550
+ count: String(count),
4551
+ windowDays: String(RELEVANCE_PATHS_DRIFT_WINDOW_DAYS),
4552
+ detail
4553
+ }),
4554
+ t("doctor.check.relevance_paths_drift.remediation")
4189
4555
  );
4190
4556
  }
4191
4557
  function inspectRelevanceFieldsMissing(projectRoot) {
@@ -4328,11 +4694,11 @@ async function applyRelevanceFieldsMissing(candidate) {
4328
4694
  };
4329
4695
  }
4330
4696
  }
4331
- function createRelevanceFieldsMissingCheck(inspection) {
4697
+ function createRelevanceFieldsMissingCheck(t, inspection) {
4332
4698
  if (inspection.candidates.length === 0) {
4333
4699
  return okCheck(
4334
- "Knowledge relevance fields missing",
4335
- "All pending entries declare both relevance_scope and relevance_paths."
4700
+ t("doctor.check.relevance_fields_missing.name"),
4701
+ t("doctor.check.relevance_fields_missing.ok")
4336
4702
  );
4337
4703
  }
4338
4704
  const first = inspection.candidates[0];
@@ -4340,13 +4706,17 @@ function createRelevanceFieldsMissingCheck(inspection) {
4340
4706
  if (first.missing_scope) missingParts.push("relevance_scope");
4341
4707
  if (first.missing_paths) missingParts.push("relevance_paths");
4342
4708
  const detail = `${first.pending_path} (missing: ${missingParts.join(", ")})`;
4709
+ const count = inspection.candidates.length;
4343
4710
  return issueCheck(
4344
- "Knowledge relevance fields missing",
4711
+ t("doctor.check.relevance_fields_missing.name"),
4345
4712
  "ok",
4346
4713
  "info",
4347
4714
  "knowledge_relevance_fields_missing",
4348
- `${inspection.candidates.length} pending entr${inspection.candidates.length === 1 ? "y is" : "ies are"} missing relevance_scope and/or relevance_paths in frontmatter. First: ${detail}.`,
4349
- "Run `fab doctor --apply-lint` to back-fill the schema defaults (relevance_scope: broad, relevance_paths: [])."
4715
+ t(`doctor.check.relevance_fields_missing.message.${count === 1 ? "singular" : "plural"}`, {
4716
+ count: String(count),
4717
+ detail
4718
+ }),
4719
+ t("doctor.check.relevance_fields_missing.remediation")
4350
4720
  );
4351
4721
  }
4352
4722
  var SKILL_MD_FRONTMATTER_ROOTS = [".claude/skills", ".codex/skills"];
@@ -4417,23 +4787,26 @@ function extractSkillFrontmatterLines(raw) {
4417
4787
  }
4418
4788
  return null;
4419
4789
  }
4420
- function createSkillMdYamlInvalidCheck(inspection) {
4790
+ function createSkillMdYamlInvalidCheck(t, inspection) {
4421
4791
  if (inspection.candidates.length === 0) {
4422
4792
  return okCheck(
4423
- "Skill markdown YAML",
4424
- "All .claude/.codex SKILL.md frontmatter values parse as strict YAML."
4793
+ t("doctor.check.skill_md_yaml_invalid.name"),
4794
+ t("doctor.check.skill_md_yaml_invalid.ok")
4425
4795
  );
4426
4796
  }
4427
4797
  const first = inspection.candidates[0];
4428
4798
  const detail = `${first.path}:${first.line} (key \`${first.key}\` value contains an unquoted ': ' \u2014 preview: \`${first.preview}\`)`;
4429
4799
  const plural = inspection.candidates.length === 1;
4430
4800
  return issueCheck(
4431
- "Skill markdown YAML",
4801
+ t("doctor.check.skill_md_yaml_invalid.name"),
4432
4802
  "warn",
4433
4803
  "warning",
4434
4804
  "skill_md_yaml_invalid",
4435
- `${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}.`,
4436
- 'Quote the value with double quotes (`description: "\u2026"`) or rewrite the inner `key: value` token to `key=value`.'
4805
+ t(`doctor.check.skill_md_yaml_invalid.message.${plural ? "singular" : "plural"}`, {
4806
+ count: String(inspection.candidates.length),
4807
+ detail
4808
+ }),
4809
+ t("doctor.check.skill_md_yaml_invalid.remediation")
4437
4810
  );
4438
4811
  }
4439
4812
  var KNOWLEDGE_CANONICAL_TYPE_DIRS_FOR_ONBOARD = [
@@ -4528,55 +4901,83 @@ function readFrontmatterScalar(content, key) {
4528
4901
  }
4529
4902
  return void 0;
4530
4903
  }
4531
- function createOnboardCoverageCheck(inspection) {
4904
+ function createOnboardCoverageCheck(t, inspection) {
4532
4905
  const filledCount = ONBOARD_SLOT_NAMES.filter(
4533
4906
  (slot) => inspection.filled[slot].length > 0
4534
4907
  ).length;
4535
4908
  if (inspection.missing.length === 0) {
4536
4909
  return okCheck(
4537
- "Onboard coverage",
4538
- `Onboard coverage: ${filledCount}/${ONBOARD_SLOT_TOTAL} \u2713 (opted-out: ${inspection.opted_out.length}).`
4910
+ t("doctor.check.onboard_coverage.name"),
4911
+ t("doctor.check.onboard_coverage.ok.complete", {
4912
+ filledCount: String(filledCount),
4913
+ total: String(ONBOARD_SLOT_TOTAL),
4914
+ optedOutCount: String(inspection.opted_out.length)
4915
+ })
4539
4916
  );
4540
4917
  }
4541
4918
  return issueCheck(
4542
- "Onboard coverage",
4919
+ t("doctor.check.onboard_coverage.name"),
4543
4920
  "ok",
4544
4921
  "info",
4545
4922
  "onboard_coverage_incomplete",
4546
- `Onboard slots not yet covered: [${inspection.missing.join(", ")}]. ${filledCount}/${ONBOARD_SLOT_TOTAL} filled; ${inspection.opted_out.length} opted-out.`,
4547
- "Run /fabric-archive to onboard \u2014 the Skill's first-run phase will tour the project and propose pending entries for each unclaimed slot."
4923
+ t("doctor.check.onboard_coverage.message.incomplete", {
4924
+ missingSlots: inspection.missing.join(", "),
4925
+ filledCount: String(filledCount),
4926
+ total: String(ONBOARD_SLOT_TOTAL),
4927
+ optedOutCount: String(inspection.opted_out.length)
4928
+ }),
4929
+ t("doctor.check.onboard_coverage.remediation.incomplete")
4548
4930
  );
4549
4931
  }
4550
- function createNarrowTooFewCheck(inspection) {
4932
+ function createNarrowTooFewCheck(t, inspection) {
4551
4933
  const { structural_flagged, telemetry_flagged } = inspection;
4552
4934
  if (!structural_flagged && !telemetry_flagged) {
4553
4935
  const ratioPct = (inspection.narrow_ratio * 100).toFixed(0);
4554
- 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`;
4936
+ const teleNote = inspection.telemetry_skipped ? t("doctor.check.narrow_too_few.message.telemetry_skipped") : t("doctor.check.narrow_too_few.message.telemetry_window", {
4937
+ silencePct: (inspection.silence_rate * 100).toFixed(0),
4938
+ windowDays: String(SILENCE_WINDOW_DAYS)
4939
+ });
4555
4940
  return okCheck(
4556
- "Knowledge narrow too few",
4557
- `Narrow-with-paths ratio ${ratioPct}% (${inspection.narrow_with_paths_count}/${inspection.total_canonical_entries}); ${teleNote}.`
4941
+ t("doctor.check.narrow_too_few.name"),
4942
+ t("doctor.check.narrow_too_few.ok", {
4943
+ ratioPct,
4944
+ narrowCount: String(inspection.narrow_with_paths_count),
4945
+ totalCount: String(inspection.total_canonical_entries),
4946
+ teleNote
4947
+ })
4558
4948
  );
4559
4949
  }
4560
4950
  const parts = [];
4561
4951
  if (structural_flagged) {
4562
4952
  const ratioPct = (inspection.narrow_ratio * 100).toFixed(0);
4563
4953
  parts.push(
4564
- `narrow-with-paths share ${ratioPct}% (${inspection.narrow_with_paths_count}/${inspection.total_canonical_entries}) below ${(NARROW_RATIO_THRESHOLD * 100).toFixed(0)}% threshold`
4954
+ t("doctor.check.narrow_too_few.message.structural", {
4955
+ ratioPct,
4956
+ narrowCount: String(inspection.narrow_with_paths_count),
4957
+ totalCount: String(inspection.total_canonical_entries),
4958
+ thresholdPct: (NARROW_RATIO_THRESHOLD * 100).toFixed(0)
4959
+ })
4565
4960
  );
4566
4961
  }
4567
4962
  if (telemetry_flagged) {
4568
4963
  const silencePct = (inspection.silence_rate * 100).toFixed(0);
4569
4964
  parts.push(
4570
- `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`
4965
+ t("doctor.check.narrow_too_few.message.telemetry", {
4966
+ silencePct,
4967
+ silenceFires: String(inspection.silence_fires_in_window),
4968
+ totalFires: String(inspection.total_edit_fires_in_window),
4969
+ windowDays: String(SILENCE_WINDOW_DAYS),
4970
+ thresholdPct: (SILENCE_RATE_THRESHOLD * 100).toFixed(0)
4971
+ })
4571
4972
  );
4572
4973
  }
4573
4974
  return issueCheck(
4574
- "Knowledge narrow too few",
4975
+ t("doctor.check.narrow_too_few.name"),
4575
4976
  "ok",
4576
4977
  "info",
4577
4978
  "knowledge_narrow_too_few",
4578
- `Narrow-scope KB coverage is below the useful floor: ${parts.join("; ")}.`,
4579
- "Run the fabric-import Skill (`/fabric-import`) to re-seed narrow anchors against the current codebase."
4979
+ t("doctor.check.narrow_too_few.message.summary", { parts: parts.join("; ") }),
4980
+ t("doctor.check.narrow_too_few.remediation")
4580
4981
  );
4581
4982
  }
4582
4983
  function resolvePersonalKnowledgeRoot() {
@@ -4713,58 +5114,70 @@ function inspectIndexDrift(projectRoot, meta) {
4713
5114
  );
4714
5115
  return { drifts };
4715
5116
  }
4716
- function createStableIdDuplicateCheck(inspection) {
5117
+ function createStableIdDuplicateCheck(t, inspection) {
4717
5118
  if (inspection.duplicates.length === 0) {
4718
5119
  return okCheck(
4719
- "Knowledge stable_id duplicate",
4720
- "No canonical knowledge files share a stable_id across team / personal trees."
5120
+ t("doctor.check.stable_id_duplicate.name"),
5121
+ t("doctor.check.stable_id_duplicate.ok")
4721
5122
  );
4722
5123
  }
4723
5124
  const first = inspection.duplicates[0];
4724
5125
  const detail = `${first.stable_id} appears in ${first.paths.length} files: ${first.paths.join(", ")}`;
5126
+ const count = inspection.duplicates.length;
4725
5127
  return issueCheck(
4726
- "Knowledge stable_id duplicate",
5128
+ t("doctor.check.stable_id_duplicate.name"),
4727
5129
  "error",
4728
5130
  "manual_error",
4729
5131
  "knowledge_stable_id_duplicate",
4730
- `${inspection.duplicates.length} stable_id${inspection.duplicates.length === 1 ? "" : "s"} duplicated across canonical knowledge files (path-decoupled identity invariant). First: ${detail}.`,
4731
- "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."
5132
+ t(`doctor.check.stable_id_duplicate.message.${count === 1 ? "singular" : "plural"}`, {
5133
+ count: String(count),
5134
+ detail
5135
+ }),
5136
+ t("doctor.check.stable_id_duplicate.remediation")
4732
5137
  );
4733
5138
  }
4734
- function createLayerMismatchCheck(inspection) {
5139
+ function createLayerMismatchCheck(t, inspection) {
4735
5140
  if (inspection.mismatches.length === 0) {
4736
5141
  return okCheck(
4737
- "Knowledge layer mismatch",
4738
- "All canonical knowledge files are physically located under the layer their stable_id prefix declares."
5142
+ t("doctor.check.layer_mismatch.name"),
5143
+ t("doctor.check.layer_mismatch.ok")
4739
5144
  );
4740
5145
  }
4741
5146
  const first = inspection.mismatches[0];
4742
5147
  const detail = `${first.stable_id} at ${first.path} (located in ${first.located_in}, expected ${first.expected_layer})`;
5148
+ const count = inspection.mismatches.length;
4743
5149
  return issueCheck(
4744
- "Knowledge layer mismatch",
5150
+ t("doctor.check.layer_mismatch.name"),
4745
5151
  "error",
4746
5152
  "manual_error",
4747
5153
  "knowledge_layer_mismatch",
4748
- `${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}.`,
4749
- "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)."
5154
+ t(`doctor.check.layer_mismatch.message.${count === 1 ? "singular" : "plural"}`, {
5155
+ count: String(count),
5156
+ detail
5157
+ }),
5158
+ t("doctor.check.layer_mismatch.remediation")
4750
5159
  );
4751
5160
  }
4752
- function createIndexDriftCheck(inspection) {
5161
+ function createIndexDriftCheck(t, inspection) {
4753
5162
  if (inspection.drifts.length === 0) {
4754
5163
  return okCheck(
4755
- "Knowledge index drift",
4756
- "agents.meta.json counters envelope is at or above the highest existing canonical counter for every (layer, type) pair."
5164
+ t("doctor.check.index_drift.name"),
5165
+ t("doctor.check.index_drift.ok")
4757
5166
  );
4758
5167
  }
4759
5168
  const first = inspection.drifts[0];
4760
5169
  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})`;
5170
+ const count = inspection.drifts.length;
4761
5171
  return issueCheck(
4762
- "Knowledge index drift",
5172
+ t("doctor.check.index_drift.name"),
4763
5173
  "error",
4764
5174
  "fixable_error",
4765
5175
  "knowledge_index_drift",
4766
- `${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}.`,
4767
- "Run `fab doctor --apply-lint` (rc.4 TASK-003) to bump agents.meta.json counters to max_observed + 1."
5176
+ t(`doctor.check.index_drift.message.${count === 1 ? "singular" : "plural"}`, {
5177
+ count: String(count),
5178
+ detail
5179
+ }),
5180
+ t("doctor.check.index_drift.remediation")
4768
5181
  );
4769
5182
  }
4770
5183
  async function migrateBootstrapMarkers(projectRoot) {
@@ -5137,7 +5550,7 @@ async function runDoctorCiteCoverage(projectRoot, options) {
5137
5550
  break;
5138
5551
  }
5139
5552
  }
5140
- const filteredTurns = options.client === "all" ? assistantTurns : assistantTurns.filter((t2) => t2.client === options.client);
5553
+ const filteredTurns = options.client === "all" ? assistantTurns : assistantTurns.filter((t) => t.client === options.client);
5141
5554
  let clientSessionIds = null;
5142
5555
  if (options.client !== "all") {
5143
5556
  clientSessionIds = /* @__PURE__ */ new Set();