@nanobpm/nano-workforce 0.57.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 (82) hide show
  1. package/.github/workflows/ci.yml +7 -0
  2. package/AGENTS.md +83 -1
  3. package/CHANGELOG.md +7 -0
  4. package/README.md +1 -1
  5. package/SPEC.md +21 -22
  6. package/app/agentCompletion.test.ts +337 -0
  7. package/app/agentCompletion.ts +219 -0
  8. package/app/answer-escalation.test.ts +106 -0
  9. package/app/answerEscalation.test.ts +67 -0
  10. package/app/baseGuard.test.ts +9 -1
  11. package/app/baseGuard.ts +11 -0
  12. package/app/escalationSla.test.ts +39 -0
  13. package/app/escalationSla.ts +28 -0
  14. package/app/escalationTaxonomy.test.ts +115 -0
  15. package/app/escalationTaxonomy.ts +115 -0
  16. package/app/feature.test.ts +161 -0
  17. package/app/feature.ts +173 -0
  18. package/app/mergeProtocol.test.ts +15 -0
  19. package/app/mergeProtocol.ts +10 -0
  20. package/app/persist-escalation.test.ts +34 -36
  21. package/app/plan.test.ts +0 -294
  22. package/app/plan.ts +26 -216
  23. package/app/reviewWait.ts +12 -4
  24. package/app/roundResultDefault.test.ts +111 -2
  25. package/app/roundResultDefault.ts +35 -0
  26. package/app/service.test.ts +6 -7
  27. package/app/service.ts +52 -35
  28. package/db/migrations/026_agent_completion.sql +36 -0
  29. package/db/migrations/027_retire_escalation_subsystem.sql +43 -0
  30. package/db/migrations/028_feature_runs.sql +28 -0
  31. package/e2e/agent-answerable.e2e.ts +185 -0
  32. package/e2e/convergence-escalation.e2e.ts +180 -0
  33. package/e2e/convergence-loop.e2e.ts +1 -1
  34. package/e2e/feature-run.e2e.ts +231 -0
  35. package/e2e/plan-fanout-sla.e2e.ts +238 -0
  36. package/e2e/plan-fanout.e2e.ts +303 -0
  37. package/e2e/retire-escalation-subsystem.e2e.ts +223 -0
  38. package/e2e/support/github-admit.ts +99 -0
  39. package/e2e/user-task-spine.e2e.ts +155 -0
  40. package/nano.app.json +37 -11
  41. package/openapi.yaml +181 -73
  42. package/operations/agentCompleteEscalation.ts +53 -0
  43. package/operations/listActivePrs.test.ts +39 -6
  44. package/operations/postMessage.ts +10 -41
  45. package/operations/revertEscalationCompletion.ts +44 -0
  46. package/operations/startAndMessage.test.ts +0 -58
  47. package/operations/startFeature.ts +127 -0
  48. package/package.json +4 -1
  49. package/pages/cockpit.page.json +1 -0
  50. package/pages/epic-detail.page.json +11 -37
  51. package/pages/epic.page.json +1 -1
  52. package/pages/feature.page.json +82 -0
  53. package/pages/home.page.json +6 -18
  54. package/resources/agent-guide.md +52 -24
  55. package/resources/forms/feature-escalation.form +27 -0
  56. package/resources/forms/plan-review-decision.form +27 -0
  57. package/resources/forms/pr-escalation.form +23 -0
  58. package/resources/forms/spine-demo.form +15 -0
  59. package/resources/forms/trial-merge-decision.form +25 -0
  60. package/resources/processes/convergence-loop.bpmn +127 -75
  61. package/resources/processes/feature.bpmn +240 -0
  62. package/resources/processes/plan-fanout.bpmn +304 -223
  63. package/resources/processes/spine-demo.bpmn +72 -0
  64. package/scripts/check-migrations.ts +68 -0
  65. package/workers/answer-escalation/worker.ts +78 -0
  66. package/workers/converge-feature/worker.ts +51 -0
  67. package/workers/finalize/worker.ts +0 -2
  68. package/workers/mark-merged/worker.ts +0 -2
  69. package/workers/merge/worker.ts +6 -5
  70. package/workers/persist-escalation/worker.ts +28 -32
  71. package/workers/record-feature/worker.ts +61 -0
  72. package/workers/record-plan-review/worker.test.ts +9 -10
  73. package/workers/record-plan-review/worker.ts +15 -5
  74. package/workers/resolve-trial-attention/worker.test.ts +77 -0
  75. package/workers/resolve-trial-attention/worker.ts +43 -0
  76. package/operations/answerFeatureEscalation.test.ts +0 -112
  77. package/operations/answerFeatureEscalation.ts +0 -58
  78. package/operations/answerPlanEscalation.test.ts +0 -115
  79. package/operations/answerPlanEscalation.ts +0 -41
  80. package/workers/persist-plan-escalation/worker.test.ts +0 -80
  81. package/workers/persist-plan-escalation/worker.ts +0 -73
  82. package/workers/persist-task-escalation/worker.ts +0 -120
@@ -7,13 +7,24 @@ import type { AppApi } from "@nanobpm/urban";
7
7
  import { noopLog } from "../test/log.ts";
8
8
  import handler from "./listActivePrs.ts";
9
9
 
10
- function memApp(rows: any[]): AppApi {
11
- const tbl = {
12
- async all() {
13
- return rows;
14
- },
10
+ function memApp(rows: any[], escalations: any[] = []): AppApi {
11
+ const table = (name: string) => {
12
+ if (name === "escalations") {
13
+ return {
14
+ async find(where: Record<string, unknown>) {
15
+ return escalations.filter((e) =>
16
+ Object.entries(where).every(([k, v]) => e[k] === v)
17
+ );
18
+ },
19
+ };
20
+ }
21
+ return {
22
+ async all() {
23
+ return rows;
24
+ },
25
+ };
15
26
  };
16
- return { data: { table: () => tbl }, log: noopLog() } as any as AppApi;
27
+ return { data: { table }, log: noopLog() } as any as AppApi;
17
28
  }
18
29
 
19
30
  function input(headers: Record<string, string> = {}) {
@@ -46,6 +57,28 @@ test("returns 200 with a count + projected active PRs", async () => {
46
57
  assertEquals(r.body.prs[0].processKey, "9");
47
58
  });
48
59
 
60
+ test("surfaces openEscalation for an escalated PR from its open escalations row (both loops)", async () => {
61
+ // Regression: a merge-loop escalation parks on a message catch (no user task), so deriving
62
+ // openEscalation from a user-task probe hid it. Deriving from the canonical `escalations` row
63
+ // surfaces it. Two escalated PRs — one with an open row (visible), one already answered (null).
64
+ const app = memApp(
65
+ [
66
+ { pr_key: "o/r#10", repo: "o/r", number: 10, url: "u10", title: "merge blocked", status: "escalated", current_round: 3, process_key: "m1", updated_at: "2026-02-02" },
67
+ { pr_key: "o/r#11", repo: "o/r", number: 11, url: "u11", title: "answered", status: "escalated", current_round: 4, process_key: "m2", updated_at: "2026-02-01" },
68
+ ],
69
+ [
70
+ { id: 1, pr_key: "o/r#10", status: "open", question: "Resolve the conflict on the branch, then retry?" },
71
+ { id: 2, pr_key: "o/r#11", status: "answered", question: "old question" },
72
+ ],
73
+ );
74
+ const res = (await handler(input(), app)) as any;
75
+ assertEquals(res.status, 200);
76
+ const p10 = res.body.prs.find((p: any) => p.prKey === "o/r#10");
77
+ const p11 = res.body.prs.find((p: any) => p.prKey === "o/r#11");
78
+ assertEquals(p10.openEscalation, "Resolve the conflict on the branch, then retry?");
79
+ assertEquals(p11.openEscalation, null);
80
+ });
81
+
49
82
  test("shared-secret guard rejects a missing secret when configured", async () => {
50
83
  const prev = process.env["NANO_PR_WEBHOOK_SECRET"];
51
84
  process.env["NANO_PR_WEBHOOK_SECRET"] = "s3cr3t";
@@ -1,21 +1,18 @@
1
1
  // POST /app/api/actions/message → operationId `postMessage` (ADR 0058, base /app/api).
2
2
  // Replaces the hand-rolled action that overrode the generic publishMessage action. For the
3
- // `escalation-answered` message we run the review answer flow, for `feature-escalation-answered`
4
- // (issue #25) the implementation-phase (per-task) answer flow, and for
5
- // `plan-escalation-answered` the plan-review cap answer flow. Any other message falls back to a
6
- // plain publishMessage.
3
+ // merge-loop `escalation-answered` message we run the merge-loop escalation answer flow; any other
4
+ // message falls back to a plain publishMessage.
5
+ //
6
+ // The four #156 escalation kinds (task, plan-review, trial-merge, PR review-loop) are now native
7
+ // `userTask`s answered directly through the task inbox (`POST /tasks/api/complete`), so this
8
+ // delegate no longer carries their bespoke `feature-escalation-answered` / `plan-escalation-answered`
9
+ // discriminators. The merge-loop escalation is still a durable message catch (out of scope for
10
+ // #156), so its `escalation-answered` branch is kept.
7
11
  //
8
12
  // The runtime validates the body against openapi.yaml (`name` is required, so a missing name is a 400
9
13
  // for free); this delegate keeps the message-name dispatch — the discriminator + downstream behavior
10
14
  // is app logic, not something the JSON schema can express.
11
15
 
12
- import {
13
- answerPlanEscalation,
14
- answerTaskEscalation,
15
- FEATURE_ESCALATION_MESSAGE,
16
- PLAN_ESCALATION_MESSAGE,
17
- parsePlanEscalationDirective,
18
- } from "../app/plan.ts";
19
16
  import { answerEscalation } from "../app/service.ts";
20
17
  import { defineOperation } from "../nano-generated/operations.ts";
21
18
 
@@ -33,36 +30,8 @@ export default defineOperation("postMessage", async ({ body }, app) => {
33
30
  if (!prKey) return { status: 400, body: { error: "correlationKey is required" } };
34
31
  if (!answer) return { status: 400, body: { error: "answer is required" } };
35
32
  const r = await answerEscalation(app.data, app.engine, prKey, answer);
36
- if (r.ok) app.log.info("review escalation answered", { name, prKey });
37
- else app.log.warn("postMessage: no open review escalation to answer", { name, prKey });
38
- return { status: r.ok ? 200 : 404, body: r };
39
- }
40
-
41
- if (name === FEATURE_ESCALATION_MESSAGE) {
42
- // Implementation-phase task escalation (issue #25): correlationKey is the task's
43
- // `<plan_key>:<task_id>`; record the answer, resume the parked child, and re-surface the next
44
- // open escalation.
45
- const corrKey = String(b.correlationKey ?? "");
46
- const answer = String(b.variables?.answer ?? "").trim();
47
- if (!corrKey) return { status: 400, body: { error: "correlationKey is required" } };
48
- if (!answer) return { status: 400, body: { error: "answer is required" } };
49
- const r = await answerTaskEscalation(app.data, app.engine, corrKey, answer);
50
- if (r.ok) app.log.info("feature escalation answered", { name, corrKey });
51
- else app.log.warn("postMessage: no open feature escalation to answer", { name, corrKey });
52
- return { status: r.ok ? 200 : 404, body: r };
53
- }
54
-
55
- if (name === PLAN_ESCALATION_MESSAGE) {
56
- const planKey = String(b.correlationKey ?? "");
57
- const directive = parsePlanEscalationDirective(b.variables?.directive ?? "revise");
58
- const note = String(b.variables?.note ?? b.variables?.answer ?? "").trim();
59
- if (!planKey) return { status: 400, body: { error: "correlationKey is required" } };
60
- if (!directive) {
61
- return { status: 400, body: { error: "directive must be proceed or revise" } };
62
- }
63
- const r = await answerPlanEscalation(app.data, app.engine, planKey, directive, note);
64
- if (r.ok) app.log.info("plan escalation answered", { name, planKey, directive });
65
- else app.log.warn("postMessage: no open plan escalation to answer", { name, planKey });
33
+ if (r.ok) app.log.info("merge-loop escalation answered", { name, prKey });
34
+ else app.log.warn("postMessage: no open merge-loop escalation to answer", { name, prKey });
66
35
  return { status: r.ok ? 200 : 404, body: r };
67
36
  }
68
37
 
@@ -0,0 +1,44 @@
1
+ // POST /app/api/hooks/revert-completion → operationId `revertEscalationCompletion` (epic #156, slice
2
+ // U6; ADR 0046 reversibility). A completed user task cannot be un-completed in the engine, so an
3
+ // AGENT answer is never a silent irreversible commit: this endpoint lets a human mark a reversible
4
+ // agent completion reverted/overridden, recording who did it and when. Host-side consumers read the
5
+ // `task_completions` ledger to see whether the latest completion is still authoritative. Human
6
+ // completions are not reversible (they are already the authority), and a completion can be reverted
7
+ // only once. Optional shared-secret guard (x-hook-secret), enforced only when NANO_PR_WEBHOOK_SECRET
8
+ // is set.
9
+
10
+ import { revertAgentCompletion } from "../app/agentCompletion.ts";
11
+ import { envVar } from "../app/version.ts";
12
+ import { defineOperation } from "../nano-generated/operations.ts";
13
+
14
+ const WEBHOOK_SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
15
+
16
+ const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
17
+
18
+ export default defineOperation("revertEscalationCompletion", async ({ req, body }, app) => {
19
+ if (WEBHOOK_SECRET && req.headers.get("x-hook-secret") !== WEBHOOK_SECRET) {
20
+ app.log.warn("revert-completion rejected: missing/invalid shared secret");
21
+ return { status: 401, body: { ok: false, error: "unauthorized" } };
22
+ }
23
+ if (!body || typeof body !== "object") {
24
+ app.log.warn("revert-completion rejected: missing request body");
25
+ return { status: 400, body: { ok: false, error: "completionId and reverterId are required" } };
26
+ }
27
+
28
+ const completionId = typeof body.completionId === "number" ? body.completionId : Number.NaN;
29
+ const reverterId = str(body.reverterId);
30
+ const note = str(body.note);
31
+ if (!Number.isInteger(completionId)) {
32
+ return { status: 400, body: { ok: false, error: "completionId must be an integer" } };
33
+ }
34
+ if (!reverterId) return { status: 400, body: { ok: false, error: "reverterId is required" } };
35
+
36
+ const r = await revertAgentCompletion(app.data, completionId, { kind: "human", id: reverterId }, note);
37
+ if (r.ok) {
38
+ app.log.info("agent completion reverted", { completionId, reverterId });
39
+ return { status: 200, body: { ok: true, completionId: r.completionId } };
40
+ }
41
+ const status = r.reason === "no such completion" ? 404 : 400;
42
+ app.log.warn("revert-completion: not reverted", { completionId, reason: r.reason });
43
+ return { status, body: { ok: false, error: r.reason } };
44
+ });
@@ -222,61 +222,3 @@ test("postMessage → 400 when escalation-answered lacks a correlationKey", asyn
222
222
  assertEquals(r.status, 400);
223
223
  assertEquals(r.body.error, "correlationKey is required");
224
224
  });
225
-
226
- function planEscalationMessageApp() {
227
- const plans = [{ plan_key: "owner/repo#12", open_plan_escalation_id: 1 }];
228
- const escalations = [{
229
- id: 1,
230
- plan_key: "owner/repo#12",
231
- epoch: 0,
232
- round: 2,
233
- findings: "needs guidance",
234
- status: "open",
235
- directive: null,
236
- note: null,
237
- }];
238
- const published: any[] = [];
239
- const match = (r: Record<string, unknown>, q: Record<string, unknown>) =>
240
- Object.entries(q).every(([f, v]) => r[f] === v);
241
- const table = (rows: any[], key: string) => ({
242
- find: (q: any) => Promise.resolve(rows.filter((r) => match(r, q))),
243
- update: (id: any, patch: any) => {
244
- const row = rows.find((r) => r[key] === id);
245
- if (row) Object.assign(row, patch);
246
- return Promise.resolve(row);
247
- },
248
- });
249
- return {
250
- app: {
251
- data: {
252
- table(name: string) {
253
- return name === "plans" ? table(plans, "plan_key") : table(escalations, "id");
254
- },
255
- },
256
- engine: {
257
- publishMessage: (m: any) => {
258
- published.push(m);
259
- return Promise.resolve();
260
- },
261
- },
262
- log: noopLog(),
263
- } as any as AppApi,
264
- escalations,
265
- published,
266
- };
267
- }
268
-
269
- test("postMessage accepts mixed-case plan escalation directive like the dedicated hook", async () => {
270
- const { app: msgApp, escalations, published } = planEscalationMessageApp();
271
- const res = await postMessage(input({
272
- name: "plan-escalation-answered",
273
- correlationKey: "owner/repo#12",
274
- variables: { directive: "PrOcEeD", note: "ship it" },
275
- }), msgApp);
276
- const r = res as any;
277
- assertEquals(r.status, 200);
278
- assertEquals(r.body.ok, true);
279
- assertEquals(r.body.directive, "proceed");
280
- assertEquals(escalations[0].directive, "proceed");
281
- assertEquals(published[0].variables.planEscalationDirective, "proceed");
282
- });
@@ -0,0 +1,127 @@
1
+ // POST /app/api/actions/start/feature → operationId `startFeature` (ADR 0058/0059, base /app/api).
2
+ // The ONE door for starting a SINGLE-issue feature run — the "missing middle" between Epics
3
+ // (startPlanFanout) and PR convergence (startConvergenceLoop): hand one issue to a single
4
+ // implementation agent that raises exactly one PR, then OPTIONALLY converge + merge (issue #172).
5
+ //
6
+ // The request body is FLAT (`{ issue | url, baseBranch, converge?, autoMerge? }`), not wrapped in a
7
+ // `variables` envelope — a purpose-built operation, not a generic engine "start process" call. The
8
+ // body is a `oneOf` — EXACTLY ONE of `issue` or `url` — so an empty or ambiguous target is a 400 at
9
+ // the edge; this delegate narrows the validated variant and keeps the issue-FORMAT parse guard.
10
+ //
11
+ // Base-branch handling is IDENTICAL to the epic path: it reuses `admitPlan` (ADR 0003) verbatim, so
12
+ // a feature run's PR is admitted through the same required+explicit / create-if-missing /
13
+ // confirm-default / shared-base rules, with the same typed-error → HTTP mapping.
14
+
15
+ import { startFeature } from "../app/feature.ts";
16
+ import { BaseBranchMustExistError } from "../app/github.ts";
17
+ import {
18
+ admitPlan,
19
+ DefaultBaseNotConfirmedError,
20
+ InvalidBaseBranchError,
21
+ MissingBaseBranchError,
22
+ parseIssue,
23
+ SharedBaseError,
24
+ } from "../app/plan.ts";
25
+ import { defineOperation } from "../nano-generated/operations.ts";
26
+
27
+ export default defineOperation("startFeature", async ({ body }, app) => {
28
+ // The runtime validates a well-formed body against openapi.yaml, but a directly-invoked delegate
29
+ // (or a missing body) leaves `body` undefined — guard so that becomes a 400, not a 500 from `in`.
30
+ if (!body || typeof body !== "object") {
31
+ app.log.warn("start-feature rejected: missing request body");
32
+ return { status: 400, body: { error: "request body is required (owner/repo#123 or an issue URL)" } };
33
+ }
34
+ // The `oneOf` variant is normally narrowed by OpenAPI validation, but a directly-invoked delegate
35
+ // can pass a missing/mistyped `issue`/`url` — guard so that stays a 400, not a 500 from `.trim()`.
36
+ const target = "issue" in body ? body.issue : "url" in body ? body.url : undefined;
37
+ if (typeof target !== "string") {
38
+ app.log.warn("start-feature rejected: issue/url must be a string");
39
+ return { status: 400, body: { error: "issue or url must be a string (owner/repo#123 or an issue URL)" } };
40
+ }
41
+ const raw = target.trim();
42
+ const parsed = parseIssue(raw);
43
+ if (!parsed) {
44
+ app.log.warn("start-feature rejected: unparseable issue reference", { raw });
45
+ return { status: 400, body: { error: "could not parse issue (use owner/repo#123 or an issue URL)" } };
46
+ }
47
+ // Base branch (ADR 0003): admit the launch through the same fail-fast `admitPlan` gate the epic
48
+ // path uses BEFORE starting the run. A feature run also raises a PR against a base, so it honors
49
+ // the identical admission policy and error → HTTP mapping.
50
+ const rawBase = "baseBranch" in body && typeof body.baseBranch === "string" ? body.baseBranch : null;
51
+ const allowSharedBase = "allowSharedBase" in body && body.allowSharedBase === true;
52
+ const confirmDefaultBase = "confirmDefaultBase" in body && body.confirmDefaultBase === true;
53
+ const token = process.env.GITHUB_TOKEN ?? "";
54
+ let normalizedBase: string;
55
+ try {
56
+ // No `selfPlanKey`: a feature run creates a `feature_runs` row, NOT a `plans` row, so there is no
57
+ // own plan to exclude from the shared-base guard (rule 4). Passing `parsed.planKey` here would
58
+ // exclude an ACTIVE EPIC sharing the same `owner/repo#N` key, silently bypassing shared-base
59
+ // protection. Feature-run idempotency is enforced separately by `startFeature` on `feature_runs`.
60
+ normalizedBase = await admitPlan(app.data, parsed.repo, rawBase, token, {
61
+ allowSharedBase,
62
+ confirmDefaultBase,
63
+ });
64
+ } catch (err) {
65
+ if (err instanceof MissingBaseBranchError) {
66
+ app.log.warn("start-feature rejected: missing base branch");
67
+ return {
68
+ status: 400,
69
+ body: { error: "baseBranch is required (name the branch the PR targets, e.g. main or epic/agent-protocol)" },
70
+ };
71
+ }
72
+ if (err instanceof InvalidBaseBranchError) {
73
+ app.log.warn("start-feature rejected: invalid base branch", { baseBranch: err.value });
74
+ return {
75
+ status: 400,
76
+ body: { error: "invalid baseBranch (must be a plausible git branch name, e.g. main)" },
77
+ };
78
+ }
79
+ if (err instanceof BaseBranchMustExistError) {
80
+ app.log.warn("start-feature rejected: base branch does not exist", { baseBranch: err.branch });
81
+ return {
82
+ status: 400,
83
+ body: {
84
+ error:
85
+ `baseBranch "${err.branch}" does not exist and is not an epic/* branch, so it is not ` +
86
+ `auto-created — create it first, or use the epic/* convention`,
87
+ },
88
+ };
89
+ }
90
+ if (err instanceof DefaultBaseNotConfirmedError) {
91
+ app.log.warn("start-feature rejected: default base not confirmed", { baseBranch: err.branch });
92
+ return {
93
+ status: 400,
94
+ body: {
95
+ error:
96
+ `baseBranch "${err.branch}" is the repository default branch — the PR would target it ` +
97
+ `directly. Re-submit with confirmDefaultBase: true to proceed`,
98
+ },
99
+ };
100
+ }
101
+ if (err instanceof SharedBaseError) {
102
+ app.log.warn("start-feature rejected: shared base branch", { baseBranch: err.branch });
103
+ return {
104
+ status: 409,
105
+ body: {
106
+ error:
107
+ `baseBranch "${err.branch}" is already in use by another active epic. Re-submit with ` +
108
+ `allowSharedBase: true to stack on it, or name a distinct branch`,
109
+ },
110
+ };
111
+ }
112
+ throw err;
113
+ }
114
+ const converge = "converge" in body && body.converge === true;
115
+ // Auto-merge is only meaningful as a follow-on to convergence; pin it off when converge is off so
116
+ // the persisted row and the process variable can't disagree.
117
+ const autoMerge = converge && "autoMerge" in body && body.autoMerge === true;
118
+ const result = await startFeature(app.data, app.engine, parsed, normalizedBase, converge, autoMerge);
119
+ app.log.info("feature run started", {
120
+ featureKey: parsed.planKey,
121
+ requestedBaseBranch: normalizedBase,
122
+ converge,
123
+ autoMerge,
124
+ alreadyRunning: "alreadyRunning" in result && result.alreadyRunning === true,
125
+ });
126
+ return { status: 202, body: result };
127
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.57.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
  }
@@ -75,7 +76,6 @@
75
76
  { "field": "base_branch", "header": "Base branch" },
76
77
  { "field": "wave_label", "header": "Wave" },
77
78
  { "field": "task_count", "header": "Tasks" },
78
- { "field": "open_plan_findings", "header": "Attention", "badge": { "tone": "danger", "label": "!" } },
79
79
  { "field": "issue_number", "header": "Issue", "linkField": "issue_url" },
80
80
  { "field": "updated_at", "header": "Updated" }
81
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
  }