@nanobpm/nano-workforce 0.104.0 → 0.106.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +1 -0
  3. package/app/abandon.ts +12 -3
  4. package/app/agentCompletion.test.ts +28 -0
  5. package/app/agentCompletion.ts +14 -2
  6. package/app/conformance.test.ts +313 -0
  7. package/app/conformance.ts +329 -0
  8. package/app/dbFence.ts +18 -0
  9. package/app/instance-tracking.test.ts +24 -0
  10. package/app/migration053.test.ts +84 -0
  11. package/app/plan.ts +6 -0
  12. package/app/pollUserTasks.test.ts +37 -0
  13. package/app/retro.test.ts +32 -2
  14. package/app/retro.ts +26 -10
  15. package/app/service.test.ts +191 -3
  16. package/app/service.ts +163 -2
  17. package/app/userTasks.test.ts +20 -0
  18. package/app/userTasks.ts +2 -0
  19. package/app/waves.test.ts +12 -0
  20. package/app/world/store.ts +6 -6
  21. package/db/migrations/004_planning.sql +1 -1
  22. package/db/migrations/052_plan_conformance.sql +28 -0
  23. package/db/migrations/053_merges_abandon_dedupe.sql +29 -0
  24. package/db/migrations/054_conformance_review_tracking.sql +27 -0
  25. package/nano.app.json +24 -1
  26. package/package.json +1 -1
  27. package/pages/tasks.page.json +84 -0
  28. package/resources/forms/conformance-escalation.form +17 -0
  29. package/resources/processes/retro.bpmn +123 -11
  30. package/resources/prompts/conformance.md +105 -0
  31. package/resources/prompts/retro.md +5 -0
  32. package/workers/conformance-ack/worker.test.ts +50 -0
  33. package/workers/conformance-ack/worker.ts +31 -0
  34. package/workers/conformance-record/worker.test.ts +241 -0
  35. package/workers/conformance-record/worker.ts +127 -0
  36. package/workers/merge/worker.test.ts +4 -0
  37. package/workers/merge/worker.ts +13 -18
  38. package/workers/retro-gather/worker.test.ts +6 -0
  39. package/workers/retro-gather/worker.ts +17 -5
@@ -0,0 +1,241 @@
1
+ import { test } from "node:test";
2
+ import { assertEquals, assertRejects } from "#test-assert";
3
+ import { noopLog } from "../../test/log.ts";
4
+ import handler from "./worker.ts";
5
+
6
+ function fakeApp() {
7
+ const stores: Record<string, any[]> = { plan_conformance: [] };
8
+ const seq: Record<string, number> = {};
9
+ function tbl(name: string, pk = "id") {
10
+ const rows = (stores[name] ??= [] as any[]);
11
+ const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
12
+ return {
13
+ async insert(row: any) {
14
+ const id = (seq[name] = (seq[name] ?? 0) + 1);
15
+ rows.push(pk === "id" ? { id, ...row } : { ...row });
16
+ return pk === "id" ? id : row[pk];
17
+ },
18
+ async find(where: any = {}) {
19
+ return rows.filter((r) => match(r, where));
20
+ },
21
+ async findOne(where: any = {}) {
22
+ return rows.find((r) => match(r, where));
23
+ },
24
+ async get(id: any) {
25
+ return rows.find((row) => row[pk] === id);
26
+ },
27
+ async update(id: any, patch: any) {
28
+ const r = rows.find((row) => row[pk] === id);
29
+ if (r) Object.assign(r, patch);
30
+ },
31
+ };
32
+ }
33
+ const app = { data: { table: (n: string, pk?: string) => tbl(n, pk) }, log: noopLog() };
34
+ return { app, stores };
35
+ }
36
+
37
+ test("conformance-record: persists a filed conformance from hoisted result vars", async () => {
38
+ const { app, stores } = fakeApp();
39
+ const out = await handler(
40
+ {
41
+ processInstanceKey: "retro-inst-5",
42
+ variables: {
43
+ planKey: "o/r#5",
44
+ status: "filed",
45
+ commentUrl: "https://github.com/o/r/issues/5#issuecomment-1",
46
+ slicesMet: 4,
47
+ slicesReduced: 1,
48
+ slicesNotVerified: 1,
49
+ deviationsRaised: 2,
50
+ deviationsUnraised: 1,
51
+ hasDeviations: true,
52
+ summary: "6 items, 4 met",
53
+ "io.nanobpm.agentResult": { output: "the full conformance report" },
54
+ },
55
+ } as any,
56
+ app as any,
57
+ );
58
+
59
+ assertEquals(stores.plan_conformance.length, 1);
60
+ const row = stores.plan_conformance[0];
61
+ assertEquals(row.status, "filed");
62
+ assertEquals(row.comment_url, "https://github.com/o/r/issues/5#issuecomment-1");
63
+ assertEquals(row.slices_met, 4);
64
+ assertEquals(row.slices_reduced, 1);
65
+ assertEquals(row.slices_not_verified, 1);
66
+ assertEquals(row.deviations_raised, 2);
67
+ assertEquals(row.deviations_unraised, 1);
68
+ assertEquals(row.has_deviations, 1);
69
+ assertEquals(row.report, "the full conformance report");
70
+ // Tracks the retro instance and enters the inbox scan (issue #216) — a deviation escalates.
71
+ assertEquals(row.process_key, "retro-inst-5");
72
+ assertEquals(row.review_status, "reviewing");
73
+ // The gateway routes off the returned ground-truth flag, not the agent's hoisted var.
74
+ assertEquals(out, { hasDeviations: true });
75
+ });
76
+
77
+ test("conformance-record: derives has_deviations from ground truth even when the agent flag is absent", async () => {
78
+ const { app, stores } = fakeApp();
79
+ await handler(
80
+ { processInstanceKey: "retro-inst-6", variables: { planKey: "o/r#6", status: "filed", commentUrl: "https://x/6#c", slicesNotVerified: 1 } } as any,
81
+ app as any,
82
+ );
83
+ // The agent didn't set hasDeviations, but a not-verified item means the epic didn't cleanly meet spec.
84
+ assertEquals(stores.plan_conformance[0].has_deviations, 1);
85
+ });
86
+
87
+ test("conformance-record: a clean epic records has_deviations = 0", async () => {
88
+ const { app, stores } = fakeApp();
89
+ const out = await handler(
90
+ { processInstanceKey: "retro-inst-7", variables: { planKey: "o/r#7", status: "filed", commentUrl: "https://x/7#c", slicesMet: 3, hasDeviations: false } } as any,
91
+ app as any,
92
+ );
93
+ assertEquals(stores.plan_conformance[0].has_deviations, 0);
94
+ assertEquals(stores.plan_conformance[0].slices_met, 3);
95
+ // No deviation → settles straight to `reviewed`, never entering the inbox scan.
96
+ assertEquals(stores.plan_conformance[0].review_status, "reviewed");
97
+ assertEquals(out, { hasDeviations: false });
98
+ });
99
+
100
+ test("conformance-record: coerces filed without a comment URL to skipped", async () => {
101
+ const { app, stores } = fakeApp();
102
+ await handler(
103
+ { variables: { planKey: "o/r#8", status: "filed", summary: "forgot to post" } } as any,
104
+ app as any,
105
+ );
106
+ assertEquals(stores.plan_conformance[0].status, "skipped");
107
+ assertEquals(stores.plan_conformance[0].comment_url, null);
108
+ });
109
+
110
+ test("conformance-record: a non-filed status carries no verdict counts or deviations", async () => {
111
+ const { app, stores } = fakeApp();
112
+ // A "filed" that downgrades to skipped (no comment) must not persist the agent's counts /
113
+ // has_deviations — a skipped/blocked audit produced no verified verdict, so the row would be
114
+ // internally inconsistent (status=skipped yet has_deviations=1 with non-zero counts).
115
+ await handler(
116
+ {
117
+ variables: {
118
+ planKey: "o/r#11",
119
+ status: "filed",
120
+ slicesMet: 4,
121
+ slicesReduced: 1,
122
+ slicesNotVerified: 1,
123
+ deviationsRaised: 2,
124
+ deviationsUnraised: 1,
125
+ hasDeviations: true,
126
+ summary: "audit ran but never posted",
127
+ "io.nanobpm.agentResult": { output: "transcript explaining why" },
128
+ },
129
+ } as any,
130
+ app as any,
131
+ );
132
+ const row = stores.plan_conformance[0];
133
+ assertEquals(row.status, "skipped");
134
+ assertEquals(row.slices_met, 0);
135
+ assertEquals(row.slices_reduced, 0);
136
+ assertEquals(row.slices_not_verified, 0);
137
+ assertEquals(row.deviations_raised, 0);
138
+ assertEquals(row.deviations_unraised, 0);
139
+ assertEquals(row.has_deviations, 0);
140
+ // summary + report are human-readable context — retained so a skipped/blocked row still explains itself.
141
+ assertEquals(row.summary, "audit ran but never posted");
142
+ assertEquals(row.report, "transcript explaining why");
143
+ });
144
+
145
+ test("conformance-record: coerces string-encoded numeric counts hoisted by the agent", async () => {
146
+ const { app, stores } = fakeApp();
147
+ // The agentTask runner hoists result-JSON keys as-is; an agent may emit counts as strings ("1").
148
+ // These must be parsed, not silently coerced to 0 (which would wrongly clear the verdict).
149
+ await handler(
150
+ {
151
+ processInstanceKey: "retro-inst-12",
152
+ variables: {
153
+ planKey: "o/r#12",
154
+ status: "filed",
155
+ commentUrl: "https://x/12#c",
156
+ slicesMet: "4",
157
+ slicesReduced: "1",
158
+ slicesNotVerified: "0",
159
+ deviationsRaised: "2",
160
+ deviationsUnraised: "0",
161
+ hasDeviations: false,
162
+ },
163
+ } as any,
164
+ app as any,
165
+ );
166
+ const row = stores.plan_conformance[0];
167
+ assertEquals(row.slices_met, 4);
168
+ assertEquals(row.slices_reduced, 1);
169
+ assertEquals(row.deviations_raised, 2);
170
+ // A reduced item is ground truth for a deviation even though the agent's flag was false.
171
+ assertEquals(row.has_deviations, 1);
172
+ });
173
+
174
+ test("conformance-record: honours a string-encoded hasDeviations flag", async () => {
175
+ const { app, stores } = fakeApp();
176
+ // A clean epic (no reduced / not-verified / unraised) where the agent emits hasDeviations as the
177
+ // string "true" must still record a deviation — a stringified boolean can't silently be dropped.
178
+ await handler(
179
+ {
180
+ processInstanceKey: "retro-inst-13",
181
+ variables: {
182
+ planKey: "o/r#13",
183
+ status: "filed",
184
+ commentUrl: "https://x/13#c",
185
+ slicesMet: 3,
186
+ hasDeviations: "true",
187
+ },
188
+ } as any,
189
+ app as any,
190
+ );
191
+ assertEquals(stores.plan_conformance[0].has_deviations, 1);
192
+ });
193
+
194
+ test("conformance-record: honours an explicit blocked status", async () => {
195
+ const { app, stores } = fakeApp();
196
+ await handler(
197
+ { variables: { planKey: "o/r#9", status: "blocked", summary: "no read access" } } as any,
198
+ app as any,
199
+ );
200
+ assertEquals(stores.plan_conformance[0].status, "blocked");
201
+ });
202
+
203
+ test("conformance-record: defaults to skipped when the agent reported nothing", async () => {
204
+ const { app, stores } = fakeApp();
205
+ await handler(
206
+ { variables: { planKey: "o/r#10", summary: "nothing shipped" } } as any,
207
+ app as any,
208
+ );
209
+ assertEquals(stores.plan_conformance[0].status, "skipped");
210
+ });
211
+
212
+ test("conformance-record: coerces a numeric processInstanceKey to a string (TEXT process_key never drifts)", async () => {
213
+ const { app, stores } = fakeApp();
214
+ await handler(
215
+ { processInstanceKey: 220592130 as any, variables: { planKey: "o/r#11", status: "filed", commentUrl: "https://x/11#c", slicesNotVerified: 1 } } as any,
216
+ app as any,
217
+ );
218
+ const row = stores.plan_conformance[0];
219
+ assertEquals(row.process_key, "220592130");
220
+ assertEquals(typeof row.process_key, "string");
221
+ assertEquals(row.review_status, "reviewing");
222
+ });
223
+
224
+ test("conformance-record: fails (not a silent, untrackable escalation) when there is no processKey but there are deviations", async () => {
225
+ const { app, stores } = fakeApp();
226
+ // No `processInstanceKey` + deviations: the handler would otherwise return `hasDeviations:true`
227
+ // (routing retro to `conformance-escalation`) while the row is `reviewed`/`process_key=null`, an
228
+ // ack `pollUserTasks` can never surface nor `onTerminated` clear — an invisible, wedged escalation.
229
+ // Fail loudly instead so the run retries/alerts rather than encoding that silent state.
230
+ await assertRejects(
231
+ () =>
232
+ handler(
233
+ { variables: { planKey: "o/r#12", status: "filed", commentUrl: "https://x/12#c", slicesNotVerified: 1, hasDeviations: true } } as any,
234
+ app as any,
235
+ ),
236
+ Error,
237
+ "no processInstanceKey",
238
+ );
239
+ // Nothing was persisted: the throw precedes the write, so no untrackable row is left behind.
240
+ assertEquals(stores.plan_conformance.length, 0);
241
+ });
@@ -0,0 +1,127 @@
1
+ // pr.conformance-record — persist the `senior:conformance` agent's result into `plan_conformance`
2
+ // (052_plan_conformance.sql): the outcome status, the report comment it posted on the epic issue,
3
+ // the per-item verdict counts, and the two deviation counts (raised / unraised). Advisory only —
4
+ // this gates no control flow; it exists so the epic surface can show what the conformance audit
5
+ // concluded, and (in a later slice) drive escalation off `has_deviations`.
6
+ //
7
+ // The agentTask runner hoists the agent's result-JSON keys (`status`, `commentUrl`, the counts,
8
+ // `hasDeviations`, `summary`) into top-level process variables (same mechanism pr.retro-record
9
+ // reads `status`/`pr`/`summary` through), and exposes the raw transcript under the
10
+ // `io.nanobpm.agentResult` envelope's `.output`.
11
+ import type { AppJobHandler } from "@nanobpm/urban";
12
+ import { recordConformance } from "../../app/conformance.ts";
13
+ import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
14
+
15
+ const AGENT_RESULT_KEY = "io.nanobpm.agentResult";
16
+
17
+ // Input typed off the model data envelope (`ConformanceRecordIn` in retro.bpmn) — ADR 0040.
18
+ type In = WorkerInputs["pr.conformance-record"];
19
+
20
+ function asStr(v: unknown): string | null {
21
+ return typeof v === "string" && v.trim() !== "" ? v.trim() : null;
22
+ }
23
+
24
+ function asInt(v: unknown): number {
25
+ // Tolerate a numeric string ("1") too — the agentTask runner hoists result-JSON keys as-is, and an
26
+ // agent may emit counts as strings; silently coercing those to 0 would wrongly clear the verdict.
27
+ const n = typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" ? Number(v) : Number.NaN;
28
+ return Number.isFinite(n) ? Math.max(0, Math.trunc(n)) : 0;
29
+ }
30
+
31
+ // Tolerant boolean coercion mirroring record-plan-review's `isApproved`: honour boolean `true` OR a
32
+ // case-insensitive "true" string, so a stringified flag the agent hoists isn't silently dropped.
33
+ function asBool(v: unknown): boolean {
34
+ return v === true || (typeof v === "string" && v.trim().toLowerCase() === "true");
35
+ }
36
+
37
+ function asStatus(v: unknown, hasComment: boolean): "filed" | "skipped" | "blocked" {
38
+ const s = asStr(v);
39
+ if (s === "filed" || s === "skipped" || s === "blocked") {
40
+ // A "filed" with no report comment is not really filed — downgrade to skipped so the record
41
+ // never claims a report a human can't open.
42
+ if (s === "filed" && !hasComment) return "skipped";
43
+ return s;
44
+ }
45
+ return hasComment ? "filed" : "skipped";
46
+ }
47
+
48
+ const handler: AppJobHandler<In> = async (job, app) => {
49
+ const planKey = job.variables.planKey;
50
+
51
+ const commentUrl = asStr(job.variables.commentUrl);
52
+ const status = asStatus(job.variables.status, commentUrl !== null);
53
+ const summary = asStr(job.variables.summary);
54
+
55
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
56
+ const env = (job.variables as Record<string, unknown>)[AGENT_RESULT_KEY] as { output?: unknown } | undefined;
57
+ const report = typeof env?.output === "string" ? env.output : null;
58
+
59
+ // Only a "filed" audit produced a verified verdict; a skipped/blocked one has no trustworthy
60
+ // per-item counts or deviations, so persist zeros rather than whatever the agent hoisted. This
61
+ // keeps the row internally consistent (no status="skipped" with has_deviations=1) and honours the
62
+ // schema/prompt contract that skipped/blocked audits omit counts. summary + report are retained as
63
+ // human-readable context explaining why the audit didn't file.
64
+ const filed = status === "filed";
65
+ const slicesReduced = filed ? asInt(job.variables.slicesReduced) : 0;
66
+ const slicesNotVerified = filed ? asInt(job.variables.slicesNotVerified) : 0;
67
+ const deviationsUnraised = filed ? asInt(job.variables.deviationsUnraised) : 0;
68
+ // Derive `has_deviations` from ground truth rather than trusting the agent's boolean alone: any
69
+ // reduced / not-verified item, or any unraised deviation, means the epic did not cleanly meet its
70
+ // spec. The agent's flag is honoured as an additional trigger but can't suppress a real signal.
71
+ // Forced false for a non-filed audit (all counts are zeroed above, and there is no verified verdict).
72
+ const hasDeviations = filed &&
73
+ (asBool(job.variables.hasDeviations) ||
74
+ slicesReduced > 0 || slicesNotVerified > 0 || deviationsUnraised > 0);
75
+
76
+ // Track this retro instance on the conformance row so `pollUserTasks` can find the escalation ack
77
+ // task, but only mark it `reviewing` when there IS something to escalate — a clean run settles
78
+ // straight to `reviewed` and never enters the inbox scan (migration 054). Coerce the instance key
79
+ // to a string (the engine can hand back a numeric key) so `plan_conformance.process_key` (TEXT)
80
+ // never drifts to a number and break the string-filter reads in `pollUserTasks`/`openUserTasks` —
81
+ // the same `String(...)` coercion app/service.ts applies when it stamps `process_key`.
82
+ const processKey = job.processInstanceKey != null ? String(job.processInstanceKey) : null;
83
+
84
+ // Invariant: an escalation must be trackable. If we found deviations to escalate but have no
85
+ // process key to key the `reviewing` row off, the `hasDeviations` return below would still route
86
+ // retro to the `conformance-escalation` user task — yet `pollUserTasks` can never surface that ack
87
+ // (it skips rows without `process_key`) nor can the `onTerminated` binding ever clear it, so the
88
+ // escalation wedges forever, invisible to any human. Rather than record that silent, unreachable
89
+ // state, fail loudly so the run retries/alerts. `job.processInstanceKey` is always present for an
90
+ // activated job, so this only fires on a genuine engine-contract violation.
91
+ if (hasDeviations && processKey == null) {
92
+ throw new Error(
93
+ `conformance-record: ${planKey} has deviations to escalate but no processInstanceKey to track ` +
94
+ "the escalation — refusing to route to an untrackable conformance-escalation ack task",
95
+ );
96
+ }
97
+
98
+ await recordConformance(app.data, planKey, {
99
+ status,
100
+ commentUrl: filed ? commentUrl : null,
101
+ slicesMet: filed ? asInt(job.variables.slicesMet) : 0,
102
+ slicesReduced,
103
+ slicesNotVerified,
104
+ deviationsRaised: filed ? asInt(job.variables.deviationsRaised) : 0,
105
+ deviationsUnraised,
106
+ hasDeviations,
107
+ summary,
108
+ report,
109
+ processKey,
110
+ // Only enter the `reviewing` inbox scan when we actually have a `processKey` to key off — a
111
+ // null key can never be found by `pollUserTasks` (it skips rows without `process_key`) nor
112
+ // cleared by the `instanceTracking` `onTerminated` binding, so a `reviewing` row with no key
113
+ // would wedge forever. The invariant guard above already rejected `hasDeviations` with a null
114
+ // key, so `reviewing` here always carries a non-null `processKey`.
115
+ reviewStatus: hasDeviations ? "reviewing" : "reviewed",
116
+ });
117
+
118
+ app.log.info(
119
+ `conformance-record: ${planKey} — status=${status} deviations=${hasDeviations ? "yes" : "no"}`,
120
+ );
121
+ // Return the ground-truth `hasDeviations` as a process variable so the `gw-deviations` gateway
122
+ // routes to the human ack task (retro.bpmn) — overriding the agent's hoisted flag with the value
123
+ // reconciled against the recorded counts above.
124
+ return { hasDeviations };
125
+ };
126
+
127
+ export default handler;
@@ -26,6 +26,10 @@ function fakeApp() {
26
26
  Promise.resolve(
27
27
  store.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v)),
28
28
  ),
29
+ findOne: (q: any) =>
30
+ Promise.resolve(
31
+ store.find((r) => Object.entries(q).every(([f, v]) => r[f] === v)) ?? null,
32
+ ),
29
33
  insert: (row: any) => {
30
34
  store.push(row);
31
35
  return Promise.resolve(store.length);
@@ -12,11 +12,11 @@
12
12
  // shapes the escalation payload on a block.
13
13
  import type { AppJobHandler } from "@nanobpm/urban";
14
14
  import { matchTags, tag } from "@nanobpm/urban/effect";
15
- import { ABANDONED_STATUS, abandonTokenFromUrl } from "../../app/abandon.ts";
15
+ import { abandonTokenFromUrl } from "../../app/abandon.ts";
16
16
  import { checkBaseTarget, classifyBaseGuard } from "../../app/baseGuard.ts";
17
17
  import { classifyPrLiveness, enqueueViaComment, fetchPrState, mergePr } from "../../app/github.ts";
18
18
  import { classifyMergeLanding, DEFAULT_MERGE_PROTOCOL, loadMergeProtocol } from "../../app/mergeProtocol.ts";
19
- import { ensurePr, MERGE_ADMIN, MERGE_METHOD } from "../../app/service.ts";
19
+ import { abandonClosedPr, ensurePr, MERGE_ADMIN, MERGE_METHOD } from "../../app/service.ts";
20
20
  import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
21
21
 
22
22
  // Input typed off the model data envelope (`MergeAttemptIn` in merge-loop.bpmn) — ADR 0040.
@@ -74,22 +74,17 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
74
74
  // user task: a closed PR is terminal state, not a human decision. Symmetric with the merged
75
75
  // short-circuit above and runs on the same live-state read, so one `fetchPrState` classifies both.
76
76
  if (liveness === "closed") {
77
- await app.data.table("merges", "id").insert({
78
- pr_key: prKey,
79
- outcome: "abandoned",
80
- method: "pr-closed",
81
- detail: "PR was closed on GitHub without merging (e.g. superseded) abandoning the merge loop",
82
- at: now,
83
- });
84
- // Terminal status write. This branch drives the model's terminate/abandon end event, which runs
85
- // NO mark-merged worker (the merged path's `pr.mark-merged` is what sets `status:"merged"`). If
86
- // we don't flip the row here it lingers on its in-flight status (e.g. the transient `merging`),
87
- // so `activePrs`/delivery keep treating a dead PR as live. Symmetric with mark-merged's write;
88
- // `ensurePr` above guarantees the row exists to update.
89
- await app.data.table("pull_requests", "pr_key").update(prKey, {
90
- status: ABANDONED_STATUS,
91
- updated_at: now,
92
- });
77
+ // One canonical abandon writer (`abandonClosedPr`, app/service.ts) records the terminal `merges`
78
+ // audit row and flips the `pull_requests` row — and every `plan_tasks` row keyed to this PR — to
79
+ // `abandoned`. Flipping the task row (not just the PR row) drops a dead wave member out of the
80
+ // wave-merge gate (#352). This branch drives the model's terminate/abandon end event, which runs
81
+ // NO mark-merged worker, so the terminal write must happen here; `ensurePr` above guarantees the
82
+ // PR row exists to update. Symmetric with the merged short-circuit on the same live-state read.
83
+ await abandonClosedPr(
84
+ app.data,
85
+ prKey,
86
+ "PR was closed on GitHub without merging (e.g. superseded) — abandoning the merge loop",
87
+ );
93
88
  return { mergeStatus: "abandoned" };
94
89
  }
95
90
 
@@ -37,6 +37,8 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
37
37
  test("retro-gather: emits a digest brief + learning count for the plan", async () => {
38
38
  const { data, stores } = memData();
39
39
  stores["plans"] = [{ plan_key: "o/r#3", repo: "o/r", issue_url: "https://x/3", title: "Epic" }];
40
+ stores["plan_tasks"] = [{ id: 1, plan_key: "o/r#3", task_index: 0, task_id: "t1", title: "Auth", prompt: "add auth", status: "opened", pr_key: "o/r#10" }];
41
+ stores["pull_requests"] = [{ pr_key: "o/r#10", status: "merged" }];
40
42
  await appendEntry(data, "o/r#3", { author_task: "t1", kind: "learning", body: "regen before build" });
41
43
  await appendEntry(data, "o/r#3", { author_task: "t2", kind: "learning", body: "use nextest" });
42
44
 
@@ -50,6 +52,10 @@ test("retro-gather: emits a digest brief + learning count for the plan", async (
50
52
  assertStringIncludes(String(out.retroDigest), "regen before build");
51
53
  assertStringIncludes(String(out.retroDigest), "use nextest");
52
54
  assertStringIncludes(String(out.retroDigest), "o/r#3");
55
+ // The gather step also produces the conformance brief pointing at the landed PR + slice spec.
56
+ assertStringIncludes(String(out.conformanceDigest), "Conformance input");
57
+ assertStringIncludes(String(out.conformanceDigest), "o/r#10");
58
+ assertStringIncludes(String(out.conformanceDigest), "add auth");
53
59
  });
54
60
 
55
61
  test("retro-gather: an epic with no learnings still renders a valid brief", async () => {
@@ -1,9 +1,13 @@
1
1
  // pr.retro-gather — first step of the `retro` process. Assemble the plan's accumulated
2
2
  // coordination knowledge (the `learning` blackboard entries agents posted while implementing, plus
3
- // the task-delta rollup and any other blackboard notes) into a compact markdown brief, and emit it
4
- // as `retroDigest`. The next step maps that onto the `senior:retro` agent's `appendPrompt`, so the
5
- // agent reflects on real material rather than re-deriving it.
3
+ // the task-delta rollup and any other blackboard notes) into a compact markdown brief, emitted as
4
+ // `retroDigest`, AND the spec-conformance material (the spec + the landed PRs to examine + the
5
+ // deviations raised during implementation) as `conformanceDigest`. The two downstream agent steps
6
+ // (`senior:conformance` then `senior:retro`) map these onto their `appendPrompt`, so each reflects
7
+ // on real material rather than re-deriving it.
6
8
  import type { AppJobHandler } from "@nanobpm/urban";
9
+ import { readBlackboard } from "../../app/blackboard.ts";
10
+ import { gatherConformance, renderConformanceBrief } from "../../app/conformance.ts";
7
11
  import { gatherRetro, renderRetroBrief } from "../../app/retro.ts";
8
12
  import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
9
13
 
@@ -13,15 +17,23 @@ type In = WorkerInputs["pr.retro-gather"];
13
17
  interface Out extends Record<string, unknown> {
14
18
  retroDigest: string;
15
19
  retroLearnings: number;
20
+ conformanceDigest: string;
16
21
  }
17
22
 
18
23
  const handler: AppJobHandler<In, Out> = async (job, app) => {
19
24
  const planKey = job.variables.planKey;
20
- const digest = await gatherRetro(app.data, planKey);
21
- app.log.info(`retro-gather: ${planKey} ${digest.counts.learnings} learnings, ${digest.counts.deltas} deltas`);
25
+ // Both gatherRetro and gatherConformance need the plan's blackboard; scan it once here and share
26
+ // the snapshot so a retro run does a single blackboard read, not one per gatherer.
27
+ const entries = await readBlackboard(app.data, planKey);
28
+ const digest = await gatherRetro(app.data, planKey, entries);
29
+ const conformance = await gatherConformance(app.data, planKey, entries);
30
+ app.log.info(
31
+ `retro-gather: ${planKey} — ${digest.counts.learnings} learnings, ${digest.counts.deltas} deltas, ${conformance.deliveredPrs.length} delivered PR(s)`,
32
+ );
22
33
  return {
23
34
  retroDigest: renderRetroBrief(digest),
24
35
  retroLearnings: digest.counts.learnings,
36
+ conformanceDigest: renderConformanceBrief(conformance),
25
37
  };
26
38
  };
27
39