@fenglimg/fabric-cli 1.8.0-rc.3 → 2.0.0

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 (46) hide show
  1. package/README.md +6 -6
  2. package/dist/chunk-6ICJICVU.js +10 -0
  3. package/dist/chunk-74SZWYPH.js +658 -0
  4. package/dist/chunk-EYIDD2YS.js +1000 -0
  5. package/dist/{chunk-QPCRBQ5Y.js → chunk-OBQU6NHO.js} +1 -52
  6. package/dist/chunk-WWNXR34K.js +49 -0
  7. package/dist/doctor-T7JWODKG.js +282 -0
  8. package/dist/hooks-Y74Y5LQS.js +12 -0
  9. package/dist/index.js +7 -5
  10. package/dist/{init-7EYGUJNJ.js → init-BIRSIOXO.js} +312 -1022
  11. package/dist/plan-context-hint-QMUPAXIB.js +98 -0
  12. package/dist/scan-LMK3UCWL.js +22 -0
  13. package/dist/{serve-466QXQ5Q.js → serve-H554BHLG.js} +8 -4
  14. package/package.json +3 -3
  15. package/templates/agents-md/AGENTS.md.template +55 -17
  16. package/templates/bootstrap/CLAUDE.md +1 -1
  17. package/templates/bootstrap/codex-AGENTS-header.md +1 -1
  18. package/templates/bootstrap/cursor-fabric-bootstrap.mdc +1 -1
  19. package/templates/hooks/configs/README.md +73 -0
  20. package/templates/hooks/configs/claude-code.json +37 -0
  21. package/templates/hooks/configs/codex-hooks.json +20 -0
  22. package/templates/hooks/configs/cursor-hooks.json +20 -0
  23. package/templates/hooks/fabric-hint.cjs +1307 -0
  24. package/templates/hooks/knowledge-hint-broad.cjs +464 -0
  25. package/templates/hooks/knowledge-hint-narrow.cjs +826 -0
  26. package/templates/hooks/lib/session-digest-writer.cjs +172 -0
  27. package/templates/skills/fabric-archive/SKILL.md +486 -0
  28. package/templates/skills/fabric-import/SKILL.md +588 -0
  29. package/templates/skills/fabric-review/SKILL.md +382 -0
  30. package/dist/chunk-NMMUETVK.js +0 -216
  31. package/dist/doctor-F52XWWZC.js +0 -98
  32. package/dist/scan-NNBNGIZG.js +0 -12
  33. package/templates/agents-md/variants/cocos.md +0 -20
  34. package/templates/agents-md/variants/next.md +0 -20
  35. package/templates/agents-md/variants/vite.md +0 -20
  36. package/templates/bootstrap/GEMINI.md +0 -8
  37. package/templates/bootstrap/roo-fabric.md +0 -5
  38. package/templates/bootstrap/windsurf-fabric.md +0 -5
  39. package/templates/claude-hooks/fabric-init-reminder.cjs +0 -18
  40. package/templates/claude-skills/fabric-init/SKILL.md +0 -163
  41. package/templates/codex-hooks/fabric-session-start.cjs +0 -19
  42. package/templates/codex-hooks/fabric-stop-reminder.cjs +0 -18
  43. package/templates/codex-skills/fabric-init/SKILL.md +0 -162
  44. package/templates/husky/pre-commit +0 -9
  45. package/templates/skill-source/fabric-init/SOURCE.md +0 -157
  46. package/templates/skill-source/fabric-init/clients.json +0 -17
@@ -0,0 +1,1307 @@
1
+ #!/usr/bin/env node
2
+ const { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } = require("node:fs");
3
+ const { dirname, join } = require("node:path");
4
+
5
+ // v2.0.0-rc.7 T5: session-digest writer. Best-effort (never blocks Stop hook
6
+ // on failure — see contract in lib/session-digest-writer.cjs).
7
+ let sessionDigestWriter = null;
8
+ try {
9
+ sessionDigestWriter = require("./lib/session-digest-writer.cjs");
10
+ } catch {
11
+ // Helper module missing — degrade silently. Digest writing is opt-in
12
+ // observability; the rest of fabric-hint must still function.
13
+ sessionDigestWriter = null;
14
+ }
15
+
16
+ // CONSTANTS — duplicated from packages/server/src/services/_shared.ts.
17
+ // DRY violation accepted: this hook script runs in user repos WITHOUT
18
+ // node_modules access, so it cannot import from @fenglimg/fabric-server.
19
+ const FABRIC_DIR = ".fabric";
20
+ const EVENT_LEDGER_FILE = "events.jsonl";
21
+ const EVENT_TYPE_PROPOSED = "knowledge_proposed";
22
+ const EVENT_TYPE_INIT_SCAN_COMPLETED = "init_scan_completed";
23
+ // v2.0.0-rc.7 T10: doctor_run event drives Signal D (maintenance hint).
24
+ const EVENT_TYPE_DOCTOR_RUN = "doctor_run";
25
+ // rc.6 TASK-022 (E5): Signal A is now `24h OR N-edits since last
26
+ // knowledge_proposed`. The edit-count branch reads
27
+ // `.fabric/.cache/edit-counter` (one ISO-8601 line per PreToolUse fire,
28
+ // populated by rc.6 TASK-020 / E4). Filters lines with ts > last
29
+ // knowledge_proposed event ts; fires when the count reaches
30
+ // archive_edit_threshold (default 20, configurable via fabric-config.json).
31
+ //
32
+ // rc.5 TASK-015 (C6) had reduced Signal A to pure 24h-only because the prior
33
+ // `5 plan_contexts since last archive` branch was unreliable (rc.5+ hooks
34
+ // auto-fire plan_context events, inflating the count). The edit-counter
35
+ // sidecar fixes that: PreToolUse fires correlate with real Edit/Write/MultiEdit
36
+ // activity, not tooling chatter.
37
+ //
38
+ // Safe-degrade contract: if `.fabric/.cache/edit-counter` is missing or every
39
+ // line malformed, the edit branch contributes 0 and Signal A reverts to
40
+ // 24h-only — matching the rc.5 contract. If no knowledge_proposed event has
41
+ // ever fired, Signal A stays silent regardless of edit count (an
42
+ // "anchor"-less workspace is Signal C's domain).
43
+ // rc.7 T7: archive_hint_hours, review_hint_pending_count, and
44
+ // review_hint_pending_age_days are now read from .fabric/fabric-config.json.
45
+ // The DEFAULT_ constants below carry the documented fallback when the config
46
+ // file is missing, malformed, or the field is absent. Call sites use the
47
+ // readArchiveHintHours / readReviewHintPendingCount /
48
+ // readReviewHintPendingAgeDays helpers — see docs/configuration.md.
49
+ const DEFAULT_ARCHIVE_HINT_HOURS = 24;
50
+ const MS_PER_HOUR = 60 * 60 * 1000;
51
+ const EDIT_COUNTER_FILE_REL = join(".fabric", ".cache", "edit-counter");
52
+ const DEFAULT_ARCHIVE_EDIT_THRESHOLD = 20;
53
+
54
+ // rc.3 TASK-004: second signal — pending-overflow → review skill recommendation.
55
+ const PENDING_DIR = "knowledge/pending";
56
+ const PENDING_TYPES = ["decisions", "pitfalls", "guidelines", "models", "processes"];
57
+ const DEFAULT_REVIEW_HINT_PENDING_COUNT = 10;
58
+ const DEFAULT_REVIEW_HINT_PENDING_AGE_DAYS = 7;
59
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
60
+
61
+ // rc.7 T7 / T10 pre-wiring: Signal D (maintenance hint) thresholds. T10 will
62
+ // consume these to decide when a "run fabric doctor" reminder fires; T7 only
63
+ // surfaces them on the config-loader surface so T10 doesn't have to bump the
64
+ // config schema in a second commit. Defaults: 14d since last doctor invoke
65
+ // triggers; 7d cooldown between repeats.
66
+ const DEFAULT_MAINTENANCE_HINT_DAYS = 14;
67
+ const DEFAULT_MAINTENANCE_HINT_COOLDOWN_DAYS = 7;
68
+
69
+ // rc.5 TASK-010: third signal — underseeded knowledge corpus → fabric-import skill.
70
+ // Triggers when (a) canonical node count is below the underseed threshold AND
71
+ // (b) the workspace has had a successful init_scan_completed event at least 24h
72
+ // ago (so we don't nag during the immediate post-init window) AND (c) no
73
+ // knowledge_proposed event has fired in the last 24h (so we don't nag while
74
+ // the user is actively archiving).
75
+ const KNOWLEDGE_CANONICAL_TYPES = PENDING_TYPES; // same five canonical type dirs
76
+ const DEFAULT_UNDERSEED_NODE_THRESHOLD = 10;
77
+ const UNDERSEED_POST_INIT_QUIET_HOURS = 24;
78
+ const UNDERSEED_NO_PROPOSED_HOURS = 24;
79
+
80
+ // Cooldown throttle. After the hook surfaces a reminder, it stays silent for
81
+ // this many hours — purely a reminder-noise throttle, not a state machine.
82
+ // Override via .fabric/fabric-config.json#archive_hint_cooldown_hours.
83
+ const CONFIG_FILE = "fabric-config.json";
84
+ const DEFAULT_COOLDOWN_HOURS = 12;
85
+ // Cache file path retains the historical `archive-hint-shown.json` name so an
86
+ // in-place rename does not flush a user's existing cooldown state on first run
87
+ // post-upgrade. The schema is signal-keyed (archive/review/import) so the new
88
+ // import signal slot lives alongside the existing two.
89
+ const SHOWN_CACHE_FILE = ".fabric/.cache/archive-hint-shown.json";
90
+
91
+ // v2.0.0-rc.7 T10: dedicated Signal-D cooldown sidecar. The shared
92
+ // SHOWN_CACHE_FILE above is signal-keyed (archive/review/import) and uses
93
+ // hours-based cooldown; the maintenance signal uses a day-based threshold
94
+ // (default 7d) so we keep it in its own sidecar to avoid mixing semantics.
95
+ const MAINTENANCE_HINT_LAST_EMIT_FILE = ".fabric/.cache/maintenance-hint-last-emit";
96
+ // Signal-D gate: only nag when canonical corpus has at least this many
97
+ // entries. A fresh-init workspace shouldn't be reminded to run lint when
98
+ // there's barely anything TO lint.
99
+ const MAINTENANCE_HINT_MIN_CANONICAL = 5;
100
+
101
+ // rc.7 T1: cross-surface sentinel from `fabric init` Y-confirm. Empty file
102
+ // at `.fabric/.import-requested`. Stop hook reads it to bypass the Signal C
103
+ // cooldown and emit the import recommendation regardless of underseed or
104
+ // 24h-since-last-emit gates. SessionStart hook (knowledge-hint-broad.cjs)
105
+ // has its own mirror of this pickup logic. The fabric-import Skill's
106
+ // Phase 3.4 clears the sentinel; until then it remains and continues to
107
+ // surface the recommendation.
108
+ const IMPORT_REQUESTED_SENTINEL_FILE = join(".fabric", ".import-requested");
109
+
110
+ /**
111
+ * Read the events.jsonl ledger from <projectRoot>/.fabric/events.jsonl.
112
+ * Mirrors the semantics of readEventLedger in packages/server/src/services/event-ledger.ts:
113
+ * - ENOENT → return [] (fabric not initialized)
114
+ * - split on /\r?\n/
115
+ * - drop final fragment if file lacks trailing newline (partial-tail tolerance)
116
+ * - JSON.parse per line, swallow per-line errors (corrupt-line tolerance)
117
+ */
118
+ function readLedger(projectRoot) {
119
+ const eventPath = join(projectRoot, FABRIC_DIR, EVENT_LEDGER_FILE);
120
+ if (!existsSync(eventPath)) {
121
+ return [];
122
+ }
123
+
124
+ let raw;
125
+ try {
126
+ raw = readFileSync(eventPath, "utf8");
127
+ } catch {
128
+ return [];
129
+ }
130
+
131
+ const lines = raw.split(/\r?\n/);
132
+ const hasTrailingNewline = raw.endsWith("\n");
133
+ if (!hasTrailingNewline && lines.length > 0) {
134
+ lines.pop();
135
+ }
136
+
137
+ const events = [];
138
+ for (const line of lines) {
139
+ const trimmed = line.trim();
140
+ if (trimmed.length === 0) continue;
141
+ try {
142
+ const parsed = JSON.parse(trimmed);
143
+ if (parsed && typeof parsed === "object") {
144
+ events.push(parsed);
145
+ }
146
+ } catch {
147
+ // corrupt JSON line — drop silently
148
+ }
149
+ }
150
+ return events;
151
+ }
152
+
153
+ /**
154
+ * Walk <projectRoot>/.fabric/knowledge/pending/<type>/*.md across all
155
+ * PENDING_TYPES subdirs, collecting count and oldest mtime.
156
+ *
157
+ * Returns { count, oldestAgeMs } where:
158
+ * - count: total .md file count across all type subdirs
159
+ * - oldestAgeMs: (nowMs - oldestMtimeMs) when count>0, else null
160
+ *
161
+ * ENOENT / unreadable subdir / unstat-able file → silently skipped
162
+ * (preserves the hook's never-block-on-failure invariant).
163
+ */
164
+ function readPendingStats(projectRoot, now) {
165
+ const nowMs = now instanceof Date ? now.getTime() : Number(now) || Date.now();
166
+ const baseDir = join(projectRoot, FABRIC_DIR, PENDING_DIR);
167
+
168
+ let count = 0;
169
+ let oldestMtime = null;
170
+
171
+ if (!existsSync(baseDir)) {
172
+ return { count: 0, oldestAgeMs: null };
173
+ }
174
+
175
+ for (const type of PENDING_TYPES) {
176
+ const typeDir = join(baseDir, type);
177
+ if (!existsSync(typeDir)) continue;
178
+
179
+ let entries;
180
+ try {
181
+ entries = readdirSync(typeDir);
182
+ } catch {
183
+ continue;
184
+ }
185
+
186
+ for (const entry of entries) {
187
+ if (!entry.endsWith(".md")) continue;
188
+ const filePath = join(typeDir, entry);
189
+ let mtime;
190
+ try {
191
+ mtime = statSync(filePath).mtimeMs;
192
+ } catch {
193
+ continue;
194
+ }
195
+ count += 1;
196
+ if (oldestMtime === null || mtime < oldestMtime) {
197
+ oldestMtime = mtime;
198
+ }
199
+ }
200
+ }
201
+
202
+ return {
203
+ count,
204
+ oldestAgeMs: count > 0 && oldestMtime !== null ? nowMs - oldestMtime : null,
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Count canonical knowledge entries across the five canonical type subdirs
210
+ * (decisions / pitfalls / guidelines / models / processes). Pending entries
211
+ * are NOT counted — they are proposals, not seeded knowledge.
212
+ *
213
+ * Returns the integer count. ENOENT / unreadable subdir → silently treated as
214
+ * zero (preserves never-block-on-failure invariant). Filters on `.md` suffix
215
+ * only; the more-precise canonical filename pattern check is owned by
216
+ * doctor.ts (the hook is a coarse signal, not a lint).
217
+ */
218
+ function countCanonicalNodes(projectRoot) {
219
+ const knowledgeRoot = join(projectRoot, FABRIC_DIR, "knowledge");
220
+ if (!existsSync(knowledgeRoot)) {
221
+ return 0;
222
+ }
223
+ let count = 0;
224
+ for (const type of KNOWLEDGE_CANONICAL_TYPES) {
225
+ const typeDir = join(knowledgeRoot, type);
226
+ if (!existsSync(typeDir)) continue;
227
+ let entries;
228
+ try {
229
+ entries = readdirSync(typeDir);
230
+ } catch {
231
+ continue;
232
+ }
233
+ for (const entry of entries) {
234
+ if (entry.endsWith(".md")) {
235
+ count += 1;
236
+ }
237
+ }
238
+ }
239
+ return count;
240
+ }
241
+
242
+ /**
243
+ * Count edit-counter lines (timestamps) with ts strictly greater than the
244
+ * given anchor ts. Each line in `.fabric/.cache/edit-counter` is one
245
+ * ISO-8601 timestamp written by the rc.6 PreToolUse hook
246
+ * (TASK-020 / E4) per Edit/Write/MultiEdit fire.
247
+ *
248
+ * Safe-degrade contract:
249
+ * - File missing → return 0 (Signal A reverts to 24h-only behaviour)
250
+ * - Line malformed (non-parseable as Date) → skip; other lines still count
251
+ * - Read failure (permission, race) → return 0
252
+ * - anchorTs is null → caller has no anchor event; we still parse but the
253
+ * caller will already short-circuit before invoking us. Returning the
254
+ * full count here is documented behaviour and used by the never-anchor
255
+ * edge case test.
256
+ *
257
+ * NEVER throws — the hook's overarching never-block invariant requires every
258
+ * helper to return a sane value on any I/O or parse error.
259
+ */
260
+ function countEditsSince(projectRoot, anchorTs) {
261
+ const filePath = join(projectRoot, EDIT_COUNTER_FILE_REL);
262
+ if (!existsSync(filePath)) return 0;
263
+ let raw;
264
+ try {
265
+ raw = readFileSync(filePath, "utf8");
266
+ } catch {
267
+ return 0;
268
+ }
269
+ const lines = raw.split(/\r?\n/);
270
+ let count = 0;
271
+ for (const line of lines) {
272
+ const trimmed = line.trim();
273
+ if (trimmed.length === 0) continue;
274
+ // rc.7 T4: support both line shapes —
275
+ // legacy (rc.6): bare ISO-8601 timestamp per line
276
+ // new (rc.7): {"ts":"<iso>","paths":[...]} JSON per line
277
+ let ms = Number.NaN;
278
+ if (trimmed.charCodeAt(0) === 123 /* '{' */) {
279
+ try {
280
+ const obj = JSON.parse(trimmed);
281
+ if (obj && typeof obj === "object" && typeof obj.ts === "string") {
282
+ ms = Date.parse(obj.ts);
283
+ }
284
+ } catch {
285
+ // fall through — malformed JSON, skip line
286
+ }
287
+ } else {
288
+ ms = Date.parse(trimmed);
289
+ }
290
+ if (!Number.isFinite(ms)) continue; // malformed → skip
291
+ if (anchorTs === null || ms > anchorTs) {
292
+ count += 1;
293
+ }
294
+ }
295
+ return count;
296
+ }
297
+
298
+ /**
299
+ * rc.7 T1: detect the `.fabric/.import-requested` sentinel. Best-effort
300
+ * presence check — returns false on any I/O error so a hostile filesystem
301
+ * never blocks the Stop hook on this branch.
302
+ */
303
+ function isImportRequestedSentinelPresent(projectRoot) {
304
+ try {
305
+ return existsSync(join(projectRoot, IMPORT_REQUESTED_SENTINEL_FILE));
306
+ } catch {
307
+ return false;
308
+ }
309
+ }
310
+
311
+ /**
312
+ * rc.7 T1: build the import-recommendation result that the Stop hook emits
313
+ * when the sentinel is present. Reuses the existing Signal C shape so
314
+ * downstream consumers (Cursor `followup_message`, etc.) need no schema
315
+ * change. The reason text reuses the rc.7 T4 人-first banner style.
316
+ */
317
+ function makeImportSentinelResult() {
318
+ const line1 =
319
+ "📋 Fabric: 检测到 fabric init 提示要回灌知识 — 是否调 /fabric-import 从 git 历史和现有文档抽取?";
320
+ return {
321
+ decision: "block",
322
+ reason: line1,
323
+ signal: "import",
324
+ recommended_skill: "fabric-import",
325
+ };
326
+ }
327
+
328
+ /**
329
+ * rc.7 T4: read the edit-counter sidecar and return the top-N most-edited
330
+ * directories (grouped by the leading 2 path segments) since `anchorTs`.
331
+ *
332
+ * Output shape: an ordered array (desc by count) of
333
+ * { dir: "packages/cli", count: 12 }
334
+ * objects, truncated to `topN`. Empty array when no aggregable lines are
335
+ * present (file missing, all lines bare-ISO legacy, all paths bare basenames,
336
+ * unreadable file, etc.). The Signal A banner uses this to render a
337
+ * 人-first "最近活动集中在: ..." overview honest to the hook's actual
338
+ * awareness (PreToolUse paths only — no content/diff peek).
339
+ *
340
+ * Safe-degrade contract:
341
+ * - File missing / unreadable → return []
342
+ * - Line malformed / non-JSON → skip; other lines still aggregate
343
+ * - paths field missing or empty → skip (no signal to add)
344
+ * - Single-segment paths (e.g. "README.md") → grouped under the literal
345
+ * filename so the user still gets *some* signal; multi-segment paths
346
+ * are bucketed by their leading two segments (".fabric/.cache" /
347
+ * "packages/cli" etc.).
348
+ * - anchorTs === null → aggregate over the entire file (matches the
349
+ * fire-counter's "no anchor" branch behaviour).
350
+ *
351
+ * NEVER throws — best-effort.
352
+ */
353
+ function getTopEditedDirectories(projectRoot, topN, anchorTs) {
354
+ const n = typeof topN === "number" && Number.isFinite(topN) && topN > 0
355
+ ? Math.floor(topN)
356
+ : 3;
357
+ const filePath = join(projectRoot, EDIT_COUNTER_FILE_REL);
358
+ if (!existsSync(filePath)) return [];
359
+ let raw;
360
+ try {
361
+ raw = readFileSync(filePath, "utf8");
362
+ } catch {
363
+ return [];
364
+ }
365
+ const lines = raw.split(/\r?\n/);
366
+ const counts = new Map();
367
+ for (const line of lines) {
368
+ const trimmed = line.trim();
369
+ if (trimmed.length === 0) continue;
370
+ // Only the JSON-line shape carries paths. Bare ISO lines (legacy rc.6
371
+ // sidecar) cannot contribute to the activity overview.
372
+ if (trimmed.charCodeAt(0) !== 123 /* '{' */) continue;
373
+ let obj;
374
+ try {
375
+ obj = JSON.parse(trimmed);
376
+ } catch {
377
+ continue;
378
+ }
379
+ if (!obj || typeof obj !== "object") continue;
380
+ // anchor gating mirrors countEditsSince() — strictly newer than anchor.
381
+ if (typeof obj.ts === "string") {
382
+ const ms = Date.parse(obj.ts);
383
+ if (anchorTs !== null && Number.isFinite(ms) && ms <= anchorTs) continue;
384
+ if (anchorTs !== null && !Number.isFinite(ms)) continue;
385
+ } else if (anchorTs !== null) {
386
+ // No parseable ts and an anchor was requested → can't decide, skip.
387
+ continue;
388
+ }
389
+ const paths = Array.isArray(obj.paths) ? obj.paths : [];
390
+ // Within one hook fire we dedupe the same directory bucket so a
391
+ // MultiEdit that touched 5 files under packages/cli/ contributes 1 to
392
+ // the bucket, not 5. The fire-cadence semantic stays consistent.
393
+ const fireBuckets = new Set();
394
+ for (const p of paths) {
395
+ if (typeof p !== "string" || p.length === 0) continue;
396
+ // Normalise to forward-slash for cross-platform stability and strip
397
+ // any leading "./". POSIX-style only — the hook ships under POSIX
398
+ // path conventions even on Windows (the project doesn't currently
399
+ // ship a CRLF/backslash test matrix for the sidecar).
400
+ const norm = p.replace(/\\/g, "/").replace(/^\.\//, "");
401
+ const segs = norm.split("/").filter((s) => s.length > 0);
402
+ let bucket;
403
+ if (segs.length >= 2) {
404
+ // Leading 2 segments: "packages/cli", "docs/decisions", etc. We
405
+ // trail with "/" so the banner reads "packages/cli/" — clearly a
406
+ // directory rather than a file basename.
407
+ bucket = `${segs[0]}/${segs[1]}/`;
408
+ } else if (segs.length === 1) {
409
+ // Single segment — treat the basename as its own bucket. Bare
410
+ // root-level files (README.md, package.json) get some signal too.
411
+ bucket = segs[0];
412
+ } else {
413
+ continue;
414
+ }
415
+ fireBuckets.add(bucket);
416
+ }
417
+ for (const b of fireBuckets) {
418
+ counts.set(b, (counts.get(b) || 0) + 1);
419
+ }
420
+ }
421
+ if (counts.size === 0) return [];
422
+ const sorted = Array.from(counts.entries()).map(([dir, count]) => ({ dir, count }));
423
+ // Sort desc by count; tie-break alphabetically so output is deterministic.
424
+ sorted.sort((a, b) => (b.count - a.count) || (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0));
425
+ return sorted.slice(0, n);
426
+ }
427
+
428
+ /**
429
+ * rc.7 T4: format the "最近活动集中在: <dir1> (N edits), <dir2> (M edits)"
430
+ * fragment used by the Signal A banner. Returns empty string when there is
431
+ * no aggregable activity (so the banner caller can skip the line entirely).
432
+ */
433
+ function formatActivityOverview(projectRoot, anchorTs) {
434
+ const top = getTopEditedDirectories(projectRoot, 3, anchorTs);
435
+ if (top.length === 0) return "";
436
+ return top.map((e) => `${e.dir} (${e.count} edits)`).join(", ");
437
+ }
438
+
439
+ /**
440
+ * Resolve the archive_edit_threshold from .fabric/fabric-config.json,
441
+ * falling back to DEFAULT_ARCHIVE_EDIT_THRESHOLD (20). Any read/parse failure
442
+ * or non-positive value → default. Mirrors readUnderseedThreshold's contract.
443
+ */
444
+ function readArchiveEditThreshold(projectRoot) {
445
+ const configPath = join(projectRoot, FABRIC_DIR, CONFIG_FILE);
446
+ if (!existsSync(configPath)) return DEFAULT_ARCHIVE_EDIT_THRESHOLD;
447
+ try {
448
+ const parsed = JSON.parse(readFileSync(configPath, "utf8"));
449
+ const v = parsed && parsed.archive_edit_threshold;
450
+ if (typeof v === "number" && Number.isFinite(v) && v > 0) return v;
451
+ } catch {
452
+ // fall through to default
453
+ }
454
+ return DEFAULT_ARCHIVE_EDIT_THRESHOLD;
455
+ }
456
+
457
+ /**
458
+ * Decide whether to emit a hook reminder.
459
+ *
460
+ * rc.6 archive signal (TASK-022 / E5 — Signal A, 24h-OR-N-edits):
461
+ * - Trigger when EITHER (a) hours since last knowledge_proposed >= 24,
462
+ * OR (b) edit-counter lines with ts > last-knowledge_proposed >= threshold
463
+ * (default 20).
464
+ * - If no knowledge_proposed event has ever been recorded, Signal A stays
465
+ * silent regardless of edit count (a never-archived workspace is handled
466
+ * by Signal C / import; Signal A needs an anchor event to count from).
467
+ * - The edit-count branch was dropped in rc.5 (TASK-015) because the prior
468
+ * `5 plan_contexts` proxy was inflated by hook auto-fires. rc.6 (TASK-022)
469
+ * reintroduces it on a reliable substrate: the PreToolUse sidecar
470
+ * written by TASK-020 / E4. Missing/malformed edit-counter degrades
471
+ * safely to the 24h-only path.
472
+ *
473
+ * rc.3 review signal (TASK-004 — Signal B):
474
+ * - Trigger when (pending count >= 10) OR (oldest pending mtime age >= 7 days).
475
+ *
476
+ * rc.5 import signal (TASK-010 — Signal C):
477
+ * - Trigger when canonical node count < underseed threshold AND an
478
+ * init_scan_completed event has fired at least 24h ago AND no
479
+ * knowledge_proposed event has fired in the last 24h.
480
+ *
481
+ * Precedence: archive > review > import. Archive wins when both archive AND
482
+ * any other signal fire — recent in-session work is the most urgent reminder.
483
+ * Review wins over import because pending overflow is a sharper backlog signal
484
+ * than a sparse corpus.
485
+ *
486
+ * The `editCounterStats` parameter is the parsed edit-counter view used by
487
+ * the new Signal A edit branch:
488
+ * { editsSinceLastProposed: number, threshold: number }
489
+ * Defaults to { editsSinceLastProposed: 0, threshold: DEFAULT_ARCHIVE_EDIT_THRESHOLD }
490
+ * when omitted — preserves existing tests that don't populate it.
491
+ *
492
+ * Returns one of:
493
+ * - { decision: 'block', reason, signal: 'archive', recommended_skill: 'fabric-archive' }
494
+ * - { decision: 'block', reason, signal: 'review', recommended_skill: 'fabric-review' }
495
+ * - { decision: 'block', reason, signal: 'import', recommended_skill: 'fabric-import' }
496
+ * - null on no trigger
497
+ */
498
+ // rc.7 T7: thresholds is the externalized-config view passed in by main().
499
+ // The shape mirrors the DEFAULT_ constants 1:1 so tests can synthesize it
500
+ // without touching the filesystem. Omitting the arg falls back to documented
501
+ // defaults so existing in-process callers (tests that pre-date T7) still
502
+ // pass without modification — they implicitly exercise the default path.
503
+ function decide(events, now, pendingStats, underseedStats, editCounterStats, thresholds, banner) {
504
+ const nowMs = now instanceof Date ? now.getTime() : Number(now) || Date.now();
505
+ const stats = pendingStats || { count: 0, oldestAgeMs: null };
506
+ const underseed =
507
+ underseedStats || { nodeCount: 0, threshold: DEFAULT_UNDERSEED_NODE_THRESHOLD };
508
+ const editStats =
509
+ editCounterStats || {
510
+ editsSinceLastProposed: 0,
511
+ threshold: DEFAULT_ARCHIVE_EDIT_THRESHOLD,
512
+ };
513
+ const cfg = thresholds || {};
514
+ const archiveHintHours =
515
+ typeof cfg.archiveHintHours === "number" && cfg.archiveHintHours > 0
516
+ ? cfg.archiveHintHours
517
+ : DEFAULT_ARCHIVE_HINT_HOURS;
518
+ const reviewHintPendingCount =
519
+ typeof cfg.reviewHintPendingCount === "number" && cfg.reviewHintPendingCount > 0
520
+ ? cfg.reviewHintPendingCount
521
+ : DEFAULT_REVIEW_HINT_PENDING_COUNT;
522
+ const reviewHintPendingAgeDays =
523
+ typeof cfg.reviewHintPendingAgeDays === "number" && cfg.reviewHintPendingAgeDays > 0
524
+ ? cfg.reviewHintPendingAgeDays
525
+ : DEFAULT_REVIEW_HINT_PENDING_AGE_DAYS;
526
+
527
+ // ---- Archive signal (rc.6 TASK-022 — Signal A, 24h-OR-N-edits) -----------
528
+ // Locate the most-recent knowledge_proposed event. If none exists, Signal A
529
+ // stays silent — a never-archived workspace is the import signal's domain.
530
+ // Edit count without an anchor is meaningless and intentionally ignored.
531
+ let lastProposedTs = null;
532
+ for (let i = events.length - 1; i >= 0; i -= 1) {
533
+ const ev = events[i];
534
+ if (ev && ev.event_type === EVENT_TYPE_PROPOSED && typeof ev.ts === "number") {
535
+ lastProposedTs = ev.ts;
536
+ break;
537
+ }
538
+ }
539
+
540
+ const hoursElapsed =
541
+ lastProposedTs === null ? null : (nowMs - lastProposedTs) / MS_PER_HOUR;
542
+
543
+ const triggerByHours =
544
+ hoursElapsed !== null && hoursElapsed >= archiveHintHours;
545
+ const triggerByEdits =
546
+ lastProposedTs !== null &&
547
+ editStats.editsSinceLastProposed >= editStats.threshold;
548
+
549
+ // PRECEDENCE: archive wins when Signal A fires, regardless of review/import
550
+ // state. The user gets the archive reminder first; other reminders wait
551
+ // until after archive happens.
552
+ if (triggerByHours || triggerByEdits) {
553
+ // rc.7 T4: 人-first banner — the first reader is the human user in the
554
+ // AI client UI, Agent reads incidentally (Q-13). We DROP the prior
555
+ // Agent-jussive imperative ("建议调用 fabric-archive skill ...") in
556
+ // favour of a polite question framing and an honest activity overview
557
+ // from the edit-counter sidecar (Q-6: the hook has zero content
558
+ // awareness, only file-fire awareness — no fabricated "N candidates
559
+ // detected" framing).
560
+ //
561
+ // The activity overview is injected by the caller (main() supplies it
562
+ // via the `banner` arg) so decide() stays pure / filesystem-free for
563
+ // tests. When omitted (legacy callers / tests pre-T4) the overview
564
+ // line is skipped — the banner remains valid 3-or-2 lines depending
565
+ // on data availability.
566
+ //
567
+ // Substring contract preserved for existing tests:
568
+ // - "<hoursElapsed.toFixed(1)>h" (e.g. "25.0h")
569
+ // - "<editCount> 次编辑"
570
+ // - "阈值 <N>"
571
+ // - "fabric-archive"
572
+ const parts = [];
573
+ if (triggerByHours) {
574
+ parts.push(`已过 ${hoursElapsed.toFixed(1)}h(阈值 ${archiveHintHours}h)`);
575
+ }
576
+ if (triggerByEdits) {
577
+ parts.push(
578
+ `累计 ${editStats.editsSinceLastProposed} 次编辑(阈值 ${editStats.threshold})`,
579
+ );
580
+ }
581
+ const line1 = `📋 Fabric: 距上次归档 ${parts.join(" / ")}。`;
582
+ const activity = banner && typeof banner.activityOverview === "string"
583
+ ? banner.activityOverview
584
+ : "";
585
+ const line2 = activity.length > 0
586
+ ? ` 最近活动集中在: ${activity}。`
587
+ : "";
588
+ const line3 = " 是否调 /fabric-archive 检查值得归档的决策/踩坑/复用?";
589
+ const reason = [line1, line2, line3].filter((l) => l.length > 0).join("\n");
590
+ return {
591
+ decision: "block",
592
+ reason,
593
+ signal: "archive",
594
+ recommended_skill: "fabric-archive",
595
+ };
596
+ }
597
+
598
+ // ---- Review signal (rc.3 TASK-004) ---------------------------------------
599
+ const triggerByPendingCount = stats.count >= reviewHintPendingCount;
600
+ const triggerByPendingAge =
601
+ stats.oldestAgeMs !== null && stats.oldestAgeMs / MS_PER_DAY >= reviewHintPendingAgeDays;
602
+
603
+ if (triggerByPendingCount || triggerByPendingAge) {
604
+ // rc.7 T4: 人-first banner reformat for Signal B. Keeps the pending
605
+ // count and age substrings (`${count} 条`, `${days} 天`) so existing
606
+ // tests pass; drops the Agent-jussive "建议调用 ... skill ..." for a
607
+ // polite question framing aimed at the human reader.
608
+ const ageSuffix =
609
+ stats.oldestAgeMs !== null
610
+ ? ` / 最早一条 ${(stats.oldestAgeMs / MS_PER_DAY).toFixed(1)} 天前`
611
+ : "";
612
+ const line1 = `📋 Fabric: 已积累 ${stats.count} 条待审核知识${ageSuffix}。`;
613
+ const line2 = " 是否调 /fabric-review 审核 pending/ 条目?";
614
+ const reason = `${line1}\n${line2}`;
615
+ return {
616
+ decision: "block",
617
+ reason,
618
+ signal: "review",
619
+ recommended_skill: "fabric-review",
620
+ };
621
+ }
622
+
623
+ // ---- Import signal (rc.5 TASK-010) — underseeded corpus -------------------
624
+ // All three conditions must hold (logical AND):
625
+ // 1. node count < threshold (sparse corpus)
626
+ // 2. init_scan_completed event >= 24h ago (workspace has been initialized
627
+ // for at least a day — we don't nag during the immediate post-init
628
+ // window when the user is still authoring baseline knowledge)
629
+ // 3. no knowledge_proposed event in last 24h (user isn't actively
630
+ // archiving — if they were, the archive signal would have fired anyway,
631
+ // but we keep this guard explicit per spec)
632
+ let lastInitScanTs = null;
633
+ for (let i = events.length - 1; i >= 0; i -= 1) {
634
+ const ev = events[i];
635
+ if (
636
+ ev &&
637
+ ev.event_type === EVENT_TYPE_INIT_SCAN_COMPLETED &&
638
+ typeof ev.ts === "number"
639
+ ) {
640
+ lastInitScanTs = ev.ts;
641
+ break;
642
+ }
643
+ }
644
+ const hoursSinceInit =
645
+ lastInitScanTs === null ? null : (nowMs - lastInitScanTs) / MS_PER_HOUR;
646
+ const hoursSinceProposed = hoursElapsed; // reuse archive-signal calc above
647
+ const triggerUnderseed =
648
+ underseed.nodeCount < underseed.threshold &&
649
+ hoursSinceInit !== null &&
650
+ hoursSinceInit >= UNDERSEED_POST_INIT_QUIET_HOURS &&
651
+ (hoursSinceProposed === null || hoursSinceProposed >= UNDERSEED_NO_PROPOSED_HOURS);
652
+
653
+ if (triggerUnderseed) {
654
+ // rc.7 T4: 人-first banner reformat for Signal C. Preserves the
655
+ // `${nodeCount}/${threshold}` substring (e.g. "3/10") that existing
656
+ // tests assert against; drops Agent-jussive phrasing.
657
+ const line1 =
658
+ `📋 Fabric: 知识库节点数 ${underseed.nodeCount}/${underseed.threshold},距 init_scan_completed ${hoursSinceInit.toFixed(1)}h。`;
659
+ const line2 = " 是否调 /fabric-import 从 git 历史与现有文档回灌知识?";
660
+ const reason = `${line1}\n${line2}`;
661
+ return {
662
+ decision: "block",
663
+ reason,
664
+ signal: "import",
665
+ recommended_skill: "fabric-import",
666
+ };
667
+ }
668
+
669
+ return null;
670
+ }
671
+
672
+ // ---------------------------------------------------------------------------
673
+ // rc.7 T7: config readers for the three externalized thresholds + two new
674
+ // maintenance_hint_* fields. All readers share the same contract as the
675
+ // pre-existing readers in this file: synchronous fs read, missing file or
676
+ // malformed JSON → return the documented default, never throw. Caching is
677
+ // not done at the reader layer because each main() invocation reads at
678
+ // most once per field and the file is <1KB.
679
+ // ---------------------------------------------------------------------------
680
+
681
+ function _readConfigNumber(projectRoot, fieldName, defaultValue) {
682
+ const configPath = join(projectRoot, FABRIC_DIR, CONFIG_FILE);
683
+ if (!existsSync(configPath)) return defaultValue;
684
+ try {
685
+ const parsed = JSON.parse(readFileSync(configPath, "utf8"));
686
+ const v = parsed && parsed[fieldName];
687
+ if (typeof v === "number" && Number.isFinite(v) && v > 0) return v;
688
+ } catch {
689
+ // fall through to default
690
+ }
691
+ return defaultValue;
692
+ }
693
+
694
+ function readArchiveHintHours(projectRoot) {
695
+ return _readConfigNumber(projectRoot, "archive_hint_hours", DEFAULT_ARCHIVE_HINT_HOURS);
696
+ }
697
+
698
+ function readReviewHintPendingCount(projectRoot) {
699
+ return _readConfigNumber(
700
+ projectRoot,
701
+ "review_hint_pending_count",
702
+ DEFAULT_REVIEW_HINT_PENDING_COUNT,
703
+ );
704
+ }
705
+
706
+ function readReviewHintPendingAgeDays(projectRoot) {
707
+ return _readConfigNumber(
708
+ projectRoot,
709
+ "review_hint_pending_age_days",
710
+ DEFAULT_REVIEW_HINT_PENDING_AGE_DAYS,
711
+ );
712
+ }
713
+
714
+ function readMaintenanceHintDays(projectRoot) {
715
+ return _readConfigNumber(projectRoot, "maintenance_hint_days", DEFAULT_MAINTENANCE_HINT_DAYS);
716
+ }
717
+
718
+ function readMaintenanceHintCooldownDays(projectRoot) {
719
+ return _readConfigNumber(
720
+ projectRoot,
721
+ "maintenance_hint_cooldown_days",
722
+ DEFAULT_MAINTENANCE_HINT_COOLDOWN_DAYS,
723
+ );
724
+ }
725
+
726
+ /**
727
+ * Resolve the cooldown setting from .fabric/fabric-config.json
728
+ * (archive_hint_cooldown_hours), falling back to DEFAULT_COOLDOWN_HOURS.
729
+ * Any read/parse failure → default (never block on config errors).
730
+ */
731
+ function readCooldownHours(projectRoot) {
732
+ const configPath = join(projectRoot, FABRIC_DIR, CONFIG_FILE);
733
+ if (!existsSync(configPath)) return DEFAULT_COOLDOWN_HOURS;
734
+ try {
735
+ const parsed = JSON.parse(readFileSync(configPath, "utf8"));
736
+ const v = parsed && parsed.archive_hint_cooldown_hours;
737
+ if (typeof v === "number" && Number.isFinite(v) && v > 0) return v;
738
+ } catch {
739
+ // fall through to default
740
+ }
741
+ return DEFAULT_COOLDOWN_HOURS;
742
+ }
743
+
744
+ /**
745
+ * Resolve the underseed-node threshold from .fabric/fabric-config.json
746
+ * (underseed_node_threshold), falling back to DEFAULT_UNDERSEED_NODE_THRESHOLD.
747
+ * Any read/parse failure → default (never block on config errors).
748
+ */
749
+ function readUnderseedThreshold(projectRoot) {
750
+ const configPath = join(projectRoot, FABRIC_DIR, CONFIG_FILE);
751
+ if (!existsSync(configPath)) return DEFAULT_UNDERSEED_NODE_THRESHOLD;
752
+ try {
753
+ const parsed = JSON.parse(readFileSync(configPath, "utf8"));
754
+ const v = parsed && parsed.underseed_node_threshold;
755
+ if (typeof v === "number" && Number.isFinite(v) && v > 0) return v;
756
+ } catch {
757
+ // fall through to default
758
+ }
759
+ return DEFAULT_UNDERSEED_NODE_THRESHOLD;
760
+ }
761
+
762
+ function readShownCache(projectRoot) {
763
+ const cachePath = join(projectRoot, SHOWN_CACHE_FILE);
764
+ if (!existsSync(cachePath)) return {};
765
+ try {
766
+ const parsed = JSON.parse(readFileSync(cachePath, "utf8"));
767
+ return parsed && typeof parsed === "object" ? parsed : {};
768
+ } catch {
769
+ return {};
770
+ }
771
+ }
772
+
773
+ function writeShownCache(projectRoot, cache) {
774
+ const cachePath = join(projectRoot, SHOWN_CACHE_FILE);
775
+ try {
776
+ mkdirSync(dirname(cachePath), { recursive: true });
777
+ writeFileSync(cachePath, JSON.stringify(cache));
778
+ } catch {
779
+ // Silent — cache failure must never block the hook.
780
+ }
781
+ }
782
+
783
+ /**
784
+ * v2.0.0-rc.7 T10: find the most recent doctor_run event ts in the ledger.
785
+ * Returns the ts (epoch ms) of the newest doctor_run event, or null if none
786
+ * has ever fired. Walks the events array tail-first for efficiency (early-out
787
+ * on first match).
788
+ */
789
+ function findLastDoctorRunTs(events) {
790
+ if (!Array.isArray(events)) return null;
791
+ for (let i = events.length - 1; i >= 0; i -= 1) {
792
+ const ev = events[i];
793
+ if (ev && ev.event_type === EVENT_TYPE_DOCTOR_RUN && typeof ev.ts === "number") {
794
+ return ev.ts;
795
+ }
796
+ }
797
+ return null;
798
+ }
799
+
800
+ /**
801
+ * v2.0.0-rc.7 T10: read the Signal-D cooldown sidecar timestamp (epoch ms).
802
+ * Missing file / parse failure → null (allow signal to fire).
803
+ */
804
+ function readMaintenanceLastEmit(projectRoot) {
805
+ const p = join(projectRoot, MAINTENANCE_HINT_LAST_EMIT_FILE);
806
+ if (!existsSync(p)) return null;
807
+ try {
808
+ const raw = readFileSync(p, "utf8").trim();
809
+ if (raw.length === 0) return null;
810
+ const ms = Date.parse(raw);
811
+ if (Number.isFinite(ms)) return ms;
812
+ const asNum = Number(raw);
813
+ if (Number.isFinite(asNum) && asNum > 0) return asNum;
814
+ } catch {
815
+ // ignore
816
+ }
817
+ return null;
818
+ }
819
+
820
+ function writeMaintenanceLastEmit(projectRoot, nowMs) {
821
+ const p = join(projectRoot, MAINTENANCE_HINT_LAST_EMIT_FILE);
822
+ try {
823
+ mkdirSync(dirname(p), { recursive: true });
824
+ writeFileSync(p, new Date(nowMs).toISOString());
825
+ } catch {
826
+ // Silent — sidecar failure must never block the hook.
827
+ }
828
+ }
829
+
830
+ /**
831
+ * v2.0.0-rc.7 T10: Signal D — maintenance hint.
832
+ *
833
+ * Trigger when ALL of the following hold:
834
+ * 1. No doctor_run event has fired in the last `maintenance_hint_days`
835
+ * (default 14), OR no doctor_run event has ever fired.
836
+ * 2. Canonical node count >= MAINTENANCE_HINT_MIN_CANONICAL (default 5).
837
+ * A fresh workspace with no knowledge has nothing to lint.
838
+ * 3. Cooldown: not within `maintenance_hint_cooldown_days` (default 7) of
839
+ * the previous Signal-D emit. Tracked via dedicated sidecar
840
+ * `.fabric/.cache/maintenance-hint-last-emit`.
841
+ *
842
+ * Returns one of:
843
+ * - { decision: 'block', reason, signal: 'maintenance', recommended_skill: null }
844
+ * - null on no trigger
845
+ *
846
+ * `recommended_skill` is intentionally null — the maintenance prompt
847
+ * recommends a CLI invocation (`fabric doctor --lint`), not a Skill, because
848
+ * doctor is a CLI surface (Q-13 boundary). The hook payload still shapes the
849
+ * `recommended_skill` key so consumers can branch on it.
850
+ */
851
+ function evaluateMaintenanceSignal(events, now, canonicalCount, lastEmitMs, thresholds) {
852
+ const nowMs = now instanceof Date ? now.getTime() : Number(now) || Date.now();
853
+ const cfg = thresholds || {};
854
+ const days =
855
+ typeof cfg.maintenanceHintDays === "number" && cfg.maintenanceHintDays > 0
856
+ ? cfg.maintenanceHintDays
857
+ : DEFAULT_MAINTENANCE_HINT_DAYS;
858
+ const cooldownDays =
859
+ typeof cfg.maintenanceHintCooldownDays === "number" && cfg.maintenanceHintCooldownDays > 0
860
+ ? cfg.maintenanceHintCooldownDays
861
+ : DEFAULT_MAINTENANCE_HINT_COOLDOWN_DAYS;
862
+
863
+ if (canonicalCount < MAINTENANCE_HINT_MIN_CANONICAL) {
864
+ return null;
865
+ }
866
+
867
+ // Cooldown gate — short-circuit when we just nagged.
868
+ if (
869
+ typeof lastEmitMs === "number" &&
870
+ Number.isFinite(lastEmitMs) &&
871
+ nowMs - lastEmitMs < cooldownDays * MS_PER_DAY
872
+ ) {
873
+ return null;
874
+ }
875
+
876
+ const lastDoctorTs = findLastDoctorRunTs(events);
877
+ // Build a reason line tailored to the "never" vs "stale" branch so the
878
+ // user sees an honest diagnosis. The Chinese phrasing is contract-locked
879
+ // (T10 spec) — keep it stable across rc.7 patches.
880
+ let ageDays = null;
881
+ if (lastDoctorTs !== null) {
882
+ ageDays = (nowMs - lastDoctorTs) / MS_PER_DAY;
883
+ if (ageDays < days) return null; // doctor ran recently, no nag.
884
+ }
885
+
886
+ // rc.7 T4: keep the existing T10 banner shape (already 人-first with the
887
+ // 📋 prefix), but split the action-prompt onto its own line for visual
888
+ // consistency with Signals A/B/C. Substrings ("从未运行 lint 检查",
889
+ // "已 N 天未跑 lint", "fabric doctor --lint") preserved for the T10 tests.
890
+ const line2 = " 是否调 `fabric doctor --lint` 看看知识库健康度?";
891
+ const reason = lastDoctorTs === null
892
+ ? `📋 Fabric: 从未运行 lint 检查。\n${line2}`
893
+ : `📋 Fabric: 已 ${days} 天未跑 lint 检查(实际 ${ageDays.toFixed(1)}d)。\n${line2}`;
894
+
895
+ return {
896
+ decision: "block",
897
+ reason,
898
+ signal: "maintenance",
899
+ // CLI recommendation rather than Skill — doctor is a CLI surface.
900
+ recommended_skill: null,
901
+ };
902
+ }
903
+
904
+ /**
905
+ * v2.0.0-rc.7 T5: best-effort sync stdin reader for the Stop hook.
906
+ *
907
+ * Claude Code passes a JSON payload via stdin on Stop hook fire (session_id,
908
+ * transcript_path, hook_event_name, etc.). We try to read it synchronously so
909
+ * we can derive a session digest. On any failure (closed stdin, non-TTY where
910
+ * fd 0 is not readable, parse error, foreign client) we degrade silently.
911
+ *
912
+ * Returns the parsed JSON object on success, or null on any error. NEVER
913
+ * throws.
914
+ */
915
+ function tryReadStdinJson() {
916
+ try {
917
+ // Skip the read entirely when stdin is a TTY (interactive invocation, no
918
+ // payload). readFileSync on fd 0 would block forever in that case.
919
+ if (process.stdin.isTTY === true) return null;
920
+ const buf = readFileSync(0, "utf8");
921
+ if (typeof buf !== "string" || buf.trim().length === 0) return null;
922
+ const parsed = JSON.parse(buf);
923
+ if (parsed === null || typeof parsed !== "object") return null;
924
+ return parsed;
925
+ } catch {
926
+ return null;
927
+ }
928
+ }
929
+
930
+ /**
931
+ * v2.0.0-rc.7 T5: extract user_messages + edit_paths + 1-line title from the
932
+ * transcript JSONL referenced by the hook's stdin payload. Best-effort, never
933
+ * throws.
934
+ *
935
+ * Claude Code's transcript_path points at a JSONL where each line is a
936
+ * message envelope. We sniff for `role: "user"` lines (text content) and
937
+ * for tool-use entries naming Edit / Write / MultiEdit to harvest file_path.
938
+ */
939
+ function summarizeTranscript(transcriptPath) {
940
+ const out = { user_messages: [], edit_paths: [], title: "" };
941
+ if (typeof transcriptPath !== "string" || transcriptPath.length === 0) return out;
942
+ if (!existsSync(transcriptPath)) return out;
943
+ let raw;
944
+ try {
945
+ raw = readFileSync(transcriptPath, "utf8");
946
+ } catch {
947
+ return out;
948
+ }
949
+ const lines = raw.split(/\r?\n/);
950
+ for (const line of lines) {
951
+ const trimmed = line.trim();
952
+ if (trimmed.length === 0) continue;
953
+ let envelope;
954
+ try {
955
+ envelope = JSON.parse(trimmed);
956
+ } catch {
957
+ continue;
958
+ }
959
+ if (envelope === null || typeof envelope !== "object") continue;
960
+
961
+ // User text message — Claude Code shape: { role: "user", content: [...] }
962
+ // OR nested under `message.role`. Be generous.
963
+ const role = envelope.role || (envelope.message && envelope.message.role);
964
+ if (role === "user") {
965
+ const content = envelope.content || (envelope.message && envelope.message.content);
966
+ if (typeof content === "string") {
967
+ out.user_messages.push(content);
968
+ } else if (Array.isArray(content)) {
969
+ for (const block of content) {
970
+ if (block && typeof block === "object" && typeof block.text === "string") {
971
+ out.user_messages.push(block.text);
972
+ }
973
+ }
974
+ }
975
+ }
976
+
977
+ // Tool use — look for Edit / Write / MultiEdit and harvest file_path.
978
+ const candidates = [];
979
+ if (envelope.type === "tool_use") candidates.push(envelope);
980
+ const msgContent = envelope.message && envelope.message.content;
981
+ if (Array.isArray(msgContent)) {
982
+ for (const block of msgContent) {
983
+ if (block && block.type === "tool_use") candidates.push(block);
984
+ }
985
+ }
986
+ for (const tu of candidates) {
987
+ const name = tu.name;
988
+ if (name === "Edit" || name === "Write" || name === "MultiEdit") {
989
+ const input = tu.input || tu.parameters || {};
990
+ const fp = input.file_path || input.filePath || input.path;
991
+ if (typeof fp === "string" && fp.length > 0) {
992
+ out.edit_paths.push(fp);
993
+ }
994
+ if (name === "MultiEdit" && Array.isArray(input.edits)) {
995
+ for (const e of input.edits) {
996
+ const f = e && (e.file_path || e.filePath || e.path);
997
+ if (typeof f === "string" && f.length > 0) out.edit_paths.push(f);
998
+ }
999
+ }
1000
+ }
1001
+ }
1002
+ }
1003
+ // 1-line title = first non-empty user message (trimmed). Falls back to "".
1004
+ if (out.user_messages.length > 0) {
1005
+ const first = out.user_messages[0].replace(/\s+/g, " ").trim();
1006
+ out.title = first.slice(0, 80);
1007
+ }
1008
+ // Dedup edit_paths preserving order.
1009
+ const seen = new Set();
1010
+ out.edit_paths = out.edit_paths.filter((p) => {
1011
+ if (seen.has(p)) return false;
1012
+ seen.add(p);
1013
+ return true;
1014
+ });
1015
+ return out;
1016
+ }
1017
+
1018
+ /**
1019
+ * v2.0.0-rc.7 T5: writeSessionDigestBestEffort — non-blocking digest fan-out.
1020
+ * Called from main() before the existing decide() flow. Failure is silently
1021
+ * swallowed; the Stop hook contract remains "never block on hook failure".
1022
+ */
1023
+ function writeSessionDigestBestEffort(projectRoot, stdinPayload) {
1024
+ if (sessionDigestWriter === null) return;
1025
+ if (stdinPayload === null) return;
1026
+ try {
1027
+ const sessionId = stdinPayload.session_id;
1028
+ if (typeof sessionId !== "string" || sessionId.length === 0) return;
1029
+ const transcript = summarizeTranscript(stdinPayload.transcript_path);
1030
+ sessionDigestWriter.writeDigest({
1031
+ projectRoot,
1032
+ session_id: sessionId,
1033
+ title: transcript.title,
1034
+ user_messages: transcript.user_messages,
1035
+ edit_paths: transcript.edit_paths,
1036
+ });
1037
+ } catch {
1038
+ // Best-effort. Stop hook continues.
1039
+ }
1040
+ }
1041
+
1042
+ /**
1043
+ * Main entry — invoked both as a CLI (require.main === module) and in-process by tests.
1044
+ *
1045
+ * Wraps the entire flow in try/catch: ANY error → silent exit 0. The hook MUST NEVER
1046
+ * block tool execution on its own failure (per existing fabric-*-reminder.cjs precedent).
1047
+ */
1048
+ function main(env, stdio) {
1049
+ try {
1050
+ const cwd = (env && env.cwd) || process.cwd();
1051
+ const now = (env && env.now) || new Date();
1052
+ const nowMs = now instanceof Date ? now.getTime() : Number(now) || Date.now();
1053
+ const out = (stdio && stdio.stdout) || process.stdout;
1054
+
1055
+ // v2.0.0-rc.7 T5: session-digest write (best-effort). Tests can inject
1056
+ // a pre-parsed stdin payload via env.stdin_payload so the digest path
1057
+ // is exercised without needing a real stdin pipe.
1058
+ const stdinPayload =
1059
+ (env && env.stdin_payload) !== undefined
1060
+ ? env.stdin_payload
1061
+ : tryReadStdinJson();
1062
+ writeSessionDigestBestEffort(cwd, stdinPayload);
1063
+
1064
+ const events = readLedger(cwd);
1065
+ let pendingStats;
1066
+ try {
1067
+ pendingStats = readPendingStats(cwd, now);
1068
+ } catch {
1069
+ // Defensive — readPendingStats already silences ENOENT/stat errors,
1070
+ // but a defense-in-depth try/catch keeps the never-block invariant.
1071
+ pendingStats = { count: 0, oldestAgeMs: null };
1072
+ }
1073
+ let underseedStats;
1074
+ try {
1075
+ underseedStats = {
1076
+ nodeCount: countCanonicalNodes(cwd),
1077
+ threshold: readUnderseedThreshold(cwd),
1078
+ };
1079
+ } catch {
1080
+ underseedStats = { nodeCount: 0, threshold: DEFAULT_UNDERSEED_NODE_THRESHOLD };
1081
+ }
1082
+
1083
+ // Edit-counter view (rc.6 TASK-022 / E5). We need the last knowledge_proposed
1084
+ // ts to anchor the count; rather than rescanning events here, we mirror
1085
+ // decide()'s scan locally to keep the helper pure. The threshold comes
1086
+ // from fabric-config.json (archive_edit_threshold, default 20).
1087
+ let editCounterStats;
1088
+ try {
1089
+ let anchorTs = null;
1090
+ for (let i = events.length - 1; i >= 0; i -= 1) {
1091
+ const ev = events[i];
1092
+ if (ev && ev.event_type === EVENT_TYPE_PROPOSED && typeof ev.ts === "number") {
1093
+ anchorTs = ev.ts;
1094
+ break;
1095
+ }
1096
+ }
1097
+ editCounterStats = {
1098
+ editsSinceLastProposed: countEditsSince(cwd, anchorTs),
1099
+ threshold: readArchiveEditThreshold(cwd),
1100
+ };
1101
+ } catch {
1102
+ editCounterStats = {
1103
+ editsSinceLastProposed: 0,
1104
+ threshold: DEFAULT_ARCHIVE_EDIT_THRESHOLD,
1105
+ };
1106
+ }
1107
+
1108
+ // rc.7 T7: read the externalized thresholds and pass them into decide.
1109
+ // Reader failures degrade silently to documented defaults — fabric-hint
1110
+ // must never block on config errors (see hook contract above).
1111
+ let thresholds;
1112
+ try {
1113
+ thresholds = {
1114
+ archiveHintHours: readArchiveHintHours(cwd),
1115
+ reviewHintPendingCount: readReviewHintPendingCount(cwd),
1116
+ reviewHintPendingAgeDays: readReviewHintPendingAgeDays(cwd),
1117
+ maintenanceHintDays: readMaintenanceHintDays(cwd),
1118
+ maintenanceHintCooldownDays: readMaintenanceHintCooldownDays(cwd),
1119
+ };
1120
+ } catch {
1121
+ thresholds = {
1122
+ archiveHintHours: DEFAULT_ARCHIVE_HINT_HOURS,
1123
+ reviewHintPendingCount: DEFAULT_REVIEW_HINT_PENDING_COUNT,
1124
+ reviewHintPendingAgeDays: DEFAULT_REVIEW_HINT_PENDING_AGE_DAYS,
1125
+ maintenanceHintDays: DEFAULT_MAINTENANCE_HINT_DAYS,
1126
+ maintenanceHintCooldownDays: DEFAULT_MAINTENANCE_HINT_COOLDOWN_DAYS,
1127
+ };
1128
+ }
1129
+
1130
+ // rc.7 T4: build the 人-first banner activity overview from the
1131
+ // edit-counter sidecar. Anchored at the last knowledge_proposed event
1132
+ // so the overview matches Signal A's "since last archive" semantics.
1133
+ // Failure (missing sidecar, malformed lines, etc.) degrades silently
1134
+ // to an empty string — the banner just omits the activity line.
1135
+ let activityOverview = "";
1136
+ try {
1137
+ let anchorTs = null;
1138
+ for (let i = events.length - 1; i >= 0; i -= 1) {
1139
+ const ev = events[i];
1140
+ if (ev && ev.event_type === EVENT_TYPE_PROPOSED && typeof ev.ts === "number") {
1141
+ anchorTs = ev.ts;
1142
+ break;
1143
+ }
1144
+ }
1145
+ activityOverview = formatActivityOverview(cwd, anchorTs);
1146
+ } catch {
1147
+ activityOverview = "";
1148
+ }
1149
+
1150
+ // rc.7 T1: sentinel-priority pickup. The `.fabric/.import-requested`
1151
+ // file is the cross-surface signal from `fabric init` Y-confirm. When
1152
+ // present, the Stop hook emits a Signal C "import" result regardless of
1153
+ // underseed thresholds, cooldown sidecar state, or precedence with
1154
+ // other signals. This branch sits BEFORE decide() so the import
1155
+ // recommendation always wins until the fabric-import Skill clears the
1156
+ // sentinel in its Phase 3.4. Cooldown sidecar IS bypassed (the
1157
+ // recommendation surface area is intentionally aggressive — the user
1158
+ // explicitly asked for it at init time).
1159
+ const sentinelPresent = isImportRequestedSentinelPresent(cwd);
1160
+
1161
+ let result = sentinelPresent
1162
+ ? makeImportSentinelResult()
1163
+ : decide(
1164
+ events,
1165
+ now,
1166
+ pendingStats,
1167
+ underseedStats,
1168
+ editCounterStats,
1169
+ thresholds,
1170
+ { activityOverview },
1171
+ );
1172
+
1173
+ // v2.0.0-rc.7 T10: Signal D — maintenance hint. Evaluated AFTER A/B/C
1174
+ // because the existing three signals carry higher urgency (in-flight
1175
+ // archive backlog > review backlog > sparse corpus > stale lint). The
1176
+ // maintenance prompt only surfaces when none of the in-flight signals
1177
+ // fire and the corpus has had time to accumulate enough lint surface
1178
+ // for the prompt to be actionable.
1179
+ if (result === null) {
1180
+ try {
1181
+ const lastEmit = readMaintenanceLastEmit(cwd);
1182
+ result = evaluateMaintenanceSignal(
1183
+ events,
1184
+ now,
1185
+ underseedStats.nodeCount,
1186
+ lastEmit,
1187
+ thresholds,
1188
+ );
1189
+ } catch {
1190
+ result = null;
1191
+ }
1192
+ }
1193
+
1194
+ if (result === null) return;
1195
+
1196
+ // v2.0.0-rc.7 T10: Signal D uses its own cooldown sidecar (day-based,
1197
+ // see MAINTENANCE_HINT_LAST_EMIT_FILE). The A/B/C shared cooldown cache
1198
+ // uses hours, so we branch here to avoid mixing semantics.
1199
+ if (result.signal === "maintenance") {
1200
+ out.write(JSON.stringify(result));
1201
+ writeMaintenanceLastEmit(cwd, nowMs);
1202
+ return;
1203
+ }
1204
+
1205
+ // rc.7 T1: sentinel-driven results bypass the cooldown sidecar entirely.
1206
+ // The user explicitly asked at init time for the import recommendation
1207
+ // to surface; the cooldown is a noise-throttle for organic signals,
1208
+ // not for explicit user-driven hand-offs. We also do NOT bump the
1209
+ // cooldown cache when the sentinel fires — that would silence the
1210
+ // *next* organic Signal C unnecessarily.
1211
+ if (sentinelPresent) {
1212
+ out.write(JSON.stringify(result));
1213
+ return;
1214
+ }
1215
+
1216
+ // Cooldown throttle: once a signal fires, stay silent for
1217
+ // archive_hint_cooldown_hours (default 12h) regardless of state drift.
1218
+ // Pure reminder-noise reduction; the underlying trigger logic is unchanged.
1219
+ const cooldownMs = readCooldownHours(cwd) * MS_PER_HOUR;
1220
+ const cache = readShownCache(cwd);
1221
+ const lastShown = cache[result.signal];
1222
+ if (typeof lastShown === "number" && nowMs - lastShown < cooldownMs) {
1223
+ return; // Still in cooldown — silent.
1224
+ }
1225
+
1226
+ out.write(JSON.stringify(result));
1227
+ cache[result.signal] = nowMs;
1228
+ writeShownCache(cwd, cache);
1229
+ } catch {
1230
+ // Silent — never block on hook failure.
1231
+ }
1232
+ }
1233
+
1234
+ module.exports = {
1235
+ main,
1236
+ readLedger,
1237
+ readPendingStats,
1238
+ countCanonicalNodes,
1239
+ countEditsSince,
1240
+ // rc.7 T4: top-edited-directories aggregator + banner overview formatter.
1241
+ getTopEditedDirectories,
1242
+ formatActivityOverview,
1243
+ // rc.7 T1: cross-surface sentinel pickup helpers (exported for testing).
1244
+ isImportRequestedSentinelPresent,
1245
+ makeImportSentinelResult,
1246
+ decide,
1247
+ readCooldownHours,
1248
+ readUnderseedThreshold,
1249
+ readArchiveEditThreshold,
1250
+ // v2.0.0-rc.7 T5: session digest helpers (exported for unit testing).
1251
+ tryReadStdinJson,
1252
+ summarizeTranscript,
1253
+ writeSessionDigestBestEffort,
1254
+ // v2.0.0-rc.7 T10: Signal D helpers (exported for unit testing).
1255
+ evaluateMaintenanceSignal,
1256
+ findLastDoctorRunTs,
1257
+ readMaintenanceLastEmit,
1258
+ writeMaintenanceLastEmit,
1259
+ // rc.7 T7: externalized-threshold readers (3 moved + 2 new for T10).
1260
+ readArchiveHintHours,
1261
+ readReviewHintPendingCount,
1262
+ readReviewHintPendingAgeDays,
1263
+ readMaintenanceHintDays,
1264
+ readMaintenanceHintCooldownDays,
1265
+ readShownCache,
1266
+ writeShownCache,
1267
+ CONSTANTS: {
1268
+ FABRIC_DIR,
1269
+ EVENT_LEDGER_FILE,
1270
+ EVENT_TYPE_PROPOSED,
1271
+ EVENT_TYPE_INIT_SCAN_COMPLETED,
1272
+ // rc.7 T7: legacy aliases kept for back-compat with the existing test
1273
+ // CONSTANTS surface. They point at the same documented defaults the
1274
+ // readers return when the config file is absent — never branch on these
1275
+ // in production code, always go through the readers so a config
1276
+ // override is honored.
1277
+ THRESHOLD_HOURS: DEFAULT_ARCHIVE_HINT_HOURS,
1278
+ THRESHOLD_PENDING_COUNT: DEFAULT_REVIEW_HINT_PENDING_COUNT,
1279
+ THRESHOLD_PENDING_AGE_DAYS: DEFAULT_REVIEW_HINT_PENDING_AGE_DAYS,
1280
+ DEFAULT_ARCHIVE_HINT_HOURS,
1281
+ DEFAULT_REVIEW_HINT_PENDING_COUNT,
1282
+ DEFAULT_REVIEW_HINT_PENDING_AGE_DAYS,
1283
+ DEFAULT_MAINTENANCE_HINT_DAYS,
1284
+ DEFAULT_MAINTENANCE_HINT_COOLDOWN_DAYS,
1285
+ PENDING_DIR,
1286
+ PENDING_TYPES,
1287
+ KNOWLEDGE_CANONICAL_TYPES,
1288
+ DEFAULT_UNDERSEED_NODE_THRESHOLD,
1289
+ UNDERSEED_POST_INIT_QUIET_HOURS,
1290
+ UNDERSEED_NO_PROPOSED_HOURS,
1291
+ CONFIG_FILE,
1292
+ DEFAULT_COOLDOWN_HOURS,
1293
+ SHOWN_CACHE_FILE,
1294
+ EDIT_COUNTER_FILE_REL,
1295
+ DEFAULT_ARCHIVE_EDIT_THRESHOLD,
1296
+ EVENT_TYPE_DOCTOR_RUN,
1297
+ MAINTENANCE_HINT_LAST_EMIT_FILE,
1298
+ MAINTENANCE_HINT_MIN_CANONICAL,
1299
+ // rc.7 T1: cross-surface sentinel for `fabric init` → import-skill hand-off.
1300
+ IMPORT_REQUESTED_SENTINEL_FILE,
1301
+ },
1302
+ };
1303
+
1304
+ if (require.main === module) {
1305
+ main({ cwd: process.cwd(), now: new Date() }, { stdout: process.stdout });
1306
+ process.exit(0);
1307
+ }