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

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 (26) hide show
  1. package/dist/{doctor-TTDTKOFJ.js → doctor-E26YO67D.js} +8 -2
  2. package/dist/index.js +3 -3
  3. package/dist/{install-ODEKSJDS.js → install-YSFVNY3T.js} +1 -1
  4. package/package.json +3 -3
  5. package/templates/hooks/knowledge-hint-broad.cjs +268 -21
  6. package/templates/hooks/knowledge-hint-narrow.cjs +466 -14
  7. package/templates/skills/fabric-archive/SKILL.md +144 -738
  8. package/templates/skills/fabric-archive/ref/e5-cron-recap.md +5 -5
  9. package/templates/skills/fabric-archive/ref/i18n-policy.md +3 -3
  10. package/templates/skills/fabric-archive/ref/phase-0-range-resolution.md +156 -0
  11. package/templates/skills/fabric-archive/ref/{phase-0-4-onboard.md → phase-1-5-onboard.md} +21 -21
  12. package/templates/skills/fabric-archive/ref/phase-1-cross-session.md +60 -0
  13. package/templates/skills/fabric-archive/ref/phase-2-5-viability.md +54 -0
  14. package/templates/skills/fabric-archive/ref/phase-3-5-scope.md +80 -0
  15. package/templates/skills/fabric-archive/ref/phase-3-classify.md +63 -0
  16. package/templates/skills/fabric-archive/ref/phase-4-5-emit.md +78 -0
  17. package/templates/skills/fabric-archive/ref/phase-4-mcp-persist.md +89 -0
  18. package/templates/skills/fabric-archive/ref/rc-history.md +6 -6
  19. package/templates/skills/fabric-archive/ref/worked-examples.md +1 -1
  20. package/templates/skills/fabric-import/SKILL.md +29 -556
  21. package/templates/skills/fabric-import/ref/checkpoint-state.md +85 -0
  22. package/templates/skills/fabric-import/ref/output-contract.md +61 -0
  23. package/templates/skills/fabric-import/ref/phase-2-mining.md +213 -0
  24. package/templates/skills/fabric-import/ref/phase-3-dedup.md +75 -0
  25. package/templates/skills/fabric-import/ref/worked-examples.md +127 -0
  26. package/templates/skills/fabric-review/SKILL.md +1 -1
@@ -261,8 +261,12 @@ var doctorCommand = defineCommand({
261
261
  fixKnowledgeReport = await runDoctorFixKnowledge(resolution.target);
262
262
  report = fixKnowledgeReport.report;
263
263
  } else if (fix) {
264
- fixReport = await runDoctorFix(resolution.target);
265
- report = fixReport.report;
264
+ if (args["dry-run"] === true) {
265
+ report = await runDoctorReport(resolution.target);
266
+ } else {
267
+ fixReport = await runDoctorFix(resolution.target);
268
+ report = fixReport.report;
269
+ }
266
270
  } else {
267
271
  report = await runDoctorReport(resolution.target);
268
272
  }
@@ -277,6 +281,8 @@ var doctorCommand = defineCommand({
277
281
  renderFixKnowledgeMutations(fixKnowledgeReport, dt);
278
282
  } else if (fixReport !== null) {
279
283
  writeStdout(fixReport.message);
284
+ } else if (fix && args["dry-run"] === true) {
285
+ writeStdout(dt("cli.doctor.fix-dry-run-banner"));
280
286
  }
281
287
  renderHumanReport(report, dt);
282
288
  }
package/dist/index.js CHANGED
@@ -11,8 +11,8 @@ import { defineCommand, runMain } from "citty";
11
11
 
12
12
  // src/commands/index.ts
13
13
  var allCommands = {
14
- install: () => import("./install-ODEKSJDS.js").then((module) => module.default),
15
- doctor: () => import("./doctor-TTDTKOFJ.js").then((module) => module.default),
14
+ install: () => import("./install-YSFVNY3T.js").then((module) => module.default),
15
+ doctor: () => import("./doctor-E26YO67D.js").then((module) => module.default),
16
16
  serve: () => import("./serve-43JTEM3U.js").then((module) => module.default),
17
17
  uninstall: () => import("./uninstall-VLLJG7JT.js").then((module) => module.default),
18
18
  config: () => import("./config-5CH4EJQ2.js").then((module) => module.default),
@@ -26,7 +26,7 @@ var allCommands = {
26
26
  var main = defineCommand({
27
27
  meta: {
28
28
  name: "fabric",
29
- version: "2.0.0-rc.29",
29
+ version: "2.0.0-rc.33",
30
30
  description: t("cli.main.description")
31
31
  },
32
32
  subCommands: allCommands
@@ -1348,7 +1348,7 @@ function readProjectName(target) {
1348
1348
  return basename(target);
1349
1349
  }
1350
1350
  function getCliVersion() {
1351
- return true ? "2.0.0-rc.29" : "unknown";
1351
+ return true ? "2.0.0-rc.33" : "unknown";
1352
1352
  }
1353
1353
  function sortRecord(record) {
1354
1354
  return Object.fromEntries(Object.entries(record).sort(([left], [right]) => left.localeCompare(right)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fenglimg/fabric-cli",
3
- "version": "2.0.0-rc.29",
3
+ "version": "2.0.0-rc.33",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "fab": "dist/index.js",
@@ -20,8 +20,8 @@
20
20
  "tree-sitter-javascript": "^0.25.0",
21
21
  "tree-sitter-typescript": "^0.23.2",
22
22
  "web-tree-sitter": "^0.26.8",
23
- "@fenglimg/fabric-server": "2.0.0-rc.29",
24
- "@fenglimg/fabric-shared": "2.0.0-rc.29"
23
+ "@fenglimg/fabric-server": "2.0.0-rc.33",
24
+ "@fenglimg/fabric-shared": "2.0.0-rc.33"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^22.15.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,25 @@ 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
+ nowMs - lastEmitMs < cooldownHours * MS_PER_HOUR
712
+ ) {
713
+ return; // still in cooldown — silent
714
+ }
715
+ }
536
716
 
537
717
  // Test seam: env.payload short-circuits the CLI spawn so unit tests can
538
718
  // feed canned plan-context-hint JSON without depending on a built CLI.
@@ -540,6 +720,16 @@ function main(env, stdio) {
540
720
  env && env.payload !== undefined ? env.payload : invokePlanContextHint(cwd);
541
721
  if (payload === null || payload === undefined) return; // silent
542
722
 
723
+ // v2.0.0-rc.33 W2-1 (P0-9): apply TopK slice BEFORE renderSummary so the
724
+ // grouped/truncation rendering operates on the bounded set. Slicing here
725
+ // (not inside renderSummary) keeps the formatter pure — it never has to
726
+ // know about the cap.
727
+ const topK = readBroadTopK(cwd);
728
+ const slicedPayload =
729
+ payload && Array.isArray(payload.entries) && payload.entries.length > topK
730
+ ? { ...payload, entries: payload.entries.slice(0, topK) }
731
+ : payload;
732
+
543
733
  // rc.8 underseed self-check: decide whether to surface the one-line
544
734
  // `/fabric-import` recommendation banner alongside the broad summary.
545
735
  const recommendImport = shouldRecommendImport(cwd);
@@ -548,8 +738,10 @@ function main(env, stdio) {
548
738
  // SessionStart fire (Skill-style progressive disclosure). The prior
549
739
  // revision_hash cooldown gate (rc.7 T8 — rc.11) was removed because
550
740
  // compact/clear-triggered SessionStart re-fires must re-inject the menu
551
- // for the agent's working memory.
552
- const lines = renderSummary(payload);
741
+ // for the agent's working memory. rc.33 W2-5 reintroduces an opt-in
742
+ // hours-based cooldown via fabric-config (see gate above).
743
+ const summaryMaxLen = readSummaryMaxLen(cwd);
744
+ const lines = renderSummary(slicedPayload, summaryMaxLen);
553
745
 
554
746
  if (recommendImport) {
555
747
  // rc.16 TASK-003: resolve fabric_language ONCE per invocation (only when
@@ -562,9 +754,52 @@ function main(env, stdio) {
562
754
 
563
755
  if (lines.length === 0) return; // nothing to say — silent exit
564
756
 
757
+ // Stderr: always emit (human-facing breadcrumb + legacy contract).
565
758
  for (const line of lines) {
566
759
  err.write(`${line}\n`);
567
760
  }
761
+
762
+ // v2.0.0-rc.33 W2-6 (P0-7): stdout JSON envelope. When
763
+ // hint_reminder_to_context is true (default), serialize the same banner
764
+ // body as Claude Code's SessionStart hookSpecificOutput shape so the model
765
+ // receives the reminder IN-CONTEXT (rc.32 baseline cite-coverage 3.1%
766
+ // root cause: reminders never entered model context). Stderr stays the
767
+ // host-facing channel.
768
+ //
769
+ // Failure to write JSON envelope must NOT crash the hook — stderr already
770
+ // delivered, the stdout layer is best-effort.
771
+ // v2.0.0-rc.33 W4 review-fix (gemini High-1): the stdout JSON envelope
772
+ // is Claude Code-specific (hookSpecificOutput.additionalContext contract).
773
+ // Codex CLI / Cursor don't parse it — leaking it to their stdout risks
774
+ // either polluting the terminal or crashing the host's hook-parsing
775
+ // pipeline. CLAUDE_PROJECT_DIR is set by CC when invoking hooks (see
776
+ // packages/cli/templates/hooks/configs/claude-code.json sigil paths);
777
+ // its presence is the single-bit "this is Claude Code" signal.
778
+ const isClaudeCode =
779
+ typeof process.env.CLAUDE_PROJECT_DIR === "string" &&
780
+ process.env.CLAUDE_PROJECT_DIR.length > 0;
781
+ const reminderToContext = readReminderToContext(cwd) && isClaudeCode;
782
+ if (reminderToContext && !(env && env.skipStdout === true)) {
783
+ try {
784
+ const envelope = {
785
+ hookSpecificOutput: {
786
+ hookEventName: "SessionStart",
787
+ additionalContext: lines.join("\n"),
788
+ },
789
+ };
790
+ out.write(`${JSON.stringify(envelope)}\n`);
791
+ } catch {
792
+ // Best-effort — stderr is the durable contract
793
+ }
794
+ }
795
+
796
+ // v2.0.0-rc.33 W2-5 (P1-8): record successful emit timestamp for the
797
+ // cooldown gate's next-invocation check. Skip when cooldown is disabled
798
+ // (cooldownHours === 0) to avoid polluting the FS with a never-read
799
+ // sidecar on rc.32-style "no cooldown" workspaces.
800
+ if (cooldownHours > 0 && !(env && env.skipCooldownWrite === true)) {
801
+ writeBroadLastEmit(cwd, nowMs);
802
+ }
568
803
  } catch {
569
804
  // Silent — never block session start on hook failure.
570
805
  }
@@ -583,16 +818,28 @@ module.exports = {
583
818
  readUnderseedThreshold,
584
819
  isImportTouched,
585
820
  shouldRecommendImport,
821
+ // v2.0.0-rc.33 W2-1 / W2-5 / W2-6 helpers.
822
+ readBroadTopK,
823
+ readBroadCooldownHours,
824
+ readReminderToContext,
825
+ readBroadLastEmit,
826
+ writeBroadLastEmit,
827
+ readSummaryMaxLen,
586
828
  CONSTANTS: {
587
829
  TRUNCATION_THRESHOLD,
588
830
  CLI_TIMEOUT_MS,
589
- SUMMARY_MAX_LEN,
831
+ SUMMARY_MAX_LEN: DEFAULT_SUMMARY_MAX_LEN,
832
+ DEFAULT_SUMMARY_MAX_LEN,
590
833
  CANONICAL_TYPE_ORDER,
591
834
  MATURITY_PROVEN,
592
835
  MATURITY_VERIFIED,
593
836
  MATURITY_DRAFT,
594
837
  DEFAULT_UNDERSEED_NODE_THRESHOLD,
595
838
  KNOWLEDGE_CANONICAL_TYPES,
839
+ DEFAULT_HINT_BROAD_TOP_K,
840
+ DEFAULT_HINT_BROAD_COOLDOWN_HOURS,
841
+ DEFAULT_HINT_REMINDER_TO_CONTEXT,
842
+ HINT_BROAD_LAST_EMIT_FILE,
596
843
  },
597
844
  };
598
845