@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.
- package/CHANGELOG.md +14 -0
- package/SPEC.md +40 -0
- package/app/plan.test.ts +199 -4
- package/app/plan.ts +196 -9
- package/app/service.test.ts +36 -0
- package/app/service.ts +8 -0
- package/db/migrations/019_plan_base_branch.sql +8 -0
- package/db/migrations/020_plan_review_escalation.sql +51 -0
- package/nano.app.json +4 -0
- package/openapi.yaml +84 -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 +70 -0
- package/operations/startPlanFanout.ts +22 -2
- package/package.json +1 -1
- package/pages/epic.page.json +64 -2
- package/prompts/feature.md +14 -2
- package/prompts/plan.md +11 -0
- package/prompts/review-round.md +13 -0
- package/resources/agent-guide.md +13 -3
- package/resources/processes/plan-fanout.bpmn +192 -110
- 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,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
|
|
568
|
-
feature-escalation-answered names, runs the corresponding
|
|
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,
|
|
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,
|
|
@@ -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
|
-
|
|
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.
|
|
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
|
@@ -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",
|
package/prompts/feature.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|