@fenglimg/fabric-cli 2.2.0-rc.1 → 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.
- package/dist/chunk-5LQIHYFC.js +64 -0
- package/dist/chunk-5ZUMLCD5.js +248 -0
- package/dist/chunk-EOT63RDH.js +36 -0
- package/dist/{chunk-AOE6AYI7.js → chunk-F6ITRM7T.js} +2 -2
- package/dist/{chunk-WU6GAPKH.js → chunk-H3FE6VIK.js} +3 -5
- package/dist/chunk-XCBVSGCS.js +25 -0
- package/dist/{chunk-2R55HNVD.js → chunk-XHHCRDIR.js} +71 -6
- package/dist/{config-XYRBZJDU.js → config-VJMXCLXW.js} +1 -1
- package/dist/{doctor-YONYXDX6.js → doctor-J4O3X54I.js} +118 -7
- package/dist/index.js +13 -12
- package/dist/{install-74ANPCCP.js → install-BULNDUIM.js} +159 -80
- package/dist/{plan-context-hint-FC6P3WFE.js → plan-context-hint-CHVZGOZ5.js} +21 -8
- package/dist/{scope-explain-CDIZESP5.js → scope-explain-BWRWBCCP.js} +14 -4
- package/dist/{status-GLQWLWH6.js → status-PANEGKU2.js} +17 -6
- package/dist/store-66NK2FTQ.js +443 -0
- package/dist/{sync-UJ4BBCZJ.js → sync-EA5HZMXM.js} +165 -21
- package/dist/{uninstall-C3QXKOO6.js → uninstall-F75MPKQC.js} +27 -1
- package/dist/{whoami-2MLO4Y37.js → whoami-66YKY5DZ.js} +16 -5
- package/package.json +3 -3
- package/templates/hooks/cite-policy-evict.cjs +412 -160
- package/templates/hooks/configs/claude-code.json +17 -2
- package/templates/hooks/configs/codex-hooks.json +14 -2
- package/templates/hooks/configs/cursor-hooks.json +14 -2
- package/templates/hooks/fabric-hint.cjs +151 -15
- package/templates/hooks/knowledge-hint-broad.cjs +12 -1
- package/templates/hooks/knowledge-hint-narrow.cjs +54 -1
- package/templates/hooks/post-tooluse-mutation.cjs +285 -0
- package/templates/hooks/session-end-marker.cjs +140 -0
- package/templates/skills/fabric-archive/SKILL.md +7 -1
- package/dist/chunk-4R2CYEA4.js +0 -116
- package/dist/chunk-L4Q55UC4.js +0 -52
- package/dist/chunk-LFIKMVY7.js +0 -27
- package/dist/chunk-RYAFBNES.js +0 -33
- package/dist/chunk-T5RPGCCM.js +0 -40
- package/dist/store-XB3ADT65.js +0 -144
|
@@ -1,229 +1,481 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* v2.
|
|
3
|
+
* v2.1 ⑤ cite-redesign (P5) — recall-based cite accounting hook.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* `
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
*
|
|
19
|
-
*
|
|
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
|
-
*
|
|
22
|
-
*
|
|
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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
//
|
|
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
|
-
|
|
50
|
-
|
|
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
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
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
|
|
65
|
-
*
|
|
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
|
|
70
|
-
|
|
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
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
|
82
|
-
return
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
(
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
101
|
-
*
|
|
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
|
-
*
|
|
104
|
-
*
|
|
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
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
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
|
|
116
|
-
|
|
117
|
-
if (
|
|
118
|
-
|
|
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
|
|
123
|
-
*
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
134
|
-
"
|
|
135
|
-
"
|
|
136
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
200
|
-
const
|
|
201
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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
|
-
//
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
458
|
+
// Silent — never block the edit on hook failure.
|
|
217
459
|
}
|
|
218
460
|
}
|
|
219
461
|
|
|
220
462
|
module.exports = {
|
|
221
463
|
main,
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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) {
|