@fenglimg/fabric-cli 2.0.0-rc.30 → 2.0.0-rc.34

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 (37) hide show
  1. package/dist/{chunk-PNRWNUFX.js → chunk-5N3KXIVI.js} +73 -4
  2. package/dist/{doctor-TTDTKOFJ.js → doctor-E26YO67D.js} +8 -2
  3. package/dist/index.js +4 -4
  4. package/dist/{install-OEBNSCS5.js → install-XCRX34CX.js} +4 -2
  5. package/dist/{uninstall-VLLJG7JT.js → uninstall-Q7V55BXH.js} +1 -1
  6. package/package.json +3 -3
  7. package/templates/hooks/cite-policy-evict.cjs +242 -0
  8. package/templates/hooks/configs/claude-code.json +11 -0
  9. package/templates/hooks/fabric-hint.cjs +11 -1
  10. package/templates/hooks/knowledge-hint-broad.cjs +276 -21
  11. package/templates/hooks/knowledge-hint-narrow.cjs +466 -14
  12. package/templates/skills/fabric-archive/SKILL.md +53 -864
  13. package/templates/skills/fabric-archive/ref/dry-run-scope.md +16 -0
  14. package/templates/skills/fabric-archive/ref/e5-cron-recap.md +5 -5
  15. package/templates/skills/fabric-archive/ref/i18n-policy.md +3 -3
  16. package/templates/skills/fabric-archive/ref/phase-0-range-resolution.md +156 -0
  17. package/templates/skills/fabric-archive/ref/{phase-0-4-onboard.md → phase-1-5-onboard.md} +21 -21
  18. package/templates/skills/fabric-archive/ref/phase-1-cross-session.md +60 -0
  19. package/templates/skills/fabric-archive/ref/phase-2-5-viability.md +54 -0
  20. package/templates/skills/fabric-archive/ref/phase-3-5-scope.md +80 -0
  21. package/templates/skills/fabric-archive/ref/phase-3-classify.md +63 -0
  22. package/templates/skills/fabric-archive/ref/phase-4-5-emit.md +78 -0
  23. package/templates/skills/fabric-archive/ref/phase-4-mcp-persist.md +89 -0
  24. package/templates/skills/fabric-archive/ref/rc-history.md +6 -6
  25. package/templates/skills/fabric-archive/ref/worked-examples.md +1 -1
  26. package/templates/skills/fabric-import/SKILL.md +29 -556
  27. package/templates/skills/fabric-import/ref/checkpoint-state.md +85 -0
  28. package/templates/skills/fabric-import/ref/output-contract.md +61 -0
  29. package/templates/skills/fabric-import/ref/phase-2-mining.md +213 -0
  30. package/templates/skills/fabric-import/ref/phase-3-dedup.md +75 -0
  31. package/templates/skills/fabric-import/ref/worked-examples.md +127 -0
  32. package/templates/skills/fabric-review/SKILL.md +56 -414
  33. package/templates/skills/fabric-review/ref/askuserquestion-policy.md +66 -0
  34. package/templates/skills/fabric-review/ref/modify-flow.md +95 -0
  35. package/templates/skills/fabric-review/ref/output-contract.md +58 -0
  36. package/templates/skills/fabric-review/ref/per-mode-flows.md +155 -0
  37. package/templates/skills/fabric-review/ref/semantic-check.md +26 -0
@@ -57,6 +57,7 @@ var SKILL_IMPORT_TEMPLATE_REL = "skills/fabric-import/SKILL.md";
57
57
  var HOOK_SCRIPT_TEMPLATE_REL = "hooks/fabric-hint.cjs";
58
58
  var HOOK_BROAD_SCRIPT_TEMPLATE_REL = "hooks/knowledge-hint-broad.cjs";
59
59
  var HOOK_NARROW_SCRIPT_TEMPLATE_REL = "hooks/knowledge-hint-narrow.cjs";
60
+ var HOOK_CITE_EVICT_SCRIPT_TEMPLATE_REL = "hooks/cite-policy-evict.cjs";
60
61
  var HOOK_LIB_TEMPLATE_DIR_REL = "hooks/lib";
61
62
  var CLAUDE_HOOK_CONFIG_TEMPLATE_REL = "hooks/configs/claude-code.json";
62
63
  var CODEX_HOOK_CONFIG_TEMPLATE_REL = "hooks/configs/codex-hooks.json";
@@ -90,7 +91,12 @@ var HOOK_SCRIPT_DESTINATIONS = {
90
91
  ".claude/hooks/knowledge-hint-narrow.cjs",
91
92
  ".codex/hooks/knowledge-hint-narrow.cjs",
92
93
  ".cursor/hooks/knowledge-hint-narrow.cjs"
93
- ]
94
+ ],
95
+ // v2.0.0-rc.34 TASK-06: Claude Code only — UserPromptSubmit cite-policy
96
+ // long-session evict sidecar. Codex / Cursor don't have an equivalent
97
+ // event registration; cite-coverage telemetry there relies on the existing
98
+ // Stop / SessionStart hooks (knowledge-hint-broad rc.33 W2 channel).
99
+ citePolicyEvict: [".claude/hooks/cite-policy-evict.cjs"]
94
100
  };
95
101
  var HOOK_LIB_DESTINATIONS = [
96
102
  ".claude/hooks/lib",
@@ -141,32 +147,78 @@ function readFabricLanguagePreference(projectRoot) {
141
147
  return "match-existing";
142
148
  }
143
149
  }
150
+ var SKILL_TOKEN_ERROR_TOKENS = 1e4;
151
+ var STALE_INSTALL_RATIO = 1.5;
152
+ function estimateSkillTokens(text) {
153
+ return Math.ceil(text.length / 3);
154
+ }
155
+ function validateSkillCanonicalSize(source, slug) {
156
+ const tokens = estimateSkillTokens(source);
157
+ if (tokens > SKILL_TOKEN_ERROR_TOKENS) {
158
+ throw new Error(
159
+ `Skill '${slug}' canonical SKILL.md estimates ${tokens} tok (>${SKILL_TOKEN_ERROR_TOKENS} ERROR threshold). Install aborted \u2014 this is a Fabric release bug, not a user-recoverable state. Re-split SKILL.md via progressive disclosure (see fabric-archive/phases/* as canonical example) and rebuild.`
160
+ );
161
+ }
162
+ }
163
+ function inspectStaleInstall(target, source) {
164
+ if (!existsSync2(target)) return null;
165
+ let existing;
166
+ try {
167
+ existing = readFileSync2(target, "utf8");
168
+ } catch {
169
+ return null;
170
+ }
171
+ const existingTok = estimateSkillTokens(existing);
172
+ const sourceTok = estimateSkillTokens(source);
173
+ if (existingTok > sourceTok * STALE_INSTALL_RATIO) {
174
+ return `stale-replaced (${existingTok} tok \u2192 ${sourceTok} tok canonical)`;
175
+ }
176
+ return null;
177
+ }
144
178
  async function installFabricArchiveSkill(projectRoot, _options = {}) {
145
179
  const source = await readTemplate(SKILL_TEMPLATE_REL);
180
+ validateSkillCanonicalSize(source, "fabric-archive");
146
181
  const targets = SKILL_DESTINATIONS.fabricArchive.map((rel) => join2(projectRoot, rel));
147
182
  const results = [];
148
183
  for (const target of targets) {
149
- results.push(await copyTextIdempotent("skill", source, target));
184
+ const staleMsg = inspectStaleInstall(target, source);
185
+ const result = await copyTextIdempotent("skill", source, target);
186
+ if (staleMsg && result.status === "written") {
187
+ result.message = result.message ? `${staleMsg}; ${result.message}` : staleMsg;
188
+ }
189
+ results.push(result);
150
190
  }
151
191
  results.push(...await installSkillRefFiles(projectRoot, "fabric-archive"));
152
192
  return results;
153
193
  }
154
194
  async function installFabricReviewSkill(projectRoot, _options = {}) {
155
195
  const source = await readTemplate(SKILL_REVIEW_TEMPLATE_REL);
196
+ validateSkillCanonicalSize(source, "fabric-review");
156
197
  const targets = SKILL_DESTINATIONS.fabricReview.map((rel) => join2(projectRoot, rel));
157
198
  const results = [];
158
199
  for (const target of targets) {
159
- results.push(await copyTextIdempotent("skill-review", source, target));
200
+ const staleMsg = inspectStaleInstall(target, source);
201
+ const result = await copyTextIdempotent("skill-review", source, target);
202
+ if (staleMsg && result.status === "written") {
203
+ result.message = result.message ? `${staleMsg}; ${result.message}` : staleMsg;
204
+ }
205
+ results.push(result);
160
206
  }
161
207
  results.push(...await installSkillRefFiles(projectRoot, "fabric-review"));
162
208
  return results;
163
209
  }
164
210
  async function installFabricImportSkill(projectRoot, _options = {}) {
165
211
  const source = await readTemplate(SKILL_IMPORT_TEMPLATE_REL);
212
+ validateSkillCanonicalSize(source, "fabric-import");
166
213
  const targets = SKILL_DESTINATIONS.fabricImport.map((rel) => join2(projectRoot, rel));
167
214
  const results = [];
168
215
  for (const target of targets) {
169
- results.push(await copyTextIdempotent("skill-import", source, target));
216
+ const staleMsg = inspectStaleInstall(target, source);
217
+ const result = await copyTextIdempotent("skill-import", source, target);
218
+ if (staleMsg && result.status === "written") {
219
+ result.message = result.message ? `${staleMsg}; ${result.message}` : staleMsg;
220
+ }
221
+ results.push(result);
170
222
  }
171
223
  results.push(...await installSkillRefFiles(projectRoot, "fabric-import"));
172
224
  return results;
@@ -279,6 +331,22 @@ async function installKnowledgeHintNarrowHook(projectRoot, _options = {}) {
279
331
  }
280
332
  return results;
281
333
  }
334
+ async function installCitePolicyEvictHook(projectRoot, _options = {}) {
335
+ const source = await readTemplate(HOOK_CITE_EVICT_SCRIPT_TEMPLATE_REL);
336
+ const targets = HOOK_SCRIPT_DESTINATIONS.citePolicyEvict.map((rel) => join2(projectRoot, rel));
337
+ const results = [];
338
+ for (const target of targets) {
339
+ const result = await copyTextIdempotent("hook-cite-evict-script", source, target);
340
+ if (result.status === "written" && process.platform !== "win32") {
341
+ try {
342
+ chmodSync(target, 493);
343
+ } catch {
344
+ }
345
+ }
346
+ results.push(result);
347
+ }
348
+ return results;
349
+ }
282
350
  async function installHookLibs(projectRoot, _options = {}) {
283
351
  const libTemplateDir = findTemplatePath(HOOK_LIB_TEMPLATE_DIR_REL);
284
352
  let libFiles;
@@ -725,6 +793,7 @@ export {
725
793
  installArchiveHintHook,
726
794
  installKnowledgeHintBroadHook,
727
795
  installKnowledgeHintNarrowHook,
796
+ installCitePolicyEvictHook,
728
797
  installHookLibs,
729
798
  mergeClaudeCodeHookConfig,
730
799
  mergeCodexHookConfig,
@@ -261,8 +261,12 @@ var doctorCommand = defineCommand({
261
261
  fixKnowledgeReport = await runDoctorFixKnowledge(resolution.target);
262
262
  report = fixKnowledgeReport.report;
263
263
  } else if (fix) {
264
- fixReport = await runDoctorFix(resolution.target);
265
- report = fixReport.report;
264
+ if (args["dry-run"] === true) {
265
+ report = await runDoctorReport(resolution.target);
266
+ } else {
267
+ fixReport = await runDoctorFix(resolution.target);
268
+ report = fixReport.report;
269
+ }
266
270
  } else {
267
271
  report = await runDoctorReport(resolution.target);
268
272
  }
@@ -277,6 +281,8 @@ var doctorCommand = defineCommand({
277
281
  renderFixKnowledgeMutations(fixKnowledgeReport, dt);
278
282
  } else if (fixReport !== null) {
279
283
  writeStdout(fixReport.message);
284
+ } else if (fix && args["dry-run"] === true) {
285
+ writeStdout(dt("cli.doctor.fix-dry-run-banner"));
280
286
  }
281
287
  renderHumanReport(report, dt);
282
288
  }
package/dist/index.js CHANGED
@@ -11,10 +11,10 @@ import { defineCommand, runMain } from "citty";
11
11
 
12
12
  // src/commands/index.ts
13
13
  var allCommands = {
14
- install: () => import("./install-OEBNSCS5.js").then((module) => module.default),
15
- doctor: () => import("./doctor-TTDTKOFJ.js").then((module) => module.default),
14
+ install: () => import("./install-XCRX34CX.js").then((module) => module.default),
15
+ doctor: () => import("./doctor-E26YO67D.js").then((module) => module.default),
16
16
  serve: () => import("./serve-43JTEM3U.js").then((module) => module.default),
17
- uninstall: () => import("./uninstall-VLLJG7JT.js").then((module) => module.default),
17
+ uninstall: () => import("./uninstall-Q7V55BXH.js").then((module) => module.default),
18
18
  config: () => import("./config-5CH4EJQ2.js").then((module) => module.default),
19
19
  "plan-context-hint": () => import("./plan-context-hint-CXTLNVSV.js").then((module) => module.default),
20
20
  // v2.0.0-rc.23 TASK-014 (F8c): S5 onboard-slot coverage. Used by the
@@ -26,7 +26,7 @@ var allCommands = {
26
26
  var main = defineCommand({
27
27
  meta: {
28
28
  name: "fabric",
29
- version: "2.0.0-rc.30",
29
+ version: "2.0.0-rc.34",
30
30
  description: t("cli.main.description")
31
31
  },
32
32
  subCommands: allCommands
@@ -4,6 +4,7 @@ import {
4
4
  } from "./chunk-SRX7WZUG.js";
5
5
  import {
6
6
  installArchiveHintHook,
7
+ installCitePolicyEvictHook,
7
8
  installFabricArchiveSkill,
8
9
  installFabricImportSkill,
9
10
  installFabricReviewSkill,
@@ -18,7 +19,7 @@ import {
18
19
  writeCodexBootstrapManagedBlock,
19
20
  writeCursorBootstrapManagedBlock,
20
21
  writeFabricAgentsSnapshot
21
- } from "./chunk-PNRWNUFX.js";
22
+ } from "./chunk-5N3KXIVI.js";
22
23
  import {
23
24
  detectClientSupports
24
25
  } from "./chunk-MF3OTILQ.js";
@@ -62,6 +63,7 @@ async function installHooks(target, _options = {}) {
62
63
  results.push(...await runStep(() => installArchiveHintHook(normalizedTarget)));
63
64
  results.push(...await runStep(() => installKnowledgeHintBroadHook(normalizedTarget)));
64
65
  results.push(...await runStep(() => installKnowledgeHintNarrowHook(normalizedTarget)));
66
+ results.push(...await runStep(() => installCitePolicyEvictHook(normalizedTarget)));
65
67
  results.push(...await runStep(() => installHookLibs(normalizedTarget)));
66
68
  results.push(await runSingleStep("claude-hook-config", () => mergeClaudeCodeHookConfig(normalizedTarget)));
67
69
  results.push(await runSingleStep("codex-hook-config", () => mergeCodexHookConfig(normalizedTarget)));
@@ -1348,7 +1350,7 @@ function readProjectName(target) {
1348
1350
  return basename(target);
1349
1351
  }
1350
1352
  function getCliVersion() {
1351
- return true ? "2.0.0-rc.30" : "unknown";
1353
+ return true ? "2.0.0-rc.34" : "unknown";
1352
1354
  }
1353
1355
  function sortRecord(record) {
1354
1356
  return Object.fromEntries(Object.entries(record).sort(([left], [right]) => left.localeCompare(right)));
@@ -7,7 +7,7 @@ import {
7
7
  HOOK_SCRIPT_DESTINATIONS,
8
8
  SKILL_DESTINATIONS,
9
9
  fabricAgentsSnapshotPath
10
- } from "./chunk-PNRWNUFX.js";
10
+ } from "./chunk-5N3KXIVI.js";
11
11
  import {
12
12
  detectClientSupports,
13
13
  resolveClients
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fenglimg/fabric-cli",
3
- "version": "2.0.0-rc.30",
3
+ "version": "2.0.0-rc.34",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "fab": "dist/index.js",
@@ -20,8 +20,8 @@
20
20
  "tree-sitter-javascript": "^0.25.0",
21
21
  "tree-sitter-typescript": "^0.23.2",
22
22
  "web-tree-sitter": "^0.26.8",
23
- "@fenglimg/fabric-server": "2.0.0-rc.30",
24
- "@fenglimg/fabric-shared": "2.0.0-rc.30"
23
+ "@fenglimg/fabric-server": "2.0.0-rc.34",
24
+ "@fenglimg/fabric-shared": "2.0.0-rc.34"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^22.15.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: fab 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