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

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 (40) hide show
  1. package/dist/{chunk-PWLW3B57.js → chunk-2CY4BMTH.js} +5 -1
  2. package/dist/{chunk-D25XJ4BC.js → chunk-2R55HNVD.js} +105 -5
  3. package/dist/chunk-4R2CYEA4.js +116 -0
  4. package/dist/{chunk-BATF4PEJ.js → chunk-AOE6AYI7.js} +2 -2
  5. package/dist/{chunk-WWNXR34K.js → chunk-BO4XIZWZ.js} +8 -1
  6. package/dist/chunk-L4Q55UC4.js +52 -0
  7. package/dist/chunk-LFIKMVY7.js +27 -0
  8. package/dist/chunk-RYAFBNES.js +33 -0
  9. package/dist/chunk-T5RPGCCM.js +40 -0
  10. package/dist/chunk-WU6GAPKH.js +36 -0
  11. package/dist/{chunk-MF3OTILQ.js → chunk-XC5RUHLK.js} +29 -8
  12. package/dist/{config-XJIPZNUP.js → config-XYRBZJDU.js} +3 -3
  13. package/dist/{doctor-EJDSEJSS.js → doctor-YONYXDX6.js} +147 -24
  14. package/dist/index.js +58 -10
  15. package/dist/{install-EKWMFLUU.js → install-74ANPCCP.js} +320 -75
  16. package/dist/{metrics-ACEQFPDU.js → metrics-RER6NLFC.js} +22 -9
  17. package/dist/{onboard-coverage-MFCAEBDO.js → onboard-coverage-JWQWDZW7.js} +1 -1
  18. package/dist/scope-explain-CDIZESP5.js +37 -0
  19. package/dist/status-GLQWLWH6.js +23 -0
  20. package/dist/store-XB3ADT65.js +144 -0
  21. package/dist/sync-UJ4BBCZJ.js +251 -0
  22. package/dist/{uninstall-MH7ZIB6M.js → uninstall-C3QXKOO6.js} +47 -7
  23. package/dist/whoami-2MLO4Y37.js +36 -0
  24. package/package.json +3 -3
  25. package/templates/hooks/fabric-hint.cjs +139 -7
  26. package/templates/hooks/knowledge-hint-broad.cjs +204 -9
  27. package/templates/hooks/knowledge-hint-narrow.cjs +49 -4
  28. package/templates/hooks/lib/bindings-snapshot-reader.cjs +81 -0
  29. package/templates/hooks/lib/cite-contract-reminder.cjs +15 -9
  30. package/templates/hooks/lib/cite-line-parser.cjs +48 -26
  31. package/templates/hooks/lib/injection-log.cjs +91 -0
  32. package/templates/hooks/lib/state-store.cjs +30 -11
  33. package/templates/skills/fabric-archive/SKILL.md +4 -0
  34. package/templates/skills/fabric-audit/SKILL.md +53 -0
  35. package/templates/skills/fabric-connect/SKILL.md +48 -0
  36. package/templates/skills/fabric-import/SKILL.md +4 -0
  37. package/templates/skills/fabric-review/SKILL.md +6 -0
  38. package/templates/skills/fabric-review/ref/cite-contract.md +56 -0
  39. package/templates/skills/fabric-store/SKILL.md +44 -0
  40. package/templates/skills/fabric-sync/SKILL.md +46 -0
@@ -1,7 +1,13 @@
1
1
  #!/usr/bin/env node
2
- const { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } = require("node:fs");
2
+ const { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } = require("node:fs");
3
3
  const { dirname, join } = require("node:path");
4
4
 
5
+ // W1-01 (ISS-012): Stop / SessionStart hooks append to shared, non-session-scoped
6
+ // ledgers (events.jsonl, metrics.jsonl). Under multi-window concurrency a bare
7
+ // appendFileSync can interleave a partial write; route through the advisory-lock
8
+ // primitive (drop-on-contention, best-effort — matches injection-log).
9
+ const { appendLockedLine } = require("./lib/injection-log.cjs");
10
+
5
11
  // v2.0.0-rc.7 T5: session-digest writer. Best-effort (never blocks Stop hook
6
12
  // on failure — see contract in lib/session-digest-writer.cjs).
7
13
  let sessionDigestWriter = null;
@@ -81,6 +87,27 @@ try {
81
87
  stateStore = null;
82
88
  }
83
89
 
90
+ // v2.1.0-rc.1 P4 (F4/S63): hook-side reader for the CLI pre-generated
91
+ // resolved-bindings snapshot. The Stop hint surfaces the read-set stores
92
+ // (per-store, NOT aggregated into one pile) without re-resolving / walking
93
+ // store trees. Best-effort — a missing lib/snapshot omits the store line.
94
+ let bindingsSnapshotReader = null;
95
+ try {
96
+ bindingsSnapshotReader = require("./lib/bindings-snapshot-reader.cjs");
97
+ } catch {
98
+ bindingsSnapshotReader = null;
99
+ }
100
+
101
+ // Read the project's own `project_id` (the snapshot key) from its config.
102
+ function readProjectId(cwd) {
103
+ try {
104
+ const parsed = JSON.parse(readFileSync(join(cwd, ".fabric", "fabric-config.json"), "utf8"));
105
+ return typeof parsed.project_id === "string" ? parsed.project_id : null;
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
84
111
  // CONSTANTS — duplicated from packages/server/src/services/_shared.ts.
85
112
  // DRY violation accepted: this hook script runs in user repos WITHOUT
86
113
  // node_modules access, so it cannot import from @fenglimg/fabric-server.
@@ -762,6 +789,11 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
762
789
  reason,
763
790
  signal: "archive",
764
791
  recommended_skill: "fabric-archive",
792
+ // v2.1 NEW-N-3: surface the firing sub-signal's numbers for the
793
+ // hook_signal_emitted ledger row main() writes. Dual trigger (24h OR
794
+ // N-edits): report the hours pair when it fired, else the edit-count pair.
795
+ threshold: triggerByHours ? archiveHintHours : editStats.threshold,
796
+ actual_value: triggerByHours ? hoursElapsed : editStats.editsSinceLastProposed,
765
797
  };
766
798
  }
767
799
 
@@ -798,6 +830,10 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
798
830
  reason,
799
831
  signal: "review",
800
832
  recommended_skill: "fabric-review",
833
+ // v2.1 NEW-N-3: dual trigger (pending-count OR oldest-age). Report the
834
+ // count pair when it fired, else the oldest-age-in-days pair.
835
+ threshold: triggerByPendingCount ? reviewHintPendingCount : reviewHintPendingAgeDays,
836
+ actual_value: triggerByPendingCount ? stats.count : stats.oldestAgeMs / MS_PER_DAY,
801
837
  };
802
838
  }
803
839
 
@@ -848,6 +884,10 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
848
884
  reason,
849
885
  signal: "import",
850
886
  recommended_skill: "fabric-import",
887
+ // v2.1 NEW-N-3: underseed corpus trigger — node-count vs threshold. The
888
+ // "import" signal collapses to schema signal_type "other" in main().
889
+ threshold: underseed.threshold,
890
+ actual_value: underseed.nodeCount,
851
891
  };
852
892
  }
853
893
 
@@ -958,8 +998,15 @@ function readShownCache(projectRoot) {
958
998
  function writeShownCache(projectRoot, cache) {
959
999
  const cachePath = join(projectRoot, SHOWN_CACHE_FILE);
960
1000
  try {
961
- mkdirSync(dirname(cachePath), { recursive: true });
962
- writeFileSync(cachePath, JSON.stringify(cache));
1001
+ // ISS-016: atomic tmp+rename so concurrent windows / a crash never leave a
1002
+ // truncated shown-cache (this file is NOT session-scoped). Falls back to a
1003
+ // plain write only if the shared lib failed to load.
1004
+ if (stateStore && typeof stateStore.atomicWrite === "function") {
1005
+ stateStore.atomicWrite(cachePath, JSON.stringify(cache));
1006
+ } else {
1007
+ mkdirSync(dirname(cachePath), { recursive: true });
1008
+ writeFileSync(cachePath, JSON.stringify(cache));
1009
+ }
963
1010
  } catch {
964
1011
  // Silent — cache failure must never block the hook.
965
1012
  }
@@ -1096,8 +1143,13 @@ function readMaintenanceLastEmit(projectRoot) {
1096
1143
  function writeMaintenanceLastEmit(projectRoot, nowMs) {
1097
1144
  const p = join(projectRoot, MAINTENANCE_HINT_LAST_EMIT_FILE);
1098
1145
  try {
1099
- mkdirSync(dirname(p), { recursive: true });
1100
- writeFileSync(p, new Date(nowMs).toISOString());
1146
+ // ISS-016: atomic tmp+rename (see writeShownCache).
1147
+ if (stateStore && typeof stateStore.atomicWrite === "function") {
1148
+ stateStore.atomicWrite(p, new Date(nowMs).toISOString());
1149
+ } else {
1150
+ mkdirSync(dirname(p), { recursive: true });
1151
+ writeFileSync(p, new Date(nowMs).toISOString());
1152
+ }
1101
1153
  } catch {
1102
1154
  // Silent — sidecar failure must never block the hook.
1103
1155
  }
@@ -1186,9 +1238,64 @@ function evaluateMaintenanceSignal(events, now, canonicalCount, lastEmitMs, thre
1186
1238
  signal: "maintenance",
1187
1239
  // CLI recommendation rather than Skill — doctor is a CLI surface.
1188
1240
  recommended_skill: null,
1241
+ // v2.1 NEW-N-3: staleness trigger. threshold=days; actual=ageDays. When
1242
+ // lint was NEVER run ageDays is null — main() skips the signal emit rather
1243
+ // than fabricate a number (honest gap over fake telemetry).
1244
+ threshold: days,
1245
+ actual_value: ageDays,
1189
1246
  };
1190
1247
  }
1191
1248
 
1249
+ // v2.1 NEW-N-3 (ADJ-NEWN-3): hook_signal_emitted instrumentation. Writes ONE
1250
+ // best-effort ledger row at the point a nudge is actually delivered (post-
1251
+ // cooldown), so the join key measures nudge-trigger logic (which signal fired,
1252
+ // at what threshold vs. actual). Emitted at delivery rather than at
1253
+ // threshold-cross so it inherits the cooldown gate — a fired-but-cooled signal
1254
+ // does not spam the ledger every session. Skips silently when threshold /
1255
+ // actual_value are not finite numbers (e.g. maintenance "never run" → null
1256
+ // age). Never blocks the hook (KT-DEC-0007).
1257
+ const SIGNAL_TYPE_ENUM = new Set(["archive", "review", "maintenance", "other"]);
1258
+ function emitSignalFiredEvent(cwd, sessionId, result) {
1259
+ try {
1260
+ if (!result || typeof result.signal !== "string") return;
1261
+ const threshold = result.threshold;
1262
+ const actualValue = result.actual_value;
1263
+ if (
1264
+ typeof threshold !== "number" ||
1265
+ !Number.isFinite(threshold) ||
1266
+ typeof actualValue !== "number" ||
1267
+ !Number.isFinite(actualValue)
1268
+ ) {
1269
+ return;
1270
+ }
1271
+ const fabricDir = join(cwd, FABRIC_DIR);
1272
+ if (!existsSync(fabricDir)) return;
1273
+ // "import" / any non-canonical signal collapses to schema's catch-all "other".
1274
+ const signalType = SIGNAL_TYPE_ENUM.has(result.signal) ? result.signal : "other";
1275
+ let idSuffix;
1276
+ try {
1277
+ idSuffix = require("node:crypto").randomUUID();
1278
+ } catch {
1279
+ idSuffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
1280
+ }
1281
+ const event = {
1282
+ kind: "fabric-event",
1283
+ id: `event:${idSuffix}`,
1284
+ ts: Date.now(),
1285
+ schema_version: 1,
1286
+ event_type: "hook_signal_emitted",
1287
+ signal_type: signalType,
1288
+ threshold,
1289
+ actual_value: actualValue,
1290
+ fired: true,
1291
+ };
1292
+ if (typeof sessionId === "string" && sessionId.length > 0) event.session_id = sessionId;
1293
+ appendLockedLine(join(fabricDir, EVENT_LEDGER_FILE), JSON.stringify(event) + "\n");
1294
+ } catch {
1295
+ // best-effort telemetry — never block the hook
1296
+ }
1297
+ }
1298
+
1192
1299
  /**
1193
1300
  * v2.0.0-rc.7 T5: best-effort sync stdin reader for the Stop hook.
1194
1301
  *
@@ -1393,7 +1500,7 @@ function extractAndWriteAssistantTurnsBestEffort(cwd, stdinPayload) {
1393
1500
  timestamp: new Date().toISOString(),
1394
1501
  };
1395
1502
  if (client !== undefined) event.client = client;
1396
- appendFileSync(ledgerPath, JSON.stringify(event) + "\n", "utf8");
1503
+ appendLockedLine(ledgerPath, JSON.stringify(event) + "\n");
1397
1504
  } catch {
1398
1505
  // Per-turn failure must not abort the remaining turns; the Stop hook
1399
1506
  // contract is "never block on hook failure". Best-effort continues.
@@ -1418,7 +1525,7 @@ function extractAndWriteAssistantTurnsBestEffort(cwd, stdinPayload) {
1418
1525
  counters: { [counterKey]: emptyShellCount },
1419
1526
  };
1420
1527
  const metricsPath = join(fabricDir, METRICS_LEDGER_FILE);
1421
- appendFileSync(metricsPath, JSON.stringify(metricsRow) + "\n", "utf8");
1528
+ appendLockedLine(metricsPath, JSON.stringify(metricsRow) + "\n");
1422
1529
  } catch {
1423
1530
  // metrics fold is observability-only; never block the hook on failure.
1424
1531
  }
@@ -1907,10 +2014,32 @@ function main(env, stdio) {
1907
2014
  result.reason = `${result.reason}\n${renderDismissOption(result.signal, variant)}`;
1908
2015
  }
1909
2016
 
2017
+ // v2.1.0-rc.1 P4 (F4/S63): surface the read-set stores on the Stop hint so
2018
+ // backlog/maintenance nudges are read per-store, not as one undifferentiated
2019
+ // pile. Best-effort; missing snapshot / single-store omits the line.
2020
+ if (bindingsSnapshotReader !== null && typeof result.reason === "string") {
2021
+ try {
2022
+ const projectId = readProjectId(cwd);
2023
+ if (projectId) {
2024
+ const label = bindingsSnapshotReader.formatStoreLabels(
2025
+ bindingsSnapshotReader.readBindingsSnapshot(projectId),
2026
+ );
2027
+ if (label) {
2028
+ result.reason = `${result.reason}\n${label}`;
2029
+ }
2030
+ }
2031
+ } catch {
2032
+ // store label is decorative provenance — never crash the hook
2033
+ }
2034
+ }
2035
+
1910
2036
  // v2.0.0-rc.7 T10: Signal D uses its own cooldown sidecar (day-based,
1911
2037
  // see MAINTENANCE_HINT_LAST_EMIT_FILE). The A/B/C shared cooldown cache
1912
2038
  // uses hours, so we branch here to avoid mixing semantics.
1913
2039
  if (result.signal === "maintenance") {
2040
+ emitSignalFiredEvent(cwd, sessionId, result);
2041
+ delete result.threshold;
2042
+ delete result.actual_value;
1914
2043
  out.write(JSON.stringify(result));
1915
2044
  writeMaintenanceLastEmit(cwd, nowMs);
1916
2045
  return;
@@ -1932,6 +2061,9 @@ function main(env, stdio) {
1932
2061
  return; // Still in cooldown — silent.
1933
2062
  }
1934
2063
 
2064
+ emitSignalFiredEvent(cwd, sessionId, result);
2065
+ delete result.threshold;
2066
+ delete result.actual_value;
1935
2067
  out.write(JSON.stringify(result));
1936
2068
  cache[result.signal] = nowMs;
1937
2069
  writeShownCache(cwd, cache);
@@ -22,7 +22,7 @@
22
22
  * - <id> · <summary>
23
23
  * ...
24
24
  * revision_hash: <hash>
25
- * Use `fab_get_knowledge_sections` to fetch full content.
25
+ * Load full content: `fab_recall(paths)`, or `fab_plan_context` -> `fab_get_knowledge_sections` to trim first.
26
26
  *
27
27
  * When narrow count > 30 (grouped-truncation mode, per type):
28
28
  * [fabric] Session start — N broad-scoped knowledge entries available (truncated):
@@ -33,7 +33,7 @@
33
33
  * [decision] draft: 7 entries
34
34
  * ...
35
35
  * revision_hash: <hash>
36
- * Use `fab_get_knowledge_sections` to fetch full content.
36
+ * Load full content: `fab_recall(paths)`, or `fab_plan_context` -> `fab_get_knowledge_sections` to trim first.
37
37
  *
38
38
  * When 0 entries / CLI unavailable / CLI error / parse failure:
39
39
  * (no output — silent exit 0)
@@ -50,6 +50,12 @@ const { spawnSync } = require("node:child_process");
50
50
  const { existsSync, readdirSync, readFileSync } = require("node:fs");
51
51
  const { join } = require("node:path");
52
52
 
53
+ // W1-01 (ISS-012): the SessionStart broad hook appends a hook_surface_emitted
54
+ // event to the shared events.jsonl. Under multi-window concurrency a bare
55
+ // appendFileSync can interleave a partial write; route through the advisory-lock
56
+ // primitive (drop-on-contention, best-effort — matches injection-log).
57
+ const { appendLockedLine } = require("./lib/injection-log.cjs");
58
+
53
59
  // rc.16 TASK-003: shared banner-i18n lib (resolves fabric_language config and
54
60
  // renders localized banner text). Mirror of the wiring in fabric-hint.cjs
55
61
  // (TASK-002). Variant is resolved ONCE per main() invocation via
@@ -63,11 +69,43 @@ const { resolveOpaqueSummaries } = require("./lib/summary-fallback.cjs");
63
69
  const {
64
70
  readConfigNumber,
65
71
  readConfigBoolean,
72
+ readConfigString,
66
73
  } = require("./lib/config-cache.cjs");
67
74
  const { readTextState, writeTextState } = require("./lib/state-store.cjs");
68
75
  // v2.0.0-rc.37 NEW-30: shared client detection (replaces the inline
69
76
  // CLAUDE_PROJECT_DIR single-bit check below).
70
- const { isClaudeCode } = require("./lib/client-adapter.cjs");
77
+ const { isClaudeCode, detectClient } = require("./lib/client-adapter.cjs");
78
+ // v2.1.0-rc.1 P4 (F4/S63): hook-side reader for the CLI pre-generated
79
+ // resolved-bindings snapshot. The hook NEVER re-resolves stores or walks store
80
+ // trees — it only echoes the read-set the CLI already computed. Best-effort.
81
+ let bindingsSnapshotReader = null;
82
+ try {
83
+ bindingsSnapshotReader = require("./lib/bindings-snapshot-reader.cjs");
84
+ } catch {
85
+ // Lib missing (old install) — store labels degrade to silent absence.
86
+ }
87
+ // v2.2 HK3-telemetry (W3-T1): injection-side per-inject logger. Optional require
88
+ // so an old install lacking the lib degrades to silent absence (no telemetry,
89
+ // hook still works).
90
+ let injectionLog = null;
91
+ try {
92
+ injectionLog = require("./lib/injection-log.cjs");
93
+ } catch {
94
+ // Lib missing (old install) — injection telemetry degrades to silent absence.
95
+ }
96
+
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) {
101
+ try {
102
+ const raw = readFileSync(join(cwd, ".fabric", "fabric-config.json"), "utf8");
103
+ const parsed = JSON.parse(raw);
104
+ return typeof parsed.project_id === "string" ? parsed.project_id : null;
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
71
109
 
72
110
  // -----------------------------------------------------------------------------
73
111
  // rc.12: SessionStart broad-menu is now unconditionally emitted on every
@@ -327,6 +365,65 @@ const CLI_TIMEOUT_MS = 2000;
327
365
  // `hint_summary_max_len` in fabric-config overrides this default (range 40..240).
328
366
  const DEFAULT_SUMMARY_MAX_LEN = 80;
329
367
 
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
+
330
427
  function readSummaryMaxLen(projectRoot) {
331
428
  return readConfigNumber(projectRoot, "hint_summary_max_len", DEFAULT_SUMMARY_MAX_LEN, {
332
429
  min: 40,
@@ -565,7 +662,7 @@ function renderTruncated(narrow, maxLen) {
565
662
  * after writing exactly one stderr breadcrumb so operators grepping a stuck-
566
663
  * banner report can diagnose the version drift without source-diving.
567
664
  */
568
- function renderSummary(payload, maxLen) {
665
+ function renderSummary(payload, maxLen, budgetChars) {
569
666
  if (!payload || payload.version !== 2) {
570
667
  if (payload && payload.version !== undefined) {
571
668
  try {
@@ -588,7 +685,9 @@ function renderSummary(payload, maxLen) {
588
685
  ? `[fabric] Session start — ${entries.length} broad-scoped knowledge entries available (truncated):`
589
686
  : `[fabric] Session start — ${entries.length} broad-scoped knowledge entries available:`;
590
687
 
591
- const body = truncated ? renderTruncated(entries, maxLen) : renderFull(entries, maxLen);
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);
592
691
 
593
692
  const lines = [banner, ...body];
594
693
  const revHash = typeof payload.revision_hash === "string" ? payload.revision_hash : null;
@@ -635,7 +734,18 @@ function renderSummary(payload, maxLen) {
635
734
  }
636
735
  }
637
736
 
638
- lines.push(" Use `fab_get_knowledge_sections` to fetch full content.");
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`.
746
+ lines.push(
747
+ " Load full content: `fab_recall(paths)` (one step), or `fab_plan_context` → `fab_get_knowledge_sections` to trim first.",
748
+ );
639
749
  return lines;
640
750
  }
641
751
 
@@ -722,7 +832,9 @@ function main(env, stdio) {
722
832
  // for the agent's working memory. rc.33 W2-5 reintroduces an opt-in
723
833
  // hours-based cooldown via fabric-config (see gate above).
724
834
  const summaryMaxLen = readSummaryMaxLen(cwd);
725
- const lines = renderSummary(resolvedPayload, summaryMaxLen);
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);
726
838
 
727
839
  // v2.0.0-rc.37 NEW-23: resolve fabric_language ONCE per emit path —
728
840
  // shared between the (existing) broadImportBanner branch and the new
@@ -735,14 +847,35 @@ function main(env, stdio) {
735
847
 
736
848
  if (lines.length === 0) return; // nothing to say — silent exit
737
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
+
738
868
  // v2.0.0-rc.37 NEW-23: SessionStart 索引末尾"下一步"引导。Tail line that
739
869
  // tells the AI what to do with the broad index it just received. Without
740
870
  // this, the model often parses the index and moves on without ever calling
741
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.
742
875
  const nextStepNudge =
743
876
  fabricLanguageForEmit === "zh-CN"
744
- ? "下一步: 调 fab_recall(paths) 拿 KB 相关条目;或调 fab_plan_context 先看候选 description_index。"
745
- : "Next: call fab_recall(paths) to fetch related KB entries, or fab_plan_context to preview the description_index first.";
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.";
746
879
  lines.push(nextStepNudge);
747
880
 
748
881
  // Stderr: always emit (human-facing breadcrumb + legacy contract).
@@ -750,6 +883,26 @@ function main(env, stdio) {
750
883
  err.write(`${line}\n`);
751
884
  }
752
885
 
886
+ // v2.2 HK3-telemetry (W3-T1): record the injection side. We just OFFERED the
887
+ // agent `resolvedPayload.entries` (the top_k-sliced broad menu); log their
888
+ // ids so the true hit rate (consumed ÷ injected) is computable against the
889
+ // consumption-side metrics.jsonl. Best-effort — never affects the emit.
890
+ if (injectionLog !== null) {
891
+ const injectedEntries = Array.isArray(resolvedPayload && resolvedPayload.entries)
892
+ ? resolvedPayload.entries
893
+ : [];
894
+ injectionLog.logInjection(cwd, {
895
+ surface: "broad",
896
+ stableIds: injectedEntries.map((e) => (e && e.id) || "").filter(Boolean),
897
+ count: injectedEntries.length,
898
+ revisionHash:
899
+ resolvedPayload && typeof resolvedPayload.revision_hash === "string"
900
+ ? resolvedPayload.revision_hash
901
+ : null,
902
+ ts: nowMs,
903
+ });
904
+ }
905
+
753
906
  // v2.0.0-rc.33 W2-6 (P0-7): stdout JSON envelope. When
754
907
  // hint_reminder_to_context is true (default), serialize the same banner
755
908
  // body as Claude Code's SessionStart hookSpecificOutput shape so the model
@@ -782,6 +935,48 @@ function main(env, stdio) {
782
935
  }
783
936
  }
784
937
 
938
+ // v2.1 NEW-N-3 (ADJ-NEWN-3): hook_surface_emitted instrumentation. One
939
+ // best-effort ledger row recording WHICH broad-scoped ids were surfaced
940
+ // into the session — the join key for measuring hook→behavior delta (did
941
+ // the agent fab_recall what the hook surfaced?). SessionStart fires once
942
+ // per session boot so this never bloats the ledger. Never blocks the hook
943
+ // (KT-DEC-0007): any failure (no .fabric/, undetected client, write error)
944
+ // degrades to silent skip. Client is omitted-by-skip when undetectable
945
+ // because the schema's `client` enum admits only cc/codex/cursor.
946
+ try {
947
+ const surfaceClient = detectClient();
948
+ const fabricDir = join(cwd, FABRIC_DIR_REL);
949
+ if (surfaceClient !== undefined && existsSync(fabricDir)) {
950
+ const renderedIds =
951
+ resolvedPayload && Array.isArray(resolvedPayload.entries)
952
+ ? resolvedPayload.entries
953
+ .map((e) => (e && typeof e.id === "string" ? e.id : null))
954
+ .filter((x) => x !== null)
955
+ : [];
956
+ let idSuffix;
957
+ try {
958
+ idSuffix = require("node:crypto").randomUUID();
959
+ } catch {
960
+ idSuffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
961
+ }
962
+ const surfaceEvent = {
963
+ kind: "fabric-event",
964
+ id: `event:${idSuffix}`,
965
+ ts: Date.now(),
966
+ schema_version: 1,
967
+ event_type: "hook_surface_emitted",
968
+ hook_name: "knowledge-hint-broad",
969
+ client: surfaceClient,
970
+ target_channel: reminderToContext ? "stdout-additionalContext" : "stderr",
971
+ rendered_ids: renderedIds,
972
+ delivery_status: "delivered",
973
+ };
974
+ appendLockedLine(join(fabricDir, "events.jsonl"), JSON.stringify(surfaceEvent) + "\n");
975
+ }
976
+ } catch {
977
+ // best-effort telemetry — never block session start
978
+ }
979
+
785
980
  // v2.0.0-rc.33 W2-5 (P1-8): record successful emit timestamp for the
786
981
  // cooldown gate's next-invocation check. Skip when cooldown is disabled
787
982
  // (cooldownHours === 0) to avoid polluting the FS with a never-read
@@ -66,7 +66,6 @@
66
66
  const { spawnSync } = require("node:child_process");
67
67
  const { createHash, randomUUID } = require("node:crypto");
68
68
  const {
69
- appendFileSync,
70
69
  existsSync,
71
70
  mkdirSync,
72
71
  readFileSync,
@@ -85,6 +84,33 @@ const { resolveOpaqueSummaries } = require("./lib/summary-fallback.cjs");
85
84
  // cache (skips a redundant CLI cold-start spawn when the same path-set is
86
85
  // re-edited within a session and the knowledge graph hasn't changed).
87
86
  const { readJsonState, writeJsonState } = require("./lib/state-store.cjs");
87
+ // W1-01 (ISS-011): the PreToolUse hook is the highest-frequency, most
88
+ // concurrency-exposed write surface in Fabric. Multi-window edits spawn
89
+ // concurrent hook processes that all append to the SAME non-session-scoped
90
+ // ledger/counter files; a bare appendFileSync can interleave a partial write
91
+ // and corrupt a line. Route every shared-file append through the advisory-lock
92
+ // primitive (drop-on-contention, best-effort — matches injection-log).
93
+ const { appendLockedLine } = require("./lib/injection-log.cjs");
94
+ // v2.1.0-rc.1 P4 (F4/S63): hook-side reader for the CLI pre-generated
95
+ // resolved-bindings snapshot. Store-aware hint surfaces the write-target store
96
+ // for the edited file WITHOUT re-resolving or walking store trees. Best-effort.
97
+ let bindingsSnapshotReader = null;
98
+ try {
99
+ bindingsSnapshotReader = require("./lib/bindings-snapshot-reader.cjs");
100
+ } catch {
101
+ // Lib missing (old install) — store labels degrade to silent absence.
102
+ }
103
+
104
+ // Read the project's own `project_id` (the snapshot key) from its config. Not a
105
+ // store-tree read — it is how the hook learns which snapshot to fetch.
106
+ function readProjectId(cwd) {
107
+ try {
108
+ const parsed = JSON.parse(readFileSync(join(cwd, ".fabric", "fabric-config.json"), "utf8"));
109
+ return typeof parsed.project_id === "string" ? parsed.project_id : null;
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
88
114
 
89
115
  // -----------------------------------------------------------------------------
90
116
  // CONSTANTS
@@ -407,7 +433,7 @@ function appendEditIntentToLedger(projectRoot, now, paths, toolName, sessionId)
407
433
  window_ms: 0,
408
434
  }))
409
435
  .join("\n") + "\n";
410
- appendFileSync(join(fabricDir, EVENTS_LEDGER_FILE), lines, "utf8");
436
+ appendLockedLine(join(fabricDir, EVENTS_LEDGER_FILE), lines);
411
437
  } catch {
412
438
  // Silent — events ledger failure must never block the edit.
413
439
  }
@@ -451,7 +477,7 @@ function appendEditCounter(projectRoot, now, paths) {
451
477
  .filter((p) => typeof p === "string" && p.length > 0)
452
478
  : [];
453
479
  const line = JSON.stringify({ ts: iso, paths: pathList });
454
- appendFileSync(file, `${line}\n`, "utf8");
480
+ appendLockedLine(file, `${line}\n`);
455
481
  } catch {
456
482
  // Silent — sidecar failure must never block the edit.
457
483
  }
@@ -481,7 +507,7 @@ function appendHintSilenceCounter(projectRoot, now) {
481
507
  mkdirSync(dir, { recursive: true });
482
508
  }
483
509
  const iso = now instanceof Date ? now.toISOString() : new Date(now).toISOString();
484
- appendFileSync(file, `${iso}\n`, "utf8");
510
+ appendLockedLine(file, `${iso}\n`);
485
511
  } catch {
486
512
  // Silent — sidecar failure must never block the edit.
487
513
  }
@@ -1466,6 +1492,25 @@ function main(env, stdio) {
1466
1492
  const lines = renderSummary({ ...cliPayload, entries: resolvedEntries }, summaryMaxLen);
1467
1493
  if (lines.length === 0) return;
1468
1494
 
1495
+ // v2.1.0-rc.1 P4 (F4/S63): store-aware hint — append the write-target store
1496
+ // so the edit-time hint says WHERE a derived knowledge entry would land.
1497
+ // Best-effort; missing snapshot / single-store setup omits the line.
1498
+ if (bindingsSnapshotReader !== null) {
1499
+ try {
1500
+ const projectId = readProjectId(cwd);
1501
+ if (projectId) {
1502
+ const snapshot = bindingsSnapshotReader.readBindingsSnapshot(projectId);
1503
+ const writeAlias =
1504
+ snapshot && snapshot.write_target && snapshot.write_target.alias;
1505
+ if (writeAlias) {
1506
+ lines.push(`[fabric] writes here land in store '${writeAlias}'`);
1507
+ }
1508
+ }
1509
+ } catch {
1510
+ // store label is decorative provenance — never crash the hook
1511
+ }
1512
+ }
1513
+
1469
1514
  // Stderr: human-facing breadcrumb + legacy contract.
1470
1515
  for (const line of lines) {
1471
1516
  err.write(`${line}\n`);