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

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 (36) hide show
  1. package/LICENSE +21 -0
  2. package/dist/{chunk-XVS4F3P6.js → chunk-D25XJ4BC.js} +49 -5
  3. package/dist/{chunk-G2CIOLD4.js → chunk-WWNXR34K.js} +1 -16
  4. package/dist/{doctor-2FCRAWDZ.js → doctor-764NFF3X.js} +112 -16
  5. package/dist/index.js +7 -6
  6. package/dist/{install-HOTE5BPA.js → install-U7MGIJ2L.js} +50 -22
  7. package/dist/metrics-ACEQFPDU.js +122 -0
  8. package/dist/{uninstall-BIJ5GLEU.js → uninstall-MH7ZIB6M.js} +6 -18
  9. package/package.json +30 -4
  10. package/templates/hooks/cite-policy-evict.cjs +80 -91
  11. package/templates/hooks/configs/README.md +19 -0
  12. package/templates/hooks/configs/codex-hooks.json +3 -0
  13. package/templates/hooks/configs/cursor-hooks.json +2 -1
  14. package/templates/hooks/fabric-hint.cjs +146 -8
  15. package/templates/hooks/knowledge-hint-broad.cjs +65 -104
  16. package/templates/hooks/knowledge-hint-narrow.cjs +122 -5
  17. package/templates/hooks/lib/cite-line-parser.cjs +7 -1
  18. package/templates/hooks/lib/client-adapter.cjs +106 -0
  19. package/templates/hooks/lib/config-cache.cjs +107 -0
  20. package/templates/hooks/lib/state-store.cjs +84 -0
  21. package/templates/skills/fabric-archive/SKILL.md +29 -7
  22. package/templates/skills/fabric-archive/ref/dry-run-scope.md +1 -1
  23. package/templates/skills/fabric-archive/ref/i18n-policy.md +6 -0
  24. package/templates/skills/fabric-archive/ref/phase-1-5-onboard.md +1 -1
  25. package/templates/skills/fabric-archive/ref/phase-1-cross-session.md +2 -0
  26. package/templates/skills/fabric-archive/ref/phase-2-5-viability.md +25 -11
  27. package/templates/skills/fabric-archive/ref/phase-3-5-scope.md +43 -15
  28. package/templates/skills/fabric-import/SKILL.md +75 -163
  29. package/templates/skills/fabric-import/ref/i18n-policy.md +6 -0
  30. package/templates/skills/fabric-import/ref/phase-2-mining.md +2 -2
  31. package/templates/skills/fabric-review/SKILL.md +31 -25
  32. package/templates/skills/fabric-review/ref/i18n-policy.md +6 -0
  33. package/templates/skills/fabric-review/ref/modify-flow.md +9 -1
  34. package/templates/skills/fabric-review/ref/per-mode-flows.md +1 -1
  35. package/templates/skills/lib/shared-policy.md +69 -0
  36. package/dist/serve-43JTEM3U.js +0 -142
package/package.json CHANGED
@@ -1,6 +1,30 @@
1
1
  {
2
2
  "name": "@fenglimg/fabric-cli",
3
- "version": "2.0.0-rc.35",
3
+ "version": "2.0.0-rc.37",
4
+ "description": "Fabric CLI — installs the MCP server + skills + hooks for Claude Code, Cursor, and Codex CLI; runs doctor / knowledge maintenance.",
5
+ "license": "MIT",
6
+ "author": "wangzhichao <fenglimg90@gmail.com>",
7
+ "homepage": "https://github.com/fenglimg/fabric-v2#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/fenglimg/fabric-v2.git",
11
+ "directory": "packages/cli"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/fenglimg/fabric-v2/issues"
15
+ },
16
+ "keywords": [
17
+ "fabric",
18
+ "mcp",
19
+ "cli",
20
+ "claude-code",
21
+ "cursor",
22
+ "codex-cli",
23
+ "ai-knowledge-management"
24
+ ],
25
+ "engines": {
26
+ "node": ">=20.0.0"
27
+ },
4
28
  "type": "module",
5
29
  "bin": {
6
30
  "fabric": "dist/index.js"
@@ -9,7 +33,9 @@
9
33
  "types": "./dist/index.d.ts",
10
34
  "files": [
11
35
  "dist",
12
- "templates"
36
+ "templates",
37
+ "LICENSE",
38
+ "README.md"
13
39
  ],
14
40
  "dependencies": {
15
41
  "@clack/prompts": "^1.2.0",
@@ -19,8 +45,8 @@
19
45
  "tree-sitter-javascript": "^0.25.0",
20
46
  "tree-sitter-typescript": "^0.23.2",
21
47
  "web-tree-sitter": "^0.26.8",
22
- "@fenglimg/fabric-server": "2.0.0-rc.35",
23
- "@fenglimg/fabric-shared": "2.0.0-rc.35"
48
+ "@fenglimg/fabric-server": "2.0.0-rc.37",
49
+ "@fenglimg/fabric-shared": "2.0.0-rc.37"
24
50
  },
25
51
  "devDependencies": {
26
52
  "@types/node": "^22.15.0",
@@ -35,18 +35,30 @@
35
35
  * fabric-hint and SessionStart knowledge-hint-broad (rc.33 W2 channel).
36
36
  */
37
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");
38
+ // v2.0.0-rc.37 NEW-19: config + sidecar I/O now flow through shared libs so
39
+ // the read-config-or-default and read/write-sidecar boilerplate lives in one
40
+ // canonical place. Unguarded require mirrors knowledge-hint-broad's
41
+ // banner-i18n import — the installer copies every lib/*.cjs alongside the hook.
42
+ const { readConfigNumber } = require("./lib/config-cache.cjs");
43
+ const { readJsonState, writeJsonState } = require("./lib/state-store.cjs");
44
+ // v2.0.0-rc.37 NEW-30: client detect + stdin + channel-aware emit now flow
45
+ // through the shared adapter (Claude Code stdout envelope vs Codex/Cursor
46
+ // stderr). Replaces the local isClaudeCode + readStdinJson + inline emits.
47
+ const { isClaudeCode, readStdinJson, emitContext } = require("./lib/client-adapter.cjs");
48
+
49
+ // Sidecar basename resolved under .fabric/.cache/ by state-store.
50
+ const EVICT_STATE_FILE_NAME = "cite-evict-state.json";
44
51
 
45
52
  // Default OFF (opt-in). Mirrors hint_broad_cooldown_hours and
46
53
  // archive_hint_cooldown_hours convention of "feature exists but inert until
47
54
  // user enables it." Schema in packages/shared/src/schemas/fabric-config.ts
48
55
  // caps at sensible bounds (positive int).
49
- const DEFAULT_CITE_EVICT_INTERVAL = 0;
56
+ // v2.0.0-rc.37 NEW-18: default flipped 0 (opt-in OFF) → 10 (default ON every
57
+ // 10 turns) so users get cite-policy nudges out-of-the-box. Operators on
58
+ // short / scripted sessions can still set `cite_evict_interval: 0` in
59
+ // .fabric/fabric-config.json to opt back out. Per-NEW-1 reminder body now
60
+ // uses the simplified 2-state vocabulary ([applied] / [dismissed:<reason>]).
61
+ const DEFAULT_CITE_EVICT_INTERVAL = 10;
50
62
 
51
63
  /**
52
64
  * Read .fabric/fabric-config.json#cite_evict_interval. Returns the parsed
@@ -55,18 +67,10 @@ const DEFAULT_CITE_EVICT_INTERVAL = 0;
55
67
  * defensive config-read pattern in knowledge-hint-broad.cjs readBroadCooldownHours.
56
68
  */
57
69
  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
+ return readConfigNumber(cwd, "cite_evict_interval", DEFAULT_CITE_EVICT_INTERVAL, {
71
+ min: 0,
72
+ integer: true,
73
+ });
70
74
  }
71
75
 
72
76
  /**
@@ -75,33 +79,21 @@ function readEvictInterval(cwd) {
75
79
  * with turn_count=1).
76
80
  */
77
81
  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 (
82
+ return readJsonState(
83
+ cwd,
84
+ EVICT_STATE_FILE_NAME,
85
+ (parsed) =>
83
86
  parsed &&
84
87
  typeof parsed.session_id === "string" &&
85
88
  typeof parsed.turn_count === "number" &&
86
89
  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;
90
+ parsed.turn_count >= 0,
91
+ );
95
92
  }
96
93
 
97
94
  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
- }
95
+ // best-effort counter loss is acceptable, hook never blocks
96
+ writeJsonState(cwd, EVICT_STATE_FILE_NAME, { session_id: sessionId, turn_count: turnCount });
105
97
  }
106
98
 
107
99
  /**
@@ -134,48 +126,20 @@ function evaluateCiteEvict(turnCount, interval) {
134
126
  * Returns a multi-line string ready for hookSpecificOutput.additionalContext.
135
127
  */
136
128
  function renderReminder(turnCount, interval) {
129
+ // v2.0.0-rc.37 NEW-1: cite policy simplified 4-state → 2-state.
130
+ // [applied] consolidates planned/recalled/chained-from; dismissed:<reason>
131
+ // unchanged. Old tags still parse for back-compat.
137
132
  return [
138
133
  `[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>}.",
134
+ "Before edit / decide / propose plan, write KB: <id> (<≤8字 用法>) [applied|dismissed:<reason>] OR KB: none [<reason>].",
135
+ "Verify [applied] by actually fetching KB body via fab_recall(paths) or fab_plan_context → fab_get_knowledge_sections (no fabricated ids).",
136
+ "decisions/pitfalls [applied] cite MUST end with contract: → <operator> [<operator>...] where operator ∈ {edit:<glob> !edit:<glob> require:<symbol> forbid:<symbol> skip:<reason>}.",
142
137
  "skip reasons: sequencing | conditional | semantic | aesthetic | architectural | other:<text>.",
143
138
  "KB: none sentinels: [no-relevant] (queried but nothing matched) | [not-applicable] (pure exploration / read-only / user Q&A).",
144
139
  "Audit: fabric doctor --cite-coverage — this rule does not block work, only records.",
145
140
  ].join("\n");
146
141
  }
147
142
 
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
143
  async function main(env) {
180
144
  try {
181
145
  const cwd =
@@ -188,15 +152,45 @@ async function main(env) {
188
152
  return; // feature off — silent exit
189
153
  }
190
154
 
191
- // Skip Claude Code-specific stdout envelope on Codex/Cursor. Counter
192
- // bookkeeping also skipped there's no fire path on those clients.
155
+ // Read stdin payload (Claude Code passes hook_event_name; Codex/Cursor
156
+ // SessionStart payloads are smaller but still JSON). Tests inject
157
+ // env.payload to bypass the stdin read.
158
+ const payload = env && env.payload !== undefined ? env.payload : await readStdinJson();
159
+
160
+ // v2.0.0-rc.37 NEW-21: SessionStart-mode parity for Codex/Cursor.
161
+ // When the hook fires on SessionStart (instead of UserPromptSubmit),
162
+ // emit ONE unconditional cite-policy reminder to stderr. This gives
163
+ // Codex/Cursor users the cite-contract nudge at session boot — lower
164
+ // cadence than Claude Code's per-prompt UserPromptSubmit window, but
165
+ // strictly better than 0 (rc.32 cite-coverage baseline 3.1% measured
166
+ // when Codex/Cursor had no cite-reminder surface at all).
167
+ const eventName =
168
+ payload && typeof payload.hook_event_name === "string"
169
+ ? payload.hook_event_name
170
+ : null;
171
+ const sessionStartMode =
172
+ (env && env.forceSessionStart === true) || eventName === "SessionStart";
173
+
174
+ const streams = (env && env.stdio) || {};
175
+
176
+ if (sessionStartMode) {
177
+ // One-shot stderr emit (knowledge-hint-broad convention). forceStderr
178
+ // pins stderr even on Claude Code — Codex/Cursor parse stderr; CC
179
+ // SessionStart also surfaces stderr to the user.
180
+ emitContext(renderReminder(/* turnCount = */ 0, interval), {
181
+ forceStderr: true,
182
+ streams,
183
+ });
184
+ return;
185
+ }
186
+
187
+ // Claude Code UserPromptSubmit path (unchanged from rc.34 TASK-06).
188
+ // Skip Claude Code-specific stdout envelope on Codex/Cursor when not
189
+ // in SessionStart mode (no UserPromptSubmit event registration there).
193
190
  if (!isClaudeCode() && !(env && env.forceClaudeCode === true)) {
194
191
  return;
195
192
  }
196
193
 
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
194
  const sessionId =
201
195
  payload && typeof payload.session_id === "string" && payload.session_id.length > 0
202
196
  ? payload.session_id
@@ -210,19 +204,14 @@ async function main(env) {
210
204
  return; // not on a window boundary — silent
211
205
  }
212
206
 
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
- }
207
+ // Claude Code UserPromptSubmit: stdout JSON envelope. client:'cc' forces
208
+ // the envelope since the isClaudeCode/forceClaudeCode gate above already
209
+ // confirmed this is the Claude Code path.
210
+ emitContext(renderReminder(turnCount, interval), {
211
+ client: "cc",
212
+ eventName: "UserPromptSubmit",
213
+ streams,
214
+ });
226
215
  } catch {
227
216
  // Silent — never block user prompt on hook failure.
228
217
  }
@@ -37,6 +37,25 @@ envelope (was `{events: {Stop, SessionStart, PreToolUse}}` PascalCase, which
37
37
  Cursor rejects with "Config version must be a number; Config hooks must be an
38
38
  object").
39
39
 
40
+ ## Per-client schema comparison (v2.0.0-rc.37 NEW-29)
41
+
42
+ Each host program enforces its own wire format — `fabric install` cannot
43
+ serialize one shared shape across all three. Differences are pinned here
44
+ side-by-side so anyone editing one config knows what the others require.
45
+
46
+ | Axis | Claude Code | Codex CLI | Cursor |
47
+ | -------------------- | ---------------------------------------- | -------------------------------------------------- | ----------------------------------------------- |
48
+ | Settings file | `.claude/settings.json` | `.codex/hooks.json` | `.cursor/hooks.json` |
49
+ | Top-level envelope | `hooks: { ... }` (no version) | `events: { ... }` (no version) | `{ version: 1, hooks: { ... } }` (number, not string) |
50
+ | Event-name case | PascalCase: `Stop`, `SessionStart`, `PreToolUse`, `UserPromptSubmit` | PascalCase: `Stop`, `SessionStart`, `PreToolUse` | camelCase: `stop`, `sessionStart`, `preToolUse` |
51
+ | Per-entry shape | Nested matcher: `[{matcher, hooks:[{type:"command", command}]}]` | Flat: `[{command, matcher?}]` | Flat: `[{command, matcher?, type?, timeout?, loop_limit?, failClosed?}]` |
52
+ | Path interpolation | `${CLAUDE_PROJECT_DIR}` (env var) | `"$(git rev-parse --show-toplevel)"` (shell expansion) | project-relative (resolved by Cursor) |
53
+ | Cite-policy event | `UserPromptSubmit` (per-prompt) | `SessionStart` 2nd entry (rc.37 NEW-21 parity) | `sessionStart` 2nd entry (rc.37 NEW-21 parity) |
54
+
55
+ Whenever a hook is added to one config, walk this table and add the equivalent
56
+ entry to the other two — `fabric install` merges each into its respective
57
+ target verbatim, so missing entries silently degrade the cross-client surface.
58
+
40
59
  ## fabric-hint.cjs script paths
41
60
 
42
61
  - Claude: `.claude/hooks/fabric-hint.cjs` (project-relative)
@@ -8,6 +8,9 @@
8
8
  "SessionStart": [
9
9
  {
10
10
  "command": "\"$(git rev-parse --show-toplevel)/.codex/hooks/knowledge-hint-broad.cjs\""
11
+ },
12
+ {
13
+ "command": "\"$(git rev-parse --show-toplevel)/.codex/hooks/cite-policy-evict.cjs\""
11
14
  }
12
15
  ],
13
16
  "PreToolUse": [
@@ -5,7 +5,8 @@
5
5
  { "command": ".cursor/hooks/fabric-hint.cjs" }
6
6
  ],
7
7
  "sessionStart": [
8
- { "command": ".cursor/hooks/knowledge-hint-broad.cjs" }
8
+ { "command": ".cursor/hooks/knowledge-hint-broad.cjs" },
9
+ { "command": ".cursor/hooks/cite-policy-evict.cjs" }
9
10
  ],
10
11
  "preToolUse": [
11
12
  {
@@ -55,6 +55,32 @@ try {
55
55
  citeContractReminder = null;
56
56
  }
57
57
 
58
+ // v2.0.0-rc.37 NEW-30: shared client-protocol adapter. Guarded require (this
59
+ // hook runs in arbitrary user repos); detectClient delegates the 3-tier
60
+ // detection to the lib, falling back to env-only when the lib is absent.
61
+ let clientAdapter = null;
62
+ try {
63
+ clientAdapter = require("./lib/client-adapter.cjs");
64
+ } catch {
65
+ clientAdapter = null;
66
+ }
67
+
68
+ // v2.0.0-rc.37 NEW-16: shared config + sidecar I/O for the per-signal dismiss
69
+ // feature (config-level durable opt-out + session-scoped sidecar). Guarded
70
+ // require (house style); dismiss simply doesn't fire if the lib is absent.
71
+ let configCache = null;
72
+ let stateStore = null;
73
+ try {
74
+ configCache = require("./lib/config-cache.cjs");
75
+ } catch {
76
+ configCache = null;
77
+ }
78
+ try {
79
+ stateStore = require("./lib/state-store.cjs");
80
+ } catch {
81
+ stateStore = null;
82
+ }
83
+
58
84
  // CONSTANTS — duplicated from packages/server/src/services/_shared.ts.
59
85
  // DRY violation accepted: this hook script runs in user repos WITHOUT
60
86
  // node_modules access, so it cannot import from @fenglimg/fabric-server.
@@ -929,6 +955,97 @@ function writeShownCache(projectRoot, cache) {
929
955
  }
930
956
  }
931
957
 
958
+ // -----------------------------------------------------------------------------
959
+ // v2.0.0-rc.37 NEW-16 — per-signal dismiss.
960
+ //
961
+ // Two suppression levers, both honoured at emit time (a chosen signal whose
962
+ // type is dismissed exits silently, exactly like a cooldown hit):
963
+ // 1. Durable opt-out — fabric-config.json#hint_dismiss_signals: string[].
964
+ // Mirrors the cite_evict_interval=0 opt-out convention; survives across
965
+ // sessions. The concrete user-actionable lever surfaced in the nudge.
966
+ // 2. Session-scoped — .fabric/.cache/hint-dismiss-{sessionId}.json
967
+ // { dismissed: string[] }. Ephemeral; written by the agent when the user
968
+ // asks to silence a nudge type for the current session (Fabric's
969
+ // AI-driven write convention — no new CLI surface).
970
+ //
971
+ // The four signal types ('archive' / 'review' / 'import' / 'maintenance')
972
+ // each have an independent cooldown ALREADY (signal-keyed SHOWN_CACHE for
973
+ // A/B/C + the maintenance day-cooldown sidecar), so dismiss layers cleanly on
974
+ // top of per-signal cadence without a physical 4-hook split (which would 4×
975
+ // the per-Stop process spawn and break the deliberate single-nudge-per-turn
976
+ // precedence model — KT-DEC-0007 anti-nag spirit).
977
+ // -----------------------------------------------------------------------------
978
+
979
+ const DISMISSABLE_SIGNALS = ["archive", "review", "import", "maintenance"];
980
+
981
+ function sessionDismissFileName(sessionId) {
982
+ const safe = String(sessionId || "anonymous").replace(/[^A-Za-z0-9_.-]/g, "-");
983
+ return `hint-dismiss-${safe}.json`;
984
+ }
985
+
986
+ // Returns a Set of dismissed signal types (config-durable ∪ session sidecar).
987
+ // Never throws — degrades to an empty set when libs are absent.
988
+ function readDismissedSignals(projectRoot, sessionId) {
989
+ const dismissed = new Set();
990
+ try {
991
+ if (configCache && typeof configCache.readConfig === "function") {
992
+ const cfg = configCache.readConfig(projectRoot);
993
+ const list = cfg && cfg.hint_dismiss_signals;
994
+ if (Array.isArray(list)) {
995
+ for (const s of list) {
996
+ if (DISMISSABLE_SIGNALS.includes(s)) dismissed.add(s);
997
+ }
998
+ }
999
+ }
1000
+ } catch {
1001
+ // defensive
1002
+ }
1003
+ try {
1004
+ if (stateStore && typeof stateStore.readJsonState === "function" && sessionId) {
1005
+ const sidecar = stateStore.readJsonState(
1006
+ projectRoot,
1007
+ sessionDismissFileName(sessionId),
1008
+ (p) => p && typeof p === "object" && Array.isArray(p.dismissed),
1009
+ );
1010
+ if (sidecar) {
1011
+ for (const s of sidecar.dismissed) {
1012
+ if (DISMISSABLE_SIGNALS.includes(s)) dismissed.add(s);
1013
+ }
1014
+ }
1015
+ }
1016
+ } catch {
1017
+ // defensive
1018
+ }
1019
+ return dismissed;
1020
+ }
1021
+
1022
+ // Persist a session-scoped dismiss set (additive merge). Exposed for the
1023
+ // agent-driven write path + tests; not auto-invoked by the hook. Never throws.
1024
+ function writeSessionDismiss(projectRoot, sessionId, signals) {
1025
+ if (!stateStore || typeof stateStore.writeJsonState !== "function") return;
1026
+ const fileName = sessionDismissFileName(sessionId);
1027
+ const prior = stateStore.readJsonState(
1028
+ projectRoot,
1029
+ fileName,
1030
+ (p) => p && typeof p === "object" && Array.isArray(p.dismissed),
1031
+ );
1032
+ const merged = new Set(prior && Array.isArray(prior.dismissed) ? prior.dismissed : []);
1033
+ for (const s of Array.isArray(signals) ? signals : []) {
1034
+ if (DISMISSABLE_SIGNALS.includes(s)) merged.add(s);
1035
+ }
1036
+ stateStore.writeJsonState(projectRoot, fileName, { dismissed: [...merged] });
1037
+ }
1038
+
1039
+ // Bilingual one-line dismiss hint appended to every nudge so the user knows
1040
+ // the lever exists. Variant fold mirrors banner-i18n: zh-CN / zh-CN-hybrid →
1041
+ // Chinese; en / match-existing / unknown → English.
1042
+ function renderDismissOption(signal, variant) {
1043
+ const zh = variant === "zh-CN" || variant === "zh-CN-hybrid";
1044
+ return zh
1045
+ ? ` (不想再看到此类提醒?在 .fabric/fabric-config.json 设 "hint_dismiss_signals": ["${signal}"],或让我本会话关闭 ${signal} 提醒)`
1046
+ : ` (Silence this nudge? Set "hint_dismiss_signals": ["${signal}"] in .fabric/fabric-config.json, or ask me to dismiss ${signal} for this session)`;
1047
+ }
1048
+
932
1049
  /**
933
1050
  * v2.0.0-rc.7 T10: find the most recent doctor_run event ts in the ledger.
934
1051
  * Returns the ts (epoch ms) of the newest doctor_run event, or null if none
@@ -1161,6 +1278,13 @@ function parseKbLine(raw) {
1161
1278
  * so omitting it leaves the event valid.
1162
1279
  */
1163
1280
  function detectClient() {
1281
+ // Delegate the full 3-tier detection (env → CLAUDE_PROJECT_DIR → path
1282
+ // heuristic, incl. .cursor) to the shared adapter. __dirname is passed so
1283
+ // the path heuristic reflects THIS hook's location.
1284
+ if (clientAdapter && typeof clientAdapter.detectClient === "function") {
1285
+ return clientAdapter.detectClient(__dirname);
1286
+ }
1287
+ // Fallback (adapter lib absent): env override only.
1164
1288
  const envClient = process.env.FABRIC_HINT_CLIENT;
1165
1289
  if (typeof envClient === "string" && envClient.length > 0) {
1166
1290
  const normalised = envClient.trim().toLowerCase();
@@ -1168,14 +1292,6 @@ function detectClient() {
1168
1292
  return normalised;
1169
1293
  }
1170
1294
  }
1171
- // Path heuristic — __dirname is the directory containing this .cjs file
1172
- // when invoked normally (require.main === module).
1173
- try {
1174
- if (__dirname.includes(".claude/") || __dirname.includes(".claude\\")) return "cc";
1175
- if (__dirname.includes(".codex/") || __dirname.includes(".codex\\")) return "codex";
1176
- } catch {
1177
- // __dirname always defined for cjs modules; fall through defensively.
1178
- }
1179
1295
  return undefined;
1180
1296
  }
1181
1297
 
@@ -1722,6 +1838,21 @@ function main(env, stdio) {
1722
1838
 
1723
1839
  if (result === null) return;
1724
1840
 
1841
+ // v2.0.0-rc.37 NEW-16: per-signal dismiss. A chosen signal whose type the
1842
+ // user dismissed (config-durable or session sidecar) exits silently —
1843
+ // same shape as a cooldown hit. Covers BOTH maintenance and A/B/C paths.
1844
+ const sessionId =
1845
+ stdinPayload && typeof stdinPayload.session_id === "string"
1846
+ ? stdinPayload.session_id
1847
+ : null;
1848
+ if (readDismissedSignals(cwd, sessionId).has(result.signal)) {
1849
+ return;
1850
+ }
1851
+ // Append the bilingual dismiss-option line so the lever is discoverable.
1852
+ if (typeof result.reason === "string") {
1853
+ result.reason = `${result.reason}\n${renderDismissOption(result.signal, variant)}`;
1854
+ }
1855
+
1725
1856
  // v2.0.0-rc.7 T10: Signal D uses its own cooldown sidecar (day-based,
1726
1857
  // see MAINTENANCE_HINT_LAST_EMIT_FILE). The A/B/C shared cooldown cache
1727
1858
  // uses hours, so we branch here to avoid mixing semantics.
@@ -1771,6 +1902,13 @@ module.exports = {
1771
1902
  readCooldownHours,
1772
1903
  readUnderseedThreshold,
1773
1904
  readArchiveEditThreshold,
1905
+ // v2.0.0-rc.37 NEW-16: per-signal dismiss helpers (exported for tests +
1906
+ // the agent-driven session-dismiss write path).
1907
+ readDismissedSignals,
1908
+ writeSessionDismiss,
1909
+ sessionDismissFileName,
1910
+ renderDismissOption,
1911
+ DISMISSABLE_SIGNALS,
1774
1912
  // v2.0.0-rc.7 T5: session digest helpers (exported for unit testing).
1775
1913
  tryReadStdinJson,
1776
1914
  summarizeTranscript,