@fenglimg/fabric-cli 2.0.0-rc.36 → 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.
- package/LICENSE +21 -0
- package/dist/{chunk-XVS4F3P6.js → chunk-D25XJ4BC.js} +49 -5
- package/dist/{chunk-G2CIOLD4.js → chunk-WWNXR34K.js} +1 -16
- package/dist/{doctor-2FCRAWDZ.js → doctor-764NFF3X.js} +112 -16
- package/dist/index.js +7 -6
- package/dist/{install-XSUIX6AD.js → install-U7MGIJ2L.js} +50 -22
- package/dist/metrics-ACEQFPDU.js +122 -0
- package/dist/{uninstall-BIJ5GLEU.js → uninstall-MH7ZIB6M.js} +6 -18
- package/package.json +30 -4
- package/templates/hooks/cite-policy-evict.cjs +80 -91
- package/templates/hooks/configs/README.md +19 -0
- package/templates/hooks/configs/codex-hooks.json +3 -0
- package/templates/hooks/configs/cursor-hooks.json +2 -1
- package/templates/hooks/fabric-hint.cjs +146 -8
- package/templates/hooks/knowledge-hint-broad.cjs +65 -104
- package/templates/hooks/knowledge-hint-narrow.cjs +122 -5
- package/templates/hooks/lib/cite-line-parser.cjs +7 -1
- package/templates/hooks/lib/client-adapter.cjs +106 -0
- package/templates/hooks/lib/config-cache.cjs +107 -0
- package/templates/hooks/lib/state-store.cjs +84 -0
- package/templates/skills/fabric-archive/SKILL.md +29 -7
- package/templates/skills/fabric-archive/ref/dry-run-scope.md +1 -1
- package/templates/skills/fabric-archive/ref/i18n-policy.md +6 -0
- package/templates/skills/fabric-archive/ref/phase-1-5-onboard.md +1 -1
- package/templates/skills/fabric-archive/ref/phase-1-cross-session.md +2 -0
- package/templates/skills/fabric-archive/ref/phase-2-5-viability.md +25 -11
- package/templates/skills/fabric-archive/ref/phase-3-5-scope.md +43 -15
- package/templates/skills/fabric-import/SKILL.md +3 -3
- package/templates/skills/fabric-import/ref/i18n-policy.md +6 -0
- package/templates/skills/fabric-import/ref/phase-2-mining.md +2 -2
- package/templates/skills/fabric-review/SKILL.md +31 -25
- package/templates/skills/fabric-review/ref/i18n-policy.md +6 -0
- package/templates/skills/fabric-review/ref/modify-flow.md +9 -1
- package/templates/skills/fabric-review/ref/per-mode-flows.md +1 -1
- package/templates/skills/lib/shared-policy.md +69 -0
- 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.
|
|
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.
|
|
23
|
-
"@fenglimg/fabric-shared": "2.0.0-rc.
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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字 用法>) [
|
|
140
|
-
"Verify [
|
|
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
|
-
//
|
|
192
|
-
//
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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)
|
|
@@ -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,
|