@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 +7 -0
- package/README.md +1 -0
- package/app/abandon.ts +12 -3
- package/app/conformance.test.ts +220 -0
- package/app/conformance.ts +240 -0
- package/app/dbFence.ts +18 -0
- package/app/migration053.test.ts +84 -0
- package/app/plan.ts +6 -0
- package/app/retro.test.ts +32 -2
- package/app/retro.ts +26 -10
- package/app/service.test.ts +191 -3
- package/app/service.ts +123 -2
- package/app/waves.test.ts +12 -0
- package/app/world/store.ts +6 -6
- package/db/migrations/004_planning.sql +1 -1
- package/db/migrations/052_plan_conformance.sql +28 -0
- package/db/migrations/053_merges_abandon_dedupe.sql +29 -0
- package/nano.app.json +6 -1
- package/package.json +1 -1
- package/resources/processes/retro.bpmn +59 -8
- package/resources/prompts/conformance.md +105 -0
- package/resources/prompts/retro.md +5 -0
- package/workers/conformance-record/worker.test.ts +199 -0
- package/workers/conformance-record/worker.ts +95 -0
- package/workers/merge/worker.test.ts +4 -0
- package/workers/merge/worker.ts +13 -18
- package/workers/retro-gather/worker.test.ts +6 -0
- package/workers/retro-gather/worker.ts +17 -5
|
@@ -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 & 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="
|
|
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="
|
|
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="
|
|
123
|
+
<dc:Bounds x="1216" y="102" width="36" height="36" />
|
|
81
124
|
<bpmndi:BPMNLabel>
|
|
82
|
-
<dc:Bounds x="
|
|
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="
|
|
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="
|
|
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="
|
|
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);
|
package/workers/merge/worker.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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,
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
|
|
21
|
-
|
|
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
|
|