@nanobpm/nano-workforce 0.45.0 → 0.46.1

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;
@@ -0,0 +1,44 @@
1
+ -- Durable "needs attention" resolution for the trial-merge audit log (issue: the
2
+ -- epic page's "Needs attention" tab never cleared).
3
+ --
4
+ -- `plan_trial_merges` is an append-only audit trail: a re-run after a suite
5
+ -- failure INSERTs a fresh row but never supersedes the old red one, so a
6
+ -- `merge-conflict`/`suite-failed` row stayed in "Needs attention" forever even
7
+ -- after the escalation was answered and the wave re-run clean. Add an explicit
8
+ -- `resolved` flag so the page can hide history, and backfill it for existing
9
+ -- rows. Going forward `recordTrialMergeAudit` marks prior same-(plan,wave) rows
10
+ -- resolved on each new insert (supersede-on-insert).
11
+ --
12
+ -- NB: the migration runner wraps each file in its own transaction — this file
13
+ -- must NOT contain BEGIN/COMMIT.
14
+
15
+ ALTER TABLE plan_trial_merges ADD COLUMN resolved INTEGER NOT NULL DEFAULT 0;
16
+
17
+ -- Backfill 1 (supersede): any audit row that has a NEWER row (higher id) for the
18
+ -- same (plan_key, wave) is superseded history — resolve it. The newest row per
19
+ -- wave stays unresolved so a still-red latest attempt keeps showing.
20
+ UPDATE plan_trial_merges
21
+ SET resolved = 1
22
+ WHERE EXISTS (
23
+ SELECT 1 FROM plan_trial_merges AS newer
24
+ WHERE newer.plan_key = plan_trial_merges.plan_key
25
+ AND newer.wave = plan_trial_merges.wave
26
+ AND newer.id > plan_trial_merges.id
27
+ );
28
+
29
+ -- Backfill 2 (answered): a red (needs-attention) row whose trial escalation has
30
+ -- already been answered is resolved — even if no re-run row was ever recorded
31
+ -- (e.g. the operator answered "proceed"/override). The trial escalation's
32
+ -- task_id is 'trial-merge-wave-<wave>' (see app/trialMerge.ts trialMergeTaskId).
33
+ UPDATE plan_trial_merges
34
+ SET resolved = 1
35
+ WHERE result IN ('merge-conflict', 'suite-failed')
36
+ AND EXISTS (
37
+ SELECT 1 FROM plan_escalations AS e
38
+ WHERE e.plan_key = plan_trial_merges.plan_key
39
+ AND e.task_id = 'trial-merge-wave-' || plan_trial_merges.wave
40
+ AND e.status = 'answered'
41
+ );
42
+
43
+ CREATE INDEX IF NOT EXISTS idx_plan_trial_merges_attention
44
+ ON plan_trial_merges(plan_key, resolved, result);
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
@@ -345,6 +345,27 @@ components:
345
345
  type: string
346
346
  minLength: 1
347
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`).
348
369
  BlackboardEntry:
349
370
  type: object
350
371
  additionalProperties: false
@@ -578,9 +599,9 @@ paths:
578
599
  /actions/message:
579
600
  post:
580
601
  operationId: postMessage
581
- summary: Publish a message / answer an escalation. For escalation-answered and
582
- feature-escalation-answered names, runs the corresponding answer flow; otherwise a plain
583
- 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.
584
605
  requestBody:
585
606
  required: true
586
607
  content:
@@ -603,6 +624,13 @@ paths:
603
624
  properties:
604
625
  answer:
605
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
606
634
  responses:
607
635
  "200":
608
636
  description: The message was published (or the escalation answered).
@@ -661,6 +689,45 @@ paths:
661
689
  application/json:
662
690
  schema:
663
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"
664
731
  /hooks/blackboard:
665
732
  get:
666
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,
@@ -162,3 +162,61 @@ test("postMessage → 400 when escalation-answered lacks a correlationKey", asyn
162
162
  assertEquals(r.status, 400);
163
163
  assertEquals(r.body.error, "correlationKey is required");
164
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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.45.0",
3
+ "version": "0.46.1",
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",
@@ -70,8 +70,24 @@
70
70
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
71
71
  { "field": "task_count", "header": "Tasks" },
72
72
  { "field": "open_task_id", "header": "Open escalation" },
73
+ { "field": "open_plan_round", "header": "Plan escalation round" },
73
74
  { "field": "updated_at", "header": "Updated" }
74
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
+ ],
75
91
  "detail": {
76
92
  "linkField": "issue_url",
77
93
  "fields": [
@@ -79,8 +95,25 @@
79
95
  { "field": "issue_number", "label": "Issue number" },
80
96
  { "field": "base_branch", "label": "Base branch (blank = repo default)" },
81
97
  { "field": "outcome", "label": "Outcome" },
98
+ { "field": "open_plan_findings", "label": "Open plan-review findings" },
82
99
  { "field": "open_task_question", "label": "Open escalation question" }
83
- ]
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
+ }
84
117
  }
85
118
  }
86
119
  },
@@ -99,12 +132,39 @@
99
132
  "columns": [
100
133
  { "field": "plan_key", "header": "Plan" },
101
134
  { "field": "round", "header": "Round" },
135
+ { "field": "epoch", "header": "Epoch" },
102
136
  { "field": "approved", "header": "Approved? (1/0)" },
103
137
  { "field": "findings", "header": "Reviewer findings" },
104
138
  { "field": "created_at", "header": "Recorded" }
105
139
  ]
106
140
  }
107
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
+ },
108
168
  {
109
169
  "type": "dataGrid",
110
170
  "id": "wave-state",
@@ -213,12 +273,18 @@
213
273
  "source": "app",
214
274
  "table": "plan_trial_merges",
215
275
  "orderBy": { "field": "created_at", "dir": "desc" },
216
- "filter": [{ "field": "result", "in": ["merge-conflict", "suite-failed"] }]
276
+ "filter": [
277
+ { "field": "result", "in": ["merge-conflict", "suite-failed"] },
278
+ { "field": "resolved", "in": [0] }
279
+ ]
217
280
  },
218
281
  "tabs": [
219
282
  {
220
283
  "label": "Needs attention",
221
- "filter": [{ "field": "result", "in": ["merge-conflict", "suite-failed"] }]
284
+ "filter": [
285
+ { "field": "result", "in": ["merge-conflict", "suite-failed"] },
286
+ { "field": "resolved", "in": [0] }
287
+ ]
222
288
  },
223
289
  {
224
290
  "label": "Clean",
package/prompts/plan.md CHANGED
@@ -36,6 +36,17 @@ Before decomposing anything yourself, check whether the issue is an **epic that
36
36
  has already been split into sub-issues**. If it has, **do not invent a new
37
37
  breakdown** — adopt the existing one, one task per sub-issue. This keeps the
38
38
  fan-out faithful to the human's plan and links each PR back to its sub-issue.
39
+ On the first pass this means adopting the open sub-issue set faithfully. On a
40
+ rejected review, however, still keep the task set 1:1 with those open
41
+ sub-issues while you revise each task's `prompt` and `dependsOn` to satisfy the
42
+ findings. You MAY and SHOULD add a contract/seam deliverable to one existing
43
+ sub-issue task's prompt (for example, a shared-surface registration seam), point
44
+ sibling tasks at it, and create wave-0 ordering via an existing sub-issue — but
45
+ never add, merge, re-split, or remove tasks, and never edit the GitHub issues. If
46
+ a finding genuinely requires changing the sub-issue boundary (splitting,
47
+ merging, adding, or removing a sub-issue) and cannot be expressed as a
48
+ `prompt`/`dependsOn` revision, say that it requires a human decomposition change,
49
+ then re-emit the best in-boundary plan you can.
39
50
 
40
51
  Detect existing children two ways (try both; union the results, de-duplicated):
41
52
 
@@ -151,9 +151,19 @@ curl -sS -X POST __BASE__/hooks/feature-answer \
151
151
 
152
152
  If `NANO_PR_WEBHOOK_SECRET` is set on the deployment, add `-H "x-hook-secret: <secret>"`.
153
153
 
154
- Guidance for the human you assist: read the escalation `question` first (it is the
155
- exact blocker text the agent surfaced), decide the smallest unblocking answer, and
156
- answer it precisely the answer becomes the agent's next-round context.
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:
157
+
158
+ ```bash
159
+ curl -sS -X POST __BASE__/hooks/plan-answer \
160
+ -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." }'
162
+ ```
163
+
164
+ Guidance for the human you assist: read the escalation `question` or plan-review
165
+ `findings` first, decide the smallest unblocking answer, and answer it precisely —
166
+ the answer becomes the agent's next-round context.
157
167
 
158
168
  ---
159
169