@gr8ful/spf 0.6.0 → 0.8.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 +122 -27
- package/assets/prompts/refiner/system.md +11 -1
- package/assets/prompts/refiner/user.md +9 -3
- package/assets/skill/references/config.md +51 -13
- package/assets/templates/ts.spf.config.yaml +6 -2
- package/dist/chains/context.d.ts +26 -0
- package/dist/chains/simple_sdlc.js +9 -0
- package/dist/chains/steps.d.ts +0 -27
- package/dist/chains/steps.js +21 -2
- package/dist/cli/ask.d.ts +13 -0
- package/dist/cli/ask.js +15 -1
- package/dist/cli/commands/doctor.js +47 -9
- package/dist/cli/commands/fanout.js +49 -5
- package/dist/cli/commands/init.js +11 -3
- package/dist/cli/commands/list.d.ts +1 -1
- package/dist/cli/commands/list.js +31 -12
- package/dist/cli/commands/phases.d.ts +1 -1
- package/dist/cli/commands/phases.js +18 -4
- package/dist/cli/commands/run.js +30 -2
- package/dist/cli/commands/sessions.d.ts +1 -1
- package/dist/cli/commands/sessions.js +11 -3
- package/dist/cli/commands/watch.d.ts +8 -0
- package/dist/cli/commands/watch.js +93 -13
- package/dist/cli/index.js +4 -4
- package/dist/cli/interview.js +9 -5
- package/dist/cli/ui/fanout_dashboard.d.ts +22 -0
- package/dist/cli/ui/fanout_dashboard.js +102 -0
- package/dist/cli/ui/ink_asker.d.ts +13 -0
- package/dist/cli/ui/ink_asker.js +247 -0
- package/dist/cli/ui/reports.d.ts +30 -0
- package/dist/cli/ui/reports.js +61 -0
- package/dist/cli/ui/run_dashboard.d.ts +15 -0
- package/dist/cli/ui/run_dashboard.js +131 -0
- package/dist/cli/ui/watch_dashboard.d.ts +22 -0
- package/dist/cli/ui/watch_dashboard.js +78 -0
- package/dist/core/console.d.ts +40 -1
- package/dist/core/console.js +25 -3
- package/dist/core/data_types.d.ts +108 -5
- package/dist/core/data_types.js +50 -5
- package/dist/core/fanout.d.ts +9 -0
- package/dist/core/fanout.js +6 -2
- package/dist/core/gates.js +24 -1
- package/dist/core/issues/github_provider.d.ts +39 -5
- package/dist/core/issues/github_provider.js +103 -4
- package/dist/core/issues/jira_provider.d.ts +79 -12
- package/dist/core/issues/jira_provider.js +97 -2
- package/dist/core/issues/provider.d.ts +73 -19
- package/dist/core/issues/provider.js +24 -7
- package/dist/core/notify/channel.d.ts +1 -1
- package/dist/core/refine.d.ts +45 -8
- package/dist/core/refine.js +98 -24
- package/dist/core/runner.d.ts +5 -1
- package/dist/core/runner.js +2 -1
- package/dist/core/session.d.ts +7 -1
- package/dist/core/session.js +5 -1
- package/dist/core/watch.d.ts +86 -3
- package/dist/core/watch.js +353 -29
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -369,7 +369,7 @@ the Bitbucket identifier — finds no matching issues and looks exactly like
|
|
|
369
369
|
nothing being configured at all.
|
|
370
370
|
|
|
371
371
|
```bash
|
|
372
|
-
spf watch init # idempotently seed tracker state (no-op
|
|
372
|
+
spf watch init # idempotently seed tracker state (labels are a no-op report on Jira — see below); run this first
|
|
373
373
|
spf watch # foreground daemon; Ctrl-C drains in-flight claims first
|
|
374
374
|
spf watch --once # one poll tick, then exit — good for cron
|
|
375
375
|
spf watch --dry-run # log intended claims/transitions, mutate nothing
|
|
@@ -377,6 +377,18 @@ spf watch --dry-run # log intended claims/transitions, mutate nothing
|
|
|
377
377
|
|
|
378
378
|
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.
|
|
379
379
|
|
|
380
|
+
### Priority, dependencies, and picking what's next
|
|
381
|
+
|
|
382
|
+
Among every `<prefix>:ready` issue, `spf watch` claims in this order:
|
|
383
|
+
|
|
384
|
+
1. **Priority** — the `<prefix>:priority:p0|p1|p2|p3` label (`p0` drop-everything, `p2` the default, `p3` someday). `spf watch init` seeds all four. A human relabeling an issue is the whole override mechanism — there's no separate priority command.
|
|
385
|
+
2. **Sibling affinity** — among issues at the same priority, one whose parent feature already has a sibling in flight goes first. This is what tends to finish one feature before starting the next without giving up the one-PR-per-story design (see "Refining specs" below): each story is still its own claim, its own worktree, its own PR — affinity only orders which claim happens next.
|
|
386
|
+
3. **Creation order** (oldest first) — the final tiebreaker.
|
|
387
|
+
|
|
388
|
+
Before claiming anything, `spf watch` also checks the **frontier**: an issue's `blocked_by` dependencies (set by the refine lane, or by hand) must all carry `<prefix>:done` first. A leaf whose blockers aren't done yet is skipped, not blocked — it's simply reconsidered next tick, once the log line naming what it's waiting on stops applying.
|
|
389
|
+
|
|
390
|
+
This is a **label**, not this repo's own GitHub Projects v2 "Priority" field (if your board has one — Urgent/High/Medium/Low, say). `spf watch` never reads or writes Projects v2: no GraphQL, no `project` token scope, no Jira equivalent. If you use both, they're independent — nothing reconciles them, and `spf watch` obeys only the label. Keep them aligned yourself, or don't use the board field for this repo's issues.
|
|
391
|
+
|
|
380
392
|
### Refining specs (`watch.refine`)
|
|
381
393
|
|
|
382
394
|
A product spec isn't individually workable — it needs to become a feature,
|
|
@@ -385,12 +397,12 @@ anything to claim. `watch.refine` is a second lane over the same poll loop
|
|
|
385
397
|
that does exactly that: it polls `<prefix>:spec-ready`, runs a decomposition
|
|
386
398
|
chain (`refine` by default — `request → scout → refiner → publish`) against
|
|
387
399
|
the spec in its own worktree, and publishes what it produces as real tracker
|
|
388
|
-
issues — a feature/epic container plus story/bug/task leaves, linked
|
|
389
|
-
GitHub's native sub-
|
|
400
|
+
issues — a feature/epic container plus story/bug/task leaves, linked into a
|
|
401
|
+
real hierarchy: GitHub's native sub-issues API, or Jira's `parent` field.
|
|
390
402
|
|
|
391
403
|
```yaml
|
|
392
404
|
watch:
|
|
393
|
-
issue_provider: github #
|
|
405
|
+
issue_provider: github # or jira — both implement issue authoring
|
|
394
406
|
repo: owner/name
|
|
395
407
|
refine:
|
|
396
408
|
enabled: true
|
|
@@ -398,14 +410,58 @@ watch:
|
|
|
398
410
|
concurrency: 1 # this lane's own budget, independent of watch.concurrency
|
|
399
411
|
```
|
|
400
412
|
|
|
413
|
+
On Jira, every `RefinedIssue.kind` (`epic`/`feature`/`story`/`bug`/`task`) maps
|
|
414
|
+
to a real Jira issue type through `watch.jira.issue_types` — defaults
|
|
415
|
+
`epic`/`feature` → `Epic`, `story` → `Story`, `bug` → `Bug`, `task` → `Task`,
|
|
416
|
+
overridable per kind since real projects rename or customize these:
|
|
417
|
+
|
|
418
|
+
```yaml
|
|
419
|
+
watch:
|
|
420
|
+
issue_provider: jira
|
|
421
|
+
jira:
|
|
422
|
+
base_url: https://your-domain.atlassian.net
|
|
423
|
+
project_key: PROJ
|
|
424
|
+
issue_types: # optional — shown are the defaults
|
|
425
|
+
epic: Epic
|
|
426
|
+
feature: Epic
|
|
427
|
+
story: Story
|
|
428
|
+
bug: Bug
|
|
429
|
+
task: Task
|
|
430
|
+
refine:
|
|
431
|
+
enabled: true
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
Hierarchy uses Jira's modern `parent` field only (no legacy "Epic Link"
|
|
435
|
+
custom-field support) — this works on team-managed projects and on
|
|
436
|
+
company-managed projects with Jira's current issue-hierarchy setting; a
|
|
437
|
+
project not configured for it surfaces Jira's own API error, unmodified.
|
|
438
|
+
One accepted platform limitation: Jira doesn't support Epic-under-Epic
|
|
439
|
+
nesting the way GitHub's sub-issues API supports up to 8 levels, so a
|
|
440
|
+
`feature` node parented under another `epic`/`feature` (both `Epic` by
|
|
441
|
+
default) will fail at publish time on Jira specifically — a real platform
|
|
442
|
+
difference, not a bug. Both `spf watch init` and `spf watch`'s own startup
|
|
443
|
+
check validate `watch.jira.issue_types` against the real project before
|
|
444
|
+
anything unattended runs — see "Jira" below.
|
|
445
|
+
|
|
401
446
|
Every generated issue carries a `<prefix>:type:epic|feature|story|bug|task`
|
|
402
|
-
label
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
447
|
+
label AND a `<prefix>:priority:p0|p1|p2|p3` label (see "Priority, dependencies,
|
|
448
|
+
and picking what's next" above) — the refiner proposes the priority, a human
|
|
449
|
+
can relabel it before promoting. A container (a feature/epic — something
|
|
450
|
+
else names it as `parent`) gets only those two labels; a leaf additionally
|
|
451
|
+
gets `<prefix>:refined` — **never** `<prefix>:ready`. Promoting a leaf to
|
|
452
|
+
`<prefix>:ready` is a deliberate human decision: the refine lane never
|
|
453
|
+
auto-promotes anything, so a spec fanning out into twenty stories doesn't
|
|
454
|
+
turn into twenty unattended chain runs and twenty PRs with nobody having
|
|
455
|
+
looked at the breakdown first. Once you do promote a leaf, the existing build
|
|
456
|
+
lane picks it up completely unchanged — including the frontier check: a
|
|
457
|
+
promoted leaf isn't claimed until every issue named in its `blocked_by` is
|
|
458
|
+
`<prefix>:done`.
|
|
459
|
+
|
|
460
|
+
A spec issue's own `<prefix>:priority:pN` label, if it has one, reaches the
|
|
461
|
+
refiner as a **ceiling**: no generated node may be more urgent than the spec
|
|
462
|
+
itself (enforced regardless of what the refiner emits — see
|
|
463
|
+
`core/refine.ts`'s `publish()`). `spf refine ... --priority p1` sets the same
|
|
464
|
+
ceiling for a bare manual run with no spec issue to read a label from.
|
|
409
465
|
|
|
410
466
|
Grounding the decomposition in real code is structural, not just prose: a
|
|
411
467
|
`scout` phase maps the subsystems the spec touches before the refiner ever
|
|
@@ -421,11 +477,11 @@ model, an external dependency choice, a UX contract, a breaking change, or
|
|
|
421
477
|
anything that would contradict an existing ADR — see
|
|
422
478
|
`assets/prompts/refiner/system.md`'s "Ask, don't decide"). When it hits real
|
|
423
479
|
ambiguity it raises questions instead of publishing a partial tree, and the
|
|
424
|
-
spec moves through an extra loop before it
|
|
480
|
+
spec moves through an extra loop before it's even fully published:
|
|
425
481
|
|
|
426
482
|
```text
|
|
427
|
-
spec-ready → refining ──┬─→ done / blocked
|
|
428
|
-
└─→ needs-feedback
|
|
483
|
+
spec-ready → refining ──┬─→ spec-in-progress → done / blocked (published a tree, THEN tracked to completion)
|
|
484
|
+
└─→ needs-feedback (raised questions)
|
|
429
485
|
│ a human answers in the issue's comments,
|
|
430
486
|
│ then adds continue-refinement
|
|
431
487
|
▼
|
|
@@ -440,19 +496,55 @@ already had, and it can loop through as many rounds as it takes; there's no
|
|
|
440
496
|
cap. If channels are configured (see "Notifications" below), a
|
|
441
497
|
`spec_needs_feedback` event fires every round.
|
|
442
498
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
499
|
+
**Publishing a tree does not mean the spec is done.** A product manager
|
|
500
|
+
watching a spec's status needs "done" to mean the work is actually finished,
|
|
501
|
+
not merely that a decomposition happened — so once the refiner publishes,
|
|
502
|
+
the spec moves to `<prefix>:spec-in-progress`, not `<prefix>:done`. It stays
|
|
503
|
+
there — with a summary comment listing every issue it created, noting how
|
|
504
|
+
many rounds of feedback it took, if any — until **every one of those
|
|
505
|
+
issues** reaches `<prefix>:done` (a leaf that lands, or a feature/epic
|
|
506
|
+
container once `rollUp` has already finished it — see "Container roll-up"
|
|
507
|
+
below). Only then does `spf watch` transition the spec to `<prefix>:done`,
|
|
508
|
+
post a closing comment, and close it on the tracker where the tracker
|
|
509
|
+
supports closing at all — GitHub does (best-effort: a failed close never
|
|
510
|
+
turns completed work back into `blocked`); Jira doesn't have this wired up,
|
|
511
|
+
so a Jira spec still relabels and comments correctly, just stays open. A
|
|
512
|
+
`spec_done` event fires either way, distinct from the earlier
|
|
513
|
+
`spec_refined`. A spec whose
|
|
514
|
+
generated leaf never gets promoted, or whose work stalls `blocked`, simply
|
|
515
|
+
stays `spec-in-progress` — which is the truthful state, not a bug.
|
|
516
|
+
|
|
517
|
+
The spec issue's full lifecycle: `spec-ready → refining → needs-feedback ⇄
|
|
518
|
+
refining → spec-in-progress → done`/`blocked` (the `needs-feedback ⇄
|
|
519
|
+
refining` loop only when the refiner actually escalates). Try it by hand
|
|
520
|
+
first, against a real spec, before turning on the daemon:
|
|
451
521
|
|
|
452
522
|
```bash
|
|
453
|
-
spf refine "<spec text or path/to/spec.md>" --issue 42
|
|
523
|
+
spf refine "<spec text or path/to/spec.md>" --issue 42 # --issue renders a "## Parent" section, "Decomposed from #42."
|
|
524
|
+
spf refine "<spec text or path/to/spec.md>" --issue 42 --priority p1 # --priority clamps every generated node to p1 or less urgent
|
|
454
525
|
```
|
|
455
526
|
|
|
527
|
+
#### Container roll-up
|
|
528
|
+
|
|
529
|
+
A feature or epic isn't itself a unit of work, so it's never claimed and
|
|
530
|
+
never opens a PR — but it isn't abandoned once its children exist, either.
|
|
531
|
+
Every time a leaf's PR merges, `spf watch` checks that leaf's parent: once
|
|
532
|
+
**every** child under a container carries `<prefix>:done`, the container
|
|
533
|
+
gets a summary comment (every child it rolled up, by id), transitions to
|
|
534
|
+
`<prefix>:done`, and closes on the tracker if the tracker supports closing
|
|
535
|
+
at all (GitHub does; Jira doesn't have this wired up, so a Jira container
|
|
536
|
+
still relabels and comments, just stays open) — then the same check runs on
|
|
537
|
+
*its* parent, so an epic of features rolls up once its last feature does.
|
|
538
|
+
A container with even one unfinished child (including one still sitting at
|
|
539
|
+
`<prefix>:refined`, never promoted) is left exactly as it is; nothing times
|
|
540
|
+
out or force-closes it.
|
|
541
|
+
|
|
542
|
+
Roll-up needs to read back the hierarchy `publish()` created — GitHub's
|
|
543
|
+
native sub-issues API, or a Jira `parent = "<id>"` JQL search — so it works
|
|
544
|
+
on both providers today, same as authoring itself. A tracker that isn't
|
|
545
|
+
authoring-capable at all gets a logged no-op, not a startup failure, since
|
|
546
|
+
the build lane still works fine without roll-up.
|
|
547
|
+
|
|
456
548
|
`spf watch init` seeds the type labels alongside the state ones — **re-run
|
|
457
549
|
it** after upgrading to this version, so it can create the new
|
|
458
550
|
`<prefix>:needs-feedback` and `<prefix>:continue-refinement` labels. This
|
|
@@ -492,7 +584,7 @@ export JIRA_API_TOKEN=... # id.atlassian.com -> Security -> API tokens
|
|
|
492
584
|
|
|
493
585
|
State is modeled as Jira **labels** (`<prefix>:ready`, etc.), mirroring GitHub exactly, rather than native workflow status transitions — the latter would need per-project transition-id mapping, since workflows vary by project/scheme; labels work identically everywhere with zero per-project setup. One caveat: colons are a legal Jira label character and JQL matches on them fine, but they won't show up in Jira's own label autocomplete UI — cosmetic only.
|
|
494
586
|
|
|
495
|
-
`spf watch init`
|
|
587
|
+
`spf watch init` still doesn't create any labels here (Jira labels are freeform strings with no color/description registry to seed, unlike GitHub's) — it reports the labels this run will use. But with `watch.refine.enabled`, it now also validates `watch.jira.issue_types` against the real project's issue types, read-only, and exits non-zero on a mismatch — see "Refining specs" above.
|
|
496
588
|
|
|
497
589
|
### Bitbucket (`code_host: bitbucket`)
|
|
498
590
|
|
|
@@ -527,9 +619,12 @@ issues, watch errors, and a spec needing feedback (`spec_needs_feedback` —
|
|
|
527
619
|
see "Human-in-the-loop escalation" above; it's `error`-level on purpose, the
|
|
528
620
|
same class of event as a blocked issue, so an `errors`-scope channel sees it
|
|
529
621
|
too); `all` adds every milestone — run started/finished, issue claimed, PR
|
|
530
|
-
opened, issue done
|
|
531
|
-
|
|
532
|
-
|
|
622
|
+
opened, issue done, a container's roll-up (`feature_done` — see "Container
|
|
623
|
+
roll-up" above), and a spec reaching actual completion (`spec_done` —
|
|
624
|
+
distinct from `spec_refined`, which fires the moment a tree is published;
|
|
625
|
+
see "Human-in-the-loop escalation" above). A channel's own `events`
|
|
626
|
+
overrides the top-level scope for just that channel. `spf doctor` reports
|
|
627
|
+
whether each configured channel's env var is set.
|
|
533
628
|
|
|
534
629
|
The webhook URL is a secret and lives only in `.env` — `webhook_url_env`
|
|
535
630
|
names the key, never the URL itself, matching `GITHUB_TOKEN`/
|
|
@@ -31,7 +31,7 @@ You will hit real ambiguity: places where the spec (or the codebase, or both) ge
|
|
|
31
31
|
- **Non-functional targets** — performance, scale, availability numbers the spec doesn't state.
|
|
32
32
|
- **Anything that would contradict an existing ADR.**
|
|
33
33
|
|
|
34
|
-
You MAY decide these yourself, following whatever pattern the codebase already uses — that's ordinary judgment, not ambiguity: naming, file placement, test framework and layout, internal module structure, and the ordering of independent slices.
|
|
34
|
+
You MAY decide these yourself, following whatever pattern the codebase already uses — that's ordinary judgment, not ambiguity: naming, file placement, test framework and layout, internal module structure, and the ordering of independent slices (defer to the `priority` you assign each one — see "Priority" below — rather than an arbitrary reading order).
|
|
35
35
|
|
|
36
36
|
When you escalate, ask **everything you need in one batch** — don't trickle questions across rounds when you could have asked them all up front. Emit **no `issues`** in a round where you're asking questions; the two are mutually exclusive and a gate enforces it. For each question, give a human enough to answer in a word or two:
|
|
37
37
|
|
|
@@ -61,6 +61,16 @@ Emit a **flat list**, not nested JSON: each node names its `parent` by another n
|
|
|
61
61
|
|
|
62
62
|
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.
|
|
63
63
|
|
|
64
|
+
## Priority
|
|
65
|
+
|
|
66
|
+
Give every node a `priority`: `p0` (drop everything — a broken promise to users, or blocking everything else), `p1` (the spec's core value — the slices without which it isn't shipped), `p2` (the default — real scope, can wait a cycle), or `p3` (worth writing down, not worth scheduling yet). This is independent of `blocked_by` — a `p0` slice still waits for its blockers, same as anything else; priority decides scheduling ORDER among leaves that are otherwise both ready to start, not which one is allowed to start.
|
|
67
|
+
|
|
68
|
+
Three rules, all enforced, not just suggested:
|
|
69
|
+
|
|
70
|
+
- **A container takes the urgency of its most urgent child.** Don't set a feature's priority independently of the stories under it — pick the highest urgency among its children.
|
|
71
|
+
- **No node may be more urgent than its parent.** A `p0` story under a `p3` feature is a decomposition mistake, not a valid tree — a gate rejects it.
|
|
72
|
+
- **When the prompt states the spec's own priority, it is a CEILING for every node you produce — never a floor.** Deviate downward freely (a `p1` spec can still have `p3` polish tucked inside it); nothing you produce may be more urgent than the spec itself. This is enforced by the harness at publish time regardless of what you emit, so state the priority you actually mean — a violation is silently clamped, not sent back as a correction.
|
|
73
|
+
|
|
64
74
|
## Wide refactors — the one exception to vertical slicing
|
|
65
75
|
|
|
66
76
|
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):
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
`prompt` may include a `## Discussion on the spec issue` section — the spec issue's own comment thread, if it has one. On a first pass this is whatever discussion already existed before the spec was labeled ready; on a resumed pass (after you raised questions and a human answered) it's split into "Answers to your open questions (round N)" and "Earlier discussion." Treat the answers section as authoritative — see your system instructions on resuming.
|
|
10
10
|
|
|
11
|
+
`prompt` may also include a `## Priority` section, when the spec issue itself carries a priority label. Treat that as a **ceiling** for every node you produce — see your system instructions on priority. Its absence means no ceiling, not "assume p2."
|
|
12
|
+
|
|
11
13
|
### previous_envelope
|
|
12
14
|
|
|
13
15
|
{{previous_envelope}}
|
|
@@ -46,7 +48,8 @@ Respond with ONLY valid JSON matching `RefineOutput` — no prose before or afte
|
|
|
46
48
|
"title": "<feature title>",
|
|
47
49
|
"body": "## What to build\n\n<end-to-end behavior this feature covers>\n\n## Acceptance criteria\n\n- [ ] <criterion>",
|
|
48
50
|
"parent": "",
|
|
49
|
-
"blocked_by": []
|
|
51
|
+
"blocked_by": [],
|
|
52
|
+
"priority": "p1"
|
|
50
53
|
},
|
|
51
54
|
{
|
|
52
55
|
"key": "S1",
|
|
@@ -54,7 +57,8 @@ Respond with ONLY valid JSON matching `RefineOutput` — no prose before or afte
|
|
|
54
57
|
"title": "<leaf title>",
|
|
55
58
|
"body": "## What to build\n\n<end-to-end behavior from the user's perspective>\n\n## Acceptance criteria\n\n- [ ] <criterion>\n- [ ] <criterion>",
|
|
56
59
|
"parent": "F1",
|
|
57
|
-
"blocked_by": []
|
|
60
|
+
"blocked_by": [],
|
|
61
|
+
"priority": "p1"
|
|
58
62
|
},
|
|
59
63
|
{
|
|
60
64
|
"key": "S2",
|
|
@@ -62,7 +66,8 @@ Respond with ONLY valid JSON matching `RefineOutput` — no prose before or afte
|
|
|
62
66
|
"title": "<leaf title>",
|
|
63
67
|
"body": "## What to build\n\n<the fix, described end-to-end>\n\n## Acceptance criteria\n\n- [ ] <criterion>",
|
|
64
68
|
"parent": "F1",
|
|
65
|
-
"blocked_by": ["S1"]
|
|
69
|
+
"blocked_by": ["S1"],
|
|
70
|
+
"priority": "p2"
|
|
66
71
|
}
|
|
67
72
|
],
|
|
68
73
|
"questions": []
|
|
@@ -99,4 +104,5 @@ Rules on the shape (enforced by a gate — a violation sends this back to you as
|
|
|
99
104
|
- `parent` is another node's `key`, or `""` for a top-level feature/epic.
|
|
100
105
|
- `blocked_by` is a list of other nodes' `key`s — real dependencies only, and no cycles (through `parent` or `blocked_by`, or both together).
|
|
101
106
|
- `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.
|
|
107
|
+
- `priority` is one of `p0`|`p1`|`p2`|`p3`, defaulting to `p2` if you omit it. No node may be more urgent than its parent, and no node may exceed the spec's priority where the `## Priority` section states one — a violation is clamped, not sent back as a correction, so state the priority you actually mean.
|
|
102
108
|
- `questions[].id` is your own local id for this round, unique within `questions` — stable enough that, on a resumed run, an answer in the thread can be matched back to the question it answers.
|
|
@@ -155,7 +155,8 @@ Full mechanism: the main README's "`spf watch`" section. Field reference:
|
|
|
155
155
|
| `concurrency` | int ≥1 | Max issues claimed and run at once, the build lane's own budget (independent of `refine.concurrency`). Default `2`. |
|
|
156
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). |
|
|
157
157
|
| `jira.base_url` / `jira.project_key` | string | Only consulted when `issue_provider: jira`. |
|
|
158
|
-
| `
|
|
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. |
|
|
159
160
|
| `refine.chain` | string | Which registered chain runs per claimed spec. Default `refine`. |
|
|
160
161
|
| `refine.concurrency` | int ≥1 | The refine lane's own budget, separate from `concurrency`. Default `1`. |
|
|
161
162
|
|
|
@@ -180,23 +181,60 @@ additionally gets `<prefix>:refined`, so a human can review and promote it to
|
|
|
180
181
|
`<prefix>:ready` when it's worth building — the refine lane never
|
|
181
182
|
auto-promotes anything.
|
|
182
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
|
+
|
|
183
196
|
The `refine` chain grounds its decomposition with a `scout` phase before the
|
|
184
197
|
refiner runs, so `scout` is a required agent for it — a roster that pruned
|
|
185
198
|
it fails `spf watch` startup by name.
|
|
186
199
|
|
|
187
200
|
The refine lane's own state machine has an extra loop beyond
|
|
188
|
-
`spec-ready → refining → done`/`blocked`: when the
|
|
189
|
-
ambiguity instead of a tree (see
|
|
190
|
-
don't decide"), the spec moves to
|
|
191
|
-
naming its questions, instead of
|
|
192
|
-
issue's comments and adds
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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.
|
|
200
238
|
|
|
201
239
|
### `notifications`
|
|
202
240
|
|
|
@@ -77,8 +77,12 @@ agents:
|
|
|
77
77
|
# # suite: strict
|
|
78
78
|
# # Optional second lane: decompose a spf:spec-ready product spec into a
|
|
79
79
|
# # feature/story-or-bug tree of real issues instead of building it
|
|
80
|
-
# # directly. Off by default; needs issue_provider: github
|
|
81
|
-
# #
|
|
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.
|
|
82
86
|
# refine:
|
|
83
87
|
# enabled: true
|
|
84
88
|
# chain: refine
|
package/dist/chains/context.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { RunObserver } from "../core/console.ts";
|
|
1
2
|
/**
|
|
2
3
|
* What every chain's `main()` receives. The CLI (or, later, the chain
|
|
3
4
|
* registry) resolves every path here — a chain never has its own opinion
|
|
@@ -57,4 +58,29 @@ export interface ChainContext {
|
|
|
57
58
|
* edited by the time anyone reads the run back).
|
|
58
59
|
*/
|
|
59
60
|
chain_source?: string;
|
|
61
|
+
/**
|
|
62
|
+
* A live TTY view's hook into this run's `Console` — see `RunObserver`'s
|
|
63
|
+
* doc comment (`core/console.ts`). Optional and omitted by every
|
|
64
|
+
* construction site except an interactive `cli/commands/run.ts`
|
|
65
|
+
* dispatch: `spf watch`'s per-issue runs, `spf fanout`'s per-attempt
|
|
66
|
+
* runs, the `refine` chain, and every test all continue to build a
|
|
67
|
+
* plain, unobserved `Console` exactly as before this field existed.
|
|
68
|
+
*/
|
|
69
|
+
render_hooks?: {
|
|
70
|
+
sink?: (line: string) => void;
|
|
71
|
+
observer?: RunObserver | null;
|
|
72
|
+
/**
|
|
73
|
+
* The live dashboard's own handoff pair — call `pause()` before a
|
|
74
|
+
* chain shows its own competing prompt (today, only `simple_sdlc.ts`'s
|
|
75
|
+
* human sign-off gate) and `resume()` once it's done, symmetrically,
|
|
76
|
+
* in a `finally`. Ink refuses a second `render()` on the same stdout
|
|
77
|
+
* while a prior instance is still live, so this isn't optional
|
|
78
|
+
* politeness — skipping `pause()` before mounting another Ink surface
|
|
79
|
+
* (or even a plain `readline` prompt sharing the same stdin/stdout,
|
|
80
|
+
* given the dashboard's own live-updating timer keeps writing to it)
|
|
81
|
+
* corrupts both.
|
|
82
|
+
*/
|
|
83
|
+
pause?: () => void | Promise<void>;
|
|
84
|
+
resume?: () => void;
|
|
85
|
+
};
|
|
60
86
|
}
|
|
@@ -217,6 +217,13 @@ export async function main(ctx) {
|
|
|
217
217
|
const canPrompt = isInteractive() && !ctx.unattended; // amendment: never infer this from TTY state alone
|
|
218
218
|
identity = committerIdentity(run.repo_root);
|
|
219
219
|
const asker = canPrompt ? createAsker() : null;
|
|
220
|
+
// A live run dashboard (cli/ui/run_dashboard.tsx) keeps writing to the
|
|
221
|
+
// same stdout/stdin on its own timer even though it never reads input
|
|
222
|
+
// itself — that alone is enough to corrupt this prompt's rendering if
|
|
223
|
+
// both are live at once. `pause()`/`resume()` are no-ops when no
|
|
224
|
+
// dashboard is mounted (every non-interactive dispatch, and any test).
|
|
225
|
+
if (canPrompt)
|
|
226
|
+
await ctx.render_hooks?.pause?.();
|
|
220
227
|
try {
|
|
221
228
|
const outcome = await run.phase(makePhaseParams({
|
|
222
229
|
name: "signoff",
|
|
@@ -238,6 +245,8 @@ export async function main(ctx) {
|
|
|
238
245
|
}
|
|
239
246
|
finally {
|
|
240
247
|
asker?.close();
|
|
248
|
+
if (canPrompt)
|
|
249
|
+
ctx.render_hooks?.resume?.();
|
|
241
250
|
}
|
|
242
251
|
}
|
|
243
252
|
if (verified) {
|
package/dist/chains/steps.d.ts
CHANGED
|
@@ -316,33 +316,6 @@ export declare function refine(opts?: {
|
|
|
316
316
|
retries?: number;
|
|
317
317
|
extraGates?: string[];
|
|
318
318
|
}): Step;
|
|
319
|
-
/**
|
|
320
|
-
* Create the tree `refine()` produced on the tracker, in dependency order,
|
|
321
|
-
* and link each node to its parent. A `code` phase, not an agent one — the
|
|
322
|
-
* decision-making (topological order, label assignment, `## Blocked by`
|
|
323
|
-
* rendering) is `core/refine.ts`'s job; this step is sequencing only, per
|
|
324
|
-
* SKILL.md's "chains stay thin" rule. Requires a preceding refine() step.
|
|
325
|
-
*
|
|
326
|
-
* When the refiner raised material ambiguity instead of a tree
|
|
327
|
-
* (`envelope.questions` non-empty — `gates.refinementWellFormed` already
|
|
328
|
-
* guarantees `issues` is empty whenever that's true), this publishes
|
|
329
|
-
* nothing: it writes those questions to
|
|
330
|
-
* `<context_handoff_dir>/refine_questions.json` instead of
|
|
331
|
-
* `refine_publish.json` and returns. That is a successful phase outcome, not
|
|
332
|
-
* a failure — escalating to a human is a legitimate way for this chain to
|
|
333
|
-
* end, same as publishing a tree is.
|
|
334
|
-
*
|
|
335
|
-
* Writes what it created (or asked) to `<context_handoff_dir>/`, one of
|
|
336
|
-
* `refine_publish.json` or `refine_questions.json` — the side channel
|
|
337
|
-
* `cli/commands/watch.ts`'s `runRefine` reads after the chain returns, since
|
|
338
|
-
* a chain's own return value is just an exit code. `spf watch`'s own
|
|
339
|
-
* marker/comment/transition bookkeeping for the spec issue (including
|
|
340
|
-
* posting the questions and moving it to `needs-feedback`) lives entirely in
|
|
341
|
-
* `core/watch.ts`'s `runSpec`/`escalateSpec`, never here — a bare `spf
|
|
342
|
-
* refine` run (no daemon, no spec issue in play) still needs this step to
|
|
343
|
-
* work standalone, and just leaves the questions on disk for a human to
|
|
344
|
-
* read.
|
|
345
|
-
*/
|
|
346
319
|
export declare function publishIssues(opts?: {
|
|
347
320
|
description?: string;
|
|
348
321
|
}): Step;
|
package/dist/chains/steps.js
CHANGED
|
@@ -73,7 +73,7 @@ function makeStep(fn, meta = {}) {
|
|
|
73
73
|
export async function startRun(ctx, requiredAgents, requiredSuites) {
|
|
74
74
|
const cfg = agentsCfg.loadConfig(ctx.config_paths);
|
|
75
75
|
agentsCfg.validate(cfg, requiredAgents, requiredSuites, ctx.cwd);
|
|
76
|
-
const run = session.ensure(cfg, ctx.adw_id, ctx.cwd, ctx.chain_name);
|
|
76
|
+
const run = session.ensure(cfg, ctx.adw_id, ctx.cwd, ctx.chain_name, ctx.render_hooks);
|
|
77
77
|
// Provenance, once per run, before any phase opens: a repo-local chain
|
|
78
78
|
// (.spf/chains/*.yaml) records the file it came from. `chain_name` alone
|
|
79
79
|
// stops being enough to reconstruct a run the moment a target repo can
|
|
@@ -706,6 +706,23 @@ export function refine(opts = {}) {
|
|
|
706
706
|
* work standalone, and just leaves the questions on disk for a human to
|
|
707
707
|
* read.
|
|
708
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
|
+
}
|
|
709
726
|
export function publishIssues(opts = {}) {
|
|
710
727
|
preflightDescription("publish", opts.description);
|
|
711
728
|
const fn = async (run, state) => {
|
|
@@ -714,6 +731,7 @@ export function publishIssues(opts = {}) {
|
|
|
714
731
|
throw new Error("publishIssues() requires a preceding refine() step in the chain's step list");
|
|
715
732
|
}
|
|
716
733
|
const questions = envelope.questions ?? [];
|
|
734
|
+
const priorityCeiling = parsePriorityOption(state.options["priority"]);
|
|
717
735
|
await run.phase(makePhaseParams({
|
|
718
736
|
name: "publish",
|
|
719
737
|
kind: "code",
|
|
@@ -729,9 +747,10 @@ export function publishIssues(opts = {}) {
|
|
|
729
747
|
const created = await refineLib.publish(tracker, envelope.issues, {
|
|
730
748
|
labelPrefix: run.cfg.watch.label_prefix,
|
|
731
749
|
specIssueId: state.issue_id,
|
|
750
|
+
priorityCeiling,
|
|
732
751
|
});
|
|
733
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));
|
|
734
|
-
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 });
|
|
735
754
|
});
|
|
736
755
|
};
|
|
737
756
|
return makeStep(fn, { label: "code(publish)" });
|
package/dist/cli/ask.d.ts
CHANGED
|
@@ -33,8 +33,21 @@ export interface Asker {
|
|
|
33
33
|
}
|
|
34
34
|
/** `stdin.isTTY` is what actually matters (the interview reads it) — `stdout.isTTY` alone, this repo's only prior TTY check (`src/ui/server/serve.ts:88`), would let a piped-in `spf init` hang waiting on input that will never arrive. */
|
|
35
35
|
export declare function isInteractive(): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Whether `cli/ui/ink_asker.tsx`'s Ink-backed `Asker` can run instead of this
|
|
38
|
+
* file's plain readline one. Ink hard-requires raw-mode stdin — confirmed by
|
|
39
|
+
* testing: rendering an Ink app against a non-raw-mode-capable stdin throws
|
|
40
|
+
* before anything is drawn. `isInteractive()` already implies raw mode is
|
|
41
|
+
* available in every real case (a TTY stdin always exposes `setRawMode`);
|
|
42
|
+
* the extra `typeof` check is cheap insurance against whatever exotic
|
|
43
|
+
* terminal doesn't, so a caller falls back to `createAsker()` instead of
|
|
44
|
+
* crashing.
|
|
45
|
+
*/
|
|
46
|
+
export declare function inkAvailable(): boolean;
|
|
36
47
|
/** Thrown when the user interrupts (Ctrl-C) or stdin closes (EOF) mid-interview. `initCommand` catches this and exits 130, writing nothing. */
|
|
37
48
|
export declare class InterviewAborted extends Error {
|
|
38
49
|
constructor();
|
|
39
50
|
}
|
|
40
51
|
export declare function createAsker(): Asker;
|
|
52
|
+
/** Exported for `cli/ui/ink_asker.tsx`'s secret prompt, which renders the same "keep current" line and must mask it identically. */
|
|
53
|
+
export declare function maskForPrompt(value: string): string;
|
package/dist/cli/ask.js
CHANGED
|
@@ -16,6 +16,19 @@ import { paint } from "../core/console.js";
|
|
|
16
16
|
export function isInteractive() {
|
|
17
17
|
return Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY) && !process.env["CI"];
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Whether `cli/ui/ink_asker.tsx`'s Ink-backed `Asker` can run instead of this
|
|
21
|
+
* file's plain readline one. Ink hard-requires raw-mode stdin — confirmed by
|
|
22
|
+
* testing: rendering an Ink app against a non-raw-mode-capable stdin throws
|
|
23
|
+
* before anything is drawn. `isInteractive()` already implies raw mode is
|
|
24
|
+
* available in every real case (a TTY stdin always exposes `setRawMode`);
|
|
25
|
+
* the extra `typeof` check is cheap insurance against whatever exotic
|
|
26
|
+
* terminal doesn't, so a caller falls back to `createAsker()` instead of
|
|
27
|
+
* crashing.
|
|
28
|
+
*/
|
|
29
|
+
export function inkAvailable() {
|
|
30
|
+
return isInteractive() && typeof process.stdin.setRawMode === "function";
|
|
31
|
+
}
|
|
19
32
|
/** Thrown when the user interrupts (Ctrl-C) or stdin closes (EOF) mid-interview. `initCommand` catches this and exits 130, writing nothing. */
|
|
20
33
|
export class InterviewAborted extends Error {
|
|
21
34
|
constructor() {
|
|
@@ -148,7 +161,8 @@ export function createAsker() {
|
|
|
148
161
|
},
|
|
149
162
|
};
|
|
150
163
|
}
|
|
151
|
-
|
|
164
|
+
/** Exported for `cli/ui/ink_asker.tsx`'s secret prompt, which renders the same "keep current" line and must mask it identically. */
|
|
165
|
+
export function maskForPrompt(value) {
|
|
152
166
|
if (value.length <= 4)
|
|
153
167
|
return "•".repeat(value.length);
|
|
154
168
|
return `${"•".repeat(Math.max(0, value.length - 4))}${value.slice(-4)}`;
|