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

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 (30) hide show
  1. package/README.md +1 -1
  2. package/dist/{chunk-SRX7WZUG.js → chunk-BATF4PEJ.js} +2 -2
  3. package/dist/{chunk-PNRWNUFX.js → chunk-XVS4F3P6.js} +105 -4
  4. package/dist/{config-5CH4EJQ2.js → config-XJIPZNUP.js} +1 -1
  5. package/dist/{doctor-E26YO67D.js → doctor-2FCRAWDZ.js} +23 -8
  6. package/dist/index.js +7 -7
  7. package/dist/{install-YSFVNY3T.js → install-HOTE5BPA.js} +61 -4
  8. package/dist/{onboard-coverage-6MN3CYHT.js → onboard-coverage-MFCAEBDO.js} +4 -4
  9. package/dist/{plan-context-hint-CXTLNVSV.js → plan-context-hint-UQLRKGBZ.js} +2 -2
  10. package/dist/{uninstall-VLLJG7JT.js → uninstall-BIJ5GLEU.js} +1 -1
  11. package/package.json +3 -4
  12. package/templates/hooks/cite-policy-evict.cjs +242 -0
  13. package/templates/hooks/configs/claude-code.json +11 -0
  14. package/templates/hooks/fabric-hint.cjs +11 -1
  15. package/templates/hooks/knowledge-hint-broad.cjs +34 -6
  16. package/templates/hooks/knowledge-hint-narrow.cjs +106 -1
  17. package/templates/hooks/lib/summary-fallback.cjs +210 -0
  18. package/templates/skills/fabric-archive/SKILL.md +38 -255
  19. package/templates/skills/fabric-archive/ref/dry-run-scope.md +16 -0
  20. package/templates/skills/fabric-archive/ref/e5-cron-recap.md +1 -1
  21. package/templates/skills/fabric-archive/ref/i18n-policy.md +1 -1
  22. package/templates/skills/fabric-archive/ref/phase-1-5-onboard.md +10 -10
  23. package/templates/skills/fabric-import/ref/i18n-policy.md +1 -1
  24. package/templates/skills/fabric-review/SKILL.md +55 -413
  25. package/templates/skills/fabric-review/ref/askuserquestion-policy.md +66 -0
  26. package/templates/skills/fabric-review/ref/i18n-policy.md +1 -1
  27. package/templates/skills/fabric-review/ref/modify-flow.md +95 -0
  28. package/templates/skills/fabric-review/ref/output-contract.md +58 -0
  29. package/templates/skills/fabric-review/ref/per-mode-flows.md +155 -0
  30. package/templates/skills/fabric-review/ref/semantic-check.md +26 -0
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * v2.0.0-rc.34 TASK-06 — cite-policy long-session evict sidecar.
4
+ *
5
+ * UserPromptSubmit hook (Claude Code only). Drives periodic cite-policy
6
+ * reminder injection in long sessions where attention decay erodes contract
7
+ * adherence (rc.32 Batch 1: 3.1% cite coverage baseline).
8
+ *
9
+ * Strategy: **turn-count window** (locked decision per rc.34 plan 2026-05-26;
10
+ * time-based and token-budget strategies pushed to rc.35). The hook maintains
11
+ * a per-session counter in `.fabric/.cache/cite-evict-state.json`; on each
12
+ * UserPromptSubmit, increment the counter and — when
13
+ * `turn_count % cite_evict_interval == 0` AND `cite_evict_interval > 0` —
14
+ * emit a compact cite-contract reminder via Claude Code's stdout JSON
15
+ * envelope (hookSpecificOutput.additionalContext, same channel as rc.33 W2
16
+ * knowledge-hint-broad reminder-to-context).
17
+ *
18
+ * Config: `cite_evict_interval` (number, default 0 = OFF, opt-in). Recommend
19
+ * 10-20 for active sessions; 5 for high-contract-criticality projects.
20
+ *
21
+ * State sidecar shape:
22
+ * { session_id: string, turn_count: number }
23
+ *
24
+ * Session-boundary semantics: when incoming `session_id` (read from stdin
25
+ * payload) differs from sidecar's `session_id`, the counter resets to 1 (new
26
+ * session always starts at 1, never 0 — first turn is "turn 1" not "turn 0").
27
+ *
28
+ * Failure invariant: any error path (sidecar I/O failure, stdin parse error,
29
+ * config read failure) MUST end in silent exit 0. The hook never blocks user
30
+ * prompt submission on its own malfunction.
31
+ *
32
+ * Cross-client scope: Claude Code only (relies on hookSpecificOutput contract
33
+ * + UserPromptSubmit event registration). Codex CLI and Cursor don't have an
34
+ * equivalent event hook; cite-coverage telemetry there relies on Stop-hook
35
+ * fabric-hint and SessionStart knowledge-hint-broad (rc.33 W2 channel).
36
+ */
37
+
38
+ const { existsSync, mkdirSync, readFileSync, writeFileSync } = require("node:fs");
39
+ const { dirname, join } = require("node:path");
40
+
41
+ const FABRIC_DIR_REL = ".fabric";
42
+ const FABRIC_CONFIG_FILE = "fabric-config.json";
43
+ const EVICT_STATE_FILE = join(".fabric", ".cache", "cite-evict-state.json");
44
+
45
+ // Default OFF (opt-in). Mirrors hint_broad_cooldown_hours and
46
+ // archive_hint_cooldown_hours convention of "feature exists but inert until
47
+ // user enables it." Schema in packages/shared/src/schemas/fabric-config.ts
48
+ // caps at sensible bounds (positive int).
49
+ const DEFAULT_CITE_EVICT_INTERVAL = 0;
50
+
51
+ /**
52
+ * Read .fabric/fabric-config.json#cite_evict_interval. Returns the parsed
53
+ * positive integer OR DEFAULT_CITE_EVICT_INTERVAL on any failure path
54
+ * (missing file, parse error, non-numeric value, negative). Mirrors the
55
+ * defensive config-read pattern in knowledge-hint-broad.cjs readBroadCooldownHours.
56
+ */
57
+ function readEvictInterval(cwd) {
58
+ const configPath = join(cwd, FABRIC_DIR_REL, FABRIC_CONFIG_FILE);
59
+ if (!existsSync(configPath)) return DEFAULT_CITE_EVICT_INTERVAL;
60
+ try {
61
+ const parsed = JSON.parse(readFileSync(configPath, "utf8"));
62
+ const v = parsed && parsed.cite_evict_interval;
63
+ if (typeof v === "number" && Number.isInteger(v) && v >= 0) {
64
+ return v;
65
+ }
66
+ } catch {
67
+ // ignore — defensive default
68
+ }
69
+ return DEFAULT_CITE_EVICT_INTERVAL;
70
+ }
71
+
72
+ /**
73
+ * Read prior state sidecar. Returns `null` on first-run or any failure;
74
+ * callers treat null as "no prior state" (caller will write fresh state
75
+ * with turn_count=1).
76
+ */
77
+ function readEvictState(cwd) {
78
+ const path = join(cwd, EVICT_STATE_FILE);
79
+ if (!existsSync(path)) return null;
80
+ try {
81
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
82
+ if (
83
+ parsed &&
84
+ typeof parsed.session_id === "string" &&
85
+ typeof parsed.turn_count === "number" &&
86
+ Number.isInteger(parsed.turn_count) &&
87
+ parsed.turn_count >= 0
88
+ ) {
89
+ return parsed;
90
+ }
91
+ } catch {
92
+ // ignore — corrupted sidecar is treated as no prior state
93
+ }
94
+ return null;
95
+ }
96
+
97
+ function writeEvictState(cwd, sessionId, turnCount) {
98
+ const path = join(cwd, EVICT_STATE_FILE);
99
+ try {
100
+ mkdirSync(dirname(path), { recursive: true });
101
+ writeFileSync(path, JSON.stringify({ session_id: sessionId, turn_count: turnCount }));
102
+ } catch {
103
+ // best-effort — counter loss is acceptable, hook never blocks
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Pure helper for unit-testing. Given current `turnCount` (post-increment)
109
+ * and `interval`, decide whether to emit the reminder.
110
+ *
111
+ * Contract:
112
+ * - interval <= 0 → never emit (feature off)
113
+ * - turnCount <= 0 → never emit (guard against bogus state)
114
+ * - emit iff turnCount % interval === 0
115
+ *
116
+ * Examples:
117
+ * evaluateCiteEvict(10, 10) → true (10 % 10 === 0)
118
+ * evaluateCiteEvict(20, 10) → true
119
+ * evaluateCiteEvict(15, 10) → false
120
+ * evaluateCiteEvict(5, 0) → false (off)
121
+ * evaluateCiteEvict(0, 10) → false (no turns yet)
122
+ */
123
+ function evaluateCiteEvict(turnCount, interval) {
124
+ if (typeof interval !== "number" || interval <= 0) return false;
125
+ if (typeof turnCount !== "number" || turnCount <= 0) return false;
126
+ return turnCount % interval === 0;
127
+ }
128
+
129
+ /**
130
+ * Build the cite-contract reminder body. Compact — under 10 lines. The
131
+ * fully-specified contract lives in `.fabric/AGENTS.md` Cite policy section;
132
+ * the reminder is a tactical re-anchor, not the canonical reference.
133
+ *
134
+ * Returns a multi-line string ready for hookSpecificOutput.additionalContext.
135
+ */
136
+ function renderReminder(turnCount, interval) {
137
+ return [
138
+ `[fabric cite-evict] long-session reminder (turn ${turnCount}, interval ${interval}):`,
139
+ "Before edit / decide / propose plan, write KB: <id> (<≤8字 用法>) [planned|recalled|chained-from <id>|dismissed:<reason>] OR KB: none [<reason>].",
140
+ "Verify [recalled] via fab_plan_context → fab_get_knowledge_sections two-step (no fabricated ids).",
141
+ "decisions/pitfalls cite MUST end with contract: → <operator> [<operator>...] where operator ∈ {edit:<glob> !edit:<glob> require:<symbol> forbid:<symbol> skip:<reason>}.",
142
+ "skip reasons: sequencing | conditional | semantic | aesthetic | architectural | other:<text>.",
143
+ "KB: none sentinels: [no-relevant] (queried but nothing matched) | [not-applicable] (pure exploration / read-only / user Q&A).",
144
+ "Audit: fabric doctor --cite-coverage — this rule does not block work, only records.",
145
+ ].join("\n");
146
+ }
147
+
148
+ /**
149
+ * Detect Claude Code via CLAUDE_PROJECT_DIR env. Same single-bit signal used
150
+ * by knowledge-hint-broad.cjs rc.33 W4 review-fix (Gemini High-1). Codex /
151
+ * Cursor don't set this var.
152
+ */
153
+ function isClaudeCode() {
154
+ return (
155
+ typeof process.env.CLAUDE_PROJECT_DIR === "string" &&
156
+ process.env.CLAUDE_PROJECT_DIR.length > 0
157
+ );
158
+ }
159
+
160
+ async function readStdinJson() {
161
+ return new Promise((resolve) => {
162
+ let buffer = "";
163
+ process.stdin.on("data", (chunk) => {
164
+ buffer += chunk;
165
+ });
166
+ process.stdin.on("end", () => {
167
+ try {
168
+ resolve(JSON.parse(buffer));
169
+ } catch {
170
+ resolve(null);
171
+ }
172
+ });
173
+ process.stdin.on("error", () => resolve(null));
174
+ // Defensive timeout: if stdin never closes (host bug), give up after 1s.
175
+ setTimeout(() => resolve(null), 1000).unref();
176
+ });
177
+ }
178
+
179
+ async function main(env) {
180
+ try {
181
+ const cwd =
182
+ (env && typeof env.cwd === "string" && env.cwd) ||
183
+ process.env.CLAUDE_PROJECT_DIR ||
184
+ process.cwd();
185
+
186
+ const interval = readEvictInterval(cwd);
187
+ if (interval <= 0) {
188
+ return; // feature off — silent exit
189
+ }
190
+
191
+ // Skip Claude Code-specific stdout envelope on Codex/Cursor. Counter
192
+ // bookkeeping also skipped — there's no fire path on those clients.
193
+ if (!isClaudeCode() && !(env && env.forceClaudeCode === true)) {
194
+ return;
195
+ }
196
+
197
+ // Read stdin payload to learn session_id. Tests inject env.payload to
198
+ // bypass the stdin read; production reads JSON envelope from stdin.
199
+ const payload = env && env.payload !== undefined ? env.payload : await readStdinJson();
200
+ const sessionId =
201
+ payload && typeof payload.session_id === "string" && payload.session_id.length > 0
202
+ ? payload.session_id
203
+ : "anonymous";
204
+
205
+ const prior = readEvictState(cwd);
206
+ const turnCount = prior && prior.session_id === sessionId ? prior.turn_count + 1 : 1;
207
+ writeEvictState(cwd, sessionId, turnCount);
208
+
209
+ if (!evaluateCiteEvict(turnCount, interval)) {
210
+ return; // not on a window boundary — silent
211
+ }
212
+
213
+ const reminder = renderReminder(turnCount, interval);
214
+ const out = (env && env.stdio && env.stdio.stdout) || process.stdout;
215
+ try {
216
+ const envelope = {
217
+ hookSpecificOutput: {
218
+ hookEventName: "UserPromptSubmit",
219
+ additionalContext: reminder,
220
+ },
221
+ };
222
+ out.write(`${JSON.stringify(envelope)}\n`);
223
+ } catch {
224
+ // best-effort
225
+ }
226
+ } catch {
227
+ // Silent — never block user prompt on hook failure.
228
+ }
229
+ }
230
+
231
+ module.exports = {
232
+ main,
233
+ evaluateCiteEvict,
234
+ renderReminder,
235
+ readEvictInterval,
236
+ readEvictState,
237
+ writeEvictState,
238
+ };
239
+
240
+ if (require.main === module) {
241
+ main();
242
+ }
@@ -32,6 +32,17 @@
32
32
  }
33
33
  ]
34
34
  }
35
+ ],
36
+ "UserPromptSubmit": [
37
+ {
38
+ "matcher": "*",
39
+ "hooks": [
40
+ {
41
+ "type": "command",
42
+ "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/cite-policy-evict.cjs"
43
+ }
44
+ ]
45
+ }
35
46
  ]
36
47
  }
37
48
  }
@@ -1018,9 +1018,13 @@ function evaluateMaintenanceSignal(events, now, canonicalCount, lastEmitMs, thre
1018
1018
  }
1019
1019
 
1020
1020
  // Cooldown gate — short-circuit when we just nagged.
1021
+ // rc.34 TASK-01 + review-fix (Gemini P1): future-stamped lastEmit (backward
1022
+ // clock skew) bypasses cooldown — treats sidecar as "expired" so the gate
1023
+ // heals on the next invocation instead of waiting (cooldown + |skew|).
1021
1024
  if (
1022
1025
  typeof lastEmitMs === "number" &&
1023
1026
  Number.isFinite(lastEmitMs) &&
1027
+ nowMs >= lastEmitMs &&
1024
1028
  nowMs - lastEmitMs < cooldownDays * MS_PER_DAY
1025
1029
  ) {
1026
1030
  return null;
@@ -1733,7 +1737,13 @@ function main(env, stdio) {
1733
1737
  const cooldownMs = readCooldownHours(cwd) * MS_PER_HOUR;
1734
1738
  const cache = readShownCache(cwd);
1735
1739
  const lastShown = cache[result.signal];
1736
- if (typeof lastShown === "number" && nowMs - lastShown < cooldownMs) {
1740
+ // rc.34 TASK-01 + review-fix (Gemini P1): future-stamped lastShown
1741
+ // (backward clock skew) bypasses cooldown — sidecar treated as expired.
1742
+ if (
1743
+ typeof lastShown === "number" &&
1744
+ nowMs >= lastShown &&
1745
+ nowMs - lastShown < cooldownMs
1746
+ ) {
1737
1747
  return; // Still in cooldown — silent.
1738
1748
  }
1739
1749
 
@@ -61,6 +61,7 @@ const { dirname, join } = require("node:path");
61
61
  // (TASK-002). Variant is resolved ONCE per main() invocation via
62
62
  // readFabricLanguage(cwd) and threaded into renderBanner — no fs in render path.
63
63
  const { renderBanner, readFabricLanguage } = require("./lib/banner-i18n.cjs");
64
+ const { resolveOpaqueSummaries } = require("./lib/summary-fallback.cjs");
64
65
 
65
66
  // -----------------------------------------------------------------------------
66
67
  // rc.12: SessionStart broad-menu is now unconditionally emitted on every
@@ -415,12 +416,11 @@ const MATURITY_DRAFT = "draft";
415
416
  * Spawn `fabric plan-context-hint --all` and return parsed JSON. Returns
416
417
  * null on any failure (ENOENT, non-zero exit, malformed JSON). Never throws.
417
418
  *
418
- * spawn strategy: try `fabric` first (user-PATH install) then `fab` (the
419
- * alternate bin name shipped by @fenglimg/fabric-cli). If neither is on PATH,
420
- * return null — the hook stays silent rather than nagging about install state.
419
+ * If `fabric` is not on PATH, return null the hook stays silent rather
420
+ * than nagging about install state.
421
421
  */
422
422
  function invokePlanContextHint(cwd) {
423
- const candidates = ["fabric", "fab"];
423
+ const candidates = ["fabric"];
424
424
  // rc.31 NEW-6: capture the last meaningful failure so we can surface it on
425
425
  // stderr before fail-open. Without this, hook silently swallows backend
426
426
  // crashes (e.g. agents_meta_invalid → plan-context-hint exits with stderr
@@ -439,7 +439,7 @@ function invokePlanContextHint(cwd) {
439
439
  continue; // spawn throw (extremely rare) — try next candidate
440
440
  }
441
441
  // ENOENT surfaces as error on the result object. Skip silently for ENOENT
442
- // (bin not installed is expected for `fabric` when only `fab` is shipped).
442
+ // (bin not installed is the only legitimate reason to bail).
443
443
  if (res.error) {
444
444
  if (res.error.code !== "ENOENT") {
445
445
  lastFailure = { bin, reason: String(res.error.message || res.error.code || res.error) };
@@ -708,6 +708,14 @@ function main(env, stdio) {
708
708
  const lastEmitMs = readBroadLastEmit(cwd);
709
709
  if (
710
710
  typeof lastEmitMs === "number" &&
711
+ // rc.34 TASK-01 + review-fix (Gemini P1): when lastEmit is in the
712
+ // FUTURE relative to now (backward clock skew — NTP sync /
713
+ // suspend-wake / TZ change), the gate fires immediately. Otherwise
714
+ // standard cooldown check. Math.max(0, …) was a no-op (silent for
715
+ // cooldown + |skew| under both formulations); this guard actually
716
+ // heals the skew on the next invocation by treating future-stamped
717
+ // sidecar as "expired."
718
+ nowMs >= lastEmitMs &&
711
719
  nowMs - lastEmitMs < cooldownHours * MS_PER_HOUR
712
720
  ) {
713
721
  return; // still in cooldown — silent
@@ -730,6 +738,26 @@ function main(env, stdio) {
730
738
  ? { ...payload, entries: payload.entries.slice(0, topK) }
731
739
  : payload;
732
740
 
741
+ // rc.35 TASK-06 (P0-10.b): summary-fallback substitution. Entries whose
742
+ // description.summary equals stable_id render as "<id> · <id>" and the
743
+ // AI skips fetching them; the fallback reads `## Summary` from the
744
+ // entry's .md file and swaps in the first paragraph. Best-effort —
745
+ // failure leaves the original opaque summary untouched.
746
+ let resolvedPayload = slicedPayload;
747
+ try {
748
+ if (slicedPayload && Array.isArray(slicedPayload.entries)) {
749
+ const resolvedEntries = resolveOpaqueSummaries(
750
+ slicedPayload.entries,
751
+ cwd,
752
+ typeof slicedPayload.revision_hash === "string" ? slicedPayload.revision_hash : "",
753
+ );
754
+ resolvedPayload = { ...slicedPayload, entries: resolvedEntries };
755
+ }
756
+ } catch {
757
+ // resolveOpaqueSummaries swallows its own errors; this catch is belt
758
+ // + suspenders for any unexpected exception from the lib layer.
759
+ }
760
+
733
761
  // rc.8 underseed self-check: decide whether to surface the one-line
734
762
  // `/fabric-import` recommendation banner alongside the broad summary.
735
763
  const recommendImport = shouldRecommendImport(cwd);
@@ -741,7 +769,7 @@ function main(env, stdio) {
741
769
  // for the agent's working memory. rc.33 W2-5 reintroduces an opt-in
742
770
  // hours-based cooldown via fabric-config (see gate above).
743
771
  const summaryMaxLen = readSummaryMaxLen(cwd);
744
- const lines = renderSummary(slicedPayload, summaryMaxLen);
772
+ const lines = renderSummary(resolvedPayload, summaryMaxLen);
745
773
 
746
774
  if (recommendImport) {
747
775
  // rc.16 TASK-003: resolve fabric_language ONCE per invocation (only when
@@ -75,6 +75,12 @@ const {
75
75
  } = require("node:fs");
76
76
  const { dirname, join } = require("node:path");
77
77
 
78
+ // rc.35 TASK-06 (P0-10.b): summary-fallback. Substitutes opaque entries
79
+ // (where description.summary === stable_id) with a snippet read from the
80
+ // entry's .md `## Summary` section. Caches results in
81
+ // `.fabric/.cache/summary-fallback.json` keyed by revision_hash.
82
+ const { resolveOpaqueSummaries } = require("./lib/summary-fallback.cjs");
83
+
78
84
  // -----------------------------------------------------------------------------
79
85
  // CONSTANTS
80
86
  // -----------------------------------------------------------------------------
@@ -95,6 +101,14 @@ const DEFAULT_SUMMARY_MAX_LEN = 80;
95
101
  const EDIT_COUNTER_DIR_REL = join(".fabric", ".cache");
96
102
  const EDIT_COUNTER_FILE = "edit-counter";
97
103
 
104
+ // rc.35 TASK-07 (P0-2): events.jsonl path. PreToolUse Edit fires append a
105
+ // `edit_intent_checked` event (ledger_source: 'hook') so doctor cite-
106
+ // coverage's editsTouched metric sees actual edit signals. Without this
107
+ // signal the entire cite-policy contract validation is structurally inert
108
+ // (rc.30 audit P0-2: 18582 turns / 240 edits / 0 events).
109
+ const EVENTS_LEDGER_DIR_REL = ".fabric";
110
+ const EVENTS_LEDGER_FILE = "events.jsonl";
111
+
98
112
  // rc.6 TASK-023 (E6): hint-silence-counter sidecar — companion to the
99
113
  // edit-counter above. Where edit-counter records every PreToolUse fire
100
114
  // (numerator-agnostic), the silence-counter records only those fires that
@@ -317,6 +331,72 @@ function extractPaths(toolInput) {
317
331
  * array. The fire-count signal is preserved; the activity overview
318
332
  * just contributes nothing from those lines.
319
333
  */
334
+ /**
335
+ * rc.35 TASK-07 (P0-2): append one `edit_intent_checked` event per touched
336
+ * path to `.fabric/events.jsonl`. Carries `ledger_source: 'hook'` so doctor
337
+ * cite-coverage can distinguish hook-originated edit signals from
338
+ * AI/human-originated `appendLedgerEntry` calls.
339
+ *
340
+ * Best-effort:
341
+ * - Skips silently when `.fabric/` does not exist (project not init'd).
342
+ * - Skips silently when paths is empty (counter signal is preserved by
343
+ * the sibling appendEditCounter call; cite-coverage only cares about
344
+ * non-empty path events).
345
+ * - ANY error (mkdir, append, JSON throw) is swallowed — the hook must
346
+ * remain non-blocking per the rc.6 contract.
347
+ *
348
+ * Atomicity:
349
+ * - One JSON line per path. Append on small writes (< PIPE_BUF, ~4KB on
350
+ * POSIX) is atomic at the OS level, so concurrent PreToolUse fires
351
+ * from parallel sessions interleave cleanly without partial writes.
352
+ */
353
+ function appendEditIntentToLedger(projectRoot, now, paths, toolName) {
354
+ try {
355
+ const fabricDir = join(projectRoot, EVENTS_LEDGER_DIR_REL);
356
+ // No .fabric/ → project not initialised. Bail before any write.
357
+ if (!existsSync(fabricDir)) return;
358
+ const { isAbsolute: pathIsAbsolute, relative: pathRelative } = require("node:path");
359
+ const pathList = Array.isArray(paths)
360
+ ? paths
361
+ .filter((p) => typeof p === "string" && p.length > 0)
362
+ .map((p) => {
363
+ if (pathIsAbsolute(p)) {
364
+ const rel = pathRelative(projectRoot, p);
365
+ return rel.startsWith("..") ? null : rel;
366
+ }
367
+ // Already-relative paths: drop ones that escape the project tree.
368
+ return p.startsWith("..") ? null : p;
369
+ })
370
+ .filter((p) => typeof p === "string" && p.length > 0)
371
+ // Use forward slashes for cross-platform consistency on disk.
372
+ .map((p) => p.split(/[\\/]/).join("/"))
373
+ : [];
374
+ if (pathList.length === 0) return;
375
+ const tsMs = now instanceof Date ? now.getTime() : Number(now);
376
+ const ledgerEntryId = `hook:${randomUUID()}`;
377
+ const intent = typeof toolName === "string" && toolName.length > 0 ? toolName : "edit";
378
+ const lines = pathList
379
+ .map((p) => JSON.stringify({
380
+ kind: "fabric-event",
381
+ id: `event:${randomUUID()}`,
382
+ ts: tsMs,
383
+ schema_version: 1,
384
+ event_type: "edit_intent_checked",
385
+ path: p,
386
+ compliant: true,
387
+ intent,
388
+ ledger_entry_id: ledgerEntryId,
389
+ ledger_source: "hook",
390
+ matched_rule_context_ts: null,
391
+ window_ms: 0,
392
+ }))
393
+ .join("\n") + "\n";
394
+ appendFileSync(join(fabricDir, EVENTS_LEDGER_FILE), lines, "utf8");
395
+ } catch {
396
+ // Silent — events ledger failure must never block the edit.
397
+ }
398
+ }
399
+
320
400
  function appendEditCounter(projectRoot, now, paths) {
321
401
  try {
322
402
  const dir = join(projectRoot, EDIT_COUNTER_DIR_REL);
@@ -1079,6 +1159,11 @@ function main(env, stdio) {
1079
1159
  }
1080
1160
  if (!(env && env.skipCounter === true)) {
1081
1161
  appendEditCounter(cwd, now, paths);
1162
+ // rc.35 TASK-07 (P0-2): mirror the edit-counter sidecar into the
1163
+ // events.jsonl ledger so doctor cite-coverage's editsTouched metric
1164
+ // sees actual edit signals. Best-effort — failure is swallowed inside
1165
+ // appendEditIntentToLedger and does not block the hook.
1166
+ appendEditIntentToLedger(cwd, now, paths, toolName);
1082
1167
  }
1083
1168
 
1084
1169
  // E2 path is conditional on a recognized tool + extractable paths.
@@ -1237,7 +1322,21 @@ function main(env, stdio) {
1237
1322
  }
1238
1323
 
1239
1324
  const summaryMaxLen = readSummaryMaxLen(cwd);
1240
- const lines = renderSummary({ ...cliPayload, entries: dedupDecision.filtered }, summaryMaxLen);
1325
+ // rc.35 TASK-06 (P0-10.b): substitute opaque summaries before render.
1326
+ // Same lib used by the broad hook — opaque entries seen from both call
1327
+ // sites share a single .fabric/.cache/summary-fallback.json file.
1328
+ // Best-effort — any failure leaves the original opaque summary intact.
1329
+ let resolvedEntries = dedupDecision.filtered;
1330
+ try {
1331
+ resolvedEntries = resolveOpaqueSummaries(
1332
+ dedupDecision.filtered,
1333
+ cwd,
1334
+ currentRevisionHash,
1335
+ );
1336
+ } catch {
1337
+ // resolveOpaqueSummaries swallows its own errors; defensive catch.
1338
+ }
1339
+ const lines = renderSummary({ ...cliPayload, entries: resolvedEntries }, summaryMaxLen);
1241
1340
  if (lines.length === 0) return;
1242
1341
 
1243
1342
  // Stderr: human-facing breadcrumb + legacy contract.
@@ -1294,6 +1393,10 @@ module.exports = {
1294
1393
  renderSummary,
1295
1394
  truncateSummary,
1296
1395
  formatEntryLine,
1396
+ // rc.35 TASK-07 (P0-2): cite-infrastructure wire-up. Exported so the
1397
+ // integration test can drive the writer directly without standing up the
1398
+ // entire PreToolUse main() flow.
1399
+ appendEditIntentToLedger,
1297
1400
  // rc.6 TASK-021 (E3) — session-hints cache exports for tests / future
1298
1401
  // consumers (TASK-023 silence-counter telemetry will reuse the same
1299
1402
  // session-id resolution + cache shape).
@@ -1323,6 +1426,8 @@ module.exports = {
1323
1426
  EDIT_COUNTER_FILE,
1324
1427
  HINT_SILENCE_COUNTER_DIR_REL,
1325
1428
  HINT_SILENCE_COUNTER_FILE,
1429
+ EVENTS_LEDGER_DIR_REL,
1430
+ EVENTS_LEDGER_FILE,
1326
1431
  EDIT_TOOL_NAMES,
1327
1432
  SESSION_HINTS_DIR_REL,
1328
1433
  SESSION_HINTS_FILE_PREFIX,