@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
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.46.0](https://github.com/nanobpm/nano-workforce/compare/v0.45.0...v0.46.0) (2026-08-12)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* add plan review escalation ([#128](https://github.com/nanobpm/nano-workforce/issues/128)) ([b67cc9e](https://github.com/nanobpm/nano-workforce/commit/b67cc9ebd84ef251e38e1e0fc332f712f82438c2)), closes [owner/repo#N](https://github.com/owner/repo/issues/N)
|
|
7
|
+
|
|
8
|
+
# [0.45.0](https://github.com/nanobpm/nano-workforce/compare/v0.44.1...v0.45.0) (2026-08-12)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **plan:** pin an epic's base branch so the fleet lands on an integration branch ([#125](https://github.com/nanobpm/nano-workforce/issues/125)) ([1c3bfa1](https://github.com/nanobpm/nano-workforce/commit/1c3bfa1a1dbc212e31e510931766f46904dae1a3)), closes [#124](https://github.com/nanobpm/nano-workforce/issues/124) [nanobpm/nano-workforce#124](https://github.com/nanobpm/nano-workforce/issues/124)
|
|
14
|
+
|
|
1
15
|
## [0.44.1](https://github.com/nanobpm/nano-workforce/compare/v0.44.0...v0.44.1) (2026-08-11)
|
|
2
16
|
|
|
3
17
|
|
package/SPEC.md
CHANGED
|
@@ -511,6 +511,44 @@ the loop runs one parallel `implement` MI fan-out per wave:
|
|
|
511
511
|
directive in a sub-issue body, mapping each prerequisite `#M` to `issue-M` in the
|
|
512
512
|
adopted task's `dependsOn` — so a human-declared blocking order survives adoption.
|
|
513
513
|
|
|
514
|
+
### 13.2 Trial-merge integration gate (D3) — issue #69
|
|
515
|
+
|
|
516
|
+
Before a wave's still-open heads land, the fan-out runs a **D3 trial merge** to catch
|
|
517
|
+
**emergent** conflicts: heads that merge cleanly but whose *combination* breaks the
|
|
518
|
+
target repo's suite. `app/trialMerge.ts` classifies the result `clean | merge-conflict
|
|
519
|
+
| suite-failed`; only `suite-failed` escalates (`trialMergeDecision`). Textual
|
|
520
|
+
merge-conflicts are pass-through — D2/D6 own merge-exclusion and merge-train ordering.
|
|
521
|
+
It runs only for `headCount >= 2` on non-mergify repos (`shouldRunTrialMerge`).
|
|
522
|
+
|
|
523
|
+
Flow (`resources/processes/plan-fanout.bpmn`): `gw-trial-needed` → `trial-merge`
|
|
524
|
+
(`senior:trial-merge`) → `record-trial-merge` (audit row in `plan_trial_merges`) →
|
|
525
|
+
`gw-trial` (`trial red?`). On red it persists a plan-level escalation
|
|
526
|
+
(`pr.persist-task-escalation`, task id `trial-merge-wave-<N>`, corrKey
|
|
527
|
+
`<plan_key>:trial-merge-wave-<N>`) and parks at `wait-trial-answer`
|
|
528
|
+
(`feature-escalation-answered`). The operator answers exactly `proceed` to override and
|
|
529
|
+
continue, or anything else to **rerun** the trial after pushing a fix.
|
|
530
|
+
|
|
531
|
+
**Known gap — inherited vs emergent failures (issue #129, PLANNED).** As shipped, D3
|
|
532
|
+
escalates on *any* red combined suite, including a failure that was **already red on
|
|
533
|
+
each head individually** (e.g. a per-PR build defect, or a repo-wide workspace
|
|
534
|
+
build-ordering bug). That parks a human on something that is not an integration
|
|
535
|
+
decision. The target behaviour is an **autonomy ladder**:
|
|
536
|
+
|
|
537
|
+
1. **Shift-left** — a head whose *required* checks are red never enters the trial merge;
|
|
538
|
+
the convergence loop's `senior:fix-ci` path owns per-PR failures. D3 only sees
|
|
539
|
+
individually-green heads.
|
|
540
|
+
2. **Baseline-diff** — the `senior:trial-merge` agent reports, per failing check,
|
|
541
|
+
whether it was green on each head alone; D3 escalates **only** on checks that
|
|
542
|
+
*regress under combination* (green-per-head → red-combined) and attributes inherited
|
|
543
|
+
failures back to the owning head's loop.
|
|
544
|
+
3. **Auto-remediation** — for deterministic, agent-diagnosable classes (build ordering,
|
|
545
|
+
lockfile drift, renamed scripts) a `senior:integration-fix` agent pushes the fix and
|
|
546
|
+
reruns the trial before any human is parked (reusing the escalate→wait→rerun/proceed
|
|
547
|
+
branch from the plan-review escalation, PR #128).
|
|
548
|
+
|
|
549
|
+
A human escalation is then reserved for its one true case: **two slices that each pass
|
|
550
|
+
but encode incompatible decisions about a shared contract** — a genuine design call.
|
|
551
|
+
|
|
514
552
|
## 14. Open questions / future
|
|
515
553
|
|
|
516
554
|
- **Provisioning the existing PR branch** — resolved: the `c8ctl` host-git
|
|
@@ -524,6 +562,8 @@ the loop runs one parallel `implement` MI fan-out per wave:
|
|
|
524
562
|
- **Supervised vs external worker** — the agent runs as an external
|
|
525
563
|
`c8ctl nano work` daemon by default; a supervised in-server mode is possible
|
|
526
564
|
later (ADR 0041 decision).
|
|
565
|
+
- **Autonomous D3** — shift-left + baseline-diff + auto-remediation so the trial-merge
|
|
566
|
+
gate only escalates genuine cross-slice design conflicts (§13.2, issue #129).
|
|
527
567
|
- **Prompt versioning/hash** per PR for auditability.
|
|
528
568
|
- **Auth on the web UI** — the manifest `security` block (ADR 0028) if this is
|
|
529
569
|
exposed beyond localhost.
|
package/app/plan.test.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// planner could revise forever. `positiveIntEnv` must fall back to the default on any value that
|
|
6
6
|
// is not a positive integer, so the loop is always bounded.
|
|
7
7
|
import { test } from "node:test";
|
|
8
|
-
import { assertEquals } from "#test-assert";
|
|
8
|
+
import { assertEquals, assertThrows } from "#test-assert";
|
|
9
9
|
import { positiveIntEnv } from "./plan.ts";
|
|
10
10
|
|
|
11
11
|
const KEY = "NANO_PLAN_REVIEW_ROUNDS_TEST";
|
|
@@ -70,6 +70,10 @@ function memTable(rows: any[], key: string) {
|
|
|
70
70
|
rows.push(r);
|
|
71
71
|
return Promise.resolve(r);
|
|
72
72
|
},
|
|
73
|
+
count: (q: any) =>
|
|
74
|
+
Promise.resolve(
|
|
75
|
+
rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v)).length,
|
|
76
|
+
),
|
|
73
77
|
update: (k: any, patch: any) => {
|
|
74
78
|
const r = rows.find((x) => x[key] === k);
|
|
75
79
|
if (r) Object.assign(r, patch);
|
|
@@ -97,8 +101,8 @@ test("re-plan of a finished issue clears stale plan_reviews rows", async () => {
|
|
|
97
101
|
},
|
|
98
102
|
plan_reviews: {
|
|
99
103
|
rows: [
|
|
100
|
-
{ plan_key: PLAN_KEY, round: 0 },
|
|
101
|
-
{ plan_key: PLAN_KEY, round: 1 },
|
|
104
|
+
{ plan_key: PLAN_KEY, epoch: 0, round: 0 },
|
|
105
|
+
{ plan_key: PLAN_KEY, epoch: 0, round: 1 },
|
|
102
106
|
],
|
|
103
107
|
key: "plan_key",
|
|
104
108
|
},
|
|
@@ -195,7 +199,13 @@ test("re-plan of a finished issue clears stale open escalations and the denormal
|
|
|
195
199
|
// task row, and publishing the correlated resume message — that had no unit coverage. These
|
|
196
200
|
// drive both against the in-memory data layer above and assert the oldest-first surfacing,
|
|
197
201
|
// the answer mirroring, and the published message.
|
|
198
|
-
import {
|
|
202
|
+
import {
|
|
203
|
+
answerPlanEscalation,
|
|
204
|
+
answerTaskEscalation,
|
|
205
|
+
currentPlanReviewEpoch,
|
|
206
|
+
PLAN_ESCALATION_MESSAGE,
|
|
207
|
+
refreshOpenTaskEscalation,
|
|
208
|
+
} from "./plan.ts";
|
|
199
209
|
|
|
200
210
|
function escalationStores(rows: unknown[]): Record<string, { rows: unknown[]; key: string }> {
|
|
201
211
|
return {
|
|
@@ -292,3 +302,188 @@ test("answerTaskEscalation is a no-op when no open escalation matches the correl
|
|
|
292
302
|
const r = await answerTaskEscalation(data, engine, "owner/repo#9:missing", "x");
|
|
293
303
|
assertEquals(r.ok, false);
|
|
294
304
|
});
|
|
305
|
+
|
|
306
|
+
test("currentPlanReviewEpoch counts answered plan-review escalations only", async () => {
|
|
307
|
+
const stores = {
|
|
308
|
+
plan_review_escalations: {
|
|
309
|
+
rows: [
|
|
310
|
+
{ id: 1, plan_key: "owner/repo#10", status: "answered" },
|
|
311
|
+
{ id: 2, plan_key: "owner/repo#10", status: "open" },
|
|
312
|
+
{ id: 3, plan_key: "owner/repo#other", status: "answered" },
|
|
313
|
+
],
|
|
314
|
+
key: "id",
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
assertEquals(await currentPlanReviewEpoch(memData(stores), "owner/repo#10"), 1);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("answerPlanEscalation records directive, clears the plan pointer, and publishes the resume message", async () => {
|
|
321
|
+
const stores = {
|
|
322
|
+
plans: {
|
|
323
|
+
rows: [{
|
|
324
|
+
plan_key: "owner/repo#11",
|
|
325
|
+
open_plan_escalation_id: 7,
|
|
326
|
+
open_plan_findings: "reviewer findings",
|
|
327
|
+
open_plan_round: 2,
|
|
328
|
+
}],
|
|
329
|
+
key: "plan_key",
|
|
330
|
+
},
|
|
331
|
+
plan_review_escalations: {
|
|
332
|
+
rows: [{
|
|
333
|
+
id: 7,
|
|
334
|
+
plan_key: "owner/repo#11",
|
|
335
|
+
epoch: 0,
|
|
336
|
+
round: 2,
|
|
337
|
+
findings: "reviewer findings",
|
|
338
|
+
status: "open",
|
|
339
|
+
directive: null,
|
|
340
|
+
note: null,
|
|
341
|
+
}],
|
|
342
|
+
key: "id",
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
const published: any[] = [];
|
|
346
|
+
const engine = {
|
|
347
|
+
publishMessage: (m: any) => {
|
|
348
|
+
published.push(m);
|
|
349
|
+
return Promise.resolve();
|
|
350
|
+
},
|
|
351
|
+
} as any;
|
|
352
|
+
|
|
353
|
+
const r = await answerPlanEscalation(memData(stores), engine, "owner/repo#11", "revise", "Use issue-1 as seam.");
|
|
354
|
+
assertEquals(r.ok, true);
|
|
355
|
+
assertEquals(r.directive, "revise");
|
|
356
|
+
const esc = stores.plan_review_escalations.rows[0] as any;
|
|
357
|
+
assertEquals(esc.status, "answered");
|
|
358
|
+
assertEquals(esc.directive, "revise");
|
|
359
|
+
assertEquals(esc.note, "Use issue-1 as seam.");
|
|
360
|
+
const plan = stores.plans.rows[0] as any;
|
|
361
|
+
assertEquals(plan.open_plan_escalation_id, null);
|
|
362
|
+
assertEquals(plan.open_plan_findings, null);
|
|
363
|
+
assertEquals(plan.open_plan_round, null);
|
|
364
|
+
assertEquals(published[0].name, PLAN_ESCALATION_MESSAGE);
|
|
365
|
+
assertEquals(published[0].correlationKey, "owner/repo#11");
|
|
366
|
+
assertEquals(published[0].variables.planEscalationDirective, "revise");
|
|
367
|
+
assertEquals(
|
|
368
|
+
String(published[0].variables.planFindings).includes("Use issue-1 as seam."),
|
|
369
|
+
true,
|
|
370
|
+
);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
// Coverage for the epic base-branch control (issue nano-ide #124 / 019_plan_base_branch.sql).
|
|
374
|
+
//
|
|
375
|
+
// A plan may pin a base branch so the fleet branches off — and opens every PR against — a long-lived
|
|
376
|
+
// integration branch instead of the repo default, keeping an epic off the default branch (and off any
|
|
377
|
+
// merge-to-default side effect such as auto-publishing) until the integration branch is deliberately
|
|
378
|
+
// merged. `normalizeBaseBranch` decides "unset" (fall back to default), `renderBaseBranchBrief` is the
|
|
379
|
+
// authoritative prompt override, and `startPlan` must persist the branch and seed BOTH the `baseBranch`
|
|
380
|
+
// variable and the `baseBranchBrief` (which rides `appendPrompt`) — or leave them null when unpinned.
|
|
381
|
+
import { InvalidBaseBranchError, normalizeBaseBranch, renderBaseBranchBrief } from "./plan.ts";
|
|
382
|
+
|
|
383
|
+
test("normalizeBaseBranch: blank/whitespace/undefined → null; a real branch is trimmed", () => {
|
|
384
|
+
assertEquals(normalizeBaseBranch(undefined), null);
|
|
385
|
+
assertEquals(normalizeBaseBranch(null), null);
|
|
386
|
+
assertEquals(normalizeBaseBranch(""), null);
|
|
387
|
+
assertEquals(normalizeBaseBranch(" "), null);
|
|
388
|
+
assertEquals(normalizeBaseBranch(" epic/agent-protocol "), "epic/agent-protocol");
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("normalizeBaseBranch: accepts conservative git-branch shapes", () => {
|
|
392
|
+
assertEquals(normalizeBaseBranch("main"), "main");
|
|
393
|
+
assertEquals(normalizeBaseBranch("release-1.2"), "release-1.2");
|
|
394
|
+
assertEquals(normalizeBaseBranch("feature/x_y.z"), "feature/x_y.z");
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("normalizeBaseBranch: rejects injection-prone / implausible branch names", () => {
|
|
398
|
+
// `baseBranch` is interpolated into an authoritative agent prompt that carries shell
|
|
399
|
+
// commands, so anything that isn't a plausible git ref must be rejected at the edge —
|
|
400
|
+
// not silently rendered into `git`/`gh` snippets or the prompt Markdown.
|
|
401
|
+
const bad = [
|
|
402
|
+
"foo bar", // whitespace
|
|
403
|
+
"-rf", // leading dash → looks like a CLI flag
|
|
404
|
+
"foo; rm -rf /", // shell metacharacters
|
|
405
|
+
"foo`whoami`", // command substitution
|
|
406
|
+
"foo$(id)", // command substitution
|
|
407
|
+
"foo\nbar", // newline → breaks rendered instructions
|
|
408
|
+
"foo..bar", // git-illegal double dot
|
|
409
|
+
"/foo", // leading slash
|
|
410
|
+
"foo/", // trailing slash
|
|
411
|
+
"foo.", // trailing dot
|
|
412
|
+
"foo//bar", // empty path component
|
|
413
|
+
"foo.lock", // git-reserved .lock suffix
|
|
414
|
+
"épée", // outside the conservative allowlist
|
|
415
|
+
];
|
|
416
|
+
for (const value of bad) {
|
|
417
|
+
assertThrows(() => normalizeBaseBranch(value), InvalidBaseBranchError);
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test("renderBaseBranchBrief names the branch in every instruction (branch-off, read, PR base)", () => {
|
|
422
|
+
const brief = renderBaseBranchBrief("epic/agent-protocol");
|
|
423
|
+
// Authoritative marker so it overrides the static "default branch" wording.
|
|
424
|
+
assertEquals(brief.includes("authoritative"), true);
|
|
425
|
+
assertEquals(brief.includes("git checkout -b feat/<task.id> origin/epic/agent-protocol"), true);
|
|
426
|
+
assertEquals(brief.includes("gh pr create --base epic/agent-protocol"), true);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("startPlan pins the base branch: persisted on the row + seeded as baseBranch/baseBranchBrief variables", async () => {
|
|
430
|
+
const PLAN_KEY = "owner/repo#124";
|
|
431
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
432
|
+
plans: { rows: [], key: "plan_key" },
|
|
433
|
+
plan_tasks: { rows: [], key: "id" },
|
|
434
|
+
plan_reviews: { rows: [], key: "plan_key" },
|
|
435
|
+
plan_escalations: { rows: [], key: "id" },
|
|
436
|
+
plan_task_deps: { rows: [], key: "plan_key" },
|
|
437
|
+
};
|
|
438
|
+
const data = memData(stores);
|
|
439
|
+
let seen: any = null;
|
|
440
|
+
const engine = {
|
|
441
|
+
createInstance: (req: any) => {
|
|
442
|
+
seen = req.variables;
|
|
443
|
+
return Promise.resolve({ processInstanceKey: "PI-1" });
|
|
444
|
+
},
|
|
445
|
+
} as any;
|
|
446
|
+
|
|
447
|
+
await startPlan(
|
|
448
|
+
data,
|
|
449
|
+
engine,
|
|
450
|
+
{ repo: "owner/repo", number: 124, url: "https://github.com/owner/repo/issues/124", planKey: PLAN_KEY },
|
|
451
|
+
" epic/agent-protocol ",
|
|
452
|
+
);
|
|
453
|
+
|
|
454
|
+
// Persisted (trimmed) on the plan row for the epic UI + resume.
|
|
455
|
+
assertEquals((stores.plans.rows[0] as any).base_branch, "epic/agent-protocol");
|
|
456
|
+
// Process variables the implement-task consumes.
|
|
457
|
+
assertEquals(seen.baseBranch, "epic/agent-protocol");
|
|
458
|
+
assertEquals(seen.baseBranchBrief.includes("gh pr create --base epic/agent-protocol"), true);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
test("startPlan without a base branch keeps default-branch behaviour (null row + null variables)", async () => {
|
|
462
|
+
const PLAN_KEY = "owner/repo#200";
|
|
463
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
464
|
+
plans: { rows: [], key: "plan_key" },
|
|
465
|
+
plan_tasks: { rows: [], key: "id" },
|
|
466
|
+
plan_reviews: { rows: [], key: "plan_key" },
|
|
467
|
+
plan_escalations: { rows: [], key: "id" },
|
|
468
|
+
plan_task_deps: { rows: [], key: "plan_key" },
|
|
469
|
+
};
|
|
470
|
+
const data = memData(stores);
|
|
471
|
+
let seen: any = null;
|
|
472
|
+
const engine = {
|
|
473
|
+
createInstance: (req: any) => {
|
|
474
|
+
seen = req.variables;
|
|
475
|
+
return Promise.resolve({ processInstanceKey: "PI-2" });
|
|
476
|
+
},
|
|
477
|
+
} as any;
|
|
478
|
+
|
|
479
|
+
await startPlan(data, engine, {
|
|
480
|
+
repo: "owner/repo",
|
|
481
|
+
number: 200,
|
|
482
|
+
url: "https://github.com/owner/repo/issues/200",
|
|
483
|
+
planKey: PLAN_KEY,
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
assertEquals((stores.plans.rows[0] as any).base_branch, null);
|
|
487
|
+
assertEquals(seen.baseBranch, null);
|
|
488
|
+
assertEquals(seen.baseBranchBrief, null);
|
|
489
|
+
});
|
package/app/plan.ts
CHANGED
|
@@ -44,6 +44,13 @@ export interface Plan {
|
|
|
44
44
|
open_task_question: string | null;
|
|
45
45
|
open_task_corr_key: string | null;
|
|
46
46
|
open_task_id: string | null;
|
|
47
|
+
// Denormalised "open plan-review escalation" pointer (# plan-review escalation): when the
|
|
48
|
+
// adversarial plan-review cap is reached without approval, the process parks for a human
|
|
49
|
+
// proceed/revise directive. These fields surface the newest open plan-level escalation on the
|
|
50
|
+
// plans page without overloading the implementation-phase `plan_escalations` table.
|
|
51
|
+
open_plan_escalation_id: number | null;
|
|
52
|
+
open_plan_findings: string | null;
|
|
53
|
+
open_plan_round: number | null;
|
|
47
54
|
// Wave-merge barrier (007_wave_gate.sql): the wave index whose PRs the plan is currently
|
|
48
55
|
// waiting to see MERGED before dispatching the next wave, or null when not parked at the barrier.
|
|
49
56
|
gate_wave: number | null;
|
|
@@ -51,6 +58,10 @@ export interface Plan {
|
|
|
51
58
|
// Minted at plan start; baked into the blackboard URL handed to implementer agents. NULL for
|
|
52
59
|
// plans created before the blackboard shipped.
|
|
53
60
|
blackboard_token: string | null;
|
|
61
|
+
// Optional target base branch (019_plan_base_branch.sql): when set, the fleet branches off this
|
|
62
|
+
// branch and opens every task PR against it instead of the repository's default branch, landing
|
|
63
|
+
// the whole epic on a long-lived integration branch. NULL keeps the default-branch behaviour.
|
|
64
|
+
base_branch: string | null;
|
|
54
65
|
created_at: string;
|
|
55
66
|
updated_at: string;
|
|
56
67
|
}
|
|
@@ -111,6 +122,10 @@ export const planEscalations = (data: DataLayer) =>
|
|
|
111
122
|
* subscription correlates on `<plan_key>:<task_id>` (see plan-fanout.bpmn). */
|
|
112
123
|
export const FEATURE_ESCALATION_MESSAGE = "feature-escalation-answered";
|
|
113
124
|
|
|
125
|
+
/** The message the plan-fanout process catches to resume a plan-review escalation; its
|
|
126
|
+
* subscription correlates on `<plan_key>` (see plan-fanout.bpmn). */
|
|
127
|
+
export const PLAN_ESCALATION_MESSAGE = "plan-escalation-answered";
|
|
128
|
+
|
|
114
129
|
/** Build the per-task message correlation key the process parks on. */
|
|
115
130
|
export const featureCorrKey = (planKey: string, taskId: string) => `${planKey}:${taskId}`;
|
|
116
131
|
|
|
@@ -132,6 +147,7 @@ export const planTaskDeps = (data: DataLayer) =>
|
|
|
132
147
|
* (crash/timeout after the insert) reuses its row instead of appending a duplicate round. */
|
|
133
148
|
export interface PlanReview {
|
|
134
149
|
plan_key: string;
|
|
150
|
+
epoch: number;
|
|
135
151
|
round: number;
|
|
136
152
|
approved: number;
|
|
137
153
|
findings: string | null;
|
|
@@ -140,6 +156,31 @@ export interface PlanReview {
|
|
|
140
156
|
}
|
|
141
157
|
export const planReviews = (data: DataLayer) => data.table<PlanReview>("plan_reviews", "plan_key");
|
|
142
158
|
|
|
159
|
+
export type PlanEscalationDirective = "proceed" | "revise";
|
|
160
|
+
|
|
161
|
+
export function parsePlanEscalationDirective(input: unknown): PlanEscalationDirective | null {
|
|
162
|
+
const s = typeof input === "string" ? input.trim().toLowerCase() : "";
|
|
163
|
+
return s === "proceed" || s === "revise" ? s : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** One plan-review cap escalation. Kept in a dedicated table rather than overloading
|
|
167
|
+
* `plan_escalations`: the latter is task-scoped (`task_id`/`corr_key` are NOT NULL and mirrored
|
|
168
|
+
* onto `plan_tasks`), while this row is plan-scoped and drives the review epoch reset. */
|
|
169
|
+
export interface PlanReviewEscalation {
|
|
170
|
+
id: number;
|
|
171
|
+
plan_key: string;
|
|
172
|
+
epoch: number;
|
|
173
|
+
round: number;
|
|
174
|
+
findings: string | null;
|
|
175
|
+
status: string;
|
|
176
|
+
directive: PlanEscalationDirective | null;
|
|
177
|
+
note: string | null;
|
|
178
|
+
asked_at: string;
|
|
179
|
+
answered_at: string | null;
|
|
180
|
+
}
|
|
181
|
+
export const planReviewEscalations = (data: DataLayer) =>
|
|
182
|
+
data.table<PlanReviewEscalation>("plan_review_escalations", "id");
|
|
183
|
+
|
|
143
184
|
/** Read a positive-integer env override, falling back when unset/blank/invalid. A bad value
|
|
144
185
|
* (e.g. "", "abc", "0", "2.5") must NOT silently become `NaN`/`0` — that would make the round
|
|
145
186
|
* cap `round + 1 >= cap` always false and allow an unbounded revise loop. */
|
|
@@ -150,11 +191,17 @@ export function positiveIntEnv(name: string, fallback: number): number {
|
|
|
150
191
|
return Number.isInteger(n) && n > 0 ? n : fallback;
|
|
151
192
|
}
|
|
152
193
|
|
|
153
|
-
/** Max adversarial plan-review rounds. Reaching the cap WITHOUT approval
|
|
154
|
-
* fan-out
|
|
155
|
-
* #86).
|
|
194
|
+
/** Max adversarial plan-review rounds per epoch. Reaching the cap WITHOUT approval parks the
|
|
195
|
+
* fan-out on a human plan-review escalation rather than dispatching an un-approved plan (issue
|
|
196
|
+
* #86). A human `revise` answer starts a fresh epoch, so the next plan gets a full new budget. */
|
|
156
197
|
export const MAX_PLAN_REVIEW_ROUNDS = positiveIntEnv("NANO_PLAN_REVIEW_ROUNDS", 3);
|
|
157
198
|
|
|
199
|
+
/** The current review epoch is derived from the append-only escalation log: every answered
|
|
200
|
+
* plan-review escalation represents a human decision to leave the prior budget behind. */
|
|
201
|
+
export async function currentPlanReviewEpoch(data: DataLayer, planKey: string): Promise<number> {
|
|
202
|
+
return await planReviewEscalations(data).count({ plan_key: planKey, status: "answered" });
|
|
203
|
+
}
|
|
204
|
+
|
|
158
205
|
/** A plan is "done" in exactly these states; everything else (planning, dispatched)
|
|
159
206
|
* is in flight. The cancel guard and the active view key off this. */
|
|
160
207
|
export const PLAN_TERMINAL_STATUSES: readonly string[] = ["done", "failed", "abandoned"];
|
|
@@ -185,14 +232,79 @@ export function parseIssue(input: string): ParsedIssue | null {
|
|
|
185
232
|
return null;
|
|
186
233
|
}
|
|
187
234
|
|
|
235
|
+
/** Raised when a caller supplies a `baseBranch` that isn't a plausible git branch name. The
|
|
236
|
+
* value is interpolated into the authoritative implementer prompt (which carries `git`/`gh`
|
|
237
|
+
* shell snippets and inline-code Markdown), so a non-ref value could break the rendered
|
|
238
|
+
* instructions or smuggle in a command/prompt fragment — reject it at the edge instead. */
|
|
239
|
+
export class InvalidBaseBranchError extends Error {
|
|
240
|
+
readonly value: string;
|
|
241
|
+
constructor(value: string) {
|
|
242
|
+
super(`invalid base branch name: ${JSON.stringify(value)}`);
|
|
243
|
+
this.name = "InvalidBaseBranchError";
|
|
244
|
+
this.value = value;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Conservative allowlist gate for a base-branch name. Stricter than `git check-ref-format` on
|
|
249
|
+
* purpose: only `[A-Za-z0-9._/-]`, no leading `/`/`.`/`-` (a leading dash reads as a CLI flag),
|
|
250
|
+
* no trailing `/`/`.`, no `..`/`//`, no empty or `.lock`-suffixed path component, bounded length.
|
|
251
|
+
* This rejects whitespace, shell metacharacters, command substitution, and newlines outright. */
|
|
252
|
+
function isPlausibleBranchName(s: string): boolean {
|
|
253
|
+
if (s.length === 0 || s.length > 255) return false;
|
|
254
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(s)) return false;
|
|
255
|
+
if (/^[/.-]/.test(s) || /[/.]$/.test(s)) return false;
|
|
256
|
+
if (s.includes("..") || s.includes("//")) return false;
|
|
257
|
+
return s.split("/").every((seg) => seg.length > 0 && !seg.startsWith(".") && !seg.endsWith(".lock"));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Normalise a caller-supplied base branch: trim, and treat blank as "unset" (null) so the fleet
|
|
261
|
+
* falls back to the repository's default branch — the legacy behaviour. A non-blank value that is
|
|
262
|
+
* not a plausible git branch name is rejected (`InvalidBaseBranchError`) rather than persisted or
|
|
263
|
+
* rendered into the agent prompt; the operation edge maps that to a 400. */
|
|
264
|
+
export function normalizeBaseBranch(input: string | null | undefined): string | null {
|
|
265
|
+
const s = (input ?? "").trim();
|
|
266
|
+
if (s.length === 0) return null;
|
|
267
|
+
if (!isPlausibleBranchName(s)) throw new InvalidBaseBranchError(s);
|
|
268
|
+
return s;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** The per-instance brief appended to an implementer agent's prompt when the plan pins a base
|
|
272
|
+
* branch. It is authoritative over the static "branch off the default branch" wording in
|
|
273
|
+
* prompts/feature.md, so the agent branches off — and opens its PR against — the integration
|
|
274
|
+
* branch, and reads the epic's latest landed state there rather than the repo default branch. */
|
|
275
|
+
export function renderBaseBranchBrief(baseBranch: string): string {
|
|
276
|
+
return [
|
|
277
|
+
"",
|
|
278
|
+
"",
|
|
279
|
+
"---",
|
|
280
|
+
"",
|
|
281
|
+
`**Base branch (authoritative — overrides any "default branch" instruction above): \`${baseBranch}\`.**`,
|
|
282
|
+
"",
|
|
283
|
+
`This epic lands on \`${baseBranch}\`, NOT the repository default branch. Everywhere the`,
|
|
284
|
+
"instructions say \"default branch\", use this branch instead:",
|
|
285
|
+
"",
|
|
286
|
+
`- Branch off it: \`git fetch origin ${baseBranch} && git checkout -b feat/<task.id> origin/${baseBranch}\`.`,
|
|
287
|
+
`- Read the epic's latest landed state from \`${baseBranch}\` (your prerequisites merged there, not into the default branch).`,
|
|
288
|
+
`- Open your PR against it: \`gh pr create --base ${baseBranch} ...\`.`,
|
|
289
|
+
"",
|
|
290
|
+
"Do not target the repository default branch — a PR opened against it will not be merged into the epic.",
|
|
291
|
+
].join("\n");
|
|
292
|
+
}
|
|
293
|
+
|
|
188
294
|
/** Register a plan row (if new) and start the plan-fanout process. Idempotent on
|
|
189
295
|
* planKey: a plan already in flight is not restarted. */
|
|
190
|
-
export async function startPlan(
|
|
296
|
+
export async function startPlan(
|
|
297
|
+
data: DataLayer,
|
|
298
|
+
engine: EngineClient,
|
|
299
|
+
parsed: ParsedIssue,
|
|
300
|
+
baseBranch: string | null = null,
|
|
301
|
+
) {
|
|
191
302
|
const table = plans(data);
|
|
192
303
|
const existing = await table.get(parsed.planKey);
|
|
193
304
|
if (existing && !PLAN_TERMINAL_STATUSES.includes(existing.status)) {
|
|
194
305
|
return { planKey: parsed.planKey, alreadyRunning: true };
|
|
195
306
|
}
|
|
307
|
+
const base = normalizeBaseBranch(baseBranch);
|
|
196
308
|
const ts = now();
|
|
197
309
|
// Mint (or reuse, on a re-plan) this plan's blackboard capability token, and render the
|
|
198
310
|
// coordination brief that carries its concrete URL. The token is the credential; agents reach
|
|
@@ -210,14 +322,18 @@ export async function startPlan(data: DataLayer, engine: EngineClient, parsed: P
|
|
|
210
322
|
// Clear them here — the table is keyed on `plan_key`, so one delete drops the
|
|
211
323
|
// whole set (mirrors how record-plan clears `plan_task_deps`).
|
|
212
324
|
await planReviews(data).delete(parsed.planKey);
|
|
213
|
-
// Same class of stale-row bug for
|
|
214
|
-
//
|
|
215
|
-
// the
|
|
216
|
-
//
|
|
217
|
-
// re-surfaces a question for a `task_id` we just deleted from `plan_tasks`.
|
|
325
|
+
// Same class of stale-row bug for escalation state: task escalations are keyed on `id` (not
|
|
326
|
+
// `plan_key`), so drop the prior run's rows one-by-one. Otherwise a still-"open" escalation
|
|
327
|
+
// from the previous run survives the re-plan and `refreshOpenTaskEscalation` re-surfaces a
|
|
328
|
+
// question for a `task_id` we just deleted from `plan_tasks`.
|
|
218
329
|
for (const e of await planEscalations(data).find({ plan_key: parsed.planKey })) {
|
|
219
330
|
await planEscalations(data).delete(e.id);
|
|
220
331
|
}
|
|
332
|
+
// Plan-review escalations are also keyed on `id` because they are an audit trail; clear them
|
|
333
|
+
// on a fresh submission so the epoch derived from answered escalations resets to 0.
|
|
334
|
+
for (const e of await planReviewEscalations(data).find({ plan_key: parsed.planKey })) {
|
|
335
|
+
await planReviewEscalations(data).delete(e.id);
|
|
336
|
+
}
|
|
221
337
|
// Same for the structured impl-change deltas (D5, #55): keyed on `id`, so drop the prior run's
|
|
222
338
|
// rows one-by-one, otherwise a stale delta lingers in the epic report for a task we just deleted.
|
|
223
339
|
await clearTaskDeltas(data, parsed.planKey);
|
|
@@ -234,7 +350,11 @@ export async function startPlan(data: DataLayer, engine: EngineClient, parsed: P
|
|
|
234
350
|
open_task_question: null,
|
|
235
351
|
open_task_corr_key: null,
|
|
236
352
|
open_task_id: null,
|
|
353
|
+
open_plan_escalation_id: null,
|
|
354
|
+
open_plan_findings: null,
|
|
355
|
+
open_plan_round: null,
|
|
237
356
|
blackboard_token: token,
|
|
357
|
+
base_branch: base,
|
|
238
358
|
updated_at: ts,
|
|
239
359
|
});
|
|
240
360
|
} else {
|
|
@@ -246,6 +366,7 @@ export async function startPlan(data: DataLayer, engine: EngineClient, parsed: P
|
|
|
246
366
|
status: "planning",
|
|
247
367
|
task_count: 0,
|
|
248
368
|
blackboard_token: token,
|
|
369
|
+
base_branch: base,
|
|
249
370
|
created_at: ts,
|
|
250
371
|
updated_at: ts,
|
|
251
372
|
});
|
|
@@ -265,6 +386,12 @@ export async function startPlan(data: DataLayer, engine: EngineClient, parsed: P
|
|
|
265
386
|
// out-of-band.
|
|
266
387
|
blackboardUrl: bbUrl,
|
|
267
388
|
blackboardBrief: renderCoordinationBrief(bbUrl),
|
|
389
|
+
// Optional epic base branch (019_plan_base_branch.sql): the branch the fleet branches off and
|
|
390
|
+
// opens every PR against instead of the repo default. `baseBranchBrief` rides `appendPrompt`
|
|
391
|
+
// in the implement-task (like `blackboardBrief`); both are null when no base branch is pinned,
|
|
392
|
+
// so the agent keeps the default-branch behaviour from prompts/feature.md.
|
|
393
|
+
baseBranch: base,
|
|
394
|
+
baseBranchBrief: base == null ? null : renderBaseBranchBrief(base),
|
|
268
395
|
},
|
|
269
396
|
});
|
|
270
397
|
const processKey = processInstanceKey == null ? null : String(processInstanceKey);
|
|
@@ -321,3 +448,63 @@ export async function answerTaskEscalation(
|
|
|
321
448
|
await refreshOpenTaskEscalation(data, open.plan_key);
|
|
322
449
|
return { ok: true, escalationId: open.id, planKey: open.plan_key, taskId: open.task_id };
|
|
323
450
|
}
|
|
451
|
+
|
|
452
|
+
export function normalizePlanEscalationDirective(input: unknown): PlanEscalationDirective {
|
|
453
|
+
return parsePlanEscalationDirective(input) ?? "revise";
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function renderPlanEscalationFindings(open: PlanReviewEscalation, note: string): string {
|
|
457
|
+
const parts = [
|
|
458
|
+
`Plan review reached its round budget at epoch ${open.epoch}, round ${open.round}.`,
|
|
459
|
+
"",
|
|
460
|
+
"Reviewer findings:",
|
|
461
|
+
(open.findings ?? "").trim() || "(no reviewer findings were provided.)",
|
|
462
|
+
];
|
|
463
|
+
if (note) {
|
|
464
|
+
parts.push("", "Human guidance:", note);
|
|
465
|
+
} else {
|
|
466
|
+
parts.push("", "Human directive: revise the plan within the allowed task boundaries.");
|
|
467
|
+
}
|
|
468
|
+
return parts.join("\n");
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Answer the newest open plan-review escalation. `proceed` is an explicit human override that lets
|
|
472
|
+
* the current (unapproved) plan continue to wave dispatch; `revise` (the default) folds the human
|
|
473
|
+
* note into `planFindings` and starts a fresh review epoch on the next planner pass. */
|
|
474
|
+
export async function answerPlanEscalation(
|
|
475
|
+
data: DataLayer,
|
|
476
|
+
engine: EngineClient,
|
|
477
|
+
planKey: string,
|
|
478
|
+
directiveInput: unknown,
|
|
479
|
+
noteInput: unknown,
|
|
480
|
+
) {
|
|
481
|
+
const open = (await planReviewEscalations(data).find({ plan_key: planKey, status: "open" }))
|
|
482
|
+
.sort((a, b) => b.id - a.id)[0];
|
|
483
|
+
if (!open) return { ok: false, reason: "no open plan escalation" };
|
|
484
|
+
|
|
485
|
+
const directive = normalizePlanEscalationDirective(directiveInput);
|
|
486
|
+
const note = typeof noteInput === "string" ? noteInput.trim() : "";
|
|
487
|
+
const ts = now();
|
|
488
|
+
await planReviewEscalations(data).update(open.id, {
|
|
489
|
+
directive,
|
|
490
|
+
note: note || null,
|
|
491
|
+
status: "answered",
|
|
492
|
+
answered_at: ts,
|
|
493
|
+
});
|
|
494
|
+
await plans(data).update(planKey, {
|
|
495
|
+
open_plan_escalation_id: null,
|
|
496
|
+
open_plan_findings: null,
|
|
497
|
+
open_plan_round: null,
|
|
498
|
+
updated_at: ts,
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
await engine.publishMessage({
|
|
502
|
+
name: PLAN_ESCALATION_MESSAGE,
|
|
503
|
+
correlationKey: planKey,
|
|
504
|
+
variables: {
|
|
505
|
+
planEscalationDirective: directive,
|
|
506
|
+
planFindings: directive === "revise" ? renderPlanEscalationFindings(open, note) : "",
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
return { ok: true, escalationId: open.id, planKey, directive };
|
|
510
|
+
}
|
package/app/service.test.ts
CHANGED
|
@@ -347,3 +347,39 @@ test("repoEnvelopeVars emits the repository envelope keyed on the PR head branch
|
|
|
347
347
|
test("repoEnvelopeVars emits nothing when the head branch is unresolved", () => {
|
|
348
348
|
assertEquals(Object.keys(repoEnvelopeVars("owner/repo", null)).length, 0);
|
|
349
349
|
});
|
|
350
|
+
|
|
351
|
+
test("repoEnvelopeVars emits nothing for a malformed repo (not owner/repo)", () => {
|
|
352
|
+
// Defence in depth: a repo that isn't exactly `owner/repo` would build a bogus clone URL, so the
|
|
353
|
+
// helper emits no envelope (harness falls back to the launch dir) rather than a malformed URL.
|
|
354
|
+
for (const bad of [
|
|
355
|
+
"",
|
|
356
|
+
"noslash",
|
|
357
|
+
"a/b/c",
|
|
358
|
+
"owner /repo",
|
|
359
|
+
"owner/re po",
|
|
360
|
+
"/repo",
|
|
361
|
+
"owner/",
|
|
362
|
+
// A trailing `.git` would build a double-suffixed clone URL (…/owner/repo.git.git).
|
|
363
|
+
"owner/repo.git",
|
|
364
|
+
"owner/repo.GIT",
|
|
365
|
+
// Query/fragment/host-injection characters must never reach the clone URL.
|
|
366
|
+
"owner/repo?x",
|
|
367
|
+
"owner/repo#frag",
|
|
368
|
+
"owner/repo:x",
|
|
369
|
+
"owner/re~po",
|
|
370
|
+
// Owner is a GitHub login: no dots or underscores allowed there.
|
|
371
|
+
"own.er/repo",
|
|
372
|
+
"own_er/repo",
|
|
373
|
+
]) {
|
|
374
|
+
assertEquals(Object.keys(repoEnvelopeVars(bad, "feat/x")).length, 0, `expected no envelope for "${bad}"`);
|
|
375
|
+
}
|
|
376
|
+
// Well-formed repos still emit (guard is not over-eager): hyphens, dots and underscores
|
|
377
|
+
// are legal in the repo-name segment, mixed case is preserved.
|
|
378
|
+
for (const good of ["owner/repo", "my-org/my.repo", "Owner123/Repo_2", "a-b/c-d"]) {
|
|
379
|
+
assertEquals(
|
|
380
|
+
((repoEnvelopeVars(good, "feat/x") as any)["io.nanobpm.agentTask"].repository.url),
|
|
381
|
+
`https://github.com/${good}.git`,
|
|
382
|
+
`expected envelope for "${good}"`,
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
});
|
package/app/service.ts
CHANGED
|
@@ -292,6 +292,14 @@ const AGENT_TASK_NS = "io.nanobpm.agentTask";
|
|
|
292
292
|
* `task.prompt` header on the service task deep-merges with this over the same namespace. */
|
|
293
293
|
export function repoEnvelopeVars(repo: string, ref: string | null): Record<string, unknown> {
|
|
294
294
|
if (!ref) return {};
|
|
295
|
+
// Defence in depth: every current caller derives `repo` from parsePr/parseIssue (regex-bounded to
|
|
296
|
+
// `owner/repo`), but this is an exported helper the fan-out epic gives many new callers. A repo
|
|
297
|
+
// that is not exactly `owner/repo` would build a bogus clone URL, so emit nothing (the harness
|
|
298
|
+
// then falls back to the launch-dir behaviour) rather than handing the harness a malformed URL.
|
|
299
|
+
// The owner is a GitHub login (alphanumeric + hyphen); the repo-name segment additionally allows
|
|
300
|
+
// `.` and `_`. A trailing `.git` is rejected outright so we never emit a double-suffixed
|
|
301
|
+
// `…/owner/repo.git.git`, and the anchored allowlist bars query/fragment/host-injection chars.
|
|
302
|
+
if (!/^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/.test(repo) || /\.git$/i.test(repo)) return {};
|
|
295
303
|
return {
|
|
296
304
|
[AGENT_TASK_NS]: {
|
|
297
305
|
repository: { provider: "github", url: `https://github.com/${repo}.git`, ref },
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
-- Per-plan target base branch (epic base-branch control). When set, the fleet branches off this
|
|
2
|
+
-- branch and opens every task PR against it instead of the repository's default branch, so an
|
|
3
|
+
-- entire epic can land on a long-lived integration branch (e.g. `epic/agent-protocol`) and reach
|
|
4
|
+
-- the default branch — and any merge-to-default side effect such as auto-publishing a package —
|
|
5
|
+
-- only when the integration branch is deliberately merged. NULL keeps the legacy behaviour (the
|
|
6
|
+
-- repo default branch), so pre-migration plans are unaffected.
|
|
7
|
+
|
|
8
|
+
ALTER TABLE plans ADD COLUMN base_branch TEXT;
|