@fenglimg/fabric-cli 2.0.0-rc.1 → 2.0.0-rc.8
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/README.md +6 -6
- package/dist/chunk-6ICJICVU.js +10 -0
- package/dist/chunk-74SZWYPH.js +658 -0
- package/dist/{chunk-UHNP7T7W.js → chunk-EYIDD2YS.js} +346 -86
- package/dist/{chunk-5LOYBXWD.js → chunk-OBQU6NHO.js} +2 -52
- package/dist/chunk-WWNXR34K.js +49 -0
- package/dist/doctor-T7JWODKG.js +282 -0
- package/dist/hooks-Y74Y5LQS.js +12 -0
- package/dist/index.js +7 -5
- package/dist/{init-DRHUYHYA.js → init-55WZSUK6.js} +212 -271
- package/dist/plan-context-hint-QMUPAXIB.js +98 -0
- package/dist/{scan-HU2EGITF.js → scan-LMK3UCWL.js} +4 -2
- package/dist/{serve-3LXXSBFR.js → serve-H554BHLG.js} +8 -4
- package/package.json +3 -3
- package/templates/bootstrap/CLAUDE.md +1 -1
- package/templates/bootstrap/codex-AGENTS-header.md +1 -1
- package/templates/bootstrap/cursor-fabric-bootstrap.mdc +1 -1
- package/templates/hooks/configs/README.md +73 -0
- package/templates/hooks/configs/claude-code.json +37 -0
- package/templates/hooks/configs/codex-hooks.json +20 -0
- package/templates/hooks/configs/cursor-hooks.json +20 -0
- package/templates/hooks/fabric-hint.cjs +1307 -0
- package/templates/hooks/knowledge-hint-broad.cjs +464 -0
- package/templates/hooks/knowledge-hint-narrow.cjs +826 -0
- package/templates/hooks/lib/session-digest-writer.cjs +172 -0
- package/templates/skills/fabric-archive/SKILL.md +486 -0
- package/templates/skills/fabric-import/SKILL.md +588 -0
- package/templates/skills/fabric-review/SKILL.md +382 -0
- 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
|
+
}
|