@nanobpm/nano-workforce 0.48.1 → 0.49.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,17 @@
1
+ # [0.49.0](https://github.com/nanobpm/nano-workforce/compare/v0.48.2...v0.49.0) (2026-08-12)
2
+
3
+
4
+ ### Features
5
+
6
+ * **ui:** per-epic operator workspace with live wave progress ([#138](https://github.com/nanobpm/nano-workforce/issues/138)) ([127888d](https://github.com/nanobpm/nano-workforce/commit/127888d5d2b66e1e9d1c85d015cbd65f3696216f)), closes [#137](https://github.com/nanobpm/nano-workforce/issues/137) [nanobpm/nano-workforce#137](https://github.com/nanobpm/nano-workforce/issues/137) [nanobpm/nano-ide#213](https://github.com/nanobpm/nano-ide/issues/213) [nanobpm/nano-ide#214](https://github.com/nanobpm/nano-ide/issues/214)
7
+
8
+ ## [0.48.2](https://github.com/nanobpm/nano-workforce/compare/v0.48.1...v0.48.2) (2026-08-12)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **merge-loop:** reconcile from ground truth instead of escalating on a missing agent status ([#135](https://github.com/nanobpm/nano-workforce/issues/135)) ([6861cbe](https://github.com/nanobpm/nano-workforce/commit/6861cbe160d0aebb903924f5aaf67f9c90adac6d)), closes [#134](https://github.com/nanobpm/nano-workforce/issues/134)
14
+
1
15
  ## [0.48.1](https://github.com/nanobpm/nano-workforce/compare/v0.48.0...v0.48.1) (2026-08-12)
2
16
 
3
17
 
@@ -29,6 +29,26 @@ function hasFlow(source: string, target: string): boolean {
29
29
  return re.test(flat);
30
30
  }
31
31
 
32
+ // Assert a specific `<sequenceFlow>` (matched by id) has the given source/target, regardless of
33
+ // attribute order. Unlike `hasFlow`, this pins the *named* flow so a test can't be satisfied by a
34
+ // sibling flow that happens to share the same source/target (e.g. a success arm masking a default).
35
+ function flowHasId(id: string, source: string, target: string): boolean {
36
+ const m = flat.match(new RegExp(`<bpmn:sequenceFlow\\b[^>]*\\bid="${id}"[^>]*/?>`));
37
+ if (!m) return false;
38
+ const tag = m[0];
39
+ return tag.includes(`sourceRef="${source}"`) && tag.includes(`targetRef="${target}"`);
40
+ }
41
+
42
+ // Assert an `<exclusiveGateway>` (matched by id) declares the given `default` flow, regardless of
43
+ // attribute order. Matching the start tag by id and reading `default` from it keeps the guard
44
+ // robust to harmless XML reformatting (attribute reordering / wrapping) that a fixed-order literal
45
+ // substring would spuriously trip on.
46
+ function gatewayDefault(id: string, def: string): boolean {
47
+ const m = flat.match(new RegExp(`<bpmn:exclusiveGateway\\b[^>]*\\bid="${id}"[^>]*>`));
48
+ if (!m) return false;
49
+ return m[0].includes(`default="${def}"`);
50
+ }
51
+
32
52
  test("conflict routes to the rebase budget gate, not straight to a human", () => {
33
53
  // The conflict verdict must reach the auto-rebase gate…
34
54
  assert(hasFlow("gw-mergeable", "gw-rebase"), "gw-mergeable → gw-rebase (conflict) missing");
@@ -62,6 +82,49 @@ test("rebase result: success re-arms the poller; unresolved escalates to a human
62
82
  );
63
83
  });
64
84
 
85
+ test("rebase result: a missing/ambiguous status reconciles from ground truth, not escalation (#134)", () => {
86
+ // #134 / Magikcraft/nano-bpm#751: the rebase agent resolved everything and the PR became
87
+ // MERGEABLE, but it emitted no machine-readable `status`, so the old default arm escalated a
88
+ // PR that was already landable. Ground truth is authoritative: the default (no-verdict) arm
89
+ // now re-arms the merge poller, which re-checks `gw-mergeable` from GitHub state — bounded by
90
+ // the existing rebaseMax budget — instead of pulling in a human.
91
+ assert(
92
+ gatewayDefault("gw-rebase-result", "f_reb_reconcile"),
93
+ 'gw-rebase-result must default to f_reb_reconcile (reconcile)',
94
+ );
95
+ // Pin the *default* reconcile flow itself, not just any gw-rebase-result → arm-merge edge (the
96
+ // success arm `f_reb_rebased` shares that target), so a mis-wired default can't pass silently.
97
+ assert(
98
+ flowHasId("f_reb_reconcile", "gw-rebase-result", "arm-merge"),
99
+ "f_reb_reconcile must default gw-rebase-result → arm-merge (reconcile)",
100
+ );
101
+ // Escalation is now reserved for the agent's explicit "I cannot proceed" verdict.
102
+ assertStringIncludes(flat, 'id="f_reb_blocked"');
103
+ const rebBlocked = flat.match(/<bpmn:sequenceFlow[^>]*id="f_reb_blocked"[\s\S]*?<\/bpmn:sequenceFlow>/);
104
+ assert(rebBlocked, "f_reb_blocked flow missing");
105
+ assertStringIncludes(rebBlocked![0], 'status = "blocked"');
106
+ });
107
+
108
+ test("ci-fix result: a missing/ambiguous status reconciles from ground truth, not escalation (#134)", () => {
109
+ // Symmetric to the rebase arm: the CI-fix agent's no-verdict default re-arms the merge poller
110
+ // (ground-truth re-check, bounded by ciFixMax) rather than escalating a possibly-green PR.
111
+ assert(
112
+ gatewayDefault("gw-ci-result", "f_ci_reconcile"),
113
+ 'gw-ci-result must default to f_ci_reconcile (reconcile)',
114
+ );
115
+ // Pin the *default* reconcile flow itself, not just any gw-ci-result → arm-merge edge (the
116
+ // success arm `f_ci_fixed` shares that target), so a mis-wired default can't pass silently.
117
+ assert(
118
+ flowHasId("f_ci_reconcile", "gw-ci-result", "arm-merge"),
119
+ "f_ci_reconcile must default gw-ci-result → arm-merge (reconcile)",
120
+ );
121
+ // Escalation reserved for the agent's explicit `blocked` verdict.
122
+ const ciBlocked = flat.match(/<bpmn:sequenceFlow[^>]*id="f_ci_blocked"[\s\S]*?<\/bpmn:sequenceFlow>/);
123
+ assert(ciBlocked, "f_ci_blocked flow missing");
124
+ assertStringIncludes(ciBlocked![0], 'status = "blocked"');
125
+ assert(hasFlow("gw-ci-result", "merge-esc-attempt"), "gw-ci-result → merge-esc-attempt (blocked) missing");
126
+ });
127
+
65
128
  test("regression: the conflict verdict passes through the rebase actor, not straight to escalation", () => {
66
129
  // The original #42 livelock had the conflict verdict escalate to a human with no remediation
67
130
  // actor. The primary target of the conflict verdict must now be the rebase gate.
package/app/plan.ts CHANGED
@@ -55,6 +55,18 @@ export interface Plan {
55
55
  // Wave-merge barrier (007_wave_gate.sql): the wave index whose PRs the plan is currently
56
56
  // waiting to see MERGED before dispatching the next wave, or null when not parked at the barrier.
57
57
  gate_wave: number | null;
58
+ // Operator-visibility wave progress (022_plan_wave_progress.sql, #137): denormalised so the
59
+ // epics-index can show wave X/N at a glance. `wave_count` is the total waves (N); `current_wave`
60
+ // is the 0-based index of the wave the fleet is actively implementing (advanced by select-wave,
61
+ // pinned to wave_count-1 on completion). Display-only — never gates control flow. NULL until the
62
+ // plan is dispatched with tasks.
63
+ wave_count: number | null;
64
+ current_wave: number | null;
65
+ // Pre-formatted 1-based "X/N" progress string for the epics-index at-a-glance column
66
+ // (022_plan_wave_progress.sql, #137). The dataGrid has no per-cell templating (nano-ide#214),
67
+ // so this is projected alongside the numeric columns by the same worker writes. NULL until
68
+ // dispatched with tasks.
69
+ wave_label: string | null;
58
70
  // Per-plan capability token for the coordination blackboard (009_plan_blackboard.sql, #51).
59
71
  // Minted at plan start; baked into the blackboard URL handed to implementer agents. NULL for
60
72
  // plans created before the blackboard shipped.
@@ -0,0 +1,24 @@
1
+ -- Operator visibility: at-a-glance wave X/N on the Epic Coordination view (issue #137).
2
+ --
3
+ -- `plan_tasks.wave` (005_plan_deps.sql) holds each task's 0-based wave, but the `plans`
4
+ -- row itself carries no progress summary — so the epics-index grid had no way to show how
5
+ -- far through its waves an epic is without joining/aggregating per row. Urban's datasource
6
+ -- cannot read a SQL VIEW (gateway.ts schema() whitelists only type='table'), so following
7
+ -- the codebase's existing convention (denormalised, worker-maintained pointers on `plans`
8
+ -- like open_task_id / open_plan_round / gate_wave) we project two flat columns:
9
+ --
10
+ -- • wave_count — total number of waves (N). Written by `record-plan` alongside
11
+ -- task_count, from the `waveCount` it already computes (app/waves.ts).
12
+ -- • current_wave — the 0-based index of the wave the fleet is ACTIVELY implementing
13
+ -- (live). Initialised to 0 by `record-plan` at dispatch, advanced by
14
+ -- `select-wave` at the start of each wave, and pinned to wave_count-1 on
15
+ -- completion so a finished epic reads N/N. Display-only: it must never
16
+ -- gate control flow (that stays driven by the currentWave/waveCount/
17
+ -- gate_wave process state). NULL until the plan is dispatched with tasks.
18
+ -- • wave_label — the human "X/N" progress string (1-based), e.g. "2/3". The dataGrid
19
+ -- renders raw field values with no per-cell templating, so this pre-formats
20
+ -- (current_wave+1)/wave_count for the epics-index at-a-glance column. Kept in
21
+ -- lockstep with the two numeric columns by the same worker writes.
22
+ ALTER TABLE plans ADD COLUMN wave_count INTEGER;
23
+ ALTER TABLE plans ADD COLUMN current_wave INTEGER;
24
+ ALTER TABLE plans ADD COLUMN wave_label TEXT;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.48.1",
3
+ "version": "0.49.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",
@@ -46,7 +46,7 @@
46
46
  "lint:fix": "biome check --write app operations workers pages components scripts e2e main.ts"
47
47
  },
48
48
  "dependencies": {
49
- "@nanobpm/urban": "^0.43.0"
49
+ "@nanobpm/urban": "^0.44.0"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@biomejs/biome": "^2.4.11",
@@ -0,0 +1,314 @@
1
+ {
2
+ "schemaVersion": "1.0",
3
+ "title": "Epic",
4
+ "nodes": [
5
+ {
6
+ "type": "nav",
7
+ "id": "nav",
8
+ "props": {
9
+ "variant": "bar",
10
+ "sticky": true,
11
+ "title": "Nano Workforce",
12
+ "items": [
13
+ { "label": "Convergence", "page": "home" },
14
+ { "label": "Epics", "page": "epic" }
15
+ ]
16
+ }
17
+ },
18
+ {
19
+ "type": "text",
20
+ "id": "title",
21
+ "props": { "text": "Epic {{param}}", "variant": "heading" }
22
+ },
23
+ {
24
+ "type": "text",
25
+ "id": "subtitle",
26
+ "props": {
27
+ "text": "This epic's plan, review trace, wave state, conflict graph, coordination notes, and trial-merge results. Sections start collapsed and remember your choice.",
28
+ "variant": "sub"
29
+ }
30
+ },
31
+ {
32
+ "type": "dataGrid",
33
+ "id": "epic-plan",
34
+ "props": {
35
+ "title": "Plan",
36
+ "rowKey": "plan_key",
37
+ "refreshMs": 5000,
38
+ "data": {
39
+ "kind": "datasource",
40
+ "source": "app",
41
+ "table": "plans",
42
+ "orderBy": { "field": "updated_at", "dir": "desc" },
43
+ "filter": [{ "field": "plan_key", "eqParam": true }]
44
+ },
45
+ "columns": [
46
+ { "field": "plan_key", "header": "Issue", "linkField": "issue_url" },
47
+ { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
48
+ { "field": "wave_label", "header": "Wave" },
49
+ { "field": "task_count", "header": "Tasks" },
50
+ { "field": "open_task_id", "header": "Open escalation" },
51
+ { "field": "open_plan_round", "header": "Plan escalation round" },
52
+ { "field": "updated_at", "header": "Updated" }
53
+ ],
54
+ "rowActions": [
55
+ {
56
+ "label": "Proceed with plan",
57
+ "confirm": "Dispatch the current unapproved plan as a human override?",
58
+ "showWhenField": "open_plan_escalation_id",
59
+ "action": {
60
+ "path": "/app/api/actions/message",
61
+ "body": {
62
+ "name": "plan-escalation-answered",
63
+ "correlationKey": "{{row.plan_key}}",
64
+ "variables": { "directive": "proceed", "note": "Proceed override from the epic page." }
65
+ }
66
+ }
67
+ }
68
+ ],
69
+ "detail": {
70
+ "linkField": "issue_url",
71
+ "fields": [
72
+ { "field": "repo", "label": "Repository" },
73
+ { "field": "issue_number", "label": "Issue number" },
74
+ { "field": "base_branch", "label": "Base branch (blank = repo default)" },
75
+ { "field": "outcome", "label": "Outcome" },
76
+ { "field": "open_plan_findings", "label": "Open plan-review findings" },
77
+ { "field": "open_task_question", "label": "Open escalation question" }
78
+ ],
79
+ "form": {
80
+ "showWhenField": "open_plan_escalation_id",
81
+ "title": "Answer the open plan-review escalation",
82
+ "promptField": "open_plan_findings",
83
+ "inputKey": "note",
84
+ "inputLabel": "Revision directive note for the planner",
85
+ "submitLabel": "Revise plan",
86
+ "action": {
87
+ "path": "/app/api/actions/message",
88
+ "body": {
89
+ "name": "plan-escalation-answered",
90
+ "correlationKey": "{{row.plan_key}}",
91
+ "variables": { "directive": "revise", "note": "{{form.note}}" }
92
+ }
93
+ }
94
+ }
95
+ }
96
+ }
97
+ },
98
+ {
99
+ "type": "dataGrid",
100
+ "id": "wave-state",
101
+ "props": {
102
+ "title": "Wave state",
103
+ "rowKey": "id",
104
+ "refreshMs": 5000,
105
+ "collapsible": true,
106
+ "defaultCollapsed": true,
107
+ "groupBy": "wave",
108
+ "groupLabel": "Wave",
109
+ "data": {
110
+ "kind": "datasource",
111
+ "source": "app",
112
+ "table": "plan_tasks",
113
+ "orderBy": { "field": "wave", "dir": "asc" },
114
+ "filter": [
115
+ { "field": "plan_key", "eqParam": true },
116
+ { "field": "status", "in": ["pending", "opened", "escalated", "waiting-for-lane", "blocked"] }
117
+ ]
118
+ },
119
+ "tabs": [
120
+ {
121
+ "label": "Active",
122
+ "filter": [
123
+ { "field": "plan_key", "eqParam": true },
124
+ { "field": "status", "in": ["pending", "opened", "escalated", "waiting-for-lane", "blocked"] }
125
+ ]
126
+ },
127
+ {
128
+ "label": "Skipped",
129
+ "filter": [
130
+ { "field": "plan_key", "eqParam": true },
131
+ { "field": "status", "in": ["skipped"] }
132
+ ]
133
+ },
134
+ {
135
+ "label": "All",
136
+ "filter": [{ "field": "plan_key", "eqParam": true }]
137
+ }
138
+ ],
139
+ "columns": [
140
+ { "field": "wave", "header": "Wave" },
141
+ { "field": "task_id", "header": "Task" },
142
+ { "field": "title", "header": "Title" },
143
+ { "field": "status", "header": "Status" },
144
+ { "field": "pr_key", "header": "PR" },
145
+ { "field": "summary", "header": "Summary" },
146
+ { "field": "updated_at", "header": "Updated" }
147
+ ],
148
+ "detail": {
149
+ "fields": [
150
+ { "field": "open_question", "label": "Open question" },
151
+ { "field": "answer", "label": "Answer" },
152
+ { "field": "draft_pr_key", "label": "Draft PR" },
153
+ { "field": "prompt", "label": "Prompt" }
154
+ ]
155
+ }
156
+ }
157
+ },
158
+ {
159
+ "type": "dataGrid",
160
+ "id": "plan-reviews",
161
+ "props": {
162
+ "title": "Plan review trace",
163
+ "refreshMs": 5000,
164
+ "collapsible": true,
165
+ "defaultCollapsed": true,
166
+ "data": {
167
+ "kind": "datasource",
168
+ "source": "app",
169
+ "table": "plan_reviews",
170
+ "orderBy": { "field": "round", "dir": "asc" },
171
+ "filter": [{ "field": "plan_key", "eqParam": true }]
172
+ },
173
+ "columns": [
174
+ { "field": "round", "header": "Round" },
175
+ { "field": "epoch", "header": "Epoch" },
176
+ { "field": "approved", "header": "Approved? (1/0)" },
177
+ { "field": "findings", "header": "Reviewer findings" },
178
+ { "field": "created_at", "header": "Recorded" }
179
+ ]
180
+ }
181
+ },
182
+ {
183
+ "type": "dataGrid",
184
+ "id": "plan-review-escalations",
185
+ "props": {
186
+ "title": "Plan-review escalations",
187
+ "rowKey": "id",
188
+ "refreshMs": 5000,
189
+ "collapsible": true,
190
+ "defaultCollapsed": true,
191
+ "data": {
192
+ "kind": "datasource",
193
+ "source": "app",
194
+ "table": "plan_review_escalations",
195
+ "orderBy": { "field": "id", "dir": "desc" },
196
+ "filter": [{ "field": "plan_key", "eqParam": true }]
197
+ },
198
+ "columns": [
199
+ { "field": "epoch", "header": "Epoch" },
200
+ { "field": "round", "header": "Round" },
201
+ { "field": "findings", "header": "Findings" },
202
+ { "field": "status", "header": "Status" },
203
+ { "field": "directive", "header": "Directive" },
204
+ { "field": "note", "header": "Human note" },
205
+ { "field": "asked_at", "header": "Asked" },
206
+ { "field": "answered_at", "header": "Answered" }
207
+ ]
208
+ }
209
+ },
210
+ {
211
+ "type": "dataGrid",
212
+ "id": "merge-exclusions",
213
+ "props": {
214
+ "title": "Conflict graph / who blocks whom",
215
+ "rowKey": "id",
216
+ "refreshMs": 5000,
217
+ "collapsible": true,
218
+ "defaultCollapsed": true,
219
+ "data": {
220
+ "kind": "datasource",
221
+ "source": "app",
222
+ "table": "plan_merge_exclusions",
223
+ "orderBy": { "field": "id", "dir": "asc" },
224
+ "filter": [{ "field": "plan_key", "eqParam": true }]
225
+ },
226
+ "columns": [
227
+ { "field": "task_a", "header": "Task A" },
228
+ { "field": "task_b", "header": "Task B" },
229
+ { "field": "files", "header": "Shared files" },
230
+ { "field": "source", "header": "Source" },
231
+ { "field": "updated_at", "header": "Updated" }
232
+ ]
233
+ }
234
+ },
235
+ {
236
+ "type": "dataGrid",
237
+ "id": "coordination-notes",
238
+ "props": {
239
+ "title": "Coordination notes",
240
+ "rowKey": "id",
241
+ "refreshMs": 5000,
242
+ "collapsible": true,
243
+ "defaultCollapsed": true,
244
+ "data": {
245
+ "kind": "datasource",
246
+ "source": "app",
247
+ "table": "plan_blackboard",
248
+ "orderBy": { "field": "id", "dir": "asc" },
249
+ "filter": [{ "field": "plan_key", "eqParam": true }]
250
+ },
251
+ "columns": [
252
+ { "field": "wave", "header": "Wave" },
253
+ { "field": "author_task", "header": "Agent" },
254
+ { "field": "kind", "header": "Kind" },
255
+ { "field": "files", "header": "Files" },
256
+ { "field": "body", "header": "Note" },
257
+ { "field": "created_at", "header": "Posted" }
258
+ ]
259
+ }
260
+ },
261
+ {
262
+ "type": "dataGrid",
263
+ "id": "trial-merge-results",
264
+ "props": {
265
+ "title": "Trial-merge results",
266
+ "rowKey": "id",
267
+ "refreshMs": 5000,
268
+ "collapsible": true,
269
+ "defaultCollapsed": true,
270
+ "data": {
271
+ "kind": "datasource",
272
+ "source": "app",
273
+ "table": "plan_trial_merges",
274
+ "orderBy": { "field": "created_at", "dir": "desc" },
275
+ "filter": [
276
+ { "field": "plan_key", "eqParam": true },
277
+ { "field": "result", "in": ["merge-conflict", "suite-failed"] },
278
+ { "field": "resolved", "in": [0] }
279
+ ]
280
+ },
281
+ "tabs": [
282
+ {
283
+ "label": "Needs attention",
284
+ "filter": [
285
+ { "field": "plan_key", "eqParam": true },
286
+ { "field": "result", "in": ["merge-conflict", "suite-failed"] },
287
+ { "field": "resolved", "in": [0] }
288
+ ]
289
+ },
290
+ {
291
+ "label": "Clean",
292
+ "filter": [
293
+ { "field": "plan_key", "eqParam": true },
294
+ { "field": "result", "in": ["clean"] }
295
+ ]
296
+ },
297
+ {
298
+ "label": "All",
299
+ "filter": [{ "field": "plan_key", "eqParam": true }]
300
+ }
301
+ ],
302
+ "columns": [
303
+ { "field": "wave", "header": "Wave" },
304
+ { "field": "result", "header": "Result" },
305
+ { "field": "heads", "header": "Heads" },
306
+ { "field": "conflicts", "header": "Conflicts" },
307
+ { "field": "failing", "header": "Failing" },
308
+ { "field": "summary", "header": "Summary" },
309
+ { "field": "created_at", "header": "Recorded" }
310
+ ]
311
+ }
312
+ }
313
+ ]
314
+ }