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.
- package/dist/config/ConfigDefaults.d.ts.map +1 -1
- package/dist/config/ConfigDefaults.js +7 -0
- package/dist/config/ConfigDefaults.js.map +1 -1
- package/dist/core/types.d.ts +13 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/monitoring/GrowthDigestPublisher.d.ts +168 -0
- package/dist/monitoring/GrowthDigestPublisher.d.ts.map +1 -0
- package/dist/monitoring/GrowthDigestPublisher.js +477 -0
- package/dist/monitoring/GrowthDigestPublisher.js.map +1 -0
- package/dist/server/AgentServer.d.ts +1 -0
- package/dist/server/AgentServer.d.ts.map +1 -1
- package/dist/server/AgentServer.js +67 -0
- package/dist/server/AgentServer.js.map +1 -1
- package/dist/server/routes.d.ts +5 -0
- package/dist/server/routes.d.ts.map +1 -1
- package/dist/server/routes.js +95 -71
- package/dist/server/routes.js.map +1 -1
- package/package.json +1 -1
- package/scripts/lib/throwaway-identity.mjs +150 -0
- package/scripts/throwaway-identity.mjs +77 -0
- package/src/data/builtin-manifest.json +47 -47
- package/upgrades/1.3.475.md +66 -0
- package/upgrades/1.3.476.md +33 -0
- package/upgrades/side-effects/proactive-growth-digest-publisher-slice2.md +201 -0
- package/upgrades/side-effects/throwaway-identity-helper.md +48 -0
|
@@ -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":"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"}
|
|
@@ -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
|