@gr8ful/spf 0.4.0 → 0.5.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 (80) hide show
  1. package/README.md +122 -4
  2. package/assets/defaults/spf.config.yaml +6 -0
  3. package/assets/prompts/reviewer/system.md +1 -1
  4. package/assets/skill/SKILL.md +1 -0
  5. package/assets/skill/cookbooks/authoring_chains.md +90 -7
  6. package/assets/skill/cookbooks/ocr_reviewer.md +196 -0
  7. package/assets/skill/cookbooks/roster.md +15 -4
  8. package/assets/skill/cookbooks/spf_overview.md +1 -0
  9. package/assets/skill/references/config.md +69 -4
  10. package/assets/skill/references/observability.md +11 -2
  11. package/assets/templates/ts-flue-ollama.spf.config.yaml +67 -0
  12. package/assets/templates/ts.spf.config.yaml +5 -0
  13. package/dist/chains/context.d.ts +30 -0
  14. package/dist/chains/index.d.ts +94 -10
  15. package/dist/chains/index.js +70 -5
  16. package/dist/chains/repo_chains.d.ts +139 -0
  17. package/dist/chains/repo_chains.js +428 -0
  18. package/dist/chains/simple_sdlc.d.ts +74 -1
  19. package/dist/chains/simple_sdlc.js +134 -4
  20. package/dist/chains/steps.d.ts +215 -20
  21. package/dist/chains/steps.js +429 -61
  22. package/dist/cli/ask.d.ts +14 -1
  23. package/dist/cli/ask.js +32 -2
  24. package/dist/cli/commands/doctor.d.ts +1 -1
  25. package/dist/cli/commands/doctor.js +319 -11
  26. package/dist/cli/commands/init.d.ts +12 -0
  27. package/dist/cli/commands/init.js +78 -1
  28. package/dist/cli/commands/list.js +42 -5
  29. package/dist/cli/commands/run.js +25 -2
  30. package/dist/cli/commands/watch.d.ts +18 -0
  31. package/dist/cli/commands/watch.js +158 -10
  32. package/dist/cli/index.js +60 -3
  33. package/dist/cli/interview.js +65 -10
  34. package/dist/core/agent_cc.d.ts +40 -1
  35. package/dist/core/agent_cc.js +51 -4
  36. package/dist/core/agent_flue.js +28 -4
  37. package/dist/core/agents.d.ts +8 -0
  38. package/dist/core/agents.js +43 -3
  39. package/dist/core/data_types.d.ts +104 -4
  40. package/dist/core/data_types.js +99 -2
  41. package/dist/core/git_helper.d.ts +29 -0
  42. package/dist/core/git_helper.js +41 -1
  43. package/dist/core/ollama_provider.d.ts +70 -0
  44. package/dist/core/ollama_provider.js +208 -0
  45. package/dist/core/otel.d.ts +352 -0
  46. package/dist/core/otel.js +793 -0
  47. package/dist/core/paths.d.ts +3 -0
  48. package/dist/core/paths.js +48 -1
  49. package/dist/core/providers.js +4 -0
  50. package/dist/core/refine.js +11 -3
  51. package/dist/core/session.js +39 -2
  52. package/dist/core/tracer.d.ts +31 -2
  53. package/dist/core/tracer.js +69 -11
  54. package/dist/core/watch.d.ts +11 -0
  55. package/dist/core/watch.js +17 -2
  56. package/dist/test/chains.test.js +8 -3
  57. package/dist/test/data_types.test.js +140 -2
  58. package/dist/test/git_helper.test.d.ts +1 -0
  59. package/dist/test/git_helper.test.js +59 -0
  60. package/dist/test/hermetic_git.d.ts +1 -0
  61. package/dist/test/hermetic_git.js +22 -0
  62. package/dist/test/init_command.test.d.ts +14 -1
  63. package/dist/test/init_command.test.js +54 -1
  64. package/dist/test/interview.test.d.ts +15 -1
  65. package/dist/test/interview.test.js +127 -0
  66. package/dist/test/ollama_provider.test.d.ts +1 -0
  67. package/dist/test/ollama_provider.test.js +103 -0
  68. package/dist/test/otel.test.d.ts +26 -0
  69. package/dist/test/otel.test.js +512 -0
  70. package/dist/test/paths.test.d.ts +1 -0
  71. package/dist/test/paths.test.js +68 -0
  72. package/dist/test/refine.test.js +64 -1
  73. package/dist/test/repo_chains.test.d.ts +21 -0
  74. package/dist/test/repo_chains.test.js +416 -0
  75. package/dist/test/signoff.test.d.ts +1 -0
  76. package/dist/test/signoff.test.js +329 -0
  77. package/dist/test/ui_server.test.d.ts +7 -1
  78. package/dist/test/ui_server.test.js +1 -0
  79. package/dist/test/watch.test.js +124 -1
  80. package/package.json +5 -5
@@ -16,9 +16,29 @@
16
16
  * The loops (`fixLoop`, `reviseLoop`) own their bounded iteration and dynamic
17
17
  * phase naming (`test_1`, `fix_1`, ...) INTERNALLY — a chain's step list is
18
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.
19
+ * composition layer. That is what makes a declarative (YAML) chain
20
+ * tractable: it only ever names steps and passes them tuning params, never
21
+ * expresses control flow. `chains/repo_chains.ts` is that reader — every
22
+ * factory below is a name a `.spf/chains/*.yaml` file may use, and its
23
+ * `opts` object is that step's entire vocabulary.
24
+ *
25
+ * Consequences of being that vocabulary, all of which apply to the code
26
+ * below and none of which are cosmetic:
27
+ *
28
+ * - EVERY DEFAULT IS A PUBLISHED DEFAULT. `src/test/chains.test.ts` pins the
29
+ * exact `phases`/`requiredAgents`/`requiredSuites` strings every built-in
30
+ * chain derives from these factories. A factory called with no opts must
31
+ * keep producing byte-identical output forever; if a default label moves,
32
+ * that is a bug in this file, not in the test.
33
+ * - PARAMS ADD, THEY DO NOT SUBTRACT. A repo-local chain is data supplied by
34
+ * the target repo; it may widen what a step does (an extra gate, a
35
+ * different owner, more retries) but it must never be able to weaken the
36
+ * checks spf itself imposes. See GATE_ALLOWLIST below for the one place
37
+ * that rule bites hardest.
38
+ * - A BAD PARAM MUST FAIL AT DEFINITION TIME. A factory validates what it
39
+ * can the moment it is called (see `preflightDescription`), so a malformed
40
+ * chain is a load-time problem the loader can report against a file and a
41
+ * line, never a phase that blows up ten minutes into an unattended run.
22
42
  */
23
43
  import { writeFileSync } from "node:fs";
24
44
  import path from "node:path";
@@ -29,7 +49,7 @@ import * as agentsCfg from "../core/agents.js";
29
49
  import * as session from "../core/session.js";
30
50
  import * as refineLib from "../core/refine.js";
31
51
  import { DOCUMENT_NOTES } from "../core/prompts.js";
32
- import { BuildOutput, DocumentOutput, GenericOutput, PlanOutput, RefineOutput, ReviewOutput, ScoutOutput, makeAgentCall, makeChangeCapture, makePhaseParams, } from "../core/data_types.js";
52
+ import { BuildOutput, DocumentOutput, GenericOutput, PlanOutput, RefineOutput, ReviewOutput, ScoutOutput, makeAgentCall, makeChangeCapture, makeEventRecord, makePhaseParams, } from "../core/data_types.js";
33
53
  import { Run } from "../core/runner.js";
34
54
  function makeState(prompt, options, issueId) {
35
55
  return { prompt, options, previous: null, quality: null, review: null, changeset: null, baseline: "", issue_id: issueId, accepted: true, reason: "" };
@@ -46,11 +66,84 @@ function makeStep(fn, meta = {}) {
46
66
  export function startRun(ctx, requiredAgents, requiredSuites) {
47
67
  const cfg = agentsCfg.loadConfig(ctx.config_paths);
48
68
  agentsCfg.validate(cfg, requiredAgents, requiredSuites, ctx.cwd);
49
- return session.ensure(cfg, ctx.adw_id, ctx.cwd, ctx.chain_name);
69
+ const run = session.ensure(cfg, ctx.adw_id, ctx.cwd, ctx.chain_name);
70
+ // Provenance, once per run, before any phase opens: a repo-local chain
71
+ // (.spf/chains/*.yaml) records the file it came from. `chain_name` alone
72
+ // stops being enough to reconstruct a run the moment a target repo can
73
+ // ship its own chain definitions — the same name can mean a different
74
+ // step list next week, and the trace is the only copy that does not
75
+ // change when the yaml does.
76
+ //
77
+ // Deliberately a `type: "log"` event with a `chain_source` name, NOT a new
78
+ // event kind: EventRecord.type is a closed picklist mirrored by the UI
79
+ // (data_types.ts's EVENT_RECORD_TYPES <-> ui/shared/types.ts's EventType),
80
+ // and "this run's chain came from a file" is not a new lifecycle stage —
81
+ // it is a note. Adding a picklist member would force a UI change for a
82
+ // payload the UI already renders generically.
83
+ if (ctx.chain_source) {
84
+ run.tracer.event(makeEventRecord({ adw_id: run.adw_id, type: "log", name: "chain_source", payload: { source: ctx.chain_source } }));
85
+ }
86
+ return run;
50
87
  }
51
- /** Commit an envelope in its own author's words the message-fallback four chains repeated. */
52
- export function commitEnvelope(run, ph, envelope) {
53
- const message = envelope.commit_message || `spf(${run.adw_id}): ${envelope.summary}`;
88
+ /** A trailer line: `Key: value...`, one per line, no blank lines inside the block. */
89
+ const TRAILER_LINE = /^[A-Za-z][A-Za-z0-9-]*: .+$/;
90
+ /**
91
+ * Append `trailer` as a proper trailer: joined into the message's existing
92
+ * trailer block (its LAST paragraph, if every line there is already
93
+ * `Key: value` shaped) or, failing that, appended after a blank line of its
94
+ * own — never glued onto prose with no separation. `commitEnvelope` builds
95
+ * its message from agent-authored free text, so this cannot assume the
96
+ * message already ends cleanly.
97
+ *
98
+ * A single paragraph is never treated as a trailer block, even when it
99
+ * happens to be `Key: value` shaped — a lone paragraph is the commit's
100
+ * SUBJECT line, and every conventional-commit subject (`feat: add X`,
101
+ * `fix: handle Y`) matches that shape. Joining onto it directly (no blank
102
+ * line) would glue the trailer onto the subject itself — invisible to git
103
+ * as a trailer at all (`git interpret-trailers` requires the trailer block
104
+ * to be its own paragraph, separated from the subject by a blank line).
105
+ * Only a message that ALREADY has a body — at least two paragraphs — can
106
+ * have an existing trailer block to join.
107
+ */
108
+ function appendTrailer(message, trailer) {
109
+ const trimmed = message.replace(/\s+$/, "");
110
+ if (!trimmed)
111
+ return trailer;
112
+ const paragraphs = trimmed.split(/\n[ \t]*\n/);
113
+ const last = paragraphs[paragraphs.length - 1];
114
+ const lastLines = last.split("\n").map((l) => l.trim()).filter(Boolean);
115
+ const isTrailerBlock = paragraphs.length > 1 && lastLines.length > 0 && lastLines.every((l) => TRAILER_LINE.test(l));
116
+ if (isTrailerBlock) {
117
+ paragraphs[paragraphs.length - 1] = `${last}\n${trailer}`;
118
+ return paragraphs.join("\n\n");
119
+ }
120
+ return `${trimmed}\n\n${trailer}`;
121
+ }
122
+ /**
123
+ * Commit an envelope in its own author's words — the message-fallback four
124
+ * chains repeated.
125
+ *
126
+ * `signoff`, when passed, MUST be a human's own recorded explicit "yes" —
127
+ * see `chains/simple_sdlc.ts`'s `decideSignoff`, the only caller that ever
128
+ * passes one. It is appended as a real `Signed-off-by:` trailer (blank-line
129
+ * separated, or joined into an existing trailer block — see
130
+ * `appendTrailer`). Every other caller (every chain built from `./steps.ts`'s
131
+ * own `commit()`, plus `simple_sdlc`'s `commit_plan`/`commit_docs`) passes
132
+ * nothing, because nothing gates those commits on an AI verdict — the
133
+ * `fixLoop` chains gate on tests, which is the philosophy working. Omitted
134
+ * -> no trailer, ever: a trailer that lies launders an AI verdict into a git
135
+ * attestation.
136
+ */
137
+ export function commitEnvelope(run, ph, envelope, signoff) {
138
+ let message = envelope.commit_message || `spf(${run.adw_id}): ${envelope.summary}`;
139
+ if (signoff) {
140
+ const trailerLine = `Signed-off-by: ${signoff.name} <${signoff.email}>`;
141
+ // An agent that already wrote its own sign-off line (verbatim) does not
142
+ // get a second, duplicate one.
143
+ const alreadyPresent = message.split("\n").some((line) => line.trim() === trailerLine);
144
+ if (!alreadyPresent)
145
+ message = appendTrailer(message, trailerLine);
146
+ }
54
147
  ph.log({ sha: run.git.commitAll(message), message });
55
148
  }
56
149
  /** Log a change-capture result the same way every chain that captures one did. */
@@ -63,9 +156,129 @@ export function logChangeset(ph, result) {
63
156
  diff: result.diff_path,
64
157
  });
65
158
  }
159
+ // ── layer 1.5: the params a chain DEFINITION may supply ─────────────────
160
+ /**
161
+ * The gates a chain definition is allowed to name, by name.
162
+ *
163
+ * An EXPLICIT map, not `gates` itself and not a lookup on `gates[name]`:
164
+ * `core/gates.ts` is a module of exported functions, and reflecting over it
165
+ * would silently promote every future export (and every internal helper that
166
+ * ever gets exported for a test) into the surface a target repo's YAML can
167
+ * reach. Adding a gate to this map is a deliberate, reviewable act.
168
+ *
169
+ * CRITICAL POLICY — this map backs `extraGates`, which is ADDITIVE ONLY. A
170
+ * step's built-in gates are NON-REMOVABLE: there is no `gates:` param that
171
+ * replaces them, and there must never be one.
172
+ *
173
+ * The reason is what a repo-local chain IS. SPF's contract is "agent
174
+ * proposes, code disposes", and the code that disposes is SPF'S code — that
175
+ * is precisely why chains load as data (YAML naming these factories) instead
176
+ * of as imported repo code. A `gates: []` override would hand that back:
177
+ * the target repo would be editing its own disposer, and the first thing
178
+ * anyone under deadline pressure deletes is the gate that keeps failing —
179
+ * `diffMatchesClaims` (the builder claimed files it never wrote) or
180
+ * `verdictConsistent` (the reviewer approved while listing blockers).
181
+ * Those two are exactly the checks that catch an agent grading its own
182
+ * homework, and `spf watch` runs chains UNATTENDED (see
183
+ * `ChainContext.unattended`), so nobody is at the console to notice they
184
+ * stopped running. A weaker chain must therefore be un-expressible, not
185
+ * merely discouraged.
186
+ *
187
+ * `gates.testsPass` is deliberately absent: it is a FACTORY over a shell
188
+ * command string, so allowing it by name would mean letting a YAML file
189
+ * name an arbitrary command to execute inside a gate. Commands belong in
190
+ * `quality.checks`/`quality.suites` in spf.config.yaml, where
191
+ * `core/quality.ts` owns them and `core/permissions.ts` applies. If a
192
+ * command-running gate is ever wanted here, it needs its own param with its
193
+ * own review, not an entry in this map.
194
+ */
195
+ export const GATE_ALLOWLIST = Object.freeze({
196
+ artifactsExist: gates.artifactsExist,
197
+ filesNonEmpty: gates.filesNonEmpty,
198
+ jsonParses: gates.jsonParses,
199
+ diffMatchesClaims: gates.diffMatchesClaims,
200
+ verdictConsistent: gates.verdictConsistent,
201
+ });
202
+ /** Every name a chain definition may put in SOME `extraGates` param — for the module-level backstop in `resolveExtraGates` only. Schema validation must use the narrower, per-param lists below, never this one. */
203
+ export const GATE_NAMES = Object.freeze(Object.keys(GATE_ALLOWLIST));
204
+ /**
205
+ * Which gate names are meaningful on which envelope shape — the missing
206
+ * per-step subset `GATE_ALLOWLIST` never had. `GATE_ALLOWLIST` says "these
207
+ * functions exist and are the ones a chain may ever name"; these three lists
208
+ * say "here is the subset that reads fields THIS envelope actually has."
209
+ *
210
+ * `artifactsExist`/`filesNonEmpty`/`jsonParses` only ever read
211
+ * `envelope.artifacts`, which every envelope has (see `EnvelopeBase`) — safe
212
+ * anywhere. `diffMatchesClaims` reads `changed_files`, which only a
213
+ * `BuildOutput`-shaped envelope populates; naming it on a step whose
214
+ * envelope is something else (e.g. a review) makes it read an absent field
215
+ * as `[]` and pass vacuously — silent, not a real check. `verdictConsistent`
216
+ * reads `approved`/`blocking`/`findings`, which only a review envelope
217
+ * populates; on anything else its "rejection names a problem" check reads
218
+ * `approved: false` with nothing to blame and fails EVERY time, which is
219
+ * exactly the shape of bug this module exists to make load-time-visible
220
+ * instead of run-time-fatal.
221
+ */
222
+ export const GENERIC_GATE_NAMES = Object.freeze(["artifactsExist", "filesNonEmpty", "jsonParses"]);
223
+ export const BUILD_ENVELOPE_GATE_NAMES = Object.freeze([...GENERIC_GATE_NAMES, "diffMatchesClaims"]);
224
+ export const REVIEW_ENVELOPE_GATE_NAMES = Object.freeze([...GENERIC_GATE_NAMES, "verdictConsistent"]);
225
+ /**
226
+ * Resolve `extraGates` names to functions, throwing on an unknown one.
227
+ *
228
+ * The loader validates names against `GATE_NAMES` first, so in practice a
229
+ * YAML typo is reported as a load problem and never reaches here; this
230
+ * throw is the backstop for a programmatic caller (a test, a built-in chain)
231
+ * and for any future path that forgets to pre-validate. Failing loudly at
232
+ * factory-call time beats an `undefined` landing in a gate list and blowing
233
+ * up mid-phase.
234
+ */
235
+ function resolveExtraGates(names) {
236
+ if (!names || names.length === 0)
237
+ return [];
238
+ return names.map((name) => {
239
+ const gate = GATE_ALLOWLIST[name];
240
+ if (!gate) {
241
+ throw new Error(`unknown gate ${JSON.stringify(name)} — allowed: ${GATE_NAMES.join(", ")}`);
242
+ }
243
+ return gate;
244
+ });
245
+ }
246
+ /** Built-in gates first, in their compiled-in order, then whatever the definition added. Never fewer. */
247
+ function withExtraGates(builtIn, extra) {
248
+ return [...builtIn, ...resolveExtraGates(extra)];
249
+ }
250
+ /**
251
+ * Validate a chain-definition-supplied `description` NOW, at factory-call
252
+ * time, instead of letting `makePhaseParams` reject it when the phase opens.
253
+ *
254
+ * `makePhaseParams` throws on a blank description and on one that merely
255
+ * echoes the phase name (see PhaseParamsSchema) — a rule worth keeping
256
+ * exactly as strict for a repo-authored description as for spf's own. But a
257
+ * chain definition is read minutes or hours before the phase it describes
258
+ * runs: `repo_chains.ts` builds every step at LOAD time, so a bad
259
+ * description surfaces there as a `{file, message}` problem, and an
260
+ * unattended `spf watch` run never starts a session it was always going to
261
+ * abort in phase four.
262
+ *
263
+ * The phase name used here is the step's STATIC name. Two steps compute
264
+ * their real name at run time from `--suite` (`qualityCheck`, `fixLoop`)
265
+ * and the loops append an iteration counter (`test_1`, `fix_1`) — so this
266
+ * is a pre-flight, not a replacement for the real check inside
267
+ * `makePhaseParams`, which still runs on the actual name. Both are wanted:
268
+ * this one catches the definition, that one catches the run.
269
+ */
270
+ function preflightDescription(phaseName, description) {
271
+ if (description === undefined)
272
+ return;
273
+ // kind/owner are irrelevant to the description rule; any valid pair works.
274
+ // Thrown as-is on purpose: makePhaseParams' message already names the phase
275
+ // and says what is wrong, and the loader prefixes the file it came from.
276
+ makePhaseParams({ name: phaseName, kind: "code", owner: "spf", description });
277
+ }
66
278
  // ── layer 2: step factories ──────────────────────────────────────────────
67
279
  /** The engineer(request) phase every chain opens with. */
68
280
  export function request(opts = {}) {
281
+ preflightDescription("request", opts.description);
69
282
  const fn = async (run, state) => {
70
283
  if (opts.logBaseline)
71
284
  state.baseline = run.git.rev("HEAD");
@@ -86,90 +299,164 @@ function agentStep(opts) {
86
299
  };
87
300
  return makeStep(fn, { requiredAgents: [opts.owner], label: opts.label ?? opts.owner });
88
301
  }
89
- export function plan() {
302
+ /**
303
+ * `owner` names WHO plans, defaulting to the `planner` agent. It flows
304
+ * straight into `agentStep`'s existing owner argument, which is also what
305
+ * `makeStep` records as `requiredAgents` and as the display `label` — so a
306
+ * chain that plans with a differently-named agent updates its own
307
+ * `spf list` line and its own config validation with no second place to
308
+ * edit. Same shape for `build`/`scout`/`document` below.
309
+ */
310
+ export function plan(opts = {}) {
311
+ preflightDescription("plan", opts.description);
90
312
  return agentStep({
91
313
  name: "plan",
92
- owner: "planner",
314
+ owner: opts.owner ?? "planner",
93
315
  output_type: PlanOutput,
94
- description: "Turn the request into an implementable plan",
95
- gates: [gates.artifactsExist, gates.filesNonEmpty],
316
+ description: opts.description ?? "Turn the request into an implementable plan",
317
+ gates: withExtraGates([gates.artifactsExist, gates.filesNonEmpty], opts.extraGates),
318
+ retries: opts.retries ?? 0,
96
319
  });
97
320
  }
98
321
  /**
99
322
  * `fromPlan` only changes the description — whether a plan() step precedes
100
323
  * this one already decides whether `state.previous` is a plan or null, so
101
- * there is nothing else for this flag to gate.
324
+ * there is nothing else for this flag to gate. An explicit `description`
325
+ * therefore makes `fromPlan` moot; it is still accepted rather than made
326
+ * mutually exclusive, because the two chains that pass `fromPlan` today read
327
+ * better for it and a definition that sets both is not ambiguous (the
328
+ * explicit text wins).
102
329
  */
103
330
  export function build(opts = {}) {
104
331
  const fromPlan = opts.fromPlan ?? true;
332
+ preflightDescription("build", opts.description);
105
333
  return agentStep({
106
334
  name: "build",
107
- owner: "builder",
335
+ owner: opts.owner ?? "builder",
108
336
  output_type: BuildOutput,
109
- description: fromPlan ? "Implement the plan exactly" : "Implement the request",
110
- gates: [gates.diffMatchesClaims],
337
+ description: opts.description ?? (fromPlan ? "Implement the plan exactly" : "Implement the request"),
338
+ gates: withExtraGates([gates.diffMatchesClaims], opts.extraGates),
111
339
  retries: opts.retries ?? 0,
112
340
  });
113
341
  }
114
- export function scout() {
342
+ export function scout(opts = {}) {
343
+ preflightDescription("scout", opts.description);
115
344
  return agentStep({
116
345
  name: "scout",
117
- owner: "scout",
346
+ owner: opts.owner ?? "scout",
118
347
  output_type: ScoutOutput,
119
- description: "Find and report where things live — change nothing",
120
- gates: [gates.artifactsExist],
348
+ description: opts.description ?? "Find and report where things live — change nothing",
349
+ gates: withExtraGates([gates.artifactsExist], opts.extraGates),
350
+ retries: opts.retries ?? 0,
121
351
  });
122
352
  }
123
- /** The `prompt` chain's one step: --agent picks who, at run time and at requiredAgents-derivation time alike. */
124
- export function promptOnly() {
353
+ /**
354
+ * The `prompt` chain's one step: --agent picks who, at run time and at
355
+ * requiredAgents-derivation time alike.
356
+ *
357
+ * No `owner` param, unlike the steps above: this step's whole purpose is
358
+ * that the OPERATOR chooses the agent at invocation time, so an owner baked
359
+ * into a definition would defeat it. A chain that wants a fixed agent
360
+ * already has one — that is what `plan`/`build`/`scout` with an `owner` are.
361
+ * `extraGates` is accepted (the default is genuinely no gates — a generic
362
+ * envelope claims nothing specific) so a definition can still demand, say,
363
+ * that whatever files the agent claims actually exist.
364
+ */
365
+ export function promptOnly(opts = {}) {
366
+ preflightDescription("prompt", opts.description);
125
367
  const fn = async (run, state) => {
126
368
  const owner = state.options["agent"] ?? "builder";
127
- 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 })));
369
+ const envelope = await run.phase(makePhaseParams({
370
+ name: "prompt",
371
+ kind: "agent",
372
+ owner,
373
+ description: opts.description ?? `Send the request straight to ${owner} and parse its envelope`,
374
+ retries: opts.retries ?? 0,
375
+ }), (ph) => ph.call(makeAgentCall({
376
+ output_type: GenericOutput,
377
+ prompt: state.prompt,
378
+ previous: state.previous,
379
+ gates: resolveExtraGates(opts.extraGates),
380
+ })));
128
381
  state.previous = envelope;
129
382
  };
130
383
  return makeStep(fn, { requiredAgents: (options) => [options["agent"] ?? "builder"], label: "<agent>" });
131
384
  }
132
- /** One deterministic quality block, standalone — never throws; sets state.accepted for run.finish() to check. */
385
+ /**
386
+ * One deterministic quality block, standalone — never throws; sets
387
+ * state.accepted for run.finish() to check.
388
+ *
389
+ * `opts.suite` names any suite in `quality.suites`, not just "test"/"all" —
390
+ * those two keep their historical phase name ("test"/"quality") and
391
+ * description; any other configured suite name is used verbatim for both, so
392
+ * a chain naming a custom suite still reads truthfully in its trace and in
393
+ * `spf list`. `--suite` (see run.ts) overrides the compiled-in default at
394
+ * invocation time, the same way `promptOnly()` lets `--agent` override its
395
+ * owner.
396
+ */
133
397
  export function qualityCheck(opts = { suite: "all" }) {
398
+ const staticName = opts.suite === "all" ? "quality" : opts.suite;
399
+ // No extraGates here, and none below on fixLoop's suite phase either: a
400
+ // gate validates an AGENT's envelope against reality (see core/gates.ts).
401
+ // A `code` phase has no envelope to check — its command's exit status IS
402
+ // the check. Widening a deterministic block belongs in
403
+ // `quality.checks`/`quality.suites`, not in a gate list.
404
+ preflightDescription(staticName, opts.description);
134
405
  const fn = async (run, state) => {
406
+ const suiteName = state.options["suite"] ?? opts.suite;
407
+ const name = suiteName === "all" ? "quality" : suiteName;
135
408
  await run.phase(makePhaseParams({
136
- name: opts.suite === "all" ? "quality" : "test",
409
+ name,
137
410
  kind: "code",
138
411
  owner: "quality",
139
412
  description: opts.description ??
140
- (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"),
413
+ (suiteName === "all" ? "Run the deterministic quality blocks" : "Run the suite — a known command, so code runs it and no agent has to rediscover it"),
141
414
  }), async (ph) => {
142
- const result = opts.suite === "all" ? quality.runQuality(run) : quality.runTests(run);
415
+ const result = quality.runSuite(run, suiteName);
143
416
  quality.record(ph, result);
144
417
  state.quality = result;
145
418
  state.accepted = result.passed;
146
419
  state.reason = result.passed ? "" : `quality failed: ${result.failures.join("; ")}`;
147
420
  });
148
421
  };
149
- return makeStep(fn, { requiredSuites: [opts.suite], label: opts.suite === "all" ? "code(quality)" : "code(test)" });
422
+ return makeStep(fn, {
423
+ requiredSuites: (options) => [options["suite"] ?? opts.suite],
424
+ label: `code(${staticName})`,
425
+ });
150
426
  }
151
427
  /**
152
428
  * Bounded check -> fix loop: a known command finds the failure, the builder
153
429
  * repairs it. Always guards the last iteration — a fix on the final attempt
154
430
  * is never re-verified, so it is never spawned. Sets state.accepted/reason.
431
+ *
432
+ * `opts.suite` names any suite in `quality.suites` — "all" alone keeps its
433
+ * historical "verify"/"verification" naming; every other suite (including
434
+ * the default, "test") is named for itself, exactly as in `qualityCheck()`.
435
+ * `--suite` (run.ts) overrides the compiled-in default at invocation time.
155
436
  */
156
437
  export function fixLoop(opts = { suite: "test" }) {
157
438
  const max = opts.max ?? 3;
158
439
  const owner = opts.owner ?? "builder";
159
- const stepName = opts.suite === "all" ? "verify" : "test";
160
- const what = opts.suite === "all" ? "verification" : "tests";
440
+ const staticStepName = opts.suite === "all" ? "verify" : opts.suite;
441
+ const fixGates = withExtraGates([gates.diffMatchesClaims], opts.fixExtraGates);
442
+ preflightDescription(staticStepName, opts.description);
443
+ preflightDescription("fix", opts.fixDescription);
161
444
  const fn = async (run, state) => {
445
+ const suiteName = state.options["suite"] ?? opts.suite;
446
+ const stepName = suiteName === "all" ? "verify" : suiteName;
447
+ const what = suiteName === "all" ? "verification" : suiteName === "test" ? "tests" : suiteName;
162
448
  let result = null;
163
449
  for (let i = 1; i <= max; i++) {
164
450
  result = await run.phase(makePhaseParams({
165
451
  name: `${stepName}_${i}`,
166
452
  kind: "code",
167
453
  owner: "quality",
168
- description: opts.suite === "all"
169
- ? "Lint, typecheck, and build before testing"
170
- : "Run the suite — a known command, so code runs it and no agent has to rediscover it",
454
+ description: opts.description ??
455
+ (suiteName === "all"
456
+ ? "Lint, typecheck, and build before testing"
457
+ : "Run the suite — a known command, so code runs it and no agent has to rediscover it"),
171
458
  }), async (ph) => {
172
- const r = opts.suite === "all" ? quality.runQuality(run) : quality.runTests(run);
459
+ const r = quality.runSuite(run, suiteName);
173
460
  quality.record(ph, r);
174
461
  return r;
175
462
  });
@@ -177,11 +464,17 @@ export function fixLoop(opts = { suite: "test" }) {
177
464
  break;
178
465
  if (i === max)
179
466
  break; // never leave an unverified fix on the table
180
- 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({
467
+ state.previous = await run.phase(makePhaseParams({
468
+ name: `fix_${i}`,
469
+ kind: "agent",
470
+ owner,
471
+ retries: opts.fixRetries ?? 1,
472
+ description: opts.fixDescription ?? "Repair what the suite reported, from its verbatim output",
473
+ }), (ph) => ph.call(makeAgentCall({
181
474
  output_type: BuildOutput,
182
475
  prompt: state.prompt,
183
476
  previous: quality.asEnvelope(result, what),
184
- gates: [gates.diffMatchesClaims],
477
+ gates: fixGates,
185
478
  })));
186
479
  }
187
480
  state.quality = result;
@@ -190,29 +483,67 @@ export function fixLoop(opts = { suite: "test" }) {
190
483
  };
191
484
  return makeStep(fn, {
192
485
  requiredAgents: [owner],
193
- requiredSuites: [opts.suite],
194
- label: `code(${stepName}) [-> ${owner}(fix) -> code(${stepName}) ...] bounded`,
486
+ requiredSuites: (options) => [options["suite"] ?? opts.suite],
487
+ label: `code(${staticStepName}) [-> ${owner}(fix) -> code(${staticStepName}) ...] bounded`,
195
488
  });
196
489
  }
197
- /** Bounded review -> revise loop. Sets state.accepted/reason from the final verdict. */
490
+ /**
491
+ * Bounded review -> revise loop. Sets state.accepted/reason from the final
492
+ * verdict.
493
+ *
494
+ * `reviewer`/`builder` name the two agents by role, defaulting to the agents
495
+ * literally called "reviewer" and "builder". They are two params rather than
496
+ * one because the whole point of this loop is that the critic and the author
497
+ * are DIFFERENT agents — a chain that points both at the same name has an
498
+ * agent reviewing its own work, which is the failure mode
499
+ * `gates.verdictConsistent` exists to make visible. Nothing here enforces
500
+ * that they differ (a repo may legitimately have one strong agent and want
501
+ * the self-review anyway), but the trace will say so plainly: the derived
502
+ * label below is built from these names, so `spf list` and the run's phases
503
+ * both read `x [-> x(revise) -> x ...]`.
504
+ */
198
505
  export function reviseLoop(opts = {}) {
199
506
  const max = opts.max ?? 3;
507
+ const reviewer = opts.reviewer ?? "reviewer";
508
+ const builder = opts.builder ?? "builder";
509
+ const reviewGates = withExtraGates([gates.artifactsExist, gates.verdictConsistent], opts.extraGates);
510
+ const reviseGates = withExtraGates([gates.diffMatchesClaims], opts.reviseExtraGates);
511
+ preflightDescription("review", opts.description);
512
+ preflightDescription("revise", opts.reviseDescription);
200
513
  const fn = async (run, state) => {
201
514
  let review = null;
202
515
  for (let i = 1; i <= max; i++) {
203
- 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] })));
516
+ review = await run.phase(makePhaseParams({
517
+ name: `review_${i}`,
518
+ kind: "agent",
519
+ owner: reviewer,
520
+ retries: opts.retries ?? 0,
521
+ description: opts.description ?? "Rule on every requirement in the spec, against the code on disk",
522
+ }), (ph) => ph.call(makeAgentCall({ output_type: ReviewOutput, prompt: state.prompt, previous: state.previous, gates: reviewGates })));
204
523
  if (review.approved || i === max)
205
524
  break;
206
- 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] })));
525
+ state.previous = await run.phase(makePhaseParams({
526
+ name: `revise_${i}`,
527
+ kind: "agent",
528
+ owner: builder,
529
+ retries: opts.reviseRetries ?? 1,
530
+ description: opts.reviseDescription ?? "Close every blocking finding the reviewer named",
531
+ }), (ph) => ph.call(makeAgentCall({ output_type: BuildOutput, prompt: state.prompt, previous: review, gates: reviseGates })));
207
532
  }
208
533
  state.review = review;
209
534
  state.accepted = review !== null && review.approved;
210
535
  state.reason = state.accepted ? "" : `the reviewer never approved after ${max} revision(s)`;
211
536
  };
212
- return makeStep(fn, { requiredAgents: ["reviewer", "builder"], label: "reviewer [-> builder(revise) -> reviewer ...] bounded" });
537
+ // requiredAgents deduplicates via deriveRequiredAgents' Set, so pointing
538
+ // both roles at one agent yields a one-name list, not a duplicate.
539
+ return makeStep(fn, {
540
+ requiredAgents: [reviewer, builder],
541
+ label: `${reviewer} [-> ${builder}(revise) -> ${reviewer} ...] bounded`,
542
+ });
213
543
  }
214
544
  /** Commit the last agent step's envelope. `onlyIfAccepted` gates it on state.accepted (a preceding fixLoop/reviseLoop). */
215
545
  export function commit(opts = {}) {
546
+ preflightDescription("commit", opts.description);
216
547
  const fn = async (run, state) => {
217
548
  if (opts.onlyIfAccepted && !state.accepted)
218
549
  return;
@@ -222,18 +553,26 @@ export function commit(opts = {}) {
222
553
  name: "commit",
223
554
  kind: "code",
224
555
  owner: "git",
225
- description: opts.onlyIfAccepted
226
- ? "Land the code only after the suite came back green"
227
- : "Land the builder's changes, using the message it wrote",
556
+ description: opts.description ??
557
+ (opts.onlyIfAccepted ? "Land the code only after the suite came back green" : "Land the builder's changes, using the message it wrote"),
228
558
  }), async (ph) => commitEnvelope(run, ph, state.previous));
229
559
  };
230
560
  return makeStep(fn, { label: "git(commit)" });
231
561
  }
232
562
  /** Diff the working tree against a base ref — code, not judgement. `--base` (default "main") if opts.base is unset. */
233
563
  export function changes(opts = {}) {
564
+ preflightDescription("changes", opts.description);
234
565
  const fn = async (run, state) => {
235
566
  const base = opts.base ?? state.options["base"] ?? "main";
236
- 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) => {
567
+ const changeset = await run.phase(makePhaseParams({
568
+ name: "changes",
569
+ kind: "code",
570
+ owner: "git",
571
+ // The default names the base it actually diffed against, which is only
572
+ // known once --base is resolved — an override trades that for whatever
573
+ // the definition says, so it should say something at least as specific.
574
+ description: opts.description ?? `Diff the working tree against ${base} — the change to be written up`,
575
+ }), async (ph) => {
237
576
  const result = changesLib.capture(run, makeChangeCapture({ base }));
238
577
  logChangeset(ph, result);
239
578
  if (result.empty) {
@@ -247,28 +586,46 @@ export function changes(opts = {}) {
247
586
  return makeStep(fn, { label: "code(changes)" });
248
587
  }
249
588
  /** Write up the captured change. Requires a preceding changes() step. */
250
- export function document() {
589
+ export function document(opts = {}) {
590
+ const owner = opts.owner ?? "documenter";
591
+ const docGates = withExtraGates([gates.artifactsExist, gates.filesNonEmpty], opts.extraGates);
592
+ preflightDescription("document", opts.description);
251
593
  const fn = async (run, state) => {
252
594
  if (!state.changeset)
253
595
  throw new Error("document() requires a preceding changes() step in the chain's step list");
254
- 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({
596
+ const envelope = await run.phase(makePhaseParams({
597
+ name: "document",
598
+ kind: "agent",
599
+ owner,
600
+ // Default 1, not 0: a write-up's gates check files the documenter
601
+ // claims it wrote, and "wrote the file somewhere else" is a mistake
602
+ // the same session fixes on a re-prompt. Historical behavior; kept as
603
+ // the default so an existing chain is unchanged.
604
+ retries: opts.retries ?? 1,
605
+ description: opts.description ?? "Turn the captured diff into a write-up an engineer can read",
606
+ }), (ph) => ph.call(makeAgentCall({
255
607
  output_type: DocumentOutput,
256
608
  prompt: state.prompt,
257
609
  previous: changesLib.asEnvelope(state.changeset, DOCUMENT_NOTES),
258
- gates: [gates.artifactsExist, gates.filesNonEmpty],
610
+ gates: docGates,
259
611
  })));
260
612
  state.previous = envelope;
261
613
  };
262
- return makeStep(fn, { requiredAgents: ["documenter"], label: "documenter" });
614
+ return makeStep(fn, { requiredAgents: [owner], label: owner });
263
615
  }
264
616
  /** Decompose the spec in `prompt` into a feature/story tree — see `RefinedIssueSchema`'s doc comment. Gated so a malformed tree (wrong container/leaf kinds, an unresolved reference, a dependency cycle) re-prompts the same session before publishIssues() ever runs. */
265
- export function refine() {
617
+ export function refine(opts = {}) {
618
+ preflightDescription("refine", opts.description);
266
619
  return agentStep({
267
620
  name: "refine",
268
- owner: "refiner",
621
+ owner: opts.owner ?? "refiner",
269
622
  output_type: RefineOutput,
270
- description: "Decompose the spec into a feature/story tree of vertical slices",
271
- gates: [gates.refinementWellFormed],
623
+ description: opts.description ?? "Decompose the spec into a feature/story tree of vertical slices",
624
+ // refinementWellFormed is NOT in GATE_ALLOWLIST — it is meaningless on
625
+ // any other envelope type (it reads `issues`), so there is nothing to
626
+ // gain by letting a definition name it, and it stays non-removable here.
627
+ gates: withExtraGates([gates.refinementWellFormed], opts.extraGates),
628
+ retries: opts.retries ?? 0,
272
629
  });
273
630
  }
274
631
  /**
@@ -286,7 +643,8 @@ export function refine() {
286
643
  * `spf refine` run (no daemon, no spec issue in play) still needs this step
287
644
  * to work standalone.
288
645
  */
289
- export function publishIssues() {
646
+ export function publishIssues(opts = {}) {
647
+ preflightDescription("publish", opts.description);
290
648
  const fn = async (run, state) => {
291
649
  const envelope = state.previous;
292
650
  if (!envelope || !Array.isArray(envelope.issues)) {
@@ -296,7 +654,7 @@ export function publishIssues() {
296
654
  name: "publish",
297
655
  kind: "code",
298
656
  owner: "tracker",
299
- description: "Create the feature/story tree on the tracker, in dependency order, and link each to its parent",
657
+ description: opts.description ?? "Create the feature/story tree on the tracker, in dependency order, and link each to its parent",
300
658
  }), async (ph) => {
301
659
  const tracker = refineLib.resolveAuthoringProvider(run.cfg);
302
660
  const created = await refineLib.publish(tracker, envelope.issues, {
@@ -328,11 +686,21 @@ export function deriveRequiredAgents(steps) {
328
686
  };
329
687
  }
330
688
  export function deriveRequiredSuites(steps) {
331
- const set = new Set();
332
- for (const s of steps)
333
- for (const suite of s.requiredSuites ?? [])
334
- set.add(suite);
335
- return [...set];
689
+ const dynamicSteps = steps.filter((s) => typeof s.requiredSuites === "function");
690
+ const staticSteps = steps.filter((s) => Array.isArray(s.requiredSuites));
691
+ if (dynamicSteps.length === 0) {
692
+ return [...new Set(staticSteps.flatMap((s) => s.requiredSuites))];
693
+ }
694
+ return (options) => {
695
+ const set = new Set();
696
+ for (const s of staticSteps)
697
+ for (const suite of s.requiredSuites)
698
+ set.add(suite);
699
+ for (const s of dynamicSteps)
700
+ for (const suite of s.requiredSuites(options))
701
+ set.add(suite);
702
+ return [...set];
703
+ };
336
704
  }
337
705
  /** A display string for `spf list` — derived so it can no longer drift from what actually runs. */
338
706
  export function derivePhases(steps) {