@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.
Files changed (49) hide show
  1. package/dist/{chunk-PWLW3B57.js → chunk-2CY4BMTH.js} +5 -1
  2. package/dist/chunk-5LQIHYFC.js +64 -0
  3. package/dist/chunk-5ZUMLCD5.js +248 -0
  4. package/dist/{chunk-WWNXR34K.js → chunk-BO4XIZWZ.js} +8 -1
  5. package/dist/chunk-EOT63RDH.js +36 -0
  6. package/dist/{chunk-BATF4PEJ.js → chunk-F6ITRM7T.js} +4 -4
  7. package/dist/{chunk-WU6GAPKH.js → chunk-H3FE6VIK.js} +3 -5
  8. package/dist/{chunk-MF3OTILQ.js → chunk-XC5RUHLK.js} +29 -8
  9. package/dist/chunk-XCBVSGCS.js +25 -0
  10. package/dist/{chunk-F46ORPOA.js → chunk-XHHCRDIR.js} +149 -7
  11. package/dist/{config-XJIPZNUP.js → config-VJMXCLXW.js} +3 -3
  12. package/dist/{doctor-QVNPHLJK.js → doctor-J4O3X54I.js} +154 -30
  13. package/dist/index.js +57 -16
  14. package/dist/{install-2HDO5FTQ.js → install-BULNDUIM.js} +241 -108
  15. package/dist/{metrics-ACEQFPDU.js → metrics-RER6NLFC.js} +22 -9
  16. package/dist/{onboard-coverage-MFCAEBDO.js → onboard-coverage-JWQWDZW7.js} +1 -1
  17. package/dist/{plan-context-hint-FC6P3WFE.js → plan-context-hint-CHVZGOZ5.js} +21 -8
  18. package/dist/{scope-explain-2F2R5URO.js → scope-explain-BWRWBCCP.js} +19 -5
  19. package/dist/{status-GLQWLWH6.js → status-PANEGKU2.js} +17 -6
  20. package/dist/store-66NK2FTQ.js +443 -0
  21. package/dist/sync-EA5HZMXM.js +395 -0
  22. package/dist/{uninstall-TAXSUSKH.js → uninstall-F75MPKQC.js} +61 -4
  23. package/dist/whoami-66YKY5DZ.js +47 -0
  24. package/package.json +3 -3
  25. package/templates/hooks/cite-policy-evict.cjs +412 -160
  26. package/templates/hooks/configs/claude-code.json +17 -2
  27. package/templates/hooks/configs/codex-hooks.json +14 -2
  28. package/templates/hooks/configs/cursor-hooks.json +14 -2
  29. package/templates/hooks/fabric-hint.cjs +247 -19
  30. package/templates/hooks/knowledge-hint-broad.cjs +176 -10
  31. package/templates/hooks/knowledge-hint-narrow.cjs +64 -5
  32. package/templates/hooks/lib/injection-log.cjs +91 -0
  33. package/templates/hooks/lib/state-store.cjs +30 -11
  34. package/templates/hooks/post-tooluse-mutation.cjs +285 -0
  35. package/templates/hooks/session-end-marker.cjs +140 -0
  36. package/templates/skills/fabric-archive/SKILL.md +7 -1
  37. package/templates/skills/fabric-audit/SKILL.md +53 -0
  38. package/templates/skills/fabric-connect/SKILL.md +48 -0
  39. package/templates/skills/fabric-review/SKILL.md +2 -0
  40. package/templates/skills/fabric-review/ref/cite-contract.md +56 -0
  41. package/templates/skills/fabric-store/SKILL.md +44 -0
  42. package/dist/chunk-HFQVXY6P.js +0 -86
  43. package/dist/chunk-L4Q55UC4.js +0 -52
  44. package/dist/chunk-LFIKMVY7.js +0 -27
  45. package/dist/chunk-RYAFBNES.js +0 -33
  46. package/dist/chunk-T5RPGCCM.js +0 -40
  47. package/dist/store-XTSE5TY6.js +0 -105
  48. package/dist/sync-BJCWDPNC.js +0 -245
  49. 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
- "UserPromptSubmit": [
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/cite-policy-evict.cjs"
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
- "PreToolUse": [
23
+ "PostToolUse": [
17
24
  {
18
25
  "matcher": "Edit|Write|MultiEdit",
19
- "command": "\"$(git rev-parse --show-toplevel)/.codex/hooks/knowledge-hint-narrow.cjs\""
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 { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } = require("node:fs");
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
- function readShownCache(projectRoot) {
969
- const cachePath = join(projectRoot, SHOWN_CACHE_FILE);
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
- mkdirSync(dirname(cachePath), { recursive: true });
983
- writeFileSync(cachePath, JSON.stringify(cache));
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
- mkdirSync(dirname(p), { recursive: true });
1121
- writeFileSync(p, new Date(nowMs).toISOString());
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
- appendFileSync(ledgerPath, JSON.stringify(event) + "\n", "utf8");
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
- appendFileSync(metricsPath, JSON.stringify(metricsRow) + "\n", "utf8");
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