@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.
- package/README.md +122 -4
- package/assets/defaults/spf.config.yaml +6 -0
- package/assets/prompts/reviewer/system.md +1 -1
- package/assets/skill/SKILL.md +1 -0
- package/assets/skill/cookbooks/authoring_chains.md +90 -7
- package/assets/skill/cookbooks/ocr_reviewer.md +196 -0
- package/assets/skill/cookbooks/roster.md +15 -4
- package/assets/skill/cookbooks/spf_overview.md +1 -0
- package/assets/skill/references/config.md +69 -4
- package/assets/skill/references/observability.md +11 -2
- package/assets/templates/ts-flue-ollama.spf.config.yaml +67 -0
- package/assets/templates/ts.spf.config.yaml +5 -0
- package/dist/chains/context.d.ts +30 -0
- package/dist/chains/index.d.ts +94 -10
- package/dist/chains/index.js +70 -5
- package/dist/chains/repo_chains.d.ts +139 -0
- package/dist/chains/repo_chains.js +428 -0
- package/dist/chains/simple_sdlc.d.ts +74 -1
- package/dist/chains/simple_sdlc.js +134 -4
- package/dist/chains/steps.d.ts +215 -20
- package/dist/chains/steps.js +429 -61
- package/dist/cli/ask.d.ts +14 -1
- package/dist/cli/ask.js +32 -2
- package/dist/cli/commands/doctor.d.ts +1 -1
- package/dist/cli/commands/doctor.js +319 -11
- package/dist/cli/commands/init.d.ts +12 -0
- package/dist/cli/commands/init.js +78 -1
- package/dist/cli/commands/list.js +42 -5
- package/dist/cli/commands/run.js +25 -2
- package/dist/cli/commands/watch.d.ts +18 -0
- package/dist/cli/commands/watch.js +158 -10
- package/dist/cli/index.js +60 -3
- package/dist/cli/interview.js +65 -10
- package/dist/core/agent_cc.d.ts +40 -1
- package/dist/core/agent_cc.js +51 -4
- package/dist/core/agent_flue.js +28 -4
- package/dist/core/agents.d.ts +8 -0
- package/dist/core/agents.js +43 -3
- package/dist/core/data_types.d.ts +104 -4
- package/dist/core/data_types.js +99 -2
- package/dist/core/git_helper.d.ts +29 -0
- package/dist/core/git_helper.js +41 -1
- package/dist/core/ollama_provider.d.ts +70 -0
- package/dist/core/ollama_provider.js +208 -0
- package/dist/core/otel.d.ts +352 -0
- package/dist/core/otel.js +793 -0
- package/dist/core/paths.d.ts +3 -0
- package/dist/core/paths.js +48 -1
- package/dist/core/providers.js +4 -0
- package/dist/core/refine.js +11 -3
- package/dist/core/session.js +39 -2
- package/dist/core/tracer.d.ts +31 -2
- package/dist/core/tracer.js +69 -11
- package/dist/core/watch.d.ts +11 -0
- package/dist/core/watch.js +17 -2
- package/dist/test/chains.test.js +8 -3
- package/dist/test/data_types.test.js +140 -2
- package/dist/test/git_helper.test.d.ts +1 -0
- package/dist/test/git_helper.test.js +59 -0
- package/dist/test/hermetic_git.d.ts +1 -0
- package/dist/test/hermetic_git.js +22 -0
- package/dist/test/init_command.test.d.ts +14 -1
- package/dist/test/init_command.test.js +54 -1
- package/dist/test/interview.test.d.ts +15 -1
- package/dist/test/interview.test.js +127 -0
- package/dist/test/ollama_provider.test.d.ts +1 -0
- package/dist/test/ollama_provider.test.js +103 -0
- package/dist/test/otel.test.d.ts +26 -0
- package/dist/test/otel.test.js +512 -0
- package/dist/test/paths.test.d.ts +1 -0
- package/dist/test/paths.test.js +68 -0
- package/dist/test/refine.test.js +64 -1
- package/dist/test/repo_chains.test.d.ts +21 -0
- package/dist/test/repo_chains.test.js +416 -0
- package/dist/test/signoff.test.d.ts +1 -0
- package/dist/test/signoff.test.js +329 -0
- package/dist/test/ui_server.test.d.ts +7 -1
- package/dist/test/ui_server.test.js +1 -0
- package/dist/test/watch.test.js +124 -1
- package/package.json +5 -5
package/dist/chains/steps.js
CHANGED
|
@@ -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
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
124
|
-
|
|
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({
|
|
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
|
-
/**
|
|
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
|
|
409
|
+
name,
|
|
137
410
|
kind: "code",
|
|
138
411
|
owner: "quality",
|
|
139
412
|
description: opts.description ??
|
|
140
|
-
(
|
|
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 =
|
|
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, {
|
|
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
|
|
160
|
-
const
|
|
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.
|
|
169
|
-
|
|
170
|
-
|
|
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 =
|
|
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({
|
|
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:
|
|
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(${
|
|
486
|
+
requiredSuites: (options) => [options["suite"] ?? opts.suite],
|
|
487
|
+
label: `code(${staticStepName}) [-> ${owner}(fix) -> code(${staticStepName}) ...] bounded`,
|
|
195
488
|
});
|
|
196
489
|
}
|
|
197
|
-
/**
|
|
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({
|
|
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({
|
|
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
|
-
|
|
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.
|
|
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({
|
|
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({
|
|
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:
|
|
610
|
+
gates: docGates,
|
|
259
611
|
})));
|
|
260
612
|
state.previous = envelope;
|
|
261
613
|
};
|
|
262
|
-
return makeStep(fn, { requiredAgents: [
|
|
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
|
-
|
|
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
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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) {
|