@fenglimg/fabric-cli 2.0.0 → 2.1.0-rc.2

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 (86) 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-F46ORPOA.js +903 -0
  6. package/dist/chunk-HFQVXY6P.js +86 -0
  7. package/dist/chunk-L4Q55UC4.js +52 -0
  8. package/dist/chunk-LFIKMVY7.js +27 -0
  9. package/dist/chunk-MF3OTILQ.js +544 -0
  10. package/dist/chunk-PWLW3B57.js +18 -0
  11. package/dist/chunk-RYAFBNES.js +33 -0
  12. package/dist/chunk-T5RPGCCM.js +40 -0
  13. package/dist/chunk-WU6GAPKH.js +36 -0
  14. package/dist/config-XJIPZNUP.js +13 -0
  15. package/dist/doctor-QVNPHLJK.js +920 -0
  16. package/dist/index.js +23 -8
  17. package/dist/{init-BIRSIOXO.js → install-2HDO5FTQ.js} +807 -705
  18. package/dist/metrics-ACEQFPDU.js +122 -0
  19. package/dist/onboard-coverage-MFCAEBDO.js +220 -0
  20. package/dist/{plan-context-hint-QMUPAXIB.js → plan-context-hint-FC6P3WFE.js} +34 -28
  21. package/dist/scope-explain-2F2R5URO.js +33 -0
  22. package/dist/status-GLQWLWH6.js +23 -0
  23. package/dist/store-XTSE5TY6.js +105 -0
  24. package/dist/sync-BJCWDPNC.js +245 -0
  25. package/dist/uninstall-TAXSUSKH.js +1073 -0
  26. package/dist/whoami-B6AEMSEV.js +31 -0
  27. package/package.json +30 -5
  28. package/templates/hooks/cite-policy-evict.cjs +231 -0
  29. package/templates/hooks/configs/README.md +29 -6
  30. package/templates/hooks/configs/claude-code.json +14 -3
  31. package/templates/hooks/configs/codex-hooks.json +6 -3
  32. package/templates/hooks/configs/cursor-hooks.json +8 -10
  33. package/templates/hooks/fabric-hint.cjs +873 -105
  34. package/templates/hooks/knowledge-hint-broad.cjs +549 -135
  35. package/templates/hooks/knowledge-hint-narrow.cjs +830 -26
  36. package/templates/hooks/lib/banner-i18n.cjs +309 -0
  37. package/templates/hooks/lib/bindings-snapshot-reader.cjs +81 -0
  38. package/templates/hooks/lib/cite-contract-reminder.cjs +179 -0
  39. package/templates/hooks/lib/cite-line-parser.cjs +180 -0
  40. package/templates/hooks/lib/client-adapter.cjs +106 -0
  41. package/templates/hooks/lib/config-cache.cjs +107 -0
  42. package/templates/hooks/lib/state-store.cjs +84 -0
  43. package/templates/hooks/lib/summary-fallback.cjs +210 -0
  44. package/templates/skills/fabric-archive/SKILL.md +97 -419
  45. package/templates/skills/fabric-archive/ref/dry-run-scope.md +16 -0
  46. package/templates/skills/fabric-archive/ref/e5-cron-recap.md +58 -0
  47. package/templates/skills/fabric-archive/ref/i18n-policy.md +86 -0
  48. package/templates/skills/fabric-archive/ref/phase-0-range-resolution.md +156 -0
  49. package/templates/skills/fabric-archive/ref/phase-1-5-onboard.md +218 -0
  50. package/templates/skills/fabric-archive/ref/phase-1-cross-session.md +62 -0
  51. package/templates/skills/fabric-archive/ref/phase-2-5-viability.md +68 -0
  52. package/templates/skills/fabric-archive/ref/phase-3-5-scope.md +108 -0
  53. package/templates/skills/fabric-archive/ref/phase-3-classify.md +63 -0
  54. package/templates/skills/fabric-archive/ref/phase-4-5-emit.md +78 -0
  55. package/templates/skills/fabric-archive/ref/phase-4-mcp-persist.md +89 -0
  56. package/templates/skills/fabric-archive/ref/rc-history.md +38 -0
  57. package/templates/skills/fabric-archive/ref/worked-examples.md +78 -0
  58. package/templates/skills/fabric-import/SKILL.md +77 -514
  59. package/templates/skills/fabric-import/ref/checkpoint-state.md +85 -0
  60. package/templates/skills/fabric-import/ref/i18n-policy.md +79 -0
  61. package/templates/skills/fabric-import/ref/output-contract.md +61 -0
  62. package/templates/skills/fabric-import/ref/phase-2-mining.md +213 -0
  63. package/templates/skills/fabric-import/ref/phase-3-dedup.md +75 -0
  64. package/templates/skills/fabric-import/ref/state-recovery.md +57 -0
  65. package/templates/skills/fabric-import/ref/worked-examples.md +127 -0
  66. package/templates/skills/fabric-review/SKILL.md +90 -284
  67. package/templates/skills/fabric-review/ref/askuserquestion-policy.md +66 -0
  68. package/templates/skills/fabric-review/ref/i18n-policy.md +111 -0
  69. package/templates/skills/fabric-review/ref/modify-flow.md +103 -0
  70. package/templates/skills/fabric-review/ref/output-contract.md +58 -0
  71. package/templates/skills/fabric-review/ref/per-mode-flows.md +155 -0
  72. package/templates/skills/fabric-review/ref/semantic-check.md +26 -0
  73. package/templates/skills/fabric-review/ref/worked-examples.md +95 -0
  74. package/templates/skills/fabric-sync/SKILL.md +46 -0
  75. package/templates/skills/lib/shared-policy.md +69 -0
  76. package/dist/chunk-6ICJICVU.js +0 -10
  77. package/dist/chunk-74SZWYPH.js +0 -658
  78. package/dist/chunk-EYIDD2YS.js +0 -1000
  79. package/dist/doctor-T7JWODKG.js +0 -282
  80. package/dist/hooks-Y74Y5LQS.js +0 -12
  81. package/dist/scan-LMK3UCWL.js +0 -22
  82. package/dist/serve-H554BHLG.js +0 -124
  83. package/templates/agents-md/AGENTS.md.template +0 -59
  84. package/templates/bootstrap/CLAUDE.md +0 -8
  85. package/templates/bootstrap/codex-AGENTS-header.md +0 -6
  86. package/templates/bootstrap/cursor-fabric-bootstrap.mdc +0 -10
@@ -0,0 +1,180 @@
1
+ // v2.0.0-rc.24 TASK-04: CJS twin of packages/shared/src/cite-line-parser.ts.
2
+ //
3
+ // Hook runtime has NO node_modules access, so the shared TS module cannot be
4
+ // imported. This file is a hand-authored CJS mirror; behavioral parity is
5
+ // asserted by packages/cli/__tests__/cite-line-parser-parity.test.ts which
6
+ // runs both implementations against the same corpus and asserts identical
7
+ // output. Any drift between this file and ../../shared/src/cite-line-parser.ts
8
+ // MUST be reflected in BOTH files plus the parity-test corpus, otherwise the
9
+ // parity test fails and blocks the commit.
10
+ //
11
+ // Why a hand-authored twin (not transpile-at-install or string-template inject)?
12
+ // - tsup/esbuild are CLI build-time deps, NOT install-time deps; bundling
13
+ // them into the install pipeline grows the user-facing footprint.
14
+ // - The parser is small (≤150 LOC), pure (zero deps), and rarely changes —
15
+ // hand-syncing is cheaper than introducing transpile machinery.
16
+ // - The existing `installHookLibs` pipeline auto-copies every `.cjs` under
17
+ // templates/hooks/lib/ to each client's hooks/lib/ dir, so this file
18
+ // auto-ships to cc/codex/cursor with no install pipeline change.
19
+ //
20
+ // Vocabulary contract (mirrored 1:1 with the TS source):
21
+ // - cite_tags enum: applied | dismissed | none (rc.37 NEW-1 2-state vocab).
22
+ // v2.1.0-rc.1 (ADJ-P4-1, full remap): legacy rc≤36 tags (planned / recalled
23
+ // / chained-from) are REMAPPED to `applied` here — accepted as input but no
24
+ // longer emitted verbatim, so the TS source and this twin stay in lockstep.
25
+ // - operator kinds: edit | not_edit | require | forbid
26
+ // (source token `!edit:` → schema kind `not_edit`)
27
+ // - skip:<reason> captures everything after the first colon, so
28
+ // `skip:other:non-codifiable` yields skip_reason="other:non-codifiable".
29
+ // - Index contract: cite_commitments[i] ↔ cite_ids[i]. Sentinel `KB: none`
30
+ // contributes a "none" cite_tag only — no id, no commitment.
31
+
32
+ const ID_RE = /^K[TP]-[A-Z]+-\d+$/;
33
+ const SENTINEL_RE = /^KB:\s*none\b\s*(?:\[[^\]]*\])?\s*$/i;
34
+ // v2.0.0-rc.27 TASK-003 (audit §2.18): multi-id citations supported via
35
+ // comma-separated ID group. v2.1.0-rc.1 P4 (F3/S62): each id may carry an
36
+ // optional `<store>:` prefix. Mirrors packages/shared/src/cite-line-parser.ts.
37
+ const QUALIFIED_ID = "(?:[^\\s,:]+:)?K[TP]-[A-Z]+-\\d+";
38
+ const FULL_RE = new RegExp(
39
+ "^KB:\\s+(" +
40
+ QUALIFIED_ID +
41
+ "(?:\\s*,\\s*" +
42
+ QUALIFIED_ID +
43
+ ")*)(?:\\s+\\(([^)]*)\\))?(?:\\s+\\[([^\\]]+)\\])?(?:\\s+→\\s*(.+))?\\s*$",
44
+ );
45
+ const CHAINED_FROM_ID_RE = /chained-from\s+(K[TP]-[A-Z]+-\d+)/i;
46
+
47
+ // Split `<store>:<id>` into qualifier + local id; bare id → null qualifier.
48
+ function splitStorePrefix(token) {
49
+ const colon = token.lastIndexOf(":");
50
+ return colon === -1
51
+ ? { store: null, id: token }
52
+ : { store: token.slice(0, colon), id: token.slice(colon + 1) };
53
+ }
54
+
55
+ // v2.1.0-rc.1 (ADJ-P4-1, full remap): legacy rc≤36 tags collapse to `applied`.
56
+ // Mirrors LEGACY_CITE_TAG_REMAP / normalizeCiteTag in the TS source — accepted
57
+ // as input but emitted as the 2-state vocab so cite-coverage never undercounts.
58
+ const LEGACY_CITE_TAG_REMAP = {
59
+ planned: "applied",
60
+ recalled: "applied",
61
+ "chained-from": "applied",
62
+ };
63
+
64
+ function parseTag(rawTag) {
65
+ if (!rawTag) return "none";
66
+ // Tags may carry tails like `chained-from KT-DEC-0001` or
67
+ // `dismissed:scope-mismatch`; head token (whitespace/colon-bounded) wins.
68
+ const head = rawTag.trim().split(/[\s:]+/)[0].toLowerCase();
69
+ if (head === "applied" || head === "dismissed" || head === "none") {
70
+ return head;
71
+ }
72
+ return LEGACY_CITE_TAG_REMAP[head] || "none";
73
+ }
74
+
75
+ function parseContractTail(tail) {
76
+ const result = { operators: [], skip_reason: null };
77
+ if (!tail) return result;
78
+ const tokens = tail.trim().split(/\s+/).filter((t) => t.length > 0);
79
+ for (const token of tokens) {
80
+ // skip:<reason> — reason may itself contain a colon (skip:other:<text>).
81
+ const skipMatch = token.match(/^skip:(.+)$/i);
82
+ if (skipMatch) {
83
+ if (result.skip_reason === null) result.skip_reason = skipMatch[1];
84
+ continue;
85
+ }
86
+ // !edit:<target> → schema kind "not_edit".
87
+ const notEditMatch = token.match(/^!edit:(.+)$/i);
88
+ if (notEditMatch) {
89
+ result.operators.push({ kind: "not_edit", target: notEditMatch[1] });
90
+ continue;
91
+ }
92
+ const opMatch = token.match(/^(edit|require|forbid):(.+)$/i);
93
+ if (opMatch) {
94
+ result.operators.push({
95
+ kind: opMatch[1].toLowerCase(),
96
+ target: opMatch[2],
97
+ });
98
+ }
99
+ // Unknown token → forward-compat drop.
100
+ }
101
+ return result;
102
+ }
103
+
104
+ function parseLine(line) {
105
+ const trimmed = line.trim();
106
+ if (trimmed.length === 0) return null;
107
+ if (SENTINEL_RE.test(trimmed)) {
108
+ return { ids: [], stores: [], tag: "none", commitment: null };
109
+ }
110
+ const fullMatch = trimmed.match(FULL_RE);
111
+ if (fullMatch) {
112
+ // v2.0.0-rc.27 TASK-003 (audit §2.18): split + revalidate each id;
113
+ // capture chained-from tail id when present. v2.1.0-rc.1 P4 (F3): strip +
114
+ // surface any `<store>:` prefix into a parallel stores array.
115
+ const split = fullMatch[1]
116
+ .split(",")
117
+ .map((part) => part.trim())
118
+ .filter((part) => part.length > 0)
119
+ .map(splitStorePrefix);
120
+ if (split.some((entry) => !ID_RE.test(entry.id))) return null;
121
+ const primaryIds = split.map((entry) => entry.id);
122
+ const primaryStores = split.map((entry) => entry.store);
123
+
124
+ const rawTag = fullMatch[3];
125
+ const tag = parseTag(rawTag);
126
+
127
+ const chainedIds = [];
128
+ if (rawTag) {
129
+ const chained = CHAINED_FROM_ID_RE.exec(rawTag);
130
+ if (chained && ID_RE.test(chained[1])) {
131
+ chainedIds.push(chained[1]);
132
+ }
133
+ }
134
+
135
+ return {
136
+ ids: primaryIds.concat(chainedIds),
137
+ stores: primaryStores.concat(chainedIds.map(() => null)),
138
+ tag,
139
+ commitment: parseContractTail(fullMatch[4]),
140
+ };
141
+ }
142
+ return null;
143
+ }
144
+
145
+ /**
146
+ * Parse one or more newline-separated `KB:` cite lines into structured arrays
147
+ * matching the assistant_turn_observed event-ledger fields. Tolerates
148
+ * whitespace, CR/LF, blank lines, interleaved prose. Never throws.
149
+ *
150
+ * v2.0.0-rc.27 TASK-003 (audit §2.18): supports multi-id citations
151
+ * (`KB: KT-DEC-0001, KT-PIT-0005 ...`) and surfaces `chained-from <id>`'s
152
+ * embedded id as an additional cite_id. cite_tags carries one tag per LINE.
153
+ */
154
+ function parseCiteLine(raw) {
155
+ const result = { cite_ids: [], cite_tags: [], cite_commitments: [], cite_stores: [] };
156
+ if (typeof raw !== "string") return result;
157
+ for (const line of raw.split(/\r?\n/)) {
158
+ const parsed = parseLine(line);
159
+ if (!parsed) continue;
160
+ result.cite_tags.push(parsed.tag);
161
+ for (let i = 0; i < parsed.ids.length; i += 1) {
162
+ result.cite_ids.push(parsed.ids[i]);
163
+ result.cite_stores.push(parsed.stores[i] == null ? null : parsed.stores[i]);
164
+ }
165
+ if (parsed.commitment !== null) {
166
+ // v2.0.0-rc.27.1 (Codex review fix): cite_commitments MUST be index-
167
+ // aligned with cite_ids per the schema doc on event-ledger.ts:428.
168
+ // Multi-id citations share ONE parsed contract — propagate it across
169
+ // every id slot so downstream consumers (`doctor.ts` per-cite walk +
170
+ // `cite-contract-reminder.cjs`) can look up `commitments[i]` for any
171
+ // valid `i < cite_ids.length` without falling into an undefined slot.
172
+ for (let i = 0; i < parsed.ids.length; i += 1) {
173
+ result.cite_commitments.push(parsed.commitment);
174
+ }
175
+ }
176
+ }
177
+ return result;
178
+ }
179
+
180
+ module.exports = { parseCiteLine };
@@ -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
+ };