@gr8ful/spf 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +177 -9
- package/assets/defaults/spf.config.yaml +22 -0
- package/assets/prompts/refiner/system.md +53 -0
- package/assets/prompts/refiner/user.md +70 -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 +104 -3
- package/assets/skill/references/observability.md +11 -2
- package/assets/templates/ts-cc.spf.config.yaml +3 -3
- package/assets/templates/ts-flue-ollama.spf.config.yaml +67 -0
- package/assets/templates/ts.spf.config.yaml +15 -2
- package/dist/chains/context.d.ts +39 -0
- package/dist/chains/index.d.ts +94 -10
- package/dist/chains/index.js +75 -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 +237 -18
- package/dist/chains/steps.js +477 -58
- 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 +324 -10
- package/dist/cli/commands/init.d.ts +12 -0
- package/dist/cli/commands/init.js +108 -4
- package/dist/cli/commands/install-skill.js +5 -2
- package/dist/cli/commands/list.js +43 -5
- package/dist/cli/commands/run.js +29 -2
- package/dist/cli/commands/watch.d.ts +18 -0
- package/dist/cli/commands/watch.js +214 -16
- package/dist/cli/index.js +63 -6
- package/dist/cli/interview.js +81 -9
- 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 +182 -4
- package/dist/core/data_types.js +141 -2
- package/dist/core/gates.d.ts +13 -0
- package/dist/core/gates.js +103 -0
- package/dist/core/git_helper.d.ts +29 -0
- package/dist/core/git_helper.js +41 -1
- package/dist/core/issues/github_provider.d.ts +35 -9
- package/dist/core/issues/github_provider.js +76 -28
- package/dist/core/issues/jira_provider.d.ts +14 -1
- package/dist/core/issues/jira_provider.js +9 -7
- package/dist/core/issues/provider.d.ts +77 -15
- package/dist/core/issues/provider.js +7 -4
- package/dist/core/notify/channel.d.ts +1 -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/providers.js +4 -0
- package/dist/core/refine.d.ts +39 -0
- package/dist/core/refine.js +152 -0
- 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 +67 -1
- package/dist/core/watch.js +217 -13
- package/dist/test/chains.test.js +9 -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 +71 -1
- package/dist/test/interview.test.d.ts +15 -1
- package/dist/test/interview.test.js +131 -3
- 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/refine.test.d.ts +1 -0
- package/dist/test/refine.test.js +189 -0
- 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 +297 -6
- package/package.json +5 -5
package/dist/chains/steps.js
CHANGED
|
@@ -16,20 +16,43 @@
|
|
|
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
|
*/
|
|
43
|
+
import { writeFileSync } from "node:fs";
|
|
44
|
+
import path from "node:path";
|
|
23
45
|
import * as changesLib from "../core/changes.js";
|
|
24
46
|
import * as gates from "../core/gates.js";
|
|
25
47
|
import * as quality from "../core/quality.js";
|
|
26
48
|
import * as agentsCfg from "../core/agents.js";
|
|
27
49
|
import * as session from "../core/session.js";
|
|
50
|
+
import * as refineLib from "../core/refine.js";
|
|
28
51
|
import { DOCUMENT_NOTES } from "../core/prompts.js";
|
|
29
|
-
import { BuildOutput, DocumentOutput, GenericOutput, PlanOutput, 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";
|
|
30
53
|
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: "" };
|
|
54
|
+
function makeState(prompt, options, issueId) {
|
|
55
|
+
return { prompt, options, previous: null, quality: null, review: null, changeset: null, baseline: "", issue_id: issueId, accepted: true, reason: "" };
|
|
33
56
|
}
|
|
34
57
|
function makeStep(fn, meta = {}) {
|
|
35
58
|
const step = fn;
|
|
@@ -43,11 +66,84 @@ function makeStep(fn, meta = {}) {
|
|
|
43
66
|
export function startRun(ctx, requiredAgents, requiredSuites) {
|
|
44
67
|
const cfg = agentsCfg.loadConfig(ctx.config_paths);
|
|
45
68
|
agentsCfg.validate(cfg, requiredAgents, requiredSuites, ctx.cwd);
|
|
46
|
-
|
|
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;
|
|
47
87
|
}
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
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
|
+
}
|
|
51
147
|
ph.log({ sha: run.git.commitAll(message), message });
|
|
52
148
|
}
|
|
53
149
|
/** Log a change-capture result the same way every chain that captures one did. */
|
|
@@ -60,9 +156,129 @@ export function logChangeset(ph, result) {
|
|
|
60
156
|
diff: result.diff_path,
|
|
61
157
|
});
|
|
62
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
|
+
}
|
|
63
278
|
// ── layer 2: step factories ──────────────────────────────────────────────
|
|
64
279
|
/** The engineer(request) phase every chain opens with. */
|
|
65
280
|
export function request(opts = {}) {
|
|
281
|
+
preflightDescription("request", opts.description);
|
|
66
282
|
const fn = async (run, state) => {
|
|
67
283
|
if (opts.logBaseline)
|
|
68
284
|
state.baseline = run.git.rev("HEAD");
|
|
@@ -83,90 +299,164 @@ function agentStep(opts) {
|
|
|
83
299
|
};
|
|
84
300
|
return makeStep(fn, { requiredAgents: [opts.owner], label: opts.label ?? opts.owner });
|
|
85
301
|
}
|
|
86
|
-
|
|
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);
|
|
87
312
|
return agentStep({
|
|
88
313
|
name: "plan",
|
|
89
|
-
owner: "planner",
|
|
314
|
+
owner: opts.owner ?? "planner",
|
|
90
315
|
output_type: PlanOutput,
|
|
91
|
-
description: "Turn the request into an implementable plan",
|
|
92
|
-
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,
|
|
93
319
|
});
|
|
94
320
|
}
|
|
95
321
|
/**
|
|
96
322
|
* `fromPlan` only changes the description — whether a plan() step precedes
|
|
97
323
|
* this one already decides whether `state.previous` is a plan or null, so
|
|
98
|
-
* 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).
|
|
99
329
|
*/
|
|
100
330
|
export function build(opts = {}) {
|
|
101
331
|
const fromPlan = opts.fromPlan ?? true;
|
|
332
|
+
preflightDescription("build", opts.description);
|
|
102
333
|
return agentStep({
|
|
103
334
|
name: "build",
|
|
104
|
-
owner: "builder",
|
|
335
|
+
owner: opts.owner ?? "builder",
|
|
105
336
|
output_type: BuildOutput,
|
|
106
|
-
description: fromPlan ? "Implement the plan exactly" : "Implement the request",
|
|
107
|
-
gates: [gates.diffMatchesClaims],
|
|
337
|
+
description: opts.description ?? (fromPlan ? "Implement the plan exactly" : "Implement the request"),
|
|
338
|
+
gates: withExtraGates([gates.diffMatchesClaims], opts.extraGates),
|
|
108
339
|
retries: opts.retries ?? 0,
|
|
109
340
|
});
|
|
110
341
|
}
|
|
111
|
-
export function scout() {
|
|
342
|
+
export function scout(opts = {}) {
|
|
343
|
+
preflightDescription("scout", opts.description);
|
|
112
344
|
return agentStep({
|
|
113
345
|
name: "scout",
|
|
114
|
-
owner: "scout",
|
|
346
|
+
owner: opts.owner ?? "scout",
|
|
115
347
|
output_type: ScoutOutput,
|
|
116
|
-
description: "Find and report where things live — change nothing",
|
|
117
|
-
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,
|
|
118
351
|
});
|
|
119
352
|
}
|
|
120
|
-
/**
|
|
121
|
-
|
|
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);
|
|
122
367
|
const fn = async (run, state) => {
|
|
123
368
|
const owner = state.options["agent"] ?? "builder";
|
|
124
|
-
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
|
+
})));
|
|
125
381
|
state.previous = envelope;
|
|
126
382
|
};
|
|
127
383
|
return makeStep(fn, { requiredAgents: (options) => [options["agent"] ?? "builder"], label: "<agent>" });
|
|
128
384
|
}
|
|
129
|
-
/**
|
|
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
|
+
*/
|
|
130
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);
|
|
131
405
|
const fn = async (run, state) => {
|
|
406
|
+
const suiteName = state.options["suite"] ?? opts.suite;
|
|
407
|
+
const name = suiteName === "all" ? "quality" : suiteName;
|
|
132
408
|
await run.phase(makePhaseParams({
|
|
133
|
-
name
|
|
409
|
+
name,
|
|
134
410
|
kind: "code",
|
|
135
411
|
owner: "quality",
|
|
136
412
|
description: opts.description ??
|
|
137
|
-
(
|
|
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"),
|
|
138
414
|
}), async (ph) => {
|
|
139
|
-
const result =
|
|
415
|
+
const result = quality.runSuite(run, suiteName);
|
|
140
416
|
quality.record(ph, result);
|
|
141
417
|
state.quality = result;
|
|
142
418
|
state.accepted = result.passed;
|
|
143
419
|
state.reason = result.passed ? "" : `quality failed: ${result.failures.join("; ")}`;
|
|
144
420
|
});
|
|
145
421
|
};
|
|
146
|
-
return makeStep(fn, {
|
|
422
|
+
return makeStep(fn, {
|
|
423
|
+
requiredSuites: (options) => [options["suite"] ?? opts.suite],
|
|
424
|
+
label: `code(${staticName})`,
|
|
425
|
+
});
|
|
147
426
|
}
|
|
148
427
|
/**
|
|
149
428
|
* Bounded check -> fix loop: a known command finds the failure, the builder
|
|
150
429
|
* repairs it. Always guards the last iteration — a fix on the final attempt
|
|
151
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.
|
|
152
436
|
*/
|
|
153
437
|
export function fixLoop(opts = { suite: "test" }) {
|
|
154
438
|
const max = opts.max ?? 3;
|
|
155
439
|
const owner = opts.owner ?? "builder";
|
|
156
|
-
const
|
|
157
|
-
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);
|
|
158
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;
|
|
159
448
|
let result = null;
|
|
160
449
|
for (let i = 1; i <= max; i++) {
|
|
161
450
|
result = await run.phase(makePhaseParams({
|
|
162
451
|
name: `${stepName}_${i}`,
|
|
163
452
|
kind: "code",
|
|
164
453
|
owner: "quality",
|
|
165
|
-
description: opts.
|
|
166
|
-
|
|
167
|
-
|
|
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"),
|
|
168
458
|
}), async (ph) => {
|
|
169
|
-
const r =
|
|
459
|
+
const r = quality.runSuite(run, suiteName);
|
|
170
460
|
quality.record(ph, r);
|
|
171
461
|
return r;
|
|
172
462
|
});
|
|
@@ -174,11 +464,17 @@ export function fixLoop(opts = { suite: "test" }) {
|
|
|
174
464
|
break;
|
|
175
465
|
if (i === max)
|
|
176
466
|
break; // never leave an unverified fix on the table
|
|
177
|
-
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({
|
|
178
474
|
output_type: BuildOutput,
|
|
179
475
|
prompt: state.prompt,
|
|
180
476
|
previous: quality.asEnvelope(result, what),
|
|
181
|
-
gates:
|
|
477
|
+
gates: fixGates,
|
|
182
478
|
})));
|
|
183
479
|
}
|
|
184
480
|
state.quality = result;
|
|
@@ -187,29 +483,67 @@ export function fixLoop(opts = { suite: "test" }) {
|
|
|
187
483
|
};
|
|
188
484
|
return makeStep(fn, {
|
|
189
485
|
requiredAgents: [owner],
|
|
190
|
-
requiredSuites: [opts.suite],
|
|
191
|
-
label: `code(${
|
|
486
|
+
requiredSuites: (options) => [options["suite"] ?? opts.suite],
|
|
487
|
+
label: `code(${staticStepName}) [-> ${owner}(fix) -> code(${staticStepName}) ...] bounded`,
|
|
192
488
|
});
|
|
193
489
|
}
|
|
194
|
-
/**
|
|
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
|
+
*/
|
|
195
505
|
export function reviseLoop(opts = {}) {
|
|
196
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);
|
|
197
513
|
const fn = async (run, state) => {
|
|
198
514
|
let review = null;
|
|
199
515
|
for (let i = 1; i <= max; i++) {
|
|
200
|
-
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 })));
|
|
201
523
|
if (review.approved || i === max)
|
|
202
524
|
break;
|
|
203
|
-
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 })));
|
|
204
532
|
}
|
|
205
533
|
state.review = review;
|
|
206
534
|
state.accepted = review !== null && review.approved;
|
|
207
535
|
state.reason = state.accepted ? "" : `the reviewer never approved after ${max} revision(s)`;
|
|
208
536
|
};
|
|
209
|
-
|
|
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
|
+
});
|
|
210
543
|
}
|
|
211
544
|
/** Commit the last agent step's envelope. `onlyIfAccepted` gates it on state.accepted (a preceding fixLoop/reviseLoop). */
|
|
212
545
|
export function commit(opts = {}) {
|
|
546
|
+
preflightDescription("commit", opts.description);
|
|
213
547
|
const fn = async (run, state) => {
|
|
214
548
|
if (opts.onlyIfAccepted && !state.accepted)
|
|
215
549
|
return;
|
|
@@ -219,18 +553,26 @@ export function commit(opts = {}) {
|
|
|
219
553
|
name: "commit",
|
|
220
554
|
kind: "code",
|
|
221
555
|
owner: "git",
|
|
222
|
-
description: opts.
|
|
223
|
-
? "Land the code only after the suite came back green"
|
|
224
|
-
: "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"),
|
|
225
558
|
}), async (ph) => commitEnvelope(run, ph, state.previous));
|
|
226
559
|
};
|
|
227
560
|
return makeStep(fn, { label: "git(commit)" });
|
|
228
561
|
}
|
|
229
562
|
/** Diff the working tree against a base ref — code, not judgement. `--base` (default "main") if opts.base is unset. */
|
|
230
563
|
export function changes(opts = {}) {
|
|
564
|
+
preflightDescription("changes", opts.description);
|
|
231
565
|
const fn = async (run, state) => {
|
|
232
566
|
const base = opts.base ?? state.options["base"] ?? "main";
|
|
233
|
-
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) => {
|
|
234
576
|
const result = changesLib.capture(run, makeChangeCapture({ base }));
|
|
235
577
|
logChangeset(ph, result);
|
|
236
578
|
if (result.empty) {
|
|
@@ -244,19 +586,86 @@ export function changes(opts = {}) {
|
|
|
244
586
|
return makeStep(fn, { label: "code(changes)" });
|
|
245
587
|
}
|
|
246
588
|
/** Write up the captured change. Requires a preceding changes() step. */
|
|
247
|
-
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);
|
|
248
593
|
const fn = async (run, state) => {
|
|
249
594
|
if (!state.changeset)
|
|
250
595
|
throw new Error("document() requires a preceding changes() step in the chain's step list");
|
|
251
|
-
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({
|
|
252
607
|
output_type: DocumentOutput,
|
|
253
608
|
prompt: state.prompt,
|
|
254
609
|
previous: changesLib.asEnvelope(state.changeset, DOCUMENT_NOTES),
|
|
255
|
-
gates:
|
|
610
|
+
gates: docGates,
|
|
256
611
|
})));
|
|
257
612
|
state.previous = envelope;
|
|
258
613
|
};
|
|
259
|
-
return makeStep(fn, { requiredAgents: [
|
|
614
|
+
return makeStep(fn, { requiredAgents: [owner], label: owner });
|
|
615
|
+
}
|
|
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. */
|
|
617
|
+
export function refine(opts = {}) {
|
|
618
|
+
preflightDescription("refine", opts.description);
|
|
619
|
+
return agentStep({
|
|
620
|
+
name: "refine",
|
|
621
|
+
owner: opts.owner ?? "refiner",
|
|
622
|
+
output_type: RefineOutput,
|
|
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,
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Create the tree `refine()` produced on the tracker, in dependency order,
|
|
633
|
+
* and link each node to its parent. A `code` phase, not an agent one — the
|
|
634
|
+
* decision-making (topological order, label assignment, `## Blocked by`
|
|
635
|
+
* rendering) is `core/refine.ts`'s job; this step is sequencing only, per
|
|
636
|
+
* SKILL.md's "chains stay thin" rule. Requires a preceding refine() step.
|
|
637
|
+
*
|
|
638
|
+
* Writes what it created to `<context_handoff_dir>/refine_publish.json` —
|
|
639
|
+
* the side channel `cli/commands/watch.ts`'s `runRefine` reads after the
|
|
640
|
+
* chain returns, since a chain's own return value is just an exit code.
|
|
641
|
+
* `spf watch`'s own marker/comment/transition bookkeeping for the spec
|
|
642
|
+
* issue lives entirely in `core/watch.ts`'s `runSpec`, never here — a bare
|
|
643
|
+
* `spf refine` run (no daemon, no spec issue in play) still needs this step
|
|
644
|
+
* to work standalone.
|
|
645
|
+
*/
|
|
646
|
+
export function publishIssues(opts = {}) {
|
|
647
|
+
preflightDescription("publish", opts.description);
|
|
648
|
+
const fn = async (run, state) => {
|
|
649
|
+
const envelope = state.previous;
|
|
650
|
+
if (!envelope || !Array.isArray(envelope.issues)) {
|
|
651
|
+
throw new Error("publishIssues() requires a preceding refine() step in the chain's step list");
|
|
652
|
+
}
|
|
653
|
+
await run.phase(makePhaseParams({
|
|
654
|
+
name: "publish",
|
|
655
|
+
kind: "code",
|
|
656
|
+
owner: "tracker",
|
|
657
|
+
description: opts.description ?? "Create the feature/story tree on the tracker, in dependency order, and link each to its parent",
|
|
658
|
+
}), async (ph) => {
|
|
659
|
+
const tracker = refineLib.resolveAuthoringProvider(run.cfg);
|
|
660
|
+
const created = await refineLib.publish(tracker, envelope.issues, {
|
|
661
|
+
labelPrefix: run.cfg.watch.label_prefix,
|
|
662
|
+
specIssueId: state.issue_id,
|
|
663
|
+
});
|
|
664
|
+
writeFileSync(path.join(run.context_handoff_dir, "refine_publish.json"), JSON.stringify(created.map((c) => ({ id: c.issue.id, title: c.issue.title, kind: c.kind, isLeaf: c.isLeaf })), null, 2));
|
|
665
|
+
ph.log({ created: created.length, leaves: created.filter((c) => c.isLeaf).length });
|
|
666
|
+
});
|
|
667
|
+
};
|
|
668
|
+
return makeStep(fn, { label: "code(publish)" });
|
|
260
669
|
}
|
|
261
670
|
// ── layer 3: derive ChainDefinition fields from a step list ─────────────
|
|
262
671
|
export function deriveRequiredAgents(steps) {
|
|
@@ -277,11 +686,21 @@ export function deriveRequiredAgents(steps) {
|
|
|
277
686
|
};
|
|
278
687
|
}
|
|
279
688
|
export function deriveRequiredSuites(steps) {
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
+
};
|
|
285
704
|
}
|
|
286
705
|
/** A display string for `spf list` — derived so it can no longer drift from what actually runs. */
|
|
287
706
|
export function derivePhases(steps) {
|
|
@@ -291,7 +710,7 @@ export function derivePhases(steps) {
|
|
|
291
710
|
/** Run a chain's step list start to finish: prologue, every step in order, then run.finish(). */
|
|
292
711
|
export async function runSteps(ctx, requiredAgents, requiredSuites, steps, options = {}) {
|
|
293
712
|
const run = startRun(ctx, requiredAgents, requiredSuites);
|
|
294
|
-
const state = makeState(ctx.prompt, options);
|
|
713
|
+
const state = makeState(ctx.prompt, options, ctx.issue_id ?? null);
|
|
295
714
|
for (const step of steps) {
|
|
296
715
|
await step(run, state);
|
|
297
716
|
}
|