@nanobpm/nano-workforce 0.157.0 → 0.158.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.158.0](https://github.com/nanobpm/nano-workforce/compare/v0.157.0...v0.158.0) (2026-08-29)
2
+
3
+ ### Features
4
+
5
+ * **delivery-graph:** converge?/merge? as first-class edge-gated node policy; two-level merge (ADR 0006 S5) ([#604](https://github.com/nanobpm/nano-workforce/issues/604)) ([a880bac](https://github.com/nanobpm/nano-workforce/commit/a880baca513328af01e57cc9bcb5c1c6371a858c)), closes [#592](https://github.com/nanobpm/nano-workforce/issues/592)
6
+
1
7
  ## [0.157.0](https://github.com/nanobpm/nano-workforce/compare/v0.156.0...v0.157.0) (2026-08-29)
2
8
 
3
9
  ### Features
@@ -10,21 +10,31 @@
10
10
  export const CONVERGE_TARGET = "converge";
11
11
 
12
12
  /** The converge-AND-merge target: enrolls a PR into the shared convergence loop and drives the merge
13
- * loop too (the canonical `agent → connector[converge-merge] → wait[pr, merged]` land shape). */
13
+ * loop too (the canonical `agent → connector[converge-merge] → wait[pr, merged]` land shape). This is
14
+ * the UNIT-level land (ADR 0006 §3 two-level merge): a unit PR lands onto its own base branch — for a
15
+ * unit inside an epic that base is the epic integration branch, never `main` directly. */
14
16
  export const CONVERGE_MERGE_TARGET = "converge-merge";
15
17
 
16
- /** Is `target` one of the converge-enrollment targets (`converge` / `converge-merge`)? The single
17
- * predicate the connector worker branches on to route a dispatch into `submitPr`, and the validator
18
- * branches on to require a bound/literal PR (issue #548). */
18
+ /** The GRAPH-level top-level merge target (ADR 0006 §3 / S5 two-level merge). Enrolls the graph/epic
19
+ * INTEGRATION PR into the shared convergence+merge doors like {@link CONVERGE_MERGE_TARGET}, but names
20
+ * the second, top-of-graph level whose base branch IS `main`. Behaviourally identical to
21
+ * `converge-merge` in dispatch (both `submitPr` with `convergeOnly=false`, landing to the PR's own
22
+ * base); the distinction is the LEVEL — kept a first-class literal so the two-level merge (unit → base;
23
+ * graph → `main`, ADR 0003 base-branch admission) is authored/enforced explicitly, not left emergent. */
24
+ export const MERGE_MAIN_TARGET = "merge-main";
25
+
26
+ /** Is `target` one of the converge-enrollment targets (`converge` / `converge-merge` / `merge-main`)?
27
+ * The single predicate the connector worker branches on to route a dispatch into `submitPr`, and the
28
+ * validator branches on to require a bound/literal PR (issue #548). */
19
29
  export function isConvergeTarget(target: string): boolean {
20
- return target === CONVERGE_TARGET || target === CONVERGE_MERGE_TARGET;
30
+ return target === CONVERGE_TARGET || target === CONVERGE_MERGE_TARGET || target === MERGE_MAIN_TARGET;
21
31
  }
22
32
 
23
33
  /** The DEFAULT `convergeOnly` for a converge target: `converge` is review-only (`true` — stop at
24
- * `converged`), `converge-merge` drives the merge loop too (`false`). Maps directly onto `submitPr`'s
25
- * `convergeOnly` argument. An author may still override it per-dispatch via the connector payload's
26
- * `convergeOnly`. Only ever consulted behind `isConvergeTarget`, so a non-converge target's `false`
27
- * is unreachable. */
34
+ * `converged`), `converge-merge` and `merge-main` drive the merge loop too (`false`). Maps directly
35
+ * onto `submitPr`'s `convergeOnly` argument. An author may still override it per-dispatch via the
36
+ * connector payload's `convergeOnly`. Only ever consulted behind `isConvergeTarget`, so a non-converge
37
+ * target's `false` is unreachable. */
28
38
  export function convergeOnlyForTarget(target: string): boolean {
29
39
  return target === CONVERGE_TARGET;
30
40
  }
@@ -17,6 +17,7 @@ import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
17
17
  import {
18
18
  CONVERGE_MERGE_TARGET,
19
19
  CONVERGE_TARGET,
20
+ MERGE_MAIN_TARGET,
20
21
  connectorDedupeKey,
21
22
  convergeOnlyForTarget,
22
23
  type DeliveryConnectorDispatchRow,
@@ -103,6 +104,14 @@ test("converge targets: `converge`/`converge-merge` are the enrollment targets;
103
104
  assertEquals(convergeOnlyForTarget("converge-merge"), false);
104
105
  });
105
106
 
107
+ test("two-level merge: `merge-main` is the graph-level enrollment target that drives the merge loop (S5)", () => {
108
+ assertEquals(MERGE_MAIN_TARGET, "merge-main");
109
+ // The graph-level top-level merge (graph → main) enrolls via `submitPr` like `converge-merge`.
110
+ assert(isConvergeTarget("merge-main"));
111
+ // It drives the merge loop (not review-only), so its `convergeOnly` default is false.
112
+ assertEquals(convergeOnlyForTarget("merge-main"), false);
113
+ });
114
+
106
115
  test("first dispatch delivers exactly once; a redelivery on the same key dedupes and never re-acts", async () => {
107
116
  await withApp(async (app) => {
108
117
  const at = "2025-01-01T00:00:00.000Z";
@@ -33,12 +33,13 @@ export const DELIVERY_CONNECTOR_TASK_TYPE = "pr.delivery-connector";
33
33
  export const OUTCOME_CLAIMED = "claimed";
34
34
  export const OUTCOME_DELIVERED = "delivered";
35
35
 
36
- /** The two connector `target`s that enroll an agent-opened PR into the app's SHARED convergence /
36
+ /** The three connector `target`s that enroll an agent-opened PR into the app's SHARED convergence /
37
37
  * merge doors via `submitPr` (issue #500) — the delivery-graph side of the exact seam the feature
38
- * cell reuses (`workers/converge-feature`), no duplicated machinery. `converge-merge` drives review
39
- * convergence AND the merge loop; `converge` stops at `converged` (converge-only). This is the "real
38
+ * cell reuses (`workers/converge-feature`), no duplicated machinery. `converge-merge` (unit-level) and
39
+ * `merge-main` (graph-level, ADR 0006 §3 two-level merge) drive review convergence AND the merge loop;
40
+ * `converge` stops at `converged` (converge-only). This is the "real
40
41
  * target dispatch" ADR 0005 deferred as a later slice for the connector I/O surface: a `converge`/
41
- * `converge-merge` connector IS the "automated, side-effecting outbound action" a connector is
42
+ * `converge-merge`/`merge-main` connector IS the "automated, side-effecting outbound action" a connector is
42
43
  * defined to be. The converge-target vocabulary lives in the dependency-free {@link ./convergeTargets.ts}
43
44
  * so the pure validator can share it; re-exported here for the worker's existing import surface. */
44
45
  export {
@@ -46,6 +47,7 @@ export {
46
47
  CONVERGE_TARGET,
47
48
  convergeOnlyForTarget,
48
49
  isConvergeTarget,
50
+ MERGE_MAIN_TARGET,
49
51
  } from "./convergeTargets.ts";
50
52
 
51
53
  /** One durable dispatch-claim row — the at-most-once ledger entry a connector writes before it acts. */
@@ -311,6 +311,67 @@ test("a human node may omit its config (generic-fallback resolution lands in S3)
311
311
  assertEquals(validateDeliveryGraph({ nodes: [{ id: "done", kind: "human" }] }), []);
312
312
  });
313
313
 
314
+ test("raw-converge-node: a raw `senior:converge`/`senior:merge` agent job is not expressible (S5)", () => {
315
+ for (const jobType of ["senior:converge", "senior:merge", "converge", "merge"]) {
316
+ const errors = validateDeliveryGraph({ nodes: [{ id: "a", kind: "agent", agent: { jobType } }] });
317
+ const err = hasCode(errors, "raw-converge-node");
318
+ assertEquals(err.path, "nodes[0].agent.jobType");
319
+ }
320
+ });
321
+
322
+ test("raw-converge-node: `senior:trial-merge` (the merge-cell body) is NOT swept up (exact-verb)", () => {
323
+ assertEquals(
324
+ validateDeliveryGraph({ nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:trial-merge" } }] }),
325
+ [],
326
+ );
327
+ });
328
+
329
+ test("a cell node may carry first-class `converge`/`merge` policy (S5)", () => {
330
+ assertEquals(
331
+ validateDeliveryGraph({
332
+ nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:feature", converge: true, merge: true } }],
333
+ }),
334
+ [],
335
+ );
336
+ // converge-only (stop at green) is legal on its own.
337
+ assertEquals(
338
+ validateDeliveryGraph({
339
+ nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:feature", converge: true } }],
340
+ }),
341
+ [],
342
+ );
343
+ });
344
+
345
+ test("converge-merge-type: `agent.converge`/`agent.merge` must be boolean when present (S5 trust boundary)", () => {
346
+ // `validateDeliveryGraph` is the trust boundary before `as DeliveryGraph`, so a graph that bypassed
347
+ // OpenAPI validation must not be able to smuggle a non-boolean `converge`/`merge` past the S5 policy
348
+ // checks (which compare `=== true`) — a truthy `"true"`/`1` would silently evade merge-requires-converge.
349
+ for (const bad of ["true", 1, 0, null] as const) {
350
+ const cErr = hasCode(
351
+ validateDeliveryGraph({
352
+ nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:feature", converge: bad } }],
353
+ }),
354
+ "converge-merge-type",
355
+ );
356
+ assertEquals(cErr.path, "nodes[0].agent.converge");
357
+ const mErr = hasCode(
358
+ validateDeliveryGraph({
359
+ nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:feature", converge: true, merge: bad } }],
360
+ }),
361
+ "converge-merge-type",
362
+ );
363
+ assertEquals(mErr.path, "nodes[0].agent.merge");
364
+ }
365
+ });
366
+
367
+ test("merge-requires-converge: `agent.merge` without `agent.converge` is rejected (S5 edge-gate)", () => {
368
+ const errors = validateDeliveryGraph({
369
+ nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:feature", merge: true } }],
370
+ });
371
+ const err = hasCode(errors, "merge-requires-converge");
372
+ assertEquals(err.path, "nodes[0].agent.merge");
373
+ });
374
+
314
375
  test("duplicate-fact: two emits sharing a name on one node is rejected", () => {
315
376
  const errors = validateDeliveryGraph({
316
377
  nodes: [
@@ -20,6 +20,7 @@
20
20
  // caller can point the author straight at the offending input.
21
21
 
22
22
  import { isConvergeTarget } from "./convergeTargets.ts";
23
+ import { isRawConvergeMergeJobType, NODE_COMPLETION_POLICIES } from "./nodePolicy.ts";
23
24
 
24
25
  /** The CLOSED node-kind allowlist (ADR 0005 Decision 2) — the trust boundary. Extensible only by a
25
26
  * deliberate ADR/PR (add the openapi variant + a case here), never by a graph author. Kept as the
@@ -90,6 +91,9 @@ export type DeliveryGraphErrorCode =
90
91
  | "non-exhaustive-split"
91
92
  | "exclusive-merge-parity"
92
93
  | "unsupported-on-timeout"
94
+ | "raw-converge-node"
95
+ | "merge-requires-converge"
96
+ | "converge-merge-type"
93
97
  | "unbound-pr";
94
98
 
95
99
  /** A single semantic validation failure. `path` is a JSON-path-qualified pointer at the offending
@@ -377,6 +381,53 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
377
381
  code: "unsupported-on-timeout",
378
382
  });
379
383
  }
384
+ // S5 (ADR 0006 §3): converge/merge are first-class, edge-gated CELL POLICY, not raw nodes.
385
+ // A raw converge/merge agent job (`senior:converge`, `senior:merge`, or a bare `converge`/
386
+ // `merge` verb) is retired as user-facing vocabulary — reject it at compile so "a raw converge
387
+ // node is not expressible" (issue #592). The author expresses convergence/landing via the
388
+ // cell's `converge?`/`merge?` policy instead. `senior:trial-merge` (the merge-cell's internal
389
+ // trial body) and every other verb are unaffected (exact-verb match).
390
+ if (kind === "agent" && typeof config.jobType === "string" && isRawConvergeMergeJobType(config.jobType)) {
391
+ errors.push({
392
+ path: `${path}.${configKey}.jobType`,
393
+ message:
394
+ `raw converge/merge agent job "${config.jobType}" is not expressible — converge and merge ` +
395
+ "are first-class cell policy (set `agent.converge`/`agent.merge`), not a raw agent node " +
396
+ "(ADR 0006 §3 / S5)",
397
+ code: "raw-converge-node",
398
+ });
399
+ }
400
+ // S5 trust boundary: `validateDeliveryGraph` is the gate before `dispatchDeliveryGraphRun`
401
+ // narrows `graph: unknown` with `as DeliveryGraph`, so a graph that bypassed OpenAPI validation
402
+ // must not smuggle a non-boolean `converge`/`merge` past the policy checks below (which compare
403
+ // `=== true`). A truthy `"true"`/`1` would otherwise silently evade merge-requires-converge and
404
+ // the S5 cell policy. Reject any present-but-non-boolean value path-qualified.
405
+ if (kind === "agent") {
406
+ for (const flag of NODE_COMPLETION_POLICIES) {
407
+ const value = config[flag];
408
+ if (value !== undefined && typeof value !== "boolean") {
409
+ errors.push({
410
+ path: `${path}.${configKey}.${flag}`,
411
+ message:
412
+ `\`agent.${flag}\`, when present, must be a boolean — got ${JSON.stringify(value)} ` +
413
+ "(the S5 cell policy is edge-gated on strict `true`/`false`, not a truthy value)",
414
+ code: "converge-merge-type",
415
+ });
416
+ }
417
+ }
418
+ }
419
+ // S5 edge-gate: `merge` presupposes `converge`. Landing a PR you have not driven to green is
420
+ // incoherent (the two are separable phases, but merge REQUIRES converge). Reject `merge: true`
421
+ // without `converge: true` so the enforced policy can't express "land without converging".
422
+ if (kind === "agent" && config.merge === true && config.converge !== true) {
423
+ errors.push({
424
+ path: `${path}.${configKey}.merge`,
425
+ message:
426
+ "`agent.merge` requires `agent.converge` — a PR cannot be landed before it is driven to " +
427
+ "green (ADR 0006 §3 two separable-but-ordered phases / S5)",
428
+ code: "merge-requires-converge",
429
+ });
430
+ }
380
431
  // #548: register a converge-connector / pr-wait as a PR-binding consumer (pass 4 validates the
381
432
  // binding once edges are resolved). Only when the id is usable so pass 4 can key by node id.
382
433
  if (typeof id === "string" && id.length > 0) {
@@ -0,0 +1,84 @@
1
+ // Unit coverage for the pure node completion-policy vocabulary `app/nodePolicy.ts` (ADR 0006 §3,
2
+ // slice S5). Exercises the two ideas S5 promotes to first-class, enforced policy — with no engine and
3
+ // no side effects:
4
+ // • the RAW converge/merge retirement predicate (`isRawConvergeMergeJobType`) that makes "a raw
5
+ // converge node not expressible" (issue #592 acceptance #1) while leaving every legitimate verb —
6
+ // crucially `senior:trial-merge`, the merge-cell's own internal body — untouched, and
7
+ // • the TWO-LEVEL merge mapping (`mergeLevelForTarget` / `mergeBranchForLevel`): a unit lands onto
8
+ // its base branch, the graph lands onto `main`, and a unit can NEVER silently collapse onto `main`
9
+ // (issue #592 acceptance #2, ADR 0003 base-branch admission).
10
+ import { test } from "node:test";
11
+ import { assert, assertEquals, assertThrows } from "#test-assert";
12
+ import { CONVERGE_MERGE_TARGET, CONVERGE_TARGET, MERGE_MAIN_TARGET } from "./convergeTargets.ts";
13
+ import {
14
+ GRAPH_MERGE_BRANCH,
15
+ isRawConvergeMergeJobType,
16
+ jobTypeVerb,
17
+ MERGE_LEVEL_TARGET,
18
+ mergeBranchForLevel,
19
+ mergeLevelForTarget,
20
+ NODE_COMPLETION_POLICIES,
21
+ RAW_CONVERGE_MERGE_VERBS,
22
+ } from "./nodePolicy.ts";
23
+
24
+ test("RAW_CONVERGE_MERGE_VERBS is derived from NODE_COMPLETION_POLICIES (single source of truth, no drift)", () => {
25
+ assertEquals([...RAW_CONVERGE_MERGE_VERBS], [...NODE_COMPLETION_POLICIES]);
26
+ });
27
+
28
+ test("jobTypeVerb extracts the task verb after the last colon, trimmed + lower-cased", () => {
29
+ assertEquals(jobTypeVerb("senior:feature"), "feature");
30
+ assertEquals(jobTypeVerb("senior:trial-merge"), "trial-merge");
31
+ assertEquals(jobTypeVerb("converge"), "converge");
32
+ assertEquals(jobTypeVerb("staff:Merge"), "merge");
33
+ assertEquals(jobTypeVerb("a:b:merge "), "merge");
34
+ });
35
+
36
+ test("isRawConvergeMergeJobType retires raw converge/merge agent jobs (any rank, bare, mixed case)", () => {
37
+ assert(isRawConvergeMergeJobType("senior:converge"));
38
+ assert(isRawConvergeMergeJobType("senior:merge"));
39
+ assert(isRawConvergeMergeJobType("converge"));
40
+ assert(isRawConvergeMergeJobType("merge"));
41
+ assert(isRawConvergeMergeJobType("staff:Merge"));
42
+ });
43
+
44
+ test("isRawConvergeMergeJobType leaves legitimate verbs untouched (exact-verb match, not substring)", () => {
45
+ // `senior:trial-merge` is the merge-cell's own internal trial body — it must NOT be swept up.
46
+ assert(!isRawConvergeMergeJobType("senior:trial-merge"));
47
+ assert(!isRawConvergeMergeJobType("senior:feature"));
48
+ assert(!isRawConvergeMergeJobType("senior:fix"));
49
+ assert(!isRawConvergeMergeJobType("j"));
50
+ assert(!isRawConvergeMergeJobType("merge-cell"));
51
+ });
52
+
53
+ test("MERGE_LEVEL_TARGET pairs each level with its converge-enrollment target", () => {
54
+ assertEquals(MERGE_LEVEL_TARGET.unit, CONVERGE_MERGE_TARGET);
55
+ assertEquals(MERGE_LEVEL_TARGET.graph, MERGE_MAIN_TARGET);
56
+ });
57
+
58
+ test("mergeLevelForTarget maps the two-level merge targets, null for non-landing/other targets", () => {
59
+ assertEquals(mergeLevelForTarget(CONVERGE_MERGE_TARGET), "unit");
60
+ assertEquals(mergeLevelForTarget(MERGE_MAIN_TARGET), "graph");
61
+ // `converge` is review-only (non-landing) → not a merge level.
62
+ assertEquals(mergeLevelForTarget(CONVERGE_TARGET), null);
63
+ assertEquals(mergeLevelForTarget("slack:#x"), null);
64
+ });
65
+
66
+ test("mergeBranchForLevel: two-level merge — unit lands on its base branch, graph lands on main", () => {
67
+ assertEquals(mergeBranchForLevel("unit", { baseBranch: "epic/some-epic" }), "epic/some-epic");
68
+ assertEquals(mergeBranchForLevel("graph", { baseBranch: "epic/some-epic" }), GRAPH_MERGE_BRANCH);
69
+ assertEquals(GRAPH_MERGE_BRANCH, "main");
70
+ // The graph level ignores any supplied base — it ALWAYS targets main.
71
+ assertEquals(mergeBranchForLevel("graph", { baseBranch: null }), "main");
72
+ });
73
+
74
+ test("mergeBranchForLevel: a unit can NEVER silently collapse onto main — missing base throws", () => {
75
+ assertThrows(() => mergeBranchForLevel("unit", { baseBranch: null }));
76
+ assertThrows(() => mergeBranchForLevel("unit", { baseBranch: " " }));
77
+ assertThrows(() => mergeBranchForLevel("unit", {}));
78
+ });
79
+
80
+ test("mergeBranchForLevel: a unit can NEVER target main directly — explicit main base throws", () => {
81
+ assertThrows(() => mergeBranchForLevel("unit", { baseBranch: "main" }));
82
+ assertThrows(() => mergeBranchForLevel("unit", { baseBranch: " main " }));
83
+ assertThrows(() => mergeBranchForLevel("unit", { baseBranch: GRAPH_MERGE_BRANCH }));
84
+ });
@@ -0,0 +1,113 @@
1
+ // nano-workforce — the first-class NODE COMPLETION-POLICY vocabulary for an agent-authored delivery
2
+ // graph (ADR 0006 §3, slice S5). The pure, dependency-free source of truth for two ideas the ADR
3
+ // promotes from EMERGENT behaviour / smuggled prompt prose to explicit, EDGE-GATED, compiler-enforced
4
+ // policy:
5
+ //
6
+ // 1. `converge?` / `merge?` are first-class completion-policy FLAGS on a cell (`agent`) node, NOT
7
+ // raw nodes. "get to green, then land" used to live in a gateway inside `feature.bpmn`
8
+ // (`gw-converge` + the `autoMerge` boolean) and, for a delivery-graph `agent` node, in FREE TEXT
9
+ // in a prompt ("un-draft + merge #B"). This module gives the validator/compiler the predicate
10
+ // that RETIRES a raw converge/merge agent job (`senior:converge`, `senior:merge`) — converge and
11
+ // merge survive only as the `converge`/`merge` policy on a cell, so "a raw converge node is not
12
+ // expressible in the authored vocabulary" (issue #592 acceptance #1).
13
+ //
14
+ // 2. `merge` is TWO-LEVEL (ADR 0003 base-branch admission, ADR 0006 §3): a UNIT's merge lands onto
15
+ // its epic/graph BASE branch, never `main` directly; the GRAPH's final merge-to-`main` is a
16
+ // separate top-level step. This module maps each level to its converge-enrollment target
17
+ // (`converge-merge` = unit → base, `merge-main` = graph → main) and resolves the branch a level
18
+ // lands on, so the two levels are authored/enforced explicitly rather than collapsed.
19
+ //
20
+ // Kept import-light (only the dependency-free `convergeTargets.ts` literals) so the pure semantic
21
+ // validator (`deliveryGraph.ts`) can share these predicates WITHOUT pulling in the connector module's
22
+ // urban/data-layer deps.
23
+
24
+ import { CONVERGE_MERGE_TARGET, MERGE_MAIN_TARGET } from "./convergeTargets.ts";
25
+
26
+ /** The first-class node completion-policy flags a cell (`agent`) node may carry (ADR 0006 §3). Both
27
+ * are separable phases: `converge` drives the PR through its review-convergence loop to green;
28
+ * `merge` lands it. Kept as the single source of truth so the validator, the openapi shape, and any
29
+ * future compiler agree on the closed policy set. */
30
+ export const NODE_COMPLETION_POLICIES = ["converge", "merge"] as const;
31
+
32
+ /** A node completion policy, narrowed to the closed set. */
33
+ export type NodeCompletionPolicy = (typeof NODE_COMPLETION_POLICIES)[number];
34
+
35
+ /** The reserved task VERBS that name a converge/merge phase. An `agent` node's `jobType` is
36
+ * `<rank>:<task>` (e.g. `senior:feature`), or a bare `<task>`; its task verb is the segment after the
37
+ * last `:`. A jobType whose verb is exactly `converge` or `merge` is a RAW converge/merge node — the
38
+ * exact thing S5 retires: converge/merge are cell POLICY (`converge?`/`merge?`), never a raw agent
39
+ * job. Matched by exact verb equality (case-insensitive) so a legitimately-different verb that merely
40
+ * CONTAINS the word — e.g. `senior:trial-merge` (verb `trial-merge`), the real merge-cell body — is
41
+ * NOT swept up. Derived from {@link NODE_COMPLETION_POLICIES} so the reserved-verb vocabulary and the
42
+ * cell policy set cannot drift (they are the same closed set, seen from two angles). */
43
+ export const RAW_CONVERGE_MERGE_VERBS: readonly string[] = NODE_COMPLETION_POLICIES;
44
+
45
+ /** Extract the task verb from an agent `jobType`: the segment after the LAST `:` (`senior:feature` →
46
+ * `feature`), or the whole string when unqualified. Trimmed and lower-cased for a stable compare. */
47
+ export function jobTypeVerb(jobType: string): string {
48
+ const colon = jobType.lastIndexOf(":");
49
+ const verb = colon >= 0 ? jobType.slice(colon + 1) : jobType;
50
+ return verb.trim().toLowerCase();
51
+ }
52
+
53
+ /** True when `jobType` names a RAW converge/merge node — an agent job whose task verb is exactly
54
+ * `converge` or `merge`. These are retired as user-facing vocabulary (issue #592): the author must
55
+ * express convergence/landing via the cell's first-class `converge?` / `merge?` policy, not a raw
56
+ * agent job. `senior:trial-merge` (the merge-cell's internal trial body) and every non-converge verb
57
+ * are unaffected. */
58
+ export function isRawConvergeMergeJobType(jobType: string): boolean {
59
+ const verb = jobTypeVerb(jobType);
60
+ for (const raw of RAW_CONVERGE_MERGE_VERBS) if (raw === verb) return true;
61
+ return false;
62
+ }
63
+
64
+ /** The two levels a `merge` lands at (ADR 0006 §3 two-level merge). `unit` = a delivery UNIT (a
65
+ * feature/slice PR) landing onto its epic/graph base branch; `graph` = the top-of-graph integration
66
+ * landing onto `main`. */
67
+ export const MERGE_LEVELS = ["unit", "graph"] as const;
68
+
69
+ /** A merge level, narrowed to the closed set. */
70
+ export type MergeLevel = (typeof MERGE_LEVELS)[number];
71
+
72
+ /** The converge-enrollment connector target each merge level dispatches through: a UNIT merge lands
73
+ * via `converge-merge` (onto its own base branch), the GRAPH's top-level merge via `merge-main` (onto
74
+ * `main`). The single source of truth pairing the two-level policy with the connector vocabulary. */
75
+ export const MERGE_LEVEL_TARGET: Record<MergeLevel, string> = {
76
+ unit: CONVERGE_MERGE_TARGET,
77
+ graph: MERGE_MAIN_TARGET,
78
+ };
79
+
80
+ /** Reverse of {@link MERGE_LEVEL_TARGET}: the merge level a converge-enrollment `target` names, or
81
+ * `null` when the target is not a two-level merge target (`converge` is review-only, non-landing).
82
+ * Lets the validator/compiler decide which branch a merge cell lands on from the authored target. */
83
+ export function mergeLevelForTarget(target: string): MergeLevel | null {
84
+ if (target === CONVERGE_MERGE_TARGET) return "unit";
85
+ if (target === MERGE_MAIN_TARGET) return "graph";
86
+ return null;
87
+ }
88
+
89
+ /** Resolve the branch a merge LEVEL lands on (ADR 0003 base-branch admission). A `unit` merge lands on
90
+ * the supplied `baseBranch` (the epic/graph integration branch the unit PR targets); a `graph` merge
91
+ * lands on `main`. The two-level invariant in one place: a unit NEVER lands on `main` directly, and
92
+ * the graph's top-level merge ALWAYS targets `main`. `baseBranch` is required for a unit level (its
93
+ * whole point is "not main"); an absent/empty base for a unit — or an explicit `main` base — is a
94
+ * caller error surfaced as a throw so a unit can never silently collapse onto `main`. */
95
+ export const GRAPH_MERGE_BRANCH = "main";
96
+
97
+ export function mergeBranchForLevel(level: MergeLevel, opts: { baseBranch?: string | null }): string {
98
+ if (level === "graph") return GRAPH_MERGE_BRANCH;
99
+ const base = opts.baseBranch?.trim();
100
+ if (!base) {
101
+ throw new Error(
102
+ "unit-level merge requires a base branch (ADR 0003 two-level merge: a unit lands onto its " +
103
+ "epic/graph base branch, never `main` directly)",
104
+ );
105
+ }
106
+ if (base === GRAPH_MERGE_BRANCH) {
107
+ throw new Error(
108
+ "unit-level merge must not target `main` directly (ADR 0003 two-level merge: a unit lands onto " +
109
+ "its epic/graph base branch; the graph's top-level `merge-main` is the only path to `main`)",
110
+ );
111
+ }
112
+ return base;
113
+ }
@@ -438,10 +438,10 @@ layer schedules, it does not re-implement execution):
438
438
 
439
439
  | kind | config | what it does | may `emits`? |
440
440
  |---|---|---|---|
441
- | `agent` | `agent: { jobType, prompt? }` | a worker runs an agent job type (the fan-out body). **Side-effecting.** | yes |
441
+ | `agent` | `agent: { jobType, prompt?, converge?, merge? }` | a worker runs an agent job type (the fan-out body). **Side-effecting.** First-class **`converge?` / `merge?`** cell policy (§9.4) — a **declared, compiler-validated** completion-policy flag that *declares* review-convergence / landing intent (`merge` requires `converge`); a raw `senior:converge`/`senior:merge` job is rejected. This slice adds + validates the flags; the delivery-graph execution wiring that consumes them lands in a follow-up slice. | yes |
442
442
  | `wait` | `wait: <ReadinessProbe>` | a durable, bounded readiness probe — kind ∈ `http`, `command`, `npm`, `github-check`, `capability`, `pr`, `epic`. Read-only. | yes (binds observed facts) |
443
443
  | `human` | `human?: { formKey?, prompt? }` | a scheduled user task + form (the Tasks inbox, §3). Blocks dependents, SLA-bounded, answerable by a human **or** an agent. | yes |
444
- | `connector` | `connector: { target, dedupeKey?, payload? }` | an automated, side-effecting outbound action. Carries a `dedupeKey` (at-least-once safe). Two **real targets** ship today — **`converge`** and **`converge-merge`** (§9.4); other targets are a forward-declared stub. | yes |
444
+ | `connector` | `connector: { target, dedupeKey?, payload? }` | an automated, side-effecting outbound action. Carries a `dedupeKey` (at-least-once safe). Three **real targets** ship today — **`converge`**, **`converge-merge`** (unit → base branch) and **`merge-main`** (graph → `main`, the two-level top-level land) (§9.4); other targets are a forward-declared stub. | yes |
445
445
 
446
446
  A **`wait` node's `wait` is a `ReadinessProbe` verbatim** (the same shape feature-run
447
447
  intake uses): `{ kind, target, onTimeout?, match?, poll? }`, where `poll` is
@@ -578,7 +578,7 @@ publish and records the version → open+merge PR #303 (repo 3) consuming that v
578
578
  "wait": { "kind": "pr", "target": "acme/repo-1#101", "match": { "prState": "merged" },
579
579
  "poll": { "everyMs": 300000, "timeoutMs": 259200000 }, "onTimeout": "escalate" } },
580
580
  { "id": "undraft-merge-b", "kind": "agent",
581
- "agent": { "jobType": "senior:merge", "prompt": "Take draft PR acme/repo-2#202 out of draft and merge it once its required checks are green." } },
581
+ "agent": { "jobType": "senior:feature", "converge": true, "merge": true, "prompt": "Take draft PR acme/repo-2#202 out of draft; converge it to green and land it." } },
582
582
  { "id": "manual-publish", "kind": "human",
583
583
  "human": { "prompt": "Run the manual OTP-authenticated `npm publish` for @acme/widget and set up OIDC trusted publishing. Record the exact published version." },
584
584
  "emits": [ { "name": "publishedVersion", "type": "version", "description": "The version just published to npm." } ] },
@@ -621,19 +621,31 @@ the consumer a `wait` node with `kind: "capability"` (resolving *which published
621
621
  `pkg@version` first carries the change*) fed by the same `manual-publish.publishedVersion`
622
622
  fact — the fact-edge syntax is identical.
623
623
 
624
- ### 9.4 Connector targets — drive a PR to convergence + merge (`converge` / `converge-merge`)
624
+ ### 9.4 Connector targets — drive a PR to convergence + merge (`converge` / `converge-merge` / `merge-main`)
625
625
 
626
- A `connector` node with **`target: "converge-merge"`** (or **`"converge"`**) enrolls an
627
- agent-opened PR into the app's **shared convergence loop** — the *same* enrollment §1 (a
626
+ A `connector` node with **`target: "converge-merge"`** (or **`"converge"`** / **`"merge-main"`**)
627
+ enrolls an agent-opened PR into the app's **shared convergence loop** — the *same* enrollment §1 (a
628
628
  standalone submit) and a feature run use (`submitPr`), no duplicated machinery. This replaces
629
629
  the old habit of bridging an `agent`-opened PR to review with a **human `land-*` gate** whose
630
630
  only job was "go run convergence yourself".
631
631
 
632
- - **`converge-merge`** — drive review convergence **and then the merge loop** (the PR merges
633
- once converged + green). Equivalent to a submit with `convergeOnly: false`.
632
+ - **`converge-merge`** — the **unit-level** land: drive review convergence **and then the merge
633
+ loop**, landing the PR onto **its own base branch** (for a unit inside an epic that base is the
634
+ epic integration branch, never `main` directly — ADR 0003 base-branch admission). Equivalent to a
635
+ submit with `convergeOnly: false`.
636
+ - **`merge-main`** — the **graph-level** top-level land: the second level of the two-level merge
637
+ (ADR 0006 §3), landing the graph/epic **integration** PR onto **`main`**. Dispatch-identical to
638
+ `converge-merge` (both enroll + merge); the distinction is the *level*, kept a first-class literal
639
+ so the two levels are authored explicitly rather than left emergent.
634
640
  - **`converge`** — **converge-only**: drive review convergence and stop at `converged`, never
635
641
  handing off to the merge loop (equivalent to `convergeOnly: true`).
636
642
 
643
+ > **converge/merge are cell POLICY, not raw nodes.** A raw `senior:converge` / `senior:merge`
644
+ > **agent** job is **not expressible** — the compiler rejects it (`raw-converge-node`). Express
645
+ > "get to green, then land" via a cell node's first-class **`agent.converge` / `agent.merge`**
646
+ > policy flags (`merge` requires `converge`), or, for enrolling an already-open PR, the
647
+ > `connector` targets above (ADR 0006 §3 / S5).
648
+
637
649
  **Payload:** `{ pr: "owner/repo#123", convergeOnly?: boolean, dependsOn?: string[] }`. `pr` is
638
650
  required (a literal `owner/repo#N`, identical to how a `wait: pr` node targets a known PR).
639
651
  `convergeOnly` defaults from the target and may be overridden per-node; `dependsOn` is unioned
package/openapi.yaml CHANGED
@@ -1430,6 +1430,29 @@ components:
1430
1430
  type: string
1431
1431
  maxLength: 20000
1432
1432
  description: OPTIONAL steering prompt appended to the node's job brief.
1433
+ converge:
1434
+ type: boolean
1435
+ description: >-
1436
+ OPTIONAL first-class CONVERGE policy (ADR 0006 §3 / S5) — a DECLARED, compiler-
1437
+ validated completion-policy flag on this cell node. It declares that the node's
1438
+ opened PR is to be driven through the review-convergence loop to green as an
1439
+ edge-gated completion policy; this slice adds and validates the flag, with the
1440
+ delivery-graph execution wiring that consumes it landing in a follow-up slice.
1441
+ It supersedes (in intent) the emergent `feature.bpmn` `gw-converge` gateway and
1442
+ the "un-draft + merge #B" prompt prose a delivery-graph `agent` node used to
1443
+ smuggle. Converge and merge are SEPARABLE phases; a node may converge without
1444
+ merging (stop at green and gate the landing behind a downstream node).
1445
+ merge:
1446
+ type: boolean
1447
+ description: >-
1448
+ OPTIONAL first-class MERGE (land) policy (ADR 0006 §3 / S5) — a DECLARED,
1449
+ compiler-validated flag. When set it declares that the cell lands its PR.
1450
+ REQUIRES `converge: true` — you cannot land a PR you have not driven to green
1451
+ (the validator rejects `merge` without `converge`). This slice adds and validates
1452
+ the flag; the execution wiring that consumes it lands in a follow-up slice.
1453
+ TWO-LEVEL (ADR 0003 base-branch admission): a UNIT node lands onto its epic/graph
1454
+ base branch, never `main` directly; the graph's final merge-to-`main` is a
1455
+ separate top-level step.
1433
1456
  timeout:
1434
1457
  type: string
1435
1458
  pattern: '^[Pp](?!$)(\d+[Yy])?(\d+[Mm])?(\d+[Ww])?(\d+[Dd])?([Tt](?=\d)(\d+[Hh])?(\d+[Mm])?(\d+[Ss])?)?$'
@@ -60,7 +60,7 @@ test("the guide documents the delivery-graph surface (ADR 0005)", async () => {
60
60
  // The closed node vocabulary: assert the exact config snippet for each of the four kinds,
61
61
  // so the test fails if §9's node-kind table is removed or reworded — not merely if the bare
62
62
  // words "agent"/"wait"/"human"/"connector" appear anywhere else in the guide.
63
- assert(md.includes("agent: { jobType, prompt? }"), "documents the agent node config");
63
+ assert(md.includes("agent: { jobType, prompt?, converge?, merge? }"), "documents the agent node config");
64
64
  assert(md.includes("wait: <ReadinessProbe>"), "documents the wait node config");
65
65
  assert(md.includes("human?: { formKey?, prompt? }"), "documents the human node config");
66
66
  assert(md.includes("connector: { target, dedupeKey?, payload? }"), "documents the connector node config");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.157.0",
3
+ "version": "0.158.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",
@@ -132,6 +132,17 @@ test("readConvergeInput: parses pr; convergeOnly defaults from the target; depen
132
132
  assertEquals(conv.convergeOnly, true);
133
133
  });
134
134
 
135
+ test("readConvergeInput: `merge-main` (graph-level two-level merge, S5) parses pr and defaults convergeOnly false", () => {
136
+ // The graph-level top-level enrollment target drives the merge loop like `converge-merge`,
137
+ // so its worker-level `readConvergeInput` default must also be `convergeOnly=false`.
138
+ const mm = readConvergeInput("merge-main", { pr: "owner/repo#7" }, null);
139
+ assertEquals(mm.parsed.prKey, "owner/repo#7");
140
+ assertEquals(mm.convergeOnly, false);
141
+ assertEquals(mm.dependsOn, []);
142
+ // An explicit payload override still wins over the target default.
143
+ assertEquals(readConvergeInput("merge-main", { pr: "owner/repo#7", convergeOnly: true }, null).convergeOnly, true);
144
+ });
145
+
135
146
  test("readConvergeInput: an explicit payload.convergeOnly overrides the target default; dependsOn threads through", () => {
136
147
  const r = readConvergeInput("converge-merge", { pr: "owner/repo#7", convergeOnly: true, dependsOn: ["owner/repo#5", 42] as unknown as string[] }, null);
137
148
  assertEquals(r.convergeOnly, true, "the explicit boolean wins over the target default");
@@ -77,9 +77,9 @@ export function safeStringify(value: unknown): string {
77
77
  }
78
78
 
79
79
  /** Parse + validate the converge connector's payload (`{ pr, convergeOnly?, dependsOn? }`) for a
80
- * `converge` / `converge-merge` target. `pr` is REQUIRED and must parse to a canonical `owner/repo#N`
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` → drive
82
+ * `convergeOnly` DEFAULTS from the target (`converge` → review-only `true`; `converge-merge`/`merge-main` → drive
83
83
  * the merge loop `false`) and may be overridden per-dispatch by an explicit boolean. `dependsOn` is an
84
84
  * optional list of PR refs unioned into the enrolled PR's merge-stage dependency set (only non-string
85
85
  * entries are dropped; `submitPr` itself ignores unparseable refs). Exported for unit coverage.