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