@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
@@ -876,6 +876,111 @@ test("submitPr seeds the #796 auto-ack budget onto the instance (ackRetryRound=0
876
876
  });
877
877
  });
878
878
 
879
+ test("submitPr seeds humanApproval (issue #826), pinned off for a converge-only run that never merges", async () => {
880
+ await withGithubOff(async () => {
881
+ const pr = (n: number) => ({ repo: "owner/repo", number: n, url: `https://github.com/owner/repo/pull/${n}`, prKey: `owner/repo#${n}` });
882
+ const merging = captureVars();
883
+ await submitPr(merging.data, merging.engine, pr(11), [], 20, false, null, true);
884
+ assertEquals(merging.get()?.humanApproval, true);
885
+ const convergeOnly = captureVars();
886
+ await submitPr(convergeOnly.data, convergeOnly.engine, pr(12), [], 20, true, null, true);
887
+ assertEquals(convergeOnly.get()?.humanApproval, false);
888
+ const unset = captureVars();
889
+ await submitPr(unset.data, unset.engine, pr(13));
890
+ assertEquals(unset.get()?.humanApproval, false);
891
+ });
892
+ });
893
+
894
+ // Adoption (issue #826, Copilot review): `submitPr` returns `alreadyRunning` for a PR whose loop is
895
+ // live, so a gated caller adopting an UNGATED loop would inherit its auto-merge. `humanApproval` is
896
+ // monotonic: the gated caller narrows the live loop (idempotent, so a retry against its own gated loop
897
+ // is a no-op). A PR already finalized (`converged_at` set) is past its approval point: `startMerge`
898
+ // re-pointed its `process_key` at the LIVE merge-loop, which must never be written to.
899
+ function adoptLoop(status: string, loopState: string, convergedAt: string | null = null) {
900
+ const stores: Record<string, { rows: unknown[]; key: string }> = {
901
+ pull_requests: {
902
+ rows: [
903
+ {
904
+ pr_key: "owner/repo#14",
905
+ repo: "owner/repo",
906
+ number: 14,
907
+ url: "https://github.com/owner/repo/pull/14",
908
+ title: "t",
909
+ status,
910
+ current_round: 1,
911
+ process_key: "PI-LOOP",
912
+ converged_at: convergedAt,
913
+ },
914
+ ],
915
+ key: "pr_key",
916
+ },
917
+ escalations: { rows: [], key: "id" },
918
+ pr_dependencies: { rows: [], key: "pr_key" },
919
+ };
920
+ const data = {
921
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
922
+ open: () => memOpen(stores),
923
+ } as any;
924
+ const narrowed: unknown[] = [];
925
+ const engine = {
926
+ createInstance: () => Promise.reject(new Error("a live PR is adopted, never re-created")),
927
+ searchProcessInstances: () => Promise.resolve([{ processInstanceKey: "PI-LOOP", state: loopState }]),
928
+ setVariables: (req: unknown) => {
929
+ narrowed.push(req);
930
+ return Promise.resolve();
931
+ },
932
+ } as any;
933
+ return { data, engine, narrowed };
934
+ }
935
+
936
+ test("a gated submitPr adopting a live loop narrows it to humanApproval (issue #826)", async () => {
937
+ await withGithubOff(async () => {
938
+ const pr = { repo: "owner/repo", number: 14, url: "https://github.com/owner/repo/pull/14", prKey: "owner/repo#14" };
939
+ const live = adoptLoop("converging", "ACTIVE");
940
+ assertEquals(await submitPr(live.data, live.engine, pr, [], 20, false, null, true), { prKey: pr.prKey, alreadyRunning: true });
941
+ assertEquals(live.narrowed, [{ scopeKey: "PI-LOOP", variables: { humanApproval: true } }]);
942
+
943
+ const ungated = adoptLoop("converging", "ACTIVE");
944
+ await submitPr(ungated.data, ungated.engine, pr);
945
+ assertEquals(ungated.narrowed, [], "an ungated adopter leaves the loop's mode alone");
946
+
947
+ const convergeOnly = adoptLoop("converging", "ACTIVE");
948
+ await submitPr(convergeOnly.data, convergeOnly.engine, pr, [], 20, true, null, true);
949
+ assertEquals(convergeOnly.narrowed, [], "a converge-only adopter never merges, so never gates");
950
+
951
+ const merging = adoptLoop("waiting_deps", "ACTIVE", "2026-01-01T00:00:00.000Z");
952
+ assertEquals(await submitPr(merging.data, merging.engine, pr, [], 20, false, null, true), { prKey: pr.prKey, alreadyRunning: true });
953
+ assertEquals(merging.narrowed, [], "the live merge-loop of a finalized PR is never written to");
954
+
955
+ const ended = adoptLoop("converging", "COMPLETED");
956
+ await submitPr(ended.data, ended.engine, pr, [], 20, false, null, true);
957
+ assertEquals(ended.narrowed, [], "a loop the engine no longer reports running is left alone");
958
+ });
959
+ });
960
+
961
+ test("human approval is pinned off when NANO_PR_AUTO_MERGE is off — the PR never merges (issue #826)", async () => {
962
+ // `AUTO_MERGE` is read once at import, so load a fresh copy of the module under the env.
963
+ const prev = process.env["NANO_PR_AUTO_MERGE"];
964
+ process.env["NANO_PR_AUTO_MERGE"] = "0";
965
+ try {
966
+ const mod: typeof import("./service.ts") = await import(`./service.ts?autoMergeOff=${Date.now()}`);
967
+ await withGithubOff(async () => {
968
+ const fresh = captureVars();
969
+ const pr15 = { repo: "owner/repo", number: 15, url: "https://github.com/owner/repo/pull/15", prKey: "owner/repo#15" };
970
+ await mod.submitPr(fresh.data, fresh.engine, pr15, [], 20, false, null, true);
971
+ assertEquals(fresh.get()?.humanApproval, false, "no approval task for a merge that never happens");
972
+
973
+ const live = adoptLoop("converging", "ACTIVE");
974
+ const pr14 = { repo: "owner/repo", number: 14, url: "https://github.com/owner/repo/pull/14", prKey: "owner/repo#14" };
975
+ await mod.submitPr(live.data, live.engine, pr14, [], 20, false, null, true);
976
+ assertEquals(live.narrowed, [], "nor is an adopted loop narrowed");
977
+ });
978
+ } finally {
979
+ if (prev === undefined) delete process.env["NANO_PR_AUTO_MERGE"];
980
+ else process.env["NANO_PR_AUTO_MERGE"] = prev;
981
+ }
982
+ });
983
+
879
984
  // Lineage threading (issue #245): `submitPr` persists the origin `root_request_key` on the PR row
880
985
  // and carries it onto the convergence instance; `startMerge` reads it back off the row onto the
881
986
  // merge instance. A human/webhook submit that supplies no root self-roots on the `pr_key` (its own
package/app/service.ts CHANGED
@@ -107,6 +107,7 @@ import {
107
107
  type OpenEscalation,
108
108
  PLAN_REVIEW_ELEMENT,
109
109
  PR_ESCALATION_PRODUCER_ELEMENTS,
110
+ PR_MERGE_APPROVAL_ELEMENT,
110
111
  PR_RECORD_ANSWER_ELEMENT,
111
112
  PR_RECORD_MERGE_ANSWER_ELEMENT,
112
113
  PR_WAIT_ANSWER_ELEMENT,
@@ -573,9 +574,10 @@ export async function submitPr(
573
574
  maxRounds: number = MAX_ROUNDS,
574
575
  convergeOnly = false,
575
576
  rootRequestKey: string | null = null,
577
+ humanApproval = false,
576
578
  ) {
577
579
  return withPrSubmitLock(parsed.prKey, () =>
578
- submitPrCritical(data, engine, parsed, dependsOn, maxRounds, convergeOnly, rootRequestKey),
580
+ submitPrCritical(data, engine, parsed, dependsOn, maxRounds, convergeOnly, rootRequestKey, humanApproval),
579
581
  );
580
582
  }
581
583
 
@@ -587,7 +589,12 @@ async function submitPrCritical(
587
589
  maxRounds: number = MAX_ROUNDS,
588
590
  convergeOnly = false,
589
591
  rootRequestKey: string | null = null,
592
+ humanApproval = false,
590
593
  ) {
594
+ // Human approval before merge (issue #826) gates only a PR that `pr.finalize` would hand to the
595
+ // merge-loop — its own predicate, `AUTO_MERGE && convergeOnly !== true`. A run that won't merge
596
+ // (review-only, or the global switch off) must not park for a pointless approval.
597
+ const gated = humanApproval && !convergeOnly && AUTO_MERGE;
591
598
  const table = prs(data);
592
599
  const existing = await table.get(parsed.prKey);
593
600
  // ADR-0065: classify "already running" on the DERIVED terminal edge, not the base transient. A
@@ -607,6 +614,23 @@ async function submitPrCritical(
607
614
  // RESUBMITTABLE — fall through and re-enroll, exactly as `startFeature`'s intake guard (feature.ts)
608
615
  // treats a keyless `running` feature row. A terminal row (any `process_key`) already falls through.
609
616
  if (trackedExisting && existing?.process_key != null && !TERMINAL_STATUSES.includes(trackedExisting.derived_status)) {
617
+ // Human approval is monotonic (issue #826): a gated caller adopting a live loop — possibly an
618
+ // ungated one another run started — narrows it to gated instead of inheriting its auto-merge.
619
+ // Idempotent, so a retry against our own gated loop is a no-op. A loop the engine no longer reports
620
+ // running is left alone; one already past its `human approval?` gateway can no longer be gated. A
621
+ // finalized PR (`converged_at` set) is past it, and `startMerge` has re-pointed its `process_key` at
622
+ // the merge-loop, so it is skipped. Mid-handoff (key re-pointed, `converged_at` not yet stamped) the
623
+ // write reaches the merge-loop but is inert: nothing there reads `humanApproval`.
624
+ if (gated && existing.converged_at == null) {
625
+ const loopKey = existing.process_key;
626
+ const match = (await engine.searchProcessInstances({ processInstanceKeys: [loopKey] })).find(
627
+ (s) => String(s.processInstanceKey) === loopKey,
628
+ );
629
+ const state = match ? String(match.state ?? "").trim().toUpperCase() : null;
630
+ if (state !== null && !ENGINE_TERMINAL_STATES.has(state)) {
631
+ await engine.setVariables({ scopeKey: loopKey, variables: { humanApproval: true } });
632
+ }
633
+ }
610
634
  return { prKey: parsed.prKey, alreadyRunning: true };
611
635
  }
612
636
 
@@ -724,6 +748,9 @@ async function submitPrCritical(
724
748
  // `converged` for this PR without handing off to the merge-loop, independent of the global
725
749
  // NANO_PR_AUTO_MERGE default. Only ever narrows (never forces merge on when auto-merge is off).
726
750
  convergeOnly,
751
+ // Human approval before merge (issue #826): a converged PR parks at the `merge-approval` user
752
+ // task before `pr.finalize` hands it to the merge-loop. Pinned off when the PR won't merge.
753
+ humanApproval: gated,
727
754
  // Cooperative abandon check (#76): the capability URL + the abort brief appended to the
728
755
  // review-round agent's prompt, so it can stop before pushing if the run is cancelled.
729
756
  abandonUrl: abUrl,
@@ -2469,13 +2496,24 @@ export async function pollFeatureDelivery(
2469
2496
  // it would wedge `converging` forever with no PR process.
2470
2497
  // Both heal via the SAME idempotent `submitPr`: it is idempotent on the PR key (a redundant call
2471
2498
  // on an already-live PR early-returns `alreadyRunning`), and now treats a keyless non-terminal row
2472
- // as resubmittable, so it re-creates the instance and installs `process_key`. `convergeOnly`
2473
- // mirrors `converge-feature` — the inverse of the run's `auto_merge` flag.
2499
+ // as resubmittable, so it re-creates the instance and installs `process_key`. `convergeOnly` and
2500
+ // `humanApproval` mirror `converge-feature` — derived from the run's `auto_merge`/`human_approval`.
2474
2501
  const partiallyEnrolled =
2475
2502
  trackedPr != null && trackedPr.process_key == null && !TERMINAL_STATUSES.includes(trackedPr.derived_status);
2476
2503
  if (prStatus === null || partiallyEnrolled) {
2477
2504
  const parsed = parsePr(run.pr_key);
2478
- if (parsed) await submitPr(data, engine, parsed, [], MAX_ROUNDS, run.auto_merge !== 1, run.feature_key);
2505
+ if (parsed) {
2506
+ await submitPr(
2507
+ data,
2508
+ engine,
2509
+ parsed,
2510
+ [],
2511
+ MAX_ROUNDS,
2512
+ run.auto_merge !== 1,
2513
+ run.feature_key,
2514
+ run.human_approval === 1,
2515
+ );
2516
+ }
2479
2517
  }
2480
2518
  const { status, label } = deriveFeatureDelivery(prStatus);
2481
2519
  if (run.status !== status || run.delivery_label !== label) {
@@ -3057,6 +3095,7 @@ export async function pollUserTasks(
3057
3095
  [READINESS_ESCALATION_ELEMENT]: "plan",
3058
3096
  [PR_WAIT_ANSWER_ELEMENT]: "pr",
3059
3097
  [PR_WAIT_MERGE_ANSWER_ELEMENT]: "pr",
3098
+ [PR_MERGE_APPROVAL_ELEMENT]: "pr",
3060
3099
  };
3061
3100
 
3062
3101
  // The SINGLE enrichment derivation both discovery paths feed: resolve one open escalation task (by
package/app/userTasks.ts CHANGED
@@ -52,6 +52,12 @@ export const PR_WAIT_ANSWER_ELEMENT = "wait-answer";
52
52
  * canonical `completeUserTask` door and surfaced in this same Tasks inbox. */
53
53
  export const PR_WAIT_MERGE_ANSWER_ELEMENT = "wait-merge-answer";
54
54
 
55
+ /** The optional human merge-approval user task (convergence-loop.bpmn, issue #826) — a converged PR
56
+ * parks here before `pr.finalize` hands it to the merge-loop. Approve merges; request changes loops
57
+ * the guidance (`answer`) back to the review agent on the same PR. HUMAN-only: never agent-answerable,
58
+ * so the fleet can never auto-approve a merge. */
59
+ export const PR_MERGE_APPROVAL_ELEMENT = "merge-approval";
60
+
55
61
  /** The PR review-loop answer-recording service task (convergence-loop.bpmn) — the `pr.answer-escalation`
56
62
  * job the token moves to IMMEDIATELY when `wait-answer` completes. Its ACTIVE element instance is the
57
63
  * positive-evidence marker that an operator's answer is in-flight (recorded but the loop hasn't looped
@@ -237,6 +243,7 @@ export const USER_TASK_KIND_LABELS: Readonly<Record<string, string>> = {
237
243
  [TRIAL_MERGE_ELEMENT]: "Trial merge",
238
244
  [PR_WAIT_ANSWER_ELEMENT]: "PR review",
239
245
  [PR_WAIT_MERGE_ANSWER_ELEMENT]: "PR merge",
246
+ [PR_MERGE_APPROVAL_ELEMENT]: "PR merge approval",
240
247
  [CONFORMANCE_ESCALATION_ELEMENT]: "Conformance review",
241
248
  [READINESS_ESCALATION_PF_ELEMENT]: "Upstream readiness stalled",
242
249
  [READINESS_ESCALATION_ELEMENT]: "Readiness escalation",
@@ -0,0 +1,51 @@
1
+ -- Issue #826: optional human approval before merge. 1 ⇒ the converged PR parks at the
2
+ -- `merge-approval` user task in convergence-loop.bpmn before the merge-loop runs. Additive (expand).
3
+ ALTER TABLE feature_runs ADD COLUMN human_approval INTEGER NOT NULL DEFAULT 0;
4
+
5
+ -- Mirror the new column through the `delivery_units` aggregate so the `feature_runs__units` compat VIEW
6
+ -- stays row-for-row equal to `feature_runs` (ADR 0006, parity-tested in app/deliveryUnit.test.ts): the
7
+ -- Feature sync triggers (089) and VIEW (091) are re-created verbatim plus `human_approval`.
8
+ ALTER TABLE delivery_units ADD COLUMN human_approval INTEGER;
9
+ UPDATE delivery_units SET human_approval = 0 WHERE kind = 'feature';
10
+
11
+ DROP TRIGGER IF EXISTS feature_runs__du_ai;
12
+ CREATE TRIGGER feature_runs__du_ai AFTER INSERT ON feature_runs
13
+ BEGIN
14
+ INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, base_branch, status, process_key, pr_key, converge, auto_merge, human_approval, outcome, delivery_label, acknowledged_at, stage, stage_state, stage_skipped, attention, list_bucket, created_at, updated_at)
15
+ VALUES ('feature:' || NEW.feature_key, 'feature', NEW.feature_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'escalated' THEN 'escalated' WHEN NEW.status = 'opened' THEN 'opened' WHEN NEW.status = 'converging' THEN 'converging' WHEN NEW.status = 'awaiting_operator' THEN 'awaiting_operator' WHEN NEW.status = 'merged' THEN 'merged' WHEN NEW.status = 'converged' THEN 'converged' WHEN NEW.status = 'blocked' THEN 'blocked' WHEN NEW.status = 'skipped' THEN 'skipped' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('opened', 'converging', 'merged', 'converged', 'blocked', 'skipped', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running', 'escalated', 'awaiting_operator') THEN 'dispatched' ELSE NULL END, NEW.repo, NEW.issue_number, NEW.issue_url, NEW.title, NEW.base_branch, NEW.status, NEW.process_key, NEW.pr_key, NEW.converge, NEW.auto_merge, NEW.human_approval, NEW.outcome, NEW.delivery_label, NEW.acknowledged_at, NEW.stage, NEW.stage_state, NEW.stage_skipped, NEW.attention, NEW.list_bucket, NEW.created_at, NEW.updated_at);
16
+ END;
17
+
18
+ DROP TRIGGER IF EXISTS feature_runs__du_au;
19
+ CREATE TRIGGER feature_runs__du_au AFTER UPDATE ON feature_runs
20
+ BEGIN
21
+ INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, base_branch, status, process_key, pr_key, converge, auto_merge, human_approval, outcome, delivery_label, acknowledged_at, stage, stage_state, stage_skipped, attention, list_bucket, created_at, updated_at)
22
+ VALUES ('feature:' || NEW.feature_key, 'feature', NEW.feature_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'escalated' THEN 'escalated' WHEN NEW.status = 'opened' THEN 'opened' WHEN NEW.status = 'converging' THEN 'converging' WHEN NEW.status = 'awaiting_operator' THEN 'awaiting_operator' WHEN NEW.status = 'merged' THEN 'merged' WHEN NEW.status = 'converged' THEN 'converged' WHEN NEW.status = 'blocked' THEN 'blocked' WHEN NEW.status = 'skipped' THEN 'skipped' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('opened', 'converging', 'merged', 'converged', 'blocked', 'skipped', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running', 'escalated', 'awaiting_operator') THEN 'dispatched' ELSE NULL END, NEW.repo, NEW.issue_number, NEW.issue_url, NEW.title, NEW.base_branch, NEW.status, NEW.process_key, NEW.pr_key, NEW.converge, NEW.auto_merge, NEW.human_approval, NEW.outcome, NEW.delivery_label, NEW.acknowledged_at, NEW.stage, NEW.stage_state, NEW.stage_skipped, NEW.attention, NEW.list_bucket, NEW.created_at, NEW.updated_at);
23
+ END;
24
+
25
+ DROP VIEW IF EXISTS feature_runs__units;
26
+ CREATE VIEW feature_runs__units AS
27
+ SELECT
28
+ du.legacy_key AS feature_key,
29
+ du.repo AS repo,
30
+ du.issue_number AS issue_number,
31
+ du.issue_url AS issue_url,
32
+ du.base_branch AS base_branch,
33
+ du.status AS status,
34
+ du.process_key AS process_key,
35
+ du.pr_key AS pr_key,
36
+ du.converge AS converge,
37
+ du.auto_merge AS auto_merge,
38
+ du.human_approval AS human_approval,
39
+ du.outcome AS outcome,
40
+ du.created_at AS created_at,
41
+ du.updated_at AS updated_at,
42
+ du.delivery_label AS delivery_label,
43
+ du.title AS title,
44
+ du.acknowledged_at AS acknowledged_at,
45
+ du.stage AS stage,
46
+ du.stage_state AS stage_state,
47
+ du.stage_skipped AS stage_skipped,
48
+ du.attention AS attention,
49
+ du.list_bucket AS list_bucket
50
+ FROM delivery_units du
51
+ WHERE du.kind = 'feature';
@@ -62,6 +62,7 @@ interface FeatureRow {
62
62
  }
63
63
  interface PrRow {
64
64
  pr_key: string;
65
+ process_key: string | null;
65
66
  }
66
67
 
67
68
  describe("single-issue feature run (#172 — feature.bpmn)", () => {
@@ -184,6 +185,23 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
184
185
  );
185
186
  });
186
187
 
188
+ test("raise + converge + human approval: the enrolled convergence loop carries the approval gate (issue #826)", async () => {
189
+ await withApp(
190
+ { "senior:feature": () => ({ status: "opened", pr: "owner/repo#103", summary: "built it" }) },
191
+ { baseBranch: "epic/e2e", converge: true, autoMerge: true, humanApproval: true },
192
+ async ({ app }) => {
193
+ // The flag must survive the real handoff: feature.bpmn → converge-cell callActivity →
194
+ // pr.converge-feature → submitPr → the live loop instance (still ACTIVE, so its vars are readable).
195
+ const pr = await app.db.table<PrRow>("pull_requests", "pr_key").findOne({ pr_key: "owner/repo#103" });
196
+ assert.ok(pr?.process_key, "converge enrolled the raised PR into a live convergence loop");
197
+ const instances = app.snapshot().instances;
198
+ assert.ok(Array.isArray(instances), "snapshot.instances is an array of instance rows");
199
+ const loop = instances.find((i) => i?.key === pr.process_key);
200
+ assert.equal(loop?.variables?.humanApproval, true, "the convergence loop was seeded with humanApproval");
201
+ },
202
+ );
203
+ });
204
+
187
205
  test("blocked: parks at the operators' user task (non-terminal), never enrolls a PR, settles on ack", async () => {
188
206
  await withApp(
189
207
  { "senior:feature": () => ({ status: "blocked", summary: "could not proceed" }) },
package/openapi.yaml CHANGED
@@ -3442,6 +3442,12 @@ components:
3442
3442
  When true (and `converge` is true), drive the merge-loop after convergence only when
3443
3443
  `NANO_PR_AUTO_MERGE` is enabled. When false the run stops at `converged`.
3444
3444
  Moot when `converge` is false.
3445
+ humanApproval:
3446
+ type: boolean
3447
+ description: >-
3448
+ When true (with `autoMerge`), the converged PR waits in Tasks for a human to approve the
3449
+ merge or request changes; the agent applies them to the same PR and convergence re-runs.
3450
+ Moot when the run does not merge (see `autoMerge`).
3445
3451
  confirmDefaultBase:
3446
3452
  type: boolean
3447
3453
  description: >-
@@ -3513,6 +3519,9 @@ components:
3513
3519
  autoMerge:
3514
3520
  type: boolean
3515
3521
  description: When true (with `converge`), drive the merge-loop. See `FeatureStartByIssue.autoMerge`.
3522
+ humanApproval:
3523
+ type: boolean
3524
+ description: When true (with `autoMerge`), a human approves the merge. See `FeatureStartByIssue.humanApproval`.
3516
3525
  confirmDefaultBase:
3517
3526
  type: boolean
3518
3527
  description: Acknowledge landing on the default branch. See `PlanStartByIssue.confirmDefaultBase`.
@@ -6181,6 +6190,9 @@ paths:
6181
6190
  autoMerge:
6182
6191
  type: boolean
6183
6192
  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.
6193
+ humanApproval:
6194
+ type: boolean
6195
+ description: When true (with `autoMerge`), the converged PR waits in Tasks for a human to approve the merge or request changes; the agent applies them to the same PR and convergence re-runs. Moot when the run does not merge (see `autoMerge`).
6184
6196
  confirmDefaultBase:
6185
6197
  type: boolean
6186
6198
  description: Acknowledge that `baseBranch` is the repository default branch (the PR would target it directly). See `PlanStartByIssue.confirmDefaultBase`.
@@ -6326,6 +6338,9 @@ paths:
6326
6338
  autoMerge:
6327
6339
  type: boolean
6328
6340
  description: When true (with `converge`), drive the merge-loop. See `FeatureStartByIssue.autoMerge`.
6341
+ humanApproval:
6342
+ type: boolean
6343
+ description: When true (with `autoMerge`), a human approves the merge. See `FeatureStartByIssue.humanApproval`.
6329
6344
  confirmDefaultBase:
6330
6345
  type: boolean
6331
6346
  description: Acknowledge landing on the default branch. See `PlanStartByIssue.confirmDefaultBase`.
@@ -193,3 +193,17 @@ test("malformed readiness descriptor → 400 at the edge (never a 500)", async (
193
193
  assertEquals(started.length, 0);
194
194
  });
195
195
  });
196
+
197
+ // ── human approval before merge (issue #826): threaded through the edge, pinned off without autoMerge ─
198
+
199
+ test("humanApproval reaches the run only together with autoMerge", async () => {
200
+ await withGithub(REPO, async () => {
201
+ for (const [autoMerge, expected] of [[true, true], [false, false]] as const) {
202
+ const { app, started } = makeApp();
203
+ const body = { issue: `${REPO}#577`, ...GATED_BASE, converge: true, autoMerge, humanApproval: true };
204
+ const res = (await startFeature(input(body), app)) as any;
205
+ assertEquals(res.status, 202);
206
+ assertEquals(started[0].variables?.humanApproval, expected);
207
+ }
208
+ });
209
+ });
@@ -116,6 +116,8 @@ export default defineOperation("startFeature", async ({ body }, app) => {
116
116
  // Auto-merge is only meaningful as a follow-on to convergence; pin it off when converge is off so
117
117
  // the persisted row and the process variable can't disagree.
118
118
  const autoMerge = converge && "autoMerge" in body && body.autoMerge === true;
119
+ // Human approval gates the merge-loop, so it is moot (pinned off) without auto-merge (issue #826).
120
+ const humanApproval = autoMerge && "humanApproval" in body && body.humanApproval === true;
119
121
  // Optional operator steering threaded to the implementation agent's prompt. Empty/whitespace is
120
122
  // normalized to null downstream (startFeature) so it never appends an empty instruction block.
121
123
  const customInstructions = "customInstructions" in body && typeof body.customInstructions === "string"
@@ -176,12 +178,14 @@ export default defineOperation("startFeature", async ({ body }, app) => {
176
178
  autoMerge,
177
179
  customInstructions,
178
180
  { probes: readiness.probes, probeTimeout: readiness.probeTimeout, probePollEvery: readiness.probePollEvery },
181
+ humanApproval,
179
182
  );
180
183
  app.log.info("feature run intake", {
181
184
  featureKey: parsed.planKey,
182
185
  requestedBaseBranch: normalizedBase,
183
186
  converge,
184
187
  autoMerge,
188
+ humanApproval,
185
189
  hasCustomInstructions: typeof customInstructions === "string" && customInstructions.trim() !== "",
186
190
  readinessProbes: readiness.probes.length,
187
191
  outcome: result.outcome,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.193.2",
3
+ "version": "0.195.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",
@@ -316,9 +316,24 @@
316
316
  display: flex;
317
317
  gap: 8px;
318
318
  align-items: baseline;
319
- margin-bottom: 4px;
319
+ cursor: pointer;
320
+ list-style: none;
321
+ user-select: none;
322
+ }
323
+
324
+ .cockpit-agent-turn-meta::-webkit-details-marker { display: none; }
325
+
326
+ .cockpit-agent-turn-meta::before {
327
+ content: "\25B8";
328
+ color: var(--cockpit-muted);
329
+ font-size: 11px;
330
+ width: 10px;
320
331
  }
321
332
 
333
+ .cockpit-agent-turn[open] > .cockpit-agent-turn-meta::before { content: "\25BE"; }
334
+
335
+ .cockpit-agent-turn-body { margin-top: 4px; padding-left: 18px; }
336
+
322
337
  .cockpit-agent-turn-role {
323
338
  font-size: 11px;
324
339
  text-transform: uppercase;
@@ -327,6 +342,7 @@
327
342
  }
328
343
 
329
344
  .cockpit-agent-turn-iter,
345
+ .cockpit-agent-turn-time,
330
346
  .cockpit-agent-turn-metrics {
331
347
  font-size: 11px;
332
348
  color: var(--cockpit-muted);
@@ -574,6 +574,15 @@ function turnMetrics(m) {
574
574
  return dur != null ? `${base} \u00b7 ${dur}` : base;
575
575
  }
576
576
 
577
+ function turnTime(iso) {
578
+ if (iso == null || iso === "") return undefined;
579
+ const ms = Date.parse(iso);
580
+ if (!Number.isFinite(ms)) return undefined;
581
+ const d = new Date(ms);
582
+ const pad = (n) => String(n).padStart(2, "0");
583
+ return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
584
+ }
585
+
577
586
  function agentHistoryView(report) {
578
587
  const turns = (report.records ?? []).map((r) => ({
579
588
  historyItemKey: r.historyItemKey,
@@ -582,6 +591,8 @@ function agentHistoryView(report) {
582
591
  text: turnText(r),
583
592
  toolCalls: (r.toolCalls ?? []).map((c) => ({ toolCallId: c.toolCallId, toolName: c.toolName, elementId: c.elementId })),
584
593
  metrics: turnMetrics(r.metrics),
594
+ producedAt: r.producedAt != null && r.producedAt !== "" ? r.producedAt : undefined,
595
+ time: turnTime(r.producedAt),
585
596
  }));
586
597
  return {
587
598
  agentInstanceKey: report.agentInstanceKey,
@@ -639,17 +650,28 @@ function renderAgentSessions(host, doc, view, onSelect, activeInstanceKey) {
639
650
  host.appendChild(root);
640
651
  }
641
652
 
653
+ // One turn as a native collapsible <details> (collapsed by default); its <summary> heading (role,
654
+ // iteration, timestamp, metrics) toggles the details body on click (#831).
642
655
  function agentTurnBlock(doc, t) {
643
- const block = el(doc, "div", "cockpit-agent-turn");
656
+ const block = el(doc, "details", "cockpit-agent-turn");
644
657
  block.setAttribute("data-history-item-key", t.historyItemKey);
645
658
  block.setAttribute("data-role", t.role);
646
659
  block.setAttribute("data-loop-iteration", String(t.loopIteration));
647
- const meta = el(doc, "div", "cockpit-agent-turn-meta");
660
+ const meta = el(doc, "summary", "cockpit-agent-turn-meta");
648
661
  meta.appendChild(el(doc, "span", "cockpit-agent-turn-role", t.role));
649
662
  meta.appendChild(el(doc, "span", "cockpit-agent-turn-iter", `#${t.loopIteration}`));
663
+ if (t.time != null) {
664
+ const time = el(doc, "time", "cockpit-agent-turn-time", t.time);
665
+ if (t.producedAt != null) {
666
+ time.setAttribute("datetime", t.producedAt);
667
+ time.setAttribute("title", t.producedAt);
668
+ }
669
+ meta.appendChild(time);
670
+ }
650
671
  if (t.metrics != null) meta.appendChild(el(doc, "span", "cockpit-agent-turn-metrics", t.metrics));
651
672
  block.appendChild(meta);
652
- if (t.text !== "") block.appendChild(el(doc, "pre", "cockpit-agent-turn-text", t.text));
673
+ const body = el(doc, "div", "cockpit-agent-turn-body");
674
+ if (t.text !== "") body.appendChild(el(doc, "pre", "cockpit-agent-turn-text", t.text));
653
675
  if (t.toolCalls.length > 0) {
654
676
  const tools = el(doc, "ul", "cockpit-agent-turn-tools");
655
677
  for (const call of t.toolCalls) {
@@ -657,8 +679,9 @@ function agentTurnBlock(doc, t) {
657
679
  li.setAttribute("data-tool-call-id", call.toolCallId);
658
680
  tools.appendChild(li);
659
681
  }
660
- block.appendChild(tools);
682
+ body.appendChild(tools);
661
683
  }
684
+ block.appendChild(body);
662
685
  return block;
663
686
  }
664
687
 
@@ -90,6 +90,7 @@
90
90
  { "key": "baseBranch", "label": "Base branch: the branch the PR targets, e.g. main. A missing epic/* branch is auto-created off default HEAD; a non-epic/* branch must already exist.", "type": "text", "required": true, "requiredMessage": "Name the branch the PR targets (e.g. main or epic/agent-protocol)" },
91
91
  { "key": "customInstructions", "label": "Custom instructions (optional) \u2014 extra steering appended to the implementation agent's prompt, e.g. \u201cprefer Deno; keep the diff small; add integration tests\u201d", "type": "text" },
92
92
  { "key": "converge", "label": "Converge \u2014 hand the raised PR to the review-convergence loop", "type": "checkbox" },
93
+ { "key": "humanApproval", "label": "Human approval before merge \u2014 a converged PR waits in Tasks for you to approve the merge or request changes (only applies when Auto-merge is on)", "type": "checkbox" },
93
94
  { "key": "autoMerge", "label": "Auto-merge \u2014 after convergence, drive the merge-loop (only applies when Converge is on)", "type": "checkbox" },
94
95
  { "key": "confirmDefaultBase", "label": "Confirm landing on the default branch \u2014 required only when the base above IS the repository default", "type": "checkbox" },
95
96
  { "key": "allowSharedBase", "label": "Allow sharing a custom integration branch with another active epic", "type": "checkbox" }
@@ -0,0 +1,36 @@
1
+ {
2
+ "id": "merge-approval",
3
+ "schemaVersion": 18,
4
+ "type": "default",
5
+ "components": [
6
+ {
7
+ "type": "text",
8
+ "label": "Approve merge",
9
+ "text": "This PR passed review convergence. Approve it to hand it to the merge loop, or request changes: your guidance goes to the review agent, which updates this same PR. Convergence then re-runs and approval is asked again."
10
+ },
11
+ {
12
+ "type": "select",
13
+ "key": "mergeDecision",
14
+ "label": "Decision",
15
+ "values": [
16
+ { "label": "Approve — merge the PR", "value": "approve" },
17
+ { "label": "Request changes — send guidance to the agent", "value": "revise" }
18
+ ],
19
+ "validate": {
20
+ "required": true
21
+ }
22
+ },
23
+ {
24
+ "type": "textarea",
25
+ "key": "answer",
26
+ "label": "Guidance for the agent",
27
+ "description": "What should change before this PR merges? The agent implements it on the same PR.",
28
+ "validate": {
29
+ "required": true
30
+ },
31
+ "conditional": {
32
+ "hide": "=mergeDecision != \"revise\""
33
+ }
34
+ }
35
+ ]
36
+ }