@gr8ful/spf 0.2.1 → 0.3.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.
@@ -310,12 +310,42 @@ export const WatchConfigSchema = v.object({
310
310
  concurrency: v.optional(v.pipe(v.number(), v.integer(), v.minValue(1)), 2),
311
311
  jira: v.optional(WatchJiraConfigSchema, () => v.parse(WatchJiraConfigSchema, {})),
312
312
  });
313
+ /**
314
+ * Optional outbound push for unattended work (`spf watch`, any chain run) —
315
+ * everything else (`spf doctor`, `list`, `sessions`, ...) is interactive, so
316
+ * it stays console-only on purpose; see `core/notify/notifier.ts`.
317
+ *
318
+ * `events` is the whole filter: "off" sends nothing, "errors" sends only
319
+ * NotifyEvents whose `level` is "error", "all" sends every curated
320
+ * milestone. A channel's own `events` overrides the top-level scope for
321
+ * just that channel (e.g. Slack gets everything, Teams gets errors only).
322
+ *
323
+ * `webhook_url_env` names the .env key holding the secret URL — never the
324
+ * URL itself, matching GITHUB_TOKEN/JIRA_API_TOKEN. Empty = the kind's own
325
+ * default key (see core/notify/notifier.ts's DEFAULT_ENV_KEY).
326
+ */
327
+ export const NotifyScopeSchema = v.picklist(["off", "errors", "all"]);
328
+ export const NotifyChannelKindSchema = v.picklist(["slack", "teams", "webhook"]);
329
+ export const NotifyChannelSchema = v.object({
330
+ kind: NotifyChannelKindSchema,
331
+ webhook_url_env: v.optional(v.string(), ""),
332
+ events: v.optional(v.nullable(NotifyScopeSchema)),
333
+ // Shown in warning lines / message footers to tell two channels of the
334
+ // same kind apart (e.g. two webhook: entries) — cosmetic only.
335
+ name: v.optional(v.string(), ""),
336
+ });
337
+ export const NotificationsConfigSchema = v.object({
338
+ events: v.optional(NotifyScopeSchema, "off"),
339
+ timeout_ms: v.optional(v.number(), 5_000),
340
+ channels: v.optional(v.array(NotifyChannelSchema), () => []),
341
+ });
313
342
  export const SFConfigSchema = v.object({
314
343
  defaults: v.optional(ConfigDefaultsSchema, () => v.parse(ConfigDefaultsSchema, {})),
315
344
  observability: v.optional(ObservabilityConfigSchema, () => v.parse(ObservabilityConfigSchema, {})),
316
345
  agents: v.optional(v.array(AgentConfigSchema), () => []),
317
346
  quality: v.optional(QualityConfigSchema, () => v.parse(QualityConfigSchema, {})),
318
347
  watch: v.optional(WatchConfigSchema, () => v.parse(WatchConfigSchema, {})),
348
+ notifications: v.optional(NotificationsConfigSchema, () => v.parse(NotificationsConfigSchema, {})),
319
349
  });
320
350
  export function makeEventRecord(input) {
321
351
  return {
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The vocabulary a `NotificationChannel` speaks, and the seam itself —
3
+ * mirrors `core/issues/provider.ts`'s shape (an interface per concern,
4
+ * one small implementation module per backend).
5
+ *
6
+ * `NotifyKind` is a curated set of milestones, deliberately not the raw
7
+ * tracer event stream (`Tracer.event()` — see `core/tracer.ts`): a single
8
+ * chain run emits hundreds of `tool_call`/`log` events, which would need
9
+ * batching/rate-limiting to be a usable Slack message and would cut against
10
+ * the tracer's own "no push transport" design. `level` is the ENTIRE filter
11
+ * predicate a `Notifier` applies — no separate per-kind severity table to
12
+ * keep in sync with this list.
13
+ */
14
+ export type NotifyKind = "run_started" | "run_finished" | "run_failed" | "phase_failed" | "phase_retry" | "watch_started" | "watch_stopped" | "watch_error" | "issue_claimed" | "pr_opened" | "issue_done" | "issue_blocked";
15
+ export interface NotifyEvent {
16
+ kind: NotifyKind;
17
+ /** "error" sends under both `events: errors` and `events: all`; "info" only under `all`. */
18
+ level: "info" | "error";
19
+ /** One line, e.g. "run failed — plan-build-test". */
20
+ title: string;
21
+ /** The error text / PR body / block detail, if any. */
22
+ detail?: string;
23
+ /** Ordered label/value pairs — adw_id, chain, phase, tokens, cost, issue, pr, repo, ... */
24
+ fields: Array<[string, string]>;
25
+ /** A PR or issue link, when there is one. */
26
+ url?: string;
27
+ }
28
+ export interface NotificationChannel {
29
+ /** For warning lines — "slack", "teams (ops-bus)". */
30
+ readonly label: string;
31
+ send(event: NotifyEvent, timeoutMs: number): Promise<void>;
32
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The vocabulary a `NotificationChannel` speaks, and the seam itself —
3
+ * mirrors `core/issues/provider.ts`'s shape (an interface per concern,
4
+ * one small implementation module per backend).
5
+ *
6
+ * `NotifyKind` is a curated set of milestones, deliberately not the raw
7
+ * tracer event stream (`Tracer.event()` — see `core/tracer.ts`): a single
8
+ * chain run emits hundreds of `tool_call`/`log` events, which would need
9
+ * batching/rate-limiting to be a usable Slack message and would cut against
10
+ * the tracer's own "no push transport" design. `level` is the ENTIRE filter
11
+ * predicate a `Notifier` applies — no separate per-kind severity table to
12
+ * keep in sync with this list.
13
+ */
14
+ export {};
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Filter, fan-out, and lifecycle for outbound notifications. A notification
3
+ * must never be able to break a run: `send()` never throws and never
4
+ * blocks — it fires the request and tracks it in a pending set, and a
5
+ * failure is swallowed after logging one line (the URL itself never
6
+ * appears in that line, or anywhere else — see `resolveNotifier` below).
7
+ *
8
+ * `resolveNotifier` returns `null` when notifications are off or no
9
+ * channel resolved, so every call site uses the same `notifier?.send(...)`
10
+ * shape as an optional dependency, not a conditional branch.
11
+ */
12
+ import type { NotifyEvent, NotificationChannel } from "./channel.ts";
13
+ import type { NotifyScope, SFConfig } from "../data_types.ts";
14
+ /** Exported so `spf doctor` and the init interview can name the same key without duplicating this table. */
15
+ export declare const DEFAULT_NOTIFY_ENV_KEY: Record<string, string>;
16
+ export declare class Notifier {
17
+ private readonly channels;
18
+ private readonly timeoutMs;
19
+ private readonly dryRun;
20
+ private readonly log;
21
+ private pending;
22
+ constructor(channels: Array<{
23
+ channel: NotificationChannel;
24
+ scope: NotifyScope;
25
+ }>, timeoutMs: number, dryRun: boolean, log?: (message: string) => void);
26
+ /** Sync, fire-and-forget — every call site is sync and must stay that way. */
27
+ send(event: NotifyEvent): void;
28
+ /** Await every in-flight send — call before process exit so a slow webhook isn't dropped mid-flight. */
29
+ flush(): Promise<void>;
30
+ }
31
+ /**
32
+ * Build a `Notifier` from `cfg.notifications`, or `null` if it's off or no
33
+ * channel resolved. A channel whose env var isn't set is skipped with one
34
+ * warning naming the missing key — never a hard failure, since a broken
35
+ * notification setup shouldn't stop the work it's supposed to report on.
36
+ */
37
+ export declare function resolveNotifier(cfg: SFConfig, opts?: {
38
+ dryRun?: boolean;
39
+ log?: (message: string) => void;
40
+ }): Notifier | null;
41
+ /** Await every Notifier this process has created — call once, from the CLI's shutdown path. */
42
+ export declare function flushAll(): Promise<void>;
@@ -0,0 +1,100 @@
1
+ import { SlackChannel } from "./slack_channel.js";
2
+ import { TeamsChannel } from "./teams_channel.js";
3
+ import { WebhookChannel } from "./webhook_channel.js";
4
+ /** Exported so `spf doctor` and the init interview can name the same key without duplicating this table. */
5
+ export const DEFAULT_NOTIFY_ENV_KEY = {
6
+ slack: "SLACK_WEBHOOK_URL",
7
+ teams: "TEAMS_WEBHOOK_URL",
8
+ webhook: "SPF_WEBHOOK_URL",
9
+ };
10
+ /** `errors` mode only sends `level: "error"`; `all` sends everything; `off` sends nothing. */
11
+ function scopeAllows(scope, level) {
12
+ if (scope === "off")
13
+ return false;
14
+ if (scope === "all")
15
+ return true;
16
+ return level === "error";
17
+ }
18
+ export class Notifier {
19
+ channels;
20
+ timeoutMs;
21
+ dryRun;
22
+ log;
23
+ pending = new Set();
24
+ constructor(channels, timeoutMs, dryRun, log = (m) => console.error(m)) {
25
+ this.channels = channels;
26
+ this.timeoutMs = timeoutMs;
27
+ this.dryRun = dryRun;
28
+ this.log = log;
29
+ }
30
+ /** Sync, fire-and-forget — every call site is sync and must stay that way. */
31
+ send(event) {
32
+ for (const { channel, scope } of this.channels) {
33
+ if (!scopeAllows(scope, event.level))
34
+ continue;
35
+ if (this.dryRun) {
36
+ this.log(`spf: would notify (${channel.label}): ${event.title}`);
37
+ continue;
38
+ }
39
+ const task = channel.send(event, this.timeoutMs).catch((error) => {
40
+ this.log(`spf: ${channel.label} notification failed: ${error.message}`);
41
+ });
42
+ this.pending.add(task);
43
+ task.finally(() => this.pending.delete(task));
44
+ }
45
+ }
46
+ /** Await every in-flight send — call before process exit so a slow webhook isn't dropped mid-flight. */
47
+ async flush() {
48
+ await Promise.all([...this.pending]);
49
+ }
50
+ }
51
+ function makeChannel(kind, url, name) {
52
+ switch (kind) {
53
+ case "slack":
54
+ return new SlackChannel(url, name);
55
+ case "teams":
56
+ return new TeamsChannel(url, name);
57
+ case "webhook":
58
+ return new WebhookChannel(url, name);
59
+ default:
60
+ return null;
61
+ }
62
+ }
63
+ // Every live Notifier this process created — so the CLI's shutdown path can
64
+ // flush all of them without threading a handle through every call site,
65
+ // matching agent_flue.shutdown()/agent_cc.shutdown()'s module-level shape.
66
+ const LIVE = [];
67
+ /**
68
+ * Build a `Notifier` from `cfg.notifications`, or `null` if it's off or no
69
+ * channel resolved. A channel whose env var isn't set is skipped with one
70
+ * warning naming the missing key — never a hard failure, since a broken
71
+ * notification setup shouldn't stop the work it's supposed to report on.
72
+ */
73
+ export function resolveNotifier(cfg, opts = {}) {
74
+ const nc = cfg.notifications;
75
+ if (nc.events === "off" || nc.channels.length === 0)
76
+ return null;
77
+ const log = opts.log ?? ((m) => console.error(m));
78
+ const resolved = [];
79
+ for (const entry of nc.channels) {
80
+ const envKey = entry.webhook_url_env || DEFAULT_NOTIFY_ENV_KEY[entry.kind];
81
+ const url = process.env[envKey];
82
+ if (!url) {
83
+ log(`spf: notifications.channels[kind=${entry.kind}] is configured but ${envKey} is not set — skipping this channel`);
84
+ continue;
85
+ }
86
+ const channel = makeChannel(entry.kind, url, entry.name);
87
+ if (!channel)
88
+ continue;
89
+ resolved.push({ channel, scope: entry.events ?? nc.events });
90
+ }
91
+ if (resolved.length === 0)
92
+ return null;
93
+ const notifier = new Notifier(resolved, nc.timeout_ms, Boolean(opts.dryRun), log);
94
+ LIVE.push(notifier);
95
+ return notifier;
96
+ }
97
+ /** Await every Notifier this process has created — call once, from the CLI's shutdown path. */
98
+ export async function flushAll() {
99
+ await Promise.all(LIVE.map((n) => n.flush()));
100
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Slack Incoming Webhook — a plain `POST` of a Block Kit payload, via native
3
+ * `fetch()` (same no-dependency stance as `core/issues/github_provider.ts`).
4
+ * Set up: Slack app -> Incoming Webhooks -> "Add New Webhook to Workspace".
5
+ * https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks
6
+ */
7
+ import type { NotificationChannel, NotifyEvent } from "./channel.ts";
8
+ export declare class SlackChannel implements NotificationChannel {
9
+ private readonly webhookUrl;
10
+ readonly label: string;
11
+ constructor(webhookUrl: string, name?: string);
12
+ send(event: NotifyEvent, timeoutMs: number): Promise<void>;
13
+ }
@@ -0,0 +1,30 @@
1
+ export class SlackChannel {
2
+ webhookUrl;
3
+ label;
4
+ constructor(webhookUrl, name = "") {
5
+ this.webhookUrl = webhookUrl;
6
+ this.label = name ? `slack (${name})` : "slack";
7
+ }
8
+ async send(event, timeoutMs) {
9
+ const emoji = event.level === "error" ? ":x:" : ":white_check_mark:";
10
+ const fieldsText = event.fields.map(([k, v]) => `*${k}:* ${v}`).join(" · ");
11
+ const body = {
12
+ text: `${emoji} ${event.title}`,
13
+ blocks: [
14
+ { type: "section", text: { type: "mrkdwn", text: `${emoji} *${event.title}*` } },
15
+ ...(event.detail ? [{ type: "section", text: { type: "mrkdwn", text: event.detail.slice(0, 2900) } }] : []),
16
+ ...(fieldsText ? [{ type: "context", elements: [{ type: "mrkdwn", text: fieldsText }] }] : []),
17
+ ...(event.url ? [{ type: "section", text: { type: "mrkdwn", text: `<${event.url}|open>` } }] : []),
18
+ ],
19
+ };
20
+ const response = await fetch(this.webhookUrl, {
21
+ method: "POST",
22
+ headers: { "Content-Type": "application/json" },
23
+ body: JSON.stringify(body),
24
+ signal: AbortSignal.timeout(timeoutMs),
25
+ });
26
+ if (!response.ok) {
27
+ throw new Error(`slack webhook -> ${response.status}: ${(await response.text().catch(() => "")).slice(0, 300)}`);
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Microsoft Teams via a Power Automate "Workflows" webhook, posting an
3
+ * Adaptive Card. This is the ONLY supported path: the legacy Office 365
4
+ * connector webhook (a bare `MessageCard`/`@type` POST straight to a
5
+ * channel-configured URL) has been retired by Microsoft. Set up: in the
6
+ * target channel, add a Workflows webhook template (naming has shifted
7
+ * between Microsoft revisions — search for one along the lines of "Post to
8
+ * a channel when a webhook request is received") and copy the generated URL.
9
+ * https://support.microsoft.com/en-us/office/post-a-workflow-when-a-webhook-request-is-received-in-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498
10
+ */
11
+ import type { NotificationChannel, NotifyEvent } from "./channel.ts";
12
+ export declare class TeamsChannel implements NotificationChannel {
13
+ private readonly webhookUrl;
14
+ readonly label: string;
15
+ constructor(webhookUrl: string, name?: string);
16
+ send(event: NotifyEvent, timeoutMs: number): Promise<void>;
17
+ }
@@ -0,0 +1,38 @@
1
+ export class TeamsChannel {
2
+ webhookUrl;
3
+ label;
4
+ constructor(webhookUrl, name = "") {
5
+ this.webhookUrl = webhookUrl;
6
+ this.label = name ? `teams (${name})` : "teams";
7
+ }
8
+ async send(event, timeoutMs) {
9
+ const color = event.level === "error" ? "attention" : "good";
10
+ const facts = event.fields.map(([title, value]) => ({ title, value }));
11
+ const card = {
12
+ type: "AdaptiveCard",
13
+ $schema: "http://adaptivecards.io/schemas/adaptive-card.json",
14
+ version: "1.4",
15
+ body: [
16
+ { type: "TextBlock", text: event.title, weight: "bolder", size: "medium", color, wrap: true },
17
+ ...(event.detail ? [{ type: "TextBlock", text: event.detail.slice(0, 2900), wrap: true }] : []),
18
+ ...(facts.length > 0 ? [{ type: "FactSet", facts }] : []),
19
+ ],
20
+ ...(event.url
21
+ ? { actions: [{ type: "Action.OpenUrl", title: "Open", url: event.url }] }
22
+ : {}),
23
+ };
24
+ const body = {
25
+ type: "message",
26
+ attachments: [{ contentType: "application/vnd.microsoft.card.adaptive", contentUrl: null, content: card }],
27
+ };
28
+ const response = await fetch(this.webhookUrl, {
29
+ method: "POST",
30
+ headers: { "Content-Type": "application/json" },
31
+ body: JSON.stringify(body),
32
+ signal: AbortSignal.timeout(timeoutMs),
33
+ });
34
+ if (!response.ok) {
35
+ throw new Error(`teams webhook -> ${response.status}: ${(await response.text().catch(() => "")).slice(0, 300)}`);
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * A generic webhook: `POST` the `NotifyEvent` as raw JSON, no vendor shape.
3
+ * Covers Discord/n8n/Zapier/a homegrown receiver with no new channel module
4
+ * each time, and is what `notify.test.ts` posts against a real local
5
+ * `node:http` receiver instead of mocking `fetch`.
6
+ */
7
+ import type { NotificationChannel, NotifyEvent } from "./channel.ts";
8
+ export declare class WebhookChannel implements NotificationChannel {
9
+ private readonly url;
10
+ readonly label: string;
11
+ constructor(url: string, name?: string);
12
+ send(event: NotifyEvent, timeoutMs: number): Promise<void>;
13
+ }
@@ -0,0 +1,19 @@
1
+ export class WebhookChannel {
2
+ url;
3
+ label;
4
+ constructor(url, name = "") {
5
+ this.url = url;
6
+ this.label = name ? `webhook (${name})` : "webhook";
7
+ }
8
+ async send(event, timeoutMs) {
9
+ const response = await fetch(this.url, {
10
+ method: "POST",
11
+ headers: { "Content-Type": "application/json" },
12
+ body: JSON.stringify(event),
13
+ signal: AbortSignal.timeout(timeoutMs),
14
+ });
15
+ if (!response.ok) {
16
+ throw new Error(`webhook -> ${response.status}: ${(await response.text().catch(() => "")).slice(0, 300)}`);
17
+ }
18
+ }
19
+ }
@@ -12,6 +12,7 @@ import { type GitHandle } from "./git_helper.ts";
12
12
  import { Console } from "./console.ts";
13
13
  import { Tracer } from "./tracer.ts";
14
14
  import { type AgentCall, type EnvelopeBase, type Phase, type PhaseParams, type SFConfig } from "./data_types.ts";
15
+ import type { Notifier } from "./notify/notifier.ts";
15
16
  interface AgentMapEntry {
16
17
  session_id: string;
17
18
  model: string;
@@ -32,12 +33,18 @@ export interface RunInit {
32
33
  sfDir: string | null;
33
34
  /** Absolute. Resolved once, upstream, by paths.resolveDataPaths(). */
34
35
  dataDir: string;
36
+ /** The CLI chain name, for a notification's title — see session.ts. */
37
+ chainName?: string;
38
+ /** `null`/omitted when notifications are off (the default) or no channel resolved. */
39
+ notifier?: Notifier | null;
35
40
  }
36
41
  export declare class Run {
37
42
  cfg: SFConfig;
38
43
  adw_id: string;
39
44
  tracer: Tracer;
40
45
  console: Console;
46
+ /** `null` when notifications are off — `run.notify?.send(...)` at any future call site. */
47
+ notify: Notifier | null;
41
48
  engineer: string;
42
49
  phases: Phase[];
43
50
  tokens: number;
@@ -48,6 +48,8 @@ export class Run {
48
48
  adw_id;
49
49
  tracer;
50
50
  console;
51
+ /** `null` when notifications are off — `run.notify?.send(...)` at any future call site. */
52
+ notify;
51
53
  engineer;
52
54
  phases = [];
53
55
  tokens = 0;
@@ -68,7 +70,8 @@ export class Run {
68
70
  this.cfg = init.cfg;
69
71
  this.adw_id = init.adwId;
70
72
  this.tracer = init.tracer;
71
- this.console = new Console(init.tracer, init.adwId);
73
+ this.notify = init.notifier ?? null;
74
+ this.console = new Console(init.tracer, init.adwId, this.notify, init.chainName || "adw");
72
75
  this.engineer = init.engineer;
73
76
  this.seq = init.tracer.maxPhaseSeq(init.adwId);
74
77
  this.repo_root = init.repoRoot;
@@ -10,6 +10,7 @@ import * as paths from "./paths.js";
10
10
  import { Run } from "./runner.js";
11
11
  import { Tracer } from "./tracer.js";
12
12
  import { engineerName, newId } from "./utils.js";
13
+ import { resolveNotifier } from "./notify/notifier.js";
13
14
  /**
14
15
  * A killed run still closes its own trace.
15
16
  *
@@ -52,6 +53,8 @@ export function ensure(cfg, adwId, cwd, chainName) {
52
53
  repoRoot: anchor.repo_root,
53
54
  sfDir: anchor.spf_dir,
54
55
  dataDir: dataPaths.data_dir,
56
+ chainName: chainName || "adw",
57
+ notifier: resolveNotifier(cfg),
55
58
  });
56
59
  const scriptPath = process.argv[1] || "adw";
57
60
  tracer.sessionStart(id, run.engineer, chainName || "adw");
@@ -1,5 +1,6 @@
1
1
  import type { GitHandle } from "./git_helper.ts";
2
2
  import type { CodeHostProvider, Issue, IssueProvider } from "./issues/provider.ts";
3
+ import type { NotifyEvent } from "./notify/channel.ts";
3
4
  export interface ChainRunResult {
4
5
  accepted: boolean;
5
6
  adwId: string;
@@ -36,6 +37,15 @@ export interface WatchDeps {
36
37
  adwId: string;
37
38
  }) => Promise<ChainRunResult>;
38
39
  log: (message: string) => void;
40
+ /**
41
+ * Structured push, alongside `log`'s plain string — a required field, like
42
+ * `log`, so a test must consciously supply one (a no-op fake is fine).
43
+ * Fired only at meaningful state transitions (claim, PR, done, blocked,
44
+ * error) — NOT at routine self-healing (an orphan resume/retry, a lost
45
+ * claim race, a cleanup warning), which recovers on its own and would
46
+ * just be noise in a channel.
47
+ */
48
+ notify: (event: NotifyEvent) => void;
39
49
  }
40
50
  export interface WatchRunState {
41
51
  inflight: Set<string>;
@@ -92,6 +92,13 @@ export async function reconcileOrphans(deps, state) {
92
92
  }
93
93
  else {
94
94
  deps.log(`watch: ${issue.id} orphaned past ${MAX_ORPHAN_ATTEMPTS} attempts — blocked`);
95
+ deps.notify({
96
+ kind: "issue_blocked",
97
+ level: "error",
98
+ title: `issue ${issue.id} blocked`,
99
+ detail: `Gave up after ${MAX_ORPHAN_ATTEMPTS} orphaned attempts.`,
100
+ fields: [["issue", issue.id], ["title", issue.title]],
101
+ });
95
102
  if (!deps.dryRun) {
96
103
  await deps.provider.transition(issue, "blocked", `Gave up after ${MAX_ORPHAN_ATTEMPTS} orphaned attempts.`);
97
104
  cleanupWorktree(deps, marker);
@@ -109,6 +116,13 @@ export async function finishReviews(deps) {
109
116
  const status = await deps.codeHost.prStatus({ number: marker.pr, branch: marker.branch ?? "", url: "" });
110
117
  if (status.merged) {
111
118
  deps.log(`watch: ${issue.id}'s PR #${marker.pr} merged — done`);
119
+ deps.notify({
120
+ kind: "issue_done",
121
+ level: "info",
122
+ title: `issue ${issue.id} done`,
123
+ detail: `PR #${marker.pr} merged.`,
124
+ fields: [["issue", issue.id], ["title", issue.title], ["pr", `#${marker.pr}`]],
125
+ });
112
126
  if (!deps.dryRun) {
113
127
  await deps.provider.transition(issue, "done");
114
128
  cleanupWorktree(deps, marker);
@@ -116,6 +130,13 @@ export async function finishReviews(deps) {
116
130
  }
117
131
  else if (status.state === "closed") {
118
132
  deps.log(`watch: ${issue.id}'s PR #${marker.pr} closed without merging — blocked`);
133
+ deps.notify({
134
+ kind: "issue_blocked",
135
+ level: "error",
136
+ title: `issue ${issue.id} blocked`,
137
+ detail: `PR #${marker.pr} was closed without merging.`,
138
+ fields: [["issue", issue.id], ["title", issue.title], ["pr", `#${marker.pr}`]],
139
+ });
119
140
  if (!deps.dryRun) {
120
141
  await deps.provider.transition(issue, "blocked", `PR #${marker.pr} was closed without merging.`);
121
142
  cleanupWorktree(deps, marker);
@@ -147,13 +168,28 @@ async function runIssue(deps, issue) {
147
168
  const result = await deps.runChain({ prompt, cwd: worktreePath, adwId });
148
169
  if (!result.accepted) {
149
170
  deps.log(`watch: ${issue.id}: chain "${deps.chain}" did not succeed — blocked`);
150
- await deps.provider.transition(issue, "blocked", result.detail || `Chain "${deps.chain}" (adw_id ${adwId}) did not complete successfully. Run \`spf phases ${adwId}\` for detail.`);
171
+ const detail = result.detail || `Chain "${deps.chain}" (adw_id ${adwId}) did not complete successfully. Run \`spf phases ${adwId}\` for detail.`;
172
+ deps.notify({
173
+ kind: "issue_blocked",
174
+ level: "error",
175
+ title: `issue ${issue.id} blocked`,
176
+ detail,
177
+ fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.chain], ["adw_id", adwId]],
178
+ });
179
+ await deps.provider.transition(issue, "blocked", detail);
151
180
  cleanupWorktree(deps, { worktree: worktreePath, branch });
152
181
  return;
153
182
  }
154
183
  const wtGit = deps.worktreeGit(worktreePath);
155
184
  if (wtGit.diffFiles(`origin/${deps.baseBranch}`).length === 0) {
156
185
  deps.log(`watch: ${issue.id}: chain succeeded but committed nothing — blocked`);
186
+ deps.notify({
187
+ kind: "issue_blocked",
188
+ level: "error",
189
+ title: `issue ${issue.id} blocked`,
190
+ detail: `Chain "${deps.chain}" (adw_id ${adwId}) completed but left no committed changes.`,
191
+ fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.chain], ["adw_id", adwId]],
192
+ });
157
193
  await deps.provider.transition(issue, "blocked", `Chain "${deps.chain}" (adw_id ${adwId}) completed but left no committed changes.`);
158
194
  cleanupWorktree(deps, { worktree: worktreePath, branch });
159
195
  return;
@@ -173,10 +209,24 @@ async function runIssue(deps, issue) {
173
209
  await deps.provider.writeMarker(issue, { worktree: worktreePath, branch, pr: pr.number, attempt: 0 });
174
210
  await deps.provider.transition(issue, "review");
175
211
  deps.log(`watch: ${issue.id}: opened PR #${pr.number} — review`);
212
+ deps.notify({
213
+ kind: "pr_opened",
214
+ level: "info",
215
+ title: `PR #${pr.number} opened`,
216
+ fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.chain]],
217
+ url: pr.url || undefined,
218
+ });
176
219
  }
177
220
  catch (error) {
178
221
  const message = error.message;
179
222
  deps.log(`watch: ${issue.id}: error: ${message}`);
223
+ deps.notify({
224
+ kind: "watch_error",
225
+ level: "error",
226
+ title: `issue ${issue.id} errored`,
227
+ detail: message,
228
+ fields: [["issue", issue.id], ["title", issue.title]],
229
+ });
180
230
  await deps.provider.transition(issue, "blocked", `spf watch error: ${message}`).catch(() => undefined);
181
231
  cleanupWorktree(deps, { worktree: worktreePath, branch });
182
232
  }
@@ -201,13 +251,26 @@ export async function claimNewWork(deps, state) {
201
251
  continue;
202
252
  }
203
253
  deps.log(`watch: claimed ${issue.id}: ${issue.title}`);
254
+ deps.notify({
255
+ kind: "issue_claimed",
256
+ level: "info",
257
+ title: `issue ${issue.id} claimed`,
258
+ fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.chain]],
259
+ });
204
260
  state.inflight.add(issue.id);
205
261
  runIssue(deps, issue).finally(() => state.inflight.delete(issue.id));
206
262
  }
207
263
  }
264
+ function tickErrorHandler(deps, stage) {
265
+ return (error) => {
266
+ const message = error.message;
267
+ deps.log(`watch: ${stage} error: ${message}`);
268
+ deps.notify({ kind: "watch_error", level: "error", title: `watch: ${stage} error`, detail: message, fields: [] });
269
+ };
270
+ }
208
271
  /** One poll tick: reconcile, finish, claim — each independently caught, so one phase's error never blocks the rest. */
209
272
  export async function tick(deps, state) {
210
- await reconcileOrphans(deps, state).catch((error) => deps.log(`watch: reconcileOrphans error: ${error.message}`));
211
- await finishReviews(deps).catch((error) => deps.log(`watch: finishReviews error: ${error.message}`));
212
- await claimNewWork(deps, state).catch((error) => deps.log(`watch: claimNewWork error: ${error.message}`));
273
+ await reconcileOrphans(deps, state).catch(tickErrorHandler(deps, "reconcileOrphans"));
274
+ await finishReviews(deps).catch(tickErrorHandler(deps, "finishReviews"));
275
+ await claimNewWork(deps, state).catch(tickErrorHandler(deps, "claimNewWork"));
213
276
  }
@@ -9,9 +9,13 @@
9
9
  */
10
10
  import { test } from "node:test";
11
11
  import assert from "node:assert/strict";
12
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
12
15
  import * as v from "valibot";
13
16
  import { toJsonSchema } from "@valibot/to-json-schema";
14
- import { AgentConfigSchema, BuildOutput, ChangesOutput, DocumentOutput, GenericOutput, PhaseParamsSchema, PlanOutput, ReviewOutput, ScoutOutput, VerifyOutput, makePhaseParams, } from "../core/data_types.js";
17
+ import { AgentConfigSchema, BuildOutput, ChangesOutput, DocumentOutput, GenericOutput, NotificationsConfigSchema, PhaseParamsSchema, PlanOutput, ReviewOutput, ScoutOutput, VerifyOutput, makePhaseParams, } from "../core/data_types.js";
18
+ import { loadConfig } from "../core/agents.js";
15
19
  test("writes: three-state semantics — absent, null, and [] all mean something different", () => {
16
20
  const base = { name: "builder", prompt_engineering: { system: "s.md", user: "u.md" } };
17
21
  const unrestricted = v.parse(AgentConfigSchema, base);
@@ -47,3 +51,32 @@ test("every envelope type still converts to JSON Schema (the sf_report tool wiri
47
51
  test("PhaseParamsSchema itself (the one schema WITH a rawTransform) correctly refuses JSON Schema conversion", () => {
48
52
  assert.throws(() => toJsonSchema(PhaseParamsSchema), /raw_transform/);
49
53
  });
54
+ test("NotificationsConfigSchema defaults to off, no channels", () => {
55
+ const parsed = v.parse(NotificationsConfigSchema, {});
56
+ assert.equal(parsed.events, "off");
57
+ assert.equal(parsed.timeout_ms, 5_000);
58
+ assert.deepEqual(parsed.channels, []);
59
+ });
60
+ test("a channel's own `events` overrides the top-level scope; unset inherits it", () => {
61
+ const parsed = v.parse(NotificationsConfigSchema, {
62
+ events: "errors",
63
+ channels: [{ kind: "slack" }, { kind: "teams", events: "all" }],
64
+ });
65
+ assert.equal(parsed.channels[0].events, undefined, "unset per-channel scope stays undefined, not defaulted to the top-level value — the caller inherits at read time");
66
+ assert.equal(parsed.channels[1].events, "all");
67
+ });
68
+ test("notifications survives loadConfig's merge — key-by-key like observability/quality, channels replaced wholesale on override", () => {
69
+ const dir = mkdtempSync(join(tmpdir(), "spf-notify-merge-test-"));
70
+ try {
71
+ const base = join(dir, "base.yaml");
72
+ const override = join(dir, "override.yaml");
73
+ writeFileSync(base, "notifications:\n events: off\n channels:\n - {kind: webhook, webhook_url_env: BASE_HOOK}\n");
74
+ writeFileSync(override, "notifications:\n events: all\n channels:\n - {kind: slack, webhook_url_env: SLACK_WEBHOOK_URL}\n");
75
+ const cfg = loadConfig([base, override]);
76
+ assert.equal(cfg.notifications.events, "all", "events: key-by-key, override wins");
77
+ assert.deepEqual(cfg.notifications.channels.map((c) => c.kind), ["slack"], "channels: a whole-array replace, not an append — same semantics as quality.checks");
78
+ }
79
+ finally {
80
+ rmSync(dir, { recursive: true, force: true });
81
+ }
82
+ });