@fenglimg/fabric-cli 2.0.0-rc.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,464 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * rc.6 TASK-019 (E1) — SessionStart broad-injection hook.
4
+ *
5
+ * Stateless ambient-awareness hook: on every SessionStart event, invokes
6
+ * `fabric plan-context-hint --all` to fetch the workspace's broad-scoped
7
+ * knowledge index, then renders a human-readable summary to stderr so the
8
+ * Agent's session opens with passive awareness of what knowledge exists.
9
+ *
10
+ * No state file. No fingerprint dedup. No cooldown. SessionStart fires once
11
+ * per session boot — the rendering cost is paid exactly that often. The
12
+ * narrow-injection sibling (E2, knowledge-hint-narrow.cjs) handles
13
+ * per-Edit/Write hints with a session-hints cache.
14
+ *
15
+ * Output contract (stderr only):
16
+ *
17
+ * When narrow count <= 30 (full per-type listing mode):
18
+ * [fabric] Session start — N broad-scoped knowledge entries available:
19
+ * [decision] (proven)
20
+ * - <id> · <summary>
21
+ * [pitfall] (verified)
22
+ * - <id> · <summary>
23
+ * ...
24
+ * revision_hash: <hash>
25
+ * Use `fab_get_knowledge_sections` to fetch full content.
26
+ *
27
+ * When narrow count > 30 (grouped-truncation mode, per type):
28
+ * [fabric] Session start — N broad-scoped knowledge entries available (truncated):
29
+ * [decision] proven (3):
30
+ * - <id> · <summary>
31
+ * - ...
32
+ * [decision] verified (12): <id1>, <id2>, ...
33
+ * [decision] draft: 7 entries
34
+ * ...
35
+ * revision_hash: <hash>
36
+ * Use `fab_get_knowledge_sections` to fetch full content.
37
+ *
38
+ * When 0 entries / CLI unavailable / CLI error / parse failure:
39
+ * (no output — silent exit 0)
40
+ *
41
+ * Stdout is intentionally empty: Stop hooks may pollute stdout to signal
42
+ * `decision:block`, but SessionStart is informational, never blocking.
43
+ *
44
+ * Failure invariant: any error path (spawn failure, ENOENT, timeout,
45
+ * JSON.parse throw) MUST end in silent exit 0. The hook never blocks
46
+ * session start on its own malfunction.
47
+ */
48
+
49
+ const { spawnSync } = require("node:child_process");
50
+ const { existsSync, mkdirSync, readFileSync, writeFileSync } = require("node:fs");
51
+ const { dirname, join } = require("node:path");
52
+
53
+ // -----------------------------------------------------------------------------
54
+ // rc.7 T8: SessionStart revision_hash gating.
55
+ //
56
+ // Q-14 problem: every SessionStart re-dumped the full broad knowledge list,
57
+ // causing banner blindness. Solution: hash-of-canonical-graph gating — record
58
+ // the last-emitted `payload.revision_hash` to a sidecar; on subsequent
59
+ // SessionStart fires, compare. Match → silent exit 0 (no re-dump). Mismatch
60
+ // (canonical/ corpus changed → planContext bumps revision_hash) → emit AND
61
+ // update sidecar.
62
+ //
63
+ // The revision_hash is supplied by `fabric plan-context-hint --all`'s JSON
64
+ // payload (carried in payload.revision_hash since rc.5). Reusing the existing
65
+ // hash primitive keeps the gating predicate exactly aligned with the "is the
66
+ // knowledge graph different from last time?" question — no second hashing
67
+ // scheme to maintain. computeRevisionHash() is not needed at this layer; we
68
+ // compare the strings the CLI hands us.
69
+ //
70
+ // rc.7 T1 (sentinel hand-off) overrides this gate: a `.fabric/.import-requested`
71
+ // sentinel forces emission regardless of revision_hash, because the user has
72
+ // asked (via `fabric init` Y-confirm) for the import recommendation to surface
73
+ // on next SessionStart. That branch is layered on top in main() — see T1
74
+ // implementation.
75
+ // -----------------------------------------------------------------------------
76
+
77
+ const FABRIC_DIR_REL = ".fabric";
78
+ const SESSIONSTART_HASH_CACHE_FILE = join(".fabric", ".cache", "sessionstart-last-hash");
79
+
80
+ /**
81
+ * Read the previously-emitted revision_hash from
82
+ * `.fabric/.cache/sessionstart-last-hash`. Missing file / read failure /
83
+ * empty file → null (treat as "no prior emit", forces re-emit).
84
+ *
85
+ * NEVER throws — best-effort read.
86
+ */
87
+ function readSessionStartLastHash(projectRoot) {
88
+ try {
89
+ const p = join(projectRoot, SESSIONSTART_HASH_CACHE_FILE);
90
+ if (!existsSync(p)) return null;
91
+ const raw = readFileSync(p, "utf8").trim();
92
+ return raw.length > 0 ? raw : null;
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Write `hash` to `.fabric/.cache/sessionstart-last-hash` so subsequent
100
+ * SessionStart fires can compare. Creates the directory if missing.
101
+ * Best-effort: any write failure is swallowed so a read-only .fabric/
102
+ * never blocks session start.
103
+ */
104
+ function writeSessionStartLastHash(projectRoot, hash) {
105
+ try {
106
+ if (typeof hash !== "string" || hash.length === 0) return;
107
+ const p = join(projectRoot, SESSIONSTART_HASH_CACHE_FILE);
108
+ mkdirSync(dirname(p), { recursive: true });
109
+ writeFileSync(p, hash, "utf8");
110
+ } catch {
111
+ // Silent — sidecar failure must never block session start.
112
+ }
113
+ }
114
+
115
+ /**
116
+ * rc.7 T1 sentinel pickup: `.fabric/.import-requested` is an empty marker
117
+ * file written by `fabric init` (clack.confirm Y answer) signalling that
118
+ * the user wants the next SessionStart to recommend `fabric-import`.
119
+ *
120
+ * When the sentinel is present, the gate is overridden — the broad-injection
121
+ * banner is appended with the import recommendation line and the
122
+ * revision_hash gate is bypassed entirely (we always want to surface the
123
+ * recommendation until the import Skill clears the sentinel).
124
+ *
125
+ * Best-effort presence check. NEVER throws.
126
+ */
127
+ function isImportRequestedSentinelPresent(projectRoot) {
128
+ try {
129
+ return existsSync(join(projectRoot, FABRIC_DIR_REL, ".import-requested"));
130
+ } catch {
131
+ return false;
132
+ }
133
+ }
134
+
135
+ // -----------------------------------------------------------------------------
136
+ // CONSTANTS
137
+ // -----------------------------------------------------------------------------
138
+
139
+ // Per-type truncation triggers when total narrow entries > 30. The threshold
140
+ // was originally aligned with the rc.5 plan-context degenerate-mode cutoff,
141
+ // which is now retired (rc.7 T9 — see docs/decisions/rc5-a3-superseded.md).
142
+ // We keep 30 here as a stable rendering boundary independent of that protocol
143
+ // change: it's a UI-density choice, not a wire-shape one.
144
+ const TRUNCATION_THRESHOLD = 30;
145
+
146
+ // `fabric plan-context-hint` is a thin wrapper over planContext(); on a
147
+ // well-seeded repo it returns in ~100ms. Two-second cap is defensive — any
148
+ // pathological hang must not stall session start.
149
+ const CLI_TIMEOUT_MS = 2000;
150
+
151
+ // Maximum summary length per entry. Keeps each line bounded so stderr does
152
+ // not blow up terminal width with multi-paragraph summaries from sloppy
153
+ // pending entries. Truncation appends an ellipsis.
154
+ const SUMMARY_MAX_LEN = 80;
155
+
156
+ // Canonical type order — render groups in this sequence so output is stable
157
+ // across runs (Object.keys iteration order is insertion order, but the JSON
158
+ // payload may shuffle if planContext's internal sort changes). Unknown types
159
+ // are appended after canonical types in encounter order.
160
+ const CANONICAL_TYPE_ORDER = [
161
+ "decision",
162
+ "pitfall",
163
+ "guideline",
164
+ "model",
165
+ "process",
166
+ ];
167
+
168
+ // Canonical maturity order for truncation rendering. proven is the highest-
169
+ // signal tier so it gets full per-line treatment; verified gets id-list; draft
170
+ // gets count-only. Unknown maturities fall through to the verified bucket.
171
+ const MATURITY_PROVEN = "proven";
172
+ const MATURITY_VERIFIED = "verified";
173
+ const MATURITY_DRAFT = "draft";
174
+
175
+ // -----------------------------------------------------------------------------
176
+ // CLI invocation
177
+ // -----------------------------------------------------------------------------
178
+
179
+ /**
180
+ * Spawn `fabric plan-context-hint --all` and return parsed JSON. Returns
181
+ * null on any failure (ENOENT, non-zero exit, malformed JSON). Never throws.
182
+ *
183
+ * spawn strategy: try `fabric` first (user-PATH install) then `fab` (the
184
+ * alternate bin name shipped by @fenglimg/fabric-cli). If neither is on PATH,
185
+ * return null — the hook stays silent rather than nagging about install state.
186
+ */
187
+ function invokePlanContextHint(cwd) {
188
+ const candidates = ["fabric", "fab"];
189
+ for (const bin of candidates) {
190
+ let res;
191
+ try {
192
+ res = spawnSync(bin, ["plan-context-hint", "--all"], {
193
+ cwd,
194
+ encoding: "utf8",
195
+ timeout: CLI_TIMEOUT_MS,
196
+ stdio: ["ignore", "pipe", "pipe"],
197
+ });
198
+ } catch {
199
+ continue; // spawn throw (extremely rare) — try next candidate
200
+ }
201
+ // ENOENT surfaces as error on the result object.
202
+ if (res.error || res.status === null || res.status !== 0) continue;
203
+ const raw = (res.stdout || "").trim();
204
+ if (raw.length === 0) continue;
205
+ try {
206
+ const parsed = JSON.parse(raw);
207
+ if (parsed && typeof parsed === "object") return parsed;
208
+ } catch {
209
+ // malformed JSON — try next bin (unlikely to differ, but no harm)
210
+ }
211
+ }
212
+ return null;
213
+ }
214
+
215
+ // -----------------------------------------------------------------------------
216
+ // Rendering
217
+ // -----------------------------------------------------------------------------
218
+
219
+ /**
220
+ * Group narrow entries by type (preserving canonical order), then by maturity
221
+ * within each type. Returns { typeOrder: string[], byType: Map<type, Map<maturity, entries[]>> }.
222
+ */
223
+ function groupEntries(narrow) {
224
+ const byType = new Map();
225
+ const encounterOrder = [];
226
+
227
+ for (const entry of narrow) {
228
+ const type = entry.type || "unknown";
229
+ if (!byType.has(type)) {
230
+ byType.set(type, new Map());
231
+ encounterOrder.push(type);
232
+ }
233
+ const maturity = entry.maturity || "unknown";
234
+ const maturityMap = byType.get(type);
235
+ if (!maturityMap.has(maturity)) maturityMap.set(maturity, []);
236
+ maturityMap.get(maturity).push(entry);
237
+ }
238
+
239
+ // Stable type order: canonical types first (when present), then anything
240
+ // else in encounter order.
241
+ const typeOrder = [];
242
+ for (const t of CANONICAL_TYPE_ORDER) {
243
+ if (byType.has(t)) typeOrder.push(t);
244
+ }
245
+ for (const t of encounterOrder) {
246
+ if (!CANONICAL_TYPE_ORDER.includes(t)) typeOrder.push(t);
247
+ }
248
+
249
+ return { typeOrder, byType };
250
+ }
251
+
252
+ function truncateSummary(raw) {
253
+ const s = typeof raw === "string" ? raw : "";
254
+ // Collapse newlines / runs of whitespace so each entry fits one line.
255
+ const flat = s.replace(/\s+/g, " ").trim();
256
+ if (flat.length <= SUMMARY_MAX_LEN) return flat;
257
+ return `${flat.slice(0, SUMMARY_MAX_LEN - 1)}…`;
258
+ }
259
+
260
+ function formatEntryLine(entry) {
261
+ const id = entry.id || "(no-id)";
262
+ const summary = truncateSummary(entry.summary);
263
+ return summary.length > 0 ? ` - ${id} · ${summary}` : ` - ${id}`;
264
+ }
265
+
266
+ /**
267
+ * Render full per-type listing — used when total narrow entries <= 30.
268
+ * Each entry gets one line: ` - <id> · <summary>`. Type/maturity headers
269
+ * group the listing.
270
+ */
271
+ function renderFull(narrow) {
272
+ const { typeOrder, byType } = groupEntries(narrow);
273
+ const lines = [];
274
+ for (const type of typeOrder) {
275
+ const maturityMap = byType.get(type);
276
+ // Within each type, render maturity buckets in proven > verified > draft
277
+ // > unknown order so the most-trusted entries surface first.
278
+ const maturities = [];
279
+ for (const m of [MATURITY_PROVEN, MATURITY_VERIFIED, MATURITY_DRAFT]) {
280
+ if (maturityMap.has(m)) maturities.push(m);
281
+ }
282
+ for (const m of maturityMap.keys()) {
283
+ if (![MATURITY_PROVEN, MATURITY_VERIFIED, MATURITY_DRAFT].includes(m)) {
284
+ maturities.push(m);
285
+ }
286
+ }
287
+ for (const maturity of maturities) {
288
+ lines.push(` [${type}] (${maturity}):`);
289
+ for (const entry of maturityMap.get(maturity)) {
290
+ lines.push(formatEntryLine(entry));
291
+ }
292
+ }
293
+ }
294
+ return lines;
295
+ }
296
+
297
+ /**
298
+ * Render grouped truncation — used when total narrow entries > 30. Per the
299
+ * task spec: proven entries get full per-line treatment; verified entries get
300
+ * an inline id list (no summary); draft (and unknown) buckets collapse to a
301
+ * count.
302
+ */
303
+ function renderTruncated(narrow) {
304
+ const { typeOrder, byType } = groupEntries(narrow);
305
+ const lines = [];
306
+ for (const type of typeOrder) {
307
+ const maturityMap = byType.get(type);
308
+
309
+ // Proven: full per-line listing.
310
+ const proven = maturityMap.get(MATURITY_PROVEN);
311
+ if (proven && proven.length > 0) {
312
+ lines.push(` [${type}] proven (${proven.length}):`);
313
+ for (const entry of proven) {
314
+ lines.push(formatEntryLine(entry));
315
+ }
316
+ }
317
+
318
+ // Verified: inline id list.
319
+ const verified = maturityMap.get(MATURITY_VERIFIED);
320
+ if (verified && verified.length > 0) {
321
+ const ids = verified.map((e) => e.id || "(no-id)").join(", ");
322
+ lines.push(` [${type}] verified (${verified.length}): ${ids}`);
323
+ }
324
+
325
+ // Draft + any unknown maturity: count-only.
326
+ let countOnly = 0;
327
+ for (const [maturity, entries] of maturityMap.entries()) {
328
+ if (maturity === MATURITY_PROVEN || maturity === MATURITY_VERIFIED) continue;
329
+ countOnly += entries.length;
330
+ }
331
+ if (countOnly > 0) {
332
+ lines.push(` [${type}] draft: ${countOnly} entries`);
333
+ }
334
+ }
335
+ return lines;
336
+ }
337
+
338
+ /**
339
+ * Top-level rendering — picks the mode based on entry count and prepends the
340
+ * session-start banner + appends the revision_hash and usage hint footers.
341
+ *
342
+ * Returns an array of lines (one stderr write per line keeps the formatter
343
+ * trivial and testable). Returns [] when there is nothing meaningful to say
344
+ * (empty narrow set) so callers know to stay silent.
345
+ */
346
+ function renderSummary(payload) {
347
+ const narrow = Array.isArray(payload && payload.narrow) ? payload.narrow : [];
348
+ if (narrow.length === 0) return [];
349
+
350
+ const truncated = narrow.length > TRUNCATION_THRESHOLD;
351
+ const banner = truncated
352
+ ? `[fabric] Session start — ${narrow.length} broad-scoped knowledge entries available (truncated):`
353
+ : `[fabric] Session start — ${narrow.length} broad-scoped knowledge entries available:`;
354
+
355
+ const body = truncated ? renderTruncated(narrow) : renderFull(narrow);
356
+
357
+ const lines = [banner, ...body];
358
+ const revHash = typeof payload.revision_hash === "string" ? payload.revision_hash : null;
359
+ if (revHash !== null && revHash.length > 0) {
360
+ lines.push(` revision_hash: ${revHash}`);
361
+ }
362
+ lines.push(" Use `fab_get_knowledge_sections` to fetch full content.");
363
+ return lines;
364
+ }
365
+
366
+ // -----------------------------------------------------------------------------
367
+ // Main entry — invoked both as a CLI (require.main === module) and in-process
368
+ // by tests. Wraps the entire flow in try/catch: ANY error → silent exit 0.
369
+ // -----------------------------------------------------------------------------
370
+
371
+ function main(env, stdio) {
372
+ try {
373
+ const cwd = (env && env.cwd) || process.cwd();
374
+ const err = (stdio && stdio.stderr) || process.stderr;
375
+
376
+ // Test seam: env.payload short-circuits the CLI spawn so unit tests can
377
+ // feed canned plan-context-hint JSON without depending on a built CLI.
378
+ const payload =
379
+ env && env.payload !== undefined ? env.payload : invokePlanContextHint(cwd);
380
+ if (payload === null || payload === undefined) return; // silent
381
+
382
+ // rc.7 T1: sentinel-override gate. When `.fabric/.import-requested` is
383
+ // present, the import-recommendation banner ALWAYS surfaces regardless
384
+ // of revision_hash equality — the user asked for it on init Y-confirm
385
+ // and the fabric-import Skill is responsible for clearing the sentinel
386
+ // when its Phase 3 completes. The override sits BEFORE the gate so the
387
+ // revision_hash cache is not updated either (we want the
388
+ // recommendation to keep surfacing on subsequent boots until the user
389
+ // actually runs import).
390
+ const sentinelPresent = isImportRequestedSentinelPresent(cwd);
391
+
392
+ // rc.7 T8: revision_hash gate. If the CLI payload carries a stable
393
+ // revision_hash and it matches the previously-emitted hash recorded in
394
+ // the sidecar, the knowledge graph is unchanged since last session →
395
+ // silent exit 0 (no re-dump). The sentinel override above takes
396
+ // precedence and bypasses this gate.
397
+ const currentHash =
398
+ typeof payload.revision_hash === "string" ? payload.revision_hash : "";
399
+ if (!sentinelPresent && currentHash.length > 0) {
400
+ const lastHash = readSessionStartLastHash(cwd);
401
+ if (lastHash !== null && lastHash === currentHash) {
402
+ // Same canonical graph as last session — banner blindness mitigation.
403
+ return;
404
+ }
405
+ }
406
+
407
+ const lines = renderSummary(payload);
408
+
409
+ // rc.7 T1: when the sentinel is present, append the import-recommendation
410
+ // banner. This line is appended whether or not the broad summary had
411
+ // entries — even an empty knowledge graph benefits from the prompt.
412
+ if (sentinelPresent) {
413
+ lines.push(
414
+ " 📋 Fabric: 检测到 fabric init 提示要回灌知识 — 是否调 /fabric-import 从 git 历史和现有文档抽取?",
415
+ );
416
+ }
417
+
418
+ if (lines.length === 0) return; // empty narrow set + no sentinel — silent
419
+
420
+ for (const line of lines) {
421
+ err.write(`${line}\n`);
422
+ }
423
+
424
+ // Update sidecar AFTER successful emit. We only persist the hash when
425
+ // the gate actually let the dump through (i.e. when not sentinel-only).
426
+ // Sentinel-only emits don't bump the cache so the next non-sentinel
427
+ // SessionStart still gets to compare the prior session's true hash.
428
+ if (!sentinelPresent && currentHash.length > 0) {
429
+ writeSessionStartLastHash(cwd, currentHash);
430
+ }
431
+ } catch {
432
+ // Silent — never block session start on hook failure.
433
+ }
434
+ }
435
+
436
+ module.exports = {
437
+ main,
438
+ invokePlanContextHint,
439
+ groupEntries,
440
+ renderFull,
441
+ renderTruncated,
442
+ renderSummary,
443
+ truncateSummary,
444
+ // rc.7 T8: revision_hash gating sidecar helpers (exported for unit testing).
445
+ readSessionStartLastHash,
446
+ writeSessionStartLastHash,
447
+ // rc.7 T1: sentinel-override pickup (exported for unit testing).
448
+ isImportRequestedSentinelPresent,
449
+ CONSTANTS: {
450
+ TRUNCATION_THRESHOLD,
451
+ CLI_TIMEOUT_MS,
452
+ SUMMARY_MAX_LEN,
453
+ CANONICAL_TYPE_ORDER,
454
+ MATURITY_PROVEN,
455
+ MATURITY_VERIFIED,
456
+ MATURITY_DRAFT,
457
+ SESSIONSTART_HASH_CACHE_FILE,
458
+ },
459
+ };
460
+
461
+ if (require.main === module) {
462
+ main({ cwd: process.cwd() }, { stderr: process.stderr });
463
+ process.exit(0);
464
+ }