@fenglimg/fabric-cli 2.0.0 → 2.1.0-rc.2

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 (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -5
  3. package/dist/chunk-BATF4PEJ.js +361 -0
  4. package/dist/{chunk-OBQU6NHO.js → chunk-COI5VDFU.js} +0 -18
  5. package/dist/chunk-F46ORPOA.js +903 -0
  6. package/dist/chunk-HFQVXY6P.js +86 -0
  7. package/dist/chunk-L4Q55UC4.js +52 -0
  8. package/dist/chunk-LFIKMVY7.js +27 -0
  9. package/dist/chunk-MF3OTILQ.js +544 -0
  10. package/dist/chunk-PWLW3B57.js +18 -0
  11. package/dist/chunk-RYAFBNES.js +33 -0
  12. package/dist/chunk-T5RPGCCM.js +40 -0
  13. package/dist/chunk-WU6GAPKH.js +36 -0
  14. package/dist/config-XJIPZNUP.js +13 -0
  15. package/dist/doctor-QVNPHLJK.js +920 -0
  16. package/dist/index.js +23 -8
  17. package/dist/{init-BIRSIOXO.js → install-2HDO5FTQ.js} +807 -705
  18. package/dist/metrics-ACEQFPDU.js +122 -0
  19. package/dist/onboard-coverage-MFCAEBDO.js +220 -0
  20. package/dist/{plan-context-hint-QMUPAXIB.js → plan-context-hint-FC6P3WFE.js} +34 -28
  21. package/dist/scope-explain-2F2R5URO.js +33 -0
  22. package/dist/status-GLQWLWH6.js +23 -0
  23. package/dist/store-XTSE5TY6.js +105 -0
  24. package/dist/sync-BJCWDPNC.js +245 -0
  25. package/dist/uninstall-TAXSUSKH.js +1073 -0
  26. package/dist/whoami-B6AEMSEV.js +31 -0
  27. package/package.json +30 -5
  28. package/templates/hooks/cite-policy-evict.cjs +231 -0
  29. package/templates/hooks/configs/README.md +29 -6
  30. package/templates/hooks/configs/claude-code.json +14 -3
  31. package/templates/hooks/configs/codex-hooks.json +6 -3
  32. package/templates/hooks/configs/cursor-hooks.json +8 -10
  33. package/templates/hooks/fabric-hint.cjs +873 -105
  34. package/templates/hooks/knowledge-hint-broad.cjs +549 -135
  35. package/templates/hooks/knowledge-hint-narrow.cjs +830 -26
  36. package/templates/hooks/lib/banner-i18n.cjs +309 -0
  37. package/templates/hooks/lib/bindings-snapshot-reader.cjs +81 -0
  38. package/templates/hooks/lib/cite-contract-reminder.cjs +179 -0
  39. package/templates/hooks/lib/cite-line-parser.cjs +180 -0
  40. package/templates/hooks/lib/client-adapter.cjs +106 -0
  41. package/templates/hooks/lib/config-cache.cjs +107 -0
  42. package/templates/hooks/lib/state-store.cjs +84 -0
  43. package/templates/hooks/lib/summary-fallback.cjs +210 -0
  44. package/templates/skills/fabric-archive/SKILL.md +97 -419
  45. package/templates/skills/fabric-archive/ref/dry-run-scope.md +16 -0
  46. package/templates/skills/fabric-archive/ref/e5-cron-recap.md +58 -0
  47. package/templates/skills/fabric-archive/ref/i18n-policy.md +86 -0
  48. package/templates/skills/fabric-archive/ref/phase-0-range-resolution.md +156 -0
  49. package/templates/skills/fabric-archive/ref/phase-1-5-onboard.md +218 -0
  50. package/templates/skills/fabric-archive/ref/phase-1-cross-session.md +62 -0
  51. package/templates/skills/fabric-archive/ref/phase-2-5-viability.md +68 -0
  52. package/templates/skills/fabric-archive/ref/phase-3-5-scope.md +108 -0
  53. package/templates/skills/fabric-archive/ref/phase-3-classify.md +63 -0
  54. package/templates/skills/fabric-archive/ref/phase-4-5-emit.md +78 -0
  55. package/templates/skills/fabric-archive/ref/phase-4-mcp-persist.md +89 -0
  56. package/templates/skills/fabric-archive/ref/rc-history.md +38 -0
  57. package/templates/skills/fabric-archive/ref/worked-examples.md +78 -0
  58. package/templates/skills/fabric-import/SKILL.md +77 -514
  59. package/templates/skills/fabric-import/ref/checkpoint-state.md +85 -0
  60. package/templates/skills/fabric-import/ref/i18n-policy.md +79 -0
  61. package/templates/skills/fabric-import/ref/output-contract.md +61 -0
  62. package/templates/skills/fabric-import/ref/phase-2-mining.md +213 -0
  63. package/templates/skills/fabric-import/ref/phase-3-dedup.md +75 -0
  64. package/templates/skills/fabric-import/ref/state-recovery.md +57 -0
  65. package/templates/skills/fabric-import/ref/worked-examples.md +127 -0
  66. package/templates/skills/fabric-review/SKILL.md +90 -284
  67. package/templates/skills/fabric-review/ref/askuserquestion-policy.md +66 -0
  68. package/templates/skills/fabric-review/ref/i18n-policy.md +111 -0
  69. package/templates/skills/fabric-review/ref/modify-flow.md +103 -0
  70. package/templates/skills/fabric-review/ref/output-contract.md +58 -0
  71. package/templates/skills/fabric-review/ref/per-mode-flows.md +155 -0
  72. package/templates/skills/fabric-review/ref/semantic-check.md +26 -0
  73. package/templates/skills/fabric-review/ref/worked-examples.md +95 -0
  74. package/templates/skills/fabric-sync/SKILL.md +46 -0
  75. package/templates/skills/lib/shared-policy.md +69 -0
  76. package/dist/chunk-6ICJICVU.js +0 -10
  77. package/dist/chunk-74SZWYPH.js +0 -658
  78. package/dist/chunk-EYIDD2YS.js +0 -1000
  79. package/dist/doctor-T7JWODKG.js +0 -282
  80. package/dist/hooks-Y74Y5LQS.js +0 -12
  81. package/dist/scan-LMK3UCWL.js +0 -22
  82. package/dist/serve-H554BHLG.js +0 -124
  83. package/templates/agents-md/AGENTS.md.template +0 -59
  84. package/templates/bootstrap/CLAUDE.md +0 -8
  85. package/templates/bootstrap/codex-AGENTS-header.md +0 -6
  86. package/templates/bootstrap/cursor-fabric-bootstrap.mdc +0 -10
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- const { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } = require("node:fs");
2
+ const { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } = require("node:fs");
3
3
  const { dirname, join } = require("node:path");
4
4
 
5
5
  // v2.0.0-rc.7 T5: session-digest writer. Best-effort (never blocks Stop hook
@@ -13,15 +13,119 @@ try {
13
13
  sessionDigestWriter = null;
14
14
  }
15
15
 
16
+ // v2.0.0-rc.16 TASK-002 (F2-apply): banner-i18n lib for the 5 Signal
17
+ // banners (A/B/C/D-never/D-aged). Resolved ONCE per main() invocation and
18
+ // threaded into decide() / evaluateMaintenanceSignal() via the existing
19
+ // thresholds object. Lib is required at module load; failure to load is
20
+ // fatal-here-but-silent: the require itself can't throw without the .cjs
21
+ // being missing entirely (a packaging bug we'd want to surface during
22
+ // install integration tests, not silently swallow).
23
+ const { renderBanner, readFabricLanguage } = require("./lib/banner-i18n.cjs");
24
+
25
+ // v2.0.0-rc.24 TASK-04: shared cite-line parser (CJS twin of
26
+ // packages/shared/src/cite-line-parser.ts, byte-shipped via installHookLibs).
27
+ // Provides `parseCiteLine(raw)` → { cite_ids, cite_tags, cite_commitments }.
28
+ // Hook runtime has no node_modules access; the twin is hand-synced and
29
+ // behavior-parity-tested against the TS source.
30
+ let citeLineParser = null;
31
+ try {
32
+ citeLineParser = require("./lib/cite-line-parser.cjs");
33
+ } catch {
34
+ // Helper module missing — degrade silently. parseKbLine falls back to a
35
+ // legacy in-file regex when the lib is unavailable (e.g. mid-upgrade where
36
+ // hook script lands before lib is copied). New cite_commitments output is
37
+ // empty in degraded mode.
38
+ citeLineParser = null;
39
+ }
40
+
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
+ // v2.0.0-rc.37 NEW-30: shared client-protocol adapter. Guarded require (this
59
+ // hook runs in arbitrary user repos); detectClient delegates the 3-tier
60
+ // detection to the lib, falling back to env-only when the lib is absent.
61
+ let clientAdapter = null;
62
+ try {
63
+ clientAdapter = require("./lib/client-adapter.cjs");
64
+ } catch {
65
+ clientAdapter = null;
66
+ }
67
+
68
+ // v2.0.0-rc.37 NEW-16: shared config + sidecar I/O for the per-signal dismiss
69
+ // feature (config-level durable opt-out + session-scoped sidecar). Guarded
70
+ // require (house style); dismiss simply doesn't fire if the lib is absent.
71
+ let configCache = null;
72
+ let stateStore = null;
73
+ try {
74
+ configCache = require("./lib/config-cache.cjs");
75
+ } catch {
76
+ configCache = null;
77
+ }
78
+ try {
79
+ stateStore = require("./lib/state-store.cjs");
80
+ } catch {
81
+ stateStore = null;
82
+ }
83
+
84
+ // v2.1.0-rc.1 P4 (F4/S63): hook-side reader for the CLI pre-generated
85
+ // resolved-bindings snapshot. The Stop hint surfaces the read-set stores
86
+ // (per-store, NOT aggregated into one pile) without re-resolving / walking
87
+ // store trees. Best-effort — a missing lib/snapshot omits the store line.
88
+ let bindingsSnapshotReader = null;
89
+ try {
90
+ bindingsSnapshotReader = require("./lib/bindings-snapshot-reader.cjs");
91
+ } catch {
92
+ bindingsSnapshotReader = null;
93
+ }
94
+
95
+ // Read the project's own `project_id` (the snapshot key) from its config.
96
+ function readProjectId(cwd) {
97
+ try {
98
+ const parsed = JSON.parse(readFileSync(join(cwd, ".fabric", "fabric-config.json"), "utf8"));
99
+ return typeof parsed.project_id === "string" ? parsed.project_id : null;
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
16
105
  // CONSTANTS — duplicated from packages/server/src/services/_shared.ts.
17
106
  // DRY violation accepted: this hook script runs in user repos WITHOUT
18
107
  // node_modules access, so it cannot import from @fenglimg/fabric-server.
19
108
  const FABRIC_DIR = ".fabric";
20
109
  const EVENT_LEDGER_FILE = "events.jsonl";
110
+ // v2.0.0-rc.39 (P1 emit-fold): high-frequency empty-shell assistant_turn_observed
111
+ // turns (kb_line_raw=null AND no cite_ids AND no cite_commitments) carry zero
112
+ // cite-audit signal, so emitting one events.jsonl line each is pure bloat. They
113
+ // are folded at the emit source into a single per-Stop metrics.jsonl counter row
114
+ // `{ counters: { assistant_turn_observed[:<client>]: N } }`. The cite-coverage /
115
+ // emit-cadence readers add this counter back into total_turns so the metric is
116
+ // byte-for-byte invariant (the fold preserves count semantics, incl. the legacy
117
+ // per-Stop re-emission, exactly). Mirrors packages/server/src/services/metrics.ts
118
+ // row shape; written directly (the .cjs hook cannot import the TS service).
119
+ const METRICS_LEDGER_FILE = "metrics.jsonl";
21
120
  const EVENT_TYPE_PROPOSED = "knowledge_proposed";
22
121
  const EVENT_TYPE_INIT_SCAN_COMPLETED = "init_scan_completed";
23
122
  // v2.0.0-rc.7 T10: doctor_run event drives Signal D (maintenance hint).
24
123
  const EVENT_TYPE_DOCTOR_RUN = "doctor_run";
124
+ // v2.0.0-rc.20 TASK-03: per-turn cite-policy observation event. Emitted by
125
+ // extractAndWriteAssistantTurnsBestEffort() after the Stop hook parses each
126
+ // assistant envelope's first non-empty line for a `KB:` prefix. Schema
127
+ // registered in packages/shared/src/schemas/event-ledger.ts (rc.20 TASK-02).
128
+ const EVENT_TYPE_ASSISTANT_TURN_OBSERVED = "assistant_turn_observed";
25
129
  // rc.6 TASK-022 (E5): Signal A is now `24h OR N-edits since last
26
130
  // knowledge_proposed`. The edit-count branch reads
27
131
  // `.fabric/.cache/edit-counter` (one ISO-8601 line per PreToolUse fire,
@@ -98,14 +202,20 @@ const MAINTENANCE_HINT_LAST_EMIT_FILE = ".fabric/.cache/maintenance-hint-last-em
98
202
  // there's barely anything TO lint.
99
203
  const MAINTENANCE_HINT_MIN_CANONICAL = 5;
100
204
 
101
- // rc.7 T1: cross-surface sentinel from `fabric init` Y-confirm. Empty file
102
- // at `.fabric/.import-requested`. Stop hook reads it to bypass the Signal C
103
- // cooldown and emit the import recommendation regardless of underseed or
104
- // 24h-since-last-emit gates. SessionStart hook (knowledge-hint-broad.cjs)
105
- // has its own mirror of this pickup logic. The fabric-import Skill's
106
- // Phase 3.4 clears the sentinel; until then it remains and continues to
107
- // surface the recommendation.
108
- const IMPORT_REQUESTED_SENTINEL_FILE = join(".fabric", ".import-requested");
205
+ // v2.0.0-rc.8 (TASK-002): in-flight import gate for Signal B.
206
+ // fabric-import skill writes `.fabric/.import-state.json` checkpoints after
207
+ // every successful sub-step (P1/P2/P3 see fabric-import/SKILL.md). The
208
+ // Stop hook reads this file as a soft signal to know that an import is
209
+ // mid-run, so we can silence Signal B (review hint at pending count >= 10)
210
+ // to avoid interrupting the import while it accumulates pending entries.
211
+ //
212
+ // Gate is intentionally narrow: ONLY Signal B is suppressed. Signals A
213
+ // (archive), C (import recommendation), D (maintenance) retain their
214
+ // pre-existing behaviour byte-for-byte. The 24h TTL on `last_checkpoint_at`
215
+ // guards against stale state files that would otherwise permanently
216
+ // silence Signal B if a user abandoned an import without completing.
217
+ const IMPORT_STATE_FILE_REL = join(".fabric", ".import-state.json");
218
+ const IMPORT_IN_FLIGHT_MAX_AGE_HOURS = 24;
109
219
 
110
220
  /**
111
221
  * Read the events.jsonl ledger from <projectRoot>/.fabric/events.jsonl.
@@ -296,35 +406,62 @@ function countEditsSince(projectRoot, anchorTs) {
296
406
  }
297
407
 
298
408
  /**
299
- * rc.7 T1: detect the `.fabric/.import-requested` sentinel. Best-effort
300
- * presence check returns false on any I/O error so a hostile filesystem
301
- * never blocks the Stop hook on this branch.
409
+ * v2.0.0-rc.8 (TASK-002): detect whether a fabric-import skill run is
410
+ * currently in flight, used to gate Signal B (review hint) so the Stop
411
+ * hook does not interrupt an active import when its pending pile crosses
412
+ * the review threshold.
413
+ *
414
+ * Truth table — returns false (i.e. NOT in flight, do not gate) on:
415
+ * - `.fabric/.import-state.json` missing (no import has ever started or
416
+ * state file was deleted)
417
+ * - JSON.parse failure (malformed state file — never-block invariant
418
+ * forbids permanently silencing Signal B due to corruption)
419
+ * - `phase === "complete"` (import finished — see fabric-import SKILL.md
420
+ * Phase 3.4)
421
+ * - `last_checkpoint_at` missing OR older than IMPORT_IN_FLIGHT_MAX_AGE_HOURS
422
+ * (stale state — user likely abandoned the import; do not let a forever
423
+ * orphaned state file silence Signal B forever)
424
+ * - any unexpected throw (defensive — never-block invariant)
425
+ *
426
+ * Returns true ONLY when state file exists, parses, has a non-"complete"
427
+ * phase, and a fresh `last_checkpoint_at` (< 24h ago). Field names
428
+ * (`phase`, `last_checkpoint_at`) verified against fabric-import SKILL.md
429
+ * § Checkpoint Logic.
430
+ *
431
+ * `now` is optional — defaults to `new Date()`. Tests can inject a fixed
432
+ * Date for determinism; production callers may omit it.
302
433
  */
303
- function isImportRequestedSentinelPresent(projectRoot) {
434
+ function isImportInFlight(projectRoot, now) {
304
435
  try {
305
- return existsSync(join(projectRoot, IMPORT_REQUESTED_SENTINEL_FILE));
436
+ const p = join(projectRoot, IMPORT_STATE_FILE_REL);
437
+ if (!existsSync(p)) return false;
438
+ let raw;
439
+ try {
440
+ raw = readFileSync(p, "utf8");
441
+ } catch {
442
+ return false;
443
+ }
444
+ let parsed;
445
+ try {
446
+ parsed = JSON.parse(raw);
447
+ } catch {
448
+ return false;
449
+ }
450
+ if (parsed === null || typeof parsed !== "object") return false;
451
+ if (parsed.phase === "complete") return false;
452
+ const ts = parsed.last_checkpoint_at;
453
+ if (typeof ts !== "string" || ts.length === 0) return false;
454
+ const ms = Date.parse(ts);
455
+ if (!Number.isFinite(ms)) return false;
456
+ const nowMs = now instanceof Date ? now.getTime() : Number(now) || Date.now();
457
+ const ageHours = (nowMs - ms) / MS_PER_HOUR;
458
+ if (ageHours > IMPORT_IN_FLIGHT_MAX_AGE_HOURS) return false;
459
+ return true;
306
460
  } catch {
307
461
  return false;
308
462
  }
309
463
  }
310
464
 
311
- /**
312
- * rc.7 T1: build the import-recommendation result that the Stop hook emits
313
- * when the sentinel is present. Reuses the existing Signal C shape so
314
- * downstream consumers (Cursor `followup_message`, etc.) need no schema
315
- * change. The reason text reuses the rc.7 T4 人-first banner style.
316
- */
317
- function makeImportSentinelResult() {
318
- const line1 =
319
- "📋 Fabric: 检测到 fabric init 提示要回灌知识 — 是否调 /fabric-import 从 git 历史和现有文档抽取?";
320
- return {
321
- decision: "block",
322
- reason: line1,
323
- signal: "import",
324
- recommended_skill: "fabric-import",
325
- };
326
- }
327
-
328
465
  /**
329
466
  * rc.7 T4: read the edit-counter sidecar and return the top-N most-edited
330
467
  * directories (grouped by the leading 2 path segments) since `anchorTs`.
@@ -397,14 +534,49 @@ function getTopEditedDirectories(projectRoot, topN, anchorTs) {
397
534
  // any leading "./". POSIX-style only — the hook ships under POSIX
398
535
  // path conventions even on Windows (the project doesn't currently
399
536
  // ship a CRLF/backslash test matrix for the sidecar).
400
- const norm = p.replace(/\\/g, "/").replace(/^\.\//, "");
537
+ //
538
+ // v2.0.0-rc.27 TASK-005 (audit §2.8 leak surface): absolute paths
539
+ // already accumulated in legacy sidecars start with `/`. We strip
540
+ // the leading slash and also reject buckets that resolve to user-home
541
+ // segments (`Users/<name>/...`, `home/<name>/...`) so historical
542
+ // pollution from absolute-path writes doesn't surface the user's
543
+ // $HOME in the archive banner. The rc.27 appendEditCounter no longer
544
+ // writes such paths, but the sidecar is append-only so old lines
545
+ // persist until rotation.
546
+ let norm = p.replace(/\\/g, "/").replace(/^\.\//, "");
547
+ // Strip leading `/` so a stale absolute entry doesn't generate a leak.
548
+ while (norm.startsWith("/")) norm = norm.slice(1);
401
549
  const segs = norm.split("/").filter((s) => s.length > 0);
550
+ // Reject any bucket whose top segments look like a host-system home
551
+ // prefix. The pattern is `<top>/<user>/...` where top ∈ Users|home|root.
552
+ // This silently drops legacy absolute-path entries from $HOME without
553
+ // mangling the buckets for legitimate project-relative `Users/...`
554
+ // (unlikely but possible) — the heuristic favours $HOME leak prevention
555
+ // over false-positive bucketing of project paths named after Unix
556
+ // conventions.
557
+ if (segs.length >= 2 && (segs[0] === "Users" || segs[0] === "home" || segs[0] === "root")) {
558
+ continue;
559
+ }
560
+ // v2.0.0-rc.27 TASK-005 (audit §2.8 file-as-dir): when segs[1] looks
561
+ // like a file (contains a dot-extension at the end), surface segs[0]
562
+ // alone instead of `segs[0]/segs[1]/` — a 2-seg path of the form
563
+ // `assets/foo.ts` would otherwise render as "assets/foo.ts/" which
564
+ // misleads the operator about whether they're seeing a file or a
565
+ // directory. The extension regex is permissive: any `.X` where X is
566
+ // 1-8 alphanumerics counts. README.md / package.json / foo.ts all
567
+ // match; "v1.2" or "dotted.module" do too — acceptable false-positive
568
+ // rate, since the worst outcome is over-aggregation to the parent.
569
+ const looksLikeFile = (segment) => /\.[A-Za-z0-9]{1,8}$/u.test(segment);
402
570
  let bucket;
403
571
  if (segs.length >= 2) {
404
- // Leading 2 segments: "packages/cli", "docs/decisions", etc. We
405
- // trail with "/" so the banner reads "packages/cli/" — clearly a
406
- // directory rather than a file basename.
407
- bucket = `${segs[0]}/${segs[1]}/`;
572
+ if (looksLikeFile(segs[1])) {
573
+ bucket = `${segs[0]}/`;
574
+ } else {
575
+ // Leading 2 segments: "packages/cli", "docs/decisions", etc. We
576
+ // trail with "/" so the banner reads "packages/cli/" — clearly a
577
+ // directory rather than a file basename.
578
+ bucket = `${segs[0]}/${segs[1]}/`;
579
+ }
408
580
  } else if (segs.length === 1) {
409
581
  // Single segment — treat the basename as its own bucket. Bare
410
582
  // root-level files (README.md, package.json) get some signal too.
@@ -500,7 +672,7 @@ function readArchiveEditThreshold(projectRoot) {
500
672
  // without touching the filesystem. Omitting the arg falls back to documented
501
673
  // defaults so existing in-process callers (tests that pre-date T7) still
502
674
  // pass without modification — they implicitly exercise the default path.
503
- function decide(events, now, pendingStats, underseedStats, editCounterStats, thresholds, banner) {
675
+ function decide(events, now, pendingStats, underseedStats, editCounterStats, thresholds, banner, importInFlight) {
504
676
  const nowMs = now instanceof Date ? now.getTime() : Number(now) || Date.now();
505
677
  const stats = pendingStats || { count: 0, oldestAgeMs: null };
506
678
  const underseed =
@@ -523,6 +695,10 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
523
695
  typeof cfg.reviewHintPendingAgeDays === "number" && cfg.reviewHintPendingAgeDays > 0
524
696
  ? cfg.reviewHintPendingAgeDays
525
697
  : DEFAULT_REVIEW_HINT_PENDING_AGE_DAYS;
698
+ // rc.16 TASK-002: banner variant for the i18n lib. Defaults to 'zh-CN' so
699
+ // existing test callers (which never pass thresholds.variant) get the rc.15
700
+ // byte-identical Chinese output. main() always supplies the resolved variant.
701
+ const variant = typeof cfg.variant === "string" ? cfg.variant : "zh-CN";
526
702
 
527
703
  // ---- Archive signal (rc.6 TASK-022 — Signal A, 24h-OR-N-edits) -----------
528
704
  // Locate the most-recent knowledge_proposed event. If none exists, Signal A
@@ -569,23 +745,38 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
569
745
  // - "<editCount> 次编辑"
570
746
  // - "阈值 <N>"
571
747
  // - "fabric-archive"
748
+ // v2.0.0-rc.27 TASK-005 (audit §2.17): parts now assembled per-variant
749
+ // via banner-i18n's archivePartsHours / archivePartsEdits so en mode
750
+ // gets fully-English fragments instead of mixed-language output. zh-CN
751
+ // / zh-CN-hybrid still render the original substring contract verbatim.
572
752
  const parts = [];
573
753
  if (triggerByHours) {
574
- parts.push(`已过 ${hoursElapsed.toFixed(1)}h(阈值 ${archiveHintHours}h)`);
754
+ parts.push(
755
+ renderBanner("archivePartsHours", variant, {
756
+ hoursFixed: hoursElapsed.toFixed(1),
757
+ threshold: archiveHintHours,
758
+ }),
759
+ );
575
760
  }
576
761
  if (triggerByEdits) {
577
762
  parts.push(
578
- `累计 ${editStats.editsSinceLastProposed} 次编辑(阈值 ${editStats.threshold})`,
763
+ renderBanner("archivePartsEdits", variant, {
764
+ count: editStats.editsSinceLastProposed,
765
+ threshold: editStats.threshold,
766
+ }),
579
767
  );
580
768
  }
581
- const line1 = `📋 Fabric: 距上次归档 ${parts.join(" / ")}。`;
769
+ // rc.16 TASK-002: 5-banner i18n via lib/banner-i18n.cjs. Substring
770
+ // contracts ('25.0h', '阈值 N', 'fabric-archive') preserved by the lib's
771
+ // zh-CN templates — see lib header for the full contract.
772
+ const line1 = renderBanner("archiveLine1", variant, { parts: parts.join(" / ") });
582
773
  const activity = banner && typeof banner.activityOverview === "string"
583
774
  ? banner.activityOverview
584
775
  : "";
585
776
  const line2 = activity.length > 0
586
- ? ` 最近活动集中在: ${activity}。`
777
+ ? renderBanner("archiveActivity", variant, { activity })
587
778
  : "";
588
- const line3 = " 是否调 /fabric-archive 检查值得归档的决策/踩坑/复用?";
779
+ const line3 = renderBanner("archiveCta", variant, {});
589
780
  const reason = [line1, line2, line3].filter((l) => l.length > 0).join("\n");
590
781
  return {
591
782
  decision: "block",
@@ -600,7 +791,13 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
600
791
  const triggerByPendingAge =
601
792
  stats.oldestAgeMs !== null && stats.oldestAgeMs / MS_PER_DAY >= reviewHintPendingAgeDays;
602
793
 
603
- if (triggerByPendingCount || triggerByPendingAge) {
794
+ // v2.0.0-rc.8 (TASK-002): suppress ONLY Signal B while a fabric-import
795
+ // skill run is in flight (read from .fabric/.import-state.json by main()
796
+ // and threaded in as `importInFlight`). Signals A, C, D are unaffected.
797
+ // We fall through to Signal C evaluation rather than returning null —
798
+ // review backlog should not pre-empt import-recommendation evaluation
799
+ // when import is mid-run.
800
+ if ((triggerByPendingCount || triggerByPendingAge) && importInFlight !== true) {
604
801
  // rc.7 T4: 人-first banner reformat for Signal B. Keeps the pending
605
802
  // count and age substrings (`${count} 条`, `${days} 天`) so existing
606
803
  // tests pass; drops the Agent-jussive "建议调用 ... skill ..." for a
@@ -609,8 +806,13 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
609
806
  stats.oldestAgeMs !== null
610
807
  ? ` / 最早一条 ${(stats.oldestAgeMs / MS_PER_DAY).toFixed(1)} 天前`
611
808
  : "";
612
- const line1 = `📋 Fabric: 已积累 ${stats.count} 条待审核知识${ageSuffix}。`;
613
- const line2 = " 是否调 /fabric-review 审核 pending/ 条目?";
809
+ // rc.16 TASK-002: i18n via lib. Substrings ('${count} 条', 'fabric-review')
810
+ // preserved by the lib's zh-CN templates.
811
+ const line1 = renderBanner("reviewLine1", variant, {
812
+ count: stats.count,
813
+ ageSuffix,
814
+ });
815
+ const line2 = renderBanner("reviewCta", variant, {});
614
816
  const reason = `${line1}\n${line2}`;
615
817
  return {
616
818
  decision: "block",
@@ -651,12 +853,16 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
651
853
  (hoursSinceProposed === null || hoursSinceProposed >= UNDERSEED_NO_PROPOSED_HOURS);
652
854
 
653
855
  if (triggerUnderseed) {
654
- // rc.7 T4: 人-first banner reformat for Signal C. Preserves the
655
- // `${nodeCount}/${threshold}` substring (e.g. "3/10") that existing
656
- // tests assert against; drops Agent-jussive phrasing.
657
- const line1 =
658
- `📋 Fabric: 知识库节点数 ${underseed.nodeCount}/${underseed.threshold},距 init_scan_completed ${hoursSinceInit.toFixed(1)}h。`;
659
- const line2 = " 是否调 /fabric-import 从 git 历史与现有文档回灌知识?";
856
+ // rc.16 TASK-002: i18n via lib. Substrings ('${nodeCount}/${threshold}',
857
+ // 'fabric-import', '${hoursSinceInit}h') preserved by the lib's zh-CN
858
+ // templates. Note: hoursSinceInit is passed as already-toFixed(1) string
859
+ // to keep the lib pure (no number formatting in render path).
860
+ const line1 = renderBanner("importLine1", variant, {
861
+ nodeCount: underseed.nodeCount,
862
+ threshold: underseed.threshold,
863
+ hoursSinceInit: hoursSinceInit.toFixed(1),
864
+ });
865
+ const line2 = renderBanner("importCta", variant, {});
660
866
  const reason = `${line1}\n${line2}`;
661
867
  return {
662
868
  decision: "block",
@@ -780,6 +986,97 @@ function writeShownCache(projectRoot, cache) {
780
986
  }
781
987
  }
782
988
 
989
+ // -----------------------------------------------------------------------------
990
+ // v2.0.0-rc.37 NEW-16 — per-signal dismiss.
991
+ //
992
+ // Two suppression levers, both honoured at emit time (a chosen signal whose
993
+ // type is dismissed exits silently, exactly like a cooldown hit):
994
+ // 1. Durable opt-out — fabric-config.json#hint_dismiss_signals: string[].
995
+ // Mirrors the cite_evict_interval=0 opt-out convention; survives across
996
+ // sessions. The concrete user-actionable lever surfaced in the nudge.
997
+ // 2. Session-scoped — .fabric/.cache/hint-dismiss-{sessionId}.json
998
+ // { dismissed: string[] }. Ephemeral; written by the agent when the user
999
+ // asks to silence a nudge type for the current session (Fabric's
1000
+ // AI-driven write convention — no new CLI surface).
1001
+ //
1002
+ // The four signal types ('archive' / 'review' / 'import' / 'maintenance')
1003
+ // each have an independent cooldown ALREADY (signal-keyed SHOWN_CACHE for
1004
+ // A/B/C + the maintenance day-cooldown sidecar), so dismiss layers cleanly on
1005
+ // top of per-signal cadence without a physical 4-hook split (which would 4×
1006
+ // the per-Stop process spawn and break the deliberate single-nudge-per-turn
1007
+ // precedence model — KT-DEC-0007 anti-nag spirit).
1008
+ // -----------------------------------------------------------------------------
1009
+
1010
+ const DISMISSABLE_SIGNALS = ["archive", "review", "import", "maintenance"];
1011
+
1012
+ function sessionDismissFileName(sessionId) {
1013
+ const safe = String(sessionId || "anonymous").replace(/[^A-Za-z0-9_.-]/g, "-");
1014
+ return `hint-dismiss-${safe}.json`;
1015
+ }
1016
+
1017
+ // Returns a Set of dismissed signal types (config-durable ∪ session sidecar).
1018
+ // Never throws — degrades to an empty set when libs are absent.
1019
+ function readDismissedSignals(projectRoot, sessionId) {
1020
+ const dismissed = new Set();
1021
+ try {
1022
+ if (configCache && typeof configCache.readConfig === "function") {
1023
+ const cfg = configCache.readConfig(projectRoot);
1024
+ const list = cfg && cfg.hint_dismiss_signals;
1025
+ if (Array.isArray(list)) {
1026
+ for (const s of list) {
1027
+ if (DISMISSABLE_SIGNALS.includes(s)) dismissed.add(s);
1028
+ }
1029
+ }
1030
+ }
1031
+ } catch {
1032
+ // defensive
1033
+ }
1034
+ try {
1035
+ if (stateStore && typeof stateStore.readJsonState === "function" && sessionId) {
1036
+ const sidecar = stateStore.readJsonState(
1037
+ projectRoot,
1038
+ sessionDismissFileName(sessionId),
1039
+ (p) => p && typeof p === "object" && Array.isArray(p.dismissed),
1040
+ );
1041
+ if (sidecar) {
1042
+ for (const s of sidecar.dismissed) {
1043
+ if (DISMISSABLE_SIGNALS.includes(s)) dismissed.add(s);
1044
+ }
1045
+ }
1046
+ }
1047
+ } catch {
1048
+ // defensive
1049
+ }
1050
+ return dismissed;
1051
+ }
1052
+
1053
+ // Persist a session-scoped dismiss set (additive merge). Exposed for the
1054
+ // agent-driven write path + tests; not auto-invoked by the hook. Never throws.
1055
+ function writeSessionDismiss(projectRoot, sessionId, signals) {
1056
+ if (!stateStore || typeof stateStore.writeJsonState !== "function") return;
1057
+ const fileName = sessionDismissFileName(sessionId);
1058
+ const prior = stateStore.readJsonState(
1059
+ projectRoot,
1060
+ fileName,
1061
+ (p) => p && typeof p === "object" && Array.isArray(p.dismissed),
1062
+ );
1063
+ const merged = new Set(prior && Array.isArray(prior.dismissed) ? prior.dismissed : []);
1064
+ for (const s of Array.isArray(signals) ? signals : []) {
1065
+ if (DISMISSABLE_SIGNALS.includes(s)) merged.add(s);
1066
+ }
1067
+ stateStore.writeJsonState(projectRoot, fileName, { dismissed: [...merged] });
1068
+ }
1069
+
1070
+ // Bilingual one-line dismiss hint appended to every nudge so the user knows
1071
+ // the lever exists. Variant fold mirrors banner-i18n: zh-CN / zh-CN-hybrid →
1072
+ // Chinese; en / match-existing / unknown → English.
1073
+ function renderDismissOption(signal, variant) {
1074
+ const zh = variant === "zh-CN" || variant === "zh-CN-hybrid";
1075
+ return zh
1076
+ ? ` (不想再看到此类提醒?在 .fabric/fabric-config.json 设 "hint_dismiss_signals": ["${signal}"],或让我本会话关闭 ${signal} 提醒)`
1077
+ : ` (Silence this nudge? Set "hint_dismiss_signals": ["${signal}"] in .fabric/fabric-config.json, or ask me to dismiss ${signal} for this session)`;
1078
+ }
1079
+
783
1080
  /**
784
1081
  * v2.0.0-rc.7 T10: find the most recent doctor_run event ts in the ledger.
785
1082
  * Returns the ts (epoch ms) of the newest doctor_run event, or null if none
@@ -859,15 +1156,23 @@ function evaluateMaintenanceSignal(events, now, canonicalCount, lastEmitMs, thre
859
1156
  typeof cfg.maintenanceHintCooldownDays === "number" && cfg.maintenanceHintCooldownDays > 0
860
1157
  ? cfg.maintenanceHintCooldownDays
861
1158
  : DEFAULT_MAINTENANCE_HINT_COOLDOWN_DAYS;
1159
+ // rc.16 TASK-002: banner variant for the i18n lib. Defaults to 'zh-CN' so
1160
+ // existing rc.7 T10 test fixtures (which never set thresholds.variant) get
1161
+ // the byte-identical Chinese maintenance banner.
1162
+ const variant = typeof cfg.variant === "string" ? cfg.variant : "zh-CN";
862
1163
 
863
1164
  if (canonicalCount < MAINTENANCE_HINT_MIN_CANONICAL) {
864
1165
  return null;
865
1166
  }
866
1167
 
867
1168
  // Cooldown gate — short-circuit when we just nagged.
1169
+ // rc.34 TASK-01 + review-fix (Gemini P1): future-stamped lastEmit (backward
1170
+ // clock skew) bypasses cooldown — treats sidecar as "expired" so the gate
1171
+ // heals on the next invocation instead of waiting (cooldown + |skew|).
868
1172
  if (
869
1173
  typeof lastEmitMs === "number" &&
870
1174
  Number.isFinite(lastEmitMs) &&
1175
+ nowMs >= lastEmitMs &&
871
1176
  nowMs - lastEmitMs < cooldownDays * MS_PER_DAY
872
1177
  ) {
873
1178
  return null;
@@ -883,14 +1188,18 @@ function evaluateMaintenanceSignal(events, now, canonicalCount, lastEmitMs, thre
883
1188
  if (ageDays < days) return null; // doctor ran recently, no nag.
884
1189
  }
885
1190
 
886
- // rc.7 T4: keep the existing T10 banner shape (already 人-first with the
887
- // 📋 prefix), but split the action-prompt onto its own line for visual
888
- // consistency with Signals A/B/C. Substrings ("从未运行 lint 检查",
889
- // "已 N 天未跑 lint", "fabric doctor --lint") preserved for the T10 tests.
890
- const line2 = " 是否调 `fabric doctor --lint` 看看知识库健康度?";
891
- const reason = lastDoctorTs === null
892
- ? `📋 Fabric: 从未运行 lint 检查。\n${line2}`
893
- : `📋 Fabric: 已 ${days} 天未跑 lint 检查(实际 ${ageDays.toFixed(1)}d)。\n${line2}`;
1191
+ // rc.16 TASK-002: i18n via lib. Substrings ('从未运行 lint 检查',
1192
+ // '已 N 天未跑 lint', 'fabric doctor --lint') preserved by the lib's
1193
+ // zh-CN templates. ageDays passed as already-toFixed(1) string to keep
1194
+ // the lib pure (no number formatting in render path).
1195
+ const line2 = renderBanner("maintenanceLine2", variant, {});
1196
+ const line1 = lastDoctorTs === null
1197
+ ? renderBanner("maintenanceLine1Never", variant, {})
1198
+ : renderBanner("maintenanceLine1Aged", variant, {
1199
+ days,
1200
+ ageDays: ageDays.toFixed(1),
1201
+ });
1202
+ const reason = `${line1}\n${line2}`;
894
1203
 
895
1204
  return {
896
1205
  decision: "block",
@@ -922,11 +1231,224 @@ function tryReadStdinJson() {
922
1231
  const parsed = JSON.parse(buf);
923
1232
  if (parsed === null || typeof parsed !== "object") return null;
924
1233
  return parsed;
925
- } catch {
1234
+ } catch (e) {
1235
+ // v2.0.0-rc.29 TASK-008 (BUG-L1): hook used to silent-swallow JSON.parse
1236
+ // errors which masked real client-side payload bugs (e.g. CLI hosts that
1237
+ // stopped emitting Stop-hook JSON envelopes). Log a single best-effort
1238
+ // diagnostic line so operators see WHY the hook went quiet; keep returning
1239
+ // null so downstream behaviour (graceful exit 0, no rule render) is
1240
+ // unchanged.
1241
+ try {
1242
+ const message = (e && typeof e === "object" && "message" in e) ? String(e.message) : String(e);
1243
+ process.stderr.write(`[fabric-hint] malformed input: ${message}\n`);
1244
+ } catch {
1245
+ // stderr write failed (very unusual — sandbox / closed fd). The
1246
+ // hook contract still requires we never throw upward.
1247
+ }
926
1248
  return null;
927
1249
  }
928
1250
  }
929
1251
 
1252
+ /**
1253
+ * v2.0.0-rc.20 TASK-03 → v2.0.0-rc.24 TASK-04: legacy shim signature for
1254
+ * parsing the raw text that follows the `KB:` prefix on the first non-empty
1255
+ * line of an assistant turn. As of rc.24 the implementation delegates to the
1256
+ * shared `parseCiteLine` (inline-shipped via lib/cite-line-parser.cjs) to
1257
+ * eliminate per-client regex drift.
1258
+ *
1259
+ * Contract (rc.24 strict mode — superset of rc.20):
1260
+ * - Sentinel `none` (incl. `[no-relevant]` / `[not-applicable]` tail)
1261
+ * → cite_ids=[], cite_tags=["none"], cite_commitments=[]
1262
+ * - `KT-DEC-0001 [planned]` → cite_ids=["KT-DEC-0001"], cite_tags=["planned"],
1263
+ * cite_commitments=[{operators:[], skip_reason:null}]
1264
+ * - `KT-DEC-0001 [recalled] → edit:foo.ts` → cite_commitments=[{operators:
1265
+ * [{kind:"edit", target:"foo.ts"}], skip_reason:null}]
1266
+ * - `KT-DEC-0001 [recalled] → skip:sequencing` → cite_commitments=[{operators:
1267
+ * [], skip_reason:"sequencing"}]
1268
+ * - Id form is now strict `K[TP]-[A-Z]+-\d+` (rc.20 lax form `KP-001`
1269
+ * without letter-prefix is rejected — see TASK-03 schema).
1270
+ *
1271
+ * Argument is the post-`KB:` substring (matches the rc.20 call site). Returns
1272
+ * { cite_ids, cite_tags, cite_commitments }; cite_commitments was added in
1273
+ * rc.24 and is always present (empty array when no cite-line found).
1274
+ *
1275
+ * Never throws.
1276
+ */
1277
+ function parseKbLine(raw) {
1278
+ // Compose the full `KB: <raw>` line because the shared parser anchors on
1279
+ // the `KB:` prefix. Handles the legacy `none` / `<sentinel>` inputs naturally
1280
+ // because parseCiteLine's SENTINEL_RE matches the composed line.
1281
+ if (typeof raw !== "string") {
1282
+ return { cite_ids: [], cite_tags: [], cite_commitments: [] };
1283
+ }
1284
+ const composed = `KB: ${raw}`;
1285
+ if (citeLineParser && typeof citeLineParser.parseCiteLine === "function") {
1286
+ return citeLineParser.parseCiteLine(composed);
1287
+ }
1288
+ // Degraded fallback: lib missing (e.g. partial install). Emit empty result
1289
+ // so downstream consumers see the cite-line as unobservable rather than
1290
+ // mis-parsed. The Stop-hook contract is best-effort, never blocking.
1291
+ return { cite_ids: [], cite_tags: [], cite_commitments: [] };
1292
+ }
1293
+
1294
+ /**
1295
+ * v2.0.0-rc.20 TASK-03: detect which client surface invoked the hook so the
1296
+ * emitted assistant_turn_observed event can carry a `client` discriminator
1297
+ * without having to inspect the transcript shape.
1298
+ *
1299
+ * Resolution order (first match wins):
1300
+ * 1. `FABRIC_HINT_CLIENT` env var — explicit override, set by the per-
1301
+ * client install pipeline when the hook-config schema supports env
1302
+ * injection.
1303
+ * 2. Path heuristic against `__dirname` — `.claude/` → "cc", `.codex/` →
1304
+ * "codex". Covers the dominant deployment shape (hook script lives
1305
+ * under the client's per-repo dir).
1306
+ *
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.
1310
+ */
1311
+ function detectClient() {
1312
+ // 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.
1315
+ if (clientAdapter && typeof clientAdapter.detectClient === "function") {
1316
+ return clientAdapter.detectClient(__dirname);
1317
+ }
1318
+ // Fallback (adapter lib absent): env override only.
1319
+ const envClient = process.env.FABRIC_HINT_CLIENT;
1320
+ if (typeof envClient === "string" && envClient.length > 0) {
1321
+ const normalised = envClient.trim().toLowerCase();
1322
+ if (normalised === "cc" || normalised === "codex" || normalised === "cursor") {
1323
+ return normalised;
1324
+ }
1325
+ }
1326
+ return undefined;
1327
+ }
1328
+
1329
+ /**
1330
+ * v2.0.0-rc.20 TASK-03: emit one `assistant_turn_observed` event per
1331
+ * assistant envelope harvested from the transcript. Wrapped in try/catch
1332
+ * (best-effort, never throws — Stop hook MUST stay non-blocking on any
1333
+ * failure here). The event shape mirrors
1334
+ * assistantTurnObservedEventSchema in
1335
+ * packages/shared/src/schemas/event-ledger.ts (registered in rc.20 TASK-02).
1336
+ *
1337
+ * Call site sits immediately AFTER writeSessionDigestBestEffort so both
1338
+ * digest + per-turn events derive from the same transcript snapshot.
1339
+ *
1340
+ * `id` mirrors the server's convention (`event:<uuid>`) using
1341
+ * crypto.randomUUID when available — falls back to a timestamp+counter
1342
+ * tuple on older Node where randomUUID is missing (cjs hook tooling
1343
+ * defensively targets Node 18+, but the fallback keeps it event-shaped).
1344
+ */
1345
+ function extractAndWriteAssistantTurnsBestEffort(cwd, stdinPayload) {
1346
+ if (stdinPayload === null || typeof stdinPayload !== "object") return;
1347
+ try {
1348
+ const sessionId = stdinPayload.session_id;
1349
+ if (typeof sessionId !== "string" || sessionId.length === 0) return;
1350
+ const transcript = summarizeTranscript(stdinPayload.transcript_path);
1351
+ const turns = transcript.assistant_turns;
1352
+ if (!Array.isArray(turns) || turns.length === 0) return;
1353
+
1354
+ // Resolve event-ledger path. Caller already validated cwd shape.
1355
+ const fabricDir = join(cwd, FABRIC_DIR);
1356
+ if (!existsSync(fabricDir)) {
1357
+ // No .fabric/ → workspace is uninitialised. Silently skip; the digest
1358
+ // writer applies the same guard via its own internal check.
1359
+ return;
1360
+ }
1361
+ const ledgerPath = join(fabricDir, EVENT_LEDGER_FILE);
1362
+ const client = detectClient();
1363
+ let randomUUID;
1364
+ try {
1365
+ ({ randomUUID } = require("node:crypto"));
1366
+ } catch {
1367
+ randomUUID = null;
1368
+ }
1369
+
1370
+ // v2.0.0-rc.39 (P1 emit-fold): empty-shell turns (no KB: line, no cites)
1371
+ // do not get an events.jsonl line — they are tallied and folded into one
1372
+ // metrics.jsonl counter row at the end of this batch. This zeroes the 99%
1373
+ // empty-shell bloat at the source while keeping cite-bearing turns as
1374
+ // discrete audit events. Count carries per-Stop re-emission exactly (we
1375
+ // tally every empty turn the transcript presents, not just new ones), so
1376
+ // the reader-side counter merge reconstructs total_turns byte-for-byte.
1377
+ let emptyShellCount = 0;
1378
+ for (const turn of turns) {
1379
+ try {
1380
+ const citeIds = Array.isArray(turn.cite_ids) ? turn.cite_ids : [];
1381
+ const citeCommitments = Array.isArray(turn.cite_commitments)
1382
+ ? turn.cite_commitments
1383
+ : [];
1384
+ const isEmptyShell =
1385
+ (turn.kb_line_raw === null || turn.kb_line_raw === undefined) &&
1386
+ citeIds.length === 0 &&
1387
+ citeCommitments.length === 0;
1388
+ if (isEmptyShell) {
1389
+ emptyShellCount += 1;
1390
+ continue;
1391
+ }
1392
+ const idSuffix = typeof randomUUID === "function"
1393
+ ? randomUUID()
1394
+ : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
1395
+ const event = {
1396
+ kind: "fabric-event",
1397
+ id: `event:${idSuffix}`,
1398
+ ts: Date.now(),
1399
+ schema_version: 1,
1400
+ session_id: sessionId,
1401
+ event_type: EVENT_TYPE_ASSISTANT_TURN_OBSERVED,
1402
+ kb_line_raw: turn.kb_line_raw,
1403
+ cite_ids: citeIds,
1404
+ cite_tags: Array.isArray(turn.cite_tags) ? turn.cite_tags : [],
1405
+ // rc.24 TASK-04: cite_commitments parallel array (assistantTurn
1406
+ // ObservedEventSchema gained this slot in rc.24 TASK-01). Empty
1407
+ // array for legacy turns or when the parser lib is unavailable —
1408
+ // the schema defaults `.default([])` so omitting it would also be
1409
+ // valid, but emitting an explicit `[]` keeps the on-disk shape
1410
+ // uniform across rc.24+ events.
1411
+ cite_commitments: citeCommitments,
1412
+ turn_id: `${sessionId}-${turn.envelope_index}`,
1413
+ envelope_index: turn.envelope_index,
1414
+ timestamp: new Date().toISOString(),
1415
+ };
1416
+ if (client !== undefined) event.client = client;
1417
+ appendFileSync(ledgerPath, JSON.stringify(event) + "\n", "utf8");
1418
+ } catch {
1419
+ // Per-turn failure must not abort the remaining turns; the Stop hook
1420
+ // contract is "never block on hook failure". Best-effort continues.
1421
+ }
1422
+ }
1423
+
1424
+ // rc.39 emit-fold: write one metrics.jsonl counter row for the folded
1425
+ // empty-shell turns. Best-effort — a failure here must never block the
1426
+ // Stop hook (KT-DEC-0007). The counter key is namespaced by client so the
1427
+ // reader's per_client total_turns breakdown stays invariant; an undefined
1428
+ // client (adapter lib absent) folds into the bare `assistant_turn_observed`
1429
+ // key, mirroring how such turns omit the event-side `client` discriminator.
1430
+ if (emptyShellCount > 0) {
1431
+ try {
1432
+ const counterKey =
1433
+ client !== undefined
1434
+ ? `${EVENT_TYPE_ASSISTANT_TURN_OBSERVED}:${client}`
1435
+ : EVENT_TYPE_ASSISTANT_TURN_OBSERVED;
1436
+ const metricsRow = {
1437
+ timestamp: new Date().toISOString(),
1438
+ window: "stop",
1439
+ counters: { [counterKey]: emptyShellCount },
1440
+ };
1441
+ const metricsPath = join(fabricDir, METRICS_LEDGER_FILE);
1442
+ appendFileSync(metricsPath, JSON.stringify(metricsRow) + "\n", "utf8");
1443
+ } catch {
1444
+ // metrics fold is observability-only; never block the hook on failure.
1445
+ }
1446
+ }
1447
+ } catch {
1448
+ // Outer guard — never throw. Hook continues silently.
1449
+ }
1450
+ }
1451
+
930
1452
  /**
931
1453
  * v2.0.0-rc.7 T5: extract user_messages + edit_paths + 1-line title from the
932
1454
  * transcript JSONL referenced by the hook's stdin payload. Best-effort, never
@@ -935,9 +1457,18 @@ function tryReadStdinJson() {
935
1457
  * Claude Code's transcript_path points at a JSONL where each line is a
936
1458
  * message envelope. We sniff for `role: "user"` lines (text content) and
937
1459
  * for tool-use entries naming Edit / Write / MultiEdit to harvest file_path.
1460
+ *
1461
+ * v2.0.0-rc.20 TASK-03: additionally collects `assistant_turns[]` — one
1462
+ * entry per assistant envelope with the parsed KB-line cite metadata. Field
1463
+ * is additive; existing callers (writeSessionDigestBestEffort) ignore it.
938
1464
  */
939
1465
  function summarizeTranscript(transcriptPath) {
940
- const out = { user_messages: [], edit_paths: [], title: "" };
1466
+ // rc.20 TASK-03: additive `assistant_turns` array one entry per assistant
1467
+ // envelope, regardless of whether the first line matched KB:. Downstream
1468
+ // consumers (extractAndWriteAssistantTurnsBestEffort) emit one
1469
+ // assistant_turn_observed event per element; `kb_line_raw=null` when no
1470
+ // KB: line was found.
1471
+ const out = { user_messages: [], edit_paths: [], title: "", assistant_turns: [] };
941
1472
  if (typeof transcriptPath !== "string" || transcriptPath.length === 0) return out;
942
1473
  if (!existsSync(transcriptPath)) return out;
943
1474
  let raw;
@@ -947,6 +1478,7 @@ function summarizeTranscript(transcriptPath) {
947
1478
  return out;
948
1479
  }
949
1480
  const lines = raw.split(/\r?\n/);
1481
+ let envelopeIndex = -1;
950
1482
  for (const line of lines) {
951
1483
  const trimmed = line.trim();
952
1484
  if (trimmed.length === 0) continue;
@@ -957,12 +1489,23 @@ function summarizeTranscript(transcriptPath) {
957
1489
  continue;
958
1490
  }
959
1491
  if (envelope === null || typeof envelope !== "object") continue;
960
-
961
- // User text message — Claude Code shape: { role: "user", content: [...] }
962
- // OR nested under `message.role`. Be generous.
963
- const role = envelope.role || (envelope.message && envelope.message.role);
1492
+ envelopeIndex += 1;
1493
+
1494
+ // v2.0.0-rc.27 TASK-009 (audit §2.16): Codex CLI uses a different
1495
+ // envelope shape { type:"response_item", payload:{ type:"message",
1496
+ // role, content:[{type:"input_text"|"output_text", text}] } } — vs Claude
1497
+ // Code's { type:"user", message:{ role, content } }. Resolve role +
1498
+ // content from whichever shape is present; without this, every Codex
1499
+ // session's digest came out empty (audit §2.16 — fixed here).
1500
+ const role =
1501
+ envelope.role ||
1502
+ (envelope.message && envelope.message.role) ||
1503
+ (envelope.payload && envelope.payload.role);
964
1504
  if (role === "user") {
965
- const content = envelope.content || (envelope.message && envelope.message.content);
1505
+ const content =
1506
+ envelope.content ||
1507
+ (envelope.message && envelope.message.content) ||
1508
+ (envelope.payload && envelope.payload.content);
966
1509
  if (typeof content === "string") {
967
1510
  out.user_messages.push(content);
968
1511
  } else if (Array.isArray(content)) {
@@ -974,6 +1517,85 @@ function summarizeTranscript(transcriptPath) {
974
1517
  }
975
1518
  }
976
1519
 
1520
+ // rc.20 TASK-03: assistant envelope — capture first non-empty line of the
1521
+ // first text block and parse for `KB:` prefix. We push ONE assistant_turns
1522
+ // entry per assistant envelope (even when no KB: line) so downstream can
1523
+ // distinguish "turn observed, no KB" (kb_line_raw=null) from "no turn".
1524
+ if (role === "assistant") {
1525
+ const content =
1526
+ envelope.content ||
1527
+ (envelope.message && envelope.message.content) ||
1528
+ (envelope.payload && envelope.payload.content);
1529
+ let firstText = null;
1530
+ if (typeof content === "string") {
1531
+ firstText = content;
1532
+ } else if (Array.isArray(content)) {
1533
+ for (const block of content) {
1534
+ if (block && typeof block === "object" && block.type === "text" && typeof block.text === "string") {
1535
+ firstText = block.text;
1536
+ break;
1537
+ }
1538
+ }
1539
+ }
1540
+ let kbLineRaw = null;
1541
+ let citeIds = [];
1542
+ let citeTags = [];
1543
+ // rc.24 TASK-04: parallel `cite_commitments` array, populated by the
1544
+ // shared cite-line parser. One entry per non-sentinel cite (index-aligned
1545
+ // with cite_ids). Sentinel `KB: none` contributes a `cite_tags=["none"]`
1546
+ // entry but no commitment — matches the parseCiteLine index contract.
1547
+ let citeCommitments = [];
1548
+ // v2.0.0-rc.27 TASK-009: Codex assistant blocks carry text under
1549
+ // `type:"output_text"` (not `type:"text"`). Fall back when no text-typed
1550
+ // block matched but a typed output_text block exists.
1551
+ if (firstText === null && Array.isArray(content)) {
1552
+ for (const block of content) {
1553
+ if (block && typeof block === "object" && block.type === "output_text" && typeof block.text === "string") {
1554
+ firstText = block.text;
1555
+ break;
1556
+ }
1557
+ }
1558
+ }
1559
+ if (typeof firstText === "string" && firstText.length > 0) {
1560
+ // First non-empty line.
1561
+ const linesOfText = firstText.split(/\r?\n/);
1562
+ let firstNonEmpty = "";
1563
+ for (const l of linesOfText) {
1564
+ if (l.trim().length > 0) {
1565
+ firstNonEmpty = l.trim();
1566
+ break;
1567
+ }
1568
+ }
1569
+ if (firstNonEmpty.length > 0) {
1570
+ // rc.24 TASK-04: route the FULL `KB: ...` line to the shared parser.
1571
+ // parseCiteLine handles sentinels (`KB: none [<reason>]`) AND full
1572
+ // cite form including contract tail (`KB: KT-DEC-0001 [recalled] →
1573
+ // edit:foo.ts`) uniformly. The sentinel's `[<reason>]` tail stays in
1574
+ // `kb_line_raw` for doctor's downstream histogram parse; cite_tags
1575
+ // still emits the bare `none` token (schema enum-bound).
1576
+ if (/^KB:\s*/i.test(firstNonEmpty)) {
1577
+ kbLineRaw = firstNonEmpty;
1578
+ if (citeLineParser && typeof citeLineParser.parseCiteLine === "function") {
1579
+ const parsed = citeLineParser.parseCiteLine(firstNonEmpty);
1580
+ citeIds = parsed.cite_ids;
1581
+ citeTags = parsed.cite_tags;
1582
+ citeCommitments = parsed.cite_commitments;
1583
+ }
1584
+ // Degraded mode (lib missing) → keep kbLineRaw but emit empty
1585
+ // arrays; doctor downstream treats this as "turn observed, parse
1586
+ // unavailable" without crashing.
1587
+ }
1588
+ }
1589
+ }
1590
+ out.assistant_turns.push({
1591
+ envelope_index: envelopeIndex,
1592
+ kb_line_raw: kbLineRaw,
1593
+ cite_ids: citeIds,
1594
+ cite_tags: citeTags,
1595
+ cite_commitments: citeCommitments,
1596
+ });
1597
+ }
1598
+
977
1599
  // Tool use — look for Edit / Write / MultiEdit and harvest file_path.
978
1600
  const candidates = [];
979
1601
  if (envelope.type === "tool_use") candidates.push(envelope);
@@ -999,6 +1621,27 @@ function summarizeTranscript(transcriptPath) {
999
1621
  }
1000
1622
  }
1001
1623
  }
1624
+
1625
+ // v2.0.0-rc.27 TASK-009 (audit §2.16): Codex apply_patch path. Codex
1626
+ // emits one response_item envelope per file-edit invocation with payload
1627
+ // shape { type:"custom_tool_call", name:"apply_patch", input:<patch
1628
+ // string> }. The patch body lists target files via `*** Update File:`,
1629
+ // `*** Add File:`, `*** Delete File:` directives — harvest those.
1630
+ if (
1631
+ envelope.type === "response_item" &&
1632
+ envelope.payload &&
1633
+ envelope.payload.type === "custom_tool_call" &&
1634
+ envelope.payload.name === "apply_patch" &&
1635
+ typeof envelope.payload.input === "string"
1636
+ ) {
1637
+ const patchInput = envelope.payload.input;
1638
+ const fileDirectiveRe = /^\*\*\*\s+(?:Update|Add|Delete)\s+File:\s+(.+?)\s*$/gm;
1639
+ let m;
1640
+ while ((m = fileDirectiveRe.exec(patchInput)) !== null) {
1641
+ const fp = m[1].trim();
1642
+ if (fp.length > 0) out.edit_paths.push(fp);
1643
+ }
1644
+ }
1002
1645
  }
1003
1646
  // 1-line title = first non-empty user message (trimmed). Falls back to "".
1004
1647
  if (out.user_messages.length > 0) {
@@ -1015,6 +1658,50 @@ function summarizeTranscript(transcriptPath) {
1015
1658
  return out;
1016
1659
  }
1017
1660
 
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
+
1018
1705
  /**
1019
1706
  * v2.0.0-rc.7 T5: writeSessionDigestBestEffort — non-blocking digest fan-out.
1020
1707
  * Called from main() before the existing decide() flow. Failure is silently
@@ -1060,6 +1747,23 @@ function main(env, stdio) {
1060
1747
  ? env.stdin_payload
1061
1748
  : tryReadStdinJson();
1062
1749
  writeSessionDigestBestEffort(cwd, stdinPayload);
1750
+ // v2.0.0-rc.20 TASK-03: per-turn cite-policy observation events. Same
1751
+ // best-effort contract as the digest writer — never throws, never blocks
1752
+ // the Stop hook on failure. Shares the transcript snapshot read by
1753
+ // writeSessionDigestBestEffort (each call re-reads independently; the
1754
+ // transcript file is small in practice and re-parse cost is dwarfed by
1755
+ // the hook's other I/O).
1756
+ extractAndWriteAssistantTurnsBestEffort(cwd, stdinPayload);
1757
+
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
+ );
1063
1767
 
1064
1768
  const events = readLedger(cwd);
1065
1769
  let pendingStats;
@@ -1108,6 +1812,19 @@ function main(env, stdio) {
1108
1812
  // rc.7 T7: read the externalized thresholds and pass them into decide.
1109
1813
  // Reader failures degrade silently to documented defaults — fabric-hint
1110
1814
  // must never block on config errors (see hook contract above).
1815
+ //
1816
+ // rc.16 TASK-002 (F2-apply): resolve `fabric_language` ONCE per main()
1817
+ // invocation via the banner-i18n lib. The result threads through
1818
+ // `thresholds.variant` into both decide() and evaluateMaintenanceSignal()
1819
+ // so we read the config file at most once, not five times. Lib reader
1820
+ // is never-throw; defensive try/catch is belt-and-suspenders.
1821
+ let variant = "zh-CN";
1822
+ try {
1823
+ variant = readFabricLanguage(cwd);
1824
+ } catch {
1825
+ variant = "zh-CN";
1826
+ }
1827
+
1111
1828
  let thresholds;
1112
1829
  try {
1113
1830
  thresholds = {
@@ -1116,6 +1833,7 @@ function main(env, stdio) {
1116
1833
  reviewHintPendingAgeDays: readReviewHintPendingAgeDays(cwd),
1117
1834
  maintenanceHintDays: readMaintenanceHintDays(cwd),
1118
1835
  maintenanceHintCooldownDays: readMaintenanceHintCooldownDays(cwd),
1836
+ variant,
1119
1837
  };
1120
1838
  } catch {
1121
1839
  thresholds = {
@@ -1124,6 +1842,7 @@ function main(env, stdio) {
1124
1842
  reviewHintPendingAgeDays: DEFAULT_REVIEW_HINT_PENDING_AGE_DAYS,
1125
1843
  maintenanceHintDays: DEFAULT_MAINTENANCE_HINT_DAYS,
1126
1844
  maintenanceHintCooldownDays: DEFAULT_MAINTENANCE_HINT_COOLDOWN_DAYS,
1845
+ variant,
1127
1846
  };
1128
1847
  }
1129
1848
 
@@ -1147,28 +1866,29 @@ function main(env, stdio) {
1147
1866
  activityOverview = "";
1148
1867
  }
1149
1868
 
1150
- // rc.7 T1: sentinel-priority pickup. The `.fabric/.import-requested`
1151
- // file is the cross-surface signal from `fabric init` Y-confirm. When
1152
- // present, the Stop hook emits a Signal C "import" result regardless of
1153
- // underseed thresholds, cooldown sidecar state, or precedence with
1154
- // other signals. This branch sits BEFORE decide() so the import
1155
- // recommendation always wins until the fabric-import Skill clears the
1156
- // sentinel in its Phase 3.4. Cooldown sidecar IS bypassed (the
1157
- // recommendation surface area is intentionally aggressive — the user
1158
- // explicitly asked for it at init time).
1159
- const sentinelPresent = isImportRequestedSentinelPresent(cwd);
1160
-
1161
- let result = sentinelPresent
1162
- ? makeImportSentinelResult()
1163
- : decide(
1164
- events,
1165
- now,
1166
- pendingStats,
1167
- underseedStats,
1168
- editCounterStats,
1169
- thresholds,
1170
- { activityOverview },
1171
- );
1869
+ // v2.0.0-rc.8 (TASK-002): probe `.fabric/.import-state.json` to
1870
+ // determine whether a fabric-import skill run is currently in flight.
1871
+ // Threaded into decide() so Signal B (review hint) is suppressed for
1872
+ // the duration of an active import preventing the Stop hook from
1873
+ // interrupting the import when its pending pile crosses the review
1874
+ // threshold. See isImportInFlight() docstring for the full truth table.
1875
+ let importInFlight = false;
1876
+ try {
1877
+ importInFlight = isImportInFlight(cwd, now);
1878
+ } catch {
1879
+ importInFlight = false;
1880
+ }
1881
+
1882
+ let result = decide(
1883
+ events,
1884
+ now,
1885
+ pendingStats,
1886
+ underseedStats,
1887
+ editCounterStats,
1888
+ thresholds,
1889
+ { activityOverview },
1890
+ importInFlight,
1891
+ );
1172
1892
 
1173
1893
  // v2.0.0-rc.7 T10: Signal D — maintenance hint. Evaluated AFTER A/B/C
1174
1894
  // because the existing three signals carry higher urgency (in-flight
@@ -1193,6 +1913,40 @@ function main(env, stdio) {
1193
1913
 
1194
1914
  if (result === null) return;
1195
1915
 
1916
+ // v2.0.0-rc.37 NEW-16: per-signal dismiss. A chosen signal whose type the
1917
+ // user dismissed (config-durable or session sidecar) exits silently —
1918
+ // same shape as a cooldown hit. Covers BOTH maintenance and A/B/C paths.
1919
+ const sessionId =
1920
+ stdinPayload && typeof stdinPayload.session_id === "string"
1921
+ ? stdinPayload.session_id
1922
+ : null;
1923
+ if (readDismissedSignals(cwd, sessionId).has(result.signal)) {
1924
+ return;
1925
+ }
1926
+ // Append the bilingual dismiss-option line so the lever is discoverable.
1927
+ if (typeof result.reason === "string") {
1928
+ result.reason = `${result.reason}\n${renderDismissOption(result.signal, variant)}`;
1929
+ }
1930
+
1931
+ // v2.1.0-rc.1 P4 (F4/S63): surface the read-set stores on the Stop hint so
1932
+ // backlog/maintenance nudges are read per-store, not as one undifferentiated
1933
+ // pile. Best-effort; missing snapshot / single-store omits the line.
1934
+ if (bindingsSnapshotReader !== null && typeof result.reason === "string") {
1935
+ try {
1936
+ const projectId = readProjectId(cwd);
1937
+ if (projectId) {
1938
+ const label = bindingsSnapshotReader.formatStoreLabels(
1939
+ bindingsSnapshotReader.readBindingsSnapshot(projectId),
1940
+ );
1941
+ if (label) {
1942
+ result.reason = `${result.reason}\n${label}`;
1943
+ }
1944
+ }
1945
+ } catch {
1946
+ // store label is decorative provenance — never crash the hook
1947
+ }
1948
+ }
1949
+
1196
1950
  // v2.0.0-rc.7 T10: Signal D uses its own cooldown sidecar (day-based,
1197
1951
  // see MAINTENANCE_HINT_LAST_EMIT_FILE). The A/B/C shared cooldown cache
1198
1952
  // uses hours, so we branch here to avoid mixing semantics.
@@ -1202,24 +1956,19 @@ function main(env, stdio) {
1202
1956
  return;
1203
1957
  }
1204
1958
 
1205
- // rc.7 T1: sentinel-driven results bypass the cooldown sidecar entirely.
1206
- // The user explicitly asked at init time for the import recommendation
1207
- // to surface; the cooldown is a noise-throttle for organic signals,
1208
- // not for explicit user-driven hand-offs. We also do NOT bump the
1209
- // cooldown cache when the sentinel fires — that would silence the
1210
- // *next* organic Signal C unnecessarily.
1211
- if (sentinelPresent) {
1212
- out.write(JSON.stringify(result));
1213
- return;
1214
- }
1215
-
1216
1959
  // Cooldown throttle: once a signal fires, stay silent for
1217
1960
  // archive_hint_cooldown_hours (default 12h) regardless of state drift.
1218
1961
  // Pure reminder-noise reduction; the underlying trigger logic is unchanged.
1219
1962
  const cooldownMs = readCooldownHours(cwd) * MS_PER_HOUR;
1220
1963
  const cache = readShownCache(cwd);
1221
1964
  const lastShown = cache[result.signal];
1222
- if (typeof lastShown === "number" && nowMs - lastShown < cooldownMs) {
1965
+ // rc.34 TASK-01 + review-fix (Gemini P1): future-stamped lastShown
1966
+ // (backward clock skew) bypasses cooldown — sidecar treated as expired.
1967
+ if (
1968
+ typeof lastShown === "number" &&
1969
+ nowMs >= lastShown &&
1970
+ nowMs - lastShown < cooldownMs
1971
+ ) {
1223
1972
  return; // Still in cooldown — silent.
1224
1973
  }
1225
1974
 
@@ -1240,13 +1989,20 @@ module.exports = {
1240
1989
  // rc.7 T4: top-edited-directories aggregator + banner overview formatter.
1241
1990
  getTopEditedDirectories,
1242
1991
  formatActivityOverview,
1243
- // rc.7 T1: cross-surface sentinel pickup helpers (exported for testing).
1244
- isImportRequestedSentinelPresent,
1245
- makeImportSentinelResult,
1992
+ // v2.0.0-rc.8 (TASK-002): in-flight import gate for Signal B (exported
1993
+ // for unit testing of the truth table).
1994
+ isImportInFlight,
1246
1995
  decide,
1247
1996
  readCooldownHours,
1248
1997
  readUnderseedThreshold,
1249
1998
  readArchiveEditThreshold,
1999
+ // v2.0.0-rc.37 NEW-16: per-signal dismiss helpers (exported for tests +
2000
+ // the agent-driven session-dismiss write path).
2001
+ readDismissedSignals,
2002
+ writeSessionDismiss,
2003
+ sessionDismissFileName,
2004
+ renderDismissOption,
2005
+ DISMISSABLE_SIGNALS,
1250
2006
  // v2.0.0-rc.7 T5: session digest helpers (exported for unit testing).
1251
2007
  tryReadStdinJson,
1252
2008
  summarizeTranscript,
@@ -1264,9 +2020,20 @@ module.exports = {
1264
2020
  readMaintenanceHintCooldownDays,
1265
2021
  readShownCache,
1266
2022
  writeShownCache,
2023
+ // v2.0.0-rc.20 TASK-03 / TASK-09: cite-policy parsing + per-turn emission
2024
+ // helpers (exported for unit testing of the parse + emit contract).
2025
+ parseKbLine,
2026
+ detectClient,
2027
+ 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,
1267
2032
  CONSTANTS: {
1268
2033
  FABRIC_DIR,
1269
2034
  EVENT_LEDGER_FILE,
2035
+ METRICS_LEDGER_FILE,
2036
+ EVENT_TYPE_ASSISTANT_TURN_OBSERVED,
1270
2037
  EVENT_TYPE_PROPOSED,
1271
2038
  EVENT_TYPE_INIT_SCAN_COMPLETED,
1272
2039
  // rc.7 T7: legacy aliases kept for back-compat with the existing test
@@ -1296,8 +2063,9 @@ module.exports = {
1296
2063
  EVENT_TYPE_DOCTOR_RUN,
1297
2064
  MAINTENANCE_HINT_LAST_EMIT_FILE,
1298
2065
  MAINTENANCE_HINT_MIN_CANONICAL,
1299
- // rc.7 T1: cross-surface sentinel for `fabric init` → import-skill hand-off.
1300
- IMPORT_REQUESTED_SENTINEL_FILE,
2066
+ // v2.0.0-rc.8 (TASK-002): in-flight import gate for Signal B.
2067
+ IMPORT_STATE_FILE_REL,
2068
+ IMPORT_IN_FLIGHT_MAX_AGE_HOURS,
1301
2069
  },
1302
2070
  };
1303
2071