@nanobpm/nano-workforce 0.193.2 → 0.195.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +6 -0
  3. package/SPEC.md +19 -1
  4. package/app/agentCompletion.ts +5 -1
  5. package/app/agentic/cockpit/agent-history-render.test.ts +24 -0
  6. package/app/agentic/cockpit/agent-history-render.ts +18 -4
  7. package/app/agentic/cockpit/agent-history-view.test.ts +16 -0
  8. package/app/agentic/cockpit/agent-history-view.ts +17 -0
  9. package/app/agentic/cockpit/mount.test.ts +12 -0
  10. package/app/convergeGate.test.ts +9 -2
  11. package/app/convergenceStaleRoundReset.test.ts +1 -1
  12. package/app/feature.test.ts +16 -0
  13. package/app/feature.ts +8 -0
  14. package/app/featureDelivery.test.ts +21 -0
  15. package/app/fineGrainedCells.test.ts +1 -1
  16. package/app/mcpToolSurface.ts +6 -1
  17. package/app/mergeApproval.test.ts +126 -0
  18. package/app/service.test.ts +105 -0
  19. package/app/service.ts +43 -4
  20. package/app/userTasks.ts +7 -0
  21. package/db/migrations/116_feature_runs_human_approval.sql +51 -0
  22. package/e2e/feature-run.e2e.ts +18 -0
  23. package/openapi.yaml +15 -0
  24. package/operations/startFeature.readiness.integration.test.ts +14 -0
  25. package/operations/startFeature.ts +4 -0
  26. package/package.json +1 -1
  27. package/pages/cockpit/cockpit.css +17 -1
  28. package/pages/cockpit/mount.js +27 -4
  29. package/pages/feature.page.json +1 -0
  30. package/resources/forms/merge-approval.form +36 -0
  31. package/resources/processes/convergence-loop.bpmn +216 -132
  32. package/resources/processes/feature.bpmn +2 -0
  33. package/resources/prompts/review-round.md +4 -3
  34. package/test/derivation-parity/README.md +4 -3
  35. package/test/derivation-parity/derivation-parity.test.ts +7 -7
  36. package/test/derivation-parity/flows.ts +6 -5
  37. package/workers/converge-feature/worker.ts +3 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.195.0](https://github.com/nanobpm/nano-workforce/compare/v0.194.0...v0.195.0) (2026-09-30)
2
+
3
+ ### Features
4
+
5
+ * **feature:** add optional human approval before merge ([#827](https://github.com/nanobpm/nano-workforce/issues/827)) ([fea6b54](https://github.com/nanobpm/nano-workforce/commit/fea6b54ee3fd63f0adccd230e45ee46ed9df24f8)), closes [#826](https://github.com/nanobpm/nano-workforce/issues/826) [#826](https://github.com/nanobpm/nano-workforce/issues/826)
6
+
7
+ ## [0.194.0](https://github.com/nanobpm/nano-workforce/compare/v0.193.2...v0.194.0) (2026-09-30)
8
+
9
+ ### Features
10
+
11
+ * **cockpit:** collapse agent-history turns behind a timestamped heading ([#832](https://github.com/nanobpm/nano-workforce/issues/832)) ([812907d](https://github.com/nanobpm/nano-workforce/commit/812907d13c49dc8a008bb8682d7d58f04c8ba323)), closes [#831](https://github.com/nanobpm/nano-workforce/issues/831)
12
+
1
13
  ## [0.193.2](https://github.com/nanobpm/nano-workforce/compare/v0.193.1...v0.193.2) (2026-09-29)
2
14
 
3
15
  ### Bug Fixes
package/README.md CHANGED
@@ -290,6 +290,12 @@ A single submission can choose its merge behavior explicitly with the positive
290
290
  `converged` regardless of the global default, while `true` enables the merge-loop
291
291
  after convergence only when `NANO_PR_AUTO_MERGE` is enabled.
292
292
 
293
+ A feature run started with `humanApproval` adds a human gate before the merge-loop
294
+ (so it applies only when the run would merge: `autoMerge` with `NANO_PR_AUTO_MERGE`
295
+ enabled): the converged PR waits in the Tasks inbox as "PR merge approval". Approve it
296
+ to merge, or request changes with guidance. The agent then updates the same PR,
297
+ convergence re-runs, and approval is asked again.
298
+
293
299
  ### Fleet mode: hand it an issue (plan → implement → converge)
294
300
 
295
301
  ```
package/SPEC.md CHANGED
@@ -101,7 +101,7 @@ known at submit time, carried as a process variable and stored on the DB row.
101
101
  │ <gateway: status>
102
102
  │ ├── converged → [Check review comments] (pr.converge-gate; ++ackRetryRound on ack-only block)
103
103
  │ │ → <gateway: comments addressed?>
104
- │ │ ├── addressed → [Scope classifier] → … → [Mark converged] → (end)
104
+ │ │ ├── addressed → [Scope classifier] → … → <human approval?> → [Mark converged] → (end)
105
105
  │ │ ├── review stale (#799) → [Record round] (re-solicit fresh review) ┐
106
106
  │ │ └── unaddressed → <gateway: auto-ack within budget?> │
107
107
  │ │ ├── convergeAckOnly and ackRetryRound ≤ ackRetryMax │
@@ -155,6 +155,24 @@ escalates, resolution failure fails open to the human, and re-submitting the PR
155
155
  loop feeds and reads this memory — a merge-loop answer (same `pr.answer-escalation` step)
156
156
  is tagged `answerContext = "merge"` and never recorded as a convergence adjudication.
157
157
 
158
+ **Human approval before merge (issue #826).** A feature run started with `humanApproval`
159
+ seeds `humanApproval = true` on its convergence instance — only when the PR would merge
160
+ (`autoMerge` on and `NANO_PR_AUTO_MERGE` enabled; otherwise it is pinned off).
161
+ After the scope gate passes, the `human approval?` gateway then parks the converged PR on the
162
+ native `merge-approval` user task (`merge-approval.form`, surfaced in the Tasks inbox as
163
+ "PR merge approval") instead of going straight to `[Mark converged]`. `mergeDecision = "approve"`
164
+ continues to `pr.finalize`, which hands the PR to the merge-loop. Approval covers the PR, not a
165
+ pinned SHA: as with any auto-merge, the merge-loop may still rebase it or fix CI before landing.
166
+ Anything else (the form's
167
+ `revise` plus required guidance) loops back to `capture-head` with the guidance as `answer`, so
168
+ the review agent updates the **same** PR. Convergence then re-runs and approval is asked again.
169
+ The default route is fail-closed: once parked, nothing merges without an explicit approval. A
170
+ gated run that adopts a PR already converging under a live loop (e.g. one an ungated run started)
171
+ narrows that loop to `humanApproval = true` rather than inheriting its auto-merge; a loop already
172
+ past its `human approval?` gateway can no longer be gated. The task is
173
+ human-only (never agent-answerable) and never auto-resumed from an adjudication. Without the
174
+ flag the gateway defaults straight through, so existing runs are unchanged.
175
+
158
176
  Guard: after progress classification, a **progressing** round with round ≥
159
177
  MAX_ROUNDS forces an escalation ("not converged after N rounds") so a human
160
178
  decides rather than looping forever. The guard sits *after* `check-progress`
@@ -25,7 +25,7 @@ import type { DataLayer, EngineClient, GatewayDataSource } from "@nanobpm/urban"
25
25
  import { invalidateAdjudication, invalidateAdjudicationByCompletion } from "./adjudications.ts";
26
26
  import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
27
27
  import { DELIVERY_HUMAN_ELEMENT, isDeliveryHumanElement } from "./deliveryHuman.ts";
28
- import { ACP_PERMISSION_ELEMENT, EMPTY_PLAN_ELEMENT, READINESS_ESCALATION_ELEMENT, READINESS_ESCALATION_PF_ELEMENT } from "./userTasks.ts";
28
+ import { ACP_PERMISSION_ELEMENT, EMPTY_PLAN_ELEMENT, PR_MERGE_APPROVAL_ELEMENT, READINESS_ESCALATION_ELEMENT, READINESS_ESCALATION_PF_ELEMENT } from "./userTasks.ts";
29
29
 
30
30
  const now = () => new Date().toISOString();
31
31
 
@@ -136,6 +136,9 @@ export const HUMAN_COMPLETABLE_ELEMENTS: ReadonlySet<string> = new Set([
136
136
  READINESS_ESCALATION_PF_TASK_ELEMENT,
137
137
  READINESS_ESCALATION_TASK_ELEMENT,
138
138
  ACP_PERMISSION_ELEMENT,
139
+ // Human-only (issue #826): never agent-answerable, so it stays OUT of ESCALATION_TASK_ELEMENTS and the
140
+ // fleet can never auto-approve a merge.
141
+ PR_MERGE_APPROVAL_ELEMENT,
139
142
  ]);
140
143
 
141
144
  /** Each escalation `elementId` → the `.form` whose contract governs its completion variables (the
@@ -153,6 +156,7 @@ const ESCALATION_FORM_BY_ELEMENT: Readonly<Record<string, string>> = {
153
156
  [CONFORMANCE_ESCALATION_TASK_ELEMENT]: "conformance-escalation",
154
157
  [READINESS_ESCALATION_PF_TASK_ELEMENT]: "readiness-escalation",
155
158
  [READINESS_ESCALATION_TASK_ELEMENT]: "readiness-escalation",
159
+ [PR_MERGE_APPROVAL_ELEMENT]: "merge-approval",
156
160
  // NOTE: the delivery-graph `human` node (`DELIVERY_HUMAN_ELEMENT`, ADR 0005 S3) is intentionally
157
161
  // ABSENT here. Unlike the fixed-form escalations above, ONE `delivery-human-task` element is DESIGNED
158
162
  // to render DIFFERENT forms per node (explicit → category → generic → agent-router, `app/deliveryHuman.ts`
@@ -63,6 +63,8 @@ test("renderAgentHistory renders turns with role, text, tool calls and per-turn
63
63
  text: "on it",
64
64
  toolCalls: [{ toolCallId: "t-1", toolName: "grep", elementId: "tool" }],
65
65
  metrics: "12 in \u00b7 4 out \u00b7 900ms",
66
+ producedAt: "2024-05-01T12:34:56.789Z",
67
+ time: "12:34:56",
66
68
  },
67
69
  ],
68
70
  };
@@ -78,6 +80,28 @@ test("renderAgentHistory renders turns with role, text, tool calls and per-turn
78
80
  assert.equal(host.byClass("cockpit-agent-turn-metrics")[0]?.text(), "12 in \u00b7 4 out \u00b7 900ms");
79
81
  });
80
82
 
83
+ test("renderAgentHistory renders each turn collapsed: a <details> whose <summary> heading carries the timestamp (#831)", () => {
84
+ const host = new FakeElement("div");
85
+ renderAgentHistory(host, doc, {
86
+ agentInstanceKey: "ai-1",
87
+ count: 1,
88
+ turns: [{ historyItemKey: "h-1", loopIteration: 1, role: "ASSISTANT", text: "on it", toolCalls: [], producedAt: "2024-05-01T12:34:56.789Z", time: "12:34:56" }],
89
+ });
90
+ const turn = host.byClass("cockpit-agent-turn")[0];
91
+ assert.equal(turn?.tagName, "details");
92
+ // Collapsed by default: no `open` attribute — the native <summary> toggles it on click.
93
+ assert.equal(turn?.getAttribute("open"), undefined);
94
+ const heading = host.byClass("cockpit-agent-turn-meta")[0];
95
+ assert.equal(heading?.tagName, "summary");
96
+ const time = host.byClass("cockpit-agent-turn-time")[0];
97
+ assert.equal(time?.tagName, "time");
98
+ assert.equal(time?.text(), "12:34:56");
99
+ assert.equal(time?.getAttribute("datetime"), "2024-05-01T12:34:56.789Z");
100
+ // The details body sits inside the collapsible turn, not in the heading.
101
+ const body = host.byClass("cockpit-agent-turn-body")[0];
102
+ assert.equal(body?.byClass("cockpit-agent-turn-text")[0]?.text(), "on it");
103
+ });
104
+
81
105
  test("renderAgentHistory renders an empty history state", () => {
82
106
  const host = new FakeElement("div");
83
107
  renderAgentHistory(host, doc, { agentInstanceKey: "ai-9", count: 0, turns: [] });
@@ -106,19 +106,32 @@ export function renderAgentSessions(
106
106
  return { root };
107
107
  }
108
108
 
109
+ /**
110
+ * One turn as a native collapsible: a `<details>` (collapsed by default — no `open`) whose `<summary>`
111
+ * heading (role, iteration, timestamp, metrics) toggles the turn's details body on click (#831).
112
+ */
109
113
  function turnBlock(doc: DocumentLike, t: AgentTurnView): ElementLike {
110
- const block = el(doc, "div", "cockpit-agent-turn");
114
+ const block = el(doc, "details", "cockpit-agent-turn");
111
115
  block.setAttribute("data-history-item-key", t.historyItemKey);
112
116
  block.setAttribute("data-role", t.role);
113
117
  block.setAttribute("data-loop-iteration", String(t.loopIteration));
114
118
 
115
- const meta = el(doc, "div", "cockpit-agent-turn-meta");
119
+ const meta = el(doc, "summary", "cockpit-agent-turn-meta");
116
120
  meta.appendChild(el(doc, "span", "cockpit-agent-turn-role", t.role));
117
121
  meta.appendChild(el(doc, "span", "cockpit-agent-turn-iter", `#${t.loopIteration}`));
122
+ if (t.time !== undefined) {
123
+ const time = el(doc, "time", "cockpit-agent-turn-time", t.time);
124
+ if (t.producedAt !== undefined) {
125
+ time.setAttribute("datetime", t.producedAt);
126
+ time.setAttribute("title", t.producedAt);
127
+ }
128
+ meta.appendChild(time);
129
+ }
118
130
  if (t.metrics !== undefined) meta.appendChild(el(doc, "span", "cockpit-agent-turn-metrics", t.metrics));
119
131
  block.appendChild(meta);
120
132
 
121
- if (t.text !== "") block.appendChild(el(doc, "pre", "cockpit-agent-turn-text", t.text));
133
+ const body = el(doc, "div", "cockpit-agent-turn-body");
134
+ if (t.text !== "") body.appendChild(el(doc, "pre", "cockpit-agent-turn-text", t.text));
122
135
 
123
136
  if (t.toolCalls.length > 0) {
124
137
  const tools = el(doc, "ul", "cockpit-agent-turn-tools");
@@ -127,8 +140,9 @@ function turnBlock(doc: DocumentLike, t: AgentTurnView): ElementLike {
127
140
  li.setAttribute("data-tool-call-id", call.toolCallId);
128
141
  tools.appendChild(li);
129
142
  }
130
- block.appendChild(tools);
143
+ body.appendChild(tools);
131
144
  }
145
+ block.appendChild(body);
132
146
  return block;
133
147
  }
134
148
 
@@ -96,6 +96,22 @@ test("agentHistoryView projects turns in transport order with text, tool calls a
96
96
  assert.equal(view.turns[1]?.metrics, "12 in \u00b7 4 out \u00b7 900ms");
97
97
  });
98
98
 
99
+ test("agentHistoryView carries each turn's producedAt timestamp (absent when unreported)", () => {
100
+ const view = agentHistoryView({
101
+ agentInstanceKey: "ai-1",
102
+ count: 2,
103
+ records: [
104
+ { historyItemKey: "h-0", agentInstanceKey: "ai-1", loopIteration: 0, role: "USER", commitStatus: "COMMITTED", content: [], toolCalls: [], producedAt: "2024-05-01T12:34:56.789Z" },
105
+ { historyItemKey: "h-1", agentInstanceKey: "ai-1", loopIteration: 1, role: "ASSISTANT", commitStatus: "COMMITTED", content: [], toolCalls: [] },
106
+ ],
107
+ });
108
+ assert.equal(view.turns[0]?.producedAt, "2024-05-01T12:34:56.789Z");
109
+ // UTC: the heading is locale-free, so the expected value is fixed regardless of the runner's timezone.
110
+ assert.equal(view.turns[0]?.time, "12:34:56");
111
+ assert.equal(view.turns[1]?.producedAt, undefined);
112
+ assert.equal(view.turns[1]?.time, undefined);
113
+ });
114
+
99
115
  test("agentHistoryView read-as-absence: an empty history yields zero turns", () => {
100
116
  const view = agentHistoryView({ agentInstanceKey: "ai-9", count: 0, records: [] });
101
117
  assert.equal(view.count, 0);
@@ -63,6 +63,10 @@ export interface AgentTurnView {
63
63
  readonly toolCalls: readonly AgentTurnToolCallView[];
64
64
  /** A compact per-turn token/duration rollup, when metrics exist. */
65
65
  readonly metrics?: string;
66
+ /** When the turn was produced (ISO-8601), when the engine reports it. */
67
+ readonly producedAt?: string;
68
+ /** The turn's compact heading timestamp (`HH:MM:SS`, UTC), when `producedAt` parses. */
69
+ readonly time?: string;
66
70
  }
67
71
 
68
72
  /** The full renderable history for one agent instance: its rolled-up header + ordered turns. */
@@ -113,6 +117,16 @@ function turnMetrics(r: AgentHistoryRecord): string | undefined {
113
117
  return dur !== undefined ? `${base} \u00b7 ${dur}` : base;
114
118
  }
115
119
 
120
+ /** A compact `HH:MM:SS` heading timestamp in UTC for an ISO instant; undefined when unparseable. */
121
+ export function turnTime(iso: string | undefined): string | undefined {
122
+ if (iso === undefined || iso === "") return undefined;
123
+ const ms = Date.parse(iso);
124
+ if (!Number.isFinite(ms)) return undefined;
125
+ const d = new Date(ms);
126
+ const pad = (n: number): string => String(n).padStart(2, "0");
127
+ return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
128
+ }
129
+
116
130
  /** The textual content of a turn: TEXT blocks joined in order (non-textual/empty blocks dropped). */
117
131
  function turnText(r: AgentHistoryRecord): string {
118
132
  const texts: string[] = [];
@@ -156,6 +170,7 @@ export function agentSessionsView(report: AgentInstanceListReport): AgentSession
156
170
  /** Project one engine {@link AgentHistoryRecord} onto a renderable {@link AgentTurnView}. */
157
171
  export function agentTurnView(r: AgentHistoryRecord): AgentTurnView {
158
172
  const metrics = turnMetrics(r);
173
+ const time = turnTime(r.producedAt);
159
174
  return {
160
175
  historyItemKey: r.historyItemKey,
161
176
  loopIteration: r.loopIteration,
@@ -167,6 +182,8 @@ export function agentTurnView(r: AgentHistoryRecord): AgentTurnView {
167
182
  ...(c.elementId !== undefined && c.elementId !== "" ? { elementId: c.elementId } : {}),
168
183
  })),
169
184
  ...(metrics !== undefined ? { metrics } : {}),
185
+ ...(r.producedAt !== undefined && r.producedAt !== "" ? { producedAt: r.producedAt } : {}),
186
+ ...(time !== undefined ? { time } : {}),
170
187
  };
171
188
  }
172
189
 
@@ -303,6 +303,7 @@ test("agent-history panel renders the engine run list and a selected run's turns
303
303
  content: [{ contentType: "TEXT", text: "did the thing" }],
304
304
  toolCalls: [{ toolCallId: "t-1", toolName: "grep", elementId: "tool", arguments: {} }],
305
305
  metrics: { inputTokens: 12, outputTokens: 4, reasoningTokenCount: 0, cacheCreationTokenCount: 0, cacheReadTokenCount: 0, durationMs: 900 },
306
+ producedAt: "2024-05-01T12:34:56.789Z",
306
307
  },
307
308
  ],
308
309
  };
@@ -334,6 +335,17 @@ test("agent-history panel renders the engine run list and a selected run's turns
334
335
  assert(transcript != null, "the selected run's history rendered");
335
336
  assert((transcript?.textContent ?? "").includes("did the thing"), "the turn text rendered");
336
337
  assert((transcript?.textContent ?? "").includes("grep"), "the tool call rendered");
338
+ // #831 — each turn renders as a collapsed native <details> whose <summary> heading carries the
339
+ // UTC timestamp. Assert the browser twin mirrors the typed renderer so mount.js can't regress.
340
+ const turn = transcript?.querySelector(".cockpit-agent-turn");
341
+ assertEquals(turn?.tagName?.toLowerCase(), "details", "the turn is a <details>");
342
+ assertEquals(turn?.hasAttribute("open"), false, "collapsed by default: no open attribute");
343
+ const summary = turn?.querySelector(".cockpit-agent-turn-meta");
344
+ assertEquals(summary?.tagName?.toLowerCase(), "summary", "the heading is a <summary>");
345
+ const time = turn?.querySelector(".cockpit-agent-turn-time");
346
+ assertEquals(time?.tagName?.toLowerCase(), "time", "the timestamp is a <time>");
347
+ assertEquals(time?.textContent, "12:34:56", "the heading shows the UTC HH:MM:SS");
348
+ assertEquals(time?.getAttribute("datetime"), "2024-05-01T12:34:56.789Z", "the <time> carries the ISO instant");
337
349
  } finally {
338
350
  handle.dispose();
339
351
  }
@@ -1073,14 +1073,21 @@ test("classify-scope feeds gw-scope-gate, which blocks on scopeBlocked = true",
1073
1073
  assertStringIncludes(blocked, "scopeBlocked = true");
1074
1074
  });
1075
1075
 
1076
- test("gw-scope-gate default arm finalizes (scope ok → persist-converged)", () => {
1076
+ test("gw-scope-gate default arm continues to the optional merge approval, whose default finalizes", () => {
1077
1077
  const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="gw-scope-gate"[^>]*>/);
1078
1078
  assert(gw, "gw-scope-gate gateway missing");
1079
1079
  assertStringIncludes(gw[0], 'default="f_scopeOk"');
1080
1080
  const ok = flowElement("f_scopeOk");
1081
1081
  assert(ok, "f_scopeOk flow missing");
1082
- assertStringIncludes(ok, 'targetRef="persist-converged"');
1082
+ assertStringIncludes(ok, 'targetRef="gw-approval"');
1083
1083
  assert(!/conditionExpression/.test(ok), "the default arm must carry no conditionExpression");
1084
+ // Issue #826: without `humanApproval` the approval gateway's default arm finalizes as before.
1085
+ const approval = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="gw-approval"[^>]*>/);
1086
+ assert(approval, "gw-approval gateway missing");
1087
+ assertStringIncludes(approval[0], 'default="f_noApproval"');
1088
+ const noApproval = flowElement("f_noApproval");
1089
+ assert(noApproval, "f_noApproval flow missing");
1090
+ assertStringIncludes(noApproval, 'targetRef="persist-converged"');
1084
1091
  });
1085
1092
 
1086
1093
  test("the scope escalation routes through gw-escalated with the classifier's specific reason", () => {
@@ -73,7 +73,7 @@ test("capture-head still precedes review-round on every loop entry (the reset is
73
73
  const el = serviceTask("capture-head");
74
74
  assert(el, "capture-head service task must exist");
75
75
  const incoming = [...el!.matchAll(/<bpmn:incoming>([^<]+)<\/bpmn:incoming>/g)].map((m) => m[1]);
76
- for (const flow of ["f_start", "f_reviewLoop", "f_answerLoop", "f_huskRetry", "f_ackRetry"]) {
76
+ for (const flow of ["f_start", "f_reviewLoop", "f_answerLoop", "f_huskRetry", "f_ackRetry", "f_revise"]) {
77
77
  assert(incoming.includes(flow), `capture-head must be the entry join for ${flow} so the reset covers that re-entry (issue #822)`);
78
78
  }
79
79
  const capture = flat.match(/<bpmn:sequenceFlow[^>]*\bid="f_capture"[^>]*\/>/);
@@ -124,6 +124,22 @@ test("startFeature: converge/autoMerge flags are persisted as 0/1", async () =>
124
124
  assertEquals(row.auto_merge, 1);
125
125
  });
126
126
 
127
+ test("startFeature: humanApproval is persisted as 0/1 and seeded onto the instance (issue #826)", async () => {
128
+ for (const humanApproval of [true, false]) {
129
+ const stores = { feature_runs: { rows: [] as any[], key: "feature_key" } };
130
+ let vars: any = null;
131
+ const engine = {
132
+ createInstance: (req: any) => {
133
+ vars = req.variables;
134
+ return Promise.resolve({ processInstanceKey: "PI-1" });
135
+ },
136
+ } as any;
137
+ await startFeature(memData(stores), engine, PARSED, "main", true, true, null, {}, humanApproval);
138
+ assertEquals(stores.feature_runs.rows[0].human_approval, humanApproval ? 1 : 0);
139
+ assertEquals(vars.humanApproval, humanApproval);
140
+ }
141
+ });
142
+
127
143
  test("startFeature: seeds the single task slice + base-branch brief onto the instance", async () => {
128
144
  let captured: any = null;
129
145
  const engine = {
package/app/feature.ts CHANGED
@@ -60,6 +60,9 @@ export interface FeatureRun {
60
60
  pr_key: string | null;
61
61
  converge: number;
62
62
  auto_merge: number;
63
+ /** 1 ⇒ a human must approve the converged PR before the merge-loop runs (issue #826). Only
64
+ * meaningful with `auto_merge = 1`; the `startFeature` operation pins it to 0 otherwise. */
65
+ human_approval: number;
63
66
  outcome: string | null;
64
67
  /** Human rollup detail. Projected by `pollFeatureDelivery` (fix: Feature history stuck at
65
68
  * `converging`) and also written by `pr.record-blocked-ack` with the operator's disposition
@@ -352,6 +355,7 @@ export async function startFeature(
352
355
  autoMerge: boolean,
353
356
  customInstructions: string | null = null,
354
357
  readiness: FeatureReadinessOptions = {},
358
+ humanApproval = false,
355
359
  ): Promise<StartFeatureResult> {
356
360
  // Intake-time readiness gate (issue #295): the probes the run must satisfy before it implements,
357
361
  // and the bound its preflight escalation timers fire off. Both are load-bearing together —
@@ -450,6 +454,7 @@ export async function startFeature(
450
454
  pr_key: null,
451
455
  converge: converge ? 1 : 0,
452
456
  auto_merge: autoMerge ? 1 : 0,
457
+ human_approval: humanApproval ? 1 : 0,
453
458
  outcome: null,
454
459
  delivery_label: null,
455
460
  // Clear the operator tick-off so a re-dispatched run is NOT silently dropped into History when
@@ -472,6 +477,7 @@ export async function startFeature(
472
477
  pr_key: null,
473
478
  converge: converge ? 1 : 0,
474
479
  auto_merge: autoMerge ? 1 : 0,
480
+ human_approval: humanApproval ? 1 : 0,
475
481
  outcome: null,
476
482
  delivery_label: null,
477
483
  created_at: ts,
@@ -507,6 +513,8 @@ export async function startFeature(
507
513
  // merge-loop. `converge=false` ⇒ merge is moot.
508
514
  converge,
509
515
  autoMerge,
516
+ // Park the converged PR at a human approval user task before the merge-loop (issue #826).
517
+ humanApproval,
510
518
  // A single-issue feature run OWNS its issue (the whole issue is the slice), so the agent may
511
519
  // claim it with a "starting work" comment on a first run (resources/prompts/feature.md). Epic slices
512
520
  // (plan-fanout) deliberately DO NOT set this — their `issue` is the shared parent epic, which
@@ -197,6 +197,27 @@ test("pollFeatureDelivery: a converging run whose PR row is MISSING is re-enroll
197
197
  assertEquals(stores.feature_runs[0].delivery_label, "PR record missing");
198
198
  });
199
199
 
200
+ test("pollFeatureDelivery: the re-enroll keeps the run's human approval gate (issue #826)", async () => {
201
+ const { data, stores } = memData();
202
+ stores.feature_runs = [
203
+ { feature_key: "o/r#7", status: "converging", pr_key: "o/r#407", auto_merge: 1, human_approval: 1, delivery_label: null },
204
+ ];
205
+ stores.pull_requests = [];
206
+ let vars: Record<string, unknown> | undefined;
207
+ const engine = {
208
+ searchProcessInstances: async () => [],
209
+ createInstance: async (req: { variables?: Record<string, unknown> }) => {
210
+ vars = req.variables;
211
+ return { processInstanceKey: "pi-407" };
212
+ },
213
+ } as any;
214
+
215
+ await pollFeatureDelivery(data, engine);
216
+
217
+ assertEquals(vars?.convergeOnly, false);
218
+ assertEquals(vars?.humanApproval, true, "a healed enrollment must not silently drop the approval gate");
219
+ });
220
+
200
221
  test("pollFeatureDelivery: a converging run whose PR row is PARTIALLY enrolled (present, non-terminal, process_key NULL) is re-enrolled — a create-instance crash after the row insert does not wedge it (PR #809)", async () => {
201
222
  const { data, stores } = memData();
202
223
  stores.feature_runs = [
@@ -115,7 +115,7 @@ test("feature.bpmn composes its converge step via callActivity to converge-cell
115
115
  /<zeebe:ioMapping>[\s\S]*?<\/zeebe:ioMapping>/.test(convergeBlock),
116
116
  "the converge callActivity must carry an explicit zeebe:ioMapping",
117
117
  );
118
- for (const field of ["featureKey", "prKey", "autoMerge"]) {
118
+ for (const field of ["featureKey", "prKey", "autoMerge", "humanApproval"]) {
119
119
  assert(
120
120
  new RegExp(`<zeebe:input\\b[^>]*\\btarget="${field}"`).test(convergeBlock),
121
121
  `the converge callActivity ioMapping must map ConvergeFeatureIn.${field} into the child scope`,
@@ -111,8 +111,13 @@ export const MCP_TOOL_COUNT_BUDGET = 60;
111
111
  * `getAgenticSupply`, and registry read schemas (plus the `StaleWorker` leaf) so an operator can spot
112
112
  * and drain a stale harness from the tool surface alone. Measured 86,509 bytes serialized — 9 over the
113
113
  * old ceiling's headroom. Deliberate, documented growth — not schema fat.
114
+ *
115
+ * RAISE PROVENANCE — 87_000 → 87_500 (#826): the per-run `humanApproval` start option on both
116
+ * `startFeature` body variants (`FeatureStartByIssue`/`FeatureStartByUrl`) — the Feature tab's
117
+ * "Human approval before merge" checkbox. Measured 87,293 bytes serialized (86,862 → 87,293 — over
118
+ * the old ceiling's 138-byte headroom). Deliberate, documented growth — not schema fat.
114
119
  */
115
- export const MCP_SURFACE_BYTES_BUDGET = 87_000;
120
+ export const MCP_SURFACE_BYTES_BUDGET = 87_500;
116
121
 
117
122
  /**
118
123
  * The eagerly-loaded curated subset MUST stay materially smaller than the full surface — otherwise it
@@ -0,0 +1,126 @@
1
+ // Human approval before merge (issue #826): an optional `merge-approval` user task in the convergence
2
+ // loop, between the scope gate and `persist-converged` (`pr.finalize`, the merge-loop handoff). These
3
+ // deploy the committed model into the WASM engine and drive tokens through it: flag off → finalizes
4
+ // straight away; flag on → parks for a human; approve → finalizes; request changes (or anything that is
5
+ // not an explicit approve) → the guidance goes back to the review agent on the same PR, then approval is
6
+ // asked again.
7
+ import { after, test } from "node:test";
8
+ import { readFileSync } from "node:fs";
9
+ import { assert, assertEquals } from "#test-assert";
10
+ import { assertThatInstance, byProcessId, createWasmEngineClient, type WasmEngineClient } from "@nanobpm/urban-testkit";
11
+ import { ESCALATION_TASK_ELEMENTS, HUMAN_COMPLETABLE_ELEMENTS, validateEscalationVariables } from "./agentCompletion.ts";
12
+ import { PR_MERGE_APPROVAL_ELEMENT } from "./userTasks.ts";
13
+
14
+ const MODEL = readFileSync("resources/processes/convergence-loop.bpmn", "utf8");
15
+
16
+ const engines: WasmEngineClient[] = [];
17
+ after(async () => {
18
+ await Promise.all(engines.map((e) => e.close()));
19
+ });
20
+
21
+ /** Boots one loop whose every round converges cleanly; `answers` records the `answer` each review round saw.
22
+ * Job types in `held` get no worker until `register(type)`, so a test can act while the loop waits there. */
23
+ async function boot(vars: Record<string, unknown>, held: string[] = []) {
24
+ const engine = await createWasmEngineClient();
25
+ engines.push(engine);
26
+ await engine.deployResources([{ name: "convergence-loop.bpmn", content: MODEL, contentType: "text/xml" }]);
27
+ const answers: unknown[] = [];
28
+ const workers: Record<string, (v: Record<string, unknown>) => Record<string, unknown>> = {
29
+ "senior:pr-review": (v) => {
30
+ answers.push(v.answer);
31
+ return { status: "converged", summary: "done" };
32
+ },
33
+ "pr.capture-head": () => ({ roundEntryHead: "sha" }),
34
+ "pr.persist-round": () => ({}),
35
+ "pr.progress-check": () => ({ progressed: true }),
36
+ "pr.converge-gate": () => ({ convergeBlocked: false, convergeBlockReason: "", convergeAckOnly: false }),
37
+ "senior:scope-classify": () => ({ scopeBlocked: false, scopeBlockReason: "" }),
38
+ "pr.finalize": () => ({}),
39
+ };
40
+ const register = (type: string) =>
41
+ engine.registerWorker(type, (job) => workers[type]((job as { variables: Record<string, unknown> }).variables));
42
+ for (const type of Object.keys(workers)) if (!held.includes(type)) await register(type);
43
+ const { processInstanceKey } = await engine.createInstance({
44
+ processDefinitionId: "convergence-loop",
45
+ awaitCompletion: false,
46
+ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1, round: 1, maxRounds: 20, answer: null, huskRetries: 0, ...vars },
47
+ });
48
+ return { engine, answers, register, processInstanceKey };
49
+ }
50
+
51
+ function completions(engine: WasmEngineClient, elementId: string): number {
52
+ const snap = engine.snapshot() as { elementStats?: { elementId: string; completed: number }[] };
53
+ return (snap.elementStats ?? []).find((s) => s.elementId === elementId)?.completed ?? 0;
54
+ }
55
+
56
+ async function openApproval(engine: WasmEngineClient): Promise<string | undefined> {
57
+ return (await engine.openUserTasks()).find((t) => t.elementId === PR_MERGE_APPROVAL_ELEMENT)?.userTaskKey;
58
+ }
59
+
60
+ const loop = (engine: WasmEngineClient) => assertThatInstance(engine, byProcessId("convergence-loop"));
61
+
62
+ test("without humanApproval a converged PR finalizes straight away — no approval task", async () => {
63
+ // `humanApproval` deliberately unseeded: a missing flag must behave exactly like `false`.
64
+ const { engine } = await boot({});
65
+ loop(engine).hasCompleted().hasNoIncident();
66
+ assertEquals(completions(engine, "persist-converged"), 1);
67
+ assertEquals(completions(engine, PR_MERGE_APPROVAL_ELEMENT), 0);
68
+ });
69
+
70
+ test("with humanApproval a converged PR parks for a human, and approve finalizes it", async () => {
71
+ const { engine } = await boot({ humanApproval: true });
72
+ loop(engine).isActive().hasActiveElement(PR_MERGE_APPROVAL_ELEMENT).hasNoIncident();
73
+ assertEquals(completions(engine, "persist-converged"), 0, "nothing hands off to the merge-loop before approval");
74
+
75
+ const key = await openApproval(engine);
76
+ assert(key !== undefined, "the approval task is open");
77
+ await engine.completeUserTask(key, { mergeDecision: "approve" });
78
+ loop(engine).hasCompleted().hasNoIncident();
79
+ assertEquals(completions(engine, "persist-converged"), 1);
80
+ });
81
+
82
+ test("request changes sends the guidance to the review agent on the same PR, then asks again", async () => {
83
+ const { engine, answers } = await boot({ humanApproval: true });
84
+ const first = await openApproval(engine);
85
+ assert(first !== undefined, "the approval task is open");
86
+ await engine.completeUserTask(first, { mergeDecision: "revise", answer: "Rename foo to bar." });
87
+
88
+ loop(engine).isActive().hasActiveElement(PR_MERGE_APPROVAL_ELEMENT).hasNoIncident();
89
+ assertEquals(answers.length, 2, "the review agent ran again");
90
+ assertEquals(answers[1], "Rename foo to bar.", "the revise round received the human's guidance");
91
+ assertEquals(completions(engine, "capture-head"), 2, "the revise re-entered the same loop");
92
+ assertEquals(completions(engine, "persist-converged"), 0);
93
+ const second = await openApproval(engine);
94
+ assert(second !== undefined && second !== first, "approval is asked again after the revision");
95
+ });
96
+
97
+ test("a live ungated loop narrowed to humanApproval (a gated submitPr adopting it) parks for approval", async () => {
98
+ // `submitPr` narrows an adopted live loop with setVariables (app/service.ts); the gateway must honour it.
99
+ const { engine, register, processInstanceKey } = await boot({ humanApproval: false }, ["senior:pr-review"]);
100
+ loop(engine).isActive().hasActiveElement("review-round");
101
+ await engine.setVariables({ scopeKey: String(processInstanceKey), variables: { humanApproval: true } });
102
+ await register("senior:pr-review");
103
+ loop(engine).isActive().hasActiveElement(PR_MERGE_APPROVAL_ELEMENT).hasNoIncident();
104
+ assertEquals(completions(engine, "persist-converged"), 0, "the adopted loop never merges unapproved");
105
+ });
106
+
107
+ test("fail-closed: a completion without an explicit approve never merges", async () => {
108
+ const { engine } = await boot({ humanApproval: true });
109
+ const key = await openApproval(engine);
110
+ assert(key !== undefined, "the approval task is open");
111
+ await engine.completeUserTask(key, {});
112
+ loop(engine).isActive().hasActiveElement(PR_MERGE_APPROVAL_ELEMENT).hasNoIncident();
113
+ assertEquals(completions(engine, "persist-converged"), 0);
114
+ });
115
+
116
+ test("merge-approval is human-only, and its form requires guidance only for request changes", () => {
117
+ assert(HUMAN_COMPLETABLE_ELEMENTS.has(PR_MERGE_APPROVAL_ELEMENT), "the human completer accepts it");
118
+ assert(!ESCALATION_TASK_ELEMENTS.has(PR_MERGE_APPROVAL_ELEMENT), "an agent must never approve a merge");
119
+ const check = (vars: Record<string, unknown>) => validateEscalationVariables(PR_MERGE_APPROVAL_ELEMENT, vars);
120
+ assertEquals(check({ mergeDecision: "approve" }), null);
121
+ assertEquals(check({ mergeDecision: "revise", answer: "Rename foo." }), null);
122
+ assert(check({}) !== null, "a decision is required");
123
+ assert(check({ mergeDecision: "revise" }) !== null, "request changes needs guidance");
124
+ assert(check({ mergeDecision: "revise", answer: " " }) !== null, "blank guidance is rejected");
125
+ assert(check({ mergeDecision: "merge" }) !== null, "only approve/revise are accepted");
126
+ });