@gr8ful/spf 0.4.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 (76) 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 +147 -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/providers.js +4 -0
  48. package/dist/core/refine.js +11 -3
  49. package/dist/core/session.js +39 -2
  50. package/dist/core/tracer.d.ts +31 -2
  51. package/dist/core/tracer.js +69 -11
  52. package/dist/core/watch.d.ts +11 -0
  53. package/dist/core/watch.js +17 -2
  54. package/dist/test/chains.test.js +8 -3
  55. package/dist/test/data_types.test.js +140 -2
  56. package/dist/test/git_helper.test.d.ts +1 -0
  57. package/dist/test/git_helper.test.js +59 -0
  58. package/dist/test/hermetic_git.d.ts +1 -0
  59. package/dist/test/hermetic_git.js +22 -0
  60. package/dist/test/init_command.test.d.ts +14 -1
  61. package/dist/test/init_command.test.js +54 -1
  62. package/dist/test/interview.test.d.ts +15 -1
  63. package/dist/test/interview.test.js +127 -0
  64. package/dist/test/ollama_provider.test.d.ts +1 -0
  65. package/dist/test/ollama_provider.test.js +103 -0
  66. package/dist/test/otel.test.d.ts +26 -0
  67. package/dist/test/otel.test.js +512 -0
  68. package/dist/test/refine.test.js +64 -1
  69. package/dist/test/repo_chains.test.d.ts +21 -0
  70. package/dist/test/repo_chains.test.js +416 -0
  71. package/dist/test/signoff.test.d.ts +1 -0
  72. package/dist/test/signoff.test.js +329 -0
  73. package/dist/test/ui_server.test.d.ts +7 -1
  74. package/dist/test/ui_server.test.js +1 -0
  75. package/dist/test/watch.test.js +124 -1
  76. package/package.json +5 -5
@@ -12,7 +12,7 @@ always shows the resolved, merged result for the repo you're in.
12
12
  1. The packaged built-in default (`assets/defaults/spf.config.yaml` inside
13
13
  the installed CLI).
14
14
  2. `.spf/spf.config.yaml` in the target repo, if present — merged on top,
15
- field by field (`defaults`/`observability`/`quality`/`watch`/`notifications`
15
+ field by field (`defaults`/`observability`/`quality`/`watch`/`notifications`/`review`
16
16
  merge key-by-key — `notifications.channels` replaces wholesale, same as
17
17
  `quality.checks`; `agents` merges by `name`: a matching name patches that
18
18
  entry, a new name appends).
@@ -72,6 +72,8 @@ agents:
72
72
  | `color` | hex string | Lane color fallback for agents that don't set their own. |
73
73
  | `harness_engineering` | string[] | **Must stay `[]`** — no analogue on any current backend; a non-empty entry fails validate(). |
74
74
  | `tools` | string[] \| null | Roster-wide allowlist. Unset/null = every built-in tool usable. |
75
+ | `writes` | string[] \| null | Roster-wide write allowlist, back-filled onto any agent that doesn't set its own — see `writes` under `agents[]` below for the three-state semantics. |
76
+ | `env_allowlist` | string[] \| null | Roster-wide env allowlist, back-filled the same way as `writes`. Unset/null = every agent gets the full operator environment (see `env_allowlist` under `agents[]`). |
75
77
  | `protected_files` | string[] | Paths no agent may touch unless named in its own `writes`. Default `[".spf/", "spf.config.yaml"]`. |
76
78
  | `data_dir` | path | Runtime home, repo-relative. Default `.spf/data`. |
77
79
 
@@ -81,6 +83,11 @@ agents:
81
83
  |---|---|---|
82
84
  | `db` | path | The trace sqlite db. Default `.spf/data/spf.db`. |
83
85
  | `poll_ms` | int | UI live-poll cadence. Default `500`. |
86
+ | `otel.endpoint` | string | OTLP/HTTP collector endpoint (e.g., `https://your-host/v1/traces`). Omit to disable OTel export. |
87
+ | `otel.headers` | object | Optional HTTP headers (e.g., auth tokens). Each value is a string. |
88
+ | `otel.service_name` | string | Optional service name in exported spans. Default `spf`. |
89
+
90
+ **No ambient env activation**: OTEL export requires explicit `observability.otel` config — the `OTEL_EXPORTER_OTLP_ENDPOINT` shell variable is never consulted. An unrelated shell env variable must not become a data-egress switch.
84
91
 
85
92
  ### `quality`
86
93
 
@@ -116,7 +123,8 @@ Full mechanism: the main README's "`spf watch`" section. Field reference:
116
123
  |---|---|---|
117
124
  | `issue_provider` | `"github"` \| `"jira"` | The tracker `spf watch` polls. Default `github`. |
118
125
  | `code_host` | `"github"` \| `"bitbucket"` | Where PRs open — independent of `issue_provider` (Jira issues against a Bitbucket repo is a real setup). Default `github`. |
119
- | `repo` | string | Required once watch is actually run (not schema-validated — fails loudly at `spf watch` startup instead). `"owner/name"` for `code_host: github`, `"workspace/repo_slug"` for `code_host: bitbucket`. |
126
+ | `repo` | string | The **code host's** repo — required once watch is actually run (not schema-validated — fails loudly at `spf watch` startup instead). `"owner/name"` for `code_host: github`, `"workspace/repo_slug"` for `code_host: bitbucket`. |
127
+ | `issue_repo` | string | Overrides `repo` for the **issue tracker** side, only meaningful (and only needed) for `issue_provider: github` + `code_host: bitbucket` — the one combination where the tracker and the code host are genuinely different repos in different systems. Unset (the default) falls back to `repo`, which is exactly right for `github`+`github` (one repo) and for `issue_provider: jira` (which never reads `repo` at all). |
120
128
  | `label_prefix` | string | State-machine label prefix — polls/writes `<prefix>:ready`, `<prefix>:working`, etc. Default `spf`. |
121
129
  | `chain` | string | Which registered chain runs per claimed `<prefix>:ready` issue. Default `plan-build-test`. |
122
130
  | `base_branch` | string | Branch worktrees fork from and PRs target. Default `main`. |
@@ -146,7 +154,9 @@ additionally gets `<prefix>:refined`, so a human can review and promote it to
146
154
  `<prefix>:ready` when it's worth building — the refine lane never
147
155
  auto-promotes anything.
148
156
 
157
+ ### `notifications`
149
158
 
159
+ Optional outbound push for unattended work — `spf watch`'s daemon lifecycle,
150
160
  and every chain run (`spf <chain>` / `spf run`, including watch's own
151
161
  per-issue runs). Interactive commands (`doctor`, `list`, `sessions`,
152
162
  `phases`, `events`, `init`, `ui`, `migrate`, `eject`, `abort`, `version`)
@@ -187,6 +197,33 @@ run's exit code. See the main README's "Notifications" section for how to
187
197
  get each channel's webhook URL, and `spf init`'s interview, which asks for
188
198
  this section and collects the URL straight into `.env`.
189
199
 
200
+ ### `review`
201
+
202
+ The human sign-off gate in front of `simple-sdlc`'s `commit_build` phase —
203
+ the one place in this codebase an AI reviewer's `approved` flag alone would
204
+ otherwise gate a commit (`build-review` has no commit step, and no other
205
+ chain runs a reviewer at all). See the main README's "Isolation" section for
206
+ the full behavior; this is just the two knobs.
207
+
208
+ | Field | Type | Meaning |
209
+ |---|---|---|
210
+ | `require_human_signoff` | bool | `false` (default, this release): an unattended run (`spf watch`, CI, no TTY) proceeds on the reviewer's verdict alone, with a loud warning printed and traced every time. `true`: an unattended run instead fails the phase closed — rerun attended, or route the work through `spf watch`, whose own human gate is the PR merge. Never affects an attended run's prompt, which is always shown either way. |
211
+ | `signoff_timeout_seconds` | number ≥1 | Bounds the attended confirm prompt. Default `300`. Not required to be an integer — `1.5` parses and becomes a 1500ms timer. Expiry resolves to the prompt's own default — **not accepted**, same as if the answer had been "no" — never an unbounded `stdin` read inside `run.phase()`. |
212
+
213
+ ```yaml
214
+ review:
215
+ require_human_signoff: false # this release's default — see above
216
+ signoff_timeout_seconds: 300
217
+ ```
218
+
219
+ The confirm prompt's own default is always `false` — never `review.approved`,
220
+ so an AI's own verdict can never auto-approve itself by way of an unanswered
221
+ default. A `Signed-off-by:` trailer is appended to the commit only on a
222
+ recorded explicit "yes," built from `git config user.name`/`user.email` at
223
+ the repo (never `ENGINEER_NAME`/`$USER`, which are spoofable and fall back to
224
+ the literal string `"engineer"`) — an unattended run's AI-only commit never
225
+ carries one, because nobody said yes to attest to.
226
+
190
227
  ### `agents[]`
191
228
 
192
229
  | Field | Required | Meaning |
@@ -197,6 +234,7 @@ this section and collects the URL straight into `.env`.
197
234
  | `coding_agent`, `model`, `thinking`, `color`, `harness_engineering` | no | Override the matching `defaults` key. |
198
235
  | `tools` | no | Allowlist. Omitting it means all tools usable. A capability list, not a boundary — see `writes`. |
199
236
  | `writes` | no | What this agent may modify **in the repo**, enforced after every call. `undefined`/`null` = unrestricted (still barred from `protected_files`); `[]` = no repo writes; a list = only those paths (trailing `/` = directory prefix, `*` = one path segment, `**` = crosses segments, anything else = exact path). |
237
+ | `env_allowlist` | no | Opt-in filter on the environment handed to this agent's subprocess/sandbox. `undefined` (default) = the full operator environment, unchanged. A list = only those keys, plus the baseline (`PATH`, `HOME`, `USER`, `LANG`, `TERM`, `TMPDIR`) either backend keeps regardless. |
200
238
 
201
239
  Output types are deliberately absent from config: an entry defines who an
202
240
  agent *is*; the call site defines how it's *used*.
@@ -210,7 +248,15 @@ no public model-registry API) — `agents.ts` checks only the static shape at
210
248
  first real dispatch instead. Provider credentials come from the
211
249
  environment, matching the provider you named (`GEMINI_API_KEY`/
212
250
  `GOOGLE_API_KEY` for `google/...`, `ANTHROPIC_API_KEY` for `anthropic/...`,
213
- etc.) — `spf doctor` checks the common ones are set.
251
+ etc.) — `spf doctor` checks the common ones are set. `ollama/...` is the one
252
+ keyless provider (`PROVIDER_ENV_KEYS.ollama` is `[]`) — instead of a key, set
253
+ `OLLAMA_BASE_URL` (default `http://localhost:11434/v1` if unset) to point at
254
+ your server; the `spf init` interview asks for this instead of a secret when
255
+ you pick `ollama`, and `spf doctor` probes it (informational — a down server
256
+ is reported, never a hard failure). See README.md's "flue + local Ollama"
257
+ section for the full walkthrough and
258
+ [`assets/templates/ts-flue-ollama.spf.config.yaml`](../../templates/ts-flue-ollama.spf.config.yaml)
259
+ for a ready-to-run starting config.
214
260
 
215
261
  **For `coding_agent: claude_code`:** write `model` in Claude Code's own
216
262
  vocabulary — a bare alias (`sonnet`, `opus`) or a full model name — never
@@ -269,4 +315,23 @@ No config section for this — it's an environment-variable recipe, since
269
315
  `claude` subprocess, exactly like every other env var. Set
270
316
  `ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN` (local or cloud Ollama) before
271
317
  running `spf`; see `roster.md`'s "Coding agent backends" section for the
272
- exact commands.
318
+ exact commands. `spf doctor` probes `ANTHROPIC_BASE_URL` (informational — a
319
+ down endpoint or wrong path is reported, never a hard failure) and flags a
320
+ base URL that already ends in `/v1` as a likely double-path mistake, since
321
+ the `claude` CLI appends `/v1/messages` itself.
322
+
323
+ Routing through `SPF_CLAUDE_CMD="ollama launch claude --model granite4.1:8b"`
324
+ instead needs an explicit `--` before `claude`'s own flags (cobra flag
325
+ parsing otherwise consumes them as `ollama launch`'s own) — `agent_cc.ts`
326
+ detects this exact `ollama launch ...` token shape and inserts that
327
+ separator automatically, so you never add it by hand. The `--model` before
328
+ that separator is NOT optional, though: it's `ollama launch`'s own flag, and
329
+ it's mandatory in headless mode (SPF always pipes stdio, so the interactive
330
+ model picker `ollama launch` falls back to without it can never run) — a
331
+ `--model` typed after the `--` belongs to `claude`, not to `ollama launch`,
332
+ and doesn't help. `spf doctor` hard-fails a `SPF_CLAUDE_CMD` missing it. See
333
+ README.md's "Proxy or wrapper launchers" section for the full explanation.
334
+
335
+ For `flue` (the default backend) pointed at Ollama instead of `claude_code`
336
+ — i.e. `model: ollama/<tag>` — see "Model resolution" above and README.md's
337
+ "flue + local Ollama" section.
@@ -31,8 +31,17 @@ Location comes from `observability.db` in `spf.config.yaml`, default
31
31
  | `phase_end` | the block exits; carries the resolved status |
32
32
  | `error` | a throw inside a phase block |
33
33
 
34
- `parent_id` nests spans, so an agent phase expands into its tool-call spans
35
- in the UI.
34
+ `parent_id` is reserved and structurally empty today SPF's phases are flat
35
+ siblings, and nothing writes nesting into it. The UI reconstructs phase/
36
+ tool-call nesting from `phase_id` plus agent-call bracketing instead of
37
+ reading `parent_id`.
38
+
39
+ **Optional OTel export.** When `observability.otel.endpoint` is set in
40
+ `spf.config.yaml`, a lossy, allowlisted projection of phase/agent/tool spans
41
+ (status, model, token/cost counts, gate results — never prompts, envelopes,
42
+ tool arguments, or source code) is also pushed to an OTLP/HTTP collector,
43
+ fire-and-forget. SQLite remains the source of truth regardless; see
44
+ `config.md`'s `observability.otel.*` rows for the field reference.
36
45
 
37
46
  **Spend is itemized per phase.** `agent_end.usage` carries tokens *and*
38
47
  dollars for each component Flue reports (matching pi-ai's field names
@@ -0,0 +1,67 @@
1
+ # .spf/spf.config.yaml — Flue backend (the default), routed at a local
2
+ # Ollama server instead of a hosted provider. `spf init --template
3
+ # ts-flue-ollama` writes this file as-is.
4
+ #
5
+ # model: is ALWAYS provider/model-id for Flue — "ollama/<tag>", where <tag>
6
+ # is whatever `ollama list` shows on the machine actually running the model
7
+ # (agent_flue.ts registers each distinct "ollama/<tag>" it sees with Flue's
8
+ # own provider registry the first time it's dispatched — no separate model
9
+ # catalog to keep in sync here). agents.validate() only checks the STRING
10
+ # SHAPE (provider/id, via agent_flue.ts's resolveModel()) — never that the
11
+ # tag actually exists on the server. A wrong or not-yet-pulled tag only
12
+ # surfaces at the first real dispatch, as an "Unknown model" error from
13
+ # Ollama itself, not at validate() time.
14
+ #
15
+ # The two model ids below (qwen3.8:27b-mlx, granite4.1:8b) are EXAMPLES from
16
+ # one spike machine's own `ollama list` output, not a stable public catalog —
17
+ # replace both with whatever `ollama list` shows on YOUR machine before
18
+ # running spf. Nothing here pins a specific model family; the split is
19
+ # purely "heavier reasoning agent" vs. "lighter/faster agent", same as any
20
+ # other template's planner-vs-builder split.
21
+ #
22
+ # ollama is a KEYLESS provider (providers.ts's PROVIDER_ENV_KEYS.ollama is
23
+ # `[]`) — no API key to export. What IS required:
24
+ # export OLLAMA_BASE_URL=http://localhost:11434/v1 # default if unset
25
+ # `spf doctor` probes OLLAMA_BASE_URL/models (informational, never a hard
26
+ # failure) so a server that isn't running shows up before your first real
27
+ # run does, not during it.
28
+ #
29
+ # Context-window occupancy reporting is disabled for every ollama/* model:
30
+ # agent_flue.ts registers these models with contextWindow: 0 (spike-verified
31
+ # safe — the same convention agent_flue.ts already uses for
32
+ # context_window: 0 elsewhere), which turns off threshold-based compaction
33
+ # rather than reporting a fabricated number Ollama's OpenAI-compatible API
34
+ # doesn't actually provide per-model.
35
+ quality:
36
+ checks:
37
+ - { name: typecheck, operation: typecheck, argv: ["npm", "run", "typecheck"], timeout_seconds: 60 }
38
+ - { name: lint, operation: lint, argv: ["npm", "run", "lint"], timeout_seconds: 60 }
39
+ - { name: build, operation: build, argv: ["npm", "run", "build"], timeout_seconds: 300 }
40
+ - { name: test, operation: build, argv: ["npm", "test"], timeout_seconds: 300 }
41
+ suites:
42
+ test: [test]
43
+ all: [typecheck, lint, build, test]
44
+
45
+ defaults:
46
+ coding_agent: flue
47
+ # EXAMPLE tag — replace with a heavier model from your own `ollama list`.
48
+ model: ollama/qwen3.8:27b-mlx
49
+
50
+ # Overridden here (unlike the OpenRouter template's rationale, which applies
51
+ # equally): the packaged roster's planner/reviewer/documenter pin their own
52
+ # Flue-style provider/model-id strings (fireworks/openai models), unrelated
53
+ # to a local Ollama server — left alone, those three would still resolve and
54
+ # run fine, just against a hosted provider's key this template never asks
55
+ # for. Overridden so every agent actually demonstrates local-only routing.
56
+ agents:
57
+ - name: planner
58
+ model: ollama/qwen3.8:27b-mlx
59
+ - name: reviewer
60
+ model: ollama/qwen3.8:27b-mlx
61
+ - name: documenter
62
+ # EXAMPLE tag — replace with a lighter/faster model from your own
63
+ # `ollama list`. Tool-calling (the injected sf_report contract every
64
+ # agent's structured output rides on) was spike-verified to work down to
65
+ # a 3B model — a lighter tag here is a real, not merely theoretical,
66
+ # option.
67
+ model: ollama/granite4.1:8b
@@ -61,6 +61,11 @@ agents:
61
61
  # Needs a GITHUB_TOKEN env var (classic PAT, repo scope) for the defaults below.
62
62
  # watch:
63
63
  # repo: owner/name
64
+ # # issue_repo: owner/name # ONLY for issue_provider: github + code_host: bitbucket,
65
+ # # # where the issue tracker and code host are genuinely
66
+ # # # different repos — repo above is then the BITBUCKET
67
+ # # # repo, and issue_repo is the GitHub one. Unused/unset
68
+ # # # for every other combination.
64
69
  # label_prefix: spf
65
70
  # chain: plan-build-test
66
71
  # base_branch: main
@@ -27,4 +27,34 @@ export interface ChainContext {
27
27
  * ignores this field.
28
28
  */
29
29
  issue_id?: string | null;
30
+ /**
31
+ * True when no human is at the keyboard for this run — `spf watch`'s
32
+ * daemon lane, a cron/CI invocation — false for an interactive
33
+ * `spf <chain> "..."`.
34
+ *
35
+ * REQUIRED, deliberately: it is not a flag a call site may forget. Every
36
+ * ChainContext construction site has to state which lane it is, because
37
+ * "unattended" is what makes several safety rules non-negotiable rather
38
+ * than merely advisable — most concretely, a repo-local chain
39
+ * (`.spf/chains/*.yaml`) may only ADD gates, never drop a built-in one
40
+ * (see `steps.ts`'s GATE_ALLOWLIST comment): there is nobody watching the
41
+ * console to notice that `diffMatchesClaims` was quietly turned off. A
42
+ * boolean that defaults to `false` would make the dangerous lane the one
43
+ * you get by accident.
44
+ */
45
+ unattended: boolean;
46
+ /**
47
+ * Absolute path of the YAML file this chain was loaded from, when it is a
48
+ * repo-local chain (`.spf/chains/<name>.yaml` — see
49
+ * `chains/repo_chains.ts`). `undefined` for a built-in chain, which has no
50
+ * file: it IS spf's own code.
51
+ *
52
+ * `steps.startRun()` writes this into the trace as a single
53
+ * `chain_source` log event, so a session in the UI or in `spf trace` can
54
+ * always answer "whose chain definition ran this?" — the answer stops
55
+ * being obvious the moment a target repo can ship its own chains, and the
56
+ * trace is the only durable record (the YAML file itself may have been
57
+ * edited by the time anyone reads the run back).
58
+ */
59
+ chain_source?: string;
30
60
  }
@@ -1,14 +1,34 @@
1
1
  /**
2
- * The chain registry — the one place a CLI-facing short name maps to a
3
- * chain module. `spf list` reads this; `spf <name>`/`spf run <name>` dispatch
4
- * through it. Replaces guessing a module filename from the CLI argument.
2
+ * The chain registry — the one place a CLI-facing short name resolves to a
3
+ * chain. `spf list` reads it; `spf <name>`/`spf run <name>` dispatch through
4
+ * it. Replaces guessing a module filename from the CLI argument.
5
5
  *
6
- * Every chain but `simple-sdlc` is a flat `steps` list composed from
7
- * `./steps.ts`'s primitives — no module of its own, and no hand-written
8
- * `phases`/`requiredAgents`/`requiredSuites` to keep in sync with what
9
- * actually runs; those are derived from the step list itself.
10
- * `simple-sdlc`'s three-commit, pinned-baseline shape is still imperative
11
- * see `./simple_sdlc.ts` via the `run` escape hatch below.
6
+ * TWO TIERS, and the distinction is load-bearing:
7
+ *
8
+ * 1. BUILT-INS — `CHAINS`, below. Compiled in, always present, the same in
9
+ * every repo spf is ever run against. Every one but `simple-sdlc` is a
10
+ * flat `steps` list composed from `./steps.ts`'s primitives no module
11
+ * of its own, and no hand-written `phases`/`requiredAgents`/
12
+ * `requiredSuites` to keep in sync with what actually runs; those are
13
+ * derived from the step list itself. `simple-sdlc`'s three-commit,
14
+ * pinned-baseline shape is still imperative — see `./simple_sdlc.ts` —
15
+ * via the `run` escape hatch below.
16
+ * 2. REPO-LOCAL CHAINS — registered at CLI startup from
17
+ * `<repo>/.spf/chains/*.yaml` by `registerRepoChains()`, having been
18
+ * read by `./repo_chains.ts`. These are DATA: a yaml file names built-in
19
+ * step factories and passes them params. It cannot introduce code, and
20
+ * it runs on the same `steps.runSteps()` driver as a built-in, because
21
+ * "agent proposes, code disposes" only means anything if the code that
22
+ * disposes is SPF's code — not the target repo's.
23
+ *
24
+ * `CHAINS` stays a built-in-only const: nothing ever pushes into it, and
25
+ * `src/test/chains.test.ts` pins its exact name set. Repo chains live in a
26
+ * separate module-level list that `registerRepoChains()` REPLACES (never
27
+ * appends to), so a second registration — a long-lived process, `spf watch`
28
+ * re-reading after an edit, a test — cannot double-register a name. Read
29
+ * both tiers through `allChains()`; built-ins always come first, and
30
+ * `findChain()` resolves them first, so a built-in name can never be
31
+ * shadowed by a file on disk.
12
32
  */
13
33
  import type { ChainContext } from "./context.ts";
14
34
  import * as steps from "./steps.ts";
@@ -19,15 +39,79 @@ export interface ChainDefinition {
19
39
  phases: string;
20
40
  /** Static for most chains; prompt's depends on --agent, so it's a function there. */
21
41
  requiredAgents: string[] | ((options: Record<string, string>) => string[]);
22
- requiredSuites: string[];
42
+ /** Static unless a step's suite can be overridden by --suite (qualityCheck/fixLoop), in which case it's a function — see steps.deriveRequiredSuites. */
43
+ requiredSuites: string[] | ((options: Record<string, string>) => string[]);
23
44
  /** The declarative path — a flat step list, run by runChain() via steps.runSteps(). */
24
45
  steps?: steps.Step[];
25
46
  /** The imperative escape hatch for a chain too shaped by its own logic to be a flat list (simple-sdlc). */
26
47
  run?: (ctx: ChainContext, options?: Record<string, string>) => Promise<number>;
48
+ /**
49
+ * Absolute path of the `.spf/chains/*.yaml` this chain was loaded from —
50
+ * set only for a repo-local chain, `undefined` for a built-in (a built-in
51
+ * has no source file: it IS spf). The CLI copies it into
52
+ * `ChainContext.chain_source` so the run's trace records whose definition
53
+ * ran, and `spf list`/`spf doctor` use its presence to mark which chains
54
+ * came from the repo.
55
+ */
56
+ source?: string;
27
57
  }
58
+ /**
59
+ * Build a step-based ChainDefinition, deriving phases/requiredAgents/
60
+ * requiredSuites from its steps.
61
+ *
62
+ * Exported so `./repo_chains.ts` builds a yaml-defined chain through the
63
+ * EXACT same function as a built-in. That is deliberate and should not be
64
+ * "simplified" into a second, parallel constructor: a repo chain must be
65
+ * indistinguishable downstream — same derivation of phases/agents/suites,
66
+ * same `steps` array, same `runSteps` driver, one run path — or the
67
+ * guarantees the built-ins are tested for stop applying to it.
68
+ */
69
+ export declare function stepChain(name: string, describe: string, list: steps.Step[]): ChainDefinition;
28
70
  export declare const CHAINS: ChainDefinition[];
71
+ /**
72
+ * Every built-in name, reserved. `repo_chains.ts` checks against this to
73
+ * refuse a repo chain that reuses one — a collision is reported as a
74
+ * problem, never resolved by shadowing either way round.
75
+ */
76
+ export declare const BUILTIN_CHAIN_NAMES: ReadonlySet<string>;
77
+ /**
78
+ * Install the repo-local chains (and the problems found reading them) for
79
+ * this process. Call once, early, from the CLI entry point.
80
+ *
81
+ * REPLACES rather than appends, so it is idempotent: calling it twice with
82
+ * the same input leaves exactly one copy of each chain. That matters because
83
+ * a long-running `spf watch` may re-read `.spf/chains/` after an edit, and
84
+ * because a partial second registration (say, one file fixed) must not leave
85
+ * the previous file's stale definition behind.
86
+ *
87
+ * The `problems` list is stored, not thrown and not printed: loading is
88
+ * never allowed to fail a command that has nothing to do with the broken
89
+ * file (`spf trace`, `spf sessions`, `spf --version` must all still work in
90
+ * a repo with one malformed yaml). `spf list` and `spf doctor` surface them;
91
+ * dispatch surfaces them when the name someone typed is missing.
92
+ */
93
+ export declare function registerRepoChains(chains: ChainDefinition[], problems: {
94
+ file: string;
95
+ message: string;
96
+ }[]): void;
97
+ /** Built-ins first, then repo-local chains — the order `spf list` prints and `findChain` resolves in. */
98
+ export declare function allChains(): ChainDefinition[];
99
+ /** What went wrong reading `.spf/chains/*.yaml`, for `spf list`/`spf doctor` to report. */
100
+ export declare function repoChainProblems(): {
101
+ file: string;
102
+ message: string;
103
+ }[];
104
+ /**
105
+ * Resolve a CLI-facing name. Built-ins are consulted FIRST and win
106
+ * unconditionally — a repo chain that reuses a built-in name is rejected at
107
+ * load time (see `repo_chains.ts`), so this ordering is a second line of
108
+ * defence rather than the policy itself, and it is what makes `spf build` in
109
+ * any repo mean what the docs say it means.
110
+ */
29
111
  export declare function findChain(name: string): ChainDefinition | undefined;
30
112
  export declare function resolveRequiredAgents(chain: ChainDefinition, options: Record<string, string>): string[];
113
+ /** Same idea as resolveRequiredAgents(), for the suite(s) --suite can override before validate() ever runs. */
114
+ export declare function resolveRequiredSuites(chain: ChainDefinition, options: Record<string, string>): string[];
31
115
  /**
32
116
  * Run a chain, whichever path it defines: `run` (the imperative escape
33
117
  * hatch) if it has one, otherwise `steps` through the shared driver. Both
@@ -1,7 +1,17 @@
1
1
  import * as steps from "./steps.js";
2
2
  import * as simpleSdlc from "./simple_sdlc.js";
3
- /** Build a step-based ChainDefinition, deriving phases/requiredAgents/requiredSuites from its steps. */
4
- function stepChain(name, describe, list) {
3
+ /**
4
+ * Build a step-based ChainDefinition, deriving phases/requiredAgents/
5
+ * requiredSuites from its steps.
6
+ *
7
+ * Exported so `./repo_chains.ts` builds a yaml-defined chain through the
8
+ * EXACT same function as a built-in. That is deliberate and should not be
9
+ * "simplified" into a second, parallel constructor: a repo chain must be
10
+ * indistinguishable downstream — same derivation of phases/agents/suites,
11
+ * same `steps` array, same `runSteps` driver, one run path — or the
12
+ * guarantees the built-ins are tested for stop applying to it.
13
+ */
14
+ export function stepChain(name, describe, list) {
5
15
  return {
6
16
  name,
7
17
  describe,
@@ -63,19 +73,74 @@ export const CHAINS = [
63
73
  name: "simple-sdlc",
64
74
  describe: "the work is real and its shape is not obvious — plan, build, test, review, document; 3 commits",
65
75
  phases: "engineer(request) -> planner -> git(commit_plan) -> builder -> code(test) [-> builder(fix) -> code(test) ...] " +
66
- "-> reviewer [-> builder(revise) -> reviewer ...] -> code(retest, if revised) -> git(commit_build) " +
76
+ "-> reviewer [-> builder(revise) -> reviewer ...] -> code(retest, if revised) -> engineer(signoff) -> git(commit_build) " +
67
77
  "-> code(changes) -> documenter -> git(commit_docs)",
68
78
  requiredAgents: simpleSdlc.REQUIRED_AGENTS,
69
79
  requiredSuites: simpleSdlc.REQUIRED_SUITES,
70
80
  run: simpleSdlc.main,
71
81
  },
72
82
  ];
83
+ /**
84
+ * Every built-in name, reserved. `repo_chains.ts` checks against this to
85
+ * refuse a repo chain that reuses one — a collision is reported as a
86
+ * problem, never resolved by shadowing either way round.
87
+ */
88
+ export const BUILTIN_CHAIN_NAMES = new Set(CHAINS.map((c) => c.name));
89
+ // ── tier 2: repo-local chains, registered at startup ────────────────────
90
+ /**
91
+ * Module-level, not a parameter threaded through every call site: `spf list`,
92
+ * `spf <chain>`, `spf watch`, `spf doctor` and the interview all resolve
93
+ * chains by name from unrelated places, and passing a registry object to
94
+ * each would mean four more chances for one of them to be looking at the
95
+ * built-ins only. One process, one repo, one registration.
96
+ */
97
+ let REPO_CHAINS = [];
98
+ let REPO_CHAIN_PROBLEMS = [];
99
+ /**
100
+ * Install the repo-local chains (and the problems found reading them) for
101
+ * this process. Call once, early, from the CLI entry point.
102
+ *
103
+ * REPLACES rather than appends, so it is idempotent: calling it twice with
104
+ * the same input leaves exactly one copy of each chain. That matters because
105
+ * a long-running `spf watch` may re-read `.spf/chains/` after an edit, and
106
+ * because a partial second registration (say, one file fixed) must not leave
107
+ * the previous file's stale definition behind.
108
+ *
109
+ * The `problems` list is stored, not thrown and not printed: loading is
110
+ * never allowed to fail a command that has nothing to do with the broken
111
+ * file (`spf trace`, `spf sessions`, `spf --version` must all still work in
112
+ * a repo with one malformed yaml). `spf list` and `spf doctor` surface them;
113
+ * dispatch surfaces them when the name someone typed is missing.
114
+ */
115
+ export function registerRepoChains(chains, problems) {
116
+ REPO_CHAINS = [...chains];
117
+ REPO_CHAIN_PROBLEMS = [...problems];
118
+ }
119
+ /** Built-ins first, then repo-local chains — the order `spf list` prints and `findChain` resolves in. */
120
+ export function allChains() {
121
+ return [...CHAINS, ...REPO_CHAINS];
122
+ }
123
+ /** What went wrong reading `.spf/chains/*.yaml`, for `spf list`/`spf doctor` to report. */
124
+ export function repoChainProblems() {
125
+ return [...REPO_CHAIN_PROBLEMS];
126
+ }
127
+ /**
128
+ * Resolve a CLI-facing name. Built-ins are consulted FIRST and win
129
+ * unconditionally — a repo chain that reuses a built-in name is rejected at
130
+ * load time (see `repo_chains.ts`), so this ordering is a second line of
131
+ * defence rather than the policy itself, and it is what makes `spf build` in
132
+ * any repo mean what the docs say it means.
133
+ */
73
134
  export function findChain(name) {
74
- return CHAINS.find((c) => c.name === name);
135
+ return CHAINS.find((c) => c.name === name) ?? REPO_CHAINS.find((c) => c.name === name);
75
136
  }
76
137
  export function resolveRequiredAgents(chain, options) {
77
138
  return typeof chain.requiredAgents === "function" ? chain.requiredAgents(options) : chain.requiredAgents;
78
139
  }
140
+ /** Same idea as resolveRequiredAgents(), for the suite(s) --suite can override before validate() ever runs. */
141
+ export function resolveRequiredSuites(chain, options) {
142
+ return typeof chain.requiredSuites === "function" ? chain.requiredSuites(options) : chain.requiredSuites;
143
+ }
79
144
  /**
80
145
  * Run a chain, whichever path it defines: `run` (the imperative escape
81
146
  * hatch) if it has one, otherwise `steps` through the shared driver. Both
@@ -86,5 +151,5 @@ export function resolveRequiredAgents(chain, options) {
86
151
  export async function runChain(chain, ctx, options = {}) {
87
152
  if (chain.run)
88
153
  return chain.run(ctx, options);
89
- return steps.runSteps(ctx, resolveRequiredAgents(chain, options), chain.requiredSuites, chain.steps, options);
154
+ return steps.runSteps(ctx, resolveRequiredAgents(chain, options), resolveRequiredSuites(chain, options), chain.steps, options);
90
155
  }
@@ -0,0 +1,139 @@
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 * as v from "valibot";
70
+ import type * as paths from "../core/paths.ts";
71
+ import * as steps from "./steps.ts";
72
+ import { type ChainDefinition } from "./index.ts";
73
+ /** What went wrong in one file, and which file. */
74
+ export interface RepoChainProblem {
75
+ /** Absolute path — the thing the operator has to open and edit. */
76
+ file: string;
77
+ message: string;
78
+ }
79
+ /**
80
+ * A step's yaml surface: the schema its params must satisfy, and how to
81
+ * build the actual `Step` from them.
82
+ *
83
+ * `params` is redundant with the schema's own entries, and kept anyway: it
84
+ * lets an unknown param be reported as `unknown param "ownr" — allowed:
85
+ * owner, description, ...`, which is what an author needs, instead of
86
+ * valibot's structural complaint about an unexpected key.
87
+ */
88
+ export interface StepSpec {
89
+ schema: v.GenericSchema;
90
+ build: (params: any) => steps.Step;
91
+ params: readonly string[];
92
+ }
93
+ /**
94
+ * One entry per exported flat-step factory in `steps.ts`. The key is what a
95
+ * yaml `step:` names.
96
+ *
97
+ * `qualityCheck`/`fixLoop` default their `suite` HERE as well as in the
98
+ * factory signature: the factory's default lives on the whole `opts` object
99
+ * (`opts = { suite: "test" }`), which a yaml-supplied `{}` would satisfy
100
+ * without ever supplying `suite`. Defaulting in the schema is what makes
101
+ * `- step: fixLoop` with no params mean the same thing as `fixLoop()`.
102
+ */
103
+ export declare const STEP_SPECS: Record<string, StepSpec>;
104
+ /** Every step name a yaml file may use — for schema validation and for error messages. */
105
+ export declare const STEP_NAMES: readonly string[];
106
+ /**
107
+ * The whole file. One document, one chain — deliberately not a list: the
108
+ * filename then documents which chain lives where, and a problem can point
109
+ * at a file the operator can open, which is the only handle they have.
110
+ *
111
+ * `steps` is validated loosely here (each entry only has to be an object
112
+ * naming a `step`); the per-step params are validated against that step's
113
+ * own schema afterwards, because which schema applies depends on the value
114
+ * of `step`.
115
+ */
116
+ export declare const RepoChainFileSchema: v.StrictObjectSchema<{
117
+ readonly name: v.SchemaWithPipe<readonly [v.StringSchema<"name is required — it is what `spf <name>` types">, v.RegexAction<string, "name must be lowercase letters/digits/._- and start with a letter or digit (it is typed on the command line)">]>;
118
+ readonly describe: v.SchemaWithPipe<readonly [v.StringSchema<"describe is required — it is the line `spf list` prints">, v.MinLengthAction<string, 1, "describe must not be empty">]>;
119
+ readonly steps: v.SchemaWithPipe<readonly [v.ArraySchema<v.LooseObjectSchema<{
120
+ readonly step: v.StringSchema<"each step entry needs a `step:` naming a step factory">;
121
+ }, undefined>, "steps must be a list">, v.MinLengthAction<({
122
+ step: string;
123
+ } & {
124
+ [key: string]: unknown;
125
+ })[], 1, "steps must name at least one step — a chain with no steps would run nothing and report success">]>;
126
+ }, undefined>;
127
+ export type RepoChainFile = v.InferOutput<typeof RepoChainFileSchema>;
128
+ /**
129
+ * Read every chain in `<anchor.spf_dir>/chains/`.
130
+ *
131
+ * Never throws (see the module header). Returns the chains that loaded and a
132
+ * problem per file that didn't; a file contributes at most one chain and, on
133
+ * failure, exactly one problem — the first thing wrong with it, because the
134
+ * second is usually a consequence of the first.
135
+ */
136
+ export declare function loadRepoChains(anchor: paths.RepoAnchor): {
137
+ chains: ChainDefinition[];
138
+ problems: RepoChainProblem[];
139
+ };