@nanobpm/nano-workforce 0.45.0 → 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,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.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",
@@ -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",
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