@nanobpm/nano-workforce 0.103.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.
@@ -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