@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
@@ -71,10 +71,41 @@ 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");
77
78
 
79
+ // rc.35 TASK-06 (P0-10.b): summary-fallback. Substitutes opaque entries
80
+ // (where description.summary === stable_id) with a snippet read from the
81
+ // entry's .md `## Summary` section. Caches results in
82
+ // `.fabric/.cache/summary-fallback.json` keyed by revision_hash.
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");
88
+ // v2.1.0-rc.1 P4 (F4/S63): hook-side reader for the CLI pre-generated
89
+ // resolved-bindings snapshot. Store-aware hint surfaces the write-target store
90
+ // for the edited file WITHOUT re-resolving or walking store trees. Best-effort.
91
+ let bindingsSnapshotReader = null;
92
+ try {
93
+ bindingsSnapshotReader = require("./lib/bindings-snapshot-reader.cjs");
94
+ } catch {
95
+ // Lib missing (old install) — store labels degrade to silent absence.
96
+ }
97
+
98
+ // Read the project's own `project_id` (the snapshot key) from its config. Not a
99
+ // store-tree read — it is how the hook learns which snapshot to fetch.
100
+ function readProjectId(cwd) {
101
+ try {
102
+ const parsed = JSON.parse(readFileSync(join(cwd, ".fabric", "fabric-config.json"), "utf8"));
103
+ return typeof parsed.project_id === "string" ? parsed.project_id : null;
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
78
109
  // -----------------------------------------------------------------------------
79
110
  // CONSTANTS
80
111
  // -----------------------------------------------------------------------------
@@ -86,13 +117,23 @@ const CLI_TIMEOUT_MS = 2000;
86
117
 
87
118
  // Maximum summary length per entry. Bounds each stderr line so a sloppy
88
119
  // pending entry can't blow up terminal width. Truncation appends an ellipsis.
89
- const SUMMARY_MAX_LEN = 80;
120
+ // v2.0.0-rc.33 W4-A3: `hint_summary_max_len` in fabric-config overrides this
121
+ // default (range 40..240). Resolved per-invocation via readSummaryMaxLen.
122
+ const DEFAULT_SUMMARY_MAX_LEN = 80;
90
123
 
91
124
  // Edit-counter sidecar — workspace-relative path. Process-local file; no
92
125
  // network. TASK-022 will read this back to compute edits-since-archive.
93
126
  const EDIT_COUNTER_DIR_REL = join(".fabric", ".cache");
94
127
  const EDIT_COUNTER_FILE = "edit-counter";
95
128
 
129
+ // rc.35 TASK-07 (P0-2): events.jsonl path. PreToolUse Edit fires append a
130
+ // `edit_intent_checked` event (ledger_source: 'hook') so doctor cite-
131
+ // coverage's editsTouched metric sees actual edit signals. Without this
132
+ // signal the entire cite-policy contract validation is structurally inert
133
+ // (rc.30 audit P0-2: 18582 turns / 240 edits / 0 events).
134
+ const EVENTS_LEDGER_DIR_REL = ".fabric";
135
+ const EVENTS_LEDGER_FILE = "events.jsonl";
136
+
96
137
  // rc.6 TASK-023 (E6): hint-silence-counter sidecar — companion to the
97
138
  // edit-counter above. Where edit-counter records every PreToolUse fire
98
139
  // (numerator-agnostic), the silence-counter records only those fires that
@@ -126,6 +167,53 @@ let SYNTHETIC_SESSION_ID = null;
126
167
  // many tool names across clients; we only react to file-edit tools.
127
168
  const EDIT_TOOL_NAMES = new Set(["Edit", "Write", "MultiEdit"]);
128
169
 
170
+ // v2.0.0-rc.33 W2 fabric-config keys & defaults. Mirror of the schema in
171
+ // packages/shared/src/schemas/fabric-config.ts — hooks cannot require()
172
+ // shared modules (rendered as standalone templates at init), so the values
173
+ // are duplicated inline. Keep these in sync if the schema changes.
174
+ const FABRIC_DIR_REL = ".fabric";
175
+ const FABRIC_CONFIG_FILE = "fabric-config.json";
176
+ // rc.37 NEW-17: derived index the server rewrites on any knowledge edit; its
177
+ // mtime is the cheap freshness token for the plan-context-hint result cache.
178
+ const AGENTS_META_FILE = "agents.meta.json";
179
+
180
+ // W2-1 (P0-9): narrow TopK upper bound. Five matches the per-Edit hint
181
+ // "terse banner" UX: any more and the model's working memory bloats.
182
+ const DEFAULT_HINT_NARROW_TOP_K = 5;
183
+
184
+ // W2-2 (P0-9): per-file dedup window in turns. Same (file_path, stable_id)
185
+ // stays silent for this many PreToolUse fires across sessions, addressing
186
+ // the rc.32 finding that a single hot file (e.g. GameRoom.tsx edited 30
187
+ // times in a row) re-fired identical narrow hints and trained the agent
188
+ // to ignore them. Distinct sidecar from session-hints (E3) so window-only
189
+ // suppression doesn't poison cross-session dedupe semantics.
190
+ const DEFAULT_HINT_NARROW_DEDUP_WINDOW_TURNS = 5;
191
+ const NARROW_DEDUP_WINDOW_FILE = join(
192
+ ".fabric",
193
+ ".cache",
194
+ "narrow-dedup-window.json",
195
+ );
196
+ // Cap the recent-emission ring buffer at this many records so the sidecar
197
+ // stays bounded on long-running workspaces. The window check only needs the
198
+ // last `window` entries per (path, entry_id) so a 4x safety multiplier is
199
+ // generous. Pruning happens lazily on write.
200
+ const NARROW_DEDUP_RING_CAP = 1000;
201
+
202
+ // W2-5 (P1-8): cooldown between narrow-hint re-emits in hours. 0 = no
203
+ // cooldown (rc.32 behavior, every PreToolUse fire is gate-eligible).
204
+ const DEFAULT_HINT_NARROW_COOLDOWN_HOURS = 0;
205
+ const MS_PER_HOUR = 60 * 60 * 1000;
206
+ const HINT_NARROW_LAST_EMIT_FILE = join(
207
+ ".fabric",
208
+ ".cache",
209
+ "knowledge-hint-narrow-last-emit",
210
+ );
211
+
212
+ // W2-6 (P0-7): mirror of the broad hook flag — when true, emit the banner
213
+ // as a Claude Code PreToolUse hookSpecificOutput.additionalContext JSON
214
+ // envelope on stdout so the model receives the reminder IN-CONTEXT.
215
+ const DEFAULT_HINT_REMINDER_TO_CONTEXT = true;
216
+
129
217
  // -----------------------------------------------------------------------------
130
218
  // Payload parsing
131
219
  // -----------------------------------------------------------------------------
@@ -142,7 +230,19 @@ function readPayload(rawStdin) {
142
230
  return null;
143
231
  }
144
232
  return parsed;
145
- } catch {
233
+ } catch (e) {
234
+ // v2.0.0-rc.29 REVIEW (codex LOW-1): apply BUG-L1's malformed-input
235
+ // diagnostic uniformly across hook scripts. fabric-hint.cjs got the stderr
236
+ // trace in TASK-008; without this matching write here, a broken Codex /
237
+ // Cursor host payload silently kills the narrow hint with no operator
238
+ // signal at all. Best-effort: a failed stderr write must not throw upward
239
+ // (hook contract — never crash the host's edit pipeline).
240
+ try {
241
+ const message = (e && typeof e === "object" && "message" in e) ? String(e.message) : String(e);
242
+ process.stderr.write(`[fabric-knowledge-hint-narrow] malformed input: ${message}\n`);
243
+ } catch {
244
+ // stderr write itself failed (sandbox / closed fd) — accept silence.
245
+ }
146
246
  return null;
147
247
  }
148
248
  }
@@ -259,6 +359,80 @@ function extractPaths(toolInput) {
259
359
  * array. The fire-count signal is preserved; the activity overview
260
360
  * just contributes nothing from those lines.
261
361
  */
362
+ /**
363
+ * rc.35 TASK-07 (P0-2): append one `edit_intent_checked` event per touched
364
+ * path to `.fabric/events.jsonl`. Carries `ledger_source: 'hook'` so doctor
365
+ * cite-coverage can distinguish hook-originated edit signals from
366
+ * AI/human-originated `appendLedgerEntry` calls.
367
+ *
368
+ * Best-effort:
369
+ * - Skips silently when `.fabric/` does not exist (project not init'd).
370
+ * - Skips silently when paths is empty (counter signal is preserved by
371
+ * the sibling appendEditCounter call; cite-coverage only cares about
372
+ * non-empty path events).
373
+ * - ANY error (mkdir, append, JSON throw) is swallowed — the hook must
374
+ * remain non-blocking per the rc.6 contract.
375
+ *
376
+ * Atomicity:
377
+ * - One JSON line per path. Append on small writes (< PIPE_BUF, ~4KB on
378
+ * POSIX) is atomic at the OS level, so concurrent PreToolUse fires
379
+ * from parallel sessions interleave cleanly without partial writes.
380
+ */
381
+ function appendEditIntentToLedger(projectRoot, now, paths, toolName, sessionId) {
382
+ try {
383
+ const fabricDir = join(projectRoot, EVENTS_LEDGER_DIR_REL);
384
+ // No .fabric/ → project not initialised. Bail before any write.
385
+ if (!existsSync(fabricDir)) return;
386
+ const { isAbsolute: pathIsAbsolute, relative: pathRelative } = require("node:path");
387
+ const pathList = Array.isArray(paths)
388
+ ? paths
389
+ .filter((p) => typeof p === "string" && p.length > 0)
390
+ .map((p) => {
391
+ if (pathIsAbsolute(p)) {
392
+ const rel = pathRelative(projectRoot, p);
393
+ return rel.startsWith("..") ? null : rel;
394
+ }
395
+ // Already-relative paths: drop ones that escape the project tree.
396
+ return p.startsWith("..") ? null : p;
397
+ })
398
+ .filter((p) => typeof p === "string" && p.length > 0)
399
+ // Use forward slashes for cross-platform consistency on disk.
400
+ .map((p) => p.split(/[\\/]/).join("/"))
401
+ : [];
402
+ if (pathList.length === 0) return;
403
+ const tsMs = now instanceof Date ? now.getTime() : Number(now);
404
+ const ledgerEntryId = `hook:${randomUUID()}`;
405
+ const intent = typeof toolName === "string" && toolName.length > 0 ? toolName : "edit";
406
+ // rc.38 UX-8 (C): thread the REAL payload session_id (never the synthetic
407
+ // fallback) so doctor cite-coverage's expected_but_missed arm can correlate
408
+ // this edit against the same session's assistant_turn cite lines. Omitting
409
+ // it (the rc.35 oversight) left the correlation key undefined → missed
410
+ // permanently 0 → cite_compliance_rate structurally pinned at 100%.
411
+ const validSessionId =
412
+ typeof sessionId === "string" && sessionId.length > 0 ? sessionId : null;
413
+ const lines = pathList
414
+ .map((p) => JSON.stringify({
415
+ kind: "fabric-event",
416
+ id: `event:${randomUUID()}`,
417
+ ts: tsMs,
418
+ schema_version: 1,
419
+ ...(validSessionId ? { session_id: validSessionId } : {}),
420
+ event_type: "edit_intent_checked",
421
+ path: p,
422
+ compliant: true,
423
+ intent,
424
+ ledger_entry_id: ledgerEntryId,
425
+ ledger_source: "hook",
426
+ matched_rule_context_ts: null,
427
+ window_ms: 0,
428
+ }))
429
+ .join("\n") + "\n";
430
+ appendFileSync(join(fabricDir, EVENTS_LEDGER_FILE), lines, "utf8");
431
+ } catch {
432
+ // Silent — events ledger failure must never block the edit.
433
+ }
434
+ }
435
+
262
436
  function appendEditCounter(projectRoot, now, paths) {
263
437
  try {
264
438
  const dir = join(projectRoot, EDIT_COUNTER_DIR_REL);
@@ -267,8 +441,34 @@ function appendEditCounter(projectRoot, now, paths) {
267
441
  mkdirSync(dir, { recursive: true });
268
442
  }
269
443
  const iso = now instanceof Date ? now.toISOString() : new Date(now).toISOString();
444
+ // v2.0.0-rc.27 TASK-005 (audit §2.8): normalize every path to a
445
+ // project-relative form BEFORE persistence. rc.26 wrote whatever the
446
+ // tool_input handed in — frequently absolute paths like
447
+ // `/Users/wepie/.../foo.ts` — which then leaked into the archive banner's
448
+ // "recent activity centered on: Users/wepie/" prose (the dirname pass
449
+ // stripped the leading `/` but produced a $HOME-prefix surface). The
450
+ // normalize-on-write keeps the sidecar containing only project-internal
451
+ // paths so downstream banner rendering can't accidentally surface
452
+ // host-system paths.
453
+ //
454
+ // Strategy: for each path, attempt path.relative(projectRoot, abs). When
455
+ // the result starts with `..` (path is outside the project tree) we
456
+ // silently drop the entry — out-of-tree edits are not meaningful
457
+ // activity for THIS project's banner. Bare relative paths (already in
458
+ // canonical form) round-trip through relative() unchanged.
459
+ const { isAbsolute: pathIsAbsolute, relative: pathRelative } = require("node:path");
270
460
  const pathList = Array.isArray(paths)
271
- ? paths.filter((p) => typeof p === "string" && p.length > 0)
461
+ ? paths
462
+ .filter((p) => typeof p === "string" && p.length > 0)
463
+ .map((p) => {
464
+ if (pathIsAbsolute(p)) {
465
+ const rel = pathRelative(projectRoot, p);
466
+ // path.relative returns `..` segments when p escapes projectRoot.
467
+ return rel.startsWith("..") ? null : rel;
468
+ }
469
+ return p;
470
+ })
471
+ .filter((p) => typeof p === "string" && p.length > 0)
272
472
  : [];
273
473
  const line = JSON.stringify({ ts: iso, paths: pathList });
274
474
  appendFileSync(file, `${line}\n`, "utf8");
@@ -568,6 +768,9 @@ function invokePlanContextHint(cwd, paths) {
568
768
  if (!Array.isArray(paths) || paths.length === 0) return null;
569
769
  const pathsArg = paths.join(",");
570
770
  const candidates = ["fabric", "fab"];
771
+ // rc.31 NEW-6: see knowledge-hint-broad.cjs for rationale — surface plan-
772
+ // context-hint failures on stderr so degraded KB chain is observable.
773
+ let lastFailure = null;
571
774
  for (const bin of candidates) {
572
775
  let res;
573
776
  try {
@@ -580,59 +783,443 @@ function invokePlanContextHint(cwd, paths) {
580
783
  } catch {
581
784
  continue;
582
785
  }
583
- if (res.error || res.status === null || res.status !== 0) continue;
786
+ if (res.error) {
787
+ if (res.error.code !== "ENOENT") {
788
+ lastFailure = { bin, reason: String(res.error.message || res.error.code || res.error) };
789
+ }
790
+ continue;
791
+ }
792
+ if (res.status === null || res.status !== 0) {
793
+ const stderrSnip = (res.stderr || "").trim().slice(0, 240);
794
+ if (stderrSnip.length > 0) {
795
+ lastFailure = { bin, reason: stderrSnip };
796
+ }
797
+ continue;
798
+ }
584
799
  const raw = (res.stdout || "").trim();
585
800
  if (raw.length === 0) continue;
586
801
  try {
587
802
  const parsed = JSON.parse(raw);
588
803
  if (parsed && typeof parsed === "object") return parsed;
589
- } catch {
590
- // malformed JSON try next bin
804
+ } catch (err) {
805
+ lastFailure = { bin, reason: `malformed JSON from plan-context-hint: ${String(err && err.message || err)}` };
591
806
  }
592
807
  }
808
+ if (lastFailure !== null) {
809
+ process.stderr.write(
810
+ `[fabric-hint] plan-context-hint (${lastFailure.bin}) failed: ${lastFailure.reason.replace(/\n/g, " ")}\n`,
811
+ );
812
+ }
593
813
  return null;
594
814
  }
595
815
 
816
+ // -----------------------------------------------------------------------------
817
+ // v2.0.0-rc.37 NEW-17 — plan-context-hint result cache (per-session).
818
+ //
819
+ // Each PreToolUse fire is a separate process, so "in-memory" caching means a
820
+ // per-session sidecar of the CLI result keyed on the edited path-set. A repeat
821
+ // edit to the same file(s) — common during iterative work on one module —
822
+ // re-reads the cached result instead of paying another `fabric plan-context-
823
+ // hint` cold-start spawn (~50-150ms). MultiEdit's N paths already collapse to
824
+ // ONE spawn (extractPaths dedupes + invokePlanContextHint joins --paths); this
825
+ // cache extends that win across fires within a stable knowledge graph.
826
+ //
827
+ // Freshness: the cache is invalidated wholesale when `.fabric/agents.meta.json`
828
+ // mtime changes (the derived index the server rewrites on any knowledge edit).
829
+ // This is a cheap stat — no spawn — so the freshness check itself is ~free.
830
+ // -----------------------------------------------------------------------------
831
+
832
+ // Bound the per-session result map so a long stable session editing many
833
+ // distinct files can't grow the sidecar without limit.
834
+ const NARROW_RESULT_CACHE_MAX_ENTRIES = 50;
835
+
836
+ function metaFreshnessToken(cwd) {
837
+ try {
838
+ const metaPath = join(cwd, FABRIC_DIR_REL, AGENTS_META_FILE);
839
+ if (!existsSync(metaPath)) return null;
840
+ return statSync(metaPath).mtimeMs;
841
+ } catch {
842
+ return null;
843
+ }
844
+ }
845
+
846
+ function narrowResultCacheFileName(sessionId) {
847
+ const safe = String(sessionId || "anonymous").replace(/[^A-Za-z0-9_.-]/g, "-");
848
+ return `narrow-result-cache-${safe}.json`;
849
+ }
850
+
851
+ // Order-independent key for a path-set (sorted + NUL-joined so [a,b] and [b,a]
852
+ // hit the same cache slot).
853
+ function pathSetKey(paths) {
854
+ return [...paths].sort().join("");
855
+ }
856
+
857
+ // Returns the cached cliPayload for `paths` iff the cache's meta token matches
858
+ // the current knowledge-graph freshness, else null (caller spawns the CLI).
859
+ function readNarrowResultCache(cwd, sessionId, paths, metaToken) {
860
+ if (metaToken === null) return null;
861
+ const cache = readJsonState(
862
+ cwd,
863
+ narrowResultCacheFileName(sessionId),
864
+ (parsed) => parsed && typeof parsed === "object" && parsed.results && typeof parsed.results === "object",
865
+ );
866
+ if (!cache || cache.meta_token !== metaToken) return null;
867
+ const hit = cache.results[pathSetKey(paths)];
868
+ return hit && typeof hit === "object" ? hit : null;
869
+ }
870
+
871
+ // Persist `cliPayload` under the path-set key. Resets the map when the meta
872
+ // token changed (stale graph) and caps the map size (FIFO-ish: drop oldest
873
+ // insertion-order keys). Best-effort — never throws.
874
+ function writeNarrowResultCache(cwd, sessionId, paths, metaToken, cliPayload) {
875
+ if (metaToken === null) return;
876
+ const fileName = narrowResultCacheFileName(sessionId);
877
+ const prior = readJsonState(
878
+ cwd,
879
+ fileName,
880
+ (parsed) => parsed && typeof parsed === "object" && parsed.results && typeof parsed.results === "object",
881
+ );
882
+ const results =
883
+ prior && prior.meta_token === metaToken && prior.results ? { ...prior.results } : {};
884
+ results[pathSetKey(paths)] = cliPayload;
885
+ const keys = Object.keys(results);
886
+ if (keys.length > NARROW_RESULT_CACHE_MAX_ENTRIES) {
887
+ for (const stale of keys.slice(0, keys.length - NARROW_RESULT_CACHE_MAX_ENTRIES)) {
888
+ delete results[stale];
889
+ }
890
+ }
891
+ writeJsonState(cwd, fileName, { meta_token: metaToken, results });
892
+ }
893
+
894
+ // -----------------------------------------------------------------------------
895
+ // v2.0.0-rc.33 W2 — fabric-config readers + per-file dedup-window sidecar.
896
+ //
897
+ // All readers follow the project convention: inline JSON.parse of
898
+ // .fabric/fabric-config.json with default-on-failure. Hooks cannot require()
899
+ // the TS schema, so the schema's range constraints are duplicated inline as
900
+ // guard clauses (kept in sync with packages/shared/src/schemas/fabric-config.ts).
901
+ // -----------------------------------------------------------------------------
902
+
903
+ function _readNarrowConfigValue(projectRoot) {
904
+ const configPath = join(projectRoot, FABRIC_DIR_REL, FABRIC_CONFIG_FILE);
905
+ if (!existsSync(configPath)) return null;
906
+ try {
907
+ return JSON.parse(readFileSync(configPath, "utf8"));
908
+ } catch {
909
+ return null;
910
+ }
911
+ }
912
+
913
+ function readNarrowTopK(projectRoot) {
914
+ const parsed = _readNarrowConfigValue(projectRoot);
915
+ if (parsed && typeof parsed === "object") {
916
+ const v = parsed.hint_narrow_top_k;
917
+ if (typeof v === "number" && Number.isFinite(v) && v >= 1 && v <= 20) {
918
+ return Math.floor(v);
919
+ }
920
+ }
921
+ return DEFAULT_HINT_NARROW_TOP_K;
922
+ }
923
+
924
+ function readNarrowDedupWindowTurns(projectRoot) {
925
+ const parsed = _readNarrowConfigValue(projectRoot);
926
+ if (parsed && typeof parsed === "object") {
927
+ const v = parsed.hint_narrow_dedup_window_turns;
928
+ if (typeof v === "number" && Number.isFinite(v) && v >= 1 && v <= 50) {
929
+ return Math.floor(v);
930
+ }
931
+ }
932
+ return DEFAULT_HINT_NARROW_DEDUP_WINDOW_TURNS;
933
+ }
934
+
935
+ function readNarrowCooldownHours(projectRoot) {
936
+ const parsed = _readNarrowConfigValue(projectRoot);
937
+ if (parsed && typeof parsed === "object") {
938
+ const v = parsed.hint_narrow_cooldown_hours;
939
+ if (typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= 168) {
940
+ return v;
941
+ }
942
+ }
943
+ return DEFAULT_HINT_NARROW_COOLDOWN_HOURS;
944
+ }
945
+
946
+ function readReminderToContext(projectRoot) {
947
+ const parsed = _readNarrowConfigValue(projectRoot);
948
+ if (parsed && typeof parsed === "object") {
949
+ const v = parsed.hint_reminder_to_context;
950
+ if (typeof v === "boolean") return v;
951
+ }
952
+ return DEFAULT_HINT_REMINDER_TO_CONTEXT;
953
+ }
954
+
955
+ function readNarrowLastEmit(projectRoot) {
956
+ const p = join(projectRoot, HINT_NARROW_LAST_EMIT_FILE);
957
+ if (!existsSync(p)) return null;
958
+ try {
959
+ const raw = readFileSync(p, "utf8").trim();
960
+ if (raw.length === 0) return null;
961
+ const asNum = Number(raw);
962
+ if (Number.isFinite(asNum) && asNum > 0) return asNum;
963
+ const ms = Date.parse(raw);
964
+ if (Number.isFinite(ms)) return ms;
965
+ } catch {
966
+ // ignore
967
+ }
968
+ return null;
969
+ }
970
+
971
+ function writeNarrowLastEmit(projectRoot, nowMs) {
972
+ const p = join(projectRoot, HINT_NARROW_LAST_EMIT_FILE);
973
+ try {
974
+ if (!existsSync(dirname(p))) {
975
+ mkdirSync(dirname(p), { recursive: true });
976
+ }
977
+ writeFileSync(p, String(nowMs));
978
+ } catch {
979
+ // Silent — sidecar failure must never block edits.
980
+ }
981
+ }
982
+
983
+ /**
984
+ * v2.0.0-rc.33 W2-2: per-file dedup window sidecar.
985
+ *
986
+ * On-disk shape (in .fabric/.cache/narrow-dedup-window.json):
987
+ * {
988
+ * "counter": <monotonic int — incremented on each render>,
989
+ * "recent": [
990
+ * { "path": "<file_path>", "entry_id": "<stable_id>", "at_turn": <int> },
991
+ * ...
992
+ * ]
993
+ * }
994
+ *
995
+ * The `recent` array is a ring buffer capped at NARROW_DEDUP_RING_CAP entries
996
+ * so the sidecar stays bounded on long-running workspaces. Pruning happens
997
+ * lazily on write.
998
+ *
999
+ * Read failures and shape mismatches both return a fresh zero-state — the
1000
+ * window degrades to "no dedup" rather than blocking the hint.
1001
+ */
1002
+ function readNarrowDedupWindow(projectRoot) {
1003
+ const empty = { revision_hash: "", counter: 0, recent: [] };
1004
+ const p = join(projectRoot, NARROW_DEDUP_WINDOW_FILE);
1005
+ if (!existsSync(p)) return empty;
1006
+ try {
1007
+ const raw = readFileSync(p, "utf8");
1008
+ if (raw.length === 0) return empty;
1009
+ const parsed = JSON.parse(raw);
1010
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
1011
+ return empty;
1012
+ }
1013
+ const counter =
1014
+ typeof parsed.counter === "number" && Number.isFinite(parsed.counter)
1015
+ ? parsed.counter
1016
+ : 0;
1017
+ const revision_hash =
1018
+ typeof parsed.revision_hash === "string" ? parsed.revision_hash : "";
1019
+ const recent = Array.isArray(parsed.recent)
1020
+ ? parsed.recent.filter(
1021
+ (r) =>
1022
+ r &&
1023
+ typeof r === "object" &&
1024
+ typeof r.path === "string" &&
1025
+ r.path.length > 0 &&
1026
+ typeof r.entry_id === "string" &&
1027
+ r.entry_id.length > 0 &&
1028
+ typeof r.at_turn === "number" &&
1029
+ Number.isFinite(r.at_turn),
1030
+ )
1031
+ : [];
1032
+ return { revision_hash, counter, recent };
1033
+ } catch {
1034
+ return empty;
1035
+ }
1036
+ }
1037
+
1038
+ function writeNarrowDedupWindow(projectRoot, state) {
1039
+ const p = join(projectRoot, NARROW_DEDUP_WINDOW_FILE);
1040
+ try {
1041
+ if (!existsSync(dirname(p))) {
1042
+ mkdirSync(dirname(p), { recursive: true });
1043
+ }
1044
+ // Lazy prune: keep only the most recent NARROW_DEDUP_RING_CAP records.
1045
+ // Newer records are at the tail; slicing from -CAP preserves ring semantics.
1046
+ const recent =
1047
+ state.recent.length > NARROW_DEDUP_RING_CAP
1048
+ ? state.recent.slice(-NARROW_DEDUP_RING_CAP)
1049
+ : state.recent;
1050
+ const tmp = `${p}.tmp-${process.pid}`;
1051
+ writeFileSync(
1052
+ tmp,
1053
+ JSON.stringify({
1054
+ revision_hash: state.revision_hash || "",
1055
+ counter: state.counter,
1056
+ recent,
1057
+ }),
1058
+ );
1059
+ renameSync(tmp, p);
1060
+ } catch {
1061
+ // Silent — sidecar failure must never block edits.
1062
+ }
1063
+ }
1064
+
1065
+ /**
1066
+ * Apply the dedup-window filter. Returns `{ filtered, nextState }`:
1067
+ * filtered: NarrowEntry[] — entries whose (path, id) is NOT within `window`
1068
+ * turns of a prior emission for any of `targetPaths`.
1069
+ * nextState: the merged window state to persist if the caller decides to
1070
+ * render (records appended with at_turn = state.counter + 1).
1071
+ *
1072
+ * Decision rule: an entry is filtered out if ALL of its candidate
1073
+ * (path, entry_id) pairs already appear in `state.recent` with
1074
+ * `state.counter - at_turn < window`. The entry's "candidate pairs" are
1075
+ * (path, entry.id) for every path in targetPaths (the entry was about to be
1076
+ * surfaced for those paths). One path still missing → keep the entry.
1077
+ *
1078
+ * Side-effect-free; caller persists nextState only after a successful render.
1079
+ */
1080
+ function applyNarrowDedupWindow(state, narrow, targetPaths, windowTurns, currentRevisionHash) {
1081
+ const revHash =
1082
+ typeof currentRevisionHash === "string" ? currentRevisionHash : "";
1083
+ // Wholesale drop on revision flip — mirrors E3 emit-gate semantics so the
1084
+ // two layers stay coherent. Without this coordination a revision-graph
1085
+ // change would re-emit at the E3 layer but the dedup-window layer would
1086
+ // still suppress the hint.
1087
+ const liveState =
1088
+ state && state.revision_hash === revHash && revHash.length > 0
1089
+ ? state
1090
+ : { revision_hash: revHash, counter: state ? state.counter : 0, recent: [] };
1091
+
1092
+ if (!Array.isArray(narrow) || narrow.length === 0) {
1093
+ return { filtered: [], nextState: liveState };
1094
+ }
1095
+ if (!Array.isArray(targetPaths) || targetPaths.length === 0) {
1096
+ return { filtered: narrow.slice(), nextState: liveState };
1097
+ }
1098
+
1099
+ const currentTurn = liveState.counter + 1;
1100
+ const cutoff = currentTurn - windowTurns;
1101
+
1102
+ // Build a (path, entry_id) → at_turn lookup. Most recent wins on duplicates.
1103
+ const lookup = new Map();
1104
+ for (const rec of liveState.recent) {
1105
+ const key = `${rec.path}${rec.entry_id}`;
1106
+ const existing = lookup.get(key);
1107
+ if (existing === undefined || rec.at_turn > existing) {
1108
+ lookup.set(key, rec.at_turn);
1109
+ }
1110
+ }
1111
+
1112
+ const filtered = [];
1113
+ const newRecords = [];
1114
+ for (const entry of narrow) {
1115
+ const entryId = entry && typeof entry.id === "string" ? entry.id : null;
1116
+ if (entryId === null) {
1117
+ // No id — can't dedup, surface defensively.
1118
+ filtered.push(entry);
1119
+ continue;
1120
+ }
1121
+ // Entry is suppressed only if every targetPath has a recent record.
1122
+ let allRecent = true;
1123
+ for (const path of targetPaths) {
1124
+ const key = `${path}${entryId}`;
1125
+ const lastTurn = lookup.get(key);
1126
+ if (lastTurn === undefined || lastTurn < cutoff) {
1127
+ allRecent = false;
1128
+ break;
1129
+ }
1130
+ }
1131
+ if (!allRecent) {
1132
+ filtered.push(entry);
1133
+ for (const path of targetPaths) {
1134
+ newRecords.push({ path, entry_id: entryId, at_turn: currentTurn });
1135
+ }
1136
+ }
1137
+ }
1138
+
1139
+ const nextState = {
1140
+ revision_hash: revHash,
1141
+ counter: currentTurn,
1142
+ recent: filtered.length > 0 ? liveState.recent.concat(newRecords) : liveState.recent,
1143
+ };
1144
+ return { filtered, nextState };
1145
+ }
1146
+
596
1147
  // -----------------------------------------------------------------------------
597
1148
  // Rendering
598
1149
  // -----------------------------------------------------------------------------
599
1150
 
600
- function truncateSummary(raw) {
1151
+ // v2.0.0-rc.33 W4-A3: maxLen sourced from fabric-config#hint_summary_max_len.
1152
+ function truncateSummary(raw, maxLen) {
601
1153
  const s = typeof raw === "string" ? raw : "";
602
1154
  const flat = s.replace(/\s+/g, " ").trim();
603
- if (flat.length <= SUMMARY_MAX_LEN) return flat;
604
- return `${flat.slice(0, SUMMARY_MAX_LEN - 1)}…`;
1155
+ const cap = typeof maxLen === "number" && maxLen > 0 ? maxLen : DEFAULT_SUMMARY_MAX_LEN;
1156
+ if (flat.length <= cap) return flat;
1157
+ return `${flat.slice(0, cap - 1)}…`;
605
1158
  }
606
1159
 
607
- function formatEntryLine(entry) {
1160
+ function formatEntryLine(entry, maxLen) {
608
1161
  const id = entry.id || "(no-id)";
609
1162
  const type = entry.type || "unknown";
610
1163
  const maturity = entry.maturity || "unknown";
611
- const summary = truncateSummary(entry.summary);
1164
+ const summary = truncateSummary(entry.summary, maxLen);
612
1165
  const tail = summary.length > 0 ? ` ${summary}` : "";
613
1166
  return ` [${id}] (${type}/${maturity})${tail}`;
614
1167
  }
615
1168
 
1169
+ function readSummaryMaxLen(projectRoot) {
1170
+ const parsed = _readNarrowConfigValue(projectRoot);
1171
+ if (parsed && typeof parsed === "object") {
1172
+ const v = parsed.hint_summary_max_len;
1173
+ if (typeof v === "number" && Number.isFinite(v) && v >= 40 && v <= 240) {
1174
+ return Math.floor(v);
1175
+ }
1176
+ }
1177
+ return DEFAULT_SUMMARY_MAX_LEN;
1178
+ }
1179
+
616
1180
  /**
617
1181
  * Render the narrow-match block to an array of stderr lines. Returns []
618
- * when there is nothing to render (empty narrow set). Callers stay silent
1182
+ * when there is nothing to render (empty entries set). Callers stay silent
619
1183
  * on empty output.
620
1184
  *
1185
+ * Protocol gate (rc.18): only `payload.version === 2` payloads are
1186
+ * rendered. Anything else returns []. When the payload exists but carries
1187
+ * a mismatched (non-undefined) version, a one-line stderr breadcrumb is
1188
+ * emitted as a debug aid — see `_protocol-v2-decisions.md` (Decision 2,
1189
+ * "silent-skip + one-line stderr breadcrumb"). The wire field is
1190
+ * `payload.entries` (renamed from `payload.narrow` in protocol v2,
1191
+ * Decision 1).
1192
+ *
621
1193
  * Output shape:
622
1194
  * [fabric] N narrow-scoped knowledge entries match your edit targets:
623
1195
  * [<id>] (<type>/<maturity>) <summary>
624
1196
  * ...
625
1197
  * (如需重读 broad 决策,调 fab_plan_context 或 fabric plan-context-hint --all)
626
1198
  */
627
- function renderSummary(payload) {
628
- const narrow = Array.isArray(payload && payload.narrow) ? payload.narrow : [];
629
- if (narrow.length === 0) return [];
1199
+ function renderSummary(payload, maxLen) {
1200
+ if (!payload || payload.version !== 2) {
1201
+ if (payload && payload.version !== undefined) {
1202
+ // breadcrumb only if payload exists but version mismatches (avoid
1203
+ // spam on null). Best-effort write — silent-on-failure honors the
1204
+ // hook's "never block edits" contract.
1205
+ try {
1206
+ process.stderr.write(
1207
+ `[fabric] hint payload version=${payload.version} unsupported (expected 2), skipping\n`,
1208
+ );
1209
+ } catch {
1210
+ // ignore — stderr unavailable, silent-skip still applies
1211
+ }
1212
+ }
1213
+ return [];
1214
+ }
1215
+ const entries = Array.isArray(payload.entries) ? payload.entries : [];
1216
+ if (entries.length === 0) return [];
630
1217
 
631
1218
  const lines = [
632
- `[fabric] ${narrow.length} narrow-scoped knowledge entries match your edit targets:`,
1219
+ `[fabric] ${entries.length} narrow-scoped knowledge entries match your edit targets:`,
633
1220
  ];
634
- for (const entry of narrow) {
635
- lines.push(formatEntryLine(entry));
1221
+ for (const entry of entries) {
1222
+ lines.push(formatEntryLine(entry, maxLen));
636
1223
  }
637
1224
  lines.push(" (如需重读 broad 决策,调 fab_plan_context 或 fabric plan-context-hint --all)");
638
1225
  return lines;
@@ -646,7 +1233,9 @@ function main(env, stdio) {
646
1233
  try {
647
1234
  const cwd = (env && env.cwd) || process.cwd();
648
1235
  const now = (env && env.now) || new Date();
1236
+ const nowMs = now instanceof Date ? now.getTime() : Number(now);
649
1237
  const err = (stdio && stdio.stderr) || process.stderr;
1238
+ const out = (stdio && stdio.stdout) || process.stdout;
650
1239
 
651
1240
  // Parse hook payload. Test seam: env.payload short-circuits stdin so
652
1241
  // unit tests don't need to muck with process.stdin.
@@ -684,6 +1273,19 @@ function main(env, stdio) {
684
1273
  }
685
1274
  if (!(env && env.skipCounter === true)) {
686
1275
  appendEditCounter(cwd, now, paths);
1276
+ // rc.35 TASK-07 (P0-2): mirror the edit-counter sidecar into the
1277
+ // events.jsonl ledger so doctor cite-coverage's editsTouched metric
1278
+ // sees actual edit signals. Best-effort — failure is swallowed inside
1279
+ // appendEditIntentToLedger and does not block the hook.
1280
+ // rc.38 UX-8 (C): pass the REAL payload session_id (not resolveSessionId,
1281
+ // which would substitute a synthetic per-process id that matches no
1282
+ // assistant_turn and would inflate expected_but_missed with false
1283
+ // positives under --client=all). null when the client omits session_id.
1284
+ const payloadSessionId =
1285
+ payload && typeof payload === "object" && typeof payload.session_id === "string"
1286
+ ? payload.session_id
1287
+ : null;
1288
+ appendEditIntentToLedger(cwd, now, paths, toolName, payloadSessionId);
687
1289
  }
688
1290
 
689
1291
  // E2 path is conditional on a recognized tool + extractable paths.
@@ -691,15 +1293,83 @@ function main(env, stdio) {
691
1293
  if (!toolName || !EDIT_TOOL_NAMES.has(toolName)) return;
692
1294
  if (paths.length === 0) return;
693
1295
 
1296
+ // v2.0.0-rc.33 W2-5 (P1-8): cooldown gate. When configured > 0, suppress
1297
+ // the hint for that many hours after a successful emit. Counted as
1298
+ // silence so doctor lint #26 sees the suppression. Test seam
1299
+ // env.skipCooldown bypasses for unit tests.
1300
+ const cooldownHours = readNarrowCooldownHours(cwd);
1301
+ if (cooldownHours > 0 && !(env && env.skipCooldown === true)) {
1302
+ const lastEmitMs = readNarrowLastEmit(cwd);
1303
+ if (
1304
+ typeof lastEmitMs === "number" &&
1305
+ nowMs - lastEmitMs < cooldownHours * MS_PER_HOUR
1306
+ ) {
1307
+ if (!(env && env.skipSilenceCounter === true)) {
1308
+ appendHintSilenceCounter(cwd, now);
1309
+ }
1310
+ return;
1311
+ }
1312
+ }
1313
+
1314
+ // Resolve session id up-front (needed for the rc.37 NEW-17 result cache
1315
+ // key, and reused by the E3 emit-gate below).
1316
+ const sessionId = resolveSessionId(payload, env);
1317
+
694
1318
  // Test seam: env.cliResult short-circuits the CLI spawn so unit tests
695
1319
  // can feed canned plan-context-hint JSON without a built CLI binary.
696
- const cliPayload =
697
- env && env.cliResult !== undefined
698
- ? env.cliResult
699
- : invokePlanContextHint(cwd, paths);
1320
+ //
1321
+ // rc.37 NEW-17: when not in test-seam mode, first consult the per-session
1322
+ // result cache keyed on the edited path-set. A hit (same paths, unchanged
1323
+ // knowledge-graph mtime) skips the redundant `fabric plan-context-hint`
1324
+ // cold-start spawn. Misses spawn the CLI then populate the cache. The
1325
+ // env.skipResultCache seam disables both read+write for tests asserting
1326
+ // raw spawn behaviour.
1327
+ let cliPayload;
1328
+ if (env && env.cliResult !== undefined) {
1329
+ cliPayload = env.cliResult;
1330
+ } else {
1331
+ const useResultCache = !(env && env.skipResultCache === true);
1332
+ const metaToken = useResultCache ? metaFreshnessToken(cwd) : null;
1333
+ const cached = useResultCache
1334
+ ? readNarrowResultCache(cwd, sessionId, paths, metaToken)
1335
+ : null;
1336
+ if (cached !== null) {
1337
+ cliPayload = cached;
1338
+ } else {
1339
+ cliPayload = invokePlanContextHint(cwd, paths);
1340
+ if (useResultCache && cliPayload !== null && cliPayload !== undefined) {
1341
+ writeNarrowResultCache(cwd, sessionId, paths, metaToken, cliPayload);
1342
+ }
1343
+ }
1344
+ }
700
1345
  if (cliPayload === null || cliPayload === undefined) return;
701
1346
 
702
- const narrow = Array.isArray(cliPayload.narrow) ? cliPayload.narrow : [];
1347
+ // Protocol v2 (rc.18 TASK-005): wire field is `entries`, no v1 shim.
1348
+ //
1349
+ // v2.0.0-rc.27 TASK-005 (audit §2.5/§2.7): filter to entries whose
1350
+ // `relevance_scope === "narrow"` so broad cross-cutting entries do NOT
1351
+ // pollute the PreToolUse banner. rc.26 emitted broad + narrow as a
1352
+ // single list — every Edit fired a hint even for paths the entry never
1353
+ // anchored against (audit §2.5 reproduction). Broad entries are already
1354
+ // surfaced once per session by the SessionStart hook so the PreToolUse
1355
+ // surface should be narrow-only by design.
1356
+ //
1357
+ // Defensive default: when the CLI omits `relevance_scope` (older server
1358
+ // / malformed item) we treat it as broad and skip — pre-rc.27 entries
1359
+ // without the field are exactly the broad-leak surface §2.5 calls out.
1360
+ const allEntries = Array.isArray(cliPayload.entries) ? cliPayload.entries : [];
1361
+ const narrowFiltered = allEntries.filter((entry) => entry && entry.relevance_scope === "narrow");
1362
+
1363
+ // v2.0.0-rc.33 W2-1 (P0-9): apply TopK slice to narrow set BEFORE the
1364
+ // emit-gate / dedup-window cascade. The server-side ranking already
1365
+ // produced a sensible order, so slicing here bounds the per-Edit hint
1366
+ // surface area to `hint_narrow_top_k` (default 5) so the agent's working
1367
+ // memory isn't displaced by an unwieldy banner.
1368
+ const topK = readNarrowTopK(cwd);
1369
+ const narrow = narrowFiltered.length > topK
1370
+ ? narrowFiltered.slice(0, topK)
1371
+ : narrowFiltered;
1372
+
703
1373
  if (narrow.length === 0) {
704
1374
  // rc.6 TASK-023 (E6): silence-counter — matched-narrow == 0. The CLI
705
1375
  // had a chance to match against the extracted paths but came back
@@ -726,7 +1396,7 @@ function main(env, stdio) {
726
1396
  // can add the counter increment either here (before the early return)
727
1397
  // or inside applyEmitGate when render === false.
728
1398
  // -------------------------------------------------------------------------
729
- const sessionId = resolveSessionId(payload, env);
1399
+ // sessionId already resolved up-front (rc.37 NEW-17) for the result cache.
730
1400
  const currentRevisionHash =
731
1401
  typeof cliPayload.revision_hash === "string" ? cliPayload.revision_hash : "";
732
1402
  // Test seam: env.cacheSeed short-circuits the on-disk cache read so unit
@@ -748,6 +1418,39 @@ function main(env, stdio) {
748
1418
  return;
749
1419
  }
750
1420
 
1421
+ // v2.0.0-rc.33 W2-2 (P0-9): per-file dedup window. The E3 session-hints
1422
+ // cache covers per-session dedupe; this layer adds workspace-level "same
1423
+ // (file, entry) not within last N turns" suppression so a hot file's
1424
+ // identical hints don't train the agent to ignore them. Counted as
1425
+ // silence on full filter-out so doctor lint #26 visibility is preserved.
1426
+ const windowTurns = readNarrowDedupWindowTurns(cwd);
1427
+ const windowState =
1428
+ env && env.dedupWindowSeed !== undefined
1429
+ ? env.dedupWindowSeed
1430
+ : readNarrowDedupWindow(cwd);
1431
+ const dedupDecision = applyNarrowDedupWindow(
1432
+ windowState,
1433
+ gateDecision.narrow,
1434
+ paths,
1435
+ windowTurns,
1436
+ currentRevisionHash,
1437
+ );
1438
+ if (dedupDecision.filtered.length === 0) {
1439
+ // v2.0.0-rc.33 W4 review-fix (gemini Critical-1): persist the counter
1440
+ // BEFORE returning so the turn-window check still advances on suppressed
1441
+ // fires. Skipping the write here caused dedup state to permanently stick
1442
+ // — every subsequent fire would read the old counter, see at_turn within
1443
+ // the window, and keep suppressing. Now: counter ticks on every fire,
1444
+ // window-naturally expires after `windowTurns` PreToolUse events.
1445
+ if (!(env && env.skipCacheWrite === true)) {
1446
+ writeNarrowDedupWindow(cwd, dedupDecision.nextState);
1447
+ }
1448
+ if (!(env && env.skipSilenceCounter === true)) {
1449
+ appendHintSilenceCounter(cwd, now);
1450
+ }
1451
+ return;
1452
+ }
1453
+
751
1454
  // Persist the cache BEFORE rendering. If the render itself throws (e.g.
752
1455
  // stderr write errors), the cache update still reflects the intent —
753
1456
  // the alternative (post-render write) could leave us in a state where
@@ -762,13 +1465,83 @@ function main(env, stdio) {
762
1465
  ...gateDecision.cache,
763
1466
  session_id: sessionId,
764
1467
  });
1468
+ writeNarrowDedupWindow(cwd, dedupDecision.nextState);
765
1469
  }
766
1470
 
767
- const lines = renderSummary({ ...cliPayload, narrow: gateDecision.narrow });
1471
+ const summaryMaxLen = readSummaryMaxLen(cwd);
1472
+ // rc.35 TASK-06 (P0-10.b): substitute opaque summaries before render.
1473
+ // Same lib used by the broad hook — opaque entries seen from both call
1474
+ // sites share a single .fabric/.cache/summary-fallback.json file.
1475
+ // Best-effort — any failure leaves the original opaque summary intact.
1476
+ let resolvedEntries = dedupDecision.filtered;
1477
+ try {
1478
+ resolvedEntries = resolveOpaqueSummaries(
1479
+ dedupDecision.filtered,
1480
+ cwd,
1481
+ currentRevisionHash,
1482
+ );
1483
+ } catch {
1484
+ // resolveOpaqueSummaries swallows its own errors; defensive catch.
1485
+ }
1486
+ const lines = renderSummary({ ...cliPayload, entries: resolvedEntries }, summaryMaxLen);
768
1487
  if (lines.length === 0) return;
1488
+
1489
+ // v2.1.0-rc.1 P4 (F4/S63): store-aware hint — append the write-target store
1490
+ // so the edit-time hint says WHERE a derived knowledge entry would land.
1491
+ // Best-effort; missing snapshot / single-store setup omits the line.
1492
+ if (bindingsSnapshotReader !== null) {
1493
+ try {
1494
+ const projectId = readProjectId(cwd);
1495
+ if (projectId) {
1496
+ const snapshot = bindingsSnapshotReader.readBindingsSnapshot(projectId);
1497
+ const writeAlias =
1498
+ snapshot && snapshot.write_target && snapshot.write_target.alias;
1499
+ if (writeAlias) {
1500
+ lines.push(`[fabric] writes here land in store '${writeAlias}'`);
1501
+ }
1502
+ }
1503
+ } catch {
1504
+ // store label is decorative provenance — never crash the hook
1505
+ }
1506
+ }
1507
+
1508
+ // Stderr: human-facing breadcrumb + legacy contract.
769
1509
  for (const line of lines) {
770
1510
  err.write(`${line}\n`);
771
1511
  }
1512
+
1513
+ // v2.0.0-rc.33 W2-6 (P0-7): stdout JSON envelope. When
1514
+ // hint_reminder_to_context is true (default), serialize the same banner
1515
+ // body as Claude Code's PreToolUse hookSpecificOutput shape so the model
1516
+ // receives the reminder IN-CONTEXT (rc.32 baseline cite-coverage 3.1%
1517
+ // root cause: reminders never entered model context). PreToolUse hook
1518
+ // contract: stdout JSON with hookSpecificOutput.additionalContext is
1519
+ // injected into the model's context window; the hook DOES NOT block the
1520
+ // edit (additionalContext is informational, not a permissionDecision).
1521
+ // v2.0.0-rc.33 W4 review-fix (gemini High-1): CC-specific stdout envelope.
1522
+ // See knowledge-hint-broad.cjs companion for rationale — CLAUDE_PROJECT_DIR
1523
+ // is the CC presence signal; Codex CLI / Cursor don't set it.
1524
+ const _isClaudeCode =
1525
+ typeof process.env.CLAUDE_PROJECT_DIR === "string" &&
1526
+ process.env.CLAUDE_PROJECT_DIR.length > 0;
1527
+ if (!(env && env.skipStdout === true) && _isClaudeCode && readReminderToContext(cwd)) {
1528
+ try {
1529
+ const envelope = {
1530
+ hookSpecificOutput: {
1531
+ hookEventName: "PreToolUse",
1532
+ additionalContext: lines.join("\n"),
1533
+ },
1534
+ };
1535
+ out.write(`${JSON.stringify(envelope)}\n`);
1536
+ } catch {
1537
+ // Best-effort — stderr is the durable contract.
1538
+ }
1539
+ }
1540
+
1541
+ // v2.0.0-rc.33 W2-5: record successful emit for cooldown gate.
1542
+ if (cooldownHours > 0 && !(env && env.skipCooldownWrite === true)) {
1543
+ writeNarrowLastEmit(cwd, nowMs);
1544
+ }
772
1545
  } catch {
773
1546
  // Silent — never block edits on hook failure.
774
1547
  }
@@ -786,6 +1559,10 @@ module.exports = {
786
1559
  renderSummary,
787
1560
  truncateSummary,
788
1561
  formatEntryLine,
1562
+ // rc.35 TASK-07 (P0-2): cite-infrastructure wire-up. Exported so the
1563
+ // integration test can drive the writer directly without standing up the
1564
+ // entire PreToolUse main() flow.
1565
+ appendEditIntentToLedger,
789
1566
  // rc.6 TASK-021 (E3) — session-hints cache exports for tests / future
790
1567
  // consumers (TASK-023 silence-counter telemetry will reuse the same
791
1568
  // session-id resolution + cache shape).
@@ -796,17 +1573,44 @@ module.exports = {
796
1573
  writeSessionHintsCache,
797
1574
  computeIndexHash,
798
1575
  applyEmitGate,
1576
+ // v2.0.0-rc.33 W2-1 / W2-2 / W2-5 / W2-6 — exports for unit tests.
1577
+ readNarrowTopK,
1578
+ readNarrowDedupWindowTurns,
1579
+ readNarrowCooldownHours,
1580
+ readReminderToContext,
1581
+ readNarrowLastEmit,
1582
+ writeNarrowLastEmit,
1583
+ readNarrowDedupWindow,
1584
+ writeNarrowDedupWindow,
1585
+ applyNarrowDedupWindow,
1586
+ readSummaryMaxLen,
1587
+ // v2.0.0-rc.37 NEW-17 — plan-context-hint result cache exports for tests.
1588
+ metaFreshnessToken,
1589
+ narrowResultCacheFileName,
1590
+ pathSetKey,
1591
+ readNarrowResultCache,
1592
+ writeNarrowResultCache,
799
1593
  CONSTANTS: {
800
1594
  CLI_TIMEOUT_MS,
801
- SUMMARY_MAX_LEN,
1595
+ SUMMARY_MAX_LEN: DEFAULT_SUMMARY_MAX_LEN,
1596
+ DEFAULT_SUMMARY_MAX_LEN,
802
1597
  EDIT_COUNTER_DIR_REL,
803
1598
  EDIT_COUNTER_FILE,
804
1599
  HINT_SILENCE_COUNTER_DIR_REL,
805
1600
  HINT_SILENCE_COUNTER_FILE,
1601
+ EVENTS_LEDGER_DIR_REL,
1602
+ EVENTS_LEDGER_FILE,
806
1603
  EDIT_TOOL_NAMES,
807
1604
  SESSION_HINTS_DIR_REL,
808
1605
  SESSION_HINTS_FILE_PREFIX,
809
1606
  SESSION_HINTS_FILE_SUFFIX,
1607
+ DEFAULT_HINT_NARROW_TOP_K,
1608
+ DEFAULT_HINT_NARROW_DEDUP_WINDOW_TURNS,
1609
+ DEFAULT_HINT_NARROW_COOLDOWN_HOURS,
1610
+ DEFAULT_HINT_REMINDER_TO_CONTEXT,
1611
+ NARROW_DEDUP_WINDOW_FILE,
1612
+ NARROW_DEDUP_RING_CAP,
1613
+ HINT_NARROW_LAST_EMIT_FILE,
810
1614
  },
811
1615
  };
812
1616