@gr8ful/spf 0.1.7 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -2
- package/assets/skill/cookbooks/authoring_chains.md +96 -84
- package/assets/skill/cookbooks/roster.md +3 -1
- package/assets/skill/references/config.md +52 -5
- package/assets/templates/ts.spf.config.yaml +12 -0
- package/dist/chains/context.d.ts +2 -0
- package/dist/chains/index.d.ts +21 -2
- package/dist/chains/index.js +73 -104
- package/dist/chains/{adw_simple_sdlc.d.ts → simple_sdlc.d.ts} +7 -1
- package/dist/chains/{adw_simple_sdlc.js → simple_sdlc.js} +19 -30
- package/dist/chains/steps.d.ts +117 -0
- package/dist/chains/steps.js +299 -0
- package/dist/cli/ask.d.ts +27 -0
- package/dist/cli/ask.js +125 -0
- package/dist/cli/commands/doctor.js +14 -24
- package/dist/cli/commands/init.d.ts +1 -1
- package/dist/cli/commands/init.js +96 -9
- package/dist/cli/commands/run.d.ts +1 -1
- package/dist/cli/commands/run.js +3 -1
- package/dist/cli/commands/watch.js +20 -3
- package/dist/cli/env_file.d.ts +18 -0
- package/dist/cli/env_file.js +99 -0
- package/dist/cli/index.js +6 -2
- package/dist/cli/interview.d.ts +26 -0
- package/dist/cli/interview.js +417 -0
- package/dist/core/agents.js +4 -1
- package/dist/core/console.d.ts +13 -1
- package/dist/core/console.js +51 -1
- package/dist/core/data_types.d.ts +55 -0
- package/dist/core/data_types.js +30 -0
- package/dist/core/notify/channel.d.ts +32 -0
- package/dist/core/notify/channel.js +14 -0
- package/dist/core/notify/notifier.d.ts +42 -0
- package/dist/core/notify/notifier.js +100 -0
- package/dist/core/notify/slack_channel.d.ts +13 -0
- package/dist/core/notify/slack_channel.js +30 -0
- package/dist/core/notify/teams_channel.d.ts +17 -0
- package/dist/core/notify/teams_channel.js +38 -0
- package/dist/core/notify/webhook_channel.d.ts +13 -0
- package/dist/core/notify/webhook_channel.js +19 -0
- package/dist/core/prompts.d.ts +2 -0
- package/dist/core/prompts.js +2 -0
- package/dist/core/providers.d.ts +12 -0
- package/dist/core/providers.js +24 -0
- package/dist/core/quality.d.ts +9 -0
- package/dist/core/quality.js +10 -0
- package/dist/core/runner.d.ts +7 -0
- package/dist/core/runner.js +4 -1
- package/dist/core/session.d.ts +6 -1
- package/dist/core/session.js +10 -3
- package/dist/core/tracer.js +1 -1
- package/dist/core/utils.d.ts +6 -2
- package/dist/core/utils.js +11 -2
- package/dist/core/watch.d.ts +10 -0
- package/dist/core/watch.js +67 -4
- package/dist/test/chains.test.d.ts +12 -0
- package/dist/test/chains.test.js +86 -0
- package/dist/test/data_types.test.js +34 -1
- package/dist/test/env_file.test.d.ts +1 -0
- package/dist/test/env_file.test.js +74 -0
- package/dist/test/fake_asker.d.ts +23 -0
- package/dist/test/fake_asker.js +30 -0
- package/dist/test/init_command.test.d.ts +1 -0
- package/dist/test/init_command.test.js +66 -0
- package/dist/test/interview.test.d.ts +1 -0
- package/dist/test/interview.test.js +297 -0
- package/dist/test/notify.test.d.ts +1 -0
- package/dist/test/notify.test.js +174 -0
- package/dist/test/ui_server.test.js +1 -1
- package/dist/test/watch.test.js +114 -1
- package/dist/ui/shared/types.d.ts +1 -1
- package/package.json +5 -2
- package/dist/chains/adw_build.d.ts +0 -12
- package/dist/chains/adw_build.js +0 -27
- package/dist/chains/adw_build_review.d.ts +0 -21
- package/dist/chains/adw_build_review.js +0 -55
- package/dist/chains/adw_build_test.d.ts +0 -21
- package/dist/chains/adw_build_test.js +0 -67
- package/dist/chains/adw_document.d.ts +0 -23
- package/dist/chains/adw_document.js +0 -59
- package/dist/chains/adw_plan.d.ts +0 -12
- package/dist/chains/adw_plan.js +0 -27
- package/dist/chains/adw_plan_build.d.ts +0 -12
- package/dist/chains/adw_plan_build.js +0 -30
- package/dist/chains/adw_plan_build_test.d.ts +0 -16
- package/dist/chains/adw_plan_build_test.js +0 -65
- package/dist/chains/adw_plan_build_test_quality.d.ts +0 -18
- package/dist/chains/adw_plan_build_test_quality.js +0 -66
- package/dist/chains/adw_prompt.d.ts +0 -12
- package/dist/chains/adw_prompt.js +0 -25
- package/dist/chains/adw_quality.d.ts +0 -12
- package/dist/chains/adw_quality.js +0 -32
- package/dist/chains/adw_scout.d.ts +0 -12
- package/dist/chains/adw_scout.js +0 -27
package/README.md
CHANGED
|
@@ -28,12 +28,15 @@ spf scout "describe this repo" # a real, read-only run, no setup required
|
|
|
28
28
|
### Customizing a repo
|
|
29
29
|
|
|
30
30
|
```bash
|
|
31
|
-
spf init #
|
|
31
|
+
spf init # on a TTY: an interview — agent, model, quality checks, spf watch, secrets
|
|
32
|
+
spf init --yes # skip the interview — seed the commented, all-defaults starter config instead
|
|
32
33
|
spf init --template ts-cc # or start from a packaged, ready-to-run template instead
|
|
33
34
|
spf list # every chain this install knows, its phases, what it needs
|
|
34
35
|
```
|
|
35
36
|
|
|
36
|
-
`spf init`
|
|
37
|
+
On a real terminal, `spf init` asks a short interview — which coding agent (`claude_code` or `flue`) and model (optionally customized per agent instead of one model for the whole roster), which quality checks to gate on, whether to turn on `spf watch` and against which tracker/code host, and whether to push notifications to Slack/Teams/a webhook — and writes `.spf/spf.config.yaml` with only what you answered differently from the packaged defaults, plus whatever secrets those answers imply appended to `.env` (already gitignored, and already auto-loaded by every command) and their key names mirrored into a committable `.env.example`. Re-running it later shows any existing `.env` value masked and keeps it on an empty answer, so rotating one secret doesn't mean re-answering everything. Piped input, `--yes`, or `--template <name>` all skip the interview and fall back to the original non-interactive behavior — a scripted `spf init` never blocks on stdin.
|
|
38
|
+
|
|
39
|
+
Without an interview, `spf init` writes the same small starter `.spf/spf.config.yaml`, commented, that merges on top of the packaged built-ins field by field. `--template <name>` writes a real, filled-in config instead of the commented-out starter — every packaged template's name prints after `spf init` runs, and the same files live in [`assets/templates/`](assets/templates/) to browse directly. Nothing here needs to exist for `spf` to run; it's how you make one repo's roster diverge from the defaults.
|
|
37
40
|
|
|
38
41
|
### Local development
|
|
39
42
|
|
|
@@ -242,6 +245,8 @@ Polls an issue tracker for issues labeled `<prefix>:ready`, runs a configured ch
|
|
|
242
245
|
|
|
243
246
|
The tracker (`issue_provider`) and the code host (`code_host`) are independent config choices, not one bundled "provider" — a tracker and a host are independent choices in practice (Jira issues against a Bitbucket repo is a real setup). Supported today: `issue_provider: github | jira`, `code_host: github | bitbucket` — any combination works, including Jira+GitHub or GitHub-issues+Bitbucket.
|
|
244
247
|
|
|
248
|
+
The easiest way into any of this is `spf init`'s interview: it asks whether to enable `spf watch`, which tracker and code host, and collects exactly the env vars that combination needs (below) straight into `.env` — no hand-editing YAML or hunting down which credential pair a given combination wants.
|
|
249
|
+
|
|
245
250
|
```yaml
|
|
246
251
|
# .spf/spf.config.yaml — GitHub issues + GitHub PRs (the default)
|
|
247
252
|
watch:
|
|
@@ -317,6 +322,56 @@ export BITBUCKET_API_TOKEN=... # same Atlassian API token mechanism as Jira ab
|
|
|
317
322
|
|
|
318
323
|
**Bitbucket Cloud app passwords are being fully removed** (brownout window closing July 28, 2026) — this project only supports the replacement, API tokens, which need the account's email alongside the token (username alone no longer works).
|
|
319
324
|
|
|
325
|
+
## Notifications
|
|
326
|
+
|
|
327
|
+
Optional, off by default: push a curated set of milestones to Slack,
|
|
328
|
+
Microsoft Teams, or a generic webhook. It's scoped to **unattended work**:
|
|
329
|
+
`spf watch`'s daemon lifecycle, and every chain run (`spf <chain>` / `spf
|
|
330
|
+
run`, including watch's own per-issue runs). Interactive commands — `doctor`,
|
|
331
|
+
`list`, `sessions`, `phases`, `events`, `init`, `ui`, `migrate`, `eject`,
|
|
332
|
+
`abort`, `version` — never notify; you're already looking at the terminal
|
|
333
|
+
for those.
|
|
334
|
+
|
|
335
|
+
```yaml
|
|
336
|
+
# .spf/spf.config.yaml
|
|
337
|
+
notifications:
|
|
338
|
+
events: errors # off (default) | errors | all
|
|
339
|
+
channels:
|
|
340
|
+
- kind: slack # slack | teams | webhook
|
|
341
|
+
webhook_url_env: SLACK_WEBHOOK_URL # optional; this is the default for slack
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
`events` is the whole filter: `errors` sends only failed runs/phases, blocked
|
|
345
|
+
issues, and watch errors; `all` adds every milestone — run started/finished,
|
|
346
|
+
issue claimed, PR opened, issue done. A channel's own `events` overrides the
|
|
347
|
+
top-level scope for just that channel. `spf doctor` reports whether each
|
|
348
|
+
configured channel's env var is set.
|
|
349
|
+
|
|
350
|
+
The webhook URL is a secret and lives only in `.env` — `webhook_url_env`
|
|
351
|
+
names the key, never the URL itself, matching `GITHUB_TOKEN`/
|
|
352
|
+
`JIRA_API_TOKEN`. Defaults per kind: `SLACK_WEBHOOK_URL`, `TEAMS_WEBHOOK_URL`,
|
|
353
|
+
`SPF_WEBHOOK_URL`. `spf init`'s interview asks for this section and collects
|
|
354
|
+
the URL straight into `.env`, same as every other credential.
|
|
355
|
+
|
|
356
|
+
Getting each channel's URL:
|
|
357
|
+
|
|
358
|
+
- **Slack** — [Sending messages using Incoming Webhooks](https://docs.slack.dev/messaging/sending-messages-using-incoming-webhooks): create a Slack app, enable Incoming Webhooks, "Add New Webhook to Workspace".
|
|
359
|
+
- **Microsoft Teams** — [Send messages in Teams using incoming webhooks](https://support.microsoft.com/en-us/office/post-a-workflow-when-a-webhook-request-is-received-in-microsoft-teams-8ae491c7-0394-4861-ba59-055e33f75498): in the target channel, add a Workflows webhook template (search for one along the lines of "Post to a channel when a webhook request is received" / "Send webhook alerts to a channel" — Microsoft's own naming here has shifted between revisions) and copy the generated URL. This is the *only* supported path now — the old Office 365 connector webhook has been retired by Microsoft.
|
|
360
|
+
- **webhook** — any endpoint that accepts a JSON POST of the event: Discord,
|
|
361
|
+
n8n, Zapier, a homegrown receiver.
|
|
362
|
+
|
|
363
|
+
Delivery never blocks or fails a run: an unconfigured/misconfigured channel
|
|
364
|
+
is skipped with one warning, and a failed POST logs one line and is
|
|
365
|
+
swallowed — never changes a run's exit code. One thing worth knowing under
|
|
366
|
+
`events: all`: `spf watch` runs its per-issue chains in-process, so a failed
|
|
367
|
+
issue produces **two** notifications for the same failure — `run_failed`
|
|
368
|
+
(keyed to the chain's own `adw_id`, e.g. `issue-142`) from the chain-run
|
|
369
|
+
side, and `issue_blocked` (keyed to the issue) from watch itself. Both are
|
|
370
|
+
genuinely informative, just worth expecting.
|
|
371
|
+
|
|
372
|
+
Full field reference: `spf install-skill`'s installed skill
|
|
373
|
+
(`references/config.md`).
|
|
374
|
+
|
|
320
375
|
## What's in this repo
|
|
321
376
|
|
|
322
377
|
```
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
# Authoring Chains
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
primitives a chain needs (an output type, a gate) are one skill with
|
|
5
|
-
doors. All three live in `src/` inside the SPF package itself — there
|
|
6
|
-
per-repo copy to edit. If you need to change engine behavior for a
|
|
7
|
-
target repo without forking the package, that's `spf eject` (prints
|
|
8
|
-
to the installed package's `src/` for you to copy and load from your
|
|
9
|
-
`.spf/` — engine-level changes are the one thing `.spf/` config can't
|
|
3
|
+
Composing a new chain, extending an existing one, and adding the engine
|
|
4
|
+
primitives a chain needs (an output type, a gate, a step) are one skill with
|
|
5
|
+
three doors. All three live in `src/` inside the SPF package itself — there
|
|
6
|
+
is no per-repo copy to edit. If you need to change engine behavior for a
|
|
7
|
+
specific target repo without forking the package, that's `spf eject` (prints
|
|
8
|
+
the path to the installed package's `src/` for you to copy and load from your
|
|
9
|
+
own `.spf/` — engine-level changes are the one thing `.spf/` config can't
|
|
10
|
+
express).
|
|
10
11
|
|
|
11
12
|
## Step 1 — design the chain before writing code
|
|
12
13
|
|
|
13
14
|
Lay out the phases as a table: name, kind, owner, output type (if `agent`),
|
|
14
|
-
gates. This is the same table `spf list` will end up describing
|
|
15
|
+
gates. This is the same table `spf list` will end up describing (derived from
|
|
16
|
+
the step list itself — see Step 2 — so it can't drift from what actually runs).
|
|
15
17
|
|
|
16
18
|
| Phase | Kind | Owner | Output type | Gates |
|
|
17
19
|
|---|---|---|---|---|
|
|
@@ -34,62 +36,52 @@ gates. This is the same table `spf list` will end up describing.
|
|
|
34
36
|
(`quality.runSuite`, `changes.capture`, `run.git.commitAll`). If a human
|
|
35
37
|
could write the exact steps down without judgment, it's `code`.
|
|
36
38
|
|
|
37
|
-
## Step 2 —
|
|
39
|
+
## Step 2 — compose the chain from steps
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
`
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
Almost every chain is a flat array of **steps** — named primitives from
|
|
42
|
+
`src/chains/steps.ts`, each one a `run.phase(...)` call (or a small bounded
|
|
43
|
+
group of them, for the two loops). There is no module to write and nothing to
|
|
44
|
+
register anywhere else: a `CHAINS` entry built with `stepChain()` *is* the
|
|
45
|
+
registration, in `src/chains/index.ts`.
|
|
43
46
|
|
|
44
47
|
```ts
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
import * as quality from "../core/quality.ts";
|
|
53
|
-
import * as session from "../core/session.ts";
|
|
54
|
-
import { BuildOutput, PlanOutput, makeAgentCall, makePhaseParams } from "../core/data_types.ts";
|
|
55
|
-
import type { ChainContext } from "./context.ts";
|
|
56
|
-
|
|
57
|
-
export const REQUIRED_AGENTS = ["planner", "builder"];
|
|
58
|
-
export const REQUIRED_SUITES: string[] = ["test"]; // [] if this chain runs no quality suite
|
|
59
|
-
|
|
60
|
-
export async function main(ctx: ChainContext): Promise<number> {
|
|
61
|
-
const { prompt, config_paths, adw_id, cwd } = ctx;
|
|
62
|
-
const cfg = agents.loadConfig(config_paths);
|
|
63
|
-
agents.validate(cfg, REQUIRED_AGENTS, REQUIRED_SUITES, cwd);
|
|
64
|
-
const run = session.ensure(cfg, adw_id, cwd);
|
|
65
|
-
|
|
66
|
-
await run.phase(
|
|
67
|
-
makePhaseParams({ name: "request", kind: "engineer", owner: run.engineer, description: "Capture the incoming ask" }),
|
|
68
|
-
async (ph) => { ph.log({ input: prompt }); },
|
|
69
|
-
);
|
|
70
|
-
|
|
71
|
-
const plan = await run.phase(
|
|
72
|
-
makePhaseParams({ name: "plan", kind: "agent", owner: "planner", description: "Turn the request into an implementable plan" }),
|
|
73
|
-
(ph) => ph.call(makeAgentCall({ output_type: PlanOutput, prompt, gates: [gates.artifactsExist] })),
|
|
74
|
-
);
|
|
75
|
-
|
|
76
|
-
const build = await run.phase(
|
|
77
|
-
makePhaseParams({ name: "build", kind: "agent", owner: "builder", description: "Implement the plan" }),
|
|
78
|
-
(ph) => ph.call(makeAgentCall({ output_type: BuildOutput, prompt, previous: plan, gates: [gates.diffMatchesClaims] })),
|
|
79
|
-
);
|
|
80
|
-
|
|
81
|
-
await run.phase(
|
|
82
|
-
makePhaseParams({ name: "commit", kind: "code", owner: "git", description: "Commit the implementation" }),
|
|
83
|
-
async () => run.git.commitAll(build.commit_message || `spf: ${prompt.slice(0, 72)}`),
|
|
84
|
-
);
|
|
85
|
-
|
|
86
|
-
return run.finish();
|
|
87
|
-
}
|
|
48
|
+
stepChain("plan-build-test", "the standard chain — plan, build, test, commit", [
|
|
49
|
+
steps.request(),
|
|
50
|
+
steps.plan(),
|
|
51
|
+
steps.build(),
|
|
52
|
+
steps.fixLoop({ suite: "test" }),
|
|
53
|
+
steps.commit({ onlyIfAccepted: true }),
|
|
54
|
+
]),
|
|
88
55
|
```
|
|
89
56
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
57
|
+
`stepChain()` derives `phases` (the `spf list` display string),
|
|
58
|
+
`requiredAgents`, and `requiredSuites` from the step list itself — nothing to
|
|
59
|
+
keep in sync by hand. The available steps:
|
|
60
|
+
|
|
61
|
+
| Step | What it replaces | Notes |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| `request({description?, logBaseline?})` | the opening `engineer(request)` phase | every chain starts with this |
|
|
64
|
+
| `plan()` | a `planner` phase producing `PlanOutput` | — |
|
|
65
|
+
| `build({fromPlan?, retries?})` | a `builder` phase producing `BuildOutput` | `fromPlan` only changes the phase's description |
|
|
66
|
+
| `scout()` | a `scout` phase producing `ScoutOutput` | read-only |
|
|
67
|
+
| `promptOnly()` | the `prompt` chain's one step | owner comes from `--agent`, default `builder` |
|
|
68
|
+
| `qualityCheck({suite})` | one deterministic quality/test phase | never fails its own phase — see below |
|
|
69
|
+
| `fixLoop({suite, max?, owner?})` | a bounded check→fix loop | owns its own iteration and phase naming (`test_1`, `fix_1`, ...) |
|
|
70
|
+
| `reviseLoop({max?})` | a bounded review→revise loop | same idea, for `reviewer`/`builder` |
|
|
71
|
+
| `changes({base?})` | a `git diff`-against-a-base code phase | feeds `document()` |
|
|
72
|
+
| `document()` | a `documenter` phase | requires a preceding `changes()` |
|
|
73
|
+
| `commit({onlyIfAccepted?})` | a `git commit` code phase | commits whatever the last agent step produced |
|
|
74
|
+
|
|
75
|
+
**Why the loops are steps, not a `for` you write in the chain.** `fixLoop`
|
|
76
|
+
and `reviseLoop` own their bounded iteration internally, so a chain's step
|
|
77
|
+
list is always flat — no loop or conditional syntax at the composition
|
|
78
|
+
layer. This is also what keeps a future declarative (YAML) chain tractable:
|
|
79
|
+
it only ever needs to name steps and pass them tuning params.
|
|
80
|
+
|
|
81
|
+
Steps read and write a shared `ChainState` (`prompt`, `options`, `previous` —
|
|
82
|
+
the last agent envelope, `accepted`/`reason` for `run.finish()`, etc.) so a
|
|
83
|
+
step never has to be told what the step before it produced; it just reads
|
|
84
|
+
`state.previous`.
|
|
93
85
|
|
|
94
86
|
**Before you ship it:** run it against a scratch repo (`spf <name> "..." --cwd
|
|
95
87
|
/tmp/scratch-repo`), then `spf phases <adw_id>` and `spf events <adw_id>` to
|
|
@@ -98,47 +90,67 @@ expect, and open one `envelope.json` under
|
|
|
98
90
|
`.spf/data/sessions/<adw_id>/<agent>/` to confirm it matches the type you
|
|
99
91
|
declared.
|
|
100
92
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
Insert a `run.phase(...)` block in sequence; nothing else in the chain needs
|
|
104
|
-
to change unless the new phase's output feeds a later one (thread it through
|
|
105
|
-
as a local variable, the way `plan`/`build` are threaded above).
|
|
93
|
+
### When a chain doesn't fit the step vocabulary
|
|
106
94
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
95
|
+
`simple-sdlc` (`src/chains/simple_sdlc.ts`) is the one exception: three
|
|
96
|
+
commits, a pinned baseline, and a conditional retest don't collapse into a
|
|
97
|
+
flat list. A chain like that is still a module exporting `REQUIRED_AGENTS`,
|
|
98
|
+
`REQUIRED_SUITES`, and `main(ctx: ChainContext): Promise<number>`, registered
|
|
99
|
+
in `CHAINS` with a `run:` field instead of `steps:`. Reuse
|
|
100
|
+
`steps.startRun`/`commitEnvelope`/`logChangeset` and `quality.record` rather
|
|
101
|
+
than re-copying them — read `simple_sdlc.ts` before reaching for this escape
|
|
102
|
+
hatch; it is meant for the rare chain whose control flow genuinely doesn't
|
|
103
|
+
reduce to a list, not a shortcut around learning the step vocabulary.
|
|
110
104
|
|
|
111
|
-
## Step
|
|
105
|
+
## Step 3 — add a phase to an existing chain
|
|
112
106
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
107
|
+
Most of the time this means adding, removing, or reordering an entry in a
|
|
108
|
+
`stepChain(...)` array in `src/chains/index.ts` — nothing else needs to
|
|
109
|
+
change unless the new step's output feeds a later one (it will, automatically,
|
|
110
|
+
if it writes to `state.previous`/`state.changeset`/etc., since every step
|
|
111
|
+
reads from the same shared state).
|
|
112
|
+
|
|
113
|
+
If no existing step does what you need, write one in `src/chains/steps.ts`:
|
|
114
|
+
a function returning a `Step` (a `(run, state) => Promise<void>`), doing one
|
|
115
|
+
`run.phase(...)` call (or a small bounded group, if it's loop-shaped like
|
|
116
|
+
`fixLoop`/`reviseLoop`). Declare what it needs via the optional
|
|
117
|
+
`requiredAgents`/`requiredSuites`/`label` properties so
|
|
118
|
+
`deriveRequiredAgents`/`deriveRequiredSuites`/`derivePhases` pick it up
|
|
119
|
+
automatically — `makeStep(fn, meta)` attaches these for you.
|
|
120
|
+
|
|
121
|
+
## Step 4 — the bounded fix loop, if you need a different shape than `fixLoop()` provides
|
|
122
|
+
|
|
123
|
+
`steps.fixLoop({suite, max, owner})` already covers the common case — a
|
|
124
|
+
known check, and if it fails, the builder repairs it, bounded so a chain
|
|
125
|
+
can't spin forever, never leaving an unverified fix on the last iteration.
|
|
126
|
+
Reach for it first. If you need a genuinely different shape (a different
|
|
127
|
+
envelope type feeding the fix, say), its body in `src/chains/steps.ts` is the
|
|
128
|
+
reference implementation to start from:
|
|
116
129
|
|
|
117
130
|
```ts
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
for (let i = 1; i <= MAX_FIX_LOOPS; i++) {
|
|
121
|
-
test = await run.phase(
|
|
131
|
+
for (let i = 1; i <= max; i++) {
|
|
132
|
+
const result = await run.phase(
|
|
122
133
|
makePhaseParams({ name: `test_${i}`, kind: "code", owner: "quality", description: "Run the suite" }),
|
|
123
|
-
async (ph) => { const
|
|
134
|
+
async (ph) => { const r = quality.runTests(run); quality.record(ph, r); return r; },
|
|
124
135
|
);
|
|
125
|
-
if (
|
|
126
|
-
|
|
136
|
+
if (result.passed) break;
|
|
137
|
+
if (i === max) break; // never leave an unverified fix on the table
|
|
138
|
+
state.previous = await run.phase(
|
|
127
139
|
makePhaseParams({ name: `fix_${i}`, kind: "agent", owner: "builder", retries: 1, description: "Repair what the suite reported" }),
|
|
128
|
-
(ph) => ph.call(makeAgentCall({ output_type: BuildOutput, prompt, previous: quality.asEnvelope(
|
|
140
|
+
(ph) => ph.call(makeAgentCall({ output_type: BuildOutput, prompt: state.prompt, previous: quality.asEnvelope(result, "tests"), gates: [gates.diffMatchesClaims] })),
|
|
129
141
|
);
|
|
130
142
|
}
|
|
131
|
-
return run.finish(test !== null && test.passed, `the suite still failed after ${MAX_FIX_LOOPS} fix attempt(s)`);
|
|
132
143
|
```
|
|
133
144
|
|
|
134
145
|
A failing suite does **not** fail its own phase — the runner did its job,
|
|
135
146
|
the *code under test* is what failed. It's `run.finish()`'s job, at the end,
|
|
136
|
-
to decide whether the whole run is accepted
|
|
147
|
+
to decide whether the whole run is accepted — which is why a step sets
|
|
148
|
+
`state.accepted`/`state.reason` rather than throwing.
|
|
137
149
|
|
|
138
150
|
## Adding an engine primitive
|
|
139
151
|
|
|
140
|
-
These live in `src/core/`, not in a chain. Chains
|
|
141
|
-
only, no business logic.
|
|
152
|
+
These live in `src/core/`, not in a chain or a step. Chains and steps stay
|
|
153
|
+
thin: sequencing only, no business logic.
|
|
142
154
|
|
|
143
155
|
### Adding an output type
|
|
144
156
|
|
|
@@ -153,7 +165,7 @@ export const MyOutput = envelopeType("MyOutput", {
|
|
|
153
165
|
export type MyOutputT = v.InferOutput<typeof MyOutput.schema>;
|
|
154
166
|
```
|
|
155
167
|
|
|
156
|
-
Then the two other legs of the synced triad: the calling
|
|
168
|
+
Then the two other legs of the synced triad: the calling step's
|
|
157
169
|
`output_type: MyOutput`, and the agent's `user.md` `## Report` section
|
|
158
170
|
showing the exact JSON shape. All three must move together — see
|
|
159
171
|
`references/handoff.md`.
|
|
@@ -155,7 +155,9 @@ startup, before anything spawns:
|
|
|
155
155
|
`authoring_chains.md`. The user prompt's `## Report` section must show
|
|
156
156
|
exactly that JSON shape.
|
|
157
157
|
|
|
158
|
-
Then
|
|
158
|
+
Then use it as a step's `owner` (`steps.build()`, `steps.plan()`, ...) or write
|
|
159
|
+
a new step naming it — see `authoring_chains.md`. A chain's `requiredAgents` is
|
|
160
|
+
derived from its step list, not hand-maintained.
|
|
159
161
|
|
|
160
162
|
## Write permissions — `writes` and `protected_files`
|
|
161
163
|
|
|
@@ -12,14 +12,18 @@ 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`
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
field by field (`defaults`/`observability`/`quality`/`watch`/`notifications`
|
|
16
|
+
merge key-by-key — `notifications.channels` replaces wholesale, same as
|
|
17
|
+
`quality.checks`; `agents` merges by `name`: a matching name patches that
|
|
18
|
+
entry, a new name appends).
|
|
18
19
|
3. An explicit `--config <path>` replaces both — standalone, no built-in
|
|
19
20
|
underneath it.
|
|
20
21
|
|
|
21
|
-
`spf init` seeds step 2
|
|
22
|
-
|
|
22
|
+
`spf init` seeds step 2 — on a TTY, via an interview that asks the fields
|
|
23
|
+
below and appends whatever secrets they imply to `.env`; non-interactively
|
|
24
|
+
(`--yes`, `--template <name>`, or no TTY) it writes a commented starter
|
|
25
|
+
instead. Omitting `.spf/spf.config.yaml` entirely means running off pure
|
|
26
|
+
built-ins, which is a fully supported, valid state.
|
|
23
27
|
|
|
24
28
|
## Shape
|
|
25
29
|
|
|
@@ -104,6 +108,49 @@ sections: `assets/templates/ts.spf.config.yaml` in the spf package (or
|
|
|
104
108
|
`spf init --template ts` to write it straight into `.spf/spf.config.yaml`).
|
|
105
109
|
`spf init` with no `--template` prints every packaged template's name.
|
|
106
110
|
|
|
111
|
+
### `notifications`
|
|
112
|
+
|
|
113
|
+
Optional outbound push for unattended work — `spf watch`'s daemon lifecycle,
|
|
114
|
+
and every chain run (`spf <chain>` / `spf run`, including watch's own
|
|
115
|
+
per-issue runs). Interactive commands (`doctor`, `list`, `sessions`,
|
|
116
|
+
`phases`, `events`, `init`, `ui`, `migrate`, `eject`, `abort`, `version`)
|
|
117
|
+
never notify — you're already looking at the terminal for those. Off by
|
|
118
|
+
default; adding it is entirely additive.
|
|
119
|
+
|
|
120
|
+
| Field | Type | Meaning |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| `events` | `"off"` \| `"errors"` \| `"all"` | The whole filter. `off` (default): nothing. `errors`: only failed runs/phases, blocked issues, watch errors. `all`: every curated milestone (run started, issue claimed, PR opened, ...) plus errors. |
|
|
123
|
+
| `timeout_ms` | int | Per-request timeout for a channel's HTTP POST. Default `5000`. |
|
|
124
|
+
| `channels[]` | array | See below. |
|
|
125
|
+
|
|
126
|
+
`channels[].kind`: `"slack"` \| `"teams"` \| `"webhook"`. `channels[].events`
|
|
127
|
+
overrides `events` for just that channel (unset = inherit). `webhook_url_env`
|
|
128
|
+
names the `.env` key holding the secret URL — never the URL itself, matching
|
|
129
|
+
`GITHUB_TOKEN`/`JIRA_API_TOKEN`. Empty/omitted uses the kind's own default:
|
|
130
|
+
`SLACK_WEBHOOK_URL`, `TEAMS_WEBHOOK_URL`, `SPF_WEBHOOK_URL`. `name` is a
|
|
131
|
+
cosmetic label for warning lines when you have two channels of the same
|
|
132
|
+
kind.
|
|
133
|
+
|
|
134
|
+
```yaml
|
|
135
|
+
notifications:
|
|
136
|
+
events: errors
|
|
137
|
+
channels:
|
|
138
|
+
- kind: slack
|
|
139
|
+
webhook_url_env: SLACK_WEBHOOK_URL # optional; this is the default for slack
|
|
140
|
+
- kind: teams
|
|
141
|
+
events: all # per-channel override
|
|
142
|
+
- kind: webhook
|
|
143
|
+
webhook_url_env: OPS_WEBHOOK_URL
|
|
144
|
+
name: ops-bus
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Delivery never blocks or fails a run: a channel with an unset env var is
|
|
148
|
+
skipped with one warning at startup (`spf doctor` reports the same thing as
|
|
149
|
+
a check); a failed POST logs one line and is swallowed, never changing the
|
|
150
|
+
run's exit code. See the main README's "Notifications" section for how to
|
|
151
|
+
get each channel's webhook URL, and `spf init`'s interview, which asks for
|
|
152
|
+
this section and collects the URL straight into `.env`.
|
|
153
|
+
|
|
107
154
|
### `agents[]`
|
|
108
155
|
|
|
109
156
|
| Field | Required | Meaning |
|
|
@@ -64,3 +64,15 @@ agents:
|
|
|
64
64
|
# label_prefix: spf
|
|
65
65
|
# chain: plan-build-test
|
|
66
66
|
# base_branch: main
|
|
67
|
+
|
|
68
|
+
# Optional: push notifications for unattended work — spf watch's daemon
|
|
69
|
+
# lifecycle, and every chain run (including watch's own per-issue runs).
|
|
70
|
+
# Interactive commands (doctor, list, sessions, ...) never notify. events:
|
|
71
|
+
# "errors" sends only failures/blocked issues; "all" adds every milestone.
|
|
72
|
+
# The webhook URL is a secret — put it in .env under the key named below,
|
|
73
|
+
# never here. See the main README's "Notifications" section.
|
|
74
|
+
# notifications:
|
|
75
|
+
# events: errors # off (default) | errors | all
|
|
76
|
+
# channels:
|
|
77
|
+
# - kind: slack # slack | teams | webhook
|
|
78
|
+
# webhook_url_env: SLACK_WEBHOOK_URL
|
package/dist/chains/context.d.ts
CHANGED
|
@@ -16,4 +16,6 @@ export interface ChainContext {
|
|
|
16
16
|
adw_id: string | null;
|
|
17
17
|
/** Absolute. The anchor session.ensure() resolves repo_root and data_dir from. */
|
|
18
18
|
cwd: string;
|
|
19
|
+
/** The CLI name (`"plan-build-test"`), for session.ensure()'s trace record — see core/session.ts. */
|
|
20
|
+
chain_name: string;
|
|
19
21
|
}
|
package/dist/chains/index.d.ts
CHANGED
|
@@ -2,18 +2,37 @@
|
|
|
2
2
|
* The chain registry — the one place a CLI-facing short name maps to a
|
|
3
3
|
* chain module. `spf list` reads this; `spf <name>`/`spf run <name>` dispatch
|
|
4
4
|
* through it. Replaces guessing a module filename from the CLI argument.
|
|
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.
|
|
5
12
|
*/
|
|
6
13
|
import type { ChainContext } from "./context.ts";
|
|
14
|
+
import * as steps from "./steps.ts";
|
|
7
15
|
export interface ChainDefinition {
|
|
8
16
|
/** The name typed on the CLI: `spf <name> "..."`. */
|
|
9
17
|
name: string;
|
|
10
18
|
describe: string;
|
|
11
19
|
phases: string;
|
|
12
|
-
/** Static for most chains;
|
|
20
|
+
/** Static for most chains; prompt's depends on --agent, so it's a function there. */
|
|
13
21
|
requiredAgents: string[] | ((options: Record<string, string>) => string[]);
|
|
14
22
|
requiredSuites: string[];
|
|
15
|
-
|
|
23
|
+
/** The declarative path — a flat step list, run by runChain() via steps.runSteps(). */
|
|
24
|
+
steps?: steps.Step[];
|
|
25
|
+
/** The imperative escape hatch for a chain too shaped by its own logic to be a flat list (simple-sdlc). */
|
|
26
|
+
run?: (ctx: ChainContext, options?: Record<string, string>) => Promise<number>;
|
|
16
27
|
}
|
|
17
28
|
export declare const CHAINS: ChainDefinition[];
|
|
18
29
|
export declare function findChain(name: string): ChainDefinition | undefined;
|
|
19
30
|
export declare function resolveRequiredAgents(chain: ChainDefinition, options: Record<string, string>): string[];
|
|
31
|
+
/**
|
|
32
|
+
* Run a chain, whichever path it defines: `run` (the imperative escape
|
|
33
|
+
* hatch) if it has one, otherwise `steps` through the shared driver. Both
|
|
34
|
+
* CLI dispatch sites (`spf <chain>` and `spf watch`) go through this, never
|
|
35
|
+
* `chain.run(...)` directly — the whole reason to route through here is that
|
|
36
|
+
* a `steps`-only chain has no `run` to call.
|
|
37
|
+
*/
|
|
38
|
+
export declare function runChain(chain: ChainDefinition, ctx: ChainContext, options?: Record<string, string>): Promise<number>;
|