@fenglimg/fabric-cli 2.0.0-rc.30 → 2.0.0-rc.34

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 (37) hide show
  1. package/dist/{chunk-PNRWNUFX.js → chunk-5N3KXIVI.js} +73 -4
  2. package/dist/{doctor-TTDTKOFJ.js → doctor-E26YO67D.js} +8 -2
  3. package/dist/index.js +4 -4
  4. package/dist/{install-OEBNSCS5.js → install-XCRX34CX.js} +4 -2
  5. package/dist/{uninstall-VLLJG7JT.js → uninstall-Q7V55BXH.js} +1 -1
  6. package/package.json +3 -3
  7. package/templates/hooks/cite-policy-evict.cjs +242 -0
  8. package/templates/hooks/configs/claude-code.json +11 -0
  9. package/templates/hooks/fabric-hint.cjs +11 -1
  10. package/templates/hooks/knowledge-hint-broad.cjs +276 -21
  11. package/templates/hooks/knowledge-hint-narrow.cjs +466 -14
  12. package/templates/skills/fabric-archive/SKILL.md +53 -864
  13. package/templates/skills/fabric-archive/ref/dry-run-scope.md +16 -0
  14. package/templates/skills/fabric-archive/ref/e5-cron-recap.md +5 -5
  15. package/templates/skills/fabric-archive/ref/i18n-policy.md +3 -3
  16. package/templates/skills/fabric-archive/ref/phase-0-range-resolution.md +156 -0
  17. package/templates/skills/fabric-archive/ref/{phase-0-4-onboard.md → phase-1-5-onboard.md} +21 -21
  18. package/templates/skills/fabric-archive/ref/phase-1-cross-session.md +60 -0
  19. package/templates/skills/fabric-archive/ref/phase-2-5-viability.md +54 -0
  20. package/templates/skills/fabric-archive/ref/phase-3-5-scope.md +80 -0
  21. package/templates/skills/fabric-archive/ref/phase-3-classify.md +63 -0
  22. package/templates/skills/fabric-archive/ref/phase-4-5-emit.md +78 -0
  23. package/templates/skills/fabric-archive/ref/phase-4-mcp-persist.md +89 -0
  24. package/templates/skills/fabric-archive/ref/rc-history.md +6 -6
  25. package/templates/skills/fabric-archive/ref/worked-examples.md +1 -1
  26. package/templates/skills/fabric-import/SKILL.md +29 -556
  27. package/templates/skills/fabric-import/ref/checkpoint-state.md +85 -0
  28. package/templates/skills/fabric-import/ref/output-contract.md +61 -0
  29. package/templates/skills/fabric-import/ref/phase-2-mining.md +213 -0
  30. package/templates/skills/fabric-import/ref/phase-3-dedup.md +75 -0
  31. package/templates/skills/fabric-import/ref/worked-examples.md +127 -0
  32. package/templates/skills/fabric-review/SKILL.md +56 -414
  33. package/templates/skills/fabric-review/ref/askuserquestion-policy.md +66 -0
  34. package/templates/skills/fabric-review/ref/modify-flow.md +95 -0
  35. package/templates/skills/fabric-review/ref/output-contract.md +58 -0
  36. package/templates/skills/fabric-review/ref/per-mode-flows.md +155 -0
  37. package/templates/skills/fabric-review/ref/semantic-check.md +26 -0
@@ -49,10 +49,12 @@
49
49
  const { spawnSync } = require("node:child_process");
50
50
  const {
51
51
  existsSync,
52
+ mkdirSync,
52
53
  readdirSync,
53
54
  readFileSync,
55
+ writeFileSync,
54
56
  } = require("node:fs");
55
- const { join } = require("node:path");
57
+ const { dirname, join } = require("node:path");
56
58
 
57
59
  // rc.16 TASK-003: shared banner-i18n lib (resolves fabric_language config and
58
60
  // renders localized banner text). Mirror of the wiring in fabric-hint.cjs
@@ -89,6 +91,30 @@ const KNOWLEDGE_CANONICAL_TYPES = [
89
91
  ];
90
92
  const DEFAULT_UNDERSEED_NODE_THRESHOLD = 10;
91
93
 
94
+ // v2.0.0-rc.33 W2-1 (P0-9): TopK upper bound on broad-scoped entries surfaced
95
+ // per SessionStart fire. Keeps the banner inside ~1 screenful so the agent
96
+ // actually reads the top-priority entries instead of triaging a wall of text.
97
+ // Overridable via fabric-config.json#hint_broad_top_k (range 1..50).
98
+ const DEFAULT_HINT_BROAD_TOP_K = 8;
99
+
100
+ // v2.0.0-rc.33 W2-5 (P1-8): cooldown (in hours) between broad-hint re-emits.
101
+ // Default 0 preserves rc.32 behavior — every SessionStart re-fires the banner.
102
+ // Cache key uses a separate sidecar from the fabric-hint Signal A/B/C cache
103
+ // so the two cooldowns don't interfere.
104
+ const DEFAULT_HINT_BROAD_COOLDOWN_HOURS = 0;
105
+ const MS_PER_HOUR = 60 * 60 * 1000;
106
+ const HINT_BROAD_LAST_EMIT_FILE = join(
107
+ ".fabric",
108
+ ".cache",
109
+ "knowledge-hint-broad-last-emit",
110
+ );
111
+
112
+ // v2.0.0-rc.33 W2-6 (P0-7): when true, emit banner as
113
+ // hookSpecificOutput.additionalContext JSON on stdout (Claude Code PreToolUse
114
+ // contract) so the model receives the reminder in-context. Stderr remains the
115
+ // human-facing channel for logs / breadcrumbs.
116
+ const DEFAULT_HINT_REMINDER_TO_CONTEXT = true;
117
+
92
118
  // -----------------------------------------------------------------------------
93
119
  // rc.8 underseed self-check helpers.
94
120
  //
@@ -153,6 +179,97 @@ function readUnderseedThreshold(projectRoot) {
153
179
  return DEFAULT_UNDERSEED_NODE_THRESHOLD;
154
180
  }
155
181
 
182
+ /**
183
+ * v2.0.0-rc.33 W2-1: resolve hint_broad_top_k from fabric-config.json. Slices
184
+ * the broad entry list to TopK before group/truncation render. Validates the
185
+ * schema's 1..50 range inline so a malformed config silently falls back.
186
+ */
187
+ function readBroadTopK(projectRoot) {
188
+ const configPath = join(projectRoot, FABRIC_DIR_REL, FABRIC_CONFIG_FILE);
189
+ if (!existsSync(configPath)) return DEFAULT_HINT_BROAD_TOP_K;
190
+ try {
191
+ const parsed = JSON.parse(readFileSync(configPath, "utf8"));
192
+ const v = parsed && parsed.hint_broad_top_k;
193
+ if (typeof v === "number" && Number.isFinite(v) && v >= 1 && v <= 50) {
194
+ return Math.floor(v);
195
+ }
196
+ } catch {
197
+ // fall through to default
198
+ }
199
+ return DEFAULT_HINT_BROAD_TOP_K;
200
+ }
201
+
202
+ /**
203
+ * v2.0.0-rc.33 W2-5: resolve hint_broad_cooldown_hours. Schema clamps 0..168;
204
+ * 0 means "no cooldown" (re-emit on every SessionStart, rc.32 behavior).
205
+ */
206
+ function readBroadCooldownHours(projectRoot) {
207
+ const configPath = join(projectRoot, FABRIC_DIR_REL, FABRIC_CONFIG_FILE);
208
+ if (!existsSync(configPath)) return DEFAULT_HINT_BROAD_COOLDOWN_HOURS;
209
+ try {
210
+ const parsed = JSON.parse(readFileSync(configPath, "utf8"));
211
+ const v = parsed && parsed.hint_broad_cooldown_hours;
212
+ if (typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= 168) {
213
+ return v;
214
+ }
215
+ } catch {
216
+ // fall through to default
217
+ }
218
+ return DEFAULT_HINT_BROAD_COOLDOWN_HOURS;
219
+ }
220
+
221
+ /**
222
+ * v2.0.0-rc.33 W2-6: resolve hint_reminder_to_context. Boolean flag — when
223
+ * true (default) the hook writes a Claude-Code-shaped JSON envelope to stdout
224
+ * carrying the banner under hookSpecificOutput.additionalContext so the model
225
+ * receives the reminder in-context. Stderr stays informational either way.
226
+ */
227
+ function readReminderToContext(projectRoot) {
228
+ const configPath = join(projectRoot, FABRIC_DIR_REL, FABRIC_CONFIG_FILE);
229
+ if (!existsSync(configPath)) return DEFAULT_HINT_REMINDER_TO_CONTEXT;
230
+ try {
231
+ const parsed = JSON.parse(readFileSync(configPath, "utf8"));
232
+ const v = parsed && parsed.hint_reminder_to_context;
233
+ if (typeof v === "boolean") return v;
234
+ } catch {
235
+ // fall through to default
236
+ }
237
+ return DEFAULT_HINT_REMINDER_TO_CONTEXT;
238
+ }
239
+
240
+ /**
241
+ * v2.0.0-rc.33 W2-5: read/write the broad-hint last-emit timestamp sidecar.
242
+ * Distinct from fabric-hint's shown-cache so signal cooldowns stay isolated.
243
+ * Returns epoch ms or null when missing/unreadable.
244
+ */
245
+ function readBroadLastEmit(projectRoot) {
246
+ const p = join(projectRoot, HINT_BROAD_LAST_EMIT_FILE);
247
+ if (!existsSync(p)) return null;
248
+ try {
249
+ const raw = readFileSync(p, "utf8").trim();
250
+ if (raw.length === 0) return null;
251
+ const asNum = Number(raw);
252
+ if (Number.isFinite(asNum) && asNum > 0) return asNum;
253
+ const ms = Date.parse(raw);
254
+ if (Number.isFinite(ms)) return ms;
255
+ } catch {
256
+ // ignore
257
+ }
258
+ return null;
259
+ }
260
+
261
+ function writeBroadLastEmit(projectRoot, nowMs) {
262
+ const p = join(projectRoot, HINT_BROAD_LAST_EMIT_FILE);
263
+ try {
264
+ if (!existsSync(dirname(p))) {
265
+ mkdirSync(dirname(p), { recursive: true });
266
+ }
267
+ writeFileSync(p, String(nowMs));
268
+ } catch {
269
+ // Silent — sidecar failure must never block session start.
270
+ }
271
+ }
272
+
156
273
  /**
157
274
  * Classify the on-disk import lifecycle by reading
158
275
  * `.fabric/.import-state.json`. Returns one of:
@@ -245,8 +362,24 @@ const CLI_TIMEOUT_MS = 2000;
245
362
 
246
363
  // Maximum summary length per entry. Keeps each line bounded so stderr does
247
364
  // not blow up terminal width with multi-paragraph summaries from sloppy
248
- // pending entries. Truncation appends an ellipsis.
249
- const SUMMARY_MAX_LEN = 80;
365
+ // pending entries. Truncation appends an ellipsis. v2.0.0-rc.33 W4-A3:
366
+ // `hint_summary_max_len` in fabric-config overrides this default (range 40..240).
367
+ const DEFAULT_SUMMARY_MAX_LEN = 80;
368
+
369
+ function readSummaryMaxLen(projectRoot) {
370
+ const configPath = join(projectRoot, FABRIC_DIR_REL, FABRIC_CONFIG_FILE);
371
+ if (!existsSync(configPath)) return DEFAULT_SUMMARY_MAX_LEN;
372
+ try {
373
+ const parsed = JSON.parse(readFileSync(configPath, "utf8"));
374
+ const v = parsed && parsed.hint_summary_max_len;
375
+ if (typeof v === "number" && Number.isFinite(v) && v >= 40 && v <= 240) {
376
+ return Math.floor(v);
377
+ }
378
+ } catch {
379
+ // fall through to default
380
+ }
381
+ return DEFAULT_SUMMARY_MAX_LEN;
382
+ }
250
383
 
251
384
  // Canonical type order — render groups in this sequence so output is stable
252
385
  // across runs (Object.keys iteration order is insertion order, but the JSON
@@ -288,6 +421,11 @@ const MATURITY_DRAFT = "draft";
288
421
  */
289
422
  function invokePlanContextHint(cwd) {
290
423
  const candidates = ["fabric", "fab"];
424
+ // rc.31 NEW-6: capture the last meaningful failure so we can surface it on
425
+ // stderr before fail-open. Without this, hook silently swallows backend
426
+ // crashes (e.g. agents_meta_invalid → plan-context-hint exits with stderr
427
+ // payload and the AI / user never sees KB chain is dead).
428
+ let lastFailure = null;
291
429
  for (const bin of candidates) {
292
430
  let res;
293
431
  try {
@@ -300,17 +438,37 @@ function invokePlanContextHint(cwd) {
300
438
  } catch {
301
439
  continue; // spawn throw (extremely rare) — try next candidate
302
440
  }
303
- // ENOENT surfaces as error on the result object.
304
- if (res.error || res.status === null || res.status !== 0) continue;
441
+ // ENOENT surfaces as error on the result object. Skip silently for ENOENT
442
+ // (bin not installed is expected for `fabric` when only `fab` is shipped).
443
+ if (res.error) {
444
+ if (res.error.code !== "ENOENT") {
445
+ lastFailure = { bin, reason: String(res.error.message || res.error.code || res.error) };
446
+ }
447
+ continue;
448
+ }
449
+ if (res.status === null || res.status !== 0) {
450
+ const stderrSnip = (res.stderr || "").trim().slice(0, 240);
451
+ if (stderrSnip.length > 0) {
452
+ lastFailure = { bin, reason: stderrSnip };
453
+ }
454
+ continue;
455
+ }
305
456
  const raw = (res.stdout || "").trim();
306
457
  if (raw.length === 0) continue;
307
458
  try {
308
459
  const parsed = JSON.parse(raw);
309
460
  if (parsed && typeof parsed === "object") return parsed;
310
- } catch {
311
- // malformed JSON try next bin (unlikely to differ, but no harm)
461
+ } catch (err) {
462
+ lastFailure = { bin, reason: `malformed JSON from plan-context-hint: ${String(err && err.message || err)}` };
312
463
  }
313
464
  }
465
+ if (lastFailure !== null) {
466
+ // Single warning line — never throws, never blocks the hook. Lets users /
467
+ // AI notice that the KB chain is degraded instead of being silently empty.
468
+ process.stderr.write(
469
+ `[fabric-hint] plan-context-hint (${lastFailure.bin}) failed: ${lastFailure.reason.replace(/\n/g, " ")}\n`,
470
+ );
471
+ }
314
472
  return null;
315
473
  }
316
474
 
@@ -351,17 +509,21 @@ function groupEntries(narrow) {
351
509
  return { typeOrder, byType };
352
510
  }
353
511
 
354
- function truncateSummary(raw) {
512
+ // v2.0.0-rc.33 W4-A3: maxLen is now caller-supplied (sourced from
513
+ // fabric-config#hint_summary_max_len in main; tests + ad-hoc callers may
514
+ // omit to fall back to DEFAULT_SUMMARY_MAX_LEN).
515
+ function truncateSummary(raw, maxLen) {
355
516
  const s = typeof raw === "string" ? raw : "";
356
517
  // Collapse newlines / runs of whitespace so each entry fits one line.
357
518
  const flat = s.replace(/\s+/g, " ").trim();
358
- if (flat.length <= SUMMARY_MAX_LEN) return flat;
359
- return `${flat.slice(0, SUMMARY_MAX_LEN - 1)}…`;
519
+ const cap = typeof maxLen === "number" && maxLen > 0 ? maxLen : DEFAULT_SUMMARY_MAX_LEN;
520
+ if (flat.length <= cap) return flat;
521
+ return `${flat.slice(0, cap - 1)}…`;
360
522
  }
361
523
 
362
- function formatEntryLine(entry) {
524
+ function formatEntryLine(entry, maxLen) {
363
525
  const id = entry.id || "(no-id)";
364
- const summary = truncateSummary(entry.summary);
526
+ const summary = truncateSummary(entry.summary, maxLen);
365
527
  return summary.length > 0 ? ` - ${id} · ${summary}` : ` - ${id}`;
366
528
  }
367
529
 
@@ -370,7 +532,7 @@ function formatEntryLine(entry) {
370
532
  * Each entry gets one line: ` - <id> · <summary>`. Type/maturity headers
371
533
  * group the listing.
372
534
  */
373
- function renderFull(narrow) {
535
+ function renderFull(narrow, maxLen) {
374
536
  const { typeOrder, byType } = groupEntries(narrow);
375
537
  const lines = [];
376
538
  for (const type of typeOrder) {
@@ -389,7 +551,7 @@ function renderFull(narrow) {
389
551
  for (const maturity of maturities) {
390
552
  lines.push(` [${type}] (${maturity}):`);
391
553
  for (const entry of maturityMap.get(maturity)) {
392
- lines.push(formatEntryLine(entry));
554
+ lines.push(formatEntryLine(entry, maxLen));
393
555
  }
394
556
  }
395
557
  }
@@ -402,7 +564,7 @@ function renderFull(narrow) {
402
564
  * an inline id list (no summary); draft (and unknown) buckets collapse to a
403
565
  * count.
404
566
  */
405
- function renderTruncated(narrow) {
567
+ function renderTruncated(narrow, maxLen) {
406
568
  const { typeOrder, byType } = groupEntries(narrow);
407
569
  const lines = [];
408
570
  for (const type of typeOrder) {
@@ -413,7 +575,7 @@ function renderTruncated(narrow) {
413
575
  if (proven && proven.length > 0) {
414
576
  lines.push(` [${type}] proven (${proven.length}):`);
415
577
  for (const entry of proven) {
416
- lines.push(formatEntryLine(entry));
578
+ lines.push(formatEntryLine(entry, maxLen));
417
579
  }
418
580
  }
419
581
 
@@ -450,7 +612,7 @@ function renderTruncated(narrow) {
450
612
  * after writing exactly one stderr breadcrumb so operators grepping a stuck-
451
613
  * banner report can diagnose the version drift without source-diving.
452
614
  */
453
- function renderSummary(payload) {
615
+ function renderSummary(payload, maxLen) {
454
616
  if (!payload || payload.version !== 2) {
455
617
  if (payload && payload.version !== undefined) {
456
618
  try {
@@ -473,7 +635,7 @@ function renderSummary(payload) {
473
635
  ? `[fabric] Session start — ${entries.length} broad-scoped knowledge entries available (truncated):`
474
636
  : `[fabric] Session start — ${entries.length} broad-scoped knowledge entries available:`;
475
637
 
476
- const body = truncated ? renderTruncated(entries) : renderFull(entries);
638
+ const body = truncated ? renderTruncated(entries, maxLen) : renderFull(entries, maxLen);
477
639
 
478
640
  const lines = [banner, ...body];
479
641
  const revHash = typeof payload.revision_hash === "string" ? payload.revision_hash : null;
@@ -532,7 +694,33 @@ function renderSummary(payload) {
532
694
  function main(env, stdio) {
533
695
  try {
534
696
  const cwd = (env && env.cwd) || process.cwd();
697
+ const now = (env && env.now) || new Date();
698
+ const nowMs = now instanceof Date ? now.getTime() : Number(now);
535
699
  const err = (stdio && stdio.stderr) || process.stderr;
700
+ const out = (stdio && stdio.stdout) || process.stdout;
701
+
702
+ // v2.0.0-rc.33 W2-5 (P1-8): cooldown gate. When configured > 0 hours, the
703
+ // broad banner stays silent for that many hours after a successful emit.
704
+ // 0 (default) preserves rc.32 behavior — every SessionStart re-fires the
705
+ // banner. Test seam env.skipCooldown bypasses for unit tests.
706
+ const cooldownHours = readBroadCooldownHours(cwd);
707
+ if (cooldownHours > 0 && !(env && env.skipCooldown === true)) {
708
+ const lastEmitMs = readBroadLastEmit(cwd);
709
+ if (
710
+ typeof lastEmitMs === "number" &&
711
+ // rc.34 TASK-01 + review-fix (Gemini P1): when lastEmit is in the
712
+ // FUTURE relative to now (backward clock skew — NTP sync /
713
+ // suspend-wake / TZ change), the gate fires immediately. Otherwise
714
+ // standard cooldown check. Math.max(0, …) was a no-op (silent for
715
+ // cooldown + |skew| under both formulations); this guard actually
716
+ // heals the skew on the next invocation by treating future-stamped
717
+ // sidecar as "expired."
718
+ nowMs >= lastEmitMs &&
719
+ nowMs - lastEmitMs < cooldownHours * MS_PER_HOUR
720
+ ) {
721
+ return; // still in cooldown — silent
722
+ }
723
+ }
536
724
 
537
725
  // Test seam: env.payload short-circuits the CLI spawn so unit tests can
538
726
  // feed canned plan-context-hint JSON without depending on a built CLI.
@@ -540,6 +728,16 @@ function main(env, stdio) {
540
728
  env && env.payload !== undefined ? env.payload : invokePlanContextHint(cwd);
541
729
  if (payload === null || payload === undefined) return; // silent
542
730
 
731
+ // v2.0.0-rc.33 W2-1 (P0-9): apply TopK slice BEFORE renderSummary so the
732
+ // grouped/truncation rendering operates on the bounded set. Slicing here
733
+ // (not inside renderSummary) keeps the formatter pure — it never has to
734
+ // know about the cap.
735
+ const topK = readBroadTopK(cwd);
736
+ const slicedPayload =
737
+ payload && Array.isArray(payload.entries) && payload.entries.length > topK
738
+ ? { ...payload, entries: payload.entries.slice(0, topK) }
739
+ : payload;
740
+
543
741
  // rc.8 underseed self-check: decide whether to surface the one-line
544
742
  // `/fabric-import` recommendation banner alongside the broad summary.
545
743
  const recommendImport = shouldRecommendImport(cwd);
@@ -548,8 +746,10 @@ function main(env, stdio) {
548
746
  // SessionStart fire (Skill-style progressive disclosure). The prior
549
747
  // revision_hash cooldown gate (rc.7 T8 — rc.11) was removed because
550
748
  // compact/clear-triggered SessionStart re-fires must re-inject the menu
551
- // for the agent's working memory.
552
- const lines = renderSummary(payload);
749
+ // for the agent's working memory. rc.33 W2-5 reintroduces an opt-in
750
+ // hours-based cooldown via fabric-config (see gate above).
751
+ const summaryMaxLen = readSummaryMaxLen(cwd);
752
+ const lines = renderSummary(slicedPayload, summaryMaxLen);
553
753
 
554
754
  if (recommendImport) {
555
755
  // rc.16 TASK-003: resolve fabric_language ONCE per invocation (only when
@@ -562,9 +762,52 @@ function main(env, stdio) {
562
762
 
563
763
  if (lines.length === 0) return; // nothing to say — silent exit
564
764
 
765
+ // Stderr: always emit (human-facing breadcrumb + legacy contract).
565
766
  for (const line of lines) {
566
767
  err.write(`${line}\n`);
567
768
  }
769
+
770
+ // v2.0.0-rc.33 W2-6 (P0-7): stdout JSON envelope. When
771
+ // hint_reminder_to_context is true (default), serialize the same banner
772
+ // body as Claude Code's SessionStart hookSpecificOutput shape so the model
773
+ // receives the reminder IN-CONTEXT (rc.32 baseline cite-coverage 3.1%
774
+ // root cause: reminders never entered model context). Stderr stays the
775
+ // host-facing channel.
776
+ //
777
+ // Failure to write JSON envelope must NOT crash the hook — stderr already
778
+ // delivered, the stdout layer is best-effort.
779
+ // v2.0.0-rc.33 W4 review-fix (gemini High-1): the stdout JSON envelope
780
+ // is Claude Code-specific (hookSpecificOutput.additionalContext contract).
781
+ // Codex CLI / Cursor don't parse it — leaking it to their stdout risks
782
+ // either polluting the terminal or crashing the host's hook-parsing
783
+ // pipeline. CLAUDE_PROJECT_DIR is set by CC when invoking hooks (see
784
+ // packages/cli/templates/hooks/configs/claude-code.json sigil paths);
785
+ // its presence is the single-bit "this is Claude Code" signal.
786
+ const isClaudeCode =
787
+ typeof process.env.CLAUDE_PROJECT_DIR === "string" &&
788
+ process.env.CLAUDE_PROJECT_DIR.length > 0;
789
+ const reminderToContext = readReminderToContext(cwd) && isClaudeCode;
790
+ if (reminderToContext && !(env && env.skipStdout === true)) {
791
+ try {
792
+ const envelope = {
793
+ hookSpecificOutput: {
794
+ hookEventName: "SessionStart",
795
+ additionalContext: lines.join("\n"),
796
+ },
797
+ };
798
+ out.write(`${JSON.stringify(envelope)}\n`);
799
+ } catch {
800
+ // Best-effort — stderr is the durable contract
801
+ }
802
+ }
803
+
804
+ // v2.0.0-rc.33 W2-5 (P1-8): record successful emit timestamp for the
805
+ // cooldown gate's next-invocation check. Skip when cooldown is disabled
806
+ // (cooldownHours === 0) to avoid polluting the FS with a never-read
807
+ // sidecar on rc.32-style "no cooldown" workspaces.
808
+ if (cooldownHours > 0 && !(env && env.skipCooldownWrite === true)) {
809
+ writeBroadLastEmit(cwd, nowMs);
810
+ }
568
811
  } catch {
569
812
  // Silent — never block session start on hook failure.
570
813
  }
@@ -583,16 +826,28 @@ module.exports = {
583
826
  readUnderseedThreshold,
584
827
  isImportTouched,
585
828
  shouldRecommendImport,
829
+ // v2.0.0-rc.33 W2-1 / W2-5 / W2-6 helpers.
830
+ readBroadTopK,
831
+ readBroadCooldownHours,
832
+ readReminderToContext,
833
+ readBroadLastEmit,
834
+ writeBroadLastEmit,
835
+ readSummaryMaxLen,
586
836
  CONSTANTS: {
587
837
  TRUNCATION_THRESHOLD,
588
838
  CLI_TIMEOUT_MS,
589
- SUMMARY_MAX_LEN,
839
+ SUMMARY_MAX_LEN: DEFAULT_SUMMARY_MAX_LEN,
840
+ DEFAULT_SUMMARY_MAX_LEN,
590
841
  CANONICAL_TYPE_ORDER,
591
842
  MATURITY_PROVEN,
592
843
  MATURITY_VERIFIED,
593
844
  MATURITY_DRAFT,
594
845
  DEFAULT_UNDERSEED_NODE_THRESHOLD,
595
846
  KNOWLEDGE_CANONICAL_TYPES,
847
+ DEFAULT_HINT_BROAD_TOP_K,
848
+ DEFAULT_HINT_BROAD_COOLDOWN_HOURS,
849
+ DEFAULT_HINT_REMINDER_TO_CONTEXT,
850
+ HINT_BROAD_LAST_EMIT_FILE,
596
851
  },
597
852
  };
598
853