@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.
- package/CHANGELOG.md +7 -0
- package/SPEC.md +40 -0
- package/app/plan.test.ts +80 -3
- package/app/plan.ts +118 -8
- package/db/migrations/020_plan_review_escalation.sql +51 -0
- package/nano.app.json +4 -0
- package/openapi.yaml +70 -3
- package/operations/answerPlanEscalation.test.ts +115 -0
- package/operations/answerPlanEscalation.ts +41 -0
- package/operations/postMessage.ts +25 -5
- package/operations/startAndMessage.test.ts +58 -0
- package/package.json +1 -1
- package/pages/epic.page.json +61 -1
- package/prompts/plan.md +11 -0
- package/resources/agent-guide.md +13 -3
- package/resources/processes/plan-fanout.bpmn +191 -109
- package/workers/persist-plan-escalation/worker.test.ts +80 -0
- package/workers/persist-plan-escalation/worker.ts +73 -0
- package/workers/record-plan-review/worker.test.ts +59 -39
- package/workers/record-plan-review/worker.ts +53 -29
|
@@ -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,
|
|
4
|
-
// (issue #25) the implementation-phase (per-task) answer flow
|
|
5
|
-
//
|
|
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 {
|
|
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.
|
|
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",
|
package/pages/epic.page.json
CHANGED
|
@@ -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
|
|
package/resources/agent-guide.md
CHANGED
|
@@ -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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|