@fenglimg/fabric-cli 2.1.0-rc.2 → 2.2.0-rc.3
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-5LQIHYFC.js +64 -0
- package/dist/chunk-5ZUMLCD5.js +248 -0
- package/dist/{chunk-WWNXR34K.js → chunk-BO4XIZWZ.js} +8 -1
- package/dist/chunk-EOT63RDH.js +36 -0
- package/dist/{chunk-BATF4PEJ.js → chunk-F6ITRM7T.js} +4 -4
- package/dist/{chunk-WU6GAPKH.js → chunk-H3FE6VIK.js} +3 -5
- package/dist/{chunk-MF3OTILQ.js → chunk-XC5RUHLK.js} +29 -8
- package/dist/chunk-XCBVSGCS.js +25 -0
- package/dist/{chunk-F46ORPOA.js → chunk-XHHCRDIR.js} +149 -7
- package/dist/{config-XJIPZNUP.js → config-VJMXCLXW.js} +3 -3
- package/dist/{doctor-QVNPHLJK.js → doctor-J4O3X54I.js} +154 -30
- package/dist/index.js +57 -16
- package/dist/{install-2HDO5FTQ.js → install-BULNDUIM.js} +241 -108
- package/dist/{metrics-ACEQFPDU.js → metrics-RER6NLFC.js} +22 -9
- package/dist/{onboard-coverage-MFCAEBDO.js → onboard-coverage-JWQWDZW7.js} +1 -1
- package/dist/{plan-context-hint-FC6P3WFE.js → plan-context-hint-CHVZGOZ5.js} +21 -8
- package/dist/{scope-explain-2F2R5URO.js → scope-explain-BWRWBCCP.js} +19 -5
- package/dist/{status-GLQWLWH6.js → status-PANEGKU2.js} +17 -6
- package/dist/store-66NK2FTQ.js +443 -0
- package/dist/sync-EA5HZMXM.js +395 -0
- package/dist/{uninstall-TAXSUSKH.js → uninstall-F75MPKQC.js} +61 -4
- package/dist/whoami-66YKY5DZ.js +47 -0
- package/package.json +3 -3
- package/templates/hooks/cite-policy-evict.cjs +412 -160
- package/templates/hooks/configs/claude-code.json +17 -2
- package/templates/hooks/configs/codex-hooks.json +14 -2
- package/templates/hooks/configs/cursor-hooks.json +14 -2
- package/templates/hooks/fabric-hint.cjs +247 -19
- package/templates/hooks/knowledge-hint-broad.cjs +176 -10
- package/templates/hooks/knowledge-hint-narrow.cjs +64 -5
- package/templates/hooks/lib/injection-log.cjs +91 -0
- package/templates/hooks/lib/state-store.cjs +30 -11
- package/templates/hooks/post-tooluse-mutation.cjs +285 -0
- package/templates/hooks/session-end-marker.cjs +140 -0
- package/templates/skills/fabric-archive/SKILL.md +7 -1
- 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
- package/dist/chunk-HFQVXY6P.js +0 -86
- package/dist/chunk-L4Q55UC4.js +0 -52
- package/dist/chunk-LFIKMVY7.js +0 -27
- package/dist/chunk-RYAFBNES.js +0 -33
- package/dist/chunk-T5RPGCCM.js +0 -40
- package/dist/store-XTSE5TY6.js +0 -105
- package/dist/sync-BJCWDPNC.js +0 -245
- package/dist/whoami-B6AEMSEV.js +0 -31
|
@@ -29,17 +29,32 @@
|
|
|
29
29
|
{
|
|
30
30
|
"type": "command",
|
|
31
31
|
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/knowledge-hint-narrow.cjs"
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"type": "command",
|
|
35
|
+
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/cite-policy-evict.cjs"
|
|
32
36
|
}
|
|
33
37
|
]
|
|
34
38
|
}
|
|
35
39
|
],
|
|
36
|
-
"
|
|
40
|
+
"PostToolUse": [
|
|
41
|
+
{
|
|
42
|
+
"matcher": "Edit|Write|MultiEdit",
|
|
43
|
+
"hooks": [
|
|
44
|
+
{
|
|
45
|
+
"type": "command",
|
|
46
|
+
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/post-tooluse-mutation.cjs"
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
],
|
|
51
|
+
"SessionEnd": [
|
|
37
52
|
{
|
|
38
53
|
"matcher": "*",
|
|
39
54
|
"hooks": [
|
|
40
55
|
{
|
|
41
56
|
"type": "command",
|
|
42
|
-
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/
|
|
57
|
+
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/session-end-marker.cjs"
|
|
43
58
|
}
|
|
44
59
|
]
|
|
45
60
|
}
|
|
@@ -8,15 +8,27 @@
|
|
|
8
8
|
"SessionStart": [
|
|
9
9
|
{
|
|
10
10
|
"command": "\"$(git rev-parse --show-toplevel)/.codex/hooks/knowledge-hint-broad.cjs\""
|
|
11
|
+
}
|
|
12
|
+
],
|
|
13
|
+
"PreToolUse": [
|
|
14
|
+
{
|
|
15
|
+
"matcher": "Edit|Write|MultiEdit",
|
|
16
|
+
"command": "\"$(git rev-parse --show-toplevel)/.codex/hooks/knowledge-hint-narrow.cjs\""
|
|
11
17
|
},
|
|
12
18
|
{
|
|
19
|
+
"matcher": "Edit|Write|MultiEdit",
|
|
13
20
|
"command": "\"$(git rev-parse --show-toplevel)/.codex/hooks/cite-policy-evict.cjs\""
|
|
14
21
|
}
|
|
15
22
|
],
|
|
16
|
-
"
|
|
23
|
+
"PostToolUse": [
|
|
17
24
|
{
|
|
18
25
|
"matcher": "Edit|Write|MultiEdit",
|
|
19
|
-
"command": "\"$(git rev-parse --show-toplevel)/.codex/hooks/
|
|
26
|
+
"command": "\"$(git rev-parse --show-toplevel)/.codex/hooks/post-tooluse-mutation.cjs\""
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
"SessionEnd": [
|
|
30
|
+
{
|
|
31
|
+
"command": "\"$(git rev-parse --show-toplevel)/.codex/hooks/session-end-marker.cjs\""
|
|
20
32
|
}
|
|
21
33
|
]
|
|
22
34
|
}
|
|
@@ -5,14 +5,26 @@
|
|
|
5
5
|
{ "command": ".cursor/hooks/fabric-hint.cjs" }
|
|
6
6
|
],
|
|
7
7
|
"sessionStart": [
|
|
8
|
-
{ "command": ".cursor/hooks/knowledge-hint-broad.cjs" }
|
|
9
|
-
{ "command": ".cursor/hooks/cite-policy-evict.cjs" }
|
|
8
|
+
{ "command": ".cursor/hooks/knowledge-hint-broad.cjs" }
|
|
10
9
|
],
|
|
11
10
|
"preToolUse": [
|
|
12
11
|
{
|
|
13
12
|
"matcher": "Edit|Write|MultiEdit",
|
|
14
13
|
"command": ".cursor/hooks/knowledge-hint-narrow.cjs"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"matcher": "Edit|Write|MultiEdit",
|
|
17
|
+
"command": ".cursor/hooks/cite-policy-evict.cjs"
|
|
15
18
|
}
|
|
19
|
+
],
|
|
20
|
+
"postToolUse": [
|
|
21
|
+
{
|
|
22
|
+
"matcher": "Edit|Write|MultiEdit",
|
|
23
|
+
"command": ".cursor/hooks/post-tooluse-mutation.cjs"
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
"sessionEnd": [
|
|
27
|
+
{ "command": ".cursor/hooks/session-end-marker.cjs" }
|
|
16
28
|
]
|
|
17
29
|
}
|
|
18
30
|
}
|
|
@@ -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
|
|
|
@@ -965,8 +984,34 @@ function readUnderseedThreshold(projectRoot) {
|
|
|
965
984
|
return DEFAULT_UNDERSEED_NODE_THRESHOLD;
|
|
966
985
|
}
|
|
967
986
|
|
|
968
|
-
|
|
969
|
-
|
|
987
|
+
// F13 (ISS-20260531-038): the reminder cooldown sidecars were process-global
|
|
988
|
+
// (one file per project, no session key), so in concurrent multi-window sessions
|
|
989
|
+
// one window firing a nudge wrote the cooldown and silenced that nudge in EVERY
|
|
990
|
+
// other window. Scope the sidecar filename by sessionId — mirrors the already-
|
|
991
|
+
// session-scoped dismiss sidecar (sessionDismissFileName). Backward-compatible:
|
|
992
|
+
// a null/absent sessionId falls back to the legacy non-scoped path (upgrade +
|
|
993
|
+
// pre-session-id callers), so existing on-disk state and tests are unaffected;
|
|
994
|
+
// the Stop hook always passes the real session_id from its stdin payload.
|
|
995
|
+
function resolveHookSessionId(payload) {
|
|
996
|
+
return payload && typeof payload.session_id === "string" && payload.session_id.length > 0
|
|
997
|
+
? payload.session_id
|
|
998
|
+
: null;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function sessionScopedCacheFile(baseRelPath, sessionId) {
|
|
1002
|
+
if (sessionId === undefined || sessionId === null || String(sessionId).length === 0) {
|
|
1003
|
+
return baseRelPath;
|
|
1004
|
+
}
|
|
1005
|
+
const safe = String(sessionId).replace(/[^A-Za-z0-9_.-]/g, "-");
|
|
1006
|
+
const lastSlash = baseRelPath.lastIndexOf("/");
|
|
1007
|
+
const dot = baseRelPath.lastIndexOf(".");
|
|
1008
|
+
return dot > lastSlash
|
|
1009
|
+
? `${baseRelPath.slice(0, dot)}-${safe}${baseRelPath.slice(dot)}`
|
|
1010
|
+
: `${baseRelPath}-${safe}`;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function readShownCache(projectRoot, sessionId) {
|
|
1014
|
+
const cachePath = join(projectRoot, sessionScopedCacheFile(SHOWN_CACHE_FILE, sessionId));
|
|
970
1015
|
if (!existsSync(cachePath)) return {};
|
|
971
1016
|
try {
|
|
972
1017
|
const parsed = JSON.parse(readFileSync(cachePath, "utf8"));
|
|
@@ -976,11 +1021,17 @@ function readShownCache(projectRoot) {
|
|
|
976
1021
|
}
|
|
977
1022
|
}
|
|
978
1023
|
|
|
979
|
-
function writeShownCache(projectRoot, cache) {
|
|
980
|
-
const cachePath = join(projectRoot, SHOWN_CACHE_FILE);
|
|
1024
|
+
function writeShownCache(projectRoot, cache, sessionId) {
|
|
1025
|
+
const cachePath = join(projectRoot, sessionScopedCacheFile(SHOWN_CACHE_FILE, sessionId));
|
|
981
1026
|
try {
|
|
982
|
-
|
|
983
|
-
|
|
1027
|
+
// ISS-016: atomic tmp+rename so a crash never leaves a truncated shown-cache.
|
|
1028
|
+
// Falls back to a plain write only if the shared lib failed to load.
|
|
1029
|
+
if (stateStore && typeof stateStore.atomicWrite === "function") {
|
|
1030
|
+
stateStore.atomicWrite(cachePath, JSON.stringify(cache));
|
|
1031
|
+
} else {
|
|
1032
|
+
mkdirSync(dirname(cachePath), { recursive: true });
|
|
1033
|
+
writeFileSync(cachePath, JSON.stringify(cache));
|
|
1034
|
+
}
|
|
984
1035
|
} catch {
|
|
985
1036
|
// Silent — cache failure must never block the hook.
|
|
986
1037
|
}
|
|
@@ -1098,8 +1149,8 @@ function findLastDoctorRunTs(events) {
|
|
|
1098
1149
|
* v2.0.0-rc.7 T10: read the Signal-D cooldown sidecar timestamp (epoch ms).
|
|
1099
1150
|
* Missing file / parse failure → null (allow signal to fire).
|
|
1100
1151
|
*/
|
|
1101
|
-
function readMaintenanceLastEmit(projectRoot) {
|
|
1102
|
-
const p = join(projectRoot, MAINTENANCE_HINT_LAST_EMIT_FILE);
|
|
1152
|
+
function readMaintenanceLastEmit(projectRoot, sessionId) {
|
|
1153
|
+
const p = join(projectRoot, sessionScopedCacheFile(MAINTENANCE_HINT_LAST_EMIT_FILE, sessionId));
|
|
1103
1154
|
if (!existsSync(p)) return null;
|
|
1104
1155
|
try {
|
|
1105
1156
|
const raw = readFileSync(p, "utf8").trim();
|
|
@@ -1114,11 +1165,16 @@ function readMaintenanceLastEmit(projectRoot) {
|
|
|
1114
1165
|
return null;
|
|
1115
1166
|
}
|
|
1116
1167
|
|
|
1117
|
-
function writeMaintenanceLastEmit(projectRoot, nowMs) {
|
|
1118
|
-
const p = join(projectRoot, MAINTENANCE_HINT_LAST_EMIT_FILE);
|
|
1168
|
+
function writeMaintenanceLastEmit(projectRoot, nowMs, sessionId) {
|
|
1169
|
+
const p = join(projectRoot, sessionScopedCacheFile(MAINTENANCE_HINT_LAST_EMIT_FILE, sessionId));
|
|
1119
1170
|
try {
|
|
1120
|
-
|
|
1121
|
-
|
|
1171
|
+
// ISS-016: atomic tmp+rename (see writeShownCache).
|
|
1172
|
+
if (stateStore && typeof stateStore.atomicWrite === "function") {
|
|
1173
|
+
stateStore.atomicWrite(p, new Date(nowMs).toISOString());
|
|
1174
|
+
} else {
|
|
1175
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
1176
|
+
writeFileSync(p, new Date(nowMs).toISOString());
|
|
1177
|
+
}
|
|
1122
1178
|
} catch {
|
|
1123
1179
|
// Silent — sidecar failure must never block the hook.
|
|
1124
1180
|
}
|
|
@@ -1207,9 +1263,159 @@ function evaluateMaintenanceSignal(events, now, canonicalCount, lastEmitMs, thre
|
|
|
1207
1263
|
signal: "maintenance",
|
|
1208
1264
|
// CLI recommendation rather than Skill — doctor is a CLI surface.
|
|
1209
1265
|
recommended_skill: null,
|
|
1266
|
+
// v2.1 NEW-N-3: staleness trigger. threshold=days; actual=ageDays. When
|
|
1267
|
+
// lint was NEVER run ageDays is null — main() skips the signal emit rather
|
|
1268
|
+
// than fabricate a number (honest gap over fake telemetry).
|
|
1269
|
+
threshold: days,
|
|
1270
|
+
actual_value: ageDays,
|
|
1210
1271
|
};
|
|
1211
1272
|
}
|
|
1212
1273
|
|
|
1274
|
+
// lifecycle-refactor W3-A2 (§7 graph generation signal): after a successful
|
|
1275
|
+
// archive the Stop hook REQUESTS edge extraction by emitting one
|
|
1276
|
+
// graph_edge_candidate_requested{stable_id, store?}. The hook never PRODUCES
|
|
1277
|
+
// edges (that is the archive/import skill's or doctor co-occurrence's job,
|
|
1278
|
+
// KT-DEC-0007) — it only flags "this entry just landed; someone should extract
|
|
1279
|
+
// its `related` edges". FROZEN-safe: O(1) tail scan, best-effort silent, single
|
|
1280
|
+
// advisory-locked appendLockedLine (same primitive the rest of this hook uses).
|
|
1281
|
+
//
|
|
1282
|
+
// HONEST stable_id sourcing — the deliberate limitation: pending entries (the
|
|
1283
|
+
// fabric-archive → extractKnowledge path) carry NO canonical stable_id (id is
|
|
1284
|
+
// late-bound at fab_review approve), so their knowledge_proposed event omits
|
|
1285
|
+
// stable_id (or sets the `pending:<key>` sentinel). A graph edge between
|
|
1286
|
+
// id-less pending drafts is meaningless, so we DO NOT fabricate one. We emit
|
|
1287
|
+
// ONLY when the most-recent knowledge_proposed event carries a real
|
|
1288
|
+
// K[TP]-XXX-NNNN stable_id (the approve/promote path) — i.e. an entry that
|
|
1289
|
+
// actually has a canonical node to attach edges to. When the latest proposed
|
|
1290
|
+
// is id-less we honestly skip; the request will fire on the approve event that
|
|
1291
|
+
// allocates the id. A session-scoped sidecar de-dupes so repeated Stop fires in
|
|
1292
|
+
// one session don't re-request the same id.
|
|
1293
|
+
const STABLE_ID_RE = /^K[TP]-[A-Z]{3}-\d{4}$/;
|
|
1294
|
+
const GRAPH_EDGE_REQUESTED_SIDECAR = ".fabric/.cache/graph-edge-requested";
|
|
1295
|
+
|
|
1296
|
+
function emitGraphEdgeCandidateBestEffort(cwd, events, sessionId) {
|
|
1297
|
+
try {
|
|
1298
|
+
if (!Array.isArray(events) || events.length === 0) return;
|
|
1299
|
+
const fabricDir = join(cwd, FABRIC_DIR);
|
|
1300
|
+
if (!existsSync(fabricDir)) return;
|
|
1301
|
+
|
|
1302
|
+
// O(1)-amortized tail scan for the newest knowledge_proposed carrying a
|
|
1303
|
+
// real (non-sentinel) stable_id. Stop at the first knowledge_proposed we
|
|
1304
|
+
// see — if the latest archive is id-less, we honestly skip rather than
|
|
1305
|
+
// reaching back to an older approved entry (that older entry's edges were
|
|
1306
|
+
// already requested when IT landed).
|
|
1307
|
+
let stableId = null;
|
|
1308
|
+
let store;
|
|
1309
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
1310
|
+
const ev = events[i];
|
|
1311
|
+
if (!ev || ev.event_type !== EVENT_TYPE_PROPOSED) continue;
|
|
1312
|
+
const candidate = typeof ev.stable_id === "string" ? ev.stable_id : null;
|
|
1313
|
+
if (candidate && STABLE_ID_RE.test(candidate)) {
|
|
1314
|
+
stableId = candidate;
|
|
1315
|
+
if (typeof ev.store === "string" && ev.store.length > 0) store = ev.store;
|
|
1316
|
+
}
|
|
1317
|
+
// First knowledge_proposed encountered (newest) decides; do not walk past
|
|
1318
|
+
// it to an older one.
|
|
1319
|
+
break;
|
|
1320
|
+
}
|
|
1321
|
+
if (stableId === null) return;
|
|
1322
|
+
|
|
1323
|
+
// Session-scoped de-dup: skip if we already requested edges for this exact
|
|
1324
|
+
// stable_id this session. Sidecar is a single line holding the last id.
|
|
1325
|
+
const sidecarPath = join(cwd, sessionScopedCacheFile(GRAPH_EDGE_REQUESTED_SIDECAR, sessionId));
|
|
1326
|
+
try {
|
|
1327
|
+
if (existsSync(sidecarPath)) {
|
|
1328
|
+
const prev = readFileSync(sidecarPath, "utf8").trim();
|
|
1329
|
+
if (prev === stableId) return;
|
|
1330
|
+
}
|
|
1331
|
+
} catch {
|
|
1332
|
+
// unreadable sidecar → fall through and (re)emit; de-dup is best-effort.
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
let idSuffix;
|
|
1336
|
+
try {
|
|
1337
|
+
idSuffix = require("node:crypto").randomUUID();
|
|
1338
|
+
} catch {
|
|
1339
|
+
idSuffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
1340
|
+
}
|
|
1341
|
+
const event = {
|
|
1342
|
+
kind: "fabric-event",
|
|
1343
|
+
id: `event:${idSuffix}`,
|
|
1344
|
+
ts: Date.now(),
|
|
1345
|
+
schema_version: 1,
|
|
1346
|
+
event_type: "graph_edge_candidate_requested",
|
|
1347
|
+
stable_id: stableId,
|
|
1348
|
+
};
|
|
1349
|
+
if (store !== undefined) event.store = store;
|
|
1350
|
+
if (typeof sessionId === "string" && sessionId.length > 0) event.session_id = sessionId;
|
|
1351
|
+
appendLockedLine(join(fabricDir, EVENT_LEDGER_FILE), JSON.stringify(event) + "\n");
|
|
1352
|
+
|
|
1353
|
+
// Record the de-dup marker (best-effort; atomic when state-store lib loaded).
|
|
1354
|
+
try {
|
|
1355
|
+
if (stateStore && typeof stateStore.atomicWrite === "function") {
|
|
1356
|
+
stateStore.atomicWrite(sidecarPath, stableId);
|
|
1357
|
+
} else {
|
|
1358
|
+
mkdirSync(dirname(sidecarPath), { recursive: true });
|
|
1359
|
+
writeFileSync(sidecarPath, stableId);
|
|
1360
|
+
}
|
|
1361
|
+
} catch {
|
|
1362
|
+
// de-dup marker write failed — at worst we re-request next Stop; harmless.
|
|
1363
|
+
}
|
|
1364
|
+
} catch {
|
|
1365
|
+
// best-effort §7 signal — never block the Stop hook (KT-DEC-0007).
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// v2.1 NEW-N-3 (ADJ-NEWN-3): hook_signal_emitted instrumentation. Writes ONE
|
|
1370
|
+
// best-effort ledger row at the point a nudge is actually delivered (post-
|
|
1371
|
+
// cooldown), so the join key measures nudge-trigger logic (which signal fired,
|
|
1372
|
+
// at what threshold vs. actual). Emitted at delivery rather than at
|
|
1373
|
+
// threshold-cross so it inherits the cooldown gate — a fired-but-cooled signal
|
|
1374
|
+
// does not spam the ledger every session. Skips silently when threshold /
|
|
1375
|
+
// actual_value are not finite numbers (e.g. maintenance "never run" → null
|
|
1376
|
+
// age). Never blocks the hook (KT-DEC-0007).
|
|
1377
|
+
const SIGNAL_TYPE_ENUM = new Set(["archive", "review", "maintenance", "other"]);
|
|
1378
|
+
function emitSignalFiredEvent(cwd, sessionId, result) {
|
|
1379
|
+
try {
|
|
1380
|
+
if (!result || typeof result.signal !== "string") return;
|
|
1381
|
+
const threshold = result.threshold;
|
|
1382
|
+
const actualValue = result.actual_value;
|
|
1383
|
+
if (
|
|
1384
|
+
typeof threshold !== "number" ||
|
|
1385
|
+
!Number.isFinite(threshold) ||
|
|
1386
|
+
typeof actualValue !== "number" ||
|
|
1387
|
+
!Number.isFinite(actualValue)
|
|
1388
|
+
) {
|
|
1389
|
+
return;
|
|
1390
|
+
}
|
|
1391
|
+
const fabricDir = join(cwd, FABRIC_DIR);
|
|
1392
|
+
if (!existsSync(fabricDir)) return;
|
|
1393
|
+
// "import" / any non-canonical signal collapses to schema's catch-all "other".
|
|
1394
|
+
const signalType = SIGNAL_TYPE_ENUM.has(result.signal) ? result.signal : "other";
|
|
1395
|
+
let idSuffix;
|
|
1396
|
+
try {
|
|
1397
|
+
idSuffix = require("node:crypto").randomUUID();
|
|
1398
|
+
} catch {
|
|
1399
|
+
idSuffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
1400
|
+
}
|
|
1401
|
+
const event = {
|
|
1402
|
+
kind: "fabric-event",
|
|
1403
|
+
id: `event:${idSuffix}`,
|
|
1404
|
+
ts: Date.now(),
|
|
1405
|
+
schema_version: 1,
|
|
1406
|
+
event_type: "hook_signal_emitted",
|
|
1407
|
+
signal_type: signalType,
|
|
1408
|
+
threshold,
|
|
1409
|
+
actual_value: actualValue,
|
|
1410
|
+
fired: true,
|
|
1411
|
+
};
|
|
1412
|
+
if (typeof sessionId === "string" && sessionId.length > 0) event.session_id = sessionId;
|
|
1413
|
+
appendLockedLine(join(fabricDir, EVENT_LEDGER_FILE), JSON.stringify(event) + "\n");
|
|
1414
|
+
} catch {
|
|
1415
|
+
// best-effort telemetry — never block the hook
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1213
1419
|
/**
|
|
1214
1420
|
* v2.0.0-rc.7 T5: best-effort sync stdin reader for the Stop hook.
|
|
1215
1421
|
*
|
|
@@ -1414,7 +1620,7 @@ function extractAndWriteAssistantTurnsBestEffort(cwd, stdinPayload) {
|
|
|
1414
1620
|
timestamp: new Date().toISOString(),
|
|
1415
1621
|
};
|
|
1416
1622
|
if (client !== undefined) event.client = client;
|
|
1417
|
-
|
|
1623
|
+
appendLockedLine(ledgerPath, JSON.stringify(event) + "\n");
|
|
1418
1624
|
} catch {
|
|
1419
1625
|
// Per-turn failure must not abort the remaining turns; the Stop hook
|
|
1420
1626
|
// contract is "never block on hook failure". Best-effort continues.
|
|
@@ -1439,7 +1645,7 @@ function extractAndWriteAssistantTurnsBestEffort(cwd, stdinPayload) {
|
|
|
1439
1645
|
counters: { [counterKey]: emptyShellCount },
|
|
1440
1646
|
};
|
|
1441
1647
|
const metricsPath = join(fabricDir, METRICS_LEDGER_FILE);
|
|
1442
|
-
|
|
1648
|
+
appendLockedLine(metricsPath, JSON.stringify(metricsRow) + "\n");
|
|
1443
1649
|
} catch {
|
|
1444
1650
|
// metrics fold is observability-only; never block the hook on failure.
|
|
1445
1651
|
}
|
|
@@ -1766,6 +1972,17 @@ function main(env, stdio) {
|
|
|
1766
1972
|
);
|
|
1767
1973
|
|
|
1768
1974
|
const events = readLedger(cwd);
|
|
1975
|
+
|
|
1976
|
+
// lifecycle-refactor W3-A2 (§7): request graph-edge extraction for a freshly
|
|
1977
|
+
// archived canonical entry. Runs UNCONDITIONALLY here (before the nudge
|
|
1978
|
+
// cooldown/dismiss early-returns) so the §7 signal is independent of whether
|
|
1979
|
+
// a reminder banner is shown this Stop. Best-effort, never throws.
|
|
1980
|
+
try {
|
|
1981
|
+
emitGraphEdgeCandidateBestEffort(cwd, events, resolveHookSessionId(stdinPayload));
|
|
1982
|
+
} catch {
|
|
1983
|
+
// never block the Stop hook
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1769
1986
|
let pendingStats;
|
|
1770
1987
|
try {
|
|
1771
1988
|
pendingStats = readPendingStats(cwd, now);
|
|
@@ -1898,7 +2115,7 @@ function main(env, stdio) {
|
|
|
1898
2115
|
// for the prompt to be actionable.
|
|
1899
2116
|
if (result === null) {
|
|
1900
2117
|
try {
|
|
1901
|
-
const lastEmit = readMaintenanceLastEmit(cwd);
|
|
2118
|
+
const lastEmit = readMaintenanceLastEmit(cwd, resolveHookSessionId(stdinPayload));
|
|
1902
2119
|
result = evaluateMaintenanceSignal(
|
|
1903
2120
|
events,
|
|
1904
2121
|
now,
|
|
@@ -1951,8 +2168,11 @@ function main(env, stdio) {
|
|
|
1951
2168
|
// see MAINTENANCE_HINT_LAST_EMIT_FILE). The A/B/C shared cooldown cache
|
|
1952
2169
|
// uses hours, so we branch here to avoid mixing semantics.
|
|
1953
2170
|
if (result.signal === "maintenance") {
|
|
2171
|
+
emitSignalFiredEvent(cwd, sessionId, result);
|
|
2172
|
+
delete result.threshold;
|
|
2173
|
+
delete result.actual_value;
|
|
1954
2174
|
out.write(JSON.stringify(result));
|
|
1955
|
-
writeMaintenanceLastEmit(cwd, nowMs);
|
|
2175
|
+
writeMaintenanceLastEmit(cwd, nowMs, resolveHookSessionId(stdinPayload));
|
|
1956
2176
|
return;
|
|
1957
2177
|
}
|
|
1958
2178
|
|
|
@@ -1960,7 +2180,7 @@ function main(env, stdio) {
|
|
|
1960
2180
|
// archive_hint_cooldown_hours (default 12h) regardless of state drift.
|
|
1961
2181
|
// Pure reminder-noise reduction; the underlying trigger logic is unchanged.
|
|
1962
2182
|
const cooldownMs = readCooldownHours(cwd) * MS_PER_HOUR;
|
|
1963
|
-
const cache = readShownCache(cwd);
|
|
2183
|
+
const cache = readShownCache(cwd, resolveHookSessionId(stdinPayload));
|
|
1964
2184
|
const lastShown = cache[result.signal];
|
|
1965
2185
|
// rc.34 TASK-01 + review-fix (Gemini P1): future-stamped lastShown
|
|
1966
2186
|
// (backward clock skew) bypasses cooldown — sidecar treated as expired.
|
|
@@ -1972,9 +2192,12 @@ function main(env, stdio) {
|
|
|
1972
2192
|
return; // Still in cooldown — silent.
|
|
1973
2193
|
}
|
|
1974
2194
|
|
|
2195
|
+
emitSignalFiredEvent(cwd, sessionId, result);
|
|
2196
|
+
delete result.threshold;
|
|
2197
|
+
delete result.actual_value;
|
|
1975
2198
|
out.write(JSON.stringify(result));
|
|
1976
2199
|
cache[result.signal] = nowMs;
|
|
1977
|
-
writeShownCache(cwd, cache);
|
|
2200
|
+
writeShownCache(cwd, cache, resolveHookSessionId(stdinPayload));
|
|
1978
2201
|
} catch {
|
|
1979
2202
|
// Silent — never block on hook failure.
|
|
1980
2203
|
}
|
|
@@ -2029,6 +2252,9 @@ module.exports = {
|
|
|
2029
2252
|
// of the contract-missing emission contract). The lib module itself is
|
|
2030
2253
|
// also exported indirectly via the reminder helper.
|
|
2031
2254
|
emitCiteContractRemindersBestEffort,
|
|
2255
|
+
// lifecycle-refactor W3-A2 (§7): graph-edge-candidate request emitter
|
|
2256
|
+
// (exported for unit testing of the honest stable_id-gating + de-dup).
|
|
2257
|
+
emitGraphEdgeCandidateBestEffort,
|
|
2032
2258
|
CONSTANTS: {
|
|
2033
2259
|
FABRIC_DIR,
|
|
2034
2260
|
EVENT_LEDGER_FILE,
|
|
@@ -2066,6 +2292,8 @@ module.exports = {
|
|
|
2066
2292
|
// v2.0.0-rc.8 (TASK-002): in-flight import gate for Signal B.
|
|
2067
2293
|
IMPORT_STATE_FILE_REL,
|
|
2068
2294
|
IMPORT_IN_FLIGHT_MAX_AGE_HOURS,
|
|
2295
|
+
// lifecycle-refactor W3-A2 (§7): graph-edge-request de-dup sidecar.
|
|
2296
|
+
GRAPH_EDGE_REQUESTED_SIDECAR,
|
|
2069
2297
|
},
|
|
2070
2298
|
};
|
|
2071
2299
|
|