@nanobpm/nano-workforce 0.104.0 → 0.105.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 CHANGED
@@ -1,3 +1,10 @@
1
+ # [0.105.0](https://github.com/nanobpm/nano-workforce/compare/v0.104.0...v0.105.0) (2026-08-19)
2
+
3
+
4
+ ### Features
5
+
6
+ * examine epic implementation against spec in retro (conformance) ([#355](https://github.com/nanobpm/nano-workforce/issues/355)) ([c52d059](https://github.com/nanobpm/nano-workforce/commit/c52d0594d767c551d31ed967d1ef79cb30e82ceb)), closes [#217](https://github.com/nanobpm/nano-workforce/issues/217) [#216](https://github.com/nanobpm/nano-workforce/issues/216) [#217](https://github.com/nanobpm/nano-workforce/issues/217)
7
+
1
8
  # [0.104.0](https://github.com/nanobpm/nano-workforce/compare/v0.103.0...v0.104.0) (2026-08-19)
2
9
 
3
10
 
package/README.md CHANGED
@@ -153,6 +153,7 @@ capability):
153
153
  | `fix-ci` | `senior:fix-ci` | `merge-loop` | Green a `blocked` PR's failing checks |
154
154
  | `rebase` | `senior:rebase` | `merge-loop` | Rebase a conflicting PR up to date with its base |
155
155
  | `retro` | `senior:retro` | `retro` | Synthesize a finished epic's learnings and promote the recurring ones |
156
+ | `conformance` | `senior:conformance` | `retro` | Examine a finished epic's implementation against its spec; report met/deviations on the issue |
156
157
 
157
158
  - `--command 'copilot -p - --allow-all-tools'` starts the Copilot CLI reading its
158
159
  prompt from **stdin** (`-p -`). The harness pipes the whole job JSON (prompt +
package/app/abandon.ts CHANGED
@@ -20,11 +20,20 @@
20
20
  import type { DataLayer } from "@nanobpm/urban";
21
21
  import { publicBaseUrl } from "./blackboard.ts";
22
22
 
23
- /** The one app-row status that means "this run was cancelled". Convergence/merge terminal states
24
- * `converged`/`merged` are NOT abandonment only an explicit cancel flips a live run here. */
23
+ /** The one app-row status meaning a PR is terminally abandoned — the run must not be worked on
24
+ * further. Two disjoint producers flip a row here, and both are non-completion terminals that must
25
+ * stop a servicing agent:
26
+ * 1. an explicit **cancel** of a live convergence/merge run (Urban's cancel primitive, via the
27
+ * `instanceTracking` `onTerminated.set` patch), and
28
+ * 2. **`abandonClosedPr`** reconciling a wave-member PR that was **closed on GitHub without
29
+ * merging** (#352) — for both `pull_requests` and its `plan_tasks`.
30
+ * Convergence/merge terminal states `converged`/`merged` are NOT abandonment. In either abandoned
31
+ * case a servicing agent should stop, so the abandon-check endpoint treating both as `abandoned:
32
+ * true` is correct. */
25
33
  export const ABANDONED_STATUS = "abandoned";
26
34
 
27
- /** True when a PR's app-row status means the run was cancelled and the agent must not act. */
35
+ /** True when a PR's app-row status is terminally abandoned (run cancelled, or PR closed-unmerged)
36
+ * and the agent must not act. */
28
37
  export function isAbandoned(status: string | null | undefined): boolean {
29
38
  return status === ABANDONED_STATUS;
30
39
  }
@@ -0,0 +1,220 @@
1
+ // Unit tests for the spec-conformance review stage (app/conformance.ts, 052_plan_conformance.sql).
2
+ import { test } from "node:test";
3
+ import { assert, assertEquals, assertStringIncludes } from "#test-assert";
4
+ import type { DataLayer } from "@nanobpm/urban";
5
+ import { memBlackboardSource } from "../test/blackboardDb.ts";
6
+ import { appendEntry } from "./blackboard.ts";
7
+ import {
8
+ gatherConformance,
9
+ hasDeliveredImplementation,
10
+ hasDeliveredImplementationForPlan,
11
+ recordConformance,
12
+ renderConformanceBrief,
13
+ } from "./conformance.ts";
14
+
15
+ // In-memory record gateway matching the Table<T> subset conformance.ts uses.
16
+ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
17
+ const stores: Record<string, any[]> = {};
18
+ const seq: Record<string, number> = {};
19
+ function tbl(name: string, pk = "id") {
20
+ const rows = (stores[name] ??= [] as any[]);
21
+ const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
22
+ return {
23
+ async insert(row: any) {
24
+ if (pk !== "id" && rows.some((r) => r[pk] === row[pk])) {
25
+ throw new Error(`UNIQUE constraint failed: ${name}.${pk}`);
26
+ }
27
+ const id = (seq[name] = (seq[name] ?? 0) + 1);
28
+ rows.push(pk === "id" ? { id, ...row } : { ...row });
29
+ return pk === "id" ? id : row[pk];
30
+ },
31
+ async find(where: any = {}) {
32
+ return rows.filter((r) => match(r, where));
33
+ },
34
+ async findOne(where: any = {}) {
35
+ return rows.find((r) => match(r, where));
36
+ },
37
+ async get(id: any) {
38
+ return rows.find((row) => row[pk] === id);
39
+ },
40
+ async update(id: any, patch: any) {
41
+ const r = rows.find((row) => row[pk] === id);
42
+ if (r) Object.assign(r, patch);
43
+ },
44
+ };
45
+ }
46
+ const data = { table: (n: string, pk?: string) => tbl(n, pk), source: memBlackboardSource().source } as any as DataLayer;
47
+ return { data, stores };
48
+ }
49
+
50
+ const PLAN = "acme/widgets#7";
51
+
52
+ function seedPlan(stores: Record<string, any[]>) {
53
+ stores["plans"] = [{
54
+ plan_key: PLAN,
55
+ repo: "acme/widgets",
56
+ issue_url: "https://github.com/acme/widgets/issues/7",
57
+ title: "Widgets epic",
58
+ status: "done",
59
+ }];
60
+ }
61
+ function seedTask(stores: Record<string, any[]>, task: Record<string, unknown>) {
62
+ (stores["plan_tasks"] ??= []).push({ plan_key: PLAN, ...task });
63
+ }
64
+ function seedPr(stores: Record<string, any[]>, pr_key: string, status: string) {
65
+ (stores["pull_requests"] ??= []).push({ pr_key, status });
66
+ }
67
+
68
+ test("gatherConformance: collects the spec, only LANDED PRs, and raised scope-changes", async () => {
69
+ const { data, stores } = memData();
70
+ seedPlan(stores);
71
+ seedTask(stores, { id: 1, task_index: 0, task_id: "t1", title: "Auth", prompt: "add JWT auth", status: "opened", pr_key: "acme/widgets#10" });
72
+ seedTask(stores, { id: 2, task_index: 1, task_id: "t2", title: "Rate limit", prompt: "add rate limiting", status: "opened", pr_key: "acme/widgets#11" });
73
+ seedTask(stores, { id: 3, task_index: 2, task_id: "t3", title: "Webhook", prompt: "retry webhooks", status: "opened", pr_key: "acme/widgets#12" });
74
+ seedTask(stores, { id: 4, task_index: 3, task_id: "t4", title: "Docs", prompt: "write docs", status: "skipped", pr_key: null });
75
+ seedPr(stores, "acme/widgets#10", "merged"); // landed
76
+ seedPr(stores, "acme/widgets#11", "converged"); // landed (review-only)
77
+ seedPr(stores, "acme/widgets#12", "abandoned"); // NOT landed
78
+ await appendEntry(data, PLAN, { author_task: "t2", kind: "scope-change", body: "narrowed rate limit to per-IP only" });
79
+ await appendEntry(data, PLAN, { author_task: "t1", kind: "learning", body: "not a scope change" });
80
+
81
+ const d = await gatherConformance(data, PLAN);
82
+ assertEquals(d.repo, "acme/widgets");
83
+ assertEquals(d.issueUrl, "https://github.com/acme/widgets/issues/7");
84
+ assertEquals(d.slices.length, 4);
85
+ // Only merged/converged PRs are "delivered"; abandoned and task-less slices are excluded.
86
+ assertEquals(d.deliveredPrs, ["acme/widgets#10", "acme/widgets#11"]);
87
+ assertEquals(d.slices.find((s) => s.taskId === "t3")?.landed, false);
88
+ assertEquals(d.slices.find((s) => s.taskId === "t4")?.landed, false);
89
+ // Only scope-change entries surface as raised deviations — learnings are ignored.
90
+ assertEquals(d.scopeChanges.length, 1);
91
+ assertEquals(d.scopeChanges[0].author_task, "t2");
92
+ });
93
+
94
+ test("gatherConformance: sorts slices by task_index", async () => {
95
+ const { data, stores } = memData();
96
+ seedPlan(stores);
97
+ seedTask(stores, { id: 1, task_index: 2, task_id: "t3", status: "skipped", pr_key: null });
98
+ seedTask(stores, { id: 2, task_index: 0, task_id: "t1", status: "skipped", pr_key: null });
99
+ seedTask(stores, { id: 3, task_index: 1, task_id: "t2", status: "skipped", pr_key: null });
100
+ const d = await gatherConformance(data, PLAN);
101
+ assertEquals(d.slices.map((s) => s.taskId), ["t1", "t2", "t3"]);
102
+ });
103
+
104
+ test("gatherConformance: uses pre-fetched blackboard entries instead of re-scanning", async () => {
105
+ const { data, stores } = memData();
106
+ seedPlan(stores);
107
+ seedTask(stores, { id: 1, task_index: 0, task_id: "t1", status: "skipped", pr_key: null });
108
+ // A scope-change lives in the store, but the caller passes an EMPTY pre-fetched snapshot — the
109
+ // function must honour what it was handed and not re-read the store.
110
+ await appendEntry(data, PLAN, { author_task: "t1", kind: "scope-change", body: "should be ignored" });
111
+ const d = await gatherConformance(data, PLAN, []);
112
+ assertEquals(d.scopeChanges.length, 0);
113
+ });
114
+
115
+ test("hasDeliveredImplementation: true iff at least one PR landed", async () => {
116
+ const { data, stores } = memData();
117
+ seedPlan(stores);
118
+ seedTask(stores, { id: 1, task_index: 0, task_id: "t1", status: "opened", pr_key: "acme/widgets#10" });
119
+ seedPr(stores, "acme/widgets#10", "abandoned");
120
+ assert(!hasDeliveredImplementation(await gatherConformance(data, PLAN)));
121
+ stores["pull_requests"] = [{ pr_key: "acme/widgets#10", status: "merged" }];
122
+ assert(hasDeliveredImplementation(await gatherConformance(data, PLAN)));
123
+ });
124
+
125
+ test("hasDeliveredImplementationForPlan: matches the digest without a blackboard scan", async () => {
126
+ const { data, stores } = memData();
127
+ seedPlan(stores);
128
+ seedTask(stores, { id: 1, task_index: 0, task_id: "t1", status: "opened", pr_key: "acme/widgets#10" });
129
+ seedTask(stores, { id: 2, task_index: 1, task_id: "t2", status: "skipped", pr_key: null });
130
+ seedPr(stores, "acme/widgets#10", "abandoned");
131
+ // No landed PR yet — agrees with the full-digest helper.
132
+ assertEquals(await hasDeliveredImplementationForPlan(data, PLAN), false);
133
+ assertEquals(hasDeliveredImplementation(await gatherConformance(data, PLAN)), false);
134
+ // A landed PR flips both to true.
135
+ stores["pull_requests"] = [{ pr_key: "acme/widgets#10", status: "converged" }];
136
+ assertEquals(await hasDeliveredImplementationForPlan(data, PLAN), true);
137
+ assertEquals(hasDeliveredImplementation(await gatherConformance(data, PLAN)), true);
138
+ // The cheap check must not touch the blackboard.
139
+ const before = (stores["blackboard"] ?? []).length;
140
+ await hasDeliveredImplementationForPlan(data, PLAN);
141
+ assertEquals((stores["blackboard"] ?? []).length, before);
142
+ });
143
+
144
+ test("renderConformanceBrief: lists PRs to examine, the spec, and raised deviations", () => {
145
+ const brief = renderConformanceBrief({
146
+ planKey: PLAN,
147
+ repo: "acme/widgets",
148
+ issueUrl: "https://x/7",
149
+ title: "Epic",
150
+ slices: [
151
+ { taskId: "t1", title: "Auth", prompt: "add JWT auth", status: "opened", prKey: "acme/widgets#10", landed: true },
152
+ { taskId: "t2", title: "Docs", prompt: null, status: "skipped", prKey: null, landed: false },
153
+ ],
154
+ deliveredPrs: ["acme/widgets#10"],
155
+ scopeChanges: [{ author_task: "t1", body: "narrowed to per-IP", created_at: "now" }],
156
+ });
157
+ assertStringIncludes(brief, "gh pr diff");
158
+ assertStringIncludes(brief, "acme/widgets#10");
159
+ assertStringIncludes(brief, "add JWT auth");
160
+ assertStringIncludes(brief, "narrowed to per-IP");
161
+ assertStringIncludes(brief, "RAISED during implementation");
162
+ });
163
+
164
+ test("renderConformanceBrief: states 'none' for no delivered PRs and no scope-changes", () => {
165
+ const brief = renderConformanceBrief({
166
+ planKey: PLAN, repo: "acme/widgets", issueUrl: "", title: null,
167
+ slices: [], deliveredPrs: [], scopeChanges: [],
168
+ });
169
+ assertStringIncludes(brief, "no implementation to verify");
170
+ assertStringIncludes(brief, "treat any deviation you find as UNRAISED");
171
+ });
172
+
173
+ test("recordConformance: inserts then updates the same plan_key row in place", async () => {
174
+ const { data, stores } = memData();
175
+ await recordConformance(data, PLAN, {
176
+ status: "filed",
177
+ commentUrl: "https://x/7#issuecomment-1",
178
+ slicesMet: 4,
179
+ slicesReduced: 1,
180
+ slicesNotVerified: 1,
181
+ deviationsRaised: 2,
182
+ deviationsUnraised: 1,
183
+ hasDeviations: true,
184
+ summary: "6 items, 4 met",
185
+ report: "full report",
186
+ });
187
+ assertEquals(stores["plan_conformance"].length, 1);
188
+ const row = stores["plan_conformance"][0];
189
+ assertEquals(row.status, "filed");
190
+ assertEquals(row.comment_url, "https://x/7#issuecomment-1");
191
+ assertEquals(row.slices_met, 4);
192
+ assertEquals(row.has_deviations, 1);
193
+
194
+ await recordConformance(data, PLAN, { status: "skipped", summary: "nothing shipped" });
195
+ assertEquals(stores["plan_conformance"].length, 1, "same plan_key must not duplicate");
196
+ assertEquals(stores["plan_conformance"][0].status, "skipped");
197
+ assertEquals(stores["plan_conformance"][0].has_deviations, 0);
198
+ });
199
+
200
+ test("recordConformance: rethrows a non-unique (FOREIGN KEY) constraint error instead of swallowing it", async () => {
201
+ let updated = false;
202
+ const table = {
203
+ async insert() {
204
+ throw new Error("FOREIGN KEY constraint failed");
205
+ },
206
+ async update() {
207
+ updated = true;
208
+ },
209
+ };
210
+ const data = { table: () => table } as any as DataLayer;
211
+ let threw = false;
212
+ try {
213
+ await recordConformance(data, PLAN, { status: "filed" });
214
+ } catch (err) {
215
+ threw = true;
216
+ assertStringIncludes(String(err), "FOREIGN KEY");
217
+ }
218
+ assert(threw, "the FK error must propagate");
219
+ assertEquals(updated, false, "must not silently fall back to update on a non-unique error");
220
+ });
@@ -0,0 +1,240 @@
1
+ // Spec-conformance review — "did we build what the spec asked for?", examined against the ACTUAL
2
+ // implementation.
3
+ //
4
+ // It rides the existing `retro` process (app/retro.ts): when an epic's last PR lands, a
5
+ // `senior:conformance` agent runs BEFORE the lessons agent. Unlike retro — which reflects on what
6
+ // implementers *claimed* via `learning` blackboard entries and task deltas — conformance is
7
+ // deliberately grounded in the code: the digest it builds hands the agent the spec (the epic issue
8
+ // + every slice's `prompt`) and the set of PRs that actually LANDED, so the agent reads the real
9
+ // diffs/code/tests (`gh pr diff`, `git`) and verifies delivery rather than trusting the transcript.
10
+ //
11
+ // It surfaces two classes of deviation: those RAISED during implementation (`scope-change`
12
+ // blackboard entries — quoted here so the agent can reconcile them) and those it finds itself that
13
+ // were NEVER raised. The result is persisted to `plan_conformance` (052_plan_conformance.sql).
14
+ //
15
+ // Data access goes through the record gateway (`data.table`), never hand-written SQL — matching
16
+ // app/retro.ts, app/plan.ts, and app/blackboard.ts.
17
+ import type { DataLayer } from "@nanobpm/urban";
18
+ import { type BlackboardEntry, isUniqueViolation, readBlackboard } from "./blackboard.ts";
19
+ import { TERMINAL_STATUSES } from "./delivery.ts";
20
+ import { planTasks } from "./plan.ts";
21
+
22
+ const now = () => new Date().toISOString();
23
+
24
+ /** A slice PR "landed" — its implementation is really in the tree and worth examining — when its
25
+ * PR reached a terminal state that isn't `abandoned`. In auto-merge mode that terminal is `merged`;
26
+ * in review-only mode it is `converged`. Derived from app/delivery.ts TERMINAL_STATUSES (the single
27
+ * source of truth for PR-terminal states) minus `abandoned`, so conformance and retro can't drift
28
+ * about what counts as landed. */
29
+ const LANDED_PR_STATUSES = new Set(TERMINAL_STATUSES.filter((s) => s !== "abandoned"));
30
+
31
+ interface PlanRow extends Record<string, unknown> {
32
+ plan_key: string;
33
+ repo: string;
34
+ issue_url: string;
35
+ title: string | null;
36
+ }
37
+
38
+ const plansTbl = (data: DataLayer) => data.table<PlanRow>("plans", "plan_key");
39
+ const prsTbl = (data: DataLayer) =>
40
+ data.table<{ pr_key: string; status: string }>("pull_requests", "pr_key");
41
+
42
+ /** A slice's PR "landed" iff it exists and reached a non-abandoned terminal status. The single
43
+ * predicate both {@link gatherConformance} and {@link hasDeliveredImplementationForPlan} apply, so
44
+ * the full digest and the cheap trigger check can't disagree about what counts as landed. */
45
+ async function isLanded(data: DataLayer, prKey: string | null | undefined): Promise<boolean> {
46
+ if (!prKey) return false;
47
+ const pr = await prsTbl(data).get(prKey);
48
+ return !!pr && LANDED_PR_STATUSES.has(pr.status);
49
+ }
50
+ const conformanceTbl = (data: DataLayer) =>
51
+ data.table<{ plan_key: string } & Record<string, unknown>>("plan_conformance", "plan_key");
52
+
53
+ /** One item of the spec the agent must verify against the code: the slice's planner-supplied
54
+ * `prompt` (its acceptance brief), where it landed, and whether it landed at all. */
55
+ export interface ConformanceSlice {
56
+ taskId: string;
57
+ title: string | null;
58
+ prompt: string | null;
59
+ status: string;
60
+ prKey: string | null;
61
+ landed: boolean;
62
+ }
63
+
64
+ /** The material a conformance review examines. Unlike {@link RetroDigest}, this is spec + delivery
65
+ * pointers (not distilled claims) — the agent turns `deliveredPrs` into real diffs to inspect. */
66
+ export interface ConformanceDigest {
67
+ planKey: string;
68
+ repo: string;
69
+ issueUrl: string;
70
+ title: string | null;
71
+ slices: ConformanceSlice[];
72
+ /** The landed PR keys ("<owner>/<repo>#<n>") the agent must open and read the diff of. */
73
+ deliveredPrs: string[];
74
+ /** Deviations agents RAISED during implementation (`scope-change` blackboard entries). */
75
+ scopeChanges: { author_task: string; body: string; created_at: string }[];
76
+ }
77
+
78
+ /** Assemble the conformance material for a plan: the spec (issue + each slice's `prompt`), the set
79
+ * of PRs that actually landed (so the agent examines the real implementation), and the scope
80
+ * deviations raised during implementation. Reads only — no writes.
81
+ *
82
+ * `entries` lets a caller that has already scanned the blackboard for this plan (e.g.
83
+ * `pr.retro-gather`, which also runs {@link gatherRetro}) pass those entries in so the plan is
84
+ * scanned once, not once per gatherer — see workers/retro-gather. Omitted, it reads them itself. */
85
+ export async function gatherConformance(
86
+ data: DataLayer,
87
+ planKey: string,
88
+ entries?: BlackboardEntry[],
89
+ ): Promise<ConformanceDigest> {
90
+ const plan = await plansTbl(data).get(planKey);
91
+ const tasks = (await planTasks(data).find({ plan_key: planKey }))
92
+ .slice()
93
+ .sort((a, b) => (a.task_index ?? 0) - (b.task_index ?? 0));
94
+
95
+ const slices: ConformanceSlice[] = [];
96
+ const deliveredPrs: string[] = [];
97
+ for (const t of tasks) {
98
+ const landed = await isLanded(data, t.pr_key);
99
+ if (landed && t.pr_key) deliveredPrs.push(t.pr_key);
100
+ slices.push({
101
+ taskId: t.task_id,
102
+ title: t.title ?? null,
103
+ prompt: t.prompt ?? null,
104
+ status: t.status,
105
+ prKey: t.pr_key ?? null,
106
+ landed,
107
+ });
108
+ }
109
+
110
+ const scopeChanges = (entries ?? (await readBlackboard(data, planKey)))
111
+ .filter((e) => e.kind === "scope-change")
112
+ .map((e) => ({ author_task: e.author_task, body: e.body, created_at: e.created_at }));
113
+
114
+ return {
115
+ planKey,
116
+ repo: plan?.repo ?? planKey.split("#")[0] ?? "",
117
+ issueUrl: plan?.issue_url ?? "",
118
+ title: plan?.title ?? null,
119
+ slices,
120
+ deliveredPrs,
121
+ scopeChanges,
122
+ };
123
+ }
124
+
125
+ /** True when there is real, landed implementation to examine. A plan whose slices were all
126
+ * skipped/blocked/abandoned shipped nothing, so there is nothing to check for conformance — the
127
+ * retro trigger uses this to decide whether the conformance run is worthwhile even when the retro
128
+ * digest itself is empty. */
129
+ export function hasDeliveredImplementation(d: ConformanceDigest): boolean {
130
+ return d.deliveredPrs.length > 0;
131
+ }
132
+
133
+ /** Cheap trigger check for the retro gate: does this plan have ANY landed implementation to examine?
134
+ * Inspects only plan_tasks + pull_requests (short-circuiting on the first landed PR) and — unlike
135
+ * {@link gatherConformance} — performs no blackboard scan, so the empty-digest trigger in
136
+ * app/retro.ts doesn't pay to compute `scopeChanges` it would discard. Shares {@link isLanded} with
137
+ * the full digest so the two can't drift on what "landed" means. */
138
+ export async function hasDeliveredImplementationForPlan(
139
+ data: DataLayer,
140
+ planKey: string,
141
+ ): Promise<boolean> {
142
+ const tasks = await planTasks(data).find({ plan_key: planKey });
143
+ for (const t of tasks) {
144
+ if (await isLanded(data, t.pr_key)) return true;
145
+ }
146
+ return false;
147
+ }
148
+
149
+ /** Render the digest as the compact markdown brief handed to the conformance agent (rides
150
+ * `appendPrompt`, concatenated after the base `conformance.md` linked-resource prompt — so it owns
151
+ * its own leading separator). It deliberately gives POINTERS (the spec text + the PRs to open), not
152
+ * conclusions: the agent must reach the verdicts by reading the code. */
153
+ export function renderConformanceBrief(d: ConformanceDigest): string {
154
+ const lines: string[] = [
155
+ "",
156
+ "",
157
+ "---",
158
+ "",
159
+ `## Conformance input — epic ${d.planKey}`,
160
+ "",
161
+ `Target repo: **${d.repo}**${d.issueUrl ? ` · issue (the spec): ${d.issueUrl}` : ""}`,
162
+ d.title ? `Epic: ${d.title}` : "",
163
+ "",
164
+ "### Delivered PRs to examine",
165
+ ];
166
+ if (d.deliveredPrs.length === 0) {
167
+ lines.push("_(none landed — no implementation to verify)_");
168
+ } else {
169
+ lines.push(
170
+ `Read the actual diff of each with \`gh pr diff <n> --repo ${d.repo}\` (and the code/tests it touches):`,
171
+ );
172
+ for (const pr of d.deliveredPrs) lines.push(`- ${pr}`);
173
+ }
174
+
175
+ lines.push("", `### Spec — the ${d.slices.length} slice(s) planned`);
176
+ if (d.slices.length === 0) {
177
+ lines.push("_(no slices recorded — verify the epic issue body directly)_");
178
+ } else {
179
+ for (const s of d.slices) {
180
+ const where = s.landed && s.prKey ? `landed as ${s.prKey}` : `status: ${s.status}`;
181
+ lines.push("", `#### ${s.taskId}${s.title ? ` — ${s.title}` : ""} (${where})`);
182
+ lines.push(s.prompt ? s.prompt : "_(no per-slice prompt; verify against the epic issue body)_");
183
+ }
184
+ }
185
+
186
+ lines.push("", `### Deviations RAISED during implementation (${d.scopeChanges.length})`);
187
+ if (d.scopeChanges.length === 0) {
188
+ lines.push("_(none — no `scope-change` entries were posted; treat any deviation you find as UNRAISED)_");
189
+ } else {
190
+ lines.push("Reconcile each against the delivered code — a raised deviation is still a deviation:");
191
+ for (const c of d.scopeChanges) lines.push(`- **[${c.author_task}]** ${c.body}`);
192
+ }
193
+ return lines.join("\n");
194
+ }
195
+
196
+ /** The persisted conformance shape (written by pr.conformance-record from the agent's result). */
197
+ export interface ConformanceInput {
198
+ status: string; // filed | skipped | blocked
199
+ commentUrl?: string | null;
200
+ slicesMet?: number;
201
+ slicesReduced?: number;
202
+ slicesNotVerified?: number;
203
+ deviationsRaised?: number;
204
+ deviationsUnraised?: number;
205
+ hasDeviations?: boolean;
206
+ summary?: string | null;
207
+ report?: string | null;
208
+ }
209
+
210
+ /** Upsert a plan's conformance row (idempotent on plan_key, so a job retry overwrites in place).
211
+ *
212
+ * Insert-first, then fall back to update only on a verified unique/PK violation — mirrors
213
+ * {@link recordRetro}: a get-then-insert can race, so "row already exists" is the update path, but
214
+ * any non-duplicate constraint failure must propagate rather than be silently swallowed. */
215
+ export async function recordConformance(
216
+ data: DataLayer,
217
+ planKey: string,
218
+ input: ConformanceInput,
219
+ ): Promise<void> {
220
+ const ts = now();
221
+ const fields = {
222
+ status: input.status,
223
+ comment_url: input.commentUrl ?? null,
224
+ slices_met: input.slicesMet ?? 0,
225
+ slices_reduced: input.slicesReduced ?? 0,
226
+ slices_not_verified: input.slicesNotVerified ?? 0,
227
+ deviations_raised: input.deviationsRaised ?? 0,
228
+ deviations_unraised: input.deviationsUnraised ?? 0,
229
+ has_deviations: input.hasDeviations ? 1 : 0,
230
+ summary: input.summary ?? null,
231
+ report: input.report ?? null,
232
+ updated_at: ts,
233
+ };
234
+ try {
235
+ await conformanceTbl(data).insert({ plan_key: planKey, created_at: ts, ...fields });
236
+ } catch (err) {
237
+ if (!isUniqueViolation(err)) throw err;
238
+ await conformanceTbl(data).update(planKey, fields);
239
+ }
240
+ }
package/app/dbFence.ts ADDED
@@ -0,0 +1,18 @@
1
+ // nano-workforce — the ONE canonical classifier for a SQLite UNIQUE-constraint fence collision.
2
+ //
3
+ // A durable fence is a DB-level UNIQUE constraint that a check-then-insert races against: two
4
+ // concurrent/duplicate writers both observe "no row" and both attempt the insert, so the loser hits
5
+ // `UNIQUE constraint failed`. Turning that collision into the SAME intended idempotent outcome
6
+ // (instead of a spurious job failure) is a recurring pattern across the app — the world store's
7
+ // checkpoint/effect ledger (`db/migrations/049_world_checkpoint.sql`) and the merges-audit abandon
8
+ // guard (`abandonClosedPr`, `db/migrations/053_merges_abandon_dedupe.sql`) both rely on it.
9
+ //
10
+ // This is the ONE place that classifies the collision so every catch site shares a single
11
+ // implementation rather than re-encoding the driver's error shape (AGENTS.md: "no drift surfaces").
12
+ // Matched on the message substring the RAD `Table` surface propagates verbatim — the same one the
13
+ // schema/migration tests assert on — because that surface hides the concrete driver error type.
14
+
15
+ /** True when `err` is a SQLite `UNIQUE constraint failed` — the durable fence firing. */
16
+ export function isUniqueConstraintFence(err: unknown): boolean {
17
+ return err instanceof Error && /UNIQUE constraint failed/i.test(err.message);
18
+ }
@@ -0,0 +1,84 @@
1
+ // Regression guard for migration 053 (#352, PR #354 review — suppressed advisory on app/service.ts:867):
2
+ // the DB-level fence that makes `abandonClosedPr`'s terminal audit write TRULY idempotent under a
3
+ // concurrent race, not merely best-effort. The partial `UNIQUE INDEX ux_merges_abandon_pr_closed ON
4
+ // merges(pr_key) WHERE outcome='abandoned' AND method='pr-closed'` IS the fence: the merge worker and
5
+ // the wave-gate self-heal path can both observe "no row" between the guard's `find` and its `insert`
6
+ // and both attempt the write, and this index is what turns the loser's insert into a catchable
7
+ // `UNIQUE constraint failed` instead of a duplicate audit row.
8
+ import { readFileSync } from "node:fs";
9
+ import { DatabaseSync } from "node:sqlite";
10
+ import test from "node:test";
11
+ import { fileURLToPath } from "node:url";
12
+ import { assert, assertEquals, assertThrows } from "#test-assert";
13
+
14
+ // The `merges` audit table as created by 004_merge.sql, minus the `pull_requests` FK parent (this
15
+ // test proves the index behaviour in isolation, exactly as migration049.test.ts hosts the world
16
+ // tables FK-free). Then apply 053 on top.
17
+ function migratedDb(): DatabaseSync {
18
+ const db = new DatabaseSync(":memory:");
19
+ db.exec(`CREATE TABLE merges (
20
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
21
+ pr_key TEXT NOT NULL,
22
+ outcome TEXT NOT NULL,
23
+ method TEXT,
24
+ detail TEXT,
25
+ at TEXT NOT NULL
26
+ );`);
27
+ const sql = readFileSync(fileURLToPath(new URL("../db/migrations/053_merges_abandon_dedupe.sql", import.meta.url)), "utf8");
28
+ db.exec(sql);
29
+ return db;
30
+ }
31
+
32
+ const insertAbandon = (db: DatabaseSync, prKey: string) =>
33
+ db
34
+ .prepare("INSERT INTO merges (pr_key, outcome, method, detail, at) VALUES (?, 'abandoned', 'pr-closed', 'd', 't')")
35
+ .run(prKey);
36
+
37
+ test("migration 053 applies cleanly and enforces one abandoned/pr-closed row per pr_key", () => {
38
+ const db = migratedDb();
39
+ insertAbandon(db, "o/r#1");
40
+ // The race: a second observer inserting the SAME abandoned/pr-closed row now hits the fence.
41
+ assertThrows(() => insertAbandon(db, "o/r#1"), undefined, "UNIQUE constraint failed");
42
+ assertEquals(
43
+ Number((db.prepare("SELECT COUNT(*) c FROM merges WHERE pr_key='o/r#1'").get() as { c: number }).c),
44
+ 1,
45
+ "the loser's duplicate was rejected — one terminal audit row survives",
46
+ );
47
+ // A DIFFERENT PR's abandon is independent — the index is per pr_key, not global.
48
+ insertAbandon(db, "o/r#2");
49
+ assertEquals(Number((db.prepare("SELECT COUNT(*) c FROM merges").get() as { c: number }).c), 2);
50
+ });
51
+
52
+ test("migration 053 only fences abandoned/pr-closed rows — merged/queued/blocked still repeat freely", () => {
53
+ const db = migratedDb();
54
+ const insertMerged = () =>
55
+ db.prepare("INSERT INTO merges (pr_key, outcome, method, detail, at) VALUES ('o/r#3','merged','squash','d','t')").run();
56
+ // A PR can carry several `merged` audit rows (retry / already-merged short-circuit) — the partial
57
+ // index must NOT constrain them (mergesPerDay dedupes with COUNT(DISTINCT pr_key)).
58
+ insertMerged();
59
+ insertMerged();
60
+ assertEquals(Number((db.prepare("SELECT COUNT(*) c FROM merges WHERE outcome='merged'").get() as { c: number }).c), 2);
61
+ // An abandoned row with a DIFFERENT method is also outside the partial predicate.
62
+ db.prepare("INSERT INTO merges (pr_key, outcome, method, detail, at) VALUES ('o/r#3','abandoned','other','d','t')").run();
63
+ db.prepare("INSERT INTO merges (pr_key, outcome, method, detail, at) VALUES ('o/r#3','abandoned','other','d','t')").run();
64
+ assertEquals(Number((db.prepare("SELECT COUNT(*) c FROM merges WHERE method='other'").get() as { c: number }).c), 2);
65
+ });
66
+
67
+ test("migration 053 collapses pre-existing duplicate abandoned/pr-closed rows, keeping the earliest", () => {
68
+ // Simulate a database where the pre-fence race already wrote duplicates, then apply the migration.
69
+ const db = new DatabaseSync(":memory:");
70
+ db.exec(`CREATE TABLE merges (
71
+ id INTEGER PRIMARY KEY AUTOINCREMENT, pr_key TEXT NOT NULL, outcome TEXT NOT NULL,
72
+ method TEXT, detail TEXT, at TEXT NOT NULL);`);
73
+ db.prepare("INSERT INTO merges (pr_key,outcome,method,detail,at) VALUES ('o/r#9','abandoned','pr-closed','first','t')").run();
74
+ db.prepare("INSERT INTO merges (pr_key,outcome,method,detail,at) VALUES ('o/r#9','abandoned','pr-closed','dup','t')").run();
75
+ db.prepare("INSERT INTO merges (pr_key,outcome,method,detail,at) VALUES ('o/r#8','abandoned','pr-closed','solo','t')").run();
76
+ const sql = readFileSync(fileURLToPath(new URL("../db/migrations/053_merges_abandon_dedupe.sql", import.meta.url)), "utf8");
77
+ db.exec(sql); // dedupe + create index; must not throw despite the pre-existing duplicate
78
+ const rows = db.prepare("SELECT pr_key, detail FROM merges ORDER BY pr_key").all() as { pr_key: string; detail: string }[];
79
+ assertEquals(rows.length, 2, "the duplicate for o/r#9 was collapsed");
80
+ assert(
81
+ rows.some((r) => r.pr_key === "o/r#9" && r.detail === "first"),
82
+ "the EARLIEST (MIN(id)) row survived the collapse",
83
+ );
84
+ });
package/app/plan.ts CHANGED
@@ -181,6 +181,12 @@ export const PLAN_TASK_STATUSES = [
181
181
  "skipped",
182
182
  "escalated",
183
183
  "waiting-for-lane",
184
+ // Terminal: the task's PR was closed on GitHub without merging (abandoned / superseded /
185
+ // perpetually conflicting). Set by the canonical abandon writer (`abandonClosedPr`, app/service.ts)
186
+ // reached from BOTH the merge stage and the wave-merge gate. An `abandoned` task drops out of
187
+ // `waveMergeTargets` (so a dead member never wedges the wave barrier — #352) and stops
188
+ // `isPlanComplete`/the Epics table counting a phantom open task.
189
+ "abandoned",
184
190
  ] as const;
185
191
  export type PlanTaskStatus = typeof PLAN_TASK_STATUSES[number];
186
192