@nanobpm/nano-workforce 0.189.3 → 0.190.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,9 @@
1
+ ## [0.190.0](https://github.com/nanobpm/nano-workforce/compare/v0.189.3...v0.190.0) (2026-09-21)
2
+
3
+ ### Features
4
+
5
+ * **ui:** make Convergence auto-merge opt-in ([#752](https://github.com/nanobpm/nano-workforce/issues/752)) ([db18b17](https://github.com/nanobpm/nano-workforce/commit/db18b17ceadc1cccd48e50a12c98d867f657f3d5)), closes [#751](https://github.com/nanobpm/nano-workforce/issues/751)
6
+
1
7
  ## [0.189.3](https://github.com/nanobpm/nano-workforce/compare/v0.189.2...v0.189.3) (2026-09-20)
2
8
 
3
9
  ### Bug Fixes
package/README.md CHANGED
@@ -285,9 +285,10 @@ branch before escalating (`NANO_PR_MAX_CI_FIX_ROUNDS`, default 3). Conflicts, an
285
285
  exhausted budget, or an agent that can't fix the build escalate to a human; answer in
286
286
  the UI and the process re-arms and retries.
287
287
 
288
- A single submission can pin **review-only** regardless of the global default by
289
- passing `convergeOnly: true` on the `start/convergence-loop` request — the PR stops at
290
- `converged` and is never handed to `merge-loop`, even with `NANO_PR_AUTO_MERGE` on.
288
+ A single submission can choose its merge behavior explicitly with the positive
289
+ `autoMerge` field on the `start/convergence-loop` request: `false` stops at
290
+ `converged` regardless of the global default, while `true` enables the merge-loop
291
+ after convergence only when `NANO_PR_AUTO_MERGE` is enabled.
291
292
 
292
293
  ### Fleet mode: hand it an issue (plan → implement → converge)
293
294
 
@@ -356,7 +357,7 @@ agent at that URL to author, compile, and submit a graph unaided. See
356
357
  | `NANO_PR_POLL_MS` | `60000` | review-ready poll interval |
357
358
  | `NANO_PR_MAX_ROUNDS` | `20` | default cap: escalate after N rounds (per-submit override via the form / the `maxRounds` field on `start/convergence-loop`; clamped 1–100) |
358
359
  | `NANO_PR_WEBHOOK_SECRET` | — | optional shared secret (`x-hook-secret`) for guarded operations (e.g. `GET /app/api/agent`, `/app/api/version`, `/app/api/status`); unset = open |
359
- | `NANO_PR_AUTO_MERGE` | `1` | after convergence, run the merge stage; `0` = stop at `converged` (review-only). Per-submit override via the `convergeOnly` field on `start/convergence-loop` (`true` forces review-only for that PR) |
360
+ | `NANO_PR_AUTO_MERGE` | `1` | after convergence, run the merge stage; `0` = stop at `converged` (review-only). Per-submit override via the positive `autoMerge` field on `start/convergence-loop` |
360
361
  | `NANO_PR_MERGE_METHOD` | `squash` | merge method: `squash`, `merge`, or `rebase` |
361
362
  | `NANO_PR_MERGE_ADMIN` | `0` | pass `--admin` to override failing non-required checks (use with care) |
362
363
  | `NANO_PR_MAX_CI_FIX_ROUNDS` | `3` | max `senior:fix-ci` attempts to green a `blocked` PR before escalating; `0` disables (escalate immediately), clamped 0–20 |
package/SPEC.md CHANGED
@@ -582,10 +582,10 @@ same `prKey`, sharing the datasource and poller. It merges the PR, honouring
582
582
  merge-queue branches and cross-PR dependencies, and reuses the review stage's
583
583
  escalation machinery for anything it can't resolve autonomously.
584
584
 
585
- A per-submit `convergeOnly: true` on the `start/convergence-loop` request pins that PR
586
- to review-only regardless of the global default: `pr.finalize` reads the flag off the
587
- instance and rests the PR at `converged` without starting `merge-loop`. The flag only
588
- ever narrows (it never forces the merge stage on when `NANO_PR_AUTO_MERGE` is off).
585
+ A per-submit `autoMerge` setting on the `start/convergence-loop` request is the preferred
586
+ positive control: `false` pins that PR to review-only regardless of the global default, while
587
+ `true` enables the merge-loop after convergence only when `NANO_PR_AUTO_MERGE` is on. No per-submit
588
+ setting forces the merge stage on when the global toggle is off.
589
589
 
590
590
  Flow:
591
591
 
@@ -691,7 +691,7 @@ queries skip (`merging`), so a slow pass can't double-signal.
691
691
  | `NANO_PR_POLL_MS` | 60000 | poll interval |
692
692
  | `NANO_PR_MAX_ROUNDS` | 20 | default round cap (per-submit `maxRounds` override, clamped 1–100) |
693
693
  | `NANO_PR_WEBHOOK_SECRET` | — | optional shared secret (`x-hook-secret`) for guarded operations (e.g. `/app/api/agent`, `/app/api/version`, `/app/api/status`) |
694
- | `NANO_PR_AUTO_MERGE` | 1 | run the merge stage after convergence (`0` = review-only; per-submit `convergeOnly: true` override) |
694
+ | `NANO_PR_AUTO_MERGE` | 1 | run the merge stage after convergence (`0` = review-only; per-submit `autoMerge: false` override) |
695
695
  | `NANO_PR_MERGE_METHOD` | squash | `squash` \| `merge` \| `rebase` |
696
696
  | `NANO_PR_MERGE_ADMIN` | 0 | pass `--admin` on merge |
697
697
  | `NANO_PR_REVIEW_WAIT_TIMEOUT` | PT30M | ISO-8601 wait before a stalled review escalates (timer arm of the `wait-review` event-based gateway); malformed → default |
package/app/agentGuide.ts CHANGED
@@ -31,7 +31,7 @@ const RAW_GUIDE: string = (() => {
31
31
  "Key endpoints (under the app control-API base `__BASE__`):",
32
32
  "- `GET /status` — every PR in flight, with its engine `processKey` and any open escalation.",
33
33
  "- `GET /version` — which code is live.",
34
- "- `POST /actions/start/convergence-loop` — submit a PR (`{ pr, convergeOnly?, maxRounds?, dependsOn? }`).",
34
+ "- `POST /actions/start/convergence-loop` — submit a PR (`{ pr, autoMerge?, maxRounds?, dependsOn? }`; `autoMerge` is the positive per-request merge control).",
35
35
  "- `POST /actions/start/plan-fanout` — submit an epic (`{ issue, baseBranch }` or `{ url, baseBranch }`; base is required — a missing `epic/*` base is auto-created, and `confirmDefaultBase`/`allowSharedBase` gate the default-branch and shared-base cases — see ADR 0003).",
36
36
  "- `POST /actions/complete-user-task` — answer an escalation (`{ userTaskKey, variables }`); the parked user task's key comes from `GET /status`/the Tasks inbox, and the typed variables match its `.form` (e.g. a PR escalation's `{ answer }`).",
37
37
  "- `POST /actions/message` — publish a BPMN message (optionally correlated) into the engine.",
@@ -67,7 +67,7 @@ export interface WaitProbeEntry {
67
67
  export interface ConnectorTargetEntry {
68
68
  target: string;
69
69
  status: "real" | "forward-declared";
70
- convergeOnlyDefault?: boolean;
70
+ autoMergeDefault?: boolean;
71
71
  summary: string;
72
72
  }
73
73
 
@@ -143,7 +143,7 @@ const NODE_KIND_DETAIL: Record<string, Omit<NodeKindEntry, "kind">> = {
143
143
  mayEmit: true,
144
144
  summary:
145
145
  "An automated, side-effecting outbound action. `payload` for a converge target is " +
146
- "`{ pr, convergeOnly?, dependsOn? }` (`pr` may be a literal `owner/repo#N`, a `<node>.pr` fact " +
146
+ "`{ pr, autoMerge?, dependsOn? }` (`pr` may be a literal `owner/repo#N`, a `<node>.pr` fact " +
147
147
  "reference, or omitted to auto-bind the single incoming `pr` fact). Carries a `dedupeKey` " +
148
148
  "(at-least-once safe). See connectorTargets for which targets are real vs. forward-declared.",
149
149
  },
@@ -218,13 +218,13 @@ const REAL_CONNECTOR_TARGETS: ConnectorTargetEntry[] = [
218
218
  {
219
219
  target: CONVERGE_TARGET,
220
220
  status: "real",
221
- convergeOnlyDefault: convergeOnlyForTarget(CONVERGE_TARGET),
222
- summary: "Converge-only: drive review convergence and STOP at `converged`, never handing off to the merge loop.",
221
+ autoMergeDefault: !convergeOnlyForTarget(CONVERGE_TARGET),
222
+ summary: "Review-only: drive convergence and STOP at `converged`, never handing off to the merge loop.",
223
223
  },
224
224
  {
225
225
  target: CONVERGE_MERGE_TARGET,
226
226
  status: "real",
227
- convergeOnlyDefault: convergeOnlyForTarget(CONVERGE_MERGE_TARGET),
227
+ autoMergeDefault: !convergeOnlyForTarget(CONVERGE_MERGE_TARGET),
228
228
  summary:
229
229
  "Unit-level land: drive review convergence AND the merge loop, landing the PR onto its OWN base branch " +
230
230
  "(for a unit inside an epic that base is the epic integration branch, never `main` directly).",
@@ -232,7 +232,7 @@ const REAL_CONNECTOR_TARGETS: ConnectorTargetEntry[] = [
232
232
  {
233
233
  target: MERGE_MAIN_TARGET,
234
234
  status: "real",
235
- convergeOnlyDefault: convergeOnlyForTarget(MERGE_MAIN_TARGET),
235
+ autoMergeDefault: !convergeOnlyForTarget(MERGE_MAIN_TARGET),
236
236
  summary:
237
237
  "Graph-level top-level land (two-level merge, ADR 0006 §3): land the graph/epic INTEGRATION PR onto `main`. " +
238
238
  "Dispatch-identical to `converge-merge`; the distinction is the LEVEL, kept a first-class literal.",
@@ -113,7 +113,7 @@ The closed set (extensible only by a deliberate ADR/PR, never by graph authors):
113
113
  > PR back to `converging`. `submitPr`'s own `prKey` idempotency additionally makes a resumed re-perform
114
114
  > double-safe on a still-live row. This retires the manual `land-*` human gate whose only job was "go run convergence
115
115
  > yourself" — the canonical shape is now `agent (opens PR) → connector[converge-merge] →
116
- > wait[pr, merged]` with no human node. The payload is `{ pr, convergeOnly?, dependsOn? }`; the `pr`
116
+ > wait[pr, merged]` with no human node. The payload is `{ pr, autoMerge?, dependsOn? }`; the `pr`
117
117
  > may be a literal `owner/repo#N`, a `<node>.pr` fact reference late-bound from an upstream `agent`
118
118
  > node's emitted `pr` fact, or omitted to auto-bind the single incoming `pr` fact (issue #548 —
119
119
  > shipped: the connector resolves it via `resolveConvergePr`, and the `wait[pr]` target is late-bound
@@ -73,7 +73,7 @@ The body is flat. Fields:
73
73
  | field | type | meaning |
74
74
  |---|---|---|
75
75
  | `pr` (or `url`) | string | the PR — `owner/repo#123` or a full PR URL. Required. |
76
- | `convergeOnly` | boolean | **`true` = review only.** The PR stops at `converged` and is **never** handed to the merge loop, even when `NANO_PR_AUTO_MERGE` is on. Omit / `false` = converge **then merge**. |
76
+ | `autoMerge` | boolean | **Positive per-request setting.** `false` = review only, stopping at `converged`, regardless of the global default; `true` = converge **then merge** when `NANO_PR_AUTO_MERGE` is enabled. |
77
77
  | `maxRounds` | integer | per-submit cap before escalating (clamped 1–100; default from `NANO_PR_MAX_ROUNDS`, 20). |
78
78
  | `dependsOn` | string[] | other `prKey`s that must land before this one merges (merge-loop barrier). |
79
79
 
@@ -84,12 +84,12 @@ The body is flat. Fields:
84
84
  # or is only after a review pass):
85
85
  curl -sS -X POST __BASE__/actions/start/convergence-loop \
86
86
  -H 'content-type: application/json' \
87
- -d '{ "pr": "owner/repo#123", "convergeOnly": true }'
87
+ -d '{ "pr": "owner/repo#123", "autoMerge": false }'
88
88
 
89
89
  # Converge then merge, with a dependency barrier and a tighter round cap:
90
90
  curl -sS -X POST __BASE__/actions/start/convergence-loop \
91
91
  -H 'content-type: application/json' \
92
- -d '{ "pr": "owner/repo#42", "maxRounds": 8, "dependsOn": ["owner/repo#40"] }'
92
+ -d '{ "pr": "owner/repo#42", "autoMerge": true, "maxRounds": 8, "dependsOn": ["owner/repo#40"] }'
93
93
  ```
94
94
 
95
95
  Submitting is **idempotent on the PR key** — re-POSTing the same PR refreshes the
@@ -311,7 +311,7 @@ the answer becomes the agent's next-round context.
311
311
  ```
312
312
  submit ──► convergence-loop
313
313
  round (senior:pr-review) ──► addressed ──► wait review-ready ─┐
314
- ├─ converged ──► finalize ──► merge-loop (unless convergeOnly)
314
+ ├─ converged ──► finalize ──► merge-loop (when autoMerge and NANO_PR_AUTO_MERGE are enabled)
315
315
  └─ needs_input/blocked ──► escalate ──► wait-answer userTask (task inbox)
316
316
  merge-loop: wait deps ─► arm merge ─► (queue-aware) merge / land
317
317
  blocked (CI red) ─► senior:fix-ci ─► retry conflict ─► senior:rebase ─► retry
@@ -774,13 +774,13 @@ only job was "go run convergence yourself".
774
774
  - **`converge-merge`** — the **unit-level** land: drive review convergence **and then the merge
775
775
  loop**, landing the PR onto **its own base branch** (for a unit inside an epic that base is the
776
776
  epic integration branch, never `main` directly — ADR 0003 base-branch admission). Equivalent to a
777
- submit with `convergeOnly: false`.
777
+ submit with `autoMerge: true`.
778
778
  - **`merge-main`** — the **graph-level** top-level land: the second level of the two-level merge
779
779
  (ADR 0006 §3), landing the graph/epic **integration** PR onto **`main`**. Dispatch-identical to
780
780
  `converge-merge` (both enroll + merge); the distinction is the *level*, kept a first-class literal
781
781
  so the two levels are authored explicitly rather than left emergent.
782
782
  - **`converge`** — **converge-only**: drive review convergence and stop at `converged`, never
783
- handing off to the merge loop (equivalent to `convergeOnly: true`).
783
+ handing off to the merge loop (equivalent to `autoMerge: false`).
784
784
 
785
785
  > **converge/merge are cell POLICY, not raw nodes.** A raw `senior:converge` / `senior:merge`
786
786
  > **agent** job is **not expressible** — the compiler rejects it (`raw-converge-node`). Express
@@ -788,10 +788,10 @@ only job was "go run convergence yourself".
788
788
  > policy flags (`merge` requires `converge`), or, for enrolling an already-open PR, the
789
789
  > `connector` targets above (ADR 0006 §3 / S5).
790
790
 
791
- **Payload:** `{ pr: "owner/repo#123", convergeOnly?: boolean, dependsOn?: string[] }`. `pr` is
791
+ **Payload:** `{ pr: "owner/repo#123", autoMerge?: boolean, dependsOn?: string[] }`. `pr` is
792
792
  required (a literal `owner/repo#N`, identical to how a `wait: pr` node targets a known PR).
793
- `convergeOnly` defaults from the target and may be overridden per-node; `dependsOn` is unioned
794
- into the PR's merge-stage dependency set. The enrollment is idempotent (the connector's
793
+ `autoMerge` defaults from the target and may be overridden per-node; `dependsOn` is unioned into
794
+ the PR's merge-stage dependency set. The enrollment is idempotent (the connector's
795
795
  at-least-once dedupe fence **plus** `submitPr`'s own `prKey` idempotency), so a graph resume /
796
796
  redelivery never double-enrolls.
797
797
 
package/openapi.yaml CHANGED
@@ -1823,11 +1823,13 @@ components:
1823
1823
  type: integer
1824
1824
  minimum: 1
1825
1825
  description: Values above 100 are accepted and clamped to 100 by the delegate.
1826
+ autoMerge:
1827
+ type: boolean
1828
+ description: Preferred per-request setting. When false, stop at `converged`; when true, run the merge-loop after convergence only if `NANO_PR_AUTO_MERGE` is enabled. When omitted, the global default applies.
1826
1829
  convergeOnly:
1827
1830
  type: boolean
1828
- description: When true, run convergence only and stop at `converged` — the PR is never
1829
- handed to the merge-loop even if auto-merge is on globally (`NANO_PR_AUTO_MERGE`). A
1830
- per-request review-only override; defaults to false (the global auto-merge default applies).
1831
+ deprecated: true
1832
+ description: "Deprecated negative alias. If `autoMerge` is omitted, `true` means `autoMerge: false`; `false` means `autoMerge: true` (still gated by `NANO_PR_AUTO_MERGE`). Prefer `autoMerge`."
1831
1833
  ConvergenceStartByUrl:
1832
1834
  type: object
1833
1835
  additionalProperties: false
@@ -1845,11 +1847,13 @@ components:
1845
1847
  type: integer
1846
1848
  minimum: 1
1847
1849
  description: Values above 100 are accepted and clamped to 100 by the delegate.
1850
+ autoMerge:
1851
+ type: boolean
1852
+ description: Preferred per-request setting. When false, stop at `converged`; when true, run the merge-loop after convergence only if `NANO_PR_AUTO_MERGE` is enabled. When omitted, the global default applies.
1848
1853
  convergeOnly:
1849
1854
  type: boolean
1850
- description: When true, run convergence only and stop at `converged` — the PR is never
1851
- handed to the merge-loop even if auto-merge is on globally (`NANO_PR_AUTO_MERGE`). A
1852
- per-request review-only override; defaults to false (the global auto-merge default applies).
1855
+ deprecated: true
1856
+ description: "Deprecated negative alias. If `autoMerge` is omitted, `true` means `autoMerge: false`; `false` means `autoMerge: true` (still gated by `NANO_PR_AUTO_MERGE`). Prefer `autoMerge`."
1853
1857
  PlanStart:
1854
1858
  description: The start-plan-fanout request body. Names the target issue by EXACTLY ONE of
1855
1859
  `issue` (an `owner/repo#123` reference) or `url` (a bare issue URL), plus a REQUIRED
@@ -3435,8 +3439,8 @@ components:
3435
3439
  autoMerge:
3436
3440
  type: boolean
3437
3441
  description: >-
3438
- When true (and `converge` is true), drive the merge-loop after convergence — mirrors
3439
- `convergeOnly`/`NANO_PR_AUTO_MERGE` inverted. When false the run stops at `converged`.
3442
+ When true (and `converge` is true), drive the merge-loop after convergence only when
3443
+ `NANO_PR_AUTO_MERGE` is enabled. When false the run stops at `converged`.
3440
3444
  Moot when `converge` is false.
3441
3445
  confirmDefaultBase:
3442
3446
  type: boolean
@@ -4573,9 +4577,13 @@ paths:
4573
4577
  type: integer
4574
4578
  minimum: 1
4575
4579
  description: Values above 100 are accepted and clamped to 100 by the delegate.
4580
+ autoMerge:
4581
+ type: boolean
4582
+ description: Preferred per-request setting. When false, stop at `converged`; when true, run the merge-loop after convergence only if `NANO_PR_AUTO_MERGE` is enabled. When omitted, the global default applies.
4576
4583
  convergeOnly:
4577
4584
  type: boolean
4578
- description: When true, run convergence only and stop at `converged` — the PR is never handed to the merge-loop even if auto-merge is on globally (`NANO_PR_AUTO_MERGE`). A per-request review-only override; defaults to false (the global auto-merge default applies).
4585
+ deprecated: true
4586
+ description: 'Deprecated negative alias. If `autoMerge` is omitted, `true` means `autoMerge: false`; `false` means `autoMerge: true` (still gated by `NANO_PR_AUTO_MERGE`). Prefer `autoMerge`.'
4579
4587
  - type: object
4580
4588
  additionalProperties: false
4581
4589
  required:
@@ -4592,9 +4600,13 @@ paths:
4592
4600
  type: integer
4593
4601
  minimum: 1
4594
4602
  description: Values above 100 are accepted and clamped to 100 by the delegate.
4603
+ autoMerge:
4604
+ type: boolean
4605
+ description: Preferred per-request setting. When false, stop at `converged`; when true, run the merge-loop after convergence only if `NANO_PR_AUTO_MERGE` is enabled. When omitted, the global default applies.
4595
4606
  convergeOnly:
4596
4607
  type: boolean
4597
- description: When true, run convergence only and stop at `converged` — the PR is never handed to the merge-loop even if auto-merge is on globally (`NANO_PR_AUTO_MERGE`). A per-request review-only override; defaults to false (the global auto-merge default applies).
4608
+ deprecated: true
4609
+ description: 'Deprecated negative alias. If `autoMerge` is omitted, `true` means `autoMerge: false`; `false` means `autoMerge: true` (still gated by `NANO_PR_AUTO_MERGE`). Prefer `autoMerge`.'
4598
4610
  type: object
4599
4611
  # END generated:mcp-body
4600
4612
  responses:
@@ -5809,9 +5821,9 @@ paths:
5809
5821
  type: string
5810
5822
  enum: [real, forward-declared]
5811
5823
  description: real ⇒ dispatches a real side effect; forward-declared ⇒ a no-op stub.
5812
- convergeOnlyDefault:
5824
+ autoMergeDefault:
5813
5825
  type: boolean
5814
- description: The default `convergeOnly` for a real converge target.
5826
+ description: The default `autoMerge` for a real converge target.
5815
5827
  summary:
5816
5828
  type: string
5817
5829
  description: What the target does.
@@ -5872,7 +5884,7 @@ paths:
5872
5884
  optionalFields: [dedupeKey, payload]
5873
5885
  sideEffecting: true
5874
5886
  mayEmit: true
5875
- summary: "An automated outbound action; payload for a converge target is { pr, convergeOnly?, dependsOn? }."
5887
+ summary: "An automated outbound action; payload for a converge target is { pr, autoMerge?, dependsOn? }."
5876
5888
  factTypes: [string, number, boolean, artifact, version, url, pr]
5877
5889
  guardScalarTypes: [string, number, boolean]
5878
5890
  waitProbeKinds:
@@ -5893,15 +5905,15 @@ paths:
5893
5905
  connectorTargets:
5894
5906
  - target: converge
5895
5907
  status: real
5896
- convergeOnlyDefault: true
5908
+ autoMergeDefault: false
5897
5909
  summary: "Converge-only: drive review convergence and stop at converged."
5898
5910
  - target: converge-merge
5899
5911
  status: real
5900
- convergeOnlyDefault: false
5912
+ autoMergeDefault: true
5901
5913
  summary: "Unit-level land: converge AND merge onto the PR's own base branch."
5902
5914
  - target: merge-main
5903
5915
  status: real
5904
- convergeOnlyDefault: false
5916
+ autoMergeDefault: true
5905
5917
  summary: "Graph-level top-level land onto main (two-level merge)."
5906
5918
  - target: "<any other target>"
5907
5919
  status: forward-declared
@@ -6168,7 +6180,7 @@ paths:
6168
6180
  description: When true, hand the opened PR to the convergence loop (review rounds) as a follow-on step. When false (default) the run ends at "PR raised, not reviewed".
6169
6181
  autoMerge:
6170
6182
  type: boolean
6171
- description: When true (and `converge` is true), drive the merge-loop after convergence — mirrors `convergeOnly`/`NANO_PR_AUTO_MERGE` inverted. When false the run stops at `converged`. Moot when `converge` is false.
6183
+ description: When true (and `converge` is true), drive the merge-loop after convergence only when `NANO_PR_AUTO_MERGE` is enabled. When false the run stops at `converged`. Moot when `converge` is false.
6172
6184
  confirmDefaultBase:
6173
6185
  type: boolean
6174
6186
  description: Acknowledge that `baseBranch` is the repository default branch (the PR would target it directly). See `PlanStartByIssue.confirmDefaultBase`.
@@ -40,7 +40,8 @@ test("the guide covers every capability the endpoint promises", async () => {
40
40
  const md = ((await handler(input(), app)) as any).body.instructions as string;
41
41
  // Submit a PR (converge vs. merge), submit an epic, answer escalations…
42
42
  assert(md.includes("start/convergence-loop"), "covers submitting a PR for convergence");
43
- assert(md.includes("convergeOnly"), "documents review-only vs. merge");
43
+ assert(md.includes("autoMerge"), "documents the per-request merge control");
44
+ assert(!md.includes("convergeOnly"), "does not expose the legacy merge control in agent guidance");
44
45
  assert(md.includes("start/plan-fanout"), "covers submitting an epic");
45
46
  assert(md.includes("complete-user-task"), "covers answering escalations via the native user-task door");
46
47
  // …debug the system.
@@ -143,15 +143,57 @@ test("startConvergenceLoop forwards convergeOnly:true to the loop", async () =>
143
143
  });
144
144
  });
145
145
 
146
+ test("startConvergenceLoop maps autoMerge:true to merge-enabled convergence", async () => {
147
+ await withGithubOff(async () => {
148
+ const { app: capApp, get } = captureApp();
149
+ const res = await startConvergenceLoop(input({ pr: "owner/repo#9", autoMerge: true }), capApp);
150
+ assertEquals((res as any).status, 202);
151
+ assertEquals(get(), false);
152
+ });
153
+ });
154
+
155
+ test("startConvergenceLoop maps autoMerge:false to review-only convergence", async () => {
156
+ await withGithubOff(async () => {
157
+ const { app: capApp, get } = captureApp();
158
+ const res = await startConvergenceLoop(input({ pr: "owner/repo#10", autoMerge: false }), capApp);
159
+ assertEquals((res as any).status, 202);
160
+ assertEquals(get(), true);
161
+ });
162
+ });
163
+
164
+ test("startConvergenceLoop lets autoMerge override the legacy convergeOnly alias", async () => {
165
+ await withGithubOff(async () => {
166
+ const merge = captureApp();
167
+ const mergeRes = await startConvergenceLoop(
168
+ input({ pr: "owner/repo#13", autoMerge: true, convergeOnly: true }),
169
+ merge.app,
170
+ );
171
+ assertEquals((mergeRes as any).status, 202);
172
+ assertEquals(merge.get(), false);
173
+
174
+ const review = captureApp();
175
+ const reviewRes = await startConvergenceLoop(
176
+ input({ pr: "owner/repo#14", autoMerge: false, convergeOnly: false }),
177
+ review.app,
178
+ );
179
+ assertEquals((reviewRes as any).status, 202);
180
+ assertEquals(review.get(), true);
181
+ });
182
+ });
183
+
146
184
  test("startConvergenceLoop defaults convergeOnly to false and does not truthy-coerce a non-boolean", async () => {
147
185
  await withGithubOff(async () => {
148
186
  const omitted = captureApp();
149
- await startConvergenceLoop(input({ pr: "owner/repo#9" }), omitted.app);
187
+ await startConvergenceLoop(input({ pr: "owner/repo#11" }), omitted.app);
150
188
  assertEquals(omitted.get(), false);
151
189
 
152
190
  const stringy = captureApp();
153
- await startConvergenceLoop(input({ pr: "owner/repo#10", convergeOnly: "true" }), stringy.app);
191
+ await startConvergenceLoop(input({ pr: "owner/repo#12", convergeOnly: "true" }), stringy.app);
154
192
  assertEquals(stringy.get(), false);
193
+
194
+ const stringyAutoMerge = captureApp();
195
+ await startConvergenceLoop(input({ pr: "owner/repo#15", autoMerge: "true" }), stringyAutoMerge.app);
196
+ assertEquals(stringyAutoMerge.get(), false);
155
197
  });
156
198
  });
157
199
 
@@ -3,13 +3,15 @@
3
3
  // external webhook relay, a CI job, and Swagger all POST here. Parse the PR reference and
4
4
  // register/refresh the PR aggregate (idempotent on prKey) before starting the loop.
5
5
  //
6
- // The request body is FLAT (`{ pr | url, dependsOn?, maxRounds?, convergeOnly? }`), not wrapped in a
7
- // `variables` envelope: this is a purpose-built operation, not a generic engine "start process" call,
8
- // so it does not leak the engine's variable-map concept to callers. The body is a `oneOf` — EXACTLY
9
- // ONE of `pr` or `url` — so the runtime rejects an empty or ambiguous target at the edge (a 400 that
10
- // names the allowed shapes); this delegate no longer coalesces `pr ?? url`, it just narrows the
11
- // validated variant. It keeps the PR-parse guard because the reference FORMAT (owner/repo#123 or a
12
- // URL) is app logic the JSON schema can't express — an unparseable reference is a 400.
6
+ // The request body is FLAT (`{ pr | url, dependsOn?, maxRounds?, autoMerge?, convergeOnly? }`), not
7
+ // wrapped in a `variables` envelope: this is a purpose-built operation, not a generic engine "start
8
+ // process" call, so it does not leak the engine's variable-map concept to callers. `autoMerge` is
9
+ // the positive, preferred form for UI/API callers; `convergeOnly` remains a legacy negative alias.
10
+ // When both are present, `autoMerge` wins. The body is a `oneOf` — EXACTLY ONE of `pr` or `url` — so
11
+ // the runtime rejects an empty or ambiguous target at the edge (a 400 that names the allowed
12
+ // shapes); this delegate no longer coalesces `pr ?? url`, it just narrows the validated variant. It
13
+ // keeps the PR-parse guard because the reference FORMAT (owner/repo#123 or a URL) is app logic the
14
+ // JSON schema can't express — an unparseable reference is a 400.
13
15
 
14
16
  import { clampRounds, MAX_ROUNDS, parsePr, submitPr } from "../app/service.ts";
15
17
  import { defineOperation } from "../nano-generated/operations.ts";
@@ -29,9 +31,10 @@ export default defineOperation("startConvergenceLoop", async ({ body }, app) =>
29
31
  }
30
32
  const dependsOn = body.dependsOn ?? [];
31
33
  const maxRounds = clampRounds(body.maxRounds, MAX_ROUNDS);
32
- // Per-request review-only override: when true the PR stops at `converged` and is never
33
- // handed to the merge-loop, regardless of the global NANO_PR_AUTO_MERGE default.
34
- const convergeOnly = body.convergeOnly === true;
34
+ // Prefer the positive autoMerge form so an unchecked UI box is explicitly review-only. Legacy
35
+ // callers that omit autoMerge retain the existing convergeOnly/global-default behavior.
36
+ const hasAutoMerge = typeof body.autoMerge === "boolean";
37
+ const convergeOnly = hasAutoMerge ? body.autoMerge !== true : body.convergeOnly === true;
35
38
  const result = await submitPr(app.data, app.engine, parsed, dependsOn, maxRounds, convergeOnly);
36
39
  app.log.info("convergence loop started", {
37
40
  prKey: parsed.prKey,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.189.3",
3
+ "version": "0.190.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",
@@ -105,8 +105,8 @@
105
105
  "type": "number"
106
106
  },
107
107
  {
108
- "key": "convergeOnly",
109
- "label": "Converge only \u2014 stop at review consensus, do not merge",
108
+ "key": "autoMerge",
109
+ "label": "Auto-merge \u2014 after convergence, drive the merge-loop",
110
110
  "type": "checkbox"
111
111
  }
112
112
  ]
@@ -149,6 +149,14 @@ test("readConvergeInput: an explicit payload.convergeOnly overrides the target d
149
149
  assertEquals(r.dependsOn, ["owner/repo#5"], "non-string dependsOn entries are dropped");
150
150
  });
151
151
 
152
+ test("readConvergeInput: autoMerge is the positive per-dispatch control", () => {
153
+ const merge = readConvergeInput("converge", { pr: "owner/repo#7", autoMerge: true }, null);
154
+ assertEquals(merge.convergeOnly, false);
155
+
156
+ const review = readConvergeInput("converge-merge", { pr: "owner/repo#7", autoMerge: false }, null);
157
+ assertEquals(review.convergeOnly, true);
158
+ });
159
+
152
160
  test("readConvergeInput: a missing / unparseable pr fails CLOSED (a converge connector with no target PR is meaningless)", () => {
153
161
  assertThrows(() => readConvergeInput("converge-merge", null, null), Error, "payload.pr");
154
162
  assertThrows(() => readConvergeInput("converge-merge", {}, null), Error, "payload.pr");
@@ -76,13 +76,14 @@ export function safeStringify(value: unknown): string {
76
76
  }
77
77
  }
78
78
 
79
- /** Parse + validate the converge connector's payload (`{ pr, convergeOnly?, dependsOn? }`) for a
79
+ /** Parse + validate the converge connector's payload (`{ pr, autoMerge?, dependsOn? }`) for a
80
80
  * `converge` / `converge-merge` / `merge-main` target. `pr` is REQUIRED and must parse to a canonical `owner/repo#N`
81
81
  * (fail CLOSED — a converge connector with no target PR is meaningless and could never enroll).
82
- * `convergeOnly` DEFAULTS from the target (`converge` → review-only `true`; `converge-merge`/`merge-main` → drive
83
- * the merge loop `false`) and may be overridden per-dispatch by an explicit boolean. `dependsOn` is an
84
- * optional list of PR refs unioned into the enrolled PR's merge-stage dependency set (only non-string
85
- * entries are dropped; `submitPr` itself ignores unparseable refs). Exported for unit coverage.
82
+ * `autoMerge` DEFAULTS from the target (`converge` → `false`; `converge-merge`/`merge-main` → `true`) and
83
+ * may be overridden per-dispatch by an explicit boolean. The deprecated `convergeOnly` alias is still
84
+ * accepted for compatibility. `dependsOn` is an optional list of PR refs unioned into the enrolled PR's
85
+ * merge-stage dependency set (only non-string entries are dropped; `submitPr` itself ignores unparseable
86
+ * refs). Exported for unit coverage.
86
87
  *
87
88
  * `pr` may be sourced three ways (issue #548), resolved by {@link resolveConvergePr} against the
88
89
  * threaded `boundFacts` BEFORE parsing: a LITERAL `owner/repo#N`, an explicit fact REFERENCE
@@ -104,7 +105,13 @@ export function readConvergeInput(
104
105
  `bound from an upstream \`agent\` node (got ${safeStringify(prValue ?? null)})`,
105
106
  );
106
107
  }
107
- const convergeOnly = typeof p.convergeOnly === "boolean" ? p.convergeOnly : convergeOnlyForTarget(target);
108
+ const autoMerge =
109
+ typeof p.autoMerge === "boolean"
110
+ ? p.autoMerge
111
+ : typeof p.convergeOnly === "boolean"
112
+ ? !p.convergeOnly
113
+ : !convergeOnlyForTarget(target);
114
+ const convergeOnly = !autoMerge;
108
115
  const dependsOn = Array.isArray(p.dependsOn) ? p.dependsOn.filter((d): d is string => typeof d === "string") : [];
109
116
  return { parsed, convergeOnly, dependsOn };
110
117
  }