@nanobpm/nano-workforce 0.28.0 → 0.30.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/app/blackboard.test.ts +17 -0
- package/app/blackboard.ts +8 -4
- package/app/retro.test.ts +353 -0
- package/app/retro.ts +281 -0
- package/db/migrations/016_plan_retro.sql +38 -0
- package/deno.json +1 -1
- package/deno.lock +5 -5
- package/nano.app.json +8 -0
- package/package.json +2 -2
- package/pages/epic.page.json +12 -0
- package/pages/home.page.json +12 -0
- package/prompts/retro.md +86 -0
- package/resources/processes/retro.bpmn +83 -0
- package/workers/finalize/worker.ts +9 -0
- package/workers/mark-merged/worker.ts +6 -0
- package/workers/retro-gather/worker.test.ts +78 -0
- package/workers/retro-gather/worker.ts +28 -0
- package/workers/retro-record/worker.test.ts +127 -0
- package/workers/retro-record/worker.ts +60 -0
package/app/retro.ts
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// Epic retrospective — the post-completion reflection stage (016_plan_retro.sql).
|
|
2
|
+
//
|
|
3
|
+
// The blackboard's `learning` kind (app/blackboard.ts) lets implementer agents share reusable
|
|
4
|
+
// gotchas *while they work*. This module closes the loop: when an epic finishes, a retro agent
|
|
5
|
+
// distils those learnings (plus task deltas and escalations) and promotes the recurring ones into
|
|
6
|
+
// the target repo's AGENTS.md / a script / a CI step, via a human-reviewed PR.
|
|
7
|
+
//
|
|
8
|
+
// "Epic finished" is emergent, not a single BPMN node: `plan-fanout` only DISPATCHES the fleet
|
|
9
|
+
// (it marks the plan `done` at dispatch time), after which each PR lands asynchronously on its own
|
|
10
|
+
// `merge-loop`. So the true completion signal is *the last of a plan's PRs reaching a terminal
|
|
11
|
+
// state*. `maybeStartRetro` is called from the two terminal points — `pr.mark-merged` (auto-merge)
|
|
12
|
+
// and `pr.finalize`'s review-only `converged` path — and fires the retro exactly once.
|
|
13
|
+
//
|
|
14
|
+
// Data access goes through the record gateway (`data.table`), never hand-written SQL — matching
|
|
15
|
+
// app/plan.ts, app/blackboard.ts, and app/taskDelta.ts.
|
|
16
|
+
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
17
|
+
import { planTasks } from "./plan.ts";
|
|
18
|
+
import { isUniqueViolation, readBlackboard } from "./blackboard.ts";
|
|
19
|
+
import { aggregateEpicDeltas } from "./taskDelta.ts";
|
|
20
|
+
import { TERMINAL_STATUSES } from "./service.ts";
|
|
21
|
+
|
|
22
|
+
export const RETRO_PROCESS_ID = "retro";
|
|
23
|
+
|
|
24
|
+
/** Opt-out env toggle. Retro runs by default; set NANO_AUTO_RETRO=0/false to disable (e.g. in a
|
|
25
|
+
* review-only deployment that doesn't want the fleet opening promotion PRs). */
|
|
26
|
+
export function autoRetroEnabled(): boolean {
|
|
27
|
+
const v = (globalThis as { process?: { env?: Record<string, string | undefined> } })
|
|
28
|
+
.process?.env?.NANO_AUTO_RETRO;
|
|
29
|
+
if (v == null) return true;
|
|
30
|
+
const s = v.trim().toLowerCase();
|
|
31
|
+
return s !== "0" && s !== "false" && s !== "off" && s !== "no";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const now = () => new Date().toISOString();
|
|
35
|
+
|
|
36
|
+
/** A PR is in a terminal state (derived from app/service.ts TERMINAL_STATUSES, the single source of
|
|
37
|
+
* truth). A plan is settled only once every PR-producing task has reached one of these. */
|
|
38
|
+
const TERMINAL_PR_STATUSES = new Set(TERMINAL_STATUSES);
|
|
39
|
+
|
|
40
|
+
/** Task statuses that are settled WITHOUT a landed PR: the planner/dispatcher decided not to (or
|
|
41
|
+
* could not) produce one, so they never block epic completion. `escalated`/`waiting-for-lane` are
|
|
42
|
+
* still in flight; `pending`/`opened` are checked against their PR. */
|
|
43
|
+
const SETTLED_TASKLESS = new Set(["skipped", "blocked"]);
|
|
44
|
+
|
|
45
|
+
interface PlanRow extends Record<string, unknown> {
|
|
46
|
+
plan_key: string;
|
|
47
|
+
repo: string;
|
|
48
|
+
issue_url: string;
|
|
49
|
+
title: string | null;
|
|
50
|
+
status: string;
|
|
51
|
+
retro_started_at?: string | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const plansTbl = (data: DataLayer) => data.table<PlanRow>("plans", "plan_key");
|
|
55
|
+
const prsTbl = (data: DataLayer) =>
|
|
56
|
+
data.table<{ pr_key: string; status: string }>("pull_requests", "pr_key");
|
|
57
|
+
const retroStartsTbl = (data: DataLayer) =>
|
|
58
|
+
data.table<{ plan_key: string; started_at: string }>("plan_retro_starts", "plan_key");
|
|
59
|
+
|
|
60
|
+
/** Resolve the plan a PR belongs to, or undefined when the PR was submitted standalone (not part
|
|
61
|
+
* of a fan-out). A PR is linked to a plan via the `plan_tasks.pr_key` it produced. */
|
|
62
|
+
export async function planKeyForPr(data: DataLayer, prKey: string): Promise<string | undefined> {
|
|
63
|
+
if (!prKey) return undefined;
|
|
64
|
+
const row = await planTasks(data).findOne({ pr_key: prKey });
|
|
65
|
+
return row?.plan_key;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Is every one of a plan's tasks settled? Settled = skipped/blocked (never produced a landing
|
|
69
|
+
* PR), or a task whose PR has reached a terminal state. A `pending`/`escalated`/`waiting-for-lane`
|
|
70
|
+
* task, or an `opened` task whose PR is still in flight, means the epic is not done yet. */
|
|
71
|
+
export async function isPlanComplete(data: DataLayer, planKey: string): Promise<boolean> {
|
|
72
|
+
const tasks = await planTasks(data).find({ plan_key: planKey });
|
|
73
|
+
if (tasks.length === 0) return false; // an empty plan has nothing to retrospect
|
|
74
|
+
for (const t of tasks) {
|
|
75
|
+
if (SETTLED_TASKLESS.has(t.status)) continue;
|
|
76
|
+
// Any task that is meant to yield a PR must have a terminal PR to be settled.
|
|
77
|
+
if (!t.pr_key) return false; // pending/escalated/etc. with no PR yet → still in flight
|
|
78
|
+
const pr = await prsTbl(data).get(t.pr_key);
|
|
79
|
+
if (!pr || !TERMINAL_PR_STATUSES.has(pr.status)) return false;
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The material a retro reflects on, assembled from a plan's advisory knowledge. */
|
|
85
|
+
export interface RetroDigest {
|
|
86
|
+
planKey: string;
|
|
87
|
+
repo: string;
|
|
88
|
+
issueUrl: string;
|
|
89
|
+
title: string | null;
|
|
90
|
+
learnings: { author_task: string; body: string; created_at: string }[];
|
|
91
|
+
touchedFiles: string[];
|
|
92
|
+
contractChanges: { taskId: string; change: string }[];
|
|
93
|
+
constraints: { taskId: string; constraint: string }[];
|
|
94
|
+
notes: { author_task: string; kind: string; body: string }[];
|
|
95
|
+
counts: { learnings: number; deltas: number; notes: number };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Gather a plan's reflection material: the `learning` blackboard entries (the headline), plus the
|
|
99
|
+
* task-delta rollup (contract changes, discovered constraints, cross-slice file touches) and any
|
|
100
|
+
* other non-learning blackboard notes for colour. Reads only — no writes. */
|
|
101
|
+
export async function gatherRetro(data: DataLayer, planKey: string): Promise<RetroDigest> {
|
|
102
|
+
const plan = await plansTbl(data).get(planKey);
|
|
103
|
+
const entries = await readBlackboard(data, planKey);
|
|
104
|
+
const learnings = entries
|
|
105
|
+
.filter((e) => e.kind === "learning")
|
|
106
|
+
.map((e) => ({ author_task: e.author_task, body: e.body, created_at: e.created_at }));
|
|
107
|
+
const notes = entries
|
|
108
|
+
.filter((e) => e.kind !== "learning")
|
|
109
|
+
.map((e) => ({ author_task: e.author_task, kind: e.kind, body: e.body }));
|
|
110
|
+
const deltas = await aggregateEpicDeltas(data, planKey);
|
|
111
|
+
return {
|
|
112
|
+
planKey,
|
|
113
|
+
repo: plan?.repo ?? planKey.split("#")[0] ?? "",
|
|
114
|
+
issueUrl: plan?.issue_url ?? "",
|
|
115
|
+
title: plan?.title ?? null,
|
|
116
|
+
learnings,
|
|
117
|
+
touchedFiles: deltas.touchedFiles,
|
|
118
|
+
contractChanges: deltas.contractChanges,
|
|
119
|
+
constraints: deltas.constraints,
|
|
120
|
+
notes,
|
|
121
|
+
counts: {
|
|
122
|
+
learnings: learnings.length,
|
|
123
|
+
deltas: deltas.deltas.length,
|
|
124
|
+
notes: notes.length,
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Render the digest as the compact markdown brief handed to the retro agent (rides `appendPrompt`,
|
|
130
|
+
* concatenated after the base `{{retro}}` prompt — so it owns its own leading separator). */
|
|
131
|
+
export function renderRetroBrief(d: RetroDigest): string {
|
|
132
|
+
const lines: string[] = [
|
|
133
|
+
"",
|
|
134
|
+
"",
|
|
135
|
+
"---",
|
|
136
|
+
"",
|
|
137
|
+
`## Retro input — epic ${d.planKey}`,
|
|
138
|
+
"",
|
|
139
|
+
`Target repo: **${d.repo}**${d.issueUrl ? ` · issue: ${d.issueUrl}` : ""}`,
|
|
140
|
+
d.title ? `Epic: ${d.title}` : "",
|
|
141
|
+
"",
|
|
142
|
+
`### Learnings agents posted while implementing (${d.learnings.length})`,
|
|
143
|
+
];
|
|
144
|
+
if (d.learnings.length === 0) {
|
|
145
|
+
lines.push("_(none — agents posted no `learning` entries for this epic)_");
|
|
146
|
+
} else {
|
|
147
|
+
for (const l of d.learnings) lines.push(`- **[${l.author_task}]** ${l.body}`);
|
|
148
|
+
}
|
|
149
|
+
if (d.constraints.length > 0) {
|
|
150
|
+
lines.push("", `### Constraints discovered (${d.constraints.length})`);
|
|
151
|
+
for (const c of d.constraints) lines.push(`- **[${c.taskId}]** ${c.constraint}`);
|
|
152
|
+
}
|
|
153
|
+
if (d.contractChanges.length > 0) {
|
|
154
|
+
lines.push("", `### Contract changes (${d.contractChanges.length})`);
|
|
155
|
+
for (const c of d.contractChanges) lines.push(`- **[${c.taskId}]** ${c.change}`);
|
|
156
|
+
}
|
|
157
|
+
if (d.touchedFiles.length > 0) {
|
|
158
|
+
lines.push("", `### Files touched beyond original slices`, d.touchedFiles.map((f) => `\`${f}\``).join(", "));
|
|
159
|
+
}
|
|
160
|
+
if (d.notes.length > 0) {
|
|
161
|
+
lines.push("", `### Other blackboard notes (${d.notes.length})`);
|
|
162
|
+
for (const n of d.notes) lines.push(`- **[${n.author_task}]** _${n.kind}_: ${n.body}`);
|
|
163
|
+
}
|
|
164
|
+
return lines.join("\n");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** True when a digest carries nothing worth an agent run — no learnings, no deltas, no notes. */
|
|
168
|
+
export function isDigestEmpty(d: RetroDigest): boolean {
|
|
169
|
+
return d.counts.learnings === 0 && d.counts.deltas === 0 && d.counts.notes === 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The persisted retro shape (written by pr.retro-record). */
|
|
173
|
+
export interface RetroInput {
|
|
174
|
+
status: string; // filed | skipped | blocked
|
|
175
|
+
prKey?: string | null;
|
|
176
|
+
learnings?: number;
|
|
177
|
+
summary?: string | null;
|
|
178
|
+
report?: string | null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const retrosTbl = (data: DataLayer) =>
|
|
182
|
+
data.table<{ plan_key: string } & Record<string, unknown>>("plan_retros", "plan_key");
|
|
183
|
+
|
|
184
|
+
async function claimRetroStart(data: DataLayer, planKey: string): Promise<boolean> {
|
|
185
|
+
try {
|
|
186
|
+
await retroStartsTbl(data).insert({ plan_key: planKey, started_at: now() });
|
|
187
|
+
return true;
|
|
188
|
+
} catch (err) {
|
|
189
|
+
// Only a UNIQUE/PK collision means "another starter already elected itself" — a benign
|
|
190
|
+
// duplicate the fire-once guard exists to detect. Any other constraint (e.g. a FOREIGN KEY
|
|
191
|
+
// failure from a missing plan row) is real corruption and must propagate, not be swallowed.
|
|
192
|
+
if (isUniqueViolation(err)) return false;
|
|
193
|
+
throw err;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Upsert a plan's retro row (idempotent on plan_key, so a job retry overwrites in place).
|
|
198
|
+
*
|
|
199
|
+
* Insert-first, then fall back to update only on a verified unique/PK violation: a get-then-insert
|
|
200
|
+
* can race (two concurrent retries both see no row, then one insert wins and the other throws), so
|
|
201
|
+
* we treat "row already exists" as the update path rather than an error. */
|
|
202
|
+
export async function recordRetro(
|
|
203
|
+
data: DataLayer,
|
|
204
|
+
planKey: string,
|
|
205
|
+
input: RetroInput,
|
|
206
|
+
): Promise<void> {
|
|
207
|
+
const ts = now();
|
|
208
|
+
const fields = {
|
|
209
|
+
status: input.status,
|
|
210
|
+
pr_key: input.prKey ?? null,
|
|
211
|
+
learnings: input.learnings ?? 0,
|
|
212
|
+
summary: input.summary ?? null,
|
|
213
|
+
report: input.report ?? null,
|
|
214
|
+
updated_at: ts,
|
|
215
|
+
};
|
|
216
|
+
try {
|
|
217
|
+
await retrosTbl(data).insert({ plan_key: planKey, created_at: ts, ...fields });
|
|
218
|
+
} catch (err) {
|
|
219
|
+
// Fall back to update only on a verified UNIQUE/PK violation (the get-then-insert race, or a
|
|
220
|
+
// job retry). Restrict to unique/duplicate/primary-key: a FOREIGN KEY (or other) constraint
|
|
221
|
+
// failure would otherwise be swallowed here, making the write look successful while doing
|
|
222
|
+
// nothing — so rethrow anything that isn't a duplicate-row collision.
|
|
223
|
+
if (!isUniqueViolation(err)) throw err;
|
|
224
|
+
await retrosTbl(data).update(planKey, fields);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Called from a PR's terminal point (mark-merged / finalize review-only). If that PR was the last
|
|
229
|
+
* of its plan to land AND there is anything to reflect on, start the `retro` process exactly once.
|
|
230
|
+
*
|
|
231
|
+
* Best-effort and non-blocking: any failure here must never fail the terminal job that called it —
|
|
232
|
+
* the retro is advisory. The fire-once guard is `plan_retro_starts`: a PRIMARY KEY insert
|
|
233
|
+
* atomically elects one starter across app processes before we stamp `plans.retro_started_at`
|
|
234
|
+
* and start the instance. */
|
|
235
|
+
export async function maybeStartRetro(
|
|
236
|
+
data: DataLayer,
|
|
237
|
+
engine: EngineClient,
|
|
238
|
+
prKey: string,
|
|
239
|
+
log?: (level: "info" | "warn" | "error", msg: string, meta?: Record<string, unknown>) => void,
|
|
240
|
+
): Promise<{ started: boolean; planKey?: string; reason?: string }> {
|
|
241
|
+
if (!autoRetroEnabled()) return { started: false, reason: "disabled" };
|
|
242
|
+
try {
|
|
243
|
+
const planKey = await planKeyForPr(data, prKey);
|
|
244
|
+
if (!planKey) return { started: false, reason: "no-plan" };
|
|
245
|
+
|
|
246
|
+
const plan = await plansTbl(data).get(planKey);
|
|
247
|
+
if (!plan) return { started: false, reason: "no-plan" };
|
|
248
|
+
if (plan.retro_started_at) return { started: false, planKey, reason: "already-started" };
|
|
249
|
+
|
|
250
|
+
if (!(await isPlanComplete(data, planKey))) return { started: false, planKey, reason: "incomplete" };
|
|
251
|
+
|
|
252
|
+
const digest = await gatherRetro(data, planKey);
|
|
253
|
+
if (isDigestEmpty(digest)) {
|
|
254
|
+
if (!(await claimRetroStart(data, planKey))) return { started: false, planKey, reason: "already-started" };
|
|
255
|
+
// Nothing to reflect on — stamp anyway so we don't re-check on every future terminal PR of a
|
|
256
|
+
// (now settled) plan, and record a skipped retro for visibility.
|
|
257
|
+
await plansTbl(data).update(planKey, { retro_started_at: now(), updated_at: now() });
|
|
258
|
+
await recordRetro(data, planKey, { status: "skipped", summary: "No learnings, deltas, or notes to retrospect." });
|
|
259
|
+
return { started: false, planKey, reason: "nothing-to-retro" };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (!(await claimRetroStart(data, planKey))) return { started: false, planKey, reason: "already-started" };
|
|
263
|
+
|
|
264
|
+
// Stamp before starting so restarts/retries can take the cheap already-started path.
|
|
265
|
+
await plansTbl(data).update(planKey, { retro_started_at: now(), updated_at: now() });
|
|
266
|
+
|
|
267
|
+
const { processInstanceKey } = await engine.createInstance({
|
|
268
|
+
processDefinitionId: RETRO_PROCESS_ID,
|
|
269
|
+
variables: {
|
|
270
|
+
planKey,
|
|
271
|
+
repo: digest.repo,
|
|
272
|
+
issueUrl: digest.issueUrl,
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
log?.("info", `retro: started for epic ${planKey}`, { processInstanceKey, learnings: digest.counts.learnings });
|
|
276
|
+
return { started: true, planKey };
|
|
277
|
+
} catch (err) {
|
|
278
|
+
log?.("error", `retro: could not start for PR ${prKey}`, { err: String(err) });
|
|
279
|
+
return { started: false, reason: "error" };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
-- Epic retrospective — the post-completion reflection stage (blackboard `learning` follow-up).
|
|
2
|
+
--
|
|
3
|
+
-- An epic (a `plans` row) is dispatched by `plan-fanout` and its slices land asynchronously,
|
|
4
|
+
-- each PR riding its own `merge-loop`. "Epic complete" is therefore emergent: it is the moment
|
|
5
|
+
-- the LAST of a plan's PRs reaches a terminal state (merged / converged / abandoned). When that
|
|
6
|
+
-- happens, `maybeStartRetro` (app/retro.ts) starts one `retro` process instance for the plan.
|
|
7
|
+
--
|
|
8
|
+
-- The retro agent (`senior:retro`) reads the plan's accumulated coordination knowledge — the
|
|
9
|
+
-- `learning` blackboard entries agents posted while implementing, plus their task deltas and
|
|
10
|
+
-- escalations — clusters and ranks it, and opens a PR against the TARGET repo promoting the
|
|
11
|
+
-- recurring gotchas into that repo's AGENTS.md / a script / a CI step (human-reviewed, never
|
|
12
|
+
-- auto-committed). The report it produces is recorded here.
|
|
13
|
+
|
|
14
|
+
-- Fire-once guard. Set (to an ISO timestamp) the instant `maybeStartRetro` starts the retro
|
|
15
|
+
-- process for this plan, so a second PR of the same plan reaching terminal state near-simultaneously
|
|
16
|
+
-- cannot start a duplicate retro. NULL = no retro has been started for this plan.
|
|
17
|
+
ALTER TABLE plans ADD COLUMN retro_started_at TEXT;
|
|
18
|
+
|
|
19
|
+
-- Atomic start election for multi-process deployments. `maybeStartRetro` first inserts here; the
|
|
20
|
+
-- PRIMARY KEY lets exactly one worker claim a plan before it stamps `plans.retro_started_at` and
|
|
21
|
+
-- starts the BPMN instance.
|
|
22
|
+
CREATE TABLE plan_retro_starts (
|
|
23
|
+
plan_key TEXT PRIMARY KEY REFERENCES plans(plan_key),
|
|
24
|
+
started_at TEXT NOT NULL
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
-- One row per epic retrospective. Written by `pr.retro-record` from the `senior:retro` agent's
|
|
28
|
+
-- result. Advisory knowledge, like the blackboard it distils — it gates no control flow.
|
|
29
|
+
CREATE TABLE plan_retros (
|
|
30
|
+
plan_key TEXT PRIMARY KEY REFERENCES plans(plan_key),
|
|
31
|
+
status TEXT NOT NULL, -- filed | skipped | blocked (the agent's result status)
|
|
32
|
+
pr_key TEXT, -- the promotion PR the agent opened on the target repo ("<owner>/<repo>#<n>"), or NULL
|
|
33
|
+
learnings INTEGER NOT NULL DEFAULT 0, -- raw count of `learning` blackboard entries included in the retro digest
|
|
34
|
+
summary TEXT, -- the agent's human-readable retro summary
|
|
35
|
+
report TEXT, -- the full retro report / transcript (nullable)
|
|
36
|
+
created_at TEXT NOT NULL,
|
|
37
|
+
updated_at TEXT NOT NULL
|
|
38
|
+
);
|
package/deno.json
CHANGED
package/deno.lock
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"specifiers": {
|
|
4
4
|
"jsr:@std/assert@1": "1.0.19",
|
|
5
5
|
"jsr:@std/internal@^1.0.12": "1.0.14",
|
|
6
|
-
"npm:@nanobpm/urban@0.
|
|
6
|
+
"npm:@nanobpm/urban@0.28": "0.28.0",
|
|
7
7
|
"npm:@semantic-release/changelog@^6.0.3": "6.0.3_semantic-release@24.2.9__typescript@5.9.3_typescript@5.9.3",
|
|
8
8
|
"npm:@semantic-release/git@^10.0.1": "10.0.1_semantic-release@24.2.9__typescript@5.9.3_typescript@5.9.3",
|
|
9
9
|
"npm:@semantic-release/npm@^13.1.5": "13.1.5_semantic-release@24.2.9__typescript@5.9.3",
|
|
@@ -82,8 +82,8 @@
|
|
|
82
82
|
"ws"
|
|
83
83
|
]
|
|
84
84
|
},
|
|
85
|
-
"@nanobpm/urban@0.
|
|
86
|
-
"integrity": "sha512-
|
|
85
|
+
"@nanobpm/urban@0.28.0": {
|
|
86
|
+
"integrity": "sha512-rhzgOV+Vb1CPuNoVHVX3OtxjHqzSp2gJIV2pi3EBi245ZjDRN0gSoPGd/MEnJdufxgWwDKuvhyljRDHE8T957w==",
|
|
87
87
|
"dependencies": [
|
|
88
88
|
"@nanobpm/nano-app-schema",
|
|
89
89
|
"@nanobpm/nano-sdk",
|
|
@@ -1757,11 +1757,11 @@
|
|
|
1757
1757
|
},
|
|
1758
1758
|
"workspace": {
|
|
1759
1759
|
"dependencies": [
|
|
1760
|
-
"npm:@nanobpm/urban@0.
|
|
1760
|
+
"npm:@nanobpm/urban@0.28"
|
|
1761
1761
|
],
|
|
1762
1762
|
"packageJson": {
|
|
1763
1763
|
"dependencies": [
|
|
1764
|
-
"npm:@nanobpm/urban@0.
|
|
1764
|
+
"npm:@nanobpm/urban@0.28",
|
|
1765
1765
|
"npm:@semantic-release/changelog@^6.0.3",
|
|
1766
1766
|
"npm:@semantic-release/git@^10.0.1",
|
|
1767
1767
|
"npm:@semantic-release/npm@^13.1.5",
|
package/nano.app.json
CHANGED
|
@@ -74,6 +74,14 @@
|
|
|
74
74
|
{
|
|
75
75
|
"taskType": "pr.persist-task-escalation",
|
|
76
76
|
"handler": "workers/persist-task-escalation/worker.ts"
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"taskType": "pr.retro-gather",
|
|
80
|
+
"handler": "workers/retro-gather/worker.ts"
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"taskType": "pr.retro-record",
|
|
84
|
+
"handler": "workers/retro-record/worker.ts"
|
|
77
85
|
}
|
|
78
86
|
],
|
|
79
87
|
"surfaces": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.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",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"test": "deno test -A"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@nanobpm/urban": "^0.
|
|
43
|
+
"@nanobpm/urban": "^0.28.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@semantic-release/changelog": "^6.0.3",
|
package/pages/epic.page.json
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
"schemaVersion": "1.0",
|
|
3
3
|
"title": "Epic Coordination",
|
|
4
4
|
"nodes": [
|
|
5
|
+
{
|
|
6
|
+
"type": "nav",
|
|
7
|
+
"id": "nav",
|
|
8
|
+
"props": {
|
|
9
|
+
"variant": "bar",
|
|
10
|
+
"title": "Nano Workforce",
|
|
11
|
+
"items": [
|
|
12
|
+
{ "label": "Convergence", "page": "home" },
|
|
13
|
+
{ "label": "Epic", "page": "epic" }
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
},
|
|
5
17
|
{
|
|
6
18
|
"type": "text",
|
|
7
19
|
"id": "title",
|
package/pages/home.page.json
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
"schemaVersion": "1.0",
|
|
3
3
|
"title": "PR Review Convergence",
|
|
4
4
|
"nodes": [
|
|
5
|
+
{
|
|
6
|
+
"type": "nav",
|
|
7
|
+
"id": "nav",
|
|
8
|
+
"props": {
|
|
9
|
+
"variant": "bar",
|
|
10
|
+
"title": "Nano Workforce",
|
|
11
|
+
"items": [
|
|
12
|
+
{ "label": "Convergence", "page": "home" },
|
|
13
|
+
{ "label": "Epic", "page": "epic" }
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
},
|
|
5
17
|
{
|
|
6
18
|
"type": "text",
|
|
7
19
|
"id": "title",
|
package/prompts/retro.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Retro agent — distil an epic's learnings and promote the recurring ones
|
|
2
|
+
|
|
3
|
+
You are the **retrospective agent**. An epic (a fan-out `plan`) has finished — every one of its
|
|
4
|
+
slices has landed (merged, converged, or abandoned). While the fleet worked, its agents shared
|
|
5
|
+
reusable gotchas on a coordination **blackboard** as `learning` entries ("regenerate the API
|
|
6
|
+
surface before building", "nextest, not `cargo test`, for the console suite", …). Your job is to
|
|
7
|
+
turn that scattered, per-agent knowledge into a **durable improvement** to the target repository,
|
|
8
|
+
so the *next* fleet — and human contributors — never re-learn it the hard way.
|
|
9
|
+
|
|
10
|
+
You are the mechanism that lifts a lesson from "a thing one agent happened to hit" to "a thing the
|
|
11
|
+
repo now tells everyone up front."
|
|
12
|
+
|
|
13
|
+
## Input
|
|
14
|
+
|
|
15
|
+
The job payload (stdin JSON) carries:
|
|
16
|
+
|
|
17
|
+
- `variables.planKey` — the epic's key, e.g. `owner/repo#123`.
|
|
18
|
+
- `variables.repo` — the **target repo** `owner/repo` you will open a promotion PR against.
|
|
19
|
+
- `variables.issueUrl` — the epic's source issue, for context (`gh issue view`).
|
|
20
|
+
- **`variables.retroDigest`** — appended to this prompt below the `---` separator: the epic's
|
|
21
|
+
accumulated knowledge already gathered for you — the `learning` entries agents posted, the
|
|
22
|
+
constraints and contract changes they discovered, and the files they touched beyond their
|
|
23
|
+
original slices. **This is your primary material.** You do not need to reconstruct it.
|
|
24
|
+
|
|
25
|
+
You have `gh` / git authenticated for the target repository.
|
|
26
|
+
|
|
27
|
+
## What to do
|
|
28
|
+
|
|
29
|
+
1. **Read the digest** (below the separator). Also read prior retros for cross-epic recurrence:
|
|
30
|
+
look for an `AGENTS.md` "Learnings" / "Gotchas" section already in the repo, and skim recent
|
|
31
|
+
merged PRs titled like `retro:` — a lesson that keeps recurring across epics is the highest-
|
|
32
|
+
value promotion.
|
|
33
|
+
2. **Cluster and dedupe.** Group the raw learnings into distinct lessons. A lesson mentioned by
|
|
34
|
+
several agents, or one that also appears in a prior retro, ranks highest. Drop one-offs that are
|
|
35
|
+
genuinely specific to a single slice and won't recur.
|
|
36
|
+
3. **Rank by recurrence × severity.** Promote the lessons that are both *reusable* (a future agent
|
|
37
|
+
or contributor would hit them) and *costly* (they broke a build, wasted a wave, or caused a
|
|
38
|
+
merge collision). A single well-placed line beats an exhaustive dump.
|
|
39
|
+
4. **Choose the right home for each promoted lesson** — the whole point is to make the knowledge
|
|
40
|
+
*load-bearing*, not just written down:
|
|
41
|
+
- **`AGENTS.md`** (or `CONTRIBUTING.md`) — a convention, a "before you build, run X", a
|
|
42
|
+
non-obvious constraint. The default home.
|
|
43
|
+
- **A script** — if the lesson is "always run these steps in this order", encode it as a
|
|
44
|
+
script (or a `make`/`npm`/`deno task` target) so it can't be forgotten.
|
|
45
|
+
- **A CI step** — if the lesson is "this class of mistake should never merge", add a guard/gate
|
|
46
|
+
so CI catches it mechanically. Prefer this for anything a machine can check.
|
|
47
|
+
Pick the *most enforceable* home a lesson supports: CI gate > script > doc.
|
|
48
|
+
5. **Open ONE pull request** against `variables.repo` with `gh pr create`, collecting your
|
|
49
|
+
promotions. Keep it small and reviewable — this is a **human-reviewed** PR; you propose, a human
|
|
50
|
+
decides. Sign off (DCO: `git commit -s`). Link the epic issue. Title it `retro: <short summary>`.
|
|
51
|
+
Do **not** request Copilot review yourself and do **not** merge it.
|
|
52
|
+
6. Clean up any scratch clone/worktree you created.
|
|
53
|
+
|
|
54
|
+
## When there's nothing worth promoting
|
|
55
|
+
|
|
56
|
+
If, after clustering, no lesson is durable enough to justify a change to the repo — the learnings
|
|
57
|
+
were all slice-specific noise, or already documented — **do not manufacture a PR**. Emit
|
|
58
|
+
`status: "skipped"` with a one-line reason. A retro that correctly files nothing is a success, not
|
|
59
|
+
a failure; a low-signal PR that wastes a human's review is the bad outcome.
|
|
60
|
+
|
|
61
|
+
## Output contract
|
|
62
|
+
|
|
63
|
+
Write a JSON object of **result variables** to the file named by the `AGENT_RESULT_FILE`
|
|
64
|
+
environment variable:
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"status": "filed",
|
|
69
|
+
"summary": "Promoted 3 lessons: regen-before-build (AGENTS.md), nextest gate (CI), migration-order note (AGENTS.md).",
|
|
70
|
+
"pr": "owner/repo#789"
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Rules:
|
|
75
|
+
|
|
76
|
+
- `status` — one of:
|
|
77
|
+
- `filed` — you opened a promotion PR. Set `pr`.
|
|
78
|
+
- `skipped` — nothing durable enough to promote. Explain in `summary`; omit `pr`.
|
|
79
|
+
- `blocked` — you could not proceed (e.g. no write access to the target repo). Explain in
|
|
80
|
+
`summary`; omit `pr`.
|
|
81
|
+
- `pr` — the promotion PR as `owner/repo#<number>` (or its URL), for `filed`. Omit / null it
|
|
82
|
+
otherwise.
|
|
83
|
+
- `summary` — a short human-readable result naming the lessons you promoted (or why you skipped).
|
|
84
|
+
|
|
85
|
+
You are advisory: you never block a fleet, and every change you propose is a human's to accept.
|
|
86
|
+
Promote what will genuinely save the next contributor time; leave the rest.
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:zeebe="http://camunda.org/schema/zeebe/1.0" xmlns:nano="https://nanobpm.io/schema/shapes/1.0" id="Definitions_nano_workforce_retro" targetNamespace="http://nanobpm.io/nano-workforce">
|
|
3
|
+
<bpmn:process id="retro" name="Epic Retrospective" isExecutable="true">
|
|
4
|
+
<bpmn:startEvent id="Start" name="Epic complete">
|
|
5
|
+
<bpmn:outgoing>f_start</bpmn:outgoing>
|
|
6
|
+
</bpmn:startEvent>
|
|
7
|
+
<bpmn:serviceTask id="gather" name="Gather learnings">
|
|
8
|
+
<bpmn:extensionElements>
|
|
9
|
+
<zeebe:taskDefinition type="pr.retro-gather" />
|
|
10
|
+
</bpmn:extensionElements>
|
|
11
|
+
<bpmn:incoming>f_start</bpmn:incoming>
|
|
12
|
+
<bpmn:outgoing>f_toSynthesize</bpmn:outgoing>
|
|
13
|
+
</bpmn:serviceTask>
|
|
14
|
+
<bpmn:serviceTask id="synthesize" name="Synthesize & promote (agent)">
|
|
15
|
+
<bpmn:extensionElements>
|
|
16
|
+
<zeebe:taskDefinition type="senior:retro" />
|
|
17
|
+
<zeebe:taskHeaders>
|
|
18
|
+
<zeebe:header key="io.nanobpm.agentTask.task.prompt" value="{{retro}}" />
|
|
19
|
+
</zeebe:taskHeaders>
|
|
20
|
+
<zeebe:ioMapping>
|
|
21
|
+
<zeebe:input source="=retroDigest" target="appendPrompt" />
|
|
22
|
+
</zeebe:ioMapping>
|
|
23
|
+
</bpmn:extensionElements>
|
|
24
|
+
<bpmn:incoming>f_toSynthesize</bpmn:incoming>
|
|
25
|
+
<bpmn:outgoing>f_toRecord</bpmn:outgoing>
|
|
26
|
+
</bpmn:serviceTask>
|
|
27
|
+
<bpmn:serviceTask id="record" name="Record retro">
|
|
28
|
+
<bpmn:extensionElements>
|
|
29
|
+
<zeebe:taskDefinition type="pr.retro-record" />
|
|
30
|
+
</bpmn:extensionElements>
|
|
31
|
+
<bpmn:incoming>f_toRecord</bpmn:incoming>
|
|
32
|
+
<bpmn:outgoing>f_toEnd</bpmn:outgoing>
|
|
33
|
+
</bpmn:serviceTask>
|
|
34
|
+
<bpmn:endEvent id="End" name="Retro filed">
|
|
35
|
+
<bpmn:incoming>f_toEnd</bpmn:incoming>
|
|
36
|
+
</bpmn:endEvent>
|
|
37
|
+
<bpmn:sequenceFlow id="f_start" sourceRef="Start" targetRef="gather" />
|
|
38
|
+
<bpmn:sequenceFlow id="f_toSynthesize" sourceRef="gather" targetRef="synthesize" />
|
|
39
|
+
<bpmn:sequenceFlow id="f_toRecord" sourceRef="synthesize" targetRef="record" />
|
|
40
|
+
<bpmn:sequenceFlow id="f_toEnd" sourceRef="record" targetRef="End" />
|
|
41
|
+
</bpmn:process>
|
|
42
|
+
<bpmndi:BPMNDiagram id="BPMNDiagram_retro">
|
|
43
|
+
<bpmndi:BPMNPlane id="BPMNPlane_retro" bpmnElement="retro">
|
|
44
|
+
<bpmndi:BPMNShape id="BPMNShape_Start" bpmnElement="Start">
|
|
45
|
+
<dc:Bounds x="80" y="102" width="36" height="36" />
|
|
46
|
+
<bpmndi:BPMNLabel>
|
|
47
|
+
<dc:Bounds x="67" y="143" width="63" height="28" />
|
|
48
|
+
</bpmndi:BPMNLabel>
|
|
49
|
+
</bpmndi:BPMNShape>
|
|
50
|
+
<bpmndi:BPMNShape id="BPMNShape_gather" bpmnElement="gather">
|
|
51
|
+
<dc:Bounds x="216" y="80" width="100" height="80" />
|
|
52
|
+
</bpmndi:BPMNShape>
|
|
53
|
+
<bpmndi:BPMNShape id="BPMNShape_synthesize" bpmnElement="synthesize">
|
|
54
|
+
<dc:Bounds x="416" y="80" width="100" height="80" />
|
|
55
|
+
</bpmndi:BPMNShape>
|
|
56
|
+
<bpmndi:BPMNShape id="BPMNShape_record" bpmnElement="record">
|
|
57
|
+
<dc:Bounds x="616" y="80" width="100" height="80" />
|
|
58
|
+
</bpmndi:BPMNShape>
|
|
59
|
+
<bpmndi:BPMNShape id="BPMNShape_End" bpmnElement="End">
|
|
60
|
+
<dc:Bounds x="816" y="102" width="36" height="36" />
|
|
61
|
+
<bpmndi:BPMNLabel>
|
|
62
|
+
<dc:Bounds x="794" y="143" width="80" height="14" />
|
|
63
|
+
</bpmndi:BPMNLabel>
|
|
64
|
+
</bpmndi:BPMNShape>
|
|
65
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_start" bpmnElement="f_start">
|
|
66
|
+
<di:waypoint x="116" y="120" />
|
|
67
|
+
<di:waypoint x="216" y="120" />
|
|
68
|
+
</bpmndi:BPMNEdge>
|
|
69
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_toSynthesize" bpmnElement="f_toSynthesize">
|
|
70
|
+
<di:waypoint x="316" y="120" />
|
|
71
|
+
<di:waypoint x="416" y="120" />
|
|
72
|
+
</bpmndi:BPMNEdge>
|
|
73
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_toRecord" bpmnElement="f_toRecord">
|
|
74
|
+
<di:waypoint x="516" y="120" />
|
|
75
|
+
<di:waypoint x="616" y="120" />
|
|
76
|
+
</bpmndi:BPMNEdge>
|
|
77
|
+
<bpmndi:BPMNEdge id="BPMNEdge_f_toEnd" bpmnElement="f_toEnd">
|
|
78
|
+
<di:waypoint x="716" y="120" />
|
|
79
|
+
<di:waypoint x="816" y="120" />
|
|
80
|
+
</bpmndi:BPMNEdge>
|
|
81
|
+
</bpmndi:BPMNPlane>
|
|
82
|
+
</bpmndi:BPMNDiagram>
|
|
83
|
+
</bpmn:definitions>
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// is on, or (b) close the PR out as `converged` (review-only mode).
|
|
4
4
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
5
5
|
import { AUTO_MERGE, startMerge } from "../../app/service.ts";
|
|
6
|
+
import { maybeStartRetro } from "../../app/retro.ts";
|
|
6
7
|
|
|
7
8
|
// Extends Record so the declared fields are typed while the job may still carry
|
|
8
9
|
// other process variables (e.g. io.nanobpm.agentResult, read by transcriptOf).
|
|
@@ -83,6 +84,14 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
83
84
|
open_escalation_question: null,
|
|
84
85
|
});
|
|
85
86
|
|
|
87
|
+
// Only the review-only terminal path ends the PR here as `converged` — in auto-merge mode the
|
|
88
|
+
// terminal point is pr.mark-merged (which triggers the retro), and a PR parked in `waiting_deps`
|
|
89
|
+
// is still in flight. So fire the retro trigger only when this PR actually reached its terminal
|
|
90
|
+
// state in finalize. Best-effort: must never fail the finalize job.
|
|
91
|
+
if (status === "converged") {
|
|
92
|
+
await maybeStartRetro(app.data, app.engine, prKey, app.log);
|
|
93
|
+
}
|
|
94
|
+
|
|
86
95
|
return {};
|
|
87
96
|
};
|
|
88
97
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// pr.mark-merged — the PR has landed (directly or via the merge queue). Record the terminal
|
|
2
2
|
// `merged` state; the merge audit trail is written by pr.merge, so this only closes the row out.
|
|
3
3
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
4
|
+
import { maybeStartRetro } from "../../app/retro.ts";
|
|
4
5
|
|
|
5
6
|
interface In extends Record<string, unknown> {
|
|
6
7
|
prKey: string;
|
|
@@ -15,6 +16,11 @@ const handler: AppJobHandler<In> = async (job, app) => {
|
|
|
15
16
|
open_escalation_id: null,
|
|
16
17
|
open_escalation_question: null,
|
|
17
18
|
});
|
|
19
|
+
|
|
20
|
+
// If this PR was the last of its epic to land, kick off the retrospective. Best-effort: a
|
|
21
|
+
// failure here (or no epic) must never fail marking the PR merged — the retro is advisory.
|
|
22
|
+
await maybeStartRetro(app.data, app.engine, job.variables.prKey, app.log);
|
|
23
|
+
|
|
18
24
|
return {};
|
|
19
25
|
};
|
|
20
26
|
|