@nanobpm/nano-workforce 0.99.1 → 0.101.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 (48) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/app/agentCompletion.test.ts +92 -6
  3. package/app/agentCompletion.ts +86 -58
  4. package/app/contracts.ts +18 -2
  5. package/app/feature.ts +15 -166
  6. package/app/featureGateway.test.ts +6 -57
  7. package/app/migration049.test.ts +112 -0
  8. package/app/pollUserTasks.test.ts +39 -13
  9. package/app/service.test.ts +16 -0
  10. package/app/service.ts +107 -136
  11. package/app/stage.test.ts +7 -53
  12. package/app/stage.ts +5 -37
  13. package/app/userTasks.test.ts +1 -1
  14. package/app/userTasks.ts +3 -3
  15. package/app/world/checkpoint.test.ts +193 -0
  16. package/app/world/checkpoint.ts +142 -0
  17. package/app/world/effect-ledger.test.ts +86 -0
  18. package/app/world/effect-ledger.ts +103 -0
  19. package/app/world/git.ts +53 -0
  20. package/app/world/index.ts +26 -0
  21. package/app/world/store.test.ts +443 -0
  22. package/app/world/store.ts +320 -0
  23. package/app/world-marker.test.ts +79 -0
  24. package/db/migrations/049_drop_feature_escalation_surface.sql +25 -0
  25. package/db/migrations/049_world_checkpoint.sql +84 -0
  26. package/e2e/feature-run.e2e.ts +52 -41
  27. package/e2e/retire-escalation-subsystem.e2e.ts +26 -0
  28. package/openapi.yaml +7 -108
  29. package/operations/agentCompleteEscalation.ts +2 -2
  30. package/operations/completeUserTask.test.ts +25 -6
  31. package/operations/completeUserTask.ts +12 -10
  32. package/package.json +2 -2
  33. package/pages/feature.page.json +1 -37
  34. package/pages/overview.page.json +1 -39
  35. package/pages/tasks.page.json +78 -93
  36. package/resources/forms/feature-escalation.form +3 -0
  37. package/test/worldDb.ts +103 -0
  38. package/workers/persist-round/worker.ts +68 -0
  39. package/workers/record-blocked-ack/worker.test.ts +1 -4
  40. package/workers/record-blocked-ack/worker.ts +0 -5
  41. package/workers/record-feature/worker.ts +0 -6
  42. package/workers/record-feature-escalation/worker.test.ts +14 -27
  43. package/workers/record-feature-escalation/worker.ts +16 -22
  44. package/app/featureBlocked.test.ts +0 -182
  45. package/app/featureEscalation.test.ts +0 -235
  46. package/operations/acknowledgeBlocked.test.ts +0 -111
  47. package/operations/acknowledgeBlocked.ts +0 -62
  48. package/operations/answerFeatureEscalation.ts +0 -68
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [0.101.0](https://github.com/nanobpm/nano-workforce/compare/v0.100.0...v0.101.0) (2026-08-19)
2
+
3
+
4
+ ### Features
5
+
6
+ * **world:** durable world-restore — c8ctl working-tree reconstruction + effect fence ([#324](https://github.com/nanobpm/nano-workforce/issues/324)) ([#337](https://github.com/nanobpm/nano-workforce/issues/337)) ([d2f7655](https://github.com/nanobpm/nano-workforce/commit/d2f76557027eddeed062208ebaeb8136f7b9b922)), closes [#nextSeqOn](https://github.com/nanobpm/nano-workforce/issues/nextSeqOn) [#nextSeqOn](https://github.com/nanobpm/nano-workforce/issues/nextSeqOn) [#nextSeqOn](https://github.com/nanobpm/nano-workforce/issues/nextSeqOn) [#appendEffect](https://github.com/nanobpm/nano-workforce/issues/appendEffect) [#isFenceCollision](https://github.com/nanobpm/nano-workforce/issues/isFenceCollision) [#appendEffect](https://github.com/nanobpm/nano-workforce/issues/appendEffect) [#reconcileApplied](https://github.com/nanobpm/nano-workforce/issues/reconcileApplied) [#nextSeqOn](https://github.com/nanobpm/nano-workforce/issues/nextSeqOn)
7
+
8
+ # [0.100.0](https://github.com/nanobpm/nano-workforce/compare/v0.99.1...v0.100.0) (2026-08-19)
9
+
10
+
11
+ ### Features
12
+
13
+ * retire interim feature-run escalation/blocked surface ([#339](https://github.com/nanobpm/nano-workforce/issues/339)) ([525d0f7](https://github.com/nanobpm/nano-workforce/commit/525d0f7a7f9661f640899e4bdf1b32052cff4ed9)), closes [#305](https://github.com/nanobpm/nano-workforce/issues/305) [#310](https://github.com/nanobpm/nano-workforce/issues/310) [#332](https://github.com/nanobpm/nano-workforce/issues/332) [#332](https://github.com/nanobpm/nano-workforce/issues/332)
14
+
1
15
  ## [0.99.1](https://github.com/nanobpm/nano-workforce/compare/v0.99.0...v0.99.1) (2026-08-19)
2
16
 
3
17
 
@@ -62,11 +62,15 @@ function memData(stores: Record<string, { rows: any[]; key: string }>) {
62
62
  } as any;
63
63
  }
64
64
 
65
- /** A stub engine recording every `completeUserTask`, with a seeded set of open user tasks. */
65
+ /** A stub engine recording every `completeUserTask`, with a seeded set of open user tasks. The
66
+ * completers resolve tasks via `openUserTasks` (CREATED only), so the fixture exposes it; a legacy
67
+ * `searchUserTasks` (ANY state) is also present to prove the completer does NOT reach for it. */
66
68
  function fakeEngine(openTasks: Array<{ userTaskKey: string; elementId?: string }>) {
67
69
  const completed: Array<{ userTaskKey: string; variables?: Record<string, unknown> }> = [];
68
70
  const engine = {
69
- searchUserTasks: (_filter?: Record<string, unknown>) => Promise.resolve(openTasks),
71
+ openUserTasks: (_filter?: Record<string, unknown>) => Promise.resolve(openTasks),
72
+ searchUserTasks: (_filter?: Record<string, unknown>) =>
73
+ Promise.reject(new Error("completer must resolve via openUserTasks, not searchUserTasks")),
70
74
  completeUserTask: (userTaskKey: string, variables?: Record<string, unknown>) => {
71
75
  completed.push({ userTaskKey, variables });
72
76
  return Promise.resolve();
@@ -118,7 +122,7 @@ test("agent completer refuses a non-escalation user task (scoped to the migrated
118
122
  });
119
123
 
120
124
  assertEquals(r.ok, false);
121
- assertEquals(r.reason, "not an escalation task");
125
+ assertEquals(r.reason, "not a completable task");
122
126
  assertEquals(completed.length, 0, "a non-escalation task is never completed");
123
127
  assertEquals(stores.task_completions.rows.length, 0, "and no attribution row is written");
124
128
  });
@@ -137,10 +141,40 @@ test("agent completer is a no-op for an unknown userTaskKey", async () => {
137
141
  });
138
142
 
139
143
  assertEquals(r.ok, false);
140
- assertEquals(r.reason, "no open escalation task");
144
+ assertEquals(r.reason, "no open completable task");
141
145
  assertEquals(completed.length, 0);
142
146
  });
143
147
 
148
+ test("completer resolves only OPEN tasks — a completed/canceled task's key is a 404-style no-op, not a doomed re-completion", async () => {
149
+ // A looping instance keeps COMPLETED/CANCELED user tasks alongside the live one. If the completer
150
+ // matched by key against ANY-state tasks (`searchUserTasks`), a stale key would drive a re-completion
151
+ // that the engine rejects with a 5xx instead of the intended "no open task" no-op. The completer must
152
+ // query `openUserTasks` (CREATED only), so a key that exists only as a non-open task does not match.
153
+ const stores = { task_completions: { rows: [] as any[], key: "id" } };
154
+ const data = memData(stores);
155
+ const completed: Array<{ userTaskKey: string }> = [];
156
+ const engine = {
157
+ // No open tasks…
158
+ openUserTasks: () => Promise.resolve([]),
159
+ // …even though a completed task with this key exists in the full (any-state) search.
160
+ searchUserTasks: () => Promise.resolve([{ userTaskKey: "ut-done", elementId: "feature-escalation" }]),
161
+ completeUserTask: (userTaskKey: string) => {
162
+ completed.push({ userTaskKey });
163
+ return Promise.resolve();
164
+ },
165
+ } as any;
166
+
167
+ const r = await completeEscalationAsHuman(data, engine, {
168
+ userTaskKey: "ut-done",
169
+ operatorId: "alice",
170
+ variables: { resolution: "abandon" },
171
+ });
172
+
173
+ assertEquals(r.ok, false);
174
+ assertEquals(r.reason, "no open completable task");
175
+ assertEquals(completed.length, 0, "a non-open key never drives a doomed re-completion");
176
+ });
177
+
144
178
  test("a HUMAN operator completes a feature escalation via the SAME attributed resume path (issue #210)", async () => {
145
179
  const stores = { task_completions: { rows: [] as any[], key: "id" } };
146
180
  const data = memData(stores);
@@ -166,6 +200,35 @@ test("a HUMAN operator completes a feature escalation via the SAME attributed re
166
200
  assertEquals(row.reversible, 0, "a human completion is the authority (not reversible)");
167
201
  });
168
202
 
203
+ test("feature-blocked is HUMAN-completable but NOT agent-completable (issue #332)", async () => {
204
+ // Issue #332 folded the bespoke `acknowledge-blocked` door onto the canonical human completer, so a
205
+ // HUMAN operator retires a blocked run through `completeEscalationAsHuman`. It stays OUTSIDE the agent
206
+ // surface (`ESCALATION_TASK_ELEMENTS`) — an agent must never acknowledge a blocked run on a human's
207
+ // behalf — so the agent completer refuses it.
208
+ const stores = { task_completions: { rows: [] as any[], key: "id" } };
209
+ const data = memData(stores);
210
+ const { engine, completed } = fakeEngine([{ userTaskKey: "ut-b", elementId: "feature-blocked" }]);
211
+
212
+ const asAgent = await completeEscalationAsAgent(data, engine, {
213
+ userTaskKey: "ut-b",
214
+ agentId: "bot",
215
+ variables: { note: "n" },
216
+ });
217
+ assertEquals(asAgent.ok, false, "the agent completer refuses feature-blocked");
218
+ assertEquals(asAgent.reason, "not a completable task");
219
+ assertEquals(completed.length, 0);
220
+
221
+ const asHuman = await completeEscalationAsHuman(data, engine, {
222
+ userTaskKey: "ut-b",
223
+ operatorId: "alice",
224
+ variables: { note: "reassigned to a human" },
225
+ });
226
+ assertEquals(asHuman.ok, true, "the human completer retires feature-blocked");
227
+ assertEquals(asHuman.elementId, "feature-blocked");
228
+ assertEquals(completed.length, 1);
229
+ assertEquals(completed[0].variables, { note: "reassigned to a human" });
230
+ });
231
+
169
232
  test("human completer refuses a non-escalation user task and is a no-op for an unknown key", async () => {
170
233
  const stores = { task_completions: { rows: [] as any[], key: "id" } };
171
234
  const data = memData(stores);
@@ -177,7 +240,7 @@ test("human completer refuses a non-escalation user task and is a no-op for an u
177
240
  variables: { resolution: "abandon" },
178
241
  });
179
242
  assertEquals(notEsc.ok, false);
180
- assertEquals(notEsc.reason, "not an escalation task");
243
+ assertEquals(notEsc.reason, "not a completable task");
181
244
 
182
245
  const missing = await completeEscalationAsHuman(data, engine, {
183
246
  userTaskKey: "ut-missing",
@@ -185,7 +248,7 @@ test("human completer refuses a non-escalation user task and is a no-op for an u
185
248
  variables: { resolution: "abandon" },
186
249
  });
187
250
  assertEquals(missing.ok, false);
188
- assertEquals(missing.reason, "no open escalation task");
251
+ assertEquals(missing.reason, "no open completable task");
189
252
 
190
253
  assertEquals(completed.length, 0, "neither refusal completes a task");
191
254
  assertEquals(stores.task_completions.rows.length, 0, "and no attribution row is written");
@@ -455,3 +518,26 @@ test("validateEscalationVariables derives its contract from the canonical .form
455
518
  // an element with no linked form contract is not enforced
456
519
  assertEquals(validateEscalationVariables("some-other-task", { whatever: 1 }), null);
457
520
  });
521
+
522
+ test("feature-escalation demands non-blank answer on the answer path, but not on the hidden abandon path", async () => {
523
+ // resolution=answer shows the conditional `answer` field, which is required → blank/missing rejected.
524
+ assert(
525
+ validateEscalationVariables("feature-escalation", { resolution: "answer" }) !== null,
526
+ "resolution=answer with no guidance is rejected",
527
+ );
528
+ assert(
529
+ validateEscalationVariables("feature-escalation", { resolution: "answer", answer: " " }) !== null,
530
+ "resolution=answer with whitespace-only guidance is rejected",
531
+ );
532
+ assertEquals(
533
+ validateEscalationVariables("feature-escalation", { resolution: "answer", answer: "use v2" }),
534
+ null,
535
+ "resolution=answer with real guidance passes",
536
+ );
537
+ // resolution=abandon HIDES the `answer` field, so its required-ness is not enforced.
538
+ assertEquals(
539
+ validateEscalationVariables("feature-escalation", { resolution: "abandon" }),
540
+ null,
541
+ "the abandon path does not demand the hidden answer field",
542
+ );
543
+ });
@@ -71,6 +71,23 @@ export const ESCALATION_TASK_ELEMENTS: ReadonlySet<string> = new Set([
71
71
  "wait-merge-answer", // PR merge-loop escalation (merge-loop.bpmn) — same native user-task path (#256)
72
72
  ]);
73
73
 
74
+ /** The `feature-blocked` operator user-task element id (feature.bpmn) — the native wait a run parks on
75
+ * when the agent reports a `blocked` outcome. Unlike an escalation it is NOT agent-answerable (only a
76
+ * human operator retires a blocked run), so it lives OUTSIDE `ESCALATION_TASK_ELEMENTS` — the agent
77
+ * completer (`completeEscalationAsAgent`) must never touch it — but it IS a human-completable decision
78
+ * on the Tasks inbox, so the HUMAN completer accepts it (issue #332 folded the bespoke
79
+ * `acknowledge-blocked` door onto the one canonical `complete-user-task` door). */
80
+ export const FEATURE_BLOCKED_TASK_ELEMENT = "feature-blocked";
81
+
82
+ /** The user-task `elementId`s a HUMAN operator may complete from the Tasks inbox via the one canonical
83
+ * `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. */
86
+ export const HUMAN_COMPLETABLE_ELEMENTS: ReadonlySet<string> = new Set([
87
+ ...ESCALATION_TASK_ELEMENTS,
88
+ FEATURE_BLOCKED_TASK_ELEMENT,
89
+ ]);
90
+
74
91
  /** Each escalation `elementId` → the `.form` whose contract governs its completion variables (the
75
92
  * BPMN `zeebe:formDefinition formId`). Kept beside `ESCALATION_TASK_ELEMENTS` so the completer
76
93
  * validates against the SAME `.form` the task inbox renders — one contract, no second field list. */
@@ -80,36 +97,74 @@ const ESCALATION_FORM_BY_ELEMENT: Readonly<Record<string, string>> = {
80
97
  "trial-merge-decision": "trial-merge-decision",
81
98
  "wait-answer": "pr-escalation",
82
99
  "wait-merge-answer": "pr-escalation",
100
+ "feature-blocked": "feature-blocked",
83
101
  };
84
102
 
103
+ /** A field's `conditional.hide` rule, parsed from the FEEL subset the `.form` files use
104
+ * (`=<ref> != "<value>"` / `=<ref> == "<value>"`). A required field is only enforced when it is
105
+ * actually shown, so an "answer only when resolution=answer" field is not demanded on the abandon
106
+ * path. */
107
+ interface HideRule {
108
+ ref: string;
109
+ op: "==" | "!=";
110
+ value: string;
111
+ }
112
+
85
113
  interface FormContract {
86
114
  /** Field keys marked `validate.required` in the `.form`. */
87
115
  required: string[];
88
116
  /** `select` field key → its allowed `values`. */
89
117
  allowed: Record<string, string[]>;
118
+ /** Field key → its `conditional.hide` rule (only for fields whose visibility is conditional). */
119
+ hideWhen: Record<string, HideRule>;
90
120
  }
91
121
 
92
122
  const formContractCache = new Map<string, FormContract>();
93
123
 
94
- /** Derive a `.form`'s required-field + select allowed-value contract, cached per formId. The `.form`
95
- * files (`resources/forms/*.form`, deployed via nano.app.json) are the CANONICAL contract, so this
96
- * reads them rather than re-encoding the field lists no drift surface. */
124
+ /** Parse the `.form` FEEL subset used by `conditional.hide` `=<ref> (!=|==) "<value>"` — into a
125
+ * structured rule, or `null` for anything outside that subset (treated as "always shown", so a
126
+ * required field is never silently skipped by an unrecognised expression). */
127
+ function parseHide(expr: string | undefined): HideRule | null {
128
+ if (!expr) return null;
129
+ const m = /^=\s*([A-Za-z_$][\w$]*)\s*(==|!=)\s*"([^"]*)"\s*$/.exec(expr);
130
+ if (!m) return null;
131
+ const op = m[2] === "==" ? "==" : "!=";
132
+ return { ref: m[1], op, value: m[3] };
133
+ }
134
+
135
+ /** Whether a `conditional.hide` rule hides its field given the submitted `variables`. */
136
+ function isHidden(rule: HideRule, variables: Record<string, unknown>): boolean {
137
+ const actual = String(variables[rule.ref] ?? "");
138
+ return rule.op === "!=" ? actual !== rule.value : actual === rule.value;
139
+ }
140
+
141
+ /** Derive a `.form`'s required-field + select allowed-value + conditional-visibility contract, cached
142
+ * per formId. The `.form` files (`resources/forms/*.form`, deployed via nano.app.json) are the
143
+ * CANONICAL contract, so this reads them rather than re-encoding the field lists — no drift surface. */
97
144
  function formContract(formId: string): FormContract {
98
145
  const cached = formContractCache.get(formId);
99
146
  if (cached) return cached;
100
147
  const raw: {
101
- components?: { key?: string; validate?: { required?: boolean }; values?: { value?: string }[] }[];
148
+ components?: {
149
+ key?: string;
150
+ validate?: { required?: boolean };
151
+ values?: { value?: string }[];
152
+ conditional?: { hide?: string };
153
+ }[];
102
154
  } = JSON.parse(readFileSync(new URL(`../resources/forms/${formId}.form`, import.meta.url), "utf8"));
103
155
  const required: string[] = [];
104
156
  const allowed: Record<string, string[]> = {};
157
+ const hideWhen: Record<string, HideRule> = {};
105
158
  for (const c of raw.components ?? []) {
106
159
  if (!c.key) continue;
107
160
  if (c.validate?.required) required.push(c.key);
108
161
  if (c.values?.length) {
109
162
  allowed[c.key] = c.values.map((v) => v.value ?? "").filter((v) => v !== "");
110
163
  }
164
+ const hide = parseHide(c.conditional?.hide);
165
+ if (hide) hideWhen[c.key] = hide;
111
166
  }
112
- const contract: FormContract = { required, allowed };
167
+ const contract: FormContract = { required, allowed, hideWhen };
113
168
  formContractCache.set(formId, contract);
114
169
  return contract;
115
170
  }
@@ -117,18 +172,23 @@ function formContract(formId: string): FormContract {
117
172
  /** Validate completion `variables` against the escalation's `.form` contract (required fields present
118
173
  * + `select` values within the allowed set), so a completion can never resume the process with a
119
174
  * missing/invalid decision (e.g. a `wait-answer` with no `answer`, or a `trial-merge-decision` with
120
- * an `action` outside proceed/rebase/abandon). Returns a human-readable reason on violation, or
121
- * `null` when the variables satisfy the contract. Derived from the canonical `.form` — the same
122
- * contract the task inbox renders so both the agent and human completers reject invalid input the
123
- * exact same way, with one implementation. An element with no linked form contract is not enforced. */
175
+ * an `action` outside proceed/rebase/abandon). A conditionally-shown required field is only enforced
176
+ * when its `conditional.hide` rule leaves it visible so a `feature-escalation` with
177
+ * `resolution="answer"` demands a non-blank `answer` (re-dispatch guidance), but the `abandon` path,
178
+ * which hides `answer`, does not. Returns a human-readable reason on violation, or `null` when the
179
+ * variables satisfy the contract. Derived from the canonical `.form` — the same contract the task
180
+ * inbox renders — so both the agent and human completers reject invalid input the exact same way,
181
+ * with one implementation. An element with no linked form contract is not enforced. */
124
182
  export function validateEscalationVariables(
125
183
  elementId: string,
126
184
  variables: Record<string, unknown>,
127
185
  ): string | null {
128
186
  const formId = ESCALATION_FORM_BY_ELEMENT[elementId];
129
187
  if (!formId) return null;
130
- const { required, allowed } = formContract(formId);
188
+ const { required, allowed, hideWhen } = formContract(formId);
131
189
  for (const key of required) {
190
+ const hide = hideWhen[key];
191
+ if (hide && isHidden(hide, variables)) continue;
132
192
  const v = variables[key];
133
193
  if (v === undefined || v === null || (typeof v === "string" && v.trim() === "")) {
134
194
  return `${elementId}: "${key}" is required`;
@@ -222,19 +282,24 @@ export interface AgentCompleteResult {
222
282
  elementId?: string;
223
283
  }
224
284
 
225
- /** Resolve a parked escalation user task by key: return its `elementId` if it is one of the migrated
226
- * escalation tasks, or a failure reason otherwise. Shared by the agent and human completers so both
227
- * refuse a non-escalation / missing target the exact same way (a key with no matching open
228
- * escalation task is a 404-style no-op). */
285
+ /** Resolve a parked escalation user task by key: return its `elementId` if it is one of the `allowed`
286
+ * completable tasks, or a failure reason otherwise. Shared by the agent and human completers so both
287
+ * refuse a non-completable / missing target the exact same way (a key with no matching open task is a
288
+ * 404-style no-op). The AGENT completer passes the default `ESCALATION_TASK_ELEMENTS`; the HUMAN
289
+ * completer passes the wider `HUMAN_COMPLETABLE_ELEMENTS` (which also admits `feature-blocked`).
290
+ * Queries `openUserTasks` (lifecycle-state `CREATED` only), NOT `searchUserTasks` (which returns
291
+ * tasks in ANY state) — a looping instance keeps COMPLETED/CANCELED tasks whose key could otherwise
292
+ * match and drive a doomed re-completion (a thrown 5xx) instead of the intended 404-style no-op. */
229
293
  async function resolveEscalationTask(
230
294
  engine: EngineClient,
231
295
  userTaskKey: string,
296
+ allowed: ReadonlySet<string> = ESCALATION_TASK_ELEMENTS,
232
297
  ): Promise<{ ok: true; elementId: string } | { ok: false; reason: string }> {
233
- const open = await engine.searchUserTasks();
298
+ const open = await engine.openUserTasks();
234
299
  const match = open.find((t) => t.userTaskKey === userTaskKey);
235
- if (!match) return { ok: false, reason: "no open escalation task" };
236
- if (!match.elementId || !ESCALATION_TASK_ELEMENTS.has(match.elementId)) {
237
- return { ok: false, reason: "not an escalation task" };
300
+ if (!match) return { ok: false, reason: "no open completable task" };
301
+ if (!match.elementId || !allowed.has(match.elementId)) {
302
+ return { ok: false, reason: "not a completable task" };
238
303
  }
239
304
  return { ok: true, elementId: match.elementId };
240
305
  }
@@ -275,7 +340,8 @@ export async function completeEscalationAsAgent(
275
340
  * affordance resumes the process through the one implementation a human uses from the task inbox —
276
341
  * no parallel completion path — while recording WHO answered in the `task_completions` ledger. A
277
342
  * human completion is the authority (not reversible). A key with no matching open escalation task is
278
- * a 404-style no-op. */
343
+ * a 404-style no-op. The human surface is the wider `HUMAN_COMPLETABLE_ELEMENTS`, so it also retires a
344
+ * `feature-blocked` acknowledgement (issue #332 folded the bespoke `acknowledge-blocked` door here). */
279
345
  export async function completeEscalationAsHuman(
280
346
  data: DataLayer,
281
347
  engine: EngineClient,
@@ -286,7 +352,7 @@ export async function completeEscalationAsHuman(
286
352
  const operatorId = input.operatorId.trim();
287
353
  if (!operatorId) return { ok: false, reason: "operatorId is required" };
288
354
 
289
- const resolved = await resolveEscalationTask(engine, userTaskKey);
355
+ const resolved = await resolveEscalationTask(engine, userTaskKey, HUMAN_COMPLETABLE_ELEMENTS);
290
356
  if (!resolved.ok) return resolved;
291
357
 
292
358
  const invalid = validateEscalationVariables(resolved.elementId, input.variables);
@@ -301,44 +367,6 @@ export async function completeEscalationAsHuman(
301
367
  return { ok: true, completionId, userTaskKey, elementId: resolved.elementId };
302
368
  }
303
369
 
304
- /** The `feature-blocked` operator user-task element id (feature.bpmn). Unlike an escalation this is not
305
- * an agent-answerable task — it is a blocked-run acknowledgement only a human operator retires — so it
306
- * lives outside `ESCALATION_TASK_ELEMENTS` (the agent completer must never touch it) and has its own
307
- * human-only completer below. */
308
- export const FEATURE_BLOCKED_TASK_ELEMENT = "feature-blocked";
309
-
310
- /** Complete the `feature-blocked` operator user task AS A HUMAN (issue #220). The blocked twin of
311
- * `completeEscalationAsHuman`: it resolves the parked task by key, refuses anything that is not the
312
- * `feature-blocked` task, and routes the operator's typed form variables (an optional `note`) through
313
- * the SAME canonical `completeUserTaskAttributed` — so the nwf "Acknowledge blocked" affordance resumes
314
- * the process (→ `pr.record-blocked-ack`, which settles the row to terminal `blocked`) through the one
315
- * completion a human drives from the task inbox, recording WHO acknowledged in the `task_completions`
316
- * ledger. A human completion is the authority (not reversible). A key with no matching open
317
- * `feature-blocked` task is a 404-style no-op. */
318
- export async function completeBlockedAsHuman(
319
- data: DataLayer,
320
- engine: EngineClient,
321
- input: { userTaskKey: string; variables: Record<string, unknown>; operatorId: string },
322
- ): Promise<AgentCompleteResult> {
323
- const userTaskKey = input.userTaskKey.trim();
324
- if (!userTaskKey) return { ok: false, reason: "userTaskKey is required" };
325
- const operatorId = input.operatorId.trim();
326
- if (!operatorId) return { ok: false, reason: "operatorId is required" };
327
-
328
- const open = await engine.searchUserTasks();
329
- const match = open.find((t) => t.userTaskKey === userTaskKey);
330
- if (!match) return { ok: false, reason: "no open blocked task" };
331
- if (match.elementId !== FEATURE_BLOCKED_TASK_ELEMENT) return { ok: false, reason: "not a blocked task" };
332
-
333
- const { completionId } = await completeUserTaskAttributed(
334
- data,
335
- engine,
336
- { userTaskKey, elementId: match.elementId, variables: input.variables },
337
- { kind: "human", id: operatorId },
338
- );
339
- return { ok: true, completionId, userTaskKey, elementId: match.elementId };
340
- }
341
-
342
370
  export interface RevertResult {
343
371
  ok: boolean;
344
372
  reason?: string;
package/app/contracts.ts CHANGED
@@ -354,9 +354,9 @@ export const WIRE_CONTRACTS = {
354
354
  name: "io.nanobpm.agentTask.repository",
355
355
  owner: "app/service.ts",
356
356
  semantics:
357
- "Repo-provisioning envelope the app emits as a `createInstance` process variable (`repoEnvelopeVars`) and the c8ctl worker harness consumes to provision an isolated clone on the PR head branch. Beyond `{provider,url,ref}`, it carries clone-shaping fields for large monorepos (issue #287): `singleBranch:true` + `filter:\"blob:none\"` (a branch-scoped, blobless partial clone — trees fetched up-front, blobs lazily, no `--depth 1` so the merge-base/3-dot diff stays valid) and an optional `baseRef` (the PR base branch, emitted only when resolvable, so the harness fetches its tip and keeps `origin/<base>` reachable). Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91).",
357
+ "Repo-provisioning envelope the app emits as a `createInstance` process variable (`repoEnvelopeVars`) and the c8ctl worker harness consumes to provision an isolated clone on the PR head branch. Beyond `{provider,url,ref}`, it carries clone-shaping fields for large monorepos (issue #287): `singleBranch:true` + `filter:\"blob:none\"` (a branch-scoped, blobless partial clone — trees fetched up-front, blobs lazily, no `--depth 1` so the merge-base/3-dot diff stays valid) and an optional `baseRef` (the PR base branch, emitted only when resolvable, so the harness fetches its tip and keeps `origin/<base>` reachable). World-restore (issue #324, ADR 0062 Slice 4/5): an optional `commitSha` — the last durable push-checkpoint — is emitted so a REPLACEMENT activation on a fresh worktree reconstructs the tree to the EXACT pushed SHA (inverting the round's `git push` into `git fetch && git checkout <sha>`), omitted when the PR has no checkpoint yet. Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91).",
358
358
  shape:
359
- '{ provider: "github", url: string, ref: string, singleBranch: true, filter: "blob:none", baseRef?: string }',
359
+ '{ provider: "github", url: string, ref: string, singleBranch: true, filter: "blob:none", baseRef?: string, commitSha?: string }',
360
360
  },
361
361
  "epicSet.submit": {
362
362
  category: "wire",
@@ -367,6 +367,14 @@ export const WIRE_CONTRACTS = {
367
367
  shape:
368
368
  '{ epics: Array<{ issue|url: string, baseBranch: string, allowSharedBase?: boolean, confirmDefaultBase?: boolean }>, deps?: Array<{ consumer: string, producer: string, package: string, capabilityRef: string }> }',
369
369
  },
370
+ "world.checkpoint": {
371
+ category: "wire",
372
+ name: "world.checkpoint",
373
+ owner: "app/world/checkpoint.ts",
374
+ semantics:
375
+ "The mind/world checkpoint JOIN shape (issue #324, ADR 0062 Slice 4/5, the WORLD half). At each push the app derives ONE `{commitSha, effectLedger}` and records it in the durable world store (`world_checkpoints`/`world_effects`) AND passes the SAME object to the mind's `session.checkpoint(commitSha, effectLedger)` (Slice 1, `@nanobpm/agentic/session`), so mind + world commit at the SAME per-PR monotonic offset — closing the divergence failure (harness thinks it hasn't pushed but the push landed, or vice-versa). `effectLedger` entries carry a fence idempotency key (push→commit SHA, PR comment→comment id, `gh merge`→merge key); on a re-lease `restoreWorld` inverts the push (`git fetch && git checkout <commitSha>`) then fence-replays the tail so an already-applied effect is skipped, not repeated. Consume this ONE shape from app/world — do not re-declare a synonym.",
376
+ shape: '{ commitSha: string, effectLedger: Array<{ kind: "push"|"pr-comment"|"merge", idempotencyKey: string, description?: string }> }',
377
+ },
370
378
  } as const satisfies Record<string, WireContract>;
371
379
 
372
380
  export const TYPE_CONTRACTS = {
@@ -386,6 +394,14 @@ export const TYPE_CONTRACTS = {
386
394
  "One INTER-epic dependency edge (issue #292): dependent epic `plan_key` waits for producer epic `depends_on_plan_key`, gated by the producer's `{ package, capability_ref }` capability descriptor. This ONE row shape backs BOTH the durable `plan_deps` table (materialized by planner lowering S3) AND its FK-free admission-staging twin `admitted_plan_deps` (staged by the S2 door). Set admission (S2), planner lowering (S3), and operator visibility (S4) all import it from app/plan.ts — no re-declared synonym.",
387
395
  module: "app/plan.ts",
388
396
  },
397
+ SessionCheckpoint: {
398
+ category: "type",
399
+ name: "SessionCheckpoint",
400
+ owner: "app/world/checkpoint.ts",
401
+ semantics:
402
+ "The mind/world checkpoint contract shape (issue #324, ADR 0062 Slice 4/5). `{ commitSha, effectLedger }` — the ONE type both the world marker (recorded in `world_checkpoints`/`world_effects`) and the mind checkpoint (Slice 1's `session.checkpoint`) derive from, so a single derivation feeds both halves and they cannot diverge. Its `effectLedger` is `Effect[]` (the fence-keyed irreversible-action ledger). The world half imports it from app/world; when Slice 1's harness-side `@nanobpm/agentic/session` lands it MUST reuse this shape, not re-declare a synonym.",
403
+ module: "app/world/checkpoint.ts",
404
+ },
389
405
  } as const satisfies Record<string, TypeContract>;
390
406
 
391
407
  export const CAPABILITY_URL_CONTRACTS = {