@fenglimg/fabric-cli 2.1.0-rc.2 → 2.2.0-rc.3

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 (49) hide show
  1. package/dist/{chunk-PWLW3B57.js → chunk-2CY4BMTH.js} +5 -1
  2. package/dist/chunk-5LQIHYFC.js +64 -0
  3. package/dist/chunk-5ZUMLCD5.js +248 -0
  4. package/dist/{chunk-WWNXR34K.js → chunk-BO4XIZWZ.js} +8 -1
  5. package/dist/chunk-EOT63RDH.js +36 -0
  6. package/dist/{chunk-BATF4PEJ.js → chunk-F6ITRM7T.js} +4 -4
  7. package/dist/{chunk-WU6GAPKH.js → chunk-H3FE6VIK.js} +3 -5
  8. package/dist/{chunk-MF3OTILQ.js → chunk-XC5RUHLK.js} +29 -8
  9. package/dist/chunk-XCBVSGCS.js +25 -0
  10. package/dist/{chunk-F46ORPOA.js → chunk-XHHCRDIR.js} +149 -7
  11. package/dist/{config-XJIPZNUP.js → config-VJMXCLXW.js} +3 -3
  12. package/dist/{doctor-QVNPHLJK.js → doctor-J4O3X54I.js} +154 -30
  13. package/dist/index.js +57 -16
  14. package/dist/{install-2HDO5FTQ.js → install-BULNDUIM.js} +241 -108
  15. package/dist/{metrics-ACEQFPDU.js → metrics-RER6NLFC.js} +22 -9
  16. package/dist/{onboard-coverage-MFCAEBDO.js → onboard-coverage-JWQWDZW7.js} +1 -1
  17. package/dist/{plan-context-hint-FC6P3WFE.js → plan-context-hint-CHVZGOZ5.js} +21 -8
  18. package/dist/{scope-explain-2F2R5URO.js → scope-explain-BWRWBCCP.js} +19 -5
  19. package/dist/{status-GLQWLWH6.js → status-PANEGKU2.js} +17 -6
  20. package/dist/store-66NK2FTQ.js +443 -0
  21. package/dist/sync-EA5HZMXM.js +395 -0
  22. package/dist/{uninstall-TAXSUSKH.js → uninstall-F75MPKQC.js} +61 -4
  23. package/dist/whoami-66YKY5DZ.js +47 -0
  24. package/package.json +3 -3
  25. package/templates/hooks/cite-policy-evict.cjs +412 -160
  26. package/templates/hooks/configs/claude-code.json +17 -2
  27. package/templates/hooks/configs/codex-hooks.json +14 -2
  28. package/templates/hooks/configs/cursor-hooks.json +14 -2
  29. package/templates/hooks/fabric-hint.cjs +247 -19
  30. package/templates/hooks/knowledge-hint-broad.cjs +176 -10
  31. package/templates/hooks/knowledge-hint-narrow.cjs +64 -5
  32. package/templates/hooks/lib/injection-log.cjs +91 -0
  33. package/templates/hooks/lib/state-store.cjs +30 -11
  34. package/templates/hooks/post-tooluse-mutation.cjs +285 -0
  35. package/templates/hooks/session-end-marker.cjs +140 -0
  36. package/templates/skills/fabric-archive/SKILL.md +7 -1
  37. package/templates/skills/fabric-audit/SKILL.md +53 -0
  38. package/templates/skills/fabric-connect/SKILL.md +48 -0
  39. package/templates/skills/fabric-review/SKILL.md +2 -0
  40. package/templates/skills/fabric-review/ref/cite-contract.md +56 -0
  41. package/templates/skills/fabric-store/SKILL.md +44 -0
  42. package/dist/chunk-HFQVXY6P.js +0 -86
  43. package/dist/chunk-L4Q55UC4.js +0 -52
  44. package/dist/chunk-LFIKMVY7.js +0 -27
  45. package/dist/chunk-RYAFBNES.js +0 -33
  46. package/dist/chunk-T5RPGCCM.js +0 -40
  47. package/dist/store-XTSE5TY6.js +0 -105
  48. package/dist/sync-BJCWDPNC.js +0 -245
  49. package/dist/whoami-B6AEMSEV.js +0 -31
@@ -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,
@@ -499,7 +574,18 @@ function truncateSummary(raw, maxLen) {
499
574
  function formatEntryLine(entry, maxLen) {
500
575
  const id = entry.id || "(no-id)";
501
576
  const summary = truncateSummary(entry.summary, maxLen);
502
- return summary.length > 0 ? ` - ${id} · ${summary}` : ` - ${id}`;
577
+ // lifecycle-refactor W3-T2 (§7 图谱消费 / §5 hook 沿 related 二阶召回): when this
578
+ // entry was pulled in by following a surfaced entry's `related` graph edge,
579
+ // tag the line with its provenance so the agent knows it arrived via the graph,
580
+ // not its own ranking. Omitted entirely for ordinarily-ranked entries — no fake
581
+ // "related" annotation is ever synthesized (graph-empty honesty).
582
+ const provenance =
583
+ typeof entry.related_to === "string" && entry.related_to.length > 0
584
+ ? ` (related-to-${entry.related_to})`
585
+ : "";
586
+ return summary.length > 0
587
+ ? ` - ${id} · ${summary}${provenance}`
588
+ : ` - ${id}${provenance}`;
503
589
  }
504
590
 
505
591
  /**
@@ -587,7 +673,7 @@ function renderTruncated(narrow, maxLen) {
587
673
  * after writing exactly one stderr breadcrumb so operators grepping a stuck-
588
674
  * banner report can diagnose the version drift without source-diving.
589
675
  */
590
- function renderSummary(payload, maxLen) {
676
+ function renderSummary(payload, maxLen, budgetChars) {
591
677
  if (!payload || payload.version !== 2) {
592
678
  if (payload && payload.version !== undefined) {
593
679
  try {
@@ -610,7 +696,9 @@ function renderSummary(payload, maxLen) {
610
696
  ? `[fabric] Session start — ${entries.length} broad-scoped knowledge entries available (truncated):`
611
697
  : `[fabric] Session start — ${entries.length} broad-scoped knowledge entries available:`;
612
698
 
613
- const body = truncated ? renderTruncated(entries, maxLen) : renderFull(entries, maxLen);
699
+ const renderedBody = truncated ? renderTruncated(entries, maxLen) : renderFull(entries, maxLen);
700
+ // v2.2 HK2-degrade (W2-T2): final budget rung — cap the body's rendered size.
701
+ const body = capBodyToBudget(renderedBody, budgetChars);
614
702
 
615
703
  const lines = [banner, ...body];
616
704
  const revHash = typeof payload.revision_hash === "string" ? payload.revision_hash : null;
@@ -657,7 +745,18 @@ function renderSummary(payload, maxLen) {
657
745
  }
658
746
  }
659
747
 
660
- lines.push(" Use `fab_get_knowledge_sections` to fetch full content.");
748
+ // v2.2 MC3-fix-guidance (W1-T5): unify the footer with the canonical recall
749
+ // flow. The prior text ("Use `fab_get_knowledge_sections` to fetch full
750
+ // content.") told the agent to call a tool that REQUIRES a selection_token it
751
+ // does not yet have — directly contradicting the bilingual next-step nudge
752
+ // (and AGENTS.md) which leads with `fab_recall`. Footer now states the same
753
+ // two-path model: single-step `fab_recall`, or `fab_plan_context` →
754
+ // `fab_get_knowledge_sections` when the bodies must be trimmed first. Keeps
755
+ // the `fab_get_knowledge_sections` token (downstream substring contracts) but
756
+ // sequences it correctly behind the token-issuing `fab_plan_context`.
757
+ lines.push(
758
+ " Load full content: `fab_recall(paths)` (one step), or `fab_plan_context` → `fab_get_knowledge_sections` to trim first.",
759
+ );
661
760
  return lines;
662
761
  }
663
762
 
@@ -744,7 +843,9 @@ function main(env, stdio) {
744
843
  // for the agent's working memory. rc.33 W2-5 reintroduces an opt-in
745
844
  // hours-based cooldown via fabric-config (see gate above).
746
845
  const summaryMaxLen = readSummaryMaxLen(cwd);
747
- const lines = renderSummary(resolvedPayload, summaryMaxLen);
846
+ // v2.2 HK2-degrade (W2-T2): thread the injection char-budget into the renderer.
847
+ const broadBudgetChars = readBroadBudgetChars(cwd);
848
+ const lines = renderSummary(resolvedPayload, summaryMaxLen, broadBudgetChars);
748
849
 
749
850
  // v2.0.0-rc.37 NEW-23: resolve fabric_language ONCE per emit path —
750
851
  // shared between the (existing) broadImportBanner branch and the new
@@ -779,10 +880,13 @@ function main(env, stdio) {
779
880
  // tells the AI what to do with the broad index it just received. Without
780
881
  // this, the model often parses the index and moves on without ever calling
781
882
  // fab_recall / fab_plan_context. One-line nudge, bilingual.
883
+ // v2.2 W1-REVIEW codex LOW-6: `description_index` was renamed to `candidates`
884
+ // in rc.38 UX-1; the nudge now uses the current field name so the guidance
885
+ // matches the actual MCP response shape.
782
886
  const nextStepNudge =
783
887
  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.";
888
+ ? "下一步: 调 fab_recall(paths) 拿 KB 相关条目;或调 fab_plan_context 先看候选描述(candidates)。"
889
+ : "Next: call fab_recall(paths) to fetch related KB entries, or fab_plan_context to preview the candidate descriptions first.";
786
890
  lines.push(nextStepNudge);
787
891
 
788
892
  // Stderr: always emit (human-facing breadcrumb + legacy contract).
@@ -790,6 +894,26 @@ function main(env, stdio) {
790
894
  err.write(`${line}\n`);
791
895
  }
792
896
 
897
+ // v2.2 HK3-telemetry (W3-T1): record the injection side. We just OFFERED the
898
+ // agent `resolvedPayload.entries` (the top_k-sliced broad menu); log their
899
+ // ids so the true hit rate (consumed ÷ injected) is computable against the
900
+ // consumption-side metrics.jsonl. Best-effort — never affects the emit.
901
+ if (injectionLog !== null) {
902
+ const injectedEntries = Array.isArray(resolvedPayload && resolvedPayload.entries)
903
+ ? resolvedPayload.entries
904
+ : [];
905
+ injectionLog.logInjection(cwd, {
906
+ surface: "broad",
907
+ stableIds: injectedEntries.map((e) => (e && e.id) || "").filter(Boolean),
908
+ count: injectedEntries.length,
909
+ revisionHash:
910
+ resolvedPayload && typeof resolvedPayload.revision_hash === "string"
911
+ ? resolvedPayload.revision_hash
912
+ : null,
913
+ ts: nowMs,
914
+ });
915
+ }
916
+
793
917
  // v2.0.0-rc.33 W2-6 (P0-7): stdout JSON envelope. When
794
918
  // hint_reminder_to_context is true (default), serialize the same banner
795
919
  // body as Claude Code's SessionStart hookSpecificOutput shape so the model
@@ -822,6 +946,48 @@ function main(env, stdio) {
822
946
  }
823
947
  }
824
948
 
949
+ // v2.1 NEW-N-3 (ADJ-NEWN-3): hook_surface_emitted instrumentation. One
950
+ // best-effort ledger row recording WHICH broad-scoped ids were surfaced
951
+ // into the session — the join key for measuring hook→behavior delta (did
952
+ // the agent fab_recall what the hook surfaced?). SessionStart fires once
953
+ // per session boot so this never bloats the ledger. Never blocks the hook
954
+ // (KT-DEC-0007): any failure (no .fabric/, undetected client, write error)
955
+ // degrades to silent skip. Client is omitted-by-skip when undetectable
956
+ // because the schema's `client` enum admits only cc/codex/cursor.
957
+ try {
958
+ const surfaceClient = detectClient();
959
+ const fabricDir = join(cwd, FABRIC_DIR_REL);
960
+ if (surfaceClient !== undefined && existsSync(fabricDir)) {
961
+ const renderedIds =
962
+ resolvedPayload && Array.isArray(resolvedPayload.entries)
963
+ ? resolvedPayload.entries
964
+ .map((e) => (e && typeof e.id === "string" ? e.id : null))
965
+ .filter((x) => x !== null)
966
+ : [];
967
+ let idSuffix;
968
+ try {
969
+ idSuffix = require("node:crypto").randomUUID();
970
+ } catch {
971
+ idSuffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
972
+ }
973
+ const surfaceEvent = {
974
+ kind: "fabric-event",
975
+ id: `event:${idSuffix}`,
976
+ ts: Date.now(),
977
+ schema_version: 1,
978
+ event_type: "hook_surface_emitted",
979
+ hook_name: "knowledge-hint-broad",
980
+ client: surfaceClient,
981
+ target_channel: reminderToContext ? "stdout-additionalContext" : "stderr",
982
+ rendered_ids: renderedIds,
983
+ delivery_status: "delivered",
984
+ };
985
+ appendLockedLine(join(fabricDir, "events.jsonl"), JSON.stringify(surfaceEvent) + "\n");
986
+ }
987
+ } catch {
988
+ // best-effort telemetry — never block session start
989
+ }
990
+
825
991
  // v2.0.0-rc.33 W2-5 (P1-8): record successful emit timestamp for the
826
992
  // cooldown gate's next-invocation check. Skip when cooldown is disabled
827
993
  // (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,16 @@ 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
+ // lifecycle-refactor W1-T2: client discriminator for the hook_surface_emitted
95
+ // event (schema requires the `client` enum). Mirrors the broad hook's import.
96
+ const { detectClient } = require("./lib/client-adapter.cjs");
88
97
  // v2.1.0-rc.1 P4 (F4/S63): hook-side reader for the CLI pre-generated
89
98
  // resolved-bindings snapshot. Store-aware hint surfaces the write-target store
90
99
  // for the edited file WITHOUT re-resolving or walking store trees. Best-effort.
@@ -427,7 +436,7 @@ function appendEditIntentToLedger(projectRoot, now, paths, toolName, sessionId)
427
436
  window_ms: 0,
428
437
  }))
429
438
  .join("\n") + "\n";
430
- appendFileSync(join(fabricDir, EVENTS_LEDGER_FILE), lines, "utf8");
439
+ appendLockedLine(join(fabricDir, EVENTS_LEDGER_FILE), lines);
431
440
  } catch {
432
441
  // Silent — events ledger failure must never block the edit.
433
442
  }
@@ -471,7 +480,7 @@ function appendEditCounter(projectRoot, now, paths) {
471
480
  .filter((p) => typeof p === "string" && p.length > 0)
472
481
  : [];
473
482
  const line = JSON.stringify({ ts: iso, paths: pathList });
474
- appendFileSync(file, `${line}\n`, "utf8");
483
+ appendLockedLine(file, `${line}\n`);
475
484
  } catch {
476
485
  // Silent — sidecar failure must never block the edit.
477
486
  }
@@ -501,7 +510,7 @@ function appendHintSilenceCounter(projectRoot, now) {
501
510
  mkdirSync(dir, { recursive: true });
502
511
  }
503
512
  const iso = now instanceof Date ? now.toISOString() : new Date(now).toISOString();
504
- appendFileSync(file, `${iso}\n`, "utf8");
513
+ appendLockedLine(file, `${iso}\n`);
505
514
  } catch {
506
515
  // Silent — sidecar failure must never block the edit.
507
516
  }
@@ -1163,7 +1172,15 @@ function formatEntryLine(entry, maxLen) {
1163
1172
  const maturity = entry.maturity || "unknown";
1164
1173
  const summary = truncateSummary(entry.summary, maxLen);
1165
1174
  const tail = summary.length > 0 ? ` ${summary}` : "";
1166
- return ` [${id}] (${type}/${maturity})${tail}`;
1175
+ // lifecycle-refactor W3-T2 (§7 图谱消费 / §5 hook 沿 related 二阶召回): mark entries
1176
+ // pulled in by a surfaced entry's one-hop `related` graph edge with their source
1177
+ // provenance. Omitted for ordinarily-ranked entries — no fake graph annotation
1178
+ // is ever synthesized (graph-empty honesty).
1179
+ const provenance =
1180
+ typeof entry.related_to === "string" && entry.related_to.length > 0
1181
+ ? ` (related-to-${entry.related_to})`
1182
+ : "";
1183
+ return ` [${id}] (${type}/${maturity})${tail}${provenance}`;
1167
1184
  }
1168
1185
 
1169
1186
  function readSummaryMaxLen(projectRoot) {
@@ -1510,6 +1527,48 @@ function main(env, stdio) {
1510
1527
  err.write(`${line}\n`);
1511
1528
  }
1512
1529
 
1530
+ // lifecycle-refactor W1-T2: hook_surface_emitted — record WHICH narrow-scoped
1531
+ // stable_ids this PreToolUse fire surfaced into the edit, so doctor can join
1532
+ // surfaced→edited (this is the join's LEFT half; the edit_intent_checked event
1533
+ // appended above supplies the edited path / RIGHT half, keyed on the same
1534
+ // real payload session_id). Fires only after all gates passed and lines were
1535
+ // rendered (so it tracks genuinely-surfaced hints, never bloat). Best-effort,
1536
+ // never blocks the edit (KT-DEC-0007); the schema's `client` is a required
1537
+ // enum, so skip when the client is undetectable rather than emit an invalid
1538
+ // row. Mirrors the broad SessionStart emit (knowledge-hint-broad.cjs).
1539
+ try {
1540
+ const surfaceClient = detectClient();
1541
+ const fabricDir = join(cwd, FABRIC_DIR_REL);
1542
+ if (surfaceClient !== undefined && existsSync(fabricDir)) {
1543
+ const renderedIds = resolvedEntries
1544
+ .map((e) => (e && typeof e.id === "string" ? e.id : null))
1545
+ .filter((x) => x !== null);
1546
+ const realSessionId =
1547
+ payload &&
1548
+ typeof payload === "object" &&
1549
+ typeof payload.session_id === "string" &&
1550
+ payload.session_id.length > 0
1551
+ ? payload.session_id
1552
+ : null;
1553
+ const surfaceEvent = {
1554
+ kind: "fabric-event",
1555
+ id: `event:${randomUUID()}`,
1556
+ ts: nowMs,
1557
+ schema_version: 1,
1558
+ ...(realSessionId ? { session_id: realSessionId } : {}),
1559
+ event_type: "hook_surface_emitted",
1560
+ hook_name: "knowledge-hint-narrow",
1561
+ client: surfaceClient,
1562
+ target_channel: "stderr",
1563
+ rendered_ids: renderedIds,
1564
+ delivery_status: "delivered",
1565
+ };
1566
+ appendLockedLine(join(fabricDir, EVENTS_LEDGER_FILE), JSON.stringify(surfaceEvent) + "\n");
1567
+ }
1568
+ } catch {
1569
+ // best-effort telemetry — never block the edit
1570
+ }
1571
+
1513
1572
  // v2.0.0-rc.33 W2-6 (P0-7): stdout JSON envelope. When
1514
1573
  // hint_reminder_to_context is true (default), serialize the same banner
1515
1574
  // body as Claude Code's PreToolUse hookSpecificOutput shape so the model
@@ -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 };
@@ -21,7 +21,9 @@
21
21
  * acceptable — the hook never blocks user flow on sidecar I/O, KT-DEC-0007).
22
22
  */
23
23
 
24
- const { existsSync, mkdirSync, readFileSync, writeFileSync } = require("node:fs");
24
+ // Namespace import (not destructured) so the atomic write goes through a single
25
+ // mutable fs reference — also what the atomicity tests spy on (ISS-016).
26
+ const fs = require("node:fs");
25
27
  const { dirname, join } = require("node:path");
26
28
 
27
29
  const CACHE_DIR_REL = join(".fabric", ".cache");
@@ -30,11 +32,31 @@ function cachePath(projectRoot, fileName) {
30
32
  return join(projectRoot, CACHE_DIR_REL, fileName);
31
33
  }
32
34
 
35
+ // ISS-016: write to a unique temp file then rename over the target. rename is
36
+ // atomic on POSIX, so a reader sees either the old or the new file in full —
37
+ // never a truncated/garbled write from a crash or concurrent writer. The temp
38
+ // suffix (pid + clock) keeps concurrent windows from colliding on the temp.
39
+ function atomicWrite(path, data) {
40
+ fs.mkdirSync(dirname(path), { recursive: true });
41
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
42
+ try {
43
+ fs.writeFileSync(tmp, data);
44
+ fs.renameSync(tmp, path);
45
+ } catch (err) {
46
+ try {
47
+ if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
48
+ } catch {
49
+ // best-effort temp cleanup
50
+ }
51
+ throw err;
52
+ }
53
+ }
54
+
33
55
  function readJsonState(projectRoot, fileName, validate) {
34
56
  const path = cachePath(projectRoot, fileName);
35
- if (!existsSync(path)) return null;
57
+ if (!fs.existsSync(path)) return null;
36
58
  try {
37
- const parsed = JSON.parse(readFileSync(path, "utf8"));
59
+ const parsed = JSON.parse(fs.readFileSync(path, "utf8"));
38
60
  if (typeof validate === "function" && !validate(parsed)) return null;
39
61
  return parsed;
40
62
  } catch {
@@ -43,10 +65,8 @@ function readJsonState(projectRoot, fileName, validate) {
43
65
  }
44
66
 
45
67
  function writeJsonState(projectRoot, fileName, value) {
46
- const path = cachePath(projectRoot, fileName);
47
68
  try {
48
- mkdirSync(dirname(path), { recursive: true });
49
- writeFileSync(path, JSON.stringify(value));
69
+ atomicWrite(cachePath(projectRoot, fileName), JSON.stringify(value));
50
70
  return true;
51
71
  } catch {
52
72
  return false;
@@ -55,19 +75,17 @@ function writeJsonState(projectRoot, fileName, value) {
55
75
 
56
76
  function readTextState(projectRoot, fileName) {
57
77
  const path = cachePath(projectRoot, fileName);
58
- if (!existsSync(path)) return null;
78
+ if (!fs.existsSync(path)) return null;
59
79
  try {
60
- return readFileSync(path, "utf8").trim();
80
+ return fs.readFileSync(path, "utf8").trim();
61
81
  } catch {
62
82
  return null;
63
83
  }
64
84
  }
65
85
 
66
86
  function writeTextState(projectRoot, fileName, text) {
67
- const path = cachePath(projectRoot, fileName);
68
87
  try {
69
- mkdirSync(dirname(path), { recursive: true });
70
- writeFileSync(path, String(text));
88
+ atomicWrite(cachePath(projectRoot, fileName), String(text));
71
89
  return true;
72
90
  } catch {
73
91
  return false;
@@ -80,5 +98,6 @@ module.exports = {
80
98
  writeJsonState,
81
99
  readTextState,
82
100
  writeTextState,
101
+ atomicWrite,
83
102
  CACHE_DIR_REL,
84
103
  };