@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.
@@ -13,6 +13,18 @@
13
13
  <nano:extend name="pr" type="string" optional="true" />
14
14
  <nano:extend name="summary" type="string" optional="true" />
15
15
  </nano:shape>
16
+ <nano:shape id="ConformanceRecordIn" name="Record conformance — input">
17
+ <nano:extend name="planKey" type="string" />
18
+ <nano:extend name="status" type="string" optional="true" />
19
+ <nano:extend name="commentUrl" type="string" optional="true" />
20
+ <nano:extend name="slicesMet" type="integer" optional="true" />
21
+ <nano:extend name="slicesReduced" type="integer" optional="true" />
22
+ <nano:extend name="slicesNotVerified" type="integer" optional="true" />
23
+ <nano:extend name="deviationsRaised" type="integer" optional="true" />
24
+ <nano:extend name="deviationsUnraised" type="integer" optional="true" />
25
+ <nano:extend name="hasDeviations" type="boolean" optional="true" />
26
+ <nano:extend name="summary" type="string" optional="true" />
27
+ </nano:shape>
16
28
  </nano:shapes>
17
29
  </bpmn:extensionElements>
18
30
  <bpmn:startEvent id="Start" name="Epic complete">
@@ -26,6 +38,29 @@
26
38
  </zeebe:properties>
27
39
  </bpmn:extensionElements>
28
40
  <bpmn:incoming>f_start</bpmn:incoming>
41
+ <bpmn:outgoing>f_toConformance</bpmn:outgoing>
42
+ </bpmn:serviceTask>
43
+ <bpmn:serviceTask id="conformance" name="Verify implementation vs spec (agent)">
44
+ <bpmn:extensionElements>
45
+ <zeebe:taskDefinition type="senior:conformance" />
46
+ <zeebe:linkedResources>
47
+ <zeebe:linkedResource resourceId="conformance.md" bindingType="latest" resourceType="GenericScript" linkName="prompt" />
48
+ </zeebe:linkedResources>
49
+ <zeebe:ioMapping>
50
+ <zeebe:input source="=conformanceDigest" target="appendPrompt" />
51
+ </zeebe:ioMapping>
52
+ </bpmn:extensionElements>
53
+ <bpmn:incoming>f_toConformance</bpmn:incoming>
54
+ <bpmn:outgoing>f_toRecordConformance</bpmn:outgoing>
55
+ </bpmn:serviceTask>
56
+ <bpmn:serviceTask id="record-conformance" name="Record conformance">
57
+ <bpmn:extensionElements>
58
+ <zeebe:taskDefinition type="pr.conformance-record" />
59
+ <zeebe:properties>
60
+ <zeebe:property name="io.nanobpm.dataEnvelope.in" value="ConformanceRecordIn" />
61
+ </zeebe:properties>
62
+ </bpmn:extensionElements>
63
+ <bpmn:incoming>f_toRecordConformance</bpmn:incoming>
29
64
  <bpmn:outgoing>f_toSynthesize</bpmn:outgoing>
30
65
  </bpmn:serviceTask>
31
66
  <bpmn:serviceTask id="synthesize" name="Synthesize &#38; promote (agent)">
@@ -55,7 +90,9 @@
55
90
  <bpmn:incoming>f_toEnd</bpmn:incoming>
56
91
  </bpmn:endEvent>
57
92
  <bpmn:sequenceFlow id="f_start" sourceRef="Start" targetRef="gather" />
58
- <bpmn:sequenceFlow id="f_toSynthesize" sourceRef="gather" targetRef="synthesize" />
93
+ <bpmn:sequenceFlow id="f_toConformance" sourceRef="gather" targetRef="conformance" />
94
+ <bpmn:sequenceFlow id="f_toRecordConformance" sourceRef="conformance" targetRef="record-conformance" />
95
+ <bpmn:sequenceFlow id="f_toSynthesize" sourceRef="record-conformance" targetRef="synthesize" />
59
96
  <bpmn:sequenceFlow id="f_toRecord" sourceRef="synthesize" targetRef="record" />
60
97
  <bpmn:sequenceFlow id="f_toEnd" sourceRef="record" targetRef="End" />
61
98
  </bpmn:process>
@@ -70,34 +107,48 @@
70
107
  <bpmndi:BPMNShape id="BPMNShape_gather" bpmnElement="gather">
71
108
  <dc:Bounds x="216" y="80" width="100" height="80" />
72
109
  </bpmndi:BPMNShape>
73
- <bpmndi:BPMNShape id="BPMNShape_synthesize" bpmnElement="synthesize">
110
+ <bpmndi:BPMNShape id="BPMNShape_conformance" bpmnElement="conformance">
74
111
  <dc:Bounds x="416" y="80" width="100" height="80" />
75
112
  </bpmndi:BPMNShape>
76
- <bpmndi:BPMNShape id="BPMNShape_record" bpmnElement="record">
113
+ <bpmndi:BPMNShape id="BPMNShape_record-conformance" bpmnElement="record-conformance">
77
114
  <dc:Bounds x="616" y="80" width="100" height="80" />
78
115
  </bpmndi:BPMNShape>
116
+ <bpmndi:BPMNShape id="BPMNShape_synthesize" bpmnElement="synthesize">
117
+ <dc:Bounds x="816" y="80" width="100" height="80" />
118
+ </bpmndi:BPMNShape>
119
+ <bpmndi:BPMNShape id="BPMNShape_record" bpmnElement="record">
120
+ <dc:Bounds x="1016" y="80" width="100" height="80" />
121
+ </bpmndi:BPMNShape>
79
122
  <bpmndi:BPMNShape id="BPMNShape_End" bpmnElement="End">
80
- <dc:Bounds x="816" y="102" width="36" height="36" />
123
+ <dc:Bounds x="1216" y="102" width="36" height="36" />
81
124
  <bpmndi:BPMNLabel>
82
- <dc:Bounds x="794" y="143" width="80" height="14" />
125
+ <dc:Bounds x="1194" y="143" width="80" height="14" />
83
126
  </bpmndi:BPMNLabel>
84
127
  </bpmndi:BPMNShape>
85
128
  <bpmndi:BPMNEdge id="BPMNEdge_f_start" bpmnElement="f_start">
86
129
  <di:waypoint x="116" y="120" />
87
130
  <di:waypoint x="216" y="120" />
88
131
  </bpmndi:BPMNEdge>
89
- <bpmndi:BPMNEdge id="BPMNEdge_f_toSynthesize" bpmnElement="f_toSynthesize">
132
+ <bpmndi:BPMNEdge id="BPMNEdge_f_toConformance" bpmnElement="f_toConformance">
90
133
  <di:waypoint x="316" y="120" />
91
134
  <di:waypoint x="416" y="120" />
92
135
  </bpmndi:BPMNEdge>
93
- <bpmndi:BPMNEdge id="BPMNEdge_f_toRecord" bpmnElement="f_toRecord">
136
+ <bpmndi:BPMNEdge id="BPMNEdge_f_toRecordConformance" bpmnElement="f_toRecordConformance">
94
137
  <di:waypoint x="516" y="120" />
95
138
  <di:waypoint x="616" y="120" />
96
139
  </bpmndi:BPMNEdge>
97
- <bpmndi:BPMNEdge id="BPMNEdge_f_toEnd" bpmnElement="f_toEnd">
140
+ <bpmndi:BPMNEdge id="BPMNEdge_f_toSynthesize" bpmnElement="f_toSynthesize">
98
141
  <di:waypoint x="716" y="120" />
99
142
  <di:waypoint x="816" y="120" />
100
143
  </bpmndi:BPMNEdge>
144
+ <bpmndi:BPMNEdge id="BPMNEdge_f_toRecord" bpmnElement="f_toRecord">
145
+ <di:waypoint x="916" y="120" />
146
+ <di:waypoint x="1016" y="120" />
147
+ </bpmndi:BPMNEdge>
148
+ <bpmndi:BPMNEdge id="BPMNEdge_f_toEnd" bpmnElement="f_toEnd">
149
+ <di:waypoint x="1116" y="120" />
150
+ <di:waypoint x="1216" y="120" />
151
+ </bpmndi:BPMNEdge>
101
152
  </bpmndi:BPMNPlane>
102
153
  </bpmndi:BPMNDiagram>
103
154
  </bpmn:definitions>
@@ -0,0 +1,105 @@
1
+ # Conformance agent — verify the implementation against the spec, by reading the code
2
+
3
+ You are the **spec-conformance agent**. An epic (a fan-out `plan`) has finished — every one of its
4
+ slices has reached a terminal state and the landed PRs are now in the tree. Your job is to answer a
5
+ single, evidence-based question for a human:
6
+
7
+ > **Did we actually build what the spec asked for — no more, no less?**
8
+
9
+ You are NOT the retro agent (that one distils lessons). You are the auditor. Crucially, **you verify
10
+ against the real implementation, not against what the agents claimed.** Transcripts, `learning`
11
+ notes, and even a slice's own status can be optimistic or wrong. The diff cannot. Read the code.
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` the work landed in.
19
+ - `variables.issueUrl` — the epic's source issue. **This issue body is the SPEC.**
20
+ - **`variables.conformanceDigest`** — appended below the `---` separator: the spec broken into
21
+ slices (each slice's planner-supplied `prompt` is its acceptance brief), the list of **delivered
22
+ PRs** you must examine, and the deviations that were **raised** during implementation
23
+ (`scope-change` entries). This tells you *what to check* and *where to look* — it does NOT tell
24
+ you the answer.
25
+
26
+ You have `gh` / git authenticated for the target repository.
27
+
28
+ ## What to do
29
+
30
+ 1. **Read the spec.** `gh issue view <n> --repo <repo>` for the epic issue body, plus every slice's
31
+ `prompt` in the digest. Together these are the acceptance criteria you are auditing against.
32
+ 2. **Examine the ACTUAL implementation.** For every delivered PR in the digest:
33
+ `gh pr diff <n> --repo <repo>`, and read the touched source and tests (clone/checkout if you
34
+ need to navigate). Do not trust the PR description — read what the code does.
35
+ 3. **Distinguish real delivery from the appearance of it.** This is the whole point of examining
36
+ code. For each spec item, decide whether it is *load-bearing* in the shipped system:
37
+ - Is the new code actually **wired in / reachable**, or is it a dead entrypoint behind a flag,
38
+ a stub, or a synthetic path nothing calls?
39
+ - Do the **tests exercise the real behaviour**, or are they asserting on mocks/gated paths so
40
+ they pass without proving the feature works?
41
+ - Was the item delivered **in full**, or narrowed to a subset while looking complete?
42
+ 4. **Assign each spec item an acceptance verdict**, with a one-line evidence pointer (file / PR /
43
+ test) for each:
44
+ - **met** — delivered as specified, wired in, and genuinely tested.
45
+ - **met-in-unit-only** — implemented and unit-tested, but not proven wired into the live system.
46
+ - **reduced** — delivered in a narrower form than the spec asked for.
47
+ - **not-verified** — you could not confirm it from the implementation (missing, stubbed, dead
48
+ code, or tests that don't actually exercise it).
49
+ 5. **Reconcile the raised deviations.** For each `scope-change` entry in the digest, confirm the
50
+ code matches what was said, and note it as a **raised** deviation.
51
+ 6. **Hunt for UNRAISED deviations** — things the code does that the spec did *not* ask for, or spec
52
+ items silently dropped, that were **never** flagged on the blackboard. These are the most
53
+ valuable finding: scope drift nobody surfaced.
54
+ 7. **Post the conformance report as a comment on the epic issue**
55
+ (`gh issue comment <n> --repo <repo> --body-file <file>`). Structure it: a one-line verdict, a
56
+ per-item table (item → verdict → evidence), the raised deviations, and the unraised deviations.
57
+ Be specific and cite files/PRs — this comment is the deliverable a human reads.
58
+
59
+ ## Do not
60
+
61
+ - Do **not** open, modify, or merge any PR. You audit; you do not remediate. (A later stage decides
62
+ whether a finding warrants a follow-up task.)
63
+ - Do **not** soften a `not-verified` into a `met` because the transcript sounded confident. Absence
64
+ of evidence in the code is `not-verified`.
65
+ - Do **not** manufacture deviations to look thorough. A clean epic that fully met its spec is a
66
+ perfectly good — and common — result.
67
+
68
+ ## Output contract
69
+
70
+ Write a JSON object of **result variables** to the file named by the `AGENT_RESULT_FILE`
71
+ environment variable:
72
+
73
+ ```json
74
+ {
75
+ "status": "filed",
76
+ "commentUrl": "https://github.com/owner/repo/issues/123#issuecomment-456",
77
+ "slicesMet": 4,
78
+ "slicesReduced": 1,
79
+ "slicesNotVerified": 1,
80
+ "deviationsRaised": 2,
81
+ "deviationsUnraised": 1,
82
+ "hasDeviations": true,
83
+ "summary": "6 items: 4 met, 1 reduced (auth rate-limit narrowed to per-IP), 1 not-verified (webhook retry path is dead code). 2 raised + 1 unraised deviation (added a /debug route not in spec)."
84
+ }
85
+ ```
86
+
87
+ Rules:
88
+
89
+ - `status` — one of:
90
+ - `filed` — you examined the implementation and posted the report comment. Set `commentUrl`.
91
+ - `skipped` — there was no landed implementation to examine (nothing shipped). Explain in
92
+ `summary`; omit the counts.
93
+ - `blocked` — you could not proceed (e.g. no read access, could not fetch a PR). Explain in
94
+ `summary`.
95
+ - `commentUrl` — the URL of the report comment you posted on the epic issue, for `filed`.
96
+ - `slicesMet` / `slicesReduced` / `slicesNotVerified` — integer counts of your per-item verdicts
97
+ (count `met-in-unit-only` under `slicesReduced`, since it is not full live delivery).
98
+ - `deviationsRaised` — count of `scope-change` deviations you reconciled.
99
+ - `deviationsUnraised` — count of deviations you found that were never flagged.
100
+ - `hasDeviations` — `true` when anything is reduced / not-verified / an unraised deviation exists;
101
+ i.e. the epic did not cleanly meet its spec. A later stage uses this to decide escalation.
102
+ - `summary` — a short human-readable verdict.
103
+
104
+ You are advisory and post-merge: you gate no delivery. Your value is an honest, code-grounded
105
+ account of what shipped versus what was asked — surface it plainly, and let the humans act on it.
@@ -10,6 +10,11 @@ so the *next* fleet — and human contributors — never re-learn it the hard wa
10
10
  You are the mechanism that lifts a lesson from "a thing one agent happened to hit" to "a thing the
11
11
  repo now tells everyone up front."
12
12
 
13
+ > **Scope:** a separate **conformance** agent runs before you and audits whether the epic met its
14
+ > spec (posting its own report on the epic issue). That is *not* your job — do **not** re-verify
15
+ > acceptance or re-litigate scope here. Focus purely on distilling reusable **lessons** and
16
+ > promoting them into the repo.
17
+
13
18
  ## Input
14
19
 
15
20
  The job payload (stdin JSON) carries:
@@ -0,0 +1,199 @@
1
+ import { test } from "node:test";
2
+ import { assertEquals } 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
+ await handler(
40
+ {
41
+ variables: {
42
+ planKey: "o/r#5",
43
+ status: "filed",
44
+ commentUrl: "https://github.com/o/r/issues/5#issuecomment-1",
45
+ slicesMet: 4,
46
+ slicesReduced: 1,
47
+ slicesNotVerified: 1,
48
+ deviationsRaised: 2,
49
+ deviationsUnraised: 1,
50
+ hasDeviations: true,
51
+ summary: "6 items, 4 met",
52
+ "io.nanobpm.agentResult": { output: "the full conformance report" },
53
+ },
54
+ } as any,
55
+ app as any,
56
+ );
57
+
58
+ assertEquals(stores.plan_conformance.length, 1);
59
+ const row = stores.plan_conformance[0];
60
+ assertEquals(row.status, "filed");
61
+ assertEquals(row.comment_url, "https://github.com/o/r/issues/5#issuecomment-1");
62
+ assertEquals(row.slices_met, 4);
63
+ assertEquals(row.slices_reduced, 1);
64
+ assertEquals(row.slices_not_verified, 1);
65
+ assertEquals(row.deviations_raised, 2);
66
+ assertEquals(row.deviations_unraised, 1);
67
+ assertEquals(row.has_deviations, 1);
68
+ assertEquals(row.report, "the full conformance report");
69
+ });
70
+
71
+ test("conformance-record: derives has_deviations from ground truth even when the agent flag is absent", async () => {
72
+ const { app, stores } = fakeApp();
73
+ await handler(
74
+ { variables: { planKey: "o/r#6", status: "filed", commentUrl: "https://x/6#c", slicesNotVerified: 1 } } as any,
75
+ app as any,
76
+ );
77
+ // The agent didn't set hasDeviations, but a not-verified item means the epic didn't cleanly meet spec.
78
+ assertEquals(stores.plan_conformance[0].has_deviations, 1);
79
+ });
80
+
81
+ test("conformance-record: a clean epic records has_deviations = 0", async () => {
82
+ const { app, stores } = fakeApp();
83
+ await handler(
84
+ { variables: { planKey: "o/r#7", status: "filed", commentUrl: "https://x/7#c", slicesMet: 3, hasDeviations: false } } as any,
85
+ app as any,
86
+ );
87
+ assertEquals(stores.plan_conformance[0].has_deviations, 0);
88
+ assertEquals(stores.plan_conformance[0].slices_met, 3);
89
+ });
90
+
91
+ test("conformance-record: coerces filed without a comment URL to skipped", async () => {
92
+ const { app, stores } = fakeApp();
93
+ await handler(
94
+ { variables: { planKey: "o/r#8", status: "filed", summary: "forgot to post" } } as any,
95
+ app as any,
96
+ );
97
+ assertEquals(stores.plan_conformance[0].status, "skipped");
98
+ assertEquals(stores.plan_conformance[0].comment_url, null);
99
+ });
100
+
101
+ test("conformance-record: a non-filed status carries no verdict counts or deviations", async () => {
102
+ const { app, stores } = fakeApp();
103
+ // A "filed" that downgrades to skipped (no comment) must not persist the agent's counts /
104
+ // has_deviations — a skipped/blocked audit produced no verified verdict, so the row would be
105
+ // internally inconsistent (status=skipped yet has_deviations=1 with non-zero counts).
106
+ await handler(
107
+ {
108
+ variables: {
109
+ planKey: "o/r#11",
110
+ status: "filed",
111
+ slicesMet: 4,
112
+ slicesReduced: 1,
113
+ slicesNotVerified: 1,
114
+ deviationsRaised: 2,
115
+ deviationsUnraised: 1,
116
+ hasDeviations: true,
117
+ summary: "audit ran but never posted",
118
+ "io.nanobpm.agentResult": { output: "transcript explaining why" },
119
+ },
120
+ } as any,
121
+ app as any,
122
+ );
123
+ const row = stores.plan_conformance[0];
124
+ assertEquals(row.status, "skipped");
125
+ assertEquals(row.slices_met, 0);
126
+ assertEquals(row.slices_reduced, 0);
127
+ assertEquals(row.slices_not_verified, 0);
128
+ assertEquals(row.deviations_raised, 0);
129
+ assertEquals(row.deviations_unraised, 0);
130
+ assertEquals(row.has_deviations, 0);
131
+ // summary + report are human-readable context — retained so a skipped/blocked row still explains itself.
132
+ assertEquals(row.summary, "audit ran but never posted");
133
+ assertEquals(row.report, "transcript explaining why");
134
+ });
135
+
136
+ test("conformance-record: coerces string-encoded numeric counts hoisted by the agent", async () => {
137
+ const { app, stores } = fakeApp();
138
+ // The agentTask runner hoists result-JSON keys as-is; an agent may emit counts as strings ("1").
139
+ // These must be parsed, not silently coerced to 0 (which would wrongly clear the verdict).
140
+ await handler(
141
+ {
142
+ variables: {
143
+ planKey: "o/r#12",
144
+ status: "filed",
145
+ commentUrl: "https://x/12#c",
146
+ slicesMet: "4",
147
+ slicesReduced: "1",
148
+ slicesNotVerified: "0",
149
+ deviationsRaised: "2",
150
+ deviationsUnraised: "0",
151
+ hasDeviations: false,
152
+ },
153
+ } as any,
154
+ app as any,
155
+ );
156
+ const row = stores.plan_conformance[0];
157
+ assertEquals(row.slices_met, 4);
158
+ assertEquals(row.slices_reduced, 1);
159
+ assertEquals(row.deviations_raised, 2);
160
+ // A reduced item is ground truth for a deviation even though the agent's flag was false.
161
+ assertEquals(row.has_deviations, 1);
162
+ });
163
+
164
+ test("conformance-record: honours a string-encoded hasDeviations flag", async () => {
165
+ const { app, stores } = fakeApp();
166
+ // A clean epic (no reduced / not-verified / unraised) where the agent emits hasDeviations as the
167
+ // string "true" must still record a deviation — a stringified boolean can't silently be dropped.
168
+ await handler(
169
+ {
170
+ variables: {
171
+ planKey: "o/r#13",
172
+ status: "filed",
173
+ commentUrl: "https://x/13#c",
174
+ slicesMet: 3,
175
+ hasDeviations: "true",
176
+ },
177
+ } as any,
178
+ app as any,
179
+ );
180
+ assertEquals(stores.plan_conformance[0].has_deviations, 1);
181
+ });
182
+
183
+ test("conformance-record: honours an explicit blocked status", async () => {
184
+ const { app, stores } = fakeApp();
185
+ await handler(
186
+ { variables: { planKey: "o/r#9", status: "blocked", summary: "no read access" } } as any,
187
+ app as any,
188
+ );
189
+ assertEquals(stores.plan_conformance[0].status, "blocked");
190
+ });
191
+
192
+ test("conformance-record: defaults to skipped when the agent reported nothing", async () => {
193
+ const { app, stores } = fakeApp();
194
+ await handler(
195
+ { variables: { planKey: "o/r#10", summary: "nothing shipped" } } as any,
196
+ app as any,
197
+ );
198
+ assertEquals(stores.plan_conformance[0].status, "skipped");
199
+ });
@@ -0,0 +1,95 @@
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
+ await recordConformance(app.data, planKey, {
77
+ status,
78
+ commentUrl: filed ? commentUrl : null,
79
+ slicesMet: filed ? asInt(job.variables.slicesMet) : 0,
80
+ slicesReduced,
81
+ slicesNotVerified,
82
+ deviationsRaised: filed ? asInt(job.variables.deviationsRaised) : 0,
83
+ deviationsUnraised,
84
+ hasDeviations,
85
+ summary,
86
+ report,
87
+ });
88
+
89
+ app.log.info(
90
+ `conformance-record: ${planKey} — status=${status} deviations=${hasDeviations ? "yes" : "no"}`,
91
+ );
92
+ return {};
93
+ };
94
+
95
+ 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