@gr8ful/spf 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +122 -4
  2. package/assets/defaults/spf.config.yaml +6 -0
  3. package/assets/prompts/reviewer/system.md +1 -1
  4. package/assets/skill/SKILL.md +1 -0
  5. package/assets/skill/cookbooks/authoring_chains.md +90 -7
  6. package/assets/skill/cookbooks/ocr_reviewer.md +196 -0
  7. package/assets/skill/cookbooks/roster.md +15 -4
  8. package/assets/skill/cookbooks/spf_overview.md +1 -0
  9. package/assets/skill/references/config.md +69 -4
  10. package/assets/skill/references/observability.md +11 -2
  11. package/assets/templates/ts-flue-ollama.spf.config.yaml +67 -0
  12. package/assets/templates/ts.spf.config.yaml +5 -0
  13. package/dist/chains/context.d.ts +30 -0
  14. package/dist/chains/index.d.ts +94 -10
  15. package/dist/chains/index.js +70 -5
  16. package/dist/chains/repo_chains.d.ts +139 -0
  17. package/dist/chains/repo_chains.js +428 -0
  18. package/dist/chains/simple_sdlc.d.ts +74 -1
  19. package/dist/chains/simple_sdlc.js +134 -4
  20. package/dist/chains/steps.d.ts +215 -20
  21. package/dist/chains/steps.js +429 -61
  22. package/dist/cli/ask.d.ts +14 -1
  23. package/dist/cli/ask.js +32 -2
  24. package/dist/cli/commands/doctor.d.ts +1 -1
  25. package/dist/cli/commands/doctor.js +319 -11
  26. package/dist/cli/commands/init.d.ts +12 -0
  27. package/dist/cli/commands/init.js +78 -1
  28. package/dist/cli/commands/list.js +42 -5
  29. package/dist/cli/commands/run.js +25 -2
  30. package/dist/cli/commands/watch.d.ts +18 -0
  31. package/dist/cli/commands/watch.js +158 -10
  32. package/dist/cli/index.js +60 -3
  33. package/dist/cli/interview.js +65 -10
  34. package/dist/core/agent_cc.d.ts +40 -1
  35. package/dist/core/agent_cc.js +51 -4
  36. package/dist/core/agent_flue.js +28 -4
  37. package/dist/core/agents.d.ts +8 -0
  38. package/dist/core/agents.js +43 -3
  39. package/dist/core/data_types.d.ts +104 -4
  40. package/dist/core/data_types.js +99 -2
  41. package/dist/core/git_helper.d.ts +29 -0
  42. package/dist/core/git_helper.js +41 -1
  43. package/dist/core/ollama_provider.d.ts +70 -0
  44. package/dist/core/ollama_provider.js +208 -0
  45. package/dist/core/otel.d.ts +352 -0
  46. package/dist/core/otel.js +793 -0
  47. package/dist/core/paths.d.ts +3 -0
  48. package/dist/core/paths.js +48 -1
  49. package/dist/core/providers.js +4 -0
  50. package/dist/core/refine.js +11 -3
  51. package/dist/core/session.js +39 -2
  52. package/dist/core/tracer.d.ts +31 -2
  53. package/dist/core/tracer.js +69 -11
  54. package/dist/core/watch.d.ts +11 -0
  55. package/dist/core/watch.js +17 -2
  56. package/dist/test/chains.test.js +8 -3
  57. package/dist/test/data_types.test.js +140 -2
  58. package/dist/test/git_helper.test.d.ts +1 -0
  59. package/dist/test/git_helper.test.js +59 -0
  60. package/dist/test/hermetic_git.d.ts +1 -0
  61. package/dist/test/hermetic_git.js +22 -0
  62. package/dist/test/init_command.test.d.ts +14 -1
  63. package/dist/test/init_command.test.js +54 -1
  64. package/dist/test/interview.test.d.ts +15 -1
  65. package/dist/test/interview.test.js +127 -0
  66. package/dist/test/ollama_provider.test.d.ts +1 -0
  67. package/dist/test/ollama_provider.test.js +103 -0
  68. package/dist/test/otel.test.d.ts +26 -0
  69. package/dist/test/otel.test.js +512 -0
  70. package/dist/test/paths.test.d.ts +1 -0
  71. package/dist/test/paths.test.js +68 -0
  72. package/dist/test/refine.test.js +64 -1
  73. package/dist/test/repo_chains.test.d.ts +21 -0
  74. package/dist/test/repo_chains.test.js +416 -0
  75. package/dist/test/signoff.test.d.ts +1 -0
  76. package/dist/test/signoff.test.js +329 -0
  77. package/dist/test/ui_server.test.d.ts +7 -1
  78. package/dist/test/ui_server.test.js +1 -0
  79. package/dist/test/watch.test.js +124 -1
  80. package/package.json +5 -5
@@ -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>;