@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
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Step primitives: named, reusable pieces of chain composition.
3
+ *
4
+ * Every chain used to be a hand-written module repeating the same prologue,
5
+ * the same `request` phase, and — where chains overlapped — byte-identical
6
+ * `plan`/`build`/test-fix/review-revise/commit phases. This file is that
7
+ * code, extracted once: a `Step` is one `run.phase(...)` call (or a small
8
+ * bounded group of them) lifted out verbatim and parameterized only where
9
+ * the chains genuinely differed.
10
+ *
11
+ * A Step reads and writes a shared `ChainState` — the local variables a
12
+ * hand-written chain would otherwise thread from one `run.phase` call to the
13
+ * next (the last envelope, the last quality result, whether the run is
14
+ * accepted). Composing a chain is then an ordered array of Steps.
15
+ *
16
+ * The loops (`fixLoop`, `reviseLoop`) own their bounded iteration and dynamic
17
+ * phase naming (`test_1`, `fix_1`, ...) INTERNALLY — a chain's step list is
18
+ * therefore always flat, with no loop or conditional syntax at the
19
+ * composition layer. That is what would make a future declarative (YAML)
20
+ * chain tractable: it would only ever need to name steps and pass them
21
+ * tuning params, never express control flow.
22
+ */
23
+ import * as changesLib from "../core/changes.js";
24
+ import * as gates from "../core/gates.js";
25
+ import * as quality from "../core/quality.js";
26
+ import * as agentsCfg from "../core/agents.js";
27
+ import * as session from "../core/session.js";
28
+ import { DOCUMENT_NOTES } from "../core/prompts.js";
29
+ import { BuildOutput, DocumentOutput, GenericOutput, PlanOutput, ReviewOutput, ScoutOutput, makeAgentCall, makeChangeCapture, makePhaseParams, } from "../core/data_types.js";
30
+ import { Run } from "../core/runner.js";
31
+ function makeState(prompt, options) {
32
+ return { prompt, options, previous: null, quality: null, review: null, changeset: null, baseline: "", accepted: true, reason: "" };
33
+ }
34
+ function makeStep(fn, meta = {}) {
35
+ const step = fn;
36
+ step.requiredAgents = meta.requiredAgents;
37
+ step.requiredSuites = meta.requiredSuites;
38
+ step.label = meta.label;
39
+ return step;
40
+ }
41
+ // ── layer 1: the shared prologue ────────────────────────────────────────
42
+ /** The identical loadConfig -> validate -> session.ensure prologue every chain repeated. */
43
+ export function startRun(ctx, requiredAgents, requiredSuites) {
44
+ const cfg = agentsCfg.loadConfig(ctx.config_paths);
45
+ agentsCfg.validate(cfg, requiredAgents, requiredSuites, ctx.cwd);
46
+ return session.ensure(cfg, ctx.adw_id, ctx.cwd, ctx.chain_name);
47
+ }
48
+ /** Commit an envelope in its own author's words — the message-fallback four chains repeated. */
49
+ export function commitEnvelope(run, ph, envelope) {
50
+ const message = envelope.commit_message || `spf(${run.adw_id}): ${envelope.summary}`;
51
+ ph.log({ sha: run.git.commitAll(message), message });
52
+ }
53
+ /** Log a change-capture result the same way every chain that captures one did. */
54
+ export function logChangeset(ph, result) {
55
+ ph.log({
56
+ base: `${result.base.label} @ ${result.base.commit.slice(0, 7)}`,
57
+ reason: result.base.reason,
58
+ files: result.files.length + result.untracked.length,
59
+ lines: `+${result.insertions} -${result.deletions}`,
60
+ diff: result.diff_path,
61
+ });
62
+ }
63
+ // ── layer 2: step factories ──────────────────────────────────────────────
64
+ /** The engineer(request) phase every chain opens with. */
65
+ export function request(opts = {}) {
66
+ const fn = async (run, state) => {
67
+ if (opts.logBaseline)
68
+ state.baseline = run.git.rev("HEAD");
69
+ await run.phase(makePhaseParams({ name: "request", kind: "engineer", owner: run.engineer, description: opts.description ?? "Capture the incoming ask" }), async (ph) => {
70
+ const payload = { input: state.prompt };
71
+ if (opts.logBaseline)
72
+ payload.baseline = run.git.shortSha(state.baseline);
73
+ ph.log(payload);
74
+ });
75
+ };
76
+ return makeStep(fn, { label: "engineer(request)" });
77
+ }
78
+ /** One agent phase: prompt in, typed envelope out, `previous` is whatever the prior step left in state. */
79
+ function agentStep(opts) {
80
+ const fn = async (run, state) => {
81
+ const envelope = await run.phase(makePhaseParams({ name: opts.name, kind: "agent", owner: opts.owner, description: opts.description, retries: opts.retries ?? 0 }), (ph) => ph.call(makeAgentCall({ output_type: opts.output_type, prompt: state.prompt, previous: state.previous, gates: opts.gates ?? [] })));
82
+ state.previous = envelope;
83
+ };
84
+ return makeStep(fn, { requiredAgents: [opts.owner], label: opts.label ?? opts.owner });
85
+ }
86
+ export function plan() {
87
+ return agentStep({
88
+ name: "plan",
89
+ owner: "planner",
90
+ output_type: PlanOutput,
91
+ description: "Turn the request into an implementable plan",
92
+ gates: [gates.artifactsExist, gates.filesNonEmpty],
93
+ });
94
+ }
95
+ /**
96
+ * `fromPlan` only changes the description — whether a plan() step precedes
97
+ * this one already decides whether `state.previous` is a plan or null, so
98
+ * there is nothing else for this flag to gate.
99
+ */
100
+ export function build(opts = {}) {
101
+ const fromPlan = opts.fromPlan ?? true;
102
+ return agentStep({
103
+ name: "build",
104
+ owner: "builder",
105
+ output_type: BuildOutput,
106
+ description: fromPlan ? "Implement the plan exactly" : "Implement the request",
107
+ gates: [gates.diffMatchesClaims],
108
+ retries: opts.retries ?? 0,
109
+ });
110
+ }
111
+ export function scout() {
112
+ return agentStep({
113
+ name: "scout",
114
+ owner: "scout",
115
+ output_type: ScoutOutput,
116
+ description: "Find and report where things live — change nothing",
117
+ gates: [gates.artifactsExist],
118
+ });
119
+ }
120
+ /** The `prompt` chain's one step: --agent picks who, at run time and at requiredAgents-derivation time alike. */
121
+ export function promptOnly() {
122
+ const fn = async (run, state) => {
123
+ const owner = state.options["agent"] ?? "builder";
124
+ const envelope = await run.phase(makePhaseParams({ name: "prompt", kind: "agent", owner, description: `Send the request straight to ${owner} and parse its envelope` }), (ph) => ph.call(makeAgentCall({ output_type: GenericOutput, prompt: state.prompt, previous: state.previous })));
125
+ state.previous = envelope;
126
+ };
127
+ return makeStep(fn, { requiredAgents: (options) => [options["agent"] ?? "builder"], label: "<agent>" });
128
+ }
129
+ /** One deterministic quality block, standalone — never throws; sets state.accepted for run.finish() to check. */
130
+ export function qualityCheck(opts = { suite: "all" }) {
131
+ const fn = async (run, state) => {
132
+ await run.phase(makePhaseParams({
133
+ name: opts.suite === "all" ? "quality" : "test",
134
+ kind: "code",
135
+ owner: "quality",
136
+ description: opts.description ??
137
+ (opts.suite === "all" ? "Run the deterministic quality blocks" : "Run the suite — a known command, so code runs it and no agent has to rediscover it"),
138
+ }), async (ph) => {
139
+ const result = opts.suite === "all" ? quality.runQuality(run) : quality.runTests(run);
140
+ quality.record(ph, result);
141
+ state.quality = result;
142
+ state.accepted = result.passed;
143
+ state.reason = result.passed ? "" : `quality failed: ${result.failures.join("; ")}`;
144
+ });
145
+ };
146
+ return makeStep(fn, { requiredSuites: [opts.suite], label: opts.suite === "all" ? "code(quality)" : "code(test)" });
147
+ }
148
+ /**
149
+ * Bounded check -> fix loop: a known command finds the failure, the builder
150
+ * repairs it. Always guards the last iteration — a fix on the final attempt
151
+ * is never re-verified, so it is never spawned. Sets state.accepted/reason.
152
+ */
153
+ export function fixLoop(opts = { suite: "test" }) {
154
+ const max = opts.max ?? 3;
155
+ const owner = opts.owner ?? "builder";
156
+ const stepName = opts.suite === "all" ? "verify" : "test";
157
+ const what = opts.suite === "all" ? "verification" : "tests";
158
+ const fn = async (run, state) => {
159
+ let result = null;
160
+ for (let i = 1; i <= max; i++) {
161
+ result = await run.phase(makePhaseParams({
162
+ name: `${stepName}_${i}`,
163
+ kind: "code",
164
+ owner: "quality",
165
+ description: opts.suite === "all"
166
+ ? "Lint, typecheck, and build before testing"
167
+ : "Run the suite — a known command, so code runs it and no agent has to rediscover it",
168
+ }), async (ph) => {
169
+ const r = opts.suite === "all" ? quality.runQuality(run) : quality.runTests(run);
170
+ quality.record(ph, r);
171
+ return r;
172
+ });
173
+ if (result.passed)
174
+ break;
175
+ if (i === max)
176
+ break; // never leave an unverified fix on the table
177
+ state.previous = await run.phase(makePhaseParams({ name: `fix_${i}`, kind: "agent", owner, retries: 1, description: "Repair what the suite reported, from its verbatim output" }), (ph) => ph.call(makeAgentCall({
178
+ output_type: BuildOutput,
179
+ prompt: state.prompt,
180
+ previous: quality.asEnvelope(result, what),
181
+ gates: [gates.diffMatchesClaims],
182
+ })));
183
+ }
184
+ state.quality = result;
185
+ state.accepted = result !== null && result.passed;
186
+ state.reason = state.accepted ? "" : `the suite still failed after ${max} fix attempt(s)`;
187
+ };
188
+ return makeStep(fn, {
189
+ requiredAgents: [owner],
190
+ requiredSuites: [opts.suite],
191
+ label: `code(${stepName}) [-> ${owner}(fix) -> code(${stepName}) ...] bounded`,
192
+ });
193
+ }
194
+ /** Bounded review -> revise loop. Sets state.accepted/reason from the final verdict. */
195
+ export function reviseLoop(opts = {}) {
196
+ const max = opts.max ?? 3;
197
+ const fn = async (run, state) => {
198
+ let review = null;
199
+ for (let i = 1; i <= max; i++) {
200
+ review = await run.phase(makePhaseParams({ name: `review_${i}`, kind: "agent", owner: "reviewer", description: "Rule on every requirement in the spec, against the code on disk" }), (ph) => ph.call(makeAgentCall({ output_type: ReviewOutput, prompt: state.prompt, previous: state.previous, gates: [gates.artifactsExist, gates.verdictConsistent] })));
201
+ if (review.approved || i === max)
202
+ break;
203
+ state.previous = await run.phase(makePhaseParams({ name: `revise_${i}`, kind: "agent", owner: "builder", retries: 1, description: "Close every blocking finding the reviewer named" }), (ph) => ph.call(makeAgentCall({ output_type: BuildOutput, prompt: state.prompt, previous: review, gates: [gates.diffMatchesClaims] })));
204
+ }
205
+ state.review = review;
206
+ state.accepted = review !== null && review.approved;
207
+ state.reason = state.accepted ? "" : `the reviewer never approved after ${max} revision(s)`;
208
+ };
209
+ return makeStep(fn, { requiredAgents: ["reviewer", "builder"], label: "reviewer [-> builder(revise) -> reviewer ...] bounded" });
210
+ }
211
+ /** Commit the last agent step's envelope. `onlyIfAccepted` gates it on state.accepted (a preceding fixLoop/reviseLoop). */
212
+ export function commit(opts = {}) {
213
+ const fn = async (run, state) => {
214
+ if (opts.onlyIfAccepted && !state.accepted)
215
+ return;
216
+ if (!state.previous)
217
+ throw new Error("commit() has nothing to commit — no preceding agent step produced an envelope");
218
+ await run.phase(makePhaseParams({
219
+ name: "commit",
220
+ kind: "code",
221
+ owner: "git",
222
+ description: opts.onlyIfAccepted
223
+ ? "Land the code only after the suite came back green"
224
+ : "Land the builder's changes, using the message it wrote",
225
+ }), async (ph) => commitEnvelope(run, ph, state.previous));
226
+ };
227
+ return makeStep(fn, { label: "git(commit)" });
228
+ }
229
+ /** Diff the working tree against a base ref — code, not judgement. `--base` (default "main") if opts.base is unset. */
230
+ export function changes(opts = {}) {
231
+ const fn = async (run, state) => {
232
+ const base = opts.base ?? state.options["base"] ?? "main";
233
+ const changeset = await run.phase(makePhaseParams({ name: "changes", kind: "code", owner: "git", description: `Diff the working tree against ${base} — the change to be written up` }), async (ph) => {
234
+ const result = changesLib.capture(run, makeChangeCapture({ base }));
235
+ logChangeset(ph, result);
236
+ if (result.empty) {
237
+ throw new Error(`nothing changed since ${result.base.label} (${result.base.reason}) — documenting runs after a build. ` +
238
+ `Build something first, or point --base at the ref the work should be measured from.`);
239
+ }
240
+ return result;
241
+ });
242
+ state.changeset = changeset;
243
+ };
244
+ return makeStep(fn, { label: "code(changes)" });
245
+ }
246
+ /** Write up the captured change. Requires a preceding changes() step. */
247
+ export function document() {
248
+ const fn = async (run, state) => {
249
+ if (!state.changeset)
250
+ throw new Error("document() requires a preceding changes() step in the chain's step list");
251
+ const envelope = await run.phase(makePhaseParams({ name: "document", kind: "agent", owner: "documenter", retries: 1, description: "Turn the captured diff into a write-up an engineer can read" }), (ph) => ph.call(makeAgentCall({
252
+ output_type: DocumentOutput,
253
+ prompt: state.prompt,
254
+ previous: changesLib.asEnvelope(state.changeset, DOCUMENT_NOTES),
255
+ gates: [gates.artifactsExist, gates.filesNonEmpty],
256
+ })));
257
+ state.previous = envelope;
258
+ };
259
+ return makeStep(fn, { requiredAgents: ["documenter"], label: "documenter" });
260
+ }
261
+ // ── layer 3: derive ChainDefinition fields from a step list ─────────────
262
+ export function deriveRequiredAgents(steps) {
263
+ const dynamicSteps = steps.filter((s) => typeof s.requiredAgents === "function");
264
+ const staticSteps = steps.filter((s) => Array.isArray(s.requiredAgents));
265
+ if (dynamicSteps.length === 0) {
266
+ return [...new Set(staticSteps.flatMap((s) => s.requiredAgents))];
267
+ }
268
+ return (options) => {
269
+ const set = new Set();
270
+ for (const s of staticSteps)
271
+ for (const a of s.requiredAgents)
272
+ set.add(a);
273
+ for (const s of dynamicSteps)
274
+ for (const a of s.requiredAgents(options))
275
+ set.add(a);
276
+ return [...set];
277
+ };
278
+ }
279
+ export function deriveRequiredSuites(steps) {
280
+ const set = new Set();
281
+ for (const s of steps)
282
+ for (const suite of s.requiredSuites ?? [])
283
+ set.add(suite);
284
+ return [...set];
285
+ }
286
+ /** A display string for `spf list` — derived so it can no longer drift from what actually runs. */
287
+ export function derivePhases(steps) {
288
+ return steps.map((s) => s.label ?? "?").join(" -> ");
289
+ }
290
+ // ── the driver ────────────────────────────────────────────────────────────
291
+ /** Run a chain's step list start to finish: prologue, every step in order, then run.finish(). */
292
+ export async function runSteps(ctx, requiredAgents, requiredSuites, steps, options = {}) {
293
+ const run = startRun(ctx, requiredAgents, requiredSuites);
294
+ const state = makeState(ctx.prompt, options);
295
+ for (const step of steps) {
296
+ await step(run, state);
297
+ }
298
+ return run.finish(state.accepted, state.reason);
299
+ }
@@ -0,0 +1,27 @@
1
+ export interface SelectChoice<T extends string> {
2
+ value: T;
3
+ label?: string;
4
+ hint?: string;
5
+ }
6
+ export interface Asker {
7
+ text(label: string, opts?: {
8
+ default?: string;
9
+ validate?: (value: string) => string | null;
10
+ }): Promise<string>;
11
+ select<T extends string>(label: string, choices: SelectChoice<T>[], dflt: T): Promise<T>;
12
+ confirm(label: string, dflt: boolean): Promise<boolean>;
13
+ /** Echo-suppressed. `current` (if any) is shown masked; an empty answer keeps it and resolves to `""`. */
14
+ secret(label: string, opts?: {
15
+ current?: string;
16
+ }): Promise<string>;
17
+ note(text: string): void;
18
+ heading(text: string): void;
19
+ close(): void;
20
+ }
21
+ /** `stdin.isTTY` is what actually matters (the interview reads it) — `stdout.isTTY` alone, this repo's only prior TTY check (`src/ui/server/serve.ts:88`), would let a piped-in `spf init` hang waiting on input that will never arrive. */
22
+ export declare function isInteractive(): boolean;
23
+ /** Thrown when the user interrupts (Ctrl-C) or stdin closes (EOF) mid-interview. `initCommand` catches this and exits 130, writing nothing. */
24
+ export declare class InterviewAborted extends Error {
25
+ constructor();
26
+ }
27
+ export declare function createAsker(): Asker;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Zero-dependency interactive prompt primitives for `spf init`'s interview.
3
+ *
4
+ * No prompting library exists anywhere in this repo, and the project keeps a
5
+ * deliberately thin dependency list (`@flue/runtime`, `hono`, `valibot`,
6
+ * `yaml`) — this builds directly on `node:readline/promises` rather than
7
+ * adding one. `Asker` is an interface, not a class, so a test can supply a
8
+ * scripted fake instead of driving a real TTY — the same seam this repo
9
+ * already uses for `IssueProvider`/`CodeHostProvider` (see
10
+ * `src/test/watch.test.ts`'s `FakeProvider`).
11
+ */
12
+ import { createInterface } from "node:readline/promises";
13
+ import { Writable } from "node:stream";
14
+ import { paint } from "../core/console.js";
15
+ /** `stdin.isTTY` is what actually matters (the interview reads it) — `stdout.isTTY` alone, this repo's only prior TTY check (`src/ui/server/serve.ts:88`), would let a piped-in `spf init` hang waiting on input that will never arrive. */
16
+ export function isInteractive() {
17
+ return Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY) && !process.env["CI"];
18
+ }
19
+ /** Thrown when the user interrupts (Ctrl-C) or stdin closes (EOF) mid-interview. `initCommand` catches this and exits 130, writing nothing. */
20
+ export class InterviewAborted extends Error {
21
+ constructor() {
22
+ super("interview interrupted");
23
+ }
24
+ }
25
+ class MutableWritable extends Writable {
26
+ muted = false;
27
+ _write(chunk, _encoding, callback) {
28
+ if (!this.muted)
29
+ process.stdout.write(chunk);
30
+ callback();
31
+ }
32
+ }
33
+ export function createAsker() {
34
+ const muteableOut = new MutableWritable();
35
+ const rl = createInterface({ input: process.stdin, output: muteableOut, terminal: true });
36
+ let aborted = false;
37
+ rl.on("SIGINT", () => {
38
+ aborted = true;
39
+ rl.close();
40
+ });
41
+ async function raw(prompt) {
42
+ if (aborted)
43
+ throw new InterviewAborted();
44
+ try {
45
+ const answer = await rl.question(prompt);
46
+ return answer.trim();
47
+ }
48
+ catch {
49
+ // readline rejects `question()` if the interface is closed underneath it (EOF/SIGINT mid-prompt).
50
+ throw new InterviewAborted();
51
+ }
52
+ }
53
+ return {
54
+ async text(label, opts) {
55
+ const suffix = opts?.default ? paint("dim", ` [${opts.default}]`) : "";
56
+ while (true) {
57
+ const answer = await raw(`${label}${suffix}: `);
58
+ const value = answer || opts?.default || "";
59
+ const problem = opts?.validate?.(value);
60
+ if (problem) {
61
+ console.log(paint("red", ` ${problem}`));
62
+ continue;
63
+ }
64
+ return value;
65
+ }
66
+ },
67
+ async select(label, choices, dflt) {
68
+ console.log(label);
69
+ for (const c of choices) {
70
+ const marker = c.value === dflt ? paint("bold", "*") : " ";
71
+ const hint = c.hint ? paint("dim", ` — ${c.hint}`) : "";
72
+ console.log(` ${marker} ${c.label ?? c.value}${hint}`);
73
+ }
74
+ const valid = new Set(choices.map((c) => c.value));
75
+ while (true) {
76
+ const answer = await raw(paint("dim", `choose [${dflt}]: `));
77
+ if (!answer)
78
+ return dflt;
79
+ if (valid.has(answer))
80
+ return answer;
81
+ console.log(paint("red", ` not one of: ${[...valid].join(", ")}`));
82
+ }
83
+ },
84
+ async confirm(label, dflt) {
85
+ const hint = dflt ? "Y/n" : "y/N";
86
+ const answer = (await raw(`${label} ${paint("dim", `[${hint}]`)}: `)).toLowerCase();
87
+ if (!answer)
88
+ return dflt;
89
+ return answer === "y" || answer === "yes";
90
+ },
91
+ async secret(label, opts) {
92
+ const maskedCurrent = opts?.current ? paint("dim", ` [keep current: ${maskForPrompt(opts.current)}]`) : "";
93
+ // The " > " marker is written unmuted, before muting starts — otherwise
94
+ // it vanishes along with the (correctly) suppressed keystroke echo,
95
+ // and an empty line reads as a hang rather than a waiting prompt.
96
+ console.log(`${label}${maskedCurrent}`);
97
+ process.stdout.write(" > ");
98
+ muteableOut.muted = true;
99
+ let answer;
100
+ try {
101
+ answer = await raw("");
102
+ }
103
+ finally {
104
+ muteableOut.muted = false;
105
+ process.stdout.write("\n"); // the newline the muted output swallowed
106
+ }
107
+ return answer;
108
+ },
109
+ note(text) {
110
+ console.log(paint("dim", ` ${text}`));
111
+ },
112
+ heading(text) {
113
+ console.log("");
114
+ console.log(paint("bold cyan", `── ${text} ──`));
115
+ },
116
+ close() {
117
+ rl.close();
118
+ },
119
+ };
120
+ }
121
+ function maskForPrompt(value) {
122
+ if (value.length <= 4)
123
+ return "•".repeat(value.length);
124
+ return `${"•".repeat(Math.max(0, value.length - 4))}${value.slice(-4)}`;
125
+ }
@@ -13,38 +13,17 @@ import * as agents from "../../core/agents.js";
13
13
  import * as paths from "../../core/paths.js";
14
14
  import * as permissions from "../../core/permissions.js";
15
15
  import * as agentCc from "../../core/agent_cc.js";
16
+ import { DEFAULT_NOTIFY_ENV_KEY } from "../../core/notify/notifier.js";
16
17
  import { isKnownToolName as isKnownFlueToolName, resolveModel } from "../../core/agent_flue.js";
17
- import { parseCli } from "../../core/utils.js";
18
+ import { binaryOnPath, parseCli } from "../../core/utils.js";
19
+ import { PROVIDER_ENV_KEYS } from "../../core/providers.js";
18
20
  import { isRepoAt } from "../../core/git_helper.js";
19
21
  import { findChain } from "../../chains/index.js";
20
- // Common providers' env var conventions — public knowledge (pi-ai's own
21
- // resolution table is internal, unexported, and not something to reach into
22
- // for this). Missing from this table just means "unknown provider, skipped
23
- // the key check" — never a false failure.
24
- const PROVIDER_ENV_KEYS = {
25
- anthropic: ["ANTHROPIC_API_KEY"],
26
- openai: ["OPENAI_API_KEY"],
27
- google: ["GEMINI_API_KEY", "GOOGLE_API_KEY"],
28
- openrouter: ["OPENROUTER_API_KEY"],
29
- fireworks: ["FIREWORKS_API_KEY"],
30
- groq: ["GROQ_API_KEY"],
31
- mistral: ["MISTRAL_API_KEY"],
32
- xai: ["XAI_API_KEY"],
33
- deepseek: ["DEEPSEEK_API_KEY"],
34
- together: ["TOGETHER_API_KEY"],
35
- cerebras: ["CEREBRAS_API_KEY"],
36
- };
37
22
  function check(report, name, ok, detail) {
38
23
  report.checks.push({ name, ok, detail });
39
24
  if (!ok)
40
25
  report.ok = false;
41
26
  }
42
- function binaryOnPath(bin) {
43
- if (path.isAbsolute(bin) || bin.includes("/"))
44
- return existsSync(bin);
45
- const result = spawnSync(process.platform === "win32" ? "where" : "which", [bin], { encoding: "utf-8" });
46
- return result.status === 0;
47
- }
48
27
  export function doctorCommand(argv) {
49
28
  const { options, flags } = parseCli(argv, ["cwd", "config"], ["json"]);
50
29
  const report = { ok: true, checks: [] };
@@ -165,6 +144,17 @@ export function doctorCommand(argv) {
165
144
  }
166
145
  check(report, "watch.chain", Boolean(findChain(cfg.watch.chain)), findChain(cfg.watch.chain) ? cfg.watch.chain : `"${cfg.watch.chain}" is not a registered chain`);
167
146
  }
147
+ if (cfg.notifications.events !== "off") {
148
+ check(report, "notifications.events", true, cfg.notifications.events);
149
+ if (cfg.notifications.channels.length === 0) {
150
+ check(report, "notifications.channels", false, `notifications.events is ${JSON.stringify(cfg.notifications.events)} but no channels are configured`);
151
+ }
152
+ for (const ch of cfg.notifications.channels) {
153
+ const envKey = ch.webhook_url_env || DEFAULT_NOTIFY_ENV_KEY[ch.kind];
154
+ const label = ch.name ? `${ch.kind} (${ch.name})` : ch.kind;
155
+ check(report, `notifications: ${label}`, Boolean(process.env[envKey]), process.env[envKey] ? `${envKey} set` : `${envKey} is not set`);
156
+ }
157
+ }
168
158
  return finish(report, flags["json"]);
169
159
  }
170
160
  function finish(report, json) {
@@ -1 +1 @@
1
- export declare function initCommand(argv: string[]): number;
1
+ export declare function initCommand(argv: string[]): Promise<number>;
@@ -1,10 +1,27 @@
1
- /** `spf init` — seed a `.spf/` override directory. Everything else is inherited from the packaged defaults. */
1
+ /**
2
+ * `spf init` — seed a `.spf/` override directory. Everything else is
3
+ * inherited from the packaged defaults.
4
+ *
5
+ * On a TTY (and without `--template`/`--yes`), this runs an interview
6
+ * instead of writing the all-comments starter file: it asks which coding
7
+ * agent, model/provider, quality checks, and (if wanted) `spf watch`
8
+ * tracker/host to use, collects the secrets those answers imply, and
9
+ * appends them to `.env` (already auto-loaded by every command — see
10
+ * `src/cli/index.ts`). Piped input, `--yes`, or `--template <name>` all
11
+ * fall through to the original non-interactive behavior unchanged — a
12
+ * scripted `spf init` must never hang waiting on stdin.
13
+ */
2
14
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
15
  import path from "node:path";
16
+ import { stringify } from "yaml";
4
17
  import * as paths from "../../core/paths.js";
18
+ import * as agents from "../../core/agents.js";
5
19
  import { ensureGitignore } from "../gitignore.js";
6
20
  import { parseCli } from "../../core/utils.js";
7
21
  import { paint } from "../../core/console.js";
22
+ import { createAsker, isInteractive, InterviewAborted } from "../ask.js";
23
+ import { gatherContext, runInterview } from "../interview.js";
24
+ import { readEnvFile, upsertEnvFile, writeEnvExample } from "../env_file.js";
8
25
  const TEMPLATE_SUFFIX = ".spf.config.yaml";
9
26
  /** Every template's short name (e.g. "ts-cc"), derived from disk rather than hand-maintained — never drifts from what's actually packaged. */
10
27
  function listTemplates() {
@@ -69,26 +86,96 @@ const STARTER_CONFIG = `# .spf/spf.config.yaml — merged ON TOP of spf's packag
69
86
  # label_prefix: spf
70
87
  # chain: plan-build-test
71
88
  # base_branch: main
89
+
90
+ # Uncomment to push notifications for unattended work — spf watch's daemon
91
+ # lifecycle, and every chain run (spf <chain> / spf run, including watch's
92
+ # own per-issue runs). Interactive commands (doctor, list, sessions, ...)
93
+ # never notify — you're already looking at the terminal for those. events:
94
+ # "errors" sends only failures/blocked issues; "all" adds every milestone
95
+ # (run started, issue claimed, PR opened, ...). The URL is a secret and
96
+ # lives only in .env — never in this file. See README.md's "Notifications"
97
+ # section for how to get each webhook URL.
98
+ # notifications:
99
+ # events: errors # off (default) | errors | all
100
+ # channels:
101
+ # - kind: slack # slack | teams | webhook
102
+ # webhook_url_env: SLACK_WEBHOOK_URL # default for slack; TEAMS_WEBHOOK_URL / SPF_WEBHOOK_URL for the others
72
103
  `;
73
104
  // .spf/spf.config.yaml and .spf/prompt_engineering/ stay tracked — they're
74
105
  // shared project config, same as package.json. Only runtime/generated
75
106
  // content is ignored: session traces (data/), a hand-editable engine copy
76
107
  // (engine/, from `spf eject`), and secrets (.env).
77
108
  const GITIGNORE_ENTRIES = [".spf/data/", ".spf/engine/", ".env"];
78
- export function initCommand(argv) {
79
- const { options, flags } = parseCli(argv, ["cwd", "template"], ["force"]);
109
+ const GENERATED_HEADER = `# .spf/spf.config.yaml — written by \`spf init\`'s interview, merged ON TOP of
110
+ # spf's packaged built-in defaults. Only what you changed is here; run
111
+ # \`spf doctor\` any time to see what's actually in effect for this repo, and
112
+ # where each value came from. Secrets this config implies live in .env
113
+ # (gitignored) — .env.example lists the key names only.
114
+ `;
115
+ export async function initCommand(argv) {
116
+ const { options, flags } = parseCli(argv, ["cwd", "template"], ["force", "yes"]);
80
117
  const anchor = paths.resolveAnchor(options["cwd"]);
81
118
  const sfDir = path.join(anchor.repo_root, ".spf");
82
119
  mkdirSync(sfDir, { recursive: true });
83
120
  const configPath = path.join(sfDir, "spf.config.yaml");
84
- if (existsSync(configPath) && !flags["force"]) {
85
- console.log(`${configPath} already exists leaving it alone (--force to overwrite)`);
121
+ const templateName = options["template"];
122
+ const interactive = !templateName && !flags["yes"] && isInteractive();
123
+ if (!interactive) {
124
+ if (existsSync(configPath) && !flags["force"]) {
125
+ console.log(`${configPath} already exists — leaving it alone (--force to overwrite)`);
126
+ }
127
+ else {
128
+ const content = templateName ? loadTemplate(templateName) : STARTER_CONFIG;
129
+ writeFileSync(configPath, content);
130
+ console.log(`wrote ${configPath}${templateName ? ` (from template "${templateName}")` : ""}`);
131
+ }
86
132
  }
87
133
  else {
88
- const templateName = options["template"];
89
- const content = templateName ? loadTemplate(templateName) : STARTER_CONFIG;
90
- writeFileSync(configPath, content);
91
- console.log(`wrote ${configPath}${templateName ? ` (from template "${templateName}")` : ""}`);
134
+ const asker = createAsker();
135
+ try {
136
+ if (existsSync(configPath) && !flags["force"]) {
137
+ const overwrite = await asker.confirm(`${configPath} already exists overwrite it?`, false);
138
+ if (!overwrite) {
139
+ console.log("leaving the existing config alone (--force to skip this prompt)");
140
+ asker.close();
141
+ ensureGitignore(anchor.repo_root, GITIGNORE_ENTRIES);
142
+ return 0;
143
+ }
144
+ }
145
+ const envPath = path.join(anchor.repo_root, ".env");
146
+ const ctx = gatherContext(anchor.repo_root, readEnvFile(envPath));
147
+ const result = await runInterview(asker, ctx);
148
+ asker.close();
149
+ if (!result) {
150
+ console.log("init cancelled — nothing written");
151
+ return 1;
152
+ }
153
+ writeFileSync(configPath, GENERATED_HEADER + stringify(result.config));
154
+ console.log(`wrote ${configPath}`);
155
+ if (Object.keys(result.env).length > 0)
156
+ upsertEnvFile(anchor.repo_root, result.env);
157
+ writeEnvExample(anchor.repo_root, result.envExampleKeys);
158
+ // The same merge-then-validate pipeline `spf doctor` runs — catches a
159
+ // bad answer (e.g. a suite naming an unconfigured check) right after
160
+ // writing, not at the user's first real chain run. Non-fatal: the
161
+ // config is already written either way, and `spf doctor` gives the
162
+ // full picture.
163
+ try {
164
+ const cfg = agents.loadConfig([paths.BUILTIN_CONFIG_PATH, configPath]);
165
+ agents.validate(cfg, cfg.agents.map((a) => a.name), Object.keys(cfg.quality.suites), anchor.cwd);
166
+ }
167
+ catch (error) {
168
+ console.log(paint("yellow", `warning: ${error.message}\nrun \`spf doctor\` for the full picture.`));
169
+ }
170
+ }
171
+ catch (error) {
172
+ asker.close();
173
+ if (error instanceof InterviewAborted) {
174
+ console.log("\ninit interrupted — nothing written");
175
+ return 130;
176
+ }
177
+ throw error;
178
+ }
92
179
  }
93
180
  ensureGitignore(anchor.repo_root, GITIGNORE_ENTRIES);
94
181
  const templates = listTemplates();
@@ -1,3 +1,3 @@
1
- import type { ChainDefinition } from "../../chains/index.ts";
1
+ import { type ChainDefinition } from "../../chains/index.ts";
2
2
  export declare function usageFor(chain: ChainDefinition): string;
3
3
  export declare function dispatchChain(chain: ChainDefinition, argv: string[]): Promise<number>;