@fenglimg/fabric-cli 2.1.0-rc.2 → 2.2.0-rc.10

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 (88) hide show
  1. package/README.md +8 -5
  2. package/dist/chunk-27HK6H5Y.js +69 -0
  3. package/dist/{chunk-BATF4PEJ.js → chunk-2KBCTMID.js} +31 -8
  4. package/dist/chunk-3D7B2UAZ.js +149 -0
  5. package/dist/{chunk-MF3OTILQ.js → chunk-3IOLS5EK.js} +48 -42
  6. package/dist/{plan-context-hint-FC6P3WFE.js → chunk-722JU5BP.js} +52 -12
  7. package/dist/{chunk-F46ORPOA.js → chunk-7ZDXBOOU.js} +271 -166
  8. package/dist/{doctor-QVNPHLJK.js → chunk-E7HJUU34.js} +248 -72
  9. package/dist/chunk-EOT63RDH.js +36 -0
  10. package/dist/chunk-FNHDQTPC.js +16 -0
  11. package/dist/chunk-HORSMSZL.js +26 -0
  12. package/dist/chunk-NLNH64A3.js +43 -0
  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-XJIPZNUP.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 +167 -16
  22. package/dist/info-7FKBTMVO.js +139 -0
  23. package/dist/install-v2-RINEA24K.js +3279 -0
  24. package/dist/{metrics-ACEQFPDU.js → metrics-HMFH4YHK.js} +22 -9
  25. package/dist/{onboard-coverage-MFCAEBDO.js → onboard-coverage-XSG77LL3.js} +48 -27
  26. package/dist/plan-context-hint-5TNGH3R4.js +12 -0
  27. package/dist/scope-explain-HLJZ2M33.js +48 -0
  28. package/dist/status-4R3TM4FJ.js +37 -0
  29. package/dist/store-HOCORVL3.js +563 -0
  30. package/dist/sync-DT5UJMMR.js +418 -0
  31. package/dist/{uninstall-TAXSUSKH.js → uninstall-IFN2KYBK.js} +128 -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 +573 -180
  39. package/templates/hooks/knowledge-hint-broad.cjs +648 -190
  40. package/templates/hooks/knowledge-hint-narrow.cjs +123 -77
  41. package/templates/hooks/lib/banner-i18n.cjs +31 -0
  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/injection-log.cjs +91 -0
  46. package/templates/hooks/lib/nudge-policy.cjs +117 -0
  47. package/templates/hooks/lib/state-store.cjs +90 -11
  48. package/templates/hooks/post-tooluse-mutation.cjs +386 -0
  49. package/templates/hooks/session-end-marker.cjs +140 -0
  50. package/templates/skills/fabric/SKILL.md +100 -0
  51. package/templates/skills/fabric-archive/SKILL.md +35 -24
  52. package/templates/skills/fabric-archive/ref/dry-run-scope.md +1 -1
  53. package/templates/skills/fabric-archive/ref/i18n-policy.md +2 -3
  54. package/templates/skills/fabric-archive/ref/phase-1-5-onboard.md +2 -3
  55. package/templates/skills/fabric-archive/ref/phase-1-cross-session.md +1 -1
  56. package/templates/skills/fabric-archive/ref/phase-2-5-viability.md +1 -1
  57. package/templates/skills/fabric-archive/ref/phase-3-6-related-edges.md +18 -0
  58. package/templates/skills/fabric-archive/ref/phase-3-7-semantic-scope.md +47 -0
  59. package/templates/skills/fabric-audit/SKILL.md +63 -0
  60. package/templates/skills/fabric-connect/SKILL.md +48 -0
  61. package/templates/skills/fabric-import/SKILL.md +7 -7
  62. package/templates/skills/fabric-import/ref/i18n-policy.md +2 -3
  63. package/templates/skills/fabric-import/ref/state-recovery.md +1 -2
  64. package/templates/skills/fabric-review/SKILL.md +16 -5
  65. package/templates/skills/fabric-review/ref/cite-contract.md +56 -0
  66. package/templates/skills/fabric-review/ref/i18n-policy.md +2 -3
  67. package/templates/skills/fabric-review/ref/output-contract.md +1 -1
  68. package/templates/skills/fabric-review/ref/per-mode-flows.md +2 -2
  69. package/templates/skills/fabric-review/ref/worked-examples.md +1 -1
  70. package/templates/skills/fabric-store/SKILL.md +44 -0
  71. package/templates/skills/fabric-sync/SKILL.md +1 -1
  72. package/templates/skills/lib/shared-policy.md +2 -2
  73. package/dist/chunk-HFQVXY6P.js +0 -86
  74. package/dist/chunk-L4Q55UC4.js +0 -52
  75. package/dist/chunk-LFIKMVY7.js +0 -27
  76. package/dist/chunk-PWLW3B57.js +0 -18
  77. package/dist/chunk-RYAFBNES.js +0 -33
  78. package/dist/chunk-T5RPGCCM.js +0 -40
  79. package/dist/chunk-WWNXR34K.js +0 -49
  80. package/dist/install-2HDO5FTQ.js +0 -2683
  81. package/dist/scope-explain-2F2R5URO.js +0 -33
  82. package/dist/status-GLQWLWH6.js +0 -23
  83. package/dist/store-XTSE5TY6.js +0 -105
  84. package/dist/sync-BJCWDPNC.js +0 -245
  85. package/dist/whoami-B6AEMSEV.js +0 -31
  86. package/templates/hooks/configs/cursor-hooks.json +0 -18
  87. package/templates/hooks/lib/cite-contract-reminder.cjs +0 -179
  88. package/templates/hooks/lib/summary-fallback.cjs +0 -210
@@ -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;
@@ -38,23 +44,6 @@ try {
38
44
  citeLineParser = null;
39
45
  }
40
46
 
41
- // v2.0.0-rc.24 TASK-05: L1 enforcement layer — soft Stop hook reminder for
42
- // [recalled] cites of decision/pitfall types that arrived without operator
43
- // contract or skip:<reason>. Reads .fabric/agents.meta.json (via
44
- // lib/cite-contract-reminder.cjs#readKnowledgeTypeMap) to type-route cite
45
- // ids per B6 lock; emits one
46
- // ⚠ KB: <id> cited as [recalled] but missing contract; add → edit:<glob>
47
- // or → skip:<reason> next turn
48
- // line to stderr per offending id. Non-blocking, never throws.
49
- let citeContractReminder = null;
50
- try {
51
- citeContractReminder = require("./lib/cite-contract-reminder.cjs");
52
- } catch {
53
- // Helper module missing — soft reminder simply doesn't fire. Audit-side
54
- // doctor (TASK-08) still catches contract violations at the next run.
55
- citeContractReminder = null;
56
- }
57
-
58
47
  // v2.0.0-rc.37 NEW-30: shared client-protocol adapter. Guarded require (this
59
48
  // hook runs in arbitrary user repos); detectClient delegates the 3-tier
60
49
  // detection to the lib, falling back to env-only when the lib is absent.
@@ -65,6 +54,17 @@ try {
65
54
  clientAdapter = null;
66
55
  }
67
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
+
68
68
  // v2.0.0-rc.37 NEW-16: shared config + sidecar I/O for the per-signal dismiss
69
69
  // feature (config-level durable opt-out + session-scoped sidecar). Guarded
70
70
  // require (house style); dismiss simply doesn't fire if the lib is absent.
@@ -92,16 +92,115 @@ try {
92
92
  bindingsSnapshotReader = null;
93
93
  }
94
94
 
95
- // Read the project's own `project_id` (the snapshot key) from its config.
96
- 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) {
97
99
  try {
98
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;
99
102
  return typeof parsed.project_id === "string" ? parsed.project_id : null;
100
103
  } catch {
101
104
  return null;
102
105
  }
103
106
  }
104
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
+
105
204
  // CONSTANTS — duplicated from packages/server/src/services/_shared.ts.
106
205
  // DRY violation accepted: this hook script runs in user repos WITHOUT
107
206
  // node_modules access, so it cannot import from @fenglimg/fabric-server.
@@ -119,6 +218,59 @@ const EVENT_LEDGER_FILE = "events.jsonl";
119
218
  const METRICS_LEDGER_FILE = "metrics.jsonl";
120
219
  const EVENT_TYPE_PROPOSED = "knowledge_proposed";
121
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
+ function hasHighValueArchiveSignal(events, watermarkTs) {
252
+ if (!Array.isArray(events)) return false;
253
+ const wm = typeof watermarkTs === "number" ? watermarkTs : 0;
254
+ let latestTurn = null;
255
+ for (const e of events) {
256
+ if (!e || typeof e.ts !== "number" || e.ts <= wm) continue;
257
+ if (typeof e.event_type === "string" && ARCHIVE_HIGH_VALUE_EVENT_TYPES.has(e.event_type)) {
258
+ return true;
259
+ }
260
+ if (e.event_type === "assistant_turn_observed") {
261
+ if (latestTurn === null || (typeof latestTurn.ts === "number" && e.ts > latestTurn.ts)) {
262
+ latestTurn = e;
263
+ }
264
+ }
265
+ }
266
+ if (latestTurn !== null) {
267
+ const haystack = JSON.stringify(latestTurn).toLowerCase();
268
+ for (const kw of ARCHIVE_NORMATIVE_KEYWORDS) {
269
+ if (haystack.includes(kw.toLowerCase())) return true;
270
+ }
271
+ }
272
+ return false;
273
+ }
122
274
  // v2.0.0-rc.7 T10: doctor_run event drives Signal D (maintenance hint).
123
275
  const EVENT_TYPE_DOCTOR_RUN = "doctor_run";
124
276
  // v2.0.0-rc.20 TASK-03: per-turn cite-policy observation event. Emitted by
@@ -261,92 +413,40 @@ function readLedger(projectRoot) {
261
413
  }
262
414
 
263
415
  /**
264
- * Walk <projectRoot>/.fabric/knowledge/pending/<type>/*.md across all
265
- * PENDING_TYPES subdirs, collecting count and oldest mtime.
416
+ * Read pending counts from the CLI-generated resolved-bindings snapshot.
266
417
  *
267
418
  * Returns { count, oldestAgeMs } where:
268
419
  * - count: total .md file count across all type subdirs
269
420
  * - oldestAgeMs: (nowMs - oldestMtimeMs) when count>0, else null
270
421
  *
271
- * ENOENT / unreadable subdir / unstat-able file silently skipped
272
- * (preserves the hook's never-block-on-failure invariant).
422
+ * Store-only cutover: hooks never walk project-local knowledge or store
423
+ * trees. Missing snapshot stats degrade to zero (KT-DEC-0007).
273
424
  */
274
425
  function readPendingStats(projectRoot, now) {
275
- const nowMs = now instanceof Date ? now.getTime() : Number(now) || Date.now();
276
- const baseDir = join(projectRoot, FABRIC_DIR, PENDING_DIR);
277
-
278
- let count = 0;
279
- let oldestMtime = null;
280
-
281
- if (!existsSync(baseDir)) {
282
- return { count: 0, oldestAgeMs: null };
426
+ const stats = readSnapshotKnowledgeStats(projectRoot, now);
427
+ // `!= null` (loose) also catches the `undefined` old-snapshot marker (#3)
428
+ // fall through to the legacy reader (which degrades to 0 → no phantom review
429
+ // nudge), rather than dereferencing pendingCount on a non-object.
430
+ if (stats != null) {
431
+ return { count: stats.pendingCount, oldestAgeMs: stats.oldestPendingAgeMs };
283
432
  }
284
-
285
- for (const type of PENDING_TYPES) {
286
- const typeDir = join(baseDir, type);
287
- if (!existsSync(typeDir)) continue;
288
-
289
- let entries;
290
- try {
291
- entries = readdirSync(typeDir);
292
- } catch {
293
- continue;
294
- }
295
-
296
- for (const entry of entries) {
297
- if (!entry.endsWith(".md")) continue;
298
- const filePath = join(typeDir, entry);
299
- let mtime;
300
- try {
301
- mtime = statSync(filePath).mtimeMs;
302
- } catch {
303
- continue;
304
- }
305
- count += 1;
306
- if (oldestMtime === null || mtime < oldestMtime) {
307
- oldestMtime = mtime;
308
- }
309
- }
310
- }
311
-
312
- return {
313
- count,
314
- oldestAgeMs: count > 0 && oldestMtime !== null ? nowMs - oldestMtime : null,
315
- };
433
+ return readLegacyPendingStats(projectRoot, now);
316
434
  }
317
435
 
318
436
  /**
319
- * Count canonical knowledge entries across the five canonical type subdirs
320
- * (decisions / pitfalls / guidelines / models / processes). Pending entries
321
- * are NOT counted they are proposals, not seeded knowledge.
322
- *
323
- * Returns the integer count. ENOENT / unreadable subdir → silently treated as
324
- * zero (preserves never-block-on-failure invariant). Filters on `.md` suffix
325
- * only; the more-precise canonical filename pattern check is owned by
326
- * doctor.ts (the hook is a coarse signal, not a lint).
437
+ * Count canonical knowledge entries from the CLI-generated resolved-bindings
438
+ * snapshot. Store-only: hooks never walk project-local knowledge or store
439
+ * trees a missing snapshot degrades to zero (KT-DEC-0007).
327
440
  */
328
441
  function countCanonicalNodes(projectRoot) {
329
- const knowledgeRoot = join(projectRoot, FABRIC_DIR, "knowledge");
330
- if (!existsSync(knowledgeRoot)) {
331
- return 0;
332
- }
333
- let count = 0;
334
- for (const type of KNOWLEDGE_CANONICAL_TYPES) {
335
- const typeDir = join(knowledgeRoot, type);
336
- if (!existsSync(typeDir)) continue;
337
- let entries;
338
- try {
339
- entries = readdirSync(typeDir);
340
- } catch {
341
- continue;
342
- }
343
- for (const entry of entries) {
344
- if (entry.endsWith(".md")) {
345
- count += 1;
346
- }
347
- }
348
- }
349
- return count;
442
+ const stats = readSnapshotKnowledgeStats(projectRoot);
443
+ // #3: `undefined` = snapshot EXISTS but predates knowledge_store_dirs →
444
+ // undeterminable → return null so decide()'s underseed signal SKIPS rather than
445
+ // false-firing on a stale corpus. `null` = no reader / not bound → degrade to 0
446
+ // (KT-DEC-0007, preserved). The `empty` object (missing snapshot) → canonical 0,
447
+ // still firing correctly for a genuinely fresh corpus.
448
+ if (stats === undefined) return null;
449
+ return stats === null ? 0 : stats.canonicalCount;
350
450
  }
351
451
 
352
452
  /**
@@ -405,6 +505,81 @@ function countEditsSince(projectRoot, anchorTs) {
405
505
  return count;
406
506
  }
407
507
 
508
+ // ---------------------------------------------------------------------------
509
+ // Observability grill (a + Q4): session-activity status breadcrumb.
510
+ //
511
+ // A no-signal Stop used to return SILENT — the human only ever heard from
512
+ // Fabric when there was a nudge to act on, never a "here is what I did" recap,
513
+ // which reads as "Fabric does nothing in the background". These two helpers add
514
+ // a HUMAN-ONLY trust anchor (the AI gets no activity recap — flow ⊥ observation,
515
+ // D5) plus the nudge_mode tier-guidance line (so the human-channel volume knob
516
+ // is discoverable). Cadence is gated by nudge_mode at emit time.
517
+ // ---------------------------------------------------------------------------
518
+
519
+ // Session-scoped tally. Counts ONLY events that carry session_id, filtered to
520
+ // the current session — knowledge_context_planned / knowledge_proposed lack
521
+ // session_id and are intentionally excluded (a cross-session count would
522
+ // mislead). Exported for unit tests.
523
+ function tallySessionActivity(events, sessionId) {
524
+ let edits = 0;
525
+ let consumed = 0;
526
+ if (!Array.isArray(events) || typeof sessionId !== "string" || sessionId.length === 0) {
527
+ return { edits, consumed };
528
+ }
529
+ for (const ev of events) {
530
+ if (!ev || ev.session_id !== sessionId) continue;
531
+ if (ev.event_type === "file_mutated") edits += 1;
532
+ else if (ev.event_type === "knowledge_consumed") consumed += 1;
533
+ }
534
+ return { edits, consumed };
535
+ }
536
+
537
+ // Emit the human-facing session status breadcrumb when no actionable signal
538
+ // fired. Human sink ONLY. Cadence by nudge_mode: silent → never; minimal/normal
539
+ // → once per session; verbose → every turn. Folds in the tier-guidance line on
540
+ // the first status of the session so the volume knob is discoverable. Never
541
+ // throws — the caller wraps it, but every branch degrades silently anyway.
542
+ function emitSessionStatus(cwd, events, stdinPayload, nowMs, pendingStats, out) {
543
+ if (nudgePolicy === null || clientAdapter === null) return;
544
+ if (typeof clientAdapter.emitDualSink !== "function") return;
545
+ const sessionId = resolveHookSessionId(stdinPayload);
546
+ if (typeof sessionId !== "string" || sessionId.length === 0) return;
547
+
548
+ const mode =
549
+ typeof nudgePolicy.readNudgeMode === "function" ? nudgePolicy.readNudgeMode(cwd) : "normal";
550
+ if (mode === "silent") return; // human channel globally muted
551
+
552
+ const tally = tallySessionActivity(events, sessionId);
553
+ const pending = pendingStats && typeof pendingStats.total === "number" ? pendingStats.total : 0;
554
+ // Nothing happened yet this session AND no backlog → no trust anchor to show.
555
+ if (tally.edits === 0 && tally.consumed === 0 && pending === 0) return;
556
+
557
+ // Cadence gate: normal/minimal show once per session; verbose every turn.
558
+ const cache = readShownCache(cwd, sessionId);
559
+ const firstThisSession = cache._status === undefined;
560
+ if (mode !== "verbose" && !firstThisSession) return;
561
+
562
+ const variant = readFabricLanguage(cwd);
563
+ const line1 = renderBanner("statusLine", variant, {
564
+ edits: tally.edits,
565
+ consumed: tally.consumed,
566
+ pending,
567
+ });
568
+ // Tier guidance only on the first status of the session (don't repeat it on
569
+ // every verbose turn).
570
+ const human = firstThisSession
571
+ ? `${line1}\n${renderBanner("statusTier", variant, { mode })}`
572
+ : line1;
573
+
574
+ clientAdapter.emitDualSink(
575
+ { human, ai: null },
576
+ { client: clientAdapter.detectClient(__dirname), eventName: "Stop", streams: { stdout: out } },
577
+ );
578
+
579
+ cache._status = nowMs;
580
+ writeShownCache(cwd, cache, sessionId);
581
+ }
582
+
408
583
  /**
409
584
  * v2.0.0-rc.8 (TASK-002): detect whether a fabric-import skill run is
410
585
  * currently in flight, used to gate Signal B (review hint) so the Stop
@@ -783,6 +958,11 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
783
958
  reason,
784
959
  signal: "archive",
785
960
  recommended_skill: "fabric-archive",
961
+ // v2.1 NEW-N-3: surface the firing sub-signal's numbers for the
962
+ // hook_signal_emitted ledger row main() writes. Dual trigger (24h OR
963
+ // N-edits): report the hours pair when it fired, else the edit-count pair.
964
+ threshold: triggerByHours ? archiveHintHours : editStats.threshold,
965
+ actual_value: triggerByHours ? hoursElapsed : editStats.editsSinceLastProposed,
786
966
  };
787
967
  }
788
968
 
@@ -819,6 +999,10 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
819
999
  reason,
820
1000
  signal: "review",
821
1001
  recommended_skill: "fabric-review",
1002
+ // v2.1 NEW-N-3: dual trigger (pending-count OR oldest-age). Report the
1003
+ // count pair when it fired, else the oldest-age-in-days pair.
1004
+ threshold: triggerByPendingCount ? reviewHintPendingCount : reviewHintPendingAgeDays,
1005
+ actual_value: triggerByPendingCount ? stats.count : stats.oldestAgeMs / MS_PER_DAY,
822
1006
  };
823
1007
  }
824
1008
 
@@ -847,6 +1031,10 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
847
1031
  lastInitScanTs === null ? null : (nowMs - lastInitScanTs) / MS_PER_HOUR;
848
1032
  const hoursSinceProposed = hoursElapsed; // reuse archive-signal calc above
849
1033
  const triggerUnderseed =
1034
+ // #3: null = undeterminable canonical count (old snapshot) → skip. Guard
1035
+ // first because `null < threshold` coerces to true in JS and would else
1036
+ // false-fire the underseed nudge on a stale corpus.
1037
+ underseed.nodeCount != null &&
850
1038
  underseed.nodeCount < underseed.threshold &&
851
1039
  hoursSinceInit !== null &&
852
1040
  hoursSinceInit >= UNDERSEED_POST_INIT_QUIET_HOURS &&
@@ -869,6 +1057,10 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
869
1057
  reason,
870
1058
  signal: "import",
871
1059
  recommended_skill: "fabric-import",
1060
+ // v2.1 NEW-N-3: underseed corpus trigger — node-count vs threshold. The
1061
+ // "import" signal collapses to schema signal_type "other" in main().
1062
+ threshold: underseed.threshold,
1063
+ actual_value: underseed.nodeCount,
872
1064
  };
873
1065
  }
874
1066
 
@@ -965,8 +1157,34 @@ function readUnderseedThreshold(projectRoot) {
965
1157
  return DEFAULT_UNDERSEED_NODE_THRESHOLD;
966
1158
  }
967
1159
 
968
- function readShownCache(projectRoot) {
969
- const cachePath = join(projectRoot, SHOWN_CACHE_FILE);
1160
+ // F13 (ISS-20260531-038): the reminder cooldown sidecars were process-global
1161
+ // (one file per project, no session key), so in concurrent multi-window sessions
1162
+ // one window firing a nudge wrote the cooldown and silenced that nudge in EVERY
1163
+ // other window. Scope the sidecar filename by sessionId — mirrors the already-
1164
+ // session-scoped dismiss sidecar (sessionDismissFileName). Backward-compatible:
1165
+ // a null/absent sessionId falls back to the legacy non-scoped path (upgrade +
1166
+ // pre-session-id callers), so existing on-disk state and tests are unaffected;
1167
+ // the Stop hook always passes the real session_id from its stdin payload.
1168
+ function resolveHookSessionId(payload) {
1169
+ return payload && typeof payload.session_id === "string" && payload.session_id.length > 0
1170
+ ? payload.session_id
1171
+ : null;
1172
+ }
1173
+
1174
+ function sessionScopedCacheFile(baseRelPath, sessionId) {
1175
+ if (sessionId === undefined || sessionId === null || String(sessionId).length === 0) {
1176
+ return baseRelPath;
1177
+ }
1178
+ const safe = String(sessionId).replace(/[^A-Za-z0-9_.-]/g, "-");
1179
+ const lastSlash = baseRelPath.lastIndexOf("/");
1180
+ const dot = baseRelPath.lastIndexOf(".");
1181
+ return dot > lastSlash
1182
+ ? `${baseRelPath.slice(0, dot)}-${safe}${baseRelPath.slice(dot)}`
1183
+ : `${baseRelPath}-${safe}`;
1184
+ }
1185
+
1186
+ function readShownCache(projectRoot, sessionId) {
1187
+ const cachePath = join(projectRoot, sessionScopedCacheFile(SHOWN_CACHE_FILE, sessionId));
970
1188
  if (!existsSync(cachePath)) return {};
971
1189
  try {
972
1190
  const parsed = JSON.parse(readFileSync(cachePath, "utf8"));
@@ -976,11 +1194,17 @@ function readShownCache(projectRoot) {
976
1194
  }
977
1195
  }
978
1196
 
979
- function writeShownCache(projectRoot, cache) {
980
- const cachePath = join(projectRoot, SHOWN_CACHE_FILE);
1197
+ function writeShownCache(projectRoot, cache, sessionId) {
1198
+ const cachePath = join(projectRoot, sessionScopedCacheFile(SHOWN_CACHE_FILE, sessionId));
981
1199
  try {
982
- mkdirSync(dirname(cachePath), { recursive: true });
983
- writeFileSync(cachePath, JSON.stringify(cache));
1200
+ // ISS-016: atomic tmp+rename so a crash never leaves a truncated shown-cache.
1201
+ // Falls back to a plain write only if the shared lib failed to load.
1202
+ if (stateStore && typeof stateStore.atomicWrite === "function") {
1203
+ stateStore.atomicWrite(cachePath, JSON.stringify(cache));
1204
+ } else {
1205
+ mkdirSync(dirname(cachePath), { recursive: true });
1206
+ writeFileSync(cachePath, JSON.stringify(cache));
1207
+ }
984
1208
  } catch {
985
1209
  // Silent — cache failure must never block the hook.
986
1210
  }
@@ -1098,8 +1322,8 @@ function findLastDoctorRunTs(events) {
1098
1322
  * v2.0.0-rc.7 T10: read the Signal-D cooldown sidecar timestamp (epoch ms).
1099
1323
  * Missing file / parse failure → null (allow signal to fire).
1100
1324
  */
1101
- function readMaintenanceLastEmit(projectRoot) {
1102
- const p = join(projectRoot, MAINTENANCE_HINT_LAST_EMIT_FILE);
1325
+ function readMaintenanceLastEmit(projectRoot, sessionId) {
1326
+ const p = join(projectRoot, sessionScopedCacheFile(MAINTENANCE_HINT_LAST_EMIT_FILE, sessionId));
1103
1327
  if (!existsSync(p)) return null;
1104
1328
  try {
1105
1329
  const raw = readFileSync(p, "utf8").trim();
@@ -1114,11 +1338,16 @@ function readMaintenanceLastEmit(projectRoot) {
1114
1338
  return null;
1115
1339
  }
1116
1340
 
1117
- function writeMaintenanceLastEmit(projectRoot, nowMs) {
1118
- const p = join(projectRoot, MAINTENANCE_HINT_LAST_EMIT_FILE);
1341
+ function writeMaintenanceLastEmit(projectRoot, nowMs, sessionId) {
1342
+ const p = join(projectRoot, sessionScopedCacheFile(MAINTENANCE_HINT_LAST_EMIT_FILE, sessionId));
1119
1343
  try {
1120
- mkdirSync(dirname(p), { recursive: true });
1121
- writeFileSync(p, new Date(nowMs).toISOString());
1344
+ // ISS-016: atomic tmp+rename (see writeShownCache).
1345
+ if (stateStore && typeof stateStore.atomicWrite === "function") {
1346
+ stateStore.atomicWrite(p, new Date(nowMs).toISOString());
1347
+ } else {
1348
+ mkdirSync(dirname(p), { recursive: true });
1349
+ writeFileSync(p, new Date(nowMs).toISOString());
1350
+ }
1122
1351
  } catch {
1123
1352
  // Silent — sidecar failure must never block the hook.
1124
1353
  }
@@ -1207,9 +1436,159 @@ function evaluateMaintenanceSignal(events, now, canonicalCount, lastEmitMs, thre
1207
1436
  signal: "maintenance",
1208
1437
  // CLI recommendation rather than Skill — doctor is a CLI surface.
1209
1438
  recommended_skill: null,
1439
+ // v2.1 NEW-N-3: staleness trigger. threshold=days; actual=ageDays. When
1440
+ // lint was NEVER run ageDays is null — main() skips the signal emit rather
1441
+ // than fabricate a number (honest gap over fake telemetry).
1442
+ threshold: days,
1443
+ actual_value: ageDays,
1210
1444
  };
1211
1445
  }
1212
1446
 
1447
+ // lifecycle-refactor W3-A2 (§7 graph generation signal): after a successful
1448
+ // archive the Stop hook REQUESTS edge extraction by emitting one
1449
+ // graph_edge_candidate_requested{stable_id, store?}. The hook never PRODUCES
1450
+ // edges (that is the archive/import skill's or doctor co-occurrence's job,
1451
+ // KT-DEC-0007) — it only flags "this entry just landed; someone should extract
1452
+ // its `related` edges". FROZEN-safe: O(1) tail scan, best-effort silent, single
1453
+ // advisory-locked appendLockedLine (same primitive the rest of this hook uses).
1454
+ //
1455
+ // HONEST stable_id sourcing — the deliberate limitation: pending entries (the
1456
+ // fabric-archive → extractKnowledge path) carry NO canonical stable_id (id is
1457
+ // late-bound at fab_review approve), so their knowledge_proposed event omits
1458
+ // stable_id (or sets the `pending:<key>` sentinel). A graph edge between
1459
+ // id-less pending drafts is meaningless, so we DO NOT fabricate one. We emit
1460
+ // ONLY when the most-recent knowledge_proposed event carries a real
1461
+ // K[TP]-XXX-NNNN stable_id (the approve/promote path) — i.e. an entry that
1462
+ // actually has a canonical node to attach edges to. When the latest proposed
1463
+ // is id-less we honestly skip; the request will fire on the approve event that
1464
+ // allocates the id. A session-scoped sidecar de-dupes so repeated Stop fires in
1465
+ // one session don't re-request the same id.
1466
+ const STABLE_ID_RE = /^K[TP]-[A-Z]{3}-\d{4}$/;
1467
+ const GRAPH_EDGE_REQUESTED_SIDECAR = ".fabric/.cache/graph-edge-requested";
1468
+
1469
+ function emitGraphEdgeCandidateBestEffort(cwd, events, sessionId) {
1470
+ try {
1471
+ if (!Array.isArray(events) || events.length === 0) return;
1472
+ const fabricDir = join(cwd, FABRIC_DIR);
1473
+ if (!existsSync(fabricDir)) return;
1474
+
1475
+ // O(1)-amortized tail scan for the newest knowledge_proposed carrying a
1476
+ // real (non-sentinel) stable_id. Stop at the first knowledge_proposed we
1477
+ // see — if the latest archive is id-less, we honestly skip rather than
1478
+ // reaching back to an older approved entry (that older entry's edges were
1479
+ // already requested when IT landed).
1480
+ let stableId = null;
1481
+ let store;
1482
+ for (let i = events.length - 1; i >= 0; i -= 1) {
1483
+ const ev = events[i];
1484
+ if (!ev || ev.event_type !== EVENT_TYPE_PROPOSED) continue;
1485
+ const candidate = typeof ev.stable_id === "string" ? ev.stable_id : null;
1486
+ if (candidate && STABLE_ID_RE.test(candidate)) {
1487
+ stableId = candidate;
1488
+ if (typeof ev.store === "string" && ev.store.length > 0) store = ev.store;
1489
+ }
1490
+ // First knowledge_proposed encountered (newest) decides; do not walk past
1491
+ // it to an older one.
1492
+ break;
1493
+ }
1494
+ if (stableId === null) return;
1495
+
1496
+ // Session-scoped de-dup: skip if we already requested edges for this exact
1497
+ // stable_id this session. Sidecar is a single line holding the last id.
1498
+ const sidecarPath = join(cwd, sessionScopedCacheFile(GRAPH_EDGE_REQUESTED_SIDECAR, sessionId));
1499
+ try {
1500
+ if (existsSync(sidecarPath)) {
1501
+ const prev = readFileSync(sidecarPath, "utf8").trim();
1502
+ if (prev === stableId) return;
1503
+ }
1504
+ } catch {
1505
+ // unreadable sidecar → fall through and (re)emit; de-dup is best-effort.
1506
+ }
1507
+
1508
+ let idSuffix;
1509
+ try {
1510
+ idSuffix = require("node:crypto").randomUUID();
1511
+ } catch {
1512
+ idSuffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
1513
+ }
1514
+ const event = {
1515
+ kind: "fabric-event",
1516
+ id: `event:${idSuffix}`,
1517
+ ts: Date.now(),
1518
+ schema_version: 1,
1519
+ event_type: "graph_edge_candidate_requested",
1520
+ stable_id: stableId,
1521
+ };
1522
+ if (store !== undefined) event.store = store;
1523
+ if (typeof sessionId === "string" && sessionId.length > 0) event.session_id = sessionId;
1524
+ appendLockedLine(join(fabricDir, EVENT_LEDGER_FILE), JSON.stringify(event) + "\n");
1525
+
1526
+ // Record the de-dup marker (best-effort; atomic when state-store lib loaded).
1527
+ try {
1528
+ if (stateStore && typeof stateStore.atomicWrite === "function") {
1529
+ stateStore.atomicWrite(sidecarPath, stableId);
1530
+ } else {
1531
+ mkdirSync(dirname(sidecarPath), { recursive: true });
1532
+ writeFileSync(sidecarPath, stableId);
1533
+ }
1534
+ } catch {
1535
+ // de-dup marker write failed — at worst we re-request next Stop; harmless.
1536
+ }
1537
+ } catch {
1538
+ // best-effort §7 signal — never block the Stop hook (KT-DEC-0007).
1539
+ }
1540
+ }
1541
+
1542
+ // v2.1 NEW-N-3 (ADJ-NEWN-3): hook_signal_emitted instrumentation. Writes ONE
1543
+ // best-effort ledger row at the point a nudge is actually delivered (post-
1544
+ // cooldown), so the join key measures nudge-trigger logic (which signal fired,
1545
+ // at what threshold vs. actual). Emitted at delivery rather than at
1546
+ // threshold-cross so it inherits the cooldown gate — a fired-but-cooled signal
1547
+ // does not spam the ledger every session. Skips silently when threshold /
1548
+ // actual_value are not finite numbers (e.g. maintenance "never run" → null
1549
+ // age). Never blocks the hook (KT-DEC-0007).
1550
+ const SIGNAL_TYPE_ENUM = new Set(["archive", "review", "maintenance", "other"]);
1551
+ function emitSignalFiredEvent(cwd, sessionId, result) {
1552
+ try {
1553
+ if (!result || typeof result.signal !== "string") return;
1554
+ const threshold = result.threshold;
1555
+ const actualValue = result.actual_value;
1556
+ if (
1557
+ typeof threshold !== "number" ||
1558
+ !Number.isFinite(threshold) ||
1559
+ typeof actualValue !== "number" ||
1560
+ !Number.isFinite(actualValue)
1561
+ ) {
1562
+ return;
1563
+ }
1564
+ const fabricDir = join(cwd, FABRIC_DIR);
1565
+ if (!existsSync(fabricDir)) return;
1566
+ // "import" / any non-canonical signal collapses to schema's catch-all "other".
1567
+ const signalType = SIGNAL_TYPE_ENUM.has(result.signal) ? result.signal : "other";
1568
+ let idSuffix;
1569
+ try {
1570
+ idSuffix = require("node:crypto").randomUUID();
1571
+ } catch {
1572
+ idSuffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
1573
+ }
1574
+ const event = {
1575
+ kind: "fabric-event",
1576
+ id: `event:${idSuffix}`,
1577
+ ts: Date.now(),
1578
+ schema_version: 1,
1579
+ event_type: "hook_signal_emitted",
1580
+ signal_type: signalType,
1581
+ threshold,
1582
+ actual_value: actualValue,
1583
+ fired: true,
1584
+ };
1585
+ if (typeof sessionId === "string" && sessionId.length > 0) event.session_id = sessionId;
1586
+ appendLockedLine(join(fabricDir, EVENT_LEDGER_FILE), JSON.stringify(event) + "\n");
1587
+ } catch {
1588
+ // best-effort telemetry — never block the hook
1589
+ }
1590
+ }
1591
+
1213
1592
  /**
1214
1593
  * v2.0.0-rc.7 T5: best-effort sync stdin reader for the Stop hook.
1215
1594
  *
@@ -1304,14 +1683,13 @@ function parseKbLine(raw) {
1304
1683
  * "codex". Covers the dominant deployment shape (hook script lives
1305
1684
  * under the client's per-repo dir).
1306
1685
  *
1307
- * Returns `undefined` when neither signal fires (e.g. Cursor deferred to
1308
- * rc.21 — or a custom deployment). The Zod schema marks `client` optional,
1309
- * so omitting it leaves the event valid.
1686
+ * Returns `undefined` when neither signal fires (a custom deployment). The
1687
+ * Zod schema marks `client` optional, so omitting it leaves the event valid.
1310
1688
  */
1311
1689
  function detectClient() {
1312
1690
  // Delegate the full 3-tier detection (env → CLAUDE_PROJECT_DIR → path
1313
- // heuristic, incl. .cursor) to the shared adapter. __dirname is passed so
1314
- // the path heuristic reflects THIS hook's location.
1691
+ // heuristic) to the shared adapter. __dirname is passed so the path
1692
+ // heuristic reflects THIS hook's location.
1315
1693
  if (clientAdapter && typeof clientAdapter.detectClient === "function") {
1316
1694
  return clientAdapter.detectClient(__dirname);
1317
1695
  }
@@ -1319,7 +1697,7 @@ function detectClient() {
1319
1697
  const envClient = process.env.FABRIC_HINT_CLIENT;
1320
1698
  if (typeof envClient === "string" && envClient.length > 0) {
1321
1699
  const normalised = envClient.trim().toLowerCase();
1322
- if (normalised === "cc" || normalised === "codex" || normalised === "cursor") {
1700
+ if (normalised === "cc" || normalised === "codex") {
1323
1701
  return normalised;
1324
1702
  }
1325
1703
  }
@@ -1414,7 +1792,7 @@ function extractAndWriteAssistantTurnsBestEffort(cwd, stdinPayload) {
1414
1792
  timestamp: new Date().toISOString(),
1415
1793
  };
1416
1794
  if (client !== undefined) event.client = client;
1417
- appendFileSync(ledgerPath, JSON.stringify(event) + "\n", "utf8");
1795
+ appendLockedLine(ledgerPath, JSON.stringify(event) + "\n");
1418
1796
  } catch {
1419
1797
  // Per-turn failure must not abort the remaining turns; the Stop hook
1420
1798
  // contract is "never block on hook failure". Best-effort continues.
@@ -1439,7 +1817,7 @@ function extractAndWriteAssistantTurnsBestEffort(cwd, stdinPayload) {
1439
1817
  counters: { [counterKey]: emptyShellCount },
1440
1818
  };
1441
1819
  const metricsPath = join(fabricDir, METRICS_LEDGER_FILE);
1442
- appendFileSync(metricsPath, JSON.stringify(metricsRow) + "\n", "utf8");
1820
+ appendLockedLine(metricsPath, JSON.stringify(metricsRow) + "\n");
1443
1821
  } catch {
1444
1822
  // metrics fold is observability-only; never block the hook on failure.
1445
1823
  }
@@ -1658,50 +2036,6 @@ function summarizeTranscript(transcriptPath) {
1658
2036
  return out;
1659
2037
  }
1660
2038
 
1661
- /**
1662
- * v2.0.0-rc.24 TASK-05: emit soft L1 reminder to stderr when assistant turns
1663
- * cited a decision/pitfall id with [recalled] but no operator contract and no
1664
- * skip:<reason>. Reads agents.meta.json once per invocation; aggregated per
1665
- * turn (one line per offending id). Non-blocking — never throws, always
1666
- * returns the array of emitted reminder strings (for unit tests + callers
1667
- * that want to observe what was written).
1668
- *
1669
- * The reminder writes go to stderr (the hook contract: stdout is structured
1670
- * banner JSON consumed by the harness; stderr is free-text system message
1671
- * that surfaces back to the model on the next turn in cc / codex / cursor).
1672
- */
1673
- function emitCiteContractRemindersBestEffort(cwd, stdinPayload, stderr) {
1674
- if (citeContractReminder === null) return [];
1675
- if (stdinPayload === null || typeof stdinPayload !== "object") return [];
1676
- try {
1677
- const transcript = summarizeTranscript(stdinPayload.transcript_path);
1678
- const turns = transcript.assistant_turns;
1679
- if (!Array.isArray(turns) || turns.length === 0) return [];
1680
-
1681
- const idTypeMap = citeContractReminder.readKnowledgeTypeMap(cwd);
1682
- if (!(idTypeMap instanceof Map) || idTypeMap.size === 0) return [];
1683
-
1684
- const reminders = citeContractReminder.formatContractMissingReminders({
1685
- assistant_turns: turns,
1686
- idTypeMap,
1687
- });
1688
- if (!Array.isArray(reminders) || reminders.length === 0) return [];
1689
-
1690
- const sink = stderr || process.stderr;
1691
- for (const line of reminders) {
1692
- try {
1693
- sink.write(line + "\n");
1694
- } catch {
1695
- // Sink write failure must not abort emission of remaining reminders.
1696
- }
1697
- }
1698
- return reminders;
1699
- } catch {
1700
- // Outer guard — never throw. Hook continues silently.
1701
- return [];
1702
- }
1703
- }
1704
-
1705
2039
  /**
1706
2040
  * v2.0.0-rc.7 T5: writeSessionDigestBestEffort — non-blocking digest fan-out.
1707
2041
  * Called from main() before the existing decide() flow. Failure is silently
@@ -1755,17 +2089,18 @@ function main(env, stdio) {
1755
2089
  // the hook's other I/O).
1756
2090
  extractAndWriteAssistantTurnsBestEffort(cwd, stdinPayload);
1757
2091
 
1758
- // v2.0.0-rc.24 TASK-05: L1 soft reminder layer. Surfaces ⚠ KB:<id> lines
1759
- // to stderr when decision/pitfall cites arrived with [recalled] tag but
1760
- // empty contract. Non-blocking, never throws; doctor (TASK-08) catches
1761
- // any contract violation the model ignored.
1762
- emitCiteContractRemindersBestEffort(
1763
- cwd,
1764
- stdinPayload,
1765
- stdio && stdio.stderr,
1766
- );
1767
-
1768
2092
  const events = readLedger(cwd);
2093
+
2094
+ // lifecycle-refactor W3-A2 (§7): request graph-edge extraction for a freshly
2095
+ // archived canonical entry. Runs UNCONDITIONALLY here (before the nudge
2096
+ // cooldown/dismiss early-returns) so the §7 signal is independent of whether
2097
+ // a reminder banner is shown this Stop. Best-effort, never throws.
2098
+ try {
2099
+ emitGraphEdgeCandidateBestEffort(cwd, events, resolveHookSessionId(stdinPayload));
2100
+ } catch {
2101
+ // never block the Stop hook
2102
+ }
2103
+
1769
2104
  let pendingStats;
1770
2105
  try {
1771
2106
  pendingStats = readPendingStats(cwd, now);
@@ -1898,7 +2233,7 @@ function main(env, stdio) {
1898
2233
  // for the prompt to be actionable.
1899
2234
  if (result === null) {
1900
2235
  try {
1901
- const lastEmit = readMaintenanceLastEmit(cwd);
2236
+ const lastEmit = readMaintenanceLastEmit(cwd, resolveHookSessionId(stdinPayload));
1902
2237
  result = evaluateMaintenanceSignal(
1903
2238
  events,
1904
2239
  now,
@@ -1911,7 +2246,38 @@ function main(env, stdio) {
1911
2246
  }
1912
2247
  }
1913
2248
 
1914
- if (result === null) return;
2249
+ if (result === null) {
2250
+ // Observability grill (a): no actionable signal — instead of returning
2251
+ // silently (which made Fabric feel inert in the background), surface a
2252
+ // session-activity status breadcrumb to the human sink (gated by
2253
+ // nudge_mode). Best-effort: never block the Stop hook on it.
2254
+ try {
2255
+ emitSessionStatus(cwd, events, stdinPayload, nowMs, pendingStats, out);
2256
+ } catch {
2257
+ // status breadcrumb is decorative — never throw
2258
+ }
2259
+ return;
2260
+ }
2261
+
2262
+ // v2.2 dual-sink (Goal A / D6): VALUE-GATE the archive nudge. Signal A's
2263
+ // edit/hours trigger is the CHECK cadence; the nudge only fires when a
2264
+ // deterministic high-value signal accrued since the last archive (decouples
2265
+ // check frequency from disturb frequency). Boundary-correct: replicates
2266
+ // archive-scan's ledger probe (no semantic judgement). Other signals
2267
+ // (review/import/maintenance) are unaffected.
2268
+ if (result.signal === "archive") {
2269
+ let watermarkTs = null;
2270
+ for (let i = events.length - 1; i >= 0; i -= 1) {
2271
+ const ev = events[i];
2272
+ if (ev && ev.event_type === EVENT_TYPE_PROPOSED && typeof ev.ts === "number") {
2273
+ watermarkTs = ev.ts;
2274
+ break;
2275
+ }
2276
+ }
2277
+ if (!hasHighValueArchiveSignal(events, watermarkTs)) {
2278
+ return; // no high-value candidate → stay quiet (D6 value-gate)
2279
+ }
2280
+ }
1915
2281
 
1916
2282
  // v2.0.0-rc.37 NEW-16: per-signal dismiss. A chosen signal whose type the
1917
2283
  // user dismissed (config-durable or session sidecar) exits silently —
@@ -1933,10 +2299,10 @@ function main(env, stdio) {
1933
2299
  // pile. Best-effort; missing snapshot / single-store omits the line.
1934
2300
  if (bindingsSnapshotReader !== null && typeof result.reason === "string") {
1935
2301
  try {
1936
- const projectId = readProjectId(cwd);
1937
- if (projectId) {
2302
+ const bindingId = readWorkspaceBindingId(cwd);
2303
+ if (bindingId) {
1938
2304
  const label = bindingsSnapshotReader.formatStoreLabels(
1939
- bindingsSnapshotReader.readBindingsSnapshot(projectId),
2305
+ bindingsSnapshotReader.readBindingsSnapshot(bindingId),
1940
2306
  );
1941
2307
  if (label) {
1942
2308
  result.reason = `${result.reason}\n${label}`;
@@ -1951,8 +2317,11 @@ function main(env, stdio) {
1951
2317
  // see MAINTENANCE_HINT_LAST_EMIT_FILE). The A/B/C shared cooldown cache
1952
2318
  // uses hours, so we branch here to avoid mixing semantics.
1953
2319
  if (result.signal === "maintenance") {
2320
+ emitSignalFiredEvent(cwd, sessionId, result);
2321
+ delete result.threshold;
2322
+ delete result.actual_value;
1954
2323
  out.write(JSON.stringify(result));
1955
- writeMaintenanceLastEmit(cwd, nowMs);
2324
+ writeMaintenanceLastEmit(cwd, nowMs, resolveHookSessionId(stdinPayload));
1956
2325
  return;
1957
2326
  }
1958
2327
 
@@ -1960,7 +2329,7 @@ function main(env, stdio) {
1960
2329
  // archive_hint_cooldown_hours (default 12h) regardless of state drift.
1961
2330
  // Pure reminder-noise reduction; the underlying trigger logic is unchanged.
1962
2331
  const cooldownMs = readCooldownHours(cwd) * MS_PER_HOUR;
1963
- const cache = readShownCache(cwd);
2332
+ const cache = readShownCache(cwd, resolveHookSessionId(stdinPayload));
1964
2333
  const lastShown = cache[result.signal];
1965
2334
  // rc.34 TASK-01 + review-fix (Gemini P1): future-stamped lastShown
1966
2335
  // (backward clock skew) bypasses cooldown — sidecar treated as expired.
@@ -1972,9 +2341,30 @@ function main(env, stdio) {
1972
2341
  return; // Still in cooldown — silent.
1973
2342
  }
1974
2343
 
1975
- out.write(JSON.stringify(result));
2344
+ emitSignalFiredEvent(cwd, sessionId, result);
2345
+ const reasonText = typeof result.reason === "string" ? result.reason : "";
2346
+ delete result.threshold;
2347
+ delete result.actual_value;
2348
+ // v2.2 dual-sink (Goal A / D3): the archive nudge is SOFT — emitted as
2349
+ // additionalContext(AI) + systemMessage(human), NEVER decision:block. The
2350
+ // human channel is gated by nudge_mode (D4/D5); the AI channel always carries
2351
+ // it (flow ⊥ observation). Missing it is backstopped by the SessionEnd marker
2352
+ // + cross-session debt (D3). Review/import keep the decision:block contract
2353
+ // (out of Goal A scope; KT-DEC-0007 nudge semantics unchanged for them).
2354
+ if (result.signal === "archive" && clientAdapter && typeof clientAdapter.emitDualSink === "function") {
2355
+ const humanGate =
2356
+ nudgePolicy !== null
2357
+ ? nudgePolicy.resolveHumanSink(cwd, "stop", { highValue: true })
2358
+ : { emitHuman: true };
2359
+ clientAdapter.emitDualSink(
2360
+ { human: humanGate.emitHuman ? reasonText : null, ai: reasonText },
2361
+ { client: clientAdapter.detectClient(__dirname), eventName: "Stop", streams: { stdout: out } },
2362
+ );
2363
+ } else {
2364
+ out.write(JSON.stringify(result));
2365
+ }
1976
2366
  cache[result.signal] = nowMs;
1977
- writeShownCache(cwd, cache);
2367
+ writeShownCache(cwd, cache, resolveHookSessionId(stdinPayload));
1978
2368
  } catch {
1979
2369
  // Silent — never block on hook failure.
1980
2370
  }
@@ -1986,6 +2376,8 @@ module.exports = {
1986
2376
  readPendingStats,
1987
2377
  countCanonicalNodes,
1988
2378
  countEditsSince,
2379
+ // observability grill (a): session-activity tally for the human status line.
2380
+ tallySessionActivity,
1989
2381
  // rc.7 T4: top-edited-directories aggregator + banner overview formatter.
1990
2382
  getTopEditedDirectories,
1991
2383
  formatActivityOverview,
@@ -2025,10 +2417,9 @@ module.exports = {
2025
2417
  parseKbLine,
2026
2418
  detectClient,
2027
2419
  extractAndWriteAssistantTurnsBestEffort,
2028
- // v2.0.0-rc.24 TASK-05: L1 soft reminder helpers (exported for unit testing
2029
- // of the contract-missing emission contract). The lib module itself is
2030
- // also exported indirectly via the reminder helper.
2031
- emitCiteContractRemindersBestEffort,
2420
+ // lifecycle-refactor W3-A2 (§7): graph-edge-candidate request emitter
2421
+ // (exported for unit testing of the honest stable_id-gating + de-dup).
2422
+ emitGraphEdgeCandidateBestEffort,
2032
2423
  CONSTANTS: {
2033
2424
  FABRIC_DIR,
2034
2425
  EVENT_LEDGER_FILE,
@@ -2066,6 +2457,8 @@ module.exports = {
2066
2457
  // v2.0.0-rc.8 (TASK-002): in-flight import gate for Signal B.
2067
2458
  IMPORT_STATE_FILE_REL,
2068
2459
  IMPORT_IN_FLIGHT_MAX_AGE_HOURS,
2460
+ // lifecycle-refactor W3-A2 (§7): graph-edge-request de-dup sidecar.
2461
+ GRAPH_EDGE_REQUESTED_SIDECAR,
2069
2462
  },
2070
2463
  };
2071
2464