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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +6 -6
  2. package/dist/{chunk-UHNP7T7W.js → chunk-5MQ52F42.js} +347 -86
  3. package/dist/chunk-6ICJICVU.js +10 -0
  4. package/dist/chunk-AW3G7ZH5.js +576 -0
  5. package/dist/chunk-HQLEHH4O.js +321 -0
  6. package/dist/{chunk-5LOYBXWD.js → chunk-OBQU6NHO.js} +2 -52
  7. package/dist/chunk-WPTA74BY.js +184 -0
  8. package/dist/chunk-WWNXR34K.js +49 -0
  9. package/dist/doctor-RILCO5OG.js +282 -0
  10. package/dist/hooks-NX32PPEN.js +13 -0
  11. package/dist/index.js +8 -5
  12. package/dist/{init-DRHUYHYA.js → init-C56PWHID.js} +225 -491
  13. package/dist/plan-context-hint-QMUPAXIB.js +98 -0
  14. package/dist/{scan-HU2EGITF.js → scan-66EKMNAY.js} +6 -2
  15. package/dist/{serve-3LXXSBFR.js → serve-NGLXHDYC.js} +8 -4
  16. package/dist/uninstall-DBAR2JBS.js +1082 -0
  17. package/package.json +3 -3
  18. package/templates/bootstrap/CLAUDE.md +1 -1
  19. package/templates/bootstrap/codex-AGENTS-header.md +1 -1
  20. package/templates/bootstrap/cursor-fabric-bootstrap.mdc +1 -1
  21. package/templates/hooks/configs/README.md +73 -0
  22. package/templates/hooks/configs/claude-code.json +37 -0
  23. package/templates/hooks/configs/codex-hooks.json +20 -0
  24. package/templates/hooks/configs/cursor-hooks.json +20 -0
  25. package/templates/hooks/fabric-hint.cjs +1337 -0
  26. package/templates/hooks/knowledge-hint-broad.cjs +612 -0
  27. package/templates/hooks/knowledge-hint-narrow.cjs +826 -0
  28. package/templates/hooks/lib/session-digest-writer.cjs +172 -0
  29. package/templates/skills/fabric-archive/SKILL.md +640 -0
  30. package/templates/skills/fabric-import/SKILL.md +850 -0
  31. package/templates/skills/fabric-review/SKILL.md +717 -0
  32. package/dist/doctor-DUHWLAYD.js +0 -98
@@ -0,0 +1,612 @@
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 {
51
+ existsSync,
52
+ mkdirSync,
53
+ readdirSync,
54
+ readFileSync,
55
+ writeFileSync,
56
+ } = require("node:fs");
57
+ const { dirname, join } = require("node:path");
58
+
59
+ // -----------------------------------------------------------------------------
60
+ // rc.7 T8: SessionStart revision_hash gating.
61
+ //
62
+ // Q-14 problem: every SessionStart re-dumped the full broad knowledge list,
63
+ // causing banner blindness. Solution: hash-of-canonical-graph gating — record
64
+ // the last-emitted `payload.revision_hash` to a sidecar; on subsequent
65
+ // SessionStart fires, compare. Match → silent exit 0 (no re-dump). Mismatch
66
+ // (canonical/ corpus changed → planContext bumps revision_hash) → emit AND
67
+ // update sidecar.
68
+ //
69
+ // The revision_hash is supplied by `fabric plan-context-hint --all`'s JSON
70
+ // payload (carried in payload.revision_hash since rc.5). Reusing the existing
71
+ // hash primitive keeps the gating predicate exactly aligned with the "is the
72
+ // knowledge graph different from last time?" question — no second hashing
73
+ // scheme to maintain. computeRevisionHash() is not needed at this layer; we
74
+ // compare the strings the CLI hands us.
75
+ //
76
+ // rc.8 underseed self-check: the retired `.fabric/.import-requested` sentinel
77
+ // mechanism is replaced by a deterministic three-condition probe in
78
+ // shouldRecommendImport(). When the probe says "recommend", a one-line
79
+ // `/fabric-import` banner is appended to the broad-injection output and
80
+ // the revision_hash gate is bypassed FOR THE BANNER ONLY (the broad-summary
81
+ // body itself remains hash-gated). See shouldRecommendImport() below for
82
+ // the full truth table.
83
+ // -----------------------------------------------------------------------------
84
+
85
+ const FABRIC_DIR_REL = ".fabric";
86
+ const SESSIONSTART_HASH_CACHE_FILE = join(".fabric", ".cache", "sessionstart-last-hash");
87
+
88
+ // rc.8 underseed self-check constants (mirror fabric-hint.cjs ~line 76 / 83).
89
+ // Intentionally duplicated inline — hooks are independent .cjs files and
90
+ // cannot `require` each other. If a third hook ever needs the same logic,
91
+ // refactor into packages/cli/templates/hooks/lib/. Keep these values in sync
92
+ // with packages/cli/templates/hooks/fabric-hint.cjs.
93
+ const FABRIC_CONFIG_FILE = "fabric-config.json";
94
+ const AGENTS_META_FILE = "agents.meta.json";
95
+ const IMPORT_STATE_FILE = ".import-state.json";
96
+ const KNOWLEDGE_CANONICAL_TYPES = [
97
+ "decisions",
98
+ "pitfalls",
99
+ "guidelines",
100
+ "models",
101
+ "processes",
102
+ ];
103
+ const DEFAULT_UNDERSEED_NODE_THRESHOLD = 10;
104
+
105
+ /**
106
+ * Read the previously-emitted revision_hash from
107
+ * `.fabric/.cache/sessionstart-last-hash`. Missing file / read failure /
108
+ * empty file → null (treat as "no prior emit", forces re-emit).
109
+ *
110
+ * NEVER throws — best-effort read.
111
+ */
112
+ function readSessionStartLastHash(projectRoot) {
113
+ try {
114
+ const p = join(projectRoot, SESSIONSTART_HASH_CACHE_FILE);
115
+ if (!existsSync(p)) return null;
116
+ const raw = readFileSync(p, "utf8").trim();
117
+ return raw.length > 0 ? raw : null;
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Write `hash` to `.fabric/.cache/sessionstart-last-hash` so subsequent
125
+ * SessionStart fires can compare. Creates the directory if missing.
126
+ * Best-effort: any write failure is swallowed so a read-only .fabric/
127
+ * never blocks session start.
128
+ */
129
+ function writeSessionStartLastHash(projectRoot, hash) {
130
+ try {
131
+ if (typeof hash !== "string" || hash.length === 0) return;
132
+ const p = join(projectRoot, SESSIONSTART_HASH_CACHE_FILE);
133
+ mkdirSync(dirname(p), { recursive: true });
134
+ writeFileSync(p, hash, "utf8");
135
+ } catch {
136
+ // Silent — sidecar failure must never block session start.
137
+ }
138
+ }
139
+
140
+ // -----------------------------------------------------------------------------
141
+ // rc.8 underseed self-check helpers.
142
+ //
143
+ // These three helpers (countCanonicalNodes / readUnderseedThreshold /
144
+ // isImportTouched) are inline copies of the equivalent logic in
145
+ // packages/cli/templates/hooks/fabric-hint.cjs (~lines 218 / 749). Hooks
146
+ // cannot `require` each other (each .cjs is rendered as a standalone template
147
+ // at init time), so duplication is the documented convention. Cross-reference:
148
+ // keep both copies in sync; if a third hook needs the same logic, extract to
149
+ // packages/cli/templates/hooks/lib/.
150
+ // -----------------------------------------------------------------------------
151
+
152
+ /**
153
+ * Count canonical knowledge entries across the five canonical type subdirs
154
+ * (decisions / pitfalls / guidelines / models / processes). Pending entries
155
+ * are NOT counted — they are proposals, not seeded knowledge.
156
+ *
157
+ * Returns the integer count. ENOENT / unreadable subdir → silently treated as
158
+ * zero (preserves never-block-on-failure invariant). Filters on `.md` suffix
159
+ * only; the more-precise canonical filename pattern check is owned by
160
+ * doctor.ts (the hook is a coarse signal, not a lint).
161
+ */
162
+ function countCanonicalNodes(projectRoot) {
163
+ const knowledgeRoot = join(projectRoot, FABRIC_DIR_REL, "knowledge");
164
+ if (!existsSync(knowledgeRoot)) {
165
+ return 0;
166
+ }
167
+ let count = 0;
168
+ for (const type of KNOWLEDGE_CANONICAL_TYPES) {
169
+ const typeDir = join(knowledgeRoot, type);
170
+ if (!existsSync(typeDir)) continue;
171
+ let entries;
172
+ try {
173
+ entries = readdirSync(typeDir);
174
+ } catch {
175
+ continue;
176
+ }
177
+ for (const entry of entries) {
178
+ if (entry.endsWith(".md")) {
179
+ count += 1;
180
+ }
181
+ }
182
+ }
183
+ return count;
184
+ }
185
+
186
+ /**
187
+ * Resolve the underseed-node threshold from .fabric/fabric-config.json
188
+ * (underseed_node_threshold), falling back to DEFAULT_UNDERSEED_NODE_THRESHOLD.
189
+ * Any read/parse failure → default (never block on config errors).
190
+ */
191
+ function readUnderseedThreshold(projectRoot) {
192
+ const configPath = join(projectRoot, FABRIC_DIR_REL, FABRIC_CONFIG_FILE);
193
+ if (!existsSync(configPath)) return DEFAULT_UNDERSEED_NODE_THRESHOLD;
194
+ try {
195
+ const parsed = JSON.parse(readFileSync(configPath, "utf8"));
196
+ const v = parsed && parsed.underseed_node_threshold;
197
+ if (typeof v === "number" && Number.isFinite(v) && v > 0) return v;
198
+ } catch {
199
+ // fall through to default
200
+ }
201
+ return DEFAULT_UNDERSEED_NODE_THRESHOLD;
202
+ }
203
+
204
+ /**
205
+ * Classify the on-disk import lifecycle by reading
206
+ * `.fabric/.import-state.json`. Returns one of:
207
+ * - 'absent' — state file missing → user has NEVER started import
208
+ * - 'in_progress' — file present, phase is anything that is not 'complete'
209
+ * (covers 'P1-done', 'P2-done', 'phase 1', 'in_progress',
210
+ * '1', and any other live-import marker)
211
+ * - 'complete' — file present and phase === 'complete'
212
+ * - 'error' — file present but unreadable / unparseable JSON
213
+ *
214
+ * Recommendation rule (see shouldRecommendImport): only 'absent' triggers a
215
+ * banner — both 'in_progress' (user is actively importing) and 'complete'
216
+ * (user already imported) suppress the banner. 'error' also suppresses
217
+ * (defensive: do not nag when state is unreadable, the user has clearly
218
+ * touched the file).
219
+ */
220
+ function isImportTouched(projectRoot) {
221
+ const statePath = join(projectRoot, FABRIC_DIR_REL, IMPORT_STATE_FILE);
222
+ if (!existsSync(statePath)) return "absent";
223
+ let raw;
224
+ try {
225
+ raw = readFileSync(statePath, "utf8");
226
+ } catch {
227
+ return "error";
228
+ }
229
+ let parsed;
230
+ try {
231
+ parsed = JSON.parse(raw);
232
+ } catch {
233
+ return "error";
234
+ }
235
+ if (!parsed || typeof parsed !== "object") return "error";
236
+ return parsed.phase === "complete" ? "complete" : "in_progress";
237
+ }
238
+
239
+ /**
240
+ * rc.8 underseed self-check: determine whether the SessionStart hook should
241
+ * surface the one-line `/fabric-import` recommendation banner.
242
+ *
243
+ * Three-condition truth table (ALL must hold to return true):
244
+ * 1. `.fabric/agents.meta.json` exists
245
+ * (workspace has been `fabric init`-ed; otherwise the recommendation
246
+ * is meaningless — `fabric-import` requires init's baseline scan).
247
+ * 2. countCanonicalNodes(cwd) < readUnderseedThreshold(cwd)
248
+ * (knowledge graph is sparse — import would meaningfully enrich it).
249
+ * 3. isImportTouched(cwd) === 'absent'
250
+ * (.import-state.json is missing entirely; user has neither started
251
+ * nor completed an import. ANY phase value — including 'in_progress'
252
+ * and 'complete' — returns false because the user has either started
253
+ * or finished.)
254
+ *
255
+ * Best-effort: any unexpected error → return false (do not nag on faults).
256
+ */
257
+ function shouldRecommendImport(projectRoot) {
258
+ try {
259
+ const metaPath = join(projectRoot, FABRIC_DIR_REL, AGENTS_META_FILE);
260
+ if (!existsSync(metaPath)) return false;
261
+
262
+ const threshold = readUnderseedThreshold(projectRoot);
263
+ const nodeCount = countCanonicalNodes(projectRoot);
264
+ if (nodeCount >= threshold) return false;
265
+
266
+ if (isImportTouched(projectRoot) !== "absent") return false;
267
+
268
+ return true;
269
+ } catch {
270
+ return false;
271
+ }
272
+ }
273
+
274
+ // -----------------------------------------------------------------------------
275
+ // CONSTANTS
276
+ // -----------------------------------------------------------------------------
277
+
278
+ // Per-type truncation triggers when total narrow entries > 30. The threshold
279
+ // was originally aligned with the rc.5 plan-context degenerate-mode cutoff,
280
+ // which is now retired (rc.7 T9 — see docs/decisions/rc5-a3-superseded.md).
281
+ // We keep 30 here as a stable rendering boundary independent of that protocol
282
+ // change: it's a UI-density choice, not a wire-shape one.
283
+ const TRUNCATION_THRESHOLD = 30;
284
+
285
+ // `fabric plan-context-hint` is a thin wrapper over planContext(); on a
286
+ // well-seeded repo it returns in ~100ms. Two-second cap is defensive — any
287
+ // pathological hang must not stall session start.
288
+ const CLI_TIMEOUT_MS = 2000;
289
+
290
+ // Maximum summary length per entry. Keeps each line bounded so stderr does
291
+ // not blow up terminal width with multi-paragraph summaries from sloppy
292
+ // pending entries. Truncation appends an ellipsis.
293
+ const SUMMARY_MAX_LEN = 80;
294
+
295
+ // Canonical type order — render groups in this sequence so output is stable
296
+ // across runs (Object.keys iteration order is insertion order, but the JSON
297
+ // payload may shuffle if planContext's internal sort changes). Unknown types
298
+ // are appended after canonical types in encounter order.
299
+ const CANONICAL_TYPE_ORDER = [
300
+ "decision",
301
+ "pitfall",
302
+ "guideline",
303
+ "model",
304
+ "process",
305
+ ];
306
+
307
+ // Canonical maturity order for truncation rendering. proven is the highest-
308
+ // signal tier so it gets full per-line treatment; verified gets id-list; draft
309
+ // gets count-only. Unknown maturities fall through to the verified bucket.
310
+ const MATURITY_PROVEN = "proven";
311
+ const MATURITY_VERIFIED = "verified";
312
+ const MATURITY_DRAFT = "draft";
313
+
314
+ // rc.8 underseed self-check banner text. Single line, mirrors the emoji-prefix
315
+ // style of other Fabric banners (cf. fabric-hint.cjs Signal C `📋 Fabric:`).
316
+ const IMPORT_RECOMMENDATION_BANNER =
317
+ " 📋 Fabric: 知识库稀疏,是否调 /fabric-import 从 git 历史与现有文档回灌知识?";
318
+
319
+ // -----------------------------------------------------------------------------
320
+ // CLI invocation
321
+ // -----------------------------------------------------------------------------
322
+
323
+ /**
324
+ * Spawn `fabric plan-context-hint --all` and return parsed JSON. Returns
325
+ * null on any failure (ENOENT, non-zero exit, malformed JSON). Never throws.
326
+ *
327
+ * spawn strategy: try `fabric` first (user-PATH install) then `fab` (the
328
+ * alternate bin name shipped by @fenglimg/fabric-cli). If neither is on PATH,
329
+ * return null — the hook stays silent rather than nagging about install state.
330
+ */
331
+ function invokePlanContextHint(cwd) {
332
+ const candidates = ["fabric", "fab"];
333
+ for (const bin of candidates) {
334
+ let res;
335
+ try {
336
+ res = spawnSync(bin, ["plan-context-hint", "--all"], {
337
+ cwd,
338
+ encoding: "utf8",
339
+ timeout: CLI_TIMEOUT_MS,
340
+ stdio: ["ignore", "pipe", "pipe"],
341
+ });
342
+ } catch {
343
+ continue; // spawn throw (extremely rare) — try next candidate
344
+ }
345
+ // ENOENT surfaces as error on the result object.
346
+ if (res.error || res.status === null || res.status !== 0) continue;
347
+ const raw = (res.stdout || "").trim();
348
+ if (raw.length === 0) continue;
349
+ try {
350
+ const parsed = JSON.parse(raw);
351
+ if (parsed && typeof parsed === "object") return parsed;
352
+ } catch {
353
+ // malformed JSON — try next bin (unlikely to differ, but no harm)
354
+ }
355
+ }
356
+ return null;
357
+ }
358
+
359
+ // -----------------------------------------------------------------------------
360
+ // Rendering
361
+ // -----------------------------------------------------------------------------
362
+
363
+ /**
364
+ * Group narrow entries by type (preserving canonical order), then by maturity
365
+ * within each type. Returns { typeOrder: string[], byType: Map<type, Map<maturity, entries[]>> }.
366
+ */
367
+ function groupEntries(narrow) {
368
+ const byType = new Map();
369
+ const encounterOrder = [];
370
+
371
+ for (const entry of narrow) {
372
+ const type = entry.type || "unknown";
373
+ if (!byType.has(type)) {
374
+ byType.set(type, new Map());
375
+ encounterOrder.push(type);
376
+ }
377
+ const maturity = entry.maturity || "unknown";
378
+ const maturityMap = byType.get(type);
379
+ if (!maturityMap.has(maturity)) maturityMap.set(maturity, []);
380
+ maturityMap.get(maturity).push(entry);
381
+ }
382
+
383
+ // Stable type order: canonical types first (when present), then anything
384
+ // else in encounter order.
385
+ const typeOrder = [];
386
+ for (const t of CANONICAL_TYPE_ORDER) {
387
+ if (byType.has(t)) typeOrder.push(t);
388
+ }
389
+ for (const t of encounterOrder) {
390
+ if (!CANONICAL_TYPE_ORDER.includes(t)) typeOrder.push(t);
391
+ }
392
+
393
+ return { typeOrder, byType };
394
+ }
395
+
396
+ function truncateSummary(raw) {
397
+ const s = typeof raw === "string" ? raw : "";
398
+ // Collapse newlines / runs of whitespace so each entry fits one line.
399
+ const flat = s.replace(/\s+/g, " ").trim();
400
+ if (flat.length <= SUMMARY_MAX_LEN) return flat;
401
+ return `${flat.slice(0, SUMMARY_MAX_LEN - 1)}…`;
402
+ }
403
+
404
+ function formatEntryLine(entry) {
405
+ const id = entry.id || "(no-id)";
406
+ const summary = truncateSummary(entry.summary);
407
+ return summary.length > 0 ? ` - ${id} · ${summary}` : ` - ${id}`;
408
+ }
409
+
410
+ /**
411
+ * Render full per-type listing — used when total narrow entries <= 30.
412
+ * Each entry gets one line: ` - <id> · <summary>`. Type/maturity headers
413
+ * group the listing.
414
+ */
415
+ function renderFull(narrow) {
416
+ const { typeOrder, byType } = groupEntries(narrow);
417
+ const lines = [];
418
+ for (const type of typeOrder) {
419
+ const maturityMap = byType.get(type);
420
+ // Within each type, render maturity buckets in proven > verified > draft
421
+ // > unknown order so the most-trusted entries surface first.
422
+ const maturities = [];
423
+ for (const m of [MATURITY_PROVEN, MATURITY_VERIFIED, MATURITY_DRAFT]) {
424
+ if (maturityMap.has(m)) maturities.push(m);
425
+ }
426
+ for (const m of maturityMap.keys()) {
427
+ if (![MATURITY_PROVEN, MATURITY_VERIFIED, MATURITY_DRAFT].includes(m)) {
428
+ maturities.push(m);
429
+ }
430
+ }
431
+ for (const maturity of maturities) {
432
+ lines.push(` [${type}] (${maturity}):`);
433
+ for (const entry of maturityMap.get(maturity)) {
434
+ lines.push(formatEntryLine(entry));
435
+ }
436
+ }
437
+ }
438
+ return lines;
439
+ }
440
+
441
+ /**
442
+ * Render grouped truncation — used when total narrow entries > 30. Per the
443
+ * task spec: proven entries get full per-line treatment; verified entries get
444
+ * an inline id list (no summary); draft (and unknown) buckets collapse to a
445
+ * count.
446
+ */
447
+ function renderTruncated(narrow) {
448
+ const { typeOrder, byType } = groupEntries(narrow);
449
+ const lines = [];
450
+ for (const type of typeOrder) {
451
+ const maturityMap = byType.get(type);
452
+
453
+ // Proven: full per-line listing.
454
+ const proven = maturityMap.get(MATURITY_PROVEN);
455
+ if (proven && proven.length > 0) {
456
+ lines.push(` [${type}] proven (${proven.length}):`);
457
+ for (const entry of proven) {
458
+ lines.push(formatEntryLine(entry));
459
+ }
460
+ }
461
+
462
+ // Verified: inline id list.
463
+ const verified = maturityMap.get(MATURITY_VERIFIED);
464
+ if (verified && verified.length > 0) {
465
+ const ids = verified.map((e) => e.id || "(no-id)").join(", ");
466
+ lines.push(` [${type}] verified (${verified.length}): ${ids}`);
467
+ }
468
+
469
+ // Draft + any unknown maturity: count-only.
470
+ let countOnly = 0;
471
+ for (const [maturity, entries] of maturityMap.entries()) {
472
+ if (maturity === MATURITY_PROVEN || maturity === MATURITY_VERIFIED) continue;
473
+ countOnly += entries.length;
474
+ }
475
+ if (countOnly > 0) {
476
+ lines.push(` [${type}] draft: ${countOnly} entries`);
477
+ }
478
+ }
479
+ return lines;
480
+ }
481
+
482
+ /**
483
+ * Top-level rendering — picks the mode based on entry count and prepends the
484
+ * session-start banner + appends the revision_hash and usage hint footers.
485
+ *
486
+ * Returns an array of lines (one stderr write per line keeps the formatter
487
+ * trivial and testable). Returns [] when there is nothing meaningful to say
488
+ * (empty narrow set) so callers know to stay silent.
489
+ */
490
+ function renderSummary(payload) {
491
+ const narrow = Array.isArray(payload && payload.narrow) ? payload.narrow : [];
492
+ if (narrow.length === 0) return [];
493
+
494
+ const truncated = narrow.length > TRUNCATION_THRESHOLD;
495
+ const banner = truncated
496
+ ? `[fabric] Session start — ${narrow.length} broad-scoped knowledge entries available (truncated):`
497
+ : `[fabric] Session start — ${narrow.length} broad-scoped knowledge entries available:`;
498
+
499
+ const body = truncated ? renderTruncated(narrow) : renderFull(narrow);
500
+
501
+ const lines = [banner, ...body];
502
+ const revHash = typeof payload.revision_hash === "string" ? payload.revision_hash : null;
503
+ if (revHash !== null && revHash.length > 0) {
504
+ lines.push(` revision_hash: ${revHash}`);
505
+ }
506
+ lines.push(" Use `fab_get_knowledge_sections` to fetch full content.");
507
+ return lines;
508
+ }
509
+
510
+ // -----------------------------------------------------------------------------
511
+ // Main entry — invoked both as a CLI (require.main === module) and in-process
512
+ // by tests. Wraps the entire flow in try/catch: ANY error → silent exit 0.
513
+ // -----------------------------------------------------------------------------
514
+
515
+ function main(env, stdio) {
516
+ try {
517
+ const cwd = (env && env.cwd) || process.cwd();
518
+ const err = (stdio && stdio.stderr) || process.stderr;
519
+
520
+ // Test seam: env.payload short-circuits the CLI spawn so unit tests can
521
+ // feed canned plan-context-hint JSON without depending on a built CLI.
522
+ const payload =
523
+ env && env.payload !== undefined ? env.payload : invokePlanContextHint(cwd);
524
+ if (payload === null || payload === undefined) return; // silent
525
+
526
+ // rc.8 underseed self-check: decide whether to surface the one-line
527
+ // `/fabric-import` recommendation. The decision is taken BEFORE the
528
+ // revision_hash gate so the banner can bypass it (an unchanged
529
+ // knowledge graph would otherwise hide the recommendation forever).
530
+ // The broad-summary BODY itself remains hash-gated below — only the
531
+ // banner line is unconditionally emitted when the probe says so.
532
+ const recommendImport = shouldRecommendImport(cwd);
533
+
534
+ // rc.7 T8: revision_hash gate. If the CLI payload carries a stable
535
+ // revision_hash and it matches the previously-emitted hash recorded in
536
+ // the sidecar, the knowledge graph is unchanged since last session →
537
+ // suppress the broad-summary body. The import-recommendation banner
538
+ // (when applicable) is still emitted below regardless of this gate.
539
+ const currentHash =
540
+ typeof payload.revision_hash === "string" ? payload.revision_hash : "";
541
+ let bodySuppressed = false;
542
+ if (currentHash.length > 0) {
543
+ const lastHash = readSessionStartLastHash(cwd);
544
+ if (lastHash !== null && lastHash === currentHash) {
545
+ bodySuppressed = true;
546
+ }
547
+ }
548
+
549
+ // Build emitted lines. When the body is hash-suppressed we skip the
550
+ // broad summary entirely; only the import banner (if applicable) goes
551
+ // to stderr in that case.
552
+ const lines = bodySuppressed ? [] : renderSummary(payload);
553
+
554
+ if (recommendImport) {
555
+ lines.push(IMPORT_RECOMMENDATION_BANNER);
556
+ }
557
+
558
+ if (lines.length === 0) return; // nothing to say — silent exit
559
+
560
+ for (const line of lines) {
561
+ err.write(`${line}\n`);
562
+ }
563
+
564
+ // Update sidecar AFTER successful emit. We only persist the hash when
565
+ // the broad-summary body actually went out (i.e. the gate let the body
566
+ // through). If the body was suppressed but the banner emitted on its
567
+ // own, we deliberately do NOT bump the sidecar — the next session
568
+ // should still get to compare against the prior canonical-graph hash
569
+ // and re-emit the body when the graph actually changes.
570
+ if (!bodySuppressed && currentHash.length > 0) {
571
+ writeSessionStartLastHash(cwd, currentHash);
572
+ }
573
+ } catch {
574
+ // Silent — never block session start on hook failure.
575
+ }
576
+ }
577
+
578
+ module.exports = {
579
+ main,
580
+ invokePlanContextHint,
581
+ groupEntries,
582
+ renderFull,
583
+ renderTruncated,
584
+ renderSummary,
585
+ truncateSummary,
586
+ // rc.7 T8: revision_hash gating sidecar helpers (exported for unit testing).
587
+ readSessionStartLastHash,
588
+ writeSessionStartLastHash,
589
+ // rc.8 underseed self-check helpers (exported for unit testing).
590
+ countCanonicalNodes,
591
+ readUnderseedThreshold,
592
+ isImportTouched,
593
+ shouldRecommendImport,
594
+ CONSTANTS: {
595
+ TRUNCATION_THRESHOLD,
596
+ CLI_TIMEOUT_MS,
597
+ SUMMARY_MAX_LEN,
598
+ CANONICAL_TYPE_ORDER,
599
+ MATURITY_PROVEN,
600
+ MATURITY_VERIFIED,
601
+ MATURITY_DRAFT,
602
+ SESSIONSTART_HASH_CACHE_FILE,
603
+ DEFAULT_UNDERSEED_NODE_THRESHOLD,
604
+ KNOWLEDGE_CANONICAL_TYPES,
605
+ IMPORT_RECOMMENDATION_BANNER,
606
+ },
607
+ };
608
+
609
+ if (require.main === module) {
610
+ main({ cwd: process.cwd() }, { stderr: process.stderr });
611
+ process.exit(0);
612
+ }