@nanobpm/nano-workforce 0.99.0 → 0.100.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 (39) 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/convergeGate.test.ts +8 -4
  5. package/app/convergenceEscalationGuard.test.ts +106 -0
  6. package/app/feature.ts +15 -166
  7. package/app/featureGateway.test.ts +6 -57
  8. package/app/persist-escalation.test.ts +32 -0
  9. package/app/pollUserTasks.test.ts +39 -13
  10. package/app/roundProgress.test.ts +7 -4
  11. package/app/service.ts +60 -132
  12. package/app/stage.test.ts +7 -53
  13. package/app/stage.ts +5 -37
  14. package/app/userTasks.test.ts +1 -1
  15. package/app/userTasks.ts +3 -3
  16. package/db/migrations/049_drop_feature_escalation_surface.sql +25 -0
  17. package/e2e/feature-run.e2e.ts +52 -41
  18. package/e2e/retire-escalation-subsystem.e2e.ts +26 -0
  19. package/openapi.yaml +7 -108
  20. package/operations/agentCompleteEscalation.ts +2 -2
  21. package/operations/completeUserTask.test.ts +25 -6
  22. package/operations/completeUserTask.ts +12 -10
  23. package/package.json +2 -2
  24. package/pages/feature.page.json +1 -37
  25. package/pages/overview.page.json +1 -39
  26. package/pages/tasks.page.json +78 -93
  27. package/resources/forms/feature-escalation.form +3 -0
  28. package/resources/processes/convergence-loop.bpmn +114 -118
  29. package/workers/persist-escalation/worker.ts +7 -4
  30. package/workers/record-blocked-ack/worker.test.ts +1 -4
  31. package/workers/record-blocked-ack/worker.ts +0 -5
  32. package/workers/record-feature/worker.ts +0 -6
  33. package/workers/record-feature-escalation/worker.test.ts +14 -27
  34. package/workers/record-feature-escalation/worker.ts +16 -22
  35. package/app/featureBlocked.test.ts +0 -182
  36. package/app/featureEscalation.test.ts +0 -235
  37. package/operations/acknowledgeBlocked.test.ts +0 -111
  38. package/operations/acknowledgeBlocked.ts +0 -62
  39. package/operations/answerFeatureEscalation.ts +0 -68
package/openapi.yaml CHANGED
@@ -1908,69 +1908,17 @@ paths:
1908
1908
  application/json:
1909
1909
  schema:
1910
1910
  $ref: "#/components/schemas/MessageResult"
1911
- /actions/answer-escalation:
1912
- post:
1913
- operationId: answerFeatureEscalation
1914
- summary: "Answer a native feature-run escalation (issue #210). Completes the parked
1915
- `feature-escalation` user task with the operator's typed form variables (resolution=answer/abandon
1916
- + guidance), driving the canonical attributed completer (completeUserTaskAttributed) — the same
1917
- resume path the task inbox uses, recording who answered. The poller then reconciles the run off
1918
- `escalated`. This is the nwf UI's answer affordance for a feature run parked on a human decision."
1919
- requestBody:
1920
- required: true
1921
- content:
1922
- application/json:
1923
- schema:
1924
- type: object
1925
- additionalProperties: false
1926
- required:
1927
- - userTaskKey
1928
- - resolution
1929
- properties:
1930
- userTaskKey:
1931
- type: string
1932
- minLength: 1
1933
- description: The parked `feature-escalation` user-task key (feature_runs.escalation_user_task_key).
1934
- resolution:
1935
- type: string
1936
- enum: [answer, abandon]
1937
- description: answer ⇒ re-dispatch the implement task with the guidance; abandon ⇒ give up on the task.
1938
- answer:
1939
- type: string
1940
- description: Guidance for the implementation agent. Required (non-blank) when resolution is answer.
1941
- operator:
1942
- type: string
1943
- description: Optional operator handle recorded in the attribution ledger; defaults to "operator".
1944
- responses:
1945
- "200":
1946
- description: The escalation user task was completed and the process resumed.
1947
- content:
1948
- application/json:
1949
- schema:
1950
- $ref: "#/components/schemas/MessageResult"
1951
- "400":
1952
- description: A required field was missing/invalid, or the target is not an escalation task.
1953
- content:
1954
- application/json:
1955
- schema:
1956
- $ref: "#/components/schemas/MessageResult"
1957
- "404":
1958
- description: No open escalation user task matches the userTaskKey.
1959
- content:
1960
- application/json:
1961
- schema:
1962
- $ref: "#/components/schemas/MessageResult"
1963
1911
  /actions/complete-user-task:
1964
1912
  post:
1965
1913
  operationId: completeUserTask
1966
1914
  summary: "Complete an open native user-task escalation from the nwf Tasks inbox (issue #236).
1967
1915
  Submits the parked task's typed `.form` variables (e.g. a plan-review `{ directive, notes }`, a
1968
- trial-merge `{ action, notes }`, or a PR `{ answer }`) to the canonical human completer
1969
- (completeEscalationAsHuman completeUserTaskAttributed) the same resume path the task inbox
1970
- uses, recording who answered. The completer refuses any user task that is not one of the
1971
- migrated escalation elements. The feature-run kinds keep their own operations
1972
- (answer-escalation / acknowledge-blocked); this door is for the plan-review / trial-merge / PR
1973
- `wait-answer` kinds whose completion is a straight typed pass-through. On success the answered
1916
+ trial-merge `{ action, notes }`, a PR `{ answer }`, a feature escalation `{ resolution, answer }`,
1917
+ or a blocked-run `{ note }`) to the canonical human completer (completeEscalationAsHuman
1918
+ completeUserTaskAttributed) the same resume path the task inbox uses, recording who answered.
1919
+ The completer refuses any user task that is not one of the human-completable elements. Issue #332
1920
+ retired the bespoke feature-run doors (answer-escalation / acknowledge-blocked) and folded
1921
+ `feature-escalation` / `feature-blocked` onto this one canonical door. On success the answered
1974
1922
  task's read-model row is dropped so the Tasks grid stops offering a decision for it."
1975
1923
  requestBody:
1976
1924
  required: true
@@ -2013,61 +1961,12 @@ paths:
2013
1961
  application/json:
2014
1962
  schema:
2015
1963
  $ref: "#/components/schemas/MessageResult"
2016
- /actions/acknowledge-blocked:
2017
- post:
2018
- operationId: acknowledgeBlocked
2019
- summary: "Acknowledge a blocked native feature run (issue #220). Completes the parked
2020
- `feature-blocked` operator user task with the operator's optional disposition note, driving the
2021
- canonical attributed completer (completeUserTaskAttributed) — the same resume path the task inbox
2022
- uses, recording who acknowledged. Completing it fires pr.record-blocked-ack, settling the run to
2023
- the terminal `blocked` status with the note, and the poller reconciles the completable-task
2024
- pointer off the row. This is the nwf UI's completion affordance for a feature run parked as
2025
- blocked (the blocked twin of answer-escalation)."
2026
- requestBody:
2027
- required: true
2028
- content:
2029
- application/json:
2030
- schema:
2031
- type: object
2032
- additionalProperties: false
2033
- required:
2034
- - userTaskKey
2035
- properties:
2036
- userTaskKey:
2037
- type: string
2038
- minLength: 1
2039
- description: The parked `feature-blocked` user-task key (feature_runs.blocked_user_task_key).
2040
- note:
2041
- type: string
2042
- description: Optional operator disposition note, recorded on the run (delivery_label). Blank ⇒ "acknowledged".
2043
- operator:
2044
- type: string
2045
- description: Optional operator handle recorded in the attribution ledger; defaults to "operator".
2046
- responses:
2047
- "200":
2048
- description: The blocked user task was completed and the process resumed.
2049
- content:
2050
- application/json:
2051
- schema:
2052
- $ref: "#/components/schemas/MessageResult"
2053
- "400":
2054
- description: A required field was missing/invalid, or the target is not a blocked task.
2055
- content:
2056
- application/json:
2057
- schema:
2058
- $ref: "#/components/schemas/MessageResult"
2059
- "404":
2060
- description: No open blocked user task matches the userTaskKey.
2061
- content:
2062
- application/json:
2063
- schema:
2064
- $ref: "#/components/schemas/MessageResult"
2065
1964
  /actions/acknowledge-done:
2066
1965
  post:
2067
1966
  operationId: acknowledgeDone
2068
1967
  summary: "Tick off a TERMINAL feature run (issue #254 §5). Stamps `acknowledged_at` on the run so
2069
1968
  the gateway recomputes its `list_bucket` to 'history', dropping the finished run out of the
2070
- primary Active list into History. The Done twin of acknowledge-blocked, but a terminal run is
1969
+ primary Active list into History. The Done twin of the blocked acknowledgement, but a terminal run is
2071
1970
  not parked at a user task, so this completes no user task and only writes the row. Keyed on the
2072
1971
  run's `feature_key`; idempotent-safe (re-acknowledging keeps it in History)."
2073
1972
  requestBody:
@@ -46,8 +46,8 @@ export default defineOperation("agentCompleteEscalation", async ({ req, body },
46
46
  app.log.info("agent completed escalation", { userTaskKey, agentId, elementId: r.elementId });
47
47
  return { status: 200, body: { ok: true, completionId: r.completionId, elementId: r.elementId } };
48
48
  }
49
- // A non-escalation / missing-target is a client error; an unknown key is a 404.
50
- const status = r.reason === "no open escalation task" ? 404 : 400;
49
+ // A non-completable / missing-target is a client error; an unknown key is a 404.
50
+ const status = r.reason === "no open completable task" ? 404 : 400;
51
51
  app.log.warn("agent-complete: not completed", { userTaskKey, reason: r.reason });
52
52
  return { status, body: { ok: false, error: r.reason } };
53
53
  });
@@ -1,15 +1,17 @@
1
1
  // Tests for the POST /app/api/actions/complete-user-task operation `completeUserTask` (issue #236).
2
2
  // The nwf Tasks page's decision affordance for the plan-review / trial-merge / PR `wait-answer`
3
- // escalations: it routes the typed form variables through the canonical human completer
4
- // (completeEscalationAsHuman completeUserTaskAttributed), resuming the process exactly as the task
5
- // inbox would, and drops the answered task's read-model row so the grid stops offering a decision.
3
+ // escalations plus the feature kinds `feature-escalation` / `feature-blocked` (issue #332 folded the
4
+ // bespoke feature doors onto this one canonical door): it routes the typed form variables through the
5
+ // canonical human completer (completeEscalationAsHuman completeUserTaskAttributed), resuming the
6
+ // process exactly as the task inbox would, and drops the answered task's read-model row so the grid
7
+ // stops offering a decision.
6
8
  import { test } from "node:test";
7
9
  import { assertEquals } from "#test-assert";
8
10
  import type { AppApi } from "@nanobpm/urban";
9
11
  import { noopLog } from "../test/log.ts";
10
12
  import handler from "./completeUserTask.ts";
11
13
 
12
- // biome-ignore lint/suspicious/noExplicitAny: in-memory doubles, mirrors acknowledgeBlocked.test.ts
14
+ // biome-ignore lint/suspicious/noExplicitAny: in-memory doubles, mirrors sibling op tests
13
15
  function memApp(openTasks: { userTaskKey: string; elementId?: string }[]): {
14
16
  app: AppApi;
15
17
  // biome-ignore lint/suspicious/noExplicitAny: see above
@@ -49,6 +51,7 @@ function memApp(openTasks: { userTaskKey: string; elementId?: string }[]): {
49
51
  };
50
52
  }
51
53
  const engine = {
54
+ openUserTasks: async () => openTasks,
52
55
  searchUserTasks: async () => openTasks,
53
56
  completeUserTask: async (userTaskKey: string, variables: Record<string, unknown>) => {
54
57
  completed.push({ userTaskKey, variables });
@@ -106,14 +109,30 @@ test("complete-user-task: non-object variables are rejected 400", async () => {
106
109
  assertEquals(res.status, 400);
107
110
  });
108
111
 
109
- test("complete-user-task: an unknown key is a 404 (no open escalation task)", async () => {
112
+ test("complete-user-task: an unknown key is a 404 (no open completable task)", async () => {
110
113
  const { app } = memApp([]);
111
114
  const res = await call(app, { userTaskKey: "ghost", variables: { answer: "x" } });
112
115
  assertEquals(res.status, 404);
113
116
  });
114
117
 
115
- test("complete-user-task: refuses a non-escalation user task (400)", async () => {
118
+ test("complete-user-task: completes a feature-blocked acknowledgement with the typed note (issue #332)", async () => {
116
119
  const { app, completed } = memApp([{ userTaskKey: "ut-4", elementId: "feature-blocked" }]);
120
+ const res = await call(app, { userTaskKey: "ut-4", variables: { note: "reassigned" } });
121
+ assertEquals(res.status, 200);
122
+ assertEquals(res.body.elementId, "feature-blocked");
123
+ assertEquals(completed, [{ userTaskKey: "ut-4", variables: { note: "reassigned" } }]);
124
+ });
125
+
126
+ test("complete-user-task: completes a feature-escalation answer (issue #332)", async () => {
127
+ const { app, completed } = memApp([{ userTaskKey: "ut-6", elementId: "feature-escalation" }]);
128
+ const res = await call(app, { userTaskKey: "ut-6", variables: { resolution: "answer", answer: "use v2" } });
129
+ assertEquals(res.status, 200);
130
+ assertEquals(res.body.elementId, "feature-escalation");
131
+ assertEquals(completed, [{ userTaskKey: "ut-6", variables: { resolution: "answer", answer: "use v2" } }]);
132
+ });
133
+
134
+ test("complete-user-task: refuses a non-completable internal user task (400)", async () => {
135
+ const { app, completed } = memApp([{ userTaskKey: "ut-4", elementId: "some-internal-task" }]);
117
136
  const res = await call(app, { userTaskKey: "ut-4", variables: { note: "x" } });
118
137
  assertEquals(res.status, 400);
119
138
  assertEquals(completed, []);
@@ -1,19 +1,21 @@
1
1
  // POST /app/api/actions/complete-user-task → operationId `completeUserTask` (issue #236).
2
2
  //
3
- // The nwf **Tasks** page's decision affordance for the epic/PR native escalations that had no
4
- // app-side completion path — `plan-review-decision`, `trial-merge-decision`, and the PR review-loop
5
- // `wait-answer`. An operator submits the parked task's typed `.form` variables (e.g. a plan-review
6
- // `{ directive, notes }`, a trial-merge `{ action, notes }`, or a PR `{ answer }`) directly from the
7
- // Tasks inbox instead of only from Urban's read-only task-inbox stub.
3
+ // The nwf **Tasks** page's decision affordance for the native user-task escalations that had no
4
+ // app-side completion path — `plan-review-decision`, `trial-merge-decision`, the PR review-loop
5
+ // `wait-answer`, the feature escalation `feature-escalation`, and the human-only `feature-blocked`
6
+ // acknowledgement. An operator submits the parked task's typed `.form` variables (e.g. a plan-review
7
+ // `{ directive, notes }`, a trial-merge `{ action, notes }`, a PR `{ answer }`, a feature escalation
8
+ // `{ resolution, answer }`, or a blocked-run `{ note }`) directly from the Tasks inbox instead of only
9
+ // from Urban's read-only task-inbox stub.
8
10
  //
9
11
  // It routes through the ONE canonical human completer (`completeEscalationAsHuman` →
10
12
  // `completeUserTaskAttributed`), so the completion uses the exact same typed variables and engine
11
13
  // resume path a human drives from the task inbox — no parallel completion — while recording WHO
12
14
  // answered in the `task_completions` ledger. That completer refuses any user task that is not one of
13
- // the migrated escalation elements (`ESCALATION_TASK_ELEMENTS`), so this generic door can never
14
- // complete an arbitrary internal user task. The feature-run kinds keep their own reconcile-bearing
15
- // operations (`answer-escalation` / `acknowledge-blocked`); this door is for the escalation kinds
16
- // whose completion is a straight typed pass-through.
15
+ // the human-completable elements (`HUMAN_COMPLETABLE_ELEMENTS`), so this generic door can never
16
+ // complete an arbitrary internal user task. Issue #332 retired the bespoke feature-run reconcile doors
17
+ // (`answer-escalation` / `acknowledge-blocked`) and folded `feature-escalation` / `feature-blocked`
18
+ // onto this one canonical door.
17
19
  //
18
20
  // On success it removes the answered task's `user_tasks` read-model row so the Tasks grid stops
19
21
  // offering a decision for a task that is now completed, without waiting a poll cycle; the poller
@@ -66,7 +68,7 @@ export default defineOperation("completeUserTask", async ({ body }, app) => {
66
68
  app.log.info("operator completed user task", { userTaskKey, elementId: r.elementId });
67
69
  return { status: 200, body: { ok: true, completionId: r.completionId, elementId: r.elementId } };
68
70
  }
69
- const status = r.reason === "no open escalation task" ? 404 : 400;
71
+ const status = r.reason === "no open completable task" ? 404 : 400;
70
72
  app.log.warn("complete-user-task: not completed", { userTaskKey, reason: r.reason });
71
73
  return { status, body: { ok: false, error: r.reason } };
72
74
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.99.0",
3
+ "version": "0.100.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",
@@ -55,7 +55,7 @@
55
55
  },
56
56
  "dependencies": {
57
57
  "@nanobpm/agentic": "^0.1.0",
58
- "@nanobpm/urban": "^0.61.0"
58
+ "@nanobpm/urban": "^0.68.1"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@biomejs/biome": "^2.4.11",
@@ -126,24 +126,6 @@
126
126
  { "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
127
127
  ],
128
128
  "rowActions": [
129
- {
130
- "label": "Abandon",
131
- "confirm": "Abandon this escalated task? The run gives up on it (no PR).",
132
- "showWhenField": "escalation_open",
133
- "action": {
134
- "path": "/app/api/actions/answer-escalation",
135
- "body": { "userTaskKey": "{{row.escalation_user_task_key}}", "resolution": "abandon" }
136
- }
137
- },
138
- {
139
- "label": "Acknowledge blocked",
140
- "confirm": "Acknowledge this blocked run? It settles to terminal blocked (the agent could not open a PR).",
141
- "showWhenField": "blocked_user_task_key",
142
- "action": {
143
- "path": "/app/api/actions/acknowledge-blocked",
144
- "body": { "userTaskKey": "{{row.blocked_user_task_key}}" }
145
- }
146
- },
147
129
  {
148
130
  "label": "Dismiss",
149
131
  "confirm": "Tick off this finished run? It acknowledges the run as done and files it under History.",
@@ -156,28 +138,10 @@
156
138
  ],
157
139
  "detail": {
158
140
  "fields": [
159
- { "field": "escalation_question", "label": "Escalation question" },
160
141
  { "field": "base_branch", "label": "Base branch" },
161
142
  { "field": "outcome", "label": "Outcome" },
162
143
  { "field": "delivery_label", "label": "Delivery" }
163
- ],
164
- "form": {
165
- "showWhenField": "escalation_open",
166
- "title": "Answer escalation",
167
- "promptField": "escalation_question",
168
- "inputKey": "answer",
169
- "inputLabel": "Guidance for the implementation agent",
170
- "submitLabel": "Answer & re-dispatch",
171
- "action": {
172
- "path": "/app/api/actions/answer-escalation",
173
- "successLabel": "Answered — the agent will resume",
174
- "body": {
175
- "userTaskKey": "{{row.escalation_user_task_key}}",
176
- "resolution": "answer",
177
- "answer": "{{form.answer}}"
178
- }
179
- }
180
- }
144
+ ]
181
145
  }
182
146
  }
183
147
  }
@@ -165,48 +165,10 @@
165
165
  { "field": "delivery_label", "header": "Delivery" },
166
166
  { "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
167
167
  ],
168
- "rowActions": [
169
- {
170
- "label": "Abandon",
171
- "confirm": "Abandon this escalated task? The run gives up on it (no PR).",
172
- "showWhenField": "escalation_open",
173
- "action": {
174
- "path": "/app/api/actions/answer-escalation",
175
- "body": { "userTaskKey": "{{row.escalation_user_task_key}}", "resolution": "abandon" }
176
- }
177
- },
178
- {
179
- "label": "Acknowledge blocked",
180
- "confirm": "Acknowledge this blocked run? It settles to terminal blocked (the agent could not open a PR).",
181
- "showWhenField": "blocked_user_task_key",
182
- "action": {
183
- "path": "/app/api/actions/acknowledge-blocked",
184
- "body": { "userTaskKey": "{{row.blocked_user_task_key}}" }
185
- }
186
- }
187
- ],
188
168
  "detail": {
189
169
  "fields": [
190
- { "field": "escalation_question", "label": "Escalation question" },
191
170
  { "field": "outcome", "label": "Outcome" }
192
- ],
193
- "form": {
194
- "showWhenField": "escalation_open",
195
- "title": "Answer escalation",
196
- "promptField": "escalation_question",
197
- "inputKey": "answer",
198
- "inputLabel": "Guidance for the implementation agent",
199
- "submitLabel": "Answer & re-dispatch",
200
- "action": {
201
- "path": "/app/api/actions/answer-escalation",
202
- "successLabel": "Answered — the agent will resume",
203
- "body": {
204
- "userTaskKey": "{{row.escalation_user_task_key}}",
205
- "resolution": "answer",
206
- "answer": "{{form.answer}}"
207
- }
208
- }
209
- }
171
+ ]
210
172
  }
211
173
  }
212
174
  }
@@ -119,22 +119,6 @@
119
119
  }
120
120
  ],
121
121
  "rowKey": "user_task_key",
122
- "rowActions": [
123
- {
124
- "label": "Abandon",
125
- "confirm": "Abandon this escalated task? The run gives up on it (no PR).",
126
- "showWhenField": "user_task_key",
127
- "action": {
128
- "path": "/app/api/actions/complete-user-task",
129
- "body": {
130
- "userTaskKey": "{{row.user_task_key}}",
131
- "variables": {
132
- "resolution": "abandon"
133
- }
134
- }
135
- }
136
- }
137
- ],
138
122
  "detail": {
139
123
  "linkField": "subject_url",
140
124
  "fields": [
@@ -147,18 +131,32 @@
147
131
  "showWhenField": "user_task_key",
148
132
  "title": "Answer escalation",
149
133
  "promptField": "question",
150
- "inputKey": "answer",
151
- "inputLabel": "Guidance for the implementation agent",
152
- "submitLabel": "Answer & re-dispatch",
134
+ "submitLabel": "Submit decision",
135
+ "fields": [
136
+ {
137
+ "inputKey": "resolution",
138
+ "type": "select",
139
+ "inputLabel": "Resolution",
140
+ "options": [
141
+ { "value": "answer", "label": "Answer — re-dispatch the task with this guidance" },
142
+ { "value": "abandon", "label": "Abandon — give up on this task" }
143
+ ]
144
+ },
145
+ {
146
+ "inputKey": "answer",
147
+ "type": "textarea",
148
+ "inputLabel": "Answer / guidance for the implementation agent",
149
+ "conditional": {
150
+ "hide": "=resolution != \"answer\""
151
+ }
152
+ }
153
+ ],
153
154
  "action": {
154
155
  "path": "/app/api/actions/complete-user-task",
155
- "successLabel": "Answered — the agent will resume",
156
+ "successLabel": "Decision submitted — the process will continue",
156
157
  "body": {
157
158
  "userTaskKey": "{{row.user_task_key}}",
158
- "variables": {
159
- "resolution": "answer",
160
- "answer": "{{form.answer}}"
161
- }
159
+ "variables": "{{form}}"
162
160
  }
163
161
  }
164
162
  }
@@ -220,22 +218,6 @@
220
218
  }
221
219
  ],
222
220
  "rowKey": "user_task_key",
223
- "rowActions": [
224
- {
225
- "label": "Proceed",
226
- "confirm": "Proceed with the current plan as-is? It is dispatched unchanged.",
227
- "showWhenField": "user_task_key",
228
- "action": {
229
- "path": "/app/api/actions/complete-user-task",
230
- "body": {
231
- "userTaskKey": "{{row.user_task_key}}",
232
- "variables": {
233
- "directive": "proceed"
234
- }
235
- }
236
- }
237
- }
238
- ],
239
221
  "detail": {
240
222
  "linkField": "subject_url",
241
223
  "fields": [
@@ -246,20 +228,34 @@
246
228
  ],
247
229
  "form": {
248
230
  "showWhenField": "user_task_key",
249
- "title": "Revise — send the plan back to the planner",
231
+ "title": "Review the plan",
250
232
  "promptField": "question",
251
- "inputKey": "notes",
252
- "inputLabel": "Revision guidance for the planner",
253
- "submitLabel": "Revise & re-plan",
233
+ "submitLabel": "Submit decision",
234
+ "fields": [
235
+ {
236
+ "inputKey": "directive",
237
+ "type": "select",
238
+ "inputLabel": "Directive",
239
+ "options": [
240
+ { "value": "proceed", "label": "Proceed — dispatch the current plan as-is" },
241
+ { "value": "revise", "label": "Revise — send the plan back to the planner" }
242
+ ]
243
+ },
244
+ {
245
+ "inputKey": "notes",
246
+ "type": "textarea",
247
+ "inputLabel": "Revision guidance for the planner",
248
+ "conditional": {
249
+ "hide": "=directive != \"revise\""
250
+ }
251
+ }
252
+ ],
254
253
  "action": {
255
254
  "path": "/app/api/actions/complete-user-task",
256
- "successLabel": "Sent back — the planner will revise",
255
+ "successLabel": "Decision recorded",
257
256
  "body": {
258
257
  "userTaskKey": "{{row.user_task_key}}",
259
- "variables": {
260
- "directive": "revise",
261
- "notes": "{{form.notes}}"
262
- }
258
+ "variables": "{{form}}"
263
259
  }
264
260
  }
265
261
  }
@@ -321,58 +317,45 @@
321
317
  }
322
318
  ],
323
319
  "rowKey": "user_task_key",
324
- "rowActions": [
325
- {
326
- "label": "Proceed",
327
- "confirm": "Accept the red trial merge and continue?",
328
- "showWhenField": "user_task_key",
329
- "action": {
330
- "path": "/app/api/actions/complete-user-task",
331
- "body": {
332
- "userTaskKey": "{{row.user_task_key}}",
333
- "variables": {
334
- "action": "proceed"
335
- }
336
- }
320
+ "detail": {
321
+ "linkField": "subject_url",
322
+ "fields": [
323
+ {
324
+ "field": "question",
325
+ "label": "Trial merge"
337
326
  }
338
- },
339
- {
340
- "label": "Rebase",
341
- "confirm": "Re-run the trial merge?",
327
+ ],
328
+ "form": {
342
329
  "showWhenField": "user_task_key",
343
- "action": {
344
- "path": "/app/api/actions/complete-user-task",
345
- "body": {
346
- "userTaskKey": "{{row.user_task_key}}",
347
- "variables": {
348
- "action": "rebase"
349
- }
330
+ "title": "Trial-merge decision",
331
+ "promptField": "question",
332
+ "submitLabel": "Submit decision",
333
+ "fields": [
334
+ {
335
+ "inputKey": "action",
336
+ "type": "select",
337
+ "inputLabel": "Action",
338
+ "options": [
339
+ { "value": "proceed", "label": "Proceed — accept the red trial merge and continue" },
340
+ { "value": "rebase", "label": "Rebase — re-run the trial merge" },
341
+ { "value": "abandon", "label": "Abandon — stop and finalize the plan" }
342
+ ]
343
+ },
344
+ {
345
+ "inputKey": "notes",
346
+ "type": "textarea",
347
+ "inputLabel": "Notes"
350
348
  }
351
- }
352
- },
353
- {
354
- "label": "Abandon",
355
- "confirm": "Stop and finalize the plan?",
356
- "showWhenField": "user_task_key",
349
+ ],
357
350
  "action": {
358
351
  "path": "/app/api/actions/complete-user-task",
352
+ "successLabel": "Decision recorded",
359
353
  "body": {
360
354
  "userTaskKey": "{{row.user_task_key}}",
361
- "variables": {
362
- "action": "abandon"
363
- }
355
+ "variables": "{{form}}"
364
356
  }
365
357
  }
366
358
  }
367
- ],
368
- "detail": {
369
- "linkField": "subject_url",
370
- "fields": [
371
- {
372
- "field": "question",
373
- "label": "Trial merge"
374
- }
375
- ]
376
359
  },
377
360
  "refreshMs": 5000
378
361
  }
@@ -532,11 +515,13 @@
532
515
  "inputLabel": "Disposition note (what you did / why)",
533
516
  "submitLabel": "Acknowledge & close",
534
517
  "action": {
535
- "path": "/app/api/actions/acknowledge-blocked",
518
+ "path": "/app/api/actions/complete-user-task",
536
519
  "successLabel": "Acknowledged — the run will close",
537
520
  "body": {
538
521
  "userTaskKey": "{{row.user_task_key}}",
539
- "note": "{{form.note}}"
522
+ "variables": {
523
+ "note": "{{form.note}}"
524
+ }
540
525
  }
541
526
  }
542
527
  }
@@ -21,6 +21,9 @@
21
21
  "label": "Answer / guidance for the implementation agent",
22
22
  "conditional": {
23
23
  "hide": "=resolution != \"answer\""
24
+ },
25
+ "validate": {
26
+ "required": true
24
27
  }
25
28
  }
26
29
  ]