@gr8ful/spf 0.2.1 → 0.4.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 (60) hide show
  1. package/README.md +106 -6
  2. package/assets/defaults/spf.config.yaml +16 -0
  3. package/assets/prompts/refiner/system.md +53 -0
  4. package/assets/prompts/refiner/user.md +70 -0
  5. package/assets/skill/references/config.md +83 -3
  6. package/assets/templates/ts-cc.spf.config.yaml +3 -3
  7. package/assets/templates/ts.spf.config.yaml +22 -2
  8. package/dist/chains/context.d.ts +9 -0
  9. package/dist/chains/index.js +5 -0
  10. package/dist/chains/steps.d.ts +24 -0
  11. package/dist/chains/steps.js +55 -4
  12. package/dist/cli/commands/doctor.js +18 -0
  13. package/dist/cli/commands/init.js +44 -3
  14. package/dist/cli/commands/install-skill.js +5 -2
  15. package/dist/cli/commands/list.js +1 -0
  16. package/dist/cli/commands/run.js +5 -1
  17. package/dist/cli/commands/watch.js +86 -8
  18. package/dist/cli/index.js +7 -3
  19. package/dist/cli/interview.d.ts +2 -0
  20. package/dist/cli/interview.js +107 -3
  21. package/dist/core/agents.js +4 -1
  22. package/dist/core/console.d.ts +13 -1
  23. package/dist/core/console.js +51 -1
  24. package/dist/core/data_types.d.ts +133 -0
  25. package/dist/core/data_types.js +72 -0
  26. package/dist/core/gates.d.ts +13 -0
  27. package/dist/core/gates.js +103 -0
  28. package/dist/core/issues/github_provider.d.ts +35 -9
  29. package/dist/core/issues/github_provider.js +76 -28
  30. package/dist/core/issues/jira_provider.d.ts +14 -1
  31. package/dist/core/issues/jira_provider.js +9 -7
  32. package/dist/core/issues/provider.d.ts +77 -15
  33. package/dist/core/issues/provider.js +7 -4
  34. package/dist/core/notify/channel.d.ts +32 -0
  35. package/dist/core/notify/channel.js +14 -0
  36. package/dist/core/notify/notifier.d.ts +42 -0
  37. package/dist/core/notify/notifier.js +100 -0
  38. package/dist/core/notify/slack_channel.d.ts +13 -0
  39. package/dist/core/notify/slack_channel.js +30 -0
  40. package/dist/core/notify/teams_channel.d.ts +17 -0
  41. package/dist/core/notify/teams_channel.js +38 -0
  42. package/dist/core/notify/webhook_channel.d.ts +13 -0
  43. package/dist/core/notify/webhook_channel.js +19 -0
  44. package/dist/core/refine.d.ts +39 -0
  45. package/dist/core/refine.js +144 -0
  46. package/dist/core/runner.d.ts +7 -0
  47. package/dist/core/runner.js +4 -1
  48. package/dist/core/session.js +3 -0
  49. package/dist/core/watch.d.ts +66 -1
  50. package/dist/core/watch.js +267 -15
  51. package/dist/test/chains.test.js +1 -0
  52. package/dist/test/data_types.test.js +34 -1
  53. package/dist/test/init_command.test.js +17 -0
  54. package/dist/test/interview.test.js +119 -0
  55. package/dist/test/notify.test.d.ts +1 -0
  56. package/dist/test/notify.test.js +174 -0
  57. package/dist/test/refine.test.d.ts +1 -0
  58. package/dist/test/refine.test.js +126 -0
  59. package/dist/test/watch.test.js +286 -5
  60. package/package.json +1 -1
package/README.md CHANGED
@@ -15,7 +15,7 @@ Everyone can get an agent to write code once. Almost nobody gets the same result
15
15
  npm i -g @gr8ful/spf
16
16
  ```
17
17
 
18
- No Bun, no separate coding-agent binary to install first, no template stamped into your repo. `spf` ships with a packaged default agent roster and default prompts — it runs against any repo with zero setup, and only writes files into that repo if you explicitly ask it to (`spf init`, `spf install-skill`).
18
+ No Bun, no separate coding-agent binary to install first, no template stamped into your repo. `spf` ships with a packaged default agent roster and default prompts — it runs against any repo with zero setup, and only writes files into that repo if you explicitly ask it to (`spf init`, which also installs the Claude Code skill unless you pass `--no-skills`; `spf install-skill` does just the skill by hand).
19
19
 
20
20
  ```bash
21
21
  cd your-repo
@@ -34,10 +34,12 @@ spf init --template ts-cc # or start from a packaged, ready-to-run template
34
34
  spf list # every chain this install knows, its phases, what it needs
35
35
  ```
36
36
 
37
- On a real terminal, `spf init` asks a short interview — which coding agent (`claude_code` or `flue`) and model, which quality checks to gate on, whether to turn on `spf watch` and against which tracker/code host — 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.
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
38
 
39
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.
40
40
 
41
+ Every `spf init` run — interview or not — also installs the repo-local Claude Code skill (`.claude/skills/spf`), the same work `spf install-skill` does by hand: pass `--no-skills` to skip it. It's idempotent (a no-op once the skill is already current, and writes a `.new` sibling instead of overwriting a file you've locally edited), so re-running `spf init` never clobbers anything there. `spf install-skill --user` (installing to `~/.claude/skills/spf` instead) is still its own separate invocation.
42
+
41
43
  ### Local development
42
44
 
43
45
  Working from a clone instead of the published package:
@@ -108,7 +110,7 @@ agents:
108
110
  writes: [specs/] # the plan is all it may leave in the repo
109
111
  ```
110
112
 
111
- Five starter agents ship in the box: `planner`, `builder`, `scout` (read-only recon), `reviewer`, and `documenter`. There is no tester, because running a suite is a known command and therefore code, not an agent's job.
113
+ Six starter agents ship in the box: `planner`, `builder`, `scout` (read-only recon), `refiner` (decomposes a product spec for `spf watch`'s refine lane — see "`spf watch`" below), `reviewer`, and `documenter`. There is no tester, because running a suite is a known command and therefore code, not an agent's job.
112
114
 
113
115
  Every agent gets its own model, thinking level, prompts, and tools. Give the planner a frontier model and the builder a cheap fast one. Give the reviewer no ability to write code at all.
114
116
 
@@ -243,6 +245,8 @@ spf build-test "implement the plan" --adw-id a1b2c3d4
243
245
 
244
246
  Polls an issue tracker for issues labeled `<prefix>:ready`, runs a configured chain against each in its own git worktree, opens a PR against a code host, and tracks it through to merged or blocked — driving the same chains above rather than reimplementing an SDLC. Labels are the whole state machine: `ready → working → review → done`/`blocked`.
245
247
 
248
+ A second, optional lane (`watch.refine`, off by default) decomposes a `<prefix>:spec-ready` product spec into a feature/story-or-bug tree of real issues instead — see "Refining specs" below.
249
+
246
250
  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.
247
251
 
248
252
  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.
@@ -283,15 +287,61 @@ spf watch --dry-run # log intended claims/transitions, mutate nothing
283
287
 
284
288
  No GitHub App, no webhook, no Jira/Bitbucket app install — it's a plain REST poll against whichever combination is configured, same philosophy as the trace db's own polling contract. See [`assets/templates/`](assets/templates/) for full worked configs (also usable directly via `spf init --template <name>`), and `spf install-skill`'s installed skill (`roster.md`, `references/config.md`) for the field-by-field reference.
285
289
 
290
+ ### Refining specs (`watch.refine`)
291
+
292
+ A product spec isn't individually workable — it needs to become a feature,
293
+ broken down into user stories and bugs, before the build lane above has
294
+ anything to claim. `watch.refine` is a second lane over the same poll loop
295
+ that does exactly that: it polls `<prefix>:spec-ready`, runs a decomposition
296
+ chain (`refine` by default) against the spec in its own worktree, and
297
+ publishes what it produces as real tracker issues — a feature/epic container
298
+ plus story/bug/task leaves, linked via GitHub's native sub-issue hierarchy.
299
+
300
+ ```yaml
301
+ watch:
302
+ issue_provider: github # required — issue authoring isn't implemented for Jira yet
303
+ repo: owner/name
304
+ refine:
305
+ enabled: true
306
+ chain: refine # any chain that ends in steps.publishIssues()
307
+ concurrency: 1 # this lane's own budget, independent of watch.concurrency
308
+ ```
309
+
310
+ Every generated issue carries a `<prefix>:type:epic|feature|story|bug|task`
311
+ label. A container (a feature/epic — something else names it as `parent`)
312
+ gets only that label; a leaf additionally gets `<prefix>:refined` — **never**
313
+ `<prefix>:ready`. Promoting a leaf to `<prefix>:ready` is a deliberate human
314
+ decision: the refine lane never auto-promotes anything, so a spec fanning out
315
+ into twenty stories doesn't turn into twenty unattended chain runs and twenty
316
+ PRs with nobody having looked at the breakdown first. Once you do promote a
317
+ leaf, the existing build lane picks it up completely unchanged.
318
+
319
+ The spec issue itself gets a `spec-ready → refining → done`/`blocked`
320
+ lifecycle, same shape as the build lane, and a summary comment listing every
321
+ issue it created. Try it by hand first, against a real spec, before turning
322
+ on the daemon:
323
+
324
+ ```bash
325
+ spf refine "<spec text or path/to/spec.md>" --issue 42 # --issue renders a "## Parent: #42" back-reference
326
+ ```
327
+
328
+ `spf watch init` seeds the type labels alongside the state ones. This lane's
329
+ prompt (`assets/prompts/refiner/`) is adapted from a "tracer-bullet ticket"
330
+ decomposition skill — vertical slices, a `blocked_by` dependency graph, and
331
+ an expand/migrate/contract sequence for wide mechanical refactors — with a
332
+ gate (`gates.refinementWellFormed`) added on top to enforce the
333
+ container/leaf shape that skill left as prose convention rather than a
334
+ checked rule.
335
+
286
336
  ### GitHub (`issue_provider: github` and/or `code_host: github`)
287
337
 
288
338
  ```bash
289
339
  export GITHUB_TOKEN=... # classic PAT; spf doctor checks it's set
290
340
  ```
291
341
 
292
- `spf watch init` seeds `<prefix>:ready`/`working`/`review`/`done`/`blocked` labels with a color and description each — safe to re-run any time (creates what's missing, corrects any that drifted, leaves the rest alone).
342
+ `spf watch init` seeds `<prefix>:ready`/`working`/`review`/`done`/`blocked`/`spec-ready`/`refining`/`refined` labels, plus `<prefix>:type:epic`/`feature`/`story`/`bug`/`task` (used by the refine lane whether or not it's enabled), each with a color and description — safe to re-run any time (creates what's missing, corrects any that drifted, leaves the rest alone).
293
343
 
294
- A **classic** PAT (fine-grained tokens use different permission names — not covered here), scoped to the minimum that covers every call `spf watch`/`spf watch init` makes on GitHub: creating/editing labels, reading and labeling issues, posting comments, opening PRs, and reading PR/check-run status.
344
+ A **classic** PAT (fine-grained tokens use different permission names — not covered here), scoped to the minimum that covers every call `spf watch`/`spf watch init` makes on GitHub: creating/editing labels, reading and labeling issues, posting comments, opening PRs, reading PR/check-run status, and — with `watch.refine.enabled` — creating issues and linking them via the sub-issues API. All of it is already covered by `repo`/`public_repo`; refine needs no additional scope.
295
345
 
296
346
  | Target repo | Scope | Covers |
297
347
  |---|---|---|
@@ -322,6 +372,56 @@ export BITBUCKET_API_TOKEN=... # same Atlassian API token mechanism as Jira ab
322
372
 
323
373
  **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).
324
374
 
375
+ ## Notifications
376
+
377
+ Optional, off by default: push a curated set of milestones to Slack,
378
+ Microsoft Teams, or a generic webhook. It's scoped to **unattended work**:
379
+ `spf watch`'s daemon lifecycle, and every chain run (`spf <chain>` / `spf
380
+ run`, including watch's own per-issue runs). Interactive commands — `doctor`,
381
+ `list`, `sessions`, `phases`, `events`, `init`, `ui`, `migrate`, `eject`,
382
+ `abort`, `version` — never notify; you're already looking at the terminal
383
+ for those.
384
+
385
+ ```yaml
386
+ # .spf/spf.config.yaml
387
+ notifications:
388
+ events: errors # off (default) | errors | all
389
+ channels:
390
+ - kind: slack # slack | teams | webhook
391
+ webhook_url_env: SLACK_WEBHOOK_URL # optional; this is the default for slack
392
+ ```
393
+
394
+ `events` is the whole filter: `errors` sends only failed runs/phases, blocked
395
+ issues, and watch errors; `all` adds every milestone — run started/finished,
396
+ issue claimed, PR opened, issue done. A channel's own `events` overrides the
397
+ top-level scope for just that channel. `spf doctor` reports whether each
398
+ configured channel's env var is set.
399
+
400
+ The webhook URL is a secret and lives only in `.env` — `webhook_url_env`
401
+ names the key, never the URL itself, matching `GITHUB_TOKEN`/
402
+ `JIRA_API_TOKEN`. Defaults per kind: `SLACK_WEBHOOK_URL`, `TEAMS_WEBHOOK_URL`,
403
+ `SPF_WEBHOOK_URL`. `spf init`'s interview asks for this section and collects
404
+ the URL straight into `.env`, same as every other credential.
405
+
406
+ Getting each channel's URL:
407
+
408
+ - **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".
409
+ - **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.
410
+ - **webhook** — any endpoint that accepts a JSON POST of the event: Discord,
411
+ n8n, Zapier, a homegrown receiver.
412
+
413
+ Delivery never blocks or fails a run: an unconfigured/misconfigured channel
414
+ is skipped with one warning, and a failed POST logs one line and is
415
+ swallowed — never changes a run's exit code. One thing worth knowing under
416
+ `events: all`: `spf watch` runs its per-issue chains in-process, so a failed
417
+ issue produces **two** notifications for the same failure — `run_failed`
418
+ (keyed to the chain's own `adw_id`, e.g. `issue-142`) from the chain-run
419
+ side, and `issue_blocked` (keyed to the issue) from watch itself. Both are
420
+ genuinely informative, just worth expecting.
421
+
422
+ Full field reference: `spf install-skill`'s installed skill
423
+ (`references/config.md`).
424
+
325
425
  ## What's in this repo
326
426
 
327
427
  ```
@@ -335,7 +435,7 @@ super-portable-software-factory/
335
435
  └── assets/
336
436
  ├── defaults/ # the packaged default roster
337
437
  ├── prompts/ # default system.md + user.md per starter agent
338
- └── skill/ # an optional Claude Code skill — `spf install-skill` to use it
438
+ └── skill/ # a Claude Code skill — `spf init` installs it by default (`--no-skills` to opt out), or `spf install-skill` by hand
339
439
  ```
340
440
 
341
441
  There's no template stamped into your repo. Everything above ships inside the installed npm package; a repo you run `spf` against only ever gains a `.spf/` directory, and only if you ask for one.
@@ -101,6 +101,22 @@ agents:
101
101
  # No tester agent: running the suite is a known command, so it is a kind="code"
102
102
  # phase over core/quality.ts. See SKILL.md hard rule 8.
103
103
 
104
+ - name: refiner
105
+ thinking: high
106
+ color: "#34d399"
107
+ purpose: Decompose a product spec into a feature/story-or-bug tree of vertical slices; change nothing.
108
+ prompt_engineering:
109
+ system: refiner/system.md
110
+ user: refiner/user.md
111
+ writes: [] # read-only w.r.t. the repo; refine_plan.md lands in context_handoff_dir, not here
112
+ tools: # full recon; write only for refine_plan.md, no edit
113
+ - read
114
+ - grep
115
+ - find
116
+ - ls
117
+ - bash
118
+ - write
119
+
104
120
  - name: reviewer
105
121
  model: openai/gpt-5.6-terra
106
122
  thinking: high
@@ -0,0 +1,53 @@
1
+ # Refiner Agent
2
+
3
+ ## Purpose
4
+
5
+ Decompose a product spec into a feature/story-or-bug tree of vertical slices the factory can build one at a time. Change nothing.
6
+
7
+ ## Instructions
8
+
9
+ - Read-only: explore the codebase to ground the decomposition, never write to it.
10
+ - Use the project's domain glossary vocabulary in every title and description, if the repo has one. Respect existing ADRs in any area you're touching — a slice that would contradict one is a slice to reconsider, not to write down anyway.
11
+ - You inherit the operator's shell environment — their PATH, toolchains and credentials are already live. Call tools by bare name (`bun`, `uv`, `pytest`); never hunt for a binary or fall back to an absolute `/usr/bin/*` path.
12
+ - Judge any command you run by its exit status, never by scanning its output for words. `error` or `not found` inside passing output is text, not a failure.
13
+ - Write your working notes to `<context_handoff_dir>/refine_plan.md` before emitting your Report JSON.
14
+
15
+ ## The tree
16
+
17
+ Every node you produce is either a **container** (`epic` or `feature` — exists only to group other nodes; never itself a unit of work) or a **leaf** (`story`, `bug`, or `task` — the independently workable unit). A node is a container exactly when some other node names it as `parent`; everything else is a leaf. At least one leaf is required — a decomposition that is all containers has produced nothing to build.
18
+
19
+ Emit a **flat list**, not nested JSON: each node names its `parent` by another node's `key`, or leaves `parent` empty for a top-level feature/epic. A flat list with parent pointers is far more reliable to produce correctly than a recursive tree, and it's what lets `blocked_by` point at any other node — sibling or not.
20
+
21
+ ## Vertical-slice rules (for every leaf)
22
+
23
+ - Each slice cuts a narrow but COMPLETE path through every layer it touches (schema, API, UI, tests) — vertical, never a horizontal slice of one layer.
24
+ - A completed slice is demoable or verifiable on its own.
25
+ - Size each slice to fit in a single fresh context window for the builder that will implement it — when in doubt, split.
26
+ - Any prefactoring the spec implies should be its own slice, sequenced first via `blocked_by`, not folded into the first "real" slice.
27
+
28
+ ## Dependencies: a DAG, not a tree
29
+
30
+ Give every node its `blocked_by`: the other nodes' `key`s that must land first. A node with no blockers can start immediately — leave `blocked_by` empty rather than inventing an order where none is required. The factory works the **frontier**: any leaf whose blockers are all done. For a purely linear chain that means top to bottom; for anything wider, only real dependencies belong in `blocked_by` — an artificial one just stalls the frontier.
31
+
32
+ ## Wide refactors — the one exception to vertical slicing
33
+
34
+ A **wide refactor** is one mechanical change (rename a column, retype a shared symbol) whose blast radius fans across the codebase, so a single edit breaks thousands of call sites and no vertical slice can land green on its own. Sequence it as **expand -> migrate -> contract**, each stage its own leaf(s):
35
+
36
+ - **expand**: add the new form beside the old so nothing breaks yet.
37
+ - **migrate**: batch the call sites by blast radius (per package, per directory) — each batch its own leaf, `blocked_by` the expand leaf. CI stays green throughout, because the old form still exists alongside the new one.
38
+ - **contract**: delete the old form — `blocked_by` every migrate batch.
39
+
40
+ If even a batch can't stay green alone, keep the same three-stage sequence but let the migrate batches share an integration branch that all block a final integrate-and-verify leaf — green is promised only there, not at every batch.
41
+
42
+ ## Writing titles and bodies
43
+
44
+ - Title and body describe end-to-end behavior from the user's (or the next engineer's) perspective — not a layer-by-layer implementation list.
45
+ - Avoid specific file paths or code snippets; they go stale fast. Exception: if your exploration surfaced a snippet that encodes a decision more precisely than prose can (a state machine, a reducer, a schema, a type shape), inline it and note briefly where it came from. Trim to the decision-rich part, not a working demo.
46
+ - Do **not** write a "Blocked by" or "Parent" section into `body` yourself — the harness renders both from `blocked_by`/the source issue automatically, with real issue numbers once everything is created. Writing your own would go stale or duplicate the real one.
47
+ - `body` should read as `## What to build` followed by `## Acceptance criteria` (a checklist).
48
+
49
+ ## Subagents
50
+
51
+ `subagent_create` / `_continue` / `_list` / `_remove` fan out exploration — one per subsystem or open question — when the spec spans more than you can read cheaply. Give each a self-contained task, hold it to read-only work, and omit `model`.
52
+
53
+ They run in the background. **Wait for every one you spawned to report before writing `refine_plan.md` or your Report JSON.** Skip them when a few reads would do.
@@ -0,0 +1,70 @@
1
+ # Refine Task
2
+
3
+ ## Variables
4
+
5
+ ### prompt
6
+
7
+ {{prompt}}
8
+
9
+ ### previous_envelope
10
+
11
+ {{previous_envelope}}
12
+
13
+ ### context_handoff_dir
14
+
15
+ {{context_handoff_dir}}
16
+
17
+ ## Task
18
+
19
+ Decompose the product spec in `prompt` into a feature/story-or-bug tree, following every rule in your system instructions (vertical slices, the `blocked_by` DAG, expand/migrate/contract for wide refactors, no "Blocked by"/"Parent" text of your own).
20
+
21
+ 1. Explore the codebase only as far as you need to ground the decomposition in what actually exists.
22
+ 2. Write your working notes to `<context_handoff_dir>/refine_plan.md`.
23
+ 3. Emit your `Report` JSON, declaring that one file in `artifacts`.
24
+
25
+ ## Report
26
+
27
+ Respond with ONLY valid JSON matching `RefineOutput` — no prose before or after:
28
+
29
+ ```json
30
+ {
31
+ "status": "success",
32
+ "summary": "<one sentence: how many features and how many leaves, and the shape of the dependency chain>",
33
+ "artifacts": ["<context_handoff_dir>/refine_plan.md"],
34
+ "notes_for_next_agent": "<anything the publish step or a human reviewer should know>",
35
+ "issues": [
36
+ {
37
+ "key": "F1",
38
+ "kind": "feature",
39
+ "title": "<feature title>",
40
+ "body": "## What to build\n\n<end-to-end behavior this feature covers>\n\n## Acceptance criteria\n\n- [ ] <criterion>",
41
+ "parent": "",
42
+ "blocked_by": []
43
+ },
44
+ {
45
+ "key": "S1",
46
+ "kind": "story",
47
+ "title": "<leaf title>",
48
+ "body": "## What to build\n\n<end-to-end behavior from the user's perspective>\n\n## Acceptance criteria\n\n- [ ] <criterion>\n- [ ] <criterion>",
49
+ "parent": "F1",
50
+ "blocked_by": []
51
+ },
52
+ {
53
+ "key": "S2",
54
+ "kind": "bug",
55
+ "title": "<leaf title>",
56
+ "body": "## What to build\n\n<the fix, described end-to-end>\n\n## Acceptance criteria\n\n- [ ] <criterion>",
57
+ "parent": "F1",
58
+ "blocked_by": ["S1"]
59
+ }
60
+ ]
61
+ }
62
+ ```
63
+
64
+ Rules on the shape (enforced by a gate — a violation sends this back to you as a correction, not a silent acceptance):
65
+
66
+ - `key` is your own local id for this run, unique within `issues` — never a real tracker id.
67
+ - A node is a **container** (`kind: "epic"` or `"feature"`) exactly when some other node names it in `parent`; every other node is a **leaf** (`kind: "story"`, `"bug"`, or `"task"`) and must not be a container. At least one leaf is required.
68
+ - `parent` is another node's `key`, or `""` for a top-level feature/epic.
69
+ - `blocked_by` is a list of other nodes' `key`s — real dependencies only, and no cycles (through `parent` or `blocked_by`, or both together).
70
+ - `body` is `## What to build` then `## Acceptance criteria` only — no "Blocked by" or "Parent" section; those are rendered for you once every node has a real issue number.
@@ -12,9 +12,10 @@ 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` merge key-by-key;
16
- `agents` merges by `name`: a matching name patches that entry, a new name
17
- appends).
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
 
@@ -107,6 +108,85 @@ sections: `assets/templates/ts.spf.config.yaml` in the spf package (or
107
108
  `spf init --template ts` to write it straight into `.spf/spf.config.yaml`).
108
109
  `spf init` with no `--template` prints every packaged template's name.
109
110
 
111
+ ### `watch`
112
+
113
+ Full mechanism: the main README's "`spf watch`" section. Field reference:
114
+
115
+ | Field | Type | Meaning |
116
+ |---|---|---|
117
+ | `issue_provider` | `"github"` \| `"jira"` | The tracker `spf watch` polls. Default `github`. |
118
+ | `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`. |
120
+ | `label_prefix` | string | State-machine label prefix — polls/writes `<prefix>:ready`, `<prefix>:working`, etc. Default `spf`. |
121
+ | `chain` | string | Which registered chain runs per claimed `<prefix>:ready` issue. Default `plan-build-test`. |
122
+ | `base_branch` | string | Branch worktrees fork from and PRs target. Default `main`. |
123
+ | `poll_ms` | int | Tick interval. Default `60000`. |
124
+ | `concurrency` | int ≥1 | Max issues claimed and run at once, the build lane's own budget (independent of `refine.concurrency`). Default `2`. |
125
+ | `jira.base_url` / `jira.project_key` | string | Only consulted when `issue_provider: jira`. |
126
+ | `refine.enabled` | bool | Turns on the second lane: decompose a `<prefix>:spec-ready` product spec into a feature/story-or-bug tree of real issues, instead of running `chain` against it directly (a spec isn't individually workable). Default `false` — off by default, so an existing `watch:` config is unaffected by upgrading. Needs `issue_provider: github` — `spf watch` fails loudly at startup otherwise, since issue authoring (create + link a hierarchy) isn't implemented for Jira yet. |
127
+ | `refine.chain` | string | Which registered chain runs per claimed spec. Default `refine`. |
128
+ | `refine.concurrency` | int ≥1 | The refine lane's own budget, separate from `concurrency`. Default `1`. |
129
+
130
+ ```yaml
131
+ watch:
132
+ repo: owner/name
133
+ label_prefix: spf
134
+ chain: plan-build-test
135
+ refine:
136
+ enabled: true # decompose spf:spec-ready specs into a feature/story tree
137
+ chain: refine
138
+ concurrency: 1
139
+ ```
140
+
141
+ Generated issues carry a second, independent label vocabulary —
142
+ `<prefix>:type:epic|feature|story|bug|task` — seeded by `spf watch init`
143
+ alongside the state labels. A feature/epic (a container: some other node
144
+ names it as `parent`) gets only its type label; a leaf (story/bug/task)
145
+ additionally gets `<prefix>:refined`, so a human can review and promote it to
146
+ `<prefix>:ready` when it's worth building — the refine lane never
147
+ auto-promotes anything.
148
+
149
+
150
+ and every chain run (`spf <chain>` / `spf run`, including watch's own
151
+ per-issue runs). Interactive commands (`doctor`, `list`, `sessions`,
152
+ `phases`, `events`, `init`, `ui`, `migrate`, `eject`, `abort`, `version`)
153
+ never notify — you're already looking at the terminal for those. Off by
154
+ default; adding it is entirely additive.
155
+
156
+ | Field | Type | Meaning |
157
+ |---|---|---|
158
+ | `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. |
159
+ | `timeout_ms` | int | Per-request timeout for a channel's HTTP POST. Default `5000`. |
160
+ | `channels[]` | array | See below. |
161
+
162
+ `channels[].kind`: `"slack"` \| `"teams"` \| `"webhook"`. `channels[].events`
163
+ overrides `events` for just that channel (unset = inherit). `webhook_url_env`
164
+ names the `.env` key holding the secret URL — never the URL itself, matching
165
+ `GITHUB_TOKEN`/`JIRA_API_TOKEN`. Empty/omitted uses the kind's own default:
166
+ `SLACK_WEBHOOK_URL`, `TEAMS_WEBHOOK_URL`, `SPF_WEBHOOK_URL`. `name` is a
167
+ cosmetic label for warning lines when you have two channels of the same
168
+ kind.
169
+
170
+ ```yaml
171
+ notifications:
172
+ events: errors
173
+ channels:
174
+ - kind: slack
175
+ webhook_url_env: SLACK_WEBHOOK_URL # optional; this is the default for slack
176
+ - kind: teams
177
+ events: all # per-channel override
178
+ - kind: webhook
179
+ webhook_url_env: OPS_WEBHOOK_URL
180
+ name: ops-bus
181
+ ```
182
+
183
+ Delivery never blocks or fails a run: a channel with an unset env var is
184
+ skipped with one warning at startup (`spf doctor` reports the same thing as
185
+ a check); a failed POST logs one line and is swallowed, never changing the
186
+ run's exit code. See the main README's "Notifications" section for how to
187
+ get each channel's webhook URL, and `spf init`'s interview, which asks for
188
+ this section and collects the URL straight into `.env`.
189
+
110
190
  ### `agents[]`
111
191
 
112
192
  | Field | Required | Meaning |
@@ -47,9 +47,9 @@ defaults:
47
47
  # provider/model-id strings, which an agent's own model always wins over
48
48
  # defaults.model above — switching coding_agent globally does NOT reset
49
49
  # those three, so they'd run on Claude Code (redirected at Ollama) with a
50
- # model id Ollama has never heard of, and fail outright. builder/scout
51
- # have no model of their own in the packaged roster, so they correctly
52
- # inherit defaults.model above and need no override here.
50
+ # model id Ollama has never heard of, and fail outright. builder/scout/
51
+ # refiner have no model of their own in the packaged roster, so they
52
+ # correctly inherit defaults.model above and need no override here.
53
53
  agents:
54
54
  - name: planner
55
55
  model: qwen3-coder:30b
@@ -39,8 +39,8 @@ defaults:
39
39
  # always wins over defaults.model — switching coding_agent globally does
40
40
  # NOT reset those three, so they'd run on Claude Code with a model id it
41
41
  # can't resolve at all ("There's an issue with the selected model...").
42
- # builder/scout have no model of their own in the packaged roster, so they
43
- # correctly inherit defaults.model above and need no override here.
42
+ # builder/scout/refiner have no model of their own in the packaged roster,
43
+ # so they correctly inherit defaults.model above and need no override here.
44
44
  agents:
45
45
  - name: planner
46
46
  model: sonnet
@@ -64,3 +64,23 @@ agents:
64
64
  # label_prefix: spf
65
65
  # chain: plan-build-test
66
66
  # base_branch: main
67
+ # # Optional second lane: decompose a spf:spec-ready product spec into a
68
+ # # feature/story-or-bug tree of real issues instead of building it
69
+ # # directly. Off by default; needs issue_provider: github (the default
70
+ # # above) — issue authoring isn't implemented for Jira yet.
71
+ # refine:
72
+ # enabled: true
73
+ # chain: refine
74
+ # concurrency: 1
75
+
76
+ # Optional: push notifications for unattended work — spf watch's daemon
77
+ # lifecycle, and every chain run (including watch's own per-issue runs).
78
+ # Interactive commands (doctor, list, sessions, ...) never notify. events:
79
+ # "errors" sends only failures/blocked issues; "all" adds every milestone.
80
+ # The webhook URL is a secret — put it in .env under the key named below,
81
+ # never here. See the main README's "Notifications" section.
82
+ # notifications:
83
+ # events: errors # off (default) | errors | all
84
+ # channels:
85
+ # - kind: slack # slack | teams | webhook
86
+ # webhook_url_env: SLACK_WEBHOOK_URL
@@ -18,4 +18,13 @@ export interface ChainContext {
18
18
  cwd: string;
19
19
  /** The CLI name (`"plan-build-test"`), for session.ensure()'s trace record — see core/session.ts. */
20
20
  chain_name: string;
21
+ /**
22
+ * The originating tracker issue's id, only meaningful to the `refine`
23
+ * chain — `steps.publishIssues()` renders it as a `## Parent: #<id>`
24
+ * back-reference on every issue it creates (`core/refine.ts`'s
25
+ * `renderBody`). `null`/omitted for a manual run with no source issue
26
+ * (a bare `spf refine "<spec text>"`, no `--issue`). Every other chain
27
+ * ignores this field.
28
+ */
29
+ issue_id?: string | null;
21
30
  }
@@ -54,6 +54,11 @@ export const CHAINS = [
54
54
  steps.qualityCheck({ suite: "all" }),
55
55
  ]),
56
56
  stepChain("document", "write up the work that was just done, from the diff", [steps.request(), steps.changes(), steps.document()]),
57
+ stepChain("refine", "decompose a product spec into a feature/story-or-bug tree of tracker issues — spf watch's spec-ready lane", [
58
+ steps.request(),
59
+ steps.refine(),
60
+ steps.publishIssues(),
61
+ ]),
57
62
  {
58
63
  name: "simple-sdlc",
59
64
  describe: "the work is real and its shape is not obvious — plan, build, test, review, document; 3 commits",
@@ -35,6 +35,12 @@ export interface ChainState {
35
35
  changeset: ChangeSet | null;
36
36
  /** HEAD, pinned by request({logBaseline: true}) before the run commits anything. */
37
37
  baseline: string;
38
+ /**
39
+ * The originating tracker issue, only meaningful to `publishIssues()` —
40
+ * lifted from `ChainContext.issue_id` (see its doc comment) because a
41
+ * `Step` only ever sees `(run, state)`, never the `ChainContext` itself.
42
+ */
43
+ issue_id: string | null;
38
44
  /** The run's own acceptance criterion — distinct from "every phase succeeded"; see Run.finish(). */
39
45
  accepted: boolean;
40
46
  /** Why not, when accepted is false — passed straight to run.finish(). */
@@ -109,6 +115,24 @@ export declare function changes(opts?: {
109
115
  }): Step;
110
116
  /** Write up the captured change. Requires a preceding changes() step. */
111
117
  export declare function document(): Step;
118
+ /** Decompose the spec in `prompt` into a feature/story tree — see `RefinedIssueSchema`'s doc comment. Gated so a malformed tree (wrong container/leaf kinds, an unresolved reference, a dependency cycle) re-prompts the same session before publishIssues() ever runs. */
119
+ export declare function refine(): Step;
120
+ /**
121
+ * Create the tree `refine()` produced on the tracker, in dependency order,
122
+ * and link each node to its parent. A `code` phase, not an agent one — the
123
+ * decision-making (topological order, label assignment, `## Blocked by`
124
+ * rendering) is `core/refine.ts`'s job; this step is sequencing only, per
125
+ * SKILL.md's "chains stay thin" rule. Requires a preceding refine() step.
126
+ *
127
+ * Writes what it created to `<context_handoff_dir>/refine_publish.json` —
128
+ * the side channel `cli/commands/watch.ts`'s `runRefine` reads after the
129
+ * chain returns, since a chain's own return value is just an exit code.
130
+ * `spf watch`'s own marker/comment/transition bookkeeping for the spec
131
+ * issue lives entirely in `core/watch.ts`'s `runSpec`, never here — a bare
132
+ * `spf refine` run (no daemon, no spec issue in play) still needs this step
133
+ * to work standalone.
134
+ */
135
+ export declare function publishIssues(): Step;
112
136
  export declare function deriveRequiredAgents(steps: Step[]): string[] | ((options: Record<string, string>) => string[]);
113
137
  export declare function deriveRequiredSuites(steps: Step[]): string[];
114
138
  /** A display string for `spf list` — derived so it can no longer drift from what actually runs. */
@@ -20,16 +20,19 @@
20
20
  * chain tractable: it would only ever need to name steps and pass them
21
21
  * tuning params, never express control flow.
22
22
  */
23
+ import { writeFileSync } from "node:fs";
24
+ import path from "node:path";
23
25
  import * as changesLib from "../core/changes.js";
24
26
  import * as gates from "../core/gates.js";
25
27
  import * as quality from "../core/quality.js";
26
28
  import * as agentsCfg from "../core/agents.js";
27
29
  import * as session from "../core/session.js";
30
+ import * as refineLib from "../core/refine.js";
28
31
  import { DOCUMENT_NOTES } from "../core/prompts.js";
29
- import { BuildOutput, DocumentOutput, GenericOutput, PlanOutput, ReviewOutput, ScoutOutput, makeAgentCall, makeChangeCapture, makePhaseParams, } from "../core/data_types.js";
32
+ import { BuildOutput, DocumentOutput, GenericOutput, PlanOutput, RefineOutput, ReviewOutput, ScoutOutput, makeAgentCall, makeChangeCapture, makePhaseParams, } from "../core/data_types.js";
30
33
  import { Run } from "../core/runner.js";
31
- function makeState(prompt, options) {
32
- return { prompt, options, previous: null, quality: null, review: null, changeset: null, baseline: "", accepted: true, reason: "" };
34
+ function makeState(prompt, options, issueId) {
35
+ return { prompt, options, previous: null, quality: null, review: null, changeset: null, baseline: "", issue_id: issueId, accepted: true, reason: "" };
33
36
  }
34
37
  function makeStep(fn, meta = {}) {
35
38
  const step = fn;
@@ -258,6 +261,54 @@ export function document() {
258
261
  };
259
262
  return makeStep(fn, { requiredAgents: ["documenter"], label: "documenter" });
260
263
  }
264
+ /** Decompose the spec in `prompt` into a feature/story tree — see `RefinedIssueSchema`'s doc comment. Gated so a malformed tree (wrong container/leaf kinds, an unresolved reference, a dependency cycle) re-prompts the same session before publishIssues() ever runs. */
265
+ export function refine() {
266
+ return agentStep({
267
+ name: "refine",
268
+ owner: "refiner",
269
+ output_type: RefineOutput,
270
+ description: "Decompose the spec into a feature/story tree of vertical slices",
271
+ gates: [gates.refinementWellFormed],
272
+ });
273
+ }
274
+ /**
275
+ * Create the tree `refine()` produced on the tracker, in dependency order,
276
+ * and link each node to its parent. A `code` phase, not an agent one — the
277
+ * decision-making (topological order, label assignment, `## Blocked by`
278
+ * rendering) is `core/refine.ts`'s job; this step is sequencing only, per
279
+ * SKILL.md's "chains stay thin" rule. Requires a preceding refine() step.
280
+ *
281
+ * Writes what it created to `<context_handoff_dir>/refine_publish.json` —
282
+ * the side channel `cli/commands/watch.ts`'s `runRefine` reads after the
283
+ * chain returns, since a chain's own return value is just an exit code.
284
+ * `spf watch`'s own marker/comment/transition bookkeeping for the spec
285
+ * issue lives entirely in `core/watch.ts`'s `runSpec`, never here — a bare
286
+ * `spf refine` run (no daemon, no spec issue in play) still needs this step
287
+ * to work standalone.
288
+ */
289
+ export function publishIssues() {
290
+ const fn = async (run, state) => {
291
+ const envelope = state.previous;
292
+ if (!envelope || !Array.isArray(envelope.issues)) {
293
+ throw new Error("publishIssues() requires a preceding refine() step in the chain's step list");
294
+ }
295
+ await run.phase(makePhaseParams({
296
+ name: "publish",
297
+ kind: "code",
298
+ owner: "tracker",
299
+ description: "Create the feature/story tree on the tracker, in dependency order, and link each to its parent",
300
+ }), async (ph) => {
301
+ const tracker = refineLib.resolveAuthoringProvider(run.cfg);
302
+ const created = await refineLib.publish(tracker, envelope.issues, {
303
+ labelPrefix: run.cfg.watch.label_prefix,
304
+ specIssueId: state.issue_id,
305
+ });
306
+ writeFileSync(path.join(run.context_handoff_dir, "refine_publish.json"), JSON.stringify(created.map((c) => ({ id: c.issue.id, title: c.issue.title, kind: c.kind, isLeaf: c.isLeaf })), null, 2));
307
+ ph.log({ created: created.length, leaves: created.filter((c) => c.isLeaf).length });
308
+ });
309
+ };
310
+ return makeStep(fn, { label: "code(publish)" });
311
+ }
261
312
  // ── layer 3: derive ChainDefinition fields from a step list ─────────────
262
313
  export function deriveRequiredAgents(steps) {
263
314
  const dynamicSteps = steps.filter((s) => typeof s.requiredAgents === "function");
@@ -291,7 +342,7 @@ export function derivePhases(steps) {
291
342
  /** Run a chain's step list start to finish: prologue, every step in order, then run.finish(). */
292
343
  export async function runSteps(ctx, requiredAgents, requiredSuites, steps, options = {}) {
293
344
  const run = startRun(ctx, requiredAgents, requiredSuites);
294
- const state = makeState(ctx.prompt, options);
345
+ const state = makeState(ctx.prompt, options, ctx.issue_id ?? null);
295
346
  for (const step of steps) {
296
347
  await step(run, state);
297
348
  }