@fenglimg/fabric-cli 2.0.0-rc.30 → 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.
- package/dist/{doctor-TTDTKOFJ.js → doctor-E26YO67D.js} +8 -2
- package/dist/index.js +3 -3
- package/dist/{install-OEBNSCS5.js → install-YSFVNY3T.js} +1 -1
- package/package.json +3 -3
- package/templates/hooks/knowledge-hint-broad.cjs +268 -21
- package/templates/hooks/knowledge-hint-narrow.cjs +466 -14
- package/templates/skills/fabric-archive/SKILL.md +144 -738
- package/templates/skills/fabric-archive/ref/e5-cron-recap.md +5 -5
- package/templates/skills/fabric-archive/ref/i18n-policy.md +3 -3
- package/templates/skills/fabric-archive/ref/phase-0-range-resolution.md +156 -0
- package/templates/skills/fabric-archive/ref/{phase-0-4-onboard.md → phase-1-5-onboard.md} +21 -21
- package/templates/skills/fabric-archive/ref/phase-1-cross-session.md +60 -0
- package/templates/skills/fabric-archive/ref/phase-2-5-viability.md +54 -0
- package/templates/skills/fabric-archive/ref/phase-3-5-scope.md +80 -0
- package/templates/skills/fabric-archive/ref/phase-3-classify.md +63 -0
- package/templates/skills/fabric-archive/ref/phase-4-5-emit.md +78 -0
- package/templates/skills/fabric-archive/ref/phase-4-mcp-persist.md +89 -0
- package/templates/skills/fabric-archive/ref/rc-history.md +6 -6
- package/templates/skills/fabric-archive/ref/worked-examples.md +1 -1
- package/templates/skills/fabric-import/SKILL.md +29 -556
- package/templates/skills/fabric-import/ref/checkpoint-state.md +85 -0
- package/templates/skills/fabric-import/ref/output-contract.md +61 -0
- package/templates/skills/fabric-import/ref/phase-2-mining.md +213 -0
- package/templates/skills/fabric-import/ref/phase-3-dedup.md +75 -0
- package/templates/skills/fabric-import/ref/worked-examples.md +127 -0
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
642
|
-
|
|
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
|
|
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
|
|
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
|
|