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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +8 -5
  2. package/dist/chunk-27HK6H5Y.js +69 -0
  3. package/dist/{chunk-AOE6AYI7.js → chunk-2KBCTMID.js} +31 -8
  4. package/dist/chunk-3D7B2UAZ.js +149 -0
  5. package/dist/{chunk-XC5RUHLK.js → chunk-3IOLS5EK.js} +23 -38
  6. package/dist/{plan-context-hint-FC6P3WFE.js → chunk-722JU5BP.js} +52 -12
  7. package/dist/{chunk-2R55HNVD.js → chunk-7ZDXBOOU.js} +234 -206
  8. package/dist/{doctor-YONYXDX6.js → chunk-E7HJUU34.js} +215 -52
  9. package/dist/chunk-EOT63RDH.js +36 -0
  10. package/dist/chunk-FNHDQTPC.js +16 -0
  11. package/dist/{chunk-2CY4BMTH.js → chunk-HORSMSZL.js} +9 -5
  12. package/dist/{chunk-BO4XIZWZ.js → chunk-NLNH64A3.js} +5 -18
  13. package/dist/{chunk-WU6GAPKH.js → chunk-PTGQAZEW.js} +12 -4
  14. package/dist/chunk-QFIVFZRH.js +13 -0
  15. package/dist/chunk-QPAW6IYT.js +387 -0
  16. package/dist/{chunk-COI5VDFU.js → chunk-WA3DYGSY.js} +1 -2
  17. package/dist/{config-XYRBZJDU.js → config-A3LTECAY.js} +4 -3
  18. package/dist/context-UJCGYOT6.js +117 -0
  19. package/dist/doctor-MDTZWKBK.js +24 -0
  20. package/dist/index.d.ts +2 -2
  21. package/dist/index.js +133 -22
  22. package/dist/info-7FKBTMVO.js +139 -0
  23. package/dist/install-v2-WLEJ5XHT.js +3279 -0
  24. package/dist/{metrics-RER6NLFC.js → metrics-HMFH4YHK.js} +1 -1
  25. package/dist/{onboard-coverage-JWQWDZW7.js → onboard-coverage-XSG77LL3.js} +48 -27
  26. package/dist/plan-context-hint-5TNGH3R4.js +12 -0
  27. package/dist/{scope-explain-CDIZESP5.js → scope-explain-HLJZ2M33.js} +17 -6
  28. package/dist/status-4R3TM4FJ.js +37 -0
  29. package/dist/store-HOCORVL3.js +563 -0
  30. package/dist/{sync-UJ4BBCZJ.js → sync-DT5UJMMR.js} +197 -30
  31. package/dist/{uninstall-C3QXKOO6.js → uninstall-IFN2KYBK.js} +97 -140
  32. package/dist/whoami-ITGEFWH4.js +49 -0
  33. package/package.json +7 -5
  34. package/templates/hooks/cite-policy-evict.cjs +412 -160
  35. package/templates/hooks/configs/README.md +14 -27
  36. package/templates/hooks/configs/claude-code.json +17 -2
  37. package/templates/hooks/configs/codex-hooks.json +15 -3
  38. package/templates/hooks/fabric-hint.cjs +742 -259
  39. package/templates/hooks/knowledge-hint-broad.cjs +577 -274
  40. package/templates/hooks/knowledge-hint-narrow.cjs +113 -73
  41. package/templates/hooks/lib/banner-i18n.cjs +50 -1
  42. package/templates/hooks/lib/bindings-snapshot-reader.cjs +118 -7
  43. package/templates/hooks/lib/cite-line-parser.cjs +12 -20
  44. package/templates/hooks/lib/client-adapter.cjs +66 -7
  45. package/templates/hooks/lib/nudge-policy.cjs +117 -0
  46. package/templates/hooks/lib/state-store.cjs +60 -0
  47. package/templates/hooks/post-tooluse-mutation.cjs +386 -0
  48. package/templates/hooks/session-end-marker.cjs +140 -0
  49. package/templates/skills/fabric/SKILL.md +100 -0
  50. package/templates/skills/fabric-archive/SKILL.md +47 -24
  51. package/templates/skills/fabric-archive/ref/dry-run-scope.md +1 -1
  52. package/templates/skills/fabric-archive/ref/i18n-policy.md +2 -3
  53. package/templates/skills/fabric-archive/ref/phase-1-5-onboard.md +2 -3
  54. package/templates/skills/fabric-archive/ref/phase-1-cross-session.md +1 -1
  55. package/templates/skills/fabric-archive/ref/phase-2-5-viability.md +1 -1
  56. package/templates/skills/fabric-archive/ref/phase-3-6-related-edges.md +18 -0
  57. package/templates/skills/fabric-archive/ref/phase-3-7-semantic-scope.md +47 -0
  58. package/templates/skills/fabric-audit/SKILL.md +13 -3
  59. package/templates/skills/fabric-connect/SKILL.md +3 -3
  60. package/templates/skills/fabric-import/SKILL.md +7 -7
  61. package/templates/skills/fabric-import/ref/i18n-policy.md +2 -3
  62. package/templates/skills/fabric-import/ref/state-recovery.md +1 -2
  63. package/templates/skills/fabric-review/SKILL.md +14 -5
  64. package/templates/skills/fabric-review/ref/cite-contract.md +1 -1
  65. package/templates/skills/fabric-review/ref/i18n-policy.md +2 -3
  66. package/templates/skills/fabric-review/ref/output-contract.md +1 -1
  67. package/templates/skills/fabric-review/ref/per-mode-flows.md +2 -2
  68. package/templates/skills/fabric-review/ref/worked-examples.md +1 -1
  69. package/templates/skills/fabric-store/SKILL.md +1 -1
  70. package/templates/skills/fabric-sync/SKILL.md +1 -1
  71. package/templates/skills/lib/shared-policy.md +2 -2
  72. package/dist/chunk-4R2CYEA4.js +0 -116
  73. package/dist/chunk-L4Q55UC4.js +0 -52
  74. package/dist/chunk-LFIKMVY7.js +0 -27
  75. package/dist/chunk-RYAFBNES.js +0 -33
  76. package/dist/chunk-T5RPGCCM.js +0 -40
  77. package/dist/install-74ANPCCP.js +0 -2737
  78. package/dist/status-GLQWLWH6.js +0 -23
  79. package/dist/store-XB3ADT65.js +0 -144
  80. package/dist/whoami-2MLO4Y37.js +0 -36
  81. package/templates/hooks/configs/cursor-hooks.json +0 -18
  82. package/templates/hooks/lib/cite-contract-reminder.cjs +0 -179
  83. package/templates/hooks/lib/summary-fallback.cjs +0 -210
@@ -12,28 +12,20 @@
12
12
  * narrow-injection sibling (E2, knowledge-hint-narrow.cjs) handles
13
13
  * per-Edit/Write hints with a session-hints cache.
14
14
  *
15
- * Output contract (stderr only):
15
+ * Output contract (W2 / KT-DEC-0027/0028/0029 — the SessionStart spine):
16
16
  *
17
- * When narrow count <= 30 (full per-type listing mode):
18
- * [fabric] Session start — N broad-scoped knowledge entries available:
19
- * [decision] (proven)
20
- * - <id> · <summary>
21
- * [pitfall] (verified)
22
- * - <id> · <summary>
23
- * ...
24
- * revision_hash: <hash>
25
- * Load full content: `fab_recall(paths)`, or `fab_plan_context` -> `fab_get_knowledge_sections` to trim first.
17
+ * AI sink (additionalContext) the dynamically generated "MEMORY.md":
18
+ * [fabric:SessionStart] <store>
19
+ * ALWAYS-ACTIVE RULES (unconditional · act on the line): # guideline/model, BROAD only
20
+ * [guideline] team:KT-GLD-0001 · <summary> # INDEX line; body on demand (KT-DEC-0036)
21
+ * REFERENCE (situational · Read when must_read_if fires): # decision/pitfall/process, BROAD
22
+ * [decision] team:KT-DEC-0001 <must_read_if>
23
+ * … N more folded (broad index > backstop 50; prune via fabric-audit)
24
+ * Load full content: fab_recall(paths), or Read <store>/knowledge/<type>/<id>--*.md
26
25
  *
27
- * When narrow count > 30 (grouped-truncation mode, per type):
28
- * [fabric] Session start — N broad-scoped knowledge entries available (truncated):
29
- * [decision] proven (3):
30
- * - <id> · <summary>
31
- * - ...
32
- * [decision] verified (12): <id1>, <id2>, ...
33
- * [decision] draft: 7 entries
34
- * ...
35
- * revision_hash: <hash>
36
- * Load full content: `fab_recall(paths)`, or `fab_plan_context` -> `fab_get_knowledge_sections` to trim first.
26
+ * Human sink (systemMessage) broad-only census breadcrumb; SessionStart is
27
+ * SILENT about narrow-scoped knowledge (no on-demand counts, no
28
+ * dropped-other-project line).
37
29
  *
38
30
  * When 0 entries / CLI unavailable / CLI error / parse failure:
39
31
  * (no output — silent exit 0)
@@ -61,7 +53,6 @@ const { appendLockedLine } = require("./lib/injection-log.cjs");
61
53
  // (TASK-002). Variant is resolved ONCE per main() invocation via
62
54
  // readFabricLanguage(cwd) and threaded into renderBanner — no fs in render path.
63
55
  const { renderBanner, readFabricLanguage } = require("./lib/banner-i18n.cjs");
64
- const { resolveOpaqueSummaries } = require("./lib/summary-fallback.cjs");
65
56
  // v2.0.0-rc.37 NEW-19: shared fabric-config reader + sidecar I/O. Replaces the
66
57
  // five per-key readFileSync+parse config readers (one parse per fire now) and
67
58
  // the bespoke last-emit sidecar helpers. The L78 "refactor into lib/ if a
@@ -74,7 +65,18 @@ const {
74
65
  const { readTextState, writeTextState } = require("./lib/state-store.cjs");
75
66
  // v2.0.0-rc.37 NEW-30: shared client detection (replaces the inline
76
67
  // CLAUDE_PROJECT_DIR single-bit check below).
77
- const { isClaudeCode, detectClient } = require("./lib/client-adapter.cjs");
68
+ // v2.2 dual-sink (Goal A): + emitDualSink (two-channel SessionStart emit).
69
+ const { isClaudeCode, detectClient, emitDualSink } = require("./lib/client-adapter.cjs");
70
+ // v2.2 dual-sink (Goal A / D4): human-output gate (nudge_mode + observe.*). Only
71
+ // governs the human systemMessage — the AI additionalContext is emitted
72
+ // regardless (flow ⊥ observation). Optional require so an old install lacking the
73
+ // lib degrades to "always emit human" (the pre-dual-sink default).
74
+ let nudgePolicy = null;
75
+ try {
76
+ nudgePolicy = require("./lib/nudge-policy.cjs");
77
+ } catch {
78
+ // Lib missing (old install) — human sink always emits (legacy behavior).
79
+ }
78
80
  // v2.1.0-rc.1 P4 (F4/S63): hook-side reader for the CLI pre-generated
79
81
  // resolved-bindings snapshot. The hook NEVER re-resolves stores or walks store
80
82
  // trees — it only echoes the read-set the CLI already computed. Best-effort.
@@ -94,19 +96,62 @@ try {
94
96
  // Lib missing (old install) — injection telemetry degrades to silent absence.
95
97
  }
96
98
 
97
- // Read the project's own `project_id` from `.fabric/fabric-config.json` (the
98
- // snapshot key). Reading the PROJECT config is not a store-tree read — it is how
99
- // the hook learns which snapshot to fetch. Returns null on any failure.
100
- function readProjectId(cwd) {
99
+ // Read the workspace binding id from `.fabric/fabric-config.json` (the snapshot
100
+ // key). Defaults to project_id when workspace_binding_id is absent.
101
+ function readWorkspaceBindingId(cwd) {
101
102
  try {
102
103
  const raw = readFileSync(join(cwd, ".fabric", "fabric-config.json"), "utf8");
103
104
  const parsed = JSON.parse(raw);
105
+ if (typeof parsed.workspace_binding_id === "string") return parsed.workspace_binding_id;
104
106
  return typeof parsed.project_id === "string" ? parsed.project_id : null;
105
107
  } catch {
106
108
  return null;
107
109
  }
108
110
  }
109
111
 
112
+ function readSnapshotCanonicalCount(projectRoot) {
113
+ // No reader / not bound → degrade to an empty corpus (0), the documented
114
+ // store-only behavior (KT-DEC-0007). Only the "snapshot EXISTS but predates
115
+ // knowledge_store_dirs" case below is undeterminable → null (skip).
116
+ if (bindingsSnapshotReader === null) {
117
+ return 0;
118
+ }
119
+ const bindingId = readWorkspaceBindingId(projectRoot);
120
+ if (bindingId === null) {
121
+ return 0;
122
+ }
123
+ try {
124
+ const snapshot = bindingsSnapshotReader.readBindingsSnapshot(bindingId);
125
+ // No snapshot file at all → treat as an empty corpus (KT-DEC-0007),
126
+ // preserving the fresh-project underseed nudge.
127
+ if (!snapshot) {
128
+ return 0;
129
+ }
130
+ // LIVE recount off the snapshot's resolved store dirs. The cached
131
+ // knowledge_stats.canonical_count is frozen at snapshot-write time and goes
132
+ // stale when store content syncs in out-of-band (e.g. the store grew from 1
133
+ // → 57 nodes via a `git pull`/cross-workspace sync that never regenerated
134
+ // THIS workspace's snapshot), which mis-fired the "knowledge sparse"
135
+ // underseed nudge (KT-PIT-0017, same stale-projection root cause).
136
+ const live = bindingsSnapshotReader.liveKnowledgeStats(snapshot);
137
+ // #3: a snapshot that predates knowledge_store_dirs makes liveKnowledgeStats
138
+ // return null — the count is undeterminable and the cached projection is
139
+ // unreliable. Return null (not 0) so countCanonicalNodes / shouldRecommendImport
140
+ // SKIP the nudge instead of false-firing on stale data; the snapshot
141
+ // self-heals on the next install/sync. A genuine live 0 (dirs present, no
142
+ // *.md) still returns 0 and fires correctly.
143
+ if (live === null) {
144
+ return null;
145
+ }
146
+ return Number.isFinite(live.canonicalCount) ? Math.floor(live.canonicalCount) : 0;
147
+ } catch {
148
+ // Read/parse fault → degrade to empty (0), preserving prior behavior. The
149
+ // only undeterminable→skip path is the explicit live===null above.
150
+ return 0;
151
+ }
152
+ }
153
+
154
+
110
155
  // -----------------------------------------------------------------------------
111
156
  // rc.12: SessionStart broad-menu is now unconditionally emitted on every
112
157
  // SessionStart fire (matching Skill-style progressive disclosure). Prior
@@ -124,7 +169,6 @@ const FABRIC_DIR_REL = ".fabric";
124
169
  // cannot `require` each other. If a third hook ever needs the same logic,
125
170
  // refactor into packages/cli/templates/hooks/lib/. Keep these values in sync
126
171
  // with packages/cli/templates/hooks/fabric-hint.cjs.
127
- const AGENTS_META_FILE = "agents.meta.json";
128
172
  const IMPORT_STATE_FILE = ".import-state.json";
129
173
  const KNOWLEDGE_CANONICAL_TYPES = [
130
174
  "decisions",
@@ -135,11 +179,12 @@ const KNOWLEDGE_CANONICAL_TYPES = [
135
179
  ];
136
180
  const DEFAULT_UNDERSEED_NODE_THRESHOLD = 10;
137
181
 
138
- // v2.0.0-rc.33 W2-1 (P0-9): TopK upper bound on broad-scoped entries surfaced
139
- // per SessionStart fire. Keeps the banner inside ~1 screenful so the agent
140
- // actually reads the top-priority entries instead of triaging a wall of text.
141
- // Overridable via fabric-config.json#hint_broad_top_k (range 1..50).
142
- const DEFAULT_HINT_BROAD_TOP_K = 8;
182
+ // W2-1 (KT-DEC-0028): the broad index is shown in FULL (no top-K hard cap). The
183
+ // only scale guard is a backstop: when the rendered broad index exceeds this
184
+ // many lines, the overflow tail folds into one marker that doubles as a drift
185
+ // signal (the W4 doctor `broad-index-drift` lint is the authoritative detector).
186
+ // Overridable via fabric-config.json#broad_index_backstop (W4 schema range 20..500).
187
+ const DEFAULT_HINT_BROAD_INDEX_BACKSTOP = 50;
143
188
 
144
189
  // v2.0.0-rc.33 W2-5 (P1-8): cooldown (in hours) between broad-hint re-emits.
145
190
  // Default 0 preserves rc.32 behavior — every SessionStart re-fires the banner.
@@ -169,37 +214,15 @@ const DEFAULT_HINT_REMINDER_TO_CONTEXT = true;
169
214
  // -----------------------------------------------------------------------------
170
215
 
171
216
  /**
172
- * Count canonical knowledge entries across the five canonical type subdirs
173
- * (decisions / pitfalls / guidelines / models / processes). Pending entries
174
- * are NOT counted they are proposals, not seeded knowledge.
175
- *
176
- * Returns the integer count. ENOENT / unreadable subdir → silently treated as
177
- * zero (preserves never-block-on-failure invariant). Filters on `.md` suffix
178
- * only; the more-precise canonical filename pattern check is owned by
179
- * doctor.ts (the hook is a coarse signal, not a lint).
217
+ * Count canonical knowledge entries from the CLI-generated resolved-bindings
218
+ * snapshot. Store-only: hooks never walk project-local knowledge or store
219
+ * trees a missing snapshot degrades to zero (KT-DEC-0007).
180
220
  */
181
221
  function countCanonicalNodes(projectRoot) {
182
- const knowledgeRoot = join(projectRoot, FABRIC_DIR_REL, "knowledge");
183
- if (!existsSync(knowledgeRoot)) {
184
- return 0;
185
- }
186
- let count = 0;
187
- for (const type of KNOWLEDGE_CANONICAL_TYPES) {
188
- const typeDir = join(knowledgeRoot, type);
189
- if (!existsSync(typeDir)) continue;
190
- let entries;
191
- try {
192
- entries = readdirSync(typeDir);
193
- } catch {
194
- continue;
195
- }
196
- for (const entry of entries) {
197
- if (entry.endsWith(".md")) {
198
- count += 1;
199
- }
200
- }
201
- }
202
- return count;
222
+ // #3: null = undeterminable (old snapshot lacking store dirs, or no binding
223
+ // context). Propagate it — shouldRecommendImport SKIPS on null rather than
224
+ // treating it as zero and false-firing the underseed nudge on a stale corpus.
225
+ return readSnapshotCanonicalCount(projectRoot);
203
226
  }
204
227
 
205
228
  /**
@@ -216,14 +239,15 @@ function readUnderseedThreshold(projectRoot) {
216
239
  }
217
240
 
218
241
  /**
219
- * v2.0.0-rc.33 W2-1: resolve hint_broad_top_k from fabric-config.json. Slices
220
- * the broad entry list to TopK before group/truncation render. Validates the
221
- * schema's 1..50 range inline so a malformed config silently falls back.
242
+ * W2-1 (KT-DEC-0028): resolve broad_index_backstop from fabric-config.json. Caps
243
+ * the rendered broad index line count; the overflow tail folds into a drift
244
+ * marker. Validates the W4 schema's 20..500 range inline so a malformed config
245
+ * silently falls back to the default.
222
246
  */
223
- function readBroadTopK(projectRoot) {
224
- return readConfigNumber(projectRoot, "hint_broad_top_k", DEFAULT_HINT_BROAD_TOP_K, {
225
- min: 1,
226
- max: 50,
247
+ function readBroadIndexBackstop(projectRoot) {
248
+ return readConfigNumber(projectRoot, "broad_index_backstop", DEFAULT_HINT_BROAD_INDEX_BACKSTOP, {
249
+ min: 20,
250
+ max: 500,
227
251
  floor: true,
228
252
  });
229
253
  }
@@ -309,9 +333,11 @@ function isImportTouched(projectRoot) {
309
333
  * surface the one-line `/fabric-import` recommendation banner.
310
334
  *
311
335
  * Three-condition truth table (ALL must hold to return true):
312
- * 1. `.fabric/agents.meta.json` exists
313
- * (workspace has been `fabric init`-ed; otherwise the recommendation
314
- * is meaningless — `fabric-import` requires init's baseline scan).
336
+ * 1. the workspace is fabric-bound — readWorkspaceBindingId(cwd) !== null
337
+ * (a resolved binding id in fabric-config.json; otherwise the
338
+ * recommendation is meaningless — `fabric-import` requires a bound
339
+ * workspace. Store-only: replaces the legacy derived-index-file
340
+ * existence probe.)
315
341
  * 2. countCanonicalNodes(cwd) < readUnderseedThreshold(cwd)
316
342
  * (knowledge graph is sparse — import would meaningfully enrich it).
317
343
  * 3. isImportTouched(cwd) === 'absent'
@@ -322,13 +348,26 @@ function isImportTouched(projectRoot) {
322
348
  *
323
349
  * Best-effort: any unexpected error → return false (do not nag on faults).
324
350
  */
325
- function shouldRecommendImport(projectRoot) {
351
+ function shouldRecommendImport(projectRoot, liveTotal) {
326
352
  try {
327
- const metaPath = join(projectRoot, FABRIC_DIR_REL, AGENTS_META_FILE);
328
- if (!existsSync(metaPath)) return false;
353
+ if (readWorkspaceBindingId(projectRoot) === null) return false;
329
354
 
330
355
  const threshold = readUnderseedThreshold(projectRoot);
331
- const nodeCount = countCanonicalNodes(projectRoot);
356
+ // P0 (Goal H3 / KT-PIT-0017 + KT-PIT-0019): prefer the LIVE census total the
357
+ // HUD displays as the single count source. The census walks the read-set
358
+ // fresh every fire (never a frozen snapshot projection), so feeding it here
359
+ // makes the import nudge and the HUD agree by construction — killing the
360
+ // "HUD shows 61 entries but the nudge claims the KB is sparse" contradiction
361
+ // the stale-snapshot count produced. Fall back to the snapshot-derived count
362
+ // only when no live total is supplied (e.g. direct unit-test calls).
363
+ const nodeCount =
364
+ typeof liveTotal === "number" && Number.isFinite(liveTotal)
365
+ ? liveTotal
366
+ : countCanonicalNodes(projectRoot);
367
+ // #3: undeterminable count (old snapshot predating knowledge_store_dirs) →
368
+ // skip. `null < threshold` coerces to true in JS, so an explicit guard is
369
+ // required — otherwise the stale-snapshot case would still false-fire.
370
+ if (nodeCount === null) return false;
332
371
  if (nodeCount >= threshold) return false;
333
372
 
334
373
  if (isImportTouched(projectRoot) !== "absent") return false;
@@ -354,6 +393,12 @@ function shouldRecommendImport(projectRoot) {
354
393
  // truncation summary lines) consume it as a single source of truth.
355
394
  const TRUNCATION_THRESHOLD = 12;
356
395
 
396
+ // Goal H4 action ladder — review rung: surface a single `/fabric-review` line when
397
+ // the LIVE pending backlog exceeds this. Mirrors fabric-hint.cjs's
398
+ // DEFAULT_REVIEW_HINT_PENDING_COUNT (the Stop hook's review threshold) so the two
399
+ // surfaces agree on "how much pending is too much". Strictly `> threshold`.
400
+ const REVIEW_PENDING_THRESHOLD = 10;
401
+
357
402
  // `fabric plan-context-hint` is a thin wrapper over planContext(); on a
358
403
  // well-seeded repo it returns in ~100ms. Two-second cap is defensive — any
359
404
  // pathological hang must not stall session start.
@@ -365,65 +410,6 @@ const CLI_TIMEOUT_MS = 2000;
365
410
  // `hint_summary_max_len` in fabric-config overrides this default (range 40..240).
366
411
  const DEFAULT_SUMMARY_MAX_LEN = 80;
367
412
 
368
- // v2.2 HK2-degrade (W2-T2): char budget for the rendered broad-menu BODY. The
369
- // hook already degrades by COUNT (hint_broad_top_k slice + TRUNCATION_THRESHOLD
370
- // grouped mode), but nothing bounded the total rendered SIZE — a corpus with
371
- // many types or long (near-maxLen) summaries could still emit a wall of text
372
- // that displaces the agent's working memory. Borrowing the maestro
373
- // context-budget idea, this is the final rung of the degradation ladder: once
374
- // the body exceeds the budget, the tail collapses to a single "N more omitted"
375
- // marker. Default 2000 chars ≈ one screenful. Overridable via
376
- // fabric-config.json#hint_broad_budget_chars (range 200..20000); 0 disables.
377
- const DEFAULT_HINT_BROAD_BUDGET_CHARS = 2000;
378
-
379
- // v2.2 C5-budget (W2-T3): bind the injection char budget to the layered retrieval
380
- // budget profile. Mirrors the injectionChars column of shared/retrieval-budget.ts
381
- // PROFILES (kept in sync — the hook cannot require the TS resolver). The explicit
382
- // `hint_broad_budget_chars` knob still wins; the profile only supplies the
383
- // default. `balanced` (and an absent/unknown profile) keeps the historical 2000.
384
- const RETRIEVAL_BUDGET_INJECTION_CHARS = {
385
- conservative: 1000,
386
- balanced: 2000,
387
- generous: 4000,
388
- };
389
-
390
- function readBroadBudgetChars(projectRoot) {
391
- const profile = readConfigString(projectRoot, "retrieval_budget_profile", "balanced");
392
- const profileDefault =
393
- RETRIEVAL_BUDGET_INJECTION_CHARS[profile] ?? DEFAULT_HINT_BROAD_BUDGET_CHARS;
394
- return readConfigNumber(projectRoot, "hint_broad_budget_chars", profileDefault, {
395
- min: 0,
396
- max: 20000,
397
- floor: true,
398
- });
399
- }
400
-
401
- // v2.2 HK2-degrade (W2-T2): cap the rendered body to `budgetChars`, collapsing
402
- // the overflow tail into one marker line. Structural lines (banner, revision_hash,
403
- // footer) are appended by renderSummary AFTER this pass, so they always survive —
404
- // only entry/group body lines are subject to the budget. `budgetChars` of 0 or
405
- // undefined is a no-op (preserves the pre-HK2 unbounded behavior and all
406
- // existing snapshot tests).
407
- function capBodyToBudget(body, budgetChars) {
408
- if (!budgetChars || budgetChars <= 0) return body;
409
- const kept = [];
410
- let total = 0;
411
- for (let i = 0; i < body.length; i += 1) {
412
- const line = body[i];
413
- // +1 for the newline each line costs once joined.
414
- if (kept.length > 0 && total + line.length + 1 > budgetChars) {
415
- const remaining = body.length - i;
416
- kept.push(
417
- ` … ${remaining} more entr${remaining === 1 ? "y" : "ies"} omitted (injection budget ${budgetChars} chars; raise hint_broad_budget_chars or narrow scope)`,
418
- );
419
- return kept;
420
- }
421
- kept.push(line);
422
- total += line.length + 1;
423
- }
424
- return kept;
425
- }
426
-
427
413
  function readSummaryMaxLen(projectRoot) {
428
414
  return readConfigNumber(projectRoot, "hint_summary_max_len", DEFAULT_SUMMARY_MAX_LEN, {
429
415
  min: 40,
@@ -574,7 +560,18 @@ function truncateSummary(raw, maxLen) {
574
560
  function formatEntryLine(entry, maxLen) {
575
561
  const id = entry.id || "(no-id)";
576
562
  const summary = truncateSummary(entry.summary, maxLen);
577
- return summary.length > 0 ? ` - ${id} · ${summary}` : ` - ${id}`;
563
+ // lifecycle-refactor W3-T2 (§7 图谱消费 / §5 hook 沿 related 二阶召回): when this
564
+ // entry was pulled in by following a surfaced entry's `related` graph edge,
565
+ // tag the line with its provenance so the agent knows it arrived via the graph,
566
+ // not its own ranking. Omitted entirely for ordinarily-ranked entries — no fake
567
+ // "related" annotation is ever synthesized (graph-empty honesty).
568
+ const provenance =
569
+ typeof entry.related_to === "string" && entry.related_to.length > 0
570
+ ? ` (related-to-${entry.related_to})`
571
+ : "";
572
+ return summary.length > 0
573
+ ? ` - ${id} · ${summary}${provenance}`
574
+ : ` - ${id}${provenance}`;
578
575
  }
579
576
 
580
577
  /**
@@ -662,7 +659,7 @@ function renderTruncated(narrow, maxLen) {
662
659
  * after writing exactly one stderr breadcrumb so operators grepping a stuck-
663
660
  * banner report can diagnose the version drift without source-diving.
664
661
  */
665
- function renderSummary(payload, maxLen, budgetChars) {
662
+ function renderSummary(payload, maxLen) {
666
663
  if (!payload || payload.version !== 2) {
667
664
  if (payload && payload.version !== undefined) {
668
665
  try {
@@ -685,9 +682,9 @@ function renderSummary(payload, maxLen, budgetChars) {
685
682
  ? `[fabric] Session start — ${entries.length} broad-scoped knowledge entries available (truncated):`
686
683
  : `[fabric] Session start — ${entries.length} broad-scoped knowledge entries available:`;
687
684
 
688
- const renderedBody = truncated ? renderTruncated(entries, maxLen) : renderFull(entries, maxLen);
689
- // v2.2 HK2-degrade (W2-T2): final budget rung cap the body's rendered size.
690
- const body = capBodyToBudget(renderedBody, budgetChars);
685
+ // KT-DEC-0028 completeness: the rendered census is bounded by the per-line
686
+ // maxLen + TRUNCATION_THRESHOLD grouped mode, not by a body char budget.
687
+ const body = truncated ? renderTruncated(entries, maxLen) : renderFull(entries, maxLen);
691
688
 
692
689
  const lines = [banner, ...body];
693
690
  const revHash = typeof payload.revision_hash === "string" ? payload.revision_hash : null;
@@ -734,26 +731,423 @@ function renderSummary(payload, maxLen, budgetChars) {
734
731
  }
735
732
  }
736
733
 
737
- // v2.2 MC3-fix-guidance (W1-T5): unify the footer with the canonical recall
738
- // flow. The prior text ("Use `fab_get_knowledge_sections` to fetch full
739
- // content.") told the agent to call a tool that REQUIRES a selection_token it
740
- // does not yet have — directly contradicting the bilingual next-step nudge
741
- // (and AGENTS.md) which leads with `fab_recall`. Footer now states the same
742
- // two-path model: single-step `fab_recall`, or `fab_plan_context` →
743
- // `fab_get_knowledge_sections` when the bodies must be trimmed first. Keeps
744
- // the `fab_get_knowledge_sections` token (downstream substring contracts) but
745
- // sequences it correctly behind the token-issuing `fab_plan_context`.
734
+ // W2-4 (KT-DEC-0026): single lean retrieval flow. The two-step
735
+ // fab_plan_context fab_get_knowledge_sections footer is retired fab_recall
736
+ // returns descriptions + read paths, and bodies load via a native Read.
746
737
  lines.push(
747
- " Load full content: `fab_recall(paths)` (one step), or `fab_plan_context` `fab_get_knowledge_sections` to trim first.",
738
+ " Load full content: `fab_recall(paths)`, or Read `<store>/knowledge/<type>/<id>--*.md` directly.",
748
739
  );
749
740
  return lines;
750
741
  }
751
742
 
743
+ // -----------------------------------------------------------------------------
744
+ // v2.2 dual-sink (Goal A): two-sink SessionStart rendering.
745
+ //
746
+ // HUMAN sink (systemMessage): a grouped census (§3 / D8) — always-loaded vs
747
+ // on-demand split + [team]/[personal] + ✗ dropped-other-project. Count-summary
748
+ // form (not a per-entry wall of text); the verbose nudge_mode appends the legacy
749
+ // per-entry renderSummary listing on top.
750
+ //
751
+ // AI sink (additionalContext): the always-active (guideline/model) BODIES (§3 /
752
+ // D9), bounded by the injection char budget with overflow degrading to summary +
753
+ // a recall pointer, followed by on-demand category counts. Replaces the legacy
754
+ // top_k=8 id-list that used to be the AI payload.
755
+ // -----------------------------------------------------------------------------
756
+
757
+ // Singular display label for a plural knowledge_type.
758
+ const TYPE_SINGULAR = {
759
+ decisions: "decision",
760
+ pitfalls: "pitfall",
761
+ guidelines: "guideline",
762
+ models: "model",
763
+ processes: "process",
764
+ };
765
+
766
+ const ALWAYS_TYPES = ["guidelines", "models"];
767
+
768
+ // Normalize a knowledge_type to its canonical PLURAL form. Frontmatter / entries
769
+ // may carry the singular ("decision") while the census keys on the plural enum
770
+ // ("decisions"); fold both so counting + display stay consistent.
771
+ const TYPE_TO_PLURAL = {
772
+ decision: "decisions",
773
+ pitfall: "pitfalls",
774
+ guideline: "guidelines",
775
+ model: "models",
776
+ process: "processes",
777
+ };
778
+ function toPluralType(type) {
779
+ return TYPE_TO_PLURAL[type] || type;
780
+ }
781
+
782
+ // Fallback census when payload.census is absent (old CLI / unit-test payloads):
783
+ // count the (possibly sliced) entries by knowledge_type so the human banner still
784
+ // has something to group. Production payloads always carry the unsliced census.
785
+ function deriveCensusFromEntries(entries) {
786
+ const census = {
787
+ by_type: {},
788
+ by_layer: { team: 0, personal: 0, project: 0 },
789
+ broad_by_type: {},
790
+ narrow_total: 0,
791
+ dropped_other_project: 0,
792
+ total: 0,
793
+ };
794
+ if (!Array.isArray(entries)) return census;
795
+ for (const e of entries) {
796
+ const type = e && typeof e.type === "string" ? toPluralType(e.type) : null;
797
+ if (type === null) continue;
798
+ const isNarrow = e.relevance_scope === "narrow";
799
+ census.by_type[type] = (census.by_type[type] || 0) + 1;
800
+ if (isNarrow) census.narrow_total += 1;
801
+ else census.broad_by_type[type] = (census.broad_by_type[type] || 0) + 1;
802
+ census.total += 1;
803
+ }
804
+ return census;
805
+ }
806
+
807
+ // Render the human-facing scope-primary status HUD (Goal H2). `lang` is
808
+ // "zh-CN" | other (en). Returns an array of lines (empty when census is empty).
809
+ //
810
+ // Shape (KT-DEC-0029 — SessionStart is scope-primary; broad is the spine that's
811
+ // injected this session, narrow surfaces contextually via the PreToolUse hint):
812
+ // ▸ [fabric] 共 N 条 · 团队X · 项目Y · 个人Z
813
+ // broad B · 本会话注入
814
+ // ├ 常驻规则 G+M guideline G · model M (KT-DEC-0027 resident tier)
815
+ // └ 情境参考 D+P+Pr decision D · pitfall P · process Pr (reference tier)
816
+ // narrow M · 编辑对应文件时浮现 (合计 only, no per-type)
817
+ // Self-consistency invariant: broad (= 常驻 + 参考) + narrow == total.
818
+ function renderHumanCensus(census, opts) {
819
+ const { lang } = opts || {};
820
+ const c = census || {};
821
+ const total = typeof c.total === "number" ? c.total : 0;
822
+ if (total === 0 && (c.dropped_other_project || 0) === 0) return [];
823
+ const zh = lang === "zh-CN";
824
+
825
+ const broadByType = c.broad_by_type || {};
826
+ const narrowTotal = typeof c.narrow_total === "number" ? c.narrow_total : 0;
827
+ // Per-tier broad counts. `broad_by_type` keys on the plural enum.
828
+ const g = broadByType.guidelines || 0;
829
+ const m = broadByType.models || 0;
830
+ const d = broadByType.decisions || 0;
831
+ const p = broadByType.pitfalls || 0;
832
+ const pr = broadByType.processes || 0;
833
+ const residentN = g + m; // 常驻规则 (always-active: guideline + model)
834
+ const referenceN = d + p + pr; // 情境参考 (decision + pitfall + process)
835
+ const broadN = residentN + referenceN;
836
+
837
+ const layer = c.by_layer || {};
838
+ const teamCount = layer.team || 0;
839
+ const personalCount = layer.personal || 0;
840
+ const projectCount = layer.project || 0;
841
+
842
+ const lines = [];
843
+ // Header: total entry count + semantic_scope breakdown (KT-MOD-0001 三轴).
844
+ const scopeSegs = [zh ? `团队 ${teamCount}` : `team ${teamCount}`];
845
+ if (projectCount > 0) scopeSegs.push(zh ? `项目 ${projectCount}` : `project ${projectCount}`);
846
+ scopeSegs.push(zh ? `个人 ${personalCount}` : `personal ${personalCount}`);
847
+ const totalLabel = zh ? `共 ${total} 条` : `${total} ${total === 1 ? "entry" : "entries"}`;
848
+ lines.push(`▸ [fabric] ${totalLabel} · ${scopeSegs.join(" · ")}`);
849
+
850
+ // broad spine — injected this session.
851
+ lines.push(zh ? ` broad ${broadN} · 本会话注入` : ` broad ${broadN} · injected this session`);
852
+ const residentDetail = [];
853
+ if (g > 0) residentDetail.push(`guideline ${g}`);
854
+ if (m > 0) residentDetail.push(`model ${m}`);
855
+ const refDetail = [];
856
+ if (d > 0) refDetail.push(`decision ${d}`);
857
+ if (p > 0) refDetail.push(`pitfall ${p}`);
858
+ if (pr > 0) refDetail.push(`process ${pr}`);
859
+ const dash = zh ? "—" : "—";
860
+ lines.push(
861
+ zh
862
+ ? ` ├ 常驻规则 ${residentN} ${residentDetail.join(" · ") || dash}`
863
+ : ` ├ resident ${residentN} ${residentDetail.join(" · ") || dash}`,
864
+ );
865
+ lines.push(
866
+ zh
867
+ ? ` └ 情境参考 ${referenceN} ${refDetail.join(" · ") || dash}`
868
+ : ` └ reference ${referenceN} ${refDetail.join(" · ") || dash}`,
869
+ );
870
+
871
+ // narrow remainder — 合计 only (no per-type; it's file-specific, surfaces on edit).
872
+ lines.push(
873
+ zh
874
+ ? ` narrow ${narrowTotal} · 编辑对应文件时浮现`
875
+ : ` narrow ${narrowTotal} · surfaces when you edit matching files`,
876
+ );
877
+ return lines;
878
+ }
879
+
880
+ // Goal H2: the SessionStart store label, scope-primary wording — `写入 X · 只读 Y`
881
+ // (write target receives new knowledge; the rest are read-only sources). Replaces
882
+ // the legacy `read-set stores: a (write), b (ro)` jargon line inline (kept local
883
+ // to this hook so the shared lib formatStoreLabels — used by other hooks — is
884
+ // untouched). Empty string when there is nothing to show.
885
+ function renderScopeStoreLabel(snapshot, lang) {
886
+ if (!snapshot || !snapshot.read_set || !Array.isArray(snapshot.read_set.stores)) return "";
887
+ const stores = snapshot.read_set.stores;
888
+ if (stores.length === 0) return "";
889
+ const zh = lang === "zh-CN";
890
+ const writeAlias = snapshot.write_target && snapshot.write_target.alias;
891
+ const writeStores = [];
892
+ const readonlyStores = [];
893
+ for (const s of stores) {
894
+ const alias = s && typeof s.alias === "string" ? s.alias : null;
895
+ if (alias === null) continue;
896
+ if (alias === writeAlias) writeStores.push(alias);
897
+ else readonlyStores.push(alias);
898
+ }
899
+ const segs = [];
900
+ if (writeStores.length > 0) segs.push((zh ? "写入 " : "write ") + writeStores.join(", "));
901
+ if (readonlyStores.length > 0) segs.push((zh ? "只读 " : "readonly ") + readonlyStores.join(", "));
902
+ if (segs.length === 0) return "";
903
+ return " " + segs.join(" · ");
904
+ }
905
+
906
+ // W2 (KT-DEC-0027/0028/0029): render the AI-facing sink — the dynamically
907
+ // generated "MEMORY.md" spine injected into the SessionStart context. Two
908
+ // type-tiered sections over the BROAD knowledge (narrow stays silent — D0029):
909
+ //
910
+ // ALWAYS-ACTIVE RULES (guideline/model): INDEX LINE only (title + summary) —
911
+ // never the eager body (KT-DEC-0036). The body is one cheap on-demand fetch
912
+ // away, so injecting it on every SessionStart is a permanent context tax
913
+ // (KT-GLD-0005) we no longer pay; each entry stays individually visible.
914
+ // REFERENCE (decision/pitfall/process): TITLE + must_read_if hook only
915
+ // (situational; the agent Reads the body on demand) — never the body.
916
+ //
917
+ // `broadIndexBackstop` (D0028) caps the total rendered index lines; the overflow
918
+ // tail folds into one marker that doubles as the drift signal (fabric-audit /
919
+ // the W4 doctor lint is the authoritative detector). `entries` is the broad
920
+ // plan-context-hint entry list ({id,type,maturity,summary,relevance_scope,
921
+ // must_read_if}); `alwaysBodies` is always_bodies[] ({id,type,layer,summary,body}).
922
+ const REFERENCE_TYPES = new Set(["decision", "pitfall", "process"]);
923
+
924
+ function renderAiSink(opts) {
925
+ const { entries, alwaysBodies, storeLabel, broadIndexBackstop, summaryMaxLen, lang } =
926
+ opts || {};
927
+ const zh = lang === "zh-CN";
928
+ const bodies = Array.isArray(alwaysBodies) ? alwaysBodies : [];
929
+ // REFERENCE = broad decision/pitfall/process. narrow entries stay silent (D0029).
930
+ const referenceEntries = (Array.isArray(entries) ? entries : []).filter((e) => {
931
+ if (!e || e.relevance_scope === "narrow") return false;
932
+ return REFERENCE_TYPES.has(TYPE_SINGULAR[toPluralType(e.type)] || e.type);
933
+ });
934
+ // Nothing to inject → empty so main() stays silent on an empty knowledge base.
935
+ if (bodies.length === 0 && referenceEntries.length === 0) return "";
936
+
937
+ const backstop =
938
+ typeof broadIndexBackstop === "number" && broadIndexBackstop > 0 ? broadIndexBackstop : 0;
939
+ let indexCount = 0; // total rendered index lines (always + reference), for the backstop.
940
+
941
+ const lines = [];
942
+ lines.push(`[fabric:SessionStart] ${storeLabel || "store"}`);
943
+
944
+ // ALWAYS-ACTIVE RULES — index-only (title + summary), never the eager body.
945
+ lines.push(
946
+ zh
947
+ ? "ALWAYS-ACTIVE RULES (无条件适用 · 照此行遵循,正文按需取):"
948
+ : "ALWAYS-ACTIVE RULES (unconditional · act on the line; body on demand):",
949
+ );
950
+ if (bodies.length === 0) {
951
+ lines.push(zh ? " (无 always-active 条目)" : " (none)");
952
+ } else {
953
+ // KT-DEC-0036: render each always-active entry as a single index line
954
+ // (title + summary). The body is one cheap on-demand fetch away (see footer),
955
+ // so injecting it on every SessionStart is a permanent context tax
956
+ // (KT-GLD-0005) we no longer pay.
957
+ for (const b of bodies) {
958
+ const label = `[${TYPE_SINGULAR[b.type] || b.type}] ${b.id}`;
959
+ const summary = typeof b.summary === "string" ? b.summary.trim() : "";
960
+ lines.push(summary.length > 0 ? ` ${label} · ${summary}` : ` ${label}`);
961
+ indexCount += 1;
962
+ }
963
+ }
964
+
965
+ // REFERENCE — broad decision/pitfall/process: title + must_read_if hook.
966
+ if (referenceEntries.length > 0) {
967
+ lines.push(
968
+ zh
969
+ ? "REFERENCE (情境触发 · 命中 must_read_if 时 Read / fab_recall):"
970
+ : "REFERENCE (situational · Read when must_read_if fires / fab_recall):",
971
+ );
972
+ let folded = 0;
973
+ for (const e of referenceEntries) {
974
+ if (backstop > 0 && indexCount >= backstop) {
975
+ folded += 1;
976
+ continue;
977
+ }
978
+ const type = TYPE_SINGULAR[toPluralType(e.type)] || e.type;
979
+ const rawHook =
980
+ typeof e.must_read_if === "string" && e.must_read_if.length > 0
981
+ ? e.must_read_if
982
+ : typeof e.summary === "string"
983
+ ? e.summary
984
+ : "";
985
+ const hookText = truncateSummary(rawHook, summaryMaxLen);
986
+ lines.push(hookText.length > 0 ? ` [${type}] ${e.id} — ${hookText}` : ` [${type}] ${e.id}`);
987
+ indexCount += 1;
988
+ }
989
+ // D0028 backstop: fold the overflow tail into one marker + drift signal.
990
+ if (folded > 0) {
991
+ lines.push(
992
+ zh
993
+ ? ` … 另 ${folded} 条 broad 条目折叠 (broad index > backstop ${backstop})。先跑 fabric-audit 瘦身;确需全展示再调 .fabric/fabric-config.json#broad_index_backstop (20..500)`
994
+ : ` … ${folded} more broad entr${folded === 1 ? "y" : "ies"} folded (broad index > backstop ${backstop}). Run fabric-audit to prune first; raise .fabric/fabric-config.json#broad_index_backstop (20..500) only if you truly need them all`,
995
+ );
996
+ }
997
+ }
998
+
999
+ // W2-4 footer: single lean retrieval flow — no two-step.
1000
+ lines.push(
1001
+ zh
1002
+ ? "取正文: fab_recall(paths), 或 Read <store>/knowledge/<type>/<id>--*.md"
1003
+ : "Load full content: fab_recall(paths), or Read <store>/knowledge/<type>/<id>--*.md",
1004
+ );
1005
+ // H6 scope discipline: this sink carries ONLY broad (always-relevant) knowledge;
1006
+ // narrow (file-specific) entries surface contextually via the PreToolUse hint
1007
+ // when you edit a matching file (KT-DEC-0029). Stops the agent from assuming
1008
+ // SessionStart is the whole KB.
1009
+ lines.push(
1010
+ zh
1011
+ ? "范围: 此处仅 broad(始终相关);narrow(文件专属)在你编辑对应文件时由 PreToolUse 浮现"
1012
+ : "Scope: broad only (always relevant) here; narrow (file-specific) surfaces via the PreToolUse hint when you edit a matching file",
1013
+ );
1014
+ return lines.join("\n");
1015
+ }
1016
+
752
1017
  // -----------------------------------------------------------------------------
753
1018
  // Main entry — invoked both as a CLI (require.main === module) and in-process
754
1019
  // by tests. Wraps the entire flow in try/catch: ANY error → silent exit 0.
755
1020
  // -----------------------------------------------------------------------------
756
1021
 
1022
+ // Block 5 (Option X): build the two SessionStart sinks (human systemMessage +
1023
+ // AI additionalContext) from a plan-context-hint payload, WITHOUT emitting or
1024
+ // recording telemetry. This is the single shared renderer: main() calls it then
1025
+ // emits + logs; `fabric context` calls it then prints (byte-identical injection
1026
+ // by construction — same code, same config/FS reads). Pure-ish: it reads config
1027
+ // + snapshot + .md summaries for `cwd` but has no stdout/ledger side effects.
1028
+ //
1029
+ // Returns:
1030
+ // human — gated final human text (null when gated off / empty)
1031
+ // ai — gated final AI text (null when reminder-to-context off / empty)
1032
+ // resolvedPayload — the plan-context payload, passed through unchanged (for telemetry / --explain)
1033
+ // hasRenderedContent — true when ANY sink rendered content (main's silent-exit gate)
1034
+ // reminderToContext — readReminderToContext(cwd) (telemetry target-channel)
1035
+ function buildSessionStartSinks(cwd, payload, env) {
1036
+ // KT-GLD-0006: the rc.35 opaque-summary runtime substitution
1037
+ // (resolveOpaqueSummaries) is retired. The write-time mechanical floor in
1038
+ // extractKnowledge (summary !== stable_id/slug + length floor) prevents
1039
+ // degenerate summaries at the source, so SessionStart no longer band-aids them
1040
+ // at render time; surviving legacy opaque summaries are fixed by the
1041
+ // review-time cold-eval audit pass.
1042
+ const resolvedPayload = payload;
1043
+
1044
+ const summaryMaxLen = readSummaryMaxLen(cwd);
1045
+ const fabricLanguageForEmit = readFabricLanguage(cwd);
1046
+
1047
+ const census =
1048
+ env && env.census !== undefined
1049
+ ? env.census
1050
+ : payload && payload.census
1051
+ ? payload.census
1052
+ : deriveCensusFromEntries(resolvedPayload && resolvedPayload.entries);
1053
+ const alwaysBodies =
1054
+ env && env.alwaysBodies !== undefined
1055
+ ? env.alwaysBodies
1056
+ : payload && Array.isArray(payload.always_bodies)
1057
+ ? payload.always_bodies
1058
+ : [];
1059
+
1060
+ // H3: the LIVE census total is the single count source for the import gate —
1061
+ // computed AFTER census so the nudge and the HUD agree by construction.
1062
+ const censusTotal = census && typeof census.total === "number" ? census.total : undefined;
1063
+ const recommendImport = shouldRecommendImport(cwd, censusTotal);
1064
+
1065
+ // Read the resolved-bindings snapshot ONCE — reused for the scope store label
1066
+ // (写入/只读) and the H4 review-rung pending count. Best-effort/decorative: any
1067
+ // failure leaves snapshot null and the dependent lines simply don't render.
1068
+ let snapshot = null;
1069
+ if (bindingsSnapshotReader !== null) {
1070
+ try {
1071
+ const bindingId = readWorkspaceBindingId(cwd);
1072
+ if (bindingId) snapshot = bindingsSnapshotReader.readBindingsSnapshot(bindingId);
1073
+ } catch {
1074
+ snapshot = null;
1075
+ }
1076
+ }
1077
+
1078
+ const humanGate =
1079
+ nudgePolicy !== null
1080
+ ? nudgePolicy.resolveHumanSink(cwd, "session_start", {})
1081
+ : { emitHuman: true, verbosity: "normal" };
1082
+
1083
+ // ---- HUMAN sink: §3 grouped census (+ verbose per-entry detail). ----
1084
+ const humanLines = renderHumanCensus(census, { lang: fabricLanguageForEmit });
1085
+ if (humanLines.length > 0 && humanGate.verbosity === "verbose") {
1086
+ const detail = renderSummary(resolvedPayload, summaryMaxLen);
1087
+ humanLines.push(...detail);
1088
+ }
1089
+ // H2: scope store label — `写入 X · 只读 Y` (replaces the legacy read-set jargon).
1090
+ if (humanLines.length > 0 && snapshot !== null) {
1091
+ const storeLabel = renderScopeStoreLabel(snapshot, fabricLanguageForEmit);
1092
+ if (storeLabel) humanLines.push(storeLabel);
1093
+ }
1094
+
1095
+ // H4 action ladder (KT-DEC-0007: nudge, never a gate). AT MOST ONE line, the
1096
+ // highest-priority rung wins, and steady state is fully silent:
1097
+ // 1. import — KB is sparse (recommendImport, off the live census total)
1098
+ // 2. review — pending backlog exceeds REVIEW_PENDING_THRESHOLD (live count)
1099
+ // 3. (silent)
1100
+ if (humanLines.length > 0 && fabricLanguageForEmit !== null) {
1101
+ if (recommendImport) {
1102
+ humanLines.push(renderBanner("broadImportBanner", fabricLanguageForEmit, {}));
1103
+ } else if (snapshot !== null && bindingsSnapshotReader !== null) {
1104
+ let pendingCount = 0;
1105
+ try {
1106
+ const live = bindingsSnapshotReader.liveKnowledgeStats(snapshot);
1107
+ if (live && Number.isFinite(live.pendingCount)) pendingCount = Math.floor(live.pendingCount);
1108
+ } catch {
1109
+ pendingCount = 0;
1110
+ }
1111
+ if (pendingCount > REVIEW_PENDING_THRESHOLD) {
1112
+ humanLines.push(
1113
+ fabricLanguageForEmit === "zh-CN"
1114
+ ? ` 📋 Fabric: ${pendingCount} 条 pending 待审,是否调 /fabric-review?`
1115
+ : ` 📋 Fabric: ${pendingCount} pending entries — run /fabric-review?`,
1116
+ );
1117
+ }
1118
+ }
1119
+ }
1120
+
1121
+ // H5: the `下一步: …fab_recall…` AI-plumbing line is retired from the human sink
1122
+ // (the AI gets it from its own footer + the MCP server directive). Keep only the
1123
+ // pointer to the byte-identical inspector for this injection.
1124
+ if (humanLines.length > 0) {
1125
+ humanLines.push(
1126
+ fabricLanguageForEmit === "zh-CN"
1127
+ ? " 看具体注入: fabric context (--explain 看每条来源)"
1128
+ : " Inspect this injection: fabric context (--explain for per-entry provenance)",
1129
+ );
1130
+ }
1131
+
1132
+ // ---- AI sink: spine — always-active INDEX lines (no eager body, KT-DEC-0036)
1133
+ // + reference, bounded by the broad_index_backstop fold. ----
1134
+ const broadIndexBackstop = readBroadIndexBackstop(cwd);
1135
+ const aiText = renderAiSink({
1136
+ entries: resolvedPayload && Array.isArray(resolvedPayload.entries) ? resolvedPayload.entries : [],
1137
+ alwaysBodies,
1138
+ broadIndexBackstop,
1139
+ summaryMaxLen,
1140
+ lang: fabricLanguageForEmit,
1141
+ });
1142
+
1143
+ const hasRenderedContent = humanLines.length > 0 || (typeof aiText === "string" && aiText.length > 0);
1144
+ const human = humanGate.emitHuman && humanLines.length > 0 ? humanLines.join("\n") : null;
1145
+ const reminderToContext = readReminderToContext(cwd);
1146
+ const ai = reminderToContext && aiText && aiText.length > 0 ? aiText : null;
1147
+
1148
+ return { human, ai, resolvedPayload, hasRenderedContent, reminderToContext };
1149
+ }
1150
+
757
1151
  function main(env, stdio) {
758
1152
  try {
759
1153
  const cwd = (env && env.cwd) || process.cwd();
@@ -791,96 +1185,31 @@ function main(env, stdio) {
791
1185
  env && env.payload !== undefined ? env.payload : invokePlanContextHint(cwd);
792
1186
  if (payload === null || payload === undefined) return; // silent
793
1187
 
794
- // v2.0.0-rc.33 W2-1 (P0-9): apply TopK slice BEFORE renderSummary so the
795
- // grouped/truncation rendering operates on the bounded set. Slicing here
796
- // (not inside renderSummary) keeps the formatter pure it never has to
797
- // know about the cap.
798
- const topK = readBroadTopK(cwd);
799
- const slicedPayload =
800
- payload && Array.isArray(payload.entries) && payload.entries.length > topK
801
- ? { ...payload, entries: payload.entries.slice(0, topK) }
802
- : payload;
803
-
804
- // rc.35 TASK-06 (P0-10.b): summary-fallback substitution. Entries whose
805
- // description.summary equals stable_id render as "<id> · <id>" and the
806
- // AI skips fetching them; the fallback reads `## Summary` from the
807
- // entry's .md file and swaps in the first paragraph. Best-effort
808
- // failure leaves the original opaque summary untouched.
809
- let resolvedPayload = slicedPayload;
810
- try {
811
- if (slicedPayload && Array.isArray(slicedPayload.entries)) {
812
- const resolvedEntries = resolveOpaqueSummaries(
813
- slicedPayload.entries,
814
- cwd,
815
- typeof slicedPayload.revision_hash === "string" ? slicedPayload.revision_hash : "",
816
- );
817
- resolvedPayload = { ...slicedPayload, entries: resolvedEntries };
818
- }
819
- } catch {
820
- // resolveOpaqueSummaries swallows its own errors; this catch is belt
821
- // + suspenders for any unexpected exception from the lib layer.
822
- }
823
-
824
- // rc.8 underseed self-check: decide whether to surface the one-line
825
- // `/fabric-import` recommendation banner alongside the broad summary.
826
- const recommendImport = shouldRecommendImport(cwd);
827
-
828
- // rc.12: broad-summary body is unconditionally rendered on every
829
- // SessionStart fire (Skill-style progressive disclosure). The prior
830
- // revision_hash cooldown gate (rc.7 T8 — rc.11) was removed because
831
- // compact/clear-triggered SessionStart re-fires must re-inject the menu
832
- // for the agent's working memory. rc.33 W2-5 reintroduces an opt-in
833
- // hours-based cooldown via fabric-config (see gate above).
834
- const summaryMaxLen = readSummaryMaxLen(cwd);
835
- // v2.2 HK2-degrade (W2-T2): thread the injection char-budget into the renderer.
836
- const broadBudgetChars = readBroadBudgetChars(cwd);
837
- const lines = renderSummary(resolvedPayload, summaryMaxLen, broadBudgetChars);
838
-
839
- // v2.0.0-rc.37 NEW-23: resolve fabric_language ONCE per emit path —
840
- // shared between the (existing) broadImportBanner branch and the new
841
- // 'next step' nudge tail added below. 'match-existing' / unknown variants
842
- // fold to 'en' inside renderBanner per UX i18n Policy class 1.
843
- const fabricLanguageForEmit = lines.length > 0 || recommendImport ? readFabricLanguage(cwd) : null;
844
- if (recommendImport && fabricLanguageForEmit !== null) {
845
- lines.push(renderBanner("broadImportBanner", fabricLanguageForEmit, {}));
846
- }
847
-
848
- if (lines.length === 0) return; // nothing to say — silent exit
849
-
850
- // v2.1.0-rc.1 P4 (F4/S63): append a per-store read-set label from the
851
- // CLI-pre-generated bindings snapshot so the session opens aware of which
852
- // stores it reads and where writes land. Best-effort, never blocks: a
853
- // missing snapshot / single-store setup just omits the line.
854
- if (bindingsSnapshotReader !== null) {
855
- try {
856
- const projectId = readProjectId(cwd);
857
- if (projectId) {
858
- const label = bindingsSnapshotReader.formatStoreLabels(
859
- bindingsSnapshotReader.readBindingsSnapshot(projectId),
860
- );
861
- if (label) lines.push(label);
862
- }
863
- } catch {
864
- // store labels are decorative provenance — never crash the hook
865
- }
866
- }
867
-
868
- // v2.0.0-rc.37 NEW-23: SessionStart 索引末尾"下一步"引导。Tail line that
869
- // tells the AI what to do with the broad index it just received. Without
870
- // this, the model often parses the index and moves on without ever calling
871
- // fab_recall / fab_plan_context. One-line nudge, bilingual.
872
- // v2.2 W1-REVIEW codex LOW-6: `description_index` was renamed to `candidates`
873
- // in rc.38 UX-1; the nudge now uses the current field name so the guidance
874
- // matches the actual MCP response shape.
875
- const nextStepNudge =
876
- fabricLanguageForEmit === "zh-CN"
877
- ? "下一步: 调 fab_recall(paths) 拿 KB 相关条目;或调 fab_plan_context 先看候选描述(candidates)。"
878
- : "Next: call fab_recall(paths) to fetch related KB entries, or fab_plan_context to preview the candidate descriptions first.";
879
- lines.push(nextStepNudge);
880
-
881
- // Stderr: always emit (human-facing breadcrumb + legacy contract).
882
- for (const line of lines) {
883
- err.write(`${line}\n`);
1188
+ // W2-1 (KT-DEC-0028): broad 全显示 the legacy hint_broad_top_k hard cap is
1189
+ // retired. SessionStart must SEE every broad entry; scale is bounded by the
1190
+ // per-line char cap + broad_index_backstop fold, not by dropping entries.
1191
+
1192
+ // Block 5 (Option X): build both sinks via the shared renderer (same code
1193
+ // `fabric context` uses → byte-identical injection). Side-effect-free; the
1194
+ // emit + telemetry below stay in main().
1195
+ const { human, ai, resolvedPayload, hasRenderedContent, reminderToContext } =
1196
+ buildSessionStartSinks(cwd, payload, env);
1197
+
1198
+ // Nothing to say at all → silent exit (preserves the empty-payload contract).
1199
+ if (!hasRenderedContent) return;
1200
+
1201
+ // v2.2 dual-sink (Goal A / D7): emit both channels in one render. The human
1202
+ // systemMessage is gated by nudge_mode (emitHuman); the AI additionalContext
1203
+ // is emitted regardless. emitDualSink shapes the protocol per client (CC/Codex
1204
+ // camelCase nested envelope; unknown → stderr).
1205
+ if (!(env && env.skipStdout === true)) {
1206
+ emitDualSink(
1207
+ { human, ai },
1208
+ { client: detectClient(), eventName: "SessionStart", streams: { stdout: out, stderr: err } },
1209
+ );
1210
+ } else if (human !== null) {
1211
+ // skipStdout test seam: still surface the human breadcrumb to stderr.
1212
+ err.write(`${human}\n`);
884
1213
  }
885
1214
 
886
1215
  // v2.2 HK3-telemetry (W3-T1): record the injection side. We just OFFERED the
@@ -903,37 +1232,10 @@ function main(env, stdio) {
903
1232
  });
904
1233
  }
905
1234
 
906
- // v2.0.0-rc.33 W2-6 (P0-7): stdout JSON envelope. When
907
- // hint_reminder_to_context is true (default), serialize the same banner
908
- // body as Claude Code's SessionStart hookSpecificOutput shape so the model
909
- // receives the reminder IN-CONTEXT (rc.32 baseline cite-coverage 3.1%
910
- // root cause: reminders never entered model context). Stderr stays the
911
- // host-facing channel.
912
- //
913
- // Failure to write JSON envelope must NOT crash the hook — stderr already
914
- // delivered, the stdout layer is best-effort.
915
- // v2.0.0-rc.33 W4 review-fix (gemini High-1): the stdout JSON envelope
916
- // is Claude Code-specific (hookSpecificOutput.additionalContext contract).
917
- // Codex CLI / Cursor don't parse it — leaking it to their stdout risks
918
- // either polluting the terminal or crashing the host's hook-parsing
919
- // pipeline. CLAUDE_PROJECT_DIR is set by CC when invoking hooks (see
920
- // packages/cli/templates/hooks/configs/claude-code.json sigil paths);
921
- // its presence is the single-bit "this is Claude Code" signal (now via
922
- // the shared client-adapter, rc.37 NEW-30).
923
- const reminderToContext = readReminderToContext(cwd) && isClaudeCode();
924
- if (reminderToContext && !(env && env.skipStdout === true)) {
925
- try {
926
- const envelope = {
927
- hookSpecificOutput: {
928
- hookEventName: "SessionStart",
929
- additionalContext: lines.join("\n"),
930
- },
931
- };
932
- out.write(`${JSON.stringify(envelope)}\n`);
933
- } catch {
934
- // Best-effort — stderr is the durable contract
935
- }
936
- }
1235
+ // v2.2 dual-sink (Goal A): the legacy rc.33 W2-6 stdout JSON envelope is
1236
+ // replaced by emitDualSink above (which carries BOTH the human systemMessage
1237
+ // and the AI additionalContext, shaped per client). hint_reminder_to_context
1238
+ // still gates whether the AI sink is populated (see `ai` above).
937
1239
 
938
1240
  // v2.1 NEW-N-3 (ADJ-NEWN-3): hook_surface_emitted instrumentation. One
939
1241
  // best-effort ledger row recording WHICH broad-scoped ids were surfaced
@@ -942,7 +1244,7 @@ function main(env, stdio) {
942
1244
  // per session boot so this never bloats the ledger. Never blocks the hook
943
1245
  // (KT-DEC-0007): any failure (no .fabric/, undetected client, write error)
944
1246
  // degrades to silent skip. Client is omitted-by-skip when undetectable
945
- // because the schema's `client` enum admits only cc/codex/cursor.
1247
+ // because the schema's `client` enum admits only cc/codex.
946
1248
  try {
947
1249
  const surfaceClient = detectClient();
948
1250
  const fabricDir = join(cwd, FABRIC_DIR_REL);
@@ -991,6 +1293,7 @@ function main(env, stdio) {
991
1293
 
992
1294
  module.exports = {
993
1295
  main,
1296
+ buildSessionStartSinks,
994
1297
  invokePlanContextHint,
995
1298
  groupEntries,
996
1299
  renderFull,
@@ -1002,8 +1305,8 @@ module.exports = {
1002
1305
  readUnderseedThreshold,
1003
1306
  isImportTouched,
1004
1307
  shouldRecommendImport,
1005
- // v2.0.0-rc.33 W2-1 / W2-5 / W2-6 helpers.
1006
- readBroadTopK,
1308
+ // W2-1 (KT-DEC-0028) + rc.33 W2-5 / W2-6 helpers.
1309
+ readBroadIndexBackstop,
1007
1310
  readBroadCooldownHours,
1008
1311
  readReminderToContext,
1009
1312
  readBroadLastEmit,
@@ -1020,7 +1323,7 @@ module.exports = {
1020
1323
  MATURITY_DRAFT,
1021
1324
  DEFAULT_UNDERSEED_NODE_THRESHOLD,
1022
1325
  KNOWLEDGE_CANONICAL_TYPES,
1023
- DEFAULT_HINT_BROAD_TOP_K,
1326
+ DEFAULT_HINT_BROAD_INDEX_BACKSTOP,
1024
1327
  DEFAULT_HINT_BROAD_COOLDOWN_HOURS,
1025
1328
  DEFAULT_HINT_REMINDER_TO_CONTEXT,
1026
1329
  HINT_BROAD_LAST_EMIT_FILE_NAME,