@fenglimg/fabric-cli 2.0.0-rc.29 → 2.0.0-rc.33

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 (26) hide show
  1. package/dist/{doctor-TTDTKOFJ.js → doctor-E26YO67D.js} +8 -2
  2. package/dist/index.js +3 -3
  3. package/dist/{install-ODEKSJDS.js → install-YSFVNY3T.js} +1 -1
  4. package/package.json +3 -3
  5. package/templates/hooks/knowledge-hint-broad.cjs +268 -21
  6. package/templates/hooks/knowledge-hint-narrow.cjs +466 -14
  7. package/templates/skills/fabric-archive/SKILL.md +144 -738
  8. package/templates/skills/fabric-archive/ref/e5-cron-recap.md +5 -5
  9. package/templates/skills/fabric-archive/ref/i18n-policy.md +3 -3
  10. package/templates/skills/fabric-archive/ref/phase-0-range-resolution.md +156 -0
  11. package/templates/skills/fabric-archive/ref/{phase-0-4-onboard.md → phase-1-5-onboard.md} +21 -21
  12. package/templates/skills/fabric-archive/ref/phase-1-cross-session.md +60 -0
  13. package/templates/skills/fabric-archive/ref/phase-2-5-viability.md +54 -0
  14. package/templates/skills/fabric-archive/ref/phase-3-5-scope.md +80 -0
  15. package/templates/skills/fabric-archive/ref/phase-3-classify.md +63 -0
  16. package/templates/skills/fabric-archive/ref/phase-4-5-emit.md +78 -0
  17. package/templates/skills/fabric-archive/ref/phase-4-mcp-persist.md +89 -0
  18. package/templates/skills/fabric-archive/ref/rc-history.md +6 -6
  19. package/templates/skills/fabric-archive/ref/worked-examples.md +1 -1
  20. package/templates/skills/fabric-import/SKILL.md +29 -556
  21. package/templates/skills/fabric-import/ref/checkpoint-state.md +85 -0
  22. package/templates/skills/fabric-import/ref/output-contract.md +61 -0
  23. package/templates/skills/fabric-import/ref/phase-2-mining.md +213 -0
  24. package/templates/skills/fabric-import/ref/phase-3-dedup.md +75 -0
  25. package/templates/skills/fabric-import/ref/worked-examples.md +127 -0
  26. package/templates/skills/fabric-review/SKILL.md +1 -1
@@ -86,7 +86,9 @@ const CLI_TIMEOUT_MS = 2000;
86
86
 
87
87
  // Maximum summary length per entry. Bounds each stderr line so a sloppy
88
88
  // pending entry can't blow up terminal width. Truncation appends an ellipsis.
89
- const SUMMARY_MAX_LEN = 80;
89
+ // v2.0.0-rc.33 W4-A3: `hint_summary_max_len` in fabric-config overrides this
90
+ // default (range 40..240). Resolved per-invocation via readSummaryMaxLen.
91
+ const DEFAULT_SUMMARY_MAX_LEN = 80;
90
92
 
91
93
  // Edit-counter sidecar — workspace-relative path. Process-local file; no
92
94
  // network. TASK-022 will read this back to compute edits-since-archive.
@@ -126,6 +128,50 @@ let SYNTHETIC_SESSION_ID = null;
126
128
  // many tool names across clients; we only react to file-edit tools.
127
129
  const EDIT_TOOL_NAMES = new Set(["Edit", "Write", "MultiEdit"]);
128
130
 
131
+ // v2.0.0-rc.33 W2 fabric-config keys & defaults. Mirror of the schema in
132
+ // packages/shared/src/schemas/fabric-config.ts — hooks cannot require()
133
+ // shared modules (rendered as standalone templates at init), so the values
134
+ // are duplicated inline. Keep these in sync if the schema changes.
135
+ const FABRIC_DIR_REL = ".fabric";
136
+ const FABRIC_CONFIG_FILE = "fabric-config.json";
137
+
138
+ // W2-1 (P0-9): narrow TopK upper bound. Five matches the per-Edit hint
139
+ // "terse banner" UX: any more and the model's working memory bloats.
140
+ const DEFAULT_HINT_NARROW_TOP_K = 5;
141
+
142
+ // W2-2 (P0-9): per-file dedup window in turns. Same (file_path, stable_id)
143
+ // stays silent for this many PreToolUse fires across sessions, addressing
144
+ // the rc.32 finding that a single hot file (e.g. GameRoom.tsx edited 30
145
+ // times in a row) re-fired identical narrow hints and trained the agent
146
+ // to ignore them. Distinct sidecar from session-hints (E3) so window-only
147
+ // suppression doesn't poison cross-session dedupe semantics.
148
+ const DEFAULT_HINT_NARROW_DEDUP_WINDOW_TURNS = 5;
149
+ const NARROW_DEDUP_WINDOW_FILE = join(
150
+ ".fabric",
151
+ ".cache",
152
+ "narrow-dedup-window.json",
153
+ );
154
+ // Cap the recent-emission ring buffer at this many records so the sidecar
155
+ // stays bounded on long-running workspaces. The window check only needs the
156
+ // last `window` entries per (path, entry_id) so a 4x safety multiplier is
157
+ // generous. Pruning happens lazily on write.
158
+ const NARROW_DEDUP_RING_CAP = 1000;
159
+
160
+ // W2-5 (P1-8): cooldown between narrow-hint re-emits in hours. 0 = no
161
+ // cooldown (rc.32 behavior, every PreToolUse fire is gate-eligible).
162
+ const DEFAULT_HINT_NARROW_COOLDOWN_HOURS = 0;
163
+ const MS_PER_HOUR = 60 * 60 * 1000;
164
+ const HINT_NARROW_LAST_EMIT_FILE = join(
165
+ ".fabric",
166
+ ".cache",
167
+ "knowledge-hint-narrow-last-emit",
168
+ );
169
+
170
+ // W2-6 (P0-7): mirror of the broad hook flag — when true, emit the banner
171
+ // as a Claude Code PreToolUse hookSpecificOutput.additionalContext JSON
172
+ // envelope on stdout so the model receives the reminder IN-CONTEXT.
173
+ const DEFAULT_HINT_REMINDER_TO_CONTEXT = true;
174
+
129
175
  // -----------------------------------------------------------------------------
130
176
  // Payload parsing
131
177
  // -----------------------------------------------------------------------------
@@ -606,6 +652,9 @@ function invokePlanContextHint(cwd, paths) {
606
652
  if (!Array.isArray(paths) || paths.length === 0) return null;
607
653
  const pathsArg = paths.join(",");
608
654
  const candidates = ["fabric", "fab"];
655
+ // rc.31 NEW-6: see knowledge-hint-broad.cjs for rationale — surface plan-
656
+ // context-hint failures on stderr so degraded KB chain is observable.
657
+ let lastFailure = null;
609
658
  for (const bin of candidates) {
610
659
  let res;
611
660
  try {
@@ -618,39 +667,322 @@ function invokePlanContextHint(cwd, paths) {
618
667
  } catch {
619
668
  continue;
620
669
  }
621
- if (res.error || res.status === null || res.status !== 0) continue;
670
+ if (res.error) {
671
+ if (res.error.code !== "ENOENT") {
672
+ lastFailure = { bin, reason: String(res.error.message || res.error.code || res.error) };
673
+ }
674
+ continue;
675
+ }
676
+ if (res.status === null || res.status !== 0) {
677
+ const stderrSnip = (res.stderr || "").trim().slice(0, 240);
678
+ if (stderrSnip.length > 0) {
679
+ lastFailure = { bin, reason: stderrSnip };
680
+ }
681
+ continue;
682
+ }
622
683
  const raw = (res.stdout || "").trim();
623
684
  if (raw.length === 0) continue;
624
685
  try {
625
686
  const parsed = JSON.parse(raw);
626
687
  if (parsed && typeof parsed === "object") return parsed;
627
- } catch {
628
- // malformed JSON try next bin
688
+ } catch (err) {
689
+ lastFailure = { bin, reason: `malformed JSON from plan-context-hint: ${String(err && err.message || err)}` };
629
690
  }
630
691
  }
692
+ if (lastFailure !== null) {
693
+ process.stderr.write(
694
+ `[fabric-hint] plan-context-hint (${lastFailure.bin}) failed: ${lastFailure.reason.replace(/\n/g, " ")}\n`,
695
+ );
696
+ }
631
697
  return null;
632
698
  }
633
699
 
700
+ // -----------------------------------------------------------------------------
701
+ // v2.0.0-rc.33 W2 — fabric-config readers + per-file dedup-window sidecar.
702
+ //
703
+ // All readers follow the project convention: inline JSON.parse of
704
+ // .fabric/fabric-config.json with default-on-failure. Hooks cannot require()
705
+ // the TS schema, so the schema's range constraints are duplicated inline as
706
+ // guard clauses (kept in sync with packages/shared/src/schemas/fabric-config.ts).
707
+ // -----------------------------------------------------------------------------
708
+
709
+ function _readNarrowConfigValue(projectRoot) {
710
+ const configPath = join(projectRoot, FABRIC_DIR_REL, FABRIC_CONFIG_FILE);
711
+ if (!existsSync(configPath)) return null;
712
+ try {
713
+ return JSON.parse(readFileSync(configPath, "utf8"));
714
+ } catch {
715
+ return null;
716
+ }
717
+ }
718
+
719
+ function readNarrowTopK(projectRoot) {
720
+ const parsed = _readNarrowConfigValue(projectRoot);
721
+ if (parsed && typeof parsed === "object") {
722
+ const v = parsed.hint_narrow_top_k;
723
+ if (typeof v === "number" && Number.isFinite(v) && v >= 1 && v <= 20) {
724
+ return Math.floor(v);
725
+ }
726
+ }
727
+ return DEFAULT_HINT_NARROW_TOP_K;
728
+ }
729
+
730
+ function readNarrowDedupWindowTurns(projectRoot) {
731
+ const parsed = _readNarrowConfigValue(projectRoot);
732
+ if (parsed && typeof parsed === "object") {
733
+ const v = parsed.hint_narrow_dedup_window_turns;
734
+ if (typeof v === "number" && Number.isFinite(v) && v >= 1 && v <= 50) {
735
+ return Math.floor(v);
736
+ }
737
+ }
738
+ return DEFAULT_HINT_NARROW_DEDUP_WINDOW_TURNS;
739
+ }
740
+
741
+ function readNarrowCooldownHours(projectRoot) {
742
+ const parsed = _readNarrowConfigValue(projectRoot);
743
+ if (parsed && typeof parsed === "object") {
744
+ const v = parsed.hint_narrow_cooldown_hours;
745
+ if (typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= 168) {
746
+ return v;
747
+ }
748
+ }
749
+ return DEFAULT_HINT_NARROW_COOLDOWN_HOURS;
750
+ }
751
+
752
+ function readReminderToContext(projectRoot) {
753
+ const parsed = _readNarrowConfigValue(projectRoot);
754
+ if (parsed && typeof parsed === "object") {
755
+ const v = parsed.hint_reminder_to_context;
756
+ if (typeof v === "boolean") return v;
757
+ }
758
+ return DEFAULT_HINT_REMINDER_TO_CONTEXT;
759
+ }
760
+
761
+ function readNarrowLastEmit(projectRoot) {
762
+ const p = join(projectRoot, HINT_NARROW_LAST_EMIT_FILE);
763
+ if (!existsSync(p)) return null;
764
+ try {
765
+ const raw = readFileSync(p, "utf8").trim();
766
+ if (raw.length === 0) return null;
767
+ const asNum = Number(raw);
768
+ if (Number.isFinite(asNum) && asNum > 0) return asNum;
769
+ const ms = Date.parse(raw);
770
+ if (Number.isFinite(ms)) return ms;
771
+ } catch {
772
+ // ignore
773
+ }
774
+ return null;
775
+ }
776
+
777
+ function writeNarrowLastEmit(projectRoot, nowMs) {
778
+ const p = join(projectRoot, HINT_NARROW_LAST_EMIT_FILE);
779
+ try {
780
+ if (!existsSync(dirname(p))) {
781
+ mkdirSync(dirname(p), { recursive: true });
782
+ }
783
+ writeFileSync(p, String(nowMs));
784
+ } catch {
785
+ // Silent — sidecar failure must never block edits.
786
+ }
787
+ }
788
+
789
+ /**
790
+ * v2.0.0-rc.33 W2-2: per-file dedup window sidecar.
791
+ *
792
+ * On-disk shape (in .fabric/.cache/narrow-dedup-window.json):
793
+ * {
794
+ * "counter": <monotonic int — incremented on each render>,
795
+ * "recent": [
796
+ * { "path": "<file_path>", "entry_id": "<stable_id>", "at_turn": <int> },
797
+ * ...
798
+ * ]
799
+ * }
800
+ *
801
+ * The `recent` array is a ring buffer capped at NARROW_DEDUP_RING_CAP entries
802
+ * so the sidecar stays bounded on long-running workspaces. Pruning happens
803
+ * lazily on write.
804
+ *
805
+ * Read failures and shape mismatches both return a fresh zero-state — the
806
+ * window degrades to "no dedup" rather than blocking the hint.
807
+ */
808
+ function readNarrowDedupWindow(projectRoot) {
809
+ const empty = { revision_hash: "", counter: 0, recent: [] };
810
+ const p = join(projectRoot, NARROW_DEDUP_WINDOW_FILE);
811
+ if (!existsSync(p)) return empty;
812
+ try {
813
+ const raw = readFileSync(p, "utf8");
814
+ if (raw.length === 0) return empty;
815
+ const parsed = JSON.parse(raw);
816
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
817
+ return empty;
818
+ }
819
+ const counter =
820
+ typeof parsed.counter === "number" && Number.isFinite(parsed.counter)
821
+ ? parsed.counter
822
+ : 0;
823
+ const revision_hash =
824
+ typeof parsed.revision_hash === "string" ? parsed.revision_hash : "";
825
+ const recent = Array.isArray(parsed.recent)
826
+ ? parsed.recent.filter(
827
+ (r) =>
828
+ r &&
829
+ typeof r === "object" &&
830
+ typeof r.path === "string" &&
831
+ r.path.length > 0 &&
832
+ typeof r.entry_id === "string" &&
833
+ r.entry_id.length > 0 &&
834
+ typeof r.at_turn === "number" &&
835
+ Number.isFinite(r.at_turn),
836
+ )
837
+ : [];
838
+ return { revision_hash, counter, recent };
839
+ } catch {
840
+ return empty;
841
+ }
842
+ }
843
+
844
+ function writeNarrowDedupWindow(projectRoot, state) {
845
+ const p = join(projectRoot, NARROW_DEDUP_WINDOW_FILE);
846
+ try {
847
+ if (!existsSync(dirname(p))) {
848
+ mkdirSync(dirname(p), { recursive: true });
849
+ }
850
+ // Lazy prune: keep only the most recent NARROW_DEDUP_RING_CAP records.
851
+ // Newer records are at the tail; slicing from -CAP preserves ring semantics.
852
+ const recent =
853
+ state.recent.length > NARROW_DEDUP_RING_CAP
854
+ ? state.recent.slice(-NARROW_DEDUP_RING_CAP)
855
+ : state.recent;
856
+ const tmp = `${p}.tmp-${process.pid}`;
857
+ writeFileSync(
858
+ tmp,
859
+ JSON.stringify({
860
+ revision_hash: state.revision_hash || "",
861
+ counter: state.counter,
862
+ recent,
863
+ }),
864
+ );
865
+ renameSync(tmp, p);
866
+ } catch {
867
+ // Silent — sidecar failure must never block edits.
868
+ }
869
+ }
870
+
871
+ /**
872
+ * Apply the dedup-window filter. Returns `{ filtered, nextState }`:
873
+ * filtered: NarrowEntry[] — entries whose (path, id) is NOT within `window`
874
+ * turns of a prior emission for any of `targetPaths`.
875
+ * nextState: the merged window state to persist if the caller decides to
876
+ * render (records appended with at_turn = state.counter + 1).
877
+ *
878
+ * Decision rule: an entry is filtered out if ALL of its candidate
879
+ * (path, entry_id) pairs already appear in `state.recent` with
880
+ * `state.counter - at_turn < window`. The entry's "candidate pairs" are
881
+ * (path, entry.id) for every path in targetPaths (the entry was about to be
882
+ * surfaced for those paths). One path still missing → keep the entry.
883
+ *
884
+ * Side-effect-free; caller persists nextState only after a successful render.
885
+ */
886
+ function applyNarrowDedupWindow(state, narrow, targetPaths, windowTurns, currentRevisionHash) {
887
+ const revHash =
888
+ typeof currentRevisionHash === "string" ? currentRevisionHash : "";
889
+ // Wholesale drop on revision flip — mirrors E3 emit-gate semantics so the
890
+ // two layers stay coherent. Without this coordination a revision-graph
891
+ // change would re-emit at the E3 layer but the dedup-window layer would
892
+ // still suppress the hint.
893
+ const liveState =
894
+ state && state.revision_hash === revHash && revHash.length > 0
895
+ ? state
896
+ : { revision_hash: revHash, counter: state ? state.counter : 0, recent: [] };
897
+
898
+ if (!Array.isArray(narrow) || narrow.length === 0) {
899
+ return { filtered: [], nextState: liveState };
900
+ }
901
+ if (!Array.isArray(targetPaths) || targetPaths.length === 0) {
902
+ return { filtered: narrow.slice(), nextState: liveState };
903
+ }
904
+
905
+ const currentTurn = liveState.counter + 1;
906
+ const cutoff = currentTurn - windowTurns;
907
+
908
+ // Build a (path, entry_id) → at_turn lookup. Most recent wins on duplicates.
909
+ const lookup = new Map();
910
+ for (const rec of liveState.recent) {
911
+ const key = `${rec.path}${rec.entry_id}`;
912
+ const existing = lookup.get(key);
913
+ if (existing === undefined || rec.at_turn > existing) {
914
+ lookup.set(key, rec.at_turn);
915
+ }
916
+ }
917
+
918
+ const filtered = [];
919
+ const newRecords = [];
920
+ for (const entry of narrow) {
921
+ const entryId = entry && typeof entry.id === "string" ? entry.id : null;
922
+ if (entryId === null) {
923
+ // No id — can't dedup, surface defensively.
924
+ filtered.push(entry);
925
+ continue;
926
+ }
927
+ // Entry is suppressed only if every targetPath has a recent record.
928
+ let allRecent = true;
929
+ for (const path of targetPaths) {
930
+ const key = `${path}${entryId}`;
931
+ const lastTurn = lookup.get(key);
932
+ if (lastTurn === undefined || lastTurn < cutoff) {
933
+ allRecent = false;
934
+ break;
935
+ }
936
+ }
937
+ if (!allRecent) {
938
+ filtered.push(entry);
939
+ for (const path of targetPaths) {
940
+ newRecords.push({ path, entry_id: entryId, at_turn: currentTurn });
941
+ }
942
+ }
943
+ }
944
+
945
+ const nextState = {
946
+ revision_hash: revHash,
947
+ counter: currentTurn,
948
+ recent: filtered.length > 0 ? liveState.recent.concat(newRecords) : liveState.recent,
949
+ };
950
+ return { filtered, nextState };
951
+ }
952
+
634
953
  // -----------------------------------------------------------------------------
635
954
  // Rendering
636
955
  // -----------------------------------------------------------------------------
637
956
 
638
- function truncateSummary(raw) {
957
+ // v2.0.0-rc.33 W4-A3: maxLen sourced from fabric-config#hint_summary_max_len.
958
+ function truncateSummary(raw, maxLen) {
639
959
  const s = typeof raw === "string" ? raw : "";
640
960
  const flat = s.replace(/\s+/g, " ").trim();
641
- if (flat.length <= SUMMARY_MAX_LEN) return flat;
642
- return `${flat.slice(0, SUMMARY_MAX_LEN - 1)}…`;
961
+ const cap = typeof maxLen === "number" && maxLen > 0 ? maxLen : DEFAULT_SUMMARY_MAX_LEN;
962
+ if (flat.length <= cap) return flat;
963
+ return `${flat.slice(0, cap - 1)}…`;
643
964
  }
644
965
 
645
- function formatEntryLine(entry) {
966
+ function formatEntryLine(entry, maxLen) {
646
967
  const id = entry.id || "(no-id)";
647
968
  const type = entry.type || "unknown";
648
969
  const maturity = entry.maturity || "unknown";
649
- const summary = truncateSummary(entry.summary);
970
+ const summary = truncateSummary(entry.summary, maxLen);
650
971
  const tail = summary.length > 0 ? ` ${summary}` : "";
651
972
  return ` [${id}] (${type}/${maturity})${tail}`;
652
973
  }
653
974
 
975
+ function readSummaryMaxLen(projectRoot) {
976
+ const parsed = _readNarrowConfigValue(projectRoot);
977
+ if (parsed && typeof parsed === "object") {
978
+ const v = parsed.hint_summary_max_len;
979
+ if (typeof v === "number" && Number.isFinite(v) && v >= 40 && v <= 240) {
980
+ return Math.floor(v);
981
+ }
982
+ }
983
+ return DEFAULT_SUMMARY_MAX_LEN;
984
+ }
985
+
654
986
  /**
655
987
  * Render the narrow-match block to an array of stderr lines. Returns []
656
988
  * when there is nothing to render (empty entries set). Callers stay silent
@@ -670,7 +1002,7 @@ function formatEntryLine(entry) {
670
1002
  * ...
671
1003
  * (如需重读 broad 决策,调 fab_plan_context 或 fabric plan-context-hint --all)
672
1004
  */
673
- function renderSummary(payload) {
1005
+ function renderSummary(payload, maxLen) {
674
1006
  if (!payload || payload.version !== 2) {
675
1007
  if (payload && payload.version !== undefined) {
676
1008
  // breadcrumb only if payload exists but version mismatches (avoid
@@ -693,7 +1025,7 @@ function renderSummary(payload) {
693
1025
  `[fabric] ${entries.length} narrow-scoped knowledge entries match your edit targets:`,
694
1026
  ];
695
1027
  for (const entry of entries) {
696
- lines.push(formatEntryLine(entry));
1028
+ lines.push(formatEntryLine(entry, maxLen));
697
1029
  }
698
1030
  lines.push(" (如需重读 broad 决策,调 fab_plan_context 或 fabric plan-context-hint --all)");
699
1031
  return lines;
@@ -707,7 +1039,9 @@ function main(env, stdio) {
707
1039
  try {
708
1040
  const cwd = (env && env.cwd) || process.cwd();
709
1041
  const now = (env && env.now) || new Date();
1042
+ const nowMs = now instanceof Date ? now.getTime() : Number(now);
710
1043
  const err = (stdio && stdio.stderr) || process.stderr;
1044
+ const out = (stdio && stdio.stdout) || process.stdout;
711
1045
 
712
1046
  // Parse hook payload. Test seam: env.payload short-circuits stdin so
713
1047
  // unit tests don't need to muck with process.stdin.
@@ -752,6 +1086,24 @@ function main(env, stdio) {
752
1086
  if (!toolName || !EDIT_TOOL_NAMES.has(toolName)) return;
753
1087
  if (paths.length === 0) return;
754
1088
 
1089
+ // v2.0.0-rc.33 W2-5 (P1-8): cooldown gate. When configured > 0, suppress
1090
+ // the hint for that many hours after a successful emit. Counted as
1091
+ // silence so doctor lint #26 sees the suppression. Test seam
1092
+ // env.skipCooldown bypasses for unit tests.
1093
+ const cooldownHours = readNarrowCooldownHours(cwd);
1094
+ if (cooldownHours > 0 && !(env && env.skipCooldown === true)) {
1095
+ const lastEmitMs = readNarrowLastEmit(cwd);
1096
+ if (
1097
+ typeof lastEmitMs === "number" &&
1098
+ nowMs - lastEmitMs < cooldownHours * MS_PER_HOUR
1099
+ ) {
1100
+ if (!(env && env.skipSilenceCounter === true)) {
1101
+ appendHintSilenceCounter(cwd, now);
1102
+ }
1103
+ return;
1104
+ }
1105
+ }
1106
+
755
1107
  // Test seam: env.cliResult short-circuits the CLI spawn so unit tests
756
1108
  // can feed canned plan-context-hint JSON without a built CLI binary.
757
1109
  const cliPayload =
@@ -774,7 +1126,18 @@ function main(env, stdio) {
774
1126
  // / malformed item) we treat it as broad and skip — pre-rc.27 entries
775
1127
  // without the field are exactly the broad-leak surface §2.5 calls out.
776
1128
  const allEntries = Array.isArray(cliPayload.entries) ? cliPayload.entries : [];
777
- const narrow = allEntries.filter((entry) => entry && entry.relevance_scope === "narrow");
1129
+ const narrowFiltered = allEntries.filter((entry) => entry && entry.relevance_scope === "narrow");
1130
+
1131
+ // v2.0.0-rc.33 W2-1 (P0-9): apply TopK slice to narrow set BEFORE the
1132
+ // emit-gate / dedup-window cascade. The server-side ranking already
1133
+ // produced a sensible order, so slicing here bounds the per-Edit hint
1134
+ // surface area to `hint_narrow_top_k` (default 5) so the agent's working
1135
+ // memory isn't displaced by an unwieldy banner.
1136
+ const topK = readNarrowTopK(cwd);
1137
+ const narrow = narrowFiltered.length > topK
1138
+ ? narrowFiltered.slice(0, topK)
1139
+ : narrowFiltered;
1140
+
778
1141
  if (narrow.length === 0) {
779
1142
  // rc.6 TASK-023 (E6): silence-counter — matched-narrow == 0. The CLI
780
1143
  // had a chance to match against the extracted paths but came back
@@ -823,6 +1186,39 @@ function main(env, stdio) {
823
1186
  return;
824
1187
  }
825
1188
 
1189
+ // v2.0.0-rc.33 W2-2 (P0-9): per-file dedup window. The E3 session-hints
1190
+ // cache covers per-session dedupe; this layer adds workspace-level "same
1191
+ // (file, entry) not within last N turns" suppression so a hot file's
1192
+ // identical hints don't train the agent to ignore them. Counted as
1193
+ // silence on full filter-out so doctor lint #26 visibility is preserved.
1194
+ const windowTurns = readNarrowDedupWindowTurns(cwd);
1195
+ const windowState =
1196
+ env && env.dedupWindowSeed !== undefined
1197
+ ? env.dedupWindowSeed
1198
+ : readNarrowDedupWindow(cwd);
1199
+ const dedupDecision = applyNarrowDedupWindow(
1200
+ windowState,
1201
+ gateDecision.narrow,
1202
+ paths,
1203
+ windowTurns,
1204
+ currentRevisionHash,
1205
+ );
1206
+ if (dedupDecision.filtered.length === 0) {
1207
+ // v2.0.0-rc.33 W4 review-fix (gemini Critical-1): persist the counter
1208
+ // BEFORE returning so the turn-window check still advances on suppressed
1209
+ // fires. Skipping the write here caused dedup state to permanently stick
1210
+ // — every subsequent fire would read the old counter, see at_turn within
1211
+ // the window, and keep suppressing. Now: counter ticks on every fire,
1212
+ // window-naturally expires after `windowTurns` PreToolUse events.
1213
+ if (!(env && env.skipCacheWrite === true)) {
1214
+ writeNarrowDedupWindow(cwd, dedupDecision.nextState);
1215
+ }
1216
+ if (!(env && env.skipSilenceCounter === true)) {
1217
+ appendHintSilenceCounter(cwd, now);
1218
+ }
1219
+ return;
1220
+ }
1221
+
826
1222
  // Persist the cache BEFORE rendering. If the render itself throws (e.g.
827
1223
  // stderr write errors), the cache update still reflects the intent —
828
1224
  // the alternative (post-render write) could leave us in a state where
@@ -837,13 +1233,50 @@ function main(env, stdio) {
837
1233
  ...gateDecision.cache,
838
1234
  session_id: sessionId,
839
1235
  });
1236
+ writeNarrowDedupWindow(cwd, dedupDecision.nextState);
840
1237
  }
841
1238
 
842
- const lines = renderSummary({ ...cliPayload, entries: gateDecision.narrow });
1239
+ const summaryMaxLen = readSummaryMaxLen(cwd);
1240
+ const lines = renderSummary({ ...cliPayload, entries: dedupDecision.filtered }, summaryMaxLen);
843
1241
  if (lines.length === 0) return;
1242
+
1243
+ // Stderr: human-facing breadcrumb + legacy contract.
844
1244
  for (const line of lines) {
845
1245
  err.write(`${line}\n`);
846
1246
  }
1247
+
1248
+ // v2.0.0-rc.33 W2-6 (P0-7): stdout JSON envelope. When
1249
+ // hint_reminder_to_context is true (default), serialize the same banner
1250
+ // body as Claude Code's PreToolUse hookSpecificOutput shape so the model
1251
+ // receives the reminder IN-CONTEXT (rc.32 baseline cite-coverage 3.1%
1252
+ // root cause: reminders never entered model context). PreToolUse hook
1253
+ // contract: stdout JSON with hookSpecificOutput.additionalContext is
1254
+ // injected into the model's context window; the hook DOES NOT block the
1255
+ // edit (additionalContext is informational, not a permissionDecision).
1256
+ // v2.0.0-rc.33 W4 review-fix (gemini High-1): CC-specific stdout envelope.
1257
+ // See knowledge-hint-broad.cjs companion for rationale — CLAUDE_PROJECT_DIR
1258
+ // is the CC presence signal; Codex CLI / Cursor don't set it.
1259
+ const _isClaudeCode =
1260
+ typeof process.env.CLAUDE_PROJECT_DIR === "string" &&
1261
+ process.env.CLAUDE_PROJECT_DIR.length > 0;
1262
+ if (!(env && env.skipStdout === true) && _isClaudeCode && readReminderToContext(cwd)) {
1263
+ try {
1264
+ const envelope = {
1265
+ hookSpecificOutput: {
1266
+ hookEventName: "PreToolUse",
1267
+ additionalContext: lines.join("\n"),
1268
+ },
1269
+ };
1270
+ out.write(`${JSON.stringify(envelope)}\n`);
1271
+ } catch {
1272
+ // Best-effort — stderr is the durable contract.
1273
+ }
1274
+ }
1275
+
1276
+ // v2.0.0-rc.33 W2-5: record successful emit for cooldown gate.
1277
+ if (cooldownHours > 0 && !(env && env.skipCooldownWrite === true)) {
1278
+ writeNarrowLastEmit(cwd, nowMs);
1279
+ }
847
1280
  } catch {
848
1281
  // Silent — never block edits on hook failure.
849
1282
  }
@@ -871,9 +1304,21 @@ module.exports = {
871
1304
  writeSessionHintsCache,
872
1305
  computeIndexHash,
873
1306
  applyEmitGate,
1307
+ // v2.0.0-rc.33 W2-1 / W2-2 / W2-5 / W2-6 — exports for unit tests.
1308
+ readNarrowTopK,
1309
+ readNarrowDedupWindowTurns,
1310
+ readNarrowCooldownHours,
1311
+ readReminderToContext,
1312
+ readNarrowLastEmit,
1313
+ writeNarrowLastEmit,
1314
+ readNarrowDedupWindow,
1315
+ writeNarrowDedupWindow,
1316
+ applyNarrowDedupWindow,
1317
+ readSummaryMaxLen,
874
1318
  CONSTANTS: {
875
1319
  CLI_TIMEOUT_MS,
876
- SUMMARY_MAX_LEN,
1320
+ SUMMARY_MAX_LEN: DEFAULT_SUMMARY_MAX_LEN,
1321
+ DEFAULT_SUMMARY_MAX_LEN,
877
1322
  EDIT_COUNTER_DIR_REL,
878
1323
  EDIT_COUNTER_FILE,
879
1324
  HINT_SILENCE_COUNTER_DIR_REL,
@@ -882,6 +1327,13 @@ module.exports = {
882
1327
  SESSION_HINTS_DIR_REL,
883
1328
  SESSION_HINTS_FILE_PREFIX,
884
1329
  SESSION_HINTS_FILE_SUFFIX,
1330
+ DEFAULT_HINT_NARROW_TOP_K,
1331
+ DEFAULT_HINT_NARROW_DEDUP_WINDOW_TURNS,
1332
+ DEFAULT_HINT_NARROW_COOLDOWN_HOURS,
1333
+ DEFAULT_HINT_REMINDER_TO_CONTEXT,
1334
+ NARROW_DEDUP_WINDOW_FILE,
1335
+ NARROW_DEDUP_RING_CAP,
1336
+ HINT_NARROW_LAST_EMIT_FILE,
885
1337
  },
886
1338
  };
887
1339