@nanobpm/nano-workforce 0.105.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [0.106.0](https://github.com/nanobpm/nano-workforce/compare/v0.105.0...v0.106.0) (2026-08-20)
2
+
3
+
4
+ ### Features
5
+
6
+ * escalate unmet/undisclosed conformance deviations to the Tasks inbox ([#356](https://github.com/nanobpm/nano-workforce/issues/356)) ([5ad24bc](https://github.com/nanobpm/nano-workforce/commit/5ad24bcadf833934804973a25fd96f4eb4d28a84)), closes [#354](https://github.com/nanobpm/nano-workforce/issues/354) [#354](https://github.com/nanobpm/nano-workforce/issues/354)
7
+
1
8
  # [0.105.0](https://github.com/nanobpm/nano-workforce/compare/v0.104.0...v0.105.0) (2026-08-19)
2
9
 
3
10
 
package/README.md CHANGED
@@ -153,7 +153,7 @@ capability):
153
153
  | `fix-ci` | `senior:fix-ci` | `merge-loop` | Green a `blocked` PR's failing checks |
154
154
  | `rebase` | `senior:rebase` | `merge-loop` | Rebase a conflicting PR up to date with its base |
155
155
  | `retro` | `senior:retro` | `retro` | Synthesize a finished epic's learnings and promote the recurring ones |
156
- | `conformance` | `senior:conformance` | `retro` | Examine a finished epic's implementation against its spec; report met/deviations on the issue |
156
+ | `conformance` | `senior:conformance` | `retro` | Examine a finished epic's implementation against its spec; report met/deviations on the issue, and escalate an unmet/undisclosed deviation to the Tasks inbox as a non-blocking ack |
157
157
 
158
158
  - `--command 'copilot -p - --allow-all-tools'` starts the Copilot CLI reading its
159
159
  prompt from **stdin** (`-p -`). The harness pipes the whole job JSON (prompt +
@@ -229,6 +229,34 @@ test("feature-blocked is HUMAN-completable but NOT agent-completable (issue #332
229
229
  assertEquals(completed[0].variables, { note: "reassigned to a human" });
230
230
  });
231
231
 
232
+ test("conformance-escalation is HUMAN-completable but NOT agent-completable (issue #216)", async () => {
233
+ // The retro conformance ack mirrors feature-blocked: a HUMAN operator retires it via
234
+ // `completeEscalationAsHuman`, but it stays OUTSIDE the agent surface (`ESCALATION_TASK_ELEMENTS`) —
235
+ // an agent must never acknowledge a conformance review on a human's behalf.
236
+ const stores = { task_completions: { rows: [] as any[], key: "id" } };
237
+ const data = memData(stores);
238
+ const { engine, completed } = fakeEngine([{ userTaskKey: "ut-c", elementId: "conformance-escalation" }]);
239
+
240
+ const asAgent = await completeEscalationAsAgent(data, engine, {
241
+ userTaskKey: "ut-c",
242
+ agentId: "bot",
243
+ variables: { note: "n" },
244
+ });
245
+ assertEquals(asAgent.ok, false, "the agent completer refuses conformance-escalation");
246
+ assertEquals(asAgent.reason, "not a completable task");
247
+ assertEquals(completed.length, 0);
248
+
249
+ const asHuman = await completeEscalationAsHuman(data, engine, {
250
+ userTaskKey: "ut-c",
251
+ operatorId: "alice",
252
+ variables: { note: "filed follow-up" },
253
+ });
254
+ assertEquals(asHuman.ok, true, "the human completer retires conformance-escalation");
255
+ assertEquals(asHuman.elementId, "conformance-escalation");
256
+ assertEquals(completed.length, 1);
257
+ assertEquals(completed[0].variables, { note: "filed follow-up" });
258
+ });
259
+
232
260
  test("human completer refuses a non-escalation user task and is a no-op for an unknown key", async () => {
233
261
  const stores = { task_completions: { rows: [] as any[], key: "id" } };
234
262
  const data = memData(stores);
@@ -22,6 +22,7 @@
22
22
 
23
23
  import { readFileSync } from "node:fs";
24
24
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
25
+ import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
25
26
 
26
27
  const now = () => new Date().toISOString();
27
28
 
@@ -79,13 +80,23 @@ export const ESCALATION_TASK_ELEMENTS: ReadonlySet<string> = new Set([
79
80
  * `acknowledge-blocked` door onto the one canonical `complete-user-task` door). */
80
81
  export const FEATURE_BLOCKED_TASK_ELEMENT = "feature-blocked";
81
82
 
83
+ /** The `conformance-escalation` operator user-task element id (retro.bpmn) — the native ack a retro
84
+ * run parks on when the spec-conformance audit finds the epic did NOT cleanly meet its spec (a
85
+ * reduced / not-verified slice, or an unraised deviation). Like `feature-blocked` it is a human-only
86
+ * acknowledgement (never agent-answerable), so it lives OUTSIDE `ESCALATION_TASK_ELEMENTS` and only
87
+ * the HUMAN completer accepts it (issue #216). Re-exported from the canonical
88
+ * `CONFORMANCE_ESCALATION_ELEMENT` (app/conformance.ts) — one source of truth, no drift surface. */
89
+ export const CONFORMANCE_ESCALATION_TASK_ELEMENT = CONFORMANCE_ESCALATION_ELEMENT;
90
+
82
91
  /** The user-task `elementId`s a HUMAN operator may complete from the Tasks inbox via the one canonical
83
92
  * `complete-user-task` door: every agent-answerable escalation PLUS the human-only `feature-blocked`
84
- * acknowledgement. The AGENT completer stays scoped to `ESCALATION_TASK_ELEMENTS` (no `feature-blocked`),
85
- * so widening the human surface never lets an agent retire a blocked run. */
93
+ * and `conformance-escalation` acknowledgements. The AGENT completer stays scoped to
94
+ * `ESCALATION_TASK_ELEMENTS` (neither ack), so widening the human surface never lets an agent retire
95
+ * a blocked run or a conformance review. */
86
96
  export const HUMAN_COMPLETABLE_ELEMENTS: ReadonlySet<string> = new Set([
87
97
  ...ESCALATION_TASK_ELEMENTS,
88
98
  FEATURE_BLOCKED_TASK_ELEMENT,
99
+ CONFORMANCE_ESCALATION_TASK_ELEMENT,
89
100
  ]);
90
101
 
91
102
  /** Each escalation `elementId` → the `.form` whose contract governs its completion variables (the
@@ -98,6 +109,7 @@ const ESCALATION_FORM_BY_ELEMENT: Readonly<Record<string, string>> = {
98
109
  "wait-answer": "pr-escalation",
99
110
  "wait-merge-answer": "pr-escalation",
100
111
  "feature-blocked": "feature-blocked",
112
+ [CONFORMANCE_ESCALATION_TASK_ELEMENT]: "conformance-escalation",
101
113
  };
102
114
 
103
115
  /** A field's `conditional.hide` rule, parsed from the FEEL subset the `.form` files use
@@ -1,10 +1,12 @@
1
1
  // Unit tests for the spec-conformance review stage (app/conformance.ts, 052_plan_conformance.sql).
2
2
  import { test } from "node:test";
3
- import { assert, assertEquals, assertStringIncludes } from "#test-assert";
3
+ import { assert, assertEquals, assertRejects, assertStringIncludes } from "#test-assert";
4
4
  import type { DataLayer } from "@nanobpm/urban";
5
5
  import { memBlackboardSource } from "../test/blackboardDb.ts";
6
6
  import { appendEntry } from "./blackboard.ts";
7
7
  import {
8
+ acknowledgeConformance,
9
+ activeConformanceReviews,
8
10
  gatherConformance,
9
11
  hasDeliveredImplementation,
10
12
  hasDeliveredImplementationForPlan,
@@ -218,3 +220,94 @@ test("recordConformance: rethrows a non-unique (FOREIGN KEY) constraint error in
218
220
  assert(threw, "the FK error must propagate");
219
221
  assertEquals(updated, false, "must not silently fall back to update on a non-unique error");
220
222
  });
223
+
224
+ test("recordConformance: persists the retro instance key and the escalation review_status (issue #216)", async () => {
225
+ const { data, stores } = memData();
226
+ await recordConformance(data, PLAN, {
227
+ status: "filed",
228
+ commentUrl: "https://x/7#c",
229
+ hasDeviations: true,
230
+ summary: "slice 2 reduced",
231
+ processKey: "retro-inst-7",
232
+ reviewStatus: "reviewing",
233
+ });
234
+ const row = stores["plan_conformance"][0];
235
+ assertEquals(row.process_key, "retro-inst-7");
236
+ assertEquals(row.review_status, "reviewing");
237
+
238
+ // Absent tracking fields default: no processKey, and a settled `reviewed`.
239
+ await recordConformance(data, "acme/widgets#8", { status: "skipped" });
240
+ const clean = stores["plan_conformance"].find((r) => r.plan_key === "acme/widgets#8");
241
+ assertEquals(clean.process_key, null);
242
+ assertEquals(clean.review_status, "reviewed");
243
+ });
244
+
245
+ test("recordConformance: rejects an untrackable `reviewing` row with a null processKey (invariant guard)", async () => {
246
+ const { data, stores } = memData();
247
+ // A `reviewing` row with no `process_key` is unreachable: `pollUserTasks` skips rows without a
248
+ // key and the `instanceTracking` binding keys off `process_key`, so it would strand the review
249
+ // forever. The API must refuse to persist that state, not just the worker call site.
250
+ await assertRejects(
251
+ () =>
252
+ recordConformance(data, PLAN, {
253
+ status: "filed",
254
+ hasDeviations: true,
255
+ reviewStatus: "reviewing",
256
+ }),
257
+ Error,
258
+ "untrackable",
259
+ );
260
+ assertEquals(stores["plan_conformance"] ?? [], []);
261
+ });
262
+
263
+ test("activeConformanceReviews: returns only the rows still `reviewing`", async () => {
264
+ const { data, stores } = memData();
265
+ stores["plan_conformance"] = [
266
+ { plan_key: "a/b#1", process_key: "p1", review_status: "reviewing", summary: "s1" },
267
+ { plan_key: "a/b#2", process_key: "p2", review_status: "reviewed", summary: "s2" },
268
+ ];
269
+ const active = await activeConformanceReviews(data);
270
+ assertEquals(active.map((r) => r.plan_key), ["a/b#1"]);
271
+ });
272
+
273
+ test("acknowledgeConformance: settles the row at reviewed and folds the note into the summary", async () => {
274
+ const { data, stores } = memData();
275
+ stores["plan_conformance"] = [
276
+ { plan_key: PLAN, process_key: "p1", review_status: "reviewing", summary: "slice 2 reduced" },
277
+ ];
278
+ await acknowledgeConformance(data, PLAN, "filed follow-up #9");
279
+ const row = stores["plan_conformance"][0];
280
+ assertEquals(row.review_status, "reviewed");
281
+ assertEquals(row.summary, "slice 2 reduced\n\nOperator ack: filed follow-up #9");
282
+ });
283
+
284
+ test("acknowledgeConformance: fails loudly when the plan_conformance row is missing", async () => {
285
+ const { data } = memData();
286
+ await assertRejects(
287
+ () => acknowledgeConformance(data, PLAN, "filed follow-up #9"),
288
+ Error,
289
+ PLAN,
290
+ );
291
+ });
292
+
293
+ test("acknowledgeConformance: is idempotent — a retry after settling does not re-append the note", async () => {
294
+ const { data, stores } = memData();
295
+ stores["plan_conformance"] = [
296
+ { plan_key: PLAN, process_key: "p1", review_status: "reviewing", summary: "slice 2 reduced" },
297
+ ];
298
+ await acknowledgeConformance(data, PLAN, "filed follow-up #9");
299
+ await acknowledgeConformance(data, PLAN, "filed follow-up #9");
300
+ const row = stores["plan_conformance"][0];
301
+ assertEquals(row.review_status, "reviewed");
302
+ assertEquals(row.summary, "slice 2 reduced\n\nOperator ack: filed follow-up #9");
303
+ });
304
+
305
+ test("acknowledgeConformance: a blank note settles the row without changing the summary", async () => {
306
+ const { data, stores } = memData();
307
+ stores["plan_conformance"] = [
308
+ { plan_key: PLAN, process_key: "p1", review_status: "reviewing", summary: "auth cache unverified" },
309
+ ];
310
+ await acknowledgeConformance(data, PLAN, " ");
311
+ assertEquals(stores["plan_conformance"][0].review_status, "reviewed");
312
+ assertEquals(stores["plan_conformance"][0].summary, "auth cache unverified");
313
+ });
@@ -21,6 +21,14 @@ import { planTasks } from "./plan.ts";
21
21
 
22
22
  const now = () => new Date().toISOString();
23
23
 
24
+ /** The BPMN `elementId` of the conformance escalation user task (retro.bpmn). The inbox reconciler
25
+ * (`pollUserTasks`) and the human completer (`HUMAN_COMPLETABLE_ELEMENTS`) key off this. */
26
+ export const CONFORMANCE_ESCALATION_ELEMENT = "conformance-escalation";
27
+
28
+ /** The `review_status` a `plan_conformance` row carries while its escalation ack task is OPEN — the
29
+ * only status `pollUserTasks` scans (migration 054). Every settled run is `reviewed`. */
30
+ export const CONFORMANCE_REVIEWING_STATUS = "reviewing";
31
+
24
32
  /** A slice PR "landed" — its implementation is really in the tree and worth examining — when its
25
33
  * PR reached a terminal state that isn't `abandoned`. In auto-merge mode that terminal is `merged`;
26
34
  * in review-only mode it is `converged`. Derived from app/delivery.ts TERMINAL_STATUSES (the single
@@ -50,6 +58,30 @@ async function isLanded(data: DataLayer, prKey: string | null | undefined): Prom
50
58
  const conformanceTbl = (data: DataLayer) =>
51
59
  data.table<{ plan_key: string } & Record<string, unknown>>("plan_conformance", "plan_key");
52
60
 
61
+ /** A `plan_conformance` row viewed as a retro-run tracking record, for the inbox reconciler. */
62
+ export interface ConformanceReviewRow extends Record<string, unknown> {
63
+ plan_key: string;
64
+ process_key: string | null;
65
+ review_status: string;
66
+ summary: string | null;
67
+ }
68
+
69
+ const conformanceReviewsTbl = (data: DataLayer) =>
70
+ data.table<ConformanceReviewRow>("plan_conformance", "plan_key");
71
+
72
+ /** The conformance runs whose escalation ack task is still open (`review_status = 'reviewing'`) —
73
+ * the set `pollUserTasks` scans for an open `conformance-escalation` user task. */
74
+ export async function activeConformanceReviews(data: DataLayer): Promise<ConformanceReviewRow[]> {
75
+ return await conformanceReviewsTbl(data).find({ review_status: CONFORMANCE_REVIEWING_STATUS });
76
+ }
77
+
78
+ /** The escalation question shown in the inbox row: the agent's conformance summary (which names the
79
+ * reduced / not-verified items and the unraised deviations). Best-effort — NULL when none recorded. */
80
+ export function conformanceEscalationQuestion(row: { summary?: unknown } | undefined): string | null {
81
+ const s = row?.summary;
82
+ return typeof s === "string" && s.trim() ? s.trim() : null;
83
+ }
84
+
53
85
  /** One item of the spec the agent must verify against the code: the slice's planner-supplied
54
86
  * `prompt` (its acceptance brief), where it landed, and whether it landed at all. */
55
87
  export interface ConformanceSlice {
@@ -205,6 +237,12 @@ export interface ConformanceInput {
205
237
  hasDeviations?: boolean;
206
238
  summary?: string | null;
207
239
  report?: string | null;
240
+ /** The retro process instance this conformance ran in — the tracking key `pollUserTasks` reads to
241
+ * find an open escalation user task (migration 054). */
242
+ processKey?: string | null;
243
+ /** Escalation lifecycle: `reviewing` while the ack task is open (poller scans these), else
244
+ * `reviewed`. Defaults to `reviewed` — only an escalation flips it to `reviewing`. */
245
+ reviewStatus?: "reviewing" | "reviewed";
208
246
  }
209
247
 
210
248
  /** Upsert a plan's conformance row (idempotent on plan_key, so a job retry overwrites in place).
@@ -218,6 +256,19 @@ export async function recordConformance(
218
256
  input: ConformanceInput,
219
257
  ): Promise<void> {
220
258
  const ts = now();
259
+ const processKey = input.processKey ?? null;
260
+ const reviewStatus = input.reviewStatus ?? "reviewed";
261
+ // Invariant: a `reviewing` row must be trackable. `pollUserTasks` skips rows without a
262
+ // `process_key` and the `instanceTracking` binding keys off `process_key`, so a `reviewing` row
263
+ // with a null key can never be surfaced to an operator nor cleared — it wedges forever. The
264
+ // conformance-record worker already guards its own call site, but `recordConformance` is a public
265
+ // API: reject the untrackable combination here too so no future caller can encode it.
266
+ if (reviewStatus === CONFORMANCE_REVIEWING_STATUS && processKey == null) {
267
+ throw new Error(
268
+ `recordConformance: ${planKey} would persist review_status='reviewing' with no process_key — ` +
269
+ "refusing to record an untrackable escalation that no poller or onTerminated binding can clear",
270
+ );
271
+ }
221
272
  const fields = {
222
273
  status: input.status,
223
274
  comment_url: input.commentUrl ?? null,
@@ -229,6 +280,8 @@ export async function recordConformance(
229
280
  has_deviations: input.hasDeviations ? 1 : 0,
230
281
  summary: input.summary ?? null,
231
282
  report: input.report ?? null,
283
+ process_key: processKey,
284
+ review_status: reviewStatus,
232
285
  updated_at: ts,
233
286
  };
234
287
  try {
@@ -238,3 +291,39 @@ export async function recordConformance(
238
291
  await conformanceTbl(data).update(planKey, fields);
239
292
  }
240
293
  }
294
+
295
+ /** Settle a conformance run's escalation once the operator acknowledges it: flip `review_status` to
296
+ * `reviewed` so `pollUserTasks` stops scanning it (its inbox row is already gone once the ack task
297
+ * closes) and stamp the disposition note into `summary` for the audit trail. Needed because the
298
+ * `retro` instance COMPLETES normally after the ack — `instanceTracking.onTerminated` only fires on a
299
+ * TERMINATED (crashed) instance, never a completed one, so nothing else would clear `reviewing`. */
300
+ export async function acknowledgeConformance(
301
+ data: DataLayer,
302
+ planKey: string,
303
+ note?: string | null,
304
+ ): Promise<void> {
305
+ const trimmed = typeof note === "string" && note.trim() ? note.trim() : null;
306
+ const existing = await conformanceTbl(data).get(planKey);
307
+ // Invariant: the ack task only fires after the escalation parked this exact `planKey` at
308
+ // `review_status='reviewing'`, so the row must exist. A missing row means a wrong/mismatched
309
+ // `planKey` (or unexpected DB state); silently returning would let the `retro` instance COMPLETE
310
+ // while the real conformance row stays stuck in `reviewing`, so `pollUserTasks` scans it forever.
311
+ // Fail loudly so the job retries/alerts instead of silently encoding the mismatch.
312
+ if (!existing) {
313
+ throw new Error(
314
+ `acknowledgeConformance: no plan_conformance row for ${planKey} — ` +
315
+ "refusing to settle a missing/mismatched escalation that would leave the real row stuck in 'reviewing'",
316
+ );
317
+ }
318
+ // At-least-once worker semantics can retry `pr.conformance-ack` after a successful DB update; the
319
+ // row is already settled at `reviewed`, so short-circuit to keep the operation idempotent (a retry
320
+ // must not re-append a duplicate `Operator ack: …` block to the audit trail).
321
+ if (existing.review_status === "reviewed") return;
322
+ const prior = typeof existing.summary === "string" ? existing.summary : null;
323
+ const summary = trimmed ? (prior ? `${prior}\n\nOperator ack: ${trimmed}` : `Operator ack: ${trimmed}`) : prior;
324
+ await conformanceTbl(data).update(planKey, {
325
+ review_status: "reviewed",
326
+ summary,
327
+ updated_at: now(),
328
+ });
329
+ }
@@ -11,6 +11,7 @@ import { PR_ACTIVE_STATUSES, PLAN_ACTIVE_STATUSES, FEATURE_ACTIVE_STATUSES } fro
11
11
  import { TERMINAL_STATUSES } from "./delivery.ts";
12
12
  import { PLAN_TERMINAL_STATUSES } from "./plan.ts";
13
13
  import { FEATURE_TERMINAL_STATUSES } from "./feature.ts";
14
+ import { CONFORMANCE_REVIEWING_STATUS } from "./conformance.ts";
14
15
 
15
16
  interface Binding {
16
17
  table: string;
@@ -117,3 +118,26 @@ test("FEATURE_ACTIVE_STATUSES is derived from the manifest binding (no drift)",
117
118
  const b = bindingFor(await bindings(), "feature_runs");
118
119
  assertEquals([...FEATURE_ACTIVE_STATUSES].sort(), [...(b.activeStatuses ?? [])].sort());
119
120
  });
121
+
122
+ // The retro conformance-escalation lifecycle has exactly one in-flight `review_status` — `reviewing`
123
+ // (the only status `pollUserTasks` scans via `activeConformanceReviews`) — and settles to `reviewed`.
124
+ // Tie the manifest binding to the code's single source of truth (`CONFORMANCE_REVIEWING_STATUS`) so
125
+ // the two can't drift: if a future change adds a new in-flight status but forgets the manifest, a
126
+ // terminated retro instance would strand in `review_status='reviewing'` and never clear (issue #96
127
+ // class of drift — the exact gap Copilot flagged on this binding).
128
+ test("instanceTracking: plan_conformance activeStatuses is exactly the reviewing status (no drift)", async () => {
129
+ const b = bindingFor(await bindings(), "plan_conformance");
130
+ assertEquals([...(b.activeStatuses ?? [])].sort(), [CONFORMANCE_REVIEWING_STATUS]);
131
+ });
132
+
133
+ // The settled status the reconciler flips a terminated row to (`onTerminated.set.review_status`)
134
+ // must NOT itself be listed active — otherwise `onTerminated` would leave the row scannable and the
135
+ // reconciler could clobber a settled run (mirrors the "excludes every terminal status" guards above).
136
+ test("instanceTracking: plan_conformance onTerminated status is not active", async () => {
137
+ const b = bindingFor(await bindings(), "plan_conformance");
138
+ const settled = b.onTerminated.set.review_status;
139
+ assert(
140
+ typeof settled === "string" && !b.activeStatuses?.includes(settled),
141
+ `onTerminated review_status "${String(settled)}" must not be listed active`,
142
+ );
143
+ });
@@ -209,6 +209,43 @@ test("pollUserTasks: projects a blocked feature run (feature-blocked) with the d
209
209
  assertEquals(byKey["ut-blocked"].question, "agent gave up: no PR");
210
210
  });
211
211
 
212
+ test("pollUserTasks: projects a conformance-escalation ack (issue #216) keyed to the epic, question from summary", async () => {
213
+ // The advisory `retro` process parks on a native `conformance-escalation` user task when the
214
+ // spec-conformance audit found the epic did not cleanly meet its spec. retro is not a delivery
215
+ // aggregate, so its instance is tracked on `plan_conformance` (review_status = 'reviewing'); the
216
+ // poller scans those rows, reads the open ack task from the engine, and projects it under the epic
217
+ // (plan) subject with the audit `summary` as its question. A settled ('reviewed') row is skipped.
218
+ const { data, stores } = memData({
219
+ plan_conformance: [
220
+ {
221
+ plan_key: "o/r#70",
222
+ process_key: "cp-70",
223
+ review_status: "reviewing",
224
+ summary: "slice 2 reduced; auth cache never verified",
225
+ },
226
+ { plan_key: "o/r#71", process_key: "cp-71", review_status: "reviewed", summary: "all clean" },
227
+ ],
228
+ plans: [
229
+ { plan_key: "o/r#70", status: "done", issue_url: "https://github.com/o/r/issues/70", title: "Ship the cache" },
230
+ ],
231
+ });
232
+ const engine = fakeEngine({
233
+ "cp-70": [{ userTaskKey: "ut-conf", elementId: "conformance-escalation" }],
234
+ "cp-71": [{ userTaskKey: "ut-conf-settled", elementId: "conformance-escalation" }],
235
+ });
236
+
237
+ await pollUserTasks(data, engine);
238
+
239
+ const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
240
+ assertEquals(Object.keys(byKey), ["ut-conf"]);
241
+ assertEquals(byKey["ut-conf"].element_id, "conformance-escalation");
242
+ assertEquals(byKey["ut-conf"].kind_label, "Conformance review");
243
+ assertEquals(byKey["ut-conf"].subject_type, "plan");
244
+ assertEquals(byKey["ut-conf"].subject_key, "o/r#70");
245
+ assertEquals(byKey["ut-conf"].subject_title, "Ship the cache");
246
+ assertEquals(byKey["ut-conf"].question, "slice 2 reduced; auth cache never verified");
247
+ });
248
+
212
249
  test("pollUserTasks: removes a row once its task is no longer open (completed / out-of-band)", async () => {
213
250
  const { data, stores } = memData({
214
251
  user_tasks: [
package/app/service.ts CHANGED
@@ -21,6 +21,11 @@ import {
21
21
  renderResolvedDepsBrief,
22
22
  UnresolvableCapabilityRefError,
23
23
  } from "./capabilityNeed.ts";
24
+ import {
25
+ activeConformanceReviews,
26
+ CONFORMANCE_ESCALATION_ELEMENT,
27
+ conformanceEscalationQuestion,
28
+ } from "./conformance.ts";
24
29
  import { isUniqueConstraintFence } from "./dbFence.ts";
25
30
  import { deriveDelivery, TERMINAL_STATUSES } from "./delivery.ts";
26
31
  import { fleetSupportsDurableResume } from "./durableResume.ts";
@@ -2199,6 +2204,41 @@ export async function pollUserTasks(data: DataLayer, engine: EngineClient) {
2199
2204
  }
2200
2205
  }
2201
2206
 
2207
+ // Conformance-review acks (`conformance-escalation`) — the advisory `retro` process parks on a
2208
+ // human ack when the spec-conformance audit finds the epic did NOT cleanly meet its spec (issue
2209
+ // #216). retro is not one of the delivery aggregates above, so its instance is tracked on
2210
+ // `plan_conformance` (migration 054): scan each row still `reviewing`, read its open ack task, and
2211
+ // project it keyed to the epic (plan) subject, sourcing the question from the audit's `summary`.
2212
+ for (const review of await activeConformanceReviews(data)) {
2213
+ if (!review.process_key) continue;
2214
+ let tasks: { userTaskKey: string; elementId?: string }[];
2215
+ try {
2216
+ tasks = await engine.openUserTasks({ processInstanceKey: review.process_key });
2217
+ } catch (err) {
2218
+ console.error(`[poller] user tasks (conformance ${review.plan_key}): ${err}`);
2219
+ continue;
2220
+ }
2221
+ const plan = await plans(data).get(review.plan_key);
2222
+ for (const t of tasks) {
2223
+ if (t.elementId !== CONFORMANCE_ESCALATION_ELEMENT) continue;
2224
+ push(
2225
+ buildUserTaskRow(
2226
+ {
2227
+ userTaskKey: t.userTaskKey,
2228
+ elementId: CONFORMANCE_ESCALATION_ELEMENT,
2229
+ subjectType: "plan",
2230
+ subjectKey: review.plan_key,
2231
+ subjectTitle: plan?.title ?? null,
2232
+ subjectUrl: plan?.issue_url ?? null,
2233
+ question: conformanceEscalationQuestion(review),
2234
+ processKey: review.process_key,
2235
+ },
2236
+ at,
2237
+ ),
2238
+ );
2239
+ }
2240
+ }
2241
+
2202
2242
  const persisted = await userTasks(data).all();
2203
2243
  const { inserts, updates, deletes } = reconcileUserTasks(persisted, desired);
2204
2244
  for (const row of inserts) await userTasks(data).insert(row);
@@ -5,6 +5,7 @@
5
5
  // These are the pure source of truth the poller projects.
6
6
  import { test } from "node:test";
7
7
  import { assert, assertEquals } from "#test-assert";
8
+ import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
8
9
  import type { PlanReview } from "./plan.ts";
9
10
  import type { TrialMergeAuditRow } from "./trialMerge.ts";
10
11
  import {
@@ -63,6 +64,25 @@ test("buildUserTaskRow: a blank question / missing url normalises to null", () =
63
64
  assertEquals(row?.kind_label, "Trial merge");
64
65
  });
65
66
 
67
+ test("buildUserTaskRow: a conformance-escalation projects the 'Conformance review' label (issue #216)", () => {
68
+ const row = buildUserTaskRow(
69
+ {
70
+ userTaskKey: "ut-c",
71
+ elementId: CONFORMANCE_ESCALATION_ELEMENT,
72
+ subjectType: "plan",
73
+ subjectKey: "o/r#7",
74
+ subjectTitle: "Ship the cache",
75
+ question: "slice 2 reduced",
76
+ },
77
+ AT,
78
+ );
79
+ assert(row !== null);
80
+ assertEquals(row?.element_id, "conformance-escalation");
81
+ assertEquals(row?.kind_label, "Conformance review");
82
+ assertEquals(row?.subject_type, "plan");
83
+ assertEquals(row?.question, "slice 2 reduced");
84
+ });
85
+
66
86
  test("buildUserTaskRow: an unknown (non-escalation) element yields null — no arbitrary user task leaks", () => {
67
87
  const row = buildUserTaskRow(
68
88
  { userTaskKey: "ut-3", elementId: "some-internal-task", subjectType: "plan", subjectKey: "o/r#3" },
package/app/userTasks.ts CHANGED
@@ -18,6 +18,7 @@
18
18
  // tasks visible; a completed task's row is removed on the next pass when the engine no longer reports
19
19
  // it open.
20
20
  import type { DataLayer } from "@nanobpm/urban";
21
+ import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
21
22
  import { FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, type FeatureEscalationRow } from "./feature.ts";
22
23
  import type { PlanReview } from "./plan.ts";
23
24
  import type { TrialMergeAuditRow } from "./trialMerge.ts";
@@ -74,6 +75,7 @@ export const USER_TASK_KIND_LABELS: Readonly<Record<string, string>> = {
74
75
  [TRIAL_MERGE_ELEMENT]: "Trial merge",
75
76
  [PR_WAIT_ANSWER_ELEMENT]: "PR review",
76
77
  [PR_WAIT_MERGE_ANSWER_ELEMENT]: "PR merge",
78
+ [CONFORMANCE_ESCALATION_ELEMENT]: "Conformance review",
77
79
  };
78
80
 
79
81
  /** The denormalised context the poller has resolved for an open escalation user task. */
@@ -0,0 +1,27 @@
1
+ -- Conformance review — escalation instance tracking (issue #216).
2
+ --
3
+ -- When conformance finds the epic did NOT cleanly meet its spec (a reduced / not-verified item, or a
4
+ -- deviation nobody raised), it escalates to the Tasks inbox as a NON-BLOCKING follow-up: the `retro`
5
+ -- process parks on a native `conformance-escalation` user task until an operator acknowledges it.
6
+ --
7
+ -- The unified inbox (`user_tasks`, 034) is reconciled by `pollUserTasks`, which scans the open user
8
+ -- tasks of each *instance-tracked* aggregate (feature_runs / plans / pull_requests). The retro process
9
+ -- had no such tracking, so its user task was invisible to the inbox. These two columns make
10
+ -- `plan_conformance` the retro run's tracking row: `process_key` is the retro process instance, and
11
+ -- `review_status` is its escalation lifecycle — `reviewing` while the ack task is open (the poller
12
+ -- scans these), `reviewed` once the run settles (set by `record-conformance` when there is nothing to
13
+ -- escalate, by the `pr.conformance-ack` worker (`acknowledgeConformance`) when an operator
14
+ -- acknowledges the escalation and the retro instance COMPLETES normally, and — as a crash/cancel
15
+ -- safety net — by `instanceTracking.onTerminated` when the retro instance is TERMINATED rather than
16
+ -- completing).
17
+ --
18
+ -- Forward-only, additive (expand): two nullable/defaulted columns on the table 052 just added; the
19
+ -- runner wraps each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
20
+ ALTER TABLE plan_conformance ADD COLUMN process_key TEXT;
21
+ ALTER TABLE plan_conformance ADD COLUMN review_status TEXT NOT NULL DEFAULT 'reviewed';
22
+
23
+ -- `pollUserTasks` scans open retro escalations by `review_status = 'reviewing'` every poll pass
24
+ -- (app/conformance.ts `activeConformanceReviews`). Index it so the common-case status scan stays a
25
+ -- cheap lookup instead of a full table scan as conformance rows accumulate — mirrors the
26
+ -- `idx_feature_runs_status` precedent (028) for the equivalent status-scanned aggregate.
27
+ CREATE INDEX IF NOT EXISTS idx_plan_conformance_review_status ON plan_conformance(review_status);
package/nano.app.json CHANGED
@@ -67,6 +67,20 @@
67
67
  }
68
68
  },
69
69
  "pollMs": 5000
70
+ },
71
+ {
72
+ "table": "plan_conformance",
73
+ "keyField": "process_key",
74
+ "statusField": "review_status",
75
+ "activeStatuses": [
76
+ "reviewing"
77
+ ],
78
+ "onTerminated": {
79
+ "set": {
80
+ "review_status": "reviewed"
81
+ }
82
+ },
83
+ "pollMs": 5000
70
84
  }
71
85
  ],
72
86
  "workers": [
@@ -166,6 +180,10 @@
166
180
  "taskType": "pr.conformance-record",
167
181
  "handler": "workers/conformance-record/worker.ts"
168
182
  },
183
+ {
184
+ "taskType": "pr.conformance-ack",
185
+ "handler": "workers/conformance-ack/worker.ts"
186
+ },
169
187
  {
170
188
  "taskType": "pr.progress-check",
171
189
  "handler": "workers/progress-check/worker.ts"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.105.0",
3
+ "version": "0.106.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -532,6 +532,90 @@
532
532
  },
533
533
  "refreshMs": 5000
534
534
  }
535
+ },
536
+ {
537
+ "type": "dataGrid",
538
+ "id": "conformance-reviews",
539
+ "props": {
540
+ "title": "Conformance reviews",
541
+ "data": {
542
+ "kind": "datasource",
543
+ "source": "app",
544
+ "table": "user_tasks",
545
+ "filter": [
546
+ {
547
+ "field": "element_id",
548
+ "in": [
549
+ "conformance-escalation"
550
+ ]
551
+ }
552
+ ],
553
+ "orderBy": {
554
+ "field": "updated_at",
555
+ "dir": "desc"
556
+ }
557
+ },
558
+ "columns": [
559
+ {
560
+ "field": "subject_title",
561
+ "template": "{{subject_title}}",
562
+ "header": "Epic",
563
+ "subtitleField": "subject_key",
564
+ "truncate": true,
565
+ "linkField": "subject_url"
566
+ },
567
+ {
568
+ "field": "question",
569
+ "header": "Findings"
570
+ },
571
+ {
572
+ "field": "subject_url",
573
+ "header": "Issue"
574
+ },
575
+ {
576
+ "field": "process_key",
577
+ "header": "Process",
578
+ "link": {
579
+ "kind": "processExplorer",
580
+ "keyField": "process_key"
581
+ }
582
+ },
583
+ {
584
+ "field": "updated_at",
585
+ "header": "Updated",
586
+ "format": "datetime"
587
+ }
588
+ ],
589
+ "rowKey": "user_task_key",
590
+ "detail": {
591
+ "linkField": "subject_url",
592
+ "fields": [
593
+ {
594
+ "field": "question",
595
+ "label": "Findings"
596
+ }
597
+ ],
598
+ "form": {
599
+ "showWhenField": "user_task_key",
600
+ "title": "Acknowledge conformance review",
601
+ "promptField": "question",
602
+ "inputKey": "note",
603
+ "inputLabel": "Disposition note (what you decided / follow-up)",
604
+ "submitLabel": "Acknowledge & close",
605
+ "action": {
606
+ "path": "/app/api/actions/complete-user-task",
607
+ "successLabel": "Acknowledged — the review will close",
608
+ "body": {
609
+ "userTaskKey": "{{row.user_task_key}}",
610
+ "variables": {
611
+ "note": "{{form.note}}"
612
+ }
613
+ }
614
+ }
615
+ }
616
+ },
617
+ "refreshMs": 5000
618
+ }
535
619
  }
536
620
  ]
537
621
  }
@@ -0,0 +1,17 @@
1
+ {
2
+ "id": "conformance-escalation",
3
+ "schemaVersion": 18,
4
+ "type": "default",
5
+ "components": [
6
+ {
7
+ "type": "text",
8
+ "text": "The spec-conformance review found this epic did **not** cleanly meet its spec — a slice was reduced or could not be verified against the code, or a deviation was made that nobody raised during implementation. The delivery already landed, so this is a **non-blocking** follow-up: review the report on the epic issue, decide any remediation, then acknowledge to close it.",
9
+ "label": "Conformance review"
10
+ },
11
+ {
12
+ "type": "textarea",
13
+ "key": "note",
14
+ "label": "Disposition note (what you decided / follow-up — recorded on the review)"
15
+ }
16
+ ]
17
+ }
@@ -61,7 +61,32 @@
61
61
  </zeebe:properties>
62
62
  </bpmn:extensionElements>
63
63
  <bpmn:incoming>f_toRecordConformance</bpmn:incoming>
64
- <bpmn:outgoing>f_toSynthesize</bpmn:outgoing>
64
+ <bpmn:outgoing>f_toDeviationsGw</bpmn:outgoing>
65
+ </bpmn:serviceTask>
66
+ <bpmn:exclusiveGateway id="gw-deviations" name="deviations?" default="f_noDeviations">
67
+ <bpmn:incoming>f_toDeviationsGw</bpmn:incoming>
68
+ <bpmn:outgoing>f_deviations</bpmn:outgoing>
69
+ <bpmn:outgoing>f_noDeviations</bpmn:outgoing>
70
+ </bpmn:exclusiveGateway>
71
+ <bpmn:userTask id="conformance-escalation" name="Conformance review (human)">
72
+ <bpmn:extensionElements>
73
+ <zeebe:formDefinition formId="conformance-escalation" />
74
+ <zeebe:userTask />
75
+ <zeebe:assignmentDefinition candidateGroups="operators" />
76
+ </bpmn:extensionElements>
77
+ <bpmn:incoming>f_deviations</bpmn:incoming>
78
+ <bpmn:outgoing>f_toConformanceAck</bpmn:outgoing>
79
+ </bpmn:userTask>
80
+ <bpmn:serviceTask id="record-conformance-ack" name="Record acknowledgement">
81
+ <bpmn:extensionElements>
82
+ <zeebe:taskDefinition type="pr.conformance-ack" />
83
+ <zeebe:ioMapping>
84
+ <zeebe:input source="=planKey" target="planKey" />
85
+ <zeebe:input source="=if (is defined(note)) then note else null" target="note" />
86
+ </zeebe:ioMapping>
87
+ </bpmn:extensionElements>
88
+ <bpmn:incoming>f_toConformanceAck</bpmn:incoming>
89
+ <bpmn:outgoing>f_ackToSynthesize</bpmn:outgoing>
65
90
  </bpmn:serviceTask>
66
91
  <bpmn:serviceTask id="synthesize" name="Synthesize &#38; promote (agent)">
67
92
  <bpmn:extensionElements>
@@ -73,7 +98,8 @@
73
98
  <zeebe:input source="=retroDigest" target="appendPrompt" />
74
99
  </zeebe:ioMapping>
75
100
  </bpmn:extensionElements>
76
- <bpmn:incoming>f_toSynthesize</bpmn:incoming>
101
+ <bpmn:incoming>f_noDeviations</bpmn:incoming>
102
+ <bpmn:incoming>f_ackToSynthesize</bpmn:incoming>
77
103
  <bpmn:outgoing>f_toRecord</bpmn:outgoing>
78
104
  </bpmn:serviceTask>
79
105
  <bpmn:serviceTask id="record" name="Record retro">
@@ -92,7 +118,13 @@
92
118
  <bpmn:sequenceFlow id="f_start" sourceRef="Start" targetRef="gather" />
93
119
  <bpmn:sequenceFlow id="f_toConformance" sourceRef="gather" targetRef="conformance" />
94
120
  <bpmn:sequenceFlow id="f_toRecordConformance" sourceRef="conformance" targetRef="record-conformance" />
95
- <bpmn:sequenceFlow id="f_toSynthesize" sourceRef="record-conformance" targetRef="synthesize" />
121
+ <bpmn:sequenceFlow id="f_toDeviationsGw" sourceRef="record-conformance" targetRef="gw-deviations" />
122
+ <bpmn:sequenceFlow id="f_deviations" name="deviations" sourceRef="gw-deviations" targetRef="conformance-escalation">
123
+ <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=hasDeviations = true</bpmn:conditionExpression>
124
+ </bpmn:sequenceFlow>
125
+ <bpmn:sequenceFlow id="f_noDeviations" sourceRef="gw-deviations" targetRef="synthesize" />
126
+ <bpmn:sequenceFlow id="f_toConformanceAck" sourceRef="conformance-escalation" targetRef="record-conformance-ack" />
127
+ <bpmn:sequenceFlow id="f_ackToSynthesize" sourceRef="record-conformance-ack" targetRef="synthesize" />
96
128
  <bpmn:sequenceFlow id="f_toRecord" sourceRef="synthesize" targetRef="record" />
97
129
  <bpmn:sequenceFlow id="f_toEnd" sourceRef="record" targetRef="End" />
98
130
  </bpmn:process>
@@ -113,16 +145,28 @@
113
145
  <bpmndi:BPMNShape id="BPMNShape_record-conformance" bpmnElement="record-conformance">
114
146
  <dc:Bounds x="616" y="80" width="100" height="80" />
115
147
  </bpmndi:BPMNShape>
148
+ <bpmndi:BPMNShape id="BPMNShape_gw-deviations" bpmnElement="gw-deviations" isMarkerVisible="true">
149
+ <dc:Bounds x="776" y="95" width="50" height="50" />
150
+ <bpmndi:BPMNLabel>
151
+ <dc:Bounds x="770" y="65" width="62" height="14" />
152
+ </bpmndi:BPMNLabel>
153
+ </bpmndi:BPMNShape>
154
+ <bpmndi:BPMNShape id="BPMNShape_conformance-escalation" bpmnElement="conformance-escalation">
155
+ <dc:Bounds x="751" y="240" width="100" height="80" />
156
+ </bpmndi:BPMNShape>
157
+ <bpmndi:BPMNShape id="BPMNShape_record-conformance-ack" bpmnElement="record-conformance-ack">
158
+ <dc:Bounds x="911" y="240" width="100" height="80" />
159
+ </bpmndi:BPMNShape>
116
160
  <bpmndi:BPMNShape id="BPMNShape_synthesize" bpmnElement="synthesize">
117
- <dc:Bounds x="816" y="80" width="100" height="80" />
161
+ <dc:Bounds x="1056" y="80" width="100" height="80" />
118
162
  </bpmndi:BPMNShape>
119
163
  <bpmndi:BPMNShape id="BPMNShape_record" bpmnElement="record">
120
- <dc:Bounds x="1016" y="80" width="100" height="80" />
164
+ <dc:Bounds x="1256" y="80" width="100" height="80" />
121
165
  </bpmndi:BPMNShape>
122
166
  <bpmndi:BPMNShape id="BPMNShape_End" bpmnElement="End">
123
- <dc:Bounds x="1216" y="102" width="36" height="36" />
167
+ <dc:Bounds x="1456" y="102" width="36" height="36" />
124
168
  <bpmndi:BPMNLabel>
125
- <dc:Bounds x="1194" y="143" width="80" height="14" />
169
+ <dc:Bounds x="1434" y="143" width="80" height="14" />
126
170
  </bpmndi:BPMNLabel>
127
171
  </bpmndi:BPMNShape>
128
172
  <bpmndi:BPMNEdge id="BPMNEdge_f_start" bpmnElement="f_start">
@@ -137,17 +181,34 @@
137
181
  <di:waypoint x="516" y="120" />
138
182
  <di:waypoint x="616" y="120" />
139
183
  </bpmndi:BPMNEdge>
140
- <bpmndi:BPMNEdge id="BPMNEdge_f_toSynthesize" bpmnElement="f_toSynthesize">
184
+ <bpmndi:BPMNEdge id="BPMNEdge_f_toDeviationsGw" bpmnElement="f_toDeviationsGw">
141
185
  <di:waypoint x="716" y="120" />
142
- <di:waypoint x="816" y="120" />
186
+ <di:waypoint x="776" y="120" />
187
+ </bpmndi:BPMNEdge>
188
+ <bpmndi:BPMNEdge id="BPMNEdge_f_noDeviations" bpmnElement="f_noDeviations">
189
+ <di:waypoint x="826" y="120" />
190
+ <di:waypoint x="1056" y="120" />
191
+ </bpmndi:BPMNEdge>
192
+ <bpmndi:BPMNEdge id="BPMNEdge_f_deviations" bpmnElement="f_deviations">
193
+ <di:waypoint x="801" y="145" />
194
+ <di:waypoint x="801" y="240" />
195
+ </bpmndi:BPMNEdge>
196
+ <bpmndi:BPMNEdge id="BPMNEdge_f_toConformanceAck" bpmnElement="f_toConformanceAck">
197
+ <di:waypoint x="851" y="280" />
198
+ <di:waypoint x="911" y="280" />
199
+ </bpmndi:BPMNEdge>
200
+ <bpmndi:BPMNEdge id="BPMNEdge_f_ackToSynthesize" bpmnElement="f_ackToSynthesize">
201
+ <di:waypoint x="1011" y="280" />
202
+ <di:waypoint x="1106" y="280" />
203
+ <di:waypoint x="1106" y="160" />
143
204
  </bpmndi:BPMNEdge>
144
205
  <bpmndi:BPMNEdge id="BPMNEdge_f_toRecord" bpmnElement="f_toRecord">
145
- <di:waypoint x="916" y="120" />
146
- <di:waypoint x="1016" y="120" />
206
+ <di:waypoint x="1156" y="120" />
207
+ <di:waypoint x="1256" y="120" />
147
208
  </bpmndi:BPMNEdge>
148
209
  <bpmndi:BPMNEdge id="BPMNEdge_f_toEnd" bpmnElement="f_toEnd">
149
- <di:waypoint x="1116" y="120" />
150
- <di:waypoint x="1216" y="120" />
210
+ <di:waypoint x="1356" y="120" />
211
+ <di:waypoint x="1456" y="120" />
151
212
  </bpmndi:BPMNEdge>
152
213
  </bpmndi:BPMNPlane>
153
214
  </bpmndi:BPMNDiagram>
@@ -0,0 +1,50 @@
1
+ // Unit coverage for pr.conformance-ack — the operator acknowledged a conformance-review escalation
2
+ // (issue #216). It must settle the parked `plan_conformance` row at `review_status = 'reviewed'` so
3
+ // the inbox scan drops it (the retro instance COMPLETES normally, so `instanceTracking.onTerminated`
4
+ // never fires) and fold the operator's disposition note into the audit `summary`.
5
+ import { test } from "node:test";
6
+ import { assertEquals } from "#test-assert";
7
+ import { noopLog } from "../../test/log.ts";
8
+ import handler from "./worker.ts";
9
+
10
+ function fakeApp(rows: Record<string, unknown>[]) {
11
+ const stores: Record<string, Record<string, unknown>[]> = { plan_conformance: rows };
12
+ return {
13
+ data: {
14
+ table(name: string, key: string) {
15
+ const store = (stores[name] ??= []);
16
+ return {
17
+ get: (k: any) => Promise.resolve(store.find((r) => r[key] === k)),
18
+ find: (q: any) => Promise.resolve(store.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
19
+ insert: (row: any) => {
20
+ store.push(row);
21
+ return Promise.resolve(store.length);
22
+ },
23
+ update: (k: any, patch: any) => {
24
+ const row = store.find((r) => r[key] === k);
25
+ if (row) Object.assign(row, patch);
26
+ return Promise.resolve(row);
27
+ },
28
+ };
29
+ },
30
+ },
31
+ log: noopLog(),
32
+ } as any;
33
+ }
34
+
35
+ test("conformance-ack: settles the review at reviewed and appends the operator note to the summary", async () => {
36
+ const rows = [{ plan_key: "owner/repo#7", review_status: "reviewing", summary: "slice 2 reduced" }];
37
+ const app = fakeApp(rows);
38
+ const out = await handler({ variables: { planKey: "owner/repo#7", note: "filed follow-up #9" } } as any, app);
39
+ assertEquals(out, {});
40
+ assertEquals(rows[0].review_status, "reviewed");
41
+ assertEquals(rows[0].summary, "slice 2 reduced\n\nOperator ack: filed follow-up #9");
42
+ });
43
+
44
+ test("conformance-ack: a blank note settles the review without touching the summary", async () => {
45
+ const rows = [{ plan_key: "owner/repo#8", review_status: "reviewing", summary: "auth cache unverified" }];
46
+ const app = fakeApp(rows);
47
+ await handler({ variables: { planKey: "owner/repo#8", note: " " } } as any, app);
48
+ assertEquals(rows[0].review_status, "reviewed");
49
+ assertEquals(rows[0].summary, "auth cache unverified");
50
+ });
@@ -0,0 +1,31 @@
1
+ // pr.conformance-ack — an operator acknowledged a conformance-review escalation (issue #216).
2
+ //
3
+ // When the spec-conformance audit finds the epic did NOT cleanly meet its spec, the `retro` process
4
+ // routes to the `conformance-escalation` operator user task (retro.bpmn), which parks the instance
5
+ // on the operators' inbox with `plan_conformance.review_status = 'reviewing'`. This worker fires once
6
+ // the operator completes that ack task: it settles the row at `reviewed` (so the inbox scan drops it)
7
+ // and folds the operator's optional disposition note into the audit `summary`. The instance then
8
+ // continues to the lessons (retro) synthesis, so the ack is NON-blocking — delivery already landed.
9
+ // Persistence goes through the record gateway (`app.data`), never hand-written SQL.
10
+ import type { AppJobHandler } from "@nanobpm/urban";
11
+ import { acknowledgeConformance } from "../../app/conformance.ts";
12
+
13
+ interface In extends Record<string, unknown> {
14
+ planKey: string;
15
+ note?: unknown;
16
+ }
17
+
18
+ const str = (v: unknown): string | undefined =>
19
+ typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
20
+
21
+ const handler: AppJobHandler<In, Record<string, never>> = async (job, app) => {
22
+ const planKey = job.variables.planKey;
23
+ const note = str(job.variables.note);
24
+
25
+ await acknowledgeConformance(app.data, planKey, note);
26
+ app.log.info("conformance-ack", { planKey, note: note ?? null });
27
+
28
+ return {};
29
+ };
30
+
31
+ export default handler;
@@ -1,5 +1,5 @@
1
1
  import { test } from "node:test";
2
- import { assertEquals } from "#test-assert";
2
+ import { assertEquals, assertRejects } from "#test-assert";
3
3
  import { noopLog } from "../../test/log.ts";
4
4
  import handler from "./worker.ts";
5
5
 
@@ -36,8 +36,9 @@ function fakeApp() {
36
36
 
37
37
  test("conformance-record: persists a filed conformance from hoisted result vars", async () => {
38
38
  const { app, stores } = fakeApp();
39
- await handler(
39
+ const out = await handler(
40
40
  {
41
+ processInstanceKey: "retro-inst-5",
41
42
  variables: {
42
43
  planKey: "o/r#5",
43
44
  status: "filed",
@@ -66,12 +67,17 @@ test("conformance-record: persists a filed conformance from hoisted result vars"
66
67
  assertEquals(row.deviations_unraised, 1);
67
68
  assertEquals(row.has_deviations, 1);
68
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 });
69
75
  });
70
76
 
71
77
  test("conformance-record: derives has_deviations from ground truth even when the agent flag is absent", async () => {
72
78
  const { app, stores } = fakeApp();
73
79
  await handler(
74
- { variables: { planKey: "o/r#6", status: "filed", commentUrl: "https://x/6#c", slicesNotVerified: 1 } } as any,
80
+ { processInstanceKey: "retro-inst-6", variables: { planKey: "o/r#6", status: "filed", commentUrl: "https://x/6#c", slicesNotVerified: 1 } } as any,
75
81
  app as any,
76
82
  );
77
83
  // The agent didn't set hasDeviations, but a not-verified item means the epic didn't cleanly meet spec.
@@ -80,12 +86,15 @@ test("conformance-record: derives has_deviations from ground truth even when the
80
86
 
81
87
  test("conformance-record: a clean epic records has_deviations = 0", async () => {
82
88
  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,
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,
85
91
  app as any,
86
92
  );
87
93
  assertEquals(stores.plan_conformance[0].has_deviations, 0);
88
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 });
89
98
  });
90
99
 
91
100
  test("conformance-record: coerces filed without a comment URL to skipped", async () => {
@@ -139,6 +148,7 @@ test("conformance-record: coerces string-encoded numeric counts hoisted by the a
139
148
  // These must be parsed, not silently coerced to 0 (which would wrongly clear the verdict).
140
149
  await handler(
141
150
  {
151
+ processInstanceKey: "retro-inst-12",
142
152
  variables: {
143
153
  planKey: "o/r#12",
144
154
  status: "filed",
@@ -167,6 +177,7 @@ test("conformance-record: honours a string-encoded hasDeviations flag", async ()
167
177
  // string "true" must still record a deviation — a stringified boolean can't silently be dropped.
168
178
  await handler(
169
179
  {
180
+ processInstanceKey: "retro-inst-13",
170
181
  variables: {
171
182
  planKey: "o/r#13",
172
183
  status: "filed",
@@ -197,3 +208,34 @@ test("conformance-record: defaults to skipped when the agent reported nothing",
197
208
  );
198
209
  assertEquals(stores.plan_conformance[0].status, "skipped");
199
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
+ });
@@ -73,6 +73,28 @@ const handler: AppJobHandler<In> = async (job, app) => {
73
73
  (asBool(job.variables.hasDeviations) ||
74
74
  slicesReduced > 0 || slicesNotVerified > 0 || deviationsUnraised > 0);
75
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
+
76
98
  await recordConformance(app.data, planKey, {
77
99
  status,
78
100
  commentUrl: filed ? commentUrl : null,
@@ -84,12 +106,22 @@ const handler: AppJobHandler<In> = async (job, app) => {
84
106
  hasDeviations,
85
107
  summary,
86
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",
87
116
  });
88
117
 
89
118
  app.log.info(
90
119
  `conformance-record: ${planKey} — status=${status} deviations=${hasDeviations ? "yes" : "no"}`,
91
120
  );
92
- return {};
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 };
93
125
  };
94
126
 
95
127
  export default handler;