@fenglimg/fabric-cli 2.0.0 → 2.1.0-rc.2

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 (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -5
  3. package/dist/chunk-BATF4PEJ.js +361 -0
  4. package/dist/{chunk-OBQU6NHO.js → chunk-COI5VDFU.js} +0 -18
  5. package/dist/chunk-F46ORPOA.js +903 -0
  6. package/dist/chunk-HFQVXY6P.js +86 -0
  7. package/dist/chunk-L4Q55UC4.js +52 -0
  8. package/dist/chunk-LFIKMVY7.js +27 -0
  9. package/dist/chunk-MF3OTILQ.js +544 -0
  10. package/dist/chunk-PWLW3B57.js +18 -0
  11. package/dist/chunk-RYAFBNES.js +33 -0
  12. package/dist/chunk-T5RPGCCM.js +40 -0
  13. package/dist/chunk-WU6GAPKH.js +36 -0
  14. package/dist/config-XJIPZNUP.js +13 -0
  15. package/dist/doctor-QVNPHLJK.js +920 -0
  16. package/dist/index.js +23 -8
  17. package/dist/{init-BIRSIOXO.js → install-2HDO5FTQ.js} +807 -705
  18. package/dist/metrics-ACEQFPDU.js +122 -0
  19. package/dist/onboard-coverage-MFCAEBDO.js +220 -0
  20. package/dist/{plan-context-hint-QMUPAXIB.js → plan-context-hint-FC6P3WFE.js} +34 -28
  21. package/dist/scope-explain-2F2R5URO.js +33 -0
  22. package/dist/status-GLQWLWH6.js +23 -0
  23. package/dist/store-XTSE5TY6.js +105 -0
  24. package/dist/sync-BJCWDPNC.js +245 -0
  25. package/dist/uninstall-TAXSUSKH.js +1073 -0
  26. package/dist/whoami-B6AEMSEV.js +31 -0
  27. package/package.json +30 -5
  28. package/templates/hooks/cite-policy-evict.cjs +231 -0
  29. package/templates/hooks/configs/README.md +29 -6
  30. package/templates/hooks/configs/claude-code.json +14 -3
  31. package/templates/hooks/configs/codex-hooks.json +6 -3
  32. package/templates/hooks/configs/cursor-hooks.json +8 -10
  33. package/templates/hooks/fabric-hint.cjs +873 -105
  34. package/templates/hooks/knowledge-hint-broad.cjs +549 -135
  35. package/templates/hooks/knowledge-hint-narrow.cjs +830 -26
  36. package/templates/hooks/lib/banner-i18n.cjs +309 -0
  37. package/templates/hooks/lib/bindings-snapshot-reader.cjs +81 -0
  38. package/templates/hooks/lib/cite-contract-reminder.cjs +179 -0
  39. package/templates/hooks/lib/cite-line-parser.cjs +180 -0
  40. package/templates/hooks/lib/client-adapter.cjs +106 -0
  41. package/templates/hooks/lib/config-cache.cjs +107 -0
  42. package/templates/hooks/lib/state-store.cjs +84 -0
  43. package/templates/hooks/lib/summary-fallback.cjs +210 -0
  44. package/templates/skills/fabric-archive/SKILL.md +97 -419
  45. package/templates/skills/fabric-archive/ref/dry-run-scope.md +16 -0
  46. package/templates/skills/fabric-archive/ref/e5-cron-recap.md +58 -0
  47. package/templates/skills/fabric-archive/ref/i18n-policy.md +86 -0
  48. package/templates/skills/fabric-archive/ref/phase-0-range-resolution.md +156 -0
  49. package/templates/skills/fabric-archive/ref/phase-1-5-onboard.md +218 -0
  50. package/templates/skills/fabric-archive/ref/phase-1-cross-session.md +62 -0
  51. package/templates/skills/fabric-archive/ref/phase-2-5-viability.md +68 -0
  52. package/templates/skills/fabric-archive/ref/phase-3-5-scope.md +108 -0
  53. package/templates/skills/fabric-archive/ref/phase-3-classify.md +63 -0
  54. package/templates/skills/fabric-archive/ref/phase-4-5-emit.md +78 -0
  55. package/templates/skills/fabric-archive/ref/phase-4-mcp-persist.md +89 -0
  56. package/templates/skills/fabric-archive/ref/rc-history.md +38 -0
  57. package/templates/skills/fabric-archive/ref/worked-examples.md +78 -0
  58. package/templates/skills/fabric-import/SKILL.md +77 -514
  59. package/templates/skills/fabric-import/ref/checkpoint-state.md +85 -0
  60. package/templates/skills/fabric-import/ref/i18n-policy.md +79 -0
  61. package/templates/skills/fabric-import/ref/output-contract.md +61 -0
  62. package/templates/skills/fabric-import/ref/phase-2-mining.md +213 -0
  63. package/templates/skills/fabric-import/ref/phase-3-dedup.md +75 -0
  64. package/templates/skills/fabric-import/ref/state-recovery.md +57 -0
  65. package/templates/skills/fabric-import/ref/worked-examples.md +127 -0
  66. package/templates/skills/fabric-review/SKILL.md +90 -284
  67. package/templates/skills/fabric-review/ref/askuserquestion-policy.md +66 -0
  68. package/templates/skills/fabric-review/ref/i18n-policy.md +111 -0
  69. package/templates/skills/fabric-review/ref/modify-flow.md +103 -0
  70. package/templates/skills/fabric-review/ref/output-contract.md +58 -0
  71. package/templates/skills/fabric-review/ref/per-mode-flows.md +155 -0
  72. package/templates/skills/fabric-review/ref/semantic-check.md +26 -0
  73. package/templates/skills/fabric-review/ref/worked-examples.md +95 -0
  74. package/templates/skills/fabric-sync/SKILL.md +46 -0
  75. package/templates/skills/lib/shared-policy.md +69 -0
  76. package/dist/chunk-6ICJICVU.js +0 -10
  77. package/dist/chunk-74SZWYPH.js +0 -658
  78. package/dist/chunk-EYIDD2YS.js +0 -1000
  79. package/dist/doctor-T7JWODKG.js +0 -282
  80. package/dist/hooks-Y74Y5LQS.js +0 -12
  81. package/dist/scan-LMK3UCWL.js +0 -22
  82. package/dist/serve-H554BHLG.js +0 -124
  83. package/templates/agents-md/AGENTS.md.template +0 -59
  84. package/templates/bootstrap/CLAUDE.md +0 -8
  85. package/templates/bootstrap/codex-AGENTS-header.md +0 -6
  86. package/templates/bootstrap/cursor-fabric-bootstrap.mdc +0 -10
@@ -47,86 +47,277 @@
47
47
  */
48
48
 
49
49
  const { spawnSync } = require("node:child_process");
50
- const { existsSync, mkdirSync, readFileSync, writeFileSync } = require("node:fs");
51
- const { dirname, join } = require("node:path");
50
+ const { existsSync, readdirSync, readFileSync } = require("node:fs");
51
+ const { join } = require("node:path");
52
+
53
+ // rc.16 TASK-003: shared banner-i18n lib (resolves fabric_language config and
54
+ // renders localized banner text). Mirror of the wiring in fabric-hint.cjs
55
+ // (TASK-002). Variant is resolved ONCE per main() invocation via
56
+ // readFabricLanguage(cwd) and threaded into renderBanner — no fs in render path.
57
+ const { renderBanner, readFabricLanguage } = require("./lib/banner-i18n.cjs");
58
+ const { resolveOpaqueSummaries } = require("./lib/summary-fallback.cjs");
59
+ // v2.0.0-rc.37 NEW-19: shared fabric-config reader + sidecar I/O. Replaces the
60
+ // five per-key readFileSync+parse config readers (one parse per fire now) and
61
+ // the bespoke last-emit sidecar helpers. The L78 "refactor into lib/ if a
62
+ // third hook needs it" note is now realised.
63
+ const {
64
+ readConfigNumber,
65
+ readConfigBoolean,
66
+ } = require("./lib/config-cache.cjs");
67
+ const { readTextState, writeTextState } = require("./lib/state-store.cjs");
68
+ // v2.0.0-rc.37 NEW-30: shared client detection (replaces the inline
69
+ // CLAUDE_PROJECT_DIR single-bit check below).
70
+ const { isClaudeCode } = require("./lib/client-adapter.cjs");
71
+ // v2.1.0-rc.1 P4 (F4/S63): hook-side reader for the CLI pre-generated
72
+ // resolved-bindings snapshot. The hook NEVER re-resolves stores or walks store
73
+ // trees — it only echoes the read-set the CLI already computed. Best-effort.
74
+ let bindingsSnapshotReader = null;
75
+ try {
76
+ bindingsSnapshotReader = require("./lib/bindings-snapshot-reader.cjs");
77
+ } catch {
78
+ // Lib missing (old install) — store labels degrade to silent absence.
79
+ }
80
+
81
+ // Read the project's own `project_id` from `.fabric/fabric-config.json` (the
82
+ // snapshot key). Reading the PROJECT config is not a store-tree read — it is how
83
+ // the hook learns which snapshot to fetch. Returns null on any failure.
84
+ function readProjectId(cwd) {
85
+ try {
86
+ const raw = readFileSync(join(cwd, ".fabric", "fabric-config.json"), "utf8");
87
+ const parsed = JSON.parse(raw);
88
+ return typeof parsed.project_id === "string" ? parsed.project_id : null;
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
52
93
 
53
94
  // -----------------------------------------------------------------------------
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.
95
+ // rc.12: SessionStart broad-menu is now unconditionally emitted on every
96
+ // SessionStart fire (matching Skill-style progressive disclosure). Prior
97
+ // versions (rc.5-rc.11) wrote `.fabric/.cache/sessionstart-last-hash` as a
98
+ // revision_hash cooldown sidecar to suppress re-emission on unchanged
99
+ // knowledge graphs; that gate was removed in rc.12. Orphaned sidecar files
100
+ // on existing dogfood repos are harmless dead state and are intentionally
101
+ // NOT cleaned up (zero-user clean-slate no migration logic needed).
75
102
  // -----------------------------------------------------------------------------
76
103
 
77
104
  const FABRIC_DIR_REL = ".fabric";
78
- const SESSIONSTART_HASH_CACHE_FILE = join(".fabric", ".cache", "sessionstart-last-hash");
105
+
106
+ // rc.8 underseed self-check constants (mirror fabric-hint.cjs ~line 76 / 83).
107
+ // Intentionally duplicated inline — hooks are independent .cjs files and
108
+ // cannot `require` each other. If a third hook ever needs the same logic,
109
+ // refactor into packages/cli/templates/hooks/lib/. Keep these values in sync
110
+ // with packages/cli/templates/hooks/fabric-hint.cjs.
111
+ const AGENTS_META_FILE = "agents.meta.json";
112
+ const IMPORT_STATE_FILE = ".import-state.json";
113
+ const KNOWLEDGE_CANONICAL_TYPES = [
114
+ "decisions",
115
+ "pitfalls",
116
+ "guidelines",
117
+ "models",
118
+ "processes",
119
+ ];
120
+ const DEFAULT_UNDERSEED_NODE_THRESHOLD = 10;
121
+
122
+ // v2.0.0-rc.33 W2-1 (P0-9): TopK upper bound on broad-scoped entries surfaced
123
+ // per SessionStart fire. Keeps the banner inside ~1 screenful so the agent
124
+ // actually reads the top-priority entries instead of triaging a wall of text.
125
+ // Overridable via fabric-config.json#hint_broad_top_k (range 1..50).
126
+ const DEFAULT_HINT_BROAD_TOP_K = 8;
127
+
128
+ // v2.0.0-rc.33 W2-5 (P1-8): cooldown (in hours) between broad-hint re-emits.
129
+ // Default 0 preserves rc.32 behavior — every SessionStart re-fires the banner.
130
+ // Cache key uses a separate sidecar from the fabric-hint Signal A/B/C cache
131
+ // so the two cooldowns don't interfere.
132
+ const DEFAULT_HINT_BROAD_COOLDOWN_HOURS = 0;
133
+ const MS_PER_HOUR = 60 * 60 * 1000;
134
+ // v2.0.0-rc.37 NEW-19: state-store resolves this basename under .fabric/.cache/.
135
+ const HINT_BROAD_LAST_EMIT_FILE_NAME = "knowledge-hint-broad-last-emit";
136
+
137
+ // v2.0.0-rc.33 W2-6 (P0-7): when true, emit banner as
138
+ // hookSpecificOutput.additionalContext JSON on stdout (Claude Code PreToolUse
139
+ // contract) so the model receives the reminder in-context. Stderr remains the
140
+ // human-facing channel for logs / breadcrumbs.
141
+ const DEFAULT_HINT_REMINDER_TO_CONTEXT = true;
142
+
143
+ // -----------------------------------------------------------------------------
144
+ // rc.8 underseed self-check helpers.
145
+ //
146
+ // These three helpers (countCanonicalNodes / readUnderseedThreshold /
147
+ // isImportTouched) are inline copies of the equivalent logic in
148
+ // packages/cli/templates/hooks/fabric-hint.cjs (~lines 218 / 749). Hooks
149
+ // cannot `require` each other (each .cjs is rendered as a standalone template
150
+ // at init time), so duplication is the documented convention. Cross-reference:
151
+ // keep both copies in sync; if a third hook needs the same logic, extract to
152
+ // packages/cli/templates/hooks/lib/.
153
+ // -----------------------------------------------------------------------------
79
154
 
80
155
  /**
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).
156
+ * Count canonical knowledge entries across the five canonical type subdirs
157
+ * (decisions / pitfalls / guidelines / models / processes). Pending entries
158
+ * are NOT counted they are proposals, not seeded knowledge.
84
159
  *
85
- * NEVER throws best-effort read.
160
+ * Returns the integer count. ENOENT / unreadable subdir → silently treated as
161
+ * zero (preserves never-block-on-failure invariant). Filters on `.md` suffix
162
+ * only; the more-precise canonical filename pattern check is owned by
163
+ * doctor.ts (the hook is a coarse signal, not a lint).
86
164
  */
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;
165
+ function countCanonicalNodes(projectRoot) {
166
+ const knowledgeRoot = join(projectRoot, FABRIC_DIR_REL, "knowledge");
167
+ if (!existsSync(knowledgeRoot)) {
168
+ return 0;
95
169
  }
170
+ let count = 0;
171
+ for (const type of KNOWLEDGE_CANONICAL_TYPES) {
172
+ const typeDir = join(knowledgeRoot, type);
173
+ if (!existsSync(typeDir)) continue;
174
+ let entries;
175
+ try {
176
+ entries = readdirSync(typeDir);
177
+ } catch {
178
+ continue;
179
+ }
180
+ for (const entry of entries) {
181
+ if (entry.endsWith(".md")) {
182
+ count += 1;
183
+ }
184
+ }
185
+ }
186
+ return count;
187
+ }
188
+
189
+ /**
190
+ * Resolve the underseed-node threshold from .fabric/fabric-config.json
191
+ * (underseed_node_threshold), falling back to DEFAULT_UNDERSEED_NODE_THRESHOLD.
192
+ * Any read/parse failure → default (never block on config errors).
193
+ */
194
+ function readUnderseedThreshold(projectRoot) {
195
+ // > 0 guard via min: Number.MIN_VALUE (any positive). config-cache returns
196
+ // the parsed number when finite & in-range, else the default.
197
+ return readConfigNumber(projectRoot, "underseed_node_threshold", DEFAULT_UNDERSEED_NODE_THRESHOLD, {
198
+ min: Number.MIN_VALUE,
199
+ });
200
+ }
201
+
202
+ /**
203
+ * v2.0.0-rc.33 W2-1: resolve hint_broad_top_k from fabric-config.json. Slices
204
+ * the broad entry list to TopK before group/truncation render. Validates the
205
+ * schema's 1..50 range inline so a malformed config silently falls back.
206
+ */
207
+ function readBroadTopK(projectRoot) {
208
+ return readConfigNumber(projectRoot, "hint_broad_top_k", DEFAULT_HINT_BROAD_TOP_K, {
209
+ min: 1,
210
+ max: 50,
211
+ floor: true,
212
+ });
96
213
  }
97
214
 
98
215
  /**
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.
216
+ * v2.0.0-rc.33 W2-5: resolve hint_broad_cooldown_hours. Schema clamps 0..168;
217
+ * 0 means "no cooldown" (re-emit on every SessionStart, rc.32 behavior).
103
218
  */
104
- function writeSessionStartLastHash(projectRoot, hash) {
219
+ function readBroadCooldownHours(projectRoot) {
220
+ return readConfigNumber(projectRoot, "hint_broad_cooldown_hours", DEFAULT_HINT_BROAD_COOLDOWN_HOURS, {
221
+ min: 0,
222
+ max: 168,
223
+ });
224
+ }
225
+
226
+ /**
227
+ * v2.0.0-rc.33 W2-6: resolve hint_reminder_to_context. Boolean flag — when
228
+ * true (default) the hook writes a Claude-Code-shaped JSON envelope to stdout
229
+ * carrying the banner under hookSpecificOutput.additionalContext so the model
230
+ * receives the reminder in-context. Stderr stays informational either way.
231
+ */
232
+ function readReminderToContext(projectRoot) {
233
+ return readConfigBoolean(projectRoot, "hint_reminder_to_context", DEFAULT_HINT_REMINDER_TO_CONTEXT);
234
+ }
235
+
236
+ /**
237
+ * v2.0.0-rc.33 W2-5: read/write the broad-hint last-emit timestamp sidecar.
238
+ * Distinct from fabric-hint's shown-cache so signal cooldowns stay isolated.
239
+ * Returns epoch ms or null when missing/unreadable.
240
+ */
241
+ function readBroadLastEmit(projectRoot) {
242
+ const raw = readTextState(projectRoot, HINT_BROAD_LAST_EMIT_FILE_NAME);
243
+ if (raw === null || raw.length === 0) return null;
244
+ const asNum = Number(raw);
245
+ if (Number.isFinite(asNum) && asNum > 0) return asNum;
246
+ const ms = Date.parse(raw);
247
+ if (Number.isFinite(ms)) return ms;
248
+ return null;
249
+ }
250
+
251
+ function writeBroadLastEmit(projectRoot, nowMs) {
252
+ // Silent — sidecar failure must never block session start.
253
+ writeTextState(projectRoot, HINT_BROAD_LAST_EMIT_FILE_NAME, String(nowMs));
254
+ }
255
+
256
+ /**
257
+ * Classify the on-disk import lifecycle by reading
258
+ * `.fabric/.import-state.json`. Returns one of:
259
+ * - 'absent' — state file missing → user has NEVER started import
260
+ * - 'in_progress' — file present, phase is anything that is not 'complete'
261
+ * (covers 'P1-done', 'P2-done', 'phase 1', 'in_progress',
262
+ * '1', and any other live-import marker)
263
+ * - 'complete' — file present and phase === 'complete'
264
+ * - 'error' — file present but unreadable / unparseable JSON
265
+ *
266
+ * Recommendation rule (see shouldRecommendImport): only 'absent' triggers a
267
+ * banner — both 'in_progress' (user is actively importing) and 'complete'
268
+ * (user already imported) suppress the banner. 'error' also suppresses
269
+ * (defensive: do not nag when state is unreadable, the user has clearly
270
+ * touched the file).
271
+ */
272
+ function isImportTouched(projectRoot) {
273
+ const statePath = join(projectRoot, FABRIC_DIR_REL, IMPORT_STATE_FILE);
274
+ if (!existsSync(statePath)) return "absent";
275
+ let raw;
105
276
  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");
277
+ raw = readFileSync(statePath, "utf8");
110
278
  } catch {
111
- // Silent — sidecar failure must never block session start.
279
+ return "error";
112
280
  }
281
+ let parsed;
282
+ try {
283
+ parsed = JSON.parse(raw);
284
+ } catch {
285
+ return "error";
286
+ }
287
+ if (!parsed || typeof parsed !== "object") return "error";
288
+ return parsed.phase === "complete" ? "complete" : "in_progress";
113
289
  }
114
290
 
115
291
  /**
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`.
292
+ * rc.8 underseed self-check: determine whether the SessionStart hook should
293
+ * surface the one-line `/fabric-import` recommendation banner.
119
294
  *
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).
295
+ * Three-condition truth table (ALL must hold to return true):
296
+ * 1. `.fabric/agents.meta.json` exists
297
+ * (workspace has been `fabric init`-ed; otherwise the recommendation
298
+ * is meaningless `fabric-import` requires init's baseline scan).
299
+ * 2. countCanonicalNodes(cwd) < readUnderseedThreshold(cwd)
300
+ * (knowledge graph is sparse — import would meaningfully enrich it).
301
+ * 3. isImportTouched(cwd) === 'absent'
302
+ * (.import-state.json is missing entirely; user has neither started
303
+ * nor completed an import. ANY phase value — including 'in_progress'
304
+ * and 'complete' — returns false because the user has either started
305
+ * or finished.)
124
306
  *
125
- * Best-effort presence check. NEVER throws.
307
+ * Best-effort: any unexpected error → return false (do not nag on faults).
126
308
  */
127
- function isImportRequestedSentinelPresent(projectRoot) {
309
+ function shouldRecommendImport(projectRoot) {
128
310
  try {
129
- return existsSync(join(projectRoot, FABRIC_DIR_REL, ".import-requested"));
311
+ const metaPath = join(projectRoot, FABRIC_DIR_REL, AGENTS_META_FILE);
312
+ if (!existsSync(metaPath)) return false;
313
+
314
+ const threshold = readUnderseedThreshold(projectRoot);
315
+ const nodeCount = countCanonicalNodes(projectRoot);
316
+ if (nodeCount >= threshold) return false;
317
+
318
+ if (isImportTouched(projectRoot) !== "absent") return false;
319
+
320
+ return true;
130
321
  } catch {
131
322
  return false;
132
323
  }
@@ -136,12 +327,16 @@ function isImportRequestedSentinelPresent(projectRoot) {
136
327
  // CONSTANTS
137
328
  // -----------------------------------------------------------------------------
138
329
 
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;
330
+ // Per-type truncation triggers when total broad-scope entries > N.
331
+ // v2.0.0-rc.29 TASK-007 (BUG-F1): lowered from 30 → 12. SessionStart hint
332
+ // should bias toward "is there anything relevant?" rather than "exhaustive
333
+ // index" at 30, the banner consumed several terminal screens on
334
+ // well-seeded repos and operators reported scroll fatigue. 12 keeps a
335
+ // dense-enough scan (still fits "top hits per type" in 1-2 screenfuls)
336
+ // without prompting the user to mentally truncate themselves. The constant
337
+ // stays a stable rendering boundary; downstream consumers (banner-i18n.cjs,
338
+ // truncation summary lines) consume it as a single source of truth.
339
+ const TRUNCATION_THRESHOLD = 12;
145
340
 
146
341
  // `fabric plan-context-hint` is a thin wrapper over planContext(); on a
147
342
  // well-seeded repo it returns in ~100ms. Two-second cap is defensive — any
@@ -150,8 +345,17 @@ const CLI_TIMEOUT_MS = 2000;
150
345
 
151
346
  // Maximum summary length per entry. Keeps each line bounded so stderr does
152
347
  // not blow up terminal width with multi-paragraph summaries from sloppy
153
- // pending entries. Truncation appends an ellipsis.
154
- const SUMMARY_MAX_LEN = 80;
348
+ // pending entries. Truncation appends an ellipsis. v2.0.0-rc.33 W4-A3:
349
+ // `hint_summary_max_len` in fabric-config overrides this default (range 40..240).
350
+ const DEFAULT_SUMMARY_MAX_LEN = 80;
351
+
352
+ function readSummaryMaxLen(projectRoot) {
353
+ return readConfigNumber(projectRoot, "hint_summary_max_len", DEFAULT_SUMMARY_MAX_LEN, {
354
+ min: 40,
355
+ max: 240,
356
+ floor: true,
357
+ });
358
+ }
155
359
 
156
360
  // Canonical type order — render groups in this sequence so output is stable
157
361
  // across runs (Object.keys iteration order is insertion order, but the JSON
@@ -172,6 +376,13 @@ const MATURITY_PROVEN = "proven";
172
376
  const MATURITY_VERIFIED = "verified";
173
377
  const MATURITY_DRAFT = "draft";
174
378
 
379
+ // rc.8 underseed self-check banner: single line, emoji-prefixed (cf.
380
+ // fabric-hint.cjs Signal C `📋 Fabric:`). rc.16 TASK-003 routed the literal
381
+ // through the banner-i18n lib (key: 'broadImportBanner') — see main() below
382
+ // for the renderBanner call site. Substring contracts preserved across all
383
+ // variants: leading two-space indent, `📋 Fabric:` prefix, `/fabric-import`
384
+ // verbatim token (asserted by knowledge-hint-broad.test.ts).
385
+
175
386
  // -----------------------------------------------------------------------------
176
387
  // CLI invocation
177
388
  // -----------------------------------------------------------------------------
@@ -180,12 +391,16 @@ const MATURITY_DRAFT = "draft";
180
391
  * Spawn `fabric plan-context-hint --all` and return parsed JSON. Returns
181
392
  * null on any failure (ENOENT, non-zero exit, malformed JSON). Never throws.
182
393
  *
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.
394
+ * If `fabric` is not on PATH, return null the hook stays silent rather
395
+ * than nagging about install state.
186
396
  */
187
397
  function invokePlanContextHint(cwd) {
188
- const candidates = ["fabric", "fab"];
398
+ const candidates = ["fabric"];
399
+ // rc.31 NEW-6: capture the last meaningful failure so we can surface it on
400
+ // stderr before fail-open. Without this, hook silently swallows backend
401
+ // crashes (e.g. agents_meta_invalid → plan-context-hint exits with stderr
402
+ // payload and the AI / user never sees KB chain is dead).
403
+ let lastFailure = null;
189
404
  for (const bin of candidates) {
190
405
  let res;
191
406
  try {
@@ -198,17 +413,37 @@ function invokePlanContextHint(cwd) {
198
413
  } catch {
199
414
  continue; // spawn throw (extremely rare) — try next candidate
200
415
  }
201
- // ENOENT surfaces as error on the result object.
202
- if (res.error || res.status === null || res.status !== 0) continue;
416
+ // ENOENT surfaces as error on the result object. Skip silently for ENOENT
417
+ // (bin not installed is the only legitimate reason to bail).
418
+ if (res.error) {
419
+ if (res.error.code !== "ENOENT") {
420
+ lastFailure = { bin, reason: String(res.error.message || res.error.code || res.error) };
421
+ }
422
+ continue;
423
+ }
424
+ if (res.status === null || res.status !== 0) {
425
+ const stderrSnip = (res.stderr || "").trim().slice(0, 240);
426
+ if (stderrSnip.length > 0) {
427
+ lastFailure = { bin, reason: stderrSnip };
428
+ }
429
+ continue;
430
+ }
203
431
  const raw = (res.stdout || "").trim();
204
432
  if (raw.length === 0) continue;
205
433
  try {
206
434
  const parsed = JSON.parse(raw);
207
435
  if (parsed && typeof parsed === "object") return parsed;
208
- } catch {
209
- // malformed JSON try next bin (unlikely to differ, but no harm)
436
+ } catch (err) {
437
+ lastFailure = { bin, reason: `malformed JSON from plan-context-hint: ${String(err && err.message || err)}` };
210
438
  }
211
439
  }
440
+ if (lastFailure !== null) {
441
+ // Single warning line — never throws, never blocks the hook. Lets users /
442
+ // AI notice that the KB chain is degraded instead of being silently empty.
443
+ process.stderr.write(
444
+ `[fabric-hint] plan-context-hint (${lastFailure.bin}) failed: ${lastFailure.reason.replace(/\n/g, " ")}\n`,
445
+ );
446
+ }
212
447
  return null;
213
448
  }
214
449
 
@@ -249,17 +484,21 @@ function groupEntries(narrow) {
249
484
  return { typeOrder, byType };
250
485
  }
251
486
 
252
- function truncateSummary(raw) {
487
+ // v2.0.0-rc.33 W4-A3: maxLen is now caller-supplied (sourced from
488
+ // fabric-config#hint_summary_max_len in main; tests + ad-hoc callers may
489
+ // omit to fall back to DEFAULT_SUMMARY_MAX_LEN).
490
+ function truncateSummary(raw, maxLen) {
253
491
  const s = typeof raw === "string" ? raw : "";
254
492
  // Collapse newlines / runs of whitespace so each entry fits one line.
255
493
  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)}…`;
494
+ const cap = typeof maxLen === "number" && maxLen > 0 ? maxLen : DEFAULT_SUMMARY_MAX_LEN;
495
+ if (flat.length <= cap) return flat;
496
+ return `${flat.slice(0, cap - 1)}…`;
258
497
  }
259
498
 
260
- function formatEntryLine(entry) {
499
+ function formatEntryLine(entry, maxLen) {
261
500
  const id = entry.id || "(no-id)";
262
- const summary = truncateSummary(entry.summary);
501
+ const summary = truncateSummary(entry.summary, maxLen);
263
502
  return summary.length > 0 ? ` - ${id} · ${summary}` : ` - ${id}`;
264
503
  }
265
504
 
@@ -268,7 +507,7 @@ function formatEntryLine(entry) {
268
507
  * Each entry gets one line: ` - <id> · <summary>`. Type/maturity headers
269
508
  * group the listing.
270
509
  */
271
- function renderFull(narrow) {
510
+ function renderFull(narrow, maxLen) {
272
511
  const { typeOrder, byType } = groupEntries(narrow);
273
512
  const lines = [];
274
513
  for (const type of typeOrder) {
@@ -287,7 +526,7 @@ function renderFull(narrow) {
287
526
  for (const maturity of maturities) {
288
527
  lines.push(` [${type}] (${maturity}):`);
289
528
  for (const entry of maturityMap.get(maturity)) {
290
- lines.push(formatEntryLine(entry));
529
+ lines.push(formatEntryLine(entry, maxLen));
291
530
  }
292
531
  }
293
532
  }
@@ -300,7 +539,7 @@ function renderFull(narrow) {
300
539
  * an inline id list (no summary); draft (and unknown) buckets collapse to a
301
540
  * count.
302
541
  */
303
- function renderTruncated(narrow) {
542
+ function renderTruncated(narrow, maxLen) {
304
543
  const { typeOrder, byType } = groupEntries(narrow);
305
544
  const lines = [];
306
545
  for (const type of typeOrder) {
@@ -311,7 +550,7 @@ function renderTruncated(narrow) {
311
550
  if (proven && proven.length > 0) {
312
551
  lines.push(` [${type}] proven (${proven.length}):`);
313
552
  for (const entry of proven) {
314
- lines.push(formatEntryLine(entry));
553
+ lines.push(formatEntryLine(entry, maxLen));
315
554
  }
316
555
  }
317
556
 
@@ -341,24 +580,83 @@ function renderTruncated(narrow) {
341
580
  *
342
581
  * Returns an array of lines (one stderr write per line keeps the formatter
343
582
  * trivial and testable). Returns [] when there is nothing meaningful to say
344
- * (empty narrow set) so callers know to stay silent.
583
+ * (empty entries set) so callers know to stay silent.
584
+ *
585
+ * Protocol v2 gate (rc.18): payloads must carry `version: 2`. A null/missing
586
+ * payload returns [] silently; a payload with a mismatched `version` returns []
587
+ * after writing exactly one stderr breadcrumb so operators grepping a stuck-
588
+ * banner report can diagnose the version drift without source-diving.
345
589
  */
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;
590
+ function renderSummary(payload, maxLen) {
591
+ if (!payload || payload.version !== 2) {
592
+ if (payload && payload.version !== undefined) {
593
+ try {
594
+ process.stderr.write(
595
+ `[fabric] hint payload version=${payload.version} unsupported (expected 2), skipping\n`,
596
+ );
597
+ } catch {}
598
+ }
599
+ return [];
600
+ }
601
+ // Protocol v2 (rc.18): the wire field is now `payload.entries`, matching what
602
+ // this renderer always called it locally. The historical `narrow` name (which
603
+ // degenerated in --all mode) has been retired without a compat shim per
604
+ // pre-user clean-slate policy.
605
+ const entries = Array.isArray(payload.entries) ? payload.entries : [];
606
+ if (entries.length === 0) return [];
607
+
608
+ const truncated = entries.length > TRUNCATION_THRESHOLD;
351
609
  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:`;
610
+ ? `[fabric] Session start — ${entries.length} broad-scoped knowledge entries available (truncated):`
611
+ : `[fabric] Session start — ${entries.length} broad-scoped knowledge entries available:`;
354
612
 
355
- const body = truncated ? renderTruncated(narrow) : renderFull(narrow);
613
+ const body = truncated ? renderTruncated(entries, maxLen) : renderFull(entries, maxLen);
356
614
 
357
615
  const lines = [banner, ...body];
358
616
  const revHash = typeof payload.revision_hash === "string" ? payload.revision_hash : null;
359
617
  if (revHash !== null && revHash.length > 0) {
360
618
  lines.push(` revision_hash: ${revHash}`);
361
619
  }
620
+
621
+ // rc.22 Scope D T-D4 (TASK-011): meta auto-refresh breadcrumb. Emitted ONLY
622
+ // when the server's planContext() detected meta drift and rebuilt the meta
623
+ // in-place (auto_healed === true). One informational line — operators need
624
+ // a paper trail when revision_hash flips mid-session.
625
+ //
626
+ // Variant resolution:
627
+ // - Both hashes present → full transition line (`sha PREV → CUR`) with
628
+ // 8-char hex prefixes stripped of the `sha256:` scheme prefix.
629
+ // - auto_healed:true but previous_revision_hash missing → generic line
630
+ // (T10 noted the server may emit `auto_healed:true` alone if it lost
631
+ // the prior hash for any reason). Stays informational.
632
+ //
633
+ // i18n: routed through renderBanner so zh-CN / en / zh-CN-hybrid variants
634
+ // share one call site. fabric_language is resolved via readFabricLanguage()
635
+ // ONLY when the line is actually emitted (keeps the no-banner path free of
636
+ // the extra config read, matching the broadImportBanner site below).
637
+ if (payload.auto_healed === true) {
638
+ const variant = readFabricLanguage(process.cwd());
639
+ const prevRaw =
640
+ typeof payload.previous_revision_hash === "string"
641
+ ? payload.previous_revision_hash
642
+ : null;
643
+ const curRaw =
644
+ typeof payload.revision_hash === "string" ? payload.revision_hash : null;
645
+ if (prevRaw && curRaw) {
646
+ // Strip optional `sha256:` scheme prefix, then take first 8 hex chars.
647
+ const stripScheme = (h) =>
648
+ h.startsWith("sha256:") ? h.slice("sha256:".length) : h;
649
+ const prev = stripScheme(prevRaw).slice(0, 8);
650
+ const cur = stripScheme(curRaw).slice(0, 8);
651
+ lines.push(
652
+ renderBanner("metaAutoRefreshedBanner", variant, { prev, cur }),
653
+ );
654
+ } else {
655
+ // Defensive: auto_healed:true but no usable previous hash → generic line.
656
+ lines.push(renderBanner("metaAutoRefreshedBannerGeneric", variant, {}));
657
+ }
658
+ }
659
+
362
660
  lines.push(" Use `fab_get_knowledge_sections` to fetch full content.");
363
661
  return lines;
364
662
  }
@@ -371,7 +669,33 @@ function renderSummary(payload) {
371
669
  function main(env, stdio) {
372
670
  try {
373
671
  const cwd = (env && env.cwd) || process.cwd();
672
+ const now = (env && env.now) || new Date();
673
+ const nowMs = now instanceof Date ? now.getTime() : Number(now);
374
674
  const err = (stdio && stdio.stderr) || process.stderr;
675
+ const out = (stdio && stdio.stdout) || process.stdout;
676
+
677
+ // v2.0.0-rc.33 W2-5 (P1-8): cooldown gate. When configured > 0 hours, the
678
+ // broad banner stays silent for that many hours after a successful emit.
679
+ // 0 (default) preserves rc.32 behavior — every SessionStart re-fires the
680
+ // banner. Test seam env.skipCooldown bypasses for unit tests.
681
+ const cooldownHours = readBroadCooldownHours(cwd);
682
+ if (cooldownHours > 0 && !(env && env.skipCooldown === true)) {
683
+ const lastEmitMs = readBroadLastEmit(cwd);
684
+ if (
685
+ typeof lastEmitMs === "number" &&
686
+ // rc.34 TASK-01 + review-fix (Gemini P1): when lastEmit is in the
687
+ // FUTURE relative to now (backward clock skew — NTP sync /
688
+ // suspend-wake / TZ change), the gate fires immediately. Otherwise
689
+ // standard cooldown check. Math.max(0, …) was a no-op (silent for
690
+ // cooldown + |skew| under both formulations); this guard actually
691
+ // heals the skew on the next invocation by treating future-stamped
692
+ // sidecar as "expired."
693
+ nowMs >= lastEmitMs &&
694
+ nowMs - lastEmitMs < cooldownHours * MS_PER_HOUR
695
+ ) {
696
+ return; // still in cooldown — silent
697
+ }
698
+ }
375
699
 
376
700
  // Test seam: env.payload short-circuits the CLI spawn so unit tests can
377
701
  // feed canned plan-context-hint JSON without depending on a built CLI.
@@ -379,54 +703,131 @@ function main(env, stdio) {
379
703
  env && env.payload !== undefined ? env.payload : invokePlanContextHint(cwd);
380
704
  if (payload === null || payload === undefined) return; // silent
381
705
 
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;
706
+ // v2.0.0-rc.33 W2-1 (P0-9): apply TopK slice BEFORE renderSummary so the
707
+ // grouped/truncation rendering operates on the bounded set. Slicing here
708
+ // (not inside renderSummary) keeps the formatter pure it never has to
709
+ // know about the cap.
710
+ const topK = readBroadTopK(cwd);
711
+ const slicedPayload =
712
+ payload && Array.isArray(payload.entries) && payload.entries.length > topK
713
+ ? { ...payload, entries: payload.entries.slice(0, topK) }
714
+ : payload;
715
+
716
+ // rc.35 TASK-06 (P0-10.b): summary-fallback substitution. Entries whose
717
+ // description.summary equals stable_id render as "<id> · <id>" and the
718
+ // AI skips fetching them; the fallback reads `## Summary` from the
719
+ // entry's .md file and swaps in the first paragraph. Best-effort —
720
+ // failure leaves the original opaque summary untouched.
721
+ let resolvedPayload = slicedPayload;
722
+ try {
723
+ if (slicedPayload && Array.isArray(slicedPayload.entries)) {
724
+ const resolvedEntries = resolveOpaqueSummaries(
725
+ slicedPayload.entries,
726
+ cwd,
727
+ typeof slicedPayload.revision_hash === "string" ? slicedPayload.revision_hash : "",
728
+ );
729
+ resolvedPayload = { ...slicedPayload, entries: resolvedEntries };
404
730
  }
731
+ } catch {
732
+ // resolveOpaqueSummaries swallows its own errors; this catch is belt
733
+ // + suspenders for any unexpected exception from the lib layer.
405
734
  }
406
735
 
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
- );
736
+ // rc.8 underseed self-check: decide whether to surface the one-line
737
+ // `/fabric-import` recommendation banner alongside the broad summary.
738
+ const recommendImport = shouldRecommendImport(cwd);
739
+
740
+ // rc.12: broad-summary body is unconditionally rendered on every
741
+ // SessionStart fire (Skill-style progressive disclosure). The prior
742
+ // revision_hash cooldown gate (rc.7 T8 — rc.11) was removed because
743
+ // compact/clear-triggered SessionStart re-fires must re-inject the menu
744
+ // for the agent's working memory. rc.33 W2-5 reintroduces an opt-in
745
+ // hours-based cooldown via fabric-config (see gate above).
746
+ const summaryMaxLen = readSummaryMaxLen(cwd);
747
+ const lines = renderSummary(resolvedPayload, summaryMaxLen);
748
+
749
+ // v2.0.0-rc.37 NEW-23: resolve fabric_language ONCE per emit path —
750
+ // shared between the (existing) broadImportBanner branch and the new
751
+ // 'next step' nudge tail added below. 'match-existing' / unknown variants
752
+ // fold to 'en' inside renderBanner per UX i18n Policy class 1.
753
+ const fabricLanguageForEmit = lines.length > 0 || recommendImport ? readFabricLanguage(cwd) : null;
754
+ if (recommendImport && fabricLanguageForEmit !== null) {
755
+ lines.push(renderBanner("broadImportBanner", fabricLanguageForEmit, {}));
416
756
  }
417
757
 
418
- if (lines.length === 0) return; // empty narrow set + no sentinel — silent
758
+ if (lines.length === 0) return; // nothing to say — silent exit
759
+
760
+ // v2.1.0-rc.1 P4 (F4/S63): append a per-store read-set label from the
761
+ // CLI-pre-generated bindings snapshot so the session opens aware of which
762
+ // stores it reads and where writes land. Best-effort, never blocks: a
763
+ // missing snapshot / single-store setup just omits the line.
764
+ if (bindingsSnapshotReader !== null) {
765
+ try {
766
+ const projectId = readProjectId(cwd);
767
+ if (projectId) {
768
+ const label = bindingsSnapshotReader.formatStoreLabels(
769
+ bindingsSnapshotReader.readBindingsSnapshot(projectId),
770
+ );
771
+ if (label) lines.push(label);
772
+ }
773
+ } catch {
774
+ // store labels are decorative provenance — never crash the hook
775
+ }
776
+ }
419
777
 
778
+ // v2.0.0-rc.37 NEW-23: SessionStart 索引末尾"下一步"引导。Tail line that
779
+ // tells the AI what to do with the broad index it just received. Without
780
+ // this, the model often parses the index and moves on without ever calling
781
+ // fab_recall / fab_plan_context. One-line nudge, bilingual.
782
+ const nextStepNudge =
783
+ fabricLanguageForEmit === "zh-CN"
784
+ ? "下一步: 调 fab_recall(paths) 拿 KB 相关条目;或调 fab_plan_context 先看候选 description_index。"
785
+ : "Next: call fab_recall(paths) to fetch related KB entries, or fab_plan_context to preview the description_index first.";
786
+ lines.push(nextStepNudge);
787
+
788
+ // Stderr: always emit (human-facing breadcrumb + legacy contract).
420
789
  for (const line of lines) {
421
790
  err.write(`${line}\n`);
422
791
  }
423
792
 
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);
793
+ // v2.0.0-rc.33 W2-6 (P0-7): stdout JSON envelope. When
794
+ // hint_reminder_to_context is true (default), serialize the same banner
795
+ // body as Claude Code's SessionStart hookSpecificOutput shape so the model
796
+ // receives the reminder IN-CONTEXT (rc.32 baseline cite-coverage 3.1%
797
+ // root cause: reminders never entered model context). Stderr stays the
798
+ // host-facing channel.
799
+ //
800
+ // Failure to write JSON envelope must NOT crash the hook — stderr already
801
+ // delivered, the stdout layer is best-effort.
802
+ // v2.0.0-rc.33 W4 review-fix (gemini High-1): the stdout JSON envelope
803
+ // is Claude Code-specific (hookSpecificOutput.additionalContext contract).
804
+ // Codex CLI / Cursor don't parse it — leaking it to their stdout risks
805
+ // either polluting the terminal or crashing the host's hook-parsing
806
+ // pipeline. CLAUDE_PROJECT_DIR is set by CC when invoking hooks (see
807
+ // packages/cli/templates/hooks/configs/claude-code.json sigil paths);
808
+ // its presence is the single-bit "this is Claude Code" signal (now via
809
+ // the shared client-adapter, rc.37 NEW-30).
810
+ const reminderToContext = readReminderToContext(cwd) && isClaudeCode();
811
+ if (reminderToContext && !(env && env.skipStdout === true)) {
812
+ try {
813
+ const envelope = {
814
+ hookSpecificOutput: {
815
+ hookEventName: "SessionStart",
816
+ additionalContext: lines.join("\n"),
817
+ },
818
+ };
819
+ out.write(`${JSON.stringify(envelope)}\n`);
820
+ } catch {
821
+ // Best-effort — stderr is the durable contract
822
+ }
823
+ }
824
+
825
+ // v2.0.0-rc.33 W2-5 (P1-8): record successful emit timestamp for the
826
+ // cooldown gate's next-invocation check. Skip when cooldown is disabled
827
+ // (cooldownHours === 0) to avoid polluting the FS with a never-read
828
+ // sidecar on rc.32-style "no cooldown" workspaces.
829
+ if (cooldownHours > 0 && !(env && env.skipCooldownWrite === true)) {
830
+ writeBroadLastEmit(cwd, nowMs);
430
831
  }
431
832
  } catch {
432
833
  // Silent — never block session start on hook failure.
@@ -441,20 +842,33 @@ module.exports = {
441
842
  renderTruncated,
442
843
  renderSummary,
443
844
  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,
845
+ // rc.8 underseed self-check helpers (exported for unit testing).
846
+ countCanonicalNodes,
847
+ readUnderseedThreshold,
848
+ isImportTouched,
849
+ shouldRecommendImport,
850
+ // v2.0.0-rc.33 W2-1 / W2-5 / W2-6 helpers.
851
+ readBroadTopK,
852
+ readBroadCooldownHours,
853
+ readReminderToContext,
854
+ readBroadLastEmit,
855
+ writeBroadLastEmit,
856
+ readSummaryMaxLen,
449
857
  CONSTANTS: {
450
858
  TRUNCATION_THRESHOLD,
451
859
  CLI_TIMEOUT_MS,
452
- SUMMARY_MAX_LEN,
860
+ SUMMARY_MAX_LEN: DEFAULT_SUMMARY_MAX_LEN,
861
+ DEFAULT_SUMMARY_MAX_LEN,
453
862
  CANONICAL_TYPE_ORDER,
454
863
  MATURITY_PROVEN,
455
864
  MATURITY_VERIFIED,
456
865
  MATURITY_DRAFT,
457
- SESSIONSTART_HASH_CACHE_FILE,
866
+ DEFAULT_UNDERSEED_NODE_THRESHOLD,
867
+ KNOWLEDGE_CANONICAL_TYPES,
868
+ DEFAULT_HINT_BROAD_TOP_K,
869
+ DEFAULT_HINT_BROAD_COOLDOWN_HOURS,
870
+ DEFAULT_HINT_REMINDER_TO_CONTEXT,
871
+ HINT_BROAD_LAST_EMIT_FILE_NAME,
458
872
  },
459
873
  };
460
874