@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/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
@@ -6,7 +6,7 @@
6
6
  ]
7
7
  },
8
8
  "imports": {
9
- "@nanobpm/urban": "npm:@nanobpm/urban@^0.27.0"
9
+ "@nanobpm/urban": "npm:@nanobpm/urban@^0.28.0"
10
10
  },
11
11
  "tasks": {
12
12
  "start": "deno run --allow-net --allow-read --allow-write --allow-run=gh --allow-env main.ts",
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.27": "0.27.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.27.0": {
86
- "integrity": "sha512-FjGv0ttj2TqRTvYUjHDKwBWmZImn17VWjbYVmy6cMAjhtGa5cFsuJOWHwV1WTdkJhcvC3WcsjB6gaAxYQNzglg==",
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.27"
1760
+ "npm:@nanobpm/urban@0.28"
1761
1761
  ],
1762
1762
  "packageJson": {
1763
1763
  "dependencies": [
1764
- "npm:@nanobpm/urban@0.27",
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.28.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.27.0"
43
+ "@nanobpm/urban": "^0.28.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@semantic-release/changelog": "^6.0.3",
@@ -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",
@@ -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",
@@ -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 &#38; 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