@gr8ful/spf 0.1.7 → 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.
Files changed (94) hide show
  1. package/README.md +57 -2
  2. package/assets/skill/cookbooks/authoring_chains.md +96 -84
  3. package/assets/skill/cookbooks/roster.md +3 -1
  4. package/assets/skill/references/config.md +52 -5
  5. package/assets/templates/ts.spf.config.yaml +12 -0
  6. package/dist/chains/context.d.ts +2 -0
  7. package/dist/chains/index.d.ts +21 -2
  8. package/dist/chains/index.js +73 -104
  9. package/dist/chains/{adw_simple_sdlc.d.ts → simple_sdlc.d.ts} +7 -1
  10. package/dist/chains/{adw_simple_sdlc.js → simple_sdlc.js} +19 -30
  11. package/dist/chains/steps.d.ts +117 -0
  12. package/dist/chains/steps.js +299 -0
  13. package/dist/cli/ask.d.ts +27 -0
  14. package/dist/cli/ask.js +125 -0
  15. package/dist/cli/commands/doctor.js +14 -24
  16. package/dist/cli/commands/init.d.ts +1 -1
  17. package/dist/cli/commands/init.js +96 -9
  18. package/dist/cli/commands/run.d.ts +1 -1
  19. package/dist/cli/commands/run.js +3 -1
  20. package/dist/cli/commands/watch.js +20 -3
  21. package/dist/cli/env_file.d.ts +18 -0
  22. package/dist/cli/env_file.js +99 -0
  23. package/dist/cli/index.js +6 -2
  24. package/dist/cli/interview.d.ts +26 -0
  25. package/dist/cli/interview.js +417 -0
  26. package/dist/core/agents.js +4 -1
  27. package/dist/core/console.d.ts +13 -1
  28. package/dist/core/console.js +51 -1
  29. package/dist/core/data_types.d.ts +55 -0
  30. package/dist/core/data_types.js +30 -0
  31. package/dist/core/notify/channel.d.ts +32 -0
  32. package/dist/core/notify/channel.js +14 -0
  33. package/dist/core/notify/notifier.d.ts +42 -0
  34. package/dist/core/notify/notifier.js +100 -0
  35. package/dist/core/notify/slack_channel.d.ts +13 -0
  36. package/dist/core/notify/slack_channel.js +30 -0
  37. package/dist/core/notify/teams_channel.d.ts +17 -0
  38. package/dist/core/notify/teams_channel.js +38 -0
  39. package/dist/core/notify/webhook_channel.d.ts +13 -0
  40. package/dist/core/notify/webhook_channel.js +19 -0
  41. package/dist/core/prompts.d.ts +2 -0
  42. package/dist/core/prompts.js +2 -0
  43. package/dist/core/providers.d.ts +12 -0
  44. package/dist/core/providers.js +24 -0
  45. package/dist/core/quality.d.ts +9 -0
  46. package/dist/core/quality.js +10 -0
  47. package/dist/core/runner.d.ts +7 -0
  48. package/dist/core/runner.js +4 -1
  49. package/dist/core/session.d.ts +6 -1
  50. package/dist/core/session.js +10 -3
  51. package/dist/core/tracer.js +1 -1
  52. package/dist/core/utils.d.ts +6 -2
  53. package/dist/core/utils.js +11 -2
  54. package/dist/core/watch.d.ts +10 -0
  55. package/dist/core/watch.js +67 -4
  56. package/dist/test/chains.test.d.ts +12 -0
  57. package/dist/test/chains.test.js +86 -0
  58. package/dist/test/data_types.test.js +34 -1
  59. package/dist/test/env_file.test.d.ts +1 -0
  60. package/dist/test/env_file.test.js +74 -0
  61. package/dist/test/fake_asker.d.ts +23 -0
  62. package/dist/test/fake_asker.js +30 -0
  63. package/dist/test/init_command.test.d.ts +1 -0
  64. package/dist/test/init_command.test.js +66 -0
  65. package/dist/test/interview.test.d.ts +1 -0
  66. package/dist/test/interview.test.js +297 -0
  67. package/dist/test/notify.test.d.ts +1 -0
  68. package/dist/test/notify.test.js +174 -0
  69. package/dist/test/ui_server.test.js +1 -1
  70. package/dist/test/watch.test.js +114 -1
  71. package/dist/ui/shared/types.d.ts +1 -1
  72. package/package.json +5 -2
  73. package/dist/chains/adw_build.d.ts +0 -12
  74. package/dist/chains/adw_build.js +0 -27
  75. package/dist/chains/adw_build_review.d.ts +0 -21
  76. package/dist/chains/adw_build_review.js +0 -55
  77. package/dist/chains/adw_build_test.d.ts +0 -21
  78. package/dist/chains/adw_build_test.js +0 -67
  79. package/dist/chains/adw_document.d.ts +0 -23
  80. package/dist/chains/adw_document.js +0 -59
  81. package/dist/chains/adw_plan.d.ts +0 -12
  82. package/dist/chains/adw_plan.js +0 -27
  83. package/dist/chains/adw_plan_build.d.ts +0 -12
  84. package/dist/chains/adw_plan_build.js +0 -30
  85. package/dist/chains/adw_plan_build_test.d.ts +0 -16
  86. package/dist/chains/adw_plan_build_test.js +0 -65
  87. package/dist/chains/adw_plan_build_test_quality.d.ts +0 -18
  88. package/dist/chains/adw_plan_build_test_quality.js +0 -66
  89. package/dist/chains/adw_prompt.d.ts +0 -12
  90. package/dist/chains/adw_prompt.js +0 -25
  91. package/dist/chains/adw_quality.d.ts +0 -12
  92. package/dist/chains/adw_quality.js +0 -32
  93. package/dist/chains/adw_scout.d.ts +0 -12
  94. package/dist/chains/adw_scout.js +0 -27
@@ -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
+ }
@@ -1,4 +1,6 @@
1
1
  /** Prompt rendering: load system/user refs from config, replace {{placeholders}}. */
2
+ /** Shared instructions for any documenter call fed a change.asEnvelope() capture. */
3
+ export declare const DOCUMENT_NOTES = "Read diff_path in full before writing. Document only what the diff shows, then copy the write-up into app_docs/ as your task describes.";
2
4
  export declare function render(templatePath: string, variables: Record<string, string>): string;
3
5
  /** Save the exact prompt sent, before execution — the audit copy. */
4
6
  export declare function save(directory: string, name: string, content: string): string;
@@ -1,6 +1,8 @@
1
1
  /** Prompt rendering: load system/user refs from config, replace {{placeholders}}. */
2
2
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import path from "node:path";
4
+ /** Shared instructions for any documenter call fed a change.asEnvelope() capture. */
5
+ export const DOCUMENT_NOTES = "Read diff_path in full before writing. Document only what the diff shows, then copy the write-up into app_docs/ as your task describes.";
4
6
  export function render(templatePath, variables) {
5
7
  let text = readFileSync(templatePath, "utf-8");
6
8
  for (const [key, value] of Object.entries(variables)) {
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Common Flue providers' env var conventions — public knowledge (pi-ai's own
3
+ * resolution table is internal, unexported, and not something to reach into
4
+ * for this). Missing from this table just means "unknown provider, skipped
5
+ * the key check" — never a false failure.
6
+ *
7
+ * Shared by `spf doctor` (checks whichever key the configured model's
8
+ * provider prefix implies) and the `spf init` interview (asks for the key up
9
+ * front, keyed off the provider the user picked) — one table, so the two
10
+ * can never drift apart on what a provider needs.
11
+ */
12
+ export declare const PROVIDER_ENV_KEYS: Record<string, string[]>;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Common Flue providers' env var conventions — public knowledge (pi-ai's own
3
+ * resolution table is internal, unexported, and not something to reach into
4
+ * for this). Missing from this table just means "unknown provider, skipped
5
+ * the key check" — never a false failure.
6
+ *
7
+ * Shared by `spf doctor` (checks whichever key the configured model's
8
+ * provider prefix implies) and the `spf init` interview (asks for the key up
9
+ * front, keyed off the provider the user picked) — one table, so the two
10
+ * can never drift apart on what a provider needs.
11
+ */
12
+ export const PROVIDER_ENV_KEYS = {
13
+ anthropic: ["ANTHROPIC_API_KEY"],
14
+ openai: ["OPENAI_API_KEY"],
15
+ google: ["GEMINI_API_KEY", "GOOGLE_API_KEY"],
16
+ openrouter: ["OPENROUTER_API_KEY"],
17
+ fireworks: ["FIREWORKS_API_KEY"],
18
+ groq: ["GROQ_API_KEY"],
19
+ mistral: ["MISTRAL_API_KEY"],
20
+ xai: ["XAI_API_KEY"],
21
+ deepseek: ["DEEPSEEK_API_KEY"],
22
+ together: ["TOGETHER_API_KEY"],
23
+ cerebras: ["CEREBRAS_API_KEY"],
24
+ };
@@ -53,6 +53,15 @@ export declare function runSuite(run: RunLike, suiteName: string): QualityResult
53
53
  export declare function runTests(run: RunLike): QualityResult;
54
54
  /** Every configured check, across every configured suite's union — the `all` suite. */
55
55
  export declare function runQuality(run: RunLike): QualityResult;
56
+ /**
57
+ * Log a deterministic block's verdict — the same shape every chain uses.
58
+ *
59
+ * Every quality/test phase in every chain reported this same summary by
60
+ * hand; one copy here instead of one per chain.
61
+ */
62
+ export declare function record(ph: {
63
+ log: (payload: Record<string, unknown>) => void;
64
+ }, result: QualityResult): void;
56
65
  /**
57
66
  * Wrap a deterministic result so an agent can be handed it directly.
58
67
  *
@@ -170,6 +170,16 @@ export function runTests(run) {
170
170
  export function runQuality(run) {
171
171
  return runSuite(run, "all");
172
172
  }
173
+ /**
174
+ * Log a deterministic block's verdict — the same shape every chain uses.
175
+ *
176
+ * Every quality/test phase in every chain reported this same summary by
177
+ * hand; one copy here instead of one per chain.
178
+ */
179
+ export function record(ph, result) {
180
+ const passed = result.checks.filter((c) => c.passed).length;
181
+ ph.log({ passed: result.passed, checks: `${passed}/${result.checks.length}`, artifacts: result.artifacts.join(", ") });
182
+ }
173
183
  /**
174
184
  * Wrap a deterministic result so an agent can be handed it directly.
175
185
  *
@@ -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;
@@ -12,5 +12,10 @@ import type { SFConfig } from "./data_types.ts";
12
12
  * process happened to start; it is an explicit decision, threaded down from
13
13
  * the CLI/chain context. Defaults to `process.cwd()` only for direct callers
14
14
  * (tests, scratch scripts) that have no anchor of their own to pass.
15
+ *
16
+ * `chainName` is the CLI name (`"plan-build-test"`, not a module basename) —
17
+ * every chain is a composed step list now, not its own file, so there is no
18
+ * longer a `process.argv[1]` basename that means anything. Direct callers
19
+ * that have no chain of their own fall back to `"adw"`.
15
20
  */
16
- export declare function ensure(cfg: SFConfig, adwId?: string | null, cwd?: string): Run;
21
+ export declare function ensure(cfg: SFConfig, adwId?: string | null, cwd?: string, chainName?: string): Run;
@@ -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
  *
@@ -33,8 +34,13 @@ function finalizeWhenKilled(run) {
33
34
  * process happened to start; it is an explicit decision, threaded down from
34
35
  * the CLI/chain context. Defaults to `process.cwd()` only for direct callers
35
36
  * (tests, scratch scripts) that have no anchor of their own to pass.
37
+ *
38
+ * `chainName` is the CLI name (`"plan-build-test"`, not a module basename) —
39
+ * every chain is a composed step list now, not its own file, so there is no
40
+ * longer a `process.argv[1]` basename that means anything. Direct callers
41
+ * that have no chain of their own fall back to `"adw"`.
36
42
  */
37
- export function ensure(cfg, adwId, cwd) {
43
+ export function ensure(cfg, adwId, cwd, chainName) {
38
44
  const id = adwId || newId(8);
39
45
  const anchor = paths.resolveAnchor(cwd);
40
46
  const dataPaths = paths.resolveDataPaths(anchor, cfg.defaults.data_dir, cfg.observability.db);
@@ -47,10 +53,11 @@ export function ensure(cfg, adwId, cwd) {
47
53
  repoRoot: anchor.repo_root,
48
54
  sfDir: anchor.spf_dir,
49
55
  dataDir: dataPaths.data_dir,
56
+ chainName: chainName || "adw",
57
+ notifier: resolveNotifier(cfg),
50
58
  });
51
59
  const scriptPath = process.argv[1] || "adw";
52
- const adwName = path.basename(scriptPath, path.extname(scriptPath));
53
- tracer.sessionStart(id, run.engineer, adwName);
60
+ tracer.sessionStart(id, run.engineer, chainName || "adw");
54
61
  // This process is the run. Record it before any phase opens, so a run that
55
62
  // hangs in its first agent call is still killable by adw_id.
56
63
  tracer.processStart(id, "adw", "", process.pid ?? -1, [path.basename(scriptPath), ...process.argv.slice(2)].join(" "));
@@ -12,7 +12,7 @@ import { newId, nowIso } from "./utils.js";
12
12
  const SCHEMA = `
13
13
  CREATE TABLE IF NOT EXISTS sessions (
14
14
  adw_id TEXT PRIMARY KEY,
15
- adw_name TEXT, -- ADW script(s) run, e.g. "adw_plan + adw_build_test"
15
+ adw_name TEXT, -- chain(s) run, e.g. "plan + build-test"
16
16
  request TEXT,
17
17
  status TEXT,
18
18
  engineer TEXT,
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Small shared helpers. Anything bigger belongs in its own module.
3
3
  *
4
- * Bun loads .env files automatically for anything run with `bun run`/`bun`,
5
- * so there is no load_dotenv() call to make here that parity is free.
4
+ * `.env` loading lives in cli/index.ts's `main()`, via Node's built-in
5
+ * `process.loadEnvFile()` anchored to the resolved repo root, not
6
+ * `process.cwd()`. Not here: this module has no anchor of its own to load
7
+ * relative to.
6
8
  */
7
9
  import path from "node:path";
8
10
  /**
@@ -22,6 +24,8 @@ export declare function nowIso(): string;
22
24
  export declare function ensureDir(dirPath: string): string;
23
25
  /** CLI prompt arg: a file path resolves to its contents, else inline text. */
24
26
  export declare function resolvePrompt(arg: string): string;
27
+ /** True when `bin` resolves on PATH (or exists, if given as an absolute/relative path). Shared by `spf doctor` and the `spf init` interview so both agree on what's installed. */
28
+ export declare function binaryOnPath(bin: string): boolean;
25
29
  export declare function engineerName(): string;
26
30
  /**
27
31
  * Minimal `--flag value` / `--bare-flag` CLI parsing — the CLI only ever
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Small shared helpers. Anything bigger belongs in its own module.
3
3
  *
4
- * Bun loads .env files automatically for anything run with `bun run`/`bun`,
5
- * so there is no load_dotenv() call to make here that parity is free.
4
+ * `.env` loading lives in cli/index.ts's `main()`, via Node's built-in
5
+ * `process.loadEnvFile()` anchored to the resolved repo root, not
6
+ * `process.cwd()`. Not here: this module has no anchor of its own to load
7
+ * relative to.
6
8
  */
7
9
  import { randomBytes } from "node:crypto";
8
10
  import { spawnSync } from "node:child_process";
@@ -50,6 +52,13 @@ export function resolvePrompt(arg) {
50
52
  }
51
53
  return arg;
52
54
  }
55
+ /** True when `bin` resolves on PATH (or exists, if given as an absolute/relative path). Shared by `spf doctor` and the `spf init` interview so both agree on what's installed. */
56
+ export function binaryOnPath(bin) {
57
+ if (path.isAbsolute(bin) || bin.includes("/"))
58
+ return existsSync(bin);
59
+ const result = spawnSync(process.platform === "win32" ? "where" : "which", [bin], { encoding: "utf-8" });
60
+ return result.status === 0;
61
+ }
53
62
  export function engineerName() {
54
63
  const name = (process.env.ENGINEER_NAME || "").trim();
55
64
  if (name)
@@ -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>;