@nanobpm/nano-workforce 0.70.2 → 0.72.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/.github/workflows/ci.yml +7 -0
  2. package/AGENTS.md +25 -1
  3. package/CHANGELOG.md +14 -0
  4. package/SPEC.md +7 -2
  5. package/app/agentCompletion.test.ts +69 -0
  6. package/app/agentCompletion.ts +78 -0
  7. package/app/blackboard.test.ts +75 -1
  8. package/app/blackboard.ts +144 -14
  9. package/app/contractReconcile.test.ts +94 -0
  10. package/app/contractReconcile.ts +134 -0
  11. package/app/contracts.test.ts +111 -0
  12. package/app/contracts.ts +446 -0
  13. package/app/instance-tracking.test.ts +44 -1
  14. package/app/pollUserTasks.test.ts +151 -0
  15. package/app/service.ts +229 -2
  16. package/app/trialMerge.ts +5 -0
  17. package/app/userTasks.test.ts +208 -0
  18. package/app/userTasks.ts +217 -0
  19. package/db/migrations/034_user_tasks_inbox.sql +51 -0
  20. package/docs/adr/0004-shared-contract-coordination.md +108 -0
  21. package/nano.app.json +2 -1
  22. package/openapi.yaml +77 -0
  23. package/operations/appendBlackboard.ts +29 -9
  24. package/operations/blackboard.test.ts +49 -0
  25. package/operations/completeUserTask.test.ts +146 -0
  26. package/operations/completeUserTask.ts +72 -0
  27. package/package.json +3 -1
  28. package/pages/cockpit.page.json +1 -0
  29. package/pages/epic-detail.page.json +1 -0
  30. package/pages/epic.page.json +1 -0
  31. package/pages/feature.page.json +1 -0
  32. package/pages/home.page.json +4 -0
  33. package/pages/overview.page.json +1 -0
  34. package/pages/tasks.page.json +297 -0
  35. package/scripts/check-contracts.test.ts +58 -0
  36. package/scripts/check-contracts.ts +151 -0
  37. package/scripts/reconcile-contracts.test.ts +38 -0
  38. package/scripts/reconcile-contracts.ts +104 -0
@@ -0,0 +1,217 @@
1
+ // Unified "Tasks" inbox read-model (issue #236) — the schema-driven pages' single source for the
2
+ // Tasks page, listing EVERY open native user-task escalation awaiting a human decision so an operator
3
+ // can resolve one in the nwf UI (Urban's `taskInbox` at /tasks is a read-only stub).
4
+ //
5
+ // The four migrated escalations (ADR 0046) are native `userTask`s with linked `.form`s. Their
6
+ // completable keys were only ever denormalised for the FEATURE kinds (migrations 031/032, onto
7
+ // `feature_runs`); the epic/PR kinds (`plan-review-decision`, `trial-merge-decision`, the PR
8
+ // `wait-answer`) had no app-side pointer, so the pages could not drive a completion. This module owns
9
+ // the `user_tasks` read-model row shape and the PURE derivation the `pollUserTasks` reconcile
10
+ // (app/service.ts) projects: `buildUserTaskRow` (one open task → a desired row) and
11
+ // `reconcileUserTasks` (the desired set vs the persisted set → the minimal upserts + deletes). The
12
+ // engine iteration + writes live in the poller; the decisions live here so they are unit-testable
13
+ // without a host, mirroring `deriveFeatureEscalationPatch`.
14
+ //
15
+ // Completion is NOT owned here — the page posts the typed form variables to the ONE canonical human
16
+ // completer (`completeEscalationAsHuman`, app/agentCompletion.ts) / the existing feature answer &
17
+ // acknowledge operations, the exact resume path the task inbox uses. This module only makes the open
18
+ // tasks visible; a completed task's row is removed on the next pass when the engine no longer reports
19
+ // it open.
20
+ import type { DataLayer } from "@nanobpm/urban";
21
+ import { FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT } from "./feature.ts";
22
+ import type { PlanReview } from "./plan.ts";
23
+ import type { TrialMergeAuditRow } from "./trialMerge.ts";
24
+
25
+ const now = () => new Date().toISOString();
26
+
27
+ /** The plan-review cap escalation user task (plan-fanout.bpmn) — a human directive (proceed/revise)
28
+ * when the adversarial review loop exhausts its budget without approval. */
29
+ export const PLAN_REVIEW_ELEMENT = "plan-review-decision";
30
+
31
+ /** The trial-merge escalation user task (plan-fanout.bpmn) — a human decision (proceed/rebase/abandon)
32
+ * when a wave's trial merge comes back red. */
33
+ export const TRIAL_MERGE_ELEMENT = "trial-merge-decision";
34
+
35
+ /** The PR review-loop escalation user task (convergence-loop.bpmn) — a human answer that resumes the
36
+ * review loop and is handed to the next round. */
37
+ export const PR_WAIT_ANSWER_ELEMENT = "wait-answer";
38
+
39
+ /** One row per currently-open native user-task escalation, denormalised for the Tasks page. Keyed on
40
+ * the completable `user_task_key` (a task is open at most once). Present iff the engine reports the
41
+ * task open; `pollUserTasks` deletes it once the task is gone. */
42
+ export interface UserTaskRow {
43
+ user_task_key: string;
44
+ element_id: string;
45
+ kind_label: string;
46
+ subject_type: string;
47
+ subject_key: string;
48
+ subject_url: string | null;
49
+ question: string | null;
50
+ process_key: string | null;
51
+ created_at: string;
52
+ updated_at: string;
53
+ }
54
+
55
+ export const userTasks = (data: DataLayer) => data.table<UserTaskRow>("user_tasks", "user_task_key");
56
+
57
+ /** Human-readable label per escalation element. The set of keys is the closed set of user-task
58
+ * elements the Tasks inbox surfaces — an element absent from here is not an escalation and is
59
+ * ignored by `buildUserTaskRow`, so an arbitrary internal user task can never leak into the inbox. */
60
+ export const USER_TASK_KIND_LABELS: Readonly<Record<string, string>> = {
61
+ [FEATURE_ESCALATION_ELEMENT]: "Feature escalation",
62
+ [FEATURE_BLOCKED_ELEMENT]: "Blocked feature run",
63
+ [PLAN_REVIEW_ELEMENT]: "Plan review",
64
+ [TRIAL_MERGE_ELEMENT]: "Trial merge",
65
+ [PR_WAIT_ANSWER_ELEMENT]: "PR review",
66
+ };
67
+
68
+ /** The denormalised context the poller has resolved for an open escalation user task. */
69
+ export interface UserTaskContext {
70
+ userTaskKey: string;
71
+ elementId: string;
72
+ subjectType: "feature" | "plan" | "pr";
73
+ subjectKey: string;
74
+ subjectUrl?: string | null;
75
+ question?: string | null;
76
+ processKey?: string | null;
77
+ }
78
+
79
+ /** Pure: turn one resolved open escalation task into its desired read-model row, or `null` when the
80
+ * element is not one of the surfaced escalation kinds (so a non-escalation user task is never listed)
81
+ * or the required keys are blank. `created_at`/`updated_at` default to now for a fresh row; the
82
+ * reconcile preserves the original `created_at` on an update. */
83
+ export function buildUserTaskRow(ctx: UserTaskContext, at: string = now()): UserTaskRow | null {
84
+ const userTaskKey = ctx.userTaskKey.trim();
85
+ const subjectKey = ctx.subjectKey.trim();
86
+ const kindLabel = USER_TASK_KIND_LABELS[ctx.elementId];
87
+ if (!userTaskKey || !subjectKey || !kindLabel) return null;
88
+ const question = typeof ctx.question === "string" && ctx.question.trim() ? ctx.question.trim() : null;
89
+ return {
90
+ user_task_key: userTaskKey,
91
+ element_id: ctx.elementId,
92
+ kind_label: kindLabel,
93
+ subject_type: ctx.subjectType,
94
+ subject_key: subjectKey,
95
+ subject_url: ctx.subjectUrl ?? null,
96
+ question,
97
+ process_key: ctx.processKey ?? null,
98
+ created_at: at,
99
+ updated_at: at,
100
+ };
101
+ }
102
+
103
+ /** The minimal write plan a reconcile pass applies: rows to insert, rows to update in place (the
104
+ * task is still open but its denormalised context changed), and keys to delete (the task is gone). */
105
+ export interface UserTaskReconcile {
106
+ inserts: UserTaskRow[];
107
+ updates: UserTaskRow[];
108
+ deletes: string[];
109
+ }
110
+
111
+ /** True when the persisted row already matches the freshly-derived one on every display field, so the
112
+ * reconcile can skip a no-op write (an update touches `updated_at`, which would otherwise churn the
113
+ * row on every pass). `created_at`/`updated_at` are intentionally excluded. */
114
+ function sameRow(a: UserTaskRow, b: UserTaskRow): boolean {
115
+ return (
116
+ a.element_id === b.element_id &&
117
+ a.kind_label === b.kind_label &&
118
+ a.subject_type === b.subject_type &&
119
+ a.subject_key === b.subject_key &&
120
+ a.subject_url === b.subject_url &&
121
+ a.question === b.question &&
122
+ a.process_key === b.process_key
123
+ );
124
+ }
125
+
126
+ /** Pure source of truth for the `pollUserTasks` reconcile: diff the DESIRED open-task rows (derived
127
+ * from the engine this pass) against the PERSISTED rows, returning the minimal insert/update/delete
128
+ * plan. A desired row not yet persisted is an insert; a persisted row whose task is still desired but
129
+ * whose context drifted is an update (preserving its original `created_at`); a persisted row no longer
130
+ * desired is a delete (its task was completed — here, via the inbox, or out-of-band). Idempotent: a
131
+ * steady state with no drift yields empty lists, so the poller performs zero writes. */
132
+ export function reconcileUserTasks(persisted: UserTaskRow[], desired: UserTaskRow[]): UserTaskReconcile {
133
+ const persistedByKey = new Map(persisted.map((r) => [r.user_task_key, r]));
134
+ const desiredByKey = new Map(desired.map((r) => [r.user_task_key, r]));
135
+ const inserts: UserTaskRow[] = [];
136
+ const updates: UserTaskRow[] = [];
137
+ const deletes: string[] = [];
138
+
139
+ for (const row of desired) {
140
+ const existing = persistedByKey.get(row.user_task_key);
141
+ if (!existing) {
142
+ inserts.push(row);
143
+ } else if (!sameRow(existing, row)) {
144
+ updates.push({ ...row, created_at: existing.created_at });
145
+ }
146
+ }
147
+ for (const row of persisted) {
148
+ if (!desiredByKey.has(row.user_task_key)) deletes.push(row.user_task_key);
149
+ }
150
+ return { inserts, updates, deletes };
151
+ }
152
+
153
+ // ── Audit-table accessors + derivations used by the poller to enrich the question text ────────────
154
+ // These read the question/findings each escalation kind already records in a SURVIVING audit table,
155
+ // keyed by the subject, so the Tasks grid can show WHAT is being decided. They are display-only.
156
+ //
157
+ // NB: the bespoke `plan_escalations` / `plan_review_escalations` mirror tables were retired in
158
+ // migration 027 (the CONTRACT phase of the native-userTask migration), so the enrichment is derived
159
+ // from the canonical append-only audit logs that DID survive — `plan_reviews` (the adversarial
160
+ // plan-review log) and `plan_trial_merges` (the D3 trial-merge gate log) — not from a dropped mirror.
161
+ // The PR-loop `escalations` audit table was deliberately KEPT by 027, so `prEscalations` still reads it.
162
+
163
+ /** Pure: the findings that drove the still-open `plan-review-decision` escalation — the latest
164
+ * adversarial plan-review round's critique. `plan_reviews` is append-only per (epoch, round); the
165
+ * parked escalation is the tail of the current epoch, so the latest round by (epoch, round) carries
166
+ * the rejecting findings the human is being asked to overrule. `null` when there is no round yet. */
167
+ export function latestPlanReviewFindings(reviews: readonly PlanReview[]): string | null {
168
+ let latest: PlanReview | undefined;
169
+ for (const r of reviews) {
170
+ if (!latest || r.epoch > latest.epoch || (r.epoch === latest.epoch && r.round > latest.round)) latest = r;
171
+ }
172
+ return latest?.findings ?? null;
173
+ }
174
+
175
+ /** Pure: the summary that drove the still-open `trial-merge-decision` escalation — the newest
176
+ * UNRESOLVED red trial-merge attempt. `plan_trial_merges` is append-only and supersede-on-insert
177
+ * marks superseded rows `resolved = 1`, so the newest unresolved `suite-failed`/`merge-conflict` row
178
+ * (highest `id`) is the wave awaiting the human decision. `null` when none is open. */
179
+ export function latestTrialMergeQuestion(audits: readonly TrialMergeAuditRow[]): string | null {
180
+ let latest: TrialMergeAuditRow | undefined;
181
+ for (const a of audits) {
182
+ if (a.resolved === 1) continue;
183
+ if (a.result !== "suite-failed" && a.result !== "merge-conflict") continue;
184
+ if (!latest || a.id > latest.id) latest = a;
185
+ }
186
+ return latest?.summary ?? null;
187
+ }
188
+
189
+ /** One PR review-loop escalation (001_init.sql `escalations`). `status` is open | answered; the poller
190
+ * reads the OPEN row's `question`. */
191
+ export interface PrEscalationRow {
192
+ id: number;
193
+ pr_key: string;
194
+ round_no: number;
195
+ kind: string;
196
+ question: string;
197
+ answer: string | null;
198
+ status: string;
199
+ asked_at: string;
200
+ answered_at: string | null;
201
+ }
202
+
203
+ export const prEscalations = (data: DataLayer) =>
204
+ data.table<PrEscalationRow>("escalations", "id");
205
+
206
+ /** Pure: the question for the still-open PR review-loop escalation. `escalations` is append-only and
207
+ * `id` is an AUTOINCREMENT PK, so when a PR has multiple `open` rows the newest (highest `id`) is the
208
+ * live one the human is being asked; a positional `[0]` from an unordered `find` could surface a stale
209
+ * row. `null` when there is no open escalation. */
210
+ export function latestOpenEscalationQuestion(rows: readonly PrEscalationRow[]): string | null {
211
+ let latest: PrEscalationRow | undefined;
212
+ for (const r of rows) {
213
+ if (r.status !== "open") continue;
214
+ if (!latest || r.id > latest.id) latest = r;
215
+ }
216
+ return latest?.question ?? null;
217
+ }
@@ -0,0 +1,51 @@
1
+ -- Unified "Tasks" inbox read-model (issue #236) — surface EVERY open native user-task escalation
2
+ -- awaiting a human decision in the nwf UI, so an operator can resolve one without leaving the app.
3
+ --
4
+ -- The four migrated escalations (ADR 0046) are native BPMN `userTask`s with linked `.form`s:
5
+ -- `feature-escalation` (a stuck single-issue run), `feature-blocked` (a blocked run to acknowledge),
6
+ -- `plan-review-decision` (a plan-review cap escalation), `trial-merge-decision` (a red trial merge),
7
+ -- and the PR review-loop `wait-answer`. The completable user-task keys were only ever denormalised
8
+ -- for the FEATURE kinds (migrations 031/032, onto `feature_runs`); the epic/PR kinds had NO app-side
9
+ -- pointer at all, so the only surface that listed them was Urban's read-only `taskInbox` stub.
10
+ --
11
+ -- This is the schema-driven pages' single source for the Tasks page `dataGrid`s: `pollUserTasks`
12
+ -- (app/service.ts) reconciles one row per currently-open escalation user task from the engine
13
+ -- (`searchUserTasks`), denormalising the completable `user_task_key` plus display context (the
14
+ -- escalation `question`, its `subject_*`, and `element_id`). A row exists iff the user task is open;
15
+ -- the poller deletes a row once its task is gone (answered here, via the task inbox, or out-of-band),
16
+ -- so `showCount` reflects live pending work. The completion affordances post the typed form variables
17
+ -- to the canonical human completer (`completeEscalationAsHuman` / the existing feature answer/ack
18
+ -- operations), the same resume path the task inbox uses — no parallel completion.
19
+ --
20
+ -- Forward-only, additive (expand): a brand-new table, no existing shape touched. Numbered after the
21
+ -- current highest prefix (033); the runner wraps each file in its own transaction, so this file must
22
+ -- NOT contain BEGIN/COMMIT.
23
+ CREATE TABLE user_tasks (
24
+ -- The completable native user-task key (the engine's `userTaskKey`) — the PK, since a user task is
25
+ -- open at most once and every completion affordance posts to it.
26
+ user_task_key TEXT PRIMARY KEY,
27
+ -- The BPMN `elementId` of the parked user task (one of the migrated escalation elements). Drives
28
+ -- which typed decision form / completion operation the page routes the answer through.
29
+ element_id TEXT NOT NULL,
30
+ -- Human-readable kind label for the grid (e.g. "Plan review", "Trial merge").
31
+ kind_label TEXT NOT NULL,
32
+ -- The domain subject the escalation belongs to: `subject_type` is feature | plan | pr; `subject_key`
33
+ -- is its aggregate key (feature_key / plan_key / pr_key); `subject_url` is an optional external link
34
+ -- (the issue/PR URL) shown as a clickable column.
35
+ subject_type TEXT NOT NULL,
36
+ subject_key TEXT NOT NULL,
37
+ subject_url TEXT,
38
+ -- The escalation question / findings the agent (or loop) raised, denormalised for display so the
39
+ -- operator can decide without opening the process. Best-effort; NULL when none was recorded.
40
+ question TEXT,
41
+ -- The owning process instance key, for the process-explorer link. NULL when unknown.
42
+ process_key TEXT,
43
+ created_at TEXT NOT NULL,
44
+ updated_at TEXT NOT NULL
45
+ );
46
+
47
+ -- The pages filter the grid by `element_id` (one grid per kind) and order by recency
48
+ -- (`WHERE element_id IN (...) ORDER BY updated_at DESC`). A single composite index on
49
+ -- `(element_id, updated_at)` serves both the equality filter and the ordered read in one structure —
50
+ -- no extra sort/scan — so the read stays O(matching), not O(all open tasks), as the table grows.
51
+ CREATE INDEX idx_user_tasks_element_updated ON user_tasks(element_id, updated_at);
@@ -0,0 +1,108 @@
1
+ # ADR 0004 — Coordinate shared contracts through a durable registry + a blackboard signal + a reconciliation pass
2
+
3
+ Status: **Proposed.**
4
+ Date: 2026-08-14.
5
+
6
+ > **Scope note.** A **nano-workforce-local** ADR — it governs how *this app* coordinates cross-cutting
7
+ > contracts across parallel/sliced agent work. Platform-wide ADRs live in
8
+ > `Magikcraft/nano-bpm/docs/adr` (referenced by number + repo). Continues nano-workforce's series after
9
+ > ADR 0001–0003.
10
+
11
+ Relates to:
12
+ issue **#227** (the framing + spec this ADR records the decision for),
13
+ issue **#223** (the concrete `NANO_PR_*` env-key synonym cleanup — this ADR makes its cascade impossible
14
+ to reintroduce),
15
+ nano-ide **#234 / #236** (the relay producer/hub wire-shape drift — the same failure mode in a wire
16
+ contract),
17
+ issues **#214** (real-entrypoint integration test) and **#217** (retro verifies acceptance) — the
18
+ after-the-fact verification this ADR complements with an authoring-time preventive,
19
+ and in this repo: `app/contracts.ts` (the registry + typed env schema + declaration-conflict
20
+ detection), `app/contractReconcile.ts` (the reconciliation pass), `app/blackboard.ts`
21
+ (the `contract` kind + coordination brief), `operations/appendBlackboard.ts`,
22
+ `scripts/check-contracts.ts` (the CI gate) and `scripts/reconcile-contracts.ts` (the advisory pass).
23
+
24
+ ## Context
25
+
26
+ Parallel and sliced agent work keeps producing **two divergent representations of a single contract**,
27
+ with no mechanism that binds them at authoring time. Two live examples of the *same* failure mode:
28
+
29
+ 1. **Config-key synonym** — `publicBaseUrl()` read `NANO_PR_PUBLIC_BASE_URL` and fell back to a phantom
30
+ `NANO_PR_BASE_URL` (introduced in the same commit, #53/2dcfb8a; nothing set it, the unit test even
31
+ exercised the wrong name). Two names for one value (#223). (`NANO_PR_PUBLIC_BASE_URL` was itself
32
+ later coalesced into the canonical `NANO_WORKFORCE_BASE_URL` in #226; both retired names are now
33
+ registered rejected synonyms.)
34
+ 2. **Wire-shape drift** — a relay *producer* kept emitting the legacy `{stream, offset, chunk}` frame
35
+ while the *hub* had adopted an op-tagged `{op:"produce", …}` sub-protocol; the hub rejected every
36
+ worker terminal chunk. Every isolated slice test was green because each side tested against its own
37
+ fake (nano-ide #234/#236).
38
+
39
+ The through-line: **a contract (an env key, a wire shape, a shared type name, a capability-URL scheme) is
40
+ authored independently by parallel workers, each against a mock or a local assumption, and the divergence
41
+ is discovered only at runtime.** Existing issues are *after-the-fact verification* (#214, #217) or fix a
42
+ *single symptom* (#223). None coordinate the shared contract surface **while** siblings are authoring it,
43
+ nor reconcile the accumulated blackboard for duplicate/synonymous declarations.
44
+
45
+ ## Decision
46
+
47
+ Coordinate shared contracts with **three complementary mechanisms**, at deliberately different lifetimes.
48
+
49
+ ### 1. A durable, executable contract registry (source of truth)
50
+
51
+ `app/contracts.ts` is a committed, reviewed, first-class registry of cross-cutting contracts —
52
+ env/config keys, wire-frame shapes, shared exported type/interface names, and capability-URL schemes —
53
+ each with an **owner** + **semantics**. It is *executable where possible*:
54
+
55
+ - **Env/config keys are parsed through ONE typed schema** (`ENV_CONTRACTS` + `readEnv`/`readEnvOr`).
56
+ `readEnv(key)` takes a compile-time-checked `EnvKey`, so a synonymous or misspelled key is a **type
57
+ error**, never a silent runtime fallback. Each entry may record `rejectedSynonyms` — names we
58
+ deliberately retired (e.g. `NANO_PR_BASE_URL`); their reappearance in code is a **CI failure**
59
+ (`scripts/check-contracts.ts`). The base-URL boundary is the migrated reference: `publicBaseUrl()` now
60
+ reads the schema and the phantom fallback is gone, so the #223 cascade **cannot be reintroduced**.
61
+ - **Wire/type/capability-URL contracts** are declared alongside so ONE registry answers "does a contract
62
+ for X already exist?" for every category. The blackboard's `BlackboardEntry` snake_case shape and the
63
+ blackboard capability-URL scheme are the seed entries.
64
+
65
+ ### 2. A blackboard `contract` kind (the live, in-flight signal)
66
+
67
+ `app/blackboard.ts` adds an app-recognised `contract` kind, **derived** from the shared store's kinds
68
+ (`APP_BLACKBOARD_KINDS = [...BLACKBOARD_KINDS, "contract"]`) so the two never drift. An agent posts a
69
+ `contract` entry ("I am introducing / consuming env key / wire op / type X") **as soon as it is true**,
70
+ so siblings in a wave see a new contract *before* they independently invent a synonym. The coordination
71
+ brief (`renderCoordinationBrief`) now requires: before introducing a new env key / wire field / shared
72
+ type, **consult the registry and the blackboard `contract` entries**; if a semantically-equivalent one
73
+ exists, **reuse it**; otherwise declare it in both. The registry is the durable truth; the blackboard is
74
+ the live signal. **Neither alone suffices.**
75
+
76
+ ### 3. A de-duplication / reconciliation pass
77
+
78
+ - **Write-time.** A `contract` POST runs near-duplicate *declaration* detection
79
+ (`detectDeclarationConflicts`, surfaced through `operations/appendBlackboard.ts` alongside the existing
80
+ `file-claim` conflict reporting): it flags a **synonym** (same semantics, different name), a
81
+ **contradiction** (same name, different meaning), or a **rejected synonym**, so the writer reconciles
82
+ at authoring time.
83
+ - **Reconciliation pass** (`app/contractReconcile.ts`, a sibling to the L2 retro). It reads the *whole*
84
+ blackboard + the registry and flags synonyms, contradictions, and **mock-vs-real skew** (a contract
85
+ signalled in-flight that never landed in the durable registry). Advisory: it emits a report as an
86
+ escalation / merge candidate (`npm run reconcile:contracts`) rather than silently accumulating. The
87
+ mechanically-enforceable, registry-only half is the hard CI gate `npm run check:contracts`.
88
+
89
+ ## Consequences
90
+
91
+ - The #223 failure mode is **categorically** closed for env keys: the synonym is a compile error and a
92
+ rejected synonym is a CI failure — not a runtime fallback.
93
+ - A new cross-cutting contract has ONE place to be declared and ONE way to be read; the coordination brief
94
+ routes agents through it, and the reconciliation pass catches what slips through.
95
+ - A CI gate (`check:contracts`) and an advisory pass (`reconcile:contracts`) make the coordination
96
+ observable rather than a hope.
97
+
98
+ ## Open questions / follow-ups
99
+
100
+ - **Promote the `contract` kind into `@nanobpm/agentic/blackboard`.** The shared store's normaliser coerces
101
+ unknown kinds to `note`; the app persists `contract` by patching the row after the store's insert
102
+ (reusing the store's append so the idempotency logic is not duplicated). The durable home for this kind
103
+ is the shared package — a follow-up version bump removes the app-local patch.
104
+ - **Migrate the remaining env call sites** (`app/service.ts`, `app/plan.ts`, `main.ts`, …) onto
105
+ `readEnv`/`readEnvOr`. The registry already declares every config key and the CI gate enforces
106
+ declaration; routing every read through the typed schema is a mechanical follow-up.
107
+ - **Wire the reconciliation pass into the retro process** as an automatic cross-epic step, rather than an
108
+ operator-run script.
package/nano.app.json CHANGED
@@ -66,7 +66,8 @@
66
66
  "statusField": "status",
67
67
  "activeStatuses": [
68
68
  "running",
69
- "escalated"
69
+ "escalated",
70
+ "awaiting_operator"
70
71
  ],
71
72
  "onTerminated": {
72
73
  "set": {
package/openapi.yaml CHANGED
@@ -864,6 +864,30 @@ components:
864
864
  type: string
865
865
  created_at:
866
866
  type: string
867
+ contractConflicts:
868
+ type: array
869
+ description: >-
870
+ Near-duplicate contract-DECLARATION conflicts on a `contract` POST (issue #227): a
871
+ synonym, contradiction, or rejected synonym vs. the durable contract registry. Advisory —
872
+ surfaced so the writer reconciles a divergent contract at authoring time; never a lock.
873
+ items:
874
+ type: object
875
+ additionalProperties: false
876
+ required:
877
+ - kind
878
+ - proposedName
879
+ - existingName
880
+ - detail
881
+ properties:
882
+ kind:
883
+ type: string
884
+ enum: [synonym, contradiction, rejected-synonym]
885
+ proposedName:
886
+ type: string
887
+ existingName:
888
+ type: string
889
+ detail:
890
+ type: string
867
891
  AbandonStatus:
868
892
  type: object
869
893
  required:
@@ -1244,6 +1268,59 @@ paths:
1244
1268
  application/json:
1245
1269
  schema:
1246
1270
  $ref: "#/components/schemas/MessageResult"
1271
+ /actions/complete-user-task:
1272
+ post:
1273
+ operationId: completeUserTask
1274
+ summary: "Complete an open native user-task escalation from the nwf Tasks inbox (issue #236).
1275
+ Submits the parked task's typed `.form` variables (e.g. a plan-review `{ directive, notes }`, a
1276
+ trial-merge `{ action, notes }`, or a PR `{ answer }`) to the canonical human completer
1277
+ (completeEscalationAsHuman → completeUserTaskAttributed) — the same resume path the task inbox
1278
+ uses, recording who answered. The completer refuses any user task that is not one of the
1279
+ migrated escalation elements. The feature-run kinds keep their own operations
1280
+ (answer-escalation / acknowledge-blocked); this door is for the plan-review / trial-merge / PR
1281
+ `wait-answer` kinds whose completion is a straight typed pass-through. On success the answered
1282
+ task's read-model row is dropped so the Tasks grid stops offering a decision for it."
1283
+ requestBody:
1284
+ required: true
1285
+ content:
1286
+ application/json:
1287
+ schema:
1288
+ type: object
1289
+ additionalProperties: false
1290
+ required:
1291
+ - userTaskKey
1292
+ - variables
1293
+ properties:
1294
+ userTaskKey:
1295
+ type: string
1296
+ minLength: 1
1297
+ description: The parked escalation user-task key (user_tasks.user_task_key).
1298
+ variables:
1299
+ type: object
1300
+ additionalProperties: true
1301
+ description: The typed form variables the parked task's `.form` expects (kind-specific).
1302
+ operator:
1303
+ type: string
1304
+ description: Optional operator handle recorded in the attribution ledger; defaults to "operator".
1305
+ responses:
1306
+ "200":
1307
+ description: The escalation user task was completed and the process resumed.
1308
+ content:
1309
+ application/json:
1310
+ schema:
1311
+ $ref: "#/components/schemas/MessageResult"
1312
+ "400":
1313
+ description: A required field was missing/invalid, or the target is not an escalation task.
1314
+ content:
1315
+ application/json:
1316
+ schema:
1317
+ $ref: "#/components/schemas/MessageResult"
1318
+ "404":
1319
+ description: No open escalation user task matches the userTaskKey.
1320
+ content:
1321
+ application/json:
1322
+ schema:
1323
+ $ref: "#/components/schemas/MessageResult"
1247
1324
  /actions/acknowledge-blocked:
1248
1325
  post:
1249
1326
  operationId: acknowledgeBlocked
@@ -7,11 +7,15 @@
7
7
  // POST → append one entry: { author_task?, kind?, files?, body, wave?, dedupe_key? }. Idempotent
8
8
  // on (plan, dedupe_key). Returns { id, inserted, conflicts } — `conflicts` lists prior
9
9
  // sibling `file-claim`s on the same file(s) (advisory first-writer-wins; never a lock).
10
+ // A `contract` POST additionally returns `contractConflicts` (near-duplicate declaration
11
+ // conflicts vs. the durable registry, #227); the field is absent for other kinds, per the
12
+ // OpenAPI schema's optional property.
10
13
 
11
14
  import {
12
15
  appendEntry,
16
+ detectContractDeclarationConflicts,
13
17
  detectFileClaimConflicts,
14
- normalizeKind,
18
+ normalizeAppKind,
15
19
  planKeyForToken,
16
20
  } from "../app/blackboard.ts";
17
21
  import { defineOperation } from "../nano-generated/operations.ts";
@@ -28,40 +32,56 @@ export default defineOperation("appendBlackboard", async ({ req, body }, app) =>
28
32
  const b = body ?? {};
29
33
  const text = typeof b.body === "string" ? b.body.trim() : "";
30
34
  if (!text) return { status: 400, body: { error: "'body' (the note text) is required" } };
31
- const kind = normalizeKind(b.kind);
35
+ const kind = normalizeAppKind(b.kind);
32
36
  const files = Array.isArray(b.files) ? b.files.map(String) : [];
33
37
  // Normalize once (trim + default to "system") so the value we send to appendEntry matches the
34
38
  // value we send to detectFileClaimConflicts. Otherwise an omitted/blank author_task is stored as
35
39
  // "system" but conflict detection sees "", and the caller's own prior "system" claims are wrongly
36
40
  // reported as sibling conflicts.
37
41
  const author_task = (typeof b.author_task === "string" ? b.author_task.trim() : "") || "system";
42
+ // Trim before it becomes the idempotency key: `dedupe_key` backs a unique index (and is now also
43
+ // parsed for the `<category>:<name>` contract ref), so accidental leading/trailing whitespace would
44
+ // otherwise slip past dedupe and create near-identical entries. A blank-after-trim key is no key.
45
+ const dedupe_key = typeof b.dedupe_key === "string" ? b.dedupe_key.trim() || undefined : undefined;
38
46
  const res = await appendEntry(app.data, planKey, {
39
47
  author_task,
40
48
  kind,
41
49
  files,
42
50
  body: text,
43
51
  wave: typeof b.wave === "number" ? b.wave : null,
44
- dedupe_key: typeof b.dedupe_key === "string" ? b.dedupe_key : undefined,
52
+ dedupe_key,
45
53
  });
46
- // Advisory conflict-of-intent: surface prior sibling claims on the same file(s). Computed AFTER
47
- // the append and filtered to claims strictly before ours (id < res.id), so first-writer-wins is
48
- // decided by insertion order a sibling that raced a claim in between is still caught, and our
49
- // own just-written row is never reported. Never blocks the append — the agent decides how to react.
54
+ // Advisory conflict-of-intent. For a `file-claim`, surface prior sibling claims on the same
55
+ // file(s) — computed AFTER the append and filtered to claims strictly before ours (id < res.id),
56
+ // so first-writer-wins is decided by insertion order (a sibling that raced a claim in between is
57
+ // still caught, and our own just-written row is never reported). Never blocks the append.
50
58
  const conflicts = kind === "file-claim"
51
59
  ? await detectFileClaimConflicts(app.data, planKey, {
52
60
  author_task,
53
61
  files,
54
- beforeId: Number(res.id),
62
+ beforeId: res.id,
55
63
  })
56
64
  : [];
65
+ // For a `contract`, surface near-duplicate DECLARATION conflicts (a synonym/contradiction/rejected
66
+ // synonym vs. the durable registry) so a writer reconciles a divergent contract at authoring time
67
+ // (#227). Advisory — the agent decides how to react.
68
+ const contractConflicts = kind === "contract"
69
+ ? detectContractDeclarationConflicts({ dedupe_key, body: text })
70
+ : [];
57
71
  app.log.info("blackboard entry appended", {
58
72
  planKey,
59
73
  kind,
60
74
  inserted: res.inserted,
61
75
  conflicts: conflicts.length,
76
+ contractConflicts: contractConflicts.length,
62
77
  });
63
78
  return {
64
79
  status: res.inserted ? 201 : 200,
65
- body: { id: Number(res.id), inserted: res.inserted, conflicts },
80
+ // `contractConflicts` is only meaningful on a `contract` POST and is optional in the schema, so
81
+ // omit it entirely for other kinds rather than emitting an always-empty array (keeps the response
82
+ // shape aligned with the OpenAPI contract, which does not require the field).
83
+ body: kind === "contract"
84
+ ? { id: res.id, inserted: res.inserted, conflicts, contractConflicts }
85
+ : { id: res.id, inserted: res.inserted, conflicts },
66
86
  };
67
87
  });
@@ -100,6 +100,18 @@ test("POST is idempotent on dedupe_key (retry → 200, not a duplicate)", async
100
100
  assertEquals(n, 1);
101
101
  });
102
102
 
103
+ test("POST dedupe_key is trimmed → a whitespace-padded retry still dedupes to one row (#227)", async () => {
104
+ const { app, db } = memApp();
105
+ await seedPlan(app, "o/r#1", "tok");
106
+ assertEquals((await call(app, "POST", { token: "tok" }, { author_task: "t", body: "claim", dedupe_key: "t:claim:1" })).status, 201);
107
+ // A retry whose key only differs by leading/trailing whitespace must NOT slip past dedupe.
108
+ const padded = await call(app, "POST", { token: "tok" }, { author_task: "t", body: "claim", dedupe_key: " t:claim:1 " });
109
+ assertEquals(padded.status, 200);
110
+ assertEquals(padded.body.inserted, false);
111
+ const [{ n }] = db.all<{ n: number }>("SELECT COUNT(*) AS n FROM agentic_blackboard WHERE scope = ?", ["o/r#1"]);
112
+ assertEquals(n, 1);
113
+ });
114
+
103
115
  test("GET ?since returns only newer entries", async () => {
104
116
  const { app } = memApp();
105
117
  await seedPlan(app, "o/r#1", "tok");
@@ -175,4 +187,41 @@ test("POST a non-file-claim carries no conflicts", async () => {
175
187
  await seedPlan(app, "o/r#1", "tok");
176
188
  const res = await call(app, "POST", { token: "tok" }, { author_task: "t", kind: "note", body: "fyi" });
177
189
  assertEquals(res.body.conflicts, []);
190
+ // `contractConflicts` is optional in the schema and only meaningful on a `contract` POST — a
191
+ // non-`contract` response omits it entirely rather than emitting an always-empty array (#229).
192
+ assertEquals("contractConflicts" in res.body, false);
193
+ });
194
+
195
+ test("POST kind='contract' persists the contract kind and round-trips through GET (#227)", async () => {
196
+ const { app } = memApp();
197
+ await seedPlan(app, "o/r#1", "tok");
198
+ const post = await call(app, "POST", { token: "tok" }, {
199
+ author_task: "task-a",
200
+ kind: "contract",
201
+ dedupe_key: "env:NANO_WIDGET_TIMEOUT",
202
+ body: "introducing env key NANO_WIDGET_TIMEOUT — app/widget.ts — widget request timeout in ms",
203
+ });
204
+ assertEquals(post.status, 201);
205
+ // No existing contract matches, so no declaration conflicts.
206
+ assertEquals(post.body.contractConflicts, []);
207
+
208
+ const get = await call(app, "GET", { token: "tok" });
209
+ assertEquals(get.body.entries.length, 1);
210
+ // The store's normaliser would coerce an unknown kind to 'note'; the adapter restores 'contract'.
211
+ assertEquals(get.body.entries[0].kind, "contract");
212
+ });
213
+
214
+ test("POST kind='contract' reintroducing a rejected synonym surfaces a declaration conflict (#223/#227)", async () => {
215
+ const { app } = memApp();
216
+ await seedPlan(app, "o/r#1", "tok");
217
+ const post = await call(app, "POST", { token: "tok" }, {
218
+ author_task: "task-b",
219
+ kind: "contract",
220
+ dedupe_key: "env:NANO_PR_BASE_URL",
221
+ body: "base url for the app",
222
+ });
223
+ assertEquals(post.status, 201);
224
+ assertEquals(post.body.contractConflicts.length >= 1, true);
225
+ assertEquals(post.body.contractConflicts[0].kind, "rejected-synonym");
226
+ assertEquals(post.body.contractConflicts[0].existingName, "NANO_WORKFORCE_BASE_URL");
178
227
  });