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

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