@fenglimg/fabric-cli 2.0.0 → 2.0.1

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 (73) 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-D25XJ4BC.js +880 -0
  6. package/dist/chunk-MF3OTILQ.js +544 -0
  7. package/dist/chunk-PWLW3B57.js +18 -0
  8. package/dist/config-XJIPZNUP.js +13 -0
  9. package/dist/doctor-EJDSEJSS.js +810 -0
  10. package/dist/index.js +15 -8
  11. package/dist/{init-BIRSIOXO.js → install-EKWMFLUU.js} +622 -711
  12. package/dist/metrics-ACEQFPDU.js +122 -0
  13. package/dist/onboard-coverage-MFCAEBDO.js +220 -0
  14. package/dist/{plan-context-hint-QMUPAXIB.js → plan-context-hint-FC6P3WFE.js} +34 -28
  15. package/dist/uninstall-MH7ZIB6M.js +1064 -0
  16. package/package.json +30 -5
  17. package/templates/hooks/cite-policy-evict.cjs +231 -0
  18. package/templates/hooks/configs/README.md +29 -6
  19. package/templates/hooks/configs/claude-code.json +14 -3
  20. package/templates/hooks/configs/codex-hooks.json +6 -3
  21. package/templates/hooks/configs/cursor-hooks.json +8 -10
  22. package/templates/hooks/fabric-hint.cjs +833 -105
  23. package/templates/hooks/knowledge-hint-broad.cjs +509 -135
  24. package/templates/hooks/knowledge-hint-narrow.cjs +791 -26
  25. package/templates/hooks/lib/banner-i18n.cjs +309 -0
  26. package/templates/hooks/lib/cite-contract-reminder.cjs +173 -0
  27. package/templates/hooks/lib/cite-line-parser.cjs +158 -0
  28. package/templates/hooks/lib/client-adapter.cjs +106 -0
  29. package/templates/hooks/lib/config-cache.cjs +107 -0
  30. package/templates/hooks/lib/state-store.cjs +84 -0
  31. package/templates/hooks/lib/summary-fallback.cjs +210 -0
  32. package/templates/skills/fabric-archive/SKILL.md +93 -419
  33. package/templates/skills/fabric-archive/ref/dry-run-scope.md +16 -0
  34. package/templates/skills/fabric-archive/ref/e5-cron-recap.md +58 -0
  35. package/templates/skills/fabric-archive/ref/i18n-policy.md +86 -0
  36. package/templates/skills/fabric-archive/ref/phase-0-range-resolution.md +156 -0
  37. package/templates/skills/fabric-archive/ref/phase-1-5-onboard.md +218 -0
  38. package/templates/skills/fabric-archive/ref/phase-1-cross-session.md +62 -0
  39. package/templates/skills/fabric-archive/ref/phase-2-5-viability.md +68 -0
  40. package/templates/skills/fabric-archive/ref/phase-3-5-scope.md +108 -0
  41. package/templates/skills/fabric-archive/ref/phase-3-classify.md +63 -0
  42. package/templates/skills/fabric-archive/ref/phase-4-5-emit.md +78 -0
  43. package/templates/skills/fabric-archive/ref/phase-4-mcp-persist.md +89 -0
  44. package/templates/skills/fabric-archive/ref/rc-history.md +38 -0
  45. package/templates/skills/fabric-archive/ref/worked-examples.md +78 -0
  46. package/templates/skills/fabric-import/SKILL.md +75 -516
  47. package/templates/skills/fabric-import/ref/checkpoint-state.md +85 -0
  48. package/templates/skills/fabric-import/ref/i18n-policy.md +79 -0
  49. package/templates/skills/fabric-import/ref/output-contract.md +61 -0
  50. package/templates/skills/fabric-import/ref/phase-2-mining.md +213 -0
  51. package/templates/skills/fabric-import/ref/phase-3-dedup.md +75 -0
  52. package/templates/skills/fabric-import/ref/state-recovery.md +57 -0
  53. package/templates/skills/fabric-import/ref/worked-examples.md +127 -0
  54. package/templates/skills/fabric-review/SKILL.md +86 -284
  55. package/templates/skills/fabric-review/ref/askuserquestion-policy.md +66 -0
  56. package/templates/skills/fabric-review/ref/i18n-policy.md +111 -0
  57. package/templates/skills/fabric-review/ref/modify-flow.md +103 -0
  58. package/templates/skills/fabric-review/ref/output-contract.md +58 -0
  59. package/templates/skills/fabric-review/ref/per-mode-flows.md +155 -0
  60. package/templates/skills/fabric-review/ref/semantic-check.md +26 -0
  61. package/templates/skills/fabric-review/ref/worked-examples.md +95 -0
  62. package/templates/skills/lib/shared-policy.md +69 -0
  63. package/dist/chunk-6ICJICVU.js +0 -10
  64. package/dist/chunk-74SZWYPH.js +0 -658
  65. package/dist/chunk-EYIDD2YS.js +0 -1000
  66. package/dist/doctor-T7JWODKG.js +0 -282
  67. package/dist/hooks-Y74Y5LQS.js +0 -12
  68. package/dist/scan-LMK3UCWL.js +0 -22
  69. package/dist/serve-H554BHLG.js +0 -124
  70. package/templates/agents-md/AGENTS.md.template +0 -59
  71. package/templates/bootstrap/CLAUDE.md +0 -8
  72. package/templates/bootstrap/codex-AGENTS-header.md +0 -6
  73. 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,98 @@ 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
+
16
84
  // CONSTANTS — duplicated from packages/server/src/services/_shared.ts.
17
85
  // DRY violation accepted: this hook script runs in user repos WITHOUT
18
86
  // node_modules access, so it cannot import from @fenglimg/fabric-server.
19
87
  const FABRIC_DIR = ".fabric";
20
88
  const EVENT_LEDGER_FILE = "events.jsonl";
89
+ // v2.0.0-rc.39 (P1 emit-fold): high-frequency empty-shell assistant_turn_observed
90
+ // turns (kb_line_raw=null AND no cite_ids AND no cite_commitments) carry zero
91
+ // cite-audit signal, so emitting one events.jsonl line each is pure bloat. They
92
+ // are folded at the emit source into a single per-Stop metrics.jsonl counter row
93
+ // `{ counters: { assistant_turn_observed[:<client>]: N } }`. The cite-coverage /
94
+ // emit-cadence readers add this counter back into total_turns so the metric is
95
+ // byte-for-byte invariant (the fold preserves count semantics, incl. the legacy
96
+ // per-Stop re-emission, exactly). Mirrors packages/server/src/services/metrics.ts
97
+ // row shape; written directly (the .cjs hook cannot import the TS service).
98
+ const METRICS_LEDGER_FILE = "metrics.jsonl";
21
99
  const EVENT_TYPE_PROPOSED = "knowledge_proposed";
22
100
  const EVENT_TYPE_INIT_SCAN_COMPLETED = "init_scan_completed";
23
101
  // v2.0.0-rc.7 T10: doctor_run event drives Signal D (maintenance hint).
24
102
  const EVENT_TYPE_DOCTOR_RUN = "doctor_run";
103
+ // v2.0.0-rc.20 TASK-03: per-turn cite-policy observation event. Emitted by
104
+ // extractAndWriteAssistantTurnsBestEffort() after the Stop hook parses each
105
+ // assistant envelope's first non-empty line for a `KB:` prefix. Schema
106
+ // registered in packages/shared/src/schemas/event-ledger.ts (rc.20 TASK-02).
107
+ const EVENT_TYPE_ASSISTANT_TURN_OBSERVED = "assistant_turn_observed";
25
108
  // rc.6 TASK-022 (E5): Signal A is now `24h OR N-edits since last
26
109
  // knowledge_proposed`. The edit-count branch reads
27
110
  // `.fabric/.cache/edit-counter` (one ISO-8601 line per PreToolUse fire,
@@ -98,14 +181,20 @@ const MAINTENANCE_HINT_LAST_EMIT_FILE = ".fabric/.cache/maintenance-hint-last-em
98
181
  // there's barely anything TO lint.
99
182
  const MAINTENANCE_HINT_MIN_CANONICAL = 5;
100
183
 
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");
184
+ // v2.0.0-rc.8 (TASK-002): in-flight import gate for Signal B.
185
+ // fabric-import skill writes `.fabric/.import-state.json` checkpoints after
186
+ // every successful sub-step (P1/P2/P3 see fabric-import/SKILL.md). The
187
+ // Stop hook reads this file as a soft signal to know that an import is
188
+ // mid-run, so we can silence Signal B (review hint at pending count >= 10)
189
+ // to avoid interrupting the import while it accumulates pending entries.
190
+ //
191
+ // Gate is intentionally narrow: ONLY Signal B is suppressed. Signals A
192
+ // (archive), C (import recommendation), D (maintenance) retain their
193
+ // pre-existing behaviour byte-for-byte. The 24h TTL on `last_checkpoint_at`
194
+ // guards against stale state files that would otherwise permanently
195
+ // silence Signal B if a user abandoned an import without completing.
196
+ const IMPORT_STATE_FILE_REL = join(".fabric", ".import-state.json");
197
+ const IMPORT_IN_FLIGHT_MAX_AGE_HOURS = 24;
109
198
 
110
199
  /**
111
200
  * Read the events.jsonl ledger from <projectRoot>/.fabric/events.jsonl.
@@ -296,35 +385,62 @@ function countEditsSince(projectRoot, anchorTs) {
296
385
  }
297
386
 
298
387
  /**
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.
388
+ * v2.0.0-rc.8 (TASK-002): detect whether a fabric-import skill run is
389
+ * currently in flight, used to gate Signal B (review hint) so the Stop
390
+ * hook does not interrupt an active import when its pending pile crosses
391
+ * the review threshold.
392
+ *
393
+ * Truth table — returns false (i.e. NOT in flight, do not gate) on:
394
+ * - `.fabric/.import-state.json` missing (no import has ever started or
395
+ * state file was deleted)
396
+ * - JSON.parse failure (malformed state file — never-block invariant
397
+ * forbids permanently silencing Signal B due to corruption)
398
+ * - `phase === "complete"` (import finished — see fabric-import SKILL.md
399
+ * Phase 3.4)
400
+ * - `last_checkpoint_at` missing OR older than IMPORT_IN_FLIGHT_MAX_AGE_HOURS
401
+ * (stale state — user likely abandoned the import; do not let a forever
402
+ * orphaned state file silence Signal B forever)
403
+ * - any unexpected throw (defensive — never-block invariant)
404
+ *
405
+ * Returns true ONLY when state file exists, parses, has a non-"complete"
406
+ * phase, and a fresh `last_checkpoint_at` (< 24h ago). Field names
407
+ * (`phase`, `last_checkpoint_at`) verified against fabric-import SKILL.md
408
+ * § Checkpoint Logic.
409
+ *
410
+ * `now` is optional — defaults to `new Date()`. Tests can inject a fixed
411
+ * Date for determinism; production callers may omit it.
302
412
  */
303
- function isImportRequestedSentinelPresent(projectRoot) {
413
+ function isImportInFlight(projectRoot, now) {
304
414
  try {
305
- return existsSync(join(projectRoot, IMPORT_REQUESTED_SENTINEL_FILE));
415
+ const p = join(projectRoot, IMPORT_STATE_FILE_REL);
416
+ if (!existsSync(p)) return false;
417
+ let raw;
418
+ try {
419
+ raw = readFileSync(p, "utf8");
420
+ } catch {
421
+ return false;
422
+ }
423
+ let parsed;
424
+ try {
425
+ parsed = JSON.parse(raw);
426
+ } catch {
427
+ return false;
428
+ }
429
+ if (parsed === null || typeof parsed !== "object") return false;
430
+ if (parsed.phase === "complete") return false;
431
+ const ts = parsed.last_checkpoint_at;
432
+ if (typeof ts !== "string" || ts.length === 0) return false;
433
+ const ms = Date.parse(ts);
434
+ if (!Number.isFinite(ms)) return false;
435
+ const nowMs = now instanceof Date ? now.getTime() : Number(now) || Date.now();
436
+ const ageHours = (nowMs - ms) / MS_PER_HOUR;
437
+ if (ageHours > IMPORT_IN_FLIGHT_MAX_AGE_HOURS) return false;
438
+ return true;
306
439
  } catch {
307
440
  return false;
308
441
  }
309
442
  }
310
443
 
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
444
  /**
329
445
  * rc.7 T4: read the edit-counter sidecar and return the top-N most-edited
330
446
  * directories (grouped by the leading 2 path segments) since `anchorTs`.
@@ -397,14 +513,49 @@ function getTopEditedDirectories(projectRoot, topN, anchorTs) {
397
513
  // any leading "./". POSIX-style only — the hook ships under POSIX
398
514
  // path conventions even on Windows (the project doesn't currently
399
515
  // ship a CRLF/backslash test matrix for the sidecar).
400
- const norm = p.replace(/\\/g, "/").replace(/^\.\//, "");
516
+ //
517
+ // v2.0.0-rc.27 TASK-005 (audit §2.8 leak surface): absolute paths
518
+ // already accumulated in legacy sidecars start with `/`. We strip
519
+ // the leading slash and also reject buckets that resolve to user-home
520
+ // segments (`Users/<name>/...`, `home/<name>/...`) so historical
521
+ // pollution from absolute-path writes doesn't surface the user's
522
+ // $HOME in the archive banner. The rc.27 appendEditCounter no longer
523
+ // writes such paths, but the sidecar is append-only so old lines
524
+ // persist until rotation.
525
+ let norm = p.replace(/\\/g, "/").replace(/^\.\//, "");
526
+ // Strip leading `/` so a stale absolute entry doesn't generate a leak.
527
+ while (norm.startsWith("/")) norm = norm.slice(1);
401
528
  const segs = norm.split("/").filter((s) => s.length > 0);
529
+ // Reject any bucket whose top segments look like a host-system home
530
+ // prefix. The pattern is `<top>/<user>/...` where top ∈ Users|home|root.
531
+ // This silently drops legacy absolute-path entries from $HOME without
532
+ // mangling the buckets for legitimate project-relative `Users/...`
533
+ // (unlikely but possible) — the heuristic favours $HOME leak prevention
534
+ // over false-positive bucketing of project paths named after Unix
535
+ // conventions.
536
+ if (segs.length >= 2 && (segs[0] === "Users" || segs[0] === "home" || segs[0] === "root")) {
537
+ continue;
538
+ }
539
+ // v2.0.0-rc.27 TASK-005 (audit §2.8 file-as-dir): when segs[1] looks
540
+ // like a file (contains a dot-extension at the end), surface segs[0]
541
+ // alone instead of `segs[0]/segs[1]/` — a 2-seg path of the form
542
+ // `assets/foo.ts` would otherwise render as "assets/foo.ts/" which
543
+ // misleads the operator about whether they're seeing a file or a
544
+ // directory. The extension regex is permissive: any `.X` where X is
545
+ // 1-8 alphanumerics counts. README.md / package.json / foo.ts all
546
+ // match; "v1.2" or "dotted.module" do too — acceptable false-positive
547
+ // rate, since the worst outcome is over-aggregation to the parent.
548
+ const looksLikeFile = (segment) => /\.[A-Za-z0-9]{1,8}$/u.test(segment);
402
549
  let bucket;
403
550
  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]}/`;
551
+ if (looksLikeFile(segs[1])) {
552
+ bucket = `${segs[0]}/`;
553
+ } else {
554
+ // Leading 2 segments: "packages/cli", "docs/decisions", etc. We
555
+ // trail with "/" so the banner reads "packages/cli/" — clearly a
556
+ // directory rather than a file basename.
557
+ bucket = `${segs[0]}/${segs[1]}/`;
558
+ }
408
559
  } else if (segs.length === 1) {
409
560
  // Single segment — treat the basename as its own bucket. Bare
410
561
  // root-level files (README.md, package.json) get some signal too.
@@ -500,7 +651,7 @@ function readArchiveEditThreshold(projectRoot) {
500
651
  // without touching the filesystem. Omitting the arg falls back to documented
501
652
  // defaults so existing in-process callers (tests that pre-date T7) still
502
653
  // pass without modification — they implicitly exercise the default path.
503
- function decide(events, now, pendingStats, underseedStats, editCounterStats, thresholds, banner) {
654
+ function decide(events, now, pendingStats, underseedStats, editCounterStats, thresholds, banner, importInFlight) {
504
655
  const nowMs = now instanceof Date ? now.getTime() : Number(now) || Date.now();
505
656
  const stats = pendingStats || { count: 0, oldestAgeMs: null };
506
657
  const underseed =
@@ -523,6 +674,10 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
523
674
  typeof cfg.reviewHintPendingAgeDays === "number" && cfg.reviewHintPendingAgeDays > 0
524
675
  ? cfg.reviewHintPendingAgeDays
525
676
  : DEFAULT_REVIEW_HINT_PENDING_AGE_DAYS;
677
+ // rc.16 TASK-002: banner variant for the i18n lib. Defaults to 'zh-CN' so
678
+ // existing test callers (which never pass thresholds.variant) get the rc.15
679
+ // byte-identical Chinese output. main() always supplies the resolved variant.
680
+ const variant = typeof cfg.variant === "string" ? cfg.variant : "zh-CN";
526
681
 
527
682
  // ---- Archive signal (rc.6 TASK-022 — Signal A, 24h-OR-N-edits) -----------
528
683
  // Locate the most-recent knowledge_proposed event. If none exists, Signal A
@@ -569,23 +724,38 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
569
724
  // - "<editCount> 次编辑"
570
725
  // - "阈值 <N>"
571
726
  // - "fabric-archive"
727
+ // v2.0.0-rc.27 TASK-005 (audit §2.17): parts now assembled per-variant
728
+ // via banner-i18n's archivePartsHours / archivePartsEdits so en mode
729
+ // gets fully-English fragments instead of mixed-language output. zh-CN
730
+ // / zh-CN-hybrid still render the original substring contract verbatim.
572
731
  const parts = [];
573
732
  if (triggerByHours) {
574
- parts.push(`已过 ${hoursElapsed.toFixed(1)}h(阈值 ${archiveHintHours}h)`);
733
+ parts.push(
734
+ renderBanner("archivePartsHours", variant, {
735
+ hoursFixed: hoursElapsed.toFixed(1),
736
+ threshold: archiveHintHours,
737
+ }),
738
+ );
575
739
  }
576
740
  if (triggerByEdits) {
577
741
  parts.push(
578
- `累计 ${editStats.editsSinceLastProposed} 次编辑(阈值 ${editStats.threshold})`,
742
+ renderBanner("archivePartsEdits", variant, {
743
+ count: editStats.editsSinceLastProposed,
744
+ threshold: editStats.threshold,
745
+ }),
579
746
  );
580
747
  }
581
- const line1 = `📋 Fabric: 距上次归档 ${parts.join(" / ")}。`;
748
+ // rc.16 TASK-002: 5-banner i18n via lib/banner-i18n.cjs. Substring
749
+ // contracts ('25.0h', '阈值 N', 'fabric-archive') preserved by the lib's
750
+ // zh-CN templates — see lib header for the full contract.
751
+ const line1 = renderBanner("archiveLine1", variant, { parts: parts.join(" / ") });
582
752
  const activity = banner && typeof banner.activityOverview === "string"
583
753
  ? banner.activityOverview
584
754
  : "";
585
755
  const line2 = activity.length > 0
586
- ? ` 最近活动集中在: ${activity}。`
756
+ ? renderBanner("archiveActivity", variant, { activity })
587
757
  : "";
588
- const line3 = " 是否调 /fabric-archive 检查值得归档的决策/踩坑/复用?";
758
+ const line3 = renderBanner("archiveCta", variant, {});
589
759
  const reason = [line1, line2, line3].filter((l) => l.length > 0).join("\n");
590
760
  return {
591
761
  decision: "block",
@@ -600,7 +770,13 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
600
770
  const triggerByPendingAge =
601
771
  stats.oldestAgeMs !== null && stats.oldestAgeMs / MS_PER_DAY >= reviewHintPendingAgeDays;
602
772
 
603
- if (triggerByPendingCount || triggerByPendingAge) {
773
+ // v2.0.0-rc.8 (TASK-002): suppress ONLY Signal B while a fabric-import
774
+ // skill run is in flight (read from .fabric/.import-state.json by main()
775
+ // and threaded in as `importInFlight`). Signals A, C, D are unaffected.
776
+ // We fall through to Signal C evaluation rather than returning null —
777
+ // review backlog should not pre-empt import-recommendation evaluation
778
+ // when import is mid-run.
779
+ if ((triggerByPendingCount || triggerByPendingAge) && importInFlight !== true) {
604
780
  // rc.7 T4: 人-first banner reformat for Signal B. Keeps the pending
605
781
  // count and age substrings (`${count} 条`, `${days} 天`) so existing
606
782
  // tests pass; drops the Agent-jussive "建议调用 ... skill ..." for a
@@ -609,8 +785,13 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
609
785
  stats.oldestAgeMs !== null
610
786
  ? ` / 最早一条 ${(stats.oldestAgeMs / MS_PER_DAY).toFixed(1)} 天前`
611
787
  : "";
612
- const line1 = `📋 Fabric: 已积累 ${stats.count} 条待审核知识${ageSuffix}。`;
613
- const line2 = " 是否调 /fabric-review 审核 pending/ 条目?";
788
+ // rc.16 TASK-002: i18n via lib. Substrings ('${count} 条', 'fabric-review')
789
+ // preserved by the lib's zh-CN templates.
790
+ const line1 = renderBanner("reviewLine1", variant, {
791
+ count: stats.count,
792
+ ageSuffix,
793
+ });
794
+ const line2 = renderBanner("reviewCta", variant, {});
614
795
  const reason = `${line1}\n${line2}`;
615
796
  return {
616
797
  decision: "block",
@@ -651,12 +832,16 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
651
832
  (hoursSinceProposed === null || hoursSinceProposed >= UNDERSEED_NO_PROPOSED_HOURS);
652
833
 
653
834
  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 历史与现有文档回灌知识?";
835
+ // rc.16 TASK-002: i18n via lib. Substrings ('${nodeCount}/${threshold}',
836
+ // 'fabric-import', '${hoursSinceInit}h') preserved by the lib's zh-CN
837
+ // templates. Note: hoursSinceInit is passed as already-toFixed(1) string
838
+ // to keep the lib pure (no number formatting in render path).
839
+ const line1 = renderBanner("importLine1", variant, {
840
+ nodeCount: underseed.nodeCount,
841
+ threshold: underseed.threshold,
842
+ hoursSinceInit: hoursSinceInit.toFixed(1),
843
+ });
844
+ const line2 = renderBanner("importCta", variant, {});
660
845
  const reason = `${line1}\n${line2}`;
661
846
  return {
662
847
  decision: "block",
@@ -780,6 +965,97 @@ function writeShownCache(projectRoot, cache) {
780
965
  }
781
966
  }
782
967
 
968
+ // -----------------------------------------------------------------------------
969
+ // v2.0.0-rc.37 NEW-16 — per-signal dismiss.
970
+ //
971
+ // Two suppression levers, both honoured at emit time (a chosen signal whose
972
+ // type is dismissed exits silently, exactly like a cooldown hit):
973
+ // 1. Durable opt-out — fabric-config.json#hint_dismiss_signals: string[].
974
+ // Mirrors the cite_evict_interval=0 opt-out convention; survives across
975
+ // sessions. The concrete user-actionable lever surfaced in the nudge.
976
+ // 2. Session-scoped — .fabric/.cache/hint-dismiss-{sessionId}.json
977
+ // { dismissed: string[] }. Ephemeral; written by the agent when the user
978
+ // asks to silence a nudge type for the current session (Fabric's
979
+ // AI-driven write convention — no new CLI surface).
980
+ //
981
+ // The four signal types ('archive' / 'review' / 'import' / 'maintenance')
982
+ // each have an independent cooldown ALREADY (signal-keyed SHOWN_CACHE for
983
+ // A/B/C + the maintenance day-cooldown sidecar), so dismiss layers cleanly on
984
+ // top of per-signal cadence without a physical 4-hook split (which would 4×
985
+ // the per-Stop process spawn and break the deliberate single-nudge-per-turn
986
+ // precedence model — KT-DEC-0007 anti-nag spirit).
987
+ // -----------------------------------------------------------------------------
988
+
989
+ const DISMISSABLE_SIGNALS = ["archive", "review", "import", "maintenance"];
990
+
991
+ function sessionDismissFileName(sessionId) {
992
+ const safe = String(sessionId || "anonymous").replace(/[^A-Za-z0-9_.-]/g, "-");
993
+ return `hint-dismiss-${safe}.json`;
994
+ }
995
+
996
+ // Returns a Set of dismissed signal types (config-durable ∪ session sidecar).
997
+ // Never throws — degrades to an empty set when libs are absent.
998
+ function readDismissedSignals(projectRoot, sessionId) {
999
+ const dismissed = new Set();
1000
+ try {
1001
+ if (configCache && typeof configCache.readConfig === "function") {
1002
+ const cfg = configCache.readConfig(projectRoot);
1003
+ const list = cfg && cfg.hint_dismiss_signals;
1004
+ if (Array.isArray(list)) {
1005
+ for (const s of list) {
1006
+ if (DISMISSABLE_SIGNALS.includes(s)) dismissed.add(s);
1007
+ }
1008
+ }
1009
+ }
1010
+ } catch {
1011
+ // defensive
1012
+ }
1013
+ try {
1014
+ if (stateStore && typeof stateStore.readJsonState === "function" && sessionId) {
1015
+ const sidecar = stateStore.readJsonState(
1016
+ projectRoot,
1017
+ sessionDismissFileName(sessionId),
1018
+ (p) => p && typeof p === "object" && Array.isArray(p.dismissed),
1019
+ );
1020
+ if (sidecar) {
1021
+ for (const s of sidecar.dismissed) {
1022
+ if (DISMISSABLE_SIGNALS.includes(s)) dismissed.add(s);
1023
+ }
1024
+ }
1025
+ }
1026
+ } catch {
1027
+ // defensive
1028
+ }
1029
+ return dismissed;
1030
+ }
1031
+
1032
+ // Persist a session-scoped dismiss set (additive merge). Exposed for the
1033
+ // agent-driven write path + tests; not auto-invoked by the hook. Never throws.
1034
+ function writeSessionDismiss(projectRoot, sessionId, signals) {
1035
+ if (!stateStore || typeof stateStore.writeJsonState !== "function") return;
1036
+ const fileName = sessionDismissFileName(sessionId);
1037
+ const prior = stateStore.readJsonState(
1038
+ projectRoot,
1039
+ fileName,
1040
+ (p) => p && typeof p === "object" && Array.isArray(p.dismissed),
1041
+ );
1042
+ const merged = new Set(prior && Array.isArray(prior.dismissed) ? prior.dismissed : []);
1043
+ for (const s of Array.isArray(signals) ? signals : []) {
1044
+ if (DISMISSABLE_SIGNALS.includes(s)) merged.add(s);
1045
+ }
1046
+ stateStore.writeJsonState(projectRoot, fileName, { dismissed: [...merged] });
1047
+ }
1048
+
1049
+ // Bilingual one-line dismiss hint appended to every nudge so the user knows
1050
+ // the lever exists. Variant fold mirrors banner-i18n: zh-CN / zh-CN-hybrid →
1051
+ // Chinese; en / match-existing / unknown → English.
1052
+ function renderDismissOption(signal, variant) {
1053
+ const zh = variant === "zh-CN" || variant === "zh-CN-hybrid";
1054
+ return zh
1055
+ ? ` (不想再看到此类提醒?在 .fabric/fabric-config.json 设 "hint_dismiss_signals": ["${signal}"],或让我本会话关闭 ${signal} 提醒)`
1056
+ : ` (Silence this nudge? Set "hint_dismiss_signals": ["${signal}"] in .fabric/fabric-config.json, or ask me to dismiss ${signal} for this session)`;
1057
+ }
1058
+
783
1059
  /**
784
1060
  * v2.0.0-rc.7 T10: find the most recent doctor_run event ts in the ledger.
785
1061
  * Returns the ts (epoch ms) of the newest doctor_run event, or null if none
@@ -859,15 +1135,23 @@ function evaluateMaintenanceSignal(events, now, canonicalCount, lastEmitMs, thre
859
1135
  typeof cfg.maintenanceHintCooldownDays === "number" && cfg.maintenanceHintCooldownDays > 0
860
1136
  ? cfg.maintenanceHintCooldownDays
861
1137
  : DEFAULT_MAINTENANCE_HINT_COOLDOWN_DAYS;
1138
+ // rc.16 TASK-002: banner variant for the i18n lib. Defaults to 'zh-CN' so
1139
+ // existing rc.7 T10 test fixtures (which never set thresholds.variant) get
1140
+ // the byte-identical Chinese maintenance banner.
1141
+ const variant = typeof cfg.variant === "string" ? cfg.variant : "zh-CN";
862
1142
 
863
1143
  if (canonicalCount < MAINTENANCE_HINT_MIN_CANONICAL) {
864
1144
  return null;
865
1145
  }
866
1146
 
867
1147
  // Cooldown gate — short-circuit when we just nagged.
1148
+ // rc.34 TASK-01 + review-fix (Gemini P1): future-stamped lastEmit (backward
1149
+ // clock skew) bypasses cooldown — treats sidecar as "expired" so the gate
1150
+ // heals on the next invocation instead of waiting (cooldown + |skew|).
868
1151
  if (
869
1152
  typeof lastEmitMs === "number" &&
870
1153
  Number.isFinite(lastEmitMs) &&
1154
+ nowMs >= lastEmitMs &&
871
1155
  nowMs - lastEmitMs < cooldownDays * MS_PER_DAY
872
1156
  ) {
873
1157
  return null;
@@ -883,14 +1167,18 @@ function evaluateMaintenanceSignal(events, now, canonicalCount, lastEmitMs, thre
883
1167
  if (ageDays < days) return null; // doctor ran recently, no nag.
884
1168
  }
885
1169
 
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}`;
1170
+ // rc.16 TASK-002: i18n via lib. Substrings ('从未运行 lint 检查',
1171
+ // '已 N 天未跑 lint', 'fabric doctor --lint') preserved by the lib's
1172
+ // zh-CN templates. ageDays passed as already-toFixed(1) string to keep
1173
+ // the lib pure (no number formatting in render path).
1174
+ const line2 = renderBanner("maintenanceLine2", variant, {});
1175
+ const line1 = lastDoctorTs === null
1176
+ ? renderBanner("maintenanceLine1Never", variant, {})
1177
+ : renderBanner("maintenanceLine1Aged", variant, {
1178
+ days,
1179
+ ageDays: ageDays.toFixed(1),
1180
+ });
1181
+ const reason = `${line1}\n${line2}`;
894
1182
 
895
1183
  return {
896
1184
  decision: "block",
@@ -922,11 +1210,224 @@ function tryReadStdinJson() {
922
1210
  const parsed = JSON.parse(buf);
923
1211
  if (parsed === null || typeof parsed !== "object") return null;
924
1212
  return parsed;
925
- } catch {
1213
+ } catch (e) {
1214
+ // v2.0.0-rc.29 TASK-008 (BUG-L1): hook used to silent-swallow JSON.parse
1215
+ // errors which masked real client-side payload bugs (e.g. CLI hosts that
1216
+ // stopped emitting Stop-hook JSON envelopes). Log a single best-effort
1217
+ // diagnostic line so operators see WHY the hook went quiet; keep returning
1218
+ // null so downstream behaviour (graceful exit 0, no rule render) is
1219
+ // unchanged.
1220
+ try {
1221
+ const message = (e && typeof e === "object" && "message" in e) ? String(e.message) : String(e);
1222
+ process.stderr.write(`[fabric-hint] malformed input: ${message}\n`);
1223
+ } catch {
1224
+ // stderr write failed (very unusual — sandbox / closed fd). The
1225
+ // hook contract still requires we never throw upward.
1226
+ }
926
1227
  return null;
927
1228
  }
928
1229
  }
929
1230
 
1231
+ /**
1232
+ * v2.0.0-rc.20 TASK-03 → v2.0.0-rc.24 TASK-04: legacy shim signature for
1233
+ * parsing the raw text that follows the `KB:` prefix on the first non-empty
1234
+ * line of an assistant turn. As of rc.24 the implementation delegates to the
1235
+ * shared `parseCiteLine` (inline-shipped via lib/cite-line-parser.cjs) to
1236
+ * eliminate per-client regex drift.
1237
+ *
1238
+ * Contract (rc.24 strict mode — superset of rc.20):
1239
+ * - Sentinel `none` (incl. `[no-relevant]` / `[not-applicable]` tail)
1240
+ * → cite_ids=[], cite_tags=["none"], cite_commitments=[]
1241
+ * - `KT-DEC-0001 [planned]` → cite_ids=["KT-DEC-0001"], cite_tags=["planned"],
1242
+ * cite_commitments=[{operators:[], skip_reason:null}]
1243
+ * - `KT-DEC-0001 [recalled] → edit:foo.ts` → cite_commitments=[{operators:
1244
+ * [{kind:"edit", target:"foo.ts"}], skip_reason:null}]
1245
+ * - `KT-DEC-0001 [recalled] → skip:sequencing` → cite_commitments=[{operators:
1246
+ * [], skip_reason:"sequencing"}]
1247
+ * - Id form is now strict `K[TP]-[A-Z]+-\d+` (rc.20 lax form `KP-001`
1248
+ * without letter-prefix is rejected — see TASK-03 schema).
1249
+ *
1250
+ * Argument is the post-`KB:` substring (matches the rc.20 call site). Returns
1251
+ * { cite_ids, cite_tags, cite_commitments }; cite_commitments was added in
1252
+ * rc.24 and is always present (empty array when no cite-line found).
1253
+ *
1254
+ * Never throws.
1255
+ */
1256
+ function parseKbLine(raw) {
1257
+ // Compose the full `KB: <raw>` line because the shared parser anchors on
1258
+ // the `KB:` prefix. Handles the legacy `none` / `<sentinel>` inputs naturally
1259
+ // because parseCiteLine's SENTINEL_RE matches the composed line.
1260
+ if (typeof raw !== "string") {
1261
+ return { cite_ids: [], cite_tags: [], cite_commitments: [] };
1262
+ }
1263
+ const composed = `KB: ${raw}`;
1264
+ if (citeLineParser && typeof citeLineParser.parseCiteLine === "function") {
1265
+ return citeLineParser.parseCiteLine(composed);
1266
+ }
1267
+ // Degraded fallback: lib missing (e.g. partial install). Emit empty result
1268
+ // so downstream consumers see the cite-line as unobservable rather than
1269
+ // mis-parsed. The Stop-hook contract is best-effort, never blocking.
1270
+ return { cite_ids: [], cite_tags: [], cite_commitments: [] };
1271
+ }
1272
+
1273
+ /**
1274
+ * v2.0.0-rc.20 TASK-03: detect which client surface invoked the hook so the
1275
+ * emitted assistant_turn_observed event can carry a `client` discriminator
1276
+ * without having to inspect the transcript shape.
1277
+ *
1278
+ * Resolution order (first match wins):
1279
+ * 1. `FABRIC_HINT_CLIENT` env var — explicit override, set by the per-
1280
+ * client install pipeline when the hook-config schema supports env
1281
+ * injection.
1282
+ * 2. Path heuristic against `__dirname` — `.claude/` → "cc", `.codex/` →
1283
+ * "codex". Covers the dominant deployment shape (hook script lives
1284
+ * under the client's per-repo dir).
1285
+ *
1286
+ * Returns `undefined` when neither signal fires (e.g. Cursor — deferred to
1287
+ * rc.21 — or a custom deployment). The Zod schema marks `client` optional,
1288
+ * so omitting it leaves the event valid.
1289
+ */
1290
+ function detectClient() {
1291
+ // Delegate the full 3-tier detection (env → CLAUDE_PROJECT_DIR → path
1292
+ // heuristic, incl. .cursor) to the shared adapter. __dirname is passed so
1293
+ // the path heuristic reflects THIS hook's location.
1294
+ if (clientAdapter && typeof clientAdapter.detectClient === "function") {
1295
+ return clientAdapter.detectClient(__dirname);
1296
+ }
1297
+ // Fallback (adapter lib absent): env override only.
1298
+ const envClient = process.env.FABRIC_HINT_CLIENT;
1299
+ if (typeof envClient === "string" && envClient.length > 0) {
1300
+ const normalised = envClient.trim().toLowerCase();
1301
+ if (normalised === "cc" || normalised === "codex" || normalised === "cursor") {
1302
+ return normalised;
1303
+ }
1304
+ }
1305
+ return undefined;
1306
+ }
1307
+
1308
+ /**
1309
+ * v2.0.0-rc.20 TASK-03: emit one `assistant_turn_observed` event per
1310
+ * assistant envelope harvested from the transcript. Wrapped in try/catch
1311
+ * (best-effort, never throws — Stop hook MUST stay non-blocking on any
1312
+ * failure here). The event shape mirrors
1313
+ * assistantTurnObservedEventSchema in
1314
+ * packages/shared/src/schemas/event-ledger.ts (registered in rc.20 TASK-02).
1315
+ *
1316
+ * Call site sits immediately AFTER writeSessionDigestBestEffort so both
1317
+ * digest + per-turn events derive from the same transcript snapshot.
1318
+ *
1319
+ * `id` mirrors the server's convention (`event:<uuid>`) using
1320
+ * crypto.randomUUID when available — falls back to a timestamp+counter
1321
+ * tuple on older Node where randomUUID is missing (cjs hook tooling
1322
+ * defensively targets Node 18+, but the fallback keeps it event-shaped).
1323
+ */
1324
+ function extractAndWriteAssistantTurnsBestEffort(cwd, stdinPayload) {
1325
+ if (stdinPayload === null || typeof stdinPayload !== "object") return;
1326
+ try {
1327
+ const sessionId = stdinPayload.session_id;
1328
+ if (typeof sessionId !== "string" || sessionId.length === 0) return;
1329
+ const transcript = summarizeTranscript(stdinPayload.transcript_path);
1330
+ const turns = transcript.assistant_turns;
1331
+ if (!Array.isArray(turns) || turns.length === 0) return;
1332
+
1333
+ // Resolve event-ledger path. Caller already validated cwd shape.
1334
+ const fabricDir = join(cwd, FABRIC_DIR);
1335
+ if (!existsSync(fabricDir)) {
1336
+ // No .fabric/ → workspace is uninitialised. Silently skip; the digest
1337
+ // writer applies the same guard via its own internal check.
1338
+ return;
1339
+ }
1340
+ const ledgerPath = join(fabricDir, EVENT_LEDGER_FILE);
1341
+ const client = detectClient();
1342
+ let randomUUID;
1343
+ try {
1344
+ ({ randomUUID } = require("node:crypto"));
1345
+ } catch {
1346
+ randomUUID = null;
1347
+ }
1348
+
1349
+ // v2.0.0-rc.39 (P1 emit-fold): empty-shell turns (no KB: line, no cites)
1350
+ // do not get an events.jsonl line — they are tallied and folded into one
1351
+ // metrics.jsonl counter row at the end of this batch. This zeroes the 99%
1352
+ // empty-shell bloat at the source while keeping cite-bearing turns as
1353
+ // discrete audit events. Count carries per-Stop re-emission exactly (we
1354
+ // tally every empty turn the transcript presents, not just new ones), so
1355
+ // the reader-side counter merge reconstructs total_turns byte-for-byte.
1356
+ let emptyShellCount = 0;
1357
+ for (const turn of turns) {
1358
+ try {
1359
+ const citeIds = Array.isArray(turn.cite_ids) ? turn.cite_ids : [];
1360
+ const citeCommitments = Array.isArray(turn.cite_commitments)
1361
+ ? turn.cite_commitments
1362
+ : [];
1363
+ const isEmptyShell =
1364
+ (turn.kb_line_raw === null || turn.kb_line_raw === undefined) &&
1365
+ citeIds.length === 0 &&
1366
+ citeCommitments.length === 0;
1367
+ if (isEmptyShell) {
1368
+ emptyShellCount += 1;
1369
+ continue;
1370
+ }
1371
+ const idSuffix = typeof randomUUID === "function"
1372
+ ? randomUUID()
1373
+ : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
1374
+ const event = {
1375
+ kind: "fabric-event",
1376
+ id: `event:${idSuffix}`,
1377
+ ts: Date.now(),
1378
+ schema_version: 1,
1379
+ session_id: sessionId,
1380
+ event_type: EVENT_TYPE_ASSISTANT_TURN_OBSERVED,
1381
+ kb_line_raw: turn.kb_line_raw,
1382
+ cite_ids: citeIds,
1383
+ cite_tags: Array.isArray(turn.cite_tags) ? turn.cite_tags : [],
1384
+ // rc.24 TASK-04: cite_commitments parallel array (assistantTurn
1385
+ // ObservedEventSchema gained this slot in rc.24 TASK-01). Empty
1386
+ // array for legacy turns or when the parser lib is unavailable —
1387
+ // the schema defaults `.default([])` so omitting it would also be
1388
+ // valid, but emitting an explicit `[]` keeps the on-disk shape
1389
+ // uniform across rc.24+ events.
1390
+ cite_commitments: citeCommitments,
1391
+ turn_id: `${sessionId}-${turn.envelope_index}`,
1392
+ envelope_index: turn.envelope_index,
1393
+ timestamp: new Date().toISOString(),
1394
+ };
1395
+ if (client !== undefined) event.client = client;
1396
+ appendFileSync(ledgerPath, JSON.stringify(event) + "\n", "utf8");
1397
+ } catch {
1398
+ // Per-turn failure must not abort the remaining turns; the Stop hook
1399
+ // contract is "never block on hook failure". Best-effort continues.
1400
+ }
1401
+ }
1402
+
1403
+ // rc.39 emit-fold: write one metrics.jsonl counter row for the folded
1404
+ // empty-shell turns. Best-effort — a failure here must never block the
1405
+ // Stop hook (KT-DEC-0007). The counter key is namespaced by client so the
1406
+ // reader's per_client total_turns breakdown stays invariant; an undefined
1407
+ // client (adapter lib absent) folds into the bare `assistant_turn_observed`
1408
+ // key, mirroring how such turns omit the event-side `client` discriminator.
1409
+ if (emptyShellCount > 0) {
1410
+ try {
1411
+ const counterKey =
1412
+ client !== undefined
1413
+ ? `${EVENT_TYPE_ASSISTANT_TURN_OBSERVED}:${client}`
1414
+ : EVENT_TYPE_ASSISTANT_TURN_OBSERVED;
1415
+ const metricsRow = {
1416
+ timestamp: new Date().toISOString(),
1417
+ window: "stop",
1418
+ counters: { [counterKey]: emptyShellCount },
1419
+ };
1420
+ const metricsPath = join(fabricDir, METRICS_LEDGER_FILE);
1421
+ appendFileSync(metricsPath, JSON.stringify(metricsRow) + "\n", "utf8");
1422
+ } catch {
1423
+ // metrics fold is observability-only; never block the hook on failure.
1424
+ }
1425
+ }
1426
+ } catch {
1427
+ // Outer guard — never throw. Hook continues silently.
1428
+ }
1429
+ }
1430
+
930
1431
  /**
931
1432
  * v2.0.0-rc.7 T5: extract user_messages + edit_paths + 1-line title from the
932
1433
  * transcript JSONL referenced by the hook's stdin payload. Best-effort, never
@@ -935,9 +1436,18 @@ function tryReadStdinJson() {
935
1436
  * Claude Code's transcript_path points at a JSONL where each line is a
936
1437
  * message envelope. We sniff for `role: "user"` lines (text content) and
937
1438
  * for tool-use entries naming Edit / Write / MultiEdit to harvest file_path.
1439
+ *
1440
+ * v2.0.0-rc.20 TASK-03: additionally collects `assistant_turns[]` — one
1441
+ * entry per assistant envelope with the parsed KB-line cite metadata. Field
1442
+ * is additive; existing callers (writeSessionDigestBestEffort) ignore it.
938
1443
  */
939
1444
  function summarizeTranscript(transcriptPath) {
940
- const out = { user_messages: [], edit_paths: [], title: "" };
1445
+ // rc.20 TASK-03: additive `assistant_turns` array one entry per assistant
1446
+ // envelope, regardless of whether the first line matched KB:. Downstream
1447
+ // consumers (extractAndWriteAssistantTurnsBestEffort) emit one
1448
+ // assistant_turn_observed event per element; `kb_line_raw=null` when no
1449
+ // KB: line was found.
1450
+ const out = { user_messages: [], edit_paths: [], title: "", assistant_turns: [] };
941
1451
  if (typeof transcriptPath !== "string" || transcriptPath.length === 0) return out;
942
1452
  if (!existsSync(transcriptPath)) return out;
943
1453
  let raw;
@@ -947,6 +1457,7 @@ function summarizeTranscript(transcriptPath) {
947
1457
  return out;
948
1458
  }
949
1459
  const lines = raw.split(/\r?\n/);
1460
+ let envelopeIndex = -1;
950
1461
  for (const line of lines) {
951
1462
  const trimmed = line.trim();
952
1463
  if (trimmed.length === 0) continue;
@@ -957,12 +1468,23 @@ function summarizeTranscript(transcriptPath) {
957
1468
  continue;
958
1469
  }
959
1470
  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);
1471
+ envelopeIndex += 1;
1472
+
1473
+ // v2.0.0-rc.27 TASK-009 (audit §2.16): Codex CLI uses a different
1474
+ // envelope shape { type:"response_item", payload:{ type:"message",
1475
+ // role, content:[{type:"input_text"|"output_text", text}] } } — vs Claude
1476
+ // Code's { type:"user", message:{ role, content } }. Resolve role +
1477
+ // content from whichever shape is present; without this, every Codex
1478
+ // session's digest came out empty (audit §2.16 — fixed here).
1479
+ const role =
1480
+ envelope.role ||
1481
+ (envelope.message && envelope.message.role) ||
1482
+ (envelope.payload && envelope.payload.role);
964
1483
  if (role === "user") {
965
- const content = envelope.content || (envelope.message && envelope.message.content);
1484
+ const content =
1485
+ envelope.content ||
1486
+ (envelope.message && envelope.message.content) ||
1487
+ (envelope.payload && envelope.payload.content);
966
1488
  if (typeof content === "string") {
967
1489
  out.user_messages.push(content);
968
1490
  } else if (Array.isArray(content)) {
@@ -974,6 +1496,85 @@ function summarizeTranscript(transcriptPath) {
974
1496
  }
975
1497
  }
976
1498
 
1499
+ // rc.20 TASK-03: assistant envelope — capture first non-empty line of the
1500
+ // first text block and parse for `KB:` prefix. We push ONE assistant_turns
1501
+ // entry per assistant envelope (even when no KB: line) so downstream can
1502
+ // distinguish "turn observed, no KB" (kb_line_raw=null) from "no turn".
1503
+ if (role === "assistant") {
1504
+ const content =
1505
+ envelope.content ||
1506
+ (envelope.message && envelope.message.content) ||
1507
+ (envelope.payload && envelope.payload.content);
1508
+ let firstText = null;
1509
+ if (typeof content === "string") {
1510
+ firstText = content;
1511
+ } else if (Array.isArray(content)) {
1512
+ for (const block of content) {
1513
+ if (block && typeof block === "object" && block.type === "text" && typeof block.text === "string") {
1514
+ firstText = block.text;
1515
+ break;
1516
+ }
1517
+ }
1518
+ }
1519
+ let kbLineRaw = null;
1520
+ let citeIds = [];
1521
+ let citeTags = [];
1522
+ // rc.24 TASK-04: parallel `cite_commitments` array, populated by the
1523
+ // shared cite-line parser. One entry per non-sentinel cite (index-aligned
1524
+ // with cite_ids). Sentinel `KB: none` contributes a `cite_tags=["none"]`
1525
+ // entry but no commitment — matches the parseCiteLine index contract.
1526
+ let citeCommitments = [];
1527
+ // v2.0.0-rc.27 TASK-009: Codex assistant blocks carry text under
1528
+ // `type:"output_text"` (not `type:"text"`). Fall back when no text-typed
1529
+ // block matched but a typed output_text block exists.
1530
+ if (firstText === null && Array.isArray(content)) {
1531
+ for (const block of content) {
1532
+ if (block && typeof block === "object" && block.type === "output_text" && typeof block.text === "string") {
1533
+ firstText = block.text;
1534
+ break;
1535
+ }
1536
+ }
1537
+ }
1538
+ if (typeof firstText === "string" && firstText.length > 0) {
1539
+ // First non-empty line.
1540
+ const linesOfText = firstText.split(/\r?\n/);
1541
+ let firstNonEmpty = "";
1542
+ for (const l of linesOfText) {
1543
+ if (l.trim().length > 0) {
1544
+ firstNonEmpty = l.trim();
1545
+ break;
1546
+ }
1547
+ }
1548
+ if (firstNonEmpty.length > 0) {
1549
+ // rc.24 TASK-04: route the FULL `KB: ...` line to the shared parser.
1550
+ // parseCiteLine handles sentinels (`KB: none [<reason>]`) AND full
1551
+ // cite form including contract tail (`KB: KT-DEC-0001 [recalled] →
1552
+ // edit:foo.ts`) uniformly. The sentinel's `[<reason>]` tail stays in
1553
+ // `kb_line_raw` for doctor's downstream histogram parse; cite_tags
1554
+ // still emits the bare `none` token (schema enum-bound).
1555
+ if (/^KB:\s*/i.test(firstNonEmpty)) {
1556
+ kbLineRaw = firstNonEmpty;
1557
+ if (citeLineParser && typeof citeLineParser.parseCiteLine === "function") {
1558
+ const parsed = citeLineParser.parseCiteLine(firstNonEmpty);
1559
+ citeIds = parsed.cite_ids;
1560
+ citeTags = parsed.cite_tags;
1561
+ citeCommitments = parsed.cite_commitments;
1562
+ }
1563
+ // Degraded mode (lib missing) → keep kbLineRaw but emit empty
1564
+ // arrays; doctor downstream treats this as "turn observed, parse
1565
+ // unavailable" without crashing.
1566
+ }
1567
+ }
1568
+ }
1569
+ out.assistant_turns.push({
1570
+ envelope_index: envelopeIndex,
1571
+ kb_line_raw: kbLineRaw,
1572
+ cite_ids: citeIds,
1573
+ cite_tags: citeTags,
1574
+ cite_commitments: citeCommitments,
1575
+ });
1576
+ }
1577
+
977
1578
  // Tool use — look for Edit / Write / MultiEdit and harvest file_path.
978
1579
  const candidates = [];
979
1580
  if (envelope.type === "tool_use") candidates.push(envelope);
@@ -999,6 +1600,27 @@ function summarizeTranscript(transcriptPath) {
999
1600
  }
1000
1601
  }
1001
1602
  }
1603
+
1604
+ // v2.0.0-rc.27 TASK-009 (audit §2.16): Codex apply_patch path. Codex
1605
+ // emits one response_item envelope per file-edit invocation with payload
1606
+ // shape { type:"custom_tool_call", name:"apply_patch", input:<patch
1607
+ // string> }. The patch body lists target files via `*** Update File:`,
1608
+ // `*** Add File:`, `*** Delete File:` directives — harvest those.
1609
+ if (
1610
+ envelope.type === "response_item" &&
1611
+ envelope.payload &&
1612
+ envelope.payload.type === "custom_tool_call" &&
1613
+ envelope.payload.name === "apply_patch" &&
1614
+ typeof envelope.payload.input === "string"
1615
+ ) {
1616
+ const patchInput = envelope.payload.input;
1617
+ const fileDirectiveRe = /^\*\*\*\s+(?:Update|Add|Delete)\s+File:\s+(.+?)\s*$/gm;
1618
+ let m;
1619
+ while ((m = fileDirectiveRe.exec(patchInput)) !== null) {
1620
+ const fp = m[1].trim();
1621
+ if (fp.length > 0) out.edit_paths.push(fp);
1622
+ }
1623
+ }
1002
1624
  }
1003
1625
  // 1-line title = first non-empty user message (trimmed). Falls back to "".
1004
1626
  if (out.user_messages.length > 0) {
@@ -1015,6 +1637,50 @@ function summarizeTranscript(transcriptPath) {
1015
1637
  return out;
1016
1638
  }
1017
1639
 
1640
+ /**
1641
+ * v2.0.0-rc.24 TASK-05: emit soft L1 reminder to stderr when assistant turns
1642
+ * cited a decision/pitfall id with [recalled] but no operator contract and no
1643
+ * skip:<reason>. Reads agents.meta.json once per invocation; aggregated per
1644
+ * turn (one line per offending id). Non-blocking — never throws, always
1645
+ * returns the array of emitted reminder strings (for unit tests + callers
1646
+ * that want to observe what was written).
1647
+ *
1648
+ * The reminder writes go to stderr (the hook contract: stdout is structured
1649
+ * banner JSON consumed by the harness; stderr is free-text system message
1650
+ * that surfaces back to the model on the next turn in cc / codex / cursor).
1651
+ */
1652
+ function emitCiteContractRemindersBestEffort(cwd, stdinPayload, stderr) {
1653
+ if (citeContractReminder === null) return [];
1654
+ if (stdinPayload === null || typeof stdinPayload !== "object") return [];
1655
+ try {
1656
+ const transcript = summarizeTranscript(stdinPayload.transcript_path);
1657
+ const turns = transcript.assistant_turns;
1658
+ if (!Array.isArray(turns) || turns.length === 0) return [];
1659
+
1660
+ const idTypeMap = citeContractReminder.readKnowledgeTypeMap(cwd);
1661
+ if (!(idTypeMap instanceof Map) || idTypeMap.size === 0) return [];
1662
+
1663
+ const reminders = citeContractReminder.formatContractMissingReminders({
1664
+ assistant_turns: turns,
1665
+ idTypeMap,
1666
+ });
1667
+ if (!Array.isArray(reminders) || reminders.length === 0) return [];
1668
+
1669
+ const sink = stderr || process.stderr;
1670
+ for (const line of reminders) {
1671
+ try {
1672
+ sink.write(line + "\n");
1673
+ } catch {
1674
+ // Sink write failure must not abort emission of remaining reminders.
1675
+ }
1676
+ }
1677
+ return reminders;
1678
+ } catch {
1679
+ // Outer guard — never throw. Hook continues silently.
1680
+ return [];
1681
+ }
1682
+ }
1683
+
1018
1684
  /**
1019
1685
  * v2.0.0-rc.7 T5: writeSessionDigestBestEffort — non-blocking digest fan-out.
1020
1686
  * Called from main() before the existing decide() flow. Failure is silently
@@ -1060,6 +1726,23 @@ function main(env, stdio) {
1060
1726
  ? env.stdin_payload
1061
1727
  : tryReadStdinJson();
1062
1728
  writeSessionDigestBestEffort(cwd, stdinPayload);
1729
+ // v2.0.0-rc.20 TASK-03: per-turn cite-policy observation events. Same
1730
+ // best-effort contract as the digest writer — never throws, never blocks
1731
+ // the Stop hook on failure. Shares the transcript snapshot read by
1732
+ // writeSessionDigestBestEffort (each call re-reads independently; the
1733
+ // transcript file is small in practice and re-parse cost is dwarfed by
1734
+ // the hook's other I/O).
1735
+ extractAndWriteAssistantTurnsBestEffort(cwd, stdinPayload);
1736
+
1737
+ // v2.0.0-rc.24 TASK-05: L1 soft reminder layer. Surfaces ⚠ KB:<id> lines
1738
+ // to stderr when decision/pitfall cites arrived with [recalled] tag but
1739
+ // empty contract. Non-blocking, never throws; doctor (TASK-08) catches
1740
+ // any contract violation the model ignored.
1741
+ emitCiteContractRemindersBestEffort(
1742
+ cwd,
1743
+ stdinPayload,
1744
+ stdio && stdio.stderr,
1745
+ );
1063
1746
 
1064
1747
  const events = readLedger(cwd);
1065
1748
  let pendingStats;
@@ -1108,6 +1791,19 @@ function main(env, stdio) {
1108
1791
  // rc.7 T7: read the externalized thresholds and pass them into decide.
1109
1792
  // Reader failures degrade silently to documented defaults — fabric-hint
1110
1793
  // must never block on config errors (see hook contract above).
1794
+ //
1795
+ // rc.16 TASK-002 (F2-apply): resolve `fabric_language` ONCE per main()
1796
+ // invocation via the banner-i18n lib. The result threads through
1797
+ // `thresholds.variant` into both decide() and evaluateMaintenanceSignal()
1798
+ // so we read the config file at most once, not five times. Lib reader
1799
+ // is never-throw; defensive try/catch is belt-and-suspenders.
1800
+ let variant = "zh-CN";
1801
+ try {
1802
+ variant = readFabricLanguage(cwd);
1803
+ } catch {
1804
+ variant = "zh-CN";
1805
+ }
1806
+
1111
1807
  let thresholds;
1112
1808
  try {
1113
1809
  thresholds = {
@@ -1116,6 +1812,7 @@ function main(env, stdio) {
1116
1812
  reviewHintPendingAgeDays: readReviewHintPendingAgeDays(cwd),
1117
1813
  maintenanceHintDays: readMaintenanceHintDays(cwd),
1118
1814
  maintenanceHintCooldownDays: readMaintenanceHintCooldownDays(cwd),
1815
+ variant,
1119
1816
  };
1120
1817
  } catch {
1121
1818
  thresholds = {
@@ -1124,6 +1821,7 @@ function main(env, stdio) {
1124
1821
  reviewHintPendingAgeDays: DEFAULT_REVIEW_HINT_PENDING_AGE_DAYS,
1125
1822
  maintenanceHintDays: DEFAULT_MAINTENANCE_HINT_DAYS,
1126
1823
  maintenanceHintCooldownDays: DEFAULT_MAINTENANCE_HINT_COOLDOWN_DAYS,
1824
+ variant,
1127
1825
  };
1128
1826
  }
1129
1827
 
@@ -1147,28 +1845,29 @@ function main(env, stdio) {
1147
1845
  activityOverview = "";
1148
1846
  }
1149
1847
 
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
- );
1848
+ // v2.0.0-rc.8 (TASK-002): probe `.fabric/.import-state.json` to
1849
+ // determine whether a fabric-import skill run is currently in flight.
1850
+ // Threaded into decide() so Signal B (review hint) is suppressed for
1851
+ // the duration of an active import preventing the Stop hook from
1852
+ // interrupting the import when its pending pile crosses the review
1853
+ // threshold. See isImportInFlight() docstring for the full truth table.
1854
+ let importInFlight = false;
1855
+ try {
1856
+ importInFlight = isImportInFlight(cwd, now);
1857
+ } catch {
1858
+ importInFlight = false;
1859
+ }
1860
+
1861
+ let result = decide(
1862
+ events,
1863
+ now,
1864
+ pendingStats,
1865
+ underseedStats,
1866
+ editCounterStats,
1867
+ thresholds,
1868
+ { activityOverview },
1869
+ importInFlight,
1870
+ );
1172
1871
 
1173
1872
  // v2.0.0-rc.7 T10: Signal D — maintenance hint. Evaluated AFTER A/B/C
1174
1873
  // because the existing three signals carry higher urgency (in-flight
@@ -1193,6 +1892,21 @@ function main(env, stdio) {
1193
1892
 
1194
1893
  if (result === null) return;
1195
1894
 
1895
+ // v2.0.0-rc.37 NEW-16: per-signal dismiss. A chosen signal whose type the
1896
+ // user dismissed (config-durable or session sidecar) exits silently —
1897
+ // same shape as a cooldown hit. Covers BOTH maintenance and A/B/C paths.
1898
+ const sessionId =
1899
+ stdinPayload && typeof stdinPayload.session_id === "string"
1900
+ ? stdinPayload.session_id
1901
+ : null;
1902
+ if (readDismissedSignals(cwd, sessionId).has(result.signal)) {
1903
+ return;
1904
+ }
1905
+ // Append the bilingual dismiss-option line so the lever is discoverable.
1906
+ if (typeof result.reason === "string") {
1907
+ result.reason = `${result.reason}\n${renderDismissOption(result.signal, variant)}`;
1908
+ }
1909
+
1196
1910
  // v2.0.0-rc.7 T10: Signal D uses its own cooldown sidecar (day-based,
1197
1911
  // see MAINTENANCE_HINT_LAST_EMIT_FILE). The A/B/C shared cooldown cache
1198
1912
  // uses hours, so we branch here to avoid mixing semantics.
@@ -1202,24 +1916,19 @@ function main(env, stdio) {
1202
1916
  return;
1203
1917
  }
1204
1918
 
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
1919
  // Cooldown throttle: once a signal fires, stay silent for
1217
1920
  // archive_hint_cooldown_hours (default 12h) regardless of state drift.
1218
1921
  // Pure reminder-noise reduction; the underlying trigger logic is unchanged.
1219
1922
  const cooldownMs = readCooldownHours(cwd) * MS_PER_HOUR;
1220
1923
  const cache = readShownCache(cwd);
1221
1924
  const lastShown = cache[result.signal];
1222
- if (typeof lastShown === "number" && nowMs - lastShown < cooldownMs) {
1925
+ // rc.34 TASK-01 + review-fix (Gemini P1): future-stamped lastShown
1926
+ // (backward clock skew) bypasses cooldown — sidecar treated as expired.
1927
+ if (
1928
+ typeof lastShown === "number" &&
1929
+ nowMs >= lastShown &&
1930
+ nowMs - lastShown < cooldownMs
1931
+ ) {
1223
1932
  return; // Still in cooldown — silent.
1224
1933
  }
1225
1934
 
@@ -1240,13 +1949,20 @@ module.exports = {
1240
1949
  // rc.7 T4: top-edited-directories aggregator + banner overview formatter.
1241
1950
  getTopEditedDirectories,
1242
1951
  formatActivityOverview,
1243
- // rc.7 T1: cross-surface sentinel pickup helpers (exported for testing).
1244
- isImportRequestedSentinelPresent,
1245
- makeImportSentinelResult,
1952
+ // v2.0.0-rc.8 (TASK-002): in-flight import gate for Signal B (exported
1953
+ // for unit testing of the truth table).
1954
+ isImportInFlight,
1246
1955
  decide,
1247
1956
  readCooldownHours,
1248
1957
  readUnderseedThreshold,
1249
1958
  readArchiveEditThreshold,
1959
+ // v2.0.0-rc.37 NEW-16: per-signal dismiss helpers (exported for tests +
1960
+ // the agent-driven session-dismiss write path).
1961
+ readDismissedSignals,
1962
+ writeSessionDismiss,
1963
+ sessionDismissFileName,
1964
+ renderDismissOption,
1965
+ DISMISSABLE_SIGNALS,
1250
1966
  // v2.0.0-rc.7 T5: session digest helpers (exported for unit testing).
1251
1967
  tryReadStdinJson,
1252
1968
  summarizeTranscript,
@@ -1264,9 +1980,20 @@ module.exports = {
1264
1980
  readMaintenanceHintCooldownDays,
1265
1981
  readShownCache,
1266
1982
  writeShownCache,
1983
+ // v2.0.0-rc.20 TASK-03 / TASK-09: cite-policy parsing + per-turn emission
1984
+ // helpers (exported for unit testing of the parse + emit contract).
1985
+ parseKbLine,
1986
+ detectClient,
1987
+ extractAndWriteAssistantTurnsBestEffort,
1988
+ // v2.0.0-rc.24 TASK-05: L1 soft reminder helpers (exported for unit testing
1989
+ // of the contract-missing emission contract). The lib module itself is
1990
+ // also exported indirectly via the reminder helper.
1991
+ emitCiteContractRemindersBestEffort,
1267
1992
  CONSTANTS: {
1268
1993
  FABRIC_DIR,
1269
1994
  EVENT_LEDGER_FILE,
1995
+ METRICS_LEDGER_FILE,
1996
+ EVENT_TYPE_ASSISTANT_TURN_OBSERVED,
1270
1997
  EVENT_TYPE_PROPOSED,
1271
1998
  EVENT_TYPE_INIT_SCAN_COMPLETED,
1272
1999
  // rc.7 T7: legacy aliases kept for back-compat with the existing test
@@ -1296,8 +2023,9 @@ module.exports = {
1296
2023
  EVENT_TYPE_DOCTOR_RUN,
1297
2024
  MAINTENANCE_HINT_LAST_EMIT_FILE,
1298
2025
  MAINTENANCE_HINT_MIN_CANONICAL,
1299
- // rc.7 T1: cross-surface sentinel for `fabric init` → import-skill hand-off.
1300
- IMPORT_REQUESTED_SENTINEL_FILE,
2026
+ // v2.0.0-rc.8 (TASK-002): in-flight import gate for Signal B.
2027
+ IMPORT_STATE_FILE_REL,
2028
+ IMPORT_IN_FLIGHT_MAX_AGE_HOURS,
1301
2029
  },
1302
2030
  };
1303
2031