@nanobpm/nano-workforce 0.44.1 → 0.46.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.
@@ -0,0 +1,51 @@
1
+ -- Plan-review cap escalation. When the adversarial review loop exhausts its per-epoch budget
2
+ -- without approval, the plan-fanout process parks for a human directive instead of raising an
3
+ -- unhandled PLAN_REJECTED incident. A `revise` answer starts a new review epoch; a `proceed`
4
+ -- answer explicitly dispatches the current plan as-is.
5
+ --
6
+ -- `plan_reviews.round` remains derived from the append-only review log, but now within the current
7
+ -- epoch. SQLite cannot alter the existing PRIMARY KEY (plan_key, round), so recreate the table with
8
+ -- (plan_key, epoch, round) and backfill existing rows into epoch 0.
9
+
10
+ ALTER TABLE plan_reviews ADD COLUMN epoch INTEGER NOT NULL DEFAULT 0;
11
+
12
+ CREATE TABLE plan_reviews_new (
13
+ plan_key TEXT NOT NULL REFERENCES plans(plan_key),
14
+ epoch INTEGER NOT NULL DEFAULT 0,
15
+ round INTEGER NOT NULL,
16
+ approved INTEGER NOT NULL,
17
+ findings TEXT,
18
+ created_at TEXT NOT NULL,
19
+ job_key TEXT,
20
+ PRIMARY KEY (plan_key, epoch, round)
21
+ );
22
+
23
+ INSERT INTO plan_reviews_new (plan_key, epoch, round, approved, findings, created_at, job_key)
24
+ SELECT plan_key, epoch, round, approved, findings, created_at, job_key
25
+ FROM plan_reviews;
26
+
27
+ DROP TABLE plan_reviews;
28
+ ALTER TABLE plan_reviews_new RENAME TO plan_reviews;
29
+
30
+ CREATE INDEX idx_plan_reviews_plan ON plan_reviews(plan_key);
31
+ CREATE UNIQUE INDEX idx_plan_reviews_job ON plan_reviews(plan_key, job_key);
32
+
33
+ CREATE TABLE plan_review_escalations (
34
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
35
+ plan_key TEXT NOT NULL REFERENCES plans(plan_key),
36
+ epoch INTEGER NOT NULL,
37
+ round INTEGER NOT NULL,
38
+ findings TEXT,
39
+ status TEXT NOT NULL, -- open | answered
40
+ directive TEXT, -- proceed | revise (answered rows only)
41
+ note TEXT,
42
+ asked_at TEXT NOT NULL,
43
+ answered_at TEXT
44
+ );
45
+
46
+ CREATE INDEX idx_plan_review_escalations_plan ON plan_review_escalations(plan_key);
47
+ CREATE INDEX idx_plan_review_escalations_open ON plan_review_escalations(plan_key, status);
48
+
49
+ ALTER TABLE plans ADD COLUMN open_plan_escalation_id INTEGER;
50
+ ALTER TABLE plans ADD COLUMN open_plan_findings TEXT;
51
+ ALTER TABLE plans ADD COLUMN open_plan_round INTEGER;
package/nano.app.json CHANGED
@@ -119,6 +119,10 @@
119
119
  "taskType": "pr.persist-task-escalation",
120
120
  "handler": "workers/persist-task-escalation/worker.ts"
121
121
  },
122
+ {
123
+ "taskType": "pr.persist-plan-escalation",
124
+ "handler": "workers/persist-plan-escalation/worker.ts"
125
+ },
122
126
  {
123
127
  "taskType": "pr.retro-gather",
124
128
  "handler": "workers/retro-gather/worker.ts"
package/openapi.yaml CHANGED
@@ -271,6 +271,15 @@ components:
271
271
  issue:
272
272
  type: string
273
273
  description: "Issue reference: owner/repo#123."
274
+ baseBranch:
275
+ type: string
276
+ description: >-
277
+ Optional target branch the fleet branches off and opens every PR against, instead of the
278
+ repository's default branch. Use this to land an entire epic on a long-lived integration
279
+ branch (e.g. `epic/agent-protocol`) so nothing reaches the default branch — and any
280
+ merge-to-default side effect, such as auto-publishing a package — until you deliberately
281
+ merge the integration branch. Blank/omitted keeps the current behaviour (the repo
282
+ default branch).
274
283
  PlanStartByUrl:
275
284
  type: object
276
285
  additionalProperties: false
@@ -280,6 +289,11 @@ components:
280
289
  url:
281
290
  type: string
282
291
  description: A bare issue URL, when no `owner/repo#123` reference is supplied.
292
+ baseBranch:
293
+ type: string
294
+ description: >-
295
+ Optional target branch the fleet branches off and opens every PR against, instead of the
296
+ repository's default branch. See `PlanStartByIssue.baseBranch`.
283
297
  MessageResult:
284
298
  type: object
285
299
  description: The result of publishing a message / answering an escalation. Shape varies by message
@@ -331,6 +345,27 @@ components:
331
345
  type: string
332
346
  minLength: 1
333
347
  description: The operator's answer that resumes the parked implementation agent.
348
+ PlanAnswerRequest:
349
+ type: object
350
+ additionalProperties: false
351
+ required:
352
+ - plan
353
+ - directive
354
+ properties:
355
+ plan:
356
+ type: string
357
+ description: Plan reference (owner/repo#N), also the message correlation key.
358
+ directive:
359
+ type: string
360
+ description: >-
361
+ One of `proceed` or `revise` (case-insensitive; normalized to lowercase and trimmed
362
+ server-side, see `parsePlanEscalationDirective`).
363
+ `proceed` dispatches the current unapproved plan as an explicit human override;
364
+ `revise` loops back to the planner with the note folded into planFindings and a fresh
365
+ review budget.
366
+ note:
367
+ type: string
368
+ description: Human guidance for the planner (used for `revise`; optional for `proceed`).
334
369
  BlackboardEntry:
335
370
  type: object
336
371
  additionalProperties: false
@@ -564,9 +599,9 @@ paths:
564
599
  /actions/message:
565
600
  post:
566
601
  operationId: postMessage
567
- summary: Publish a message / answer an escalation. For escalation-answered and
568
- feature-escalation-answered names, runs the corresponding answer flow; otherwise a plain
569
- publishMessage.
602
+ summary: Publish a message / answer an escalation. For escalation-answered,
603
+ feature-escalation-answered, and plan-escalation-answered names, runs the corresponding
604
+ answer flow; otherwise a plain publishMessage.
570
605
  requestBody:
571
606
  required: true
572
607
  content:
@@ -589,6 +624,13 @@ paths:
589
624
  properties:
590
625
  answer:
591
626
  type: string
627
+ directive:
628
+ type: string
629
+ description: >-
630
+ One of `proceed` or `revise` (case-insensitive; normalized to lowercase and
631
+ trimmed server-side, see `parsePlanEscalationDirective`).
632
+ note:
633
+ type: string
592
634
  responses:
593
635
  "200":
594
636
  description: The message was published (or the escalation answered).
@@ -647,6 +689,45 @@ paths:
647
689
  application/json:
648
690
  schema:
649
691
  $ref: "#/components/schemas/MessageResult"
692
+ /hooks/plan-answer:
693
+ post:
694
+ operationId: answerPlanEscalation
695
+ summary: "Answer a plan-review cap escalation out of band. Optional shared-secret guard
696
+ (x-hook-secret), enforced only when NANO_PR_WEBHOOK_SECRET is set."
697
+ security:
698
+ - hookSecret: []
699
+ - {}
700
+ requestBody:
701
+ required: true
702
+ content:
703
+ application/json:
704
+ schema:
705
+ $ref: "#/components/schemas/PlanAnswerRequest"
706
+ responses:
707
+ "200":
708
+ description: The escalation was answered and the parked plan resumed.
709
+ content:
710
+ application/json:
711
+ schema:
712
+ $ref: "#/components/schemas/MessageResult"
713
+ "400":
714
+ description: A required field was missing (plan or directive).
715
+ content:
716
+ application/json:
717
+ schema:
718
+ $ref: "#/components/schemas/MessageResult"
719
+ "401":
720
+ description: Missing/invalid shared secret (only when NANO_PR_WEBHOOK_SECRET is set).
721
+ content:
722
+ application/json:
723
+ schema:
724
+ $ref: "#/components/schemas/MessageResult"
725
+ "404":
726
+ description: No matching open plan escalation for the plan key.
727
+ content:
728
+ application/json:
729
+ schema:
730
+ $ref: "#/components/schemas/MessageResult"
650
731
  /hooks/blackboard:
651
732
  get:
652
733
  operationId: readBlackboard
@@ -0,0 +1,115 @@
1
+ import { test } from "node:test";
2
+ import { assertEquals } from "#test-assert";
3
+ import type { AppApi } from "@nanobpm/urban";
4
+ import { noopLog } from "../test/log.ts";
5
+
6
+ const hadSecret = Object.prototype.hasOwnProperty.call(process.env, "NANO_PR_WEBHOOK_SECRET");
7
+ const previousSecret = process.env.NANO_PR_WEBHOOK_SECRET;
8
+ let answerPlanEscalation: typeof import("./answerPlanEscalation.ts").default;
9
+ try {
10
+ process.env.NANO_PR_WEBHOOK_SECRET = " test-secret ";
11
+ answerPlanEscalation = (await import("./answerPlanEscalation.ts")).default;
12
+ } finally {
13
+ if (hadSecret && previousSecret !== undefined) process.env.NANO_PR_WEBHOOK_SECRET = previousSecret;
14
+ else delete process.env.NANO_PR_WEBHOOK_SECRET;
15
+ }
16
+
17
+ function memTable(rows: any[], key: string) {
18
+ return {
19
+ find: (where: Record<string, unknown>) =>
20
+ Promise.resolve(rows.filter((row) => Object.entries(where).every(([field, value]) => row[field] === value))),
21
+ update: (value: unknown, patch: Record<string, unknown>) => {
22
+ const row = rows.find((candidate) => candidate[key] === value);
23
+ if (row) Object.assign(row, patch);
24
+ return Promise.resolve(row);
25
+ },
26
+ };
27
+ }
28
+
29
+ function memApp(escalations: any[] = []) {
30
+ const stores: Record<string, { rows: any[]; key: string }> = {
31
+ plans: { rows: [{ plan_key: "owner/repo#9", open_plan_escalation_id: 1 }], key: "plan_key" },
32
+ plan_review_escalations: { rows: escalations, key: "id" },
33
+ };
34
+ const published: Record<string, unknown>[] = [];
35
+ const app = {
36
+ data: {
37
+ table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
38
+ },
39
+ engine: {
40
+ publishMessage: (message: Record<string, unknown>) => {
41
+ published.push(message);
42
+ return Promise.resolve();
43
+ },
44
+ },
45
+ log: noopLog(),
46
+ } as any as AppApi;
47
+ return { app, published, stores };
48
+ }
49
+
50
+ function input(body: Record<string, unknown>, secret?: string) {
51
+ const headers = new Headers();
52
+ if (secret !== undefined) headers.set("x-hook-secret", secret);
53
+ return {
54
+ req: {
55
+ method: "POST",
56
+ path: "/app/api/hooks/plan-answer",
57
+ query: new URLSearchParams(),
58
+ headers,
59
+ text: async () => "",
60
+ } as any,
61
+ params: {},
62
+ query: {},
63
+ body,
64
+ };
65
+ }
66
+
67
+ test("rejects a request without the configured hook secret", async () => {
68
+ const { app } = memApp();
69
+ const result = await answerPlanEscalation(input({ plan: "owner/repo#9", directive: "revise" }), app) as any;
70
+ assertEquals(result.status, 401);
71
+ assertEquals(result.body, { ok: false, error: "unauthorized" });
72
+ });
73
+
74
+ test("answers an open plan escalation and publishes the plan correlation message", async () => {
75
+ const { app, published, stores } = memApp([{
76
+ id: 1,
77
+ plan_key: "owner/repo#9",
78
+ epoch: 0,
79
+ round: 2,
80
+ findings: "needs wave 0",
81
+ status: "open",
82
+ }]);
83
+ const result = await answerPlanEscalation(
84
+ input({ plan: "owner/repo#9", directive: "revise", note: " use issue-1 first " }, "test-secret"),
85
+ app,
86
+ ) as any;
87
+ assertEquals(result.status, 200);
88
+ assertEquals(result.body.ok, true);
89
+ assertEquals(stores.plan_review_escalations.rows[0].status, "answered");
90
+ assertEquals(stores.plan_review_escalations.rows[0].directive, "revise");
91
+ assertEquals(stores.plan_review_escalations.rows[0].note, "use issue-1 first");
92
+ assertEquals(published[0]?.name, "plan-escalation-answered");
93
+ assertEquals(published[0]?.correlationKey, "owner/repo#9");
94
+ assertEquals((published[0]?.variables as Record<string, unknown>).planEscalationDirective, "revise");
95
+ });
96
+
97
+ test("maps an unmatched plan to 404", async () => {
98
+ const { app } = memApp();
99
+ const result = await answerPlanEscalation(
100
+ input({ plan: "owner/repo#missing", directive: "revise" }, "test-secret"),
101
+ app,
102
+ ) as any;
103
+ assertEquals(result.status, 404);
104
+ assertEquals(result.body.ok, false);
105
+ });
106
+
107
+ test("rejects an invalid directive with 400", async () => {
108
+ const { app } = memApp();
109
+ const result = await answerPlanEscalation(
110
+ input({ plan: "owner/repo#9", directive: "ship-it" }, "test-secret"),
111
+ app,
112
+ ) as any;
113
+ assertEquals(result.status, 400);
114
+ assertEquals(result.body.ok, false);
115
+ });
@@ -0,0 +1,41 @@
1
+ // POST /app/api/hooks/plan-answer → operationId `answerPlanEscalation`. Answers a plan-review
2
+ // cap escalation out of band. The process is parked on `plan-escalation-answered`, correlated by
3
+ // planKey; the answer records the human directive and resumes the plan.
4
+ import {
5
+ answerPlanEscalation as answerPlanEscalationState,
6
+ parsePlanEscalationDirective,
7
+ } from "../app/plan.ts";
8
+ import { envVar } from "../app/version.ts";
9
+ import { defineOperation } from "../nano-generated/operations.ts";
10
+
11
+ const WEBHOOK_SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
12
+
13
+ const str = (v: unknown): string => (typeof v === "string" ? v.trim() : "");
14
+
15
+ export default defineOperation("answerPlanEscalation", async ({ req, body }, app) => {
16
+ if (WEBHOOK_SECRET && req.headers.get("x-hook-secret") !== WEBHOOK_SECRET) {
17
+ app.log.warn("plan-answer rejected: missing/invalid shared secret");
18
+ return { status: 401, body: { ok: false, error: "unauthorized" } };
19
+ }
20
+ if (!body || typeof body !== "object") {
21
+ app.log.warn("plan-answer rejected: missing request body");
22
+ return { status: 400, body: { ok: false, error: "plan and directive are required" } };
23
+ }
24
+
25
+ const planKey = str(body.plan);
26
+ const directive = parsePlanEscalationDirective(body.directive);
27
+ const note = str(body.note);
28
+ if (!planKey) {
29
+ app.log.warn("plan-answer rejected: missing plan");
30
+ return { status: 400, body: { ok: false, error: "plan is required" } };
31
+ }
32
+ if (!directive) {
33
+ app.log.warn("plan-answer rejected: invalid directive", { directive: str(body.directive) });
34
+ return { status: 400, body: { ok: false, error: "directive must be proceed or revise" } };
35
+ }
36
+
37
+ const r = await answerPlanEscalationState(app.data, app.engine, planKey, directive, note);
38
+ if (r.ok) app.log.info("plan escalation answered", { planKey, directive });
39
+ else app.log.warn("plan-answer: no open plan escalation to answer", { planKey });
40
+ return { status: r.ok ? 200 : 404, body: r };
41
+ });
@@ -1,15 +1,21 @@
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, and for `feature-escalation-answered`
4
- // (issue #25) the implementation-phase (per-task) answer flow: record the answer, resume the parked
5
- // token, then re-surface the next open escalation. Any other message falls back to a plain
6
- // publishMessage.
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.
7
7
  //
8
8
  // The runtime validates the body against openapi.yaml (`name` is required, so a missing name is a 400
9
9
  // for free); this delegate keeps the message-name dispatch — the discriminator + downstream behavior
10
10
  // is app logic, not something the JSON schema can express.
11
11
 
12
- import { answerTaskEscalation, FEATURE_ESCALATION_MESSAGE } from "../app/plan.ts";
12
+ import {
13
+ answerPlanEscalation,
14
+ answerTaskEscalation,
15
+ FEATURE_ESCALATION_MESSAGE,
16
+ PLAN_ESCALATION_MESSAGE,
17
+ parsePlanEscalationDirective,
18
+ } from "../app/plan.ts";
13
19
  import { answerEscalation } from "../app/service.ts";
14
20
  import { defineOperation } from "../nano-generated/operations.ts";
15
21
 
@@ -46,6 +52,20 @@ export default defineOperation("postMessage", async ({ body }, app) => {
46
52
  return { status: r.ok ? 200 : 404, body: r };
47
53
  }
48
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 });
66
+ return { status: r.ok ? 200 : 404, body: r };
67
+ }
68
+
49
69
  await app.engine.publishMessage({
50
70
  name,
51
71
  correlationKey: b.correlationKey != null ? String(b.correlationKey) : undefined,
@@ -121,6 +121,18 @@ test("startPlanFanout → 400 (not 500) on a missing request body", async () =>
121
121
  assertEquals(typeof r.body.error, "string");
122
122
  });
123
123
 
124
+ test("startPlanFanout → 400 on an invalid baseBranch (not persisted/rendered)", async () => {
125
+ // A non-blank baseBranch that isn't a plausible git branch name (shell metacharacters here)
126
+ // must be rejected at the edge as a 400 — never persisted or interpolated into the agent prompt.
127
+ const res = await startPlanFanout(
128
+ input({ issue: "owner/repo#123", baseBranch: "epic/agent; rm -rf /" }),
129
+ app,
130
+ );
131
+ const r = res as any;
132
+ assertEquals(r.status, 400);
133
+ assertEquals(typeof r.body.error, "string");
134
+ });
135
+
124
136
  test("startConvergenceLoop narrows the `url` variant (no `pr` key)", async () => {
125
137
  await withGithubOff(async () => {
126
138
  const { app: capApp } = captureApp();
@@ -150,3 +162,61 @@ test("postMessage → 400 when escalation-answered lacks a correlationKey", asyn
150
162
  assertEquals(r.status, 400);
151
163
  assertEquals(r.body.error, "correlationKey is required");
152
164
  });
165
+
166
+ function planEscalationMessageApp() {
167
+ const plans = [{ plan_key: "owner/repo#12", open_plan_escalation_id: 1 }];
168
+ const escalations = [{
169
+ id: 1,
170
+ plan_key: "owner/repo#12",
171
+ epoch: 0,
172
+ round: 2,
173
+ findings: "needs guidance",
174
+ status: "open",
175
+ directive: null,
176
+ note: null,
177
+ }];
178
+ const published: any[] = [];
179
+ const match = (r: Record<string, unknown>, q: Record<string, unknown>) =>
180
+ Object.entries(q).every(([f, v]) => r[f] === v);
181
+ const table = (rows: any[], key: string) => ({
182
+ find: (q: any) => Promise.resolve(rows.filter((r) => match(r, q))),
183
+ update: (id: any, patch: any) => {
184
+ const row = rows.find((r) => r[key] === id);
185
+ if (row) Object.assign(row, patch);
186
+ return Promise.resolve(row);
187
+ },
188
+ });
189
+ return {
190
+ app: {
191
+ data: {
192
+ table(name: string) {
193
+ return name === "plans" ? table(plans, "plan_key") : table(escalations, "id");
194
+ },
195
+ },
196
+ engine: {
197
+ publishMessage: (m: any) => {
198
+ published.push(m);
199
+ return Promise.resolve();
200
+ },
201
+ },
202
+ log: noopLog(),
203
+ } as any as AppApi,
204
+ escalations,
205
+ published,
206
+ };
207
+ }
208
+
209
+ test("postMessage accepts mixed-case plan escalation directive like the dedicated hook", async () => {
210
+ const { app: msgApp, escalations, published } = planEscalationMessageApp();
211
+ const res = await postMessage(input({
212
+ name: "plan-escalation-answered",
213
+ correlationKey: "owner/repo#12",
214
+ variables: { directive: "PrOcEeD", note: "ship it" },
215
+ }), msgApp);
216
+ const r = res as any;
217
+ assertEquals(r.status, 200);
218
+ assertEquals(r.body.ok, true);
219
+ assertEquals(r.body.directive, "proceed");
220
+ assertEquals(escalations[0].directive, "proceed");
221
+ assertEquals(published[0].variables.planEscalationDirective, "proceed");
222
+ });
@@ -10,7 +10,7 @@
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 { parseIssue, startPlan } from "../app/plan.ts";
13
+ import { InvalidBaseBranchError, normalizeBaseBranch, parseIssue, startPlan } from "../app/plan.ts";
14
14
  import { defineOperation } from "../nano-generated/operations.ts";
15
15
 
16
16
  export default defineOperation("startPlanFanout", async ({ body }, app) => {
@@ -26,9 +26,29 @@ export default defineOperation("startPlanFanout", async ({ body }, app) => {
26
26
  app.log.warn("start-plan rejected: unparseable issue reference", { raw });
27
27
  return { status: 400, body: { error: "could not parse issue (use owner/repo#123 or an issue URL)" } };
28
28
  }
29
- const result = await startPlan(app.data, app.engine, parsed);
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;
36
+ try {
37
+ normalizedBase = normalizeBaseBranch(baseBranch);
38
+ } catch (err) {
39
+ if (err instanceof InvalidBaseBranchError) {
40
+ app.log.warn("start-plan rejected: invalid base branch", { baseBranch: err.value });
41
+ return {
42
+ status: 400,
43
+ body: { error: "invalid baseBranch (must be a plausible git branch name, e.g. epic/agent-protocol)" },
44
+ };
45
+ }
46
+ throw err;
47
+ }
48
+ const result = await startPlan(app.data, app.engine, parsed, normalizedBase);
30
49
  app.log.info("plan fan-out started", {
31
50
  planKey: parsed.planKey,
51
+ baseBranch: normalizedBase ?? "(default branch)",
32
52
  alreadyRunning: "alreadyRunning" in result && result.alreadyRunning === true,
33
53
  });
34
54
  return { status: 202, body: result };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.44.1",
3
+ "version": "0.46.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,7 +35,8 @@
35
35
  "submitLabel": "Plan & implement",
36
36
  "action": { "path": "/app/api/actions/start/plan-fanout", "body": "{{form}}" },
37
37
  "fields": [
38
- { "key": "issue", "label": "owner/repo#123 or a GitHub issue URL", "type": "text" }
38
+ { "key": "issue", "label": "owner/repo#123 or a GitHub issue URL", "type": "text" },
39
+ { "key": "baseBranch", "label": "Base branch (blank = repo default; e.g. epic/agent-protocol to land the whole epic on an integration branch)", "type": "text" }
39
40
  ]
40
41
  }
41
42
  },
@@ -69,16 +70,50 @@
69
70
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
70
71
  { "field": "task_count", "header": "Tasks" },
71
72
  { "field": "open_task_id", "header": "Open escalation" },
73
+ { "field": "open_plan_round", "header": "Plan escalation round" },
72
74
  { "field": "updated_at", "header": "Updated" }
73
75
  ],
76
+ "rowActions": [
77
+ {
78
+ "label": "Proceed with plan",
79
+ "confirm": "Dispatch the current unapproved plan as a human override?",
80
+ "showWhenField": "open_plan_escalation_id",
81
+ "action": {
82
+ "path": "/app/api/actions/message",
83
+ "body": {
84
+ "name": "plan-escalation-answered",
85
+ "correlationKey": "{{row.plan_key}}",
86
+ "variables": { "directive": "proceed", "note": "Proceed override from the plans page." }
87
+ }
88
+ }
89
+ }
90
+ ],
74
91
  "detail": {
75
92
  "linkField": "issue_url",
76
93
  "fields": [
77
94
  { "field": "repo", "label": "Repository" },
78
95
  { "field": "issue_number", "label": "Issue number" },
96
+ { "field": "base_branch", "label": "Base branch (blank = repo default)" },
79
97
  { "field": "outcome", "label": "Outcome" },
98
+ { "field": "open_plan_findings", "label": "Open plan-review findings" },
80
99
  { "field": "open_task_question", "label": "Open escalation question" }
81
- ]
100
+ ],
101
+ "form": {
102
+ "showWhenField": "open_plan_escalation_id",
103
+ "title": "Answer the open plan-review escalation",
104
+ "promptField": "open_plan_findings",
105
+ "inputKey": "note",
106
+ "inputLabel": "Revision directive note for the planner",
107
+ "submitLabel": "Revise plan",
108
+ "action": {
109
+ "path": "/app/api/actions/message",
110
+ "body": {
111
+ "name": "plan-escalation-answered",
112
+ "correlationKey": "{{row.plan_key}}",
113
+ "variables": { "directive": "revise", "note": "{{form.note}}" }
114
+ }
115
+ }
116
+ }
82
117
  }
83
118
  }
84
119
  },
@@ -97,12 +132,39 @@
97
132
  "columns": [
98
133
  { "field": "plan_key", "header": "Plan" },
99
134
  { "field": "round", "header": "Round" },
135
+ { "field": "epoch", "header": "Epoch" },
100
136
  { "field": "approved", "header": "Approved? (1/0)" },
101
137
  { "field": "findings", "header": "Reviewer findings" },
102
138
  { "field": "created_at", "header": "Recorded" }
103
139
  ]
104
140
  }
105
141
  },
142
+ {
143
+ "type": "dataGrid",
144
+ "id": "plan-review-escalations",
145
+ "props": {
146
+ "title": "Plan-review escalations",
147
+ "rowKey": "id",
148
+ "refreshMs": 5000,
149
+ "data": {
150
+ "kind": "datasource",
151
+ "source": "app",
152
+ "table": "plan_review_escalations",
153
+ "orderBy": { "field": "id", "dir": "desc" }
154
+ },
155
+ "columns": [
156
+ { "field": "plan_key", "header": "Plan" },
157
+ { "field": "epoch", "header": "Epoch" },
158
+ { "field": "round", "header": "Round" },
159
+ { "field": "findings", "header": "Findings" },
160
+ { "field": "status", "header": "Status" },
161
+ { "field": "directive", "header": "Directive" },
162
+ { "field": "note", "header": "Human note" },
163
+ { "field": "asked_at", "header": "Asked" },
164
+ { "field": "answered_at", "header": "Answered" }
165
+ ]
166
+ }
167
+ },
106
168
  {
107
169
  "type": "dataGrid",
108
170
  "id": "wave-state",
@@ -26,14 +26,26 @@ process with no memory of your last run, the branch name MUST be derivable from
26
26
  (`git ls-remote --heads origin feat/<task.id>` or
27
27
  `gh pr list --head feat/<task.id> --state all`):
28
28
 
29
- - **It does not exist** → this is a first run. Branch off the default branch.
29
+ - **It does not exist** → this is a first run. Branch off the base branch (see
30
+ the note below — usually the repository default branch, but an epic may pin an
31
+ integration branch in your appended task context).
30
32
  - **It exists** → this is a **resume**. `git fetch` and check it out, read its diff
31
33
  and any open (draft) PR, and **continue from there** — do not restart from
32
34
  scratch. Fold in `variables.answer` as the guidance you were waiting on.
33
35
 
36
+ ## Your base branch (default branch, unless the epic pins one)
37
+
38
+ Branch off — and open your PR against — the repository's **default branch**,
39
+ UNLESS your appended task context carries a **"Base branch (authoritative)"**
40
+ note pinning an epic integration branch. When it does, that branch wins
41
+ everywhere below: branch off `origin/<that branch>`, read the epic's latest
42
+ landed state there, and pass `gh pr create --base <that branch>`. A PR opened
43
+ against the wrong base will not be merged into the epic.
44
+
34
45
  ## What to do
35
46
 
36
- 1. Clone / check out the repository's default branch (first run) or your existing
47
+ 1. Clone / check out your base branch (first run — the default branch, or the
48
+ pinned epic branch if your context names one) or your existing
37
49
  `feat/<task.id>` branch (resume — see above).
38
50
  2. Implement `task.prompt`. Keep the change scoped to this slice only.
39
51
  3. Commit (sign off — this repo family enforces DCO: `git commit -s`), push the