@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.
- package/LICENSE +21 -0
- package/README.md +6 -5
- package/dist/chunk-BATF4PEJ.js +361 -0
- package/dist/{chunk-OBQU6NHO.js → chunk-COI5VDFU.js} +0 -18
- package/dist/chunk-D25XJ4BC.js +880 -0
- package/dist/chunk-MF3OTILQ.js +544 -0
- package/dist/chunk-PWLW3B57.js +18 -0
- package/dist/config-XJIPZNUP.js +13 -0
- package/dist/doctor-EJDSEJSS.js +810 -0
- package/dist/index.js +15 -8
- package/dist/{init-BIRSIOXO.js → install-EKWMFLUU.js} +622 -711
- package/dist/metrics-ACEQFPDU.js +122 -0
- package/dist/onboard-coverage-MFCAEBDO.js +220 -0
- package/dist/{plan-context-hint-QMUPAXIB.js → plan-context-hint-FC6P3WFE.js} +34 -28
- package/dist/uninstall-MH7ZIB6M.js +1064 -0
- package/package.json +30 -5
- package/templates/hooks/cite-policy-evict.cjs +231 -0
- package/templates/hooks/configs/README.md +29 -6
- package/templates/hooks/configs/claude-code.json +14 -3
- package/templates/hooks/configs/codex-hooks.json +6 -3
- package/templates/hooks/configs/cursor-hooks.json +8 -10
- package/templates/hooks/fabric-hint.cjs +833 -105
- package/templates/hooks/knowledge-hint-broad.cjs +509 -135
- package/templates/hooks/knowledge-hint-narrow.cjs +791 -26
- package/templates/hooks/lib/banner-i18n.cjs +309 -0
- package/templates/hooks/lib/cite-contract-reminder.cjs +173 -0
- package/templates/hooks/lib/cite-line-parser.cjs +158 -0
- package/templates/hooks/lib/client-adapter.cjs +106 -0
- package/templates/hooks/lib/config-cache.cjs +107 -0
- package/templates/hooks/lib/state-store.cjs +84 -0
- package/templates/hooks/lib/summary-fallback.cjs +210 -0
- package/templates/skills/fabric-archive/SKILL.md +93 -419
- package/templates/skills/fabric-archive/ref/dry-run-scope.md +16 -0
- package/templates/skills/fabric-archive/ref/e5-cron-recap.md +58 -0
- package/templates/skills/fabric-archive/ref/i18n-policy.md +86 -0
- package/templates/skills/fabric-archive/ref/phase-0-range-resolution.md +156 -0
- package/templates/skills/fabric-archive/ref/phase-1-5-onboard.md +218 -0
- package/templates/skills/fabric-archive/ref/phase-1-cross-session.md +62 -0
- package/templates/skills/fabric-archive/ref/phase-2-5-viability.md +68 -0
- package/templates/skills/fabric-archive/ref/phase-3-5-scope.md +108 -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 +38 -0
- package/templates/skills/fabric-archive/ref/worked-examples.md +78 -0
- package/templates/skills/fabric-import/SKILL.md +75 -516
- package/templates/skills/fabric-import/ref/checkpoint-state.md +85 -0
- package/templates/skills/fabric-import/ref/i18n-policy.md +79 -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/state-recovery.md +57 -0
- package/templates/skills/fabric-import/ref/worked-examples.md +127 -0
- package/templates/skills/fabric-review/SKILL.md +86 -284
- package/templates/skills/fabric-review/ref/askuserquestion-policy.md +66 -0
- package/templates/skills/fabric-review/ref/i18n-policy.md +111 -0
- package/templates/skills/fabric-review/ref/modify-flow.md +103 -0
- package/templates/skills/fabric-review/ref/output-contract.md +58 -0
- package/templates/skills/fabric-review/ref/per-mode-flows.md +155 -0
- package/templates/skills/fabric-review/ref/semantic-check.md +26 -0
- package/templates/skills/fabric-review/ref/worked-examples.md +95 -0
- package/templates/skills/lib/shared-policy.md +69 -0
- package/dist/chunk-6ICJICVU.js +0 -10
- package/dist/chunk-74SZWYPH.js +0 -658
- package/dist/chunk-EYIDD2YS.js +0 -1000
- package/dist/doctor-T7JWODKG.js +0 -282
- package/dist/hooks-Y74Y5LQS.js +0 -12
- package/dist/scan-LMK3UCWL.js +0 -22
- package/dist/serve-H554BHLG.js +0 -124
- package/templates/agents-md/AGENTS.md.template +0 -59
- package/templates/bootstrap/CLAUDE.md +0 -8
- package/templates/bootstrap/codex-AGENTS-header.md +0 -6
- 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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
604
|
-
|
|
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
|
|
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
|
-
|
|
629
|
-
|
|
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] ${
|
|
1199
|
+
`[fabric] ${entries.length} narrow-scoped knowledge entries match your edit targets:`,
|
|
633
1200
|
];
|
|
634
|
-
for (const entry of
|
|
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
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|