@fenglimg/fabric-cli 1.8.0-rc.3 → 2.0.0-rc.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -6
- package/dist/chunk-6ICJICVU.js +10 -0
- package/dist/chunk-74SZWYPH.js +658 -0
- package/dist/chunk-EYIDD2YS.js +1000 -0
- package/dist/{chunk-QPCRBQ5Y.js → chunk-OBQU6NHO.js} +1 -52
- package/dist/chunk-WWNXR34K.js +49 -0
- package/dist/doctor-T7JWODKG.js +282 -0
- package/dist/hooks-Y74Y5LQS.js +12 -0
- package/dist/index.js +7 -5
- package/dist/{init-7EYGUJNJ.js → init-55WZSUK6.js} +312 -1022
- package/dist/plan-context-hint-QMUPAXIB.js +98 -0
- package/dist/scan-LMK3UCWL.js +22 -0
- package/dist/{serve-466QXQ5Q.js → serve-H554BHLG.js} +8 -4
- package/package.json +3 -3
- package/templates/agents-md/AGENTS.md.template +55 -17
- package/templates/bootstrap/CLAUDE.md +1 -1
- package/templates/bootstrap/codex-AGENTS-header.md +1 -1
- package/templates/bootstrap/cursor-fabric-bootstrap.mdc +1 -1
- package/templates/hooks/configs/README.md +73 -0
- package/templates/hooks/configs/claude-code.json +37 -0
- package/templates/hooks/configs/codex-hooks.json +20 -0
- package/templates/hooks/configs/cursor-hooks.json +20 -0
- package/templates/hooks/fabric-hint.cjs +1307 -0
- package/templates/hooks/knowledge-hint-broad.cjs +464 -0
- package/templates/hooks/knowledge-hint-narrow.cjs +826 -0
- package/templates/hooks/lib/session-digest-writer.cjs +172 -0
- package/templates/skills/fabric-archive/SKILL.md +486 -0
- package/templates/skills/fabric-import/SKILL.md +588 -0
- package/templates/skills/fabric-review/SKILL.md +382 -0
- package/dist/chunk-NMMUETVK.js +0 -216
- package/dist/doctor-F52XWWZC.js +0 -98
- package/dist/scan-NNBNGIZG.js +0 -12
- package/templates/agents-md/variants/cocos.md +0 -20
- package/templates/agents-md/variants/next.md +0 -20
- package/templates/agents-md/variants/vite.md +0 -20
- package/templates/bootstrap/GEMINI.md +0 -8
- package/templates/bootstrap/roo-fabric.md +0 -5
- package/templates/bootstrap/windsurf-fabric.md +0 -5
- package/templates/claude-hooks/fabric-init-reminder.cjs +0 -18
- package/templates/claude-skills/fabric-init/SKILL.md +0 -163
- package/templates/codex-hooks/fabric-session-start.cjs +0 -19
- package/templates/codex-hooks/fabric-stop-reminder.cjs +0 -18
- package/templates/codex-skills/fabric-init/SKILL.md +0 -162
- package/templates/husky/pre-commit +0 -9
- package/templates/skill-source/fabric-init/SOURCE.md +0 -157
- 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
|
+
}
|