@fenglimg/fabric-cli 2.0.0-rc.35 → 2.0.0-rc.37

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 (36) hide show
  1. package/LICENSE +21 -0
  2. package/dist/{chunk-XVS4F3P6.js → chunk-D25XJ4BC.js} +49 -5
  3. package/dist/{chunk-G2CIOLD4.js → chunk-WWNXR34K.js} +1 -16
  4. package/dist/{doctor-2FCRAWDZ.js → doctor-764NFF3X.js} +112 -16
  5. package/dist/index.js +7 -6
  6. package/dist/{install-HOTE5BPA.js → install-U7MGIJ2L.js} +50 -22
  7. package/dist/metrics-ACEQFPDU.js +122 -0
  8. package/dist/{uninstall-BIJ5GLEU.js → uninstall-MH7ZIB6M.js} +6 -18
  9. package/package.json +30 -4
  10. package/templates/hooks/cite-policy-evict.cjs +80 -91
  11. package/templates/hooks/configs/README.md +19 -0
  12. package/templates/hooks/configs/codex-hooks.json +3 -0
  13. package/templates/hooks/configs/cursor-hooks.json +2 -1
  14. package/templates/hooks/fabric-hint.cjs +146 -8
  15. package/templates/hooks/knowledge-hint-broad.cjs +65 -104
  16. package/templates/hooks/knowledge-hint-narrow.cjs +122 -5
  17. package/templates/hooks/lib/cite-line-parser.cjs +7 -1
  18. package/templates/hooks/lib/client-adapter.cjs +106 -0
  19. package/templates/hooks/lib/config-cache.cjs +107 -0
  20. package/templates/hooks/lib/state-store.cjs +84 -0
  21. package/templates/skills/fabric-archive/SKILL.md +29 -7
  22. package/templates/skills/fabric-archive/ref/dry-run-scope.md +1 -1
  23. package/templates/skills/fabric-archive/ref/i18n-policy.md +6 -0
  24. package/templates/skills/fabric-archive/ref/phase-1-5-onboard.md +1 -1
  25. package/templates/skills/fabric-archive/ref/phase-1-cross-session.md +2 -0
  26. package/templates/skills/fabric-archive/ref/phase-2-5-viability.md +25 -11
  27. package/templates/skills/fabric-archive/ref/phase-3-5-scope.md +43 -15
  28. package/templates/skills/fabric-import/SKILL.md +75 -163
  29. package/templates/skills/fabric-import/ref/i18n-policy.md +6 -0
  30. package/templates/skills/fabric-import/ref/phase-2-mining.md +2 -2
  31. package/templates/skills/fabric-review/SKILL.md +31 -25
  32. package/templates/skills/fabric-review/ref/i18n-policy.md +6 -0
  33. package/templates/skills/fabric-review/ref/modify-flow.md +9 -1
  34. package/templates/skills/fabric-review/ref/per-mode-flows.md +1 -1
  35. package/templates/skills/lib/shared-policy.md +69 -0
  36. package/dist/serve-43JTEM3U.js +0 -142
@@ -47,14 +47,8 @@
47
47
  */
48
48
 
49
49
  const { spawnSync } = require("node:child_process");
50
- const {
51
- existsSync,
52
- mkdirSync,
53
- readdirSync,
54
- readFileSync,
55
- writeFileSync,
56
- } = require("node:fs");
57
- const { dirname, join } = require("node:path");
50
+ const { existsSync, readdirSync, readFileSync } = require("node:fs");
51
+ const { join } = require("node:path");
58
52
 
59
53
  // rc.16 TASK-003: shared banner-i18n lib (resolves fabric_language config and
60
54
  // renders localized banner text). Mirror of the wiring in fabric-hint.cjs
@@ -62,6 +56,18 @@ const { dirname, join } = require("node:path");
62
56
  // readFabricLanguage(cwd) and threaded into renderBanner — no fs in render path.
63
57
  const { renderBanner, readFabricLanguage } = require("./lib/banner-i18n.cjs");
64
58
  const { resolveOpaqueSummaries } = require("./lib/summary-fallback.cjs");
59
+ // v2.0.0-rc.37 NEW-19: shared fabric-config reader + sidecar I/O. Replaces the
60
+ // five per-key readFileSync+parse config readers (one parse per fire now) and
61
+ // the bespoke last-emit sidecar helpers. The L78 "refactor into lib/ if a
62
+ // third hook needs it" note is now realised.
63
+ const {
64
+ readConfigNumber,
65
+ readConfigBoolean,
66
+ } = require("./lib/config-cache.cjs");
67
+ const { readTextState, writeTextState } = require("./lib/state-store.cjs");
68
+ // v2.0.0-rc.37 NEW-30: shared client detection (replaces the inline
69
+ // CLAUDE_PROJECT_DIR single-bit check below).
70
+ const { isClaudeCode } = require("./lib/client-adapter.cjs");
65
71
 
66
72
  // -----------------------------------------------------------------------------
67
73
  // rc.12: SessionStart broad-menu is now unconditionally emitted on every
@@ -80,7 +86,6 @@ const FABRIC_DIR_REL = ".fabric";
80
86
  // cannot `require` each other. If a third hook ever needs the same logic,
81
87
  // refactor into packages/cli/templates/hooks/lib/. Keep these values in sync
82
88
  // with packages/cli/templates/hooks/fabric-hint.cjs.
83
- const FABRIC_CONFIG_FILE = "fabric-config.json";
84
89
  const AGENTS_META_FILE = "agents.meta.json";
85
90
  const IMPORT_STATE_FILE = ".import-state.json";
86
91
  const KNOWLEDGE_CANONICAL_TYPES = [
@@ -104,11 +109,8 @@ const DEFAULT_HINT_BROAD_TOP_K = 8;
104
109
  // so the two cooldowns don't interfere.
105
110
  const DEFAULT_HINT_BROAD_COOLDOWN_HOURS = 0;
106
111
  const MS_PER_HOUR = 60 * 60 * 1000;
107
- const HINT_BROAD_LAST_EMIT_FILE = join(
108
- ".fabric",
109
- ".cache",
110
- "knowledge-hint-broad-last-emit",
111
- );
112
+ // v2.0.0-rc.37 NEW-19: state-store resolves this basename under .fabric/.cache/.
113
+ const HINT_BROAD_LAST_EMIT_FILE_NAME = "knowledge-hint-broad-last-emit";
112
114
 
113
115
  // v2.0.0-rc.33 W2-6 (P0-7): when true, emit banner as
114
116
  // hookSpecificOutput.additionalContext JSON on stdout (Claude Code PreToolUse
@@ -168,16 +170,11 @@ function countCanonicalNodes(projectRoot) {
168
170
  * Any read/parse failure → default (never block on config errors).
169
171
  */
170
172
  function readUnderseedThreshold(projectRoot) {
171
- const configPath = join(projectRoot, FABRIC_DIR_REL, FABRIC_CONFIG_FILE);
172
- if (!existsSync(configPath)) return DEFAULT_UNDERSEED_NODE_THRESHOLD;
173
- try {
174
- const parsed = JSON.parse(readFileSync(configPath, "utf8"));
175
- const v = parsed && parsed.underseed_node_threshold;
176
- if (typeof v === "number" && Number.isFinite(v) && v > 0) return v;
177
- } catch {
178
- // fall through to default
179
- }
180
- return DEFAULT_UNDERSEED_NODE_THRESHOLD;
173
+ // > 0 guard via min: Number.MIN_VALUE (any positive). config-cache returns
174
+ // the parsed number when finite & in-range, else the default.
175
+ return readConfigNumber(projectRoot, "underseed_node_threshold", DEFAULT_UNDERSEED_NODE_THRESHOLD, {
176
+ min: Number.MIN_VALUE,
177
+ });
181
178
  }
182
179
 
183
180
  /**
@@ -186,18 +183,11 @@ function readUnderseedThreshold(projectRoot) {
186
183
  * schema's 1..50 range inline so a malformed config silently falls back.
187
184
  */
188
185
  function readBroadTopK(projectRoot) {
189
- const configPath = join(projectRoot, FABRIC_DIR_REL, FABRIC_CONFIG_FILE);
190
- if (!existsSync(configPath)) return DEFAULT_HINT_BROAD_TOP_K;
191
- try {
192
- const parsed = JSON.parse(readFileSync(configPath, "utf8"));
193
- const v = parsed && parsed.hint_broad_top_k;
194
- if (typeof v === "number" && Number.isFinite(v) && v >= 1 && v <= 50) {
195
- return Math.floor(v);
196
- }
197
- } catch {
198
- // fall through to default
199
- }
200
- return DEFAULT_HINT_BROAD_TOP_K;
186
+ return readConfigNumber(projectRoot, "hint_broad_top_k", DEFAULT_HINT_BROAD_TOP_K, {
187
+ min: 1,
188
+ max: 50,
189
+ floor: true,
190
+ });
201
191
  }
202
192
 
203
193
  /**
@@ -205,18 +195,10 @@ function readBroadTopK(projectRoot) {
205
195
  * 0 means "no cooldown" (re-emit on every SessionStart, rc.32 behavior).
206
196
  */
207
197
  function readBroadCooldownHours(projectRoot) {
208
- const configPath = join(projectRoot, FABRIC_DIR_REL, FABRIC_CONFIG_FILE);
209
- if (!existsSync(configPath)) return DEFAULT_HINT_BROAD_COOLDOWN_HOURS;
210
- try {
211
- const parsed = JSON.parse(readFileSync(configPath, "utf8"));
212
- const v = parsed && parsed.hint_broad_cooldown_hours;
213
- if (typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= 168) {
214
- return v;
215
- }
216
- } catch {
217
- // fall through to default
218
- }
219
- return DEFAULT_HINT_BROAD_COOLDOWN_HOURS;
198
+ return readConfigNumber(projectRoot, "hint_broad_cooldown_hours", DEFAULT_HINT_BROAD_COOLDOWN_HOURS, {
199
+ min: 0,
200
+ max: 168,
201
+ });
220
202
  }
221
203
 
222
204
  /**
@@ -226,16 +208,7 @@ function readBroadCooldownHours(projectRoot) {
226
208
  * receives the reminder in-context. Stderr stays informational either way.
227
209
  */
228
210
  function readReminderToContext(projectRoot) {
229
- const configPath = join(projectRoot, FABRIC_DIR_REL, FABRIC_CONFIG_FILE);
230
- if (!existsSync(configPath)) return DEFAULT_HINT_REMINDER_TO_CONTEXT;
231
- try {
232
- const parsed = JSON.parse(readFileSync(configPath, "utf8"));
233
- const v = parsed && parsed.hint_reminder_to_context;
234
- if (typeof v === "boolean") return v;
235
- } catch {
236
- // fall through to default
237
- }
238
- return DEFAULT_HINT_REMINDER_TO_CONTEXT;
211
+ return readConfigBoolean(projectRoot, "hint_reminder_to_context", DEFAULT_HINT_REMINDER_TO_CONTEXT);
239
212
  }
240
213
 
241
214
  /**
@@ -244,31 +217,18 @@ function readReminderToContext(projectRoot) {
244
217
  * Returns epoch ms or null when missing/unreadable.
245
218
  */
246
219
  function readBroadLastEmit(projectRoot) {
247
- const p = join(projectRoot, HINT_BROAD_LAST_EMIT_FILE);
248
- if (!existsSync(p)) return null;
249
- try {
250
- const raw = readFileSync(p, "utf8").trim();
251
- if (raw.length === 0) return null;
252
- const asNum = Number(raw);
253
- if (Number.isFinite(asNum) && asNum > 0) return asNum;
254
- const ms = Date.parse(raw);
255
- if (Number.isFinite(ms)) return ms;
256
- } catch {
257
- // ignore
258
- }
220
+ const raw = readTextState(projectRoot, HINT_BROAD_LAST_EMIT_FILE_NAME);
221
+ if (raw === null || raw.length === 0) return null;
222
+ const asNum = Number(raw);
223
+ if (Number.isFinite(asNum) && asNum > 0) return asNum;
224
+ const ms = Date.parse(raw);
225
+ if (Number.isFinite(ms)) return ms;
259
226
  return null;
260
227
  }
261
228
 
262
229
  function writeBroadLastEmit(projectRoot, nowMs) {
263
- const p = join(projectRoot, HINT_BROAD_LAST_EMIT_FILE);
264
- try {
265
- if (!existsSync(dirname(p))) {
266
- mkdirSync(dirname(p), { recursive: true });
267
- }
268
- writeFileSync(p, String(nowMs));
269
- } catch {
270
- // Silent — sidecar failure must never block session start.
271
- }
230
+ // Silent sidecar failure must never block session start.
231
+ writeTextState(projectRoot, HINT_BROAD_LAST_EMIT_FILE_NAME, String(nowMs));
272
232
  }
273
233
 
274
234
  /**
@@ -368,18 +328,11 @@ const CLI_TIMEOUT_MS = 2000;
368
328
  const DEFAULT_SUMMARY_MAX_LEN = 80;
369
329
 
370
330
  function readSummaryMaxLen(projectRoot) {
371
- const configPath = join(projectRoot, FABRIC_DIR_REL, FABRIC_CONFIG_FILE);
372
- if (!existsSync(configPath)) return DEFAULT_SUMMARY_MAX_LEN;
373
- try {
374
- const parsed = JSON.parse(readFileSync(configPath, "utf8"));
375
- const v = parsed && parsed.hint_summary_max_len;
376
- if (typeof v === "number" && Number.isFinite(v) && v >= 40 && v <= 240) {
377
- return Math.floor(v);
378
- }
379
- } catch {
380
- // fall through to default
381
- }
382
- return DEFAULT_SUMMARY_MAX_LEN;
331
+ return readConfigNumber(projectRoot, "hint_summary_max_len", DEFAULT_SUMMARY_MAX_LEN, {
332
+ min: 40,
333
+ max: 240,
334
+ floor: true,
335
+ });
383
336
  }
384
337
 
385
338
  // Canonical type order — render groups in this sequence so output is stable
@@ -771,17 +724,27 @@ function main(env, stdio) {
771
724
  const summaryMaxLen = readSummaryMaxLen(cwd);
772
725
  const lines = renderSummary(resolvedPayload, summaryMaxLen);
773
726
 
774
- if (recommendImport) {
775
- // rc.16 TASK-003: resolve fabric_language ONCE per invocation (only when
776
- // we actually need to emit the banner — keeps the no-banner path free of
777
- // the extra config read). 'match-existing' / unknown variant folds to 'en'
778
- // inside renderBanner per UX i18n Policy class 1.
779
- const variant = readFabricLanguage(cwd);
780
- lines.push(renderBanner("broadImportBanner", variant, {}));
727
+ // v2.0.0-rc.37 NEW-23: resolve fabric_language ONCE per emit path —
728
+ // shared between the (existing) broadImportBanner branch and the new
729
+ // 'next step' nudge tail added below. 'match-existing' / unknown variants
730
+ // fold to 'en' inside renderBanner per UX i18n Policy class 1.
731
+ const fabricLanguageForEmit = lines.length > 0 || recommendImport ? readFabricLanguage(cwd) : null;
732
+ if (recommendImport && fabricLanguageForEmit !== null) {
733
+ lines.push(renderBanner("broadImportBanner", fabricLanguageForEmit, {}));
781
734
  }
782
735
 
783
736
  if (lines.length === 0) return; // nothing to say — silent exit
784
737
 
738
+ // v2.0.0-rc.37 NEW-23: SessionStart 索引末尾"下一步"引导。Tail line that
739
+ // tells the AI what to do with the broad index it just received. Without
740
+ // this, the model often parses the index and moves on without ever calling
741
+ // fab_recall / fab_plan_context. One-line nudge, bilingual.
742
+ const nextStepNudge =
743
+ fabricLanguageForEmit === "zh-CN"
744
+ ? "下一步: 调 fab_recall(paths) 拿 KB 相关条目;或调 fab_plan_context 先看候选 description_index。"
745
+ : "Next: call fab_recall(paths) to fetch related KB entries, or fab_plan_context to preview the description_index first.";
746
+ lines.push(nextStepNudge);
747
+
785
748
  // Stderr: always emit (human-facing breadcrumb + legacy contract).
786
749
  for (const line of lines) {
787
750
  err.write(`${line}\n`);
@@ -802,11 +765,9 @@ function main(env, stdio) {
802
765
  // either polluting the terminal or crashing the host's hook-parsing
803
766
  // pipeline. CLAUDE_PROJECT_DIR is set by CC when invoking hooks (see
804
767
  // packages/cli/templates/hooks/configs/claude-code.json sigil paths);
805
- // its presence is the single-bit "this is Claude Code" signal.
806
- const isClaudeCode =
807
- typeof process.env.CLAUDE_PROJECT_DIR === "string" &&
808
- process.env.CLAUDE_PROJECT_DIR.length > 0;
809
- const reminderToContext = readReminderToContext(cwd) && isClaudeCode;
768
+ // its presence is the single-bit "this is Claude Code" signal (now via
769
+ // the shared client-adapter, rc.37 NEW-30).
770
+ const reminderToContext = readReminderToContext(cwd) && isClaudeCode();
810
771
  if (reminderToContext && !(env && env.skipStdout === true)) {
811
772
  try {
812
773
  const envelope = {
@@ -867,7 +828,7 @@ module.exports = {
867
828
  DEFAULT_HINT_BROAD_TOP_K,
868
829
  DEFAULT_HINT_BROAD_COOLDOWN_HOURS,
869
830
  DEFAULT_HINT_REMINDER_TO_CONTEXT,
870
- HINT_BROAD_LAST_EMIT_FILE,
831
+ HINT_BROAD_LAST_EMIT_FILE_NAME,
871
832
  },
872
833
  };
873
834
 
@@ -71,6 +71,7 @@ const {
71
71
  mkdirSync,
72
72
  readFileSync,
73
73
  renameSync,
74
+ statSync,
74
75
  writeFileSync,
75
76
  } = require("node:fs");
76
77
  const { dirname, join } = require("node:path");
@@ -80,6 +81,10 @@ const { dirname, join } = require("node:path");
80
81
  // entry's .md `## Summary` section. Caches results in
81
82
  // `.fabric/.cache/summary-fallback.json` keyed by revision_hash.
82
83
  const { resolveOpaqueSummaries } = require("./lib/summary-fallback.cjs");
84
+ // v2.0.0-rc.37 NEW-17: shared sidecar I/O for the plan-context-hint result
85
+ // cache (skips a redundant CLI cold-start spawn when the same path-set is
86
+ // re-edited within a session and the knowledge graph hasn't changed).
87
+ const { readJsonState, writeJsonState } = require("./lib/state-store.cjs");
83
88
 
84
89
  // -----------------------------------------------------------------------------
85
90
  // CONSTANTS
@@ -148,6 +153,9 @@ const EDIT_TOOL_NAMES = new Set(["Edit", "Write", "MultiEdit"]);
148
153
  // are duplicated inline. Keep these in sync if the schema changes.
149
154
  const FABRIC_DIR_REL = ".fabric";
150
155
  const FABRIC_CONFIG_FILE = "fabric-config.json";
156
+ // rc.37 NEW-17: derived index the server rewrites on any knowledge edit; its
157
+ // mtime is the cheap freshness token for the plan-context-hint result cache.
158
+ const AGENTS_META_FILE = "agents.meta.json";
151
159
 
152
160
  // W2-1 (P0-9): narrow TopK upper bound. Five matches the per-Edit hint
153
161
  // "terse banner" UX: any more and the model's working memory bloats.
@@ -777,6 +785,84 @@ function invokePlanContextHint(cwd, paths) {
777
785
  return null;
778
786
  }
779
787
 
788
+ // -----------------------------------------------------------------------------
789
+ // v2.0.0-rc.37 NEW-17 — plan-context-hint result cache (per-session).
790
+ //
791
+ // Each PreToolUse fire is a separate process, so "in-memory" caching means a
792
+ // per-session sidecar of the CLI result keyed on the edited path-set. A repeat
793
+ // edit to the same file(s) — common during iterative work on one module —
794
+ // re-reads the cached result instead of paying another `fabric plan-context-
795
+ // hint` cold-start spawn (~50-150ms). MultiEdit's N paths already collapse to
796
+ // ONE spawn (extractPaths dedupes + invokePlanContextHint joins --paths); this
797
+ // cache extends that win across fires within a stable knowledge graph.
798
+ //
799
+ // Freshness: the cache is invalidated wholesale when `.fabric/agents.meta.json`
800
+ // mtime changes (the derived index the server rewrites on any knowledge edit).
801
+ // This is a cheap stat — no spawn — so the freshness check itself is ~free.
802
+ // -----------------------------------------------------------------------------
803
+
804
+ // Bound the per-session result map so a long stable session editing many
805
+ // distinct files can't grow the sidecar without limit.
806
+ const NARROW_RESULT_CACHE_MAX_ENTRIES = 50;
807
+
808
+ function metaFreshnessToken(cwd) {
809
+ try {
810
+ const metaPath = join(cwd, FABRIC_DIR_REL, AGENTS_META_FILE);
811
+ if (!existsSync(metaPath)) return null;
812
+ return statSync(metaPath).mtimeMs;
813
+ } catch {
814
+ return null;
815
+ }
816
+ }
817
+
818
+ function narrowResultCacheFileName(sessionId) {
819
+ const safe = String(sessionId || "anonymous").replace(/[^A-Za-z0-9_.-]/g, "-");
820
+ return `narrow-result-cache-${safe}.json`;
821
+ }
822
+
823
+ // Order-independent key for a path-set (sorted + NUL-joined so [a,b] and [b,a]
824
+ // hit the same cache slot).
825
+ function pathSetKey(paths) {
826
+ return [...paths].sort().join("");
827
+ }
828
+
829
+ // Returns the cached cliPayload for `paths` iff the cache's meta token matches
830
+ // the current knowledge-graph freshness, else null (caller spawns the CLI).
831
+ function readNarrowResultCache(cwd, sessionId, paths, metaToken) {
832
+ if (metaToken === null) return null;
833
+ const cache = readJsonState(
834
+ cwd,
835
+ narrowResultCacheFileName(sessionId),
836
+ (parsed) => parsed && typeof parsed === "object" && parsed.results && typeof parsed.results === "object",
837
+ );
838
+ if (!cache || cache.meta_token !== metaToken) return null;
839
+ const hit = cache.results[pathSetKey(paths)];
840
+ return hit && typeof hit === "object" ? hit : null;
841
+ }
842
+
843
+ // Persist `cliPayload` under the path-set key. Resets the map when the meta
844
+ // token changed (stale graph) and caps the map size (FIFO-ish: drop oldest
845
+ // insertion-order keys). Best-effort — never throws.
846
+ function writeNarrowResultCache(cwd, sessionId, paths, metaToken, cliPayload) {
847
+ if (metaToken === null) return;
848
+ const fileName = narrowResultCacheFileName(sessionId);
849
+ const prior = readJsonState(
850
+ cwd,
851
+ fileName,
852
+ (parsed) => parsed && typeof parsed === "object" && parsed.results && typeof parsed.results === "object",
853
+ );
854
+ const results =
855
+ prior && prior.meta_token === metaToken && prior.results ? { ...prior.results } : {};
856
+ results[pathSetKey(paths)] = cliPayload;
857
+ const keys = Object.keys(results);
858
+ if (keys.length > NARROW_RESULT_CACHE_MAX_ENTRIES) {
859
+ for (const stale of keys.slice(0, keys.length - NARROW_RESULT_CACHE_MAX_ENTRIES)) {
860
+ delete results[stale];
861
+ }
862
+ }
863
+ writeJsonState(cwd, fileName, { meta_token: metaToken, results });
864
+ }
865
+
780
866
  // -----------------------------------------------------------------------------
781
867
  // v2.0.0-rc.33 W2 — fabric-config readers + per-file dedup-window sidecar.
782
868
  //
@@ -1189,12 +1275,37 @@ function main(env, stdio) {
1189
1275
  }
1190
1276
  }
1191
1277
 
1278
+ // Resolve session id up-front (needed for the rc.37 NEW-17 result cache
1279
+ // key, and reused by the E3 emit-gate below).
1280
+ const sessionId = resolveSessionId(payload, env);
1281
+
1192
1282
  // Test seam: env.cliResult short-circuits the CLI spawn so unit tests
1193
1283
  // can feed canned plan-context-hint JSON without a built CLI binary.
1194
- const cliPayload =
1195
- env && env.cliResult !== undefined
1196
- ? env.cliResult
1197
- : invokePlanContextHint(cwd, paths);
1284
+ //
1285
+ // rc.37 NEW-17: when not in test-seam mode, first consult the per-session
1286
+ // result cache keyed on the edited path-set. A hit (same paths, unchanged
1287
+ // knowledge-graph mtime) skips the redundant `fabric plan-context-hint`
1288
+ // cold-start spawn. Misses spawn the CLI then populate the cache. The
1289
+ // env.skipResultCache seam disables both read+write for tests asserting
1290
+ // raw spawn behaviour.
1291
+ let cliPayload;
1292
+ if (env && env.cliResult !== undefined) {
1293
+ cliPayload = env.cliResult;
1294
+ } else {
1295
+ const useResultCache = !(env && env.skipResultCache === true);
1296
+ const metaToken = useResultCache ? metaFreshnessToken(cwd) : null;
1297
+ const cached = useResultCache
1298
+ ? readNarrowResultCache(cwd, sessionId, paths, metaToken)
1299
+ : null;
1300
+ if (cached !== null) {
1301
+ cliPayload = cached;
1302
+ } else {
1303
+ cliPayload = invokePlanContextHint(cwd, paths);
1304
+ if (useResultCache && cliPayload !== null && cliPayload !== undefined) {
1305
+ writeNarrowResultCache(cwd, sessionId, paths, metaToken, cliPayload);
1306
+ }
1307
+ }
1308
+ }
1198
1309
  if (cliPayload === null || cliPayload === undefined) return;
1199
1310
 
1200
1311
  // Protocol v2 (rc.18 TASK-005): wire field is `entries`, no v1 shim.
@@ -1249,7 +1360,7 @@ function main(env, stdio) {
1249
1360
  // can add the counter increment either here (before the early return)
1250
1361
  // or inside applyEmitGate when render === false.
1251
1362
  // -------------------------------------------------------------------------
1252
- const sessionId = resolveSessionId(payload, env);
1363
+ // sessionId already resolved up-front (rc.37 NEW-17) for the result cache.
1253
1364
  const currentRevisionHash =
1254
1365
  typeof cliPayload.revision_hash === "string" ? cliPayload.revision_hash : "";
1255
1366
  // Test seam: env.cacheSeed short-circuits the on-disk cache read so unit
@@ -1418,6 +1529,12 @@ module.exports = {
1418
1529
  writeNarrowDedupWindow,
1419
1530
  applyNarrowDedupWindow,
1420
1531
  readSummaryMaxLen,
1532
+ // v2.0.0-rc.37 NEW-17 — plan-context-hint result cache exports for tests.
1533
+ metaFreshnessToken,
1534
+ narrowResultCacheFileName,
1535
+ pathSetKey,
1536
+ readNarrowResultCache,
1537
+ writeNarrowResultCache,
1421
1538
  CONSTANTS: {
1422
1539
  CLI_TIMEOUT_MS,
1423
1540
  SUMMARY_MAX_LEN: DEFAULT_SUMMARY_MAX_LEN,
@@ -35,10 +35,16 @@ const FULL_RE =
35
35
  const CHAINED_FROM_ID_RE = /chained-from\s+(K[TP]-[A-Z]+-\d+)/i;
36
36
 
37
37
  const ALLOWED_TAGS = new Set([
38
+ // v2.0.0-rc.37 NEW-1: new simplified 2-state tag set ([applied] / [dismissed]).
39
+ // Old 4-state tags (planned / recalled / chained-from) accepted for
40
+ // backward compat — they continue to parse and count toward cite-coverage
41
+ // so in-flight workspaces don't lose their existing audit signal.
42
+ "applied",
43
+ "dismissed",
44
+ // Legacy tags (rc ≤36).
38
45
  "planned",
39
46
  "recalled",
40
47
  "chained-from",
41
- "dismissed",
42
48
  "none",
43
49
  ]);
44
50
 
@@ -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
+ };