@fenglimg/fabric-cli 2.0.1 → 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-D25XJ4BC.js → chunk-2R55HNVD.js} +105 -5
- package/dist/chunk-4R2CYEA4.js +116 -0
- package/dist/{chunk-BATF4PEJ.js → chunk-AOE6AYI7.js} +2 -2
- package/dist/{chunk-WWNXR34K.js → chunk-BO4XIZWZ.js} +8 -1
- package/dist/chunk-L4Q55UC4.js +52 -0
- package/dist/chunk-LFIKMVY7.js +27 -0
- package/dist/chunk-RYAFBNES.js +33 -0
- package/dist/chunk-T5RPGCCM.js +40 -0
- package/dist/chunk-WU6GAPKH.js +36 -0
- package/dist/{chunk-MF3OTILQ.js → chunk-XC5RUHLK.js} +29 -8
- package/dist/{config-XJIPZNUP.js → config-XYRBZJDU.js} +3 -3
- package/dist/{doctor-EJDSEJSS.js → doctor-YONYXDX6.js} +147 -24
- package/dist/index.js +58 -10
- package/dist/{install-EKWMFLUU.js → install-74ANPCCP.js} +320 -75
- 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-CDIZESP5.js +37 -0
- package/dist/status-GLQWLWH6.js +23 -0
- package/dist/store-XB3ADT65.js +144 -0
- package/dist/sync-UJ4BBCZJ.js +251 -0
- package/dist/{uninstall-MH7ZIB6M.js → uninstall-C3QXKOO6.js} +47 -7
- package/dist/whoami-2MLO4Y37.js +36 -0
- package/package.json +3 -3
- package/templates/hooks/fabric-hint.cjs +139 -7
- package/templates/hooks/knowledge-hint-broad.cjs +204 -9
- package/templates/hooks/knowledge-hint-narrow.cjs +49 -4
- package/templates/hooks/lib/bindings-snapshot-reader.cjs +81 -0
- package/templates/hooks/lib/cite-contract-reminder.cjs +15 -9
- package/templates/hooks/lib/cite-line-parser.cjs +48 -26
- package/templates/hooks/lib/injection-log.cjs +91 -0
- package/templates/hooks/lib/state-store.cjs +30 -11
- package/templates/skills/fabric-archive/SKILL.md +4 -0
- package/templates/skills/fabric-audit/SKILL.md +53 -0
- package/templates/skills/fabric-connect/SKILL.md +48 -0
- package/templates/skills/fabric-import/SKILL.md +4 -0
- package/templates/skills/fabric-review/SKILL.md +6 -0
- package/templates/skills/fabric-review/ref/cite-contract.md +56 -0
- package/templates/skills/fabric-store/SKILL.md +44 -0
- package/templates/skills/fabric-sync/SKILL.md +46 -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;
|
|
@@ -81,6 +87,27 @@ try {
|
|
|
81
87
|
stateStore = null;
|
|
82
88
|
}
|
|
83
89
|
|
|
90
|
+
// v2.1.0-rc.1 P4 (F4/S63): hook-side reader for the CLI pre-generated
|
|
91
|
+
// resolved-bindings snapshot. The Stop hint surfaces the read-set stores
|
|
92
|
+
// (per-store, NOT aggregated into one pile) without re-resolving / walking
|
|
93
|
+
// store trees. Best-effort — a missing lib/snapshot omits the store line.
|
|
94
|
+
let bindingsSnapshotReader = null;
|
|
95
|
+
try {
|
|
96
|
+
bindingsSnapshotReader = require("./lib/bindings-snapshot-reader.cjs");
|
|
97
|
+
} catch {
|
|
98
|
+
bindingsSnapshotReader = null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Read the project's own `project_id` (the snapshot key) from its config.
|
|
102
|
+
function readProjectId(cwd) {
|
|
103
|
+
try {
|
|
104
|
+
const parsed = JSON.parse(readFileSync(join(cwd, ".fabric", "fabric-config.json"), "utf8"));
|
|
105
|
+
return typeof parsed.project_id === "string" ? parsed.project_id : null;
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
84
111
|
// CONSTANTS — duplicated from packages/server/src/services/_shared.ts.
|
|
85
112
|
// DRY violation accepted: this hook script runs in user repos WITHOUT
|
|
86
113
|
// node_modules access, so it cannot import from @fenglimg/fabric-server.
|
|
@@ -762,6 +789,11 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
|
|
|
762
789
|
reason,
|
|
763
790
|
signal: "archive",
|
|
764
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,
|
|
765
797
|
};
|
|
766
798
|
}
|
|
767
799
|
|
|
@@ -798,6 +830,10 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
|
|
|
798
830
|
reason,
|
|
799
831
|
signal: "review",
|
|
800
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,
|
|
801
837
|
};
|
|
802
838
|
}
|
|
803
839
|
|
|
@@ -848,6 +884,10 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
|
|
|
848
884
|
reason,
|
|
849
885
|
signal: "import",
|
|
850
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,
|
|
851
891
|
};
|
|
852
892
|
}
|
|
853
893
|
|
|
@@ -958,8 +998,15 @@ function readShownCache(projectRoot) {
|
|
|
958
998
|
function writeShownCache(projectRoot, cache) {
|
|
959
999
|
const cachePath = join(projectRoot, SHOWN_CACHE_FILE);
|
|
960
1000
|
try {
|
|
961
|
-
|
|
962
|
-
|
|
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
|
+
}
|
|
963
1010
|
} catch {
|
|
964
1011
|
// Silent — cache failure must never block the hook.
|
|
965
1012
|
}
|
|
@@ -1096,8 +1143,13 @@ function readMaintenanceLastEmit(projectRoot) {
|
|
|
1096
1143
|
function writeMaintenanceLastEmit(projectRoot, nowMs) {
|
|
1097
1144
|
const p = join(projectRoot, MAINTENANCE_HINT_LAST_EMIT_FILE);
|
|
1098
1145
|
try {
|
|
1099
|
-
|
|
1100
|
-
|
|
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
|
+
}
|
|
1101
1153
|
} catch {
|
|
1102
1154
|
// Silent — sidecar failure must never block the hook.
|
|
1103
1155
|
}
|
|
@@ -1186,9 +1238,64 @@ function evaluateMaintenanceSignal(events, now, canonicalCount, lastEmitMs, thre
|
|
|
1186
1238
|
signal: "maintenance",
|
|
1187
1239
|
// CLI recommendation rather than Skill — doctor is a CLI surface.
|
|
1188
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,
|
|
1189
1246
|
};
|
|
1190
1247
|
}
|
|
1191
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
|
+
|
|
1192
1299
|
/**
|
|
1193
1300
|
* v2.0.0-rc.7 T5: best-effort sync stdin reader for the Stop hook.
|
|
1194
1301
|
*
|
|
@@ -1393,7 +1500,7 @@ function extractAndWriteAssistantTurnsBestEffort(cwd, stdinPayload) {
|
|
|
1393
1500
|
timestamp: new Date().toISOString(),
|
|
1394
1501
|
};
|
|
1395
1502
|
if (client !== undefined) event.client = client;
|
|
1396
|
-
|
|
1503
|
+
appendLockedLine(ledgerPath, JSON.stringify(event) + "\n");
|
|
1397
1504
|
} catch {
|
|
1398
1505
|
// Per-turn failure must not abort the remaining turns; the Stop hook
|
|
1399
1506
|
// contract is "never block on hook failure". Best-effort continues.
|
|
@@ -1418,7 +1525,7 @@ function extractAndWriteAssistantTurnsBestEffort(cwd, stdinPayload) {
|
|
|
1418
1525
|
counters: { [counterKey]: emptyShellCount },
|
|
1419
1526
|
};
|
|
1420
1527
|
const metricsPath = join(fabricDir, METRICS_LEDGER_FILE);
|
|
1421
|
-
|
|
1528
|
+
appendLockedLine(metricsPath, JSON.stringify(metricsRow) + "\n");
|
|
1422
1529
|
} catch {
|
|
1423
1530
|
// metrics fold is observability-only; never block the hook on failure.
|
|
1424
1531
|
}
|
|
@@ -1907,10 +2014,32 @@ function main(env, stdio) {
|
|
|
1907
2014
|
result.reason = `${result.reason}\n${renderDismissOption(result.signal, variant)}`;
|
|
1908
2015
|
}
|
|
1909
2016
|
|
|
2017
|
+
// v2.1.0-rc.1 P4 (F4/S63): surface the read-set stores on the Stop hint so
|
|
2018
|
+
// backlog/maintenance nudges are read per-store, not as one undifferentiated
|
|
2019
|
+
// pile. Best-effort; missing snapshot / single-store omits the line.
|
|
2020
|
+
if (bindingsSnapshotReader !== null && typeof result.reason === "string") {
|
|
2021
|
+
try {
|
|
2022
|
+
const projectId = readProjectId(cwd);
|
|
2023
|
+
if (projectId) {
|
|
2024
|
+
const label = bindingsSnapshotReader.formatStoreLabels(
|
|
2025
|
+
bindingsSnapshotReader.readBindingsSnapshot(projectId),
|
|
2026
|
+
);
|
|
2027
|
+
if (label) {
|
|
2028
|
+
result.reason = `${result.reason}\n${label}`;
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
} catch {
|
|
2032
|
+
// store label is decorative provenance — never crash the hook
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
|
|
1910
2036
|
// v2.0.0-rc.7 T10: Signal D uses its own cooldown sidecar (day-based,
|
|
1911
2037
|
// see MAINTENANCE_HINT_LAST_EMIT_FILE). The A/B/C shared cooldown cache
|
|
1912
2038
|
// uses hours, so we branch here to avoid mixing semantics.
|
|
1913
2039
|
if (result.signal === "maintenance") {
|
|
2040
|
+
emitSignalFiredEvent(cwd, sessionId, result);
|
|
2041
|
+
delete result.threshold;
|
|
2042
|
+
delete result.actual_value;
|
|
1914
2043
|
out.write(JSON.stringify(result));
|
|
1915
2044
|
writeMaintenanceLastEmit(cwd, nowMs);
|
|
1916
2045
|
return;
|
|
@@ -1932,6 +2061,9 @@ function main(env, stdio) {
|
|
|
1932
2061
|
return; // Still in cooldown — silent.
|
|
1933
2062
|
}
|
|
1934
2063
|
|
|
2064
|
+
emitSignalFiredEvent(cwd, sessionId, result);
|
|
2065
|
+
delete result.threshold;
|
|
2066
|
+
delete result.actual_value;
|
|
1935
2067
|
out.write(JSON.stringify(result));
|
|
1936
2068
|
cache[result.signal] = nowMs;
|
|
1937
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,43 @@ 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");
|
|
78
|
+
// v2.1.0-rc.1 P4 (F4/S63): hook-side reader for the CLI pre-generated
|
|
79
|
+
// resolved-bindings snapshot. The hook NEVER re-resolves stores or walks store
|
|
80
|
+
// trees — it only echoes the read-set the CLI already computed. Best-effort.
|
|
81
|
+
let bindingsSnapshotReader = null;
|
|
82
|
+
try {
|
|
83
|
+
bindingsSnapshotReader = require("./lib/bindings-snapshot-reader.cjs");
|
|
84
|
+
} catch {
|
|
85
|
+
// Lib missing (old install) — store labels degrade to silent absence.
|
|
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
|
+
}
|
|
96
|
+
|
|
97
|
+
// Read the project's own `project_id` from `.fabric/fabric-config.json` (the
|
|
98
|
+
// snapshot key). Reading the PROJECT config is not a store-tree read — it is how
|
|
99
|
+
// the hook learns which snapshot to fetch. Returns null on any failure.
|
|
100
|
+
function readProjectId(cwd) {
|
|
101
|
+
try {
|
|
102
|
+
const raw = readFileSync(join(cwd, ".fabric", "fabric-config.json"), "utf8");
|
|
103
|
+
const parsed = JSON.parse(raw);
|
|
104
|
+
return typeof parsed.project_id === "string" ? parsed.project_id : null;
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
71
109
|
|
|
72
110
|
// -----------------------------------------------------------------------------
|
|
73
111
|
// rc.12: SessionStart broad-menu is now unconditionally emitted on every
|
|
@@ -327,6 +365,65 @@ const CLI_TIMEOUT_MS = 2000;
|
|
|
327
365
|
// `hint_summary_max_len` in fabric-config overrides this default (range 40..240).
|
|
328
366
|
const DEFAULT_SUMMARY_MAX_LEN = 80;
|
|
329
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
|
+
|
|
330
427
|
function readSummaryMaxLen(projectRoot) {
|
|
331
428
|
return readConfigNumber(projectRoot, "hint_summary_max_len", DEFAULT_SUMMARY_MAX_LEN, {
|
|
332
429
|
min: 40,
|
|
@@ -565,7 +662,7 @@ function renderTruncated(narrow, maxLen) {
|
|
|
565
662
|
* after writing exactly one stderr breadcrumb so operators grepping a stuck-
|
|
566
663
|
* banner report can diagnose the version drift without source-diving.
|
|
567
664
|
*/
|
|
568
|
-
function renderSummary(payload, maxLen) {
|
|
665
|
+
function renderSummary(payload, maxLen, budgetChars) {
|
|
569
666
|
if (!payload || payload.version !== 2) {
|
|
570
667
|
if (payload && payload.version !== undefined) {
|
|
571
668
|
try {
|
|
@@ -588,7 +685,9 @@ function renderSummary(payload, maxLen) {
|
|
|
588
685
|
? `[fabric] Session start — ${entries.length} broad-scoped knowledge entries available (truncated):`
|
|
589
686
|
: `[fabric] Session start — ${entries.length} broad-scoped knowledge entries available:`;
|
|
590
687
|
|
|
591
|
-
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);
|
|
592
691
|
|
|
593
692
|
const lines = [banner, ...body];
|
|
594
693
|
const revHash = typeof payload.revision_hash === "string" ? payload.revision_hash : null;
|
|
@@ -635,7 +734,18 @@ function renderSummary(payload, maxLen) {
|
|
|
635
734
|
}
|
|
636
735
|
}
|
|
637
736
|
|
|
638
|
-
|
|
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
|
+
);
|
|
639
749
|
return lines;
|
|
640
750
|
}
|
|
641
751
|
|
|
@@ -722,7 +832,9 @@ function main(env, stdio) {
|
|
|
722
832
|
// for the agent's working memory. rc.33 W2-5 reintroduces an opt-in
|
|
723
833
|
// hours-based cooldown via fabric-config (see gate above).
|
|
724
834
|
const summaryMaxLen = readSummaryMaxLen(cwd);
|
|
725
|
-
|
|
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);
|
|
726
838
|
|
|
727
839
|
// v2.0.0-rc.37 NEW-23: resolve fabric_language ONCE per emit path —
|
|
728
840
|
// shared between the (existing) broadImportBanner branch and the new
|
|
@@ -735,14 +847,35 @@ function main(env, stdio) {
|
|
|
735
847
|
|
|
736
848
|
if (lines.length === 0) return; // nothing to say — silent exit
|
|
737
849
|
|
|
850
|
+
// v2.1.0-rc.1 P4 (F4/S63): append a per-store read-set label from the
|
|
851
|
+
// CLI-pre-generated bindings snapshot so the session opens aware of which
|
|
852
|
+
// stores it reads and where writes land. Best-effort, never blocks: a
|
|
853
|
+
// missing snapshot / single-store setup just omits the line.
|
|
854
|
+
if (bindingsSnapshotReader !== null) {
|
|
855
|
+
try {
|
|
856
|
+
const projectId = readProjectId(cwd);
|
|
857
|
+
if (projectId) {
|
|
858
|
+
const label = bindingsSnapshotReader.formatStoreLabels(
|
|
859
|
+
bindingsSnapshotReader.readBindingsSnapshot(projectId),
|
|
860
|
+
);
|
|
861
|
+
if (label) lines.push(label);
|
|
862
|
+
}
|
|
863
|
+
} catch {
|
|
864
|
+
// store labels are decorative provenance — never crash the hook
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
738
868
|
// v2.0.0-rc.37 NEW-23: SessionStart 索引末尾"下一步"引导。Tail line that
|
|
739
869
|
// tells the AI what to do with the broad index it just received. Without
|
|
740
870
|
// this, the model often parses the index and moves on without ever calling
|
|
741
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.
|
|
742
875
|
const nextStepNudge =
|
|
743
876
|
fabricLanguageForEmit === "zh-CN"
|
|
744
|
-
? "下一步: 调 fab_recall(paths) 拿 KB 相关条目;或调 fab_plan_context
|
|
745
|
-
: "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.";
|
|
746
879
|
lines.push(nextStepNudge);
|
|
747
880
|
|
|
748
881
|
// Stderr: always emit (human-facing breadcrumb + legacy contract).
|
|
@@ -750,6 +883,26 @@ function main(env, stdio) {
|
|
|
750
883
|
err.write(`${line}\n`);
|
|
751
884
|
}
|
|
752
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
|
+
|
|
753
906
|
// v2.0.0-rc.33 W2-6 (P0-7): stdout JSON envelope. When
|
|
754
907
|
// hint_reminder_to_context is true (default), serialize the same banner
|
|
755
908
|
// body as Claude Code's SessionStart hookSpecificOutput shape so the model
|
|
@@ -782,6 +935,48 @@ function main(env, stdio) {
|
|
|
782
935
|
}
|
|
783
936
|
}
|
|
784
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
|
+
|
|
785
980
|
// v2.0.0-rc.33 W2-5 (P1-8): record successful emit timestamp for the
|
|
786
981
|
// cooldown gate's next-invocation check. Skip when cooldown is disabled
|
|
787
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,33 @@ 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");
|
|
94
|
+
// v2.1.0-rc.1 P4 (F4/S63): hook-side reader for the CLI pre-generated
|
|
95
|
+
// resolved-bindings snapshot. Store-aware hint surfaces the write-target store
|
|
96
|
+
// for the edited file WITHOUT re-resolving or walking store trees. Best-effort.
|
|
97
|
+
let bindingsSnapshotReader = null;
|
|
98
|
+
try {
|
|
99
|
+
bindingsSnapshotReader = require("./lib/bindings-snapshot-reader.cjs");
|
|
100
|
+
} catch {
|
|
101
|
+
// Lib missing (old install) — store labels degrade to silent absence.
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Read the project's own `project_id` (the snapshot key) from its config. Not a
|
|
105
|
+
// store-tree read — it is how the hook learns which snapshot to fetch.
|
|
106
|
+
function readProjectId(cwd) {
|
|
107
|
+
try {
|
|
108
|
+
const parsed = JSON.parse(readFileSync(join(cwd, ".fabric", "fabric-config.json"), "utf8"));
|
|
109
|
+
return typeof parsed.project_id === "string" ? parsed.project_id : null;
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
88
114
|
|
|
89
115
|
// -----------------------------------------------------------------------------
|
|
90
116
|
// CONSTANTS
|
|
@@ -407,7 +433,7 @@ function appendEditIntentToLedger(projectRoot, now, paths, toolName, sessionId)
|
|
|
407
433
|
window_ms: 0,
|
|
408
434
|
}))
|
|
409
435
|
.join("\n") + "\n";
|
|
410
|
-
|
|
436
|
+
appendLockedLine(join(fabricDir, EVENTS_LEDGER_FILE), lines);
|
|
411
437
|
} catch {
|
|
412
438
|
// Silent — events ledger failure must never block the edit.
|
|
413
439
|
}
|
|
@@ -451,7 +477,7 @@ function appendEditCounter(projectRoot, now, paths) {
|
|
|
451
477
|
.filter((p) => typeof p === "string" && p.length > 0)
|
|
452
478
|
: [];
|
|
453
479
|
const line = JSON.stringify({ ts: iso, paths: pathList });
|
|
454
|
-
|
|
480
|
+
appendLockedLine(file, `${line}\n`);
|
|
455
481
|
} catch {
|
|
456
482
|
// Silent — sidecar failure must never block the edit.
|
|
457
483
|
}
|
|
@@ -481,7 +507,7 @@ function appendHintSilenceCounter(projectRoot, now) {
|
|
|
481
507
|
mkdirSync(dir, { recursive: true });
|
|
482
508
|
}
|
|
483
509
|
const iso = now instanceof Date ? now.toISOString() : new Date(now).toISOString();
|
|
484
|
-
|
|
510
|
+
appendLockedLine(file, `${iso}\n`);
|
|
485
511
|
} catch {
|
|
486
512
|
// Silent — sidecar failure must never block the edit.
|
|
487
513
|
}
|
|
@@ -1466,6 +1492,25 @@ function main(env, stdio) {
|
|
|
1466
1492
|
const lines = renderSummary({ ...cliPayload, entries: resolvedEntries }, summaryMaxLen);
|
|
1467
1493
|
if (lines.length === 0) return;
|
|
1468
1494
|
|
|
1495
|
+
// v2.1.0-rc.1 P4 (F4/S63): store-aware hint — append the write-target store
|
|
1496
|
+
// so the edit-time hint says WHERE a derived knowledge entry would land.
|
|
1497
|
+
// Best-effort; missing snapshot / single-store setup omits the line.
|
|
1498
|
+
if (bindingsSnapshotReader !== null) {
|
|
1499
|
+
try {
|
|
1500
|
+
const projectId = readProjectId(cwd);
|
|
1501
|
+
if (projectId) {
|
|
1502
|
+
const snapshot = bindingsSnapshotReader.readBindingsSnapshot(projectId);
|
|
1503
|
+
const writeAlias =
|
|
1504
|
+
snapshot && snapshot.write_target && snapshot.write_target.alias;
|
|
1505
|
+
if (writeAlias) {
|
|
1506
|
+
lines.push(`[fabric] writes here land in store '${writeAlias}'`);
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
} catch {
|
|
1510
|
+
// store label is decorative provenance — never crash the hook
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1469
1514
|
// Stderr: human-facing breadcrumb + legacy contract.
|
|
1470
1515
|
for (const line of lines) {
|
|
1471
1516
|
err.write(`${line}\n`);
|