@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.
- package/dist/{chunk-PNRWNUFX.js → chunk-5N3KXIVI.js} +73 -4
- package/dist/{doctor-TTDTKOFJ.js → doctor-E26YO67D.js} +8 -2
- package/dist/index.js +4 -4
- package/dist/{install-OEBNSCS5.js → install-XCRX34CX.js} +4 -2
- package/dist/{uninstall-VLLJG7JT.js → uninstall-Q7V55BXH.js} +1 -1
- package/package.json +3 -3
- package/templates/hooks/cite-policy-evict.cjs +242 -0
- package/templates/hooks/configs/claude-code.json +11 -0
- package/templates/hooks/fabric-hint.cjs +11 -1
- package/templates/hooks/knowledge-hint-broad.cjs +276 -21
- package/templates/hooks/knowledge-hint-narrow.cjs +466 -14
- package/templates/skills/fabric-archive/SKILL.md +53 -864
- package/templates/skills/fabric-archive/ref/dry-run-scope.md +16 -0
- package/templates/skills/fabric-archive/ref/e5-cron-recap.md +5 -5
- package/templates/skills/fabric-archive/ref/i18n-policy.md +3 -3
- package/templates/skills/fabric-archive/ref/phase-0-range-resolution.md +156 -0
- package/templates/skills/fabric-archive/ref/{phase-0-4-onboard.md → phase-1-5-onboard.md} +21 -21
- package/templates/skills/fabric-archive/ref/phase-1-cross-session.md +60 -0
- package/templates/skills/fabric-archive/ref/phase-2-5-viability.md +54 -0
- package/templates/skills/fabric-archive/ref/phase-3-5-scope.md +80 -0
- package/templates/skills/fabric-archive/ref/phase-3-classify.md +63 -0
- package/templates/skills/fabric-archive/ref/phase-4-5-emit.md +78 -0
- package/templates/skills/fabric-archive/ref/phase-4-mcp-persist.md +89 -0
- package/templates/skills/fabric-archive/ref/rc-history.md +6 -6
- package/templates/skills/fabric-archive/ref/worked-examples.md +1 -1
- package/templates/skills/fabric-import/SKILL.md +29 -556
- package/templates/skills/fabric-import/ref/checkpoint-state.md +85 -0
- package/templates/skills/fabric-import/ref/output-contract.md +61 -0
- package/templates/skills/fabric-import/ref/phase-2-mining.md +213 -0
- package/templates/skills/fabric-import/ref/phase-3-dedup.md +75 -0
- package/templates/skills/fabric-import/ref/worked-examples.md +127 -0
- package/templates/skills/fabric-review/SKILL.md +56 -414
- package/templates/skills/fabric-review/ref/askuserquestion-policy.md +66 -0
- package/templates/skills/fabric-review/ref/modify-flow.md +95 -0
- package/templates/skills/fabric-review/ref/output-contract.md +58 -0
- package/templates/skills/fabric-review/ref/per-mode-flows.md +155 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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-
|
|
15
|
-
doctor: () => import("./doctor-
|
|
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-
|
|
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.
|
|
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-
|
|
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.
|
|
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)));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fenglimg/fabric-cli",
|
|
3
|
-
"version": "2.0.0-rc.
|
|
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.
|
|
24
|
-
"@fenglimg/fabric-shared": "2.0.0-rc.
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
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
|
|