@nanobpm/nano-workforce 0.56.0 → 0.58.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 (90) hide show
  1. package/.github/workflows/ci.yml +7 -0
  2. package/AGENTS.md +83 -1
  3. package/CHANGELOG.md +14 -0
  4. package/README.md +10 -2
  5. package/SPEC.md +27 -24
  6. package/app/agentCompletion.test.ts +337 -0
  7. package/app/agentCompletion.ts +219 -0
  8. package/app/agentGuide.ts +1 -1
  9. package/app/answer-escalation.test.ts +106 -0
  10. package/app/answerEscalation.test.ts +67 -0
  11. package/app/baseGuard.test.ts +9 -1
  12. package/app/baseGuard.ts +11 -0
  13. package/app/escalationSla.test.ts +39 -0
  14. package/app/escalationSla.ts +28 -0
  15. package/app/escalationTaxonomy.test.ts +115 -0
  16. package/app/escalationTaxonomy.ts +115 -0
  17. package/app/feature.test.ts +161 -0
  18. package/app/feature.ts +173 -0
  19. package/app/github.test.ts +179 -1
  20. package/app/github.ts +132 -0
  21. package/app/mergeProtocol.test.ts +15 -0
  22. package/app/mergeProtocol.ts +10 -0
  23. package/app/persist-escalation.test.ts +34 -36
  24. package/app/plan.test.ts +267 -313
  25. package/app/plan.ts +173 -231
  26. package/app/reviewWait.ts +12 -4
  27. package/app/roundResultDefault.test.ts +111 -2
  28. package/app/roundResultDefault.ts +35 -0
  29. package/app/service.test.ts +6 -7
  30. package/app/service.ts +52 -35
  31. package/db/migrations/026_agent_completion.sql +36 -0
  32. package/db/migrations/027_retire_escalation_subsystem.sql +43 -0
  33. package/db/migrations/028_feature_runs.sql +28 -0
  34. package/e2e/agent-answerable.e2e.ts +185 -0
  35. package/e2e/convergence-escalation.e2e.ts +180 -0
  36. package/e2e/convergence-loop.e2e.ts +1 -1
  37. package/e2e/feature-run.e2e.ts +231 -0
  38. package/e2e/plan-fanout-sla.e2e.ts +238 -0
  39. package/e2e/plan-fanout.e2e.ts +303 -0
  40. package/e2e/retire-escalation-subsystem.e2e.ts +223 -0
  41. package/e2e/support/github-admit.ts +99 -0
  42. package/e2e/user-task-spine.e2e.ts +155 -0
  43. package/nano.app.json +41 -11
  44. package/openapi.yaml +237 -84
  45. package/operations/agentCompleteEscalation.ts +53 -0
  46. package/operations/listActivePrs.test.ts +39 -6
  47. package/operations/postMessage.ts +10 -41
  48. package/operations/revertEscalationCompletion.ts +44 -0
  49. package/operations/startAndMessage.test.ts +62 -60
  50. package/operations/startFeature.ts +127 -0
  51. package/operations/startPlanFanout.admission.integration.test.ts +263 -0
  52. package/operations/startPlanFanout.ts +70 -11
  53. package/package.json +4 -1
  54. package/pages/cockpit.page.json +1 -0
  55. package/pages/epic-detail.page.json +11 -37
  56. package/pages/epic.page.json +5 -2
  57. package/pages/feature.page.json +82 -0
  58. package/pages/home.page.json +6 -18
  59. package/resources/agent-guide.md +90 -26
  60. package/resources/forms/feature-escalation.form +27 -0
  61. package/resources/forms/plan-review-decision.form +27 -0
  62. package/resources/forms/pr-escalation.form +23 -0
  63. package/resources/forms/spine-demo.form +15 -0
  64. package/resources/forms/trial-merge-decision.form +25 -0
  65. package/resources/processes/convergence-loop.bpmn +127 -75
  66. package/resources/processes/feature.bpmn +240 -0
  67. package/resources/processes/plan-fanout.bpmn +322 -222
  68. package/resources/processes/spine-demo.bpmn +72 -0
  69. package/scripts/check-migrations.ts +68 -0
  70. package/workers/answer-escalation/worker.ts +78 -0
  71. package/workers/converge-feature/worker.ts +51 -0
  72. package/workers/ensure-base-branch/head-task.integration.test.ts +126 -0
  73. package/workers/ensure-base-branch/worker.test.ts +104 -0
  74. package/workers/ensure-base-branch/worker.ts +31 -0
  75. package/workers/finalize/worker.ts +0 -2
  76. package/workers/mark-merged/worker.ts +0 -2
  77. package/workers/merge/worker.ts +6 -5
  78. package/workers/persist-escalation/worker.ts +28 -32
  79. package/workers/record-feature/worker.ts +61 -0
  80. package/workers/record-plan-review/worker.test.ts +9 -10
  81. package/workers/record-plan-review/worker.ts +15 -5
  82. package/workers/resolve-trial-attention/worker.test.ts +77 -0
  83. package/workers/resolve-trial-attention/worker.ts +43 -0
  84. package/operations/answerFeatureEscalation.test.ts +0 -112
  85. package/operations/answerFeatureEscalation.ts +0 -58
  86. package/operations/answerPlanEscalation.test.ts +0 -115
  87. package/operations/answerPlanEscalation.ts +0 -41
  88. package/workers/persist-plan-escalation/worker.test.ts +0 -80
  89. package/workers/persist-plan-escalation/worker.ts +0 -73
  90. package/workers/persist-task-escalation/worker.ts +0 -120
@@ -10,7 +10,16 @@
10
10
  // ONE of `issue` or `url` — so an empty or ambiguous target is a 400 at the edge; this delegate just
11
11
  // narrows the validated variant and keeps the issue-FORMAT parse guard (schema can't express it).
12
12
 
13
- import { InvalidBaseBranchError, normalizeBaseBranch, parseIssue, startPlan } from "../app/plan.ts";
13
+ import { BaseBranchMustExistError } from "../app/github.ts";
14
+ import {
15
+ admitPlan,
16
+ DefaultBaseNotConfirmedError,
17
+ InvalidBaseBranchError,
18
+ MissingBaseBranchError,
19
+ parseIssue,
20
+ SharedBaseError,
21
+ startPlan,
22
+ } from "../app/plan.ts";
14
23
  import { defineOperation } from "../nano-generated/operations.ts";
15
24
 
16
25
  export default defineOperation("startPlanFanout", async ({ body }, app) => {
@@ -26,16 +35,29 @@ export default defineOperation("startPlanFanout", async ({ body }, app) => {
26
35
  app.log.warn("start-plan rejected: unparseable issue reference", { raw });
27
36
  return { status: 400, body: { error: "could not parse issue (use owner/repo#123 or an issue URL)" } };
28
37
  }
29
- // Optional epic base branch: the branch the fleet branches off and opens every PR against instead
30
- // of the repo default. Present on both oneOf variants; blank/absent keeps the default-branch
31
- // behaviour. It is later interpolated into the authoritative implementer prompt (with `git`/`gh`
32
- // shell snippets), so validate/normalise it HERE a non-blank value that isn't a plausible git
33
- // branch name is a 400 at the edge, never persisted or rendered. `normalizeBaseBranch` blank → null.
34
- const baseBranch = "baseBranch" in body && typeof body.baseBranch === "string" ? body.baseBranch : null;
35
- let normalizedBase: string | null;
38
+ // Epic base branch (ADR 0003): admit the launch through the fail-fast `admitPlan` gate BEFORE any
39
+ // fan-out. It composes the four ordered admission rules required+explicit, create-if-missing
40
+ // (epic/* guard, run synchronously so a typo is a clean edge 400), confirm-default, and
41
+ // shared-base and returns the normalized base. Errors map to specific HTTP statuses at the edge.
42
+ const rawBase = "baseBranch" in body && typeof body.baseBranch === "string" ? body.baseBranch : null;
43
+ const allowSharedBase = "allowSharedBase" in body && body.allowSharedBase === true;
44
+ const confirmDefaultBase = "confirmDefaultBase" in body && body.confirmDefaultBase === true;
45
+ const token = process.env.GITHUB_TOKEN ?? "";
46
+ let normalizedBase: string;
36
47
  try {
37
- normalizedBase = normalizeBaseBranch(baseBranch);
48
+ normalizedBase = await admitPlan(app.data, parsed.repo, rawBase, token, {
49
+ allowSharedBase,
50
+ confirmDefaultBase,
51
+ selfPlanKey: parsed.planKey,
52
+ });
38
53
  } catch (err) {
54
+ if (err instanceof MissingBaseBranchError) {
55
+ app.log.warn("start-plan rejected: missing base branch");
56
+ return {
57
+ status: 400,
58
+ body: { error: "baseBranch is required (name the integration branch, e.g. epic/agent-protocol)" },
59
+ };
60
+ }
39
61
  if (err instanceof InvalidBaseBranchError) {
40
62
  app.log.warn("start-plan rejected: invalid base branch", { baseBranch: err.value });
41
63
  return {
@@ -43,13 +65,50 @@ export default defineOperation("startPlanFanout", async ({ body }, app) => {
43
65
  body: { error: "invalid baseBranch (must be a plausible git branch name, e.g. epic/agent-protocol)" },
44
66
  };
45
67
  }
68
+ if (err instanceof BaseBranchMustExistError) {
69
+ app.log.warn("start-plan rejected: base branch does not exist", { baseBranch: err.branch });
70
+ return {
71
+ status: 400,
72
+ body: {
73
+ error:
74
+ `baseBranch "${err.branch}" does not exist and is not an epic/* branch, so it is not ` +
75
+ `auto-created — create it first, or use the epic/* convention`,
76
+ },
77
+ };
78
+ }
79
+ if (err instanceof DefaultBaseNotConfirmedError) {
80
+ app.log.warn("start-plan rejected: default base not confirmed", { baseBranch: err.branch });
81
+ return {
82
+ status: 400,
83
+ body: {
84
+ error:
85
+ `baseBranch "${err.branch}" is the repository default branch — every task would land ` +
86
+ `directly on it with no integration branch. Re-submit with confirmDefaultBase: true to proceed`,
87
+ },
88
+ };
89
+ }
90
+ if (err instanceof SharedBaseError) {
91
+ app.log.warn("start-plan rejected: shared base branch", { baseBranch: err.branch });
92
+ return {
93
+ status: 409,
94
+ body: {
95
+ error:
96
+ `baseBranch "${err.branch}" is already in use by another active epic. Re-submit with ` +
97
+ `allowSharedBase: true to stack on it, or name a distinct epic/* branch`,
98
+ },
99
+ };
100
+ }
46
101
  throw err;
47
102
  }
48
103
  const result = await startPlan(app.data, app.engine, parsed, normalizedBase);
104
+ const alreadyRunning = "alreadyRunning" in result && result.alreadyRunning === true;
49
105
  app.log.info("plan fan-out started", {
50
106
  planKey: parsed.planKey,
51
- baseBranch: normalizedBase ?? "(default branch)",
52
- alreadyRunning: "alreadyRunning" in result && result.alreadyRunning === true,
107
+ // The base the caller requested. When `alreadyRunning`, `startPlan` short-circuits before this
108
+ // base takes effect (it may not match the in-flight plan's persisted base), so name it as the
109
+ // request — not the effective base — to keep the log honest.
110
+ requestedBaseBranch: normalizedBase,
111
+ alreadyRunning,
53
112
  });
54
113
  return { status: 202, body: result };
55
114
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.56.0",
3
+ "version": "0.58.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",
@@ -35,12 +35,15 @@
35
35
  "typecheck": "tsc --noEmit",
36
36
  "pretypecheck": "urban gen",
37
37
  "check:prompts": "node --experimental-strip-types scripts/check-agent-prompts.ts",
38
+ "check:migrations": "node --experimental-strip-types scripts/check-migrations.ts",
38
39
  "gen": "urban gen",
39
40
  "gen:check": "urban gen --check",
40
41
  "layout": "node --experimental-strip-types scripts/layout-bpmn.ts",
41
42
  "layout:check": "node --experimental-strip-types scripts/layout-bpmn.ts --check",
42
43
  "dev": "urban dev",
44
+ "pretest": "urban gen",
43
45
  "test": "node --experimental-strip-types --test",
46
+ "pree2e": "urban gen",
44
47
  "e2e": "node --experimental-strip-types --test \"e2e/**/*.e2e.ts\"",
45
48
  "lint": "biome check app operations workers pages components scripts e2e main.ts",
46
49
  "lint:fix": "biome check --write app operations workers pages components scripts e2e main.ts"
@@ -11,6 +11,7 @@
11
11
  "items": [
12
12
  { "label": "Convergence", "page": "home" },
13
13
  { "label": "Epics", "page": "epic" },
14
+ { "label": "Feature", "page": "feature" },
14
15
  { "label": "Cockpit", "page": "cockpit" }
15
16
  ],
16
17
  "sticky": true
@@ -12,6 +12,7 @@
12
12
  "items": [
13
13
  { "label": "Convergence", "page": "home" },
14
14
  { "label": "Epics", "page": "epic" },
15
+ { "label": "Feature", "page": "feature" },
15
16
  { "label": "Cockpit", "page": "cockpit" }
16
17
  ]
17
18
  }
@@ -29,6 +30,14 @@
29
30
  "variant": "sub"
30
31
  }
31
32
  },
33
+ {
34
+ "type": "text",
35
+ "id": "escalations-pointer",
36
+ "props": {
37
+ "text": "Escalations (task, plan-review, trial-merge) are native user tasks — answer them from the Task inbox at /tasks: list the open tasks, pick the one for this epic, and submit the typed decision. There is no separate answer form on this page.",
38
+ "variant": "sub"
39
+ }
40
+ },
32
41
  {
33
42
  "type": "dataGrid",
34
43
  "id": "epic-plan",
@@ -48,51 +57,16 @@
48
57
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
49
58
  { "field": "wave_label", "header": "Wave" },
50
59
  { "field": "task_count", "header": "Tasks" },
51
- { "field": "open_task_id", "header": "Open escalation" },
52
- { "field": "open_plan_round", "header": "Plan escalation round" },
53
60
  { "field": "updated_at", "header": "Updated" }
54
61
  ],
55
- "rowActions": [
56
- {
57
- "label": "Proceed with plan",
58
- "confirm": "Dispatch the current unapproved plan as a human override?",
59
- "showWhenField": "open_plan_escalation_id",
60
- "action": {
61
- "path": "/app/api/actions/message",
62
- "body": {
63
- "name": "plan-escalation-answered",
64
- "correlationKey": "{{row.plan_key}}",
65
- "variables": { "directive": "proceed", "note": "Proceed override from the epic page." }
66
- }
67
- }
68
- }
69
- ],
70
62
  "detail": {
71
63
  "linkField": "issue_url",
72
64
  "fields": [
73
65
  { "field": "repo", "label": "Repository" },
74
66
  { "field": "issue_number", "label": "Issue number" },
75
67
  { "field": "base_branch", "label": "Base branch (blank = repo default)" },
76
- { "field": "outcome", "label": "Outcome" },
77
- { "field": "open_plan_findings", "label": "Open plan-review findings" },
78
- { "field": "open_task_question", "label": "Open escalation question" }
79
- ],
80
- "form": {
81
- "showWhenField": "open_plan_escalation_id",
82
- "title": "Answer the open plan-review escalation",
83
- "promptField": "open_plan_findings",
84
- "inputKey": "note",
85
- "inputLabel": "Revision directive note for the planner",
86
- "submitLabel": "Revise plan",
87
- "action": {
88
- "path": "/app/api/actions/message",
89
- "body": {
90
- "name": "plan-escalation-answered",
91
- "correlationKey": "{{row.plan_key}}",
92
- "variables": { "directive": "revise", "note": "{{form.note}}" }
93
- }
94
- }
95
- }
68
+ { "field": "outcome", "label": "Outcome" }
69
+ ]
96
70
  }
97
71
  }
98
72
  },
@@ -12,6 +12,7 @@
12
12
  "items": [
13
13
  { "label": "Convergence", "page": "home" },
14
14
  { "label": "Epics", "page": "epic" },
15
+ { "label": "Feature", "page": "feature" },
15
16
  { "label": "Cockpit", "page": "cockpit" }
16
17
  ]
17
18
  }
@@ -38,7 +39,9 @@
38
39
  "action": { "path": "/app/api/actions/start/plan-fanout", "body": "{{form}}" },
39
40
  "fields": [
40
41
  { "key": "issue", "label": "owner/repo#123 or a GitHub issue URL", "type": "text" },
41
- { "key": "baseBranch", "label": "Base branch (blank = repo default; e.g. epic/agent-protocol to land the whole epic on an integration branch)", "type": "text" }
42
+ { "key": "baseBranch", "label": "Base branch (REQUIRED; e.g. epic/agent-protocol to land the whole epic on an integration branch). A missing epic/* branch is auto-created off default HEAD; a non-epic/* branch must already exist.", "type": "text" },
43
+ { "key": "confirmDefaultBase", "label": "Confirm landing on the default branch \u2014 required only when the base above IS the repository default (every task lands directly on it, with any merge-to-default side effect firing per task)", "type": "checkbox" },
44
+ { "key": "allowSharedBase", "label": "Allow sharing a custom integration branch with another active epic \u2014 required only when another in-flight epic already targets this same custom base", "type": "checkbox" }
42
45
  ]
43
46
  }
44
47
  },
@@ -70,9 +73,9 @@
70
73
  "columns": [
71
74
  { "field": "plan_key", "header": "Epic", "link": { "kind": "page", "page": "epic-detail", "keyField": "plan_key" } },
72
75
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
76
+ { "field": "base_branch", "header": "Base branch" },
73
77
  { "field": "wave_label", "header": "Wave" },
74
78
  { "field": "task_count", "header": "Tasks" },
75
- { "field": "open_plan_findings", "header": "Attention", "badge": { "tone": "danger", "label": "!" } },
76
79
  { "field": "issue_number", "header": "Issue", "linkField": "issue_url" },
77
80
  { "field": "updated_at", "header": "Updated" }
78
81
  ]
@@ -0,0 +1,82 @@
1
+ {
2
+ "schemaVersion": "1.0",
3
+ "title": "Feature Run",
4
+ "nodes": [
5
+ {
6
+ "type": "nav",
7
+ "id": "nav",
8
+ "props": {
9
+ "variant": "bar",
10
+ "sticky": true,
11
+ "title": "Nano Workforce",
12
+ "items": [
13
+ { "label": "Convergence", "page": "home" },
14
+ { "label": "Epics", "page": "epic" },
15
+ { "label": "Feature", "page": "feature" },
16
+ { "label": "Cockpit", "page": "cockpit" }
17
+ ]
18
+ }
19
+ },
20
+ {
21
+ "type": "text",
22
+ "id": "title",
23
+ "props": { "text": "Single-issue feature run", "variant": "heading" }
24
+ },
25
+ {
26
+ "type": "text",
27
+ "id": "subtitle",
28
+ "props": {
29
+ "text": "Hand one issue to a single implementation agent — it raises exactly one PR. Optionally converge (review rounds) and auto-merge as follow-on steps. The missing middle between Epics (many PRs) and PR convergence (an already-open PR).",
30
+ "variant": "sub"
31
+ }
32
+ },
33
+ {
34
+ "type": "actionForm",
35
+ "id": "feature-submit",
36
+ "props": {
37
+ "title": "Implement one issue",
38
+ "submitLabel": "Implement & raise PR",
39
+ "action": { "path": "/app/api/actions/start/feature", "body": "{{form}}" },
40
+ "fields": [
41
+ { "key": "issue", "label": "owner/repo#123 or a GitHub issue URL", "type": "text" },
42
+ { "key": "baseBranch", "label": "Base branch (REQUIRED; 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" },
43
+ { "key": "converge", "label": "Converge \u2014 hand the raised PR to the review-convergence loop", "type": "checkbox" },
44
+ { "key": "autoMerge", "label": "Auto-merge \u2014 after convergence, drive the merge-loop (only applies when Converge is on)", "type": "checkbox" },
45
+ { "key": "confirmDefaultBase", "label": "Confirm landing on the default branch \u2014 required only when the base above IS the repository default", "type": "checkbox" },
46
+ { "key": "allowSharedBase", "label": "Allow sharing a custom integration branch with another active epic", "type": "checkbox" }
47
+ ]
48
+ }
49
+ },
50
+ {
51
+ "type": "dataGrid",
52
+ "id": "feature-runs",
53
+ "props": {
54
+ "title": "Feature runs",
55
+ "rowKey": "feature_key",
56
+ "refreshMs": 5000,
57
+ "data": {
58
+ "kind": "datasource",
59
+ "source": "app",
60
+ "table": "feature_runs",
61
+ "orderBy": { "field": "updated_at", "dir": "desc" },
62
+ "filter": [{ "field": "status", "in": ["running"] }]
63
+ },
64
+ "tabs": [
65
+ { "label": "Active", "filter": [{ "field": "status", "in": ["running"] }] },
66
+ { "label": "History", "filter": [{ "field": "status", "in": ["opened", "converging", "blocked", "skipped", "failed", "abandoned"] }] },
67
+ { "label": "All", "filter": [] }
68
+ ],
69
+ "columns": [
70
+ { "field": "feature_key", "header": "Feature", "linkField": "issue_url" },
71
+ { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
72
+ { "field": "base_branch", "header": "Base branch" },
73
+ { "field": "pr_key", "header": "PR", "link": { "kind": "page", "page": "home", "keyField": "pr_key" } },
74
+ { "field": "converge", "header": "Converge" },
75
+ { "field": "auto_merge", "header": "Auto-merge" },
76
+ { "field": "outcome", "header": "Outcome" },
77
+ { "field": "updated_at", "header": "Updated" }
78
+ ]
79
+ }
80
+ }
81
+ ]
82
+ }
@@ -17,6 +17,10 @@
17
17
  "label": "Epics",
18
18
  "page": "epic"
19
19
  },
20
+ {
21
+ "label": "Feature",
22
+ "page": "feature"
23
+ },
20
24
  {
21
25
  "label": "Cockpit",
22
26
  "page": "cockpit"
@@ -37,7 +41,7 @@
37
41
  "type": "text",
38
42
  "id": "subtitle",
39
43
  "props": {
40
- "text": "Submit a pull request to run the autonomous review-convergence loop. Answer escalations inline; cancel a run at any time.",
44
+ "text": "Submit a pull request to run the autonomous review-convergence loop. Answer escalations from the Task inbox at /tasks; cancel a run at any time.",
41
45
  "variant": "sub"
42
46
  }
43
47
  },
@@ -329,23 +333,7 @@
329
333
  }
330
334
  ]
331
335
  }
332
- ],
333
- "form": {
334
- "showWhenField": "open_escalation_id",
335
- "title": "Answer the open escalation",
336
- "promptField": "open_escalation_question",
337
- "inputKey": "answer",
338
- "inputLabel": "Your answer",
339
- "submitLabel": "Send answer",
340
- "action": {
341
- "path": "/app/api/actions/message",
342
- "body": {
343
- "name": "escalation-answered",
344
- "correlationKey": "{{row.pr_key}}",
345
- "variables": "{{form}}"
346
- }
347
- }
348
- }
336
+ ]
349
337
  }
350
338
  }
351
339
  }
@@ -97,13 +97,49 @@ PR is then enrolled into its own convergence loop (§1).
97
97
  ```bash
98
98
  curl -sS -X POST __BASE__/actions/start/plan-fanout \
99
99
  -H 'content-type: application/json' \
100
- -d '{ "issue": "owner/repo#123" }'
100
+ -d '{ "issue": "owner/repo#123", "baseBranch": "epic/agent-protocol" }'
101
101
  ```
102
102
 
103
- The body is flat: `issue` (or `url`) — `owner/repo#123` or an issue URL. Starting a
103
+ The body is flat: `issue` (or `url`) — `owner/repo#123` or an issue URL plus a REQUIRED
104
+ `baseBranch` (ADR 0003), the branch the fleet branches off and opens every PR against; a
105
+ blank/absent base is rejected with a 400. Starting a
104
106
  plan is idempotent on the plan key; an already-running plan short-circuits. The
105
107
  response (202) echoes the `planKey` and engine `processKey`.
106
108
 
109
+ ### Base-branch admission (ADR 0003)
110
+
111
+ `startPlanFanout` admits the base through one fail-fast gate before any task fans out.
112
+ Four ordered rules govern which base is accepted:
113
+
114
+ 1. **Required + explicit.** `baseBranch` is mandatory — a blank/absent value is a `400`
115
+ (`MissingBaseBranchError`), and an implausible name is a `400` (`InvalidBaseBranchError`).
116
+ There is no silent "land on the default branch" fallback.
117
+ 2. **Create-if-missing, `epic/*` only.** A missing `epic/*` base is **auto-created** off the
118
+ repository default branch's HEAD (idempotently — an existing branch is never reset). A
119
+ missing base that is **not** `epic/*` is a clean `400` (`BaseBranchMustExistError`): a typo
120
+ can't silently spawn a wrong-rooted branch, so any non-`epic/*` base must already exist.
121
+ 3. **Confirm-default.** Naming the repository **default branch** as the base requires
122
+ `confirmDefaultBase: true`, else `400` (`DefaultBaseNotConfirmedError`). This is a
123
+ deliberate acknowledgement that every task lands directly on the default branch with no
124
+ integration buffer, and any merge-to-default side effect fires per task.
125
+ 4. **Shared-base guard.** If another **active** epic (status not `done`/`failed`/`abandoned`)
126
+ already targets the **same repo + same custom base**, admission is a `409` (`SharedBaseError`)
127
+ unless you pass `allowSharedBase: true`. The default branch is exempt — many epics target it
128
+ concurrently without colliding.
129
+
130
+ So the body may also carry two optional booleans — `confirmDefaultBase` and `allowSharedBase` —
131
+ each a "warn you can't skip" for its rule:
132
+
133
+ ```bash
134
+ curl -sS -X POST __BASE__/actions/start/plan-fanout \
135
+ -H 'content-type: application/json' \
136
+ -d '{ "issue": "owner/repo#123", "baseBranch": "main", "confirmDefaultBase": true }'
137
+ ```
138
+
139
+ Grandfathered: in-flight plans launched before this admission gate (with a `null` base branch)
140
+ keep running unchanged — the requirement is enforced at admission of **new** launches, not by a
141
+ database constraint.
142
+
107
143
  Track a plan the same way you track PRs — its `process_key` is an engine instance you
108
144
  can inspect in §5, and the PRs it opens show up in `/status` as ordinary convergence
109
145
  loops.
@@ -124,43 +160,71 @@ curl -sS __BASE__/status | jq '.prs[] | select(.openEscalation != null)
124
160
  | { prKey, status, round, openEscalation }'
125
161
  ```
126
162
 
127
- **Answer a PR/merge escalation** (convergence-loop or merge-loop). Use the message
128
- name `escalation-answered`; correlate by the PR key:
163
+ The four decision-required escalation kinds **PR review-loop**, **implementation
164
+ (feature) task**, **plan-review**, and **trial-merge** are now native BPMN
165
+ `userTask`s bearing a linked `.form`, all answered the same way through the **task
166
+ inbox** surface. There is no bespoke per-kind webhook or answer page any more.
167
+
168
+ **List the open escalation tasks.** Each task carries its context (e.g. `prKey` /
169
+ `question` / `findings` / `task`) in its `variables`, and its kind in `elementId`:
129
170
 
130
171
  ```bash
131
- curl -sS -X POST __BASE__/actions/message \
132
- -H 'content-type: application/json' \
133
- -d '{
134
- "name": "escalation-answered",
135
- "correlationKey": "owner/repo#123",
136
- "variables": { "answer": "Yes cap the retries at 5 and proceed." }
137
- }'
172
+ # Every parked escalation, across all kinds:
173
+ curl -sS __BASE__/../../tasks/api/tasks | jq '.[] | { userTaskKey, elementId, variables }'
174
+
175
+ # Filter to one kind (e.g. plan-review decisions) by elementId:
176
+ curl -sS __BASE__/../../tasks/api/tasks \
177
+ | jq '[.[] | select(.elementId == "plan-review-decision")]'
138
178
  ```
139
179
 
140
- The answer is delivered to the agent as the `answer` variable on its next round, and
141
- the loop resumes.
180
+ The inbox UI is also served at `__BASE__/../../tasks` for a human to browse, filter, and
181
+ answer (assignee/candidate-group and age surface on each task once assignment lands).
142
182
 
143
- **Answer an implementation-phase (feature) task escalation** raised during a
144
- plan fan-out a dedicated webhook operation:
183
+ **Answer a task** by completing it with the typed variables its form expects — the
184
+ completion resumes the parked process:
145
185
 
146
186
  ```bash
147
- curl -sS -X POST __BASE__/hooks/feature-answer \
148
- -H 'content-type: application/json' \
149
- -d '{ "correlationKey": "<task-or-pr-key>", "answer": "" }'
187
+ # PR review-loop (elementId `wait-answer`, pr-escalation form):
188
+ curl -sS -X POST __BASE__/../../tasks/api/complete -H 'content-type: application/json' \
189
+ -d '{ "userTaskKey": "<key>", "variables": { "answer": "Cap retries at 5 and proceed." } }'
190
+
191
+ # Implementation (feature) task (elementId `feature-escalation`):
192
+ # { "resolution": "answer", "answer": "…" } to resume, or { "resolution": "abandon" }
193
+ curl -sS -X POST __BASE__/../../tasks/api/complete -H 'content-type: application/json' \
194
+ -d '{ "userTaskKey": "<key>", "variables": { "resolution": "answer", "answer": "Use v2." } }'
195
+
196
+ # Plan-review (elementId `plan-review-decision`):
197
+ # { "directive": "revise", "notes": "…" } (fresh review budget) or { "directive": "proceed" }
198
+ curl -sS -X POST __BASE__/../../tasks/api/complete -H 'content-type: application/json' \
199
+ -d '{ "userTaskKey": "<key>", "variables": { "directive": "revise", "notes": "Make issue-7 the seam." } }'
200
+
201
+ # Trial-merge (elementId `trial-merge-decision`):
202
+ # { "action": "proceed" | "rebase" | "abandon", "notes"?: "…" }
203
+ curl -sS -X POST __BASE__/../../tasks/api/complete -H 'content-type: application/json' \
204
+ -d '{ "userTaskKey": "<key>", "variables": { "action": "rebase", "notes": "Re-run after the fix." } }'
150
205
  ```
151
206
 
152
- If `NANO_PR_WEBHOOK_SECRET` is set on the deployment, add `-H "x-hook-secret: <secret>"`.
153
-
154
- **Answer a plan-review escalation** raised when the adversarial plan review cannot
155
- converge within its round budget. Use `revise` to send guidance back to the planner
156
- with a fresh review budget, or `proceed` to explicitly approve the current plan as-is:
207
+ **Answer a merge-loop escalation** (the one out-of-scope kind that still uses the
208
+ durable message catch, not a user task). Use the message name `escalation-answered`,
209
+ correlated by the PR key:
157
210
 
158
211
  ```bash
159
- curl -sS -X POST __BASE__/hooks/plan-answer \
212
+ curl -sS -X POST __BASE__/actions/message \
160
213
  -H 'content-type: application/json' \
161
- -d '{ "plan": "owner/repo#123", "directive": "revise", "note": "Keep the sub-issues 1:1; make issue-7 the seam and point siblings at it." }'
214
+ -d '{
215
+ "name": "escalation-answered",
216
+ "correlationKey": "owner/repo#123",
217
+ "variables": { "answer": "Yes — cap the retries at 5 and proceed." }
218
+ }'
162
219
  ```
163
220
 
221
+ If `NANO_PR_WEBHOOK_SECRET` is set on the deployment, add `-H "x-hook-secret: <secret>"`.
222
+
223
+ The answer is delivered to the agent as its next-round context (e.g. the `answer`,
224
+ `directive`, or `action` variable), and the loop resumes. **Audit** is durable: the
225
+ completed user tasks form the escalation history, and the review/merge loops' rows
226
+ live in the `escalations` table (surfaced read-only per PR on the Convergence page).
227
+
164
228
  Guidance for the human you assist: read the escalation `question` or plan-review
165
229
  `findings` first, decide the smallest unblocking answer, and answer it precisely —
166
230
  the answer becomes the agent's next-round context.
@@ -173,7 +237,7 @@ the answer becomes the agent's next-round context.
173
237
  submit ──► convergence-loop
174
238
  round (senior:pr-review) ──► addressed ──► wait review-ready ─┐
175
239
  ├─ converged ──► finalize ──► merge-loop (unless convergeOnly)
176
- └─ needs_input/blocked ──► escalate ──► wait escalation-answered
240
+ └─ needs_input/blocked ──► escalate ──► wait-answer userTask (task inbox)
177
241
  merge-loop: wait deps ─► arm merge ─► (queue-aware) merge / land
178
242
  blocked (CI red) ─► senior:fix-ci ─► retry conflict ─► senior:rebase ─► retry
179
243
  ```
@@ -0,0 +1,27 @@
1
+ {
2
+ "id": "feature-escalation",
3
+ "schemaVersion": 18,
4
+ "type": "default",
5
+ "components": [
6
+ {
7
+ "type": "select",
8
+ "key": "resolution",
9
+ "label": "Resolution",
10
+ "values": [
11
+ { "label": "Answer — re-dispatch the task with this guidance", "value": "answer" },
12
+ { "label": "Abandon — give up on this task", "value": "abandon" }
13
+ ],
14
+ "validate": {
15
+ "required": true
16
+ }
17
+ },
18
+ {
19
+ "type": "textarea",
20
+ "key": "answer",
21
+ "label": "Answer / guidance for the implementation agent",
22
+ "conditional": {
23
+ "hide": "=resolution != \"answer\""
24
+ }
25
+ }
26
+ ]
27
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "id": "plan-review-decision",
3
+ "schemaVersion": 18,
4
+ "type": "default",
5
+ "components": [
6
+ {
7
+ "type": "select",
8
+ "key": "directive",
9
+ "label": "Directive",
10
+ "values": [
11
+ { "label": "Proceed — dispatch the current plan as-is", "value": "proceed" },
12
+ { "label": "Revise — send the plan back to the planner", "value": "revise" }
13
+ ],
14
+ "validate": {
15
+ "required": true
16
+ }
17
+ },
18
+ {
19
+ "type": "textarea",
20
+ "key": "notes",
21
+ "label": "Revision guidance for the planner",
22
+ "conditional": {
23
+ "hide": "=directive != \"revise\""
24
+ }
25
+ }
26
+ ]
27
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "id": "pr-escalation",
3
+ "schemaVersion": 18,
4
+ "type": "default",
5
+ "components": [
6
+ {
7
+ "type": "textarea",
8
+ "key": "question",
9
+ "label": "Escalation question",
10
+ "description": "A human decision is needed to resume the PR review-convergence loop.",
11
+ "readonly": true
12
+ },
13
+ {
14
+ "type": "textarea",
15
+ "key": "answer",
16
+ "label": "Your answer",
17
+ "description": "Your reply resumes the review loop and is handed to the next review round.",
18
+ "validate": {
19
+ "required": true
20
+ }
21
+ }
22
+ ]
23
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "id": "spine-demo",
3
+ "schemaVersion": 18,
4
+ "type": "default",
5
+ "components": [
6
+ {
7
+ "type": "textfield",
8
+ "key": "decision",
9
+ "label": "Decision",
10
+ "validate": {
11
+ "required": true
12
+ }
13
+ }
14
+ ]
15
+ }