@vauban-org/agent-sdk 1.7.0 → 1.9.0

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,114 @@
1
+ /**
2
+ * quality/index.ts — Outcome quality scoring (ADR-ECO-039).
3
+ *
4
+ * Promoted from forge consumer in SDK 1.8.0 — see Brief 2026-05-17.
5
+ *
6
+ * Companion to {@link OutcomeRecord.quality}. Each agent's
7
+ * `outcomeMapping(feedback)` returns a normalized quality score (0..1)
8
+ * computed from feedback signals. The Command Center backend reads this
9
+ * into the `agent_run.outcome_quality` column (NUMERIC(5,4)), which
10
+ * powers the EconomicObserver ROI weighting and the Quality Foreman
11
+ * regression checks.
12
+ *
13
+ * Design notes:
14
+ * - Pure function (no I/O, no SDK imports beyond types) — safe to import
15
+ * from any agent and cheap to unit-test.
16
+ * - Signals are agent-agnostic — each agent maps its own feedback fields
17
+ * into a shared {@link QualityInputs} shape.
18
+ * - Default score is `0.5` ("neutral, no information"). Signals nudge it
19
+ * up (positive outcomes) or down (errors). Final value is clamped to
20
+ * `[0, 1]`.
21
+ * - `customScore` lets an agent provide its own pre-computed score (e.g.
22
+ * a model-based eval) and bypass the heuristic.
23
+ * - Prod-validated on 33 forge agents (ADR-ECO-039 rollout 2026-05).
24
+ *
25
+ * @public
26
+ */
27
+ /**
28
+ * Feedback signals consumed by {@link computeQuality}. All fields are
29
+ * optional — pass only the signals the calling agent emits.
30
+ *
31
+ * @public
32
+ */
33
+ export interface QualityInputs {
34
+ /** Sentinel-style: number of threats blocked (e.g. paused contracts). */
35
+ readonly threatsBlocked?: number;
36
+ /** Sentinel/ops-style: alerts sent to operators. */
37
+ readonly alertsSent?: number;
38
+ /** Content-style: number of posts/articles successfully published. */
39
+ readonly postsPublished?: number;
40
+ /** Content-style: posts blocked by HITL or constitutional gate. */
41
+ readonly postsRejectedByHITL?: number;
42
+ /** Finance/treasury-style: invoices or proposals processed successfully. */
43
+ readonly invoicesProcessed?: number;
44
+ /** Generic: number of errors encountered during the cycle. */
45
+ readonly errorsEncountered?: number;
46
+ /** Lessons / retrospective bullets extracted (proxy for reflection depth). */
47
+ readonly lessons?: readonly unknown[];
48
+ /** Explicit override — bypasses the heuristic when defined. */
49
+ readonly customScore?: number;
50
+ }
51
+ /**
52
+ * Per-signal contribution to the final score. Returned by
53
+ * {@link computeQualityWithBreakdown}.
54
+ *
55
+ * @public
56
+ */
57
+ export interface QualityContribution {
58
+ /** Source signal name (matches a {@link QualityInputs} field). */
59
+ readonly signal: string;
60
+ /** Signed delta applied to the running score (positive or negative). */
61
+ readonly delta: number;
62
+ /** Human-readable explanation, e.g. `"1 post published"`. */
63
+ readonly reason: string;
64
+ }
65
+ /**
66
+ * Breakdown view of {@link computeQuality} — score plus the ordered list of
67
+ * non-zero contributions. Powers debug-grade observability surfaces
68
+ * (e.g. `/agents/[id]` quality trend tooltip).
69
+ *
70
+ * @public
71
+ */
72
+ export interface QualityBreakdown {
73
+ /** Final clamped score in `[0, 1]`. */
74
+ readonly score: number;
75
+ /** Contributions in evaluation order. Empty when no signal applies. */
76
+ readonly contributions: readonly QualityContribution[];
77
+ }
78
+ /**
79
+ * Compute a 0..1 quality score from agent feedback signals.
80
+ *
81
+ * Defaults to `0.5` when no signal is provided. Each positive signal nudges
82
+ * the score upward; errors and HITL rejections nudge it downward. The
83
+ * result is clamped to `[0, 1]`.
84
+ *
85
+ * @example
86
+ * computeQuality({ postsPublished: 1 }) // → 0.7
87
+ * computeQuality({ errorsEncountered: 1 }) // → 0.3
88
+ * computeQuality({ customScore: 0.95 }) // → 0.95
89
+ * computeQuality({}) // → 0.5
90
+ *
91
+ * @public
92
+ */
93
+ export declare function computeQuality(inputs: QualityInputs): number;
94
+ /**
95
+ * Same heuristic as {@link computeQuality}, but also returns the ordered
96
+ * list of non-zero contributions for debug-grade observability.
97
+ *
98
+ * When `customScore` is provided, the breakdown contains a single entry
99
+ * tagged `"customScore"` and the heuristic is bypassed.
100
+ *
101
+ * @example
102
+ * computeQualityWithBreakdown({ postsPublished: 1, lessons: ["a"] })
103
+ * // → {
104
+ * // score: 0.75,
105
+ * // contributions: [
106
+ * // { signal: "postsPublished", delta: 0.2, reason: "1 post published" },
107
+ * // { signal: "lessons", delta: 0.05, reason: "1 lesson extracted" },
108
+ * // ],
109
+ * // }
110
+ *
111
+ * @public
112
+ */
113
+ export declare function computeQualityWithBreakdown(inputs: QualityInputs): QualityBreakdown;
114
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/quality/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH;;;;;GAKG;AACH,MAAM,WAAW,aAAa;IAC5B,yEAAyE;IACzE,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC,oDAAoD;IACpD,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,sEAAsE;IACtE,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC,mEAAmE;IACnE,QAAQ,CAAC,mBAAmB,CAAC,EAAE,MAAM,CAAC;IACtC,4EAA4E;IAC5E,QAAQ,CAAC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IACpC,8DAA8D;IAC9D,QAAQ,CAAC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IACpC,8EAA8E;IAC9E,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,OAAO,EAAE,CAAC;IACtC,+DAA+D;IAC/D,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC/B;AAED;;;;;GAKG;AACH,MAAM,WAAW,mBAAmB;IAClC,kEAAkE;IAClE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,wEAAwE;IACxE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,6DAA6D;IAC7D,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;GAMG;AACH,MAAM,WAAW,gBAAgB;IAC/B,uCAAuC;IACvC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,uEAAuE;IACvE,QAAQ,CAAC,aAAa,EAAE,SAAS,mBAAmB,EAAE,CAAC;CACxD;AAOD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,aAAa,GAAG,MAAM,CAiB5D;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,2BAA2B,CACzC,MAAM,EAAE,aAAa,GACpB,gBAAgB,CAyFlB"}
@@ -0,0 +1,168 @@
1
+ /**
2
+ * quality/index.ts — Outcome quality scoring (ADR-ECO-039).
3
+ *
4
+ * Promoted from forge consumer in SDK 1.8.0 — see Brief 2026-05-17.
5
+ *
6
+ * Companion to {@link OutcomeRecord.quality}. Each agent's
7
+ * `outcomeMapping(feedback)` returns a normalized quality score (0..1)
8
+ * computed from feedback signals. The Command Center backend reads this
9
+ * into the `agent_run.outcome_quality` column (NUMERIC(5,4)), which
10
+ * powers the EconomicObserver ROI weighting and the Quality Foreman
11
+ * regression checks.
12
+ *
13
+ * Design notes:
14
+ * - Pure function (no I/O, no SDK imports beyond types) — safe to import
15
+ * from any agent and cheap to unit-test.
16
+ * - Signals are agent-agnostic — each agent maps its own feedback fields
17
+ * into a shared {@link QualityInputs} shape.
18
+ * - Default score is `0.5` ("neutral, no information"). Signals nudge it
19
+ * up (positive outcomes) or down (errors). Final value is clamped to
20
+ * `[0, 1]`.
21
+ * - `customScore` lets an agent provide its own pre-computed score (e.g.
22
+ * a model-based eval) and bypass the heuristic.
23
+ * - Prod-validated on 33 forge agents (ADR-ECO-039 rollout 2026-05).
24
+ *
25
+ * @public
26
+ */
27
+ function clamp01(n) {
28
+ if (!Number.isFinite(n))
29
+ return 0.5;
30
+ return Math.max(0, Math.min(1, n));
31
+ }
32
+ /**
33
+ * Compute a 0..1 quality score from agent feedback signals.
34
+ *
35
+ * Defaults to `0.5` when no signal is provided. Each positive signal nudges
36
+ * the score upward; errors and HITL rejections nudge it downward. The
37
+ * result is clamped to `[0, 1]`.
38
+ *
39
+ * @example
40
+ * computeQuality({ postsPublished: 1 }) // → 0.7
41
+ * computeQuality({ errorsEncountered: 1 }) // → 0.3
42
+ * computeQuality({ customScore: 0.95 }) // → 0.95
43
+ * computeQuality({}) // → 0.5
44
+ *
45
+ * @public
46
+ */
47
+ export function computeQuality(inputs) {
48
+ if (typeof inputs.customScore === "number") {
49
+ return clamp01(inputs.customScore);
50
+ }
51
+ let q = 0.5;
52
+ if ((inputs.errorsEncountered ?? 0) > 0)
53
+ q -= 0.2;
54
+ if ((inputs.postsRejectedByHITL ?? 0) > 0)
55
+ q -= 0.1;
56
+ if ((inputs.postsPublished ?? 0) > 0)
57
+ q += 0.2;
58
+ if ((inputs.threatsBlocked ?? 0) > 0)
59
+ q += 0.3;
60
+ if ((inputs.alertsSent ?? 0) > 0)
61
+ q += 0.1;
62
+ if ((inputs.invoicesProcessed ?? 0) > 0)
63
+ q += 0.2;
64
+ if ((inputs.lessons?.length ?? 0) > 0)
65
+ q += 0.05;
66
+ return clamp01(q);
67
+ }
68
+ /**
69
+ * Same heuristic as {@link computeQuality}, but also returns the ordered
70
+ * list of non-zero contributions for debug-grade observability.
71
+ *
72
+ * When `customScore` is provided, the breakdown contains a single entry
73
+ * tagged `"customScore"` and the heuristic is bypassed.
74
+ *
75
+ * @example
76
+ * computeQualityWithBreakdown({ postsPublished: 1, lessons: ["a"] })
77
+ * // → {
78
+ * // score: 0.75,
79
+ * // contributions: [
80
+ * // { signal: "postsPublished", delta: 0.2, reason: "1 post published" },
81
+ * // { signal: "lessons", delta: 0.05, reason: "1 lesson extracted" },
82
+ * // ],
83
+ * // }
84
+ *
85
+ * @public
86
+ */
87
+ export function computeQualityWithBreakdown(inputs) {
88
+ if (typeof inputs.customScore === "number") {
89
+ const clamped = clamp01(inputs.customScore);
90
+ return {
91
+ score: clamped,
92
+ contributions: [
93
+ {
94
+ signal: "customScore",
95
+ delta: clamped - 0.5,
96
+ reason: `customScore override = ${inputs.customScore}`,
97
+ },
98
+ ],
99
+ };
100
+ }
101
+ let q = 0.5;
102
+ const contributions = [];
103
+ const errors = inputs.errorsEncountered ?? 0;
104
+ if (errors > 0) {
105
+ q -= 0.2;
106
+ contributions.push({
107
+ signal: "errorsEncountered",
108
+ delta: -0.2,
109
+ reason: `${errors} error${errors === 1 ? "" : "s"} encountered`,
110
+ });
111
+ }
112
+ const rejects = inputs.postsRejectedByHITL ?? 0;
113
+ if (rejects > 0) {
114
+ q -= 0.1;
115
+ contributions.push({
116
+ signal: "postsRejectedByHITL",
117
+ delta: -0.1,
118
+ reason: `${rejects} post${rejects === 1 ? "" : "s"} rejected by HITL`,
119
+ });
120
+ }
121
+ const posts = inputs.postsPublished ?? 0;
122
+ if (posts > 0) {
123
+ q += 0.2;
124
+ contributions.push({
125
+ signal: "postsPublished",
126
+ delta: 0.2,
127
+ reason: `${posts} post${posts === 1 ? "" : "s"} published`,
128
+ });
129
+ }
130
+ const threats = inputs.threatsBlocked ?? 0;
131
+ if (threats > 0) {
132
+ q += 0.3;
133
+ contributions.push({
134
+ signal: "threatsBlocked",
135
+ delta: 0.3,
136
+ reason: `${threats} threat${threats === 1 ? "" : "s"} blocked`,
137
+ });
138
+ }
139
+ const alerts = inputs.alertsSent ?? 0;
140
+ if (alerts > 0) {
141
+ q += 0.1;
142
+ contributions.push({
143
+ signal: "alertsSent",
144
+ delta: 0.1,
145
+ reason: `${alerts} alert${alerts === 1 ? "" : "s"} sent`,
146
+ });
147
+ }
148
+ const invoices = inputs.invoicesProcessed ?? 0;
149
+ if (invoices > 0) {
150
+ q += 0.2;
151
+ contributions.push({
152
+ signal: "invoicesProcessed",
153
+ delta: 0.2,
154
+ reason: `${invoices} invoice${invoices === 1 ? "" : "s"} processed`,
155
+ });
156
+ }
157
+ const lessonCount = inputs.lessons?.length ?? 0;
158
+ if (lessonCount > 0) {
159
+ q += 0.05;
160
+ contributions.push({
161
+ signal: "lessons",
162
+ delta: 0.05,
163
+ reason: `${lessonCount} lesson${lessonCount === 1 ? "" : "s"} extracted`,
164
+ });
165
+ }
166
+ return { score: clamp01(q), contributions };
167
+ }
168
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/quality/index.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vauban-org/agent-sdk",
3
- "version": "1.7.0",
3
+ "version": "1.9.0",
4
4
  "description": "Vauban agent primitives: loop, budget, routing, HITL, permissions, tracking, durable execution",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -0,0 +1,260 @@
1
+ /**
2
+ * alerts/digest.ts — Adaptive notification batching (SDK 1.9.0).
3
+ *
4
+ * Promoted from forge's `src/agents/23-security-auditor/agent.ts`
5
+ * `buildEscalationMessages()` after a 47-alert Telegram flood during the
6
+ * first Dependabot-enabled cycle (2026-05-17). Generalized so any agent
7
+ * dispatching bulk operator alerts (devops, finance, devrel, inbox-monitor,
8
+ * security-auditor) can adopt it without re-implementing the
9
+ * threshold/sort/cap logic.
10
+ *
11
+ * Design notes:
12
+ * - Pure function — no I/O, no transport dependency. Caller dispatches the
13
+ * returned strings to Telegram/Slack/email/whatever channel.
14
+ * - 0 items → no message at all (empty array).
15
+ * - 1..threshold items → one full-detail message per item.
16
+ * - More than threshold items → one digest message, sorted by severity
17
+ * (configurable order), capped at `maxChars`, with truncation footer +
18
+ * optional details pointer.
19
+ * - Severities outside the configured order are appended last with a
20
+ * default bullet emoji.
21
+ *
22
+ * @public
23
+ */
24
+
25
+ /**
26
+ * A single alert item fed to {@link buildAlertDigest}. All fields except
27
+ * `severity` and `title` are optional — render skips empty lines.
28
+ *
29
+ * @public
30
+ */
31
+ export interface AlertItem {
32
+ /**
33
+ * Severity bucket. Built-in semantics: `"critical" | "high" | "medium" |
34
+ * "low"`. Any other string is accepted and ranked according to
35
+ * {@link AlertDigestOptions.severityOrder} (or appended last with a
36
+ * default emoji when not listed).
37
+ */
38
+ readonly severity:
39
+ | "critical"
40
+ | "high"
41
+ | "medium"
42
+ | "low"
43
+ | (string & Record<never, never>);
44
+ /** Human-readable headline (rendered after the emoji). */
45
+ readonly title: string;
46
+ /** Affected component / repo / file (e.g. `"vauban-org/forge:axios"`). */
47
+ readonly component?: string;
48
+ /** External reference (e.g. `"CVE-2026-0001"`). */
49
+ readonly ref?: string;
50
+ /** Remediation hint shown in individual messages. */
51
+ readonly remediation?: string;
52
+ /** Direct URL for follow-up (shown in individual messages). */
53
+ readonly url?: string;
54
+ }
55
+
56
+ /**
57
+ * Options for {@link buildAlertDigest}. All fields optional with sensible
58
+ * defaults matching forge's prod-validated security-auditor behaviour.
59
+ *
60
+ * @public
61
+ */
62
+ export interface AlertDigestOptions {
63
+ /**
64
+ * Header prefix label (e.g. `"SECURITY"`, `"DEVOPS"`). Default
65
+ * `"ALERT"`. Appears in the digest header — ignored in per-item mode.
66
+ */
67
+ readonly label?: string;
68
+ /**
69
+ * Cutoff between per-item and digest modes. `<=` threshold yields N
70
+ * messages, `>` threshold yields a single digest. Default `3`.
71
+ */
72
+ readonly threshold?: number;
73
+ /**
74
+ * Hard cap on the rendered digest length (chars). Default `4000`
75
+ * (Telegram-safe — Telegram limit is 4096).
76
+ */
77
+ readonly maxChars?: number;
78
+ /**
79
+ * Pointer to detailed source (e.g. `"query Brain category=forge_alert"`).
80
+ * Appended on a final line of every digest message when provided.
81
+ */
82
+ readonly detailsPointer?: string;
83
+ /**
84
+ * Severity sort order, highest priority first. Default
85
+ * `["critical", "high", "medium", "low"]`. Severities not listed go
86
+ * after listed ones, in input order.
87
+ */
88
+ readonly severityOrder?: readonly string[];
89
+ /**
90
+ * Per-severity emoji map. Default
91
+ * `{ critical: "🚨", high: "⚠️", medium: "ℹ️", low: "🔵" }`. Missing
92
+ * severities fall back to `"•"`.
93
+ */
94
+ readonly emojiMap?: Readonly<Record<string, string>>;
95
+ }
96
+
97
+ const DEFAULT_THRESHOLD = 3;
98
+ const DEFAULT_MAX_CHARS = 4000;
99
+ const DEFAULT_LABEL = "ALERT";
100
+ const DEFAULT_SEVERITY_ORDER: readonly string[] = [
101
+ "critical",
102
+ "high",
103
+ "medium",
104
+ "low",
105
+ ];
106
+ const DEFAULT_EMOJI_MAP: Readonly<Record<string, string>> = {
107
+ critical: "🚨",
108
+ high: "⚠️",
109
+ medium: "ℹ️",
110
+ low: "🔵",
111
+ };
112
+ const FALLBACK_EMOJI = "•";
113
+
114
+ function emojiFor(
115
+ severity: string,
116
+ map: Readonly<Record<string, string>>
117
+ ): string {
118
+ return map[severity] ?? FALLBACK_EMOJI;
119
+ }
120
+
121
+ function severityRank(severity: string, order: readonly string[]): number {
122
+ const idx = order.indexOf(severity);
123
+ return idx === -1 ? order.length : idx;
124
+ }
125
+
126
+ function renderIndividual(
127
+ item: AlertItem,
128
+ emojiMap: Readonly<Record<string, string>>
129
+ ): string {
130
+ const emoji = emojiFor(item.severity, emojiMap);
131
+ const ref = item.ref ? ` [${item.ref}]` : "";
132
+ const lines: string[] = [`${emoji} ${item.title}${ref}`];
133
+ if (item.component) lines.push(`Component: ${item.component}`);
134
+ if (item.remediation) lines.push(`Remediation: ${item.remediation}`);
135
+ if (item.url) lines.push(item.url);
136
+ return lines.join("\n");
137
+ }
138
+
139
+ function buildHeader(
140
+ label: string,
141
+ total: number,
142
+ counts: ReadonlyMap<string, number>,
143
+ order: readonly string[],
144
+ emojiMap: Readonly<Record<string, string>>
145
+ ): string {
146
+ const breakdown: string[] = [];
147
+ for (const sev of order) {
148
+ const c = counts.get(sev) ?? 0;
149
+ if (c > 0) breakdown.push(`${c} ${sev}`);
150
+ }
151
+ const topSev = order.find((s) => (counts.get(s) ?? 0) > 0);
152
+ const headEmoji = topSev ? emojiFor(topSev, emojiMap) : FALLBACK_EMOJI;
153
+ const breakdownStr = breakdown.length > 0 ? ` (${breakdown.join(", ")})` : "";
154
+ return `${headEmoji} ${label} — ${total} items${breakdownStr}`;
155
+ }
156
+
157
+ /**
158
+ * Build an adaptive alert digest from a list of items.
159
+ *
160
+ * - 0 items → `[]` (no message at all).
161
+ * - 1..threshold items → one message per item (full detail preserved).
162
+ * - More than threshold → one digest message, severity-sorted, capped at
163
+ * `maxChars`, with truncation footer `"… +K more"` when items overflow,
164
+ * followed by `"Details: <detailsPointer>"` when a pointer is provided.
165
+ *
166
+ * Header counts only non-zero severities, in the configured order.
167
+ * Severities not in `severityOrder` are appended last with a default
168
+ * bullet emoji `"•"` (or a user-provided emoji from `emojiMap`).
169
+ *
170
+ * @example
171
+ * buildAlertDigest([]) // → []
172
+ * buildAlertDigest([critical, high, low]) // → 3 messages
173
+ * buildAlertDigest(arrayOf(50, "high"), {
174
+ * label: "DEPENDABOT",
175
+ * detailsPointer: "query Brain category=forge_alert tag=security",
176
+ * }) // → 1 digest message
177
+ *
178
+ * @public
179
+ */
180
+ export function buildAlertDigest(
181
+ items: readonly AlertItem[],
182
+ opts: AlertDigestOptions = {}
183
+ ): string[] {
184
+ if (items.length === 0) return [];
185
+
186
+ const threshold = opts.threshold ?? DEFAULT_THRESHOLD;
187
+ const maxChars = opts.maxChars ?? DEFAULT_MAX_CHARS;
188
+ const label = opts.label ?? DEFAULT_LABEL;
189
+ const severityOrder = opts.severityOrder ?? DEFAULT_SEVERITY_ORDER;
190
+ const emojiMap = opts.emojiMap ?? DEFAULT_EMOJI_MAP;
191
+ const detailsPointer = opts.detailsPointer;
192
+
193
+ if (items.length <= threshold) {
194
+ return items.map((it) => renderIndividual(it, emojiMap));
195
+ }
196
+
197
+ // Sort copy: by severityOrder rank, then preserve input order (stable).
198
+ const sorted = items
199
+ .map((it, idx) => ({ it, idx }))
200
+ .sort((a, b) => {
201
+ const ra = severityRank(a.it.severity, severityOrder);
202
+ const rb = severityRank(b.it.severity, severityOrder);
203
+ if (ra !== rb) return ra - rb;
204
+ return a.idx - b.idx;
205
+ })
206
+ .map((x) => x.it);
207
+
208
+ const counts = new Map<string, number>();
209
+ for (const it of sorted) {
210
+ counts.set(it.severity, (counts.get(it.severity) ?? 0) + 1);
211
+ }
212
+
213
+ const header = buildHeader(
214
+ label,
215
+ items.length,
216
+ counts,
217
+ severityOrder,
218
+ emojiMap
219
+ );
220
+ const footerLines: string[] = [];
221
+ if (detailsPointer) footerLines.push(`Details: ${detailsPointer}`);
222
+
223
+ // Compute fixed cost: header + body lines + footer.
224
+ // We need to leave room for an eventual "… +K more" line.
225
+ const truncationLineEstimate = 16; // "\n… +999 more"
226
+ const footerStr = footerLines.length > 0 ? "\n" + footerLines.join("\n") : "";
227
+
228
+ const bodyLines: string[] = [];
229
+ let renderedSoFar = header.length;
230
+ let truncated = 0;
231
+
232
+ for (let i = 0; i < sorted.length; i++) {
233
+ const it = sorted[i]!;
234
+ const emoji = emojiFor(it.severity, emojiMap);
235
+ const ref = it.ref ? ` [${it.ref}]` : "";
236
+ const line = `${emoji} ${it.title}${ref} — ${it.component ?? "?"}`;
237
+ // +1 for the leading "\n" before this line
238
+ const lineCost = 1 + line.length;
239
+ const remainingItems = sorted.length - i;
240
+ // If adding this line would prevent us from fitting truncation footer
241
+ // for the remaining items, stop here.
242
+ const projected =
243
+ renderedSoFar +
244
+ lineCost +
245
+ footerStr.length +
246
+ (remainingItems > 1 ? truncationLineEstimate : 0);
247
+ if (projected > maxChars) {
248
+ truncated = sorted.length - i;
249
+ break;
250
+ }
251
+ bodyLines.push(line);
252
+ renderedSoFar += lineCost;
253
+ }
254
+
255
+ const out: string[] = [header, ...bodyLines];
256
+ if (truncated > 0) out.push(`… +${truncated} more`);
257
+ if (footerLines.length > 0) out.push(...footerLines);
258
+
259
+ return [out.join("\n")];
260
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * alerts/ — Adaptive notification batching (SDK 1.9.0).
3
+ *
4
+ * Promoted from forge's security-auditor `buildEscalationMessages()`
5
+ * after the 47-alert Telegram flood (2026-05-17). Pure, transport-agnostic
6
+ * helper; caller dispatches the returned strings.
7
+ *
8
+ * @public
9
+ */
10
+
11
+ export { buildAlertDigest } from "./digest.js";
12
+ export type { AlertItem, AlertDigestOptions } from "./digest.js";
package/src/index.ts CHANGED
@@ -571,6 +571,21 @@ export type {
571
571
  // 13. Outcomes module (SDK v0.5.1 — Sprint-522)
572
572
  export * from "./outcomes/index.js";
573
573
 
574
+ // 13b. Quality scoring helper (SDK 1.8.0 — promoted from forge 2026-05-17)
575
+ export {
576
+ computeQuality,
577
+ computeQualityWithBreakdown,
578
+ } from "./quality/index.js";
579
+ export type {
580
+ QualityInputs,
581
+ QualityBreakdown,
582
+ QualityContribution,
583
+ } from "./quality/index.js";
584
+
585
+ // 13c. Alert digest helper (SDK 1.9.0 — promoted from forge 2026-05-17)
586
+ export { buildAlertDigest } from "./alerts/index.js";
587
+ export type { AlertItem, AlertDigestOptions } from "./alerts/index.js";
588
+
574
589
  // 14. Proof module (SDK v0.5.2 — Sprint-521 Bloc 1)
575
590
  // Note: proof/index.js re-exports from @vauban-org/proof-core (optional peerDep).
576
591
  // Import via subpath @vauban-org/agent-sdk/proof — do NOT wildcard here.
@@ -232,8 +232,9 @@ export interface OutcomeRecord {
232
232
  * the BUSINESS quality of the cycle output (publishedCount, threatsBlocked,
233
233
  * invoicesProcessed, etc.). Persisted to `agent_run.outcome_quality` by
234
234
  * the CC backend's telemetry-ingest finish handler (SDK 1.6 / ADR-ECO-039).
235
- * Use `@vauban-org/forge`'s shared `computeQuality()` helper for consistent
236
- * scoring across agents, or override with a custom score.
235
+ * Use the SDK's `computeQuality()` helper (or `computeQualityWithBreakdown()`
236
+ * for debug-grade observability) for consistent scoring across agents, or
237
+ * override with a custom score. See `@vauban-org/agent-sdk` quality module.
237
238
  */
238
239
  readonly quality?: number;
239
240
  readonly is_pending_backfill?: boolean;