@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
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo-local chains: `<repo>/.spf/chains/*.yaml` read as DATA.
|
|
3
|
+
*
|
|
4
|
+
* WHAT THIS IS. A target repo can compose its own chain out of the step
|
|
5
|
+
* factories `./steps.ts` already exports, by naming them in a yaml file and
|
|
6
|
+
* passing them params. One file, one chain:
|
|
7
|
+
*
|
|
8
|
+
* # .spf/chains/ship-it.yaml
|
|
9
|
+
* name: ship-it
|
|
10
|
+
* describe: plan, build, test, land — with our own reviewer in the loop
|
|
11
|
+
* steps:
|
|
12
|
+
* - step: request
|
|
13
|
+
* - step: plan
|
|
14
|
+
* owner: architect
|
|
15
|
+
* - step: build
|
|
16
|
+
* retries: 2
|
|
17
|
+
* extraGates: [jsonParses]
|
|
18
|
+
* - step: fixLoop
|
|
19
|
+
* suite: test
|
|
20
|
+
* - step: commit
|
|
21
|
+
* onlyIfAccepted: true
|
|
22
|
+
*
|
|
23
|
+
* WHY DATA, AND NOT CODE. SPF's contract is "agent proposes, code disposes",
|
|
24
|
+
* and the code that disposes is SPF'S code. A chain that could `import`
|
|
25
|
+
* something out of the target repo would move the disposer into the repo
|
|
26
|
+
* being worked on — the agent's own blast radius — and there would be
|
|
27
|
+
* nothing left holding the line. So a repo chain names existing factories
|
|
28
|
+
* and nothing else: no expressions, no shell, no module paths, no way to
|
|
29
|
+
* introduce behavior that isn't already compiled into spf and covered by
|
|
30
|
+
* spf's tests. It is also what keeps the promise that a target repo is
|
|
31
|
+
* zero-setup and language-agnostic: the only thing it ever gains is `.spf/`,
|
|
32
|
+
* with no build step, no dependency on this package, and nothing to compile.
|
|
33
|
+
*
|
|
34
|
+
* The counterpart rule lives in `steps.ts` (see GATE_ALLOWLIST): params may
|
|
35
|
+
* only ADD. A repo chain can demand more checking than a built-in does; it
|
|
36
|
+
* can never demand less.
|
|
37
|
+
*
|
|
38
|
+
* ONE RUN PATH. Every chain here is built with `index.ts`'s `stepChain()` —
|
|
39
|
+
* the very same function the built-ins use — so it is a plain
|
|
40
|
+
* `ChainDefinition` with a `steps` array, its `phases`/`requiredAgents`/
|
|
41
|
+
* `requiredSuites` derived by the same code, executed by the same
|
|
42
|
+
* `steps.runSteps()` driver. There is deliberately no second interpreter,
|
|
43
|
+
* no "yaml runtime", and no branch anywhere downstream on "is this a repo
|
|
44
|
+
* chain": the only difference a repo chain carries is `source`, the file it
|
|
45
|
+
* came from, which exists so a run can be traced back to a definition that
|
|
46
|
+
* may since have been edited.
|
|
47
|
+
*
|
|
48
|
+
* NEVER THROWS. `loadRepoChains()` returns `{ chains, problems }`. That is
|
|
49
|
+
* not politeness: this function runs at CLI startup for EVERY command (see
|
|
50
|
+
* `cli/index.ts`), so a single malformed yaml must not be able to break
|
|
51
|
+
* `spf sessions`, `spf trace`, `spf doctor` or `spf --version` in a repo
|
|
52
|
+
* that has one. Anything that goes wrong — unreadable directory, invalid
|
|
53
|
+
* yaml, unknown step, wrong param type, bad description, unknown gate,
|
|
54
|
+
* colliding name — becomes a `{ file, message }` problem carrying enough
|
|
55
|
+
* detail to fix the file, and the other files still load.
|
|
56
|
+
*
|
|
57
|
+
* THE VOCABULARY IS THE FACTORY SIGNATURE. A step's yaml name is its
|
|
58
|
+
* exported function name (`fixLoop`, `promptOnly`, `publishIssues`) and its
|
|
59
|
+
* params are that function's `opts` keys, camelCase and all (`extraGates`,
|
|
60
|
+
* `onlyIfAccepted`, `fromPlan`). No snake_case aliasing, no renaming layer:
|
|
61
|
+
* a second spelling of the same thing is a second thing to keep in sync, and
|
|
62
|
+
* a chain author reading `steps.ts` (or `spf list`) would be reading a
|
|
63
|
+
* vocabulary that isn't the one they type. The schemas below are the ONE
|
|
64
|
+
* place that mapping is written down; each mirrors exactly one factory's
|
|
65
|
+
* `opts`, and adding a param to a factory without adding it here simply
|
|
66
|
+
* means yaml cannot reach it yet (a safe, loud default: unknown params are
|
|
67
|
+
* rejected, never ignored).
|
|
68
|
+
*/
|
|
69
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
70
|
+
import path from "node:path";
|
|
71
|
+
import * as v from "valibot";
|
|
72
|
+
import { parse as parseYaml } from "yaml";
|
|
73
|
+
import * as steps from "./steps.js";
|
|
74
|
+
import { BUILTIN_CHAIN_NAMES, stepChain } from "./index.js";
|
|
75
|
+
/** Build a StepSpec from valibot entries, so the schema and `params` cannot disagree. */
|
|
76
|
+
function spec(entries, build) {
|
|
77
|
+
return {
|
|
78
|
+
// strictObject, not object: an unrecognized param is a mistake worth
|
|
79
|
+
// reporting. Silently ignoring `retires: 2` would leave an author
|
|
80
|
+
// convinced they had configured something they hadn't.
|
|
81
|
+
schema: v.strictObject(entries),
|
|
82
|
+
build: build,
|
|
83
|
+
params: Object.keys(entries),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
// `v.trim()` before `v.minLength(1)`: without it, a whitespace-only string
|
|
87
|
+
// (" ") satisfies minLength and loads clean, producing an invisible blank
|
|
88
|
+
// where `spf list`/`spf doctor` would show an owner or a suite name — it
|
|
89
|
+
// does still fail closed (agents.validate() rejects the unknown/blank agent
|
|
90
|
+
// before anything spawns), but the failure names an agent nobody can see.
|
|
91
|
+
const Description = v.optional(v.pipe(v.string(), v.trim(), v.minLength(1, "description must not be empty")));
|
|
92
|
+
const Owner = v.optional(v.pipe(v.string(), v.trim(), v.minLength(1, "owner must name an agent from spf.config.yaml")));
|
|
93
|
+
/**
|
|
94
|
+
* Upper bounds, not just lower ones — `retries`/`max` are the one lane with
|
|
95
|
+
* nobody watching it (see `ChainContext.unattended`, and `GATE_ALLOWLIST`'s
|
|
96
|
+
* comment for the same argument applied to gates instead of iteration
|
|
97
|
+
* counts). `max: 100000` on `fixLoop` or `retries: 9999` on `build` is
|
|
98
|
+
* accepted with no problem today — a single typo becomes unbounded agent
|
|
99
|
+
* spawns under an unattended `spf watch` run, with nobody at the console to
|
|
100
|
+
* notice. The bounds below sit a little above the highest value any
|
|
101
|
+
* built-in chain uses (`fixLoop`/`reviseLoop` default `max` to 3, `document`
|
|
102
|
+
* defaults `retries` to 1) — raise them deliberately here if a chain
|
|
103
|
+
* genuinely needs more, rather than leaving the lane unbounded for everyone.
|
|
104
|
+
*/
|
|
105
|
+
const Retries = v.optional(v.pipe(v.number("retries must be a number"), v.integer("retries must be a whole number"), v.minValue(0, "retries must not be negative"), v.maxValue(5, "retries above 5 re-prompts the same agent more than any built-in chain does — raise this bound deliberately in repo_chains.ts if a chain genuinely needs it")));
|
|
106
|
+
const Max = v.optional(v.pipe(v.number("max must be a number"), v.integer("max must be a whole number"), v.minValue(1, "max must be at least 1"), v.maxValue(10, "max above 10 loops more than any built-in chain does — raise this bound deliberately in repo_chains.ts if a chain genuinely needs it")));
|
|
107
|
+
const Suite = v.optional(v.pipe(v.string(), v.trim(), v.minLength(1, "suite must name a suite from quality.suites in spf.config.yaml")));
|
|
108
|
+
/**
|
|
109
|
+
* Gate names, validated against a per-PARAM allowlist — never the flat
|
|
110
|
+
* `steps.GATE_NAMES` (see `steps.ts`'s `GENERIC_GATE_NAMES` /
|
|
111
|
+
* `*_ENVELOPE_GATE_NAMES` comment for why a global list is wrong here: a
|
|
112
|
+
* gate that reads a field only one envelope shape populates is meaningless,
|
|
113
|
+
* not merely unchecked, on every other step). Checked here so a typo — or a
|
|
114
|
+
* gate that doesn't apply to this step's envelope — is a load-time problem
|
|
115
|
+
* rather than either the throw `resolveExtraGates` would raise, or worse, a
|
|
116
|
+
* gate that silently passes vacuously or fails every time at run time.
|
|
117
|
+
*/
|
|
118
|
+
function extraGatesSchema(allowed) {
|
|
119
|
+
return v.optional(v.array(v.picklist(allowed, `unknown gate — allowed: ${allowed.join(", ")}`), "extraGates must be a list of gate names"));
|
|
120
|
+
}
|
|
121
|
+
const GenericExtraGates = extraGatesSchema(steps.GENERIC_GATE_NAMES);
|
|
122
|
+
const BuildExtraGates = extraGatesSchema(steps.BUILD_ENVELOPE_GATE_NAMES);
|
|
123
|
+
const ReviewExtraGates = extraGatesSchema(steps.REVIEW_ENVELOPE_GATE_NAMES);
|
|
124
|
+
/**
|
|
125
|
+
* One entry per exported flat-step factory in `steps.ts`. The key is what a
|
|
126
|
+
* yaml `step:` names.
|
|
127
|
+
*
|
|
128
|
+
* `qualityCheck`/`fixLoop` default their `suite` HERE as well as in the
|
|
129
|
+
* factory signature: the factory's default lives on the whole `opts` object
|
|
130
|
+
* (`opts = { suite: "test" }`), which a yaml-supplied `{}` would satisfy
|
|
131
|
+
* without ever supplying `suite`. Defaulting in the schema is what makes
|
|
132
|
+
* `- step: fixLoop` with no params mean the same thing as `fixLoop()`.
|
|
133
|
+
*/
|
|
134
|
+
export const STEP_SPECS = {
|
|
135
|
+
request: spec({ description: Description, logBaseline: v.optional(v.boolean("logBaseline must be true or false")) }, (p) => steps.request(p)),
|
|
136
|
+
plan: spec({ owner: Owner, description: Description, retries: Retries, extraGates: GenericExtraGates }, (p) => steps.plan(p)),
|
|
137
|
+
// extraGates: BuildExtraGates — build()'s envelope is a BuildOutput, so
|
|
138
|
+
// diffMatchesClaims (already built in) is at least a coherent thing to
|
|
139
|
+
// name again here; verdictConsistent never would be (see steps.ts).
|
|
140
|
+
build: spec({
|
|
141
|
+
fromPlan: v.optional(v.boolean("fromPlan must be true or false")),
|
|
142
|
+
owner: Owner,
|
|
143
|
+
description: Description,
|
|
144
|
+
retries: Retries,
|
|
145
|
+
extraGates: BuildExtraGates,
|
|
146
|
+
}, (p) => steps.build(p)),
|
|
147
|
+
scout: spec({ owner: Owner, description: Description, retries: Retries, extraGates: GenericExtraGates }, (p) => steps.scout(p)),
|
|
148
|
+
// No `owner`: this step's owner is chosen by `--agent` at invocation time —
|
|
149
|
+
// see promptOnly()'s doc comment. Envelope is generic (GenericOutput), so
|
|
150
|
+
// only the shape-agnostic gates apply.
|
|
151
|
+
promptOnly: spec({ description: Description, retries: Retries, extraGates: GenericExtraGates }, (p) => steps.promptOnly(p)),
|
|
152
|
+
qualityCheck: spec({ suite: Suite, description: Description }, (p) => steps.qualityCheck({ ...p, suite: p.suite ?? "all" })),
|
|
153
|
+
// fixExtraGates gates the repair phase, whose envelope is a BuildOutput —
|
|
154
|
+
// same reasoning as build() above.
|
|
155
|
+
fixLoop: spec({
|
|
156
|
+
suite: Suite,
|
|
157
|
+
max: Max,
|
|
158
|
+
owner: Owner,
|
|
159
|
+
description: Description,
|
|
160
|
+
fixDescription: Description,
|
|
161
|
+
fixRetries: Retries,
|
|
162
|
+
fixExtraGates: BuildExtraGates,
|
|
163
|
+
}, (p) => steps.fixLoop({ ...p, suite: p.suite ?? "test" })),
|
|
164
|
+
// extraGates gates the REVIEW phase (a ReviewOutput — verdictConsistent
|
|
165
|
+
// applies); reviseExtraGates gates the separate REVISE phase (a
|
|
166
|
+
// BuildOutput — diffMatchesClaims applies instead). Two different
|
|
167
|
+
// envelopes, two different allowlists, on the same step.
|
|
168
|
+
reviseLoop: spec({
|
|
169
|
+
max: Max,
|
|
170
|
+
reviewer: Owner,
|
|
171
|
+
builder: Owner,
|
|
172
|
+
description: Description,
|
|
173
|
+
retries: Retries,
|
|
174
|
+
extraGates: ReviewExtraGates,
|
|
175
|
+
reviseDescription: Description,
|
|
176
|
+
reviseRetries: Retries,
|
|
177
|
+
reviseExtraGates: BuildExtraGates,
|
|
178
|
+
}, (p) => steps.reviseLoop(p)),
|
|
179
|
+
commit: spec({ onlyIfAccepted: v.optional(v.boolean("onlyIfAccepted must be true or false")), description: Description }, (p) => steps.commit(p)),
|
|
180
|
+
changes: spec({ base: v.optional(v.pipe(v.string(), v.trim(), v.minLength(1, "base must name a git ref"))), description: Description }, (p) => steps.changes(p)),
|
|
181
|
+
document: spec({ owner: Owner, description: Description, retries: Retries, extraGates: GenericExtraGates }, (p) => steps.document(p)),
|
|
182
|
+
refine: spec({ owner: Owner, description: Description, retries: Retries, extraGates: GenericExtraGates }, (p) => steps.refine(p)),
|
|
183
|
+
publishIssues: spec({ description: Description }, (p) => steps.publishIssues(p)),
|
|
184
|
+
};
|
|
185
|
+
/** Every step name a yaml file may use — for schema validation and for error messages. */
|
|
186
|
+
export const STEP_NAMES = Object.freeze(Object.keys(STEP_SPECS));
|
|
187
|
+
// ── the file schema ──────────────────────────────────────────────────────
|
|
188
|
+
/**
|
|
189
|
+
* A chain name is typed on the command line (`spf <name> "..."`), so it is
|
|
190
|
+
* held to what a shell word can be: no spaces, no leading dash (which would
|
|
191
|
+
* parse as a flag), nothing that needs quoting.
|
|
192
|
+
*/
|
|
193
|
+
const CHAIN_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
194
|
+
/**
|
|
195
|
+
* The whole file. One document, one chain — deliberately not a list: the
|
|
196
|
+
* filename then documents which chain lives where, and a problem can point
|
|
197
|
+
* at a file the operator can open, which is the only handle they have.
|
|
198
|
+
*
|
|
199
|
+
* `steps` is validated loosely here (each entry only has to be an object
|
|
200
|
+
* naming a `step`); the per-step params are validated against that step's
|
|
201
|
+
* own schema afterwards, because which schema applies depends on the value
|
|
202
|
+
* of `step`.
|
|
203
|
+
*/
|
|
204
|
+
export const RepoChainFileSchema = v.strictObject({
|
|
205
|
+
name: v.pipe(v.string("name is required — it is what `spf <name>` types"), v.regex(CHAIN_NAME_RE, "name must be lowercase letters/digits/._- and start with a letter or digit (it is typed on the command line)")),
|
|
206
|
+
describe: v.pipe(v.string("describe is required — it is the line `spf list` prints"), v.minLength(1, "describe must not be empty")),
|
|
207
|
+
steps: v.pipe(v.array(v.looseObject({ step: v.string("each step entry needs a `step:` naming a step factory") }), "steps must be a list"), v.minLength(1, "steps must name at least one step — a chain with no steps would run nothing and report success")),
|
|
208
|
+
});
|
|
209
|
+
/**
|
|
210
|
+
* Names `cli/index.ts`'s command switch claims before it ever consults the
|
|
211
|
+
* chain registry. A chain called `watch` would be reachable only as
|
|
212
|
+
* `spf run watch` — the bare `spf watch` form every doc and every habit
|
|
213
|
+
* uses would silently run the daemon instead. Rejected rather than allowed
|
|
214
|
+
* to be that confusing.
|
|
215
|
+
*
|
|
216
|
+
* Yes, this duplicates that switch, and yes, it can go stale: a subcommand
|
|
217
|
+
* added there and not added here just means one fewer name is flagged.
|
|
218
|
+
* That's the whole cost, it fails in the harmless direction (a repo chain
|
|
219
|
+
* named after a brand-new subcommand stops being reachable bare, exactly as
|
|
220
|
+
* it would today), and the alternative — exporting the command list from
|
|
221
|
+
* `cli/index.ts` and importing the CLI layer into a chains module — inverts
|
|
222
|
+
* the dependency direction for a usability check.
|
|
223
|
+
*/
|
|
224
|
+
const RESERVED_COMMAND_NAMES = new Set([
|
|
225
|
+
"run",
|
|
226
|
+
"list",
|
|
227
|
+
"init",
|
|
228
|
+
"install-skill",
|
|
229
|
+
"migrate",
|
|
230
|
+
"eject",
|
|
231
|
+
"doctor",
|
|
232
|
+
"ui",
|
|
233
|
+
"watch",
|
|
234
|
+
"sessions",
|
|
235
|
+
"phases",
|
|
236
|
+
"events",
|
|
237
|
+
"abort",
|
|
238
|
+
"version",
|
|
239
|
+
"help",
|
|
240
|
+
]);
|
|
241
|
+
// ── loading ──────────────────────────────────────────────────────────────
|
|
242
|
+
function errorMessage(error) {
|
|
243
|
+
return error instanceof Error ? error.message : String(error);
|
|
244
|
+
}
|
|
245
|
+
/** Flatten valibot issues into one line an author can act on, each prefixed with where it was. */
|
|
246
|
+
function describeIssues(issues) {
|
|
247
|
+
return issues
|
|
248
|
+
.map((issue) => {
|
|
249
|
+
const at = v.getDotPath(issue);
|
|
250
|
+
return at ? `${at}: ${issue.message}` : issue.message;
|
|
251
|
+
})
|
|
252
|
+
.join("; ");
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Build one step from one yaml entry. Returns the Step, or a message.
|
|
256
|
+
*
|
|
257
|
+
* The `spec.build(...)` call is wrapped because a factory validates what a
|
|
258
|
+
* schema cannot: `preflightDescription` rejects a description that is blank
|
|
259
|
+
* once whitespace-collapsed or that merely echoes the phase name (see
|
|
260
|
+
* `core/data_types.ts`'s PhaseParamsSchema — the description is the one
|
|
261
|
+
* sentence the trace, the console and the UI ever show about intent, so
|
|
262
|
+
* `description: "build"` on the `build` step is refused). That check has to
|
|
263
|
+
* happen at chain-DEFINITION time, which is here: an unattended `spf watch`
|
|
264
|
+
* run must not start a session it was always going to abort four phases in.
|
|
265
|
+
*/
|
|
266
|
+
function buildStep(index, entry) {
|
|
267
|
+
const where = `steps[${index}]`;
|
|
268
|
+
const { step: stepName, ...params } = entry;
|
|
269
|
+
const spec = STEP_SPECS[String(stepName)];
|
|
270
|
+
if (!spec) {
|
|
271
|
+
return { message: `${where}: unknown step ${JSON.stringify(stepName)} — available steps: ${STEP_NAMES.join(", ")}` };
|
|
272
|
+
}
|
|
273
|
+
const unknownParams = Object.keys(params).filter((key) => !spec.params.includes(key));
|
|
274
|
+
if (unknownParams.length > 0) {
|
|
275
|
+
return {
|
|
276
|
+
message: `${where} (${stepName}): unknown param(s) ${unknownParams.map((k) => JSON.stringify(k)).join(", ")} — ` +
|
|
277
|
+
(spec.params.length > 0 ? `${stepName} accepts: ${spec.params.join(", ")}` : `${stepName} takes no params`),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
const parsed = v.safeParse(spec.schema, params);
|
|
281
|
+
if (!parsed.success) {
|
|
282
|
+
return { message: `${where} (${stepName}): ${describeIssues(parsed.issues)}` };
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
return { step: spec.build(parsed.output) };
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
return { message: `${where} (${stepName}): ${errorMessage(error)}` };
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Read, parse, validate and build one file. Returns the chain, a message, or
|
|
293
|
+
* `skip` for a document that declares no chain at all — an empty file or one
|
|
294
|
+
* that is entirely comments parses to `null`/`undefined` via `parseYaml`,
|
|
295
|
+
* and that is not a malformed chain, it is the absence of one (this is also
|
|
296
|
+
* what a freshly-scaffolded, not-yet-uncommented `spf init` template looks
|
|
297
|
+
* like — it must load silently, not report a problem against itself). A
|
|
298
|
+
* document that parses to something else non-object (a bare string, a
|
|
299
|
+
* number, a list) still gets the "is empty" message below: that shape did
|
|
300
|
+
* declare *something*, just not a chain.
|
|
301
|
+
*/
|
|
302
|
+
function loadOne(file) {
|
|
303
|
+
let raw;
|
|
304
|
+
try {
|
|
305
|
+
raw = readFileSync(file, "utf-8");
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
return { message: `could not be read: ${errorMessage(error)}` };
|
|
309
|
+
}
|
|
310
|
+
let doc;
|
|
311
|
+
try {
|
|
312
|
+
doc = parseYaml(raw);
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
// yaml's own parse errors already carry line/column — pass them through
|
|
316
|
+
// verbatim rather than summarizing away the only positional information
|
|
317
|
+
// the operator gets.
|
|
318
|
+
return { message: `is not valid YAML: ${errorMessage(error)}` };
|
|
319
|
+
}
|
|
320
|
+
if (doc === null || doc === undefined) {
|
|
321
|
+
return { skip: true };
|
|
322
|
+
}
|
|
323
|
+
if (typeof doc !== "object" || Array.isArray(doc)) {
|
|
324
|
+
return { message: "is empty — a chain file needs `name`, `describe` and `steps`" };
|
|
325
|
+
}
|
|
326
|
+
const parsed = v.safeParse(RepoChainFileSchema, doc);
|
|
327
|
+
if (!parsed.success) {
|
|
328
|
+
return { message: describeIssues(parsed.issues) };
|
|
329
|
+
}
|
|
330
|
+
const file_chain = parsed.output;
|
|
331
|
+
if (RESERVED_COMMAND_NAMES.has(file_chain.name)) {
|
|
332
|
+
return { message: `name ${JSON.stringify(file_chain.name)} is an spf subcommand — \`spf ${file_chain.name}\` would never reach this chain; pick another name` };
|
|
333
|
+
}
|
|
334
|
+
const built = [];
|
|
335
|
+
for (const [index, entry] of file_chain.steps.entries()) {
|
|
336
|
+
const result = buildStep(index, entry);
|
|
337
|
+
if ("message" in result)
|
|
338
|
+
return { message: result.message };
|
|
339
|
+
built.push(result.step);
|
|
340
|
+
}
|
|
341
|
+
// The same constructor the built-ins use — see stepChain()'s comment.
|
|
342
|
+
return { chain: { ...stepChain(file_chain.name, file_chain.describe, built), source: file } };
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Read every chain in `<anchor.spf_dir>/chains/`.
|
|
346
|
+
*
|
|
347
|
+
* Never throws (see the module header). Returns the chains that loaded and a
|
|
348
|
+
* problem per file that didn't; a file contributes at most one chain and, on
|
|
349
|
+
* failure, exactly one problem — the first thing wrong with it, because the
|
|
350
|
+
* second is usually a consequence of the first.
|
|
351
|
+
*/
|
|
352
|
+
export function loadRepoChains(anchor) {
|
|
353
|
+
const chains = [];
|
|
354
|
+
const problems = [];
|
|
355
|
+
try {
|
|
356
|
+
if (!anchor.spf_dir)
|
|
357
|
+
return { chains, problems };
|
|
358
|
+
const dir = path.join(anchor.spf_dir, "chains");
|
|
359
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory())
|
|
360
|
+
return { chains, problems };
|
|
361
|
+
let files;
|
|
362
|
+
try {
|
|
363
|
+
// Sorted, so which of two colliding files is reported (and which wins)
|
|
364
|
+
// is deterministic across machines and filesystems — a load order that
|
|
365
|
+
// depends on directory iteration order would make a collision problem
|
|
366
|
+
// reproduce differently for two people looking at the same repo.
|
|
367
|
+
files = readdirSync(dir)
|
|
368
|
+
.filter((f) => f.endsWith(".yaml") || f.endsWith(".yml"))
|
|
369
|
+
.sort();
|
|
370
|
+
}
|
|
371
|
+
catch (error) {
|
|
372
|
+
problems.push({ file: dir, message: `could not read the chains directory: ${errorMessage(error)}` });
|
|
373
|
+
return { chains, problems };
|
|
374
|
+
}
|
|
375
|
+
/** name -> the file that claimed it, for collision reporting. */
|
|
376
|
+
const claimed = new Map();
|
|
377
|
+
for (const entry of files) {
|
|
378
|
+
const file = path.join(dir, entry);
|
|
379
|
+
const loaded = loadOne(file);
|
|
380
|
+
if ("skip" in loaded)
|
|
381
|
+
continue;
|
|
382
|
+
if ("message" in loaded) {
|
|
383
|
+
problems.push({ file, message: loaded.message });
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
const name = loaded.chain.name;
|
|
387
|
+
// A built-in is NEVER shadowed — that direction is always a problem,
|
|
388
|
+
// never a silent pick. `core/session.ts`'s `ensure()` writes
|
|
389
|
+
// `chain_name` into the trace as the record of what ran; every session
|
|
390
|
+
// row, every `spf sessions` line, every UI lane is keyed on that
|
|
391
|
+
// string, so a repo chain reusing a built-in's name would make that
|
|
392
|
+
// column stop meaning one thing — retroactively, for every run already
|
|
393
|
+
// recorded under it too. Between two REPO files claiming the same
|
|
394
|
+
// name, the sorted-first one wins deterministically (see the sort
|
|
395
|
+
// above) and the loser is reported as a problem — an ergonomic
|
|
396
|
+
// trade-off, not the same guarantee: renaming the winning file changes
|
|
397
|
+
// which step list `chain_name: <name>` means for every future run,
|
|
398
|
+
// which is exactly the ambiguity the built-in case above refuses to
|
|
399
|
+
// allow. Don't read this as "collisions can't happen here" — they can,
|
|
400
|
+
// deliberately, on this one axis.
|
|
401
|
+
if (BUILTIN_CHAIN_NAMES.has(name)) {
|
|
402
|
+
problems.push({
|
|
403
|
+
file,
|
|
404
|
+
message: `name ${JSON.stringify(name)} is a built-in chain — rename this chain; a repo chain never shadows a built-in`,
|
|
405
|
+
});
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
const prior = claimed.get(name);
|
|
409
|
+
if (prior) {
|
|
410
|
+
problems.push({
|
|
411
|
+
file,
|
|
412
|
+
message: `name ${JSON.stringify(name)} is already defined by ${path.basename(prior)} — two chains cannot share one name`,
|
|
413
|
+
});
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
claimed.set(name, file);
|
|
417
|
+
chains.push(loaded.chain);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
catch (error) {
|
|
421
|
+
// Backstop for anything the per-file guards above did not anticipate.
|
|
422
|
+
// The contract is that this function never throws; a surprise here must
|
|
423
|
+
// degrade to "no repo chains, one problem", not take down whatever
|
|
424
|
+
// command the operator actually ran.
|
|
425
|
+
problems.push({ file: anchor.spf_dir ? path.join(anchor.spf_dir, "chains") : anchor.cwd, message: `could not load repo chains: ${errorMessage(error)}` });
|
|
426
|
+
}
|
|
427
|
+
return { chains, problems };
|
|
428
|
+
}
|
|
@@ -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
|
|
@@ -43,7 +53,70 @@
|
|
|
43
53
|
* other chain. It still reuses that module's shared helpers (`startRun`,
|
|
44
54
|
* `commitEnvelope`, `logChangeset`) rather than keeping its own copies.
|
|
45
55
|
*/
|
|
56
|
+
import { type Asker } from "../cli/ask.ts";
|
|
57
|
+
import { type CommitterIdentity } from "../core/git_helper.ts";
|
|
46
58
|
import type { ChainContext } from "./context.ts";
|
|
59
|
+
import { type ReviewOutputT } from "../core/data_types.ts";
|
|
47
60
|
export declare const REQUIRED_AGENTS: string[];
|
|
48
61
|
export declare const REQUIRED_SUITES: string[];
|
|
62
|
+
/** Printed (never thrown) whenever this chain commits on `review.approved` alone — nobody answered. */
|
|
63
|
+
export declare const AI_ONLY_SIGNOFF_WARNING = "committing on an AI-only verdict \u2014 set review.require_human_signoff or run attended";
|
|
64
|
+
export interface SignoffParams {
|
|
65
|
+
review: ReviewOutputT;
|
|
66
|
+
/** Both a real TTY (`isInteractive()`) AND an attended run (`!ctx.unattended`) — see the module comment on `ChainContext.unattended` for why neither alone is trusted. */
|
|
67
|
+
canPrompt: boolean;
|
|
68
|
+
requireHumanSignoff: boolean;
|
|
69
|
+
signoffTimeoutSeconds: number;
|
|
70
|
+
/** `null` whenever `canPrompt` is false — the caller never spins up a real prompt it won't use. */
|
|
71
|
+
asker: Asker | null;
|
|
72
|
+
/** `undefined` when `git config user.name`/`user.email` is unset at this repo — see `git_helper.committerIdentity`. */
|
|
73
|
+
identity: CommitterIdentity | undefined;
|
|
74
|
+
/** Routes into the phase's own trace record — `ph.log`, unchanged (no tracer changes on this thread). */
|
|
75
|
+
log: (payload: Record<string, unknown>) => void;
|
|
76
|
+
/** Console-only, never traced — the prompt itself and its framing, kept out of the event stream on purpose (the DECISION is what `log` records). */
|
|
77
|
+
warn: (line: string) => void;
|
|
78
|
+
}
|
|
79
|
+
export interface SignoffOutcome {
|
|
80
|
+
/** The chain's actual commit predicate — the human's answer where there is one, `review.approved` otherwise. */
|
|
81
|
+
accepted: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* True ONLY when a human explicitly typed "yes" through `asker.confirm`.
|
|
84
|
+
* `confirm`'s own default is `false`, so this can only be true by way of
|
|
85
|
+
* an explicit answer — never a TTY inference, never a timeout, never the
|
|
86
|
+
* unattended AI-only path. This is the one flag `commit_build` may use to
|
|
87
|
+
* decide whether a `Signed-off-by:` trailer is even considered.
|
|
88
|
+
*/
|
|
89
|
+
recordedYes: boolean;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Turn the reviewer's `approved` PROPOSAL into a human DISPOSAL wherever a
|
|
93
|
+
* human is reachable; otherwise apply this release's documented default.
|
|
94
|
+
*
|
|
95
|
+
* Attended (`canPrompt`): shows the reviewer's findings and blocking list,
|
|
96
|
+
* then asks — default `false` (never `review.approved`: an AI's own verdict
|
|
97
|
+
* must never be its own auto-approval), bounded by
|
|
98
|
+
* `review.signoff_timeout_seconds` (expiry resolves to that same `false` —
|
|
99
|
+
* see `cli/ask.ts`'s `confirm`). The human's answer is the return value;
|
|
100
|
+
* `review.approved` never overrides it either way.
|
|
101
|
+
*
|
|
102
|
+
* Unattended or no TTY: nobody to ask, so `require_human_signoff` decides.
|
|
103
|
+
* `true` fails the phase CLOSED (throws) — this release will not let an
|
|
104
|
+
* unattended run manufacture a "yes" nobody gave it. `false` (this release's
|
|
105
|
+
* default) proceeds on `review.approved` alone, but never quietly: a loud
|
|
106
|
+
* warning prints AND is logged every time, because the day this default
|
|
107
|
+
* flips is the day silence here would have been the bug.
|
|
108
|
+
*/
|
|
109
|
+
export declare function decideSignoff(params: SignoffParams): Promise<SignoffOutcome>;
|
|
110
|
+
/**
|
|
111
|
+
* Whether `commit_build` may attach a `Signed-off-by:` trailer: only when
|
|
112
|
+
* the outcome recorded an explicit human "yes" (`recordedYes`) AND there is
|
|
113
|
+
* an identity to attest it with. Deliberately NOT `outcome.accepted` alone —
|
|
114
|
+
* the AI-only unattended path can also produce `accepted: true` (see
|
|
115
|
+
* `decideSignoff`'s unattended branch), and that path must never mint a
|
|
116
|
+
* trailer. Pulled out as its own named function, rather than inlined at the
|
|
117
|
+
* `commitEnvelope` call site, specifically so a refactor that swaps
|
|
118
|
+
* `recordedYes` for `verified`/`accepted` there breaks a test here instead
|
|
119
|
+
* of silently minting trailers for AI-only commits.
|
|
120
|
+
*/
|
|
121
|
+
export declare function trailerFor(outcome: SignoffOutcome, identity: CommitterIdentity | undefined): CommitterIdentity | null;
|
|
49
122
|
export declare function main(ctx: ChainContext): Promise<number>;
|