@gr8ful/spf 0.1.6 → 0.2.1

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 (78) hide show
  1. package/README.md +7 -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 +5 -2
  5. package/assets/templates/ts-cc.spf.config.yaml +16 -0
  6. package/assets/templates/ts-flue-openrouter.spf.config.yaml +15 -0
  7. package/assets/templates/ts.spf.config.yaml +19 -4
  8. package/dist/chains/context.d.ts +2 -0
  9. package/dist/chains/index.d.ts +21 -2
  10. package/dist/chains/index.js +73 -104
  11. package/dist/chains/{adw_simple_sdlc.d.ts → simple_sdlc.d.ts} +7 -1
  12. package/dist/chains/{adw_simple_sdlc.js → simple_sdlc.js} +19 -30
  13. package/dist/chains/steps.d.ts +117 -0
  14. package/dist/chains/steps.js +299 -0
  15. package/dist/cli/ask.d.ts +27 -0
  16. package/dist/cli/ask.js +125 -0
  17. package/dist/cli/commands/doctor.js +2 -24
  18. package/dist/cli/commands/init.d.ts +1 -1
  19. package/dist/cli/commands/init.js +92 -9
  20. package/dist/cli/commands/run.d.ts +1 -1
  21. package/dist/cli/commands/run.js +3 -1
  22. package/dist/cli/commands/watch.js +26 -4
  23. package/dist/cli/env_file.d.ts +18 -0
  24. package/dist/cli/env_file.js +99 -0
  25. package/dist/cli/index.js +2 -2
  26. package/dist/cli/interview.d.ts +24 -0
  27. package/dist/cli/interview.js +330 -0
  28. package/dist/core/issues/jira_provider.d.ts +9 -0
  29. package/dist/core/issues/jira_provider.js +20 -2
  30. package/dist/core/prompts.d.ts +2 -0
  31. package/dist/core/prompts.js +2 -0
  32. package/dist/core/providers.d.ts +12 -0
  33. package/dist/core/providers.js +24 -0
  34. package/dist/core/quality.d.ts +9 -0
  35. package/dist/core/quality.js +10 -0
  36. package/dist/core/session.d.ts +6 -1
  37. package/dist/core/session.js +7 -3
  38. package/dist/core/tracer.js +1 -1
  39. package/dist/core/utils.d.ts +6 -2
  40. package/dist/core/utils.js +11 -2
  41. package/dist/core/watch.d.ts +12 -0
  42. package/dist/core/watch.js +11 -0
  43. package/dist/test/chains.test.d.ts +12 -0
  44. package/dist/test/chains.test.js +86 -0
  45. package/dist/test/env_file.test.d.ts +1 -0
  46. package/dist/test/env_file.test.js +74 -0
  47. package/dist/test/fake_asker.d.ts +23 -0
  48. package/dist/test/fake_asker.js +30 -0
  49. package/dist/test/init_command.test.d.ts +1 -0
  50. package/dist/test/init_command.test.js +66 -0
  51. package/dist/test/interview.test.d.ts +1 -0
  52. package/dist/test/interview.test.js +179 -0
  53. package/dist/test/ui_server.test.js +1 -1
  54. package/dist/test/watch.test.js +41 -1
  55. package/dist/ui/shared/types.d.ts +1 -1
  56. package/package.json +5 -2
  57. package/dist/chains/adw_build.d.ts +0 -12
  58. package/dist/chains/adw_build.js +0 -27
  59. package/dist/chains/adw_build_review.d.ts +0 -21
  60. package/dist/chains/adw_build_review.js +0 -55
  61. package/dist/chains/adw_build_test.d.ts +0 -21
  62. package/dist/chains/adw_build_test.js +0 -67
  63. package/dist/chains/adw_document.d.ts +0 -23
  64. package/dist/chains/adw_document.js +0 -59
  65. package/dist/chains/adw_plan.d.ts +0 -12
  66. package/dist/chains/adw_plan.js +0 -27
  67. package/dist/chains/adw_plan_build.d.ts +0 -12
  68. package/dist/chains/adw_plan_build.js +0 -30
  69. package/dist/chains/adw_plan_build_test.d.ts +0 -16
  70. package/dist/chains/adw_plan_build_test.js +0 -65
  71. package/dist/chains/adw_plan_build_test_quality.d.ts +0 -18
  72. package/dist/chains/adw_plan_build_test_quality.js +0 -66
  73. package/dist/chains/adw_prompt.d.ts +0 -12
  74. package/dist/chains/adw_prompt.js +0 -25
  75. package/dist/chains/adw_quality.d.ts +0 -12
  76. package/dist/chains/adw_quality.js +0 -32
  77. package/dist/chains/adw_scout.d.ts +0 -12
  78. package/dist/chains/adw_scout.js +0 -27
@@ -1,5 +1,5 @@
1
1
  /**
2
- * ADW Simple SDLC — plan, build, test, review, document, committing as it goes.
2
+ * Simple SDLC — plan, build, test, review, document, committing as it goes.
3
3
  *
4
4
  * Usage:
5
5
  * spf simple-sdlc "<prompt or path/to/prompt.md>" [--config <path>] [--adw-id a1b2c3d4]
@@ -36,39 +36,32 @@
36
36
  * The documenter measures against the commit this run STARTED from, not against
37
37
  * `main`, because by then the run has moved `main` itself. That baseline is
38
38
  * pinned before the first commit phase and printed in the request phase.
39
+ *
40
+ * This is the one chain still shaped by hand rather than by a `steps` list —
41
+ * three commits, a pinned baseline, and a conditional retest don't collapse
42
+ * cleanly into the flat, loop-free vocabulary `./steps.ts` provides for every
43
+ * other chain. It still reuses that module's shared helpers (`startRun`,
44
+ * `commitEnvelope`, `logChangeset`) rather than keeping its own copies.
39
45
  */
40
- import * as agents from "../core/agents.js";
41
46
  import * as changes from "../core/changes.js";
42
47
  import * as gates from "../core/gates.js";
43
48
  import * as quality from "../core/quality.js";
44
- import * as session from "../core/session.js";
49
+ import { DOCUMENT_NOTES } from "../core/prompts.js";
50
+ import { commitEnvelope, logChangeset, startRun } from "./steps.js";
45
51
  import { BuildOutput, DocumentOutput, PlanOutput, ReviewOutput, makeAgentCall, makeChangeCapture, makePhaseParams, } from "../core/data_types.js";
46
52
  export const REQUIRED_AGENTS = ["planner", "builder", "reviewer", "documenter"];
47
53
  export const REQUIRED_SUITES = ["test"];
48
54
  const MAX_FIX_LOOPS = 3;
49
55
  const MAX_REVISION_LOOPS = 2;
50
- 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.";
51
- /** Commit what the preceding phase produced, in that agent's own words. */
52
- function commit(run, ph, envelope) {
53
- const message = envelope.commit_message || `spf(${run.adw_id}): ${envelope.summary}`;
54
- ph.log({ sha: run.git.commitAll(message), message });
55
- }
56
- /** Log a deterministic block's verdict — the same shape every ADW uses. */
57
- function record(ph, result) {
58
- const passed = result.checks.filter((c) => c.passed).length;
59
- ph.log({ passed: result.passed, checks: `${passed}/${result.checks.length}`, artifacts: result.artifacts.join(", ") });
60
- }
61
56
  export async function main(ctx) {
62
- const { prompt, config_paths, adw_id, cwd } = ctx;
63
- const cfg = agents.loadConfig(config_paths);
64
- agents.validate(cfg, REQUIRED_AGENTS, REQUIRED_SUITES, cwd);
65
- const run = session.ensure(cfg, adw_id, cwd);
57
+ const { prompt } = ctx;
58
+ const run = startRun(ctx, REQUIRED_AGENTS, REQUIRED_SUITES);
66
59
  const baseline = run.git.rev("HEAD"); // pinned before this run commits anything
67
60
  await run.phase(makePhaseParams({ name: "request", kind: "engineer", owner: run.engineer, description: "Capture the incoming ask" }), async (ph) => {
68
61
  ph.log({ input: prompt, baseline: run.git.shortSha(baseline) });
69
62
  });
70
63
  const plan = await run.phase(makePhaseParams({ name: "plan", kind: "agent", owner: "planner", description: "Turn the request into an implementable plan" }), (ph) => ph.call(makeAgentCall({ output_type: PlanOutput, prompt, gates: [gates.artifactsExist, gates.filesNonEmpty] })));
71
- await run.phase(makePhaseParams({ name: "commit_plan", kind: "code", owner: "git", description: "Put the spec on record before any code exists to blur it" }), async (ph) => commit(run, ph, plan));
64
+ await run.phase(makePhaseParams({ name: "commit_plan", kind: "code", owner: "git", description: "Put the spec on record before any code exists to blur it" }), async (ph) => commitEnvelope(run, ph, plan));
72
65
  let build = await run.phase(makePhaseParams({ name: "build", kind: "agent", owner: "builder", description: "Implement the plan exactly" }), (ph) => ph.call(makeAgentCall({ output_type: BuildOutput, prompt, previous: plan, gates: [gates.diffMatchesClaims] })));
73
66
  let test = null;
74
67
  for (let i = 1; i <= MAX_FIX_LOOPS; i++) {
@@ -79,11 +72,13 @@ export async function main(ctx) {
79
72
  description: "Run the suite — a known command, so code runs it and no agent has to rediscover it",
80
73
  }), async (ph) => {
81
74
  const result = quality.runTests(run);
82
- record(ph, result);
75
+ quality.record(ph, result);
83
76
  return result;
84
77
  });
85
78
  if (test.passed)
86
79
  break;
80
+ if (i === MAX_FIX_LOOPS)
81
+ break; // never leave an unverified fix on the table
87
82
  build = await run.phase(makePhaseParams({
88
83
  name: `fix_${i}`,
89
84
  kind: "agent",
@@ -111,7 +106,7 @@ export async function main(ctx) {
111
106
  description: "Re-run the suite — the revision changed code after the last green result",
112
107
  }), async (ph) => {
113
108
  const result = quality.runTests(run);
114
- record(ph, result);
109
+ quality.record(ph, result);
115
110
  return result;
116
111
  });
117
112
  }
@@ -120,16 +115,10 @@ export async function main(ctx) {
120
115
  // describing yet. The plan commit stands — it is a record of what was asked.
121
116
  const verified = test !== null && test.passed && review !== null && review.approved;
122
117
  if (verified) {
123
- await run.phase(makePhaseParams({ name: "commit_build", kind: "code", owner: "git", description: "Land the code only now: green suite, approved review" }), async (ph) => commit(run, ph, build));
118
+ await run.phase(makePhaseParams({ name: "commit_build", kind: "code", owner: "git", description: "Land the code only now: green suite, approved review" }), async (ph) => commitEnvelope(run, ph, build));
124
119
  const changeset = await run.phase(makePhaseParams({ name: "changes", kind: "code", owner: "git", description: "Diff the whole run against its pinned baseline, for the documenter" }), async (ph) => {
125
120
  const result = changes.capture(run, makeChangeCapture({ base: baseline }));
126
- ph.log({
127
- base: `${result.base.label} @ ${result.base.commit.slice(0, 7)}`,
128
- reason: result.base.reason,
129
- files: result.files.length + result.untracked.length,
130
- lines: `+${result.insertions} -${result.deletions}`,
131
- diff: result.diff_path,
132
- });
121
+ logChangeset(ph, result);
133
122
  if (result.empty) {
134
123
  throw new Error(`nothing changed since ${result.base.label} (${result.base.reason}) — there is nothing to document.`);
135
124
  }
@@ -141,7 +130,7 @@ export async function main(ctx) {
141
130
  previous: changes.asEnvelope(changeset, DOCUMENT_NOTES),
142
131
  gates: [gates.artifactsExist, gates.filesNonEmpty],
143
132
  })));
144
- await run.phase(makePhaseParams({ name: "commit_docs", kind: "code", owner: "git", description: "Ship the write-up in its own commit, beside the code it describes" }), async (ph) => commit(run, ph, document));
133
+ await run.phase(makePhaseParams({ name: "commit_docs", kind: "code", owner: "git", description: "Ship the write-up in its own commit, beside the code it describes" }), async (ph) => commitEnvelope(run, ph, document));
145
134
  }
146
135
  return run.finish(verified, "the suite or the review never came back clean");
147
136
  }
@@ -0,0 +1,117 @@
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 { type EnvelopeBase, type QualityResult, type ReviewOutputT } from "../core/data_types.ts";
24
+ import type { ChangeSet } from "../core/data_types.ts";
25
+ import { Run, type PhaseHandle } from "../core/runner.ts";
26
+ import type { ChainContext } from "./context.ts";
27
+ /** What one step hands the next — the parts of a hand-written chain's local variables. */
28
+ export interface ChainState {
29
+ prompt: string;
30
+ options: Record<string, string>;
31
+ /** The last agent envelope produced — what the next agent step feeds forward as `previous`. */
32
+ previous: EnvelopeBase | null;
33
+ quality: QualityResult | null;
34
+ review: ReviewOutputT | null;
35
+ changeset: ChangeSet | null;
36
+ /** HEAD, pinned by request({logBaseline: true}) before the run commits anything. */
37
+ baseline: string;
38
+ /** The run's own acceptance criterion — distinct from "every phase succeeded"; see Run.finish(). */
39
+ accepted: boolean;
40
+ /** Why not, when accepted is false — passed straight to run.finish(). */
41
+ reason: string;
42
+ }
43
+ /**
44
+ * One phase (or a small bounded group of them). Callable like a plain
45
+ * function; the optional properties are how a step declares what it needs
46
+ * so `deriveRequiredAgents`/`deriveRequiredSuites`/`derivePhases` can build
47
+ * `ChainDefinition.requiredAgents`/`requiredSuites`/`phases` from a step list
48
+ * instead of those being a second, separately maintained set of exports.
49
+ */
50
+ export interface Step {
51
+ (run: Run, state: ChainState): Promise<void>;
52
+ requiredAgents?: string[] | ((options: Record<string, string>) => string[]);
53
+ requiredSuites?: string[];
54
+ /** Display fragment for derivePhases() — e.g. "planner", "git(commit)". */
55
+ label?: string;
56
+ }
57
+ /** The identical loadConfig -> validate -> session.ensure prologue every chain repeated. */
58
+ export declare function startRun(ctx: ChainContext, requiredAgents: string[], requiredSuites: string[]): Run;
59
+ /** Commit an envelope in its own author's words — the message-fallback four chains repeated. */
60
+ export declare function commitEnvelope(run: Run, ph: PhaseHandle, envelope: EnvelopeBase & {
61
+ commit_message?: string;
62
+ }): void;
63
+ /** Log a change-capture result the same way every chain that captures one did. */
64
+ export declare function logChangeset(ph: PhaseHandle, result: ChangeSet): void;
65
+ /** The engineer(request) phase every chain opens with. */
66
+ export declare function request(opts?: {
67
+ description?: string;
68
+ logBaseline?: boolean;
69
+ }): Step;
70
+ export declare function plan(): Step;
71
+ /**
72
+ * `fromPlan` only changes the description — whether a plan() step precedes
73
+ * this one already decides whether `state.previous` is a plan or null, so
74
+ * there is nothing else for this flag to gate.
75
+ */
76
+ export declare function build(opts?: {
77
+ fromPlan?: boolean;
78
+ retries?: number;
79
+ }): Step;
80
+ export declare function scout(): Step;
81
+ /** The `prompt` chain's one step: --agent picks who, at run time and at requiredAgents-derivation time alike. */
82
+ export declare function promptOnly(): Step;
83
+ /** One deterministic quality block, standalone — never throws; sets state.accepted for run.finish() to check. */
84
+ export declare function qualityCheck(opts?: {
85
+ suite: "test" | "all";
86
+ description?: string;
87
+ }): Step;
88
+ /**
89
+ * Bounded check -> fix loop: a known command finds the failure, the builder
90
+ * repairs it. Always guards the last iteration — a fix on the final attempt
91
+ * is never re-verified, so it is never spawned. Sets state.accepted/reason.
92
+ */
93
+ export declare function fixLoop(opts?: {
94
+ suite: "test" | "all";
95
+ max?: number;
96
+ owner?: string;
97
+ }): Step;
98
+ /** Bounded review -> revise loop. Sets state.accepted/reason from the final verdict. */
99
+ export declare function reviseLoop(opts?: {
100
+ max?: number;
101
+ }): Step;
102
+ /** Commit the last agent step's envelope. `onlyIfAccepted` gates it on state.accepted (a preceding fixLoop/reviseLoop). */
103
+ export declare function commit(opts?: {
104
+ onlyIfAccepted?: boolean;
105
+ }): Step;
106
+ /** Diff the working tree against a base ref — code, not judgement. `--base` (default "main") if opts.base is unset. */
107
+ export declare function changes(opts?: {
108
+ base?: string;
109
+ }): Step;
110
+ /** Write up the captured change. Requires a preceding changes() step. */
111
+ export declare function document(): Step;
112
+ export declare function deriveRequiredAgents(steps: Step[]): string[] | ((options: Record<string, string>) => string[]);
113
+ export declare function deriveRequiredSuites(steps: Step[]): string[];
114
+ /** A display string for `spf list` — derived so it can no longer drift from what actually runs. */
115
+ export declare function derivePhases(steps: Step[]): string;
116
+ /** Run a chain's step list start to finish: prologue, every step in order, then run.finish(). */
117
+ export declare function runSteps(ctx: ChainContext, requiredAgents: string[], requiredSuites: string[], steps: Step[], options?: Record<string, string>): Promise<number>;
@@ -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;