@gr8ful/spf 0.12.0 → 0.14.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 +120 -28
- package/assets/prompts/refiner/system.md +105 -25
- package/assets/prompts/refiner/user.md +50 -15
- package/assets/prompts/scout/system.md +2 -2
- package/assets/prompts/scout/user.md +1 -1
- package/assets/skill/references/config.md +28 -8
- package/assets/templates/ts.spf.config.yaml +11 -4
- package/dist/chains/steps.d.ts +14 -14
- package/dist/chains/steps.js +44 -25
- package/dist/cli/commands/doctor.js +15 -0
- package/dist/cli/commands/watch.js +33 -11
- package/dist/cli/interview.js +9 -0
- package/dist/core/data_types.d.ts +202 -1
- package/dist/core/data_types.js +134 -1
- package/dist/core/gates.d.ts +24 -8
- package/dist/core/gates.js +175 -20
- package/dist/core/issues/github_provider.d.ts +10 -5
- package/dist/core/issues/github_provider.js +13 -2
- package/dist/core/issues/jira_provider.d.ts +3 -3
- package/dist/core/issues/jira_provider.js +2 -0
- package/dist/core/issues/provider.d.ts +48 -4
- package/dist/core/notify/channel.d.ts +1 -1
- package/dist/core/notify/notifier.d.ts +3 -2
- package/dist/core/notify/notifier.js +32 -3
- package/dist/core/refine.d.ts +35 -1
- package/dist/core/refine.js +56 -1
- package/dist/core/utils.d.ts +5 -5
- package/dist/core/utils.js +14 -7
- package/dist/core/watch.d.ts +65 -9
- package/dist/core/watch.js +157 -11
- package/package.json +1 -1
|
@@ -159,10 +159,13 @@ Full mechanism: the main README's "`spf watch`" section. Field reference:
|
|
|
159
159
|
| `concurrency` | int ≥1 | Max ISSUES claimed and run at once — not attempts; see `fanout.concurrency`. The build lane's own budget (independent of `refine.concurrency`). Default `2`. |
|
|
160
160
|
| `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). |
|
|
161
161
|
| `jira.base_url` / `jira.project_key` | string | Only consulted when `issue_provider: jira`. |
|
|
162
|
-
| `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. |
|
|
162
|
+
| `jira.issue_types` | map: `epic`/`feature`/`story`/`bug`/`task`/`spec` -> string | Only consulted when `issue_provider: jira` AND `refine.enabled`. What each `RefinedIssue.kind` (plus `spec`, a standalone spec proposed by a split — see below) creates as on Jira — defaults `epic`/`feature` → `Epic`, `story`/`spec` → `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. |
|
|
163
163
|
| `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. |
|
|
164
164
|
| `refine.chain` | string | Which registered chain runs per claimed spec. Default `refine`. |
|
|
165
165
|
| `refine.concurrency` | int ≥1 | The refine lane's own budget, separate from `concurrency`. Default `1`. |
|
|
166
|
+
| `refine.max_leaves` | int ≥1 | The decomposition budget: at most this many LEAVES per refinement — each one becomes its own worktree, chain run, and pull request once promoted, so this is a human-review budget, not a model-effort one. Default `4`. Enforced by `gates.refinementWellFormed`; exceeding it (with no human-approved split already in the thread) sends the refiner back for a correction, and a genuine overrun proposes a `split` into several specs instead of publishing (see below) rather than force-fitting an oversized tree. |
|
|
167
|
+
| `refine.max_nodes` | int ≥1 | Total node ceiling (leaves + containers). Default `6`. Doesn't bind at the other two defaults — it exists so raising `max_leaves` or `max_depth` alone can't silently uncap the whole tree. |
|
|
168
|
+
| `refine.max_depth` | int ≥1 | Containment-depth ceiling — `1` is a top-level leaf/container, `2` is a leaf directly under a top-level container. Default `2`. **Raising this above 2 is unsafe on Jira**: `epic`/`feature` both map to Jira's Epic type by default, and Jira has no Epic-under-Epic nesting, so a 3-level tree fails partway through a non-transactional publish. At the default, that shape is rejected before any issue is created. |
|
|
166
169
|
| `fanout.n` | int, 1-8 | Best-of-N per claimed issue: run `n` sibling attempts of the same issue and let code pick a winner (`core/fanout.ts`'s `pickBest`, the same mechanism `spf fanout` uses standalone). Default `1` — single dispatch, byte-identical to `spf watch` before this key existed. `n > 1` requires `chain` to have a commit step (`spf watch` refuses to start otherwise — a chain with no commit step would have its N-1 losing attempts' uncommitted work destroyed by best-of-N's own cleanup). |
|
|
167
170
|
| `fanout.concurrency` | int ≥1 | Attempts of ONE issue's fan-out IN FLIGHT at once — **not** `concurrency`, which counts issues. The two multiply: `concurrency: 2` × `fanout.concurrency: 2` is up to 4 chain runs in flight; `spf doctor`'s `watch.fanout` line prints the exact product. Does **not** bound worktrees on disk — a successful attempt's tree is kept until every sibling in its fan-out has settled, so disk peak is `concurrency × fanout.n`, not `concurrency × fanout.concurrency`. Default `2`. |
|
|
168
171
|
|
|
@@ -177,6 +180,9 @@ watch:
|
|
|
177
180
|
enabled: true # decompose spf:spec-ready specs into a feature/story tree
|
|
178
181
|
chain: refine
|
|
179
182
|
concurrency: 1
|
|
183
|
+
max_leaves: 4 # optional — shown are the defaults; see refine.max_leaves above
|
|
184
|
+
max_nodes: 6
|
|
185
|
+
max_depth: 2
|
|
180
186
|
fanout:
|
|
181
187
|
n: 1 # attempts per claimed issue. 1 = single dispatch (default) — no-op for the daemon
|
|
182
188
|
concurrency: 2 # attempts IN FLIGHT per issue — not watch.concurrency (issues in flight)
|
|
@@ -206,18 +212,31 @@ The `refine` chain grounds its decomposition with a `scout` phase before the
|
|
|
206
212
|
refiner runs, so `scout` is a required agent for it — a roster that pruned
|
|
207
213
|
it fails `spf watch` startup by name.
|
|
208
214
|
|
|
209
|
-
The refine lane's own state machine has
|
|
210
|
-
`spec-ready → refining → spec-in-progress → done`/`blocked
|
|
211
|
-
refiner raises material ambiguity instead of a
|
|
212
|
-
`assets/prompts/refiner/system.md`'s "Ask, don't decide"), the spec
|
|
213
|
-
`<prefix>:needs-feedback` with a comment naming its questions,
|
|
214
|
-
publishing anything. A human answers in the issue's comments and
|
|
215
|
-
`<prefix>:continue-refinement`; `spf watch` claims that label back into
|
|
215
|
+
The refine lane's own state machine has two extra loops beyond
|
|
216
|
+
`spec-ready → refining → spec-in-progress → done`/`blocked`, for two
|
|
217
|
+
different problems. When the refiner raises material ambiguity instead of a
|
|
218
|
+
tree (see `assets/prompts/refiner/system.md`'s "Ask, don't decide"), the spec
|
|
219
|
+
moves to `<prefix>:needs-feedback` with a comment naming its questions,
|
|
220
|
+
instead of publishing anything. A human answers in the issue's comments and
|
|
221
|
+
adds `<prefix>:continue-refinement`; `spf watch` claims that label back into
|
|
216
222
|
`refining` and resumes the **same** `adw_id` — the comment thread (split
|
|
217
223
|
into "answers to your open questions" and "earlier discussion") is folded
|
|
218
224
|
into the resumed prompt, and the refiner's own coding-agent session
|
|
219
225
|
continues rather than starting cold. This can loop any number of rounds.
|
|
220
226
|
|
|
227
|
+
The other loop is for size, not ambiguity: when the spec honestly exceeds
|
|
228
|
+
`refine.max_leaves` (see above), the refiner proposes a `split` into two or
|
|
229
|
+
more standalone specs instead of a scope question, and the spec moves to
|
|
230
|
+
`<prefix>:split-proposed` with a comment naming each proposed spec and why
|
|
231
|
+
it's coherent on its own. A human either adds `<prefix>:split-approved`
|
|
232
|
+
(executed deterministically — `core/refine.ts`'s `publishSpecs()`, no agent
|
|
233
|
+
re-run: it creates exactly what the proposal comment showed, each new spec
|
|
234
|
+
carrying `<prefix>:type:spec` + `<prefix>:spec-ready`, never
|
|
235
|
+
`<prefix>:refined`) or revises the proposal the same way a question gets
|
|
236
|
+
answered (`<prefix>:continue-refinement`). The original spec then tracks
|
|
237
|
+
both child specs' own trees to completion transitively, the same way it
|
|
238
|
+
tracks a published tree's leaves.
|
|
239
|
+
|
|
221
240
|
Publishing a tree does NOT mean the spec is done: `<prefix>:spec-in-progress`
|
|
222
241
|
is where a spec lands right after publish, and it stays there — with a
|
|
223
242
|
summary comment listing every issue created — until **every one of those
|
|
@@ -258,6 +277,7 @@ default; adding it is entirely additive.
|
|
|
258
277
|
|---|---|---|
|
|
259
278
|
| `events` | `"off"` \| `"errors"` \| `"attention"` \| `"all"` | The whole filter, narrowest to widest. `off` (default): nothing. `errors`: only true failures — failed runs/phases (`run_failed`/`phase_failed`), `watch_error`. `attention`: `errors` plus anything needing a human but not itself a failure — a blocked issue (`issue_blocked`) or a spec needing feedback (`spec_needs_feedback`). `all`: every curated milestone (run started, issue claimed, PR opened, ...) plus `attention` and `errors`. |
|
|
260
279
|
| `timeout_ms` | int | Per-request timeout for a channel's HTTP POST. Default `5000`. |
|
|
280
|
+
| `project` | string | Label prefixed onto every outbound title (`[api] watch: ...`) and added as a `repo` field — for disambiguating multiple `spf` instances that share one webhook. Default `""`, which falls back to `watch.repo`. |
|
|
261
281
|
| `channels[]` | array | See below. |
|
|
262
282
|
|
|
263
283
|
`channels[].kind`: `"slack"` \| `"teams"` \| `"webhook"`. `channels[].events`
|
|
@@ -79,14 +79,21 @@ agents:
|
|
|
79
79
|
# # feature/story-or-bug tree of real issues instead of building it
|
|
80
80
|
# # directly. Off by default; needs issue_provider: github OR jira — both
|
|
81
81
|
# # support issue authoring (create + link a hierarchy). On Jira, each
|
|
82
|
-
# # RefinedIssue.kind
|
|
83
|
-
# # watch.jira.issue_types (defaults: epic/feature ->
|
|
84
|
-
# # bug -> Bug, task -> Task) — spf watch init
|
|
85
|
-
# # real project when refine is enabled.
|
|
82
|
+
# # RefinedIssue.kind (plus spec, from a split proposal) maps to a real
|
|
83
|
+
# # Jira issue type via watch.jira.issue_types (defaults: epic/feature ->
|
|
84
|
+
# # Epic, story/spec -> Story, bug -> Bug, task -> Task) — spf watch init
|
|
85
|
+
# # validates this against the real project when refine is enabled.
|
|
86
|
+
# # max_leaves/max_nodes/max_depth are the decomposition budget: every leaf
|
|
87
|
+
# # becomes its own pull request once promoted, so this bounds how many a
|
|
88
|
+
# # human reviews per spec. An honest overrun proposes splitting the spec
|
|
89
|
+
# # into several standalone ones instead of publishing an oversized tree.
|
|
86
90
|
# refine:
|
|
87
91
|
# enabled: true
|
|
88
92
|
# chain: refine
|
|
89
93
|
# concurrency: 1
|
|
94
|
+
# max_leaves: 4
|
|
95
|
+
# max_nodes: 6
|
|
96
|
+
# max_depth: 2
|
|
90
97
|
|
|
91
98
|
# Optional: push notifications for unattended work — spf watch's daemon
|
|
92
99
|
# lifecycle, and every chain run (including watch's own per-issue runs).
|
package/dist/chains/steps.d.ts
CHANGED
|
@@ -317,20 +317,20 @@ export declare function refine(opts?: {
|
|
|
317
317
|
extraGates?: string[];
|
|
318
318
|
}): Step;
|
|
319
319
|
/**
|
|
320
|
-
* `cli/commands/watch.ts`'s `runRefine()` reads
|
|
321
|
-
*
|
|
322
|
-
* exits — it has no other way to learn what THIS run did,
|
|
323
|
-
* return value is just an exit code. A resumed spec
|
|
324
|
-
* reruns this entire chain from `request` on, into
|
|
325
|
-
* `context_handoff_dir` a PRIOR round already wrote
|
|
326
|
-
* the
|
|
327
|
-
* from an earlier
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
* issues were already created on the tracker seconds earlier. Called
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
320
|
+
* `cli/commands/watch.ts`'s `runRefine()` reads `refine_publish.json`,
|
|
321
|
+
* `refine_questions.json`, AND `refine_split.json` back, unconditionally,
|
|
322
|
+
* after this chain exits — it has no other way to learn what THIS run did,
|
|
323
|
+
* since a chain's return value is just an exit code. A resumed spec
|
|
324
|
+
* (`continue-refinement`) reruns this entire chain from `request` on, into
|
|
325
|
+
* the SAME deterministic `context_handoff_dir` a PRIOR round already wrote
|
|
326
|
+
* into. Without clearing the files this run is NOT about to write, a stale
|
|
327
|
+
* one from an earlier round survives a LATER round's successful publish —
|
|
328
|
+
* `runRefine` then reports that stale outcome as if it happened again THIS
|
|
329
|
+
* round, so `runSpec` escalates (or splits) a second time even though real
|
|
330
|
+
* issues were already created on the tracker seconds earlier. Called before
|
|
331
|
+
* ANY branch writes, so exactly one of the three files reflects this run
|
|
332
|
+
* when the phase returns, never a leftover from a previous one. `force:
|
|
333
|
+
* true` — a first-ever run has none of them yet, which is fine.
|
|
334
334
|
*/
|
|
335
335
|
export declare function clearStaleRefineOutputFiles(contextHandoffDir: string): void;
|
|
336
336
|
export declare function publishIssues(opts?: {
|
package/dist/chains/steps.js
CHANGED
|
@@ -664,19 +664,26 @@ export function refine(opts = {}) {
|
|
|
664
664
|
output_type: RefineOutput,
|
|
665
665
|
description: opts.description ?? "Decompose the spec into a feature/story tree of vertical slices",
|
|
666
666
|
// refinementWellFormed is NOT in GATE_ALLOWLIST — it is meaningless on
|
|
667
|
-
// any other envelope type (it reads `issues`/`questions`), so
|
|
668
|
-
// nothing to gain by letting a definition name it, and it stays
|
|
667
|
+
// any other envelope type (it reads `issues`/`questions`/`split`), so
|
|
668
|
+
// there is nothing to gain by letting a definition name it, and it stays
|
|
669
669
|
// non-removable here.
|
|
670
670
|
//
|
|
671
|
-
// retries:
|
|
672
|
-
//
|
|
673
|
-
//
|
|
674
|
-
//
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
//
|
|
671
|
+
// retries: 2, not 1. `retries: 1` bought exactly one correction round
|
|
672
|
+
// (`core/agents.ts`'s gate loop: attempts 1..retries+1), which is enough
|
|
673
|
+
// for a MECHANICAL violation — an unresolved parent, a mislabeled kind —
|
|
674
|
+
// because the fix is local and obvious. The budget checks in
|
|
675
|
+
// `gates.refinementWellFormed` are not mechanical: the first correction
|
|
676
|
+
// is "cut this down," and a refiner that genuinely cannot needs a SECOND
|
|
677
|
+
// round to reach for the other branch (publish nothing, propose a
|
|
678
|
+
// `split`). Without it, the honest-overrun case exits as a
|
|
679
|
+
// `GateFailure` — which does NOT return non-zero through
|
|
680
|
+
// `cli/commands/watch.ts`'s `runRefine`, but THROWS past it into
|
|
681
|
+
// `core/watch.ts`'s `runSpec` catch, moving the spec to
|
|
682
|
+
// `<prefix>:blocked` and paging a `watch_error` at level "error". A spec
|
|
683
|
+
// that is merely too big is not an spf failure, and it should not look
|
|
684
|
+
// like one.
|
|
678
685
|
gates: withExtraGates([gates.refinementWellFormed], opts.extraGates),
|
|
679
|
-
retries: opts.retries ??
|
|
686
|
+
retries: opts.retries ?? 2,
|
|
680
687
|
});
|
|
681
688
|
}
|
|
682
689
|
/**
|
|
@@ -724,23 +731,23 @@ function parsePriorityOption(raw) {
|
|
|
724
731
|
return raw;
|
|
725
732
|
}
|
|
726
733
|
/**
|
|
727
|
-
* `cli/commands/watch.ts`'s `runRefine()` reads
|
|
728
|
-
*
|
|
729
|
-
* exits — it has no other way to learn what THIS run did,
|
|
730
|
-
* return value is just an exit code. A resumed spec
|
|
731
|
-
* reruns this entire chain from `request` on, into
|
|
732
|
-
* `context_handoff_dir` a PRIOR round already wrote
|
|
733
|
-
* the
|
|
734
|
-
* from an earlier
|
|
735
|
-
*
|
|
736
|
-
*
|
|
737
|
-
* issues were already created on the tracker seconds earlier. Called
|
|
738
|
-
*
|
|
739
|
-
*
|
|
740
|
-
*
|
|
734
|
+
* `cli/commands/watch.ts`'s `runRefine()` reads `refine_publish.json`,
|
|
735
|
+
* `refine_questions.json`, AND `refine_split.json` back, unconditionally,
|
|
736
|
+
* after this chain exits — it has no other way to learn what THIS run did,
|
|
737
|
+
* since a chain's return value is just an exit code. A resumed spec
|
|
738
|
+
* (`continue-refinement`) reruns this entire chain from `request` on, into
|
|
739
|
+
* the SAME deterministic `context_handoff_dir` a PRIOR round already wrote
|
|
740
|
+
* into. Without clearing the files this run is NOT about to write, a stale
|
|
741
|
+
* one from an earlier round survives a LATER round's successful publish —
|
|
742
|
+
* `runRefine` then reports that stale outcome as if it happened again THIS
|
|
743
|
+
* round, so `runSpec` escalates (or splits) a second time even though real
|
|
744
|
+
* issues were already created on the tracker seconds earlier. Called before
|
|
745
|
+
* ANY branch writes, so exactly one of the three files reflects this run
|
|
746
|
+
* when the phase returns, never a leftover from a previous one. `force:
|
|
747
|
+
* true` — a first-ever run has none of them yet, which is fine.
|
|
741
748
|
*/
|
|
742
749
|
export function clearStaleRefineOutputFiles(contextHandoffDir) {
|
|
743
|
-
for (const name of ["refine_questions.json", "refine_publish.json"]) {
|
|
750
|
+
for (const name of ["refine_questions.json", "refine_publish.json", "refine_split.json"]) {
|
|
744
751
|
rmSync(path.join(contextHandoffDir, name), { force: true });
|
|
745
752
|
}
|
|
746
753
|
}
|
|
@@ -752,6 +759,7 @@ export function publishIssues(opts = {}) {
|
|
|
752
759
|
throw new Error("publishIssues() requires a preceding refine() step in the chain's step list");
|
|
753
760
|
}
|
|
754
761
|
const questions = envelope.questions ?? [];
|
|
762
|
+
const split = envelope.split ?? [];
|
|
755
763
|
const priorityCeiling = parsePriorityOption(state.options["priority"]);
|
|
756
764
|
await run.phase(makePhaseParams({
|
|
757
765
|
name: "publish",
|
|
@@ -765,6 +773,17 @@ export function publishIssues(opts = {}) {
|
|
|
765
773
|
ph.log({ escalated: questions.length });
|
|
766
774
|
return;
|
|
767
775
|
}
|
|
776
|
+
if (split.length > 0) {
|
|
777
|
+
// Recorded, never executed, here: creating the proposed specs is
|
|
778
|
+
// `core/watch.ts`'s `executeApprovedSplits`' job, gated on a human
|
|
779
|
+
// adding `<prefix>:split-approved` — this phase only writes the
|
|
780
|
+
// proposal down for `runRefine` to hand to `runSpec`'s own
|
|
781
|
+
// `proposeSpecSplit`, same division of labor as the `questions`
|
|
782
|
+
// branch above (this writes, `runSpec` posts/transitions).
|
|
783
|
+
writeFileSync(path.join(run.context_handoff_dir, "refine_split.json"), JSON.stringify(split, null, 2));
|
|
784
|
+
ph.log({ split_proposed: split.length });
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
768
787
|
const tracker = refineLib.resolveAuthoringProvider(run.cfg);
|
|
769
788
|
const created = await refineLib.publish(tracker, envelope.issues, {
|
|
770
789
|
labelPrefix: run.cfg.watch.label_prefix,
|
|
@@ -23,6 +23,7 @@ import { PROVIDER_ENV_KEYS } from "../../core/providers.js";
|
|
|
23
23
|
import { probeServedOllamaTags, resolveTiering } from "../../core/tiering.js";
|
|
24
24
|
import { isRepoAt } from "../../core/git_helper.js";
|
|
25
25
|
import { allChains, findChain, hasCommitStep, repoChainProblems, resolveRequiredAgents, resolveRequiredSuites } from "../../chains/index.js";
|
|
26
|
+
import { refineBudget } from "../../core/gates.js";
|
|
26
27
|
import * as sandbox from "../../core/sandbox.js";
|
|
27
28
|
import { loadOpenSandboxSdk } from "../../core/sandbox_opensandbox.js";
|
|
28
29
|
import { isInteractive } from "../ask.js";
|
|
@@ -978,6 +979,11 @@ export async function doctorCommand(argv) {
|
|
|
978
979
|
: cfg.watch.issue_provider === "jira"
|
|
979
980
|
? "jira supports issue authoring (createIssue/parent field) — run `spf watch init` to validate watch.jira.issue_types against the real project"
|
|
980
981
|
: `watch.issue_provider is ${JSON.stringify(cfg.watch.issue_provider)} — the refine lane needs "github" or "jira"`);
|
|
982
|
+
// Same resolution `gates.refinementWellFormed` itself uses
|
|
983
|
+
// (refineBudget), never a second reading of these three keys, so this
|
|
984
|
+
// line can never drift from what a refinement is actually held to.
|
|
985
|
+
const budget = refineBudget({ repo_root: anchor.repo_root, cfg });
|
|
986
|
+
check(report, "watch.refine decomposition budget", true, `at most ${budget.maxLeaves} leaf/leaves, ${budget.maxNodes} node(s) total, ${budget.maxDepth} level(s) deep — every leaf becomes one pull request a human reviews once promoted`, "info");
|
|
981
987
|
}
|
|
982
988
|
}
|
|
983
989
|
// OTel span export: informational in every direction. It is off unless
|
|
@@ -1017,6 +1023,15 @@ export async function doctorCommand(argv) {
|
|
|
1017
1023
|
const label = ch.name ? `${ch.kind} (${ch.name})` : ch.kind;
|
|
1018
1024
|
check(report, `notifications: ${label}`, Boolean(process.env[envKey]), process.env[envKey] ? `${envKey} set` : `${envKey} is not set`);
|
|
1019
1025
|
}
|
|
1026
|
+
// See `NotificationsConfigSchema.project`'s doc comment / `resolveNotifier`'s
|
|
1027
|
+
// same fallback — informational only (never fails doctor), since an
|
|
1028
|
+
// unset project tag is harmless unless this webhook ends up shared.
|
|
1029
|
+
if (cfg.notifications.channels.length > 0) {
|
|
1030
|
+
const project = cfg.notifications.project.trim() || cfg.watch.repo.trim();
|
|
1031
|
+
check(report, "notifications.project", true, project
|
|
1032
|
+
? `tag: "${project}"`
|
|
1033
|
+
: "not set, and watch.repo is empty — outbound messages won't carry a repo/project tag; if this webhook is ever shared across multiple spf instances, set notifications.project to tell them apart", project ? "info" : "warn");
|
|
1034
|
+
}
|
|
1020
1035
|
}
|
|
1021
1036
|
return finish(report, flags["json"]);
|
|
1022
1037
|
}
|
|
@@ -17,6 +17,7 @@ import { GitHubProvider } from "../../core/issues/github_provider.js";
|
|
|
17
17
|
import { JiraProvider } from "../../core/issues/jira_provider.js";
|
|
18
18
|
import { BitbucketProvider } from "../../core/issues/bitbucket_provider.js";
|
|
19
19
|
import { isAuthoringProvider } from "../../core/issues/provider.js";
|
|
20
|
+
import * as refineLib from "../../core/refine.js";
|
|
20
21
|
import { createWatchState, tick } from "../../core/watch.js";
|
|
21
22
|
import { findChain, hasCommitStep, resolveRequiredAgents, runChain as runChainDef } from "../../chains/index.js";
|
|
22
23
|
import { withRunScope } from "../../core/sandbox.js";
|
|
@@ -559,7 +560,7 @@ export async function watchCommand(argv) {
|
|
|
559
560
|
const code = await withRunScope(opts.adwId, () => runChainDef(chainDef, ctx, opts.chainOptions));
|
|
560
561
|
if (code !== 0) {
|
|
561
562
|
const detail = detailFromFailedPhase(cfg, opts.cwd, opts.adwId, `Refine chain "${cfg.watch.refine.chain}" (adw_id ${opts.adwId}) did not complete successfully. Run \`spf phases ${opts.adwId} --cwd ${opts.cwd}\` for detail.`);
|
|
562
|
-
return { accepted: false, adwId: opts.adwId, detail, created: [], questions: [] };
|
|
563
|
+
return { accepted: false, adwId: opts.adwId, detail, created: [], questions: [], split: [] };
|
|
563
564
|
}
|
|
564
565
|
const wtAnchor = paths.resolveAnchor(opts.cwd);
|
|
565
566
|
const wtDataPaths = paths.resolveDataPaths(wtAnchor, cfg.defaults.data_dir, cfg.observability.db);
|
|
@@ -578,19 +579,32 @@ export async function watchCommand(argv) {
|
|
|
578
579
|
catch {
|
|
579
580
|
// best-effort, same as above — no questions file means this run wasn't an escalation
|
|
580
581
|
}
|
|
582
|
+
let split = [];
|
|
583
|
+
try {
|
|
584
|
+
split = JSON.parse(readFileSync(path.join(handoffDir, "refine_split.json"), "utf-8"));
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
// best-effort, same as above — no split file means this run didn't propose one
|
|
588
|
+
}
|
|
581
589
|
// Defense in depth: `steps.publishIssues()` now clears whichever of
|
|
582
|
-
// these
|
|
583
|
-
// non-empty here — but if some future change (or an older worktree's
|
|
584
|
-
// leftover files, before that fix existed) ever produces
|
|
585
|
-
// completed publish must never be silently overridden by a stale
|
|
586
|
-
// question. `runSpec` checks `questions
|
|
587
|
-
//
|
|
588
|
-
//
|
|
589
|
-
|
|
590
|
-
|
|
590
|
+
// these three files it's NOT about to write, so at most one should ever
|
|
591
|
+
// be non-empty here — but if some future change (or an older worktree's
|
|
592
|
+
// leftover files, before that fix existed) ever produces more than one, a
|
|
593
|
+
// real completed publish must never be silently overridden by a stale
|
|
594
|
+
// question or split proposal. `runSpec` checks `questions` before
|
|
595
|
+
// `split` before the publish path, so without this it would re-escalate
|
|
596
|
+
// (or re-propose a split) over issues that already landed on the tracker
|
|
597
|
+
// seconds earlier.
|
|
598
|
+
if (created.length > 0 && (questions.length > 0 || split.length > 0)) {
|
|
599
|
+
console.error(`watch: ${opts.adwId}: refine_publish.json had content alongside a stale refine_questions.json/refine_split.json — treating the ${created.length} published issue(s) as authoritative and discarding the stale escalation`);
|
|
591
600
|
questions = [];
|
|
601
|
+
split = [];
|
|
602
|
+
}
|
|
603
|
+
else if (questions.length > 0 && split.length > 0) {
|
|
604
|
+
console.error(`watch: ${opts.adwId}: refine_questions.json AND refine_split.json both had content — treating the questions as authoritative and discarding the stale split proposal`);
|
|
605
|
+
split = [];
|
|
592
606
|
}
|
|
593
|
-
return { accepted: true, adwId: opts.adwId, detail: "", created, questions };
|
|
607
|
+
return { accepted: true, adwId: opts.adwId, detail: "", created, questions, split };
|
|
594
608
|
};
|
|
595
609
|
// `IssueAuthoringProvider`'s read-back half — `isAuthoringProvider()` is a
|
|
596
610
|
// structural check (see `provider.ts`), so both GitHub and Jira are
|
|
@@ -639,6 +653,14 @@ export async function watchCommand(argv) {
|
|
|
639
653
|
dryRun: Boolean(flags["dry-run"]),
|
|
640
654
|
runChain,
|
|
641
655
|
listChildren: authoringProvider ? (parent) => authoringProvider.listChildren(parent) : undefined,
|
|
656
|
+
// See `WatchDeps.publishSpecs`'s own doc comment: `authoringProvider` is
|
|
657
|
+
// guaranteed non-null whenever `refine.enabled` is true (the startup
|
|
658
|
+
// check above already refuses any `issue_provider` besides github/jira
|
|
659
|
+
// in that case), so this is never `undefined` in the one state
|
|
660
|
+
// `executeApprovedSplits` actually reads it in.
|
|
661
|
+
publishSpecs: authoringProvider
|
|
662
|
+
? (specs, opts) => refineLib.publishSpecs(authoringProvider, specs, { labelPrefix: cfg.watch.label_prefix, originalSpecId: opts.originalSpecId, priority: opts.priority })
|
|
663
|
+
: undefined,
|
|
642
664
|
log: (message) => (dashboard ? dashboard.log(message) : console.log(message)),
|
|
643
665
|
notify: (event) => {
|
|
644
666
|
notifier?.send(event); // unaffected either way — see mountWatchDashboard's own doc comment
|
package/dist/cli/interview.js
CHANGED
|
@@ -527,6 +527,15 @@ export async function runInterview(asker, ctx) {
|
|
|
527
527
|
const timeoutMs = await asker.text("notifications.timeout_ms", { default: "5000" });
|
|
528
528
|
if (timeoutMs !== "5000")
|
|
529
529
|
notifications.timeout_ms = Number(timeoutMs);
|
|
530
|
+
// Only matters when one Slack/Teams/webhook endpoint is shared across
|
|
531
|
+
// several `spf` instances — defaults to watch.repo (resolveNotifier's
|
|
532
|
+
// own fallback), so most single-repo setups can just accept it and
|
|
533
|
+
// write nothing extra into the generated config.
|
|
534
|
+
const repoDefault = watch?.repo || "";
|
|
535
|
+
asker.note("Tags every outbound title/field so messages are distinguishable if this webhook is shared across repos — defaults to watch.repo.");
|
|
536
|
+
const project = await asker.text("notifications.project", { default: repoDefault });
|
|
537
|
+
if (project !== repoDefault)
|
|
538
|
+
notifications.project = project;
|
|
530
539
|
}
|
|
531
540
|
}
|
|
532
541
|
// ── 6. review + confirm ─────────────────────────────────────────────────────
|