@fenglimg/fabric-cli 2.1.0-rc.2 → 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 (28) hide show
  1. package/dist/{chunk-PWLW3B57.js → chunk-2CY4BMTH.js} +5 -1
  2. package/dist/{chunk-F46ORPOA.js → chunk-2R55HNVD.js} +82 -5
  3. package/dist/{chunk-HFQVXY6P.js → chunk-4R2CYEA4.js} +31 -1
  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-MF3OTILQ.js → chunk-XC5RUHLK.js} +29 -8
  7. package/dist/{config-XJIPZNUP.js → config-XYRBZJDU.js} +3 -3
  8. package/dist/{doctor-QVNPHLJK.js → doctor-YONYXDX6.js} +39 -26
  9. package/dist/index.js +54 -14
  10. package/dist/{install-2HDO5FTQ.js → install-74ANPCCP.js} +88 -34
  11. package/dist/{metrics-ACEQFPDU.js → metrics-RER6NLFC.js} +22 -9
  12. package/dist/{onboard-coverage-MFCAEBDO.js → onboard-coverage-JWQWDZW7.js} +1 -1
  13. package/dist/{scope-explain-2F2R5URO.js → scope-explain-CDIZESP5.js} +6 -2
  14. package/dist/{store-XTSE5TY6.js → store-XB3ADT65.js} +50 -11
  15. package/dist/{sync-BJCWDPNC.js → sync-UJ4BBCZJ.js} +18 -12
  16. package/dist/{uninstall-TAXSUSKH.js → uninstall-C3QXKOO6.js} +35 -4
  17. package/dist/{whoami-B6AEMSEV.js → whoami-2MLO4Y37.js} +10 -5
  18. package/package.json +3 -3
  19. package/templates/hooks/fabric-hint.cjs +99 -7
  20. package/templates/hooks/knowledge-hint-broad.cjs +164 -9
  21. package/templates/hooks/knowledge-hint-narrow.cjs +10 -4
  22. package/templates/hooks/lib/injection-log.cjs +91 -0
  23. package/templates/hooks/lib/state-store.cjs +30 -11
  24. package/templates/skills/fabric-audit/SKILL.md +53 -0
  25. package/templates/skills/fabric-connect/SKILL.md +48 -0
  26. package/templates/skills/fabric-review/SKILL.md +2 -0
  27. package/templates/skills/fabric-review/ref/cite-contract.md +56 -0
  28. package/templates/skills/fabric-store/SKILL.md +44 -0
@@ -1,4 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ getProjectTranslator
4
+ } from "./chunk-2CY4BMTH.js";
2
5
  import {
3
6
  whoami
4
7
  } from "./chunk-T5RPGCCM.js";
@@ -10,19 +13,21 @@ import { defineCommand } from "citty";
10
13
  var whoami_default = defineCommand({
11
14
  meta: { name: "whoami", description: "Show this machine's Fabric uid and mounted stores" },
12
15
  run() {
16
+ const t = getProjectTranslator();
13
17
  const info = whoami();
14
18
  if (info === null) {
15
- console.log("no global Fabric config \u2014 run `fabric install --global <url>` first");
19
+ console.log(t("cli.cmd.no-global-config"));
16
20
  return;
17
21
  }
18
- console.log(`uid: ${info.uid}`);
22
+ console.log(t("cli.whoami.uid", { uid: info.uid }));
19
23
  if (info.stores.length === 0) {
20
- console.log("stores: (none mounted)");
24
+ console.log(t("cli.whoami.stores-none"));
21
25
  return;
22
26
  }
23
- console.log("stores:");
27
+ console.log(t("cli.whoami.stores-label"));
28
+ const localOnly = t("cli.shared.local-only");
24
29
  for (const store of info.stores) {
25
- console.log(` ${store.alias} ${store.store_uuid}${store.local_only ? " (local-only)" : ""}`);
30
+ console.log(` ${store.alias} ${store.store_uuid}${store.local_only ? ` ${localOnly}` : ""}`);
26
31
  }
27
32
  }
28
33
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fenglimg/fabric-cli",
3
- "version": "2.1.0-rc.2",
3
+ "version": "2.2.0-rc.1",
4
4
  "description": "Fabric CLI — installs the MCP server + skills + hooks for Claude Code, Cursor, and Codex CLI; runs doctor / knowledge maintenance.",
5
5
  "license": "MIT",
6
6
  "author": "wangzhichao <fenglimg90@gmail.com>",
@@ -45,8 +45,8 @@
45
45
  "tree-sitter-javascript": "^0.25.0",
46
46
  "tree-sitter-typescript": "^0.23.2",
47
47
  "web-tree-sitter": "^0.26.8",
48
- "@fenglimg/fabric-server": "2.1.0-rc.2",
49
- "@fenglimg/fabric-shared": "2.1.0-rc.2"
48
+ "@fenglimg/fabric-server": "2.2.0-rc.1",
49
+ "@fenglimg/fabric-shared": "2.2.0-rc.1"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@types/node": "^22.15.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;
@@ -783,6 +789,11 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
783
789
  reason,
784
790
  signal: "archive",
785
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,
786
797
  };
787
798
  }
788
799
 
@@ -819,6 +830,10 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
819
830
  reason,
820
831
  signal: "review",
821
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,
822
837
  };
823
838
  }
824
839
 
@@ -869,6 +884,10 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
869
884
  reason,
870
885
  signal: "import",
871
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,
872
891
  };
873
892
  }
874
893
 
@@ -979,8 +998,15 @@ function readShownCache(projectRoot) {
979
998
  function writeShownCache(projectRoot, cache) {
980
999
  const cachePath = join(projectRoot, SHOWN_CACHE_FILE);
981
1000
  try {
982
- mkdirSync(dirname(cachePath), { recursive: true });
983
- 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
+ }
984
1010
  } catch {
985
1011
  // Silent — cache failure must never block the hook.
986
1012
  }
@@ -1117,8 +1143,13 @@ function readMaintenanceLastEmit(projectRoot) {
1117
1143
  function writeMaintenanceLastEmit(projectRoot, nowMs) {
1118
1144
  const p = join(projectRoot, MAINTENANCE_HINT_LAST_EMIT_FILE);
1119
1145
  try {
1120
- mkdirSync(dirname(p), { recursive: true });
1121
- 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
+ }
1122
1153
  } catch {
1123
1154
  // Silent — sidecar failure must never block the hook.
1124
1155
  }
@@ -1207,9 +1238,64 @@ function evaluateMaintenanceSignal(events, now, canonicalCount, lastEmitMs, thre
1207
1238
  signal: "maintenance",
1208
1239
  // CLI recommendation rather than Skill — doctor is a CLI surface.
1209
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,
1210
1246
  };
1211
1247
  }
1212
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
+
1213
1299
  /**
1214
1300
  * v2.0.0-rc.7 T5: best-effort sync stdin reader for the Stop hook.
1215
1301
  *
@@ -1414,7 +1500,7 @@ function extractAndWriteAssistantTurnsBestEffort(cwd, stdinPayload) {
1414
1500
  timestamp: new Date().toISOString(),
1415
1501
  };
1416
1502
  if (client !== undefined) event.client = client;
1417
- appendFileSync(ledgerPath, JSON.stringify(event) + "\n", "utf8");
1503
+ appendLockedLine(ledgerPath, JSON.stringify(event) + "\n");
1418
1504
  } catch {
1419
1505
  // Per-turn failure must not abort the remaining turns; the Stop hook
1420
1506
  // contract is "never block on hook failure". Best-effort continues.
@@ -1439,7 +1525,7 @@ function extractAndWriteAssistantTurnsBestEffort(cwd, stdinPayload) {
1439
1525
  counters: { [counterKey]: emptyShellCount },
1440
1526
  };
1441
1527
  const metricsPath = join(fabricDir, METRICS_LEDGER_FILE);
1442
- appendFileSync(metricsPath, JSON.stringify(metricsRow) + "\n", "utf8");
1528
+ appendLockedLine(metricsPath, JSON.stringify(metricsRow) + "\n");
1443
1529
  } catch {
1444
1530
  // metrics fold is observability-only; never block the hook on failure.
1445
1531
  }
@@ -1951,6 +2037,9 @@ function main(env, stdio) {
1951
2037
  // see MAINTENANCE_HINT_LAST_EMIT_FILE). The A/B/C shared cooldown cache
1952
2038
  // uses hours, so we branch here to avoid mixing semantics.
1953
2039
  if (result.signal === "maintenance") {
2040
+ emitSignalFiredEvent(cwd, sessionId, result);
2041
+ delete result.threshold;
2042
+ delete result.actual_value;
1954
2043
  out.write(JSON.stringify(result));
1955
2044
  writeMaintenanceLastEmit(cwd, nowMs);
1956
2045
  return;
@@ -1972,6 +2061,9 @@ function main(env, stdio) {
1972
2061
  return; // Still in cooldown — silent.
1973
2062
  }
1974
2063
 
2064
+ emitSignalFiredEvent(cwd, sessionId, result);
2065
+ delete result.threshold;
2066
+ delete result.actual_value;
1975
2067
  out.write(JSON.stringify(result));
1976
2068
  cache[result.signal] = nowMs;
1977
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,12 @@ 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");
71
78
  // v2.1.0-rc.1 P4 (F4/S63): hook-side reader for the CLI pre-generated
72
79
  // resolved-bindings snapshot. The hook NEVER re-resolves stores or walks store
73
80
  // trees — it only echoes the read-set the CLI already computed. Best-effort.
@@ -77,6 +84,15 @@ try {
77
84
  } catch {
78
85
  // Lib missing (old install) — store labels degrade to silent absence.
79
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
+ }
80
96
 
81
97
  // Read the project's own `project_id` from `.fabric/fabric-config.json` (the
82
98
  // snapshot key). Reading the PROJECT config is not a store-tree read — it is how
@@ -349,6 +365,65 @@ const CLI_TIMEOUT_MS = 2000;
349
365
  // `hint_summary_max_len` in fabric-config overrides this default (range 40..240).
350
366
  const DEFAULT_SUMMARY_MAX_LEN = 80;
351
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
+
352
427
  function readSummaryMaxLen(projectRoot) {
353
428
  return readConfigNumber(projectRoot, "hint_summary_max_len", DEFAULT_SUMMARY_MAX_LEN, {
354
429
  min: 40,
@@ -587,7 +662,7 @@ function renderTruncated(narrow, maxLen) {
587
662
  * after writing exactly one stderr breadcrumb so operators grepping a stuck-
588
663
  * banner report can diagnose the version drift without source-diving.
589
664
  */
590
- function renderSummary(payload, maxLen) {
665
+ function renderSummary(payload, maxLen, budgetChars) {
591
666
  if (!payload || payload.version !== 2) {
592
667
  if (payload && payload.version !== undefined) {
593
668
  try {
@@ -610,7 +685,9 @@ function renderSummary(payload, maxLen) {
610
685
  ? `[fabric] Session start — ${entries.length} broad-scoped knowledge entries available (truncated):`
611
686
  : `[fabric] Session start — ${entries.length} broad-scoped knowledge entries available:`;
612
687
 
613
- 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);
614
691
 
615
692
  const lines = [banner, ...body];
616
693
  const revHash = typeof payload.revision_hash === "string" ? payload.revision_hash : null;
@@ -657,7 +734,18 @@ function renderSummary(payload, maxLen) {
657
734
  }
658
735
  }
659
736
 
660
- 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
+ );
661
749
  return lines;
662
750
  }
663
751
 
@@ -744,7 +832,9 @@ function main(env, stdio) {
744
832
  // for the agent's working memory. rc.33 W2-5 reintroduces an opt-in
745
833
  // hours-based cooldown via fabric-config (see gate above).
746
834
  const summaryMaxLen = readSummaryMaxLen(cwd);
747
- 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);
748
838
 
749
839
  // v2.0.0-rc.37 NEW-23: resolve fabric_language ONCE per emit path —
750
840
  // shared between the (existing) broadImportBanner branch and the new
@@ -779,10 +869,13 @@ function main(env, stdio) {
779
869
  // tells the AI what to do with the broad index it just received. Without
780
870
  // this, the model often parses the index and moves on without ever calling
781
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.
782
875
  const nextStepNudge =
783
876
  fabricLanguageForEmit === "zh-CN"
784
- ? "下一步: 调 fab_recall(paths) 拿 KB 相关条目;或调 fab_plan_context 先看候选 description_index。"
785
- : "Next: call fab_recall(paths) to fetch related KB entries, or fab_plan_context to preview the description_index first.";
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.";
786
879
  lines.push(nextStepNudge);
787
880
 
788
881
  // Stderr: always emit (human-facing breadcrumb + legacy contract).
@@ -790,6 +883,26 @@ function main(env, stdio) {
790
883
  err.write(`${line}\n`);
791
884
  }
792
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
+
793
906
  // v2.0.0-rc.33 W2-6 (P0-7): stdout JSON envelope. When
794
907
  // hint_reminder_to_context is true (default), serialize the same banner
795
908
  // body as Claude Code's SessionStart hookSpecificOutput shape so the model
@@ -822,6 +935,48 @@ function main(env, stdio) {
822
935
  }
823
936
  }
824
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
+
825
980
  // v2.0.0-rc.33 W2-5 (P1-8): record successful emit timestamp for the
826
981
  // cooldown gate's next-invocation check. Skip when cooldown is disabled
827
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,13 @@ 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");
88
94
  // v2.1.0-rc.1 P4 (F4/S63): hook-side reader for the CLI pre-generated
89
95
  // resolved-bindings snapshot. Store-aware hint surfaces the write-target store
90
96
  // for the edited file WITHOUT re-resolving or walking store trees. Best-effort.
@@ -427,7 +433,7 @@ function appendEditIntentToLedger(projectRoot, now, paths, toolName, sessionId)
427
433
  window_ms: 0,
428
434
  }))
429
435
  .join("\n") + "\n";
430
- appendFileSync(join(fabricDir, EVENTS_LEDGER_FILE), lines, "utf8");
436
+ appendLockedLine(join(fabricDir, EVENTS_LEDGER_FILE), lines);
431
437
  } catch {
432
438
  // Silent — events ledger failure must never block the edit.
433
439
  }
@@ -471,7 +477,7 @@ function appendEditCounter(projectRoot, now, paths) {
471
477
  .filter((p) => typeof p === "string" && p.length > 0)
472
478
  : [];
473
479
  const line = JSON.stringify({ ts: iso, paths: pathList });
474
- appendFileSync(file, `${line}\n`, "utf8");
480
+ appendLockedLine(file, `${line}\n`);
475
481
  } catch {
476
482
  // Silent — sidecar failure must never block the edit.
477
483
  }
@@ -501,7 +507,7 @@ function appendHintSilenceCounter(projectRoot, now) {
501
507
  mkdirSync(dir, { recursive: true });
502
508
  }
503
509
  const iso = now instanceof Date ? now.toISOString() : new Date(now).toISOString();
504
- appendFileSync(file, `${iso}\n`, "utf8");
510
+ appendLockedLine(file, `${iso}\n`);
505
511
  } catch {
506
512
  // Silent — sidecar failure must never block the edit.
507
513
  }
@@ -0,0 +1,91 @@
1
+ // v2.2 HK3-telemetry (W3-T1): injection-side telemetry. `.fabric/metrics.jsonl`
2
+ // (server) records the CONSUMPTION side — which knowledge the agent actually
3
+ // fetched/consumed. But nothing recorded the INJECTION side — which knowledge a
4
+ // hook OFFERED the agent at SessionStart / PreToolUse. Without that denominator
5
+ // the "true hit rate" (consumed ÷ injected) cannot be computed: a high consume
6
+ // count tells you nothing if the hook injected ten times as many entries.
7
+ //
8
+ // This lib appends one row per injection to `.fabric/injections.jsonl`:
9
+ // { ts, surface: "broad"|"narrow", count, stable_ids: [...], revision_hash }
10
+ //
11
+ // Best-effort + synchronous: hooks are short-lived processes, so a sync append
12
+ // is simpler than threading async, and ANY failure is swallowed — telemetry
13
+ // must never break or delay the hook (failure invariant: silent). Concurrent
14
+ // writers from multiple windows are serialized with an advisory lock (see
15
+ // appendLockedLine below) so a contended write can't corrupt a line.
16
+
17
+ const { appendFileSync, mkdirSync, openSync, closeSync, statSync, rmSync } = require("node:fs");
18
+ const { join, dirname } = require("node:path");
19
+
20
+ // Multi-window concurrency guard (ADJ-W3-INJECTION-CONCURRENCY): the same repo
21
+ // is frequently edited from several client sessions at once, so multiple hook
22
+ // processes can append to injections.jsonl simultaneously. A bare appendFileSync
23
+ // can interleave a partial write under contention and corrupt a line. We guard
24
+ // each append with an advisory lock file created atomically via O_EXCL ("wx"):
25
+ // - acquired → write the row, then release the lock
26
+ // - contended → DROP this row. Telemetry is best-effort; a missing row only
27
+ // shrinks the denominator slightly, and dropping is what keeps
28
+ // the ledger from ever being corrupted by an interleave.
29
+ // - stale → a holder that crashed leaves the lock behind; reclaim it once
30
+ // past STALE_LOCK_MS so contention can't wedge forever.
31
+ const STALE_LOCK_MS = 5000;
32
+
33
+ function appendLockedLine(path, line) {
34
+ const lockPath = `${path}.lock`;
35
+ let fd;
36
+ try {
37
+ fd = openSync(lockPath, "wx"); // atomic create-exclusive = acquire
38
+ } catch (err) {
39
+ if (!err || err.code !== "EEXIST") return; // unexpected → drop (best-effort)
40
+ try {
41
+ if (Date.now() - statSync(lockPath).mtimeMs <= STALE_LOCK_MS) return; // fresh holder → drop
42
+ rmSync(lockPath, { force: true }); // stale holder crashed → reclaim
43
+ fd = openSync(lockPath, "wx");
44
+ } catch {
45
+ return; // racing another reclaimer → drop
46
+ }
47
+ }
48
+ try {
49
+ closeSync(fd);
50
+ appendFileSync(path, line);
51
+ } finally {
52
+ try {
53
+ rmSync(lockPath, { force: true });
54
+ } catch {
55
+ /* lock already released */
56
+ }
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Append one injection record to `<projectRoot>/.fabric/injections.jsonl`.
62
+ *
63
+ * @param {string} projectRoot
64
+ * @param {{ surface: "broad"|"narrow", stableIds?: string[], count?: number, revisionHash?: string|null, ts?: number }} record
65
+ */
66
+ function logInjection(projectRoot, record) {
67
+ try {
68
+ if (!projectRoot || !record || typeof record.surface !== "string") {
69
+ return;
70
+ }
71
+ const stableIds = Array.isArray(record.stableIds) ? record.stableIds.filter((id) => typeof id === "string") : [];
72
+ const count = typeof record.count === "number" ? record.count : stableIds.length;
73
+ if (count <= 0) {
74
+ return; // nothing injected → no row (keeps the denominator honest)
75
+ }
76
+ const row = {
77
+ ts: typeof record.ts === "number" ? record.ts : Date.now(),
78
+ surface: record.surface,
79
+ count,
80
+ stable_ids: stableIds,
81
+ revision_hash: typeof record.revisionHash === "string" ? record.revisionHash : null,
82
+ };
83
+ const path = join(projectRoot, ".fabric", "injections.jsonl");
84
+ mkdirSync(dirname(path), { recursive: true });
85
+ appendLockedLine(path, `${JSON.stringify(row)}\n`);
86
+ } catch {
87
+ // Telemetry is best-effort — never crash or delay the hook.
88
+ }
89
+ }
90
+
91
+ module.exports = { logInjection, appendLockedLine };