@fenglimg/fabric-cli 2.0.0-rc.27 → 2.0.0-rc.29

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.
@@ -1,463 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * v2.0.0-rc.25 TASK-03: archive-hint hook — Signal A (archive reminder).
4
- *
5
- * Standalone Signal A archive hook re-established from the rc.2 design.
6
- * fabric-hint.cjs continues to ship the merged archive/review/import flow for
7
- * existing installs; archive-hint.cjs is the rc.25-redesigned variant whose
8
- * reason copy explicitly communicates that the plan_context backlog is
9
- * project-level cross-session debt rather than current-session activity.
10
- *
11
- * Behaviour (compared to the rc.2 baseline):
12
- * 1. Bilingual reason copy. zh-CN: "跨 N 个会话累计 M 次 plan_context · 距上次归档 …
13
- * — 这是项目级长期欠债, 不一定来自本会话。若本会话有产出, 可调用 fabric-archive;
14
- * 否则可忽略, 12h 后再提醒。" English mirror references "project-level long-term
15
- * debt" so callers can grep either side. Language driven by
16
- * `.fabric/fabric-config.json#fabric_language` via readFabricLanguage().
17
- * 2. Distinct-session count via `countDistinctSessions(events, lastProposedTs)`.
18
- * When ≥50% of plan_context events since the watermark carry a `session_id`
19
- * field, the wording reads "跨 N 个会话累计"; otherwise it degrades to
20
- * "跨多个会话累计" (transitional period before TASK-02 fully lands).
21
- * 3. Watermark fallback. When the workspace has never recorded a
22
- * knowledge_proposed event (or rotation cut off the historical watermark),
23
- * decide() uses events[0]?.ts as a virtual watermark and appends a
24
- * "(watermark 已被 rotation 清理)" suffix so operators understand why the
25
- * hours-elapsed display is approximate.
26
- *
27
- * Invariants preserved:
28
- * - stdout JSON shape: { decision: "block", reason, signal: "archive" }
29
- * - Cooldown throttle via `.fabric/.cache/archive-hint-shown.json`
30
- * - Fail-silent: any error → silent exit, NEVER blocks the Stop hook.
31
- */
32
- "use strict";
33
-
34
- const { existsSync, mkdirSync, readFileSync, writeFileSync } = require("node:fs");
35
- const { dirname, join } = require("node:path");
36
-
37
- // CONSTANTS — duplicated from packages/server/src/services/_shared.ts.
38
- // DRY violation accepted: this hook script runs in user repos WITHOUT
39
- // node_modules access, so it cannot import from @fenglimg/fabric-server.
40
- const FABRIC_DIR = ".fabric";
41
- const EVENT_LEDGER_FILE = "events.jsonl";
42
- const EVENT_TYPE_PROPOSED = "knowledge_proposed";
43
- const EVENT_TYPE_PLAN_CONTEXT = "knowledge_context_planned";
44
- const EVENT_TYPE_ROTATED = "events_rotated";
45
- const THRESHOLD_PLAN_CONTEXTS = 5;
46
- const THRESHOLD_HOURS = 24;
47
- const MS_PER_HOUR = 60 * 60 * 1000;
48
-
49
- // rc.25 review remediation: differentiate "rotation cut watermark" from
50
- // "ledger truly fresh (never archived)" when `lastProposedTs === null`.
51
- // The previous wording appended `(watermark 已被 rotation 清理)` in both
52
- // cases, which is misleading for a brand-new project. Heuristic:
53
- // - events.length > ROTATION_HINT_EVENTS_THRESHOLD (>50 events
54
- // accumulated but no knowledge_proposed) → likely rotation cut the
55
- // watermark, OR
56
- // - an explicit `events_rotated` event appears in the ledger → definite
57
- // rotation evidence.
58
- // In either case, keep the legacy `(watermark 已被 rotation 清理)` suffix.
59
- // Otherwise the ledger is genuinely young (e.g. a brand-new project that
60
- // has accumulated a handful of plan_context events but never archived) —
61
- // emit no suffix, since claiming rotation cleared the watermark would
62
- // confuse the operator.
63
- const ROTATION_HINT_EVENTS_THRESHOLD = 50;
64
-
65
- // Cooldown throttle. After the hook surfaces a reminder, it stays silent for
66
- // this many hours — purely a reminder-noise throttle, not a state machine.
67
- // Override via .fabric/fabric-config.json#archive_hint_cooldown_hours.
68
- const CONFIG_FILE = "fabric-config.json";
69
- const DEFAULT_COOLDOWN_HOURS = 12;
70
- const SHOWN_CACHE_FILE = ".fabric/.cache/archive-hint-shown.json";
71
-
72
- // rc.25 TASK-03: session-id coverage threshold. When ≥50% of plan_context
73
- // events since the watermark carry a session_id, surface the distinct-session
74
- // count ("跨 N 个会话"); below that, degrade to "跨多个会话" to avoid lying
75
- // about a partial count during the transitional period before TASK-02 fully
76
- // lands AI session_id propagation.
77
- const SESSION_COVERAGE_THRESHOLD = 0.5;
78
- // rc.25 TASK-03: i18n field name + language enum. Mirrors banner-i18n.cjs's
79
- // readFabricLanguage contract; kept local so this hook stays self-contained.
80
- const FABRIC_LANGUAGE_FIELD = "fabric_language";
81
- const DEFAULT_LANGUAGE = "en";
82
- const VALID_LANGUAGES = ["zh-CN", "en", "zh-CN-hybrid", "match-existing"];
83
-
84
- /**
85
- * Read the events.jsonl ledger from <projectRoot>/.fabric/events.jsonl.
86
- * Mirrors the semantics of readEventLedger in packages/server/src/services/event-ledger.ts:
87
- * - ENOENT → return [] (fabric not initialized)
88
- * - split on /\r?\n/
89
- * - drop final fragment if file lacks trailing newline (partial-tail tolerance)
90
- * - JSON.parse per line, swallow per-line errors (corrupt-line tolerance)
91
- */
92
- function readLedger(projectRoot) {
93
- const eventPath = join(projectRoot, FABRIC_DIR, EVENT_LEDGER_FILE);
94
- if (!existsSync(eventPath)) {
95
- return [];
96
- }
97
-
98
- let raw;
99
- try {
100
- raw = readFileSync(eventPath, "utf8");
101
- } catch {
102
- return [];
103
- }
104
-
105
- const lines = raw.split(/\r?\n/);
106
- const hasTrailingNewline = raw.endsWith("\n");
107
- if (!hasTrailingNewline && lines.length > 0) {
108
- lines.pop();
109
- }
110
-
111
- const events = [];
112
- for (const line of lines) {
113
- const trimmed = line.trim();
114
- if (trimmed.length === 0) continue;
115
- try {
116
- const parsed = JSON.parse(trimmed);
117
- if (parsed && typeof parsed === "object") {
118
- events.push(parsed);
119
- }
120
- } catch {
121
- // corrupt JSON line — drop silently
122
- }
123
- }
124
- return events;
125
- }
126
-
127
- /**
128
- * Count distinct session_id values among knowledge_context_planned events that
129
- * happened AFTER the lastProposedTs watermark (or all such events when the
130
- * watermark is null).
131
- *
132
- * Returns { count, coverage_ratio, total } where:
133
- * - count = number of distinct non-empty session_id strings observed
134
- * - total = number of plan_context events considered
135
- * - coverage_ratio = (events with session_id field) / total, in [0, 1].
136
- * Used by decide() to choose between "跨 N 个会话" (high coverage) and
137
- * "跨多个会话" (degraded — most events lack session_id).
138
- *
139
- * When `total === 0` returns { count: 0, coverage_ratio: 0, total: 0 } — the
140
- * caller is responsible for not invoking the wording in that case.
141
- */
142
- function countDistinctSessions(events, lastProposedTs) {
143
- const sessions = new Set();
144
- let totalConsidered = 0;
145
- let withSessionId = 0;
146
- for (const ev of events) {
147
- if (!ev || ev.event_type !== EVENT_TYPE_PLAN_CONTEXT) continue;
148
- if (typeof ev.ts !== "number") continue;
149
- if (lastProposedTs !== null && ev.ts <= lastProposedTs) continue;
150
- totalConsidered += 1;
151
- if (typeof ev.session_id === "string" && ev.session_id.length > 0) {
152
- withSessionId += 1;
153
- sessions.add(ev.session_id);
154
- }
155
- }
156
- return {
157
- count: sessions.size,
158
- coverage_ratio: totalConsidered === 0 ? 0 : withSessionId / totalConsidered,
159
- total: totalConsidered,
160
- };
161
- }
162
-
163
- /**
164
- * Read `fabric_language` from <projectRoot>/.fabric/fabric-config.json.
165
- * Mirrors lib/banner-i18n.cjs#readFabricLanguage's never-throw contract.
166
- * Missing file / malformed JSON / missing field / unknown variant →
167
- * DEFAULT_LANGUAGE ('en' per rc.25 TASK-03 spec — en is the safe default for
168
- * non-Chinese users; explicit zh-CN config opts in to Chinese copy).
169
- */
170
- function readFabricLanguage(projectRoot) {
171
- if (typeof projectRoot !== "string" || projectRoot.length === 0) {
172
- return DEFAULT_LANGUAGE;
173
- }
174
- const configPath = join(projectRoot, FABRIC_DIR, CONFIG_FILE);
175
- if (!existsSync(configPath)) return DEFAULT_LANGUAGE;
176
- try {
177
- const parsed = JSON.parse(readFileSync(configPath, "utf8"));
178
- const v = parsed && parsed[FABRIC_LANGUAGE_FIELD];
179
- if (typeof v === "string" && VALID_LANGUAGES.indexOf(v) !== -1) {
180
- // Fold zh-CN-hybrid → zh-CN for this hook's two-variant copy (the rc.25
181
- // spec defines zh-CN and en; hybrid uses zh-CN narrative with protected
182
- // tokens, which matches our copy already). match-existing → en per
183
- // UX i18n Policy class 1.
184
- if (v === "zh-CN" || v === "zh-CN-hybrid") return "zh-CN";
185
- if (v === "en") return "en";
186
- return DEFAULT_LANGUAGE; // match-existing
187
- }
188
- } catch {
189
- // fall through to default
190
- }
191
- return DEFAULT_LANGUAGE;
192
- }
193
-
194
- /**
195
- * Render the bilingual two-line reason for an archive-signal trigger.
196
- *
197
- * Inputs:
198
- * - language: 'zh-CN' | 'en' (caller resolves via readFabricLanguage).
199
- * - sessionCount: integer ≥ 1 when distinct-session count is reliable; the
200
- * `useDistinctCount` flag controls whether to render the number or the
201
- * "跨多个会话累计" / "across multiple sessions" degraded phrase.
202
- * - planContextCount: total plan_context events since the watermark.
203
- * - hoursDisplay: pre-formatted hours-elapsed string (e.g. "24.2h" or
204
- * "尚未归档" — caller chooses).
205
- * - useDistinctCount: when true, embed `sessionCount`; when false, use the
206
- * degraded "多个" / "multiple" phrase.
207
- * - watermarkSuffix: optional suffix string ("(watermark 已被 rotation 清理)"
208
- * in zh-CN, "(watermark cleaned by rotation)" in en) appended when the
209
- * historical watermark was rotated away.
210
- */
211
- function buildReason({
212
- language,
213
- sessionCount,
214
- planContextCount,
215
- hoursDisplay,
216
- useDistinctCount,
217
- watermarkSuffix,
218
- }) {
219
- const suffix = watermarkSuffix ? ` ${watermarkSuffix}` : "";
220
- if (language === "zh-CN") {
221
- const sessionPhrase = useDistinctCount
222
- ? `跨 ${sessionCount} 个会话累计`
223
- : "跨多个会话累计";
224
- return (
225
- `${sessionPhrase} ${planContextCount} 次 plan_context · 距上次归档 ${hoursDisplay}${suffix} — 这是项目级长期欠债, 不一定来自本会话。\n` +
226
- `若本会话有产出, 可调用 fabric-archive; 否则可忽略, 12h 后再提醒。`
227
- );
228
- }
229
- // English variant. Preserves the "project-level long-term debt" substring
230
- // so convergence checks can grep either side of the bilingual split.
231
- const sessionPhrase = useDistinctCount
232
- ? `Across ${sessionCount} sessions`
233
- : "Across multiple sessions";
234
- return (
235
- `${sessionPhrase}, ${planContextCount} plan_context calls accumulated · ${hoursDisplay} since last archive${suffix} — this is project-level long-term debt, not necessarily from the current session.\n` +
236
- `If the current session produced something, run fabric-archive; otherwise feel free to ignore — next reminder in 12h.`
237
- );
238
- }
239
-
240
- /**
241
- * Decide whether to emit a hook reminder.
242
- *
243
- * Trigger logic (UNCHANGED from rc.2):
244
- * - Trigger when (plan_context count since last knowledge_proposed >= 5)
245
- * OR (hours since last knowledge_proposed >= 24).
246
- * - If no knowledge_proposed event has ever been recorded, count ALL
247
- * plan_context events and use events[0]?.ts as the virtual watermark
248
- * (rc.25 TASK-03 — fixes the Q3.8 gap where rotation-cut workspaces
249
- * reported `null` hours-elapsed forever).
250
- *
251
- * Returns one of:
252
- * - { decision: 'block', reason, signal: 'archive' } on archive trigger
253
- * - null on no trigger
254
- */
255
- function decide(events, now, language) {
256
- const nowMs = now instanceof Date ? now.getTime() : Number(now) || Date.now();
257
- const lang = language === "zh-CN" || language === "en" ? language : DEFAULT_LANGUAGE;
258
-
259
- // Locate the most-recent knowledge_proposed watermark.
260
- let lastProposedTs = null;
261
- for (let i = events.length - 1; i >= 0; i -= 1) {
262
- const ev = events[i];
263
- if (ev && ev.event_type === EVENT_TYPE_PROPOSED && typeof ev.ts === "number") {
264
- lastProposedTs = ev.ts;
265
- break;
266
- }
267
- }
268
-
269
- // Count plan_context events since the watermark (or all when null).
270
- let planContextCount = 0;
271
- for (const ev of events) {
272
- if (!ev || ev.event_type !== EVENT_TYPE_PLAN_CONTEXT) continue;
273
- if (typeof ev.ts !== "number") continue;
274
- if (lastProposedTs === null || ev.ts > lastProposedTs) {
275
- planContextCount += 1;
276
- }
277
- }
278
-
279
- // rc.25 TASK-03: watermark fallback. When the workspace has never
280
- // recorded knowledge_proposed (or rotation cut it off), use events[0]?.ts
281
- // as the virtual watermark so hoursElapsed is meaningful instead of null.
282
- // We track whether the fallback fired so the reason copy can append a
283
- // breadcrumb explaining the approximation.
284
- //
285
- // rc.25 review remediation: only claim "rotation cut the watermark" when
286
- // there is evidence of rotation (events.length > 50 OR an `events_rotated`
287
- // event appears). For a truly fresh ledger (small, no rotation marker),
288
- // the fallback still fires (so hoursElapsed renders) but the suffix is
289
- // suppressed — claiming rotation in a brand-new project is misleading.
290
- let watermarkFallbackFired = false;
291
- let rotationLikely = false;
292
- let effectiveWatermarkTs = lastProposedTs;
293
- if (lastProposedTs === null) {
294
- const firstEventTs =
295
- events.length > 0 && typeof events[0]?.ts === "number" ? events[0].ts : null;
296
- if (firstEventTs !== null) {
297
- effectiveWatermarkTs = firstEventTs;
298
- watermarkFallbackFired = true;
299
- const hasRotatedEvent = events.some(
300
- (ev) => ev && ev.event_type === EVENT_TYPE_ROTATED,
301
- );
302
- rotationLikely =
303
- events.length > ROTATION_HINT_EVENTS_THRESHOLD || hasRotatedEvent;
304
- }
305
- }
306
-
307
- const hoursElapsed =
308
- effectiveWatermarkTs === null
309
- ? null
310
- : (nowMs - effectiveWatermarkTs) / MS_PER_HOUR;
311
-
312
- const triggerByCount = planContextCount >= THRESHOLD_PLAN_CONTEXTS;
313
- // Hours threshold only applies when a watermark exists AND at least one
314
- // plan_context has happened since (otherwise the user has been idle — no
315
- // knowledge to archive).
316
- const triggerByHours =
317
- hoursElapsed !== null && hoursElapsed >= THRESHOLD_HOURS && planContextCount > 0;
318
-
319
- if (!triggerByCount && !triggerByHours) return null;
320
-
321
- // rc.25 TASK-03: distinct-session count + coverage degrade.
322
- const sessionStats = countDistinctSessions(events, lastProposedTs);
323
- const useDistinctCount =
324
- sessionStats.total > 0 &&
325
- sessionStats.coverage_ratio >= SESSION_COVERAGE_THRESHOLD &&
326
- sessionStats.count > 0;
327
-
328
- const hoursDisplay =
329
- hoursElapsed === null
330
- ? lang === "zh-CN"
331
- ? "尚未归档"
332
- : "never archived"
333
- : `${hoursElapsed.toFixed(1)}h`;
334
-
335
- // Suffix decision (rc.25 review remediation):
336
- // - Fallback fired + rotation evidence → emit rotation-clarification suffix.
337
- // - Fallback fired + truly fresh ledger → no suffix (claiming rotation
338
- // would mislead first-time users).
339
- // - Fallback did not fire (proposed event exists) → no suffix.
340
- const watermarkSuffix =
341
- watermarkFallbackFired && rotationLikely
342
- ? lang === "zh-CN"
343
- ? "(watermark 已被 rotation 清理)"
344
- : "(watermark cleaned by rotation)"
345
- : "";
346
-
347
- const reason = buildReason({
348
- language: lang,
349
- sessionCount: sessionStats.count,
350
- planContextCount,
351
- hoursDisplay,
352
- useDistinctCount,
353
- watermarkSuffix,
354
- });
355
-
356
- return { decision: "block", reason, signal: "archive" };
357
- }
358
-
359
- /**
360
- * Resolve the cooldown setting from .fabric/fabric-config.json
361
- * (archive_hint_cooldown_hours), falling back to DEFAULT_COOLDOWN_HOURS.
362
- * Any read/parse failure → default (never block on config errors).
363
- */
364
- function readCooldownHours(projectRoot) {
365
- const configPath = join(projectRoot, FABRIC_DIR, CONFIG_FILE);
366
- if (!existsSync(configPath)) return DEFAULT_COOLDOWN_HOURS;
367
- try {
368
- const parsed = JSON.parse(readFileSync(configPath, "utf8"));
369
- const v = parsed && parsed.archive_hint_cooldown_hours;
370
- if (typeof v === "number" && Number.isFinite(v) && v > 0) return v;
371
- } catch {
372
- // fall through to default
373
- }
374
- return DEFAULT_COOLDOWN_HOURS;
375
- }
376
-
377
- function readShownCache(projectRoot) {
378
- const cachePath = join(projectRoot, SHOWN_CACHE_FILE);
379
- if (!existsSync(cachePath)) return {};
380
- try {
381
- const parsed = JSON.parse(readFileSync(cachePath, "utf8"));
382
- return parsed && typeof parsed === "object" ? parsed : {};
383
- } catch {
384
- return {};
385
- }
386
- }
387
-
388
- function writeShownCache(projectRoot, cache) {
389
- const cachePath = join(projectRoot, SHOWN_CACHE_FILE);
390
- try {
391
- mkdirSync(dirname(cachePath), { recursive: true });
392
- writeFileSync(cachePath, JSON.stringify(cache));
393
- } catch {
394
- // Silent — cache failure must never block the hook.
395
- }
396
- }
397
-
398
- /**
399
- * Main entry — invoked both as a CLI (require.main === module) and in-process by tests.
400
- *
401
- * Wraps the entire flow in try/catch: ANY error → silent exit 0. The hook MUST NEVER
402
- * block tool execution on its own failure (per existing fabric-*-reminder.cjs precedent).
403
- */
404
- function main(env, stdio) {
405
- try {
406
- const cwd = (env && env.cwd) || process.cwd();
407
- const now = (env && env.now) || new Date();
408
- const nowMs = now instanceof Date ? now.getTime() : Number(now) || Date.now();
409
- const out = (stdio && stdio.stdout) || process.stdout;
410
-
411
- const events = readLedger(cwd);
412
- const language = readFabricLanguage(cwd);
413
- const result = decide(events, now, language);
414
- if (result === null) return;
415
-
416
- // Cooldown throttle: once a signal fires, stay silent for
417
- // archive_hint_cooldown_hours (default 12h) regardless of state drift.
418
- const cooldownMs = readCooldownHours(cwd) * MS_PER_HOUR;
419
- const cache = readShownCache(cwd);
420
- const lastShown = cache[result.signal];
421
- if (typeof lastShown === "number" && nowMs - lastShown < cooldownMs) {
422
- return; // Still in cooldown — silent.
423
- }
424
-
425
- out.write(JSON.stringify(result));
426
- cache[result.signal] = nowMs;
427
- writeShownCache(cwd, cache);
428
- } catch {
429
- // Silent — never block on hook failure.
430
- }
431
- }
432
-
433
- module.exports = {
434
- main,
435
- readLedger,
436
- countDistinctSessions,
437
- readFabricLanguage,
438
- buildReason,
439
- decide,
440
- readCooldownHours,
441
- readShownCache,
442
- writeShownCache,
443
- CONSTANTS: {
444
- FABRIC_DIR,
445
- EVENT_LEDGER_FILE,
446
- EVENT_TYPE_PROPOSED,
447
- EVENT_TYPE_PLAN_CONTEXT,
448
- EVENT_TYPE_ROTATED,
449
- THRESHOLD_PLAN_CONTEXTS,
450
- THRESHOLD_HOURS,
451
- CONFIG_FILE,
452
- DEFAULT_COOLDOWN_HOURS,
453
- SHOWN_CACHE_FILE,
454
- SESSION_COVERAGE_THRESHOLD,
455
- ROTATION_HINT_EVENTS_THRESHOLD,
456
- DEFAULT_LANGUAGE,
457
- },
458
- };
459
-
460
- if (require.main === module) {
461
- main({ cwd: process.cwd(), now: new Date() }, { stdout: process.stdout });
462
- process.exit(0);
463
- }