@fenglimg/fabric-cli 2.1.0-rc.2 → 2.2.0-rc.3

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 (49) hide show
  1. package/dist/{chunk-PWLW3B57.js → chunk-2CY4BMTH.js} +5 -1
  2. package/dist/chunk-5LQIHYFC.js +64 -0
  3. package/dist/chunk-5ZUMLCD5.js +248 -0
  4. package/dist/{chunk-WWNXR34K.js → chunk-BO4XIZWZ.js} +8 -1
  5. package/dist/chunk-EOT63RDH.js +36 -0
  6. package/dist/{chunk-BATF4PEJ.js → chunk-F6ITRM7T.js} +4 -4
  7. package/dist/{chunk-WU6GAPKH.js → chunk-H3FE6VIK.js} +3 -5
  8. package/dist/{chunk-MF3OTILQ.js → chunk-XC5RUHLK.js} +29 -8
  9. package/dist/chunk-XCBVSGCS.js +25 -0
  10. package/dist/{chunk-F46ORPOA.js → chunk-XHHCRDIR.js} +149 -7
  11. package/dist/{config-XJIPZNUP.js → config-VJMXCLXW.js} +3 -3
  12. package/dist/{doctor-QVNPHLJK.js → doctor-J4O3X54I.js} +154 -30
  13. package/dist/index.js +57 -16
  14. package/dist/{install-2HDO5FTQ.js → install-BULNDUIM.js} +241 -108
  15. package/dist/{metrics-ACEQFPDU.js → metrics-RER6NLFC.js} +22 -9
  16. package/dist/{onboard-coverage-MFCAEBDO.js → onboard-coverage-JWQWDZW7.js} +1 -1
  17. package/dist/{plan-context-hint-FC6P3WFE.js → plan-context-hint-CHVZGOZ5.js} +21 -8
  18. package/dist/{scope-explain-2F2R5URO.js → scope-explain-BWRWBCCP.js} +19 -5
  19. package/dist/{status-GLQWLWH6.js → status-PANEGKU2.js} +17 -6
  20. package/dist/store-66NK2FTQ.js +443 -0
  21. package/dist/sync-EA5HZMXM.js +395 -0
  22. package/dist/{uninstall-TAXSUSKH.js → uninstall-F75MPKQC.js} +61 -4
  23. package/dist/whoami-66YKY5DZ.js +47 -0
  24. package/package.json +3 -3
  25. package/templates/hooks/cite-policy-evict.cjs +412 -160
  26. package/templates/hooks/configs/claude-code.json +17 -2
  27. package/templates/hooks/configs/codex-hooks.json +14 -2
  28. package/templates/hooks/configs/cursor-hooks.json +14 -2
  29. package/templates/hooks/fabric-hint.cjs +247 -19
  30. package/templates/hooks/knowledge-hint-broad.cjs +176 -10
  31. package/templates/hooks/knowledge-hint-narrow.cjs +64 -5
  32. package/templates/hooks/lib/injection-log.cjs +91 -0
  33. package/templates/hooks/lib/state-store.cjs +30 -11
  34. package/templates/hooks/post-tooluse-mutation.cjs +285 -0
  35. package/templates/hooks/session-end-marker.cjs +140 -0
  36. package/templates/skills/fabric-archive/SKILL.md +7 -1
  37. package/templates/skills/fabric-audit/SKILL.md +53 -0
  38. package/templates/skills/fabric-connect/SKILL.md +48 -0
  39. package/templates/skills/fabric-review/SKILL.md +2 -0
  40. package/templates/skills/fabric-review/ref/cite-contract.md +56 -0
  41. package/templates/skills/fabric-store/SKILL.md +44 -0
  42. package/dist/chunk-HFQVXY6P.js +0 -86
  43. package/dist/chunk-L4Q55UC4.js +0 -52
  44. package/dist/chunk-LFIKMVY7.js +0 -27
  45. package/dist/chunk-RYAFBNES.js +0 -33
  46. package/dist/chunk-T5RPGCCM.js +0 -40
  47. package/dist/store-XTSE5TY6.js +0 -105
  48. package/dist/sync-BJCWDPNC.js +0 -245
  49. package/dist/whoami-B6AEMSEV.js +0 -31
@@ -1,229 +1,481 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * v2.0.0-rc.34 TASK-06cite-policy long-session evict sidecar.
3
+ * v2.1 ⑤ cite-redesign (P5) recall-based cite accounting hook.
4
4
  *
5
- * UserPromptSubmit hook (Claude Code only). Drives periodic cite-policy
6
- * reminder injection in long sessions where attention decay erodes contract
7
- * adherence (rc.32 Batch 1: 3.1% cite coverage baseline).
5
+ * PreToolUse(Edit/Write/MultiEdit) hook (all three clients). REPLACES the
6
+ * rc.34 turn-counter UserPromptSubmit reminder: instead of demanding a
7
+ * hand-written `KB:` first line (which the cold-eval converged 2/5 weakest
8
+ * it forces the agent to declare a citation before it has thought, and the
9
+ * `KB: none` escape hatch made the rule inert), this hook infers the citation
10
+ * from REAL behavior.
8
11
  *
9
- * Strategy: **turn-count window** (locked decision per rc.34 plan 2026-05-26;
10
- * time-based and token-budget strategies pushed to rc.35). The hook maintains
11
- * a per-session counter in `.fabric/.cache/cite-evict-state.json`; on each
12
- * UserPromptSubmit, increment the counter and when
13
- * `turn_count % cite_evict_interval == 0` AND `cite_evict_interval > 0`
14
- * emit a compact cite-contract reminder via Claude Code's stdout JSON
15
- * envelope (hookSpecificOutput.additionalContext, same channel as rc.33 W2
16
- * knowledge-hint-broad reminder-to-context).
12
+ * Core idea: the audit value of a cite = "which knowledge informed this edit".
13
+ * That fact is observable WITHOUT the agent hand-writing anything: if the
14
+ * agent ran `fab_recall(paths)` / `fab_plan_context(paths)` whose target paths
15
+ * overlap the file it is now editing, the server already logged a
16
+ * `knowledge_context_planned` event (target_paths + final_stable_ids +
17
+ * session_id) into `.fabric/events.jsonl`. The recall→edit path overlap IS the
18
+ * citation — doctor `--cite-coverage` (C3) reconstructs it by joining
19
+ * knowledge_context_planned ⋈ edit_intent_checked. No ledger write is needed
20
+ * here; the join is the accounting.
17
21
  *
18
- * Config: `cite_evict_interval` (number, default 0 = OFF, opt-in). Recommend
19
- * 10-20 for active sessions; 5 for high-contract-criticality projects.
22
+ * Hook responsibility (runtime, this file): the NUDGE. On a PreToolUse edit:
23
+ * - recall-backed (a recent in-session knowledge_context_planned overlaps the
24
+ * edit path) → silent. The edit is informed; nothing to remind.
25
+ * - manual override (the agent already wrote a `KB:` line this session →
26
+ * observed as an assistant_turn_observed event carrying cite_ids) → silent.
27
+ * The legacy hand-written cite path is still honored (back-compat).
28
+ * - otherwise → soft nudge: "改前先 fab_recall(paths)". NUDGE, never a gate
29
+ * (KT-DEC-0007): Claude Code receives it as a PreToolUse additionalContext
30
+ * envelope on stdout; Codex/Cursor as stderr. The edit always proceeds.
20
31
  *
21
- * State sidecar shape:
22
- * { session_id: string, turn_count: number }
32
+ * Config (.fabric/fabric-config.json):
33
+ * - `cite_recall_nudge` (boolean, default true) — master switch. Set false to
34
+ * silence the nudge entirely (mirrors the cite_evict_interval=0 opt-out
35
+ * convention of the rc.34 hook this replaces).
36
+ * - `cite_recall_window_minutes` (number, default 30, >=0) — how far back a
37
+ * recall counts as "for this edit". 0 = unbounded (any prior in-session
38
+ * recall of an overlapping path counts).
39
+ * - `cite_nudge_ignore_globs` (string[], default [".workflow/**"]) — F2: edit
40
+ * paths matching any glob are exempt from the nudge. Orchestration / meta
41
+ * files (e.g. `.workflow/` scratchpads) are not source the cite policy is
42
+ * meant to govern, so editing them should never demand a recall. User globs
43
+ * are MERGED with the default (not replaced), so the `.workflow/` exemption
44
+ * always holds. `*` = within a segment, `**` = across segments.
23
45
  *
24
- * Session-boundary semantics: when incoming `session_id` (read from stdin
25
- * payload) differs from sidecar's `session_id`, the counter resets to 1 (new
26
- * session always starts at 1, never 0 — first turn is "turn 1" not "turn 0").
46
+ * Failure invariant: every error path (stdin parse, ledger read, config read,
47
+ * emit failure) MUST end in silent exit 0. The hook never blocks the edit on
48
+ * its own malfunction.
27
49
  *
28
- * Failure invariant: any error path (sidecar I/O failure, stdin parse error,
29
- * config read failure) MUST end in silent exit 0. The hook never blocks user
30
- * prompt submission on its own malfunction.
31
- *
32
- * Cross-client scope: Claude Code only (relies on hookSpecificOutput contract
33
- * + UserPromptSubmit event registration). Codex CLI and Cursor don't have an
34
- * equivalent event hook; cite-coverage telemetry there relies on Stop-hook
35
- * fabric-hint and SessionStart knowledge-hint-broad (rc.33 W2 channel).
50
+ * Cross-client: PreToolUse(Edit|Write|MultiEdit) is registered on all three
51
+ * clients (Claude Code / Codex CLI / Cursor) see hooks/configs/*.json. This
52
+ * is strictly better parity than the rc.34 hook, which was Claude-Code-only
53
+ * for the per-turn window and SessionStart-only for Codex/Cursor.
36
54
  */
37
55
 
38
- // v2.0.0-rc.37 NEW-19: config + sidecar I/O now flow through shared libs so
39
- // the read-config-or-default and read/write-sidecar boilerplate lives in one
40
- // canonical place. Unguarded require mirrors knowledge-hint-broad's
41
- // banner-i18n import the installer copies every lib/*.cjs alongside the hook.
56
+ const { readFileSync } = require("node:fs");
57
+ const { isAbsolute, join, relative } = require("node:path");
58
+
59
+ // Shared config read + client-aware emit (Claude Code stdout envelope vs
60
+ // Codex/Cursor stderr). The installer copies every lib/*.cjs alongside the hook.
42
61
  const { readConfigNumber } = require("./lib/config-cache.cjs");
43
- const { readJsonState, writeJsonState } = require("./lib/state-store.cjs");
44
- // v2.0.0-rc.37 NEW-30: client detect + stdin + channel-aware emit now flow
45
- // through the shared adapter (Claude Code stdout envelope vs Codex/Cursor
46
- // stderr). Replaces the local isClaudeCode + readStdinJson + inline emits.
47
62
  const { isClaudeCode, readStdinJson, emitContext } = require("./lib/client-adapter.cjs");
48
63
 
49
- // Sidecar basename resolved under .fabric/.cache/ by state-store.
50
- const EVICT_STATE_FILE_NAME = "cite-evict-state.json";
64
+ const EVENTS_LEDGER_REL = join(".fabric", "events.jsonl");
65
+
66
+ // Tool names that trigger the recall-nudge branch. PreToolUse fires on many
67
+ // tool names across clients; we only react to file-edit tools (mirrors
68
+ // knowledge-hint-narrow.cjs EDIT_TOOL_NAMES).
69
+ const EDIT_TOOL_NAMES = new Set(["Edit", "Write", "MultiEdit"]);
51
70
 
52
- // Default OFF (opt-in). Mirrors hint_broad_cooldown_hours and
53
- // archive_hint_cooldown_hours convention of "feature exists but inert until
54
- // user enables it." Schema in packages/shared/src/schemas/fabric-config.ts
55
- // caps at sensible bounds (positive int).
56
- // v2.0.0-rc.37 NEW-18: default flipped 0 (opt-in OFF) → 10 (default ON every
57
- // 10 turns) so users get cite-policy nudges out-of-the-box. Operators on
58
- // short / scripted sessions can still set `cite_evict_interval: 0` in
59
- // .fabric/fabric-config.json to opt back out. Per-NEW-1 reminder body now
60
- // uses the simplified 2-state vocabulary ([applied] / [dismissed:<reason>]).
61
- const DEFAULT_CITE_EVICT_INTERVAL = 10;
71
+ // Default recency window: a fab_recall within the last 30 minutes counts as
72
+ // "informing" the edit. Generous a long edit session after one recall sweep
73
+ // should not re-nudge on every file.
74
+ const DEFAULT_CITE_RECALL_WINDOW_MINUTES = 30;
75
+
76
+ // -----------------------------------------------------------------------------
77
+ // Config
78
+ // -----------------------------------------------------------------------------
62
79
 
63
80
  /**
64
- * Read .fabric/fabric-config.json#cite_evict_interval. Returns the parsed
65
- * positive integer OR DEFAULT_CITE_EVICT_INTERVAL on any failure path
66
- * (missing file, parse error, non-numeric value, negative). Mirrors the
67
- * defensive config-read pattern in knowledge-hint-broad.cjs readBroadCooldownHours.
81
+ * Read `.fabric/fabric-config.json#cite_recall_nudge`. Default true (ON).
82
+ * Any failure path (missing file, parse error, non-boolean) → default.
68
83
  */
69
- function readEvictInterval(cwd) {
70
- return readConfigNumber(cwd, "cite_evict_interval", DEFAULT_CITE_EVICT_INTERVAL, {
84
+ function readNudgeEnabled(cwd) {
85
+ try {
86
+ const parsed = JSON.parse(readFileSync(join(cwd, ".fabric", "fabric-config.json"), "utf8"));
87
+ if (parsed && typeof parsed === "object" && typeof parsed.cite_recall_nudge === "boolean") {
88
+ return parsed.cite_recall_nudge;
89
+ }
90
+ } catch {
91
+ // fall through to default
92
+ }
93
+ return true;
94
+ }
95
+
96
+ /**
97
+ * Read `.fabric/fabric-config.json#cite_recall_window_minutes`. Default 30,
98
+ * floor 0 (0 = unbounded). Reuses the shared defensive numeric reader.
99
+ */
100
+ function readWindowMinutes(cwd) {
101
+ return readConfigNumber(cwd, "cite_recall_window_minutes", DEFAULT_CITE_RECALL_WINDOW_MINUTES, {
71
102
  min: 0,
72
103
  integer: true,
73
104
  });
74
105
  }
75
106
 
107
+ // F2: meta/orchestration paths exempt from the cite nudge by default. The cite
108
+ // policy governs SOURCE edits ("which knowledge informed this code change");
109
+ // editing a `.workflow/` scratchpad is not such an edit, so nudging there is
110
+ // pure noise with no clean opt-out before F2.
111
+ const DEFAULT_CITE_NUDGE_IGNORE_GLOBS = [".workflow/**"];
112
+
113
+ /**
114
+ * Read `.fabric/fabric-config.json#cite_nudge_ignore_globs` (string[]) and MERGE
115
+ * it with the built-in defaults. Any failure path (missing file, parse error,
116
+ * non-array, non-string entries) → defaults only. User entries never shrink the
117
+ * default exemption set; they only widen it.
118
+ */
119
+ function readIgnoreGlobs(cwd) {
120
+ const out = [...DEFAULT_CITE_NUDGE_IGNORE_GLOBS];
121
+ try {
122
+ const parsed = JSON.parse(readFileSync(join(cwd, ".fabric", "fabric-config.json"), "utf8"));
123
+ if (parsed && typeof parsed === "object" && Array.isArray(parsed.cite_nudge_ignore_globs)) {
124
+ for (const g of parsed.cite_nudge_ignore_globs) {
125
+ if (typeof g === "string" && g.length > 0 && !out.includes(g)) out.push(g);
126
+ }
127
+ }
128
+ } catch {
129
+ // fall through to defaults
130
+ }
131
+ return out;
132
+ }
133
+
134
+ /**
135
+ * Compile a simple glob to an anchored RegExp. `**` matches across path
136
+ * separators, `*` matches within a single segment; all other regex-special
137
+ * characters are escaped. Intentionally minimal — the patterns are short path
138
+ * prefixes like `.workflow/**`, not a full gitignore dialect.
139
+ */
140
+ function globToRegExp(glob) {
141
+ let re = "";
142
+ for (let i = 0; i < glob.length; i++) {
143
+ const c = glob[i];
144
+ if (c === "*") {
145
+ if (glob[i + 1] === "*") {
146
+ re += ".*";
147
+ i++;
148
+ } else {
149
+ re += "[^/]*";
150
+ }
151
+ } else if (".+?^${}()|[]\\/".includes(c)) {
152
+ re += "\\" + c;
153
+ } else {
154
+ re += c;
155
+ }
156
+ }
157
+ return new RegExp("^" + re + "$");
158
+ }
159
+
160
+ /**
161
+ * True if `normPath` (project-relative, forward-slashed) matches any ignore
162
+ * glob. Used to drop meta/orchestration edits from the nudge.
163
+ */
164
+ function pathIsIgnored(normPath, globs) {
165
+ if (typeof normPath !== "string" || normPath.length === 0) return false;
166
+ for (const g of globs) {
167
+ try {
168
+ if (globToRegExp(g).test(normPath)) return true;
169
+ } catch {
170
+ // a malformed user glob never breaks the hook — just skip it
171
+ }
172
+ }
173
+ return false;
174
+ }
175
+
176
+ // -----------------------------------------------------------------------------
177
+ // Payload parsing (mirror of knowledge-hint-narrow.cjs conventions)
178
+ // -----------------------------------------------------------------------------
179
+
180
+ function extractToolName(payload) {
181
+ if (!payload || typeof payload !== "object") return null;
182
+ if (typeof payload.tool_name === "string") return payload.tool_name;
183
+ if (typeof payload.tool === "string") return payload.tool;
184
+ return null;
185
+ }
186
+
187
+ function extractToolInput(payload) {
188
+ if (!payload || typeof payload !== "object") return null;
189
+ if (payload.tool_input && typeof payload.tool_input === "object") return payload.tool_input;
190
+ if (payload.input && typeof payload.input === "object") return payload.input;
191
+ return null;
192
+ }
193
+
194
+ /**
195
+ * Pull edit target paths from a tool_input object. Handles scalar file_path,
196
+ * array file_paths, and MultiEdit edits[]. Deduped, first-occurrence order.
197
+ */
198
+ function extractPaths(toolInput) {
199
+ if (!toolInput || typeof toolInput !== "object") return [];
200
+ const collected = [];
201
+ if (typeof toolInput.file_path === "string" && toolInput.file_path.length > 0) {
202
+ collected.push(toolInput.file_path);
203
+ }
204
+ if (Array.isArray(toolInput.file_paths)) {
205
+ for (const p of toolInput.file_paths) {
206
+ if (typeof p === "string" && p.length > 0) collected.push(p);
207
+ }
208
+ }
209
+ if (Array.isArray(toolInput.edits)) {
210
+ for (const edit of toolInput.edits) {
211
+ if (edit && typeof edit === "object" && typeof edit.file_path === "string" && edit.file_path.length > 0) {
212
+ collected.push(edit.file_path);
213
+ }
214
+ }
215
+ }
216
+ const seen = new Set();
217
+ const out = [];
218
+ for (const p of collected) {
219
+ if (seen.has(p)) continue;
220
+ seen.add(p);
221
+ out.push(p);
222
+ }
223
+ return out;
224
+ }
225
+
226
+ function resolveSessionId(payload, env) {
227
+ if (payload && typeof payload === "object" && typeof payload.session_id === "string" && payload.session_id.length > 0) {
228
+ return payload.session_id;
229
+ }
230
+ const envBag = (env && env.processEnv) || process.env;
231
+ if (envBag && typeof envBag.FABRIC_SESSION_ID === "string" && envBag.FABRIC_SESSION_ID.length > 0) {
232
+ return envBag.FABRIC_SESSION_ID;
233
+ }
234
+ return "anonymous";
235
+ }
236
+
237
+ // -----------------------------------------------------------------------------
238
+ // Path overlap
239
+ // -----------------------------------------------------------------------------
240
+
76
241
  /**
77
- * Read prior state sidecar. Returns `null` on first-run or any failure;
78
- * callers treat null as "no prior state" (caller will write fresh state
79
- * with turn_count=1).
242
+ * Normalize a path for overlap comparison: project-relative when possible,
243
+ * forward-slashed, leading "./" and "/" stripped. Absolute paths inside the
244
+ * project become relative; absolute paths outside collapse to a basename-style
245
+ * tail so an abs-vs-rel suffix match still works.
80
246
  */
81
- function readEvictState(cwd) {
82
- return readJsonState(
83
- cwd,
84
- EVICT_STATE_FILE_NAME,
85
- (parsed) =>
86
- parsed &&
87
- typeof parsed.session_id === "string" &&
88
- typeof parsed.turn_count === "number" &&
89
- Number.isInteger(parsed.turn_count) &&
90
- parsed.turn_count >= 0,
91
- );
247
+ function normalizeForCompare(p, projectRoot) {
248
+ if (typeof p !== "string" || p.length === 0) return "";
249
+ let s = p;
250
+ if (isAbsolute(s) && typeof projectRoot === "string" && projectRoot.length > 0) {
251
+ const rel = relative(projectRoot, s);
252
+ if (rel.length > 0 && !rel.startsWith("..")) s = rel;
253
+ }
254
+ s = s.split("\\").join("/");
255
+ while (s.startsWith("./")) s = s.slice(2);
256
+ while (s.startsWith("/")) s = s.slice(1);
257
+ return s;
258
+ }
259
+
260
+ /**
261
+ * Does `editNorm` fall within the scope a recall asked for (`recallNorm`)?
262
+ * True when they are equal, when one is a path-boundary suffix of the other
263
+ * (handles abs-vs-rel skew), or when one is an ancestor directory of the
264
+ * other. Conservative — avoids basename-only matches that would over-fire.
265
+ */
266
+ function pathPairOverlaps(editNorm, recallNorm) {
267
+ if (editNorm.length === 0 || recallNorm.length === 0) return false;
268
+ if (editNorm === recallNorm) return true;
269
+ if (editNorm.endsWith("/" + recallNorm) || recallNorm.endsWith("/" + editNorm)) return true;
270
+ if (editNorm.startsWith(recallNorm + "/") || recallNorm.startsWith(editNorm + "/")) return true;
271
+ return false;
92
272
  }
93
273
 
94
- function writeEvictState(cwd, sessionId, turnCount) {
95
- // best-effort counter loss is acceptable, hook never blocks
96
- writeJsonState(cwd, EVICT_STATE_FILE_NAME, { session_id: sessionId, turn_count: turnCount });
274
+ function pathsOverlap(recallPaths, editPaths, projectRoot) {
275
+ if (!Array.isArray(recallPaths) || !Array.isArray(editPaths)) return false;
276
+ const edits = editPaths.map((e) => normalizeForCompare(e, projectRoot)).filter((e) => e.length > 0);
277
+ const recalls = recallPaths.map((r) => normalizeForCompare(r, projectRoot)).filter((r) => r.length > 0);
278
+ for (const e of edits) {
279
+ for (const r of recalls) {
280
+ if (pathPairOverlaps(e, r)) return true;
281
+ }
282
+ }
283
+ return false;
284
+ }
285
+
286
+ // -----------------------------------------------------------------------------
287
+ // Events ledger read
288
+ // -----------------------------------------------------------------------------
289
+
290
+ /**
291
+ * Read + parse `.fabric/events.jsonl` best-effort. Returns an array of parsed
292
+ * line objects (only those with a numeric `ts`). Never throws — a missing or
293
+ * corrupt ledger yields []. Lines that fail JSON.parse are skipped.
294
+ */
295
+ function readEventsLedger(cwd) {
296
+ try {
297
+ const raw = readFileSync(join(cwd, EVENTS_LEDGER_REL), "utf8");
298
+ if (raw.length === 0) return [];
299
+ const out = [];
300
+ for (const line of raw.split("\n")) {
301
+ const t = line.trim();
302
+ if (t.length === 0) continue;
303
+ try {
304
+ const obj = JSON.parse(t);
305
+ if (obj && typeof obj === "object" && typeof obj.ts === "number") out.push(obj);
306
+ } catch {
307
+ // skip malformed line
308
+ }
309
+ }
310
+ return out;
311
+ } catch {
312
+ return [];
313
+ }
97
314
  }
98
315
 
316
+ // -----------------------------------------------------------------------------
317
+ // Decision
318
+ // -----------------------------------------------------------------------------
319
+
99
320
  /**
100
- * Pure helper for unit-testing. Given current `turnCount` (post-increment)
101
- * and `interval`, decide whether to emit the reminder.
321
+ * Pure decision helper (unit-testable). Given the ledger events, the edit
322
+ * target paths, the session id, the current time and the recency window,
323
+ * decide whether the edit is recall-backed and/or manually cited.
102
324
  *
103
- * Contract:
104
- * - interval <= 0 → never emit (feature off)
105
- * - turnCount <= 0 → never emit (guard against bogus state)
106
- * - emit iff turnCount % interval === 0
325
+ * @returns {{ recallBacked: boolean, recalledIds: string[],
326
+ * matchedRecallTs: number|null, manualCited: boolean }}
107
327
  *
108
- * Examples:
109
- * evaluateCiteEvict(10, 10) true (10 % 10 === 0)
110
- * evaluateCiteEvict(20, 10)true
111
- * evaluateCiteEvict(15, 10) false
112
- * evaluateCiteEvict(5, 0) → false (off)
113
- * evaluateCiteEvict(0, 10) false (no turns yet)
328
+ * Contract:
329
+ * - Only events with matching `session_id` are considered.
330
+ * - windowMs <= 0 unbounded (any prior in-session event counts).
331
+ * - recallBacked: a `knowledge_context_planned` event whose `target_paths`
332
+ * overlap `editPaths` exists in-window. recalledIds = its final_stable_ids
333
+ * (union across all matching recalls). matchedRecallTs = the latest match.
334
+ * - manualCited: an `assistant_turn_observed` event with a non-empty
335
+ * `cite_ids` array exists in-window (the legacy hand-written-`KB:` path).
114
336
  */
115
- function evaluateCiteEvict(turnCount, interval) {
116
- if (typeof interval !== "number" || interval <= 0) return false;
117
- if (typeof turnCount !== "number" || turnCount <= 0) return false;
118
- return turnCount % interval === 0;
337
+ function evaluateRecallCite({ events, editPaths, sessionId, nowMs, windowMs, projectRoot }) {
338
+ const result = { recallBacked: false, recalledIds: [], matchedRecallTs: null, manualCited: false };
339
+ if (!Array.isArray(events) || events.length === 0) return result;
340
+ const sinceMs = typeof windowMs === "number" && windowMs > 0 ? nowMs - windowMs : null;
341
+ const recalledSet = new Set();
342
+ for (const ev of events) {
343
+ if (ev.session_id !== sessionId) continue;
344
+ if (typeof ev.ts !== "number") continue;
345
+ if (sinceMs !== null && ev.ts < sinceMs) continue;
346
+ // Future events (ts > nowMs) are ignored — a recall cannot inform an edit
347
+ // that happened before it.
348
+ if (ev.ts > nowMs) continue;
349
+
350
+ if (ev.event_type === "knowledge_context_planned") {
351
+ if (pathsOverlap(ev.target_paths, editPaths, projectRoot)) {
352
+ result.recallBacked = true;
353
+ if (result.matchedRecallTs === null || ev.ts > result.matchedRecallTs) {
354
+ result.matchedRecallTs = ev.ts;
355
+ }
356
+ const ids = Array.isArray(ev.final_stable_ids) ? ev.final_stable_ids : [];
357
+ for (const id of ids) {
358
+ if (typeof id === "string" && id.length > 0) recalledSet.add(id);
359
+ }
360
+ }
361
+ } else if (ev.event_type === "assistant_turn_observed") {
362
+ const ids = Array.isArray(ev.cite_ids) ? ev.cite_ids : [];
363
+ if (ids.some((id) => typeof id === "string" && id.length > 0)) {
364
+ result.manualCited = true;
365
+ }
366
+ }
367
+ }
368
+ result.recalledIds = [...recalledSet];
369
+ return result;
119
370
  }
120
371
 
372
+ // -----------------------------------------------------------------------------
373
+ // Nudge rendering
374
+ // -----------------------------------------------------------------------------
375
+
121
376
  /**
122
- * Build the cite-contract reminder body. Compact — under 10 lines. The
123
- * fully-specified contract lives in `.fabric/AGENTS.md` Cite policy section;
124
- * the reminder is a tactical re-anchor, not the canonical reference.
125
- *
126
- * Returns a multi-line string ready for hookSpecificOutput.additionalContext.
377
+ * Build the soft nudge body. Compact, non-blocking it tells the agent to
378
+ * recall BEFORE editing so the citation is auto-accounted. NUDGE not gate.
127
379
  */
128
- function renderReminder(turnCount, interval) {
129
- // v2.0.0-rc.37 NEW-1: cite policy simplified 4-state → 2-state.
130
- // [applied] consolidates planned/recalled/chained-from; dismissed:<reason>
131
- // unchanged. Old tags still parse for back-compat.
380
+ function renderNudge(editPaths) {
381
+ const target = Array.isArray(editPaths) && editPaths.length > 0
382
+ ? editPaths.slice(0, 3).join(", ") + (editPaths.length > 3 ? ` (+${editPaths.length - 3})` : "")
383
+ : "this file";
132
384
  return [
133
- `[fabric cite-evict] long-session reminder (turn ${turnCount}, interval ${interval}):`,
134
- "Before edit / decide / propose plan, write KB: <id> (<≤8字 用法>) [applied|dismissed:<reason>] OR KB: none [<reason>].",
135
- "Verify [applied] by actually fetching KB body via fab_recall(paths) or fab_plan_context → fab_get_knowledge_sections (no fabricated ids).",
136
- "decisions/pitfalls [applied] cite MUST end with contract: → <operator> [<operator>...] where operator ∈ {edit:<glob> !edit:<glob> require:<symbol> forbid:<symbol> skip:<reason>}.",
137
- "skip reasons: sequencing | conditional | semantic | aesthetic | architectural | other:<text>.",
138
- "KB: none sentinels: [no-relevant] (queried but nothing matched) | [not-applicable] (pure exploration / read-only / user Q&A).",
139
- "Audit: fabric doctor --cite-coverage — this rule does not block work, only records.",
385
+ `[fabric cite] ${target} 前未检测到相关 fab_recall —`,
386
+ "建议先调 fab_recall(paths=[<被改文件>]) 让系统自动记账引用的 KB(无需手写首行 KB:)。",
387
+ " recall 过可忽略本提示。仍可手写首行 `KB: <id> [applied]` 显式 override。",
388
+ "(nudge only — 不阻塞本次编辑;cite 覆盖率见 fabric doctor --cite-coverage)",
140
389
  ].join("\n");
141
390
  }
142
391
 
143
- async function main(env) {
392
+ // -----------------------------------------------------------------------------
393
+ // Main
394
+ // -----------------------------------------------------------------------------
395
+
396
+ async function main(env, stdio) {
144
397
  try {
145
398
  const cwd =
146
399
  (env && typeof env.cwd === "string" && env.cwd) ||
147
400
  process.env.CLAUDE_PROJECT_DIR ||
148
401
  process.cwd();
149
402
 
150
- const interval = readEvictInterval(cwd);
151
- if (interval <= 0) {
152
- return; // feature off — silent exit
403
+ if (!readNudgeEnabled(cwd)) {
404
+ return; // feature off — silent
153
405
  }
154
406
 
155
- // Read stdin payload (Claude Code passes hook_event_name; Codex/Cursor
156
- // SessionStart payloads are smaller but still JSON). Tests inject
157
- // env.payload to bypass the stdin read.
158
407
  const payload = env && env.payload !== undefined ? env.payload : await readStdinJson();
159
408
 
160
- // v2.0.0-rc.37 NEW-21: SessionStart-mode parity for Codex/Cursor.
161
- // When the hook fires on SessionStart (instead of UserPromptSubmit),
162
- // emit ONE unconditional cite-policy reminder to stderr. This gives
163
- // Codex/Cursor users the cite-contract nudge at session boot — lower
164
- // cadence than Claude Code's per-prompt UserPromptSubmit window, but
165
- // strictly better than 0 (rc.32 cite-coverage baseline 3.1% measured
166
- // when Codex/Cursor had no cite-reminder surface at all).
167
- const eventName =
168
- payload && typeof payload.hook_event_name === "string"
169
- ? payload.hook_event_name
170
- : null;
171
- const sessionStartMode =
172
- (env && env.forceSessionStart === true) || eventName === "SessionStart";
173
-
174
- const streams = (env && env.stdio) || {};
175
-
176
- if (sessionStartMode) {
177
- // One-shot stderr emit (knowledge-hint-broad convention). forceStderr
178
- // pins stderr even on Claude Code — Codex/Cursor parse stderr; CC
179
- // SessionStart also surfaces stderr to the user.
180
- emitContext(renderReminder(/* turnCount = */ 0, interval), {
181
- forceStderr: true,
182
- streams,
183
- });
184
- return;
409
+ const toolName = extractToolName(payload);
410
+ if (!toolName || !EDIT_TOOL_NAMES.has(toolName)) {
411
+ return; // not a file-edit tool silent
185
412
  }
186
413
 
187
- // Claude Code UserPromptSubmit path (unchanged from rc.34 TASK-06).
188
- // Skip Claude Code-specific stdout envelope on Codex/Cursor when not
189
- // in SessionStart mode (no UserPromptSubmit event registration there).
190
- if (!isClaudeCode() && !(env && env.forceClaudeCode === true)) {
191
- return;
414
+ const editPaths = extractPaths(extractToolInput(payload));
415
+ if (editPaths.length === 0) {
416
+ return; // no recognizable edit target silent
192
417
  }
193
418
 
194
- const sessionId =
195
- payload && typeof payload.session_id === "string" && payload.session_id.length > 0
196
- ? payload.session_id
197
- : "anonymous";
419
+ // F2: drop meta/orchestration edits (e.g. `.workflow/` scratchpads) before
420
+ // nudging. If EVERY target is exempt, stay silent — the cite policy does not
421
+ // apply to these paths. A mixed batch keeps the non-exempt targets.
422
+ const ignoreGlobs = readIgnoreGlobs(cwd);
423
+ const nudgePaths = editPaths.filter((p) => !pathIsIgnored(normalizeForCompare(p, cwd), ignoreGlobs));
424
+ if (nudgePaths.length === 0) {
425
+ return; // all edit targets are cite-exempt — silent
426
+ }
198
427
 
199
- const prior = readEvictState(cwd);
200
- const turnCount = prior && prior.session_id === sessionId ? prior.turn_count + 1 : 1;
201
- writeEvictState(cwd, sessionId, turnCount);
428
+ const sessionId = resolveSessionId(payload, env);
429
+ const nowMs = env && typeof env.nowMs === "number" ? env.nowMs : Date.now();
430
+ const windowMs = readWindowMinutes(cwd) * 60_000;
202
431
 
203
- if (!evaluateCiteEvict(turnCount, interval)) {
204
- return; // not on a window boundary — silent
432
+ const events = readEventsLedger(cwd);
433
+ const decision = evaluateRecallCite({
434
+ events,
435
+ editPaths: nudgePaths,
436
+ sessionId,
437
+ nowMs,
438
+ windowMs,
439
+ projectRoot: cwd,
440
+ });
441
+
442
+ // Recall-backed or manually cited → the edit is informed; stay silent.
443
+ if (decision.recallBacked || decision.manualCited) {
444
+ return;
205
445
  }
206
446
 
207
- // Claude Code UserPromptSubmit: stdout JSON envelope. client:'cc' forces
208
- // the envelope since the isClaudeCode/forceClaudeCode gate above already
209
- // confirmed this is the Claude Code path.
210
- emitContext(renderReminder(turnCount, interval), {
211
- client: "cc",
212
- eventName: "UserPromptSubmit",
447
+ // No recall, no manual cite → soft nudge. Claude Code: PreToolUse stdout
448
+ // additionalContext envelope. Codex/Cursor: stderr. Never a gate.
449
+ const streams = (env && env.stdio) || stdio || {};
450
+ const onClaudeCode = isClaudeCode() || (env && env.forceClaudeCode === true);
451
+ emitContext(renderNudge(nudgePaths), {
452
+ client: onClaudeCode ? "cc" : undefined,
453
+ eventName: "PreToolUse",
454
+ forceStderr: !onClaudeCode,
213
455
  streams,
214
456
  });
215
457
  } catch {
216
- // Silent — never block user prompt on hook failure.
458
+ // Silent — never block the edit on hook failure.
217
459
  }
218
460
  }
219
461
 
220
462
  module.exports = {
221
463
  main,
222
- evaluateCiteEvict,
223
- renderReminder,
224
- readEvictInterval,
225
- readEvictState,
226
- writeEvictState,
464
+ extractToolName,
465
+ extractToolInput,
466
+ extractPaths,
467
+ resolveSessionId,
468
+ readNudgeEnabled,
469
+ readWindowMinutes,
470
+ readIgnoreGlobs,
471
+ globToRegExp,
472
+ pathIsIgnored,
473
+ readEventsLedger,
474
+ normalizeForCompare,
475
+ pathPairOverlaps,
476
+ pathsOverlap,
477
+ evaluateRecallCite,
478
+ renderNudge,
227
479
  };
228
480
 
229
481
  if (require.main === module) {