@nanobpm/nano-workforce 0.202.0 → 0.203.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 (77) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/SPEC.md +48 -0
  3. package/app/admittedStaging.test.ts +71 -0
  4. package/app/agentActivationLedger.ts +5 -0
  5. package/app/agentCompletion.test.ts +33 -0
  6. package/app/agentCompletion.ts +108 -2
  7. package/app/agentic/vocab/agent-sla-boundary.test.ts +6 -1
  8. package/app/contracts.ts +23 -0
  9. package/app/deliveryGraphDispatch.ts +6 -0
  10. package/app/deliveryGraphReadModel.test.ts +2 -2
  11. package/app/deliveryGraphRun.ts +18 -0
  12. package/app/deliveryRunner.ts +9 -1
  13. package/app/escalationSteward.test.ts +452 -0
  14. package/app/escalationSteward.ts +537 -0
  15. package/app/escalationStewardPoll.test.ts +123 -0
  16. package/app/feature.test.ts +16 -0
  17. package/app/feature.ts +11 -0
  18. package/app/featureDelivery.test.ts +23 -0
  19. package/app/mcpToolSurface.ts +17 -1
  20. package/app/plan.ts +49 -6
  21. package/app/planLowering.test.ts +26 -1
  22. package/app/planLowering.ts +5 -0
  23. package/app/promotionPoll.test.ts +19 -0
  24. package/app/pullRequestReadModel.test.ts +1 -1
  25. package/app/service.test.ts +36 -0
  26. package/app/service.ts +139 -5
  27. package/db/migrations/120_escalation_auto_resolutions.sql +229 -0
  28. package/db/migrations/121_delivery_graph_read_model_auto_resolve.sql +54 -0
  29. package/db/migrations/122_pull_requests_read_model_auto_resolve.sql +56 -0
  30. package/db/migrations/123_escalation_auto_resolutions_selected_option.sql +47 -0
  31. package/db/migrations/124_admitted_epics_auto_resolve.sql +21 -0
  32. package/docs/agent-guide.md +69 -30
  33. package/docs/mcp-runbook.md +2 -0
  34. package/e2e/escalation-steward.e2e.ts +211 -0
  35. package/nano.app.json +10 -1
  36. package/openapi.yaml +365 -0
  37. package/operations/dispatchDeliveryGraph.ts +6 -1
  38. package/operations/listActivePrs.test.ts +60 -1
  39. package/operations/listActivePrs.ts +3 -3
  40. package/operations/listAutoResolutions.ts +83 -0
  41. package/operations/reviewAutoResolution.test.ts +137 -0
  42. package/operations/reviewAutoResolution.ts +42 -0
  43. package/operations/startConvergenceLoop.ts +5 -1
  44. package/operations/startEpicSet.admission.integration.test.ts +2 -2
  45. package/operations/startEpicSet.ts +8 -2
  46. package/operations/startFeature.ts +5 -0
  47. package/operations/startPlanFanout.ts +4 -1
  48. package/package.json +1 -1
  49. package/pages/_nav.json +12 -0
  50. package/pages/auto-resolutions.page.json +191 -0
  51. package/pages/board.page.json +17 -0
  52. package/pages/cockpit.page.json +17 -0
  53. package/pages/delivery-graph-detail.page.json +17 -0
  54. package/pages/delivery-graphs/staged.mount.js +17 -5
  55. package/pages/delivery-graphs.page.json +17 -0
  56. package/pages/epic-detail.page.json +17 -0
  57. package/pages/epic.page.json +19 -1
  58. package/pages/feature.page.json +19 -1
  59. package/pages/home.page.json +22 -0
  60. package/pages/lineage.page.json +17 -0
  61. package/pages/mcp.page.json +17 -0
  62. package/pages/overview.page.json +17 -0
  63. package/pages/tasks.page.json +17 -0
  64. package/pages/velocity.page.json +17 -0
  65. package/resources/processes/escalation-steward.bpmn +162 -0
  66. package/resources/prompts/escalation-steward.md +87 -0
  67. package/test/delivery-graphs-staged-embed.test.ts +13 -0
  68. package/test/derivation-parity/derivation-parity.test.ts +1 -0
  69. package/test/derivation-parity/flows.ts +12 -0
  70. package/workers/converge-feature/worker.ts +6 -1
  71. package/workers/delivery-connector/worker.test.ts +72 -1
  72. package/workers/delivery-connector/worker.ts +53 -2
  73. package/workers/escalation-steward-collect/worker.test.ts +180 -0
  74. package/workers/escalation-steward-collect/worker.ts +99 -0
  75. package/workers/escalation-steward-record/worker.test.ts +740 -0
  76. package/workers/escalation-steward-record/worker.ts +428 -0
  77. package/workers/record-wave/worker.ts +5 -1
@@ -0,0 +1,229 @@
1
+ -- Issue #889: opt-in escalation steward. Two halves, both additive (expand-and-contract):
2
+ --
3
+ -- 1. `auto_resolve_escalations` (0|1, default 0) on each escalation-raising subject table —
4
+ -- `feature_runs`, `plans`, `pull_requests`, `delivery_graph_runs` — recording whether the work
5
+ -- OPTED IN to having its open escalations auto-resolved by the steward. The steward's
6
+ -- eligibility derivation (app/escalationSteward.ts) reads the flag off the SUBJECT row keyed by
7
+ -- the escalation's owning process instance, so an escalation from non-opted-in work is never
8
+ -- touched. Mirrored into the `delivery_units` aggregate (ADR 0006) — the sync triggers (089) and
9
+ -- compat VIEWs (091) are re-created verbatim plus the new column, exactly as migration 116 did
10
+ -- for `human_approval` (parity-tested in app/deliveryUnit.test.ts).
11
+ --
12
+ -- 2. `escalation_auto_resolutions` — the durable, reviewable record of every escalation the steward
13
+ -- ACTED ON or ABSTAINED from (one row each), backing the Auto-resolutions view and the
14
+ -- dismiss/intervene doors. `review_state` starts `pending`; an operator `dismiss`/`intervene`
15
+ -- folds it to `dismissed`/`intervened` with `reviewed_by`/`reviewed_at`/`review_note`.
16
+
17
+ ALTER TABLE feature_runs ADD COLUMN auto_resolve_escalations INTEGER NOT NULL DEFAULT 0;
18
+ ALTER TABLE plans ADD COLUMN auto_resolve_escalations INTEGER NOT NULL DEFAULT 0;
19
+ ALTER TABLE pull_requests ADD COLUMN auto_resolve_escalations INTEGER NOT NULL DEFAULT 0;
20
+ ALTER TABLE delivery_graph_runs ADD COLUMN auto_resolve_escalations INTEGER NOT NULL DEFAULT 0;
21
+
22
+ -- Mirror the new column through the `delivery_units` aggregate so the compat VIEWs stay row-for-row
23
+ -- equal to their base tables (ADR 0006, parity-tested in app/deliveryUnit.test.ts).
24
+ ALTER TABLE delivery_units ADD COLUMN auto_resolve_escalations INTEGER;
25
+ UPDATE delivery_units SET auto_resolve_escalations = 0 WHERE auto_resolve_escalations IS NULL;
26
+
27
+ DROP TRIGGER IF EXISTS feature_runs__du_ai;
28
+ CREATE TRIGGER feature_runs__du_ai AFTER INSERT ON feature_runs
29
+ BEGIN
30
+ INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, base_branch, status, process_key, pr_key, converge, auto_merge, human_approval, auto_resolve_escalations, outcome, delivery_label, acknowledged_at, stage, stage_state, stage_skipped, attention, list_bucket, created_at, updated_at)
31
+ VALUES ('feature:' || NEW.feature_key, 'feature', NEW.feature_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'escalated' THEN 'escalated' WHEN NEW.status = 'opened' THEN 'opened' WHEN NEW.status = 'converging' THEN 'converging' WHEN NEW.status = 'awaiting_operator' THEN 'awaiting_operator' WHEN NEW.status = 'merged' THEN 'merged' WHEN NEW.status = 'converged' THEN 'converged' WHEN NEW.status = 'blocked' THEN 'blocked' WHEN NEW.status = 'skipped' THEN 'skipped' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('opened', 'converging', 'merged', 'converged', 'blocked', 'skipped', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running', 'escalated', 'awaiting_operator') THEN 'dispatched' ELSE NULL END, NEW.repo, NEW.issue_number, NEW.issue_url, NEW.title, NEW.base_branch, NEW.status, NEW.process_key, NEW.pr_key, NEW.converge, NEW.auto_merge, NEW.human_approval, NEW.auto_resolve_escalations, NEW.outcome, NEW.delivery_label, NEW.acknowledged_at, NEW.stage, NEW.stage_state, NEW.stage_skipped, NEW.attention, NEW.list_bucket, NEW.created_at, NEW.updated_at);
32
+ END;
33
+
34
+ DROP TRIGGER IF EXISTS feature_runs__du_au;
35
+ CREATE TRIGGER feature_runs__du_au AFTER UPDATE ON feature_runs
36
+ BEGIN
37
+ INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, base_branch, status, process_key, pr_key, converge, auto_merge, human_approval, auto_resolve_escalations, outcome, delivery_label, acknowledged_at, stage, stage_state, stage_skipped, attention, list_bucket, created_at, updated_at)
38
+ VALUES ('feature:' || NEW.feature_key, 'feature', NEW.feature_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'escalated' THEN 'escalated' WHEN NEW.status = 'opened' THEN 'opened' WHEN NEW.status = 'converging' THEN 'converging' WHEN NEW.status = 'awaiting_operator' THEN 'awaiting_operator' WHEN NEW.status = 'merged' THEN 'merged' WHEN NEW.status = 'converged' THEN 'converged' WHEN NEW.status = 'blocked' THEN 'blocked' WHEN NEW.status = 'skipped' THEN 'skipped' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('opened', 'converging', 'merged', 'converged', 'blocked', 'skipped', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running', 'escalated', 'awaiting_operator') THEN 'dispatched' ELSE NULL END, NEW.repo, NEW.issue_number, NEW.issue_url, NEW.title, NEW.base_branch, NEW.status, NEW.process_key, NEW.pr_key, NEW.converge, NEW.auto_merge, NEW.human_approval, NEW.auto_resolve_escalations, NEW.outcome, NEW.delivery_label, NEW.acknowledged_at, NEW.stage, NEW.stage_state, NEW.stage_skipped, NEW.attention, NEW.list_bucket, NEW.created_at, NEW.updated_at);
39
+ END;
40
+
41
+ DROP TRIGGER IF EXISTS plans__du_ai;
42
+ CREATE TRIGGER plans__du_ai AFTER INSERT ON plans
43
+ BEGIN
44
+ INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, status, task_count, process_key, outcome, gate_wave, blackboard_token, retro_started_at, base_branch, epic_phase, promotion_pr, promotion_state, acknowledged_at, list_bucket, ack_open, wait_gate, wait_gate_label, bound_artifacts, auto_resolve_escalations, created_at, updated_at)
45
+ VALUES ('epic:' || NEW.plan_key, 'epic', NEW.plan_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'planning' THEN 'requested' WHEN NEW.status = 'dispatched' THEN 'running' WHEN NEW.status = 'done' THEN 'done' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('planning') THEN 'pending' WHEN NEW.status IN ('done', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('dispatched') THEN 'dispatched' ELSE NULL END, NEW.repo, NEW.issue_number, NEW.issue_url, NEW.title, NEW.status, NEW.task_count, NEW.process_key, NEW.outcome, NEW.gate_wave, NEW.blackboard_token, NEW.retro_started_at, NEW.base_branch, NEW.epic_phase, NEW.promotion_pr, NEW.promotion_state, NEW.acknowledged_at, NEW.list_bucket, NEW.ack_open, NEW.wait_gate, NEW.wait_gate_label, NEW.bound_artifacts, NEW.auto_resolve_escalations, NEW.created_at, NEW.updated_at);
46
+ END;
47
+
48
+ DROP TRIGGER IF EXISTS plans__du_au;
49
+ CREATE TRIGGER plans__du_au AFTER UPDATE ON plans
50
+ BEGIN
51
+ INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, repo, issue_number, issue_url, title, status, task_count, process_key, outcome, gate_wave, blackboard_token, retro_started_at, base_branch, epic_phase, promotion_pr, promotion_state, acknowledged_at, list_bucket, ack_open, wait_gate, wait_gate_label, bound_artifacts, auto_resolve_escalations, created_at, updated_at)
52
+ VALUES ('epic:' || NEW.plan_key, 'epic', NEW.plan_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'planning' THEN 'requested' WHEN NEW.status = 'dispatched' THEN 'running' WHEN NEW.status = 'done' THEN 'done' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('planning') THEN 'pending' WHEN NEW.status IN ('done', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('dispatched') THEN 'dispatched' ELSE NULL END, NEW.repo, NEW.issue_number, NEW.issue_url, NEW.title, NEW.status, NEW.task_count, NEW.process_key, NEW.outcome, NEW.gate_wave, NEW.blackboard_token, NEW.retro_started_at, NEW.base_branch, NEW.epic_phase, NEW.promotion_pr, NEW.promotion_state, NEW.acknowledged_at, NEW.list_bucket, NEW.ack_open, NEW.wait_gate, NEW.wait_gate_label, NEW.bound_artifacts, NEW.auto_resolve_escalations, NEW.created_at, NEW.updated_at);
53
+ END;
54
+
55
+ DROP TRIGGER IF EXISTS delivery_graph_runs__du_ai;
56
+ CREATE TRIGGER delivery_graph_runs__du_ai AFTER INSERT ON delivery_graph_runs
57
+ BEGIN
58
+ INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, process_key, process_definition_id, digest, status, side_effecting, node_count, human_node_count, side_effect_count, title, phase, phase_node_id, human_labels, acknowledged_at, auto_resolve_escalations, created_at, updated_at)
59
+ VALUES ('delivery-graph:' || NEW.run_key, 'delivery-graph', NEW.run_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'awaiting-approval' THEN 'requested' WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'done' THEN 'done' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('awaiting-approval') THEN 'pending' WHEN NEW.status IN ('done', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running') THEN 'dispatched' ELSE NULL END, NEW.process_key, NEW.process_definition_id, NEW.digest, NEW.status, NEW.side_effecting, NEW.node_count, NEW.human_node_count, NEW.side_effect_count, NEW.title, NEW.phase, NEW.phase_node_id, NEW.human_labels, NEW.acknowledged_at, NEW.auto_resolve_escalations, NEW.created_at, NEW.updated_at);
60
+ END;
61
+
62
+ DROP TRIGGER IF EXISTS delivery_graph_runs__du_au;
63
+ CREATE TRIGGER delivery_graph_runs__du_au AFTER UPDATE ON delivery_graph_runs
64
+ BEGIN
65
+ INSERT OR REPLACE INTO delivery_units (unit_id, kind, legacy_key, legacy_id, parent_unit_id, node_index, delivery_status, dispatch_status, process_key, process_definition_id, digest, status, side_effecting, node_count, human_node_count, side_effect_count, title, phase, phase_node_id, human_labels, acknowledged_at, auto_resolve_escalations, created_at, updated_at)
66
+ VALUES ('delivery-graph:' || NEW.run_key, 'delivery-graph', NEW.run_key, NULL, NULL, NULL, CASE WHEN NEW.status = 'awaiting-approval' THEN 'requested' WHEN NEW.status = 'running' THEN 'running' WHEN NEW.status = 'done' THEN 'done' WHEN NEW.status = 'failed' THEN 'failed' WHEN NEW.status = 'abandoned' THEN 'abandoned' ELSE NULL END, CASE WHEN NEW.status IN ('awaiting-approval') THEN 'pending' WHEN NEW.status IN ('done', 'failed', 'abandoned') THEN 'settled' WHEN NEW.status IN ('running') THEN 'dispatched' ELSE NULL END, NEW.process_key, NEW.process_definition_id, NEW.digest, NEW.status, NEW.side_effecting, NEW.node_count, NEW.human_node_count, NEW.side_effect_count, NEW.title, NEW.phase, NEW.phase_node_id, NEW.human_labels, NEW.acknowledged_at, NEW.auto_resolve_escalations, NEW.created_at, NEW.updated_at);
67
+ END;
68
+
69
+ DROP VIEW IF EXISTS feature_runs__units;
70
+ CREATE VIEW feature_runs__units AS
71
+ SELECT
72
+ du.legacy_key AS feature_key,
73
+ du.repo AS repo,
74
+ du.issue_number AS issue_number,
75
+ du.issue_url AS issue_url,
76
+ du.base_branch AS base_branch,
77
+ du.status AS status,
78
+ du.process_key AS process_key,
79
+ du.pr_key AS pr_key,
80
+ du.converge AS converge,
81
+ du.auto_merge AS auto_merge,
82
+ du.human_approval AS human_approval,
83
+ du.auto_resolve_escalations AS auto_resolve_escalations,
84
+ du.outcome AS outcome,
85
+ du.created_at AS created_at,
86
+ du.updated_at AS updated_at,
87
+ du.delivery_label AS delivery_label,
88
+ du.title AS title,
89
+ du.acknowledged_at AS acknowledged_at,
90
+ du.stage AS stage,
91
+ du.stage_state AS stage_state,
92
+ du.stage_skipped AS stage_skipped,
93
+ du.attention AS attention,
94
+ du.list_bucket AS list_bucket
95
+ FROM delivery_units du
96
+ WHERE du.kind = 'feature';
97
+
98
+ DROP VIEW IF EXISTS plans__units;
99
+ CREATE VIEW plans__units AS
100
+ SELECT
101
+ du.legacy_key AS plan_key,
102
+ du.repo AS repo,
103
+ du.issue_number AS issue_number,
104
+ du.issue_url AS issue_url,
105
+ du.title AS title,
106
+ du.status AS status,
107
+ du.task_count AS task_count,
108
+ du.process_key AS process_key,
109
+ du.outcome AS outcome,
110
+ du.created_at AS created_at,
111
+ du.updated_at AS updated_at,
112
+ du.gate_wave AS gate_wave,
113
+ du.blackboard_token AS blackboard_token,
114
+ du.retro_started_at AS retro_started_at,
115
+ du.base_branch AS base_branch,
116
+ du.epic_phase AS epic_phase,
117
+ du.promotion_pr AS promotion_pr,
118
+ du.promotion_state AS promotion_state,
119
+ du.acknowledged_at AS acknowledged_at,
120
+ du.list_bucket AS list_bucket,
121
+ du.ack_open AS ack_open,
122
+ du.wait_gate AS wait_gate,
123
+ du.wait_gate_label AS wait_gate_label,
124
+ du.bound_artifacts AS bound_artifacts,
125
+ du.auto_resolve_escalations AS auto_resolve_escalations
126
+ FROM delivery_units du
127
+ WHERE du.kind = 'epic';
128
+
129
+ DROP VIEW IF EXISTS delivery_graph_runs__units;
130
+ CREATE VIEW delivery_graph_runs__units AS
131
+ SELECT
132
+ du.legacy_key AS run_key,
133
+ du.process_key AS process_key,
134
+ du.process_definition_id AS process_definition_id,
135
+ du.digest AS digest,
136
+ du.status AS status,
137
+ du.side_effecting AS side_effecting,
138
+ du.node_count AS node_count,
139
+ du.human_node_count AS human_node_count,
140
+ du.side_effect_count AS side_effect_count,
141
+ du.title AS title,
142
+ du.phase AS phase,
143
+ du.phase_node_id AS phase_node_id,
144
+ du.human_labels AS human_labels,
145
+ du.created_at AS created_at,
146
+ du.updated_at AS updated_at,
147
+ du.acknowledged_at AS acknowledged_at,
148
+ du.auto_resolve_escalations AS auto_resolve_escalations
149
+ FROM delivery_units du
150
+ WHERE du.kind = 'delivery-graph';
151
+
152
+ -- The durable, reviewable record of every escalation the steward acted on or abstained from.
153
+ CREATE TABLE escalation_auto_resolutions (
154
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
155
+ -- The escalation user task acted on (the engine `userTaskKey`), its BPMN element id, and the
156
+ -- escalation kind label at the time (denormalised so the record survives the task completing).
157
+ user_task_key TEXT NOT NULL,
158
+ escalation_kind TEXT,
159
+ element_id TEXT,
160
+ -- The subject the escalation belongs to (PR key / feature key / plan key / delivery run key).
161
+ subject_type TEXT,
162
+ subject_key TEXT,
163
+ subject_url TEXT,
164
+ process_instance_key TEXT,
165
+ -- Snapshots of what was asked and the choices on offer, so the record is reviewable after the fact.
166
+ question TEXT,
167
+ options TEXT, -- JSON array of the form's option values/labels
168
+ -- `resolved` (the steward submitted an answer) or `abstained` (it deliberately left it for a human).
169
+ outcome TEXT NOT NULL, -- resolved | abstained
170
+ submitted_variables TEXT, -- JSON of the variables submitted (NULL when abstained)
171
+ rationale TEXT, -- one-line why
172
+ evidence TEXT, -- JSON/markdown: what was checked, with links
173
+ -- Provenance of the steward run that produced this record.
174
+ steward_instance_key TEXT,
175
+ agent_job_key TEXT,
176
+ worker TEXT,
177
+ model TEXT,
178
+ created_at TEXT NOT NULL,
179
+ -- Operator review lifecycle: pending → dismissed | intervened.
180
+ review_state TEXT NOT NULL DEFAULT 'pending', -- pending | dismissed | intervened
181
+ reviewed_by TEXT,
182
+ reviewed_at TEXT,
183
+ review_note TEXT,
184
+ -- Claim/lease bookkeeping (not part of the review surface): which steward instance holds the open
185
+ -- claim and until when. NULL once the row is finalised (resolved/abstained recorded).
186
+ claimed_by TEXT,
187
+ claimed_until TEXT
188
+ );
189
+
190
+ CREATE INDEX idx_auto_resolutions_review_state ON escalation_auto_resolutions(review_state, created_at);
191
+ CREATE INDEX idx_auto_resolutions_subject ON escalation_auto_resolutions(subject_key);
192
+ -- The claim/lease fence: at most ONE open (unfinalised, `outcome='claimed'`) claim per user task, so
193
+ -- two overlapping steward ticks can never both act on the same escalation. Final rows
194
+ -- (`resolved`/`abstained`) are excluded so a re-escalation of the SAME task can be recorded afresh.
195
+ CREATE UNIQUE INDEX ux_auto_resolutions_open_claim ON escalation_auto_resolutions(user_task_key) WHERE outcome = 'claimed';
196
+ CREATE INDEX idx_auto_resolutions_task ON escalation_auto_resolutions(user_task_key);
197
+
198
+ -- The Auto-resolutions page read model (ADR 0042): projects each FINAL record (resolved/abstained —
199
+ -- never an in-flight `claimed` placeholder) with a derived `pending_review` flag (1 iff still
200
+ -- `pending`) for the row-action `showWhenField` gate, which reads JS truthiness. Derivation over
201
+ -- duplication: the grid binds this VIEW, never the base table.
202
+ CREATE VIEW escalation_auto_resolutions__read_model AS
203
+ SELECT
204
+ id,
205
+ user_task_key,
206
+ escalation_kind,
207
+ element_id,
208
+ subject_type,
209
+ subject_key,
210
+ subject_url,
211
+ process_instance_key,
212
+ question,
213
+ options,
214
+ outcome,
215
+ submitted_variables,
216
+ rationale,
217
+ evidence,
218
+ steward_instance_key,
219
+ agent_job_key,
220
+ worker,
221
+ model,
222
+ created_at,
223
+ review_state,
224
+ reviewed_by,
225
+ reviewed_at,
226
+ review_note,
227
+ CASE WHEN review_state = 'pending' THEN 1 ELSE 0 END AS pending_review
228
+ FROM escalation_auto_resolutions
229
+ WHERE outcome IN ('resolved', 'abstained');
@@ -0,0 +1,54 @@
1
+ -- Re-create the `delivery_graph_read_model` VIEW so it re-exports the base column added by
2
+ -- 120_escalation_auto_resolutions.sql (`auto_resolve_escalations`). The read-model VIEW must pass
3
+ -- through EVERY base `delivery_graph_runs` column (the static pages↔schema contract guard +
4
+ -- app/deliveryGraphReadModel.test.ts DRIFT GUARD assert it), so adding a base column obliges a fresh
5
+ -- VIEW definition — 087/096 are immutable and cannot be edited in place.
6
+ --
7
+ -- Every DERIVED column below is emitted VERBATIM from the ONE declaration in
8
+ -- app/deliveryGraphReadModel.ts (`deliveryGraphReadModel.sqlSelectFor(col, { baseAlias: "dg" })`) —
9
+ -- the same closed-DSL AST that drives the runtime TS via `fnFor`, so the two lowerings cannot
10
+ -- diverge. This file is a mechanical re-emission of 096 with the one new base pass-through added;
11
+ -- the derived `stage`/`stage_state`/`list_bucket`/`ack_open`/`park_label` expressions are unchanged.
12
+ -- The drift guard now points at THIS migration (the latest VIEW definition).
13
+ --
14
+ -- Forward-only VIEW definition (DROP then CREATE), sourced off the managed
15
+ -- `delivery_graph_runs__tracking` re-export of the base table so a terminated run classifies on
16
+ -- ENGINE TRUTH. The runner wraps each file in its own transaction, so this file must NOT contain
17
+ -- BEGIN/COMMIT. Numbered after 120.
18
+
19
+ DROP VIEW IF EXISTS delivery_graph_read_model;
20
+ DROP VIEW IF EXISTS delivery_graph_pr_counts;
21
+
22
+ CREATE VIEW IF NOT EXISTS "delivery_graph_pr_counts" AS
23
+ SELECT
24
+ "__urban_rollup_src"."root_request_key" AS "root_request_key",
25
+ SUM(CASE WHEN COALESCE(((NOT COALESCE(("__urban_rollup_src"."root_request_key" IS NULL), 0)) AND (NOT COALESCE(COALESCE((COALESCE(("__urban_rollup_src"."derived_status" = 'converged'), 0) OR COALESCE(("__urban_rollup_src"."derived_status" = 'merged'), 0) OR COALESCE(("__urban_rollup_src"."derived_status" = 'abandoned'), 0)), 0), 0))), 0) THEN 1 ELSE 0 END) AS "prs_in_flight"
26
+ FROM "pull_requests__tracking" "__urban_rollup_src"
27
+ GROUP BY "__urban_rollup_src"."root_request_key";
28
+
29
+ CREATE VIEW delivery_graph_read_model AS
30
+ SELECT
31
+ dg.run_key AS run_key,
32
+ COALESCE(dg.derived_status, dg.status) AS status,
33
+ dg.process_key AS process_key,
34
+ dg.process_definition_id AS process_definition_id,
35
+ dg.digest AS digest,
36
+ dg.side_effecting AS side_effecting,
37
+ dg.node_count AS node_count,
38
+ dg.human_node_count AS human_node_count,
39
+ dg.side_effect_count AS side_effect_count,
40
+ dg.title AS title,
41
+ dg.phase AS phase,
42
+ dg.phase_node_id AS phase_node_id,
43
+ dg.human_labels AS human_labels,
44
+ dg.created_at AS created_at,
45
+ dg.updated_at AS updated_at,
46
+ dg.acknowledged_at AS acknowledged_at,
47
+ dg.auto_resolve_escalations AS auto_resolve_escalations,
48
+ CASE WHEN COALESCE(("dg"."derived_status" = 'done'), 0) THEN 'Done' WHEN COALESCE((COALESCE(("dg"."derived_status" = 'failed'), 0) OR COALESCE(("dg"."derived_status" = 'abandoned'), 0)), 0) THEN 'Done' WHEN COALESCE(("dg"."derived_status" = 'awaiting-approval'), 0) THEN 'Requested' WHEN COALESCE((COALESCE("pc"."prs_in_flight", 0) > 0), 0) THEN 'Converging' ELSE 'Implementing' END AS stage,
49
+ CASE WHEN COALESCE(("dg"."derived_status" = 'done'), 0) THEN 'ok' WHEN COALESCE((COALESCE(("dg"."derived_status" = 'failed'), 0) OR COALESCE(("dg"."derived_status" = 'abandoned'), 0)), 0) THEN 'failed' ELSE NULL END AS stage_state,
50
+ CASE WHEN COALESCE((COALESCE((COALESCE(("dg"."derived_status" = 'done'), 0) OR COALESCE(("dg"."derived_status" = 'failed'), 0) OR COALESCE(("dg"."derived_status" = 'abandoned'), 0)), 0) AND COALESCE(("dg"."acknowledged_at" = "dg"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket,
51
+ CASE WHEN COALESCE((COALESCE((COALESCE(("dg"."derived_status" = 'done'), 0) OR COALESCE(("dg"."derived_status" = 'failed'), 0) OR COALESCE(("dg"."derived_status" = 'abandoned'), 0)), 0) AND (NOT COALESCE(COALESCE(("dg"."acknowledged_at" = "dg"."acknowledged_at"), 0), 0))), 0) THEN 1 ELSE 0 END AS ack_open,
52
+ CASE WHEN dg.phase_node_id IS NOT NULL THEN dg.phase ELSE NULL END AS park_label
53
+ FROM delivery_graph_runs__tracking dg
54
+ LEFT JOIN delivery_graph_pr_counts pc ON dg.run_key = pc.root_request_key;
@@ -0,0 +1,56 @@
1
+ -- Re-create the `pull_requests_read_model` VIEW so it re-exports the base column added by
2
+ -- 120_escalation_auto_resolutions.sql (`auto_resolve_escalations`). The read-model VIEW must pass
3
+ -- through EVERY base `pull_requests` column (the static pages↔schema contract guard +
4
+ -- app/pullRequestReadModel.test.ts DRIFT GUARD assert it), so adding a base column obliges a fresh
5
+ -- VIEW definition — 094/104/118 are immutable and cannot be edited in place.
6
+ --
7
+ -- Every DERIVED column below is emitted VERBATIM from the ONE declaration in
8
+ -- app/pullRequestReadModel.ts (`pullRequestReadModel.sqlSelectFor(col, { baseAlias: "pr" })`) — the
9
+ -- same closed-DSL AST that drives the runtime TS via `fnFor`, so the two lowerings cannot diverge.
10
+ -- This file is a mechanical re-emission of 118 with the one new base pass-through added; the
11
+ -- derived `list_bucket`/`ack_open` expressions are unchanged. The drift guard now points at THIS
12
+ -- migration (the latest VIEW definition).
13
+ --
14
+ -- Forward-only VIEW definition (DROP then CREATE), sourced off the managed `pull_requests__tracking`
15
+ -- re-export of the base table so a terminated PR classifies on ENGINE TRUTH. SQLite does not validate
16
+ -- a view body at CREATE time. The runner wraps each file in its own transaction, so this file must
17
+ -- NOT contain BEGIN/COMMIT. Numbered after 121.
18
+
19
+ DROP VIEW IF EXISTS pull_requests_read_model;
20
+
21
+ CREATE VIEW pull_requests_read_model AS
22
+ SELECT
23
+ pr.pr_key AS pr_key,
24
+ pr.repo AS repo,
25
+ pr.number AS number,
26
+ pr.url AS url,
27
+ pr.title AS title,
28
+ COALESCE(pr.derived_status, pr.status) AS status,
29
+ pr.current_round AS current_round,
30
+ pr.process_key AS process_key,
31
+ pr.waiting_since AS waiting_since,
32
+ pr.last_review_id AS last_review_id,
33
+ pr.outcome AS outcome,
34
+ pr.created_at AS created_at,
35
+ pr.updated_at AS updated_at,
36
+ pr.converged_at AS converged_at,
37
+ pr.merged_at AS merged_at,
38
+ pr.active_worker AS active_worker,
39
+ pr.lease_until AS lease_until,
40
+ pr.last_nudge_at AS last_nudge_at,
41
+ pr.fresh_head_run_head AS fresh_head_run_head,
42
+ pr.abandon_token AS abandon_token,
43
+ pr.incident_key AS incident_key,
44
+ pr.incident_message AS incident_message,
45
+ pr.last_round_head AS last_round_head,
46
+ pr.last_progress_job_key AS last_progress_job_key,
47
+ pr.last_progress_result AS last_progress_result,
48
+ pr.last_progress_agent_watermark AS last_progress_agent_watermark,
49
+ pr.churn_escalated_round AS churn_escalated_round,
50
+ pr.root_request_key AS root_request_key,
51
+ pr.epic_phase_label AS epic_phase_label,
52
+ pr.acknowledged_at AS acknowledged_at,
53
+ pr.auto_resolve_escalations AS auto_resolve_escalations,
54
+ CASE WHEN COALESCE((COALESCE((COALESCE(("pr"."derived_status" = 'merged'), 0) OR COALESCE(("pr"."derived_status" = 'converged'), 0) OR COALESCE(("pr"."derived_status" = 'abandoned'), 0) OR COALESCE(("pr"."derived_status" = 'closed'), 0) OR COALESCE(("pr"."derived_status" = 'failed'), 0)), 0) AND COALESCE(("pr"."acknowledged_at" = "pr"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket,
55
+ CASE WHEN COALESCE((COALESCE((COALESCE(("pr"."derived_status" = 'merged'), 0) OR COALESCE(("pr"."derived_status" = 'converged'), 0) OR COALESCE(("pr"."derived_status" = 'abandoned'), 0) OR COALESCE(("pr"."derived_status" = 'closed'), 0) OR COALESCE(("pr"."derived_status" = 'failed'), 0)), 0) AND (NOT COALESCE(COALESCE(("pr"."acknowledged_at" = "pr"."acknowledged_at"), 0), 0))), 0) THEN 1 ELSE 0 END AS ack_open
56
+ FROM pull_requests__tracking pr;
@@ -0,0 +1,47 @@
1
+ -- Issue #889 review (Copilot "Previously missed"): the Auto-resolutions grid omitted the SELECTED
2
+ -- option, so an operator could not see whether the steward chose retry/continue/etc. without decoding
3
+ -- the raw submitted variables. Add a first-class `selected_option` column (the option value the steward
4
+ -- actually selected, derived host-side by the record worker from the decision's own form variables) and
5
+ -- re-create the read-model VIEW to expose it as a list column.
6
+ --
7
+ -- Expand-and-contract: the column is nullable (existing rows keep NULL — they predate the projection),
8
+ -- and the VIEW is re-created forward-only (DROP then CREATE) to add the new pass-through. Migration 120
9
+ -- is immutable and cannot be edited in place. Numbered after 122.
10
+
11
+ ALTER TABLE escalation_auto_resolutions ADD COLUMN selected_option TEXT;
12
+
13
+ DROP VIEW IF EXISTS escalation_auto_resolutions__read_model;
14
+ CREATE VIEW escalation_auto_resolutions__read_model AS
15
+ SELECT
16
+ id,
17
+ user_task_key,
18
+ escalation_kind,
19
+ element_id,
20
+ subject_type,
21
+ subject_key,
22
+ subject_url,
23
+ process_instance_key,
24
+ -- The cockpit "Agent" transcript link keys on `process_key` (the #833 invariant: every Agent →
25
+ -- cockpit link uses keyField `process_key`). The ANSWERING agent ran under `steward_instance_key`
26
+ -- (the steward process), NOT `process_instance_key` (the escalated SUBJECT's process), so project
27
+ -- the steward key under the canonical `process_key` name for that link (issue #889 review).
28
+ steward_instance_key AS process_key,
29
+ question,
30
+ options,
31
+ outcome,
32
+ submitted_variables,
33
+ selected_option,
34
+ rationale,
35
+ evidence,
36
+ steward_instance_key,
37
+ agent_job_key,
38
+ worker,
39
+ model,
40
+ created_at,
41
+ review_state,
42
+ reviewed_by,
43
+ reviewed_at,
44
+ review_note,
45
+ CASE WHEN review_state = 'pending' THEN 1 ELSE 0 END AS pending_review
46
+ FROM escalation_auto_resolutions
47
+ WHERE outcome IN ('resolved', 'abstained');
@@ -0,0 +1,21 @@
1
+ -- 124_admitted_epics_auto_resolve.sql — issue #889: carry the escalation-steward opt-in through the
2
+ -- set/batch admission door (`startEpicSet`).
3
+ --
4
+ -- The single-issue door (`startPlanFanout`) already seeds `plans.auto_resolve_escalations` (added by
5
+ -- 120) from the request body's `autoResolveEscalations`. The set/batch door dropped it: `startEpicSet`
6
+ -- stages each admitted epic into `admitted_epics` and `lowerAdmittedSet` later reads that staging to
7
+ -- call `startPlan` — but the staging row had no column for the flag, so every batch-admitted epic (and
8
+ -- every child convergence/merge loop it spawns) stayed opted OUT even when the caller opted in. That
9
+ -- contradicts the issue #889 contract that the opt-in reach "every initiation pathway".
10
+ --
11
+ -- Fix the class at the staging seam: persist the per-epic opt-in on the staged row so the lowering
12
+ -- reads it back and forwards it to `startPlan`. Additive (nullable-free `NOT NULL DEFAULT 0`), so an
13
+ -- existing install's already-staged rows read as opted-out (the safe default) and fresh admissions
14
+ -- record the caller's choice. No read-model view selects from `admitted_epics`, so no VIEW re-emission
15
+ -- is needed (unlike `pull_requests` — cf. 121/122).
16
+ --
17
+ -- Numbered 124: above this branch's highest committed prefix (123) and origin/main's (119), so a
18
+ -- fan-out sibling forking at either point cannot collide. The runner wraps each file in its own
19
+ -- transaction, so this file must NOT contain BEGIN/COMMIT.
20
+
21
+ ALTER TABLE admitted_epics ADD COLUMN auto_resolve_escalations INTEGER NOT NULL DEFAULT 0;
@@ -76,6 +76,7 @@ The body is flat. Fields:
76
76
  | `autoMerge` | boolean | **Positive per-request setting.** `false` = review only, stopping at `converged`, regardless of the global default; `true` = converge **then merge** when `NANO_PR_AUTO_MERGE` is enabled. |
77
77
  | `maxRounds` | integer | per-submit cap before escalating (clamped 1–100; default from `NANO_PR_MAX_ROUNDS`, 20). |
78
78
  | `dependsOn` | string[] | other `prKey`s that must land before this one merges (merge-loop barrier). |
79
+ | `autoResolveEscalations` | boolean | **Opt in to the escalation steward** (issue #889). When `true`, this loop's open escalations become eligible for the steward to review and auto-resolve every 30 min (recorded reviewably in **Auto-resolutions**, §3). Default `false` — escalations stay human-only. |
79
80
 
80
81
  **Converge-only vs. converge-and-merge — choose deliberately:**
81
82
 
@@ -116,6 +117,12 @@ blank/absent base is rejected with a 400. Starting a
116
117
  plan is idempotent on the plan key; an already-running plan short-circuits. The
117
118
  response (202) echoes the `planKey` and engine `processKey`.
118
119
 
120
+ Both epic (`startPlanFanout`) and feature (`startFeature`) accept the optional
121
+ **`autoResolveEscalations`** boolean (issue #889, default `false`). When `true`, the run opts in to
122
+ the escalation steward, and the opt-in **propagates to every child it spawns** — an epic's
123
+ implementation tasks and each enrolled convergence/merge loop, a feature run's handed-off PR. Only
124
+ escalations raised under opted-in work are eligible for the steward (§3).
125
+
119
126
  **Single issue, one PR (feature run, `startFeature`)** — no planning or fan-out. Same body and
120
127
  base-branch admission, plus `converge` (enrol the PR in §1) and `autoMerge`:
121
128
 
@@ -313,6 +320,37 @@ Guidance for the human you assist: read the escalation `question` or plan-review
313
320
  `findings` first, decide the smallest unblocking answer, and answer it precisely —
314
321
  the answer becomes the agent's next-round context.
315
322
 
323
+ ### The escalation steward (opt-in auto-resolution, issue #889)
324
+
325
+ Escalations are **still for humans** — unless the work opted in. Each initiation
326
+ pathway (§1, §2, §9) accepts `autoResolveEscalations` (default off). When a piece of
327
+ work opts in, the **escalation steward** reviews its open escalations every 30 minutes
328
+ (configurable via `NANO_ESCALATION_STEWARD_INTERVAL`; `NANO_ESCALATION_STEWARD=off`
329
+ disables the loop entirely) and submits a recommended choice for each — keeping the
330
+ fleet moving instead of parking for hours on a decision with a clear best option.
331
+
332
+ The steward is deliberately conservative:
333
+
334
+ - It only touches escalations on **opted-in** work, past a short minimum age (so a
335
+ human already answering wins), and never a human-only / irreversible / credential
336
+ kind (merge approval, a permission prompt, a readiness gate, a blocked/conformance
337
+ ack, an *abandon*).
338
+ - It **abstains** — leaving the escalation for a human — when the right choice is
339
+ unclear, and a **loop guard** abstains once a subject has been auto-resolved twice
340
+ and escalates again (the automatic answer isn't working).
341
+ - Every resolution goes through the canonical `completeUserTask` door with **agent**
342
+ attribution (ADR 0046) — never recorded as a human decision.
343
+
344
+ **Reviewing auto-resolutions.** Every resolution *and* abstention is written to a
345
+ durable record and shown in the **Auto-resolutions** view (a pending-count badge sits
346
+ in the nav). The default list shows `pending` records, newest first; open one to see
347
+ the full question, the options offered, the submitted variables, the rationale and the
348
+ evidence checked, plus links to the subject and the steward's transcript. From there
349
+ you can **Dismiss** it (reviewed, no action) or **Intervene** (mark the record and
350
+ record guidance for stepping in after the fact — reopening the completed user task is
351
+ not required). Over MCP / curl, `listAutoResolutions` reads the records and
352
+ `reviewAutoResolution` (`{ id, action: "dismiss" | "intervene", note? }`) reviews one.
353
+
316
354
  ---
317
355
 
318
356
  ## 4. The lifecycle & statuses (so you can reason about state)
@@ -694,15 +732,13 @@ curl -sS -X POST __BASE__/actions/compile-delivery-graph \
694
732
  ```
695
733
 
696
734
  - `200 { status:"ready", message, digest, preview, reviewUrl }` — the graph compiled and is
697
- **staged for operator review**. `digest` is the content-address that NAMES the proposal (so
698
- you can tell the operator exactly which one to dispatch); `preview` is `{ diagram,
699
- sideEffects, humanNodes }` — `diagram` is a mermaid `flowchart` of the resolved graph,
700
- `humanNodes[]` are the stop-points where it waits for a person, and `sideEffects[]` are the
701
- `agent`/`connector` actions it **will** perform once an operator dispatches it; `reviewUrl`
702
- is a **navigational** cockpit deep-link (a pointer only — **not** a dispatch handle). The
703
- response carries **no run key, no token, and no process-instance key**: nothing you can
704
- replay to start a run. Your role ends here — hand the operator the `digest` (or `reviewUrl`)
705
- and ask them to preview and dispatch it.
735
+ **staged for operator review**. `digest` is the content-address that NAMES the proposal (tell the
736
+ operator exactly which one to dispatch); `preview` is `{ diagram, sideEffects, humanNodes }` —
737
+ `diagram` is a mermaid `flowchart` of the resolved graph, `humanNodes[]` are the stop-points where
738
+ it waits for a person, and `sideEffects[]` are the `agent`/`connector` actions it **will** perform
739
+ once dispatched; `reviewUrl` is a **navigational** cockpit deep-link (a pointer only — **not** a
740
+ dispatch handle). The response carries **no run key, token, or process-instance key**: nothing you
741
+ can replay to start a run. Hand the operator the `digest` (or `reviewUrl`) and ask them to dispatch.
706
742
  - `400 { ok:false, errors:[{ path, message }] }` — every error path-qualified
707
743
  (`nodes[2].kind`, `edges[1].from`, …) for unknown kind, dangling edge, a cycle, or an
708
744
  unresolvable `from` fact. Fix and recompile; nothing is staged.
@@ -711,13 +747,16 @@ curl -sS -X POST __BASE__/actions/compile-delivery-graph \
711
747
  `start` endpoint (Decision 5/7, issue #460). Dispatch is a human action: an operator opens
712
748
  the **Delivery Graphs** page in the cockpit, reviews the staged proposal's rendered preview
713
749
  (its diagram, the human stop-points, and the side effects a dispatch authorises), and clicks
714
- **Dispatch** on the one they approve. The operator clicking Dispatch **is** the approval — it
715
- is content-addressed to the exact digest they previewed, so it cannot be a replay of some
716
- other graph. Once dispatched, the graph deploys + runs engine-natively and registers as a run
717
- aggregate, so its current phase / parked node shows in the cockpit's **Active Delivery
718
- Graphs** grid (e.g. *"parked on human node: manual OTP publish"*). A `human` node parks on the
719
- **Tasks** inbox and is answered exactly as an escalation is (§3) — its completion emits any
720
- declared facts, which downstream edges bind.
750
+ **Dispatch** on the one they approve. That click **is** the approval — content-addressed to the
751
+ exact digest previewed, so it cannot replay some other graph. Once dispatched, the graph deploys +
752
+ runs engine-natively and registers as a run aggregate, so its current phase / parked node shows in
753
+ the cockpit's **Active Delivery Graphs** grid (e.g. *"parked on human node: manual OTP publish"*).
754
+ A `human` node parks on the **Tasks** inbox and is answered exactly as an escalation is (§3) — its
755
+ completion emits any declared facts, which downstream edges bind.
756
+
757
+ The dispatch body accepts the optional **`autoResolveEscalations`** boolean (issue #889, default
758
+ `false`): when `true`, the run opts in to the escalation steward (§3) — its `human` nodes, service-node
759
+ escalation twins, and any `converge` connector's enrolled PR become eligible for auto-resolution.
721
760
 
722
761
  > **Completing a `wait` escalation proceeds *as not-ready* — it does NOT re-arm the gate.**
723
762
  > A `wait` node with `onTimeout: "escalate"` (the default) that elapses its bound parks a
@@ -892,7 +931,7 @@ Sometimes the thing you must wait for is not one PR but a **whole epic** — an
892
931
  time. The **`wait` kind `epic`** (issue #568) gates on that epic reaching **"fully merged"**
893
932
  (every opened slice landed), keyed by its durable **`planKey`** (`owner/repo#NN` — the epic
894
933
  issue), so *"start feature B once epic A has fully landed"* is an automated edge, not a human
895
- babysitting a `confirm` gate.
934
+ babysitting a gate.
896
935
 
897
936
  ```json
898
937
  {
@@ -915,26 +954,24 @@ Semantics:
915
954
 
916
955
  - **`target` is the `planKey`** (`owner/repo#NN`, the epic issue) — the *stable business id*,
917
956
  not the engine `processInstanceKey` (`64200`), so a resubmit/replay still resolves.
918
- - **`match.epicState`** is `merged` (default) or its synonym `done` — both mean "every opened
919
- slice landed". The gate reads the app's own **aggregate** (the lineage read-model over
920
- `NANO_WORKFORCE_BASE_URL`), so it is level-triggered like `pr` (no missed edge).
921
- - **A failed/abandoned/mixed epic never reports merged**, so it never falsely releases the
922
- gate; the **bounded** wait elapses and routes via **`onTimeout`** (`escalate`/`continue`) —
923
- it does **not** hang.
957
+ - **`match.epicState`** is `merged` (default) or `done` — "every opened slice landed". The gate reads
958
+ the app's own **aggregate** (the lineage read-model over `NANO_WORKFORCE_BASE_URL`), so it is
959
+ level-triggered like `pr` (no missed edge). A failed/abandoned/mixed epic never reports merged, so
960
+ it never falsely releases the gate — the **bounded** wait routes via **`onTimeout`**, never hanging.
924
961
  - **Set `poll.timeoutMs` to a realistic budget.** An epic reaching "fully merged" is a
925
- multi-day, human-paced event, so the example gives it `poll: { everyMs: 300000, timeoutMs:
926
- 259200000 }` (re-probe every 5 minutes, budget 3 days). **Omitting `poll` inherits the
927
- 30-minute default** (§9.1) — the gate would escalate long before the epic lands. Because this
928
- gate binds `prCount`, that escalation is **validated** (§9.2): it **re-parks** unless the
929
- operator supplies a valid `prCount`, rather than silently releasing `start-b` as not-ready.
962
+ multi-day, human-paced event, so the example budgets 3 days (`poll: { everyMs: 300000, timeoutMs:
963
+ 259200000 }`). **Omitting `poll` inherits the 30-minute default** (§9.1) — the gate would escalate
964
+ long before the epic lands. Because this gate binds `prCount`, that escalation is **validated**
965
+ (§9.2): it **re-parks** unless the operator supplies a valid `prCount`, rather than silently
966
+ releasing `start-b` as not-ready.
930
967
  - On a fully-merged match it binds **`prCount`** (how many slice PRs the epic landed) as an
931
968
  output fact, so a downstream node can consume it (parity with the `pr` kind's `mergedSha`).
932
969
  - **It also gates a single-PR *feature run*, not just a plan-fanout epic.** The gate resolves the
933
970
  lineage thread whose **`rootRequestKey`** matches `target` **regardless of the thread's `kind`**
934
971
  (`feature` | `epic` | `pr` | `delivery`), and `app/lineage.ts` lands a *feature* thread on
935
972
  `stage:"merged"` once its PR merges. So `wait[epic]` targeting a feature/epic **root issue**
936
- observes that thread's aggregate frontier and releases on `stage:"merged" && active:false` either
937
- way — see `getDeliveryGraphVocabulary` (the `epic` probe entry) for the structured contract.
973
+ releases on `stage:"merged" && active:false` either way — see `getDeliveryGraphVocabulary` (the
974
+ `epic` probe entry) for the structured contract.
938
975
 
939
976
  ---
940
977
 
@@ -956,6 +993,8 @@ until it does, only the curl door in the third column exists on the deployment:
956
993
  | Every PR in flight (§0/§5) | `listActivePrs` | `curl __BASE__/status` |
957
994
  | List open escalations (§3) | `listEscalations` *(projected by sibling #666; curl door until then)* | `curl __BASE__/../../tasks/api/tasks` (the answerable list — carries each `userTaskKey`); `/status` filtered with the jq recipe just below this table is only a quick *indicator* (it surfaces `openEscalation` as a question string, **no `userTaskKey`**, so you can't answer from it) |
958
995
  | Answer an escalation (§3) | `completeUserTask` (agent-assignee: `agentCompleteEscalation`) | `curl -X POST __BASE__/actions/complete-user-task` |
996
+ | List auto-resolutions (§3) | `listAutoResolutions` | `curl __BASE__/auto-resolutions` |
997
+ | Review an auto-resolution (dismiss / intervene) (§3) | `reviewAutoResolution` | `curl -X POST __BASE__/auto-resolutions/review` |
959
998
  | Cancel an instance (record-consistent) (§7) | `cancelInstance` *(projected by sibling #667; curl door until then)* | `curl -X POST __BASE__/../actions/cancel` *(the `/app/actions/cancel` door, outside `__BASE__`)* |
960
999
  | Publish a BPMN message (§7) | `postMessage` | `curl -X POST __BASE__/actions/message` |
961
1000
  | The operator guide itself | `getAgentInstructions` (full) / `getAgentGuide(section?)` (addressable) | `curl __BASE__/agent` / `curl __BASE__/agent/guide` |
@@ -115,6 +115,8 @@ CI runs) fails if the block below drifts from the source. Never hand-edit it; ed
115
115
  "listEscalations",
116
116
  "getLineage",
117
117
  "getPrHistory",
118
+ "listAutoResolutions",
119
+ "reviewAutoResolution",
118
120
  "urban_debug_search_process_instances",
119
121
  "urban_debug_search_element_instance_wait_states",
120
122
  "urban_debug_search_incidents",