@fenglimg/fabric-cli 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -5
  3. package/dist/chunk-BATF4PEJ.js +361 -0
  4. package/dist/{chunk-OBQU6NHO.js → chunk-COI5VDFU.js} +0 -18
  5. package/dist/chunk-D25XJ4BC.js +880 -0
  6. package/dist/chunk-MF3OTILQ.js +544 -0
  7. package/dist/chunk-PWLW3B57.js +18 -0
  8. package/dist/config-XJIPZNUP.js +13 -0
  9. package/dist/doctor-EJDSEJSS.js +810 -0
  10. package/dist/index.js +15 -8
  11. package/dist/{init-BIRSIOXO.js → install-EKWMFLUU.js} +622 -711
  12. package/dist/metrics-ACEQFPDU.js +122 -0
  13. package/dist/onboard-coverage-MFCAEBDO.js +220 -0
  14. package/dist/{plan-context-hint-QMUPAXIB.js → plan-context-hint-FC6P3WFE.js} +34 -28
  15. package/dist/uninstall-MH7ZIB6M.js +1064 -0
  16. package/package.json +30 -5
  17. package/templates/hooks/cite-policy-evict.cjs +231 -0
  18. package/templates/hooks/configs/README.md +29 -6
  19. package/templates/hooks/configs/claude-code.json +14 -3
  20. package/templates/hooks/configs/codex-hooks.json +6 -3
  21. package/templates/hooks/configs/cursor-hooks.json +8 -10
  22. package/templates/hooks/fabric-hint.cjs +833 -105
  23. package/templates/hooks/knowledge-hint-broad.cjs +509 -135
  24. package/templates/hooks/knowledge-hint-narrow.cjs +791 -26
  25. package/templates/hooks/lib/banner-i18n.cjs +309 -0
  26. package/templates/hooks/lib/cite-contract-reminder.cjs +173 -0
  27. package/templates/hooks/lib/cite-line-parser.cjs +158 -0
  28. package/templates/hooks/lib/client-adapter.cjs +106 -0
  29. package/templates/hooks/lib/config-cache.cjs +107 -0
  30. package/templates/hooks/lib/state-store.cjs +84 -0
  31. package/templates/hooks/lib/summary-fallback.cjs +210 -0
  32. package/templates/skills/fabric-archive/SKILL.md +93 -419
  33. package/templates/skills/fabric-archive/ref/dry-run-scope.md +16 -0
  34. package/templates/skills/fabric-archive/ref/e5-cron-recap.md +58 -0
  35. package/templates/skills/fabric-archive/ref/i18n-policy.md +86 -0
  36. package/templates/skills/fabric-archive/ref/phase-0-range-resolution.md +156 -0
  37. package/templates/skills/fabric-archive/ref/phase-1-5-onboard.md +218 -0
  38. package/templates/skills/fabric-archive/ref/phase-1-cross-session.md +62 -0
  39. package/templates/skills/fabric-archive/ref/phase-2-5-viability.md +68 -0
  40. package/templates/skills/fabric-archive/ref/phase-3-5-scope.md +108 -0
  41. package/templates/skills/fabric-archive/ref/phase-3-classify.md +63 -0
  42. package/templates/skills/fabric-archive/ref/phase-4-5-emit.md +78 -0
  43. package/templates/skills/fabric-archive/ref/phase-4-mcp-persist.md +89 -0
  44. package/templates/skills/fabric-archive/ref/rc-history.md +38 -0
  45. package/templates/skills/fabric-archive/ref/worked-examples.md +78 -0
  46. package/templates/skills/fabric-import/SKILL.md +75 -516
  47. package/templates/skills/fabric-import/ref/checkpoint-state.md +85 -0
  48. package/templates/skills/fabric-import/ref/i18n-policy.md +79 -0
  49. package/templates/skills/fabric-import/ref/output-contract.md +61 -0
  50. package/templates/skills/fabric-import/ref/phase-2-mining.md +213 -0
  51. package/templates/skills/fabric-import/ref/phase-3-dedup.md +75 -0
  52. package/templates/skills/fabric-import/ref/state-recovery.md +57 -0
  53. package/templates/skills/fabric-import/ref/worked-examples.md +127 -0
  54. package/templates/skills/fabric-review/SKILL.md +86 -284
  55. package/templates/skills/fabric-review/ref/askuserquestion-policy.md +66 -0
  56. package/templates/skills/fabric-review/ref/i18n-policy.md +111 -0
  57. package/templates/skills/fabric-review/ref/modify-flow.md +103 -0
  58. package/templates/skills/fabric-review/ref/output-contract.md +58 -0
  59. package/templates/skills/fabric-review/ref/per-mode-flows.md +155 -0
  60. package/templates/skills/fabric-review/ref/semantic-check.md +26 -0
  61. package/templates/skills/fabric-review/ref/worked-examples.md +95 -0
  62. package/templates/skills/lib/shared-policy.md +69 -0
  63. package/dist/chunk-6ICJICVU.js +0 -10
  64. package/dist/chunk-74SZWYPH.js +0 -658
  65. package/dist/chunk-EYIDD2YS.js +0 -1000
  66. package/dist/doctor-T7JWODKG.js +0 -282
  67. package/dist/hooks-Y74Y5LQS.js +0 -12
  68. package/dist/scan-LMK3UCWL.js +0 -22
  69. package/dist/serve-H554BHLG.js +0 -124
  70. package/templates/agents-md/AGENTS.md.template +0 -59
  71. package/templates/bootstrap/CLAUDE.md +0 -8
  72. package/templates/bootstrap/codex-AGENTS-header.md +0 -6
  73. package/templates/bootstrap/cursor-fabric-bootstrap.mdc +0 -10
@@ -71,10 +71,21 @@ 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
+
78
89
  // -----------------------------------------------------------------------------
79
90
  // CONSTANTS
80
91
  // -----------------------------------------------------------------------------
@@ -86,13 +97,23 @@ const CLI_TIMEOUT_MS = 2000;
86
97
 
87
98
  // Maximum summary length per entry. Bounds each stderr line so a sloppy
88
99
  // pending entry can't blow up terminal width. Truncation appends an ellipsis.
89
- const SUMMARY_MAX_LEN = 80;
100
+ // v2.0.0-rc.33 W4-A3: `hint_summary_max_len` in fabric-config overrides this
101
+ // default (range 40..240). Resolved per-invocation via readSummaryMaxLen.
102
+ const DEFAULT_SUMMARY_MAX_LEN = 80;
90
103
 
91
104
  // Edit-counter sidecar — workspace-relative path. Process-local file; no
92
105
  // network. TASK-022 will read this back to compute edits-since-archive.
93
106
  const EDIT_COUNTER_DIR_REL = join(".fabric", ".cache");
94
107
  const EDIT_COUNTER_FILE = "edit-counter";
95
108
 
109
+ // rc.35 TASK-07 (P0-2): events.jsonl path. PreToolUse Edit fires append a
110
+ // `edit_intent_checked` event (ledger_source: 'hook') so doctor cite-
111
+ // coverage's editsTouched metric sees actual edit signals. Without this
112
+ // signal the entire cite-policy contract validation is structurally inert
113
+ // (rc.30 audit P0-2: 18582 turns / 240 edits / 0 events).
114
+ const EVENTS_LEDGER_DIR_REL = ".fabric";
115
+ const EVENTS_LEDGER_FILE = "events.jsonl";
116
+
96
117
  // rc.6 TASK-023 (E6): hint-silence-counter sidecar — companion to the
97
118
  // edit-counter above. Where edit-counter records every PreToolUse fire
98
119
  // (numerator-agnostic), the silence-counter records only those fires that
@@ -126,6 +147,53 @@ let SYNTHETIC_SESSION_ID = null;
126
147
  // many tool names across clients; we only react to file-edit tools.
127
148
  const EDIT_TOOL_NAMES = new Set(["Edit", "Write", "MultiEdit"]);
128
149
 
150
+ // v2.0.0-rc.33 W2 fabric-config keys & defaults. Mirror of the schema in
151
+ // packages/shared/src/schemas/fabric-config.ts — hooks cannot require()
152
+ // shared modules (rendered as standalone templates at init), so the values
153
+ // are duplicated inline. Keep these in sync if the schema changes.
154
+ const FABRIC_DIR_REL = ".fabric";
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";
159
+
160
+ // W2-1 (P0-9): narrow TopK upper bound. Five matches the per-Edit hint
161
+ // "terse banner" UX: any more and the model's working memory bloats.
162
+ const DEFAULT_HINT_NARROW_TOP_K = 5;
163
+
164
+ // W2-2 (P0-9): per-file dedup window in turns. Same (file_path, stable_id)
165
+ // stays silent for this many PreToolUse fires across sessions, addressing
166
+ // the rc.32 finding that a single hot file (e.g. GameRoom.tsx edited 30
167
+ // times in a row) re-fired identical narrow hints and trained the agent
168
+ // to ignore them. Distinct sidecar from session-hints (E3) so window-only
169
+ // suppression doesn't poison cross-session dedupe semantics.
170
+ const DEFAULT_HINT_NARROW_DEDUP_WINDOW_TURNS = 5;
171
+ const NARROW_DEDUP_WINDOW_FILE = join(
172
+ ".fabric",
173
+ ".cache",
174
+ "narrow-dedup-window.json",
175
+ );
176
+ // Cap the recent-emission ring buffer at this many records so the sidecar
177
+ // stays bounded on long-running workspaces. The window check only needs the
178
+ // last `window` entries per (path, entry_id) so a 4x safety multiplier is
179
+ // generous. Pruning happens lazily on write.
180
+ const NARROW_DEDUP_RING_CAP = 1000;
181
+
182
+ // W2-5 (P1-8): cooldown between narrow-hint re-emits in hours. 0 = no
183
+ // cooldown (rc.32 behavior, every PreToolUse fire is gate-eligible).
184
+ const DEFAULT_HINT_NARROW_COOLDOWN_HOURS = 0;
185
+ const MS_PER_HOUR = 60 * 60 * 1000;
186
+ const HINT_NARROW_LAST_EMIT_FILE = join(
187
+ ".fabric",
188
+ ".cache",
189
+ "knowledge-hint-narrow-last-emit",
190
+ );
191
+
192
+ // W2-6 (P0-7): mirror of the broad hook flag — when true, emit the banner
193
+ // as a Claude Code PreToolUse hookSpecificOutput.additionalContext JSON
194
+ // envelope on stdout so the model receives the reminder IN-CONTEXT.
195
+ const DEFAULT_HINT_REMINDER_TO_CONTEXT = true;
196
+
129
197
  // -----------------------------------------------------------------------------
130
198
  // Payload parsing
131
199
  // -----------------------------------------------------------------------------
@@ -142,7 +210,19 @@ function readPayload(rawStdin) {
142
210
  return null;
143
211
  }
144
212
  return parsed;
145
- } catch {
213
+ } catch (e) {
214
+ // v2.0.0-rc.29 REVIEW (codex LOW-1): apply BUG-L1's malformed-input
215
+ // diagnostic uniformly across hook scripts. fabric-hint.cjs got the stderr
216
+ // trace in TASK-008; without this matching write here, a broken Codex /
217
+ // Cursor host payload silently kills the narrow hint with no operator
218
+ // signal at all. Best-effort: a failed stderr write must not throw upward
219
+ // (hook contract — never crash the host's edit pipeline).
220
+ try {
221
+ const message = (e && typeof e === "object" && "message" in e) ? String(e.message) : String(e);
222
+ process.stderr.write(`[fabric-knowledge-hint-narrow] malformed input: ${message}\n`);
223
+ } catch {
224
+ // stderr write itself failed (sandbox / closed fd) — accept silence.
225
+ }
146
226
  return null;
147
227
  }
148
228
  }
@@ -259,6 +339,80 @@ function extractPaths(toolInput) {
259
339
  * array. The fire-count signal is preserved; the activity overview
260
340
  * just contributes nothing from those lines.
261
341
  */
342
+ /**
343
+ * rc.35 TASK-07 (P0-2): append one `edit_intent_checked` event per touched
344
+ * path to `.fabric/events.jsonl`. Carries `ledger_source: 'hook'` so doctor
345
+ * cite-coverage can distinguish hook-originated edit signals from
346
+ * AI/human-originated `appendLedgerEntry` calls.
347
+ *
348
+ * Best-effort:
349
+ * - Skips silently when `.fabric/` does not exist (project not init'd).
350
+ * - Skips silently when paths is empty (counter signal is preserved by
351
+ * the sibling appendEditCounter call; cite-coverage only cares about
352
+ * non-empty path events).
353
+ * - ANY error (mkdir, append, JSON throw) is swallowed — the hook must
354
+ * remain non-blocking per the rc.6 contract.
355
+ *
356
+ * Atomicity:
357
+ * - One JSON line per path. Append on small writes (< PIPE_BUF, ~4KB on
358
+ * POSIX) is atomic at the OS level, so concurrent PreToolUse fires
359
+ * from parallel sessions interleave cleanly without partial writes.
360
+ */
361
+ function appendEditIntentToLedger(projectRoot, now, paths, toolName, sessionId) {
362
+ try {
363
+ const fabricDir = join(projectRoot, EVENTS_LEDGER_DIR_REL);
364
+ // No .fabric/ → project not initialised. Bail before any write.
365
+ if (!existsSync(fabricDir)) return;
366
+ const { isAbsolute: pathIsAbsolute, relative: pathRelative } = require("node:path");
367
+ const pathList = Array.isArray(paths)
368
+ ? paths
369
+ .filter((p) => typeof p === "string" && p.length > 0)
370
+ .map((p) => {
371
+ if (pathIsAbsolute(p)) {
372
+ const rel = pathRelative(projectRoot, p);
373
+ return rel.startsWith("..") ? null : rel;
374
+ }
375
+ // Already-relative paths: drop ones that escape the project tree.
376
+ return p.startsWith("..") ? null : p;
377
+ })
378
+ .filter((p) => typeof p === "string" && p.length > 0)
379
+ // Use forward slashes for cross-platform consistency on disk.
380
+ .map((p) => p.split(/[\\/]/).join("/"))
381
+ : [];
382
+ if (pathList.length === 0) return;
383
+ const tsMs = now instanceof Date ? now.getTime() : Number(now);
384
+ const ledgerEntryId = `hook:${randomUUID()}`;
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;
393
+ const lines = pathList
394
+ .map((p) => JSON.stringify({
395
+ kind: "fabric-event",
396
+ id: `event:${randomUUID()}`,
397
+ ts: tsMs,
398
+ schema_version: 1,
399
+ ...(validSessionId ? { session_id: validSessionId } : {}),
400
+ event_type: "edit_intent_checked",
401
+ path: p,
402
+ compliant: true,
403
+ intent,
404
+ ledger_entry_id: ledgerEntryId,
405
+ ledger_source: "hook",
406
+ matched_rule_context_ts: null,
407
+ window_ms: 0,
408
+ }))
409
+ .join("\n") + "\n";
410
+ appendFileSync(join(fabricDir, EVENTS_LEDGER_FILE), lines, "utf8");
411
+ } catch {
412
+ // Silent — events ledger failure must never block the edit.
413
+ }
414
+ }
415
+
262
416
  function appendEditCounter(projectRoot, now, paths) {
263
417
  try {
264
418
  const dir = join(projectRoot, EDIT_COUNTER_DIR_REL);
@@ -267,8 +421,34 @@ function appendEditCounter(projectRoot, now, paths) {
267
421
  mkdirSync(dir, { recursive: true });
268
422
  }
269
423
  const iso = now instanceof Date ? now.toISOString() : new Date(now).toISOString();
424
+ // v2.0.0-rc.27 TASK-005 (audit §2.8): normalize every path to a
425
+ // project-relative form BEFORE persistence. rc.26 wrote whatever the
426
+ // tool_input handed in — frequently absolute paths like
427
+ // `/Users/wepie/.../foo.ts` — which then leaked into the archive banner's
428
+ // "recent activity centered on: Users/wepie/" prose (the dirname pass
429
+ // stripped the leading `/` but produced a $HOME-prefix surface). The
430
+ // normalize-on-write keeps the sidecar containing only project-internal
431
+ // paths so downstream banner rendering can't accidentally surface
432
+ // host-system paths.
433
+ //
434
+ // Strategy: for each path, attempt path.relative(projectRoot, abs). When
435
+ // the result starts with `..` (path is outside the project tree) we
436
+ // silently drop the entry — out-of-tree edits are not meaningful
437
+ // activity for THIS project's banner. Bare relative paths (already in
438
+ // canonical form) round-trip through relative() unchanged.
439
+ const { isAbsolute: pathIsAbsolute, relative: pathRelative } = require("node:path");
270
440
  const pathList = Array.isArray(paths)
271
- ? paths.filter((p) => typeof p === "string" && p.length > 0)
441
+ ? paths
442
+ .filter((p) => typeof p === "string" && p.length > 0)
443
+ .map((p) => {
444
+ if (pathIsAbsolute(p)) {
445
+ const rel = pathRelative(projectRoot, p);
446
+ // path.relative returns `..` segments when p escapes projectRoot.
447
+ return rel.startsWith("..") ? null : rel;
448
+ }
449
+ return p;
450
+ })
451
+ .filter((p) => typeof p === "string" && p.length > 0)
272
452
  : [];
273
453
  const line = JSON.stringify({ ts: iso, paths: pathList });
274
454
  appendFileSync(file, `${line}\n`, "utf8");
@@ -568,6 +748,9 @@ function invokePlanContextHint(cwd, paths) {
568
748
  if (!Array.isArray(paths) || paths.length === 0) return null;
569
749
  const pathsArg = paths.join(",");
570
750
  const candidates = ["fabric", "fab"];
751
+ // rc.31 NEW-6: see knowledge-hint-broad.cjs for rationale — surface plan-
752
+ // context-hint failures on stderr so degraded KB chain is observable.
753
+ let lastFailure = null;
571
754
  for (const bin of candidates) {
572
755
  let res;
573
756
  try {
@@ -580,59 +763,443 @@ function invokePlanContextHint(cwd, paths) {
580
763
  } catch {
581
764
  continue;
582
765
  }
583
- if (res.error || res.status === null || res.status !== 0) continue;
766
+ if (res.error) {
767
+ if (res.error.code !== "ENOENT") {
768
+ lastFailure = { bin, reason: String(res.error.message || res.error.code || res.error) };
769
+ }
770
+ continue;
771
+ }
772
+ if (res.status === null || res.status !== 0) {
773
+ const stderrSnip = (res.stderr || "").trim().slice(0, 240);
774
+ if (stderrSnip.length > 0) {
775
+ lastFailure = { bin, reason: stderrSnip };
776
+ }
777
+ continue;
778
+ }
584
779
  const raw = (res.stdout || "").trim();
585
780
  if (raw.length === 0) continue;
586
781
  try {
587
782
  const parsed = JSON.parse(raw);
588
783
  if (parsed && typeof parsed === "object") return parsed;
589
- } catch {
590
- // malformed JSON try next bin
784
+ } catch (err) {
785
+ lastFailure = { bin, reason: `malformed JSON from plan-context-hint: ${String(err && err.message || err)}` };
786
+ }
787
+ }
788
+ if (lastFailure !== null) {
789
+ process.stderr.write(
790
+ `[fabric-hint] plan-context-hint (${lastFailure.bin}) failed: ${lastFailure.reason.replace(/\n/g, " ")}\n`,
791
+ );
792
+ }
793
+ return null;
794
+ }
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
+
874
+ // -----------------------------------------------------------------------------
875
+ // v2.0.0-rc.33 W2 — fabric-config readers + per-file dedup-window sidecar.
876
+ //
877
+ // All readers follow the project convention: inline JSON.parse of
878
+ // .fabric/fabric-config.json with default-on-failure. Hooks cannot require()
879
+ // the TS schema, so the schema's range constraints are duplicated inline as
880
+ // guard clauses (kept in sync with packages/shared/src/schemas/fabric-config.ts).
881
+ // -----------------------------------------------------------------------------
882
+
883
+ function _readNarrowConfigValue(projectRoot) {
884
+ const configPath = join(projectRoot, FABRIC_DIR_REL, FABRIC_CONFIG_FILE);
885
+ if (!existsSync(configPath)) return null;
886
+ try {
887
+ return JSON.parse(readFileSync(configPath, "utf8"));
888
+ } catch {
889
+ return null;
890
+ }
891
+ }
892
+
893
+ function readNarrowTopK(projectRoot) {
894
+ const parsed = _readNarrowConfigValue(projectRoot);
895
+ if (parsed && typeof parsed === "object") {
896
+ const v = parsed.hint_narrow_top_k;
897
+ if (typeof v === "number" && Number.isFinite(v) && v >= 1 && v <= 20) {
898
+ return Math.floor(v);
899
+ }
900
+ }
901
+ return DEFAULT_HINT_NARROW_TOP_K;
902
+ }
903
+
904
+ function readNarrowDedupWindowTurns(projectRoot) {
905
+ const parsed = _readNarrowConfigValue(projectRoot);
906
+ if (parsed && typeof parsed === "object") {
907
+ const v = parsed.hint_narrow_dedup_window_turns;
908
+ if (typeof v === "number" && Number.isFinite(v) && v >= 1 && v <= 50) {
909
+ return Math.floor(v);
910
+ }
911
+ }
912
+ return DEFAULT_HINT_NARROW_DEDUP_WINDOW_TURNS;
913
+ }
914
+
915
+ function readNarrowCooldownHours(projectRoot) {
916
+ const parsed = _readNarrowConfigValue(projectRoot);
917
+ if (parsed && typeof parsed === "object") {
918
+ const v = parsed.hint_narrow_cooldown_hours;
919
+ if (typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= 168) {
920
+ return v;
591
921
  }
592
922
  }
923
+ return DEFAULT_HINT_NARROW_COOLDOWN_HOURS;
924
+ }
925
+
926
+ function readReminderToContext(projectRoot) {
927
+ const parsed = _readNarrowConfigValue(projectRoot);
928
+ if (parsed && typeof parsed === "object") {
929
+ const v = parsed.hint_reminder_to_context;
930
+ if (typeof v === "boolean") return v;
931
+ }
932
+ return DEFAULT_HINT_REMINDER_TO_CONTEXT;
933
+ }
934
+
935
+ function readNarrowLastEmit(projectRoot) {
936
+ const p = join(projectRoot, HINT_NARROW_LAST_EMIT_FILE);
937
+ if (!existsSync(p)) return null;
938
+ try {
939
+ const raw = readFileSync(p, "utf8").trim();
940
+ if (raw.length === 0) return null;
941
+ const asNum = Number(raw);
942
+ if (Number.isFinite(asNum) && asNum > 0) return asNum;
943
+ const ms = Date.parse(raw);
944
+ if (Number.isFinite(ms)) return ms;
945
+ } catch {
946
+ // ignore
947
+ }
593
948
  return null;
594
949
  }
595
950
 
951
+ function writeNarrowLastEmit(projectRoot, nowMs) {
952
+ const p = join(projectRoot, HINT_NARROW_LAST_EMIT_FILE);
953
+ try {
954
+ if (!existsSync(dirname(p))) {
955
+ mkdirSync(dirname(p), { recursive: true });
956
+ }
957
+ writeFileSync(p, String(nowMs));
958
+ } catch {
959
+ // Silent — sidecar failure must never block edits.
960
+ }
961
+ }
962
+
963
+ /**
964
+ * v2.0.0-rc.33 W2-2: per-file dedup window sidecar.
965
+ *
966
+ * On-disk shape (in .fabric/.cache/narrow-dedup-window.json):
967
+ * {
968
+ * "counter": <monotonic int — incremented on each render>,
969
+ * "recent": [
970
+ * { "path": "<file_path>", "entry_id": "<stable_id>", "at_turn": <int> },
971
+ * ...
972
+ * ]
973
+ * }
974
+ *
975
+ * The `recent` array is a ring buffer capped at NARROW_DEDUP_RING_CAP entries
976
+ * so the sidecar stays bounded on long-running workspaces. Pruning happens
977
+ * lazily on write.
978
+ *
979
+ * Read failures and shape mismatches both return a fresh zero-state — the
980
+ * window degrades to "no dedup" rather than blocking the hint.
981
+ */
982
+ function readNarrowDedupWindow(projectRoot) {
983
+ const empty = { revision_hash: "", counter: 0, recent: [] };
984
+ const p = join(projectRoot, NARROW_DEDUP_WINDOW_FILE);
985
+ if (!existsSync(p)) return empty;
986
+ try {
987
+ const raw = readFileSync(p, "utf8");
988
+ if (raw.length === 0) return empty;
989
+ const parsed = JSON.parse(raw);
990
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
991
+ return empty;
992
+ }
993
+ const counter =
994
+ typeof parsed.counter === "number" && Number.isFinite(parsed.counter)
995
+ ? parsed.counter
996
+ : 0;
997
+ const revision_hash =
998
+ typeof parsed.revision_hash === "string" ? parsed.revision_hash : "";
999
+ const recent = Array.isArray(parsed.recent)
1000
+ ? parsed.recent.filter(
1001
+ (r) =>
1002
+ r &&
1003
+ typeof r === "object" &&
1004
+ typeof r.path === "string" &&
1005
+ r.path.length > 0 &&
1006
+ typeof r.entry_id === "string" &&
1007
+ r.entry_id.length > 0 &&
1008
+ typeof r.at_turn === "number" &&
1009
+ Number.isFinite(r.at_turn),
1010
+ )
1011
+ : [];
1012
+ return { revision_hash, counter, recent };
1013
+ } catch {
1014
+ return empty;
1015
+ }
1016
+ }
1017
+
1018
+ function writeNarrowDedupWindow(projectRoot, state) {
1019
+ const p = join(projectRoot, NARROW_DEDUP_WINDOW_FILE);
1020
+ try {
1021
+ if (!existsSync(dirname(p))) {
1022
+ mkdirSync(dirname(p), { recursive: true });
1023
+ }
1024
+ // Lazy prune: keep only the most recent NARROW_DEDUP_RING_CAP records.
1025
+ // Newer records are at the tail; slicing from -CAP preserves ring semantics.
1026
+ const recent =
1027
+ state.recent.length > NARROW_DEDUP_RING_CAP
1028
+ ? state.recent.slice(-NARROW_DEDUP_RING_CAP)
1029
+ : state.recent;
1030
+ const tmp = `${p}.tmp-${process.pid}`;
1031
+ writeFileSync(
1032
+ tmp,
1033
+ JSON.stringify({
1034
+ revision_hash: state.revision_hash || "",
1035
+ counter: state.counter,
1036
+ recent,
1037
+ }),
1038
+ );
1039
+ renameSync(tmp, p);
1040
+ } catch {
1041
+ // Silent — sidecar failure must never block edits.
1042
+ }
1043
+ }
1044
+
1045
+ /**
1046
+ * Apply the dedup-window filter. Returns `{ filtered, nextState }`:
1047
+ * filtered: NarrowEntry[] — entries whose (path, id) is NOT within `window`
1048
+ * turns of a prior emission for any of `targetPaths`.
1049
+ * nextState: the merged window state to persist if the caller decides to
1050
+ * render (records appended with at_turn = state.counter + 1).
1051
+ *
1052
+ * Decision rule: an entry is filtered out if ALL of its candidate
1053
+ * (path, entry_id) pairs already appear in `state.recent` with
1054
+ * `state.counter - at_turn < window`. The entry's "candidate pairs" are
1055
+ * (path, entry.id) for every path in targetPaths (the entry was about to be
1056
+ * surfaced for those paths). One path still missing → keep the entry.
1057
+ *
1058
+ * Side-effect-free; caller persists nextState only after a successful render.
1059
+ */
1060
+ function applyNarrowDedupWindow(state, narrow, targetPaths, windowTurns, currentRevisionHash) {
1061
+ const revHash =
1062
+ typeof currentRevisionHash === "string" ? currentRevisionHash : "";
1063
+ // Wholesale drop on revision flip — mirrors E3 emit-gate semantics so the
1064
+ // two layers stay coherent. Without this coordination a revision-graph
1065
+ // change would re-emit at the E3 layer but the dedup-window layer would
1066
+ // still suppress the hint.
1067
+ const liveState =
1068
+ state && state.revision_hash === revHash && revHash.length > 0
1069
+ ? state
1070
+ : { revision_hash: revHash, counter: state ? state.counter : 0, recent: [] };
1071
+
1072
+ if (!Array.isArray(narrow) || narrow.length === 0) {
1073
+ return { filtered: [], nextState: liveState };
1074
+ }
1075
+ if (!Array.isArray(targetPaths) || targetPaths.length === 0) {
1076
+ return { filtered: narrow.slice(), nextState: liveState };
1077
+ }
1078
+
1079
+ const currentTurn = liveState.counter + 1;
1080
+ const cutoff = currentTurn - windowTurns;
1081
+
1082
+ // Build a (path, entry_id) → at_turn lookup. Most recent wins on duplicates.
1083
+ const lookup = new Map();
1084
+ for (const rec of liveState.recent) {
1085
+ const key = `${rec.path}${rec.entry_id}`;
1086
+ const existing = lookup.get(key);
1087
+ if (existing === undefined || rec.at_turn > existing) {
1088
+ lookup.set(key, rec.at_turn);
1089
+ }
1090
+ }
1091
+
1092
+ const filtered = [];
1093
+ const newRecords = [];
1094
+ for (const entry of narrow) {
1095
+ const entryId = entry && typeof entry.id === "string" ? entry.id : null;
1096
+ if (entryId === null) {
1097
+ // No id — can't dedup, surface defensively.
1098
+ filtered.push(entry);
1099
+ continue;
1100
+ }
1101
+ // Entry is suppressed only if every targetPath has a recent record.
1102
+ let allRecent = true;
1103
+ for (const path of targetPaths) {
1104
+ const key = `${path}${entryId}`;
1105
+ const lastTurn = lookup.get(key);
1106
+ if (lastTurn === undefined || lastTurn < cutoff) {
1107
+ allRecent = false;
1108
+ break;
1109
+ }
1110
+ }
1111
+ if (!allRecent) {
1112
+ filtered.push(entry);
1113
+ for (const path of targetPaths) {
1114
+ newRecords.push({ path, entry_id: entryId, at_turn: currentTurn });
1115
+ }
1116
+ }
1117
+ }
1118
+
1119
+ const nextState = {
1120
+ revision_hash: revHash,
1121
+ counter: currentTurn,
1122
+ recent: filtered.length > 0 ? liveState.recent.concat(newRecords) : liveState.recent,
1123
+ };
1124
+ return { filtered, nextState };
1125
+ }
1126
+
596
1127
  // -----------------------------------------------------------------------------
597
1128
  // Rendering
598
1129
  // -----------------------------------------------------------------------------
599
1130
 
600
- function truncateSummary(raw) {
1131
+ // v2.0.0-rc.33 W4-A3: maxLen sourced from fabric-config#hint_summary_max_len.
1132
+ function truncateSummary(raw, maxLen) {
601
1133
  const s = typeof raw === "string" ? raw : "";
602
1134
  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)}…`;
1135
+ const cap = typeof maxLen === "number" && maxLen > 0 ? maxLen : DEFAULT_SUMMARY_MAX_LEN;
1136
+ if (flat.length <= cap) return flat;
1137
+ return `${flat.slice(0, cap - 1)}…`;
605
1138
  }
606
1139
 
607
- function formatEntryLine(entry) {
1140
+ function formatEntryLine(entry, maxLen) {
608
1141
  const id = entry.id || "(no-id)";
609
1142
  const type = entry.type || "unknown";
610
1143
  const maturity = entry.maturity || "unknown";
611
- const summary = truncateSummary(entry.summary);
1144
+ const summary = truncateSummary(entry.summary, maxLen);
612
1145
  const tail = summary.length > 0 ? ` ${summary}` : "";
613
1146
  return ` [${id}] (${type}/${maturity})${tail}`;
614
1147
  }
615
1148
 
1149
+ function readSummaryMaxLen(projectRoot) {
1150
+ const parsed = _readNarrowConfigValue(projectRoot);
1151
+ if (parsed && typeof parsed === "object") {
1152
+ const v = parsed.hint_summary_max_len;
1153
+ if (typeof v === "number" && Number.isFinite(v) && v >= 40 && v <= 240) {
1154
+ return Math.floor(v);
1155
+ }
1156
+ }
1157
+ return DEFAULT_SUMMARY_MAX_LEN;
1158
+ }
1159
+
616
1160
  /**
617
1161
  * 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
1162
+ * when there is nothing to render (empty entries set). Callers stay silent
619
1163
  * on empty output.
620
1164
  *
1165
+ * Protocol gate (rc.18): only `payload.version === 2` payloads are
1166
+ * rendered. Anything else returns []. When the payload exists but carries
1167
+ * a mismatched (non-undefined) version, a one-line stderr breadcrumb is
1168
+ * emitted as a debug aid — see `_protocol-v2-decisions.md` (Decision 2,
1169
+ * "silent-skip + one-line stderr breadcrumb"). The wire field is
1170
+ * `payload.entries` (renamed from `payload.narrow` in protocol v2,
1171
+ * Decision 1).
1172
+ *
621
1173
  * Output shape:
622
1174
  * [fabric] N narrow-scoped knowledge entries match your edit targets:
623
1175
  * [<id>] (<type>/<maturity>) <summary>
624
1176
  * ...
625
1177
  * (如需重读 broad 决策,调 fab_plan_context 或 fabric plan-context-hint --all)
626
1178
  */
627
- function renderSummary(payload) {
628
- const narrow = Array.isArray(payload && payload.narrow) ? payload.narrow : [];
629
- if (narrow.length === 0) return [];
1179
+ function renderSummary(payload, maxLen) {
1180
+ if (!payload || payload.version !== 2) {
1181
+ if (payload && payload.version !== undefined) {
1182
+ // breadcrumb only if payload exists but version mismatches (avoid
1183
+ // spam on null). Best-effort write — silent-on-failure honors the
1184
+ // hook's "never block edits" contract.
1185
+ try {
1186
+ process.stderr.write(
1187
+ `[fabric] hint payload version=${payload.version} unsupported (expected 2), skipping\n`,
1188
+ );
1189
+ } catch {
1190
+ // ignore — stderr unavailable, silent-skip still applies
1191
+ }
1192
+ }
1193
+ return [];
1194
+ }
1195
+ const entries = Array.isArray(payload.entries) ? payload.entries : [];
1196
+ if (entries.length === 0) return [];
630
1197
 
631
1198
  const lines = [
632
- `[fabric] ${narrow.length} narrow-scoped knowledge entries match your edit targets:`,
1199
+ `[fabric] ${entries.length} narrow-scoped knowledge entries match your edit targets:`,
633
1200
  ];
634
- for (const entry of narrow) {
635
- lines.push(formatEntryLine(entry));
1201
+ for (const entry of entries) {
1202
+ lines.push(formatEntryLine(entry, maxLen));
636
1203
  }
637
1204
  lines.push(" (如需重读 broad 决策,调 fab_plan_context 或 fabric plan-context-hint --all)");
638
1205
  return lines;
@@ -646,7 +1213,9 @@ function main(env, stdio) {
646
1213
  try {
647
1214
  const cwd = (env && env.cwd) || process.cwd();
648
1215
  const now = (env && env.now) || new Date();
1216
+ const nowMs = now instanceof Date ? now.getTime() : Number(now);
649
1217
  const err = (stdio && stdio.stderr) || process.stderr;
1218
+ const out = (stdio && stdio.stdout) || process.stdout;
650
1219
 
651
1220
  // Parse hook payload. Test seam: env.payload short-circuits stdin so
652
1221
  // unit tests don't need to muck with process.stdin.
@@ -684,6 +1253,19 @@ function main(env, stdio) {
684
1253
  }
685
1254
  if (!(env && env.skipCounter === true)) {
686
1255
  appendEditCounter(cwd, now, paths);
1256
+ // rc.35 TASK-07 (P0-2): mirror the edit-counter sidecar into the
1257
+ // events.jsonl ledger so doctor cite-coverage's editsTouched metric
1258
+ // sees actual edit signals. Best-effort — failure is swallowed inside
1259
+ // appendEditIntentToLedger and does not block the hook.
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);
687
1269
  }
688
1270
 
689
1271
  // E2 path is conditional on a recognized tool + extractable paths.
@@ -691,15 +1273,83 @@ function main(env, stdio) {
691
1273
  if (!toolName || !EDIT_TOOL_NAMES.has(toolName)) return;
692
1274
  if (paths.length === 0) return;
693
1275
 
1276
+ // v2.0.0-rc.33 W2-5 (P1-8): cooldown gate. When configured > 0, suppress
1277
+ // the hint for that many hours after a successful emit. Counted as
1278
+ // silence so doctor lint #26 sees the suppression. Test seam
1279
+ // env.skipCooldown bypasses for unit tests.
1280
+ const cooldownHours = readNarrowCooldownHours(cwd);
1281
+ if (cooldownHours > 0 && !(env && env.skipCooldown === true)) {
1282
+ const lastEmitMs = readNarrowLastEmit(cwd);
1283
+ if (
1284
+ typeof lastEmitMs === "number" &&
1285
+ nowMs - lastEmitMs < cooldownHours * MS_PER_HOUR
1286
+ ) {
1287
+ if (!(env && env.skipSilenceCounter === true)) {
1288
+ appendHintSilenceCounter(cwd, now);
1289
+ }
1290
+ return;
1291
+ }
1292
+ }
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
+
694
1298
  // Test seam: env.cliResult short-circuits the CLI spawn so unit tests
695
1299
  // 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);
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
+ }
700
1325
  if (cliPayload === null || cliPayload === undefined) return;
701
1326
 
702
- const narrow = Array.isArray(cliPayload.narrow) ? cliPayload.narrow : [];
1327
+ // Protocol v2 (rc.18 TASK-005): wire field is `entries`, no v1 shim.
1328
+ //
1329
+ // v2.0.0-rc.27 TASK-005 (audit §2.5/§2.7): filter to entries whose
1330
+ // `relevance_scope === "narrow"` so broad cross-cutting entries do NOT
1331
+ // pollute the PreToolUse banner. rc.26 emitted broad + narrow as a
1332
+ // single list — every Edit fired a hint even for paths the entry never
1333
+ // anchored against (audit §2.5 reproduction). Broad entries are already
1334
+ // surfaced once per session by the SessionStart hook so the PreToolUse
1335
+ // surface should be narrow-only by design.
1336
+ //
1337
+ // Defensive default: when the CLI omits `relevance_scope` (older server
1338
+ // / malformed item) we treat it as broad and skip — pre-rc.27 entries
1339
+ // without the field are exactly the broad-leak surface §2.5 calls out.
1340
+ const allEntries = Array.isArray(cliPayload.entries) ? cliPayload.entries : [];
1341
+ const narrowFiltered = allEntries.filter((entry) => entry && entry.relevance_scope === "narrow");
1342
+
1343
+ // v2.0.0-rc.33 W2-1 (P0-9): apply TopK slice to narrow set BEFORE the
1344
+ // emit-gate / dedup-window cascade. The server-side ranking already
1345
+ // produced a sensible order, so slicing here bounds the per-Edit hint
1346
+ // surface area to `hint_narrow_top_k` (default 5) so the agent's working
1347
+ // memory isn't displaced by an unwieldy banner.
1348
+ const topK = readNarrowTopK(cwd);
1349
+ const narrow = narrowFiltered.length > topK
1350
+ ? narrowFiltered.slice(0, topK)
1351
+ : narrowFiltered;
1352
+
703
1353
  if (narrow.length === 0) {
704
1354
  // rc.6 TASK-023 (E6): silence-counter — matched-narrow == 0. The CLI
705
1355
  // had a chance to match against the extracted paths but came back
@@ -726,7 +1376,7 @@ function main(env, stdio) {
726
1376
  // can add the counter increment either here (before the early return)
727
1377
  // or inside applyEmitGate when render === false.
728
1378
  // -------------------------------------------------------------------------
729
- const sessionId = resolveSessionId(payload, env);
1379
+ // sessionId already resolved up-front (rc.37 NEW-17) for the result cache.
730
1380
  const currentRevisionHash =
731
1381
  typeof cliPayload.revision_hash === "string" ? cliPayload.revision_hash : "";
732
1382
  // Test seam: env.cacheSeed short-circuits the on-disk cache read so unit
@@ -748,6 +1398,39 @@ function main(env, stdio) {
748
1398
  return;
749
1399
  }
750
1400
 
1401
+ // v2.0.0-rc.33 W2-2 (P0-9): per-file dedup window. The E3 session-hints
1402
+ // cache covers per-session dedupe; this layer adds workspace-level "same
1403
+ // (file, entry) not within last N turns" suppression so a hot file's
1404
+ // identical hints don't train the agent to ignore them. Counted as
1405
+ // silence on full filter-out so doctor lint #26 visibility is preserved.
1406
+ const windowTurns = readNarrowDedupWindowTurns(cwd);
1407
+ const windowState =
1408
+ env && env.dedupWindowSeed !== undefined
1409
+ ? env.dedupWindowSeed
1410
+ : readNarrowDedupWindow(cwd);
1411
+ const dedupDecision = applyNarrowDedupWindow(
1412
+ windowState,
1413
+ gateDecision.narrow,
1414
+ paths,
1415
+ windowTurns,
1416
+ currentRevisionHash,
1417
+ );
1418
+ if (dedupDecision.filtered.length === 0) {
1419
+ // v2.0.0-rc.33 W4 review-fix (gemini Critical-1): persist the counter
1420
+ // BEFORE returning so the turn-window check still advances on suppressed
1421
+ // fires. Skipping the write here caused dedup state to permanently stick
1422
+ // — every subsequent fire would read the old counter, see at_turn within
1423
+ // the window, and keep suppressing. Now: counter ticks on every fire,
1424
+ // window-naturally expires after `windowTurns` PreToolUse events.
1425
+ if (!(env && env.skipCacheWrite === true)) {
1426
+ writeNarrowDedupWindow(cwd, dedupDecision.nextState);
1427
+ }
1428
+ if (!(env && env.skipSilenceCounter === true)) {
1429
+ appendHintSilenceCounter(cwd, now);
1430
+ }
1431
+ return;
1432
+ }
1433
+
751
1434
  // Persist the cache BEFORE rendering. If the render itself throws (e.g.
752
1435
  // stderr write errors), the cache update still reflects the intent —
753
1436
  // the alternative (post-render write) could leave us in a state where
@@ -762,13 +1445,64 @@ function main(env, stdio) {
762
1445
  ...gateDecision.cache,
763
1446
  session_id: sessionId,
764
1447
  });
1448
+ writeNarrowDedupWindow(cwd, dedupDecision.nextState);
765
1449
  }
766
1450
 
767
- const lines = renderSummary({ ...cliPayload, narrow: gateDecision.narrow });
1451
+ const summaryMaxLen = readSummaryMaxLen(cwd);
1452
+ // rc.35 TASK-06 (P0-10.b): substitute opaque summaries before render.
1453
+ // Same lib used by the broad hook — opaque entries seen from both call
1454
+ // sites share a single .fabric/.cache/summary-fallback.json file.
1455
+ // Best-effort — any failure leaves the original opaque summary intact.
1456
+ let resolvedEntries = dedupDecision.filtered;
1457
+ try {
1458
+ resolvedEntries = resolveOpaqueSummaries(
1459
+ dedupDecision.filtered,
1460
+ cwd,
1461
+ currentRevisionHash,
1462
+ );
1463
+ } catch {
1464
+ // resolveOpaqueSummaries swallows its own errors; defensive catch.
1465
+ }
1466
+ const lines = renderSummary({ ...cliPayload, entries: resolvedEntries }, summaryMaxLen);
768
1467
  if (lines.length === 0) return;
1468
+
1469
+ // Stderr: human-facing breadcrumb + legacy contract.
769
1470
  for (const line of lines) {
770
1471
  err.write(`${line}\n`);
771
1472
  }
1473
+
1474
+ // v2.0.0-rc.33 W2-6 (P0-7): stdout JSON envelope. When
1475
+ // hint_reminder_to_context is true (default), serialize the same banner
1476
+ // body as Claude Code's PreToolUse hookSpecificOutput shape so the model
1477
+ // receives the reminder IN-CONTEXT (rc.32 baseline cite-coverage 3.1%
1478
+ // root cause: reminders never entered model context). PreToolUse hook
1479
+ // contract: stdout JSON with hookSpecificOutput.additionalContext is
1480
+ // injected into the model's context window; the hook DOES NOT block the
1481
+ // edit (additionalContext is informational, not a permissionDecision).
1482
+ // v2.0.0-rc.33 W4 review-fix (gemini High-1): CC-specific stdout envelope.
1483
+ // See knowledge-hint-broad.cjs companion for rationale — CLAUDE_PROJECT_DIR
1484
+ // is the CC presence signal; Codex CLI / Cursor don't set it.
1485
+ const _isClaudeCode =
1486
+ typeof process.env.CLAUDE_PROJECT_DIR === "string" &&
1487
+ process.env.CLAUDE_PROJECT_DIR.length > 0;
1488
+ if (!(env && env.skipStdout === true) && _isClaudeCode && readReminderToContext(cwd)) {
1489
+ try {
1490
+ const envelope = {
1491
+ hookSpecificOutput: {
1492
+ hookEventName: "PreToolUse",
1493
+ additionalContext: lines.join("\n"),
1494
+ },
1495
+ };
1496
+ out.write(`${JSON.stringify(envelope)}\n`);
1497
+ } catch {
1498
+ // Best-effort — stderr is the durable contract.
1499
+ }
1500
+ }
1501
+
1502
+ // v2.0.0-rc.33 W2-5: record successful emit for cooldown gate.
1503
+ if (cooldownHours > 0 && !(env && env.skipCooldownWrite === true)) {
1504
+ writeNarrowLastEmit(cwd, nowMs);
1505
+ }
772
1506
  } catch {
773
1507
  // Silent — never block edits on hook failure.
774
1508
  }
@@ -786,6 +1520,10 @@ module.exports = {
786
1520
  renderSummary,
787
1521
  truncateSummary,
788
1522
  formatEntryLine,
1523
+ // rc.35 TASK-07 (P0-2): cite-infrastructure wire-up. Exported so the
1524
+ // integration test can drive the writer directly without standing up the
1525
+ // entire PreToolUse main() flow.
1526
+ appendEditIntentToLedger,
789
1527
  // rc.6 TASK-021 (E3) — session-hints cache exports for tests / future
790
1528
  // consumers (TASK-023 silence-counter telemetry will reuse the same
791
1529
  // session-id resolution + cache shape).
@@ -796,17 +1534,44 @@ module.exports = {
796
1534
  writeSessionHintsCache,
797
1535
  computeIndexHash,
798
1536
  applyEmitGate,
1537
+ // v2.0.0-rc.33 W2-1 / W2-2 / W2-5 / W2-6 — exports for unit tests.
1538
+ readNarrowTopK,
1539
+ readNarrowDedupWindowTurns,
1540
+ readNarrowCooldownHours,
1541
+ readReminderToContext,
1542
+ readNarrowLastEmit,
1543
+ writeNarrowLastEmit,
1544
+ readNarrowDedupWindow,
1545
+ writeNarrowDedupWindow,
1546
+ applyNarrowDedupWindow,
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,
799
1554
  CONSTANTS: {
800
1555
  CLI_TIMEOUT_MS,
801
- SUMMARY_MAX_LEN,
1556
+ SUMMARY_MAX_LEN: DEFAULT_SUMMARY_MAX_LEN,
1557
+ DEFAULT_SUMMARY_MAX_LEN,
802
1558
  EDIT_COUNTER_DIR_REL,
803
1559
  EDIT_COUNTER_FILE,
804
1560
  HINT_SILENCE_COUNTER_DIR_REL,
805
1561
  HINT_SILENCE_COUNTER_FILE,
1562
+ EVENTS_LEDGER_DIR_REL,
1563
+ EVENTS_LEDGER_FILE,
806
1564
  EDIT_TOOL_NAMES,
807
1565
  SESSION_HINTS_DIR_REL,
808
1566
  SESSION_HINTS_FILE_PREFIX,
809
1567
  SESSION_HINTS_FILE_SUFFIX,
1568
+ DEFAULT_HINT_NARROW_TOP_K,
1569
+ DEFAULT_HINT_NARROW_DEDUP_WINDOW_TURNS,
1570
+ DEFAULT_HINT_NARROW_COOLDOWN_HOURS,
1571
+ DEFAULT_HINT_REMINDER_TO_CONTEXT,
1572
+ NARROW_DEDUP_WINDOW_FILE,
1573
+ NARROW_DEDUP_RING_CAP,
1574
+ HINT_NARROW_LAST_EMIT_FILE,
810
1575
  },
811
1576
  };
812
1577