@gr8ful/spf 0.12.0 → 0.13.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.
@@ -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 an extra loop beyond
210
- `spec-ready → refining → spec-in-progress → done`/`blocked`: when the
211
- refiner raises material ambiguity instead of a tree (see
212
- `assets/prompts/refiner/system.md`'s "Ask, don't decide"), the spec moves to
213
- `<prefix>:needs-feedback` with a comment naming its questions, instead of
214
- publishing anything. A human answers in the issue's comments and adds
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
@@ -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 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
+ # # 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).
@@ -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 BOTH `refine_publish.json`
321
- * and `refine_questions.json` back, unconditionally, after this chain
322
- * exits — it has no other way to learn what THIS run did, since a chain's
323
- * return value is just an exit code. A resumed spec (`continue-refinement`)
324
- * reruns this entire chain from `request` on, into the SAME deterministic
325
- * `context_handoff_dir` a PRIOR round already wrote into. Without clearing
326
- * the file this run is NOT about to write, a stale `refine_questions.json`
327
- * from an earlier escalation round survives a LATER round's successful
328
- * publish — `runRefine` then reports those old questions as if raised
329
- * again THIS round, so `runSpec` escalates a second time even though real
330
- * issues were already created on the tracker seconds earlier. Called
331
- * before EITHER branch writes, so exactly one of the two files reflects
332
- * this run when the phase returns, never a leftover from a previous one.
333
- * `force: true` — a first-ever run has neither file yet, which is fine.
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?: {
@@ -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 there is
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: 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.
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 ?? 1,
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 BOTH `refine_publish.json`
728
- * and `refine_questions.json` back, unconditionally, after this chain
729
- * exits — it has no other way to learn what THIS run did, since a chain's
730
- * return value is just an exit code. A resumed spec (`continue-refinement`)
731
- * reruns this entire chain from `request` on, into the SAME deterministic
732
- * `context_handoff_dir` a PRIOR round already wrote into. Without clearing
733
- * the file this run is NOT about to write, a stale `refine_questions.json`
734
- * from an earlier escalation round survives a LATER round's successful
735
- * publish — `runRefine` then reports those old questions as if raised
736
- * again THIS round, so `runSpec` escalates a second time even though real
737
- * issues were already created on the tracker seconds earlier. Called
738
- * before EITHER branch writes, so exactly one of the two files reflects
739
- * this run when the phase returns, never a leftover from a previous one.
740
- * `force: true` — a first-ever run has neither file yet, which is fine.
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
@@ -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 two files it's NOT about to write, so both should never be
583
- // non-empty here — but if some future change (or an older worktree's
584
- // leftover files, before that fix existed) ever produces both, a real
585
- // completed publish must never be silently overridden by a stale
586
- // question. `runSpec` checks `questions.length > 0` first, so without
587
- // this it would re-escalate over issues that already landed on the
588
- // tracker seconds earlier.
589
- if (created.length > 0 && questions.length > 0) {
590
- console.error(`watch: ${opts.adwId}: refine_publish.json AND refine_questions.json both had content — treating the ${created.length} published issue(s) as authoritative and discarding the stale question(s)`);
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