@fenglimg/fabric-cli 2.0.0-rc.36 → 2.0.0-rc.38

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 (37) 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-EJDSEJSS.js} +119 -16
  5. package/dist/index.js +8 -7
  6. package/dist/{install-XSUIX6AD.js → install-E6OEB3V2.js} +53 -22
  7. package/dist/metrics-ACEQFPDU.js +122 -0
  8. package/dist/{plan-context-hint-UQLRKGBZ.js → plan-context-hint-FC6P3WFE.js} +7 -28
  9. package/dist/{uninstall-BIJ5GLEU.js → uninstall-MH7ZIB6M.js} +6 -18
  10. package/package.json +30 -4
  11. package/templates/hooks/cite-policy-evict.cjs +80 -91
  12. package/templates/hooks/configs/README.md +19 -0
  13. package/templates/hooks/configs/codex-hooks.json +3 -0
  14. package/templates/hooks/configs/cursor-hooks.json +2 -1
  15. package/templates/hooks/fabric-hint.cjs +146 -8
  16. package/templates/hooks/knowledge-hint-broad.cjs +65 -104
  17. package/templates/hooks/knowledge-hint-narrow.cjs +140 -7
  18. package/templates/hooks/lib/cite-line-parser.cjs +7 -1
  19. package/templates/hooks/lib/client-adapter.cjs +106 -0
  20. package/templates/hooks/lib/config-cache.cjs +107 -0
  21. package/templates/hooks/lib/state-store.cjs +84 -0
  22. package/templates/skills/fabric-archive/SKILL.md +29 -7
  23. package/templates/skills/fabric-archive/ref/dry-run-scope.md +1 -1
  24. package/templates/skills/fabric-archive/ref/i18n-policy.md +6 -0
  25. package/templates/skills/fabric-archive/ref/phase-1-5-onboard.md +1 -1
  26. package/templates/skills/fabric-archive/ref/phase-1-cross-session.md +2 -0
  27. package/templates/skills/fabric-archive/ref/phase-2-5-viability.md +25 -11
  28. package/templates/skills/fabric-archive/ref/phase-3-5-scope.md +43 -15
  29. package/templates/skills/fabric-import/SKILL.md +3 -3
  30. package/templates/skills/fabric-import/ref/i18n-policy.md +6 -0
  31. package/templates/skills/fabric-import/ref/phase-2-mining.md +2 -2
  32. package/templates/skills/fabric-review/SKILL.md +31 -25
  33. package/templates/skills/fabric-review/ref/i18n-policy.md +6 -0
  34. package/templates/skills/fabric-review/ref/modify-flow.md +9 -1
  35. package/templates/skills/fabric-review/ref/per-mode-flows.md +1 -1
  36. package/templates/skills/lib/shared-policy.md +69 -0
  37. 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.
@@ -350,7 +358,7 @@ function extractPaths(toolInput) {
350
358
  * POSIX) is atomic at the OS level, so concurrent PreToolUse fires
351
359
  * from parallel sessions interleave cleanly without partial writes.
352
360
  */
353
- function appendEditIntentToLedger(projectRoot, now, paths, toolName) {
361
+ function appendEditIntentToLedger(projectRoot, now, paths, toolName, sessionId) {
354
362
  try {
355
363
  const fabricDir = join(projectRoot, EVENTS_LEDGER_DIR_REL);
356
364
  // No .fabric/ → project not initialised. Bail before any write.
@@ -375,12 +383,20 @@ function appendEditIntentToLedger(projectRoot, now, paths, toolName) {
375
383
  const tsMs = now instanceof Date ? now.getTime() : Number(now);
376
384
  const ledgerEntryId = `hook:${randomUUID()}`;
377
385
  const intent = typeof toolName === "string" && toolName.length > 0 ? toolName : "edit";
386
+ // rc.38 UX-8 (C): thread the REAL payload session_id (never the synthetic
387
+ // fallback) so doctor cite-coverage's expected_but_missed arm can correlate
388
+ // this edit against the same session's assistant_turn cite lines. Omitting
389
+ // it (the rc.35 oversight) left the correlation key undefined → missed
390
+ // permanently 0 → cite_compliance_rate structurally pinned at 100%.
391
+ const validSessionId =
392
+ typeof sessionId === "string" && sessionId.length > 0 ? sessionId : null;
378
393
  const lines = pathList
379
394
  .map((p) => JSON.stringify({
380
395
  kind: "fabric-event",
381
396
  id: `event:${randomUUID()}`,
382
397
  ts: tsMs,
383
398
  schema_version: 1,
399
+ ...(validSessionId ? { session_id: validSessionId } : {}),
384
400
  event_type: "edit_intent_checked",
385
401
  path: p,
386
402
  compliant: true,
@@ -777,6 +793,84 @@ function invokePlanContextHint(cwd, paths) {
777
793
  return null;
778
794
  }
779
795
 
796
+ // -----------------------------------------------------------------------------
797
+ // v2.0.0-rc.37 NEW-17 — plan-context-hint result cache (per-session).
798
+ //
799
+ // Each PreToolUse fire is a separate process, so "in-memory" caching means a
800
+ // per-session sidecar of the CLI result keyed on the edited path-set. A repeat
801
+ // edit to the same file(s) — common during iterative work on one module —
802
+ // re-reads the cached result instead of paying another `fabric plan-context-
803
+ // hint` cold-start spawn (~50-150ms). MultiEdit's N paths already collapse to
804
+ // ONE spawn (extractPaths dedupes + invokePlanContextHint joins --paths); this
805
+ // cache extends that win across fires within a stable knowledge graph.
806
+ //
807
+ // Freshness: the cache is invalidated wholesale when `.fabric/agents.meta.json`
808
+ // mtime changes (the derived index the server rewrites on any knowledge edit).
809
+ // This is a cheap stat — no spawn — so the freshness check itself is ~free.
810
+ // -----------------------------------------------------------------------------
811
+
812
+ // Bound the per-session result map so a long stable session editing many
813
+ // distinct files can't grow the sidecar without limit.
814
+ const NARROW_RESULT_CACHE_MAX_ENTRIES = 50;
815
+
816
+ function metaFreshnessToken(cwd) {
817
+ try {
818
+ const metaPath = join(cwd, FABRIC_DIR_REL, AGENTS_META_FILE);
819
+ if (!existsSync(metaPath)) return null;
820
+ return statSync(metaPath).mtimeMs;
821
+ } catch {
822
+ return null;
823
+ }
824
+ }
825
+
826
+ function narrowResultCacheFileName(sessionId) {
827
+ const safe = String(sessionId || "anonymous").replace(/[^A-Za-z0-9_.-]/g, "-");
828
+ return `narrow-result-cache-${safe}.json`;
829
+ }
830
+
831
+ // Order-independent key for a path-set (sorted + NUL-joined so [a,b] and [b,a]
832
+ // hit the same cache slot).
833
+ function pathSetKey(paths) {
834
+ return [...paths].sort().join("");
835
+ }
836
+
837
+ // Returns the cached cliPayload for `paths` iff the cache's meta token matches
838
+ // the current knowledge-graph freshness, else null (caller spawns the CLI).
839
+ function readNarrowResultCache(cwd, sessionId, paths, metaToken) {
840
+ if (metaToken === null) return null;
841
+ const cache = readJsonState(
842
+ cwd,
843
+ narrowResultCacheFileName(sessionId),
844
+ (parsed) => parsed && typeof parsed === "object" && parsed.results && typeof parsed.results === "object",
845
+ );
846
+ if (!cache || cache.meta_token !== metaToken) return null;
847
+ const hit = cache.results[pathSetKey(paths)];
848
+ return hit && typeof hit === "object" ? hit : null;
849
+ }
850
+
851
+ // Persist `cliPayload` under the path-set key. Resets the map when the meta
852
+ // token changed (stale graph) and caps the map size (FIFO-ish: drop oldest
853
+ // insertion-order keys). Best-effort — never throws.
854
+ function writeNarrowResultCache(cwd, sessionId, paths, metaToken, cliPayload) {
855
+ if (metaToken === null) return;
856
+ const fileName = narrowResultCacheFileName(sessionId);
857
+ const prior = readJsonState(
858
+ cwd,
859
+ fileName,
860
+ (parsed) => parsed && typeof parsed === "object" && parsed.results && typeof parsed.results === "object",
861
+ );
862
+ const results =
863
+ prior && prior.meta_token === metaToken && prior.results ? { ...prior.results } : {};
864
+ results[pathSetKey(paths)] = cliPayload;
865
+ const keys = Object.keys(results);
866
+ if (keys.length > NARROW_RESULT_CACHE_MAX_ENTRIES) {
867
+ for (const stale of keys.slice(0, keys.length - NARROW_RESULT_CACHE_MAX_ENTRIES)) {
868
+ delete results[stale];
869
+ }
870
+ }
871
+ writeJsonState(cwd, fileName, { meta_token: metaToken, results });
872
+ }
873
+
780
874
  // -----------------------------------------------------------------------------
781
875
  // v2.0.0-rc.33 W2 — fabric-config readers + per-file dedup-window sidecar.
782
876
  //
@@ -1163,7 +1257,15 @@ function main(env, stdio) {
1163
1257
  // events.jsonl ledger so doctor cite-coverage's editsTouched metric
1164
1258
  // sees actual edit signals. Best-effort — failure is swallowed inside
1165
1259
  // appendEditIntentToLedger and does not block the hook.
1166
- appendEditIntentToLedger(cwd, now, paths, toolName);
1260
+ // rc.38 UX-8 (C): pass the REAL payload session_id (not resolveSessionId,
1261
+ // which would substitute a synthetic per-process id that matches no
1262
+ // assistant_turn and would inflate expected_but_missed with false
1263
+ // positives under --client=all). null when the client omits session_id.
1264
+ const payloadSessionId =
1265
+ payload && typeof payload === "object" && typeof payload.session_id === "string"
1266
+ ? payload.session_id
1267
+ : null;
1268
+ appendEditIntentToLedger(cwd, now, paths, toolName, payloadSessionId);
1167
1269
  }
1168
1270
 
1169
1271
  // E2 path is conditional on a recognized tool + extractable paths.
@@ -1189,12 +1291,37 @@ function main(env, stdio) {
1189
1291
  }
1190
1292
  }
1191
1293
 
1294
+ // Resolve session id up-front (needed for the rc.37 NEW-17 result cache
1295
+ // key, and reused by the E3 emit-gate below).
1296
+ const sessionId = resolveSessionId(payload, env);
1297
+
1192
1298
  // Test seam: env.cliResult short-circuits the CLI spawn so unit tests
1193
1299
  // 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);
1300
+ //
1301
+ // rc.37 NEW-17: when not in test-seam mode, first consult the per-session
1302
+ // result cache keyed on the edited path-set. A hit (same paths, unchanged
1303
+ // knowledge-graph mtime) skips the redundant `fabric plan-context-hint`
1304
+ // cold-start spawn. Misses spawn the CLI then populate the cache. The
1305
+ // env.skipResultCache seam disables both read+write for tests asserting
1306
+ // raw spawn behaviour.
1307
+ let cliPayload;
1308
+ if (env && env.cliResult !== undefined) {
1309
+ cliPayload = env.cliResult;
1310
+ } else {
1311
+ const useResultCache = !(env && env.skipResultCache === true);
1312
+ const metaToken = useResultCache ? metaFreshnessToken(cwd) : null;
1313
+ const cached = useResultCache
1314
+ ? readNarrowResultCache(cwd, sessionId, paths, metaToken)
1315
+ : null;
1316
+ if (cached !== null) {
1317
+ cliPayload = cached;
1318
+ } else {
1319
+ cliPayload = invokePlanContextHint(cwd, paths);
1320
+ if (useResultCache && cliPayload !== null && cliPayload !== undefined) {
1321
+ writeNarrowResultCache(cwd, sessionId, paths, metaToken, cliPayload);
1322
+ }
1323
+ }
1324
+ }
1198
1325
  if (cliPayload === null || cliPayload === undefined) return;
1199
1326
 
1200
1327
  // Protocol v2 (rc.18 TASK-005): wire field is `entries`, no v1 shim.
@@ -1249,7 +1376,7 @@ function main(env, stdio) {
1249
1376
  // can add the counter increment either here (before the early return)
1250
1377
  // or inside applyEmitGate when render === false.
1251
1378
  // -------------------------------------------------------------------------
1252
- const sessionId = resolveSessionId(payload, env);
1379
+ // sessionId already resolved up-front (rc.37 NEW-17) for the result cache.
1253
1380
  const currentRevisionHash =
1254
1381
  typeof cliPayload.revision_hash === "string" ? cliPayload.revision_hash : "";
1255
1382
  // Test seam: env.cacheSeed short-circuits the on-disk cache read so unit
@@ -1418,6 +1545,12 @@ module.exports = {
1418
1545
  writeNarrowDedupWindow,
1419
1546
  applyNarrowDedupWindow,
1420
1547
  readSummaryMaxLen,
1548
+ // v2.0.0-rc.37 NEW-17 — plan-context-hint result cache exports for tests.
1549
+ metaFreshnessToken,
1550
+ narrowResultCacheFileName,
1551
+ pathSetKey,
1552
+ readNarrowResultCache,
1553
+ writeNarrowResultCache,
1421
1554
  CONSTANTS: {
1422
1555
  CLI_TIMEOUT_MS,
1423
1556
  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
+ };