@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.
Files changed (91) hide show
  1. package/README.md +177 -9
  2. package/assets/defaults/spf.config.yaml +22 -0
  3. package/assets/prompts/refiner/system.md +53 -0
  4. package/assets/prompts/refiner/user.md +70 -0
  5. package/assets/prompts/reviewer/system.md +1 -1
  6. package/assets/skill/SKILL.md +1 -0
  7. package/assets/skill/cookbooks/authoring_chains.md +90 -7
  8. package/assets/skill/cookbooks/ocr_reviewer.md +196 -0
  9. package/assets/skill/cookbooks/roster.md +15 -4
  10. package/assets/skill/cookbooks/spf_overview.md +1 -0
  11. package/assets/skill/references/config.md +104 -3
  12. package/assets/skill/references/observability.md +11 -2
  13. package/assets/templates/ts-cc.spf.config.yaml +3 -3
  14. package/assets/templates/ts-flue-ollama.spf.config.yaml +67 -0
  15. package/assets/templates/ts.spf.config.yaml +15 -2
  16. package/dist/chains/context.d.ts +39 -0
  17. package/dist/chains/index.d.ts +94 -10
  18. package/dist/chains/index.js +75 -5
  19. package/dist/chains/repo_chains.d.ts +139 -0
  20. package/dist/chains/repo_chains.js +428 -0
  21. package/dist/chains/simple_sdlc.d.ts +74 -1
  22. package/dist/chains/simple_sdlc.js +134 -4
  23. package/dist/chains/steps.d.ts +237 -18
  24. package/dist/chains/steps.js +477 -58
  25. package/dist/cli/ask.d.ts +14 -1
  26. package/dist/cli/ask.js +32 -2
  27. package/dist/cli/commands/doctor.d.ts +1 -1
  28. package/dist/cli/commands/doctor.js +324 -10
  29. package/dist/cli/commands/init.d.ts +12 -0
  30. package/dist/cli/commands/init.js +108 -4
  31. package/dist/cli/commands/install-skill.js +5 -2
  32. package/dist/cli/commands/list.js +43 -5
  33. package/dist/cli/commands/run.js +29 -2
  34. package/dist/cli/commands/watch.d.ts +18 -0
  35. package/dist/cli/commands/watch.js +214 -16
  36. package/dist/cli/index.js +63 -6
  37. package/dist/cli/interview.js +81 -9
  38. package/dist/core/agent_cc.d.ts +40 -1
  39. package/dist/core/agent_cc.js +51 -4
  40. package/dist/core/agent_flue.js +28 -4
  41. package/dist/core/agents.d.ts +8 -0
  42. package/dist/core/agents.js +43 -3
  43. package/dist/core/data_types.d.ts +182 -4
  44. package/dist/core/data_types.js +141 -2
  45. package/dist/core/gates.d.ts +13 -0
  46. package/dist/core/gates.js +103 -0
  47. package/dist/core/git_helper.d.ts +29 -0
  48. package/dist/core/git_helper.js +41 -1
  49. package/dist/core/issues/github_provider.d.ts +35 -9
  50. package/dist/core/issues/github_provider.js +76 -28
  51. package/dist/core/issues/jira_provider.d.ts +14 -1
  52. package/dist/core/issues/jira_provider.js +9 -7
  53. package/dist/core/issues/provider.d.ts +77 -15
  54. package/dist/core/issues/provider.js +7 -4
  55. package/dist/core/notify/channel.d.ts +1 -1
  56. package/dist/core/ollama_provider.d.ts +70 -0
  57. package/dist/core/ollama_provider.js +208 -0
  58. package/dist/core/otel.d.ts +352 -0
  59. package/dist/core/otel.js +793 -0
  60. package/dist/core/providers.js +4 -0
  61. package/dist/core/refine.d.ts +39 -0
  62. package/dist/core/refine.js +152 -0
  63. package/dist/core/session.js +39 -2
  64. package/dist/core/tracer.d.ts +31 -2
  65. package/dist/core/tracer.js +69 -11
  66. package/dist/core/watch.d.ts +67 -1
  67. package/dist/core/watch.js +217 -13
  68. package/dist/test/chains.test.js +9 -3
  69. package/dist/test/data_types.test.js +140 -2
  70. package/dist/test/git_helper.test.d.ts +1 -0
  71. package/dist/test/git_helper.test.js +59 -0
  72. package/dist/test/hermetic_git.d.ts +1 -0
  73. package/dist/test/hermetic_git.js +22 -0
  74. package/dist/test/init_command.test.d.ts +14 -1
  75. package/dist/test/init_command.test.js +71 -1
  76. package/dist/test/interview.test.d.ts +15 -1
  77. package/dist/test/interview.test.js +131 -3
  78. package/dist/test/ollama_provider.test.d.ts +1 -0
  79. package/dist/test/ollama_provider.test.js +103 -0
  80. package/dist/test/otel.test.d.ts +26 -0
  81. package/dist/test/otel.test.js +512 -0
  82. package/dist/test/refine.test.d.ts +1 -0
  83. package/dist/test/refine.test.js +189 -0
  84. package/dist/test/repo_chains.test.d.ts +21 -0
  85. package/dist/test/repo_chains.test.js +416 -0
  86. package/dist/test/signoff.test.d.ts +1 -0
  87. package/dist/test/signoff.test.js +329 -0
  88. package/dist/test/ui_server.test.d.ts +7 -1
  89. package/dist/test/ui_server.test.js +1 -0
  90. package/dist/test/watch.test.js +297 -6
  91. package/package.json +5 -5
@@ -8,7 +8,17 @@
8
8
  * -> builder -> code(test) [-> builder(fix) -> code(test) ... bounded]
9
9
  * -> reviewer [-> builder(revise) -> reviewer ... bounded]
10
10
  * -> code(retest, only if a revision changed code)
11
- * -> git(commit_build) -> code(changes) -> documenter -> git(commit_docs)
11
+ * -> engineer(signoff) -> git(commit_build) -> code(changes) -> documenter -> git(commit_docs)
12
+ *
13
+ * The signoff phase is where "agent proposes, code disposes" gets literal:
14
+ * `review.approved` is the AI reviewer's PROPOSAL, and this chain's
15
+ * `commit_build` predicate is the only place in this codebase where such a
16
+ * proposal gates a commit at all (`build-review` has no commit step). A
17
+ * human at the keyboard DISPOSES of it — `decideSignoff` below — and their
18
+ * answer, not `review.approved`, becomes the final predicate. Unattended
19
+ * (`spf watch`, CI) has nobody to ask, so it either proceeds on the AI
20
+ * verdict alone with a loud warning, or fails the phase closed, depending on
21
+ * `review.require_human_signoff` — see that function's own comment.
12
22
  *
13
23
  * Three commits, three work products, three authors. The plan, the code, and the
14
24
  * write-up each land in their own commit, and each commit message is the words of
@@ -47,12 +57,95 @@ import * as changes from "../core/changes.js";
47
57
  import * as gates from "../core/gates.js";
48
58
  import * as quality from "../core/quality.js";
49
59
  import { DOCUMENT_NOTES } from "../core/prompts.js";
60
+ import { createAsker, isInteractive } from "../cli/ask.js";
61
+ import { paint } from "../core/console.js";
62
+ import { committerIdentity } from "../core/git_helper.js";
50
63
  import { commitEnvelope, logChangeset, startRun } from "./steps.js";
51
64
  import { BuildOutput, DocumentOutput, PlanOutput, ReviewOutput, makeAgentCall, makeChangeCapture, makePhaseParams, } from "../core/data_types.js";
52
65
  export const REQUIRED_AGENTS = ["planner", "builder", "reviewer", "documenter"];
53
66
  export const REQUIRED_SUITES = ["test"];
54
67
  const MAX_FIX_LOOPS = 3;
55
68
  const MAX_REVISION_LOOPS = 2;
69
+ // ── sign-off: agent proposes, code disposes, literally ──────────────────────
70
+ /** Printed (never thrown) whenever this chain commits on `review.approved` alone — nobody answered. */
71
+ export const AI_ONLY_SIGNOFF_WARNING = "committing on an AI-only verdict — set review.require_human_signoff or run attended";
72
+ /**
73
+ * Turn the reviewer's `approved` PROPOSAL into a human DISPOSAL wherever a
74
+ * human is reachable; otherwise apply this release's documented default.
75
+ *
76
+ * Attended (`canPrompt`): shows the reviewer's findings and blocking list,
77
+ * then asks — default `false` (never `review.approved`: an AI's own verdict
78
+ * must never be its own auto-approval), bounded by
79
+ * `review.signoff_timeout_seconds` (expiry resolves to that same `false` —
80
+ * see `cli/ask.ts`'s `confirm`). The human's answer is the return value;
81
+ * `review.approved` never overrides it either way.
82
+ *
83
+ * Unattended or no TTY: nobody to ask, so `require_human_signoff` decides.
84
+ * `true` fails the phase CLOSED (throws) — this release will not let an
85
+ * unattended run manufacture a "yes" nobody gave it. `false` (this release's
86
+ * default) proceeds on `review.approved` alone, but never quietly: a loud
87
+ * warning prints AND is logged every time, because the day this default
88
+ * flips is the day silence here would have been the bug.
89
+ */
90
+ export async function decideSignoff(params) {
91
+ const { review, canPrompt, requireHumanSignoff, signoffTimeoutSeconds, asker, identity, log, warn } = params;
92
+ if (!canPrompt) {
93
+ if (requireHumanSignoff) {
94
+ throw new Error("review.require_human_signoff is true but this run is unattended — rerun attended, " +
95
+ "or run via `spf watch`, where a human merges the PR instead of this phase.");
96
+ }
97
+ warn(paint("bold yellow", `⚠ ${AI_ONLY_SIGNOFF_WARNING}`));
98
+ log({
99
+ decision: "ai_only",
100
+ warning: AI_ONLY_SIGNOFF_WARNING,
101
+ human: false,
102
+ review_approved: review.approved,
103
+ blocking: review.blocking,
104
+ findings: review.findings,
105
+ });
106
+ return { accepted: review.approved, recordedYes: false };
107
+ }
108
+ if (!asker)
109
+ throw new Error("decideSignoff: canPrompt is true but no asker was supplied");
110
+ warn("");
111
+ warn(paint("bold", "── sign-off required before commit ──"));
112
+ for (const finding of review.findings) {
113
+ const mark = finding.met ? paint("green", "met") : paint("red", "MISSING");
114
+ warn(` ${mark} ${finding.requirement}${finding.evidence ? paint("dim", ` — ${finding.evidence}`) : ""}`);
115
+ }
116
+ if (review.blocking.length > 0) {
117
+ warn(paint("red", ` blocking: ${review.blocking.join("; ")}`));
118
+ }
119
+ warn("");
120
+ const accepted = await asker.confirm(identity ? `${identity.name}, approve and commit?` : "Approve and commit?", false, { timeoutMs: signoffTimeoutSeconds * 1000 });
121
+ log({
122
+ decision: accepted ? "approved" : "declined",
123
+ human: true,
124
+ engineer: identity?.name ?? null,
125
+ review_approved: review.approved,
126
+ blocking: review.blocking,
127
+ findings: review.findings,
128
+ });
129
+ if (accepted && !identity) {
130
+ warn(paint("dim", " no git committer identity (user.name/user.email) is set — sign-off recorded, no Signed-off-by trailer"));
131
+ log({ note: "signoff recorded without a git committer identity — trailer skipped" });
132
+ }
133
+ return { accepted, recordedYes: accepted };
134
+ }
135
+ /**
136
+ * Whether `commit_build` may attach a `Signed-off-by:` trailer: only when
137
+ * the outcome recorded an explicit human "yes" (`recordedYes`) AND there is
138
+ * an identity to attest it with. Deliberately NOT `outcome.accepted` alone —
139
+ * the AI-only unattended path can also produce `accepted: true` (see
140
+ * `decideSignoff`'s unattended branch), and that path must never mint a
141
+ * trailer. Pulled out as its own named function, rather than inlined at the
142
+ * `commitEnvelope` call site, specifically so a refactor that swaps
143
+ * `recordedYes` for `verified`/`accepted` there breaks a test here instead
144
+ * of silently minting trailers for AI-only commits.
145
+ */
146
+ export function trailerFor(outcome, identity) {
147
+ return outcome.recordedYes && identity ? identity : null;
148
+ }
56
149
  export async function main(ctx) {
57
150
  const { prompt } = ctx;
58
151
  const run = startRun(ctx, REQUIRED_AGENTS, REQUIRED_SUITES);
@@ -113,9 +206,46 @@ export async function main(ctx) {
113
206
  // Red tests or a rejected review stop the chain here: the code stays
114
207
  // uncommitted and nothing is documented, because there is nothing worth
115
208
  // describing yet. The plan commit stands — it is a record of what was asked.
116
- const verified = test !== null && test.passed && review !== null && review.approved;
209
+ const aiApproved = test !== null && test.passed && review !== null && review.approved;
210
+ // The sign-off phase — see decideSignoff's comment and the module header —
211
+ // only runs once the AI's own two gates (green suite, approved review) are
212
+ // both already met; it never runs to rescue a run that failed either one.
213
+ let verified = false;
214
+ let recordedYes = false;
215
+ let identity; // hoisted: commit_build reads this too, gated by recordedYes
216
+ if (aiApproved) {
217
+ const canPrompt = isInteractive() && !ctx.unattended; // amendment: never infer this from TTY state alone
218
+ identity = committerIdentity(run.repo_root);
219
+ const asker = canPrompt ? createAsker() : null;
220
+ try {
221
+ const outcome = await run.phase(makePhaseParams({
222
+ name: "signoff",
223
+ kind: "engineer",
224
+ owner: identity?.name || run.engineer,
225
+ description: "A human disposes of the AI reviewer's approval before anything commits",
226
+ }), (ph) => decideSignoff({
227
+ review: review,
228
+ canPrompt,
229
+ requireHumanSignoff: run.cfg.review.require_human_signoff,
230
+ signoffTimeoutSeconds: run.cfg.review.signoff_timeout_seconds,
231
+ asker,
232
+ identity,
233
+ log: (payload) => ph.log(payload),
234
+ warn: (line) => console.log(line),
235
+ }));
236
+ verified = outcome.accepted;
237
+ recordedYes = outcome.recordedYes;
238
+ }
239
+ finally {
240
+ asker?.close();
241
+ }
242
+ }
117
243
  if (verified) {
118
- await run.phase(makePhaseParams({ name: "commit_build", kind: "code", owner: "git", description: "Land the code only now: green suite, approved review" }), async (ph) => commitEnvelope(run, ph, build));
244
+ await run.phase(makePhaseParams({ name: "commit_build", kind: "code", owner: "git", description: "Land the code only now: green suite, approved review, human sign-off" }),
245
+ // trailerFor gates the trailer on recordedYes, not just `verified` — see
246
+ // its own comment: a Signed-off-by line must trace back to an explicit
247
+ // "yes", never to the AI-only path (where `verified` can also be true).
248
+ async (ph) => commitEnvelope(run, ph, build, trailerFor({ accepted: verified, recordedYes }, identity)));
119
249
  const changeset = await run.phase(makePhaseParams({ name: "changes", kind: "code", owner: "git", description: "Diff the whole run against its pinned baseline, for the documenter" }), async (ph) => {
120
250
  const result = changes.capture(run, makeChangeCapture({ base: baseline }));
121
251
  logChangeset(ph, result);
@@ -132,5 +262,5 @@ export async function main(ctx) {
132
262
  })));
133
263
  await run.phase(makePhaseParams({ name: "commit_docs", kind: "code", owner: "git", description: "Ship the write-up in its own commit, beside the code it describes" }), async (ph) => commitEnvelope(run, ph, document));
134
264
  }
135
- return run.finish(verified, "the suite or the review never came back clean");
265
+ return run.finish(verified, "the suite or the review never came back clean, or sign-off never came");
136
266
  }
@@ -16,14 +16,35 @@
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
- import { type EnvelopeBase, type QualityResult, type ReviewOutputT } from "../core/data_types.ts";
43
+ import { type EnvelopeBase, type GateFn, type QualityResult, type ReviewOutputT } from "../core/data_types.ts";
24
44
  import type { ChangeSet } from "../core/data_types.ts";
25
45
  import { Run, type PhaseHandle } from "../core/runner.ts";
26
46
  import type { ChainContext } from "./context.ts";
47
+ import type { CommitterIdentity } from "../core/git_helper.ts";
27
48
  /** What one step hands the next — the parts of a hand-written chain's local variables. */
28
49
  export interface ChainState {
29
50
  prompt: string;
@@ -35,6 +56,12 @@ export interface ChainState {
35
56
  changeset: ChangeSet | null;
36
57
  /** HEAD, pinned by request({logBaseline: true}) before the run commits anything. */
37
58
  baseline: string;
59
+ /**
60
+ * The originating tracker issue, only meaningful to `publishIssues()` —
61
+ * lifted from `ChainContext.issue_id` (see its doc comment) because a
62
+ * `Step` only ever sees `(run, state)`, never the `ChainContext` itself.
63
+ */
64
+ issue_id: string | null;
38
65
  /** The run's own acceptance criterion — distinct from "every phase succeeded"; see Run.finish(). */
39
66
  accepted: boolean;
40
67
  /** Why not, when accepted is false — passed straight to run.finish(). */
@@ -50,67 +77,259 @@ export interface ChainState {
50
77
  export interface Step {
51
78
  (run: Run, state: ChainState): Promise<void>;
52
79
  requiredAgents?: string[] | ((options: Record<string, string>) => string[]);
53
- requiredSuites?: string[];
80
+ /** Static for most quality/fixLoop steps; dynamic wherever --suite can override the compiled-in default (see qualityCheck/fixLoop). */
81
+ requiredSuites?: string[] | ((options: Record<string, string>) => string[]);
54
82
  /** Display fragment for derivePhases() — e.g. "planner", "git(commit)". */
55
83
  label?: string;
56
84
  }
57
85
  /** The identical loadConfig -> validate -> session.ensure prologue every chain repeated. */
58
86
  export declare function startRun(ctx: ChainContext, requiredAgents: string[], requiredSuites: string[]): Run;
59
- /** Commit an envelope in its own author's words — the message-fallback four chains repeated. */
87
+ /**
88
+ * Commit an envelope in its own author's words — the message-fallback four
89
+ * chains repeated.
90
+ *
91
+ * `signoff`, when passed, MUST be a human's own recorded explicit "yes" —
92
+ * see `chains/simple_sdlc.ts`'s `decideSignoff`, the only caller that ever
93
+ * passes one. It is appended as a real `Signed-off-by:` trailer (blank-line
94
+ * separated, or joined into an existing trailer block — see
95
+ * `appendTrailer`). Every other caller (every chain built from `./steps.ts`'s
96
+ * own `commit()`, plus `simple_sdlc`'s `commit_plan`/`commit_docs`) passes
97
+ * nothing, because nothing gates those commits on an AI verdict — the
98
+ * `fixLoop` chains gate on tests, which is the philosophy working. Omitted
99
+ * -> no trailer, ever: a trailer that lies launders an AI verdict into a git
100
+ * attestation.
101
+ */
60
102
  export declare function commitEnvelope(run: Run, ph: PhaseHandle, envelope: EnvelopeBase & {
61
103
  commit_message?: string;
62
- }): void;
104
+ }, signoff?: CommitterIdentity | null): void;
63
105
  /** Log a change-capture result the same way every chain that captures one did. */
64
106
  export declare function logChangeset(ph: PhaseHandle, result: ChangeSet): void;
107
+ /**
108
+ * The gates a chain definition is allowed to name, by name.
109
+ *
110
+ * An EXPLICIT map, not `gates` itself and not a lookup on `gates[name]`:
111
+ * `core/gates.ts` is a module of exported functions, and reflecting over it
112
+ * would silently promote every future export (and every internal helper that
113
+ * ever gets exported for a test) into the surface a target repo's YAML can
114
+ * reach. Adding a gate to this map is a deliberate, reviewable act.
115
+ *
116
+ * CRITICAL POLICY — this map backs `extraGates`, which is ADDITIVE ONLY. A
117
+ * step's built-in gates are NON-REMOVABLE: there is no `gates:` param that
118
+ * replaces them, and there must never be one.
119
+ *
120
+ * The reason is what a repo-local chain IS. SPF's contract is "agent
121
+ * proposes, code disposes", and the code that disposes is SPF'S code — that
122
+ * is precisely why chains load as data (YAML naming these factories) instead
123
+ * of as imported repo code. A `gates: []` override would hand that back:
124
+ * the target repo would be editing its own disposer, and the first thing
125
+ * anyone under deadline pressure deletes is the gate that keeps failing —
126
+ * `diffMatchesClaims` (the builder claimed files it never wrote) or
127
+ * `verdictConsistent` (the reviewer approved while listing blockers).
128
+ * Those two are exactly the checks that catch an agent grading its own
129
+ * homework, and `spf watch` runs chains UNATTENDED (see
130
+ * `ChainContext.unattended`), so nobody is at the console to notice they
131
+ * stopped running. A weaker chain must therefore be un-expressible, not
132
+ * merely discouraged.
133
+ *
134
+ * `gates.testsPass` is deliberately absent: it is a FACTORY over a shell
135
+ * command string, so allowing it by name would mean letting a YAML file
136
+ * name an arbitrary command to execute inside a gate. Commands belong in
137
+ * `quality.checks`/`quality.suites` in spf.config.yaml, where
138
+ * `core/quality.ts` owns them and `core/permissions.ts` applies. If a
139
+ * command-running gate is ever wanted here, it needs its own param with its
140
+ * own review, not an entry in this map.
141
+ */
142
+ export declare const GATE_ALLOWLIST: Readonly<Record<string, GateFn>>;
143
+ /** 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. */
144
+ export declare const GATE_NAMES: readonly string[];
145
+ /**
146
+ * Which gate names are meaningful on which envelope shape — the missing
147
+ * per-step subset `GATE_ALLOWLIST` never had. `GATE_ALLOWLIST` says "these
148
+ * functions exist and are the ones a chain may ever name"; these three lists
149
+ * say "here is the subset that reads fields THIS envelope actually has."
150
+ *
151
+ * `artifactsExist`/`filesNonEmpty`/`jsonParses` only ever read
152
+ * `envelope.artifacts`, which every envelope has (see `EnvelopeBase`) — safe
153
+ * anywhere. `diffMatchesClaims` reads `changed_files`, which only a
154
+ * `BuildOutput`-shaped envelope populates; naming it on a step whose
155
+ * envelope is something else (e.g. a review) makes it read an absent field
156
+ * as `[]` and pass vacuously — silent, not a real check. `verdictConsistent`
157
+ * reads `approved`/`blocking`/`findings`, which only a review envelope
158
+ * populates; on anything else its "rejection names a problem" check reads
159
+ * `approved: false` with nothing to blame and fails EVERY time, which is
160
+ * exactly the shape of bug this module exists to make load-time-visible
161
+ * instead of run-time-fatal.
162
+ */
163
+ export declare const GENERIC_GATE_NAMES: readonly string[];
164
+ export declare const BUILD_ENVELOPE_GATE_NAMES: readonly string[];
165
+ export declare const REVIEW_ENVELOPE_GATE_NAMES: readonly string[];
65
166
  /** The engineer(request) phase every chain opens with. */
66
167
  export declare function request(opts?: {
67
168
  description?: string;
68
169
  logBaseline?: boolean;
69
170
  }): Step;
70
- export declare function plan(): Step;
171
+ /**
172
+ * `owner` names WHO plans, defaulting to the `planner` agent. It flows
173
+ * straight into `agentStep`'s existing owner argument, which is also what
174
+ * `makeStep` records as `requiredAgents` and as the display `label` — so a
175
+ * chain that plans with a differently-named agent updates its own
176
+ * `spf list` line and its own config validation with no second place to
177
+ * edit. Same shape for `build`/`scout`/`document` below.
178
+ */
179
+ export declare function plan(opts?: {
180
+ owner?: string;
181
+ description?: string;
182
+ retries?: number;
183
+ extraGates?: string[];
184
+ }): Step;
71
185
  /**
72
186
  * `fromPlan` only changes the description — whether a plan() step precedes
73
187
  * this one already decides whether `state.previous` is a plan or null, so
74
- * there is nothing else for this flag to gate.
188
+ * there is nothing else for this flag to gate. An explicit `description`
189
+ * therefore makes `fromPlan` moot; it is still accepted rather than made
190
+ * mutually exclusive, because the two chains that pass `fromPlan` today read
191
+ * better for it and a definition that sets both is not ambiguous (the
192
+ * explicit text wins).
75
193
  */
76
194
  export declare function build(opts?: {
77
195
  fromPlan?: boolean;
196
+ owner?: string;
197
+ description?: string;
78
198
  retries?: number;
199
+ extraGates?: string[];
79
200
  }): Step;
80
- export declare function scout(): Step;
81
- /** The `prompt` chain's one step: --agent picks who, at run time and at requiredAgents-derivation time alike. */
82
- export declare function promptOnly(): Step;
83
- /** One deterministic quality block, standalone — never throws; sets state.accepted for run.finish() to check. */
201
+ export declare function scout(opts?: {
202
+ owner?: string;
203
+ description?: string;
204
+ retries?: number;
205
+ extraGates?: string[];
206
+ }): Step;
207
+ /**
208
+ * The `prompt` chain's one step: --agent picks who, at run time and at
209
+ * requiredAgents-derivation time alike.
210
+ *
211
+ * No `owner` param, unlike the steps above: this step's whole purpose is
212
+ * that the OPERATOR chooses the agent at invocation time, so an owner baked
213
+ * into a definition would defeat it. A chain that wants a fixed agent
214
+ * already has one — that is what `plan`/`build`/`scout` with an `owner` are.
215
+ * `extraGates` is accepted (the default is genuinely no gates — a generic
216
+ * envelope claims nothing specific) so a definition can still demand, say,
217
+ * that whatever files the agent claims actually exist.
218
+ */
219
+ export declare function promptOnly(opts?: {
220
+ description?: string;
221
+ retries?: number;
222
+ extraGates?: string[];
223
+ }): Step;
224
+ /**
225
+ * One deterministic quality block, standalone — never throws; sets
226
+ * state.accepted for run.finish() to check.
227
+ *
228
+ * `opts.suite` names any suite in `quality.suites`, not just "test"/"all" —
229
+ * those two keep their historical phase name ("test"/"quality") and
230
+ * description; any other configured suite name is used verbatim for both, so
231
+ * a chain naming a custom suite still reads truthfully in its trace and in
232
+ * `spf list`. `--suite` (see run.ts) overrides the compiled-in default at
233
+ * invocation time, the same way `promptOnly()` lets `--agent` override its
234
+ * owner.
235
+ */
84
236
  export declare function qualityCheck(opts?: {
85
- suite: "test" | "all";
237
+ suite: string;
86
238
  description?: string;
87
239
  }): Step;
88
240
  /**
89
241
  * Bounded check -> fix loop: a known command finds the failure, the builder
90
242
  * repairs it. Always guards the last iteration — a fix on the final attempt
91
243
  * is never re-verified, so it is never spawned. Sets state.accepted/reason.
244
+ *
245
+ * `opts.suite` names any suite in `quality.suites` — "all" alone keeps its
246
+ * historical "verify"/"verification" naming; every other suite (including
247
+ * the default, "test") is named for itself, exactly as in `qualityCheck()`.
248
+ * `--suite` (run.ts) overrides the compiled-in default at invocation time.
92
249
  */
93
250
  export declare function fixLoop(opts?: {
94
- suite: "test" | "all";
251
+ suite: string;
95
252
  max?: number;
96
253
  owner?: string;
254
+ /** The suite phase's description (`test_1`, `verify_1`, ...). */
255
+ description?: string;
256
+ /** The repair phase's description (`fix_1`, ...) — a separate phase, so a separate override. */
257
+ fixDescription?: string;
258
+ fixRetries?: number;
259
+ fixExtraGates?: string[];
97
260
  }): Step;
98
- /** Bounded review -> revise loop. Sets state.accepted/reason from the final verdict. */
261
+ /**
262
+ * Bounded review -> revise loop. Sets state.accepted/reason from the final
263
+ * verdict.
264
+ *
265
+ * `reviewer`/`builder` name the two agents by role, defaulting to the agents
266
+ * literally called "reviewer" and "builder". They are two params rather than
267
+ * one because the whole point of this loop is that the critic and the author
268
+ * are DIFFERENT agents — a chain that points both at the same name has an
269
+ * agent reviewing its own work, which is the failure mode
270
+ * `gates.verdictConsistent` exists to make visible. Nothing here enforces
271
+ * that they differ (a repo may legitimately have one strong agent and want
272
+ * the self-review anyway), but the trace will say so plainly: the derived
273
+ * label below is built from these names, so `spf list` and the run's phases
274
+ * both read `x [-> x(revise) -> x ...]`.
275
+ */
99
276
  export declare function reviseLoop(opts?: {
100
277
  max?: number;
278
+ reviewer?: string;
279
+ builder?: string;
280
+ /** The review phase's description (`review_1`, ...). */
281
+ description?: string;
282
+ retries?: number;
283
+ extraGates?: string[];
284
+ /** The revise phase's description (`revise_1`, ...) — a separate phase, so a separate override. */
285
+ reviseDescription?: string;
286
+ reviseRetries?: number;
287
+ reviseExtraGates?: string[];
101
288
  }): Step;
102
289
  /** Commit the last agent step's envelope. `onlyIfAccepted` gates it on state.accepted (a preceding fixLoop/reviseLoop). */
103
290
  export declare function commit(opts?: {
104
291
  onlyIfAccepted?: boolean;
292
+ description?: string;
105
293
  }): Step;
106
294
  /** Diff the working tree against a base ref — code, not judgement. `--base` (default "main") if opts.base is unset. */
107
295
  export declare function changes(opts?: {
108
296
  base?: string;
297
+ description?: string;
109
298
  }): Step;
110
299
  /** Write up the captured change. Requires a preceding changes() step. */
111
- export declare function document(): Step;
300
+ export declare function document(opts?: {
301
+ owner?: string;
302
+ description?: string;
303
+ retries?: number;
304
+ extraGates?: string[];
305
+ }): Step;
306
+ /** 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. */
307
+ export declare function refine(opts?: {
308
+ owner?: string;
309
+ description?: string;
310
+ retries?: number;
311
+ extraGates?: string[];
312
+ }): Step;
313
+ /**
314
+ * Create the tree `refine()` produced on the tracker, in dependency order,
315
+ * and link each node to its parent. A `code` phase, not an agent one — the
316
+ * decision-making (topological order, label assignment, `## Blocked by`
317
+ * rendering) is `core/refine.ts`'s job; this step is sequencing only, per
318
+ * SKILL.md's "chains stay thin" rule. Requires a preceding refine() step.
319
+ *
320
+ * Writes what it created to `<context_handoff_dir>/refine_publish.json` —
321
+ * the side channel `cli/commands/watch.ts`'s `runRefine` reads after the
322
+ * chain returns, since a chain's own return value is just an exit code.
323
+ * `spf watch`'s own marker/comment/transition bookkeeping for the spec
324
+ * issue lives entirely in `core/watch.ts`'s `runSpec`, never here — a bare
325
+ * `spf refine` run (no daemon, no spec issue in play) still needs this step
326
+ * to work standalone.
327
+ */
328
+ export declare function publishIssues(opts?: {
329
+ description?: string;
330
+ }): Step;
112
331
  export declare function deriveRequiredAgents(steps: Step[]): string[] | ((options: Record<string, string>) => string[]);
113
- export declare function deriveRequiredSuites(steps: Step[]): string[];
332
+ export declare function deriveRequiredSuites(steps: Step[]): string[] | ((options: Record<string, string>) => string[]);
114
333
  /** A display string for `spf list` — derived so it can no longer drift from what actually runs. */
115
334
  export declare function derivePhases(steps: Step[]): string;
116
335
  /** Run a chain's step list start to finish: prologue, every step in order, then run.finish(). */