@nanobpm/nano-workforce 0.81.0 → 0.82.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/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [0.82.0](https://github.com/nanobpm/nano-workforce/compare/v0.81.0...v0.82.0) (2026-08-17)
2
+
3
+
4
+ ### Features
5
+
6
+ * reify epic domain lifecycle as derived plans.epic_phase ([#261](https://github.com/nanobpm/nano-workforce/issues/261)) ([#265](https://github.com/nanobpm/nano-workforce/issues/265)) ([4cc9dee](https://github.com/nanobpm/nano-workforce/commit/4cc9deee86b800206d264d96269e6a98e8753883)), closes [#266](https://github.com/nanobpm/nano-workforce/issues/266) [nwf#245](https://github.com/nwf/issues/245) [nano-ide#254](https://github.com/nano-ide/issues/254)
7
+
1
8
  # [0.81.0](https://github.com/nanobpm/nano-workforce/compare/v0.80.0...v0.81.0) (2026-08-17)
2
9
 
3
10
 
package/SPEC.md CHANGED
@@ -530,6 +530,17 @@ History: done/failed/abandoned) with a `plan_tasks` child grid showing each task
530
530
  status and the PR it produced (`pr_key` cross-references the Pull requests grid for
531
531
  convergence status).
532
532
 
533
+ **Epic domain phase** (issue #261): `plans.status` only distinguishes the process-instance
534
+ terminal (`dispatched` = "fan-out job done"), not the epic's *domain* lifecycle. The read model
535
+ therefore also carries a derived, display-only `plans.epic_phase` — **Planning → Reviewing →
536
+ Implementing (wave n/t) → Trial merging → Finalizing → Dispatched** — projected at write time from
537
+ `plan-fanout.bpmn`'s named activities via each spine worker's BPMN element id (`app/epicPhase.ts`,
538
+ the single binding; nwf is the first consumer of the urban phase-projection primitive, nano-ide#266).
539
+ The `Implementing` band is wave-labelled from the levelize records (`plan_tasks` waves). The epic /
540
+ epic-detail pages surface it as a **Phase** column. It never gates control flow (that stays driven by
541
+ the process `currentWave`/`waveCount`/`gate_wave`); a post-dispatch cross-instance rollup into
542
+ Converging/Merging is a later seam (nwf#245 / nano-ide#254).
543
+
533
544
  ### 13.1 Dependency waves + merge barrier (issues #20, #26, release-notes-concierge)
534
545
 
535
546
  The flat `implement → record-results` shape above evolved into a **wave loop**. The
@@ -0,0 +1,62 @@
1
+ // Read-model derivation test for the epic domain phase (issue #261). `deriveEpicPhase` /
2
+ // `implementingPhase` are the single source of truth for the write-time projection each spine
3
+ // worker stamps onto `plans.epic_phase`. The projection binds structurally to plan-fanout.bpmn's
4
+ // named activities via the job's BPMN element id (mirroring the urban #266 phase primitive), so the
5
+ // epic view can show WHICH phase an epic is in — not only the process-instance terminal status.
6
+ import { test } from "node:test";
7
+ import { assertEquals } from "#test-assert";
8
+ import { deriveEpicPhase, EPIC_PHASE, implementingPhase } from "./epicPhase.ts";
9
+
10
+ test("deriveEpicPhase maps each spine element to its domain phase", () => {
11
+ // Planning genesis + hand-off into Reviewing when the plan is recorded.
12
+ assertEquals(deriveEpicPhase("plan"), EPIC_PHASE.PLANNING);
13
+ assertEquals(deriveEpicPhase("ensure-base-branch"), EPIC_PHASE.PLANNING);
14
+ assertEquals(deriveEpicPhase("record-plan"), EPIC_PHASE.REVIEWING);
15
+ assertEquals(deriveEpicPhase("review-plan"), EPIC_PHASE.REVIEWING);
16
+ assertEquals(deriveEpicPhase("record-plan-review"), EPIC_PHASE.REVIEWING);
17
+ assertEquals(deriveEpicPhase("plan-review-decision"), EPIC_PHASE.REVIEWING);
18
+ // Trial-merge band.
19
+ assertEquals(deriveEpicPhase("trial-merge"), EPIC_PHASE.TRIAL_MERGING);
20
+ assertEquals(deriveEpicPhase("record-trial-merge"), EPIC_PHASE.TRIAL_MERGING);
21
+ assertEquals(deriveEpicPhase("trial-merge-decision"), EPIC_PHASE.TRIAL_MERGING);
22
+ assertEquals(deriveEpicPhase("resolve-trial-attention"), EPIC_PHASE.TRIAL_MERGING);
23
+ // Finalize step's lasting result is the "Fleet dispatched" terminal.
24
+ assertEquals(deriveEpicPhase("record-results"), EPIC_PHASE.DISPATCHED);
25
+ });
26
+
27
+ test("deriveEpicPhase wave-labels the Implementing band from the levelize records", () => {
28
+ // select-wave / record-wave / the implement MI + wait-wave-merged all read as Implementing,
29
+ // labelled with the 1-based wave from the wave/levelize records (0-based `current`).
30
+ assertEquals(
31
+ deriveEpicPhase("select-wave", { current: 0, total: 3 }),
32
+ "Implementing (wave 1/3)",
33
+ );
34
+ assertEquals(
35
+ deriveEpicPhase("record-wave", { current: 2, total: 3 }),
36
+ "Implementing (wave 3/3)",
37
+ );
38
+ assertEquals(
39
+ deriveEpicPhase("wait-wave-merged", { current: 1, total: 3 }),
40
+ "Implementing (wave 2/3)",
41
+ );
42
+ assertEquals(deriveEpicPhase("implement-task", { current: 0, total: 1 }), "Implementing (wave 1/1)");
43
+ });
44
+
45
+ test("deriveEpicPhase returns null for a non-spine element so a stray write never clobbers", () => {
46
+ assertEquals(deriveEpicPhase(undefined), null);
47
+ assertEquals(deriveEpicPhase(null), null);
48
+ assertEquals(deriveEpicPhase(""), null);
49
+ assertEquals(deriveEpicPhase("some-unrelated-element"), null);
50
+ });
51
+
52
+ test("implementingPhase clamps the 1-based label to the total and degrades gracefully", () => {
53
+ assertEquals(implementingPhase(0, 2), "Implementing (wave 1/2)");
54
+ // A `current` at/over the last index (record-wave pins current_wave to waveCount-1 on the final
55
+ // wave) never reads past n/n.
56
+ assertEquals(implementingPhase(5, 3), "Implementing (wave 3/3)");
57
+ // Unusable wave numbers (taskless plan / NaN counter) degrade to a bare Implementing — never
58
+ // "wave NaN/…".
59
+ assertEquals(implementingPhase(0, 0), "Implementing");
60
+ assertEquals(implementingPhase(undefined, undefined), "Implementing");
61
+ assertEquals(implementingPhase("x", "y"), "Implementing");
62
+ });
@@ -0,0 +1,125 @@
1
+ // app/epicPhase.ts — reify the epic's own domain lifecycle as a derived `epic_phase` (issue #261).
2
+ //
3
+ // `plans.status` only distinguishes `planning` / `dispatched` / `done` / `failed` / `abandoned` —
4
+ // and `dispatched` is the `plan-fanout.bpmn` PROCESS-INSTANCE terminal ("fan-out job done"), not the
5
+ // epic's domain phase. `plan-fanout.bpmn` already models the rich lifecycle as named activities
6
+ // (Ensure base branch → Plan → Review plan → Select wave → Implement task → Trial merge → Finalize
7
+ // → "Fleet dispatched"); this module reifies that lifecycle as a stored, display-only projection so
8
+ // the epic view can show which phase the epic is in.
9
+ //
10
+ // Convention over declaration: the phases ARE the activities plan-fanout.bpmn already names. Each
11
+ // spine worker derives its projection from its OWN BPMN element id (`job.elementId`) — no annotation
12
+ // map on the model, no second reconciliation pass — mirroring the urban structural phase-projection
13
+ // primitive (nano-ide#266), which derives the phase from the furthest element reached in
14
+ // write-provenance. This module is the single binding (nwf is #266's first consumer).
15
+ //
16
+ // Write-time projection: because the phase only advances when a worker writes, each spine worker
17
+ // stamps the phase the epic is ENTERING as a result of its write — the write points ARE the phase
18
+ // boundaries. Two structural defaults are coarsened where the raw activity label would mislead
19
+ // (documented on `ELEMENT_PHASE` below): `select-wave` reads as `Implementing (wave n/t)` because it
20
+ // dispatches and durably marks the (write-silent) `implement` multi-instance subProcess, and
21
+ // `record-results` reads as the `Dispatched` terminal ("Fleet dispatched").
22
+ //
23
+ // Cross-instance rollup (later): post-dispatch, the epic's effective phase extends into the
24
+ // convergence/merge loops carried on separate top-level instances correlated by lineage
25
+ // (`rootRequestKey`, nwf#245 / nano-ide#254). Once #266's Tier-2 rollup lands, `epic_phase` can
26
+ // advance past `Dispatched` into Converging/Merging with no new wiring here — the seam is this
27
+ // module's derivation staying the single source.
28
+
29
+ /** The epic's domain phases — the vocabulary the derivation projects onto `plans.epic_phase`.
30
+ * Shared with the feature-view stage vocabulary (nwf#254), which uses the same stored-projection
31
+ * pattern. `Implementing` is wave-labelled at derivation time (see {@link implementingPhase}). */
32
+ export const EPIC_PHASE = {
33
+ PLANNING: "Planning",
34
+ REVIEWING: "Reviewing",
35
+ IMPLEMENTING: "Implementing",
36
+ TRIAL_MERGING: "Trial merging",
37
+ FINALIZING: "Finalizing",
38
+ DISPATCHED: "Dispatched",
39
+ } as const;
40
+
41
+ /** Coerce a wave index/count to a non-negative integer, or null when it isn't one. Mirrors the
42
+ * `toWave` coercion the wave workers already apply, so a NaN/absent counter degrades to an
43
+ * unlabelled `Implementing` rather than emitting `wave NaN/…`. */
44
+ const toWave = (v: unknown): number | null => {
45
+ const n = Math.trunc(Number(v));
46
+ return Number.isFinite(n) && n >= 0 ? n : null;
47
+ };
48
+
49
+ /**
50
+ * `Implementing (wave n/t)` — special-cased from the wave/levelize records (`plan_tasks` waves),
51
+ * NOT the raw multi-instance counter. `current` is the 0-based wave index carried on the process
52
+ * (`currentWave` / the projected `current_wave`); the label is 1-based and clamped to `total` so a
53
+ * final wave reads `n/n`. Falls back to a bare `Implementing` when the wave numbers aren't usable
54
+ * (e.g. a taskless plan with `total` 0), so the phase never renders `wave NaN`.
55
+ */
56
+ export function implementingPhase(current: unknown, total: unknown): string {
57
+ const t = toWave(total);
58
+ const c = toWave(current);
59
+ if (t !== null && t > 0 && c !== null) {
60
+ const n = Math.min(c + 1, t);
61
+ return `${EPIC_PHASE.IMPLEMENTING} (wave ${n}/${t})`;
62
+ }
63
+ return EPIC_PHASE.IMPLEMENTING;
64
+ }
65
+
66
+ /**
67
+ * Structural binding: `plan-fanout.bpmn` element id → the domain phase the epic is IN while that
68
+ * element (or the write-silent agent step it hands off to) runs. Complete over the epic's spine, so
69
+ * the projection is derivable from provenance alone (the urban #266 semantics). Two entries are
70
+ * deliberately COARSENED from their raw activity label because the structural default misleads:
71
+ * • `record-plan` ("Record plan & levelize") → Reviewing: recording the plan hands the epic to the
72
+ * `review-plan` agent, so the review phase should already read while that (write-silent) agent
73
+ * runs. `record-plan-review` re-affirms Reviewing on each round/escalation.
74
+ * • `select-wave` ("Select wave") → Implementing: it dispatches the wave and is the last host write
75
+ * before the write-silent `implement` MI, so it durably marks the implementation phase for the
76
+ * wave it launches (wave-labelled via {@link implementingPhase} at the call site).
77
+ * • `record-results` ("Finalize plan") → Dispatched: the finalize step's lasting result is the
78
+ * "Fleet dispatched" terminal end event.
79
+ * `record-wave`'s next phase is data-dependent (trial-merge vs. next wave vs. finalize), so it is
80
+ * resolved at its call site rather than from the element id alone; its structural fallback here is
81
+ * the wave it just landed.
82
+ */
83
+ const ELEMENT_PHASE: Readonly<Record<string, string>> = {
84
+ "ensure-base-branch": EPIC_PHASE.PLANNING,
85
+ "plan": EPIC_PHASE.PLANNING,
86
+ "record-plan": EPIC_PHASE.REVIEWING,
87
+ "review-plan": EPIC_PHASE.REVIEWING,
88
+ "record-plan-review": EPIC_PHASE.REVIEWING,
89
+ "plan-review-decision": EPIC_PHASE.REVIEWING,
90
+ "select-wave": EPIC_PHASE.IMPLEMENTING,
91
+ "implement": EPIC_PHASE.IMPLEMENTING,
92
+ "implement-task": EPIC_PHASE.IMPLEMENTING,
93
+ "feature-escalation": EPIC_PHASE.IMPLEMENTING,
94
+ "record-wave": EPIC_PHASE.IMPLEMENTING,
95
+ "wait-wave-merged": EPIC_PHASE.IMPLEMENTING,
96
+ "trial-merge": EPIC_PHASE.TRIAL_MERGING,
97
+ "record-trial-merge": EPIC_PHASE.TRIAL_MERGING,
98
+ "trial-merge-decision": EPIC_PHASE.TRIAL_MERGING,
99
+ "resolve-trial-attention": EPIC_PHASE.TRIAL_MERGING,
100
+ "record-results": EPIC_PHASE.DISPATCHED,
101
+ };
102
+
103
+ /** Optional wave context for a wave-bearing phase, sourced from the wave/levelize records. */
104
+ export interface WaveContext {
105
+ current?: unknown;
106
+ total?: unknown;
107
+ }
108
+
109
+ /**
110
+ * Derive the epic phase for a spine element from its BPMN element id, or `null` when the element
111
+ * doesn't mark a phase — so a non-spine write (e.g. a poller reconcile pass) never clobbers
112
+ * `epic_phase`. A wave-bearing phase (`Implementing`) is wave-labelled from {@link WaveContext} when
113
+ * supplied. This is the single structural deriver; workers pass `job.elementId` so the phase name is
114
+ * never hardcoded at the call site.
115
+ */
116
+ export function deriveEpicPhase(
117
+ elementId: string | undefined | null,
118
+ wave?: WaveContext,
119
+ ): string | null {
120
+ if (!elementId) return null;
121
+ const base = ELEMENT_PHASE[elementId];
122
+ if (base === undefined) return null;
123
+ if (base === EPIC_PHASE.IMPLEMENTING) return implementingPhase(wave?.current, wave?.total);
124
+ return base;
125
+ }
package/app/plan.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  // hand-written SQL — matching app/service.ts.
12
12
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
13
13
  import { blackboardUrl, mintBlackboardToken, renderCoordinationBrief } from "./blackboard.ts";
14
+ import { EPIC_PHASE } from "./epicPhase.ts";
14
15
  import { DEFAULT_ESCALATION_SLA_TIMEOUT, escalationSlaTimeout } from "./escalationSla.ts";
15
16
  import { coalesceTitle, ensureBaseBranch, fetchDefaultBranch, fetchIssueTitle } from "./github.ts";
16
17
  import { clearExclusions } from "./mergeExclusion.ts";
@@ -82,6 +83,12 @@ export interface Plan {
82
83
  // in app/service.ts); `delivery_label` is the human rollup for the epic detail view. Display-only.
83
84
  delivery: string | null;
84
85
  delivery_label: string | null;
86
+ // Derived epic domain phase (038_plan_epic_phase.sql, #261): the epic's own lifecycle phase —
87
+ // Planning / Reviewing / Implementing (wave n/t) / Trial merging / Finalizing / Dispatched —
88
+ // projected at write time from plan-fanout.bpmn's named activities (app/epicPhase.ts), so the epic
89
+ // view can show which phase the epic is IN rather than only the process-instance terminal status.
90
+ // Display-only; NULL until the lifecycle first stamps it (grandfathers pre-#261 rows).
91
+ epic_phase: string | null;
85
92
  created_at: string;
86
93
  updated_at: string;
87
94
  }
@@ -436,6 +443,9 @@ export async function startPlan(
436
443
  issue_url: parsed.url,
437
444
  title,
438
445
  outcome: null,
446
+ // Genesis of the domain lifecycle (#261): the epic re-enters Planning. Cleared of any stale
447
+ // terminal phase from the prior run so the re-plan reads correctly from the first pass.
448
+ epic_phase: EPIC_PHASE.PLANNING,
439
449
  blackboard_token: token,
440
450
  base_branch: base,
441
451
  updated_at: ts,
@@ -449,6 +459,8 @@ export async function startPlan(
449
459
  title,
450
460
  status: "planning",
451
461
  task_count: 0,
462
+ // Genesis of the domain lifecycle (#261): a fresh epic starts in Planning.
463
+ epic_phase: EPIC_PHASE.PLANNING,
452
464
  blackboard_token: token,
453
465
  base_branch: base,
454
466
  created_at: ts,
@@ -0,0 +1,12 @@
1
+ -- 038_plan_epic_phase.sql — issue #261: reify the epic's own domain lifecycle as a derived,
2
+ -- write-time-projected `epic_phase`, so the epic/plan view can show WHICH phase an epic is in —
3
+ -- Planning / Reviewing / Implementing (wave n/t) / Trial merging / Finalizing / Dispatched —
4
+ -- instead of only the process-instance terminal status (`plans.status`, whose `dispatched` is the
5
+ -- `plan-fanout.bpmn` fan-out terminal, not the epic's domain phase).
6
+ --
7
+ -- Forward-only, additive (expand): a nullable TEXT column, display-only. NULL until the plan
8
+ -- lifecycle first stamps it (grandfathering pre-#261 rows), so it never gates control flow. The
9
+ -- value is derived structurally from `plan-fanout.bpmn`'s named activities via each spine worker's
10
+ -- BPMN element id (`app/epicPhase.ts`) and written through the existing plan write path — mirroring
11
+ -- the wave-progress / delivery display projections already on this table.
12
+ ALTER TABLE plans ADD COLUMN epic_phase TEXT;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.81.0",
3
+ "version": "0.82.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -57,6 +57,7 @@
57
57
  },
58
58
  "columns": [
59
59
  { "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "plan_key", "truncate": true, "width": "34%", "linkField": "issue_url" },
60
+ { "field": "epic_phase", "header": "Phase" },
60
61
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
61
62
  { "field": "delivery", "header": "Delivery" },
62
63
  { "field": "wave_label", "header": "Wave" },
@@ -75,6 +75,7 @@
75
75
  ],
76
76
  "columns": [
77
77
  { "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "plan_key", "truncate": true, "width": "34%", "link": { "kind": "page", "page": "epic-detail", "keyField": "plan_key" } },
78
+ { "field": "epic_phase", "header": "Phase" },
78
79
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
79
80
  { "field": "delivery", "header": "Delivery" },
80
81
  { "field": "base_branch", "header": "Base branch" },
@@ -16,6 +16,7 @@
16
16
  // warning; the ordering is lost but every task still runs. No `plan_task_deps` are recorded
17
17
  // in that case (the edges were invalid).
18
18
  import type { AppJobHandler } from "@nanobpm/urban";
19
+ import { deriveEpicPhase } from "../../app/epicPhase.ts";
19
20
  import { planTaskDeps, planTasks } from "../../app/plan.ts";
20
21
  import { computeWaves, WaveError, type WaveTask } from "../../app/waves.ts";
21
22
  import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
@@ -127,6 +128,11 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
127
128
  wave_label: tasks.length > 0 ? `1/${waveCount}` : null,
128
129
  updated_at: ts,
129
130
  };
131
+ // Domain-phase projection (#261): recording the plan hands the epic to the `review-plan` agent,
132
+ // so it enters the Reviewing phase (derived structurally from this worker's BPMN element id).
133
+ // Guard against a null derivation (element id absent) clobbering the genesis phase.
134
+ const epicPhase = deriveEpicPhase(job.elementId);
135
+ if (epicPhase) patch.epic_phase = epicPhase;
130
136
  if (tasks.length === 0) patch.outcome = note ? str(note) : "planner emitted no tasks";
131
137
  await app.data.table("plans", "plan_key").update(planKey, patch);
132
138
 
@@ -13,6 +13,7 @@
13
13
 
14
14
  import type { AppJobHandler } from "@nanobpm/urban";
15
15
  import { BpmnError } from "@nanobpm/urban";
16
+ import { deriveEpicPhase } from "../../app/epicPhase.ts";
16
17
  import { planTasks } from "../../app/plan.ts";
17
18
  import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
18
19
 
@@ -49,9 +50,16 @@ const handler: AppJobHandler<In> = async (job, app) => {
49
50
  throw new BpmnError("NO_WORK_DISPATCHED", `${planKey}: ${outcome}`);
50
51
  }
51
52
 
53
+ // Domain-phase projection (#261): the finalizer landed with opened PRs — the epic reaches its
54
+ // terminal "Fleet dispatched" phase (derived structurally from this worker's BPMN element id).
55
+ // The failed/no-work path above leaves epic_phase untouched: its terminal signal is status +
56
+ // outcome, and stamping "Dispatched" against a failed epic would misread. A null derivation
57
+ // (element id absent) must not clobber the last implementing phase.
58
+ const epicPhase = deriveEpicPhase(job.elementId);
52
59
  await app.data.table("plans", "plan_key").update(planKey, {
53
60
  status: "done",
54
61
  outcome: `${opened} PR(s) dispatched to convergence`,
62
+ ...(epicPhase ? { epic_phase: epicPhase } : {}),
55
63
  updated_at: ts,
56
64
  });
57
65
 
@@ -144,6 +144,8 @@ test("record-wave retries the same wave when a task is still pending", async ()
144
144
  assertEquals((planUpdates[0].patch as Record<string, unknown>).gate_wave, 1);
145
145
  // Retry keeps the projection on the same (still-pending) wave.
146
146
  assertEquals((planUpdates[0].patch as Record<string, unknown>).current_wave, 1);
147
+ // Domain-phase projection (#261): more waves remain, so the epic stays Implementing (wave n/t).
148
+ assertEquals((planUpdates[0].patch as Record<string, unknown>).epic_phase, "Implementing (wave 2/2)");
147
149
  });
148
150
 
149
151
  test("record-wave pins current_wave to the last index and clears gate_wave on the final wave", async () => {
@@ -174,6 +176,9 @@ test("record-wave pins current_wave to the last index and clears gate_wave on th
174
176
  assertEquals((planUpdates[0].patch as Record<string, unknown>).gate_wave, null);
175
177
  assertEquals((planUpdates[0].patch as Record<string, unknown>).current_wave, 2);
176
178
  assertEquals((planUpdates[0].patch as Record<string, unknown>).wave_label, "3/3");
179
+ // Domain-phase projection (#261): the final wave landed with no successor and no trial merge, so
180
+ // the epic enters Finalizing (record-results then advances to the Dispatched terminal).
181
+ assertEquals((planUpdates[0].patch as Record<string, unknown>).epic_phase, "Finalizing");
177
182
  });
178
183
 
179
184
  test("record-wave keeps all wave-progress fields NULL for a taskless plan (waveCount 0)", async () => {
@@ -15,6 +15,7 @@
15
15
  // and, crucially, so a later wave's `dependsOn` can reference the PR keys earlier waves produced.
16
16
  import type { AppJobHandler } from "@nanobpm/urban";
17
17
  import { appendEntry } from "../../app/blackboard.ts";
18
+ import { EPIC_PHASE, implementingPhase } from "../../app/epicPhase.ts";
18
19
  import { fetchPrFiles, fetchPrHead } from "../../app/github.ts";
19
20
  import { deriveExclusions, recordExclusions } from "../../app/mergeExclusion.ts";
20
21
  import { loadMergeProtocol } from "../../app/mergeProtocol.ts";
@@ -280,6 +281,16 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
280
281
  const currentWaveProjection = waveCount > 0 ? projectedCurrentWave : null;
281
282
  const waveLabel = waveCount > 0 ? `${projectedCurrentWave + 1}/${waveCount}` : null;
282
283
 
284
+ // Domain-phase projection (#261): the wave landed — stamp the phase the epic is ENTERING next,
285
+ // which is data-dependent here (unlike the structural spine writers). A trial merge runs → Trial
286
+ // merging; another wave follows → Implementing (next wave n/t); otherwise the finalizer runs →
287
+ // Finalizing (record-results then advances to the Dispatched terminal).
288
+ const epicPhase = runTrialMerge
289
+ ? EPIC_PHASE.TRIAL_MERGING
290
+ : hasMoreWaves
291
+ ? implementingPhase(projectedCurrentWave, waveCount)
292
+ : EPIC_PHASE.FINALIZING;
293
+
283
294
  // Wave-merge barrier: when another wave follows, park the plan-fanout instance at the
284
295
  // `wait-wave-merged` catch event until THIS wave's opened PRs have MERGED (not merely opened).
285
296
  // `gate_wave` is that durable marker; the poller (`pollWaveGates`) clears it and publishes
@@ -292,6 +303,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
292
303
  gate_wave: hasMoreWaves ? currentWave : null,
293
304
  current_wave: currentWaveProjection,
294
305
  wave_label: waveLabel,
306
+ epic_phase: epicPhase,
295
307
  updated_at: ts,
296
308
  });
297
309
  } catch (err) {
@@ -67,7 +67,7 @@ test("select-wave projects the active wave onto plans.current_wave", async () =>
67
67
  ];
68
68
  const plans: Record<string, unknown>[] = [{ plan_key: "owner/repo#63", current_wave: 0 }];
69
69
  const out = await handler(
70
- { variables: { planKey: "owner/repo#63", currentWave: 1 } } as any,
70
+ { variables: { planKey: "owner/repo#63", currentWave: 1 }, elementId: "select-wave" } as any,
71
71
  fakeApp(rows, [], plans),
72
72
  );
73
73
  assertEquals((out as { waveTasks: unknown[] }).waveTasks.length, 1);
@@ -76,6 +76,9 @@ test("select-wave projects the active wave onto plans.current_wave", async () =>
76
76
  // is pre-formatted for the epics-index at-a-glance column.
77
77
  assertEquals(plans[0].wave_count, 2);
78
78
  assertEquals(plans[0].wave_label, "2/2");
79
+ // Domain-phase projection (#261): dispatching the wave marks the epic Implementing (wave n/t),
80
+ // derived from this worker's BPMN element id + the levelize records.
81
+ assertEquals(plans[0].epic_phase, "Implementing (wave 2/2)");
79
82
  });
80
83
 
81
84
  test("select-wave nulls all three progress fields when there are no levelized rows", async () => {
@@ -15,6 +15,7 @@
15
15
  // Emitting an empty `waveTasks` is fine: the MI activity over an empty collection completes
16
16
  // immediately (the same 0-task path the flat fan-out already relied on).
17
17
  import type { AppJobHandler } from "@nanobpm/urban";
18
+ import { deriveEpicPhase } from "../../app/epicPhase.ts";
18
19
  import { plans, planTaskDeps, planTasks } from "../../app/plan.ts";
19
20
  import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
20
21
 
@@ -53,6 +54,13 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
53
54
  // display-only — it must never gate control flow, which stays driven by the process
54
55
  // `currentWave`/`waveCount`/`gate_wave` state.
55
56
  const waveCount = rows.reduce((m, r) => Math.max(m, r.wave ?? 0), -1) + 1;
57
+ // Domain-phase projection (#261): select-wave dispatches this wave and is the last host write
58
+ // before the write-silent `implement` MI, so it durably marks the implementation phase for the
59
+ // wave it launches — `Implementing (wave n/t)` from the levelize records (job.elementId +
60
+ // current/total waves). A null derivation (element id absent) must not clobber the phase.
61
+ const epicPhase = waveCount > 0
62
+ ? deriveEpicPhase(job.elementId, { current: currentWave, total: waveCount })
63
+ : null;
56
64
  try {
57
65
  await plans(app.data).update(planKey, {
58
66
  // Keep the three progress fields consistent: with no levelized rows (waveCount 0) there is
@@ -60,6 +68,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
60
68
  current_wave: waveCount > 0 ? currentWave : null,
61
69
  wave_count: waveCount > 0 ? waveCount : null,
62
70
  wave_label: waveCount > 0 ? `${currentWave + 1}/${waveCount}` : null,
71
+ ...(epicPhase ? { epic_phase: epicPhase } : {}),
63
72
  updated_at: ts,
64
73
  });
65
74
  } catch (err) {