@fenglimg/fabric-cli 2.1.0-rc.2 → 2.2.0-rc.1
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-PWLW3B57.js → chunk-2CY4BMTH.js} +5 -1
- package/dist/{chunk-F46ORPOA.js → chunk-2R55HNVD.js} +82 -5
- package/dist/{chunk-HFQVXY6P.js → chunk-4R2CYEA4.js} +31 -1
- package/dist/{chunk-BATF4PEJ.js → chunk-AOE6AYI7.js} +2 -2
- package/dist/{chunk-WWNXR34K.js → chunk-BO4XIZWZ.js} +8 -1
- package/dist/{chunk-MF3OTILQ.js → chunk-XC5RUHLK.js} +29 -8
- package/dist/{config-XJIPZNUP.js → config-XYRBZJDU.js} +3 -3
- package/dist/{doctor-QVNPHLJK.js → doctor-YONYXDX6.js} +39 -26
- package/dist/index.js +54 -14
- package/dist/{install-2HDO5FTQ.js → install-74ANPCCP.js} +88 -34
- package/dist/{metrics-ACEQFPDU.js → metrics-RER6NLFC.js} +22 -9
- package/dist/{onboard-coverage-MFCAEBDO.js → onboard-coverage-JWQWDZW7.js} +1 -1
- package/dist/{scope-explain-2F2R5URO.js → scope-explain-CDIZESP5.js} +6 -2
- package/dist/{store-XTSE5TY6.js → store-XB3ADT65.js} +50 -11
- package/dist/{sync-BJCWDPNC.js → sync-UJ4BBCZJ.js} +18 -12
- package/dist/{uninstall-TAXSUSKH.js → uninstall-C3QXKOO6.js} +35 -4
- package/dist/{whoami-B6AEMSEV.js → whoami-2MLO4Y37.js} +10 -5
- package/package.json +3 -3
- package/templates/hooks/fabric-hint.cjs +99 -7
- package/templates/hooks/knowledge-hint-broad.cjs +164 -9
- package/templates/hooks/knowledge-hint-narrow.cjs +10 -4
- package/templates/hooks/lib/injection-log.cjs +91 -0
- package/templates/hooks/lib/state-store.cjs +30 -11
- package/templates/skills/fabric-audit/SKILL.md +53 -0
- package/templates/skills/fabric-connect/SKILL.md +48 -0
- package/templates/skills/fabric-review/SKILL.md +2 -0
- package/templates/skills/fabric-review/ref/cite-contract.md +56 -0
- package/templates/skills/fabric-store/SKILL.md +44 -0
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
getProjectTranslator
|
|
4
|
+
} from "./chunk-2CY4BMTH.js";
|
|
2
5
|
import {
|
|
3
6
|
whoami
|
|
4
7
|
} from "./chunk-T5RPGCCM.js";
|
|
@@ -10,19 +13,21 @@ import { defineCommand } from "citty";
|
|
|
10
13
|
var whoami_default = defineCommand({
|
|
11
14
|
meta: { name: "whoami", description: "Show this machine's Fabric uid and mounted stores" },
|
|
12
15
|
run() {
|
|
16
|
+
const t = getProjectTranslator();
|
|
13
17
|
const info = whoami();
|
|
14
18
|
if (info === null) {
|
|
15
|
-
console.log("no
|
|
19
|
+
console.log(t("cli.cmd.no-global-config"));
|
|
16
20
|
return;
|
|
17
21
|
}
|
|
18
|
-
console.log(
|
|
22
|
+
console.log(t("cli.whoami.uid", { uid: info.uid }));
|
|
19
23
|
if (info.stores.length === 0) {
|
|
20
|
-
console.log("stores
|
|
24
|
+
console.log(t("cli.whoami.stores-none"));
|
|
21
25
|
return;
|
|
22
26
|
}
|
|
23
|
-
console.log("stores
|
|
27
|
+
console.log(t("cli.whoami.stores-label"));
|
|
28
|
+
const localOnly = t("cli.shared.local-only");
|
|
24
29
|
for (const store of info.stores) {
|
|
25
|
-
console.log(` ${store.alias} ${store.store_uuid}${store.local_only ?
|
|
30
|
+
console.log(` ${store.alias} ${store.store_uuid}${store.local_only ? ` ${localOnly}` : ""}`);
|
|
26
31
|
}
|
|
27
32
|
}
|
|
28
33
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fenglimg/fabric-cli",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0-rc.1",
|
|
4
4
|
"description": "Fabric CLI — installs the MCP server + skills + hooks for Claude Code, Cursor, and Codex CLI; runs doctor / knowledge maintenance.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "wangzhichao <fenglimg90@gmail.com>",
|
|
@@ -45,8 +45,8 @@
|
|
|
45
45
|
"tree-sitter-javascript": "^0.25.0",
|
|
46
46
|
"tree-sitter-typescript": "^0.23.2",
|
|
47
47
|
"web-tree-sitter": "^0.26.8",
|
|
48
|
-
"@fenglimg/fabric-server": "2.
|
|
49
|
-
"@fenglimg/fabric-shared": "2.
|
|
48
|
+
"@fenglimg/fabric-server": "2.2.0-rc.1",
|
|
49
|
+
"@fenglimg/fabric-shared": "2.2.0-rc.1"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@types/node": "^22.15.0",
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
const {
|
|
2
|
+
const { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } = require("node:fs");
|
|
3
3
|
const { dirname, join } = require("node:path");
|
|
4
4
|
|
|
5
|
+
// W1-01 (ISS-012): Stop / SessionStart hooks append to shared, non-session-scoped
|
|
6
|
+
// ledgers (events.jsonl, metrics.jsonl). Under multi-window concurrency a bare
|
|
7
|
+
// appendFileSync can interleave a partial write; route through the advisory-lock
|
|
8
|
+
// primitive (drop-on-contention, best-effort — matches injection-log).
|
|
9
|
+
const { appendLockedLine } = require("./lib/injection-log.cjs");
|
|
10
|
+
|
|
5
11
|
// v2.0.0-rc.7 T5: session-digest writer. Best-effort (never blocks Stop hook
|
|
6
12
|
// on failure — see contract in lib/session-digest-writer.cjs).
|
|
7
13
|
let sessionDigestWriter = null;
|
|
@@ -783,6 +789,11 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
|
|
|
783
789
|
reason,
|
|
784
790
|
signal: "archive",
|
|
785
791
|
recommended_skill: "fabric-archive",
|
|
792
|
+
// v2.1 NEW-N-3: surface the firing sub-signal's numbers for the
|
|
793
|
+
// hook_signal_emitted ledger row main() writes. Dual trigger (24h OR
|
|
794
|
+
// N-edits): report the hours pair when it fired, else the edit-count pair.
|
|
795
|
+
threshold: triggerByHours ? archiveHintHours : editStats.threshold,
|
|
796
|
+
actual_value: triggerByHours ? hoursElapsed : editStats.editsSinceLastProposed,
|
|
786
797
|
};
|
|
787
798
|
}
|
|
788
799
|
|
|
@@ -819,6 +830,10 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
|
|
|
819
830
|
reason,
|
|
820
831
|
signal: "review",
|
|
821
832
|
recommended_skill: "fabric-review",
|
|
833
|
+
// v2.1 NEW-N-3: dual trigger (pending-count OR oldest-age). Report the
|
|
834
|
+
// count pair when it fired, else the oldest-age-in-days pair.
|
|
835
|
+
threshold: triggerByPendingCount ? reviewHintPendingCount : reviewHintPendingAgeDays,
|
|
836
|
+
actual_value: triggerByPendingCount ? stats.count : stats.oldestAgeMs / MS_PER_DAY,
|
|
822
837
|
};
|
|
823
838
|
}
|
|
824
839
|
|
|
@@ -869,6 +884,10 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
|
|
|
869
884
|
reason,
|
|
870
885
|
signal: "import",
|
|
871
886
|
recommended_skill: "fabric-import",
|
|
887
|
+
// v2.1 NEW-N-3: underseed corpus trigger — node-count vs threshold. The
|
|
888
|
+
// "import" signal collapses to schema signal_type "other" in main().
|
|
889
|
+
threshold: underseed.threshold,
|
|
890
|
+
actual_value: underseed.nodeCount,
|
|
872
891
|
};
|
|
873
892
|
}
|
|
874
893
|
|
|
@@ -979,8 +998,15 @@ function readShownCache(projectRoot) {
|
|
|
979
998
|
function writeShownCache(projectRoot, cache) {
|
|
980
999
|
const cachePath = join(projectRoot, SHOWN_CACHE_FILE);
|
|
981
1000
|
try {
|
|
982
|
-
|
|
983
|
-
|
|
1001
|
+
// ISS-016: atomic tmp+rename so concurrent windows / a crash never leave a
|
|
1002
|
+
// truncated shown-cache (this file is NOT session-scoped). Falls back to a
|
|
1003
|
+
// plain write only if the shared lib failed to load.
|
|
1004
|
+
if (stateStore && typeof stateStore.atomicWrite === "function") {
|
|
1005
|
+
stateStore.atomicWrite(cachePath, JSON.stringify(cache));
|
|
1006
|
+
} else {
|
|
1007
|
+
mkdirSync(dirname(cachePath), { recursive: true });
|
|
1008
|
+
writeFileSync(cachePath, JSON.stringify(cache));
|
|
1009
|
+
}
|
|
984
1010
|
} catch {
|
|
985
1011
|
// Silent — cache failure must never block the hook.
|
|
986
1012
|
}
|
|
@@ -1117,8 +1143,13 @@ function readMaintenanceLastEmit(projectRoot) {
|
|
|
1117
1143
|
function writeMaintenanceLastEmit(projectRoot, nowMs) {
|
|
1118
1144
|
const p = join(projectRoot, MAINTENANCE_HINT_LAST_EMIT_FILE);
|
|
1119
1145
|
try {
|
|
1120
|
-
|
|
1121
|
-
|
|
1146
|
+
// ISS-016: atomic tmp+rename (see writeShownCache).
|
|
1147
|
+
if (stateStore && typeof stateStore.atomicWrite === "function") {
|
|
1148
|
+
stateStore.atomicWrite(p, new Date(nowMs).toISOString());
|
|
1149
|
+
} else {
|
|
1150
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
1151
|
+
writeFileSync(p, new Date(nowMs).toISOString());
|
|
1152
|
+
}
|
|
1122
1153
|
} catch {
|
|
1123
1154
|
// Silent — sidecar failure must never block the hook.
|
|
1124
1155
|
}
|
|
@@ -1207,9 +1238,64 @@ function evaluateMaintenanceSignal(events, now, canonicalCount, lastEmitMs, thre
|
|
|
1207
1238
|
signal: "maintenance",
|
|
1208
1239
|
// CLI recommendation rather than Skill — doctor is a CLI surface.
|
|
1209
1240
|
recommended_skill: null,
|
|
1241
|
+
// v2.1 NEW-N-3: staleness trigger. threshold=days; actual=ageDays. When
|
|
1242
|
+
// lint was NEVER run ageDays is null — main() skips the signal emit rather
|
|
1243
|
+
// than fabricate a number (honest gap over fake telemetry).
|
|
1244
|
+
threshold: days,
|
|
1245
|
+
actual_value: ageDays,
|
|
1210
1246
|
};
|
|
1211
1247
|
}
|
|
1212
1248
|
|
|
1249
|
+
// v2.1 NEW-N-3 (ADJ-NEWN-3): hook_signal_emitted instrumentation. Writes ONE
|
|
1250
|
+
// best-effort ledger row at the point a nudge is actually delivered (post-
|
|
1251
|
+
// cooldown), so the join key measures nudge-trigger logic (which signal fired,
|
|
1252
|
+
// at what threshold vs. actual). Emitted at delivery rather than at
|
|
1253
|
+
// threshold-cross so it inherits the cooldown gate — a fired-but-cooled signal
|
|
1254
|
+
// does not spam the ledger every session. Skips silently when threshold /
|
|
1255
|
+
// actual_value are not finite numbers (e.g. maintenance "never run" → null
|
|
1256
|
+
// age). Never blocks the hook (KT-DEC-0007).
|
|
1257
|
+
const SIGNAL_TYPE_ENUM = new Set(["archive", "review", "maintenance", "other"]);
|
|
1258
|
+
function emitSignalFiredEvent(cwd, sessionId, result) {
|
|
1259
|
+
try {
|
|
1260
|
+
if (!result || typeof result.signal !== "string") return;
|
|
1261
|
+
const threshold = result.threshold;
|
|
1262
|
+
const actualValue = result.actual_value;
|
|
1263
|
+
if (
|
|
1264
|
+
typeof threshold !== "number" ||
|
|
1265
|
+
!Number.isFinite(threshold) ||
|
|
1266
|
+
typeof actualValue !== "number" ||
|
|
1267
|
+
!Number.isFinite(actualValue)
|
|
1268
|
+
) {
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
const fabricDir = join(cwd, FABRIC_DIR);
|
|
1272
|
+
if (!existsSync(fabricDir)) return;
|
|
1273
|
+
// "import" / any non-canonical signal collapses to schema's catch-all "other".
|
|
1274
|
+
const signalType = SIGNAL_TYPE_ENUM.has(result.signal) ? result.signal : "other";
|
|
1275
|
+
let idSuffix;
|
|
1276
|
+
try {
|
|
1277
|
+
idSuffix = require("node:crypto").randomUUID();
|
|
1278
|
+
} catch {
|
|
1279
|
+
idSuffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
1280
|
+
}
|
|
1281
|
+
const event = {
|
|
1282
|
+
kind: "fabric-event",
|
|
1283
|
+
id: `event:${idSuffix}`,
|
|
1284
|
+
ts: Date.now(),
|
|
1285
|
+
schema_version: 1,
|
|
1286
|
+
event_type: "hook_signal_emitted",
|
|
1287
|
+
signal_type: signalType,
|
|
1288
|
+
threshold,
|
|
1289
|
+
actual_value: actualValue,
|
|
1290
|
+
fired: true,
|
|
1291
|
+
};
|
|
1292
|
+
if (typeof sessionId === "string" && sessionId.length > 0) event.session_id = sessionId;
|
|
1293
|
+
appendLockedLine(join(fabricDir, EVENT_LEDGER_FILE), JSON.stringify(event) + "\n");
|
|
1294
|
+
} catch {
|
|
1295
|
+
// best-effort telemetry — never block the hook
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1213
1299
|
/**
|
|
1214
1300
|
* v2.0.0-rc.7 T5: best-effort sync stdin reader for the Stop hook.
|
|
1215
1301
|
*
|
|
@@ -1414,7 +1500,7 @@ function extractAndWriteAssistantTurnsBestEffort(cwd, stdinPayload) {
|
|
|
1414
1500
|
timestamp: new Date().toISOString(),
|
|
1415
1501
|
};
|
|
1416
1502
|
if (client !== undefined) event.client = client;
|
|
1417
|
-
|
|
1503
|
+
appendLockedLine(ledgerPath, JSON.stringify(event) + "\n");
|
|
1418
1504
|
} catch {
|
|
1419
1505
|
// Per-turn failure must not abort the remaining turns; the Stop hook
|
|
1420
1506
|
// contract is "never block on hook failure". Best-effort continues.
|
|
@@ -1439,7 +1525,7 @@ function extractAndWriteAssistantTurnsBestEffort(cwd, stdinPayload) {
|
|
|
1439
1525
|
counters: { [counterKey]: emptyShellCount },
|
|
1440
1526
|
};
|
|
1441
1527
|
const metricsPath = join(fabricDir, METRICS_LEDGER_FILE);
|
|
1442
|
-
|
|
1528
|
+
appendLockedLine(metricsPath, JSON.stringify(metricsRow) + "\n");
|
|
1443
1529
|
} catch {
|
|
1444
1530
|
// metrics fold is observability-only; never block the hook on failure.
|
|
1445
1531
|
}
|
|
@@ -1951,6 +2037,9 @@ function main(env, stdio) {
|
|
|
1951
2037
|
// see MAINTENANCE_HINT_LAST_EMIT_FILE). The A/B/C shared cooldown cache
|
|
1952
2038
|
// uses hours, so we branch here to avoid mixing semantics.
|
|
1953
2039
|
if (result.signal === "maintenance") {
|
|
2040
|
+
emitSignalFiredEvent(cwd, sessionId, result);
|
|
2041
|
+
delete result.threshold;
|
|
2042
|
+
delete result.actual_value;
|
|
1954
2043
|
out.write(JSON.stringify(result));
|
|
1955
2044
|
writeMaintenanceLastEmit(cwd, nowMs);
|
|
1956
2045
|
return;
|
|
@@ -1972,6 +2061,9 @@ function main(env, stdio) {
|
|
|
1972
2061
|
return; // Still in cooldown — silent.
|
|
1973
2062
|
}
|
|
1974
2063
|
|
|
2064
|
+
emitSignalFiredEvent(cwd, sessionId, result);
|
|
2065
|
+
delete result.threshold;
|
|
2066
|
+
delete result.actual_value;
|
|
1975
2067
|
out.write(JSON.stringify(result));
|
|
1976
2068
|
cache[result.signal] = nowMs;
|
|
1977
2069
|
writeShownCache(cwd, cache);
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
* - <id> · <summary>
|
|
23
23
|
* ...
|
|
24
24
|
* revision_hash: <hash>
|
|
25
|
-
*
|
|
25
|
+
* Load full content: `fab_recall(paths)`, or `fab_plan_context` -> `fab_get_knowledge_sections` to trim first.
|
|
26
26
|
*
|
|
27
27
|
* When narrow count > 30 (grouped-truncation mode, per type):
|
|
28
28
|
* [fabric] Session start — N broad-scoped knowledge entries available (truncated):
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
* [decision] draft: 7 entries
|
|
34
34
|
* ...
|
|
35
35
|
* revision_hash: <hash>
|
|
36
|
-
*
|
|
36
|
+
* Load full content: `fab_recall(paths)`, or `fab_plan_context` -> `fab_get_knowledge_sections` to trim first.
|
|
37
37
|
*
|
|
38
38
|
* When 0 entries / CLI unavailable / CLI error / parse failure:
|
|
39
39
|
* (no output — silent exit 0)
|
|
@@ -50,6 +50,12 @@ const { spawnSync } = require("node:child_process");
|
|
|
50
50
|
const { existsSync, readdirSync, readFileSync } = require("node:fs");
|
|
51
51
|
const { join } = require("node:path");
|
|
52
52
|
|
|
53
|
+
// W1-01 (ISS-012): the SessionStart broad hook appends a hook_surface_emitted
|
|
54
|
+
// event to the shared events.jsonl. Under multi-window concurrency a bare
|
|
55
|
+
// appendFileSync can interleave a partial write; route through the advisory-lock
|
|
56
|
+
// primitive (drop-on-contention, best-effort — matches injection-log).
|
|
57
|
+
const { appendLockedLine } = require("./lib/injection-log.cjs");
|
|
58
|
+
|
|
53
59
|
// rc.16 TASK-003: shared banner-i18n lib (resolves fabric_language config and
|
|
54
60
|
// renders localized banner text). Mirror of the wiring in fabric-hint.cjs
|
|
55
61
|
// (TASK-002). Variant is resolved ONCE per main() invocation via
|
|
@@ -63,11 +69,12 @@ const { resolveOpaqueSummaries } = require("./lib/summary-fallback.cjs");
|
|
|
63
69
|
const {
|
|
64
70
|
readConfigNumber,
|
|
65
71
|
readConfigBoolean,
|
|
72
|
+
readConfigString,
|
|
66
73
|
} = require("./lib/config-cache.cjs");
|
|
67
74
|
const { readTextState, writeTextState } = require("./lib/state-store.cjs");
|
|
68
75
|
// v2.0.0-rc.37 NEW-30: shared client detection (replaces the inline
|
|
69
76
|
// CLAUDE_PROJECT_DIR single-bit check below).
|
|
70
|
-
const { isClaudeCode } = require("./lib/client-adapter.cjs");
|
|
77
|
+
const { isClaudeCode, detectClient } = require("./lib/client-adapter.cjs");
|
|
71
78
|
// v2.1.0-rc.1 P4 (F4/S63): hook-side reader for the CLI pre-generated
|
|
72
79
|
// resolved-bindings snapshot. The hook NEVER re-resolves stores or walks store
|
|
73
80
|
// trees — it only echoes the read-set the CLI already computed. Best-effort.
|
|
@@ -77,6 +84,15 @@ try {
|
|
|
77
84
|
} catch {
|
|
78
85
|
// Lib missing (old install) — store labels degrade to silent absence.
|
|
79
86
|
}
|
|
87
|
+
// v2.2 HK3-telemetry (W3-T1): injection-side per-inject logger. Optional require
|
|
88
|
+
// so an old install lacking the lib degrades to silent absence (no telemetry,
|
|
89
|
+
// hook still works).
|
|
90
|
+
let injectionLog = null;
|
|
91
|
+
try {
|
|
92
|
+
injectionLog = require("./lib/injection-log.cjs");
|
|
93
|
+
} catch {
|
|
94
|
+
// Lib missing (old install) — injection telemetry degrades to silent absence.
|
|
95
|
+
}
|
|
80
96
|
|
|
81
97
|
// Read the project's own `project_id` from `.fabric/fabric-config.json` (the
|
|
82
98
|
// snapshot key). Reading the PROJECT config is not a store-tree read — it is how
|
|
@@ -349,6 +365,65 @@ const CLI_TIMEOUT_MS = 2000;
|
|
|
349
365
|
// `hint_summary_max_len` in fabric-config overrides this default (range 40..240).
|
|
350
366
|
const DEFAULT_SUMMARY_MAX_LEN = 80;
|
|
351
367
|
|
|
368
|
+
// v2.2 HK2-degrade (W2-T2): char budget for the rendered broad-menu BODY. The
|
|
369
|
+
// hook already degrades by COUNT (hint_broad_top_k slice + TRUNCATION_THRESHOLD
|
|
370
|
+
// grouped mode), but nothing bounded the total rendered SIZE — a corpus with
|
|
371
|
+
// many types or long (near-maxLen) summaries could still emit a wall of text
|
|
372
|
+
// that displaces the agent's working memory. Borrowing the maestro
|
|
373
|
+
// context-budget idea, this is the final rung of the degradation ladder: once
|
|
374
|
+
// the body exceeds the budget, the tail collapses to a single "N more omitted"
|
|
375
|
+
// marker. Default 2000 chars ≈ one screenful. Overridable via
|
|
376
|
+
// fabric-config.json#hint_broad_budget_chars (range 200..20000); 0 disables.
|
|
377
|
+
const DEFAULT_HINT_BROAD_BUDGET_CHARS = 2000;
|
|
378
|
+
|
|
379
|
+
// v2.2 C5-budget (W2-T3): bind the injection char budget to the layered retrieval
|
|
380
|
+
// budget profile. Mirrors the injectionChars column of shared/retrieval-budget.ts
|
|
381
|
+
// PROFILES (kept in sync — the hook cannot require the TS resolver). The explicit
|
|
382
|
+
// `hint_broad_budget_chars` knob still wins; the profile only supplies the
|
|
383
|
+
// default. `balanced` (and an absent/unknown profile) keeps the historical 2000.
|
|
384
|
+
const RETRIEVAL_BUDGET_INJECTION_CHARS = {
|
|
385
|
+
conservative: 1000,
|
|
386
|
+
balanced: 2000,
|
|
387
|
+
generous: 4000,
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
function readBroadBudgetChars(projectRoot) {
|
|
391
|
+
const profile = readConfigString(projectRoot, "retrieval_budget_profile", "balanced");
|
|
392
|
+
const profileDefault =
|
|
393
|
+
RETRIEVAL_BUDGET_INJECTION_CHARS[profile] ?? DEFAULT_HINT_BROAD_BUDGET_CHARS;
|
|
394
|
+
return readConfigNumber(projectRoot, "hint_broad_budget_chars", profileDefault, {
|
|
395
|
+
min: 0,
|
|
396
|
+
max: 20000,
|
|
397
|
+
floor: true,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// v2.2 HK2-degrade (W2-T2): cap the rendered body to `budgetChars`, collapsing
|
|
402
|
+
// the overflow tail into one marker line. Structural lines (banner, revision_hash,
|
|
403
|
+
// footer) are appended by renderSummary AFTER this pass, so they always survive —
|
|
404
|
+
// only entry/group body lines are subject to the budget. `budgetChars` of 0 or
|
|
405
|
+
// undefined is a no-op (preserves the pre-HK2 unbounded behavior and all
|
|
406
|
+
// existing snapshot tests).
|
|
407
|
+
function capBodyToBudget(body, budgetChars) {
|
|
408
|
+
if (!budgetChars || budgetChars <= 0) return body;
|
|
409
|
+
const kept = [];
|
|
410
|
+
let total = 0;
|
|
411
|
+
for (let i = 0; i < body.length; i += 1) {
|
|
412
|
+
const line = body[i];
|
|
413
|
+
// +1 for the newline each line costs once joined.
|
|
414
|
+
if (kept.length > 0 && total + line.length + 1 > budgetChars) {
|
|
415
|
+
const remaining = body.length - i;
|
|
416
|
+
kept.push(
|
|
417
|
+
` … ${remaining} more entr${remaining === 1 ? "y" : "ies"} omitted (injection budget ${budgetChars} chars; raise hint_broad_budget_chars or narrow scope)`,
|
|
418
|
+
);
|
|
419
|
+
return kept;
|
|
420
|
+
}
|
|
421
|
+
kept.push(line);
|
|
422
|
+
total += line.length + 1;
|
|
423
|
+
}
|
|
424
|
+
return kept;
|
|
425
|
+
}
|
|
426
|
+
|
|
352
427
|
function readSummaryMaxLen(projectRoot) {
|
|
353
428
|
return readConfigNumber(projectRoot, "hint_summary_max_len", DEFAULT_SUMMARY_MAX_LEN, {
|
|
354
429
|
min: 40,
|
|
@@ -587,7 +662,7 @@ function renderTruncated(narrow, maxLen) {
|
|
|
587
662
|
* after writing exactly one stderr breadcrumb so operators grepping a stuck-
|
|
588
663
|
* banner report can diagnose the version drift without source-diving.
|
|
589
664
|
*/
|
|
590
|
-
function renderSummary(payload, maxLen) {
|
|
665
|
+
function renderSummary(payload, maxLen, budgetChars) {
|
|
591
666
|
if (!payload || payload.version !== 2) {
|
|
592
667
|
if (payload && payload.version !== undefined) {
|
|
593
668
|
try {
|
|
@@ -610,7 +685,9 @@ function renderSummary(payload, maxLen) {
|
|
|
610
685
|
? `[fabric] Session start — ${entries.length} broad-scoped knowledge entries available (truncated):`
|
|
611
686
|
: `[fabric] Session start — ${entries.length} broad-scoped knowledge entries available:`;
|
|
612
687
|
|
|
613
|
-
const
|
|
688
|
+
const renderedBody = truncated ? renderTruncated(entries, maxLen) : renderFull(entries, maxLen);
|
|
689
|
+
// v2.2 HK2-degrade (W2-T2): final budget rung — cap the body's rendered size.
|
|
690
|
+
const body = capBodyToBudget(renderedBody, budgetChars);
|
|
614
691
|
|
|
615
692
|
const lines = [banner, ...body];
|
|
616
693
|
const revHash = typeof payload.revision_hash === "string" ? payload.revision_hash : null;
|
|
@@ -657,7 +734,18 @@ function renderSummary(payload, maxLen) {
|
|
|
657
734
|
}
|
|
658
735
|
}
|
|
659
736
|
|
|
660
|
-
|
|
737
|
+
// v2.2 MC3-fix-guidance (W1-T5): unify the footer with the canonical recall
|
|
738
|
+
// flow. The prior text ("Use `fab_get_knowledge_sections` to fetch full
|
|
739
|
+
// content.") told the agent to call a tool that REQUIRES a selection_token it
|
|
740
|
+
// does not yet have — directly contradicting the bilingual next-step nudge
|
|
741
|
+
// (and AGENTS.md) which leads with `fab_recall`. Footer now states the same
|
|
742
|
+
// two-path model: single-step `fab_recall`, or `fab_plan_context` →
|
|
743
|
+
// `fab_get_knowledge_sections` when the bodies must be trimmed first. Keeps
|
|
744
|
+
// the `fab_get_knowledge_sections` token (downstream substring contracts) but
|
|
745
|
+
// sequences it correctly behind the token-issuing `fab_plan_context`.
|
|
746
|
+
lines.push(
|
|
747
|
+
" Load full content: `fab_recall(paths)` (one step), or `fab_plan_context` → `fab_get_knowledge_sections` to trim first.",
|
|
748
|
+
);
|
|
661
749
|
return lines;
|
|
662
750
|
}
|
|
663
751
|
|
|
@@ -744,7 +832,9 @@ function main(env, stdio) {
|
|
|
744
832
|
// for the agent's working memory. rc.33 W2-5 reintroduces an opt-in
|
|
745
833
|
// hours-based cooldown via fabric-config (see gate above).
|
|
746
834
|
const summaryMaxLen = readSummaryMaxLen(cwd);
|
|
747
|
-
|
|
835
|
+
// v2.2 HK2-degrade (W2-T2): thread the injection char-budget into the renderer.
|
|
836
|
+
const broadBudgetChars = readBroadBudgetChars(cwd);
|
|
837
|
+
const lines = renderSummary(resolvedPayload, summaryMaxLen, broadBudgetChars);
|
|
748
838
|
|
|
749
839
|
// v2.0.0-rc.37 NEW-23: resolve fabric_language ONCE per emit path —
|
|
750
840
|
// shared between the (existing) broadImportBanner branch and the new
|
|
@@ -779,10 +869,13 @@ function main(env, stdio) {
|
|
|
779
869
|
// tells the AI what to do with the broad index it just received. Without
|
|
780
870
|
// this, the model often parses the index and moves on without ever calling
|
|
781
871
|
// fab_recall / fab_plan_context. One-line nudge, bilingual.
|
|
872
|
+
// v2.2 W1-REVIEW codex LOW-6: `description_index` was renamed to `candidates`
|
|
873
|
+
// in rc.38 UX-1; the nudge now uses the current field name so the guidance
|
|
874
|
+
// matches the actual MCP response shape.
|
|
782
875
|
const nextStepNudge =
|
|
783
876
|
fabricLanguageForEmit === "zh-CN"
|
|
784
|
-
? "下一步: 调 fab_recall(paths) 拿 KB 相关条目;或调 fab_plan_context
|
|
785
|
-
: "Next: call fab_recall(paths) to fetch related KB entries, or fab_plan_context to preview the
|
|
877
|
+
? "下一步: 调 fab_recall(paths) 拿 KB 相关条目;或调 fab_plan_context 先看候选描述(candidates)。"
|
|
878
|
+
: "Next: call fab_recall(paths) to fetch related KB entries, or fab_plan_context to preview the candidate descriptions first.";
|
|
786
879
|
lines.push(nextStepNudge);
|
|
787
880
|
|
|
788
881
|
// Stderr: always emit (human-facing breadcrumb + legacy contract).
|
|
@@ -790,6 +883,26 @@ function main(env, stdio) {
|
|
|
790
883
|
err.write(`${line}\n`);
|
|
791
884
|
}
|
|
792
885
|
|
|
886
|
+
// v2.2 HK3-telemetry (W3-T1): record the injection side. We just OFFERED the
|
|
887
|
+
// agent `resolvedPayload.entries` (the top_k-sliced broad menu); log their
|
|
888
|
+
// ids so the true hit rate (consumed ÷ injected) is computable against the
|
|
889
|
+
// consumption-side metrics.jsonl. Best-effort — never affects the emit.
|
|
890
|
+
if (injectionLog !== null) {
|
|
891
|
+
const injectedEntries = Array.isArray(resolvedPayload && resolvedPayload.entries)
|
|
892
|
+
? resolvedPayload.entries
|
|
893
|
+
: [];
|
|
894
|
+
injectionLog.logInjection(cwd, {
|
|
895
|
+
surface: "broad",
|
|
896
|
+
stableIds: injectedEntries.map((e) => (e && e.id) || "").filter(Boolean),
|
|
897
|
+
count: injectedEntries.length,
|
|
898
|
+
revisionHash:
|
|
899
|
+
resolvedPayload && typeof resolvedPayload.revision_hash === "string"
|
|
900
|
+
? resolvedPayload.revision_hash
|
|
901
|
+
: null,
|
|
902
|
+
ts: nowMs,
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
|
|
793
906
|
// v2.0.0-rc.33 W2-6 (P0-7): stdout JSON envelope. When
|
|
794
907
|
// hint_reminder_to_context is true (default), serialize the same banner
|
|
795
908
|
// body as Claude Code's SessionStart hookSpecificOutput shape so the model
|
|
@@ -822,6 +935,48 @@ function main(env, stdio) {
|
|
|
822
935
|
}
|
|
823
936
|
}
|
|
824
937
|
|
|
938
|
+
// v2.1 NEW-N-3 (ADJ-NEWN-3): hook_surface_emitted instrumentation. One
|
|
939
|
+
// best-effort ledger row recording WHICH broad-scoped ids were surfaced
|
|
940
|
+
// into the session — the join key for measuring hook→behavior delta (did
|
|
941
|
+
// the agent fab_recall what the hook surfaced?). SessionStart fires once
|
|
942
|
+
// per session boot so this never bloats the ledger. Never blocks the hook
|
|
943
|
+
// (KT-DEC-0007): any failure (no .fabric/, undetected client, write error)
|
|
944
|
+
// degrades to silent skip. Client is omitted-by-skip when undetectable
|
|
945
|
+
// because the schema's `client` enum admits only cc/codex/cursor.
|
|
946
|
+
try {
|
|
947
|
+
const surfaceClient = detectClient();
|
|
948
|
+
const fabricDir = join(cwd, FABRIC_DIR_REL);
|
|
949
|
+
if (surfaceClient !== undefined && existsSync(fabricDir)) {
|
|
950
|
+
const renderedIds =
|
|
951
|
+
resolvedPayload && Array.isArray(resolvedPayload.entries)
|
|
952
|
+
? resolvedPayload.entries
|
|
953
|
+
.map((e) => (e && typeof e.id === "string" ? e.id : null))
|
|
954
|
+
.filter((x) => x !== null)
|
|
955
|
+
: [];
|
|
956
|
+
let idSuffix;
|
|
957
|
+
try {
|
|
958
|
+
idSuffix = require("node:crypto").randomUUID();
|
|
959
|
+
} catch {
|
|
960
|
+
idSuffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
961
|
+
}
|
|
962
|
+
const surfaceEvent = {
|
|
963
|
+
kind: "fabric-event",
|
|
964
|
+
id: `event:${idSuffix}`,
|
|
965
|
+
ts: Date.now(),
|
|
966
|
+
schema_version: 1,
|
|
967
|
+
event_type: "hook_surface_emitted",
|
|
968
|
+
hook_name: "knowledge-hint-broad",
|
|
969
|
+
client: surfaceClient,
|
|
970
|
+
target_channel: reminderToContext ? "stdout-additionalContext" : "stderr",
|
|
971
|
+
rendered_ids: renderedIds,
|
|
972
|
+
delivery_status: "delivered",
|
|
973
|
+
};
|
|
974
|
+
appendLockedLine(join(fabricDir, "events.jsonl"), JSON.stringify(surfaceEvent) + "\n");
|
|
975
|
+
}
|
|
976
|
+
} catch {
|
|
977
|
+
// best-effort telemetry — never block session start
|
|
978
|
+
}
|
|
979
|
+
|
|
825
980
|
// v2.0.0-rc.33 W2-5 (P1-8): record successful emit timestamp for the
|
|
826
981
|
// cooldown gate's next-invocation check. Skip when cooldown is disabled
|
|
827
982
|
// (cooldownHours === 0) to avoid polluting the FS with a never-read
|
|
@@ -66,7 +66,6 @@
|
|
|
66
66
|
const { spawnSync } = require("node:child_process");
|
|
67
67
|
const { createHash, randomUUID } = require("node:crypto");
|
|
68
68
|
const {
|
|
69
|
-
appendFileSync,
|
|
70
69
|
existsSync,
|
|
71
70
|
mkdirSync,
|
|
72
71
|
readFileSync,
|
|
@@ -85,6 +84,13 @@ const { resolveOpaqueSummaries } = require("./lib/summary-fallback.cjs");
|
|
|
85
84
|
// cache (skips a redundant CLI cold-start spawn when the same path-set is
|
|
86
85
|
// re-edited within a session and the knowledge graph hasn't changed).
|
|
87
86
|
const { readJsonState, writeJsonState } = require("./lib/state-store.cjs");
|
|
87
|
+
// W1-01 (ISS-011): the PreToolUse hook is the highest-frequency, most
|
|
88
|
+
// concurrency-exposed write surface in Fabric. Multi-window edits spawn
|
|
89
|
+
// concurrent hook processes that all append to the SAME non-session-scoped
|
|
90
|
+
// ledger/counter files; a bare appendFileSync can interleave a partial write
|
|
91
|
+
// and corrupt a line. Route every shared-file append through the advisory-lock
|
|
92
|
+
// primitive (drop-on-contention, best-effort — matches injection-log).
|
|
93
|
+
const { appendLockedLine } = require("./lib/injection-log.cjs");
|
|
88
94
|
// v2.1.0-rc.1 P4 (F4/S63): hook-side reader for the CLI pre-generated
|
|
89
95
|
// resolved-bindings snapshot. Store-aware hint surfaces the write-target store
|
|
90
96
|
// for the edited file WITHOUT re-resolving or walking store trees. Best-effort.
|
|
@@ -427,7 +433,7 @@ function appendEditIntentToLedger(projectRoot, now, paths, toolName, sessionId)
|
|
|
427
433
|
window_ms: 0,
|
|
428
434
|
}))
|
|
429
435
|
.join("\n") + "\n";
|
|
430
|
-
|
|
436
|
+
appendLockedLine(join(fabricDir, EVENTS_LEDGER_FILE), lines);
|
|
431
437
|
} catch {
|
|
432
438
|
// Silent — events ledger failure must never block the edit.
|
|
433
439
|
}
|
|
@@ -471,7 +477,7 @@ function appendEditCounter(projectRoot, now, paths) {
|
|
|
471
477
|
.filter((p) => typeof p === "string" && p.length > 0)
|
|
472
478
|
: [];
|
|
473
479
|
const line = JSON.stringify({ ts: iso, paths: pathList });
|
|
474
|
-
|
|
480
|
+
appendLockedLine(file, `${line}\n`);
|
|
475
481
|
} catch {
|
|
476
482
|
// Silent — sidecar failure must never block the edit.
|
|
477
483
|
}
|
|
@@ -501,7 +507,7 @@ function appendHintSilenceCounter(projectRoot, now) {
|
|
|
501
507
|
mkdirSync(dir, { recursive: true });
|
|
502
508
|
}
|
|
503
509
|
const iso = now instanceof Date ? now.toISOString() : new Date(now).toISOString();
|
|
504
|
-
|
|
510
|
+
appendLockedLine(file, `${iso}\n`);
|
|
505
511
|
} catch {
|
|
506
512
|
// Silent — sidecar failure must never block the edit.
|
|
507
513
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// v2.2 HK3-telemetry (W3-T1): injection-side telemetry. `.fabric/metrics.jsonl`
|
|
2
|
+
// (server) records the CONSUMPTION side — which knowledge the agent actually
|
|
3
|
+
// fetched/consumed. But nothing recorded the INJECTION side — which knowledge a
|
|
4
|
+
// hook OFFERED the agent at SessionStart / PreToolUse. Without that denominator
|
|
5
|
+
// the "true hit rate" (consumed ÷ injected) cannot be computed: a high consume
|
|
6
|
+
// count tells you nothing if the hook injected ten times as many entries.
|
|
7
|
+
//
|
|
8
|
+
// This lib appends one row per injection to `.fabric/injections.jsonl`:
|
|
9
|
+
// { ts, surface: "broad"|"narrow", count, stable_ids: [...], revision_hash }
|
|
10
|
+
//
|
|
11
|
+
// Best-effort + synchronous: hooks are short-lived processes, so a sync append
|
|
12
|
+
// is simpler than threading async, and ANY failure is swallowed — telemetry
|
|
13
|
+
// must never break or delay the hook (failure invariant: silent). Concurrent
|
|
14
|
+
// writers from multiple windows are serialized with an advisory lock (see
|
|
15
|
+
// appendLockedLine below) so a contended write can't corrupt a line.
|
|
16
|
+
|
|
17
|
+
const { appendFileSync, mkdirSync, openSync, closeSync, statSync, rmSync } = require("node:fs");
|
|
18
|
+
const { join, dirname } = require("node:path");
|
|
19
|
+
|
|
20
|
+
// Multi-window concurrency guard (ADJ-W3-INJECTION-CONCURRENCY): the same repo
|
|
21
|
+
// is frequently edited from several client sessions at once, so multiple hook
|
|
22
|
+
// processes can append to injections.jsonl simultaneously. A bare appendFileSync
|
|
23
|
+
// can interleave a partial write under contention and corrupt a line. We guard
|
|
24
|
+
// each append with an advisory lock file created atomically via O_EXCL ("wx"):
|
|
25
|
+
// - acquired → write the row, then release the lock
|
|
26
|
+
// - contended → DROP this row. Telemetry is best-effort; a missing row only
|
|
27
|
+
// shrinks the denominator slightly, and dropping is what keeps
|
|
28
|
+
// the ledger from ever being corrupted by an interleave.
|
|
29
|
+
// - stale → a holder that crashed leaves the lock behind; reclaim it once
|
|
30
|
+
// past STALE_LOCK_MS so contention can't wedge forever.
|
|
31
|
+
const STALE_LOCK_MS = 5000;
|
|
32
|
+
|
|
33
|
+
function appendLockedLine(path, line) {
|
|
34
|
+
const lockPath = `${path}.lock`;
|
|
35
|
+
let fd;
|
|
36
|
+
try {
|
|
37
|
+
fd = openSync(lockPath, "wx"); // atomic create-exclusive = acquire
|
|
38
|
+
} catch (err) {
|
|
39
|
+
if (!err || err.code !== "EEXIST") return; // unexpected → drop (best-effort)
|
|
40
|
+
try {
|
|
41
|
+
if (Date.now() - statSync(lockPath).mtimeMs <= STALE_LOCK_MS) return; // fresh holder → drop
|
|
42
|
+
rmSync(lockPath, { force: true }); // stale holder crashed → reclaim
|
|
43
|
+
fd = openSync(lockPath, "wx");
|
|
44
|
+
} catch {
|
|
45
|
+
return; // racing another reclaimer → drop
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
closeSync(fd);
|
|
50
|
+
appendFileSync(path, line);
|
|
51
|
+
} finally {
|
|
52
|
+
try {
|
|
53
|
+
rmSync(lockPath, { force: true });
|
|
54
|
+
} catch {
|
|
55
|
+
/* lock already released */
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Append one injection record to `<projectRoot>/.fabric/injections.jsonl`.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} projectRoot
|
|
64
|
+
* @param {{ surface: "broad"|"narrow", stableIds?: string[], count?: number, revisionHash?: string|null, ts?: number }} record
|
|
65
|
+
*/
|
|
66
|
+
function logInjection(projectRoot, record) {
|
|
67
|
+
try {
|
|
68
|
+
if (!projectRoot || !record || typeof record.surface !== "string") {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const stableIds = Array.isArray(record.stableIds) ? record.stableIds.filter((id) => typeof id === "string") : [];
|
|
72
|
+
const count = typeof record.count === "number" ? record.count : stableIds.length;
|
|
73
|
+
if (count <= 0) {
|
|
74
|
+
return; // nothing injected → no row (keeps the denominator honest)
|
|
75
|
+
}
|
|
76
|
+
const row = {
|
|
77
|
+
ts: typeof record.ts === "number" ? record.ts : Date.now(),
|
|
78
|
+
surface: record.surface,
|
|
79
|
+
count,
|
|
80
|
+
stable_ids: stableIds,
|
|
81
|
+
revision_hash: typeof record.revisionHash === "string" ? record.revisionHash : null,
|
|
82
|
+
};
|
|
83
|
+
const path = join(projectRoot, ".fabric", "injections.jsonl");
|
|
84
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
85
|
+
appendLockedLine(path, `${JSON.stringify(row)}\n`);
|
|
86
|
+
} catch {
|
|
87
|
+
// Telemetry is best-effort — never crash or delay the hook.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { logInjection, appendLockedLine };
|