@fenglimg/fabric-cli 2.2.0-rc.1 → 2.2.0-rc.11

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 (83) hide show
  1. package/README.md +8 -5
  2. package/dist/chunk-27HK6H5Y.js +69 -0
  3. package/dist/{chunk-AOE6AYI7.js → chunk-2KBCTMID.js} +31 -8
  4. package/dist/chunk-3D7B2UAZ.js +149 -0
  5. package/dist/{chunk-XC5RUHLK.js → chunk-3IOLS5EK.js} +23 -38
  6. package/dist/{plan-context-hint-FC6P3WFE.js → chunk-722JU5BP.js} +52 -12
  7. package/dist/{chunk-2R55HNVD.js → chunk-7ZDXBOOU.js} +234 -206
  8. package/dist/{doctor-YONYXDX6.js → chunk-E7HJUU34.js} +215 -52
  9. package/dist/chunk-EOT63RDH.js +36 -0
  10. package/dist/chunk-FNHDQTPC.js +16 -0
  11. package/dist/{chunk-2CY4BMTH.js → chunk-HORSMSZL.js} +9 -5
  12. package/dist/{chunk-BO4XIZWZ.js → chunk-NLNH64A3.js} +5 -18
  13. package/dist/{chunk-WU6GAPKH.js → chunk-PTGQAZEW.js} +12 -4
  14. package/dist/chunk-QFIVFZRH.js +13 -0
  15. package/dist/chunk-QPAW6IYT.js +387 -0
  16. package/dist/{chunk-COI5VDFU.js → chunk-WA3DYGSY.js} +1 -2
  17. package/dist/{config-XYRBZJDU.js → config-A3LTECAY.js} +4 -3
  18. package/dist/context-UJCGYOT6.js +117 -0
  19. package/dist/doctor-MDTZWKBK.js +24 -0
  20. package/dist/index.d.ts +2 -2
  21. package/dist/index.js +133 -22
  22. package/dist/info-7FKBTMVO.js +139 -0
  23. package/dist/install-v2-WLEJ5XHT.js +3279 -0
  24. package/dist/{metrics-RER6NLFC.js → metrics-HMFH4YHK.js} +1 -1
  25. package/dist/{onboard-coverage-JWQWDZW7.js → onboard-coverage-XSG77LL3.js} +48 -27
  26. package/dist/plan-context-hint-5TNGH3R4.js +12 -0
  27. package/dist/{scope-explain-CDIZESP5.js → scope-explain-HLJZ2M33.js} +17 -6
  28. package/dist/status-4R3TM4FJ.js +37 -0
  29. package/dist/store-HOCORVL3.js +563 -0
  30. package/dist/{sync-UJ4BBCZJ.js → sync-DT5UJMMR.js} +197 -30
  31. package/dist/{uninstall-C3QXKOO6.js → uninstall-IFN2KYBK.js} +97 -140
  32. package/dist/whoami-ITGEFWH4.js +49 -0
  33. package/package.json +7 -5
  34. package/templates/hooks/cite-policy-evict.cjs +412 -160
  35. package/templates/hooks/configs/README.md +14 -27
  36. package/templates/hooks/configs/claude-code.json +17 -2
  37. package/templates/hooks/configs/codex-hooks.json +15 -3
  38. package/templates/hooks/fabric-hint.cjs +742 -259
  39. package/templates/hooks/knowledge-hint-broad.cjs +577 -274
  40. package/templates/hooks/knowledge-hint-narrow.cjs +113 -73
  41. package/templates/hooks/lib/banner-i18n.cjs +50 -1
  42. package/templates/hooks/lib/bindings-snapshot-reader.cjs +118 -7
  43. package/templates/hooks/lib/cite-line-parser.cjs +12 -20
  44. package/templates/hooks/lib/client-adapter.cjs +66 -7
  45. package/templates/hooks/lib/nudge-policy.cjs +117 -0
  46. package/templates/hooks/lib/state-store.cjs +60 -0
  47. package/templates/hooks/post-tooluse-mutation.cjs +386 -0
  48. package/templates/hooks/session-end-marker.cjs +140 -0
  49. package/templates/skills/fabric/SKILL.md +100 -0
  50. package/templates/skills/fabric-archive/SKILL.md +47 -24
  51. package/templates/skills/fabric-archive/ref/dry-run-scope.md +1 -1
  52. package/templates/skills/fabric-archive/ref/i18n-policy.md +2 -3
  53. package/templates/skills/fabric-archive/ref/phase-1-5-onboard.md +2 -3
  54. package/templates/skills/fabric-archive/ref/phase-1-cross-session.md +1 -1
  55. package/templates/skills/fabric-archive/ref/phase-2-5-viability.md +1 -1
  56. package/templates/skills/fabric-archive/ref/phase-3-6-related-edges.md +18 -0
  57. package/templates/skills/fabric-archive/ref/phase-3-7-semantic-scope.md +47 -0
  58. package/templates/skills/fabric-audit/SKILL.md +13 -3
  59. package/templates/skills/fabric-connect/SKILL.md +3 -3
  60. package/templates/skills/fabric-import/SKILL.md +7 -7
  61. package/templates/skills/fabric-import/ref/i18n-policy.md +2 -3
  62. package/templates/skills/fabric-import/ref/state-recovery.md +1 -2
  63. package/templates/skills/fabric-review/SKILL.md +14 -5
  64. package/templates/skills/fabric-review/ref/cite-contract.md +1 -1
  65. package/templates/skills/fabric-review/ref/i18n-policy.md +2 -3
  66. package/templates/skills/fabric-review/ref/output-contract.md +1 -1
  67. package/templates/skills/fabric-review/ref/per-mode-flows.md +2 -2
  68. package/templates/skills/fabric-review/ref/worked-examples.md +1 -1
  69. package/templates/skills/fabric-store/SKILL.md +1 -1
  70. package/templates/skills/fabric-sync/SKILL.md +1 -1
  71. package/templates/skills/lib/shared-policy.md +2 -2
  72. package/dist/chunk-4R2CYEA4.js +0 -116
  73. package/dist/chunk-L4Q55UC4.js +0 -52
  74. package/dist/chunk-LFIKMVY7.js +0 -27
  75. package/dist/chunk-RYAFBNES.js +0 -33
  76. package/dist/chunk-T5RPGCCM.js +0 -40
  77. package/dist/install-74ANPCCP.js +0 -2737
  78. package/dist/status-GLQWLWH6.js +0 -23
  79. package/dist/store-XB3ADT65.js +0 -144
  80. package/dist/whoami-2MLO4Y37.js +0 -36
  81. package/templates/hooks/configs/cursor-hooks.json +0 -18
  82. package/templates/hooks/lib/cite-contract-reminder.cjs +0 -179
  83. package/templates/hooks/lib/summary-fallback.cjs +0 -210
@@ -44,23 +44,6 @@ try {
44
44
  citeLineParser = null;
45
45
  }
46
46
 
47
- // v2.0.0-rc.24 TASK-05: L1 enforcement layer — soft Stop hook reminder for
48
- // [recalled] cites of decision/pitfall types that arrived without operator
49
- // contract or skip:<reason>. Reads .fabric/agents.meta.json (via
50
- // lib/cite-contract-reminder.cjs#readKnowledgeTypeMap) to type-route cite
51
- // ids per B6 lock; emits one
52
- // ⚠ KB: <id> cited as [recalled] but missing contract; add → edit:<glob>
53
- // or → skip:<reason> next turn
54
- // line to stderr per offending id. Non-blocking, never throws.
55
- let citeContractReminder = null;
56
- try {
57
- citeContractReminder = require("./lib/cite-contract-reminder.cjs");
58
- } catch {
59
- // Helper module missing — soft reminder simply doesn't fire. Audit-side
60
- // doctor (TASK-08) still catches contract violations at the next run.
61
- citeContractReminder = null;
62
- }
63
-
64
47
  // v2.0.0-rc.37 NEW-30: shared client-protocol adapter. Guarded require (this
65
48
  // hook runs in arbitrary user repos); detectClient delegates the 3-tier
66
49
  // detection to the lib, falling back to env-only when the lib is absent.
@@ -71,6 +54,17 @@ try {
71
54
  clientAdapter = null;
72
55
  }
73
56
 
57
+ // v2.2 dual-sink (Goal A / D4): human-output gate for the archive nudge. The Stop
58
+ // archive nudge is SOFT (additionalContext, never decision:block — D3) and the
59
+ // human systemMessage is gated by nudge_mode. Optional require — absent → human
60
+ // always emits (legacy posture).
61
+ let nudgePolicy = null;
62
+ try {
63
+ nudgePolicy = require("./lib/nudge-policy.cjs");
64
+ } catch {
65
+ nudgePolicy = null;
66
+ }
67
+
74
68
  // v2.0.0-rc.37 NEW-16: shared config + sidecar I/O for the per-signal dismiss
75
69
  // feature (config-level durable opt-out + session-scoped sidecar). Guarded
76
70
  // require (house style); dismiss simply doesn't fire if the lib is absent.
@@ -98,16 +92,115 @@ try {
98
92
  bindingsSnapshotReader = null;
99
93
  }
100
94
 
101
- // Read the project's own `project_id` (the snapshot key) from its config.
102
- function readProjectId(cwd) {
95
+ // Read the workspace binding id (snapshot key) from project config. Standard
96
+ // repos default to project_id; worktrees can set workspace_binding_id to isolate
97
+ // hook/runtime state without changing project identity.
98
+ function readWorkspaceBindingId(cwd) {
103
99
  try {
104
100
  const parsed = JSON.parse(readFileSync(join(cwd, ".fabric", "fabric-config.json"), "utf8"));
101
+ if (typeof parsed.workspace_binding_id === "string") return parsed.workspace_binding_id;
105
102
  return typeof parsed.project_id === "string" ? parsed.project_id : null;
106
103
  } catch {
107
104
  return null;
108
105
  }
109
106
  }
110
107
 
108
+ function readSnapshotKnowledgeStats(projectRoot, now) {
109
+ const nowMs = now instanceof Date ? now.getTime() : Number(now) || Date.now();
110
+ const empty = { pendingCount: 0, oldestPendingAgeMs: null, canonicalCount: 0 };
111
+ if (bindingsSnapshotReader === null) {
112
+ return null;
113
+ }
114
+ const bindingId = readWorkspaceBindingId(projectRoot);
115
+ if (bindingId === null) {
116
+ return null;
117
+ }
118
+ try {
119
+ const snapshot = bindingsSnapshotReader.readBindingsSnapshot(bindingId);
120
+ // No snapshot file → empty corpus (KT-DEC-0007), preserving prior behavior.
121
+ if (!snapshot) {
122
+ return empty;
123
+ }
124
+ // LIVE recount off the snapshot's resolved store dirs. The cached
125
+ // knowledge_stats projection is frozen at snapshot-write time, so once the
126
+ // pending queue is reviewed (or store content syncs out-of-band) it goes
127
+ // stale — that is the phantom review-backlog this hook used to report
128
+ // (KT-PIT-0017). The authoritative count is the live *.md walk under the
129
+ // resolved store dirs.
130
+ const live = bindingsSnapshotReader.liveKnowledgeStats(snapshot);
131
+ // #3: a snapshot predating knowledge_store_dirs makes liveKnowledgeStats
132
+ // return null — counts are undeterminable and the cached projection is
133
+ // unreliable. Return `undefined` (a marker distinct from the `null` that
134
+ // lib/binding-absent returns, which readPendingStats uses as its legacy-
135
+ // fallback signal) so countCanonicalNodes maps it to "unknown" and the
136
+ // underseed signal SKIPS rather than false-firing on a stale corpus (snapshot
137
+ // self-heals on the next install/sync). Distinguished from the missing-
138
+ // snapshot case above, which stays `empty` (genuine fresh-project zero).
139
+ if (live === null) {
140
+ return undefined;
141
+ }
142
+ const pendingCount =
143
+ Number.isFinite(live.pendingCount) && live.pendingCount > 0 ? Math.floor(live.pendingCount) : 0;
144
+ const canonicalCount =
145
+ Number.isFinite(live.canonicalCount) && live.canonicalCount > 0
146
+ ? Math.floor(live.canonicalCount)
147
+ : 0;
148
+ const oldestPendingAgeMs =
149
+ pendingCount > 0 &&
150
+ Number.isFinite(live.oldestPendingMtimeMs) &&
151
+ live.oldestPendingMtimeMs > 0
152
+ ? Math.max(0, nowMs - live.oldestPendingMtimeMs)
153
+ : null;
154
+ return { pendingCount, oldestPendingAgeMs, canonicalCount };
155
+ } catch {
156
+ return empty;
157
+ }
158
+ }
159
+
160
+ function readLegacyPendingStats(projectRoot, now) {
161
+ const nowMs = now instanceof Date ? now.getTime() : Number(now) || Date.now();
162
+ const baseDir = join(projectRoot, FABRIC_DIR, PENDING_DIR);
163
+
164
+ let count = 0;
165
+ let oldestMtime = null;
166
+
167
+ if (!existsSync(baseDir)) {
168
+ return { count: 0, oldestAgeMs: null };
169
+ }
170
+
171
+ for (const type of PENDING_TYPES) {
172
+ const typeDir = join(baseDir, type);
173
+ if (!existsSync(typeDir)) continue;
174
+
175
+ let entries;
176
+ try {
177
+ entries = readdirSync(typeDir);
178
+ } catch {
179
+ continue;
180
+ }
181
+
182
+ for (const entry of entries) {
183
+ if (!entry.endsWith(".md")) continue;
184
+ const filePath = join(typeDir, entry);
185
+ let mtime;
186
+ try {
187
+ mtime = statSync(filePath).mtimeMs;
188
+ } catch {
189
+ continue;
190
+ }
191
+ count += 1;
192
+ if (oldestMtime === null || mtime < oldestMtime) {
193
+ oldestMtime = mtime;
194
+ }
195
+ }
196
+ }
197
+
198
+ return {
199
+ count,
200
+ oldestAgeMs: count > 0 && oldestMtime !== null ? nowMs - oldestMtime : null,
201
+ };
202
+ }
203
+
111
204
  // CONSTANTS — duplicated from packages/server/src/services/_shared.ts.
112
205
  // DRY violation accepted: this hook script runs in user repos WITHOUT
113
206
  // node_modules access, so it cannot import from @fenglimg/fabric-server.
@@ -125,6 +218,65 @@ const EVENT_LEDGER_FILE = "events.jsonl";
125
218
  const METRICS_LEDGER_FILE = "metrics.jsonl";
126
219
  const EVENT_TYPE_PROPOSED = "knowledge_proposed";
127
220
  const EVENT_TYPE_INIT_SCAN_COMPLETED = "init_scan_completed";
221
+
222
+ // v2.2 dual-sink (Goal A / D6): deterministic high-value probe for the archive
223
+ // nudge value-gate. Mirrors packages/server/src/services/archive-scan.ts
224
+ // (hasHighValueSignal) — the hook replicates the SAME deterministic ledger probe
225
+ // rather than running the semantic archive-scan, staying within the Hook⊥MCP
226
+ // boundary (the hook reads events.jsonl mechanically; it never judges relevance).
227
+ // Keep these two literal sets in sync with archive-scan.ts.
228
+ const ARCHIVE_HIGH_VALUE_EVENT_TYPES = new Set([
229
+ "knowledge_context_planned",
230
+ "edit_paths_recorded",
231
+ "edit_intent_checked",
232
+ ]);
233
+ const ARCHIVE_NORMATIVE_KEYWORDS = [
234
+ "以后",
235
+ "always",
236
+ "never",
237
+ "from now on",
238
+ "下次",
239
+ "记一下",
240
+ "永远不要",
241
+ ];
242
+
243
+ // v2.2 dual-sink (Goal A / D6): does the ledger carry a high-value archive signal
244
+ // since the watermark (last knowledge_proposed)? True iff any HIGH_VALUE event
245
+ // fired past the watermark, OR the latest assistant_turn carries a normative
246
+ // keyword. Deterministic — no semantic judgement. Used to VALUE-GATE the archive
247
+ // nudge so the check cadence (edits/hours) is decoupled from the nudge cadence
248
+ // (D6): a workspace that crossed the edit threshold but produced no high-value
249
+ // signal stays quiet. watermarkTs null (never archived) → treat all events as
250
+ // past-watermark (a never-archived repo with any edit signal is worth nudging).
251
+ // crack 1: optional `sessionId` scopes the probe to ONE session's events so a
252
+ // neighbour window's high-value work (past the same global watermark) cannot
253
+ // keep THIS session's archive nudge alive (or, in the backlog scan, attribute a
254
+ // neighbour's signal to a dead session). Omitted → workspace-wide (legacy).
255
+ function hasHighValueArchiveSignal(events, watermarkTs, sessionId) {
256
+ if (!Array.isArray(events)) return false;
257
+ const wm = typeof watermarkTs === "number" ? watermarkTs : 0;
258
+ const scoped = typeof sessionId === "string" && sessionId.length > 0;
259
+ let latestTurn = null;
260
+ for (const e of events) {
261
+ if (!e || typeof e.ts !== "number" || e.ts <= wm) continue;
262
+ if (scoped && e.session_id !== sessionId) continue;
263
+ if (typeof e.event_type === "string" && ARCHIVE_HIGH_VALUE_EVENT_TYPES.has(e.event_type)) {
264
+ return true;
265
+ }
266
+ if (e.event_type === "assistant_turn_observed") {
267
+ if (latestTurn === null || (typeof latestTurn.ts === "number" && e.ts > latestTurn.ts)) {
268
+ latestTurn = e;
269
+ }
270
+ }
271
+ }
272
+ if (latestTurn !== null) {
273
+ const haystack = JSON.stringify(latestTurn).toLowerCase();
274
+ for (const kw of ARCHIVE_NORMATIVE_KEYWORDS) {
275
+ if (haystack.includes(kw.toLowerCase())) return true;
276
+ }
277
+ }
278
+ return false;
279
+ }
128
280
  // v2.0.0-rc.7 T10: doctor_run event drives Signal D (maintenance hint).
129
281
  const EVENT_TYPE_DOCTOR_RUN = "doctor_run";
130
282
  // v2.0.0-rc.20 TASK-03: per-turn cite-policy observation event. Emitted by
@@ -267,92 +419,40 @@ function readLedger(projectRoot) {
267
419
  }
268
420
 
269
421
  /**
270
- * Walk <projectRoot>/.fabric/knowledge/pending/<type>/*.md across all
271
- * PENDING_TYPES subdirs, collecting count and oldest mtime.
422
+ * Read pending counts from the CLI-generated resolved-bindings snapshot.
272
423
  *
273
424
  * Returns { count, oldestAgeMs } where:
274
425
  * - count: total .md file count across all type subdirs
275
426
  * - oldestAgeMs: (nowMs - oldestMtimeMs) when count>0, else null
276
427
  *
277
- * ENOENT / unreadable subdir / unstat-able file silently skipped
278
- * (preserves the hook's never-block-on-failure invariant).
428
+ * Store-only cutover: hooks never walk project-local knowledge or store
429
+ * trees. Missing snapshot stats degrade to zero (KT-DEC-0007).
279
430
  */
280
431
  function readPendingStats(projectRoot, now) {
281
- const nowMs = now instanceof Date ? now.getTime() : Number(now) || Date.now();
282
- const baseDir = join(projectRoot, FABRIC_DIR, PENDING_DIR);
283
-
284
- let count = 0;
285
- let oldestMtime = null;
286
-
287
- if (!existsSync(baseDir)) {
288
- return { count: 0, oldestAgeMs: null };
289
- }
290
-
291
- for (const type of PENDING_TYPES) {
292
- const typeDir = join(baseDir, type);
293
- if (!existsSync(typeDir)) continue;
294
-
295
- let entries;
296
- try {
297
- entries = readdirSync(typeDir);
298
- } catch {
299
- continue;
300
- }
301
-
302
- for (const entry of entries) {
303
- if (!entry.endsWith(".md")) continue;
304
- const filePath = join(typeDir, entry);
305
- let mtime;
306
- try {
307
- mtime = statSync(filePath).mtimeMs;
308
- } catch {
309
- continue;
310
- }
311
- count += 1;
312
- if (oldestMtime === null || mtime < oldestMtime) {
313
- oldestMtime = mtime;
314
- }
315
- }
432
+ const stats = readSnapshotKnowledgeStats(projectRoot, now);
433
+ // `!= null` (loose) also catches the `undefined` old-snapshot marker (#3)
434
+ // fall through to the legacy reader (which degrades to 0 → no phantom review
435
+ // nudge), rather than dereferencing pendingCount on a non-object.
436
+ if (stats != null) {
437
+ return { count: stats.pendingCount, oldestAgeMs: stats.oldestPendingAgeMs };
316
438
  }
317
-
318
- return {
319
- count,
320
- oldestAgeMs: count > 0 && oldestMtime !== null ? nowMs - oldestMtime : null,
321
- };
439
+ return readLegacyPendingStats(projectRoot, now);
322
440
  }
323
441
 
324
442
  /**
325
- * Count canonical knowledge entries across the five canonical type subdirs
326
- * (decisions / pitfalls / guidelines / models / processes). Pending entries
327
- * are NOT counted they are proposals, not seeded knowledge.
328
- *
329
- * Returns the integer count. ENOENT / unreadable subdir → silently treated as
330
- * zero (preserves never-block-on-failure invariant). Filters on `.md` suffix
331
- * only; the more-precise canonical filename pattern check is owned by
332
- * doctor.ts (the hook is a coarse signal, not a lint).
443
+ * Count canonical knowledge entries from the CLI-generated resolved-bindings
444
+ * snapshot. Store-only: hooks never walk project-local knowledge or store
445
+ * trees a missing snapshot degrades to zero (KT-DEC-0007).
333
446
  */
334
447
  function countCanonicalNodes(projectRoot) {
335
- const knowledgeRoot = join(projectRoot, FABRIC_DIR, "knowledge");
336
- if (!existsSync(knowledgeRoot)) {
337
- return 0;
338
- }
339
- let count = 0;
340
- for (const type of KNOWLEDGE_CANONICAL_TYPES) {
341
- const typeDir = join(knowledgeRoot, type);
342
- if (!existsSync(typeDir)) continue;
343
- let entries;
344
- try {
345
- entries = readdirSync(typeDir);
346
- } catch {
347
- continue;
348
- }
349
- for (const entry of entries) {
350
- if (entry.endsWith(".md")) {
351
- count += 1;
352
- }
353
- }
354
- }
355
- return count;
448
+ const stats = readSnapshotKnowledgeStats(projectRoot);
449
+ // #3: `undefined` = snapshot EXISTS but predates knowledge_store_dirs →
450
+ // undeterminable → return null so decide()'s underseed signal SKIPS rather than
451
+ // false-firing on a stale corpus. `null` = no reader / not bound → degrade to 0
452
+ // (KT-DEC-0007, preserved). The `empty` object (missing snapshot) → canonical 0,
453
+ // still firing correctly for a genuinely fresh corpus.
454
+ if (stats === undefined) return null;
455
+ return stats === null ? 0 : stats.canonicalCount;
356
456
  }
357
457
 
358
458
  /**
@@ -411,6 +511,213 @@ function countEditsSince(projectRoot, anchorTs) {
411
511
  return count;
412
512
  }
413
513
 
514
+ // ---------------------------------------------------------------------------
515
+ // Two-lane archive strategy (crack 1 + 2).
516
+ //
517
+ // In-session lane (crack 1): the archive nudge's edit trigger counts ONLY the
518
+ // current session's `file_mutated` events since the current session's OWN
519
+ // archive watermark — a neighbour window archiving (which moves the GLOBAL
520
+ // `knowledge_proposed` anchor) must never zero THIS window's unarchived work.
521
+ // We read the event ledger (file_mutated carries session_id, written by
522
+ // post-tooluse-mutation.cjs; session_archive_attempted carries
523
+ // covered_through_ts), NOT the session-blind `.fabric/.cache/edit-counter`
524
+ // sidecar — that stays for the activity-overview DISPLAY line only.
525
+ //
526
+ // Cross-session lane (crack 2): `countBacklogSessions` is the safety net that
527
+ // replaces the old global-24h timer (which any neighbour's archive reset, so a
528
+ // low-signal "dead" session was orphaned forever). It reads events.jsonl
529
+ // directly — never the resolved-bindings snapshot (KT-PIT-0017/0019 stale
530
+ // projection class).
531
+ // ---------------------------------------------------------------------------
532
+
533
+ // rc cross-session backlog constants. ANTI_LOOP mirrors archive-scan.ts.
534
+ const ARCHIVE_BACKLOG_ANTI_LOOP_HOURS = 12;
535
+ const DEFAULT_ARCHIVE_BACKLOG_SESSION_COUNT = 2;
536
+ const DEFAULT_ARCHIVE_BACKLOG_IDLE_HOURS = 24;
537
+
538
+ // Latest session_archive_attempted.covered_through_ts for this session, else null.
539
+ function sessionArchiveWatermark(events, sessionId) {
540
+ if (!Array.isArray(events) || typeof sessionId !== "string" || sessionId.length === 0) {
541
+ return null;
542
+ }
543
+ let wm = null;
544
+ for (const ev of events) {
545
+ if (!ev || ev.session_id !== sessionId) continue;
546
+ if (ev.event_type !== "session_archive_attempted") continue;
547
+ if (typeof ev.covered_through_ts !== "number") continue;
548
+ if (wm === null || ev.covered_through_ts > wm) wm = ev.covered_through_ts;
549
+ }
550
+ return wm;
551
+ }
552
+
553
+ // Earliest event ts carrying this session_id, else null.
554
+ function sessionFirstActivityTs(events, sessionId) {
555
+ if (!Array.isArray(events) || typeof sessionId !== "string" || sessionId.length === 0) {
556
+ return null;
557
+ }
558
+ let first = null;
559
+ for (const ev of events) {
560
+ if (!ev || ev.session_id !== sessionId || typeof ev.ts !== "number") continue;
561
+ if (first === null || ev.ts < first) first = ev.ts;
562
+ }
563
+ return first;
564
+ }
565
+
566
+ // Per-session archive anchor: this session's own last archive watermark, else
567
+ // its first activity ts. null only when the session has zero ledger presence.
568
+ function sessionAnchorTs(events, sessionId) {
569
+ const wm = sessionArchiveWatermark(events, sessionId);
570
+ if (wm !== null) return wm;
571
+ return sessionFirstActivityTs(events, sessionId);
572
+ }
573
+
574
+ // Count this session's `file_mutated` events strictly after the anchor (anchor
575
+ // null → count all of the session's mutations). Replaces the session-blind
576
+ // countEditsSince(edit-counter) for the archive TRIGGER (crack 1).
577
+ function countSessionMutationsSince(events, sessionId, anchorTs) {
578
+ if (!Array.isArray(events) || typeof sessionId !== "string" || sessionId.length === 0) {
579
+ return 0;
580
+ }
581
+ let count = 0;
582
+ for (const ev of events) {
583
+ if (!ev || ev.session_id !== sessionId) continue;
584
+ if (ev.event_type !== "file_mutated" || typeof ev.ts !== "number") continue;
585
+ if (anchorTs === null || ev.ts > anchorTs) count += 1;
586
+ }
587
+ return count;
588
+ }
589
+
590
+ // Cross-session safety net (crack 2). Counts DEAD sessions (carry a
591
+ // `session_ended` marker OR have been idle beyond idleHours) — OTHER than the
592
+ // current one — that hold unarchived high-value work and are NOT
593
+ // `user_dismissed` / inside the 12h anti-loop cooldown. This is the per-session
594
+ // replacement for the global-24h archive timer: it is NOT reset by any
595
+ // neighbour's archive, so a low-signal session that simply ended is no longer
596
+ // orphaned. Mirrors archive-scan.ts's outcome-filter semantics.
597
+ function countBacklogSessions(events, nowMs, currentSessionId, idleHours) {
598
+ if (!Array.isArray(events)) return 0;
599
+ const idleMs =
600
+ (typeof idleHours === "number" && idleHours > 0 ? idleHours : DEFAULT_ARCHIVE_BACKLOG_IDLE_HOURS) *
601
+ MS_PER_HOUR;
602
+ const lastActivity = new Map(); // sid -> max ts
603
+ const ended = new Set(); // sid with a session_ended marker
604
+ const lastAttempt = new Map(); // sid -> latest session_archive_attempted event
605
+ const sessions = new Set();
606
+ for (const ev of events) {
607
+ if (!ev || typeof ev.session_id !== "string" || ev.session_id.length === 0) continue;
608
+ const sid = ev.session_id;
609
+ sessions.add(sid);
610
+ if (typeof ev.ts === "number") {
611
+ const prev = lastActivity.get(sid);
612
+ if (prev === undefined || ev.ts > prev) lastActivity.set(sid, ev.ts);
613
+ }
614
+ if (ev.event_type === "session_ended") ended.add(sid);
615
+ if (ev.event_type === "session_archive_attempted" && typeof ev.ts === "number") {
616
+ const prior = lastAttempt.get(sid);
617
+ if (!prior || (typeof prior.ts === "number" && ev.ts > prior.ts)) lastAttempt.set(sid, ev);
618
+ }
619
+ }
620
+ let count = 0;
621
+ for (const sid of sessions) {
622
+ if (sid === currentSessionId) continue; // live lane handles the current session
623
+ const last = lastActivity.get(sid);
624
+ const isDead = ended.has(sid) || (typeof last === "number" && nowMs - last >= idleMs);
625
+ if (!isDead) continue;
626
+ const attempt = lastAttempt.get(sid);
627
+ if (attempt && attempt.outcome === "user_dismissed") continue; // respect dismissal
628
+ if (
629
+ attempt &&
630
+ typeof attempt.ts === "number" &&
631
+ nowMs - attempt.ts < ARCHIVE_BACKLOG_ANTI_LOOP_HOURS * MS_PER_HOUR
632
+ ) {
633
+ continue; // inside anti-loop cooldown
634
+ }
635
+ // Probe high-value work since the session's OWN archive watermark — null
636
+ // (never archived) means probe the whole session (wm→0), so a high-value
637
+ // signal that was the session's first event still counts. Using the
638
+ // first-activity anchor here would wrongly exclude it (strict `> anchor`).
639
+ const wm = sessionArchiveWatermark(events, sid);
640
+ if (!hasHighValueArchiveSignal(events, wm, sid)) continue; // no unarchived high-value work
641
+ count += 1;
642
+ }
643
+ return count;
644
+ }
645
+
646
+ // ---------------------------------------------------------------------------
647
+ // Observability grill (a + Q4): session-activity status breadcrumb.
648
+ //
649
+ // A no-signal Stop used to return SILENT — the human only ever heard from
650
+ // Fabric when there was a nudge to act on, never a "here is what I did" recap,
651
+ // which reads as "Fabric does nothing in the background". These two helpers add
652
+ // a HUMAN-ONLY trust anchor (the AI gets no activity recap — flow ⊥ observation,
653
+ // D5) plus the nudge_mode tier-guidance line (so the human-channel volume knob
654
+ // is discoverable). Cadence is gated by nudge_mode at emit time.
655
+ // ---------------------------------------------------------------------------
656
+
657
+ // Session-scoped tally. Counts ONLY events that carry session_id, filtered to
658
+ // the current session — knowledge_context_planned / knowledge_proposed lack
659
+ // session_id and are intentionally excluded (a cross-session count would
660
+ // mislead). Exported for unit tests.
661
+ function tallySessionActivity(events, sessionId) {
662
+ let edits = 0;
663
+ let consumed = 0;
664
+ if (!Array.isArray(events) || typeof sessionId !== "string" || sessionId.length === 0) {
665
+ return { edits, consumed };
666
+ }
667
+ for (const ev of events) {
668
+ if (!ev || ev.session_id !== sessionId) continue;
669
+ if (ev.event_type === "file_mutated") edits += 1;
670
+ else if (ev.event_type === "knowledge_consumed") consumed += 1;
671
+ }
672
+ return { edits, consumed };
673
+ }
674
+
675
+ // Emit the human-facing session status breadcrumb when no actionable signal
676
+ // fired. Human sink ONLY. Cadence by nudge_mode: silent → never; minimal/normal
677
+ // → once per session; verbose → every turn. Folds in the tier-guidance line on
678
+ // the first status of the session so the volume knob is discoverable. Never
679
+ // throws — the caller wraps it, but every branch degrades silently anyway.
680
+ function emitSessionStatus(cwd, events, stdinPayload, nowMs, pendingStats, out) {
681
+ if (nudgePolicy === null || clientAdapter === null) return;
682
+ if (typeof clientAdapter.emitDualSink !== "function") return;
683
+ const sessionId = resolveHookSessionId(stdinPayload);
684
+ if (typeof sessionId !== "string" || sessionId.length === 0) return;
685
+
686
+ const mode =
687
+ typeof nudgePolicy.readNudgeMode === "function" ? nudgePolicy.readNudgeMode(cwd) : "normal";
688
+ if (mode === "silent") return; // human channel globally muted
689
+
690
+ const tally = tallySessionActivity(events, sessionId);
691
+ const pending = pendingStats && typeof pendingStats.total === "number" ? pendingStats.total : 0;
692
+ // Nothing happened yet this session AND no backlog → no trust anchor to show.
693
+ if (tally.edits === 0 && tally.consumed === 0 && pending === 0) return;
694
+
695
+ // Cadence gate: normal/minimal show once per session; verbose every turn.
696
+ const cache = readShownCache(cwd, sessionId);
697
+ const firstThisSession = cache._status === undefined;
698
+ if (mode !== "verbose" && !firstThisSession) return;
699
+
700
+ const variant = readFabricLanguage(cwd);
701
+ const line1 = renderBanner("statusLine", variant, {
702
+ edits: tally.edits,
703
+ consumed: tally.consumed,
704
+ pending,
705
+ });
706
+ // Tier guidance only on the first status of the session (don't repeat it on
707
+ // every verbose turn).
708
+ const human = firstThisSession
709
+ ? `${line1}\n${renderBanner("statusTier", variant, { mode })}`
710
+ : line1;
711
+
712
+ clientAdapter.emitDualSink(
713
+ { human, ai: null },
714
+ { client: clientAdapter.detectClient(__dirname), eventName: "Stop", streams: { stdout: out } },
715
+ );
716
+
717
+ cache._status = nowMs;
718
+ writeShownCache(cwd, cache, sessionId);
719
+ }
720
+
414
721
  /**
415
722
  * v2.0.0-rc.8 (TASK-002): detect whether a fabric-import skill run is
416
723
  * currently in flight, used to gate Signal B (review hint) so the Stop
@@ -661,14 +968,17 @@ function readArchiveEditThreshold(projectRoot) {
661
968
  * Review wins over import because pending overflow is a sharper backlog signal
662
969
  * than a sparse corpus.
663
970
  *
664
- * The `editCounterStats` parameter is the parsed edit-counter view used by
665
- * the new Signal A edit branch:
666
- * { editsSinceLastProposed: number, threshold: number }
667
- * Defaults to { editsSinceLastProposed: 0, threshold: DEFAULT_ARCHIVE_EDIT_THRESHOLD }
668
- * when omitted preserves existing tests that don't populate it.
971
+ * The `editCounterStats` parameter is the per-session edit view (crack 1)
972
+ * computed in main() from file_mutated events:
973
+ * { editsSinceArchive: number, threshold: number, anchorPresent: boolean }
974
+ * The `backlogStats` parameter (crack 2) is the cross-session view:
975
+ * { deadSessionCount: number, threshold: number }
976
+ * Both default to a no-trigger shape when omitted (back-compat for callers
977
+ * pre-dating the two-lane split).
669
978
  *
670
979
  * Returns one of:
671
980
  * - { decision: 'block', reason, signal: 'archive', recommended_skill: 'fabric-archive' }
981
+ * - { decision: 'block', reason, signal: 'archive_backlog', recommended_skill: 'fabric-archive' }
672
982
  * - { decision: 'block', reason, signal: 'review', recommended_skill: 'fabric-review' }
673
983
  * - { decision: 'block', reason, signal: 'import', recommended_skill: 'fabric-import' }
674
984
  * - null on no trigger
@@ -678,21 +988,31 @@ function readArchiveEditThreshold(projectRoot) {
678
988
  // without touching the filesystem. Omitting the arg falls back to documented
679
989
  // defaults so existing in-process callers (tests that pre-date T7) still
680
990
  // pass without modification — they implicitly exercise the default path.
681
- function decide(events, now, pendingStats, underseedStats, editCounterStats, thresholds, banner, importInFlight) {
991
+ function decide(events, now, pendingStats, underseedStats, editCounterStats, thresholds, banner, importInFlight, backlogStats) {
682
992
  const nowMs = now instanceof Date ? now.getTime() : Number(now) || Date.now();
683
993
  const stats = pendingStats || { count: 0, oldestAgeMs: null };
684
994
  const underseed =
685
995
  underseedStats || { nodeCount: 0, threshold: DEFAULT_UNDERSEED_NODE_THRESHOLD };
996
+ // crack 1: per-session edit view. `editsSinceArchive` = current session's
997
+ // file_mutated count since its own archive anchor; `anchorPresent` = the
998
+ // session has any ledger activity (the trigger gate, replacing the old
999
+ // "global knowledge_proposed exists" gate).
686
1000
  const editStats =
687
1001
  editCounterStats || {
688
- editsSinceLastProposed: 0,
1002
+ editsSinceArchive: 0,
689
1003
  threshold: DEFAULT_ARCHIVE_EDIT_THRESHOLD,
1004
+ anchorPresent: false,
1005
+ };
1006
+ // crack 2: cross-session backlog view (dead sessions with unarchived work).
1007
+ const backlog =
1008
+ backlogStats || {
1009
+ deadSessionCount: 0,
1010
+ threshold: DEFAULT_ARCHIVE_BACKLOG_SESSION_COUNT,
690
1011
  };
691
1012
  const cfg = thresholds || {};
692
- const archiveHintHours =
693
- typeof cfg.archiveHintHours === "number" && cfg.archiveHintHours > 0
694
- ? cfg.archiveHintHours
695
- : DEFAULT_ARCHIVE_HINT_HOURS;
1013
+ // crack 2: the global archive_hint_hours timer is retired (the cross-session
1014
+ // case is now the archive_backlog signal). cfg.archiveHintHours is still
1015
+ // accepted on the thresholds bag for back-compat but no longer drives Signal A.
696
1016
  const reviewHintPendingCount =
697
1017
  typeof cfg.reviewHintPendingCount === "number" && cfg.reviewHintPendingCount > 0
698
1018
  ? cfg.reviewHintPendingCount
@@ -706,10 +1026,18 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
706
1026
  // byte-identical Chinese output. main() always supplies the resolved variant.
707
1027
  const variant = typeof cfg.variant === "string" ? cfg.variant : "zh-CN";
708
1028
 
709
- // ---- Archive signal (rc.6 TASK-022Signal A, 24h-OR-N-edits) -----------
710
- // Locate the most-recent knowledge_proposed event. If none exists, Signal A
711
- // stays silent a never-archived workspace is the import signal's domain.
712
- // Edit count without an anchor is meaningless and intentionally ignored.
1029
+ // ---- Archive signal (crack 1per-session edit count) -------------------
1030
+ // In-session lane: nudge when THIS session has accumulated >= threshold file
1031
+ // mutations since its OWN archive anchor (computed per-session in main() from
1032
+ // file_mutated events `editStats.editsSinceArchive`). The old global
1033
+ // 24h-OR-N-edits trigger is retired: the hours branch became the
1034
+ // archive_backlog signal below (crack 2), and the edit count is now
1035
+ // session-scoped so a neighbour window's archive can't zero this window's
1036
+ // work. `anchorPresent` gates the trigger (a session with zero ledger
1037
+ // activity has nothing to count).
1038
+ //
1039
+ // `lastProposedTs` / `hoursElapsed` are still derived here for the IMPORT
1040
+ // signal's "no knowledge_proposed in last 24h" guard further down.
713
1041
  let lastProposedTs = null;
714
1042
  for (let i = events.length - 1; i >= 0; i -= 1) {
715
1043
  const ev = events[i];
@@ -718,63 +1046,27 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
718
1046
  break;
719
1047
  }
720
1048
  }
721
-
722
1049
  const hoursElapsed =
723
1050
  lastProposedTs === null ? null : (nowMs - lastProposedTs) / MS_PER_HOUR;
724
1051
 
725
- const triggerByHours =
726
- hoursElapsed !== null && hoursElapsed >= archiveHintHours;
727
1052
  const triggerByEdits =
728
- lastProposedTs !== null &&
729
- editStats.editsSinceLastProposed >= editStats.threshold;
730
-
731
- // PRECEDENCE: archive wins when Signal A fires, regardless of review/import
732
- // state. The user gets the archive reminder first; other reminders wait
733
- // until after archive happens.
734
- if (triggerByHours || triggerByEdits) {
735
- // rc.7 T4: 人-first banner the first reader is the human user in the
736
- // AI client UI, Agent reads incidentally (Q-13). We DROP the prior
737
- // Agent-jussive imperative ("建议调用 fabric-archive skill ...") in
738
- // favour of a polite question framing and an honest activity overview
739
- // from the edit-counter sidecar (Q-6: the hook has zero content
740
- // awareness, only file-fire awareness — no fabricated "N candidates
741
- // detected" framing).
742
- //
743
- // The activity overview is injected by the caller (main() supplies it
744
- // via the `banner` arg) so decide() stays pure / filesystem-free for
745
- // tests. When omitted (legacy callers / tests pre-T4) the overview
746
- // line is skipped — the banner remains valid 3-or-2 lines depending
747
- // on data availability.
748
- //
749
- // Substring contract preserved for existing tests:
750
- // - "<hoursElapsed.toFixed(1)>h" (e.g. "25.0h")
751
- // - "<editCount> 次编辑"
752
- // - "阈值 <N>"
753
- // - "fabric-archive"
754
- // v2.0.0-rc.27 TASK-005 (audit §2.17): parts now assembled per-variant
755
- // via banner-i18n's archivePartsHours / archivePartsEdits so en mode
756
- // gets fully-English fragments instead of mixed-language output. zh-CN
757
- // / zh-CN-hybrid still render the original substring contract verbatim.
758
- const parts = [];
759
- if (triggerByHours) {
760
- parts.push(
761
- renderBanner("archivePartsHours", variant, {
762
- hoursFixed: hoursElapsed.toFixed(1),
763
- threshold: archiveHintHours,
764
- }),
765
- );
766
- }
767
- if (triggerByEdits) {
768
- parts.push(
769
- renderBanner("archivePartsEdits", variant, {
770
- count: editStats.editsSinceLastProposed,
771
- threshold: editStats.threshold,
772
- }),
773
- );
774
- }
775
- // rc.16 TASK-002: 5-banner i18n via lib/banner-i18n.cjs. Substring
776
- // contracts ('25.0h', '阈值 N', 'fabric-archive') preserved by the lib's
777
- // zh-CN templates — see lib header for the full contract.
1053
+ editStats.anchorPresent === true &&
1054
+ typeof editStats.editsSinceArchive === "number" &&
1055
+ editStats.editsSinceArchive >= editStats.threshold;
1056
+
1057
+ // PRECEDENCE: in-session archive wins over backlog/review/import recent
1058
+ // local work is the most actionable reminder.
1059
+ if (triggerByEdits) {
1060
+ // 人-first banner: edit-count fragment only (the hours fragment retired with
1061
+ // the global timer). Substring contracts ('次编辑', '阈值 N', 'fabric-archive')
1062
+ // preserved by banner-i18n's zh-CN templates. The activity overview line is
1063
+ // injected by main() via `banner` so decide() stays pure / filesystem-free.
1064
+ const parts = [
1065
+ renderBanner("archivePartsEdits", variant, {
1066
+ count: editStats.editsSinceArchive,
1067
+ threshold: editStats.threshold,
1068
+ }),
1069
+ ];
778
1070
  const line1 = renderBanner("archiveLine1", variant, { parts: parts.join(" / ") });
779
1071
  const activity = banner && typeof banner.activityOverview === "string"
780
1072
  ? banner.activityOverview
@@ -790,10 +1082,30 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
790
1082
  signal: "archive",
791
1083
  recommended_skill: "fabric-archive",
792
1084
  // 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,
1085
+ // hook_signal_emitted ledger row main() writes.
1086
+ threshold: editStats.threshold,
1087
+ actual_value: editStats.editsSinceArchive,
1088
+ };
1089
+ }
1090
+
1091
+ // ---- Archive backlog signal (crack 2 — cross-session safety net) ---------
1092
+ // Fires when N+ DEAD sessions (session_ended / idle) carry unarchived
1093
+ // high-value work — the per-session replacement for the old global-24h timer
1094
+ // (which any neighbour's archive reset, orphaning low-signal ended sessions).
1095
+ // KT-DEC-0007: a soft reminder, never a gate. Ranked AFTER in-session archive
1096
+ // but BEFORE review/import: losing knowledge from an ended session is a
1097
+ // sharper signal than a review/import backlog.
1098
+ if (backlog.threshold > 0 && backlog.deadSessionCount >= backlog.threshold) {
1099
+ const line1 = renderBanner("backlogLine1", variant, { count: backlog.deadSessionCount });
1100
+ const line2 = renderBanner("backlogCta", variant, {});
1101
+ const reason = `${line1}\n${line2}`;
1102
+ return {
1103
+ decision: "block",
1104
+ reason,
1105
+ signal: "archive_backlog",
1106
+ recommended_skill: "fabric-archive",
1107
+ threshold: backlog.threshold,
1108
+ actual_value: backlog.deadSessionCount,
797
1109
  };
798
1110
  }
799
1111
 
@@ -862,6 +1174,10 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
862
1174
  lastInitScanTs === null ? null : (nowMs - lastInitScanTs) / MS_PER_HOUR;
863
1175
  const hoursSinceProposed = hoursElapsed; // reuse archive-signal calc above
864
1176
  const triggerUnderseed =
1177
+ // #3: null = undeterminable canonical count (old snapshot) → skip. Guard
1178
+ // first because `null < threshold` coerces to true in JS and would else
1179
+ // false-fire the underseed nudge on a stale corpus.
1180
+ underseed.nodeCount != null &&
865
1181
  underseed.nodeCount < underseed.threshold &&
866
1182
  hoursSinceInit !== null &&
867
1183
  hoursSinceInit >= UNDERSEED_POST_INIT_QUIET_HOURS &&
@@ -948,6 +1264,23 @@ function readMaintenanceHintCooldownDays(projectRoot) {
948
1264
  );
949
1265
  }
950
1266
 
1267
+ // crack 2: cross-session backlog signal thresholds.
1268
+ function readArchiveBacklogSessionCount(projectRoot) {
1269
+ return _readConfigNumber(
1270
+ projectRoot,
1271
+ "archive_backlog_session_count",
1272
+ DEFAULT_ARCHIVE_BACKLOG_SESSION_COUNT,
1273
+ );
1274
+ }
1275
+
1276
+ function readArchiveBacklogIdleHours(projectRoot) {
1277
+ return _readConfigNumber(
1278
+ projectRoot,
1279
+ "archive_backlog_idle_hours",
1280
+ DEFAULT_ARCHIVE_BACKLOG_IDLE_HOURS,
1281
+ );
1282
+ }
1283
+
951
1284
  /**
952
1285
  * Resolve the cooldown setting from .fabric/fabric-config.json
953
1286
  * (archive_hint_cooldown_hours), falling back to DEFAULT_COOLDOWN_HOURS.
@@ -984,8 +1317,34 @@ function readUnderseedThreshold(projectRoot) {
984
1317
  return DEFAULT_UNDERSEED_NODE_THRESHOLD;
985
1318
  }
986
1319
 
987
- function readShownCache(projectRoot) {
988
- const cachePath = join(projectRoot, SHOWN_CACHE_FILE);
1320
+ // F13 (ISS-20260531-038): the reminder cooldown sidecars were process-global
1321
+ // (one file per project, no session key), so in concurrent multi-window sessions
1322
+ // one window firing a nudge wrote the cooldown and silenced that nudge in EVERY
1323
+ // other window. Scope the sidecar filename by sessionId — mirrors the already-
1324
+ // session-scoped dismiss sidecar (sessionDismissFileName). Backward-compatible:
1325
+ // a null/absent sessionId falls back to the legacy non-scoped path (upgrade +
1326
+ // pre-session-id callers), so existing on-disk state and tests are unaffected;
1327
+ // the Stop hook always passes the real session_id from its stdin payload.
1328
+ function resolveHookSessionId(payload) {
1329
+ return payload && typeof payload.session_id === "string" && payload.session_id.length > 0
1330
+ ? payload.session_id
1331
+ : null;
1332
+ }
1333
+
1334
+ function sessionScopedCacheFile(baseRelPath, sessionId) {
1335
+ if (sessionId === undefined || sessionId === null || String(sessionId).length === 0) {
1336
+ return baseRelPath;
1337
+ }
1338
+ const safe = String(sessionId).replace(/[^A-Za-z0-9_.-]/g, "-");
1339
+ const lastSlash = baseRelPath.lastIndexOf("/");
1340
+ const dot = baseRelPath.lastIndexOf(".");
1341
+ return dot > lastSlash
1342
+ ? `${baseRelPath.slice(0, dot)}-${safe}${baseRelPath.slice(dot)}`
1343
+ : `${baseRelPath}-${safe}`;
1344
+ }
1345
+
1346
+ function readShownCache(projectRoot, sessionId) {
1347
+ const cachePath = join(projectRoot, sessionScopedCacheFile(SHOWN_CACHE_FILE, sessionId));
989
1348
  if (!existsSync(cachePath)) return {};
990
1349
  try {
991
1350
  const parsed = JSON.parse(readFileSync(cachePath, "utf8"));
@@ -995,12 +1354,11 @@ function readShownCache(projectRoot) {
995
1354
  }
996
1355
  }
997
1356
 
998
- function writeShownCache(projectRoot, cache) {
999
- const cachePath = join(projectRoot, SHOWN_CACHE_FILE);
1357
+ function writeShownCache(projectRoot, cache, sessionId) {
1358
+ const cachePath = join(projectRoot, sessionScopedCacheFile(SHOWN_CACHE_FILE, sessionId));
1000
1359
  try {
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.
1360
+ // ISS-016: atomic tmp+rename so a crash never leaves a truncated shown-cache.
1361
+ // Falls back to a plain write only if the shared lib failed to load.
1004
1362
  if (stateStore && typeof stateStore.atomicWrite === "function") {
1005
1363
  stateStore.atomicWrite(cachePath, JSON.stringify(cache));
1006
1364
  } else {
@@ -1033,7 +1391,7 @@ function writeShownCache(projectRoot, cache) {
1033
1391
  // precedence model — KT-DEC-0007 anti-nag spirit).
1034
1392
  // -----------------------------------------------------------------------------
1035
1393
 
1036
- const DISMISSABLE_SIGNALS = ["archive", "review", "import", "maintenance"];
1394
+ const DISMISSABLE_SIGNALS = ["archive", "archive_backlog", "review", "import", "maintenance"];
1037
1395
 
1038
1396
  function sessionDismissFileName(sessionId) {
1039
1397
  const safe = String(sessionId || "anonymous").replace(/[^A-Za-z0-9_.-]/g, "-");
@@ -1124,8 +1482,8 @@ function findLastDoctorRunTs(events) {
1124
1482
  * v2.0.0-rc.7 T10: read the Signal-D cooldown sidecar timestamp (epoch ms).
1125
1483
  * Missing file / parse failure → null (allow signal to fire).
1126
1484
  */
1127
- function readMaintenanceLastEmit(projectRoot) {
1128
- const p = join(projectRoot, MAINTENANCE_HINT_LAST_EMIT_FILE);
1485
+ function readMaintenanceLastEmit(projectRoot, sessionId) {
1486
+ const p = join(projectRoot, sessionScopedCacheFile(MAINTENANCE_HINT_LAST_EMIT_FILE, sessionId));
1129
1487
  if (!existsSync(p)) return null;
1130
1488
  try {
1131
1489
  const raw = readFileSync(p, "utf8").trim();
@@ -1140,8 +1498,8 @@ function readMaintenanceLastEmit(projectRoot) {
1140
1498
  return null;
1141
1499
  }
1142
1500
 
1143
- function writeMaintenanceLastEmit(projectRoot, nowMs) {
1144
- const p = join(projectRoot, MAINTENANCE_HINT_LAST_EMIT_FILE);
1501
+ function writeMaintenanceLastEmit(projectRoot, nowMs, sessionId) {
1502
+ const p = join(projectRoot, sessionScopedCacheFile(MAINTENANCE_HINT_LAST_EMIT_FILE, sessionId));
1145
1503
  try {
1146
1504
  // ISS-016: atomic tmp+rename (see writeShownCache).
1147
1505
  if (stateStore && typeof stateStore.atomicWrite === "function") {
@@ -1246,6 +1604,101 @@ function evaluateMaintenanceSignal(events, now, canonicalCount, lastEmitMs, thre
1246
1604
  };
1247
1605
  }
1248
1606
 
1607
+ // lifecycle-refactor W3-A2 (§7 graph generation signal): after a successful
1608
+ // archive the Stop hook REQUESTS edge extraction by emitting one
1609
+ // graph_edge_candidate_requested{stable_id, store?}. The hook never PRODUCES
1610
+ // edges (that is the archive/import skill's or doctor co-occurrence's job,
1611
+ // KT-DEC-0007) — it only flags "this entry just landed; someone should extract
1612
+ // its `related` edges". FROZEN-safe: O(1) tail scan, best-effort silent, single
1613
+ // advisory-locked appendLockedLine (same primitive the rest of this hook uses).
1614
+ //
1615
+ // HONEST stable_id sourcing — the deliberate limitation: pending entries (the
1616
+ // fabric-archive → extractKnowledge path) carry NO canonical stable_id (id is
1617
+ // late-bound at fab_review approve), so their knowledge_proposed event omits
1618
+ // stable_id (or sets the `pending:<key>` sentinel). A graph edge between
1619
+ // id-less pending drafts is meaningless, so we DO NOT fabricate one. We emit
1620
+ // ONLY when the most-recent knowledge_proposed event carries a real
1621
+ // K[TP]-XXX-NNNN stable_id (the approve/promote path) — i.e. an entry that
1622
+ // actually has a canonical node to attach edges to. When the latest proposed
1623
+ // is id-less we honestly skip; the request will fire on the approve event that
1624
+ // allocates the id. A session-scoped sidecar de-dupes so repeated Stop fires in
1625
+ // one session don't re-request the same id.
1626
+ const STABLE_ID_RE = /^K[TP]-[A-Z]{3}-\d{4}$/;
1627
+ const GRAPH_EDGE_REQUESTED_SIDECAR = ".fabric/.cache/graph-edge-requested";
1628
+
1629
+ function emitGraphEdgeCandidateBestEffort(cwd, events, sessionId) {
1630
+ try {
1631
+ if (!Array.isArray(events) || events.length === 0) return;
1632
+ const fabricDir = join(cwd, FABRIC_DIR);
1633
+ if (!existsSync(fabricDir)) return;
1634
+
1635
+ // O(1)-amortized tail scan for the newest knowledge_proposed carrying a
1636
+ // real (non-sentinel) stable_id. Stop at the first knowledge_proposed we
1637
+ // see — if the latest archive is id-less, we honestly skip rather than
1638
+ // reaching back to an older approved entry (that older entry's edges were
1639
+ // already requested when IT landed).
1640
+ let stableId = null;
1641
+ let store;
1642
+ for (let i = events.length - 1; i >= 0; i -= 1) {
1643
+ const ev = events[i];
1644
+ if (!ev || ev.event_type !== EVENT_TYPE_PROPOSED) continue;
1645
+ const candidate = typeof ev.stable_id === "string" ? ev.stable_id : null;
1646
+ if (candidate && STABLE_ID_RE.test(candidate)) {
1647
+ stableId = candidate;
1648
+ if (typeof ev.store === "string" && ev.store.length > 0) store = ev.store;
1649
+ }
1650
+ // First knowledge_proposed encountered (newest) decides; do not walk past
1651
+ // it to an older one.
1652
+ break;
1653
+ }
1654
+ if (stableId === null) return;
1655
+
1656
+ // Session-scoped de-dup: skip if we already requested edges for this exact
1657
+ // stable_id this session. Sidecar is a single line holding the last id.
1658
+ const sidecarPath = join(cwd, sessionScopedCacheFile(GRAPH_EDGE_REQUESTED_SIDECAR, sessionId));
1659
+ try {
1660
+ if (existsSync(sidecarPath)) {
1661
+ const prev = readFileSync(sidecarPath, "utf8").trim();
1662
+ if (prev === stableId) return;
1663
+ }
1664
+ } catch {
1665
+ // unreadable sidecar → fall through and (re)emit; de-dup is best-effort.
1666
+ }
1667
+
1668
+ let idSuffix;
1669
+ try {
1670
+ idSuffix = require("node:crypto").randomUUID();
1671
+ } catch {
1672
+ idSuffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
1673
+ }
1674
+ const event = {
1675
+ kind: "fabric-event",
1676
+ id: `event:${idSuffix}`,
1677
+ ts: Date.now(),
1678
+ schema_version: 1,
1679
+ event_type: "graph_edge_candidate_requested",
1680
+ stable_id: stableId,
1681
+ };
1682
+ if (store !== undefined) event.store = store;
1683
+ if (typeof sessionId === "string" && sessionId.length > 0) event.session_id = sessionId;
1684
+ appendLockedLine(join(fabricDir, EVENT_LEDGER_FILE), JSON.stringify(event) + "\n");
1685
+
1686
+ // Record the de-dup marker (best-effort; atomic when state-store lib loaded).
1687
+ try {
1688
+ if (stateStore && typeof stateStore.atomicWrite === "function") {
1689
+ stateStore.atomicWrite(sidecarPath, stableId);
1690
+ } else {
1691
+ mkdirSync(dirname(sidecarPath), { recursive: true });
1692
+ writeFileSync(sidecarPath, stableId);
1693
+ }
1694
+ } catch {
1695
+ // de-dup marker write failed — at worst we re-request next Stop; harmless.
1696
+ }
1697
+ } catch {
1698
+ // best-effort §7 signal — never block the Stop hook (KT-DEC-0007).
1699
+ }
1700
+ }
1701
+
1249
1702
  // v2.1 NEW-N-3 (ADJ-NEWN-3): hook_signal_emitted instrumentation. Writes ONE
1250
1703
  // best-effort ledger row at the point a nudge is actually delivered (post-
1251
1704
  // cooldown), so the join key measures nudge-trigger logic (which signal fired,
@@ -1390,14 +1843,13 @@ function parseKbLine(raw) {
1390
1843
  * "codex". Covers the dominant deployment shape (hook script lives
1391
1844
  * under the client's per-repo dir).
1392
1845
  *
1393
- * Returns `undefined` when neither signal fires (e.g. Cursor deferred to
1394
- * rc.21 — or a custom deployment). The Zod schema marks `client` optional,
1395
- * so omitting it leaves the event valid.
1846
+ * Returns `undefined` when neither signal fires (a custom deployment). The
1847
+ * Zod schema marks `client` optional, so omitting it leaves the event valid.
1396
1848
  */
1397
1849
  function detectClient() {
1398
1850
  // Delegate the full 3-tier detection (env → CLAUDE_PROJECT_DIR → path
1399
- // heuristic, incl. .cursor) to the shared adapter. __dirname is passed so
1400
- // the path heuristic reflects THIS hook's location.
1851
+ // heuristic) to the shared adapter. __dirname is passed so the path
1852
+ // heuristic reflects THIS hook's location.
1401
1853
  if (clientAdapter && typeof clientAdapter.detectClient === "function") {
1402
1854
  return clientAdapter.detectClient(__dirname);
1403
1855
  }
@@ -1405,7 +1857,7 @@ function detectClient() {
1405
1857
  const envClient = process.env.FABRIC_HINT_CLIENT;
1406
1858
  if (typeof envClient === "string" && envClient.length > 0) {
1407
1859
  const normalised = envClient.trim().toLowerCase();
1408
- if (normalised === "cc" || normalised === "codex" || normalised === "cursor") {
1860
+ if (normalised === "cc" || normalised === "codex") {
1409
1861
  return normalised;
1410
1862
  }
1411
1863
  }
@@ -1744,50 +2196,6 @@ function summarizeTranscript(transcriptPath) {
1744
2196
  return out;
1745
2197
  }
1746
2198
 
1747
- /**
1748
- * v2.0.0-rc.24 TASK-05: emit soft L1 reminder to stderr when assistant turns
1749
- * cited a decision/pitfall id with [recalled] but no operator contract and no
1750
- * skip:<reason>. Reads agents.meta.json once per invocation; aggregated per
1751
- * turn (one line per offending id). Non-blocking — never throws, always
1752
- * returns the array of emitted reminder strings (for unit tests + callers
1753
- * that want to observe what was written).
1754
- *
1755
- * The reminder writes go to stderr (the hook contract: stdout is structured
1756
- * banner JSON consumed by the harness; stderr is free-text system message
1757
- * that surfaces back to the model on the next turn in cc / codex / cursor).
1758
- */
1759
- function emitCiteContractRemindersBestEffort(cwd, stdinPayload, stderr) {
1760
- if (citeContractReminder === null) return [];
1761
- if (stdinPayload === null || typeof stdinPayload !== "object") return [];
1762
- try {
1763
- const transcript = summarizeTranscript(stdinPayload.transcript_path);
1764
- const turns = transcript.assistant_turns;
1765
- if (!Array.isArray(turns) || turns.length === 0) return [];
1766
-
1767
- const idTypeMap = citeContractReminder.readKnowledgeTypeMap(cwd);
1768
- if (!(idTypeMap instanceof Map) || idTypeMap.size === 0) return [];
1769
-
1770
- const reminders = citeContractReminder.formatContractMissingReminders({
1771
- assistant_turns: turns,
1772
- idTypeMap,
1773
- });
1774
- if (!Array.isArray(reminders) || reminders.length === 0) return [];
1775
-
1776
- const sink = stderr || process.stderr;
1777
- for (const line of reminders) {
1778
- try {
1779
- sink.write(line + "\n");
1780
- } catch {
1781
- // Sink write failure must not abort emission of remaining reminders.
1782
- }
1783
- }
1784
- return reminders;
1785
- } catch {
1786
- // Outer guard — never throw. Hook continues silently.
1787
- return [];
1788
- }
1789
- }
1790
-
1791
2199
  /**
1792
2200
  * v2.0.0-rc.7 T5: writeSessionDigestBestEffort — non-blocking digest fan-out.
1793
2201
  * Called from main() before the existing decide() flow. Failure is silently
@@ -1841,17 +2249,18 @@ function main(env, stdio) {
1841
2249
  // the hook's other I/O).
1842
2250
  extractAndWriteAssistantTurnsBestEffort(cwd, stdinPayload);
1843
2251
 
1844
- // v2.0.0-rc.24 TASK-05: L1 soft reminder layer. Surfaces ⚠ KB:<id> lines
1845
- // to stderr when decision/pitfall cites arrived with [recalled] tag but
1846
- // empty contract. Non-blocking, never throws; doctor (TASK-08) catches
1847
- // any contract violation the model ignored.
1848
- emitCiteContractRemindersBestEffort(
1849
- cwd,
1850
- stdinPayload,
1851
- stdio && stdio.stderr,
1852
- );
1853
-
1854
2252
  const events = readLedger(cwd);
2253
+
2254
+ // lifecycle-refactor W3-A2 (§7): request graph-edge extraction for a freshly
2255
+ // archived canonical entry. Runs UNCONDITIONALLY here (before the nudge
2256
+ // cooldown/dismiss early-returns) so the §7 signal is independent of whether
2257
+ // a reminder banner is shown this Stop. Best-effort, never throws.
2258
+ try {
2259
+ emitGraphEdgeCandidateBestEffort(cwd, events, resolveHookSessionId(stdinPayload));
2260
+ } catch {
2261
+ // never block the Stop hook
2262
+ }
2263
+
1855
2264
  let pendingStats;
1856
2265
  try {
1857
2266
  pendingStats = readPendingStats(cwd, now);
@@ -1874,24 +2283,41 @@ function main(env, stdio) {
1874
2283
  // ts to anchor the count; rather than rescanning events here, we mirror
1875
2284
  // decide()'s scan locally to keep the helper pure. The threshold comes
1876
2285
  // from fabric-config.json (archive_edit_threshold, default 20).
2286
+ // crack 1: per-session edit view. anchor = THIS session's own last archive
2287
+ // watermark (session_archive_attempted.covered_through_ts) else its first
2288
+ // ledger activity; count = THIS session's file_mutated events since anchor.
2289
+ // Reads the event ledger, NOT the session-blind edit-counter sidecar.
1877
2290
  let editCounterStats;
1878
2291
  try {
1879
- let anchorTs = null;
1880
- for (let i = events.length - 1; i >= 0; i -= 1) {
1881
- const ev = events[i];
1882
- if (ev && ev.event_type === EVENT_TYPE_PROPOSED && typeof ev.ts === "number") {
1883
- anchorTs = ev.ts;
1884
- break;
1885
- }
1886
- }
2292
+ const sid = resolveHookSessionId(stdinPayload);
2293
+ const anchorTs = sessionAnchorTs(events, sid);
1887
2294
  editCounterStats = {
1888
- editsSinceLastProposed: countEditsSince(cwd, anchorTs),
2295
+ editsSinceArchive: countSessionMutationsSince(events, sid, anchorTs),
1889
2296
  threshold: readArchiveEditThreshold(cwd),
2297
+ anchorPresent: anchorTs !== null,
1890
2298
  };
1891
2299
  } catch {
1892
2300
  editCounterStats = {
1893
- editsSinceLastProposed: 0,
2301
+ editsSinceArchive: 0,
1894
2302
  threshold: DEFAULT_ARCHIVE_EDIT_THRESHOLD,
2303
+ anchorPresent: false,
2304
+ };
2305
+ }
2306
+
2307
+ // crack 2: cross-session backlog view — count DEAD sessions (other than the
2308
+ // current one) carrying unarchived high-value work. Drives the
2309
+ // archive_backlog signal that replaces the retired global-24h timer.
2310
+ let backlogStats;
2311
+ try {
2312
+ const sid = resolveHookSessionId(stdinPayload);
2313
+ backlogStats = {
2314
+ deadSessionCount: countBacklogSessions(events, nowMs, sid, readArchiveBacklogIdleHours(cwd)),
2315
+ threshold: readArchiveBacklogSessionCount(cwd),
2316
+ };
2317
+ } catch {
2318
+ backlogStats = {
2319
+ deadSessionCount: 0,
2320
+ threshold: DEFAULT_ARCHIVE_BACKLOG_SESSION_COUNT,
1895
2321
  };
1896
2322
  }
1897
2323
 
@@ -1974,6 +2400,7 @@ function main(env, stdio) {
1974
2400
  thresholds,
1975
2401
  { activityOverview },
1976
2402
  importInFlight,
2403
+ backlogStats,
1977
2404
  );
1978
2405
 
1979
2406
  // v2.0.0-rc.7 T10: Signal D — maintenance hint. Evaluated AFTER A/B/C
@@ -1984,7 +2411,7 @@ function main(env, stdio) {
1984
2411
  // for the prompt to be actionable.
1985
2412
  if (result === null) {
1986
2413
  try {
1987
- const lastEmit = readMaintenanceLastEmit(cwd);
2414
+ const lastEmit = readMaintenanceLastEmit(cwd, resolveHookSessionId(stdinPayload));
1988
2415
  result = evaluateMaintenanceSignal(
1989
2416
  events,
1990
2417
  now,
@@ -1997,7 +2424,34 @@ function main(env, stdio) {
1997
2424
  }
1998
2425
  }
1999
2426
 
2000
- if (result === null) return;
2427
+ if (result === null) {
2428
+ // Observability grill (a): no actionable signal — instead of returning
2429
+ // silently (which made Fabric feel inert in the background), surface a
2430
+ // session-activity status breadcrumb to the human sink (gated by
2431
+ // nudge_mode). Best-effort: never block the Stop hook on it.
2432
+ try {
2433
+ emitSessionStatus(cwd, events, stdinPayload, nowMs, pendingStats, out);
2434
+ } catch {
2435
+ // status breadcrumb is decorative — never throw
2436
+ }
2437
+ return;
2438
+ }
2439
+
2440
+ // v2.2 dual-sink (Goal A / D6): VALUE-GATE the in-session archive nudge. The
2441
+ // edit trigger is the CHECK cadence; the nudge only fires when a high-value
2442
+ // signal accrued since the last archive (decouples check from disturb).
2443
+ // crack 1: re-anchored PER SESSION (watermark = this session's own anchor,
2444
+ // probe scoped to this session) so a neighbour window's high-value work past
2445
+ // the same global watermark can't keep — or suppress — THIS window's nudge.
2446
+ // archive_backlog already incorporates high-value in its count, so it is not
2447
+ // re-gated here. Other signals (review/import/maintenance) are unaffected.
2448
+ if (result.signal === "archive") {
2449
+ const sid = resolveHookSessionId(stdinPayload);
2450
+ const watermarkTs = sessionAnchorTs(events, sid);
2451
+ if (!hasHighValueArchiveSignal(events, watermarkTs, sid)) {
2452
+ return; // no high-value candidate → stay quiet (D6 value-gate)
2453
+ }
2454
+ }
2001
2455
 
2002
2456
  // v2.0.0-rc.37 NEW-16: per-signal dismiss. A chosen signal whose type the
2003
2457
  // user dismissed (config-durable or session sidecar) exits silently —
@@ -2019,10 +2473,10 @@ function main(env, stdio) {
2019
2473
  // pile. Best-effort; missing snapshot / single-store omits the line.
2020
2474
  if (bindingsSnapshotReader !== null && typeof result.reason === "string") {
2021
2475
  try {
2022
- const projectId = readProjectId(cwd);
2023
- if (projectId) {
2476
+ const bindingId = readWorkspaceBindingId(cwd);
2477
+ if (bindingId) {
2024
2478
  const label = bindingsSnapshotReader.formatStoreLabels(
2025
- bindingsSnapshotReader.readBindingsSnapshot(projectId),
2479
+ bindingsSnapshotReader.readBindingsSnapshot(bindingId),
2026
2480
  );
2027
2481
  if (label) {
2028
2482
  result.reason = `${result.reason}\n${label}`;
@@ -2041,7 +2495,7 @@ function main(env, stdio) {
2041
2495
  delete result.threshold;
2042
2496
  delete result.actual_value;
2043
2497
  out.write(JSON.stringify(result));
2044
- writeMaintenanceLastEmit(cwd, nowMs);
2498
+ writeMaintenanceLastEmit(cwd, nowMs, resolveHookSessionId(stdinPayload));
2045
2499
  return;
2046
2500
  }
2047
2501
 
@@ -2049,7 +2503,7 @@ function main(env, stdio) {
2049
2503
  // archive_hint_cooldown_hours (default 12h) regardless of state drift.
2050
2504
  // Pure reminder-noise reduction; the underlying trigger logic is unchanged.
2051
2505
  const cooldownMs = readCooldownHours(cwd) * MS_PER_HOUR;
2052
- const cache = readShownCache(cwd);
2506
+ const cache = readShownCache(cwd, resolveHookSessionId(stdinPayload));
2053
2507
  const lastShown = cache[result.signal];
2054
2508
  // rc.34 TASK-01 + review-fix (Gemini P1): future-stamped lastShown
2055
2509
  // (backward clock skew) bypasses cooldown — sidecar treated as expired.
@@ -2062,11 +2516,29 @@ function main(env, stdio) {
2062
2516
  }
2063
2517
 
2064
2518
  emitSignalFiredEvent(cwd, sessionId, result);
2519
+ const reasonText = typeof result.reason === "string" ? result.reason : "";
2065
2520
  delete result.threshold;
2066
2521
  delete result.actual_value;
2067
- out.write(JSON.stringify(result));
2522
+ // v2.2 dual-sink (Goal A / D3): the archive nudge is SOFT — emitted as
2523
+ // additionalContext(AI) + systemMessage(human), NEVER decision:block. The
2524
+ // human channel is gated by nudge_mode (D4/D5); the AI channel always carries
2525
+ // it (flow ⊥ observation). Missing it is backstopped by the SessionEnd marker
2526
+ // + cross-session debt (D3). Review/import keep the decision:block contract
2527
+ // (out of Goal A scope; KT-DEC-0007 nudge semantics unchanged for them).
2528
+ if (result.signal === "archive" && clientAdapter && typeof clientAdapter.emitDualSink === "function") {
2529
+ const humanGate =
2530
+ nudgePolicy !== null
2531
+ ? nudgePolicy.resolveHumanSink(cwd, "stop", { highValue: true })
2532
+ : { emitHuman: true };
2533
+ clientAdapter.emitDualSink(
2534
+ { human: humanGate.emitHuman ? reasonText : null, ai: reasonText },
2535
+ { client: clientAdapter.detectClient(__dirname), eventName: "Stop", streams: { stdout: out } },
2536
+ );
2537
+ } else {
2538
+ out.write(JSON.stringify(result));
2539
+ }
2068
2540
  cache[result.signal] = nowMs;
2069
- writeShownCache(cwd, cache);
2541
+ writeShownCache(cwd, cache, resolveHookSessionId(stdinPayload));
2070
2542
  } catch {
2071
2543
  // Silent — never block on hook failure.
2072
2544
  }
@@ -2078,6 +2550,8 @@ module.exports = {
2078
2550
  readPendingStats,
2079
2551
  countCanonicalNodes,
2080
2552
  countEditsSince,
2553
+ // observability grill (a): session-activity tally for the human status line.
2554
+ tallySessionActivity,
2081
2555
  // rc.7 T4: top-edited-directories aggregator + banner overview formatter.
2082
2556
  getTopEditedDirectories,
2083
2557
  formatActivityOverview,
@@ -2085,6 +2559,14 @@ module.exports = {
2085
2559
  // for unit testing of the truth table).
2086
2560
  isImportInFlight,
2087
2561
  decide,
2562
+ // crack 1 + 2: two-lane archive strategy helpers (exported for unit testing).
2563
+ sessionArchiveWatermark,
2564
+ sessionFirstActivityTs,
2565
+ sessionAnchorTs,
2566
+ countSessionMutationsSince,
2567
+ countBacklogSessions,
2568
+ readArchiveBacklogSessionCount,
2569
+ readArchiveBacklogIdleHours,
2088
2570
  readCooldownHours,
2089
2571
  readUnderseedThreshold,
2090
2572
  readArchiveEditThreshold,
@@ -2117,10 +2599,9 @@ module.exports = {
2117
2599
  parseKbLine,
2118
2600
  detectClient,
2119
2601
  extractAndWriteAssistantTurnsBestEffort,
2120
- // v2.0.0-rc.24 TASK-05: L1 soft reminder helpers (exported for unit testing
2121
- // of the contract-missing emission contract). The lib module itself is
2122
- // also exported indirectly via the reminder helper.
2123
- emitCiteContractRemindersBestEffort,
2602
+ // lifecycle-refactor W3-A2 (§7): graph-edge-candidate request emitter
2603
+ // (exported for unit testing of the honest stable_id-gating + de-dup).
2604
+ emitGraphEdgeCandidateBestEffort,
2124
2605
  CONSTANTS: {
2125
2606
  FABRIC_DIR,
2126
2607
  EVENT_LEDGER_FILE,
@@ -2158,6 +2639,8 @@ module.exports = {
2158
2639
  // v2.0.0-rc.8 (TASK-002): in-flight import gate for Signal B.
2159
2640
  IMPORT_STATE_FILE_REL,
2160
2641
  IMPORT_IN_FLIGHT_MAX_AGE_HOURS,
2642
+ // lifecycle-refactor W3-A2 (§7): graph-edge-request de-dup sidecar.
2643
+ GRAPH_EDGE_REQUESTED_SIDECAR,
2161
2644
  },
2162
2645
  };
2163
2646