@gr8ful/spf 0.5.1 → 0.7.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 +168 -29
- package/assets/defaults/spf.config.yaml +68 -0
- package/assets/prompts/refiner/system.md +42 -6
- package/assets/prompts/refiner/user.md +46 -8
- package/assets/skill/SKILL.md +1 -0
- package/assets/skill/references/config.md +192 -5
- package/assets/templates/ts-flue-ollama.spf.config.yaml +25 -0
- package/assets/templates/ts.spf.config.yaml +12 -2
- package/dist/chains/index.d.ts +11 -0
- package/dist/chains/index.js +38 -3
- package/dist/chains/repo_chains.js +1 -0
- package/dist/chains/simple_sdlc.js +1 -1
- package/dist/chains/steps.d.ts +8 -17
- package/dist/chains/steps.js +102 -14
- package/dist/cli/commands/doctor.js +112 -3
- package/dist/cli/commands/estimate.d.ts +82 -0
- package/dist/cli/commands/estimate.js +317 -0
- package/dist/cli/commands/fanout.d.ts +40 -0
- package/dist/cli/commands/fanout.js +401 -0
- package/dist/cli/commands/init.js +23 -1
- package/dist/cli/commands/run.js +9 -2
- package/dist/cli/commands/trace.d.ts +18 -0
- package/dist/cli/commands/trace.js +22 -3
- package/dist/cli/commands/watch.d.ts +8 -0
- package/dist/cli/commands/watch.js +95 -33
- package/dist/cli/index.js +13 -1
- package/dist/cli/interview.js +9 -5
- package/dist/core/agents.d.ts +56 -0
- package/dist/core/agents.js +152 -1
- package/dist/core/data_types.d.ts +340 -5
- package/dist/core/data_types.js +185 -5
- package/dist/core/fanout.d.ts +229 -0
- package/dist/core/fanout.js +313 -0
- package/dist/core/gates.d.ts +8 -0
- package/dist/core/gates.js +48 -2
- package/dist/core/git_helper.d.ts +98 -0
- package/dist/core/git_helper.js +127 -0
- package/dist/core/issues/github_provider.d.ts +46 -6
- package/dist/core/issues/github_provider.js +120 -5
- package/dist/core/issues/jira_provider.d.ts +83 -12
- package/dist/core/issues/jira_provider.js +110 -4
- package/dist/core/issues/provider.d.ts +124 -19
- package/dist/core/issues/provider.js +24 -7
- package/dist/core/notify/channel.d.ts +1 -1
- package/dist/core/notify/notifier.d.ts +16 -0
- package/dist/core/notify/notifier.js +36 -0
- package/dist/core/otel.d.ts +64 -9
- package/dist/core/otel.js +78 -14
- package/dist/core/refine.d.ts +45 -8
- package/dist/core/refine.js +98 -24
- package/dist/core/runner.d.ts +8 -0
- package/dist/core/runner.js +7 -0
- package/dist/core/session.d.ts +25 -0
- package/dist/core/session.js +97 -28
- package/dist/core/tiering.d.ts +145 -0
- package/dist/core/tiering.js +235 -0
- package/dist/core/tracer.d.ts +10 -0
- package/dist/core/tracer.js +12 -0
- package/dist/core/watch.d.ts +174 -11
- package/dist/core/watch.js +531 -33
- package/dist/ui/server/db.d.ts +39 -0
- package/dist/ui/server/db.js +61 -0
- package/package.json +2 -1
- package/dist/test/agent_cc.test.d.ts +0 -1
- package/dist/test/agent_cc.test.js +0 -95
- package/dist/test/agent_flue.test.d.ts +0 -1
- package/dist/test/agent_flue.test.js +0 -83
- package/dist/test/chains.test.d.ts +0 -12
- package/dist/test/chains.test.js +0 -92
- package/dist/test/data_types.test.d.ts +0 -10
- package/dist/test/data_types.test.js +0 -220
- package/dist/test/env_file.test.d.ts +0 -1
- package/dist/test/env_file.test.js +0 -74
- package/dist/test/fake_asker.d.ts +0 -23
- package/dist/test/fake_asker.js +0 -30
- package/dist/test/git_helper.test.d.ts +0 -1
- package/dist/test/git_helper.test.js +0 -59
- package/dist/test/hermetic_git.d.ts +0 -1
- package/dist/test/hermetic_git.js +0 -22
- package/dist/test/init_command.test.d.ts +0 -14
- package/dist/test/init_command.test.js +0 -136
- package/dist/test/interview.test.d.ts +0 -15
- package/dist/test/interview.test.js +0 -425
- package/dist/test/notify.test.d.ts +0 -1
- package/dist/test/notify.test.js +0 -174
- package/dist/test/ollama_provider.test.d.ts +0 -1
- package/dist/test/ollama_provider.test.js +0 -103
- package/dist/test/otel.test.d.ts +0 -26
- package/dist/test/otel.test.js +0 -512
- package/dist/test/paths.test.d.ts +0 -1
- package/dist/test/paths.test.js +0 -68
- package/dist/test/refine.test.d.ts +0 -1
- package/dist/test/refine.test.js +0 -189
- package/dist/test/repo_chains.test.d.ts +0 -21
- package/dist/test/repo_chains.test.js +0 -416
- package/dist/test/signoff.test.d.ts +0 -1
- package/dist/test/signoff.test.js +0 -329
- package/dist/test/ui_server.test.d.ts +0 -7
- package/dist/test/ui_server.test.js +0 -120
- package/dist/test/watch.test.d.ts +0 -1
- package/dist/test/watch.test.js +0 -687
|
@@ -12,10 +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`/`watch`/`notifications`/`review`
|
|
15
|
+
field by field (`defaults`/`observability`/`quality`/`watch`/`notifications`/`review`/`tiering`
|
|
16
16
|
merge key-by-key — `notifications.channels` replaces wholesale, same as
|
|
17
|
-
`quality.checks`; `agents` merges by
|
|
18
|
-
entry, a new name appends).
|
|
17
|
+
`quality.checks` and `tiering.tiers`/`tiering.roles`; `agents` merges by
|
|
18
|
+
`name`: a matching name patches that entry, a new name appends).
|
|
19
19
|
3. An explicit `--config <path>` replaces both — standalone, no built-in
|
|
20
20
|
underneath it.
|
|
21
21
|
|
|
@@ -76,6 +76,29 @@ agents:
|
|
|
76
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[]`). |
|
|
77
77
|
| `protected_files` | string[] | Paths no agent may touch unless named in its own `writes`. Default `[".spf/", "spf.config.yaml"]`. |
|
|
78
78
|
| `data_dir` | path | Runtime home, repo-relative. Default `.spf/data`. |
|
|
79
|
+
| `max_run_cost` | number > 0 (USD) | Stops the NEXT agent call once a run has already spent this much — checked before each call, never after. A single call is never capped (a run can overshoot by one whole call), and a chain with only one agent dispatch (`scout`, `prompt`, `build`) can never trip it at all. Absent (default) = unbounded. Throws `BudgetExceeded`, which fails the phase closed. |
|
|
80
|
+
| `max_run_tokens` | integer > 0 | Same semantics as `max_run_cost`, on `sessions.total_tokens` instead of cost. Absent (default) = unbounded. |
|
|
81
|
+
|
|
82
|
+
### `spf fanout`
|
|
83
|
+
|
|
84
|
+
`spf fanout <chain> "<prompt>" [--n 3] [--concurrency N] [--base <branch>]
|
|
85
|
+
[--adw-id <id>] [--first-success] [--config <path>] [--cwd <dir>]` — best-of-N:
|
|
86
|
+
runs `<chain>` N times in parallel, each in its own git worktree and branch
|
|
87
|
+
(`spf/fanout/<base-adw-id>-<i>`), then picks ONE winner by code (succeeded >
|
|
88
|
+
fewest gate failures > most gate passes > lowest cost > fewest tokens >
|
|
89
|
+
lowest wall time > adw_id) and deletes the rest. SPF never merges the
|
|
90
|
+
winner — it prints the branch and the basis; a human runs `git merge`. The
|
|
91
|
+
chain named MUST have a commit step (`plan-build`, `plan-build-test`,
|
|
92
|
+
`plan-build-test-quality`, `simple-sdlc`, or a repo-local chain with one) —
|
|
93
|
+
`spf fanout` refuses any chain without one, since a winner with no commit
|
|
94
|
+
leaves its only copy of the work as uncommitted edits in a worktree.
|
|
95
|
+
`--first-success` opts into stopping early once one attempt succeeds
|
|
96
|
+
(first-past-the-post) instead of the default, which runs every attempt to
|
|
97
|
+
completion for a true N-way comparison. `spf fanout --clean <base-adw-id>`
|
|
98
|
+
removes any worktrees/branches a killed or discarded run left behind under
|
|
99
|
+
that id — a `--adw-id` is never safe to reuse against session rows still in
|
|
100
|
+
the shared db, so a rerun with the same id fails loudly instead of mixing
|
|
101
|
+
the two runs' cost/tokens/gates together.
|
|
79
102
|
|
|
80
103
|
### `observability`
|
|
81
104
|
|
|
@@ -130,8 +153,10 @@ Full mechanism: the main README's "`spf watch`" section. Field reference:
|
|
|
130
153
|
| `base_branch` | string | Branch worktrees fork from and PRs target. Default `main`. |
|
|
131
154
|
| `poll_ms` | int | Tick interval. Default `60000`. |
|
|
132
155
|
| `concurrency` | int ≥1 | Max issues claimed and run at once, the build lane's own budget (independent of `refine.concurrency`). Default `2`. |
|
|
156
|
+
| `chain_options` | map of string -> string | Options passed straight through to `chain` (and `refine.chain`) for every unattended dispatch — the same shape an interactive `spf <chain> --suite <name>` builds, e.g. `{suite: strict}` or `{agent: some-agent}`. Default `{}`. Only useful for a chain whose behavior actually reads the option (a step-derived chain's `--suite`; an imperative chain ignores an option it doesn't know about). |
|
|
133
157
|
| `jira.base_url` / `jira.project_key` | string | Only consulted when `issue_provider: jira`. |
|
|
134
|
-
| `
|
|
158
|
+
| `jira.issue_types` | map: `epic`/`feature`/`story`/`bug`/`task` -> string | Only consulted when `issue_provider: jira` AND `refine.enabled`. What each `RefinedIssue.kind` creates as on Jira — defaults `epic`/`feature` → `Epic`, `story` → `Story`, `bug` → `Bug`, `task` → `Task`, overridable per kind. Validated against the real project by both `spf watch init` and `spf watch`'s own startup check. |
|
|
159
|
+
| `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` or `"jira"` — both implement issue authoring (create + link a hierarchy); any other value fails loudly at startup. |
|
|
135
160
|
| `refine.chain` | string | Which registered chain runs per claimed spec. Default `refine`. |
|
|
136
161
|
| `refine.concurrency` | int ≥1 | The refine lane's own budget, separate from `concurrency`. Default `1`. |
|
|
137
162
|
|
|
@@ -140,6 +165,8 @@ watch:
|
|
|
140
165
|
repo: owner/name
|
|
141
166
|
label_prefix: spf
|
|
142
167
|
chain: plan-build-test
|
|
168
|
+
chain_options:
|
|
169
|
+
suite: strict # only if plan-build-test's chain declares a step-derived requiredSuites
|
|
143
170
|
refine:
|
|
144
171
|
enabled: true # decompose spf:spec-ready specs into a feature/story tree
|
|
145
172
|
chain: refine
|
|
@@ -154,6 +181,61 @@ additionally gets `<prefix>:refined`, so a human can review and promote it to
|
|
|
154
181
|
`<prefix>:ready` when it's worth building — the refine lane never
|
|
155
182
|
auto-promotes anything.
|
|
156
183
|
|
|
184
|
+
A third label vocabulary — `<prefix>:priority:p0|p1|p2|p3` — is what
|
|
185
|
+
`claimNewWork` actually schedules `<prefix>:ready` issues by: priority first,
|
|
186
|
+
then sibling affinity (a leaf whose parent already has a sibling in flight
|
|
187
|
+
goes first), then creation order. The refiner proposes a priority per node
|
|
188
|
+
(clamped to the spec's own priority, when the spec issue has one, as a
|
|
189
|
+
ceiling — never a floor); a human can relabel before promoting, which is the
|
|
190
|
+
entire override mechanism. Independently, a `<prefix>:ready` leaf whose
|
|
191
|
+
`blocked_by` isn't fully `<prefix>:done` is skipped until it is — the
|
|
192
|
+
frontier check. This label is separate from any GitHub Projects v2
|
|
193
|
+
"Priority" field a repo's board might have; `spf watch` never touches
|
|
194
|
+
Projects v2, so the two are unreconciled if you use both.
|
|
195
|
+
|
|
196
|
+
The `refine` chain grounds its decomposition with a `scout` phase before the
|
|
197
|
+
refiner runs, so `scout` is a required agent for it — a roster that pruned
|
|
198
|
+
it fails `spf watch` startup by name.
|
|
199
|
+
|
|
200
|
+
The refine lane's own state machine has an extra loop beyond
|
|
201
|
+
`spec-ready → refining → spec-in-progress → done`/`blocked`: when the
|
|
202
|
+
refiner raises material ambiguity instead of a tree (see
|
|
203
|
+
`assets/prompts/refiner/system.md`'s "Ask, don't decide"), the spec moves to
|
|
204
|
+
`<prefix>:needs-feedback` with a comment naming its questions, instead of
|
|
205
|
+
publishing anything. A human answers in the issue's comments and adds
|
|
206
|
+
`<prefix>:continue-refinement`; `spf watch` claims that label back into
|
|
207
|
+
`refining` and resumes the **same** `adw_id` — the comment thread (split
|
|
208
|
+
into "answers to your open questions" and "earlier discussion") is folded
|
|
209
|
+
into the resumed prompt, and the refiner's own coding-agent session
|
|
210
|
+
continues rather than starting cold. This can loop any number of rounds.
|
|
211
|
+
|
|
212
|
+
Publishing a tree does NOT mean the spec is done: `<prefix>:spec-in-progress`
|
|
213
|
+
is where a spec lands right after publish, and it stays there — with a
|
|
214
|
+
summary comment listing every issue created — until **every one of those
|
|
215
|
+
issues** is itself `<prefix>:done` (`finishTrackedSpecs`, polled every tick,
|
|
216
|
+
reads `WatchMarker.refined` back to check). Only then does it move to
|
|
217
|
+
`<prefix>:done`, get a closing comment, and close on the tracker (GitHub
|
|
218
|
+
only, best-effort) — firing `spec_done`, distinct from the earlier
|
|
219
|
+
`spec_refined`. This exists so a spec's tracker status means what a product
|
|
220
|
+
manager expects it to mean: "done" is the work being finished, not merely
|
|
221
|
+
having been decomposed. `spf watch init` must be re-run after upgrading to
|
|
222
|
+
this version to seed the new labels.
|
|
223
|
+
|
|
224
|
+
Container roll-up mirrors that same "close, don't just label" behavior for a
|
|
225
|
+
generated feature/epic: once every child under it carries `<prefix>:done`
|
|
226
|
+
(checked reactively, each time a leaf's PR merges), the container gets a
|
|
227
|
+
summary comment and transitions to `<prefix>:done` — then the check repeats
|
|
228
|
+
on ITS OWN parent, so an epic rolls up once its last feature does. A
|
|
229
|
+
container with one unfinished child (including a leaf still sitting at
|
|
230
|
+
`<prefix>:refined`, never promoted) is left alone; nothing forces it. This
|
|
231
|
+
needs a native hierarchy read-back — GitHub's sub-issues API, or a Jira
|
|
232
|
+
`parent = "<id>"` JQL search — so it works on both today; a tracker that
|
|
233
|
+
implements neither gets a logged no-op, not a startup failure, since the
|
|
234
|
+
build lane still works fine without roll-up. Whether the container actually
|
|
235
|
+
CLOSES on the tracker (not just relabels) depends on `closeIssue` being
|
|
236
|
+
implemented at all — GitHub yes, Jira no (its own resolution workflow isn't
|
|
237
|
+
wired up), same asymmetry as leaf and spec completion already have on Jira.
|
|
238
|
+
|
|
157
239
|
### `notifications`
|
|
158
240
|
|
|
159
241
|
Optional outbound push for unattended work — `spf watch`'s daemon lifecycle,
|
|
@@ -165,7 +247,7 @@ default; adding it is entirely additive.
|
|
|
165
247
|
|
|
166
248
|
| Field | Type | Meaning |
|
|
167
249
|
|---|---|---|
|
|
168
|
-
| `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. |
|
|
250
|
+
| `events` | `"off"` \| `"errors"` \| `"all"` | The whole filter. `off` (default): nothing. `errors`: only failed runs/phases, blocked issues, watch errors, and a spec needing feedback (`spec_needs_feedback` — `error`-level on purpose, same class as a blocked issue). `all`: every curated milestone (run started, issue claimed, PR opened, ...) plus errors. |
|
|
169
251
|
| `timeout_ms` | int | Per-request timeout for a channel's HTTP POST. Default `5000`. |
|
|
170
252
|
| `channels[]` | array | See below. |
|
|
171
253
|
|
|
@@ -224,6 +306,111 @@ the repo (never `ENGINEER_NAME`/`$USER`, which are spoofable and fall back to
|
|
|
224
306
|
the literal string `"engineer"`) — an unattended run's AI-only commit never
|
|
225
307
|
carries one, because nobody said yes to attest to.
|
|
226
308
|
|
|
309
|
+
### `tiering`
|
|
310
|
+
|
|
311
|
+
Risk-tiered per-role model routing (SPF #14) — a run-global cost/strength
|
|
312
|
+
shift on top of the static roster, decided ONCE at run start from signals
|
|
313
|
+
code can read without asking an agent anything (the chain's name and the
|
|
314
|
+
prompt's word count), never re-evaluated mid-run. **OFF by default, and
|
|
315
|
+
`enabled: false`/absent is a TOTAL no-op**: every agent dispatches at
|
|
316
|
+
exactly the model its roster entry names, byte-identical to before this key
|
|
317
|
+
existed — the same discipline `defaults.max_run_cost`/`max_run_tokens` hold
|
|
318
|
+
themselves to.
|
|
319
|
+
|
|
320
|
+
**Top-level, not nested under `defaults:`.** `loadConfig`'s back-fill loop
|
|
321
|
+
copies a fixed list of `defaults` keys down onto every agent that hasn't set
|
|
322
|
+
them, and a stray copy would be silently *stripped* at parse rather than
|
|
323
|
+
rejected — a top-level key sits outside that loop entirely, so the trap
|
|
324
|
+
cannot apply here. Merges **key-by-key** with a base config, same as
|
|
325
|
+
`review`/`notifications`: a repo that only flips `enabled: true` keeps the
|
|
326
|
+
base's `tiers`/`roles`; `tiers` and `roles` are each a **whole replace** on
|
|
327
|
+
override (a repo's own ladder replaces the packaged one wholesale, never an
|
|
328
|
+
unordered splice of both).
|
|
329
|
+
|
|
330
|
+
| Field | Type | Meaning |
|
|
331
|
+
|---|---|---|
|
|
332
|
+
| `enabled` | bool | Default `false`. `true` turns on the ladder walk described below; `false` (or the key absent) is a total no-op, checked nowhere and dispatched nowhere. |
|
|
333
|
+
| `tiers` | array, WEAKEST FIRST | The ladder. A risk level shifts every routed role UP or DOWN this list by the same step — order is the whole semantics, which is why this is a sequence and not a mapping. Default `[]`. |
|
|
334
|
+
| `tiers[].name` | string | What `roles` values point at. |
|
|
335
|
+
| `tiers[].coding_agent` | `"flue"` \| `"claude_code"` | Which backend's vocabulary this rung's `model` speaks. Default `flue`, same default an agent's own `coding_agent` uses. A tier changes an agent's `model` and **nothing else** — `coding_agent` always stays the agent's own — so a rung can only route roles whose backend matches (**rule T**, below). |
|
|
336
|
+
| `tiers[].model` | string | Same vocabulary as an agent's own `model:` for that backend: `provider/model-id` for `flue`, Claude Code's bare alias/full name for `claude_code`. No per-provider table — for `flue` the provider is already the string's first segment. |
|
|
337
|
+
| `roles` | map of agent name -> tier name | The baseline tier per **role**. Naming an agent here is the operator's statement "route this one by tier" — the resolved tier then wins over that agent's own `model:`. An agent **not** named here is never retiered; its `model:` stands, untouched. That is the whole precedence rule. Default `{}`. |
|
|
338
|
+
|
|
339
|
+
```yaml
|
|
340
|
+
tiering:
|
|
341
|
+
enabled: true
|
|
342
|
+
tiers:
|
|
343
|
+
- { name: cheap, coding_agent: flue, model: ollama/granite4.1:8b }
|
|
344
|
+
- { name: strong, coding_agent: flue, model: ollama/qwen3.8:27b-mlx }
|
|
345
|
+
roles:
|
|
346
|
+
scout: cheap
|
|
347
|
+
builder: strong
|
|
348
|
+
reviewer: strong
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
**The risk signal.** Two integer-weighted signals, summed: the chain's own
|
|
352
|
+
kind (`scout`/`prompt`/`document`/`quality` weigh `-1`; `simple-sdlc`/
|
|
353
|
+
`plan-build-test-quality`/`refine` weigh `+1`; everything else, including
|
|
354
|
+
every repo-local `.spf/chains/*.yaml` chain, weighs `0`) and the prompt's
|
|
355
|
+
word count (`-1` at ≤60 words, `+1` at ≥400, `0` between). The sum
|
|
356
|
+
classifies to `low`/`standard`/`high`: reaching `high` needs only one
|
|
357
|
+
signal to agree (a long prompt forces `high` outright), reaching `low`
|
|
358
|
+
needs both to agree — demoting a role a rung is the expensive mistake (a
|
|
359
|
+
wrong answer, a whole re-run); promoting one is the cheap mistake (some
|
|
360
|
+
extra tokens), so the classifier is deliberately asymmetric toward the
|
|
361
|
+
cheap failure. `standard` (step `0`) leaves every role on its configured
|
|
362
|
+
baseline rung.
|
|
363
|
+
|
|
364
|
+
**Rule T — the backend-compatibility rule.** `coding_agent` is a per-agent
|
|
365
|
+
field and model vocabulary is backend-dependent, so a tier is only usable
|
|
366
|
+
for a role whose `coding_agent` matches the tier's own declared
|
|
367
|
+
`coding_agent`. `enabled: true` on a repo that inherited the packaged
|
|
368
|
+
flue-shaped ladder (every repo does, via key-by-key merge, until it
|
|
369
|
+
declares its own) while running a `claude_code` roster fails **loudly, by
|
|
370
|
+
name** at `agents.validate()` time, before anything spawns — never a silent
|
|
371
|
+
skip, and never a `fireworks/...` id handed to Claude Code.
|
|
372
|
+
|
|
373
|
+
**Degradation.** A rung that is unusable — a model tag `probeServedOllamaTags`
|
|
374
|
+
found absent from a local Ollama server's `/models` list, or a rule-T
|
|
375
|
+
backend mismatch — is walked **down**, never up: a degradation must never
|
|
376
|
+
silently escalate spend. If every rung at or below the target is unusable,
|
|
377
|
+
the role dispatches on its own configured `model:` unchanged, plus one noted
|
|
378
|
+
line. The availability probe **fails open**: an unreachable server drops
|
|
379
|
+
nothing rather than silently downgrading every agent to the bottom rung.
|
|
380
|
+
|
|
381
|
+
**Visibility.** Because a retiered agent's *configured* `model:` (what
|
|
382
|
+
`spf.config.yaml` says) and its *effective* model (what actually dispatched)
|
|
383
|
+
can now disagree, every run traces one `tiering` log event carrying the full
|
|
384
|
+
resolved routing, and prints one console line per retiered agent
|
|
385
|
+
(`[spf] tiering <agent> <tier> (<configured> -> <effective>) risk=<risk>`).
|
|
386
|
+
`spf doctor` reports the same resolved ladder and every routed role's
|
|
387
|
+
effective model when `tiering.enabled` — see below.
|
|
388
|
+
|
|
389
|
+
`spf init`'s interview does not write this block: it collects one model for
|
|
390
|
+
the whole roster, and a `tiering` block over a single-model roster is a
|
|
391
|
+
no-op by construction. `spf init --template ts-flue-ollama` is the one
|
|
392
|
+
packaged template that ships it **on**, with two tags measured live against
|
|
393
|
+
a real local Ollama server; the non-interactive starter (`--yes` / piped
|
|
394
|
+
stdin) shows the shape commented out. Every other packaged template
|
|
395
|
+
inherits the built-in default's populated-but-`enabled: false` ladder
|
|
396
|
+
through key-by-key merge — harmless until a repo opts in, and invisible to
|
|
397
|
+
`spf doctor` until it does (see below).
|
|
398
|
+
|
|
399
|
+
**`spf doctor`**, only when `cfg.tiering.enabled` — a disabled ladder is
|
|
400
|
+
invisible to doctor, on purpose, so a repo that never opted in (which is
|
|
401
|
+
every packaged template except `ts-flue-ollama`, plus every `spf init`
|
|
402
|
+
starter) can't fail on an unset provider key for a rung that will never
|
|
403
|
+
dispatch:
|
|
404
|
+
|
|
405
|
+
- the same provider-key check the roster gets, run over `tiers[].model`
|
|
406
|
+
instead, branching on **the tier's own** `coding_agent`;
|
|
407
|
+
- the `OLLAMA_BASE_URL` reachability probe now also fires for an `ollama/*`
|
|
408
|
+
rung even when no `cfg.agents[]` entry itself names one;
|
|
409
|
+
- a new report section: the resolved ladder, every routed role's effective
|
|
410
|
+
model (including roles whose effective model equals their configured
|
|
411
|
+
one), any degradation note, and — probed the same way `probeServedOllamaTags`
|
|
412
|
+
does, fail-open — which `ollama/*` rungs are and aren't in `ollama list`.
|
|
413
|
+
|
|
227
414
|
### `agents[]`
|
|
228
415
|
|
|
229
416
|
| Field | Required | Meaning |
|
|
@@ -65,3 +65,28 @@ agents:
|
|
|
65
65
|
# a 3B model — a lighter tag here is a real, not merely theoretical,
|
|
66
66
|
# option.
|
|
67
67
|
model: ollama/granite4.1:8b
|
|
68
|
+
|
|
69
|
+
# Risk-tiered per-role model routing (SPF #14) — ON, local only. The two
|
|
70
|
+
# tags below are the MEASURED ones (last session, live on localhost:11434)
|
|
71
|
+
# and they ALREADY appear in this template today — ollama/qwen3.8:27b-mlx at
|
|
72
|
+
# :48 (defaults.model), :58 (planner), :60 (reviewer) and
|
|
73
|
+
# ollama/granite4.1:8b at :67 (documenter). This block introduces no new
|
|
74
|
+
# model string.
|
|
75
|
+
tiering:
|
|
76
|
+
enabled: true
|
|
77
|
+
# coding_agent: flue matches this template's `defaults.coding_agent: flue`
|
|
78
|
+
# (:46) — every roster agent here is flue, so every role is routable by
|
|
79
|
+
# these rungs. Stated explicitly rather than left to the default, because
|
|
80
|
+
# it is the field that keeps a rung from being handed to a backend that
|
|
81
|
+
# cannot parse it (rule T — see assets/skill/references/config.md's
|
|
82
|
+
# "tiering" section).
|
|
83
|
+
tiers:
|
|
84
|
+
- { name: scout, coding_agent: flue, model: ollama/granite4.1:8b } # cheap read/summarize/triage
|
|
85
|
+
- { name: builder, coding_agent: flue, model: ollama/qwen3.8:27b-mlx } # first-attempt implementation
|
|
86
|
+
roles:
|
|
87
|
+
scout: scout
|
|
88
|
+
documenter: scout
|
|
89
|
+
planner: builder
|
|
90
|
+
builder: builder
|
|
91
|
+
refiner: builder
|
|
92
|
+
reviewer: builder
|
|
@@ -69,10 +69,20 @@ agents:
|
|
|
69
69
|
# label_prefix: spf
|
|
70
70
|
# chain: plan-build-test
|
|
71
71
|
# base_branch: main
|
|
72
|
+
# # Options threaded straight through to `chain` (and refine.chain below)
|
|
73
|
+
# # for every unattended dispatch — the same shape an interactive
|
|
74
|
+
# # `spf <chain> --suite <name>` builds. Only useful for a chain whose
|
|
75
|
+
# # behavior actually reads the option; empty/omitted by default.
|
|
76
|
+
# # chain_options:
|
|
77
|
+
# # suite: strict
|
|
72
78
|
# # Optional second lane: decompose a spf:spec-ready product spec into a
|
|
73
79
|
# # feature/story-or-bug tree of real issues instead of building it
|
|
74
|
-
# # directly. Off by default; needs issue_provider: github
|
|
75
|
-
# #
|
|
80
|
+
# # directly. Off by default; needs issue_provider: github OR jira — both
|
|
81
|
+
# # support issue authoring (create + link a hierarchy). On Jira, each
|
|
82
|
+
# # RefinedIssue.kind maps to a real Jira issue type via
|
|
83
|
+
# # watch.jira.issue_types (defaults: epic/feature -> Epic, story -> Story,
|
|
84
|
+
# # bug -> Bug, task -> Task) — spf watch init validates this against the
|
|
85
|
+
# # real project when refine is enabled.
|
|
76
86
|
# refine:
|
|
77
87
|
# enabled: true
|
|
78
88
|
# chain: refine
|
package/dist/chains/index.d.ts
CHANGED
|
@@ -118,5 +118,16 @@ export declare function resolveRequiredSuites(chain: ChainDefinition, options: R
|
|
|
118
118
|
* CLI dispatch sites (`spf <chain>` and `spf watch`) go through this, never
|
|
119
119
|
* `chain.run(...)` directly — the whole reason to route through here is that
|
|
120
120
|
* a `steps`-only chain has no `run` to call.
|
|
121
|
+
*
|
|
122
|
+
* Also the one call site both dispatch paths share on the way OUT — success
|
|
123
|
+
* or thrown error alike, `finally` runs either way — which is what makes it
|
|
124
|
+
* the right seam for `otel.releaseOtelExporter()` (see otel.ts's RUN-SCOPED
|
|
125
|
+
* CLEANUP note / #26): a `spf watch` daemon calls this once per issue, in
|
|
126
|
+
* the same process, for as long as it runs, so a run's exporter has to be
|
|
127
|
+
* dropped here rather than living until the whole daemon exits. `session.
|
|
128
|
+
* finalize()` is the same idea for the run itself — it stops the process-
|
|
129
|
+
* wide signal handler from reaching this (now finished) run, and closes its
|
|
130
|
+
* Tracer's sqlite handle, so a long `spf watch` daemon does not hold every
|
|
131
|
+
* issue it has ever processed reachable for the rest of its life.
|
|
121
132
|
*/
|
|
122
133
|
export declare function runChain(chain: ChainDefinition, ctx: ChainContext, options?: Record<string, string>): Promise<number>;
|
package/dist/chains/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import * as steps from "./steps.js";
|
|
2
2
|
import * as simpleSdlc from "./simple_sdlc.js";
|
|
3
|
+
import * as otel from "../core/otel.js";
|
|
4
|
+
import * as session from "../core/session.js";
|
|
3
5
|
/**
|
|
4
6
|
* Build a step-based ChainDefinition, deriving phases/requiredAgents/
|
|
5
7
|
* requiredSuites from its steps.
|
|
@@ -66,6 +68,15 @@ export const CHAINS = [
|
|
|
66
68
|
stepChain("document", "write up the work that was just done, from the diff", [steps.request(), steps.changes(), steps.document()]),
|
|
67
69
|
stepChain("refine", "decompose a product spec into a feature/story-or-bug tree of tracker issues — spf watch's spec-ready lane", [
|
|
68
70
|
steps.request(),
|
|
71
|
+
// Grounds the decomposition in code that actually exists, rather than
|
|
72
|
+
// leaving "explore the codebase" as prose the refiner may or may not
|
|
73
|
+
// follow (see assets/prompts/refiner/system.md). Its ScoutOutput flows
|
|
74
|
+
// into refine() as `previous` — agentStep() threads state.previous into
|
|
75
|
+
// every agent phase, and refiner/user.md already renders it as
|
|
76
|
+
// {{previous_envelope}}. This makes `scout` a required agent for this
|
|
77
|
+
// chain: a roster that pruned it fails agents.validate() by name at
|
|
78
|
+
// `spf watch` startup, same as any other missing required agent.
|
|
79
|
+
steps.scout({ description: "Map the subsystems this spec touches — change nothing" }),
|
|
69
80
|
steps.refine(),
|
|
70
81
|
steps.publishIssues(),
|
|
71
82
|
]),
|
|
@@ -147,9 +158,33 @@ export function resolveRequiredSuites(chain, options) {
|
|
|
147
158
|
* CLI dispatch sites (`spf <chain>` and `spf watch`) go through this, never
|
|
148
159
|
* `chain.run(...)` directly — the whole reason to route through here is that
|
|
149
160
|
* a `steps`-only chain has no `run` to call.
|
|
161
|
+
*
|
|
162
|
+
* Also the one call site both dispatch paths share on the way OUT — success
|
|
163
|
+
* or thrown error alike, `finally` runs either way — which is what makes it
|
|
164
|
+
* the right seam for `otel.releaseOtelExporter()` (see otel.ts's RUN-SCOPED
|
|
165
|
+
* CLEANUP note / #26): a `spf watch` daemon calls this once per issue, in
|
|
166
|
+
* the same process, for as long as it runs, so a run's exporter has to be
|
|
167
|
+
* dropped here rather than living until the whole daemon exits. `session.
|
|
168
|
+
* finalize()` is the same idea for the run itself — it stops the process-
|
|
169
|
+
* wide signal handler from reaching this (now finished) run, and closes its
|
|
170
|
+
* Tracer's sqlite handle, so a long `spf watch` daemon does not hold every
|
|
171
|
+
* issue it has ever processed reachable for the rest of its life.
|
|
150
172
|
*/
|
|
151
173
|
export async function runChain(chain, ctx, options = {}) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
174
|
+
try {
|
|
175
|
+
if (chain.run)
|
|
176
|
+
return await chain.run(ctx, options);
|
|
177
|
+
return await steps.runSteps(ctx, resolveRequiredAgents(chain, options), resolveRequiredSuites(chain, options), chain.steps, options);
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
// Both are no-ops for a one-shot `spf <chain>` invocation with no
|
|
181
|
+
// explicit `--adw-id` (ctx.adw_id is null; the id session.ensure()
|
|
182
|
+
// actually minted never makes it back up here) — that process exits
|
|
183
|
+
// right after this call returns, and the existing end-of-process
|
|
184
|
+
// `flushAll()` in `cli/index.ts`'s `finally` still drains the exporter
|
|
185
|
+
// exactly as it always did (the signal listener needs no such fallback:
|
|
186
|
+
// the process is gone).
|
|
187
|
+
await otel.releaseOtelExporter(ctx.adw_id);
|
|
188
|
+
session.finalize(ctx.adw_id);
|
|
189
|
+
}
|
|
155
190
|
}
|
|
@@ -148,7 +148,7 @@ export function trailerFor(outcome, identity) {
|
|
|
148
148
|
}
|
|
149
149
|
export async function main(ctx) {
|
|
150
150
|
const { prompt } = ctx;
|
|
151
|
-
const run = startRun(ctx, REQUIRED_AGENTS, REQUIRED_SUITES);
|
|
151
|
+
const run = await startRun(ctx, REQUIRED_AGENTS, REQUIRED_SUITES);
|
|
152
152
|
const baseline = run.git.rev("HEAD"); // pinned before this run commits anything
|
|
153
153
|
await run.phase(makePhaseParams({ name: "request", kind: "engineer", owner: run.engineer, description: "Capture the incoming ask" }), async (ph) => {
|
|
154
154
|
ph.log({ input: prompt, baseline: run.git.shortSha(baseline) });
|
package/dist/chains/steps.d.ts
CHANGED
|
@@ -82,8 +82,14 @@ export interface Step {
|
|
|
82
82
|
/** Display fragment for derivePhases() — e.g. "planner", "git(commit)". */
|
|
83
83
|
label?: string;
|
|
84
84
|
}
|
|
85
|
-
/**
|
|
86
|
-
|
|
85
|
+
/**
|
|
86
|
+
* The identical loadConfig -> validate -> session.ensure prologue every
|
|
87
|
+
* chain repeated. `async` since the tiering availability probe below is an
|
|
88
|
+
* awaited `fetch` — firing it unawaited would let `agents.execute` read
|
|
89
|
+
* `run.tiering` before the probe resolved. Both call sites are already
|
|
90
|
+
* inside `async` functions, so this stays a one-token change for each.
|
|
91
|
+
*/
|
|
92
|
+
export declare function startRun(ctx: ChainContext, requiredAgents: string[], requiredSuites: string[]): Promise<Run>;
|
|
87
93
|
/**
|
|
88
94
|
* Commit an envelope in its own author's words — the message-fallback four
|
|
89
95
|
* chains repeated.
|
|
@@ -310,21 +316,6 @@ export declare function refine(opts?: {
|
|
|
310
316
|
retries?: number;
|
|
311
317
|
extraGates?: string[];
|
|
312
318
|
}): Step;
|
|
313
|
-
/**
|
|
314
|
-
* Create the tree `refine()` produced on the tracker, in dependency order,
|
|
315
|
-
* and link each node to its parent. A `code` phase, not an agent one — the
|
|
316
|
-
* decision-making (topological order, label assignment, `## Blocked by`
|
|
317
|
-
* rendering) is `core/refine.ts`'s job; this step is sequencing only, per
|
|
318
|
-
* SKILL.md's "chains stay thin" rule. Requires a preceding refine() step.
|
|
319
|
-
*
|
|
320
|
-
* Writes what it created to `<context_handoff_dir>/refine_publish.json` —
|
|
321
|
-
* the side channel `cli/commands/watch.ts`'s `runRefine` reads after the
|
|
322
|
-
* chain returns, since a chain's own return value is just an exit code.
|
|
323
|
-
* `spf watch`'s own marker/comment/transition bookkeeping for the spec
|
|
324
|
-
* issue lives entirely in `core/watch.ts`'s `runSpec`, never here — a bare
|
|
325
|
-
* `spf refine` run (no daemon, no spec issue in play) still needs this step
|
|
326
|
-
* to work standalone.
|
|
327
|
-
*/
|
|
328
319
|
export declare function publishIssues(opts?: {
|
|
329
320
|
description?: string;
|
|
330
321
|
}): Step;
|
package/dist/chains/steps.js
CHANGED
|
@@ -48,6 +48,7 @@ import * as quality from "../core/quality.js";
|
|
|
48
48
|
import * as agentsCfg from "../core/agents.js";
|
|
49
49
|
import * as session from "../core/session.js";
|
|
50
50
|
import * as refineLib from "../core/refine.js";
|
|
51
|
+
import * as tiering from "../core/tiering.js";
|
|
51
52
|
import { DOCUMENT_NOTES } from "../core/prompts.js";
|
|
52
53
|
import { BuildOutput, DocumentOutput, GenericOutput, PlanOutput, RefineOutput, ReviewOutput, ScoutOutput, makeAgentCall, makeChangeCapture, makeEventRecord, makePhaseParams, } from "../core/data_types.js";
|
|
53
54
|
import { Run } from "../core/runner.js";
|
|
@@ -62,8 +63,14 @@ function makeStep(fn, meta = {}) {
|
|
|
62
63
|
return step;
|
|
63
64
|
}
|
|
64
65
|
// ── layer 1: the shared prologue ────────────────────────────────────────
|
|
65
|
-
/**
|
|
66
|
-
|
|
66
|
+
/**
|
|
67
|
+
* The identical loadConfig -> validate -> session.ensure prologue every
|
|
68
|
+
* chain repeated. `async` since the tiering availability probe below is an
|
|
69
|
+
* awaited `fetch` — firing it unawaited would let `agents.execute` read
|
|
70
|
+
* `run.tiering` before the probe resolved. Both call sites are already
|
|
71
|
+
* inside `async` functions, so this stays a one-token change for each.
|
|
72
|
+
*/
|
|
73
|
+
export async function startRun(ctx, requiredAgents, requiredSuites) {
|
|
67
74
|
const cfg = agentsCfg.loadConfig(ctx.config_paths);
|
|
68
75
|
agentsCfg.validate(cfg, requiredAgents, requiredSuites, ctx.cwd);
|
|
69
76
|
const run = session.ensure(cfg, ctx.adw_id, ctx.cwd, ctx.chain_name);
|
|
@@ -83,6 +90,41 @@ export function startRun(ctx, requiredAgents, requiredSuites) {
|
|
|
83
90
|
if (ctx.chain_source) {
|
|
84
91
|
run.tracer.event(makeEventRecord({ adw_id: run.adw_id, type: "log", name: "chain_source", payload: { source: ctx.chain_source } }));
|
|
85
92
|
}
|
|
93
|
+
// Tiering resolution (SPF #14) — one more run-scoped fact, computed once,
|
|
94
|
+
// before any phase opens, beside chain_source above. `risk`/`signals` are
|
|
95
|
+
// always computed (a pure function of chain name + prompt); `routing`/
|
|
96
|
+
// `notes` are only ever non-empty when `cfg.tiering.enabled` — see
|
|
97
|
+
// `resolveTiering`'s own no-op guarantee.
|
|
98
|
+
const servedOllamaTags = await tiering.probeServedOllamaTags(cfg);
|
|
99
|
+
run.tiering = tiering.resolveTiering({
|
|
100
|
+
cfg,
|
|
101
|
+
chainName: ctx.chain_name,
|
|
102
|
+
prompt: ctx.prompt,
|
|
103
|
+
servedOllamaTags,
|
|
104
|
+
required: requiredAgents,
|
|
105
|
+
});
|
|
106
|
+
run.tracer.event(makeEventRecord({
|
|
107
|
+
adw_id: run.adw_id,
|
|
108
|
+
type: "log",
|
|
109
|
+
name: "tiering",
|
|
110
|
+
payload: {
|
|
111
|
+
risk: run.tiering.risk,
|
|
112
|
+
signals: run.tiering.signals,
|
|
113
|
+
routing: run.tiering.routing,
|
|
114
|
+
notes: run.tiering.notes,
|
|
115
|
+
},
|
|
116
|
+
}));
|
|
117
|
+
// One console line per RETIERED agent (changedModels — not per routing
|
|
118
|
+
// entry: a role whose tier resolves to the model it was already
|
|
119
|
+
// configured with is not news). Routed through the existing
|
|
120
|
+
// `Console.note()` — no new Console method. This is what reconciles
|
|
121
|
+
// `run.cfg.agents[].model` (which keeps the configured value) with
|
|
122
|
+
// `agent_sessions.model` (which records the effective one) for a human
|
|
123
|
+
// reading the console when the two disagree.
|
|
124
|
+
for (const [agentName, effective] of Object.entries(tiering.changedModels(run.tiering))) {
|
|
125
|
+
const route = run.tiering.routing[agentName];
|
|
126
|
+
run.console.note(`[spf] tiering ${agentName} ${route.tier} (${route.configured} -> ${effective}) risk=${run.tiering.risk}`);
|
|
127
|
+
}
|
|
86
128
|
return run;
|
|
87
129
|
}
|
|
88
130
|
/** A trailer line: `Key: value...`, one per line, no blank lines inside the block. */
|
|
@@ -622,10 +664,19 @@ export function refine(opts = {}) {
|
|
|
622
664
|
output_type: RefineOutput,
|
|
623
665
|
description: opts.description ?? "Decompose the spec into a feature/story tree of vertical slices",
|
|
624
666
|
// refinementWellFormed is NOT in GATE_ALLOWLIST — it is meaningless on
|
|
625
|
-
// any other envelope type (it reads `issues`), so there is
|
|
626
|
-
// gain by letting a definition name it, and it stays
|
|
667
|
+
// any other envelope type (it reads `issues`/`questions`), so there is
|
|
668
|
+
// nothing to gain by letting a definition name it, and it stays
|
|
669
|
+
// non-removable here.
|
|
670
|
+
//
|
|
671
|
+
// retries: 1, not 0 — this gate's own doc comment says a violation
|
|
672
|
+
// "re-prompts the SAME refiner session before publishIssues() ever
|
|
673
|
+
// runs." With 0 retries that was never true: agents.ts throws
|
|
674
|
+
// GateFailure on the first violation and the whole spec goes straight to
|
|
675
|
+
// spf:blocked with no correction round-trip. The mutual-exclusion rule
|
|
676
|
+
// between `issues` and `questions` (see refinementWellFormed) makes that
|
|
677
|
+
// mismatch more likely to bite in practice, not less.
|
|
627
678
|
gates: withExtraGates([gates.refinementWellFormed], opts.extraGates),
|
|
628
|
-
retries: opts.retries ??
|
|
679
|
+
retries: opts.retries ?? 1,
|
|
629
680
|
});
|
|
630
681
|
}
|
|
631
682
|
/**
|
|
@@ -635,14 +686,43 @@ export function refine(opts = {}) {
|
|
|
635
686
|
* rendering) is `core/refine.ts`'s job; this step is sequencing only, per
|
|
636
687
|
* SKILL.md's "chains stay thin" rule. Requires a preceding refine() step.
|
|
637
688
|
*
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
*
|
|
642
|
-
*
|
|
643
|
-
* `
|
|
644
|
-
* to
|
|
689
|
+
* When the refiner raised material ambiguity instead of a tree
|
|
690
|
+
* (`envelope.questions` non-empty — `gates.refinementWellFormed` already
|
|
691
|
+
* guarantees `issues` is empty whenever that's true), this publishes
|
|
692
|
+
* nothing: it writes those questions to
|
|
693
|
+
* `<context_handoff_dir>/refine_questions.json` instead of
|
|
694
|
+
* `refine_publish.json` and returns. That is a successful phase outcome, not
|
|
695
|
+
* a failure — escalating to a human is a legitimate way for this chain to
|
|
696
|
+
* end, same as publishing a tree is.
|
|
697
|
+
*
|
|
698
|
+
* Writes what it created (or asked) to `<context_handoff_dir>/`, one of
|
|
699
|
+
* `refine_publish.json` or `refine_questions.json` — the side channel
|
|
700
|
+
* `cli/commands/watch.ts`'s `runRefine` reads after the chain returns, since
|
|
701
|
+
* a chain's own return value is just an exit code. `spf watch`'s own
|
|
702
|
+
* marker/comment/transition bookkeeping for the spec issue (including
|
|
703
|
+
* posting the questions and moving it to `needs-feedback`) lives entirely in
|
|
704
|
+
* `core/watch.ts`'s `runSpec`/`escalateSpec`, never here — a bare `spf
|
|
705
|
+
* refine` run (no daemon, no spec issue in play) still needs this step to
|
|
706
|
+
* work standalone, and just leaves the questions on disk for a human to
|
|
707
|
+
* read.
|
|
645
708
|
*/
|
|
709
|
+
/**
|
|
710
|
+
* `state.options["priority"]` -> a `RefinedPriority` ceiling, or `null` when
|
|
711
|
+
* unset — the generic chain-options path (`cli/commands/run.ts`'s
|
|
712
|
+
* `--priority`, or `core/watch.ts`'s `runSpec` threading a spec's own
|
|
713
|
+
* `<prefix>:priority:pN` label) is the ONLY place this string enters the
|
|
714
|
+
* system, so it's validated here rather than trusted: a typo'd or stale
|
|
715
|
+
* `--priority urgent` fails the phase with a clear message instead of
|
|
716
|
+
* silently publishing every node at its own, un-clamped priority.
|
|
717
|
+
*/
|
|
718
|
+
function parsePriorityOption(raw) {
|
|
719
|
+
if (raw === undefined)
|
|
720
|
+
return null;
|
|
721
|
+
if (raw !== "p0" && raw !== "p1" && raw !== "p2" && raw !== "p3") {
|
|
722
|
+
throw new Error(`publishIssues(): --priority ${JSON.stringify(raw)} is not one of p0|p1|p2|p3`);
|
|
723
|
+
}
|
|
724
|
+
return raw;
|
|
725
|
+
}
|
|
646
726
|
export function publishIssues(opts = {}) {
|
|
647
727
|
preflightDescription("publish", opts.description);
|
|
648
728
|
const fn = async (run, state) => {
|
|
@@ -650,19 +730,27 @@ export function publishIssues(opts = {}) {
|
|
|
650
730
|
if (!envelope || !Array.isArray(envelope.issues)) {
|
|
651
731
|
throw new Error("publishIssues() requires a preceding refine() step in the chain's step list");
|
|
652
732
|
}
|
|
733
|
+
const questions = envelope.questions ?? [];
|
|
734
|
+
const priorityCeiling = parsePriorityOption(state.options["priority"]);
|
|
653
735
|
await run.phase(makePhaseParams({
|
|
654
736
|
name: "publish",
|
|
655
737
|
kind: "code",
|
|
656
738
|
owner: "tracker",
|
|
657
739
|
description: opts.description ?? "Create the feature/story tree on the tracker, in dependency order, and link each to its parent",
|
|
658
740
|
}), async (ph) => {
|
|
741
|
+
if (questions.length > 0) {
|
|
742
|
+
writeFileSync(path.join(run.context_handoff_dir, "refine_questions.json"), JSON.stringify(questions, null, 2));
|
|
743
|
+
ph.log({ escalated: questions.length });
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
659
746
|
const tracker = refineLib.resolveAuthoringProvider(run.cfg);
|
|
660
747
|
const created = await refineLib.publish(tracker, envelope.issues, {
|
|
661
748
|
labelPrefix: run.cfg.watch.label_prefix,
|
|
662
749
|
specIssueId: state.issue_id,
|
|
750
|
+
priorityCeiling,
|
|
663
751
|
});
|
|
664
752
|
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));
|
|
665
|
-
ph.log({ created: created.length, leaves: created.filter((c) => c.isLeaf).length });
|
|
753
|
+
ph.log({ created: created.length, leaves: created.filter((c) => c.isLeaf).length, priority_ceiling: priorityCeiling });
|
|
666
754
|
});
|
|
667
755
|
};
|
|
668
756
|
return makeStep(fn, { label: "code(publish)" });
|
|
@@ -709,7 +797,7 @@ export function derivePhases(steps) {
|
|
|
709
797
|
// ── the driver ────────────────────────────────────────────────────────────
|
|
710
798
|
/** Run a chain's step list start to finish: prologue, every step in order, then run.finish(). */
|
|
711
799
|
export async function runSteps(ctx, requiredAgents, requiredSuites, steps, options = {}) {
|
|
712
|
-
const run = startRun(ctx, requiredAgents, requiredSuites);
|
|
800
|
+
const run = await startRun(ctx, requiredAgents, requiredSuites);
|
|
713
801
|
const state = makeState(ctx.prompt, options, ctx.issue_id ?? null);
|
|
714
802
|
for (const step of steps) {
|
|
715
803
|
await step(run, state);
|