@fenglimg/fabric-cli 2.0.0 → 2.0.1

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.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -5
  3. package/dist/chunk-BATF4PEJ.js +361 -0
  4. package/dist/{chunk-OBQU6NHO.js → chunk-COI5VDFU.js} +0 -18
  5. package/dist/chunk-D25XJ4BC.js +880 -0
  6. package/dist/chunk-MF3OTILQ.js +544 -0
  7. package/dist/chunk-PWLW3B57.js +18 -0
  8. package/dist/config-XJIPZNUP.js +13 -0
  9. package/dist/doctor-EJDSEJSS.js +810 -0
  10. package/dist/index.js +15 -8
  11. package/dist/{init-BIRSIOXO.js → install-EKWMFLUU.js} +622 -711
  12. package/dist/metrics-ACEQFPDU.js +122 -0
  13. package/dist/onboard-coverage-MFCAEBDO.js +220 -0
  14. package/dist/{plan-context-hint-QMUPAXIB.js → plan-context-hint-FC6P3WFE.js} +34 -28
  15. package/dist/uninstall-MH7ZIB6M.js +1064 -0
  16. package/package.json +30 -5
  17. package/templates/hooks/cite-policy-evict.cjs +231 -0
  18. package/templates/hooks/configs/README.md +29 -6
  19. package/templates/hooks/configs/claude-code.json +14 -3
  20. package/templates/hooks/configs/codex-hooks.json +6 -3
  21. package/templates/hooks/configs/cursor-hooks.json +8 -10
  22. package/templates/hooks/fabric-hint.cjs +833 -105
  23. package/templates/hooks/knowledge-hint-broad.cjs +509 -135
  24. package/templates/hooks/knowledge-hint-narrow.cjs +791 -26
  25. package/templates/hooks/lib/banner-i18n.cjs +309 -0
  26. package/templates/hooks/lib/cite-contract-reminder.cjs +173 -0
  27. package/templates/hooks/lib/cite-line-parser.cjs +158 -0
  28. package/templates/hooks/lib/client-adapter.cjs +106 -0
  29. package/templates/hooks/lib/config-cache.cjs +107 -0
  30. package/templates/hooks/lib/state-store.cjs +84 -0
  31. package/templates/hooks/lib/summary-fallback.cjs +210 -0
  32. package/templates/skills/fabric-archive/SKILL.md +93 -419
  33. package/templates/skills/fabric-archive/ref/dry-run-scope.md +16 -0
  34. package/templates/skills/fabric-archive/ref/e5-cron-recap.md +58 -0
  35. package/templates/skills/fabric-archive/ref/i18n-policy.md +86 -0
  36. package/templates/skills/fabric-archive/ref/phase-0-range-resolution.md +156 -0
  37. package/templates/skills/fabric-archive/ref/phase-1-5-onboard.md +218 -0
  38. package/templates/skills/fabric-archive/ref/phase-1-cross-session.md +62 -0
  39. package/templates/skills/fabric-archive/ref/phase-2-5-viability.md +68 -0
  40. package/templates/skills/fabric-archive/ref/phase-3-5-scope.md +108 -0
  41. package/templates/skills/fabric-archive/ref/phase-3-classify.md +63 -0
  42. package/templates/skills/fabric-archive/ref/phase-4-5-emit.md +78 -0
  43. package/templates/skills/fabric-archive/ref/phase-4-mcp-persist.md +89 -0
  44. package/templates/skills/fabric-archive/ref/rc-history.md +38 -0
  45. package/templates/skills/fabric-archive/ref/worked-examples.md +78 -0
  46. package/templates/skills/fabric-import/SKILL.md +75 -516
  47. package/templates/skills/fabric-import/ref/checkpoint-state.md +85 -0
  48. package/templates/skills/fabric-import/ref/i18n-policy.md +79 -0
  49. package/templates/skills/fabric-import/ref/output-contract.md +61 -0
  50. package/templates/skills/fabric-import/ref/phase-2-mining.md +213 -0
  51. package/templates/skills/fabric-import/ref/phase-3-dedup.md +75 -0
  52. package/templates/skills/fabric-import/ref/state-recovery.md +57 -0
  53. package/templates/skills/fabric-import/ref/worked-examples.md +127 -0
  54. package/templates/skills/fabric-review/SKILL.md +86 -284
  55. package/templates/skills/fabric-review/ref/askuserquestion-policy.md +66 -0
  56. package/templates/skills/fabric-review/ref/i18n-policy.md +111 -0
  57. package/templates/skills/fabric-review/ref/modify-flow.md +103 -0
  58. package/templates/skills/fabric-review/ref/output-contract.md +58 -0
  59. package/templates/skills/fabric-review/ref/per-mode-flows.md +155 -0
  60. package/templates/skills/fabric-review/ref/semantic-check.md +26 -0
  61. package/templates/skills/fabric-review/ref/worked-examples.md +95 -0
  62. package/templates/skills/lib/shared-policy.md +69 -0
  63. package/dist/chunk-6ICJICVU.js +0 -10
  64. package/dist/chunk-74SZWYPH.js +0 -658
  65. package/dist/chunk-EYIDD2YS.js +0 -1000
  66. package/dist/doctor-T7JWODKG.js +0 -282
  67. package/dist/hooks-Y74Y5LQS.js +0 -12
  68. package/dist/scan-LMK3UCWL.js +0 -22
  69. package/dist/serve-H554BHLG.js +0 -124
  70. package/templates/agents-md/AGENTS.md.template +0 -59
  71. package/templates/bootstrap/CLAUDE.md +0 -8
  72. package/templates/bootstrap/codex-AGENTS-header.md +0 -6
  73. package/templates/bootstrap/cursor-fabric-bootstrap.mdc +0 -10
@@ -0,0 +1,106 @@
1
+ /**
2
+ * v2.0.0-rc.37 NEW-30: shared client-protocol adapter for hook scripts.
3
+ *
4
+ * The three host clients (Claude Code / Codex CLI / Cursor) differ in how a
5
+ * hook surfaces context back to the model:
6
+ * - Claude Code reads a stdout JSON envelope
7
+ * ({ hookSpecificOutput: { hookEventName, additionalContext } }).
8
+ * - Codex CLI and Cursor read plain stderr text.
9
+ * Each hook had its own copy of the detect-client + read-stdin + pick-channel
10
+ * logic (fabric-hint.detectClient, cite-policy-evict.isClaudeCode + readStdinJson,
11
+ * knowledge-hint-broad inline CLAUDE_PROJECT_DIR check). This module is the
12
+ * single canonical implementation so the protocol choice lives in one place.
13
+ *
14
+ * Provides:
15
+ * - detectClient(dirnameHint?) → 'cc' | 'codex' | 'cursor' | undefined
16
+ * 3-tier: FABRIC_HINT_CLIENT env override → CLAUDE_PROJECT_DIR (cc) →
17
+ * __dirname path heuristic (.claude / .codex / .cursor). dirnameHint
18
+ * defaults to this lib's own dir (which still lives under the client
19
+ * dir, e.g. .claude/hooks/lib), so the heuristic stays accurate.
20
+ * - isClaudeCode() → boolean (CLAUDE_PROJECT_DIR present)
21
+ * - readStdinJson({ timeoutMs }) → Promise<object | null>
22
+ * Async stdin JSON reader; null on parse error / closed stdin / timeout.
23
+ * - emitContext(text, { client, eventName, streams, forceStderr }) → void
24
+ * Standardised output: Claude Code → stdout JSON envelope; Codex/Cursor
25
+ * → plain stderr. forceStderr pins stderr even on Claude Code (used for
26
+ * SessionStart one-shot reminders). Best-effort — never throws.
27
+ *
28
+ * Never-throw contract (KT-DEC-0007): every path degrades silently rather than
29
+ * blocking the host's main flow.
30
+ */
31
+
32
+ function isClaudeCode() {
33
+ return (
34
+ typeof process.env.CLAUDE_PROJECT_DIR === "string" &&
35
+ process.env.CLAUDE_PROJECT_DIR.length > 0
36
+ );
37
+ }
38
+
39
+ function detectClient(dirnameHint) {
40
+ const envClient = process.env.FABRIC_HINT_CLIENT;
41
+ if (typeof envClient === "string" && envClient.length > 0) {
42
+ const normalised = envClient.trim().toLowerCase();
43
+ if (normalised === "cc" || normalised === "codex" || normalised === "cursor") {
44
+ return normalised;
45
+ }
46
+ }
47
+ if (isClaudeCode()) return "cc";
48
+ // Path heuristic against the caller's directory (defaults to this lib's dir,
49
+ // which sits under the client root, e.g. .codex/hooks/lib).
50
+ const dir = typeof dirnameHint === "string" && dirnameHint.length > 0 ? dirnameHint : __dirname;
51
+ try {
52
+ if (dir.includes(".claude/") || dir.includes(".claude\\")) return "cc";
53
+ if (dir.includes(".codex/") || dir.includes(".codex\\")) return "codex";
54
+ if (dir.includes(".cursor/") || dir.includes(".cursor\\")) return "cursor";
55
+ } catch {
56
+ // fall through
57
+ }
58
+ return undefined;
59
+ }
60
+
61
+ function readStdinJson(opts) {
62
+ const { timeoutMs = 1000 } = opts || {};
63
+ return new Promise((resolve) => {
64
+ let buffer = "";
65
+ process.stdin.on("data", (chunk) => {
66
+ buffer += chunk;
67
+ });
68
+ process.stdin.on("end", () => {
69
+ try {
70
+ resolve(JSON.parse(buffer));
71
+ } catch {
72
+ resolve(null);
73
+ }
74
+ });
75
+ process.stdin.on("error", () => resolve(null));
76
+ // Defensive timeout: if stdin never closes (host bug), give up.
77
+ setTimeout(() => resolve(null), timeoutMs).unref();
78
+ });
79
+ }
80
+
81
+ function emitContext(text, opts) {
82
+ const { client, eventName = "UserPromptSubmit", streams = {}, forceStderr = false } = opts || {};
83
+ const stdout = streams.stdout || process.stdout;
84
+ const stderr = streams.stderr || process.stderr;
85
+ const useStdoutEnvelope =
86
+ !forceStderr && (client === "cc" || (client === undefined && isClaudeCode()));
87
+ try {
88
+ if (useStdoutEnvelope) {
89
+ const envelope = {
90
+ hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
91
+ };
92
+ stdout.write(`${JSON.stringify(envelope)}\n`);
93
+ } else {
94
+ stderr.write(`${text}\n`);
95
+ }
96
+ } catch {
97
+ // best-effort — never throw
98
+ }
99
+ }
100
+
101
+ module.exports = {
102
+ isClaudeCode,
103
+ detectClient,
104
+ readStdinJson,
105
+ emitContext,
106
+ };
@@ -0,0 +1,107 @@
1
+ /**
2
+ * v2.0.0-rc.37 NEW-19: shared fabric-config reader for hook scripts.
3
+ *
4
+ * Before this lib, every hook re-implemented the same defensive
5
+ * `readFileSync(.fabric/fabric-config.json) → JSON.parse → validate one key →
6
+ * fall back to default` boilerplate, once PER KEY (knowledge-hint-broad alone
7
+ * read the file 5× per SessionStart fire: cooldown / top_k / underseed /
8
+ * summary_max_len / reminder_to_context). This module centralises the read +
9
+ * mtime-keyed memoisation so a single hook fire parses the config once.
10
+ *
11
+ * Provides:
12
+ * - readConfig(projectRoot) → object
13
+ * Parsed fabric-config.json (memoised on path+mtime). Returns `{}` on
14
+ * any failure (missing file / parse error / non-object). Never throws.
15
+ * mtime-keyed so a config rewrite mid-process (test harness) invalidates
16
+ * the cached value automatically — production hooks are single-shot so
17
+ * the common case is one stat + one parse.
18
+ * - readConfigNumber(root, key, fallback, { min, max, integer }) → number
19
+ * - readConfigBoolean(root, key, fallback) → boolean
20
+ * - readConfigString(root, key, fallback) → string
21
+ * Typed getters with inline range/shape validation; any miss → fallback.
22
+ * - configPathFor(projectRoot) → absolute config path
23
+ * - clearConfigCache() → void (test helper)
24
+ *
25
+ * Never-throw contract: every export degrades to its fallback rather than
26
+ * throwing, preserving the reminder-layer hook invariant (KT-DEC-0007: hooks
27
+ * never block on their own malfunction).
28
+ */
29
+
30
+ const { existsSync, readFileSync, statSync } = require("node:fs");
31
+ const { join } = require("node:path");
32
+
33
+ const FABRIC_DIR_REL = ".fabric";
34
+ const FABRIC_CONFIG_FILE = "fabric-config.json";
35
+
36
+ // path → { mtime, value }. Per-process; mtime-keyed for test-mutation safety.
37
+ const _cache = new Map();
38
+
39
+ function configPathFor(projectRoot) {
40
+ return join(projectRoot, FABRIC_DIR_REL, FABRIC_CONFIG_FILE);
41
+ }
42
+
43
+ function readConfig(projectRoot) {
44
+ const path = configPathFor(projectRoot);
45
+ let mtime;
46
+ try {
47
+ if (!existsSync(path)) {
48
+ _cache.delete(path);
49
+ return {};
50
+ }
51
+ mtime = statSync(path).mtimeMs;
52
+ } catch {
53
+ return {};
54
+ }
55
+ const cached = _cache.get(path);
56
+ if (cached && cached.mtime === mtime) return cached.value;
57
+ let value = {};
58
+ try {
59
+ const raw = JSON.parse(readFileSync(path, "utf8"));
60
+ if (raw && typeof raw === "object") value = raw;
61
+ } catch {
62
+ value = {};
63
+ }
64
+ _cache.set(path, { mtime, value });
65
+ return value;
66
+ }
67
+
68
+ function clearConfigCache() {
69
+ _cache.clear();
70
+ }
71
+
72
+ // opts:
73
+ // min / max — inclusive range; out-of-range → fallback
74
+ // integer — require Number.isInteger; non-integer → fallback (strict)
75
+ // floor — accept any in-range number, return Math.floor(v) (lenient)
76
+ // `integer` and `floor` are independent: `integer` rejects fractional values,
77
+ // `floor` truncates them. Pick whichever matches the caller's legacy contract.
78
+ function readConfigNumber(projectRoot, key, fallback, opts) {
79
+ const { min, max, integer, floor } = opts || {};
80
+ const v = readConfig(projectRoot)[key];
81
+ if (typeof v === "number" && Number.isFinite(v)) {
82
+ if (integer && !Number.isInteger(v)) return fallback;
83
+ if (typeof min === "number" && v < min) return fallback;
84
+ if (typeof max === "number" && v > max) return fallback;
85
+ return floor ? Math.floor(v) : v;
86
+ }
87
+ return fallback;
88
+ }
89
+
90
+ function readConfigBoolean(projectRoot, key, fallback) {
91
+ const v = readConfig(projectRoot)[key];
92
+ return typeof v === "boolean" ? v : fallback;
93
+ }
94
+
95
+ function readConfigString(projectRoot, key, fallback) {
96
+ const v = readConfig(projectRoot)[key];
97
+ return typeof v === "string" && v.length > 0 ? v : fallback;
98
+ }
99
+
100
+ module.exports = {
101
+ readConfig,
102
+ clearConfigCache,
103
+ readConfigNumber,
104
+ readConfigBoolean,
105
+ readConfigString,
106
+ configPathFor,
107
+ };
@@ -0,0 +1,84 @@
1
+ /**
2
+ * v2.0.0-rc.37 NEW-19: shared `.fabric/.cache/` sidecar I/O for hook scripts.
3
+ *
4
+ * Hooks persist tiny per-session state (turn counters, last-emit timestamps,
5
+ * shown-hint sets) under `.fabric/.cache/`. Each hook had its own copy of the
6
+ * read-JSON-or-null / write-JSON-best-effort / read-text / write-text helpers
7
+ * (cite-policy-evict's readEvictState/writeEvictState, broad's
8
+ * readBroadLastEmit/writeBroadLastEmit, fabric-hint's shown-cache + edit-counter
9
+ * + maintenance-last-emit). This module is the single canonical implementation.
10
+ *
11
+ * Provides (all keyed on a bare `fileName` resolved under .fabric/.cache/):
12
+ * - cachePath(projectRoot, fileName) → absolute path
13
+ * - readJsonState(root, fileName, validate?) → parsed | null
14
+ * null on missing / parse error / validate() === false. Never throws.
15
+ * - writeJsonState(root, fileName, value) → boolean
16
+ * mkdir -p + write; false on failure. Never throws.
17
+ * - readTextState(root, fileName) → trimmed string | null
18
+ * - writeTextState(root, fileName, text) → boolean
19
+ *
20
+ * Never-throw contract: write failures return false (counter loss is
21
+ * acceptable — the hook never blocks user flow on sidecar I/O, KT-DEC-0007).
22
+ */
23
+
24
+ const { existsSync, mkdirSync, readFileSync, writeFileSync } = require("node:fs");
25
+ const { dirname, join } = require("node:path");
26
+
27
+ const CACHE_DIR_REL = join(".fabric", ".cache");
28
+
29
+ function cachePath(projectRoot, fileName) {
30
+ return join(projectRoot, CACHE_DIR_REL, fileName);
31
+ }
32
+
33
+ function readJsonState(projectRoot, fileName, validate) {
34
+ const path = cachePath(projectRoot, fileName);
35
+ if (!existsSync(path)) return null;
36
+ try {
37
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
38
+ if (typeof validate === "function" && !validate(parsed)) return null;
39
+ return parsed;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ function writeJsonState(projectRoot, fileName, value) {
46
+ const path = cachePath(projectRoot, fileName);
47
+ try {
48
+ mkdirSync(dirname(path), { recursive: true });
49
+ writeFileSync(path, JSON.stringify(value));
50
+ return true;
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ function readTextState(projectRoot, fileName) {
57
+ const path = cachePath(projectRoot, fileName);
58
+ if (!existsSync(path)) return null;
59
+ try {
60
+ return readFileSync(path, "utf8").trim();
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ function writeTextState(projectRoot, fileName, text) {
67
+ const path = cachePath(projectRoot, fileName);
68
+ try {
69
+ mkdirSync(dirname(path), { recursive: true });
70
+ writeFileSync(path, String(text));
71
+ return true;
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ module.exports = {
78
+ cachePath,
79
+ readJsonState,
80
+ writeJsonState,
81
+ readTextState,
82
+ writeTextState,
83
+ CACHE_DIR_REL,
84
+ };
@@ -0,0 +1,210 @@
1
+ /**
2
+ * rc.35 TASK-06 (P0-10.b) — summary-fallback library.
3
+ *
4
+ * Resolves opaque hint entries (where `entry.summary === entry.id` so the
5
+ * AI sees no information beyond the id) by reading the entry's markdown
6
+ * file at `.fabric/knowledge/<type>/<id>--<slug>.md`, extracting the first
7
+ * paragraph under `## Summary`, and substituting that text into the entry
8
+ * before the hook renders it.
9
+ *
10
+ * Caching: results are stored in `.fabric/.cache/summary-fallback.json`
11
+ * keyed by the current `revision_hash` returned by plan-context-hint. The
12
+ * cache is wiped wholesale when the revision changes (cheap invariant —
13
+ * any meta rev bump implies entry text MAY have moved). Per-process call
14
+ * also benefits from in-memory dedup since the same opaque id may appear
15
+ * across narrow + broad paths.
16
+ *
17
+ * Design contract:
18
+ * - Never throw. ANY failure (cache read, fs scan, file read) degrades
19
+ * to a no-op — the original opaque summary is left untouched. Hooks
20
+ * must remain best-effort.
21
+ * - Idempotent over identical inputs. Two calls in succession with the
22
+ * same revision_hash + entries set produce zero disk reads on the
23
+ * second call.
24
+ *
25
+ * Public API (module.exports):
26
+ * resolveOpaqueSummaries(entries, projectRoot, revisionHash) — returns
27
+ * a NEW array of entries with `summary` substituted for opaque cases.
28
+ * Original `entry.id` is preserved verbatim.
29
+ *
30
+ * _extractFirstSummaryParagraph(md) — pure helper, exposed for testing.
31
+ *
32
+ * _readCache / _writeCache — exposed for testing.
33
+ */
34
+
35
+ const { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } = require("node:fs");
36
+ const { join } = require("node:path");
37
+
38
+ const CACHE_DIR_REL = ".fabric/.cache";
39
+ const CACHE_FILE_REL = ".fabric/.cache/summary-fallback.json";
40
+ const KNOWLEDGE_DIR_REL = ".fabric/knowledge";
41
+ const SUMMARY_MAX_LEN = 80;
42
+ const KNOWLEDGE_TYPE_DIRS = ["decisions", "pitfalls", "guidelines", "models", "processes"];
43
+
44
+ function _isOpaque(entry) {
45
+ if (!entry || typeof entry.id !== "string" || typeof entry.summary !== "string") {
46
+ return false;
47
+ }
48
+ return entry.summary.trim() === entry.id.trim();
49
+ }
50
+
51
+ /**
52
+ * Pure helper: extract the first paragraph under a `## Summary` heading.
53
+ *
54
+ * - `## Summary` is case-insensitive but level-sensitive (only H2).
55
+ * - First paragraph = lines until blank line or next heading.
56
+ * - Collapses whitespace + trims; returns `""` if no summary section or
57
+ * the section is empty.
58
+ */
59
+ function _extractFirstSummaryParagraph(md) {
60
+ if (typeof md !== "string" || md.length === 0) return "";
61
+ const lines = md.split(/\r?\n/);
62
+ let i = 0;
63
+ while (i < lines.length) {
64
+ if (/^##\s+summary\s*$/i.test(lines[i].trim())) {
65
+ i += 1;
66
+ break;
67
+ }
68
+ i += 1;
69
+ }
70
+ if (i >= lines.length) return "";
71
+ // Skip blank lines after the heading
72
+ while (i < lines.length && lines[i].trim().length === 0) i += 1;
73
+ // Collect until the next blank line or next heading
74
+ const buf = [];
75
+ while (i < lines.length) {
76
+ const line = lines[i];
77
+ if (line.trim().length === 0) break;
78
+ if (/^#{1,6}\s/.test(line.trim())) break;
79
+ buf.push(line.trim());
80
+ i += 1;
81
+ }
82
+ const flat = buf.join(" ").replace(/\s+/g, " ").trim();
83
+ if (flat.length === 0) return "";
84
+ if (flat.length <= SUMMARY_MAX_LEN) return flat;
85
+ return `${flat.slice(0, SUMMARY_MAX_LEN - 1)}…`;
86
+ }
87
+
88
+ function _readCache(projectRoot) {
89
+ const cachePath = join(projectRoot, CACHE_FILE_REL);
90
+ if (!existsSync(cachePath)) return null;
91
+ try {
92
+ const raw = readFileSync(cachePath, "utf8");
93
+ const parsed = JSON.parse(raw);
94
+ if (parsed && typeof parsed === "object" && typeof parsed.revision === "string" && parsed.summaries && typeof parsed.summaries === "object") {
95
+ return parsed;
96
+ }
97
+ } catch {
98
+ // ignore — caller treats null as no-cache
99
+ }
100
+ return null;
101
+ }
102
+
103
+ function _writeCache(projectRoot, payload) {
104
+ try {
105
+ const cacheDir = join(projectRoot, CACHE_DIR_REL);
106
+ if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
107
+ const cachePath = join(projectRoot, CACHE_FILE_REL);
108
+ writeFileSync(cachePath, JSON.stringify(payload), "utf8");
109
+ } catch {
110
+ // Best-effort — failing to persist cache is not an error
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Scan `.fabric/knowledge/<type>/` for the canonical `<id>--<slug>.md`
116
+ * matching `stableId`. Tries the most likely type-dir first based on the
117
+ * entry's `type` hint, then falls back to scanning all canonical type
118
+ * directories. Returns the absolute path or null.
119
+ *
120
+ * The id→file mapping is unique by construction (stable_id is allocated
121
+ * once per file), so the first match wins.
122
+ */
123
+ function _findEntryFile(projectRoot, stableId, typeHint) {
124
+ const baseDir = join(projectRoot, KNOWLEDGE_DIR_REL);
125
+ if (!existsSync(baseDir)) return null;
126
+ const tryOrder = [];
127
+ if (typeof typeHint === "string" && typeHint.length > 0) {
128
+ // Accept both singular and plural hints — find the plural form.
129
+ const lower = typeHint.toLowerCase();
130
+ const plural = KNOWLEDGE_TYPE_DIRS.find((d) => d === lower || d.startsWith(lower));
131
+ if (plural) tryOrder.push(plural);
132
+ }
133
+ for (const t of KNOWLEDGE_TYPE_DIRS) {
134
+ if (!tryOrder.includes(t)) tryOrder.push(t);
135
+ }
136
+ const prefix = `${stableId}--`;
137
+ for (const t of tryOrder) {
138
+ const typeDir = join(baseDir, t);
139
+ if (!existsSync(typeDir)) continue;
140
+ let files;
141
+ try {
142
+ files = readdirSync(typeDir);
143
+ } catch {
144
+ continue;
145
+ }
146
+ for (const f of files) {
147
+ if (f.startsWith(prefix) && f.endsWith(".md")) {
148
+ return join(typeDir, f);
149
+ }
150
+ }
151
+ }
152
+ return null;
153
+ }
154
+
155
+ function _resolveOne(projectRoot, entry) {
156
+ const filePath = _findEntryFile(projectRoot, entry.id, entry.type);
157
+ if (filePath === null) return "";
158
+ let md;
159
+ try {
160
+ md = readFileSync(filePath, "utf8");
161
+ } catch {
162
+ return "";
163
+ }
164
+ return _extractFirstSummaryParagraph(md);
165
+ }
166
+
167
+ /**
168
+ * Main API. Returns a new array of entries with `summary` swapped for
169
+ * the extracted fallback wherever the original summary was opaque AND
170
+ * the fallback extraction yielded a non-empty string. Non-opaque entries
171
+ * pass through unchanged.
172
+ */
173
+ function resolveOpaqueSummaries(entries, projectRoot, revisionHash) {
174
+ if (!Array.isArray(entries) || entries.length === 0) return entries;
175
+ const cache = _readCache(projectRoot);
176
+ const cachedSummaries = cache && cache.revision === revisionHash && cache.summaries ? cache.summaries : {};
177
+ const nextCacheSummaries = { ...cachedSummaries };
178
+ let cacheChanged = cache === null || cache.revision !== revisionHash;
179
+ const result = entries.map((entry) => {
180
+ if (!_isOpaque(entry)) return entry;
181
+ const id = entry.id;
182
+ let fallback;
183
+ if (Object.prototype.hasOwnProperty.call(cachedSummaries, id)) {
184
+ fallback = cachedSummaries[id];
185
+ } else {
186
+ fallback = _resolveOne(projectRoot, entry);
187
+ nextCacheSummaries[id] = fallback;
188
+ cacheChanged = true;
189
+ }
190
+ if (typeof fallback === "string" && fallback.length > 0) {
191
+ return { ...entry, summary: fallback };
192
+ }
193
+ return entry;
194
+ });
195
+ if (cacheChanged) {
196
+ _writeCache(projectRoot, { revision: revisionHash, summaries: nextCacheSummaries });
197
+ }
198
+ return result;
199
+ }
200
+
201
+ module.exports = {
202
+ resolveOpaqueSummaries,
203
+ _extractFirstSummaryParagraph,
204
+ _readCache,
205
+ _writeCache,
206
+ _findEntryFile,
207
+ _isOpaque,
208
+ SUMMARY_MAX_LEN,
209
+ KNOWLEDGE_TYPE_DIRS,
210
+ };