@fenglimg/fabric-cli 2.0.0-rc.1 → 2.0.0-rc.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +6 -6
  2. package/dist/{chunk-UHNP7T7W.js → chunk-5MQ52F42.js} +347 -86
  3. package/dist/chunk-6ICJICVU.js +10 -0
  4. package/dist/chunk-AW3G7ZH5.js +576 -0
  5. package/dist/chunk-HQLEHH4O.js +321 -0
  6. package/dist/{chunk-5LOYBXWD.js → chunk-OBQU6NHO.js} +2 -52
  7. package/dist/chunk-WPTA74BY.js +184 -0
  8. package/dist/chunk-WWNXR34K.js +49 -0
  9. package/dist/doctor-RILCO5OG.js +282 -0
  10. package/dist/hooks-NX32PPEN.js +13 -0
  11. package/dist/index.js +8 -5
  12. package/dist/{init-DRHUYHYA.js → init-C56PWHID.js} +225 -491
  13. package/dist/plan-context-hint-QMUPAXIB.js +98 -0
  14. package/dist/{scan-HU2EGITF.js → scan-66EKMNAY.js} +6 -2
  15. package/dist/{serve-3LXXSBFR.js → serve-NGLXHDYC.js} +8 -4
  16. package/dist/uninstall-DBAR2JBS.js +1082 -0
  17. package/package.json +3 -3
  18. package/templates/bootstrap/CLAUDE.md +1 -1
  19. package/templates/bootstrap/codex-AGENTS-header.md +1 -1
  20. package/templates/bootstrap/cursor-fabric-bootstrap.mdc +1 -1
  21. package/templates/hooks/configs/README.md +73 -0
  22. package/templates/hooks/configs/claude-code.json +37 -0
  23. package/templates/hooks/configs/codex-hooks.json +20 -0
  24. package/templates/hooks/configs/cursor-hooks.json +20 -0
  25. package/templates/hooks/fabric-hint.cjs +1337 -0
  26. package/templates/hooks/knowledge-hint-broad.cjs +612 -0
  27. package/templates/hooks/knowledge-hint-narrow.cjs +826 -0
  28. package/templates/hooks/lib/session-digest-writer.cjs +172 -0
  29. package/templates/skills/fabric-archive/SKILL.md +640 -0
  30. package/templates/skills/fabric-import/SKILL.md +850 -0
  31. package/templates/skills/fabric-review/SKILL.md +717 -0
  32. package/dist/doctor-DUHWLAYD.js +0 -98
@@ -0,0 +1,826 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * rc.6 TASK-020 (E2 + E4) — PreToolUse narrow-injection hook + edit-counter sidecar.
4
+ * rc.6 TASK-021 (E3) — Session-hints cache emit gate (extends TASK-020).
5
+ * rc.6 TASK-023 (E6) — Hint-silence-counter telemetry (companion to E4).
6
+ *
7
+ * Three coupled responsibilities behind a single PreToolUse trigger
8
+ * (Edit / Write / MultiEdit):
9
+ *
10
+ * E2 — Narrow knowledge hint
11
+ * Read the tool_input payload, extract the file path(s) the user is
12
+ * about to edit, dedupe within the request, then invoke
13
+ * `fabric plan-context-hint --paths p1,p2,...` and render any matching
14
+ * narrow-scoped knowledge entries to stderr so the Agent sees relevant
15
+ * decisions/pitfalls/guidelines *before* the edit lands.
16
+ *
17
+ * Output contract (stderr only) when narrow.length > 0:
18
+ * [fabric] N narrow-scoped knowledge entries match your edit targets:
19
+ * [<id>] (<type>/<maturity>) <summary-line>
20
+ * [<id>] (<type>/<maturity>) <summary-line>
21
+ * ...
22
+ * (如需重读 broad 决策,调 fab_plan_context 或 fabric plan-context-hint --all)
23
+ *
24
+ * When narrow.length === 0: complete silence (exit 0, no stderr).
25
+ *
26
+ * E3 — Session-hints cache (per-session dedupe)
27
+ * Read `.fabric/.cache/session-hints-{session_id}.json` BEFORE rendering.
28
+ * Cache shape:
29
+ * { session_id, revision_hash, hinted_paths: string[],
30
+ * hinted_stable_ids: string[], last_emitted_index_hash: string }
31
+ *
32
+ * Emit-gate decision (in order):
33
+ * 1. If cache.revision_hash !== current revision_hash → drop cache
34
+ * wholesale (treat as fresh; re-emit allowed).
35
+ * 2. Compute current_index_hash = sha256(JSON.stringify(narrow ids));
36
+ * if it equals cache.last_emitted_index_hash → SKIP emit (silent).
37
+ * 3. Filter narrow entries: drop any whose stable_id is already in
38
+ * cache.hinted_stable_ids. Also drop the request entirely if every
39
+ * target path is already in cache.hinted_paths.
40
+ * 4. If filtered narrow set is non-empty → emit + update cache (append
41
+ * new paths + stable_ids, set last_emitted_index_hash).
42
+ *
43
+ * session_id resolution: payload.session_id → env FABRIC_SESSION_ID →
44
+ * synthetic per-process UUID (degenerates to process-lifetime dedupe).
45
+ * Cache files are per-session; concurrent sessions never collide.
46
+ *
47
+ * E4 — Edit-counter sidecar
48
+ * Unconditionally append one ISO-8601 timestamp line to
49
+ * `.fabric/.cache/edit-counter` per PreToolUse fire. This sidecar is
50
+ * consumed by TASK-022 (rc.6 E5) to upgrade Signal A from
51
+ * "hours-since-last-knowledge_proposed" to "edits-since-last-archive".
52
+ *
53
+ * Runs BEFORE the CLI invocation so a CLI failure does not lose the
54
+ * counter signal. One line per fire, regardless of how many paths the
55
+ * request touched (the timestamp is per-invocation, not per-path).
56
+ *
57
+ * Stdout is intentionally empty. PreToolUse hooks may pollute stdout to
58
+ * signal `decision:block`, but this hook is informational only — it never
59
+ * blocks tool execution.
60
+ *
61
+ * Failure invariant: any error path (spawn failure, ENOENT, timeout,
62
+ * JSON.parse throw, sidecar/cache write failure) MUST end in silent exit 0.
63
+ * The hook never blocks Edit/Write/MultiEdit on its own malfunction.
64
+ */
65
+
66
+ const { spawnSync } = require("node:child_process");
67
+ const { createHash, randomUUID } = require("node:crypto");
68
+ const {
69
+ appendFileSync,
70
+ existsSync,
71
+ mkdirSync,
72
+ readFileSync,
73
+ renameSync,
74
+ writeFileSync,
75
+ } = require("node:fs");
76
+ const { dirname, join } = require("node:path");
77
+
78
+ // -----------------------------------------------------------------------------
79
+ // CONSTANTS
80
+ // -----------------------------------------------------------------------------
81
+
82
+ // `fabric plan-context-hint` is a thin wrapper over planContext(); on a
83
+ // well-seeded repo it returns in ~100ms. Two-second cap mirrors
84
+ // knowledge-hint-broad.cjs — any pathological hang must not stall edits.
85
+ const CLI_TIMEOUT_MS = 2000;
86
+
87
+ // Maximum summary length per entry. Bounds each stderr line so a sloppy
88
+ // pending entry can't blow up terminal width. Truncation appends an ellipsis.
89
+ const SUMMARY_MAX_LEN = 80;
90
+
91
+ // Edit-counter sidecar — workspace-relative path. Process-local file; no
92
+ // network. TASK-022 will read this back to compute edits-since-archive.
93
+ const EDIT_COUNTER_DIR_REL = join(".fabric", ".cache");
94
+ const EDIT_COUNTER_FILE = "edit-counter";
95
+
96
+ // rc.6 TASK-023 (E6): hint-silence-counter sidecar — companion to the
97
+ // edit-counter above. Where edit-counter records every PreToolUse fire
98
+ // (numerator-agnostic), the silence-counter records only those fires that
99
+ // produced no narrow stderr emission (matched-narrow == 0 OR emit-gate
100
+ // returned render=false). Doctor lint #26 reads both files to derive a
101
+ // silence rate over a 30d window; a sustained >95% rate is a usage-pattern
102
+ // signal that narrow scope has drifted from where edits actually happen.
103
+ //
104
+ // Lives in the same .fabric/.cache/ directory so a single doctor cleanup
105
+ // pass can reason about both files together.
106
+ const HINT_SILENCE_COUNTER_DIR_REL = join(".fabric", ".cache");
107
+ const HINT_SILENCE_COUNTER_FILE = "hint-silence-counter";
108
+
109
+ // rc.6 TASK-021 (E3): session-hints cache lives alongside the edit-counter
110
+ // in .fabric/.cache/. One file per session, named session-hints-{id}.json.
111
+ // File-name prefix is referenced by the doctor lint #27 cleanup pass that
112
+ // deletes files with mtime older than 7 days.
113
+ const SESSION_HINTS_DIR_REL = join(".fabric", ".cache");
114
+ const SESSION_HINTS_FILE_PREFIX = "session-hints-";
115
+ const SESSION_HINTS_FILE_SUFFIX = ".json";
116
+
117
+ // Synthetic session id used when neither payload.session_id nor
118
+ // FABRIC_SESSION_ID is available. Generated once per process so a single
119
+ // hook invocation lifetime acts like a degenerate session (dedupes within
120
+ // the process; degrades back to per-fire renders on next spawn). This is
121
+ // the documented fallback chain — clients that want robust dedupe should
122
+ // pass session_id through the hook payload.
123
+ let SYNTHETIC_SESSION_ID = null;
124
+
125
+ // Tool names that trigger the narrow-injection branch. PreToolUse fires on
126
+ // many tool names across clients; we only react to file-edit tools.
127
+ const EDIT_TOOL_NAMES = new Set(["Edit", "Write", "MultiEdit"]);
128
+
129
+ // -----------------------------------------------------------------------------
130
+ // Payload parsing
131
+ // -----------------------------------------------------------------------------
132
+
133
+ /**
134
+ * Read stdin (or a test-supplied raw string) as JSON. Returns null on any
135
+ * parse failure — the hook stays silent rather than crashing the edit.
136
+ */
137
+ function readPayload(rawStdin) {
138
+ if (typeof rawStdin !== "string" || rawStdin.length === 0) return null;
139
+ try {
140
+ const parsed = JSON.parse(rawStdin);
141
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
142
+ return null;
143
+ }
144
+ return parsed;
145
+ } catch {
146
+ return null;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Extract the tool name from a hook payload. Clients differ in casing /
152
+ * field placement; we probe the conventional shapes:
153
+ * - Claude Code: { tool_name, tool_input: { ... } }
154
+ * - Codex CLI: { tool_name, tool_input: { ... } } (mirrors Claude)
155
+ * - Cursor: { tool, input: { ... } } (legacy variant)
156
+ * Returns null when no recognizable shape is present.
157
+ */
158
+ function extractToolName(payload) {
159
+ if (!payload || typeof payload !== "object") return null;
160
+ if (typeof payload.tool_name === "string") return payload.tool_name;
161
+ if (typeof payload.tool === "string") return payload.tool;
162
+ return null;
163
+ }
164
+
165
+ /**
166
+ * Extract the tool_input object from a hook payload, accepting both the
167
+ * `tool_input` (Claude/Codex) and `input` (Cursor) conventions.
168
+ */
169
+ function extractToolInput(payload) {
170
+ if (!payload || typeof payload !== "object") return null;
171
+ if (payload.tool_input && typeof payload.tool_input === "object") {
172
+ return payload.tool_input;
173
+ }
174
+ if (payload.input && typeof payload.input === "object") {
175
+ return payload.input;
176
+ }
177
+ return null;
178
+ }
179
+
180
+ /**
181
+ * Pull file paths out of a tool_input object. Handles three shapes:
182
+ * - single Edit/Write: { file_path: "src/foo.ts", ... }
183
+ * - bulk variant: { file_paths: ["src/foo.ts", "src/bar.ts"] }
184
+ * - MultiEdit: { file_path: "...", edits: [{file_path?, ...}, ...] }
185
+ * (Claude Code's MultiEdit currently issues per-edit operations against
186
+ * a single `file_path`; older drafts and Cursor's variant carried
187
+ * per-edit `file_path`. We accept both to be defensive.)
188
+ *
189
+ * Returns a deduped array of strings — empty when no path is recognizable.
190
+ * Order: first occurrence wins (stable across re-renders of the same payload).
191
+ */
192
+ function extractPaths(toolInput) {
193
+ if (!toolInput || typeof toolInput !== "object") return [];
194
+ const collected = [];
195
+
196
+ // Shape 1: scalar file_path
197
+ if (typeof toolInput.file_path === "string" && toolInput.file_path.length > 0) {
198
+ collected.push(toolInput.file_path);
199
+ }
200
+
201
+ // Shape 2: array file_paths
202
+ if (Array.isArray(toolInput.file_paths)) {
203
+ for (const p of toolInput.file_paths) {
204
+ if (typeof p === "string" && p.length > 0) collected.push(p);
205
+ }
206
+ }
207
+
208
+ // Shape 3: MultiEdit edits[] — each entry may carry its own file_path
209
+ if (Array.isArray(toolInput.edits)) {
210
+ for (const edit of toolInput.edits) {
211
+ if (
212
+ edit &&
213
+ typeof edit === "object" &&
214
+ typeof edit.file_path === "string" &&
215
+ edit.file_path.length > 0
216
+ ) {
217
+ collected.push(edit.file_path);
218
+ }
219
+ }
220
+ }
221
+
222
+ // Dedupe preserving first-occurrence order.
223
+ const seen = new Set();
224
+ const out = [];
225
+ for (const p of collected) {
226
+ if (seen.has(p)) continue;
227
+ seen.add(p);
228
+ out.push(p);
229
+ }
230
+ return out;
231
+ }
232
+
233
+ // -----------------------------------------------------------------------------
234
+ // Edit-counter sidecar (E4)
235
+ // -----------------------------------------------------------------------------
236
+
237
+ /**
238
+ * Append a single line to .fabric/.cache/edit-counter recording a PreToolUse
239
+ * fire. Creates the directory if missing. Best-effort: any write failure is
240
+ * swallowed so a read-only .fabric/ never blocks the edit.
241
+ *
242
+ * Per TASK-020 convergence: ONE LINE per PreToolUse fire, regardless of how
243
+ * many paths the request touched (the timestamp is per-invocation, not
244
+ * per-path). TASK-022 (rc.6 E5) counts fires, not paths.
245
+ *
246
+ * rc.7 T4 upgrade — the line is now a JSON object:
247
+ * {"ts":"<ISO-8601>","paths":["a/b/c.ts","d/e.ts"]}
248
+ * so the Stop hook can derive a "top edited directories" activity overview
249
+ * for the 人-first reminder banner (Signal A).
250
+ *
251
+ * Back-compat:
252
+ * - countEditsSince() reads each line by extracting the first ISO-8601
253
+ * substring it sees (works on both JSON-line and legacy plain-ISO files).
254
+ * - Existing sidecars from rc.6 (plain ISO per line) continue to count
255
+ * correctly; the activity-overview helper simply skips lines with no
256
+ * `paths` array.
257
+ * - When the caller cannot supply paths (e.g. unrecognized tool, payload
258
+ * parse failure) we still write the JSON line with an empty `paths`
259
+ * array. The fire-count signal is preserved; the activity overview
260
+ * just contributes nothing from those lines.
261
+ */
262
+ function appendEditCounter(projectRoot, now, paths) {
263
+ try {
264
+ const dir = join(projectRoot, EDIT_COUNTER_DIR_REL);
265
+ const file = join(dir, EDIT_COUNTER_FILE);
266
+ if (!existsSync(dir)) {
267
+ mkdirSync(dir, { recursive: true });
268
+ }
269
+ const iso = now instanceof Date ? now.toISOString() : new Date(now).toISOString();
270
+ const pathList = Array.isArray(paths)
271
+ ? paths.filter((p) => typeof p === "string" && p.length > 0)
272
+ : [];
273
+ const line = JSON.stringify({ ts: iso, paths: pathList });
274
+ appendFileSync(file, `${line}\n`, "utf8");
275
+ } catch {
276
+ // Silent — sidecar failure must never block the edit.
277
+ }
278
+ }
279
+
280
+ /**
281
+ * rc.6 TASK-023 (E6): append one ISO-8601 timestamp line to
282
+ * `.fabric/.cache/hint-silence-counter`. Called from main() on every silent
283
+ * fire path — i.e. when the hook completes without emitting any narrow
284
+ * stderr lines. This includes:
285
+ *
286
+ * - matched-narrow == 0 (CLI returned an empty narrow set)
287
+ * - emit-gate render === false (session-hints dedupe filtered everything
288
+ * out)
289
+ *
290
+ * Together with appendEditCounter (E4), this lets doctor lint #26 compute a
291
+ * silence rate: silence_count / total_fires over a rolling window. The
292
+ * write semantics mirror appendEditCounter exactly — single timestamp line
293
+ * per silent fire, best-effort (failures swallowed), directory created if
294
+ * missing.
295
+ */
296
+ function appendHintSilenceCounter(projectRoot, now) {
297
+ try {
298
+ const dir = join(projectRoot, HINT_SILENCE_COUNTER_DIR_REL);
299
+ const file = join(dir, HINT_SILENCE_COUNTER_FILE);
300
+ if (!existsSync(dir)) {
301
+ mkdirSync(dir, { recursive: true });
302
+ }
303
+ const iso = now instanceof Date ? now.toISOString() : new Date(now).toISOString();
304
+ appendFileSync(file, `${iso}\n`, "utf8");
305
+ } catch {
306
+ // Silent — sidecar failure must never block the edit.
307
+ }
308
+ }
309
+
310
+ // -----------------------------------------------------------------------------
311
+ // Session-hints cache (E3) — per-session emit-gate
312
+ // -----------------------------------------------------------------------------
313
+
314
+ /**
315
+ * Resolve the session id used to key the cache file. Priority:
316
+ * 1. payload.session_id (string, non-empty) — preferred; threads through
317
+ * from the client hook payload (Claude Code / Codex CLI / Cursor).
318
+ * 2. process.env.FABRIC_SESSION_ID — environment fallback.
319
+ * 3. SYNTHETIC_SESSION_ID — a process-lifetime UUID, generated lazily so
320
+ * tests can stub it (see resetSyntheticSessionId).
321
+ *
322
+ * The synthetic id keeps the emit-gate honest even when no upstream id is
323
+ * available: a single hook spawn won't re-render the same hint twice within
324
+ * its process lifetime, but a fresh spawn starts a new "session" — which is
325
+ * the documented degradation for clients that don't pass session_id.
326
+ */
327
+ function resolveSessionId(payload, env) {
328
+ if (payload && typeof payload === "object") {
329
+ const fromPayload = payload.session_id;
330
+ if (typeof fromPayload === "string" && fromPayload.length > 0) {
331
+ return fromPayload;
332
+ }
333
+ }
334
+ const envBag = (env && env.processEnv) || process.env;
335
+ const fromEnv = envBag && envBag.FABRIC_SESSION_ID;
336
+ if (typeof fromEnv === "string" && fromEnv.length > 0) {
337
+ return fromEnv;
338
+ }
339
+ if (SYNTHETIC_SESSION_ID === null) {
340
+ try {
341
+ SYNTHETIC_SESSION_ID = randomUUID();
342
+ } catch {
343
+ // randomUUID is available on Node >= 14.17 / 16; if it ever throws,
344
+ // fall back to a coarse pid/time stamp so the cache still keys on
345
+ // something stable for the process lifetime.
346
+ SYNTHETIC_SESSION_ID = `pid-${process.pid}-${Date.now()}`;
347
+ }
348
+ }
349
+ return SYNTHETIC_SESSION_ID;
350
+ }
351
+
352
+ /**
353
+ * Test seam: reset the synthetic session id cache. Lets unit tests verify
354
+ * the fallback chain independently per case.
355
+ */
356
+ function resetSyntheticSessionId() {
357
+ SYNTHETIC_SESSION_ID = null;
358
+ }
359
+
360
+ /**
361
+ * Compute the absolute path to a session-hints cache file. Exposed as a
362
+ * helper so the doctor cleanup pass and tests share the same naming
363
+ * convention.
364
+ */
365
+ function sessionHintsCachePath(projectRoot, sessionId) {
366
+ return join(
367
+ projectRoot,
368
+ SESSION_HINTS_DIR_REL,
369
+ `${SESSION_HINTS_FILE_PREFIX}${sessionId}${SESSION_HINTS_FILE_SUFFIX}`,
370
+ );
371
+ }
372
+
373
+ /**
374
+ * Load + parse the session-hints cache for `sessionId`. Returns null on
375
+ * any failure (missing file, parse error, shape mismatch). Never throws —
376
+ * cache miss falls through to a fresh emit.
377
+ */
378
+ function readSessionHintsCache(projectRoot, sessionId) {
379
+ try {
380
+ const file = sessionHintsCachePath(projectRoot, sessionId);
381
+ if (!existsSync(file)) return null;
382
+ const raw = readFileSync(file, "utf8");
383
+ if (raw.length === 0) return null;
384
+ const parsed = JSON.parse(raw);
385
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
386
+ return null;
387
+ }
388
+ // Defensive shape coercion: missing fields default to safe empties so
389
+ // downstream code can treat the result as a fully-shaped cache.
390
+ return {
391
+ session_id:
392
+ typeof parsed.session_id === "string" ? parsed.session_id : sessionId,
393
+ revision_hash:
394
+ typeof parsed.revision_hash === "string" ? parsed.revision_hash : "",
395
+ hinted_paths: Array.isArray(parsed.hinted_paths)
396
+ ? parsed.hinted_paths.filter((p) => typeof p === "string" && p.length > 0)
397
+ : [],
398
+ hinted_stable_ids: Array.isArray(parsed.hinted_stable_ids)
399
+ ? parsed.hinted_stable_ids.filter(
400
+ (id) => typeof id === "string" && id.length > 0,
401
+ )
402
+ : [],
403
+ last_emitted_index_hash:
404
+ typeof parsed.last_emitted_index_hash === "string"
405
+ ? parsed.last_emitted_index_hash
406
+ : "",
407
+ };
408
+ } catch {
409
+ return null;
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Atomically write the session-hints cache. Writes to a sibling tmp file
415
+ * and renames into place — keeps observers from reading a half-written
416
+ * JSON document. Silent on any failure (read-only fs, ENOSPC, etc).
417
+ */
418
+ function writeSessionHintsCache(projectRoot, cache) {
419
+ try {
420
+ const dir = join(projectRoot, SESSION_HINTS_DIR_REL);
421
+ if (!existsSync(dir)) {
422
+ mkdirSync(dir, { recursive: true });
423
+ }
424
+ const file = sessionHintsCachePath(projectRoot, cache.session_id);
425
+ const tmp = `${file}.tmp-${process.pid}`;
426
+ writeFileSync(tmp, JSON.stringify(cache, null, 2), "utf8");
427
+ renameSync(tmp, file);
428
+ } catch {
429
+ // Silent — cache write must never block the edit.
430
+ }
431
+ }
432
+
433
+ /**
434
+ * Compute a stable index hash for the narrow set. Sorted stable_ids are
435
+ * concatenated so two calls with the same id set (regardless of CLI output
436
+ * order) hash identically. Returns "" for an empty narrow set so the
437
+ * emit-gate never accidentally short-circuits on empty input (the empty
438
+ * branch is handled earlier in main()).
439
+ */
440
+ function computeIndexHash(narrow) {
441
+ if (!Array.isArray(narrow) || narrow.length === 0) return "";
442
+ const ids = narrow
443
+ .map((entry) => (entry && typeof entry.id === "string" ? entry.id : ""))
444
+ .filter((id) => id.length > 0)
445
+ .slice()
446
+ .sort();
447
+ if (ids.length === 0) return "";
448
+ return createHash("sha256").update(JSON.stringify(ids)).digest("hex");
449
+ }
450
+
451
+ /**
452
+ * Apply the emit-gate. Returns `{ render, narrow, cache }`:
453
+ * render: boolean — true if the caller should render to stderr
454
+ * narrow: NarrowEntry[] — filtered set (drops already-hinted stable_ids)
455
+ * cache: the merged cache object to persist if render === true
456
+ *
457
+ * Semantics (mirror the file header):
458
+ * 1. revision_hash mismatch (or empty cache.revision_hash, or no existing
459
+ * cache) → treat as fresh. Filter is identity on input.
460
+ * 2. revision_hash matches AND every target path is in cache.hinted_paths
461
+ * → skip emit silently (returns render=false).
462
+ * 3. revision_hash matches AND current_index_hash equals
463
+ * cache.last_emitted_index_hash → skip (returns render=false).
464
+ * 4. Otherwise filter narrow by hinted_stable_ids; if the filtered set is
465
+ * empty → skip (render=false). Else render the filtered set.
466
+ *
467
+ * The caller (main) commits the cache via writeSessionHintsCache only when
468
+ * render === true — keeps cache writes coupled to actual stderr emissions.
469
+ */
470
+ function applyEmitGate(cache, narrow, targetPaths, currentRevisionHash) {
471
+ // Branch 1: no cache or stale revision_hash → fresh emit.
472
+ const isFresh =
473
+ cache === null ||
474
+ typeof cache.revision_hash !== "string" ||
475
+ cache.revision_hash.length === 0 ||
476
+ cache.revision_hash !== currentRevisionHash;
477
+
478
+ const currentIndexHash = computeIndexHash(narrow);
479
+
480
+ // Effective cache view for the merge step. Fresh runs start from an empty
481
+ // baseline; non-fresh inherit the prior session's accumulation.
482
+ const baseline = isFresh
483
+ ? {
484
+ session_id: cache && typeof cache.session_id === "string" ? cache.session_id : "",
485
+ revision_hash: currentRevisionHash,
486
+ hinted_paths: [],
487
+ hinted_stable_ids: [],
488
+ last_emitted_index_hash: "",
489
+ }
490
+ : cache;
491
+
492
+ if (!isFresh) {
493
+ // Branch 2: every target path already hinted.
494
+ const allPathsKnown =
495
+ targetPaths.length > 0 &&
496
+ targetPaths.every((p) => baseline.hinted_paths.includes(p));
497
+ if (allPathsKnown) {
498
+ return { render: false, narrow: [], cache: baseline };
499
+ }
500
+
501
+ // Branch 3: index hash matches the last emission verbatim.
502
+ if (
503
+ currentIndexHash.length > 0 &&
504
+ currentIndexHash === baseline.last_emitted_index_hash
505
+ ) {
506
+ return { render: false, narrow: [], cache: baseline };
507
+ }
508
+ }
509
+
510
+ // Branch 4: filter narrow entries whose stable_id is already known.
511
+ const knownIds = new Set(baseline.hinted_stable_ids);
512
+ const filtered = narrow.filter(
513
+ (entry) => !(entry && typeof entry.id === "string" && knownIds.has(entry.id)),
514
+ );
515
+ if (filtered.length === 0) {
516
+ return { render: false, narrow: [], cache: baseline };
517
+ }
518
+
519
+ // Build the to-persist cache. Append new paths + stable_ids, refresh
520
+ // index hash. Use Set-based merge to preserve uniqueness without
521
+ // allocating a Set on every emit.
522
+ const mergedPaths = mergeUnique(baseline.hinted_paths, targetPaths);
523
+ const newIds = filtered
524
+ .map((e) => (e && typeof e.id === "string" ? e.id : ""))
525
+ .filter((id) => id.length > 0);
526
+ const mergedIds = mergeUnique(baseline.hinted_stable_ids, newIds);
527
+
528
+ return {
529
+ render: true,
530
+ narrow: filtered,
531
+ cache: {
532
+ session_id: baseline.session_id,
533
+ revision_hash: currentRevisionHash,
534
+ hinted_paths: mergedPaths,
535
+ hinted_stable_ids: mergedIds,
536
+ last_emitted_index_hash: currentIndexHash,
537
+ },
538
+ };
539
+ }
540
+
541
+ // Order-preserving dedupe merge — extracted because both hinted_paths and
542
+ // hinted_stable_ids share the same merge semantics.
543
+ function mergeUnique(existing, incoming) {
544
+ const seen = new Set(existing);
545
+ const out = existing.slice();
546
+ for (const item of incoming) {
547
+ if (typeof item !== "string" || item.length === 0) continue;
548
+ if (seen.has(item)) continue;
549
+ seen.add(item);
550
+ out.push(item);
551
+ }
552
+ return out;
553
+ }
554
+
555
+ // -----------------------------------------------------------------------------
556
+ // CLI invocation (E2)
557
+ // -----------------------------------------------------------------------------
558
+
559
+ /**
560
+ * Spawn `fabric plan-context-hint --paths p1,p2,...` and return parsed JSON.
561
+ * Returns null on any failure (ENOENT, non-zero exit, malformed JSON,
562
+ * timeout). Never throws.
563
+ *
564
+ * Spawn strategy mirrors knowledge-hint-broad.cjs: try `fabric` first, then
565
+ * `fab`. If neither is on PATH, return null — the hook stays silent.
566
+ */
567
+ function invokePlanContextHint(cwd, paths) {
568
+ if (!Array.isArray(paths) || paths.length === 0) return null;
569
+ const pathsArg = paths.join(",");
570
+ const candidates = ["fabric", "fab"];
571
+ for (const bin of candidates) {
572
+ let res;
573
+ try {
574
+ res = spawnSync(bin, ["plan-context-hint", "--paths", pathsArg], {
575
+ cwd,
576
+ encoding: "utf8",
577
+ timeout: CLI_TIMEOUT_MS,
578
+ stdio: ["ignore", "pipe", "pipe"],
579
+ });
580
+ } catch {
581
+ continue;
582
+ }
583
+ if (res.error || res.status === null || res.status !== 0) continue;
584
+ const raw = (res.stdout || "").trim();
585
+ if (raw.length === 0) continue;
586
+ try {
587
+ const parsed = JSON.parse(raw);
588
+ if (parsed && typeof parsed === "object") return parsed;
589
+ } catch {
590
+ // malformed JSON — try next bin
591
+ }
592
+ }
593
+ return null;
594
+ }
595
+
596
+ // -----------------------------------------------------------------------------
597
+ // Rendering
598
+ // -----------------------------------------------------------------------------
599
+
600
+ function truncateSummary(raw) {
601
+ const s = typeof raw === "string" ? raw : "";
602
+ const flat = s.replace(/\s+/g, " ").trim();
603
+ if (flat.length <= SUMMARY_MAX_LEN) return flat;
604
+ return `${flat.slice(0, SUMMARY_MAX_LEN - 1)}…`;
605
+ }
606
+
607
+ function formatEntryLine(entry) {
608
+ const id = entry.id || "(no-id)";
609
+ const type = entry.type || "unknown";
610
+ const maturity = entry.maturity || "unknown";
611
+ const summary = truncateSummary(entry.summary);
612
+ const tail = summary.length > 0 ? ` ${summary}` : "";
613
+ return ` [${id}] (${type}/${maturity})${tail}`;
614
+ }
615
+
616
+ /**
617
+ * Render the narrow-match block to an array of stderr lines. Returns []
618
+ * when there is nothing to render (empty narrow set). Callers stay silent
619
+ * on empty output.
620
+ *
621
+ * Output shape:
622
+ * [fabric] N narrow-scoped knowledge entries match your edit targets:
623
+ * [<id>] (<type>/<maturity>) <summary>
624
+ * ...
625
+ * (如需重读 broad 决策,调 fab_plan_context 或 fabric plan-context-hint --all)
626
+ */
627
+ function renderSummary(payload) {
628
+ const narrow = Array.isArray(payload && payload.narrow) ? payload.narrow : [];
629
+ if (narrow.length === 0) return [];
630
+
631
+ const lines = [
632
+ `[fabric] ${narrow.length} narrow-scoped knowledge entries match your edit targets:`,
633
+ ];
634
+ for (const entry of narrow) {
635
+ lines.push(formatEntryLine(entry));
636
+ }
637
+ lines.push(" (如需重读 broad 决策,调 fab_plan_context 或 fabric plan-context-hint --all)");
638
+ return lines;
639
+ }
640
+
641
+ // -----------------------------------------------------------------------------
642
+ // Main — invoked as a CLI (require.main === module) and in-process by tests
643
+ // -----------------------------------------------------------------------------
644
+
645
+ function main(env, stdio) {
646
+ try {
647
+ const cwd = (env && env.cwd) || process.cwd();
648
+ const now = (env && env.now) || new Date();
649
+ const err = (stdio && stdio.stderr) || process.stderr;
650
+
651
+ // Parse hook payload. Test seam: env.payload short-circuits stdin so
652
+ // unit tests don't need to muck with process.stdin.
653
+ const payload =
654
+ env && env.payload !== undefined ? env.payload : readPayload(env && env.stdin);
655
+
656
+ // E4 runs UNCONDITIONALLY — append a line even when payload is null or
657
+ // the tool is unrecognized. The counter signal measures hook fires, not
658
+ // successful renders (TASK-022 wants the raw edit-attempt cadence).
659
+ //
660
+ // rc.7 T4: best-effort path extraction is done BEFORE the counter write
661
+ // so the JSON line can carry the touched paths for the Stop hook's
662
+ // 人-first activity-overview banner. Failure to extract paths (null
663
+ // payload, unrecognized tool, etc.) yields an empty paths array — the
664
+ // fire-count signal is preserved.
665
+ //
666
+ // Test seam: env.skipCounter disables the side-effect for tests that
667
+ // want to assert rendering behaviour without touching the filesystem.
668
+ let toolName = null;
669
+ let toolInput = null;
670
+ let paths = [];
671
+ if (payload !== null && payload !== undefined) {
672
+ try {
673
+ toolName = extractToolName(payload);
674
+ if (toolName && EDIT_TOOL_NAMES.has(toolName)) {
675
+ toolInput = extractToolInput(payload);
676
+ paths = extractPaths(toolInput);
677
+ }
678
+ } catch {
679
+ // Defensive — extractors already swallow most failures, but the
680
+ // counter write must not be lost if a future extractor throws.
681
+ toolName = null;
682
+ paths = [];
683
+ }
684
+ }
685
+ if (!(env && env.skipCounter === true)) {
686
+ appendEditCounter(cwd, now, paths);
687
+ }
688
+
689
+ // E2 path is conditional on a recognized tool + extractable paths.
690
+ if (payload === null || payload === undefined) return;
691
+ if (!toolName || !EDIT_TOOL_NAMES.has(toolName)) return;
692
+ if (paths.length === 0) return;
693
+
694
+ // Test seam: env.cliResult short-circuits the CLI spawn so unit tests
695
+ // can feed canned plan-context-hint JSON without a built CLI binary.
696
+ const cliPayload =
697
+ env && env.cliResult !== undefined
698
+ ? env.cliResult
699
+ : invokePlanContextHint(cwd, paths);
700
+ if (cliPayload === null || cliPayload === undefined) return;
701
+
702
+ const narrow = Array.isArray(cliPayload.narrow) ? cliPayload.narrow : [];
703
+ if (narrow.length === 0) {
704
+ // rc.6 TASK-023 (E6): silence-counter — matched-narrow == 0. The CLI
705
+ // had a chance to match against the extracted paths but came back
706
+ // empty. Test seam env.skipSilenceCounter mirrors env.skipCounter.
707
+ if (!(env && env.skipSilenceCounter === true)) {
708
+ appendHintSilenceCounter(cwd, now);
709
+ }
710
+ return;
711
+ }
712
+
713
+ // -------------------------------------------------------------------------
714
+ // E3 emit-gate (TASK-021) — session-hints cache.
715
+ //
716
+ // Sits between the CLI result and the renderSummary() call. The gate
717
+ // decides whether to emit at all (silence on duplicate) and may also
718
+ // narrow the entries we render (skip individual stable_ids that we've
719
+ // already shown earlier in the same session).
720
+ //
721
+ // NOTE for TASK-023 (E6 silence-counter): the "skip emit" branch is
722
+ // the natural anchor for the matched-narrow == 0 silence counter — by
723
+ // the time we reach this comment the CLI has returned a non-empty
724
+ // narrow set, so an "all-skipped" gate decision is equivalent to a
725
+ // matched-narrow == 0 outcome from the user's perspective. TASK-023
726
+ // can add the counter increment either here (before the early return)
727
+ // or inside applyEmitGate when render === false.
728
+ // -------------------------------------------------------------------------
729
+ const sessionId = resolveSessionId(payload, env);
730
+ const currentRevisionHash =
731
+ typeof cliPayload.revision_hash === "string" ? cliPayload.revision_hash : "";
732
+ // Test seam: env.cacheSeed short-circuits the on-disk cache read so unit
733
+ // tests can preload a known cache state without touching the filesystem.
734
+ const cache =
735
+ env && env.cacheSeed !== undefined
736
+ ? env.cacheSeed
737
+ : readSessionHintsCache(cwd, sessionId);
738
+ const gateDecision = applyEmitGate(cache, narrow, paths, currentRevisionHash);
739
+ if (!gateDecision.render) {
740
+ // rc.6 TASK-023 (E6): silence-counter — emit-gate filtered everything
741
+ // out. From the user's perspective this is indistinguishable from
742
+ // matched-narrow == 0: the CLI had matches, but session-hints dedupe
743
+ // suppressed the render. Counted as silence so doctor lint #26 sees
744
+ // narrow-scope drift even when dedupe is masking the matches.
745
+ if (!(env && env.skipSilenceCounter === true)) {
746
+ appendHintSilenceCounter(cwd, now);
747
+ }
748
+ return;
749
+ }
750
+
751
+ // Persist the cache BEFORE rendering. If the render itself throws (e.g.
752
+ // stderr write errors), the cache update still reflects the intent —
753
+ // the alternative (post-render write) could leave us in a state where
754
+ // the user saw the hint but the cache says "not yet shown", causing a
755
+ // double-emit on the next fire. We prefer the silent-but-recorded
756
+ // outcome to the double-emit one.
757
+ //
758
+ // Test seam: env.skipCacheWrite disables the on-disk write so tests
759
+ // can assert the gate decision without filesystem side effects.
760
+ if (!(env && env.skipCacheWrite === true)) {
761
+ writeSessionHintsCache(cwd, {
762
+ ...gateDecision.cache,
763
+ session_id: sessionId,
764
+ });
765
+ }
766
+
767
+ const lines = renderSummary({ ...cliPayload, narrow: gateDecision.narrow });
768
+ if (lines.length === 0) return;
769
+ for (const line of lines) {
770
+ err.write(`${line}\n`);
771
+ }
772
+ } catch {
773
+ // Silent — never block edits on hook failure.
774
+ }
775
+ }
776
+
777
+ module.exports = {
778
+ main,
779
+ readPayload,
780
+ extractToolName,
781
+ extractToolInput,
782
+ extractPaths,
783
+ appendEditCounter,
784
+ appendHintSilenceCounter,
785
+ invokePlanContextHint,
786
+ renderSummary,
787
+ truncateSummary,
788
+ formatEntryLine,
789
+ // rc.6 TASK-021 (E3) — session-hints cache exports for tests / future
790
+ // consumers (TASK-023 silence-counter telemetry will reuse the same
791
+ // session-id resolution + cache shape).
792
+ resolveSessionId,
793
+ resetSyntheticSessionId,
794
+ sessionHintsCachePath,
795
+ readSessionHintsCache,
796
+ writeSessionHintsCache,
797
+ computeIndexHash,
798
+ applyEmitGate,
799
+ CONSTANTS: {
800
+ CLI_TIMEOUT_MS,
801
+ SUMMARY_MAX_LEN,
802
+ EDIT_COUNTER_DIR_REL,
803
+ EDIT_COUNTER_FILE,
804
+ HINT_SILENCE_COUNTER_DIR_REL,
805
+ HINT_SILENCE_COUNTER_FILE,
806
+ EDIT_TOOL_NAMES,
807
+ SESSION_HINTS_DIR_REL,
808
+ SESSION_HINTS_FILE_PREFIX,
809
+ SESSION_HINTS_FILE_SUFFIX,
810
+ },
811
+ };
812
+
813
+ if (require.main === module) {
814
+ // Read stdin synchronously (small hook payloads, no concurrency concerns).
815
+ let stdinRaw = "";
816
+ try {
817
+ stdinRaw = require("node:fs").readFileSync(0, "utf8");
818
+ } catch {
819
+ // No stdin — proceed with empty payload (E4 still runs).
820
+ }
821
+ main(
822
+ { cwd: process.cwd(), now: new Date(), stdin: stdinRaw },
823
+ { stderr: process.stderr },
824
+ );
825
+ process.exit(0);
826
+ }