@fenglimg/fabric-cli 2.2.0-rc.3 → 2.2.0-rc.8

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 (75) hide show
  1. package/README.md +8 -5
  2. package/dist/{chunk-5LQIHYFC.js → chunk-27HK6H5Y.js} +10 -5
  3. package/dist/{chunk-F6ITRM7T.js → chunk-2KBCTMID.js} +29 -6
  4. package/dist/{chunk-XC5RUHLK.js → chunk-3IOLS5EK.js} +23 -38
  5. package/dist/{chunk-XHHCRDIR.js → chunk-CMDW3PYK.js} +105 -220
  6. package/dist/chunk-FEOPLBGA.js +150 -0
  7. package/dist/{chunk-XCBVSGCS.js → chunk-FNHDQTPC.js} +1 -10
  8. package/dist/{chunk-2CY4BMTH.js → chunk-HORSMSZL.js} +9 -5
  9. package/dist/{doctor-J4O3X54I.js → chunk-JTHWLUD3.js} +103 -51
  10. package/dist/{chunk-BO4XIZWZ.js → chunk-NLNH64A3.js} +5 -18
  11. package/dist/{chunk-H3FE6VIK.js → chunk-PTGQAZEW.js} +13 -3
  12. package/dist/chunk-QFIVFZRH.js +13 -0
  13. package/dist/chunk-QPAW6IYT.js +387 -0
  14. package/dist/{chunk-COI5VDFU.js → chunk-WA3DYGSY.js} +1 -2
  15. package/dist/{plan-context-hint-CHVZGOZ5.js → chunk-YM4XATJF.js} +29 -4
  16. package/dist/{config-VJMXCLXW.js → config-A3LTECAY.js} +4 -3
  17. package/dist/context-7NUKXDB6.js +117 -0
  18. package/dist/doctor-REZDNH4A.js +24 -0
  19. package/dist/index.d.ts +2 -2
  20. package/dist/index.js +131 -21
  21. package/dist/info-7FKBTMVO.js +139 -0
  22. package/dist/install-v2-2COC3DO3.js +3277 -0
  23. package/dist/{metrics-RER6NLFC.js → metrics-HMFH4YHK.js} +1 -1
  24. package/dist/{onboard-coverage-JWQWDZW7.js → onboard-coverage-XSG77LL3.js} +48 -27
  25. package/dist/plan-context-hint-G75R4P4J.js +12 -0
  26. package/dist/{scope-explain-BWRWBCCP.js → scope-explain-HLJZ2M33.js} +3 -2
  27. package/dist/{status-PANEGKU2.js → status-4R3TM4FJ.js} +8 -5
  28. package/dist/store-HOCORVL3.js +563 -0
  29. package/dist/{sync-EA5HZMXM.js → sync-DT5UJMMR.js} +36 -13
  30. package/dist/{uninstall-F75MPKQC.js → uninstall-62F4LNKI.js} +62 -140
  31. package/dist/{whoami-66YKY5DZ.js → whoami-ITGEFWH4.js} +9 -7
  32. package/package.json +7 -5
  33. package/templates/hooks/cite-policy-evict.cjs +5 -5
  34. package/templates/hooks/configs/README.md +14 -27
  35. package/templates/hooks/configs/claude-code.json +1 -1
  36. package/templates/hooks/configs/codex-hooks.json +3 -3
  37. package/templates/hooks/fabric-hint.cjs +301 -161
  38. package/templates/hooks/knowledge-hint-broad.cjs +426 -207
  39. package/templates/hooks/knowledge-hint-narrow.cjs +56 -56
  40. package/templates/hooks/lib/banner-i18n.cjs +31 -0
  41. package/templates/hooks/lib/bindings-snapshot-reader.cjs +117 -7
  42. package/templates/hooks/lib/cite-line-parser.cjs +12 -20
  43. package/templates/hooks/lib/client-adapter.cjs +66 -7
  44. package/templates/hooks/lib/nudge-policy.cjs +117 -0
  45. package/templates/hooks/lib/state-store.cjs +60 -0
  46. package/templates/hooks/lib/summary-fallback.cjs +82 -19
  47. package/templates/hooks/post-tooluse-mutation.cjs +112 -11
  48. package/templates/skills/fabric/SKILL.md +94 -0
  49. package/templates/skills/fabric-archive/SKILL.md +29 -26
  50. package/templates/skills/fabric-archive/ref/dry-run-scope.md +1 -1
  51. package/templates/skills/fabric-archive/ref/i18n-policy.md +2 -3
  52. package/templates/skills/fabric-archive/ref/phase-1-5-onboard.md +2 -3
  53. package/templates/skills/fabric-archive/ref/phase-1-cross-session.md +1 -1
  54. package/templates/skills/fabric-archive/ref/phase-2-5-viability.md +1 -1
  55. package/templates/skills/fabric-archive/ref/phase-3-6-related-edges.md +18 -0
  56. package/templates/skills/fabric-archive/ref/phase-3-7-semantic-scope.md +47 -0
  57. package/templates/skills/fabric-audit/SKILL.md +13 -3
  58. package/templates/skills/fabric-connect/SKILL.md +3 -3
  59. package/templates/skills/fabric-import/SKILL.md +7 -7
  60. package/templates/skills/fabric-import/ref/i18n-policy.md +2 -3
  61. package/templates/skills/fabric-import/ref/state-recovery.md +1 -2
  62. package/templates/skills/fabric-review/SKILL.md +5 -5
  63. package/templates/skills/fabric-review/ref/cite-contract.md +1 -1
  64. package/templates/skills/fabric-review/ref/i18n-policy.md +2 -3
  65. package/templates/skills/fabric-review/ref/output-contract.md +1 -1
  66. package/templates/skills/fabric-review/ref/per-mode-flows.md +2 -2
  67. package/templates/skills/fabric-review/ref/worked-examples.md +1 -1
  68. package/templates/skills/fabric-store/SKILL.md +1 -1
  69. package/templates/skills/fabric-sync/SKILL.md +1 -1
  70. package/templates/skills/lib/shared-policy.md +2 -2
  71. package/dist/chunk-5ZUMLCD5.js +0 -248
  72. package/dist/install-BULNDUIM.js +0 -2816
  73. package/dist/store-66NK2FTQ.js +0 -443
  74. package/templates/hooks/configs/cursor-hooks.json +0 -30
  75. package/templates/hooks/lib/cite-contract-reminder.cjs +0 -179
@@ -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,103 @@ 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
+ // LIVE recount off the snapshot's resolved store dirs. The cached
121
+ // knowledge_stats projection is frozen at snapshot-write time, so once the
122
+ // pending queue is reviewed (or store content syncs out-of-band) it goes
123
+ // stale — that is the phantom review-backlog this hook used to report
124
+ // (KT-PIT-0017). The authoritative count is the live *.md walk under the
125
+ // resolved store dirs.
126
+ const live = bindingsSnapshotReader.liveKnowledgeStats(snapshot);
127
+ if (!live || typeof live !== "object") {
128
+ return empty;
129
+ }
130
+ const pendingCount =
131
+ Number.isFinite(live.pendingCount) && live.pendingCount > 0 ? Math.floor(live.pendingCount) : 0;
132
+ const canonicalCount =
133
+ Number.isFinite(live.canonicalCount) && live.canonicalCount > 0
134
+ ? Math.floor(live.canonicalCount)
135
+ : 0;
136
+ const oldestPendingAgeMs =
137
+ pendingCount > 0 &&
138
+ Number.isFinite(live.oldestPendingMtimeMs) &&
139
+ live.oldestPendingMtimeMs > 0
140
+ ? Math.max(0, nowMs - live.oldestPendingMtimeMs)
141
+ : null;
142
+ return { pendingCount, oldestPendingAgeMs, canonicalCount };
143
+ } catch {
144
+ return empty;
145
+ }
146
+ }
147
+
148
+ function readLegacyPendingStats(projectRoot, now) {
149
+ const nowMs = now instanceof Date ? now.getTime() : Number(now) || Date.now();
150
+ const baseDir = join(projectRoot, FABRIC_DIR, PENDING_DIR);
151
+
152
+ let count = 0;
153
+ let oldestMtime = null;
154
+
155
+ if (!existsSync(baseDir)) {
156
+ return { count: 0, oldestAgeMs: null };
157
+ }
158
+
159
+ for (const type of PENDING_TYPES) {
160
+ const typeDir = join(baseDir, type);
161
+ if (!existsSync(typeDir)) continue;
162
+
163
+ let entries;
164
+ try {
165
+ entries = readdirSync(typeDir);
166
+ } catch {
167
+ continue;
168
+ }
169
+
170
+ for (const entry of entries) {
171
+ if (!entry.endsWith(".md")) continue;
172
+ const filePath = join(typeDir, entry);
173
+ let mtime;
174
+ try {
175
+ mtime = statSync(filePath).mtimeMs;
176
+ } catch {
177
+ continue;
178
+ }
179
+ count += 1;
180
+ if (oldestMtime === null || mtime < oldestMtime) {
181
+ oldestMtime = mtime;
182
+ }
183
+ }
184
+ }
185
+
186
+ return {
187
+ count,
188
+ oldestAgeMs: count > 0 && oldestMtime !== null ? nowMs - oldestMtime : null,
189
+ };
190
+ }
191
+
111
192
  // CONSTANTS — duplicated from packages/server/src/services/_shared.ts.
112
193
  // DRY violation accepted: this hook script runs in user repos WITHOUT
113
194
  // node_modules access, so it cannot import from @fenglimg/fabric-server.
@@ -125,6 +206,59 @@ const EVENT_LEDGER_FILE = "events.jsonl";
125
206
  const METRICS_LEDGER_FILE = "metrics.jsonl";
126
207
  const EVENT_TYPE_PROPOSED = "knowledge_proposed";
127
208
  const EVENT_TYPE_INIT_SCAN_COMPLETED = "init_scan_completed";
209
+
210
+ // v2.2 dual-sink (Goal A / D6): deterministic high-value probe for the archive
211
+ // nudge value-gate. Mirrors packages/server/src/services/archive-scan.ts
212
+ // (hasHighValueSignal) — the hook replicates the SAME deterministic ledger probe
213
+ // rather than running the semantic archive-scan, staying within the Hook⊥MCP
214
+ // boundary (the hook reads events.jsonl mechanically; it never judges relevance).
215
+ // Keep these two literal sets in sync with archive-scan.ts.
216
+ const ARCHIVE_HIGH_VALUE_EVENT_TYPES = new Set([
217
+ "knowledge_context_planned",
218
+ "edit_paths_recorded",
219
+ "edit_intent_checked",
220
+ ]);
221
+ const ARCHIVE_NORMATIVE_KEYWORDS = [
222
+ "以后",
223
+ "always",
224
+ "never",
225
+ "from now on",
226
+ "下次",
227
+ "记一下",
228
+ "永远不要",
229
+ ];
230
+
231
+ // v2.2 dual-sink (Goal A / D6): does the ledger carry a high-value archive signal
232
+ // since the watermark (last knowledge_proposed)? True iff any HIGH_VALUE event
233
+ // fired past the watermark, OR the latest assistant_turn carries a normative
234
+ // keyword. Deterministic — no semantic judgement. Used to VALUE-GATE the archive
235
+ // nudge so the check cadence (edits/hours) is decoupled from the nudge cadence
236
+ // (D6): a workspace that crossed the edit threshold but produced no high-value
237
+ // signal stays quiet. watermarkTs null (never archived) → treat all events as
238
+ // past-watermark (a never-archived repo with any edit signal is worth nudging).
239
+ function hasHighValueArchiveSignal(events, watermarkTs) {
240
+ if (!Array.isArray(events)) return false;
241
+ const wm = typeof watermarkTs === "number" ? watermarkTs : 0;
242
+ let latestTurn = null;
243
+ for (const e of events) {
244
+ if (!e || typeof e.ts !== "number" || e.ts <= wm) continue;
245
+ if (typeof e.event_type === "string" && ARCHIVE_HIGH_VALUE_EVENT_TYPES.has(e.event_type)) {
246
+ return true;
247
+ }
248
+ if (e.event_type === "assistant_turn_observed") {
249
+ if (latestTurn === null || (typeof latestTurn.ts === "number" && e.ts > latestTurn.ts)) {
250
+ latestTurn = e;
251
+ }
252
+ }
253
+ }
254
+ if (latestTurn !== null) {
255
+ const haystack = JSON.stringify(latestTurn).toLowerCase();
256
+ for (const kw of ARCHIVE_NORMATIVE_KEYWORDS) {
257
+ if (haystack.includes(kw.toLowerCase())) return true;
258
+ }
259
+ }
260
+ return false;
261
+ }
128
262
  // v2.0.0-rc.7 T10: doctor_run event drives Signal D (maintenance hint).
129
263
  const EVENT_TYPE_DOCTOR_RUN = "doctor_run";
130
264
  // v2.0.0-rc.20 TASK-03: per-turn cite-policy observation event. Emitted by
@@ -267,92 +401,31 @@ function readLedger(projectRoot) {
267
401
  }
268
402
 
269
403
  /**
270
- * Walk <projectRoot>/.fabric/knowledge/pending/<type>/*.md across all
271
- * PENDING_TYPES subdirs, collecting count and oldest mtime.
404
+ * Read pending counts from the CLI-generated resolved-bindings snapshot.
272
405
  *
273
406
  * Returns { count, oldestAgeMs } where:
274
407
  * - count: total .md file count across all type subdirs
275
408
  * - oldestAgeMs: (nowMs - oldestMtimeMs) when count>0, else null
276
409
  *
277
- * ENOENT / unreadable subdir / unstat-able file silently skipped
278
- * (preserves the hook's never-block-on-failure invariant).
410
+ * Store-only cutover: hooks never walk project-local knowledge or store
411
+ * trees. Missing snapshot stats degrade to zero (KT-DEC-0007).
279
412
  */
280
413
  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 };
414
+ const stats = readSnapshotKnowledgeStats(projectRoot, now);
415
+ if (stats !== null) {
416
+ return { count: stats.pendingCount, oldestAgeMs: stats.oldestPendingAgeMs };
289
417
  }
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
- }
316
- }
317
-
318
- return {
319
- count,
320
- oldestAgeMs: count > 0 && oldestMtime !== null ? nowMs - oldestMtime : null,
321
- };
418
+ return readLegacyPendingStats(projectRoot, now);
322
419
  }
323
420
 
324
421
  /**
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).
422
+ * Count canonical knowledge entries from the CLI-generated resolved-bindings
423
+ * snapshot. Store-only: hooks never walk project-local knowledge or store
424
+ * trees a missing snapshot degrades to zero (KT-DEC-0007).
333
425
  */
334
426
  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;
427
+ const stats = readSnapshotKnowledgeStats(projectRoot);
428
+ return stats === null ? 0 : stats.canonicalCount;
356
429
  }
357
430
 
358
431
  /**
@@ -411,6 +484,81 @@ function countEditsSince(projectRoot, anchorTs) {
411
484
  return count;
412
485
  }
413
486
 
487
+ // ---------------------------------------------------------------------------
488
+ // Observability grill (a + Q4): session-activity status breadcrumb.
489
+ //
490
+ // A no-signal Stop used to return SILENT — the human only ever heard from
491
+ // Fabric when there was a nudge to act on, never a "here is what I did" recap,
492
+ // which reads as "Fabric does nothing in the background". These two helpers add
493
+ // a HUMAN-ONLY trust anchor (the AI gets no activity recap — flow ⊥ observation,
494
+ // D5) plus the nudge_mode tier-guidance line (so the human-channel volume knob
495
+ // is discoverable). Cadence is gated by nudge_mode at emit time.
496
+ // ---------------------------------------------------------------------------
497
+
498
+ // Session-scoped tally. Counts ONLY events that carry session_id, filtered to
499
+ // the current session — knowledge_context_planned / knowledge_proposed lack
500
+ // session_id and are intentionally excluded (a cross-session count would
501
+ // mislead). Exported for unit tests.
502
+ function tallySessionActivity(events, sessionId) {
503
+ let edits = 0;
504
+ let consumed = 0;
505
+ if (!Array.isArray(events) || typeof sessionId !== "string" || sessionId.length === 0) {
506
+ return { edits, consumed };
507
+ }
508
+ for (const ev of events) {
509
+ if (!ev || ev.session_id !== sessionId) continue;
510
+ if (ev.event_type === "file_mutated") edits += 1;
511
+ else if (ev.event_type === "knowledge_consumed") consumed += 1;
512
+ }
513
+ return { edits, consumed };
514
+ }
515
+
516
+ // Emit the human-facing session status breadcrumb when no actionable signal
517
+ // fired. Human sink ONLY. Cadence by nudge_mode: silent → never; minimal/normal
518
+ // → once per session; verbose → every turn. Folds in the tier-guidance line on
519
+ // the first status of the session so the volume knob is discoverable. Never
520
+ // throws — the caller wraps it, but every branch degrades silently anyway.
521
+ function emitSessionStatus(cwd, events, stdinPayload, nowMs, pendingStats, out) {
522
+ if (nudgePolicy === null || clientAdapter === null) return;
523
+ if (typeof clientAdapter.emitDualSink !== "function") return;
524
+ const sessionId = resolveHookSessionId(stdinPayload);
525
+ if (typeof sessionId !== "string" || sessionId.length === 0) return;
526
+
527
+ const mode =
528
+ typeof nudgePolicy.readNudgeMode === "function" ? nudgePolicy.readNudgeMode(cwd) : "normal";
529
+ if (mode === "silent") return; // human channel globally muted
530
+
531
+ const tally = tallySessionActivity(events, sessionId);
532
+ const pending = pendingStats && typeof pendingStats.total === "number" ? pendingStats.total : 0;
533
+ // Nothing happened yet this session AND no backlog → no trust anchor to show.
534
+ if (tally.edits === 0 && tally.consumed === 0 && pending === 0) return;
535
+
536
+ // Cadence gate: normal/minimal show once per session; verbose every turn.
537
+ const cache = readShownCache(cwd, sessionId);
538
+ const firstThisSession = cache._status === undefined;
539
+ if (mode !== "verbose" && !firstThisSession) return;
540
+
541
+ const variant = readFabricLanguage(cwd);
542
+ const line1 = renderBanner("statusLine", variant, {
543
+ edits: tally.edits,
544
+ consumed: tally.consumed,
545
+ pending,
546
+ });
547
+ // Tier guidance only on the first status of the session (don't repeat it on
548
+ // every verbose turn).
549
+ const human = firstThisSession
550
+ ? `${line1}\n${renderBanner("statusTier", variant, { mode })}`
551
+ : line1;
552
+
553
+ clientAdapter.emitDualSink(
554
+ { human, ai: null },
555
+ { client: clientAdapter.detectClient(__dirname), eventName: "Stop", streams: { stdout: out } },
556
+ );
557
+
558
+ cache._status = nowMs;
559
+ writeShownCache(cwd, cache, sessionId);
560
+ }
561
+
414
562
  /**
415
563
  * v2.0.0-rc.8 (TASK-002): detect whether a fabric-import skill run is
416
564
  * currently in flight, used to gate Signal B (review hint) so the Stop
@@ -1510,14 +1658,13 @@ function parseKbLine(raw) {
1510
1658
  * "codex". Covers the dominant deployment shape (hook script lives
1511
1659
  * under the client's per-repo dir).
1512
1660
  *
1513
- * Returns `undefined` when neither signal fires (e.g. Cursor deferred to
1514
- * rc.21 — or a custom deployment). The Zod schema marks `client` optional,
1515
- * so omitting it leaves the event valid.
1661
+ * Returns `undefined` when neither signal fires (a custom deployment). The
1662
+ * Zod schema marks `client` optional, so omitting it leaves the event valid.
1516
1663
  */
1517
1664
  function detectClient() {
1518
1665
  // Delegate the full 3-tier detection (env → CLAUDE_PROJECT_DIR → path
1519
- // heuristic, incl. .cursor) to the shared adapter. __dirname is passed so
1520
- // the path heuristic reflects THIS hook's location.
1666
+ // heuristic) to the shared adapter. __dirname is passed so the path
1667
+ // heuristic reflects THIS hook's location.
1521
1668
  if (clientAdapter && typeof clientAdapter.detectClient === "function") {
1522
1669
  return clientAdapter.detectClient(__dirname);
1523
1670
  }
@@ -1525,7 +1672,7 @@ function detectClient() {
1525
1672
  const envClient = process.env.FABRIC_HINT_CLIENT;
1526
1673
  if (typeof envClient === "string" && envClient.length > 0) {
1527
1674
  const normalised = envClient.trim().toLowerCase();
1528
- if (normalised === "cc" || normalised === "codex" || normalised === "cursor") {
1675
+ if (normalised === "cc" || normalised === "codex") {
1529
1676
  return normalised;
1530
1677
  }
1531
1678
  }
@@ -1864,50 +2011,6 @@ function summarizeTranscript(transcriptPath) {
1864
2011
  return out;
1865
2012
  }
1866
2013
 
1867
- /**
1868
- * v2.0.0-rc.24 TASK-05: emit soft L1 reminder to stderr when assistant turns
1869
- * cited a decision/pitfall id with [recalled] but no operator contract and no
1870
- * skip:<reason>. Reads agents.meta.json once per invocation; aggregated per
1871
- * turn (one line per offending id). Non-blocking — never throws, always
1872
- * returns the array of emitted reminder strings (for unit tests + callers
1873
- * that want to observe what was written).
1874
- *
1875
- * The reminder writes go to stderr (the hook contract: stdout is structured
1876
- * banner JSON consumed by the harness; stderr is free-text system message
1877
- * that surfaces back to the model on the next turn in cc / codex / cursor).
1878
- */
1879
- function emitCiteContractRemindersBestEffort(cwd, stdinPayload, stderr) {
1880
- if (citeContractReminder === null) return [];
1881
- if (stdinPayload === null || typeof stdinPayload !== "object") return [];
1882
- try {
1883
- const transcript = summarizeTranscript(stdinPayload.transcript_path);
1884
- const turns = transcript.assistant_turns;
1885
- if (!Array.isArray(turns) || turns.length === 0) return [];
1886
-
1887
- const idTypeMap = citeContractReminder.readKnowledgeTypeMap(cwd);
1888
- if (!(idTypeMap instanceof Map) || idTypeMap.size === 0) return [];
1889
-
1890
- const reminders = citeContractReminder.formatContractMissingReminders({
1891
- assistant_turns: turns,
1892
- idTypeMap,
1893
- });
1894
- if (!Array.isArray(reminders) || reminders.length === 0) return [];
1895
-
1896
- const sink = stderr || process.stderr;
1897
- for (const line of reminders) {
1898
- try {
1899
- sink.write(line + "\n");
1900
- } catch {
1901
- // Sink write failure must not abort emission of remaining reminders.
1902
- }
1903
- }
1904
- return reminders;
1905
- } catch {
1906
- // Outer guard — never throw. Hook continues silently.
1907
- return [];
1908
- }
1909
- }
1910
-
1911
2014
  /**
1912
2015
  * v2.0.0-rc.7 T5: writeSessionDigestBestEffort — non-blocking digest fan-out.
1913
2016
  * Called from main() before the existing decide() flow. Failure is silently
@@ -1961,16 +2064,6 @@ function main(env, stdio) {
1961
2064
  // the hook's other I/O).
1962
2065
  extractAndWriteAssistantTurnsBestEffort(cwd, stdinPayload);
1963
2066
 
1964
- // v2.0.0-rc.24 TASK-05: L1 soft reminder layer. Surfaces ⚠ KB:<id> lines
1965
- // to stderr when decision/pitfall cites arrived with [recalled] tag but
1966
- // empty contract. Non-blocking, never throws; doctor (TASK-08) catches
1967
- // any contract violation the model ignored.
1968
- emitCiteContractRemindersBestEffort(
1969
- cwd,
1970
- stdinPayload,
1971
- stdio && stdio.stderr,
1972
- );
1973
-
1974
2067
  const events = readLedger(cwd);
1975
2068
 
1976
2069
  // lifecycle-refactor W3-A2 (§7): request graph-edge extraction for a freshly
@@ -2128,7 +2221,38 @@ function main(env, stdio) {
2128
2221
  }
2129
2222
  }
2130
2223
 
2131
- if (result === null) return;
2224
+ if (result === null) {
2225
+ // Observability grill (a): no actionable signal — instead of returning
2226
+ // silently (which made Fabric feel inert in the background), surface a
2227
+ // session-activity status breadcrumb to the human sink (gated by
2228
+ // nudge_mode). Best-effort: never block the Stop hook on it.
2229
+ try {
2230
+ emitSessionStatus(cwd, events, stdinPayload, nowMs, pendingStats, out);
2231
+ } catch {
2232
+ // status breadcrumb is decorative — never throw
2233
+ }
2234
+ return;
2235
+ }
2236
+
2237
+ // v2.2 dual-sink (Goal A / D6): VALUE-GATE the archive nudge. Signal A's
2238
+ // edit/hours trigger is the CHECK cadence; the nudge only fires when a
2239
+ // deterministic high-value signal accrued since the last archive (decouples
2240
+ // check frequency from disturb frequency). Boundary-correct: replicates
2241
+ // archive-scan's ledger probe (no semantic judgement). Other signals
2242
+ // (review/import/maintenance) are unaffected.
2243
+ if (result.signal === "archive") {
2244
+ let watermarkTs = null;
2245
+ for (let i = events.length - 1; i >= 0; i -= 1) {
2246
+ const ev = events[i];
2247
+ if (ev && ev.event_type === EVENT_TYPE_PROPOSED && typeof ev.ts === "number") {
2248
+ watermarkTs = ev.ts;
2249
+ break;
2250
+ }
2251
+ }
2252
+ if (!hasHighValueArchiveSignal(events, watermarkTs)) {
2253
+ return; // no high-value candidate → stay quiet (D6 value-gate)
2254
+ }
2255
+ }
2132
2256
 
2133
2257
  // v2.0.0-rc.37 NEW-16: per-signal dismiss. A chosen signal whose type the
2134
2258
  // user dismissed (config-durable or session sidecar) exits silently —
@@ -2150,10 +2274,10 @@ function main(env, stdio) {
2150
2274
  // pile. Best-effort; missing snapshot / single-store omits the line.
2151
2275
  if (bindingsSnapshotReader !== null && typeof result.reason === "string") {
2152
2276
  try {
2153
- const projectId = readProjectId(cwd);
2154
- if (projectId) {
2277
+ const bindingId = readWorkspaceBindingId(cwd);
2278
+ if (bindingId) {
2155
2279
  const label = bindingsSnapshotReader.formatStoreLabels(
2156
- bindingsSnapshotReader.readBindingsSnapshot(projectId),
2280
+ bindingsSnapshotReader.readBindingsSnapshot(bindingId),
2157
2281
  );
2158
2282
  if (label) {
2159
2283
  result.reason = `${result.reason}\n${label}`;
@@ -2193,9 +2317,27 @@ function main(env, stdio) {
2193
2317
  }
2194
2318
 
2195
2319
  emitSignalFiredEvent(cwd, sessionId, result);
2320
+ const reasonText = typeof result.reason === "string" ? result.reason : "";
2196
2321
  delete result.threshold;
2197
2322
  delete result.actual_value;
2198
- out.write(JSON.stringify(result));
2323
+ // v2.2 dual-sink (Goal A / D3): the archive nudge is SOFT — emitted as
2324
+ // additionalContext(AI) + systemMessage(human), NEVER decision:block. The
2325
+ // human channel is gated by nudge_mode (D4/D5); the AI channel always carries
2326
+ // it (flow ⊥ observation). Missing it is backstopped by the SessionEnd marker
2327
+ // + cross-session debt (D3). Review/import keep the decision:block contract
2328
+ // (out of Goal A scope; KT-DEC-0007 nudge semantics unchanged for them).
2329
+ if (result.signal === "archive" && clientAdapter && typeof clientAdapter.emitDualSink === "function") {
2330
+ const humanGate =
2331
+ nudgePolicy !== null
2332
+ ? nudgePolicy.resolveHumanSink(cwd, "stop", { highValue: true })
2333
+ : { emitHuman: true };
2334
+ clientAdapter.emitDualSink(
2335
+ { human: humanGate.emitHuman ? reasonText : null, ai: reasonText },
2336
+ { client: clientAdapter.detectClient(__dirname), eventName: "Stop", streams: { stdout: out } },
2337
+ );
2338
+ } else {
2339
+ out.write(JSON.stringify(result));
2340
+ }
2199
2341
  cache[result.signal] = nowMs;
2200
2342
  writeShownCache(cwd, cache, resolveHookSessionId(stdinPayload));
2201
2343
  } catch {
@@ -2209,6 +2351,8 @@ module.exports = {
2209
2351
  readPendingStats,
2210
2352
  countCanonicalNodes,
2211
2353
  countEditsSince,
2354
+ // observability grill (a): session-activity tally for the human status line.
2355
+ tallySessionActivity,
2212
2356
  // rc.7 T4: top-edited-directories aggregator + banner overview formatter.
2213
2357
  getTopEditedDirectories,
2214
2358
  formatActivityOverview,
@@ -2248,10 +2392,6 @@ module.exports = {
2248
2392
  parseKbLine,
2249
2393
  detectClient,
2250
2394
  extractAndWriteAssistantTurnsBestEffort,
2251
- // v2.0.0-rc.24 TASK-05: L1 soft reminder helpers (exported for unit testing
2252
- // of the contract-missing emission contract). The lib module itself is
2253
- // also exported indirectly via the reminder helper.
2254
- emitCiteContractRemindersBestEffort,
2255
2395
  // lifecycle-refactor W3-A2 (§7): graph-edge-candidate request emitter
2256
2396
  // (exported for unit testing of the honest stable_id-gating + de-dup).
2257
2397
  emitGraphEdgeCandidateBestEffort,