instar 1.3.474 → 1.3.476

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.
@@ -0,0 +1,168 @@
1
+ /**
2
+ * GrowthDigestPublisher — Slice 2 of the proactive growth analyst.
3
+ *
4
+ * WHY THIS EXISTS (Justin, 2026-06-06, topic 21624): "I have YET to have an agent
5
+ * proactively check in with me about ANY of these." Slice 1
6
+ * (GrowthMilestoneAnalyst) already COMPUTES the growth picture (R1–R6) and exposes
7
+ * it via read routes — but nothing ever SENDS it. This component is the voice:
8
+ * on a cadence it takes the analyst's already-computed `GrowthDigest`, decides
9
+ * whether there is anything worth saying, formats ONE consolidated "growth
10
+ * check-in," and routes it through the SAME flood-guarded post-update funnel the
11
+ * `/telegram/post-update` route uses.
12
+ *
13
+ * It owns NO analysis. It is a cadence + lease-check + decide-to-speak + format +
14
+ * deliver + audit wrapper. It can never block, delay, or rewrite anything — it
15
+ * only sends a message or stays quiet. Spec:
16
+ * docs/specs/PROACTIVE-GROWTH-DIGEST-PUBLISHER-SLICE2-SPEC.md.
17
+ *
18
+ * Hardened by the Slice-2 convergence review (§9):
19
+ * - MULTI-MACHINE: an in-process croner runs on BOTH the awake and the standby
20
+ * machine, so the digest would double-send. The publisher is lease-gated
21
+ * (`isAwake`) — only the awake machine sends (mirrors the scheduler /
22
+ * ActivitySentinel precedent the superseded job relied on).
23
+ * - SINGLE FUNNEL: the `send` dep is the shared res-free `evaluateOutbound` path
24
+ * (`postToUpdatesTopic`), never a raw `sendToTopic` — one guarded chokepoint,
25
+ * not two.
26
+ * - NO CALM NOISE: a fully-calm week is silent by default
27
+ * (`sendOnCalmWeeks:false`) — a weekly "all healthy" is the exact noise the
28
+ * operator killed burnDetection for.
29
+ * - MISSED-RUN CATCH-UP: croner schedules only the next fire; a fire that elapsed
30
+ * while the box was asleep is replayed once on `.start()` (the proactive
31
+ * check-in must not be silently dropped for a week). Idempotent on the window
32
+ * ISO so a restart loop can't re-fire the same window.
33
+ */
34
+ import type { GrowthDigest } from './GrowthMilestoneAnalyst.js';
35
+ export type GrowthDigestDelivery = 'off' | 'dry-run' | 'live';
36
+ export type GrowthDigestTrigger = 'cron' | 'catchup' | 'manual';
37
+ export interface DeliveryResult {
38
+ ok: boolean;
39
+ /** Why a non-send happened (dedup/budget/tone block, no-updates-topic, …). A
40
+ * block is a NORMAL outcome, never an error — the publisher never re-acts. */
41
+ reason?: string;
42
+ }
43
+ export interface GrowthDigestAuditEntry {
44
+ ts: string;
45
+ action: 'sent' | 'send-blocked' | 'dry-run' | 'skipped-standby' | 'skipped-off' | 'skipped-overlap' | 'skipped-calm' | 'error';
46
+ trigger?: GrowthDigestTrigger;
47
+ /** ISO of the scheduled window this cycle belongs to. The idempotency key for
48
+ * catch-up: recorded ONLY on a real post-lease decision (never on a pre-lease
49
+ * `skipped-standby`), so the awake machine still owns an un-consumed window. */
50
+ window?: string;
51
+ reason?: string;
52
+ counts?: GrowthDigest['counts'];
53
+ /** For `dry-run`: the EXACT message that WOULD have been sent (how the operator
54
+ * inspects a real sample before going live). */
55
+ wouldSend?: string;
56
+ /** §3.5 belt: on a live send while `initiative-digest-review` is still enabled,
57
+ * a SIGNAL (never a cross-component mutation) that the old voice should be
58
+ * disabled to avoid two voices on the same initiatives. */
59
+ supersedeConflict?: boolean;
60
+ }
61
+ export interface GrowthDigestPublisherDeps {
62
+ /** Bound to the live analyst (`(now) => analyst.buildDigest(now)`). */
63
+ buildDigest: (now: Date) => GrowthDigest;
64
+ /** `monitoring.growthAnalyst.digestCron` (default '0 11 * * 1'). */
65
+ cron: string;
66
+ /** Rollout stage. The publisher is only constructed when mode !== 'off'. */
67
+ mode: GrowthDigestDelivery;
68
+ /** IANA tz for BOTH the cron fire and the rendered header date (default UTC). */
69
+ timezone?: string;
70
+ /** Send on a fully-calm week? Default false (no "all healthy" heartbeat). */
71
+ sendOnCalmWeeks?: boolean;
72
+ /** The SINGLE guarded funnel to the Updates topic. Attached at route
73
+ * registration via `attachSender` (where `ctx`/the route helper lives). */
74
+ send?: (text: string) => Promise<DeliveryResult>;
75
+ /** Multi-machine lease gate — only the awake machine sends. Default no-op. */
76
+ isAwake?: () => boolean;
77
+ /** Append-one-JSON-line audit sink (default → logs/growth-digest.jsonl). */
78
+ audit?: (entry: GrowthDigestAuditEntry) => void;
79
+ /** The set of window ISOs already decided (for catch-up idempotency). */
80
+ recordedWindows?: () => Set<string>;
81
+ /** §3.5 belt: is the superseded `initiative-digest-review` job still enabled? */
82
+ supersededJobStillEnabled?: () => boolean;
83
+ now?: () => Date;
84
+ onError?: (where: string, err: unknown) => void;
85
+ /** Settle delay before the missed-run catch-up (default 60s — long enough for
86
+ * the multi-machine lease to settle so a freshly-booted machine that acquires
87
+ * the lease isn't wrongly skipped). */
88
+ settleMs?: number;
89
+ /** Formatter: low/normal bulk cap per rule (default 5). */
90
+ perRuleCap?: number;
91
+ /** Formatter: per-detail char cap (default 200). */
92
+ detailCap?: number;
93
+ }
94
+ export declare class GrowthDigestPublisher {
95
+ private readonly deps;
96
+ private readonly cron;
97
+ private readonly mode;
98
+ private readonly timezone?;
99
+ private readonly sendOnCalmWeeks;
100
+ private readonly settleMs;
101
+ private readonly perRuleCap;
102
+ private readonly detailCap;
103
+ private sender?;
104
+ private cronTask;
105
+ private settleTimer;
106
+ private running;
107
+ constructor(deps: GrowthDigestPublisherDeps);
108
+ private nowFn;
109
+ /** Attach the guarded sender after construction (route-registration time). */
110
+ attachSender(send: (text: string) => Promise<DeliveryResult>): void;
111
+ /** True once the cron task is scheduled (false if refused by the sanity-floor
112
+ * or an invalid cron). Used by wiring tests. */
113
+ isStarted(): boolean;
114
+ /**
115
+ * Schedule the cadence + arm the missed-run catch-up. Idempotent. Refuses a
116
+ * sub-hourly cadence (sanity-floor) and an invalid cron (both logged via
117
+ * onError; the publisher simply does not start, which is the safe direction —
118
+ * an observe-only-derived component never crashes the server).
119
+ */
120
+ start(): void;
121
+ /** Stop the cron + cancel a pending catch-up. Idempotent. */
122
+ stop(): void;
123
+ /** Replay a single fire time that elapsed while the box was down/asleep. */
124
+ private catchUp;
125
+ /**
126
+ * Run ONE cadence cycle. PUBLIC so tests (and a future debug route) can drive a
127
+ * cycle deterministically. Never throws — an observe-only-derived component must
128
+ * never crash the server, so every branch is wrapped and audited.
129
+ */
130
+ publishOnce(now: Date, trigger: GrowthDigestTrigger, windowKey?: string): Promise<void>;
131
+ private record;
132
+ /** True if the cadence's two soonest fires are ≥1h apart (or not computable). */
133
+ private cadenceWithinFloor;
134
+ /**
135
+ * The most-recent scheduled fire time at/under `now`. croner's `previousRun()`
136
+ * only tracks the instance's own executions (null on a fresh instance), so we
137
+ * derive the cadence interval from two future `nextRun` probes and scan forward
138
+ * from a bounded lookback to find the last fire ≤ now.
139
+ */
140
+ private previousScheduledFire;
141
+ }
142
+ export interface FormatDigestOptions {
143
+ timezone?: string;
144
+ perRuleCap?: number;
145
+ detailCap?: number;
146
+ }
147
+ /**
148
+ * Render a `GrowthDigest` into ONE compact Telegram message. The analyst already
149
+ * decided what crosses a rule — the formatter only renders it. Guarantees:
150
+ * - Priority-never-truncate: every `priority:'high'` finding and every
151
+ * decision-demanding maturity action (R1 promote, R6 dev-gate-dark) is rendered
152
+ * IN FULL, never capped.
153
+ * - Only the low/normal BULK is capped at `perRuleCap` per rule with a "+N more".
154
+ * - Cap-before-concat: bulk sections stop appending as the running length nears
155
+ * 4096 — the full N-line string is never materialised then sliced.
156
+ * - Render-boundary scrub: every title/detail passes through `scrubSecrets`, and
157
+ * each detail is hard-capped to `detailCap` chars (covers dry-run text too).
158
+ */
159
+ export declare function formatDigest(digest: GrowthDigest, opts?: FormatDigestOptions): string;
160
+ /** Default audit sink + window-reader over logs/growth-digest.jsonl. The publisher
161
+ * injects `audit` (write) and `recordedWindows` (read) from this so the same file
162
+ * is the durable "did we publish this window?" record. */
163
+ export declare function createGrowthDigestAuditSink(stateDir: string): {
164
+ write: (entry: GrowthDigestAuditEntry) => void;
165
+ recordedWindows: () => Set<string>;
166
+ logPath: string;
167
+ };
168
+ //# sourceMappingURL=GrowthDigestPublisher.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"GrowthDigestPublisher.d.ts","sourceRoot":"","sources":["../../src/monitoring/GrowthDigestPublisher.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAMH,OAAO,KAAK,EAAE,YAAY,EAA+B,MAAM,6BAA6B,CAAC;AAE7F,MAAM,MAAM,oBAAoB,GAAG,KAAK,GAAG,SAAS,GAAG,MAAM,CAAC;AAE9D,MAAM,MAAM,mBAAmB,GAAG,MAAM,GAAG,SAAS,GAAG,QAAQ,CAAC;AAEhE,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,OAAO,CAAC;IACZ;mFAC+E;IAC/E,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,sBAAsB;IACrC,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EACF,MAAM,GACN,cAAc,GACd,SAAS,GACT,iBAAiB,GACjB,aAAa,GACb,iBAAiB,GACjB,cAAc,GACd,OAAO,CAAC;IACZ,OAAO,CAAC,EAAE,mBAAmB,CAAC;IAC9B;;qFAEiF;IACjF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,YAAY,CAAC,QAAQ,CAAC,CAAC;IAChC;qDACiD;IACjD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;gEAE4D;IAC5D,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC7B;AAWD,MAAM,WAAW,yBAAyB;IACxC,uEAAuE;IACvE,WAAW,EAAE,CAAC,GAAG,EAAE,IAAI,KAAK,YAAY,CAAC;IACzC,oEAAoE;IACpE,IAAI,EAAE,MAAM,CAAC;IACb,4EAA4E;IAC5E,IAAI,EAAE,oBAAoB,CAAC;IAC3B,iFAAiF;IACjF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,6EAA6E;IAC7E,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B;gFAC4E;IAC5E,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,cAAc,CAAC,CAAC;IACjD,8EAA8E;IAC9E,OAAO,CAAC,EAAE,MAAM,OAAO,CAAC;IACxB,4EAA4E;IAC5E,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE,sBAAsB,KAAK,IAAI,CAAC;IAChD,yEAAyE;IACzE,eAAe,CAAC,EAAE,MAAM,GAAG,CAAC,MAAM,CAAC,CAAC;IACpC,iFAAiF;IACjF,yBAAyB,CAAC,EAAE,MAAM,OAAO,CAAC;IAC1C,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;IACjB,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,KAAK,IAAI,CAAC;IAChD;;4CAEwC;IACxC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,2DAA2D;IAC3D,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,oDAAoD;IACpD,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,qBAAa,qBAAqB;IAChC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAA4B;IACjD,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAS;IAC9B,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAuB;IAC5C,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAU;IAC1C,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IAEnC,OAAO,CAAC,MAAM,CAAC,CAA4C;IAC3D,OAAO,CAAC,QAAQ,CAAqB;IACrC,OAAO,CAAC,WAAW,CAA8C;IACjE,OAAO,CAAC,OAAO,CAAS;gBAEZ,IAAI,EAAE,yBAAyB;IAY3C,OAAO,CAAC,KAAK;IAIb,8EAA8E;IAC9E,YAAY,CAAC,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,cAAc,CAAC,GAAG,IAAI;IAInE;qDACiD;IACjD,SAAS,IAAI,OAAO;IAIpB;;;;;OAKG;IACH,KAAK,IAAI,IAAI;IA6Bb,6DAA6D;IAC7D,IAAI,IAAI,IAAI;IAiBZ,4EAA4E;YAC9D,OAAO;IAgBrB;;;;OAIG;IACG,WAAW,CAAC,GAAG,EAAE,IAAI,EAAE,OAAO,EAAE,mBAAmB,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAwG7F,OAAO,CAAC,MAAM;IAkBd,iFAAiF;IACjF,OAAO,CAAC,kBAAkB;IAgB1B;;;;;OAKG;IACH,OAAO,CAAC,qBAAqB;CAwB9B;AAID,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAeD;;;;;;;;;;;GAWG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,YAAY,EAAE,IAAI,GAAE,mBAAwB,GAAG,MAAM,CAsDzF;AAqCD;;2DAE2D;AAC3D,wBAAgB,2BAA2B,CAAC,QAAQ,EAAE,MAAM,GAAG;IAC7D,KAAK,EAAE,CAAC,KAAK,EAAE,sBAAsB,KAAK,IAAI,CAAC;IAC/C,eAAe,EAAE,MAAM,GAAG,CAAC,MAAM,CAAC,CAAC;IACnC,OAAO,EAAE,MAAM,CAAC;CACjB,CAiCA"}
@@ -0,0 +1,477 @@
1
+ /**
2
+ * GrowthDigestPublisher — Slice 2 of the proactive growth analyst.
3
+ *
4
+ * WHY THIS EXISTS (Justin, 2026-06-06, topic 21624): "I have YET to have an agent
5
+ * proactively check in with me about ANY of these." Slice 1
6
+ * (GrowthMilestoneAnalyst) already COMPUTES the growth picture (R1–R6) and exposes
7
+ * it via read routes — but nothing ever SENDS it. This component is the voice:
8
+ * on a cadence it takes the analyst's already-computed `GrowthDigest`, decides
9
+ * whether there is anything worth saying, formats ONE consolidated "growth
10
+ * check-in," and routes it through the SAME flood-guarded post-update funnel the
11
+ * `/telegram/post-update` route uses.
12
+ *
13
+ * It owns NO analysis. It is a cadence + lease-check + decide-to-speak + format +
14
+ * deliver + audit wrapper. It can never block, delay, or rewrite anything — it
15
+ * only sends a message or stays quiet. Spec:
16
+ * docs/specs/PROACTIVE-GROWTH-DIGEST-PUBLISHER-SLICE2-SPEC.md.
17
+ *
18
+ * Hardened by the Slice-2 convergence review (§9):
19
+ * - MULTI-MACHINE: an in-process croner runs on BOTH the awake and the standby
20
+ * machine, so the digest would double-send. The publisher is lease-gated
21
+ * (`isAwake`) — only the awake machine sends (mirrors the scheduler /
22
+ * ActivitySentinel precedent the superseded job relied on).
23
+ * - SINGLE FUNNEL: the `send` dep is the shared res-free `evaluateOutbound` path
24
+ * (`postToUpdatesTopic`), never a raw `sendToTopic` — one guarded chokepoint,
25
+ * not two.
26
+ * - NO CALM NOISE: a fully-calm week is silent by default
27
+ * (`sendOnCalmWeeks:false`) — a weekly "all healthy" is the exact noise the
28
+ * operator killed burnDetection for.
29
+ * - MISSED-RUN CATCH-UP: croner schedules only the next fire; a fire that elapsed
30
+ * while the box was asleep is replayed once on `.start()` (the proactive
31
+ * check-in must not be silently dropped for a week). Idempotent on the window
32
+ * ISO so a restart loop can't re-fire the same window.
33
+ */
34
+ import { Cron } from 'croner';
35
+ import fs from 'node:fs';
36
+ import path from 'node:path';
37
+ import { scrubSecrets } from './scrubSecrets.js';
38
+ /** Refuse a cadence whose two soonest fires are < 1h apart — `buildDigest` is a
39
+ * synchronous, event-loop-blocking pass; a fat-fingered per-minute cron must not
40
+ * turn an observe-only-derived component into a CPU/disk churner (Scalability S2). */
41
+ const SANITY_FLOOR_MS = 60 * 60 * 1000;
42
+ const DEFAULT_SETTLE_MS = 60 * 1000;
43
+ const DEFAULT_PER_RULE_CAP = 5;
44
+ const DEFAULT_DETAIL_CAP = 200;
45
+ const TELEGRAM_MAX = 4096;
46
+ export class GrowthDigestPublisher {
47
+ deps;
48
+ cron;
49
+ mode;
50
+ timezone;
51
+ sendOnCalmWeeks;
52
+ settleMs;
53
+ perRuleCap;
54
+ detailCap;
55
+ sender;
56
+ cronTask = null;
57
+ settleTimer = null;
58
+ running = false;
59
+ constructor(deps) {
60
+ this.deps = deps;
61
+ this.cron = deps.cron;
62
+ this.mode = deps.mode;
63
+ this.timezone = deps.timezone;
64
+ this.sendOnCalmWeeks = deps.sendOnCalmWeeks === true;
65
+ this.sender = deps.send;
66
+ this.settleMs = deps.settleMs ?? DEFAULT_SETTLE_MS;
67
+ this.perRuleCap = deps.perRuleCap ?? DEFAULT_PER_RULE_CAP;
68
+ this.detailCap = deps.detailCap ?? DEFAULT_DETAIL_CAP;
69
+ }
70
+ nowFn() {
71
+ return this.deps.now ? this.deps.now() : new Date();
72
+ }
73
+ /** Attach the guarded sender after construction (route-registration time). */
74
+ attachSender(send) {
75
+ this.sender = send;
76
+ }
77
+ /** True once the cron task is scheduled (false if refused by the sanity-floor
78
+ * or an invalid cron). Used by wiring tests. */
79
+ isStarted() {
80
+ return this.cronTask !== null;
81
+ }
82
+ /**
83
+ * Schedule the cadence + arm the missed-run catch-up. Idempotent. Refuses a
84
+ * sub-hourly cadence (sanity-floor) and an invalid cron (both logged via
85
+ * onError; the publisher simply does not start, which is the safe direction —
86
+ * an observe-only-derived component never crashes the server).
87
+ */
88
+ start() {
89
+ if (this.cronTask)
90
+ return;
91
+ if (!this.cadenceWithinFloor()) {
92
+ this.deps.onError?.('start', new Error(`digestCron '${this.cron}' fires more often than the 1h sanity-floor — refusing to start`));
93
+ return;
94
+ }
95
+ try {
96
+ this.cronTask = new Cron(this.cron, { timezone: this.timezone, protect: true, unref: true }, () => {
97
+ void this.publishOnce(this.nowFn(), 'cron');
98
+ });
99
+ }
100
+ catch (err) {
101
+ this.deps.onError?.('cron-construct', err);
102
+ this.cronTask = null;
103
+ return;
104
+ }
105
+ // Missed-run catch-up after the settle delay (so the lease has time to settle).
106
+ this.settleTimer = setTimeout(() => {
107
+ void this.catchUp();
108
+ }, this.settleMs);
109
+ if (typeof this.settleTimer.unref === 'function')
110
+ this.settleTimer.unref();
111
+ }
112
+ /** Stop the cron + cancel a pending catch-up. Idempotent. */
113
+ stop() {
114
+ try {
115
+ this.cronTask?.stop();
116
+ }
117
+ catch {
118
+ /* @silent-fallback-ok — teardown is best-effort at shutdown */
119
+ }
120
+ this.cronTask = null;
121
+ if (this.settleTimer) {
122
+ try {
123
+ clearTimeout(this.settleTimer);
124
+ }
125
+ catch {
126
+ /* @silent-fallback-ok — teardown is best-effort at shutdown */
127
+ }
128
+ this.settleTimer = null;
129
+ }
130
+ }
131
+ /** Replay a single fire time that elapsed while the box was down/asleep. */
132
+ async catchUp() {
133
+ const now = this.nowFn();
134
+ const missed = this.previousScheduledFire(now);
135
+ if (!missed)
136
+ return;
137
+ const key = missed.toISOString();
138
+ let recorded;
139
+ try {
140
+ recorded = this.deps.recordedWindows ? this.deps.recordedWindows() : new Set();
141
+ }
142
+ catch (err) {
143
+ this.deps.onError?.('recordedWindows', err);
144
+ recorded = new Set();
145
+ }
146
+ if (recorded.has(key))
147
+ return; // this window was already published/decided
148
+ await this.publishOnce(now, 'catchup', key);
149
+ }
150
+ /**
151
+ * Run ONE cadence cycle. PUBLIC so tests (and a future debug route) can drive a
152
+ * cycle deterministically. Never throws — an observe-only-derived component must
153
+ * never crash the server, so every branch is wrapped and audited.
154
+ */
155
+ async publishOnce(now, trigger, windowKey) {
156
+ // 1. Lease gate (pre-lease check — a standby machine never sends and never
157
+ // consumes the window; the awake machine still owns it).
158
+ let awake = true;
159
+ try {
160
+ awake = this.deps.isAwake ? this.deps.isAwake() : true;
161
+ }
162
+ catch (err) {
163
+ this.deps.onError?.('isAwake', err);
164
+ awake = true; // fail-open toward delivery (the slice's reason to exist)
165
+ }
166
+ if (!awake) {
167
+ this.record({ action: 'skipped-standby', trigger });
168
+ return;
169
+ }
170
+ // Window key for idempotency (the scheduled fire this cycle covers).
171
+ let window = windowKey;
172
+ if (window === undefined) {
173
+ window = this.previousScheduledFire(now)?.toISOString();
174
+ }
175
+ // 2. Mode off (belt — the publisher is only constructed when mode !== 'off').
176
+ if (this.mode === 'off') {
177
+ this.record({ action: 'skipped-off', trigger, window });
178
+ return;
179
+ }
180
+ // 3. In-flight guard (belt-and-suspenders with croner protect:true, because
181
+ // publishOnce is also publicly callable).
182
+ if (this.running) {
183
+ this.record({ action: 'skipped-overlap', trigger, window });
184
+ return;
185
+ }
186
+ this.running = true;
187
+ try {
188
+ // 4. Build the digest (heavy synchronous pass).
189
+ let digest;
190
+ try {
191
+ digest = this.deps.buildDigest(now);
192
+ }
193
+ catch (err) {
194
+ this.deps.onError?.('buildDigest', err);
195
+ // No window recorded → catch-up may retry next boot (safe direction).
196
+ this.record({ action: 'error', trigger, reason: 'build-error' });
197
+ return;
198
+ }
199
+ // 5. Decide to speak.
200
+ if (digest.calm && !this.sendOnCalmWeeks) {
201
+ this.record({ action: 'skipped-calm', trigger, window, counts: digest.counts });
202
+ return;
203
+ }
204
+ // 6. Format (scrubbed + capped + clamped).
205
+ let text;
206
+ try {
207
+ text = formatDigest(digest, {
208
+ timezone: this.timezone,
209
+ perRuleCap: this.perRuleCap,
210
+ detailCap: this.detailCap,
211
+ });
212
+ }
213
+ catch (err) {
214
+ this.deps.onError?.('formatDigest', err);
215
+ this.record({ action: 'error', trigger, reason: 'format-error' });
216
+ return;
217
+ }
218
+ // 7. Dry-run — record the would-send sample, never send.
219
+ if (this.mode === 'dry-run') {
220
+ this.record({ action: 'dry-run', trigger, window, counts: digest.counts, wouldSend: text });
221
+ return;
222
+ }
223
+ // 8. Live — go through the shared guarded funnel.
224
+ let result;
225
+ try {
226
+ result = this.sender ? await this.sender(text) : { ok: false, reason: 'no-sender' };
227
+ }
228
+ catch (err) {
229
+ this.deps.onError?.('send', err);
230
+ result = { ok: false, reason: 'send-threw' };
231
+ }
232
+ const entry = {
233
+ ts: now.toISOString(),
234
+ action: result.ok ? 'sent' : 'send-blocked',
235
+ trigger,
236
+ window,
237
+ reason: result.reason,
238
+ counts: digest.counts,
239
+ };
240
+ // §3.5 belt: a SIGNAL (never a mutation) that the old voice is still on.
241
+ if (result.ok && this.deps.supersededJobStillEnabled) {
242
+ try {
243
+ if (this.deps.supersededJobStillEnabled())
244
+ entry.supersedeConflict = true;
245
+ }
246
+ catch (err) {
247
+ this.deps.onError?.('supersededJobStillEnabled', err);
248
+ }
249
+ }
250
+ this.record(entry);
251
+ }
252
+ finally {
253
+ this.running = false;
254
+ }
255
+ }
256
+ // ── helpers ───────────────────────────────────────────────────────────────
257
+ record(e) {
258
+ const entry = {
259
+ ts: e.ts ?? this.nowFn().toISOString(),
260
+ action: e.action,
261
+ ...(e.trigger ? { trigger: e.trigger } : {}),
262
+ ...(e.window ? { window: e.window } : {}),
263
+ ...(e.reason ? { reason: e.reason } : {}),
264
+ ...(e.counts ? { counts: e.counts } : {}),
265
+ ...(e.wouldSend ? { wouldSend: e.wouldSend } : {}),
266
+ ...(e.supersedeConflict ? { supersedeConflict: true } : {}),
267
+ };
268
+ try {
269
+ this.deps.audit?.(entry);
270
+ }
271
+ catch (err) {
272
+ this.deps.onError?.('audit', err);
273
+ }
274
+ }
275
+ /** True if the cadence's two soonest fires are ≥1h apart (or not computable). */
276
+ cadenceWithinFloor() {
277
+ try {
278
+ const c = new Cron(this.cron, this.timezone ? { timezone: this.timezone } : {});
279
+ const f1 = c.nextRun(this.nowFn());
280
+ if (!f1)
281
+ return true;
282
+ const f2 = c.nextRun(f1);
283
+ if (!f2)
284
+ return true;
285
+ return f2.getTime() - f1.getTime() >= SANITY_FLOOR_MS;
286
+ }
287
+ catch {
288
+ // @silent-fallback-ok — an invalid cron is re-caught + logged by start()'s
289
+ // own `new Cron` (the authoritative report path); returning true here just
290
+ // defers to it. Not a degradation — the floor check is a guard, not a sink.
291
+ return true;
292
+ }
293
+ }
294
+ /**
295
+ * The most-recent scheduled fire time at/under `now`. croner's `previousRun()`
296
+ * only tracks the instance's own executions (null on a fresh instance), so we
297
+ * derive the cadence interval from two future `nextRun` probes and scan forward
298
+ * from a bounded lookback to find the last fire ≤ now.
299
+ */
300
+ previousScheduledFire(now) {
301
+ try {
302
+ const c = new Cron(this.cron, this.timezone ? { timezone: this.timezone } : {});
303
+ const f1 = c.nextRun(now);
304
+ if (!f1)
305
+ return null;
306
+ const f2 = c.nextRun(f1);
307
+ const intervalMs = f2 ? f2.getTime() - f1.getTime() : 7 * 86_400_000;
308
+ const probe = new Date(now.getTime() - intervalMs * 2 - 60_000);
309
+ let last = null;
310
+ let n = c.nextRun(probe);
311
+ let guard = 0;
312
+ while (n && n.getTime() <= now.getTime() && guard++ < 5000) {
313
+ last = n;
314
+ n = c.nextRun(n);
315
+ }
316
+ return last;
317
+ }
318
+ catch (err) {
319
+ // @silent-fallback-ok — onError-surfaced; an uncomputable previous fire only
320
+ // skips one catch-up (the safe direction for this observe-only-derived
321
+ // publisher), never a wrong action. The weekly cron still fires normally.
322
+ this.deps.onError?.('previousScheduledFire', err);
323
+ return null;
324
+ }
325
+ }
326
+ }
327
+ const RULE_ORDER = ['R1', 'R6', 'R2', 'R3', 'R4', 'R5'];
328
+ const RULE_HEADER = {
329
+ R1: '🔸 Ready to promote',
330
+ R2: '🔸 Incubation expired (unproven)',
331
+ R3: '🔸 Stalling — waiting on you / drifting',
332
+ R4: '🔸 Spec patterns',
333
+ R5: '🔸 Recurring corrections',
334
+ R6: '🔸 Dev-gated features still dark',
335
+ };
336
+ const FOOTER = 'Read the full digest anytime: GET /growth/digest (or the dashboard).';
337
+ /**
338
+ * Render a `GrowthDigest` into ONE compact Telegram message. The analyst already
339
+ * decided what crosses a rule — the formatter only renders it. Guarantees:
340
+ * - Priority-never-truncate: every `priority:'high'` finding and every
341
+ * decision-demanding maturity action (R1 promote, R6 dev-gate-dark) is rendered
342
+ * IN FULL, never capped.
343
+ * - Only the low/normal BULK is capped at `perRuleCap` per rule with a "+N more".
344
+ * - Cap-before-concat: bulk sections stop appending as the running length nears
345
+ * 4096 — the full N-line string is never materialised then sliced.
346
+ * - Render-boundary scrub: every title/detail passes through `scrubSecrets`, and
347
+ * each detail is hard-capped to `detailCap` chars (covers dry-run text too).
348
+ */
349
+ export function formatDigest(digest, opts = {}) {
350
+ const perRuleCap = opts.perRuleCap ?? DEFAULT_PER_RULE_CAP;
351
+ const detailCap = opts.detailCap ?? DEFAULT_DETAIL_CAP;
352
+ const header = `📊 Growth check-in — ${formatHeaderDate(digest.generatedAt, opts.timezone)}`;
353
+ const summary = scrubSecrets(digest.summary);
354
+ // Calm digest: header + summary only (the summary already carries the changing
355
+ // incubating count + next-window-closes, so it is not byte-identical week/week).
356
+ if (digest.calm || digest.findings.length === 0) {
357
+ return clampToTelegram([header, '', summary].join('\n'));
358
+ }
359
+ // "Always full" = high-priority + decision-demanding (R1/R6). The rest is the
360
+ // cappable bulk (R3 stalling is the volume driver).
361
+ const alwaysFull = digest.findings.filter(isAlwaysFull);
362
+ const bulk = digest.findings.filter((f) => !isAlwaysFull(f));
363
+ const parts = [header, '', summary];
364
+ // Mandatory sections first (never capped, never dropped).
365
+ for (const rule of RULE_ORDER) {
366
+ const rows = alwaysFull.filter((f) => f.rule === rule);
367
+ if (rows.length === 0)
368
+ continue;
369
+ parts.push('', RULE_HEADER[rule] + ':');
370
+ for (const f of rows)
371
+ parts.push(renderFinding(f, detailCap));
372
+ }
373
+ // Bulk sections — capped per rule, and cap-before-concat against 4096.
374
+ const footerReserve = FOOTER.length + 8;
375
+ let truncatedBulk = false;
376
+ for (const rule of RULE_ORDER) {
377
+ const rows = bulk.filter((f) => f.rule === rule);
378
+ if (rows.length === 0)
379
+ continue;
380
+ const sectionLines = ['', RULE_HEADER[rule] + ':'];
381
+ const shown = rows.slice(0, perRuleCap);
382
+ for (const f of shown)
383
+ sectionLines.push(renderFinding(f, detailCap));
384
+ if (rows.length > perRuleCap) {
385
+ sectionLines.push(` +${rows.length - perRuleCap} more (see full digest)`);
386
+ }
387
+ const projected = parts.join('\n').length + sectionLines.join('\n').length + 1 + footerReserve;
388
+ if (projected > TELEGRAM_MAX) {
389
+ truncatedBulk = true;
390
+ break;
391
+ }
392
+ parts.push(...sectionLines);
393
+ }
394
+ if (truncatedBulk) {
395
+ parts.push('', '…(more findings — full digest at /growth/digest)');
396
+ }
397
+ parts.push('', FOOTER);
398
+ return clampToTelegram(parts.join('\n'));
399
+ }
400
+ function isAlwaysFull(f) {
401
+ return f.priority === 'high' || f.rule === 'R1' || f.rule === 'R6';
402
+ }
403
+ function renderFinding(f, detailCap) {
404
+ const title = scrubSecrets(f.title);
405
+ let detail = scrubSecrets(f.detail);
406
+ if (detail.length > detailCap)
407
+ detail = detail.slice(0, detailCap - 1) + '…';
408
+ return `• ${title} — ${detail}`;
409
+ }
410
+ function formatHeaderDate(iso, timezone) {
411
+ const d = new Date(iso);
412
+ if (!Number.isFinite(d.getTime()))
413
+ return iso;
414
+ try {
415
+ return new Intl.DateTimeFormat('en-US', {
416
+ weekday: 'short',
417
+ month: 'short',
418
+ day: 'numeric',
419
+ year: 'numeric',
420
+ timeZone: timezone || 'UTC',
421
+ }).format(d);
422
+ }
423
+ catch {
424
+ return d.toISOString().slice(0, 10);
425
+ }
426
+ }
427
+ /** Hard-clamp to Telegram's 4096 limit. Only engages if the mandatory (always-full)
428
+ * set alone somehow exceeds it — bulk is already cap-before-concat'd. */
429
+ function clampToTelegram(text) {
430
+ if (text.length <= TELEGRAM_MAX)
431
+ return text;
432
+ const note = '\n…(truncated — full digest at /growth/digest)';
433
+ return text.slice(0, TELEGRAM_MAX - note.length) + note;
434
+ }
435
+ /** Default audit sink + window-reader over logs/growth-digest.jsonl. The publisher
436
+ * injects `audit` (write) and `recordedWindows` (read) from this so the same file
437
+ * is the durable "did we publish this window?" record. */
438
+ export function createGrowthDigestAuditSink(stateDir) {
439
+ const logPath = path.join(stateDir, 'logs', 'growth-digest.jsonl');
440
+ return {
441
+ logPath,
442
+ write(entry) {
443
+ try {
444
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
445
+ fs.appendFileSync(logPath, JSON.stringify(entry) + '\n');
446
+ }
447
+ catch {
448
+ /* never throw from the audit sink */
449
+ }
450
+ },
451
+ recordedWindows() {
452
+ const out = new Set();
453
+ let raw;
454
+ try {
455
+ raw = fs.readFileSync(logPath, 'utf-8');
456
+ }
457
+ catch {
458
+ return out; // no log yet → no windows decided
459
+ }
460
+ for (const line of raw.split('\n')) {
461
+ const t = line.trim();
462
+ if (!t)
463
+ continue;
464
+ try {
465
+ const e = JSON.parse(t);
466
+ if (e && typeof e.window === 'string')
467
+ out.add(e.window);
468
+ }
469
+ catch {
470
+ /* skip a corrupt/partial line */
471
+ }
472
+ }
473
+ return out;
474
+ },
475
+ };
476
+ }
477
+ //# sourceMappingURL=GrowthDigestPublisher.js.map