@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.
- 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-HOTE5BPA.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 +75 -163
- 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
|
@@ -47,14 +47,8 @@
|
|
|
47
47
|
*/
|
|
48
48
|
|
|
49
49
|
const { spawnSync } = require("node:child_process");
|
|
50
|
-
const {
|
|
51
|
-
|
|
52
|
-
mkdirSync,
|
|
53
|
-
readdirSync,
|
|
54
|
-
readFileSync,
|
|
55
|
-
writeFileSync,
|
|
56
|
-
} = require("node:fs");
|
|
57
|
-
const { dirname, join } = require("node:path");
|
|
50
|
+
const { existsSync, readdirSync, readFileSync } = require("node:fs");
|
|
51
|
+
const { join } = require("node:path");
|
|
58
52
|
|
|
59
53
|
// rc.16 TASK-003: shared banner-i18n lib (resolves fabric_language config and
|
|
60
54
|
// renders localized banner text). Mirror of the wiring in fabric-hint.cjs
|
|
@@ -62,6 +56,18 @@ const { dirname, join } = require("node:path");
|
|
|
62
56
|
// readFabricLanguage(cwd) and threaded into renderBanner — no fs in render path.
|
|
63
57
|
const { renderBanner, readFabricLanguage } = require("./lib/banner-i18n.cjs");
|
|
64
58
|
const { resolveOpaqueSummaries } = require("./lib/summary-fallback.cjs");
|
|
59
|
+
// v2.0.0-rc.37 NEW-19: shared fabric-config reader + sidecar I/O. Replaces the
|
|
60
|
+
// five per-key readFileSync+parse config readers (one parse per fire now) and
|
|
61
|
+
// the bespoke last-emit sidecar helpers. The L78 "refactor into lib/ if a
|
|
62
|
+
// third hook needs it" note is now realised.
|
|
63
|
+
const {
|
|
64
|
+
readConfigNumber,
|
|
65
|
+
readConfigBoolean,
|
|
66
|
+
} = require("./lib/config-cache.cjs");
|
|
67
|
+
const { readTextState, writeTextState } = require("./lib/state-store.cjs");
|
|
68
|
+
// v2.0.0-rc.37 NEW-30: shared client detection (replaces the inline
|
|
69
|
+
// CLAUDE_PROJECT_DIR single-bit check below).
|
|
70
|
+
const { isClaudeCode } = require("./lib/client-adapter.cjs");
|
|
65
71
|
|
|
66
72
|
// -----------------------------------------------------------------------------
|
|
67
73
|
// rc.12: SessionStart broad-menu is now unconditionally emitted on every
|
|
@@ -80,7 +86,6 @@ const FABRIC_DIR_REL = ".fabric";
|
|
|
80
86
|
// cannot `require` each other. If a third hook ever needs the same logic,
|
|
81
87
|
// refactor into packages/cli/templates/hooks/lib/. Keep these values in sync
|
|
82
88
|
// with packages/cli/templates/hooks/fabric-hint.cjs.
|
|
83
|
-
const FABRIC_CONFIG_FILE = "fabric-config.json";
|
|
84
89
|
const AGENTS_META_FILE = "agents.meta.json";
|
|
85
90
|
const IMPORT_STATE_FILE = ".import-state.json";
|
|
86
91
|
const KNOWLEDGE_CANONICAL_TYPES = [
|
|
@@ -104,11 +109,8 @@ const DEFAULT_HINT_BROAD_TOP_K = 8;
|
|
|
104
109
|
// so the two cooldowns don't interfere.
|
|
105
110
|
const DEFAULT_HINT_BROAD_COOLDOWN_HOURS = 0;
|
|
106
111
|
const MS_PER_HOUR = 60 * 60 * 1000;
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
".cache",
|
|
110
|
-
"knowledge-hint-broad-last-emit",
|
|
111
|
-
);
|
|
112
|
+
// v2.0.0-rc.37 NEW-19: state-store resolves this basename under .fabric/.cache/.
|
|
113
|
+
const HINT_BROAD_LAST_EMIT_FILE_NAME = "knowledge-hint-broad-last-emit";
|
|
112
114
|
|
|
113
115
|
// v2.0.0-rc.33 W2-6 (P0-7): when true, emit banner as
|
|
114
116
|
// hookSpecificOutput.additionalContext JSON on stdout (Claude Code PreToolUse
|
|
@@ -168,16 +170,11 @@ function countCanonicalNodes(projectRoot) {
|
|
|
168
170
|
* Any read/parse failure → default (never block on config errors).
|
|
169
171
|
*/
|
|
170
172
|
function readUnderseedThreshold(projectRoot) {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
if (typeof v === "number" && Number.isFinite(v) && v > 0) return v;
|
|
177
|
-
} catch {
|
|
178
|
-
// fall through to default
|
|
179
|
-
}
|
|
180
|
-
return DEFAULT_UNDERSEED_NODE_THRESHOLD;
|
|
173
|
+
// > 0 guard via min: Number.MIN_VALUE (any positive). config-cache returns
|
|
174
|
+
// the parsed number when finite & in-range, else the default.
|
|
175
|
+
return readConfigNumber(projectRoot, "underseed_node_threshold", DEFAULT_UNDERSEED_NODE_THRESHOLD, {
|
|
176
|
+
min: Number.MIN_VALUE,
|
|
177
|
+
});
|
|
181
178
|
}
|
|
182
179
|
|
|
183
180
|
/**
|
|
@@ -186,18 +183,11 @@ function readUnderseedThreshold(projectRoot) {
|
|
|
186
183
|
* schema's 1..50 range inline so a malformed config silently falls back.
|
|
187
184
|
*/
|
|
188
185
|
function readBroadTopK(projectRoot) {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
if (typeof v === "number" && Number.isFinite(v) && v >= 1 && v <= 50) {
|
|
195
|
-
return Math.floor(v);
|
|
196
|
-
}
|
|
197
|
-
} catch {
|
|
198
|
-
// fall through to default
|
|
199
|
-
}
|
|
200
|
-
return DEFAULT_HINT_BROAD_TOP_K;
|
|
186
|
+
return readConfigNumber(projectRoot, "hint_broad_top_k", DEFAULT_HINT_BROAD_TOP_K, {
|
|
187
|
+
min: 1,
|
|
188
|
+
max: 50,
|
|
189
|
+
floor: true,
|
|
190
|
+
});
|
|
201
191
|
}
|
|
202
192
|
|
|
203
193
|
/**
|
|
@@ -205,18 +195,10 @@ function readBroadTopK(projectRoot) {
|
|
|
205
195
|
* 0 means "no cooldown" (re-emit on every SessionStart, rc.32 behavior).
|
|
206
196
|
*/
|
|
207
197
|
function readBroadCooldownHours(projectRoot) {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const v = parsed && parsed.hint_broad_cooldown_hours;
|
|
213
|
-
if (typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= 168) {
|
|
214
|
-
return v;
|
|
215
|
-
}
|
|
216
|
-
} catch {
|
|
217
|
-
// fall through to default
|
|
218
|
-
}
|
|
219
|
-
return DEFAULT_HINT_BROAD_COOLDOWN_HOURS;
|
|
198
|
+
return readConfigNumber(projectRoot, "hint_broad_cooldown_hours", DEFAULT_HINT_BROAD_COOLDOWN_HOURS, {
|
|
199
|
+
min: 0,
|
|
200
|
+
max: 168,
|
|
201
|
+
});
|
|
220
202
|
}
|
|
221
203
|
|
|
222
204
|
/**
|
|
@@ -226,16 +208,7 @@ function readBroadCooldownHours(projectRoot) {
|
|
|
226
208
|
* receives the reminder in-context. Stderr stays informational either way.
|
|
227
209
|
*/
|
|
228
210
|
function readReminderToContext(projectRoot) {
|
|
229
|
-
|
|
230
|
-
if (!existsSync(configPath)) return DEFAULT_HINT_REMINDER_TO_CONTEXT;
|
|
231
|
-
try {
|
|
232
|
-
const parsed = JSON.parse(readFileSync(configPath, "utf8"));
|
|
233
|
-
const v = parsed && parsed.hint_reminder_to_context;
|
|
234
|
-
if (typeof v === "boolean") return v;
|
|
235
|
-
} catch {
|
|
236
|
-
// fall through to default
|
|
237
|
-
}
|
|
238
|
-
return DEFAULT_HINT_REMINDER_TO_CONTEXT;
|
|
211
|
+
return readConfigBoolean(projectRoot, "hint_reminder_to_context", DEFAULT_HINT_REMINDER_TO_CONTEXT);
|
|
239
212
|
}
|
|
240
213
|
|
|
241
214
|
/**
|
|
@@ -244,31 +217,18 @@ function readReminderToContext(projectRoot) {
|
|
|
244
217
|
* Returns epoch ms or null when missing/unreadable.
|
|
245
218
|
*/
|
|
246
219
|
function readBroadLastEmit(projectRoot) {
|
|
247
|
-
const
|
|
248
|
-
if (
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
if (Number.isFinite(asNum) && asNum > 0) return asNum;
|
|
254
|
-
const ms = Date.parse(raw);
|
|
255
|
-
if (Number.isFinite(ms)) return ms;
|
|
256
|
-
} catch {
|
|
257
|
-
// ignore
|
|
258
|
-
}
|
|
220
|
+
const raw = readTextState(projectRoot, HINT_BROAD_LAST_EMIT_FILE_NAME);
|
|
221
|
+
if (raw === null || raw.length === 0) return null;
|
|
222
|
+
const asNum = Number(raw);
|
|
223
|
+
if (Number.isFinite(asNum) && asNum > 0) return asNum;
|
|
224
|
+
const ms = Date.parse(raw);
|
|
225
|
+
if (Number.isFinite(ms)) return ms;
|
|
259
226
|
return null;
|
|
260
227
|
}
|
|
261
228
|
|
|
262
229
|
function writeBroadLastEmit(projectRoot, nowMs) {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
if (!existsSync(dirname(p))) {
|
|
266
|
-
mkdirSync(dirname(p), { recursive: true });
|
|
267
|
-
}
|
|
268
|
-
writeFileSync(p, String(nowMs));
|
|
269
|
-
} catch {
|
|
270
|
-
// Silent — sidecar failure must never block session start.
|
|
271
|
-
}
|
|
230
|
+
// Silent — sidecar failure must never block session start.
|
|
231
|
+
writeTextState(projectRoot, HINT_BROAD_LAST_EMIT_FILE_NAME, String(nowMs));
|
|
272
232
|
}
|
|
273
233
|
|
|
274
234
|
/**
|
|
@@ -368,18 +328,11 @@ const CLI_TIMEOUT_MS = 2000;
|
|
|
368
328
|
const DEFAULT_SUMMARY_MAX_LEN = 80;
|
|
369
329
|
|
|
370
330
|
function readSummaryMaxLen(projectRoot) {
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
if (typeof v === "number" && Number.isFinite(v) && v >= 40 && v <= 240) {
|
|
377
|
-
return Math.floor(v);
|
|
378
|
-
}
|
|
379
|
-
} catch {
|
|
380
|
-
// fall through to default
|
|
381
|
-
}
|
|
382
|
-
return DEFAULT_SUMMARY_MAX_LEN;
|
|
331
|
+
return readConfigNumber(projectRoot, "hint_summary_max_len", DEFAULT_SUMMARY_MAX_LEN, {
|
|
332
|
+
min: 40,
|
|
333
|
+
max: 240,
|
|
334
|
+
floor: true,
|
|
335
|
+
});
|
|
383
336
|
}
|
|
384
337
|
|
|
385
338
|
// Canonical type order — render groups in this sequence so output is stable
|
|
@@ -771,17 +724,27 @@ function main(env, stdio) {
|
|
|
771
724
|
const summaryMaxLen = readSummaryMaxLen(cwd);
|
|
772
725
|
const lines = renderSummary(resolvedPayload, summaryMaxLen);
|
|
773
726
|
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
lines.push(renderBanner("broadImportBanner",
|
|
727
|
+
// v2.0.0-rc.37 NEW-23: resolve fabric_language ONCE per emit path —
|
|
728
|
+
// shared between the (existing) broadImportBanner branch and the new
|
|
729
|
+
// 'next step' nudge tail added below. 'match-existing' / unknown variants
|
|
730
|
+
// fold to 'en' inside renderBanner per UX i18n Policy class 1.
|
|
731
|
+
const fabricLanguageForEmit = lines.length > 0 || recommendImport ? readFabricLanguage(cwd) : null;
|
|
732
|
+
if (recommendImport && fabricLanguageForEmit !== null) {
|
|
733
|
+
lines.push(renderBanner("broadImportBanner", fabricLanguageForEmit, {}));
|
|
781
734
|
}
|
|
782
735
|
|
|
783
736
|
if (lines.length === 0) return; // nothing to say — silent exit
|
|
784
737
|
|
|
738
|
+
// v2.0.0-rc.37 NEW-23: SessionStart 索引末尾"下一步"引导。Tail line that
|
|
739
|
+
// tells the AI what to do with the broad index it just received. Without
|
|
740
|
+
// this, the model often parses the index and moves on without ever calling
|
|
741
|
+
// fab_recall / fab_plan_context. One-line nudge, bilingual.
|
|
742
|
+
const nextStepNudge =
|
|
743
|
+
fabricLanguageForEmit === "zh-CN"
|
|
744
|
+
? "下一步: 调 fab_recall(paths) 拿 KB 相关条目;或调 fab_plan_context 先看候选 description_index。"
|
|
745
|
+
: "Next: call fab_recall(paths) to fetch related KB entries, or fab_plan_context to preview the description_index first.";
|
|
746
|
+
lines.push(nextStepNudge);
|
|
747
|
+
|
|
785
748
|
// Stderr: always emit (human-facing breadcrumb + legacy contract).
|
|
786
749
|
for (const line of lines) {
|
|
787
750
|
err.write(`${line}\n`);
|
|
@@ -802,11 +765,9 @@ function main(env, stdio) {
|
|
|
802
765
|
// either polluting the terminal or crashing the host's hook-parsing
|
|
803
766
|
// pipeline. CLAUDE_PROJECT_DIR is set by CC when invoking hooks (see
|
|
804
767
|
// packages/cli/templates/hooks/configs/claude-code.json sigil paths);
|
|
805
|
-
// its presence is the single-bit "this is Claude Code" signal
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
process.env.CLAUDE_PROJECT_DIR.length > 0;
|
|
809
|
-
const reminderToContext = readReminderToContext(cwd) && isClaudeCode;
|
|
768
|
+
// its presence is the single-bit "this is Claude Code" signal (now via
|
|
769
|
+
// the shared client-adapter, rc.37 NEW-30).
|
|
770
|
+
const reminderToContext = readReminderToContext(cwd) && isClaudeCode();
|
|
810
771
|
if (reminderToContext && !(env && env.skipStdout === true)) {
|
|
811
772
|
try {
|
|
812
773
|
const envelope = {
|
|
@@ -867,7 +828,7 @@ module.exports = {
|
|
|
867
828
|
DEFAULT_HINT_BROAD_TOP_K,
|
|
868
829
|
DEFAULT_HINT_BROAD_COOLDOWN_HOURS,
|
|
869
830
|
DEFAULT_HINT_REMINDER_TO_CONTEXT,
|
|
870
|
-
|
|
831
|
+
HINT_BROAD_LAST_EMIT_FILE_NAME,
|
|
871
832
|
},
|
|
872
833
|
};
|
|
873
834
|
|
|
@@ -71,6 +71,7 @@ const {
|
|
|
71
71
|
mkdirSync,
|
|
72
72
|
readFileSync,
|
|
73
73
|
renameSync,
|
|
74
|
+
statSync,
|
|
74
75
|
writeFileSync,
|
|
75
76
|
} = require("node:fs");
|
|
76
77
|
const { dirname, join } = require("node:path");
|
|
@@ -80,6 +81,10 @@ const { dirname, join } = require("node:path");
|
|
|
80
81
|
// entry's .md `## Summary` section. Caches results in
|
|
81
82
|
// `.fabric/.cache/summary-fallback.json` keyed by revision_hash.
|
|
82
83
|
const { resolveOpaqueSummaries } = require("./lib/summary-fallback.cjs");
|
|
84
|
+
// v2.0.0-rc.37 NEW-17: shared sidecar I/O for the plan-context-hint result
|
|
85
|
+
// cache (skips a redundant CLI cold-start spawn when the same path-set is
|
|
86
|
+
// re-edited within a session and the knowledge graph hasn't changed).
|
|
87
|
+
const { readJsonState, writeJsonState } = require("./lib/state-store.cjs");
|
|
83
88
|
|
|
84
89
|
// -----------------------------------------------------------------------------
|
|
85
90
|
// CONSTANTS
|
|
@@ -148,6 +153,9 @@ const EDIT_TOOL_NAMES = new Set(["Edit", "Write", "MultiEdit"]);
|
|
|
148
153
|
// are duplicated inline. Keep these in sync if the schema changes.
|
|
149
154
|
const FABRIC_DIR_REL = ".fabric";
|
|
150
155
|
const FABRIC_CONFIG_FILE = "fabric-config.json";
|
|
156
|
+
// rc.37 NEW-17: derived index the server rewrites on any knowledge edit; its
|
|
157
|
+
// mtime is the cheap freshness token for the plan-context-hint result cache.
|
|
158
|
+
const AGENTS_META_FILE = "agents.meta.json";
|
|
151
159
|
|
|
152
160
|
// W2-1 (P0-9): narrow TopK upper bound. Five matches the per-Edit hint
|
|
153
161
|
// "terse banner" UX: any more and the model's working memory bloats.
|
|
@@ -777,6 +785,84 @@ function invokePlanContextHint(cwd, paths) {
|
|
|
777
785
|
return null;
|
|
778
786
|
}
|
|
779
787
|
|
|
788
|
+
// -----------------------------------------------------------------------------
|
|
789
|
+
// v2.0.0-rc.37 NEW-17 — plan-context-hint result cache (per-session).
|
|
790
|
+
//
|
|
791
|
+
// Each PreToolUse fire is a separate process, so "in-memory" caching means a
|
|
792
|
+
// per-session sidecar of the CLI result keyed on the edited path-set. A repeat
|
|
793
|
+
// edit to the same file(s) — common during iterative work on one module —
|
|
794
|
+
// re-reads the cached result instead of paying another `fabric plan-context-
|
|
795
|
+
// hint` cold-start spawn (~50-150ms). MultiEdit's N paths already collapse to
|
|
796
|
+
// ONE spawn (extractPaths dedupes + invokePlanContextHint joins --paths); this
|
|
797
|
+
// cache extends that win across fires within a stable knowledge graph.
|
|
798
|
+
//
|
|
799
|
+
// Freshness: the cache is invalidated wholesale when `.fabric/agents.meta.json`
|
|
800
|
+
// mtime changes (the derived index the server rewrites on any knowledge edit).
|
|
801
|
+
// This is a cheap stat — no spawn — so the freshness check itself is ~free.
|
|
802
|
+
// -----------------------------------------------------------------------------
|
|
803
|
+
|
|
804
|
+
// Bound the per-session result map so a long stable session editing many
|
|
805
|
+
// distinct files can't grow the sidecar without limit.
|
|
806
|
+
const NARROW_RESULT_CACHE_MAX_ENTRIES = 50;
|
|
807
|
+
|
|
808
|
+
function metaFreshnessToken(cwd) {
|
|
809
|
+
try {
|
|
810
|
+
const metaPath = join(cwd, FABRIC_DIR_REL, AGENTS_META_FILE);
|
|
811
|
+
if (!existsSync(metaPath)) return null;
|
|
812
|
+
return statSync(metaPath).mtimeMs;
|
|
813
|
+
} catch {
|
|
814
|
+
return null;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function narrowResultCacheFileName(sessionId) {
|
|
819
|
+
const safe = String(sessionId || "anonymous").replace(/[^A-Za-z0-9_.-]/g, "-");
|
|
820
|
+
return `narrow-result-cache-${safe}.json`;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// Order-independent key for a path-set (sorted + NUL-joined so [a,b] and [b,a]
|
|
824
|
+
// hit the same cache slot).
|
|
825
|
+
function pathSetKey(paths) {
|
|
826
|
+
return [...paths].sort().join("");
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// Returns the cached cliPayload for `paths` iff the cache's meta token matches
|
|
830
|
+
// the current knowledge-graph freshness, else null (caller spawns the CLI).
|
|
831
|
+
function readNarrowResultCache(cwd, sessionId, paths, metaToken) {
|
|
832
|
+
if (metaToken === null) return null;
|
|
833
|
+
const cache = readJsonState(
|
|
834
|
+
cwd,
|
|
835
|
+
narrowResultCacheFileName(sessionId),
|
|
836
|
+
(parsed) => parsed && typeof parsed === "object" && parsed.results && typeof parsed.results === "object",
|
|
837
|
+
);
|
|
838
|
+
if (!cache || cache.meta_token !== metaToken) return null;
|
|
839
|
+
const hit = cache.results[pathSetKey(paths)];
|
|
840
|
+
return hit && typeof hit === "object" ? hit : null;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Persist `cliPayload` under the path-set key. Resets the map when the meta
|
|
844
|
+
// token changed (stale graph) and caps the map size (FIFO-ish: drop oldest
|
|
845
|
+
// insertion-order keys). Best-effort — never throws.
|
|
846
|
+
function writeNarrowResultCache(cwd, sessionId, paths, metaToken, cliPayload) {
|
|
847
|
+
if (metaToken === null) return;
|
|
848
|
+
const fileName = narrowResultCacheFileName(sessionId);
|
|
849
|
+
const prior = readJsonState(
|
|
850
|
+
cwd,
|
|
851
|
+
fileName,
|
|
852
|
+
(parsed) => parsed && typeof parsed === "object" && parsed.results && typeof parsed.results === "object",
|
|
853
|
+
);
|
|
854
|
+
const results =
|
|
855
|
+
prior && prior.meta_token === metaToken && prior.results ? { ...prior.results } : {};
|
|
856
|
+
results[pathSetKey(paths)] = cliPayload;
|
|
857
|
+
const keys = Object.keys(results);
|
|
858
|
+
if (keys.length > NARROW_RESULT_CACHE_MAX_ENTRIES) {
|
|
859
|
+
for (const stale of keys.slice(0, keys.length - NARROW_RESULT_CACHE_MAX_ENTRIES)) {
|
|
860
|
+
delete results[stale];
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
writeJsonState(cwd, fileName, { meta_token: metaToken, results });
|
|
864
|
+
}
|
|
865
|
+
|
|
780
866
|
// -----------------------------------------------------------------------------
|
|
781
867
|
// v2.0.0-rc.33 W2 — fabric-config readers + per-file dedup-window sidecar.
|
|
782
868
|
//
|
|
@@ -1189,12 +1275,37 @@ function main(env, stdio) {
|
|
|
1189
1275
|
}
|
|
1190
1276
|
}
|
|
1191
1277
|
|
|
1278
|
+
// Resolve session id up-front (needed for the rc.37 NEW-17 result cache
|
|
1279
|
+
// key, and reused by the E3 emit-gate below).
|
|
1280
|
+
const sessionId = resolveSessionId(payload, env);
|
|
1281
|
+
|
|
1192
1282
|
// Test seam: env.cliResult short-circuits the CLI spawn so unit tests
|
|
1193
1283
|
// can feed canned plan-context-hint JSON without a built CLI binary.
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1284
|
+
//
|
|
1285
|
+
// rc.37 NEW-17: when not in test-seam mode, first consult the per-session
|
|
1286
|
+
// result cache keyed on the edited path-set. A hit (same paths, unchanged
|
|
1287
|
+
// knowledge-graph mtime) skips the redundant `fabric plan-context-hint`
|
|
1288
|
+
// cold-start spawn. Misses spawn the CLI then populate the cache. The
|
|
1289
|
+
// env.skipResultCache seam disables both read+write for tests asserting
|
|
1290
|
+
// raw spawn behaviour.
|
|
1291
|
+
let cliPayload;
|
|
1292
|
+
if (env && env.cliResult !== undefined) {
|
|
1293
|
+
cliPayload = env.cliResult;
|
|
1294
|
+
} else {
|
|
1295
|
+
const useResultCache = !(env && env.skipResultCache === true);
|
|
1296
|
+
const metaToken = useResultCache ? metaFreshnessToken(cwd) : null;
|
|
1297
|
+
const cached = useResultCache
|
|
1298
|
+
? readNarrowResultCache(cwd, sessionId, paths, metaToken)
|
|
1299
|
+
: null;
|
|
1300
|
+
if (cached !== null) {
|
|
1301
|
+
cliPayload = cached;
|
|
1302
|
+
} else {
|
|
1303
|
+
cliPayload = invokePlanContextHint(cwd, paths);
|
|
1304
|
+
if (useResultCache && cliPayload !== null && cliPayload !== undefined) {
|
|
1305
|
+
writeNarrowResultCache(cwd, sessionId, paths, metaToken, cliPayload);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1198
1309
|
if (cliPayload === null || cliPayload === undefined) return;
|
|
1199
1310
|
|
|
1200
1311
|
// Protocol v2 (rc.18 TASK-005): wire field is `entries`, no v1 shim.
|
|
@@ -1249,7 +1360,7 @@ function main(env, stdio) {
|
|
|
1249
1360
|
// can add the counter increment either here (before the early return)
|
|
1250
1361
|
// or inside applyEmitGate when render === false.
|
|
1251
1362
|
// -------------------------------------------------------------------------
|
|
1252
|
-
|
|
1363
|
+
// sessionId already resolved up-front (rc.37 NEW-17) for the result cache.
|
|
1253
1364
|
const currentRevisionHash =
|
|
1254
1365
|
typeof cliPayload.revision_hash === "string" ? cliPayload.revision_hash : "";
|
|
1255
1366
|
// Test seam: env.cacheSeed short-circuits the on-disk cache read so unit
|
|
@@ -1418,6 +1529,12 @@ module.exports = {
|
|
|
1418
1529
|
writeNarrowDedupWindow,
|
|
1419
1530
|
applyNarrowDedupWindow,
|
|
1420
1531
|
readSummaryMaxLen,
|
|
1532
|
+
// v2.0.0-rc.37 NEW-17 — plan-context-hint result cache exports for tests.
|
|
1533
|
+
metaFreshnessToken,
|
|
1534
|
+
narrowResultCacheFileName,
|
|
1535
|
+
pathSetKey,
|
|
1536
|
+
readNarrowResultCache,
|
|
1537
|
+
writeNarrowResultCache,
|
|
1421
1538
|
CONSTANTS: {
|
|
1422
1539
|
CLI_TIMEOUT_MS,
|
|
1423
1540
|
SUMMARY_MAX_LEN: DEFAULT_SUMMARY_MAX_LEN,
|
|
@@ -35,10 +35,16 @@ const FULL_RE =
|
|
|
35
35
|
const CHAINED_FROM_ID_RE = /chained-from\s+(K[TP]-[A-Z]+-\d+)/i;
|
|
36
36
|
|
|
37
37
|
const ALLOWED_TAGS = new Set([
|
|
38
|
+
// v2.0.0-rc.37 NEW-1: new simplified 2-state tag set ([applied] / [dismissed]).
|
|
39
|
+
// Old 4-state tags (planned / recalled / chained-from) accepted for
|
|
40
|
+
// backward compat — they continue to parse and count toward cite-coverage
|
|
41
|
+
// so in-flight workspaces don't lose their existing audit signal.
|
|
42
|
+
"applied",
|
|
43
|
+
"dismissed",
|
|
44
|
+
// Legacy tags (rc ≤36).
|
|
38
45
|
"planned",
|
|
39
46
|
"recalled",
|
|
40
47
|
"chained-from",
|
|
41
|
-
"dismissed",
|
|
42
48
|
"none",
|
|
43
49
|
]);
|
|
44
50
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2.0.0-rc.37 NEW-30: shared client-protocol adapter for hook scripts.
|
|
3
|
+
*
|
|
4
|
+
* The three host clients (Claude Code / Codex CLI / Cursor) differ in how a
|
|
5
|
+
* hook surfaces context back to the model:
|
|
6
|
+
* - Claude Code reads a stdout JSON envelope
|
|
7
|
+
* ({ hookSpecificOutput: { hookEventName, additionalContext } }).
|
|
8
|
+
* - Codex CLI and Cursor read plain stderr text.
|
|
9
|
+
* Each hook had its own copy of the detect-client + read-stdin + pick-channel
|
|
10
|
+
* logic (fabric-hint.detectClient, cite-policy-evict.isClaudeCode + readStdinJson,
|
|
11
|
+
* knowledge-hint-broad inline CLAUDE_PROJECT_DIR check). This module is the
|
|
12
|
+
* single canonical implementation so the protocol choice lives in one place.
|
|
13
|
+
*
|
|
14
|
+
* Provides:
|
|
15
|
+
* - detectClient(dirnameHint?) → 'cc' | 'codex' | 'cursor' | undefined
|
|
16
|
+
* 3-tier: FABRIC_HINT_CLIENT env override → CLAUDE_PROJECT_DIR (cc) →
|
|
17
|
+
* __dirname path heuristic (.claude / .codex / .cursor). dirnameHint
|
|
18
|
+
* defaults to this lib's own dir (which still lives under the client
|
|
19
|
+
* dir, e.g. .claude/hooks/lib), so the heuristic stays accurate.
|
|
20
|
+
* - isClaudeCode() → boolean (CLAUDE_PROJECT_DIR present)
|
|
21
|
+
* - readStdinJson({ timeoutMs }) → Promise<object | null>
|
|
22
|
+
* Async stdin JSON reader; null on parse error / closed stdin / timeout.
|
|
23
|
+
* - emitContext(text, { client, eventName, streams, forceStderr }) → void
|
|
24
|
+
* Standardised output: Claude Code → stdout JSON envelope; Codex/Cursor
|
|
25
|
+
* → plain stderr. forceStderr pins stderr even on Claude Code (used for
|
|
26
|
+
* SessionStart one-shot reminders). Best-effort — never throws.
|
|
27
|
+
*
|
|
28
|
+
* Never-throw contract (KT-DEC-0007): every path degrades silently rather than
|
|
29
|
+
* blocking the host's main flow.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
function isClaudeCode() {
|
|
33
|
+
return (
|
|
34
|
+
typeof process.env.CLAUDE_PROJECT_DIR === "string" &&
|
|
35
|
+
process.env.CLAUDE_PROJECT_DIR.length > 0
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function detectClient(dirnameHint) {
|
|
40
|
+
const envClient = process.env.FABRIC_HINT_CLIENT;
|
|
41
|
+
if (typeof envClient === "string" && envClient.length > 0) {
|
|
42
|
+
const normalised = envClient.trim().toLowerCase();
|
|
43
|
+
if (normalised === "cc" || normalised === "codex" || normalised === "cursor") {
|
|
44
|
+
return normalised;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (isClaudeCode()) return "cc";
|
|
48
|
+
// Path heuristic against the caller's directory (defaults to this lib's dir,
|
|
49
|
+
// which sits under the client root, e.g. .codex/hooks/lib).
|
|
50
|
+
const dir = typeof dirnameHint === "string" && dirnameHint.length > 0 ? dirnameHint : __dirname;
|
|
51
|
+
try {
|
|
52
|
+
if (dir.includes(".claude/") || dir.includes(".claude\\")) return "cc";
|
|
53
|
+
if (dir.includes(".codex/") || dir.includes(".codex\\")) return "codex";
|
|
54
|
+
if (dir.includes(".cursor/") || dir.includes(".cursor\\")) return "cursor";
|
|
55
|
+
} catch {
|
|
56
|
+
// fall through
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readStdinJson(opts) {
|
|
62
|
+
const { timeoutMs = 1000 } = opts || {};
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
let buffer = "";
|
|
65
|
+
process.stdin.on("data", (chunk) => {
|
|
66
|
+
buffer += chunk;
|
|
67
|
+
});
|
|
68
|
+
process.stdin.on("end", () => {
|
|
69
|
+
try {
|
|
70
|
+
resolve(JSON.parse(buffer));
|
|
71
|
+
} catch {
|
|
72
|
+
resolve(null);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
process.stdin.on("error", () => resolve(null));
|
|
76
|
+
// Defensive timeout: if stdin never closes (host bug), give up.
|
|
77
|
+
setTimeout(() => resolve(null), timeoutMs).unref();
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function emitContext(text, opts) {
|
|
82
|
+
const { client, eventName = "UserPromptSubmit", streams = {}, forceStderr = false } = opts || {};
|
|
83
|
+
const stdout = streams.stdout || process.stdout;
|
|
84
|
+
const stderr = streams.stderr || process.stderr;
|
|
85
|
+
const useStdoutEnvelope =
|
|
86
|
+
!forceStderr && (client === "cc" || (client === undefined && isClaudeCode()));
|
|
87
|
+
try {
|
|
88
|
+
if (useStdoutEnvelope) {
|
|
89
|
+
const envelope = {
|
|
90
|
+
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
|
|
91
|
+
};
|
|
92
|
+
stdout.write(`${JSON.stringify(envelope)}\n`);
|
|
93
|
+
} else {
|
|
94
|
+
stderr.write(`${text}\n`);
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
// best-effort — never throw
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = {
|
|
102
|
+
isClaudeCode,
|
|
103
|
+
detectClient,
|
|
104
|
+
readStdinJson,
|
|
105
|
+
emitContext,
|
|
106
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2.0.0-rc.37 NEW-19: shared fabric-config reader for hook scripts.
|
|
3
|
+
*
|
|
4
|
+
* Before this lib, every hook re-implemented the same defensive
|
|
5
|
+
* `readFileSync(.fabric/fabric-config.json) → JSON.parse → validate one key →
|
|
6
|
+
* fall back to default` boilerplate, once PER KEY (knowledge-hint-broad alone
|
|
7
|
+
* read the file 5× per SessionStart fire: cooldown / top_k / underseed /
|
|
8
|
+
* summary_max_len / reminder_to_context). This module centralises the read +
|
|
9
|
+
* mtime-keyed memoisation so a single hook fire parses the config once.
|
|
10
|
+
*
|
|
11
|
+
* Provides:
|
|
12
|
+
* - readConfig(projectRoot) → object
|
|
13
|
+
* Parsed fabric-config.json (memoised on path+mtime). Returns `{}` on
|
|
14
|
+
* any failure (missing file / parse error / non-object). Never throws.
|
|
15
|
+
* mtime-keyed so a config rewrite mid-process (test harness) invalidates
|
|
16
|
+
* the cached value automatically — production hooks are single-shot so
|
|
17
|
+
* the common case is one stat + one parse.
|
|
18
|
+
* - readConfigNumber(root, key, fallback, { min, max, integer }) → number
|
|
19
|
+
* - readConfigBoolean(root, key, fallback) → boolean
|
|
20
|
+
* - readConfigString(root, key, fallback) → string
|
|
21
|
+
* Typed getters with inline range/shape validation; any miss → fallback.
|
|
22
|
+
* - configPathFor(projectRoot) → absolute config path
|
|
23
|
+
* - clearConfigCache() → void (test helper)
|
|
24
|
+
*
|
|
25
|
+
* Never-throw contract: every export degrades to its fallback rather than
|
|
26
|
+
* throwing, preserving the reminder-layer hook invariant (KT-DEC-0007: hooks
|
|
27
|
+
* never block on their own malfunction).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const { existsSync, readFileSync, statSync } = require("node:fs");
|
|
31
|
+
const { join } = require("node:path");
|
|
32
|
+
|
|
33
|
+
const FABRIC_DIR_REL = ".fabric";
|
|
34
|
+
const FABRIC_CONFIG_FILE = "fabric-config.json";
|
|
35
|
+
|
|
36
|
+
// path → { mtime, value }. Per-process; mtime-keyed for test-mutation safety.
|
|
37
|
+
const _cache = new Map();
|
|
38
|
+
|
|
39
|
+
function configPathFor(projectRoot) {
|
|
40
|
+
return join(projectRoot, FABRIC_DIR_REL, FABRIC_CONFIG_FILE);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readConfig(projectRoot) {
|
|
44
|
+
const path = configPathFor(projectRoot);
|
|
45
|
+
let mtime;
|
|
46
|
+
try {
|
|
47
|
+
if (!existsSync(path)) {
|
|
48
|
+
_cache.delete(path);
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
mtime = statSync(path).mtimeMs;
|
|
52
|
+
} catch {
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
55
|
+
const cached = _cache.get(path);
|
|
56
|
+
if (cached && cached.mtime === mtime) return cached.value;
|
|
57
|
+
let value = {};
|
|
58
|
+
try {
|
|
59
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
60
|
+
if (raw && typeof raw === "object") value = raw;
|
|
61
|
+
} catch {
|
|
62
|
+
value = {};
|
|
63
|
+
}
|
|
64
|
+
_cache.set(path, { mtime, value });
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function clearConfigCache() {
|
|
69
|
+
_cache.clear();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// opts:
|
|
73
|
+
// min / max — inclusive range; out-of-range → fallback
|
|
74
|
+
// integer — require Number.isInteger; non-integer → fallback (strict)
|
|
75
|
+
// floor — accept any in-range number, return Math.floor(v) (lenient)
|
|
76
|
+
// `integer` and `floor` are independent: `integer` rejects fractional values,
|
|
77
|
+
// `floor` truncates them. Pick whichever matches the caller's legacy contract.
|
|
78
|
+
function readConfigNumber(projectRoot, key, fallback, opts) {
|
|
79
|
+
const { min, max, integer, floor } = opts || {};
|
|
80
|
+
const v = readConfig(projectRoot)[key];
|
|
81
|
+
if (typeof v === "number" && Number.isFinite(v)) {
|
|
82
|
+
if (integer && !Number.isInteger(v)) return fallback;
|
|
83
|
+
if (typeof min === "number" && v < min) return fallback;
|
|
84
|
+
if (typeof max === "number" && v > max) return fallback;
|
|
85
|
+
return floor ? Math.floor(v) : v;
|
|
86
|
+
}
|
|
87
|
+
return fallback;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function readConfigBoolean(projectRoot, key, fallback) {
|
|
91
|
+
const v = readConfig(projectRoot)[key];
|
|
92
|
+
return typeof v === "boolean" ? v : fallback;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function readConfigString(projectRoot, key, fallback) {
|
|
96
|
+
const v = readConfig(projectRoot)[key];
|
|
97
|
+
return typeof v === "string" && v.length > 0 ? v : fallback;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = {
|
|
101
|
+
readConfig,
|
|
102
|
+
clearConfigCache,
|
|
103
|
+
readConfigNumber,
|
|
104
|
+
readConfigBoolean,
|
|
105
|
+
readConfigString,
|
|
106
|
+
configPathFor,
|
|
107
|
+
};
|