@hyperdreamer/pi-webui 1.10.7 → 1.11.0-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -2
- package/dist/cli.js +265 -32
- package/dist/cli.js.map +1 -1
- package/dist/client/assets/{CodeViewer-BIb2Wx8W.js → CodeViewer-CAf_j27u.js} +1 -1
- package/dist/client/assets/{UnifiedDiffViewer-DpWiGlQo.js → UnifiedDiffViewer-wBjEJWa1.js} +1 -1
- package/dist/client/assets/{index-DX583DEv.js → index-5EtDb9nj.js} +1197 -476
- package/dist/client/index.html +1 -1
- package/dist/config.js +52 -11
- package/dist/config.js.map +1 -1
- package/dist/pi-webui-plugins/workspace-memory/pi-webui-plugin.js +3 -2
- package/dist/pi-webui-plugins/workspace-tasks/pi-webui-plugin.js +1 -0
- package/dist/plugin-api.d.ts +30 -0
- package/dist/server/app.js +6 -1
- package/dist/server/app.js.map +1 -1
- package/dist/server/realtime/sessionEventHub.js +40 -5
- package/dist/server/realtime/sessionEventHub.js.map +1 -1
- package/dist/server/realtime/sessionStatusCoalescer.js +137 -0
- package/dist/server/realtime/sessionStatusCoalescer.js.map +1 -0
- package/dist/server/sessiond/sessionProxyRoutes.js +2 -0
- package/dist/server/sessiond/sessionProxyRoutes.js.map +1 -1
- package/dist/server/sessiond.js +57 -5
- package/dist/server/sessiond.js.map +1 -1
- package/dist/server/sessions/modelPolicyCapability.js +88 -0
- package/dist/server/sessions/modelPolicyCapability.js.map +1 -0
- package/dist/server/sessions/modelPolicyTool.js +31 -0
- package/dist/server/sessions/modelPolicyTool.js.map +1 -0
- package/dist/server/sessions/modelTierRegistry.js +14 -0
- package/dist/server/sessions/modelTierRegistry.js.map +1 -1
- package/dist/server/sessions/modelTierSettingsRoutes.js +36 -0
- package/dist/server/sessions/modelTierSettingsRoutes.js.map +1 -0
- package/dist/server/sessions/modelTierSettingsService.js +100 -0
- package/dist/server/sessions/modelTierSettingsService.js.map +1 -0
- package/dist/server/sessions/piSessionService.js +1643 -433
- package/dist/server/sessions/piSessionService.js.map +1 -1
- package/dist/server/sessions/sessionDefaultsRoutes.js +39 -0
- package/dist/server/sessions/sessionDefaultsRoutes.js.map +1 -1
- package/dist/server/sessions/sessionDefaultsService.js +36 -6
- package/dist/server/sessions/sessionDefaultsService.js.map +1 -1
- package/dist/server/sessions/sessionMetadataStore.js +107 -13
- package/dist/server/sessions/sessionMetadataStore.js.map +1 -1
- package/dist/server/sessions/sessionModelPolicy.js +118 -0
- package/dist/server/sessions/sessionModelPolicy.js.map +1 -0
- package/dist/server/sessions/sessionReorder.js +117 -0
- package/dist/server/sessions/sessionReorder.js.map +1 -0
- package/dist/server/sessions/sessionRouteFastifyOptions.js +8 -0
- package/dist/server/sessions/sessionRouteFastifyOptions.js.map +1 -0
- package/dist/server/sessions/sessionRoutes.js +208 -3
- package/dist/server/sessions/sessionRoutes.js.map +1 -1
- package/dist/server/sessions/starterModelPolicyPreferenceStore.js +164 -0
- package/dist/server/sessions/starterModelPolicyPreferenceStore.js.map +1 -0
- package/dist/server/sessions/utilityModelExtension.js +199 -0
- package/dist/server/sessions/utilityModelExtension.js.map +1 -0
- package/dist/server/sessions/utilityModelResolver.js +77 -0
- package/dist/server/sessions/utilityModelResolver.js.map +1 -0
- package/dist/server/sessions/utilityModelSettingsRoutes.js +62 -0
- package/dist/server/sessions/utilityModelSettingsRoutes.js.map +1 -0
- package/dist/server/sessions/utilityModelSettingsService.js +101 -0
- package/dist/server/sessions/utilityModelSettingsService.js.map +1 -0
- package/dist/server/skills/optionalSkillInstall.js +69 -0
- package/dist/server/skills/optionalSkillInstall.js.map +1 -0
- package/dist/server/skills/optionalSkillInstaller.js +148 -0
- package/dist/server/skills/optionalSkillInstaller.js.map +1 -0
- package/dist/shared/apiTypes.d.ts +129 -3
- package/dist/shared/apiTypes.js +10 -0
- package/dist/shared/apiTypes.js.map +1 -1
- package/dist/shared/capabilities.js +15 -0
- package/dist/shared/capabilities.js.map +1 -1
- package/dist/shared/federatedRoutes.js +7 -0
- package/dist/shared/federatedRoutes.js.map +1 -1
- package/docs/assets/pi-webui-desktop.png +0 -0
- package/docs/config.md +61 -3
- package/docs/plugins.md +93 -16
- package/optional-skills/deterministic-subagent-driven-development/SKILL.md +224 -0
- package/optional-skills/deterministic-subagent-driven-development/pi-webui-skill.json +28 -0
- package/optional-skills/deterministic-subagent-driven-development/prompts/final-reviewer.md +132 -0
- package/optional-skills/deterministic-subagent-driven-development/prompts/implementer.md +101 -0
- package/optional-skills/deterministic-subagent-driven-development/prompts/re-reviewer.md +60 -0
- package/optional-skills/deterministic-subagent-driven-development/prompts/task-reviewer.md +80 -0
- package/optional-skills/deterministic-subagent-driven-development/references/capability-contract.md +174 -0
- package/optional-skills/deterministic-subagent-driven-development/references/plan-contract.md +268 -0
- package/optional-skills/deterministic-subagent-driven-development/references/state-machine.md +177 -0
- package/optional-skills/deterministic-subagent-driven-development/scripts/lib/manifest.mjs +258 -0
- package/optional-skills/deterministic-subagent-driven-development/scripts/lib/plan-policy.mjs +350 -0
- package/optional-skills/deterministic-subagent-driven-development/scripts/lib/prompt-renderer.mjs +290 -0
- package/optional-skills/deterministic-subagent-driven-development/scripts/lib/state-machine.mjs +1264 -0
- package/optional-skills/deterministic-subagent-driven-development/scripts/lib/state-store.mjs +532 -0
- package/optional-skills/deterministic-subagent-driven-development/scripts/sdd-state +3 -0
- package/optional-skills/deterministic-subagent-driven-development/scripts/sdd-state.mjs +349 -0
- package/optional-skills/deterministic-writing-plans/SKILL.md +232 -0
- package/optional-skills/deterministic-writing-plans/references/grammar.md +84 -0
- package/optional-skills/deterministic-writing-plans/templates/plan-skeleton.md +143 -0
- package/package.json +13 -9
package/optional-skills/deterministic-subagent-driven-development/scripts/lib/state-machine.mjs
ADDED
|
@@ -0,0 +1,1264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The deterministic SDD state machine.
|
|
3
|
+
*
|
|
4
|
+
* This module is the enforcement boundary the whole candidate skill exists for.
|
|
5
|
+
* A controller can reason its way to the right phase name and still invert a
|
|
6
|
+
* convention the skill defines: in the recorded baseline, both conditions on
|
|
7
|
+
* `post-compaction-illegal-transition` produced the correct phase token and then
|
|
8
|
+
* asserted the append-only audit ledger was authoritative and `state.json` a
|
|
9
|
+
* derived cache. Both invented repair mechanisms. Prose telling a controller to
|
|
10
|
+
* trust the ledger is advice; a reducer that refuses the transition is
|
|
11
|
+
* enforcement.
|
|
12
|
+
*
|
|
13
|
+
* `state.json` is canonical. The ledger is an append-only audit trail derived
|
|
14
|
+
* from it. That direction is not negotiable and is the rule this module exists
|
|
15
|
+
* to make unstateable.
|
|
16
|
+
*
|
|
17
|
+
* Two structural rules follow from the baseline evidence and shape every handler
|
|
18
|
+
* below:
|
|
19
|
+
*
|
|
20
|
+
* 1. **Recording a result and deciding what it means are separate transitions.**
|
|
21
|
+
* A `*-finished` event only pins a bounded artifact. An explicit controller
|
|
22
|
+
* event then selects the guarded next phase. A child's report can never
|
|
23
|
+
* choose the phase it leads to.
|
|
24
|
+
* 2. **Nothing is inferred from absence.** Every branch that a human would call
|
|
25
|
+
* a judgement call requires a persisted ruling naming a decision and a
|
|
26
|
+
* reason. The reducer never picks the agreeable option by default.
|
|
27
|
+
*
|
|
28
|
+
* The reducer is pure: no filesystem, Git, subprocess, network, randomness, or
|
|
29
|
+
* clock. Every value that varies between runs arrives in the event, so a
|
|
30
|
+
* transition is reproducible from `(state, event)` alone.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { createHash } from "node:crypto";
|
|
34
|
+
|
|
35
|
+
import { roleTier, TIERS } from "./plan-policy.mjs";
|
|
36
|
+
|
|
37
|
+
/** Current serialized state version. */
|
|
38
|
+
export const STATE_VERSION = 1;
|
|
39
|
+
|
|
40
|
+
/** Maximum characters for any single human-supplied string recorded in state. */
|
|
41
|
+
const MAX_RECORD_CHARS = 256;
|
|
42
|
+
|
|
43
|
+
/** Maximum bytes for one rendered audit line. */
|
|
44
|
+
const MAX_AUDIT_LINE_BYTES = 8 * 1024;
|
|
45
|
+
|
|
46
|
+
/** Maximum bytes for the whole serialized state document. */
|
|
47
|
+
const MAX_STATE_BYTES = 1024 * 1024;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Maximum bytes for a stored rendered prompt.
|
|
51
|
+
*
|
|
52
|
+
* Matches the rendered-prompt limit, because recovery reissues these exact bytes
|
|
53
|
+
* and a stored prompt that could not be sent is not a reissuable record.
|
|
54
|
+
*/
|
|
55
|
+
const MAX_PROMPT_BYTES = 384 * 1024;
|
|
56
|
+
|
|
57
|
+
/** Maximum findings tracked in one run's ledger. */
|
|
58
|
+
const MAX_FINDINGS = 512;
|
|
59
|
+
|
|
60
|
+
/** Maximum fix rounds per task. */
|
|
61
|
+
const MAX_FIX_ROUNDS = 5;
|
|
62
|
+
|
|
63
|
+
/** Maximum context enrichments before a task blocks. */
|
|
64
|
+
const MAX_CONTEXT_ATTEMPTS = 2;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The audit-line marker. Human text is rejected rather than escaped when it
|
|
68
|
+
* contains this, so no recorded reason can forge a transition record.
|
|
69
|
+
*/
|
|
70
|
+
const AUDIT_MARKER = "<!-- sdd-transition:";
|
|
71
|
+
|
|
72
|
+
/** The bounded controller-owned dispatch-key grammar. */
|
|
73
|
+
const DISPATCH_KEY = /^[A-Za-z0-9._:-]{1,240}$/u;
|
|
74
|
+
|
|
75
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/u;
|
|
76
|
+
const SHA1_HEX = /^[0-9a-f]{40}$/u;
|
|
77
|
+
const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u;
|
|
78
|
+
const FINDING_ID = /^[A-Za-z0-9._:-]{1,64}$/u;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Every legal phase, matching `references/state-machine.md`.
|
|
82
|
+
*
|
|
83
|
+
* The set is frozen and complete, so an unknown phase is rejected rather than
|
|
84
|
+
* becoming legal by omission.
|
|
85
|
+
*/
|
|
86
|
+
export const PHASES = Object.freeze([
|
|
87
|
+
"CAPABILITY_CHECK",
|
|
88
|
+
"CAPABILITY_BLOCKED",
|
|
89
|
+
"PLAN_VALIDATE",
|
|
90
|
+
"PLAN_INVALID",
|
|
91
|
+
"PREFLIGHT_DECISION_REQUIRED",
|
|
92
|
+
"WORKSPACE_READY",
|
|
93
|
+
"IMPLEMENT_DISPATCH_INTENT",
|
|
94
|
+
"IMPLEMENT_RUNNING",
|
|
95
|
+
"IMPLEMENT_RESULT",
|
|
96
|
+
"CONTEXT_REQUIRED",
|
|
97
|
+
"CONCERN_DECISION_REQUIRED",
|
|
98
|
+
"TASK_BLOCKED",
|
|
99
|
+
"TASK_REVIEW_DISPATCH_INTENT",
|
|
100
|
+
"TASK_REVIEW_RUNNING",
|
|
101
|
+
"TASK_REVIEW_DECISION",
|
|
102
|
+
"FIX_DISPATCH_INTENT",
|
|
103
|
+
"FIX_RUNNING",
|
|
104
|
+
"REREVIEW_DISPATCH_INTENT",
|
|
105
|
+
"REREVIEW_RUNNING",
|
|
106
|
+
"TASK_COMPLETE",
|
|
107
|
+
"FINAL_REVIEW_DISPATCH_INTENT",
|
|
108
|
+
"FINAL_REVIEW_RUNNING",
|
|
109
|
+
"FINAL_FIX_DISPATCH_INTENT",
|
|
110
|
+
"FINAL_FIX_RUNNING",
|
|
111
|
+
"FINAL_REREVIEW_DISPATCH_INTENT",
|
|
112
|
+
"FINAL_REREVIEW_RUNNING",
|
|
113
|
+
"DISPATCH_MISMATCH_BLOCKED",
|
|
114
|
+
"DISPATCH_AMBIGUOUS",
|
|
115
|
+
"FINAL_BLOCKED",
|
|
116
|
+
"COMPLETE",
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
/** Phases that accept no ordinary continuation event. */
|
|
120
|
+
export const TERMINAL_PHASES = Object.freeze([
|
|
121
|
+
"CAPABILITY_BLOCKED",
|
|
122
|
+
"PLAN_INVALID",
|
|
123
|
+
"TASK_BLOCKED",
|
|
124
|
+
"DISPATCH_MISMATCH_BLOCKED",
|
|
125
|
+
"FINAL_BLOCKED",
|
|
126
|
+
"COMPLETE",
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Dispatch-intent phases, their running counterpart, and the role dispatched.
|
|
131
|
+
*
|
|
132
|
+
* `tier` is `null` where the role formula decides, and a fixed tier where the
|
|
133
|
+
* contract pins it regardless of the task's implementer tier.
|
|
134
|
+
*/
|
|
135
|
+
const INTENT_PHASES = Object.freeze({
|
|
136
|
+
IMPLEMENT_DISPATCH_INTENT: { running: "IMPLEMENT_RUNNING", role: "implementer", tier: null },
|
|
137
|
+
TASK_REVIEW_DISPATCH_INTENT: { running: "TASK_REVIEW_RUNNING", role: "task-reviewer", tier: null },
|
|
138
|
+
FIX_DISPATCH_INTENT: { running: "FIX_RUNNING", role: "fixer", tier: null },
|
|
139
|
+
REREVIEW_DISPATCH_INTENT: { running: "REREVIEW_RUNNING", role: "re-reviewer", tier: null },
|
|
140
|
+
FINAL_REVIEW_DISPATCH_INTENT: {
|
|
141
|
+
running: "FINAL_REVIEW_RUNNING",
|
|
142
|
+
role: "final",
|
|
143
|
+
tier: "frontier",
|
|
144
|
+
},
|
|
145
|
+
FINAL_FIX_DISPATCH_INTENT: {
|
|
146
|
+
running: "FINAL_FIX_RUNNING",
|
|
147
|
+
role: "final-fixer",
|
|
148
|
+
tier: "frontier",
|
|
149
|
+
},
|
|
150
|
+
FINAL_REREVIEW_DISPATCH_INTENT: {
|
|
151
|
+
running: "FINAL_REREVIEW_RUNNING",
|
|
152
|
+
role: "final-re-reviewer",
|
|
153
|
+
tier: "frontier",
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
/** Roles that may be dispatched, and whether the key carries a fix round. */
|
|
158
|
+
const ROLE_ROUNDS = Object.freeze({
|
|
159
|
+
implementer: false,
|
|
160
|
+
"task-reviewer": false,
|
|
161
|
+
"re-reviewer": false,
|
|
162
|
+
final: false,
|
|
163
|
+
fixer: true,
|
|
164
|
+
"final-fixer": false,
|
|
165
|
+
"final-re-reviewer": false,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
/** The four implementer status tokens, inherited from the original skill. */
|
|
169
|
+
const IMPLEMENTER_STATUSES = Object.freeze([
|
|
170
|
+
"DONE",
|
|
171
|
+
"DONE_WITH_CONCERNS",
|
|
172
|
+
"NEEDS_CONTEXT",
|
|
173
|
+
"BLOCKED",
|
|
174
|
+
]);
|
|
175
|
+
|
|
176
|
+
/** Concern kinds that pass to review without a ruling. */
|
|
177
|
+
const OBSERVATIONAL_CONCERN = "observational";
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Finding severities.
|
|
181
|
+
*
|
|
182
|
+
* `Critical` and `Important` are load-bearing: they open a fix round and can
|
|
183
|
+
* never be parked. `Minor` is contestable and may be parked with a ruling.
|
|
184
|
+
*/
|
|
185
|
+
const LOAD_BEARING_SEVERITIES = Object.freeze(["Critical", "Important"]);
|
|
186
|
+
const SEVERITIES = Object.freeze([...LOAD_BEARING_SEVERITIES, "Minor"]);
|
|
187
|
+
|
|
188
|
+
/** Terminal dispositions a finding may reach. */
|
|
189
|
+
const RESOLVED_DISPOSITIONS = Object.freeze(["fixed", "parked", "out-of-scope", "cannot-verify"]);
|
|
190
|
+
|
|
191
|
+
const PHASE_SET = new Set(PHASES);
|
|
192
|
+
const TERMINAL_SET = new Set(TERMINAL_PHASES);
|
|
193
|
+
const TIER_SET = new Set(TIERS);
|
|
194
|
+
|
|
195
|
+
/** A rejected transition or malformed state. */
|
|
196
|
+
export class StateError extends Error {
|
|
197
|
+
constructor(message) {
|
|
198
|
+
super(message);
|
|
199
|
+
this.name = "StateError";
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const fail = (message) => {
|
|
204
|
+
throw new StateError(message);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Validate a bounded single-line human string.
|
|
209
|
+
*
|
|
210
|
+
* Control characters are rejected outright rather than escaped. Escaping would
|
|
211
|
+
* let a caller smuggle marker-shaped text through and rely on a later consumer
|
|
212
|
+
* unescaping it; rejecting keeps exactly one representation.
|
|
213
|
+
*/
|
|
214
|
+
const bounded = (value, field) => {
|
|
215
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
216
|
+
fail(`${field} must be a non-empty string`);
|
|
217
|
+
}
|
|
218
|
+
if (value.length > MAX_RECORD_CHARS) {
|
|
219
|
+
fail(`${field} exceeds ${String(MAX_RECORD_CHARS)} characters`);
|
|
220
|
+
}
|
|
221
|
+
// eslint-disable-next-line no-control-regex -- the point is to reject these.
|
|
222
|
+
if (/[\u0000-\u001f\u007f]/u.test(value)) {
|
|
223
|
+
fail(`${field} must not contain a control character`);
|
|
224
|
+
}
|
|
225
|
+
if (value.includes(AUDIT_MARKER)) {
|
|
226
|
+
fail(`${field} must not contain ${AUDIT_MARKER}`);
|
|
227
|
+
}
|
|
228
|
+
return value;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const instant = (value, field) => {
|
|
232
|
+
if (typeof value !== "string" || !ISO_INSTANT.test(value)) {
|
|
233
|
+
fail(`${field} must be an ISO-8601 instant with millisecond precision`);
|
|
234
|
+
}
|
|
235
|
+
return value;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const absolutePath = (value, field) => {
|
|
239
|
+
if (typeof value !== "string" || !value.startsWith("/")) {
|
|
240
|
+
fail(`${field} must be an absolute path`);
|
|
241
|
+
}
|
|
242
|
+
if (value.includes("\u0000")) fail(`${field} must not contain a NUL byte`);
|
|
243
|
+
if (value.split("/").includes("..")) {
|
|
244
|
+
fail(`${field} must be normalized and contain no ".." segment`);
|
|
245
|
+
}
|
|
246
|
+
return value;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const nonNegativeInteger = (value, field) => {
|
|
250
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
251
|
+
fail(`${field} must be a non-negative integer`);
|
|
252
|
+
}
|
|
253
|
+
return value;
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const enumerated = (value, allowed, field) => {
|
|
257
|
+
if (!allowed.includes(value)) {
|
|
258
|
+
fail(`${field} must be one of ${allowed.join(", ")}, got ${String(value)}`);
|
|
259
|
+
}
|
|
260
|
+
return value;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Derive the run identity.
|
|
265
|
+
*
|
|
266
|
+
* NUL-joined because no component may contain one, so the concatenation is
|
|
267
|
+
* unambiguous. `createdAt` participates so two runs of the same plan on the same
|
|
268
|
+
* commit are distinguishable.
|
|
269
|
+
*/
|
|
270
|
+
export const computeRunId = ({ planDigest, worktree, branch, mergeBase, createdAt }) =>
|
|
271
|
+
createHash("sha256")
|
|
272
|
+
.update([planDigest, worktree, branch, mergeBase, createdAt].join("\u0000"))
|
|
273
|
+
.digest("hex");
|
|
274
|
+
|
|
275
|
+
const deepFreeze = (value) => {
|
|
276
|
+
if (value === null || typeof value !== "object") return value;
|
|
277
|
+
for (const inner of Object.values(value)) deepFreeze(inner);
|
|
278
|
+
return Object.freeze(value);
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Compose a controller-owned dispatch key.
|
|
283
|
+
*
|
|
284
|
+
* This key never reaches `spawn_subsession`, which accepts only
|
|
285
|
+
* `{ prompt, cwd, tier }`. It names a row in the run's own dispatch ledger so
|
|
286
|
+
* recovery can correlate a recorded intent to the `sessionId` the tool returned.
|
|
287
|
+
* The runtime performs no deduplication, so this key buys correlation, never
|
|
288
|
+
* idempotency.
|
|
289
|
+
*/
|
|
290
|
+
export function dispatchKeyFor({ runId, task, role, attempt, round }) {
|
|
291
|
+
if (typeof runId !== "string" || !SHA256_HEX.test(runId)) {
|
|
292
|
+
fail("dispatchKeyFor requires the run's SHA-256 runId");
|
|
293
|
+
}
|
|
294
|
+
const needsRound = ROLE_ROUNDS[role];
|
|
295
|
+
if (needsRound === undefined) fail(`dispatchKeyFor received unknown role ${String(role)}`);
|
|
296
|
+
if (!Number.isInteger(task) || task < 1) fail("dispatchKeyFor requires a positive task number");
|
|
297
|
+
if (!Number.isInteger(attempt) || attempt < 1) {
|
|
298
|
+
fail("dispatchKeyFor requires a positive attempt number");
|
|
299
|
+
}
|
|
300
|
+
if (needsRound && !Number.isInteger(round)) {
|
|
301
|
+
fail(`role ${role} requires a fix round in its dispatch key`);
|
|
302
|
+
}
|
|
303
|
+
if (!needsRound && round !== undefined) {
|
|
304
|
+
fail(`role ${role} does not accept a fix round in its dispatch key`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const parts = [runId, `task-${String(task)}`, role, `attempt-${String(attempt)}`];
|
|
308
|
+
if (needsRound) parts.push(`round-${String(round)}`);
|
|
309
|
+
const key = parts.join(":");
|
|
310
|
+
if (!DISPATCH_KEY.test(key)) fail(`composed dispatchKey violates its grammar: ${key}`);
|
|
311
|
+
return key;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Validate an artifact path is absolute, normalized, and inside the run root. */
|
|
315
|
+
const runArtifact = (value, field, runRoot) => {
|
|
316
|
+
absolutePath(value, field);
|
|
317
|
+
if (!value.startsWith(`${runRoot}/`)) {
|
|
318
|
+
fail(`${field} must be beneath the pinned run root ${runRoot}`);
|
|
319
|
+
}
|
|
320
|
+
return value;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Validate one reported finding.
|
|
325
|
+
*
|
|
326
|
+
* Severity and load-bearing status are recorded at report time and never
|
|
327
|
+
* recomputed later, so a finding cannot be downgraded on its way to being
|
|
328
|
+
* dismissed.
|
|
329
|
+
*/
|
|
330
|
+
const normalizeFinding = (finding, index) => {
|
|
331
|
+
if (finding === null || typeof finding !== "object") {
|
|
332
|
+
fail(`findings[${String(index)}] must be an object`);
|
|
333
|
+
}
|
|
334
|
+
const id = finding.id;
|
|
335
|
+
if (typeof id !== "string" || !FINDING_ID.test(id)) {
|
|
336
|
+
fail(`findings[${String(index)}].id must match ^[A-Za-z0-9._:-]{1,64}$`);
|
|
337
|
+
}
|
|
338
|
+
enumerated(finding.severity, SEVERITIES, `findings[${String(index)}].severity`);
|
|
339
|
+
bounded(finding.summary, `findings[${String(index)}].summary`);
|
|
340
|
+
return {
|
|
341
|
+
id,
|
|
342
|
+
severity: finding.severity,
|
|
343
|
+
summary: finding.summary,
|
|
344
|
+
loadBearing: LOAD_BEARING_SEVERITIES.includes(finding.severity),
|
|
345
|
+
disposition: "open",
|
|
346
|
+
evidence: null,
|
|
347
|
+
};
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Merge newly reported findings into the run ledger.
|
|
352
|
+
*
|
|
353
|
+
* Reporting is additive: an ID already present keeps its existing disposition
|
|
354
|
+
* rather than being reset to open by a later review that mentions it again.
|
|
355
|
+
*/
|
|
356
|
+
const mergeFindings = (existing, reported, field) => {
|
|
357
|
+
if (reported === undefined) return existing;
|
|
358
|
+
if (!Array.isArray(reported)) fail(`${field} must be an array when present`);
|
|
359
|
+
|
|
360
|
+
const merged = existing.map((finding) => ({ ...finding }));
|
|
361
|
+
const byId = new Map(merged.map((finding) => [finding.id, finding]));
|
|
362
|
+
|
|
363
|
+
for (const [index, raw] of reported.entries()) {
|
|
364
|
+
const finding = normalizeFinding(raw, index);
|
|
365
|
+
const prior = byId.get(finding.id);
|
|
366
|
+
if (prior === undefined) {
|
|
367
|
+
merged.push(finding);
|
|
368
|
+
byId.set(finding.id, finding);
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
if (prior.severity !== finding.severity) {
|
|
372
|
+
fail(
|
|
373
|
+
`finding ${finding.id} was already recorded as ${prior.severity} and cannot be re-reported as ${finding.severity}`,
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (merged.length > MAX_FINDINGS) {
|
|
379
|
+
fail(`the finding ledger is bounded at ${String(MAX_FINDINGS)} entries`);
|
|
380
|
+
}
|
|
381
|
+
return merged;
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Apply controller adjudication to the ledger as a set operation.
|
|
386
|
+
*
|
|
387
|
+
* This is the retention guarantee. Every ID present before must still be present
|
|
388
|
+
* after, so no event can silently drop an open, deferred, or parked finding. A
|
|
389
|
+
* disposition may only be set with evidence, and a load-bearing finding can
|
|
390
|
+
* never be parked.
|
|
391
|
+
*/
|
|
392
|
+
const applyResolutions = (existing, resolutions, field) => {
|
|
393
|
+
if (resolutions === undefined) return existing;
|
|
394
|
+
if (!Array.isArray(resolutions)) fail(`${field} must be an array when present`);
|
|
395
|
+
|
|
396
|
+
const byId = new Map(existing.map((finding) => [finding.id, { ...finding }]));
|
|
397
|
+
|
|
398
|
+
for (const [index, entry] of resolutions.entries()) {
|
|
399
|
+
if (entry === null || typeof entry !== "object") {
|
|
400
|
+
fail(`${field}[${String(index)}] must be an object`);
|
|
401
|
+
}
|
|
402
|
+
const target = byId.get(entry.id);
|
|
403
|
+
if (target === undefined) {
|
|
404
|
+
fail(`${field}[${String(index)}] names unknown finding ${String(entry.id)}`);
|
|
405
|
+
}
|
|
406
|
+
enumerated(entry.disposition, RESOLVED_DISPOSITIONS, `${field}[${String(index)}].disposition`);
|
|
407
|
+
bounded(entry.evidence, `${field}[${String(index)}].evidence`);
|
|
408
|
+
|
|
409
|
+
if (entry.disposition === "parked" && target.loadBearing) {
|
|
410
|
+
fail(
|
|
411
|
+
`finding ${target.id} is ${target.severity} and load-bearing, so it cannot be parked; it must be fixed or the run blocks`,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
if (target.disposition !== "open" && target.disposition !== entry.disposition) {
|
|
415
|
+
fail(
|
|
416
|
+
`finding ${target.id} is already ${target.disposition} and cannot be changed to ${entry.disposition}`,
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
byId.set(entry.id, {
|
|
420
|
+
...target,
|
|
421
|
+
disposition: entry.disposition,
|
|
422
|
+
evidence: entry.evidence,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Retention check: the ledger may grow through reporting, never shrink here.
|
|
427
|
+
const after = [...byId.values()];
|
|
428
|
+
if (after.length !== existing.length) {
|
|
429
|
+
fail("adjudication must not add or remove findings; report them instead");
|
|
430
|
+
}
|
|
431
|
+
return after;
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
/** Findings still demanding work. */
|
|
435
|
+
const openFindings = (findings) => findings.filter((finding) => finding.disposition === "open");
|
|
436
|
+
|
|
437
|
+
/** Open findings that cannot be parked or deferred. */
|
|
438
|
+
const openLoadBearing = (findings) => openFindings(findings).filter((finding) => finding.loadBearing);
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Build the dispatch record for a validated intent.
|
|
442
|
+
*
|
|
443
|
+
* The record is complete before a session exists, so a crash between the spawn
|
|
444
|
+
* call and the correlation write leaves bytes that can be reissued verbatim.
|
|
445
|
+
*/
|
|
446
|
+
const buildIntent = (state, event, intentPhase) => {
|
|
447
|
+
const definition = INTENT_PHASES[intentPhase];
|
|
448
|
+
const role = definition.role;
|
|
449
|
+
const needsRound = ROLE_ROUNDS[role];
|
|
450
|
+
|
|
451
|
+
if (!Number.isInteger(event.attempt) || event.attempt < 1) {
|
|
452
|
+
fail("a dispatch intent requires a positive attempt");
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// The tier is the binding channel, so it must equal what the contract says.
|
|
456
|
+
// A controller wanting a different tier must change the plan.
|
|
457
|
+
const expected =
|
|
458
|
+
definition.tier ??
|
|
459
|
+
roleTier({
|
|
460
|
+
implementer: state.currentImplementerTier,
|
|
461
|
+
role,
|
|
462
|
+
...(needsRound ? { round: state.fixRound } : {}),
|
|
463
|
+
}).tier;
|
|
464
|
+
|
|
465
|
+
if (!TIER_SET.has(event.tier)) {
|
|
466
|
+
fail(`a dispatch intent requires a known lowercase tier, got ${String(event.tier)}`);
|
|
467
|
+
}
|
|
468
|
+
if (event.tier !== expected) {
|
|
469
|
+
fail(
|
|
470
|
+
`dispatch tier ${event.tier} differs from the role formula for ${role}, which yields ${expected}`,
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (typeof event.dispatchKey !== "string" || !DISPATCH_KEY.test(event.dispatchKey)) {
|
|
475
|
+
fail("a dispatch intent requires a dispatchKey matching ^[A-Za-z0-9._:-]{1,240}$");
|
|
476
|
+
}
|
|
477
|
+
const wanted = dispatchKeyFor({
|
|
478
|
+
runId: state.runId,
|
|
479
|
+
task: state.currentTask,
|
|
480
|
+
role,
|
|
481
|
+
attempt: event.attempt,
|
|
482
|
+
...(needsRound ? { round: state.fixRound } : {}),
|
|
483
|
+
});
|
|
484
|
+
if (event.dispatchKey !== wanted) {
|
|
485
|
+
fail("dispatchKey does not match this run, task, role, and attempt");
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (typeof event.renderedPrompt !== "string" || event.renderedPrompt.length === 0) {
|
|
489
|
+
fail(
|
|
490
|
+
"a dispatch intent requires the exact renderedPrompt bytes; an intent that cannot be reissued verbatim is not a valid intent",
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
if (Buffer.byteLength(event.renderedPrompt, "utf8") > MAX_PROMPT_BYTES) {
|
|
494
|
+
fail(`renderedPrompt exceeds ${String(MAX_PROMPT_BYTES)} bytes (384 KiB)`);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// A leading `Model tier: <tier>` line is a human-readable echo with no
|
|
498
|
+
// control effect. Its absence is fine; a disagreement with the typed tier
|
|
499
|
+
// means the renderer and the contract have diverged and must not be papered
|
|
500
|
+
// over.
|
|
501
|
+
const echo = /^Model tier: ([a-z]+)[ \t]*(?:\r?\n|$)/u.exec(event.renderedPrompt);
|
|
502
|
+
if (echo !== null && echo[1] !== event.tier) {
|
|
503
|
+
fail(
|
|
504
|
+
`renderer/formula divergence: prompt says Model tier: ${echo[1]} while the typed tier is ${event.tier}`,
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
bounded(event.expectedOutcome, "expectedOutcome");
|
|
509
|
+
|
|
510
|
+
return {
|
|
511
|
+
dispatchKey: event.dispatchKey,
|
|
512
|
+
role,
|
|
513
|
+
tier: event.tier,
|
|
514
|
+
attempt: event.attempt,
|
|
515
|
+
round: needsRound ? state.fixRound : null,
|
|
516
|
+
promptPath: runArtifact(event.promptPath, "promptPath", state.runRoot),
|
|
517
|
+
reportPath: runArtifact(event.reportPath, "reportPath", state.runRoot),
|
|
518
|
+
briefPath: runArtifact(event.briefPath, "briefPath", state.runRoot),
|
|
519
|
+
expectedOutcome: event.expectedOutcome,
|
|
520
|
+
renderedPrompt: event.renderedPrompt,
|
|
521
|
+
sessionId: null,
|
|
522
|
+
reissued: false,
|
|
523
|
+
status: null,
|
|
524
|
+
concerns: [],
|
|
525
|
+
};
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
/** Validate and normalize reported implementer concerns. */
|
|
529
|
+
const normalizeConcerns = (concerns) => {
|
|
530
|
+
if (concerns === undefined) return [];
|
|
531
|
+
if (!Array.isArray(concerns)) fail("concerns must be an array when present");
|
|
532
|
+
return concerns.map((concern, index) => {
|
|
533
|
+
if (concern === null || typeof concern !== "object") {
|
|
534
|
+
fail(`concerns[${String(index)}] must be an object`);
|
|
535
|
+
}
|
|
536
|
+
bounded(concern.kind, `concerns[${String(index)}].kind`);
|
|
537
|
+
bounded(concern.note, `concerns[${String(index)}].note`);
|
|
538
|
+
return { kind: concern.kind, note: concern.note };
|
|
539
|
+
});
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
/** A session id recorded against a dispatch. */
|
|
543
|
+
const sessionIdOf = (value) => {
|
|
544
|
+
bounded(value, "sessionId");
|
|
545
|
+
return value;
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
/** Record a bounded child artifact without letting it choose a phase. */
|
|
549
|
+
const pinResult = (state, event, extra = {}) => ({
|
|
550
|
+
...state.dispatch,
|
|
551
|
+
reportPath: runArtifact(event.reportPath, "reportPath", state.runRoot),
|
|
552
|
+
...extra,
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
/** Validate the immutable task index and detach it from caller-owned input. */
|
|
556
|
+
const normalizeTasks = (tasks) => {
|
|
557
|
+
if (!Array.isArray(tasks) || tasks.length === 0) {
|
|
558
|
+
fail("tasks must be a non-empty array");
|
|
559
|
+
}
|
|
560
|
+
return tasks.map((task, index) => {
|
|
561
|
+
if (task === null || typeof task !== "object") {
|
|
562
|
+
fail(`tasks[${String(index)}] must be an object`);
|
|
563
|
+
}
|
|
564
|
+
if (task.number !== index + 1) {
|
|
565
|
+
fail(
|
|
566
|
+
`tasks must be contiguous from 1: tasks[${String(index)}] has number ${String(task.number)}`,
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
if (!TIER_SET.has(task.implementerTier)) {
|
|
570
|
+
fail(
|
|
571
|
+
`tasks[${String(index)}].implementerTier must be one of ${TIERS.join(", ")}, got ${String(task.implementerTier)}`,
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
return { number: task.number, implementerTier: task.implementerTier };
|
|
575
|
+
});
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Build the initial state.
|
|
580
|
+
*
|
|
581
|
+
* Every version-1 field is constructed explicitly. Nothing is left undefined to
|
|
582
|
+
* be filled in by a later transition, so `validateState` can demand the whole
|
|
583
|
+
* shape from revision 0 onward.
|
|
584
|
+
*/
|
|
585
|
+
export function createInitialState({
|
|
586
|
+
planPath,
|
|
587
|
+
planDigest,
|
|
588
|
+
repoRoot,
|
|
589
|
+
worktree,
|
|
590
|
+
runRoot,
|
|
591
|
+
branch,
|
|
592
|
+
baseRef,
|
|
593
|
+
mergeBase,
|
|
594
|
+
tasks,
|
|
595
|
+
at,
|
|
596
|
+
}) {
|
|
597
|
+
absolutePath(planPath, "planPath");
|
|
598
|
+
absolutePath(repoRoot, "repoRoot");
|
|
599
|
+
absolutePath(worktree, "worktree");
|
|
600
|
+
absolutePath(runRoot, "runRoot");
|
|
601
|
+
if (typeof planDigest !== "string" || !SHA256_HEX.test(planDigest)) {
|
|
602
|
+
fail("planDigest must be a lowercase 64-character SHA-256 hex digest");
|
|
603
|
+
}
|
|
604
|
+
if (typeof mergeBase !== "string" || !SHA1_HEX.test(mergeBase)) {
|
|
605
|
+
fail("mergeBase must be a lowercase 40-character hex object name");
|
|
606
|
+
}
|
|
607
|
+
bounded(branch, "branch");
|
|
608
|
+
bounded(baseRef, "baseRef");
|
|
609
|
+
const createdAt = instant(at, "at");
|
|
610
|
+
|
|
611
|
+
// The run root must live inside the pinned worktree, so no artifact path
|
|
612
|
+
// validated against it can escape the tree the run owns.
|
|
613
|
+
if (!runRoot.startsWith(`${worktree}/`)) fail("runRoot must be beneath worktree");
|
|
614
|
+
|
|
615
|
+
const taskIndex = normalizeTasks(tasks);
|
|
616
|
+
|
|
617
|
+
return deepFreeze({
|
|
618
|
+
version: STATE_VERSION,
|
|
619
|
+
revision: 0,
|
|
620
|
+
runId: computeRunId({ planDigest, worktree, branch, mergeBase, createdAt }),
|
|
621
|
+
phase: "CAPABILITY_CHECK",
|
|
622
|
+
planPath,
|
|
623
|
+
planDigest,
|
|
624
|
+
repoRoot,
|
|
625
|
+
worktree,
|
|
626
|
+
runRoot,
|
|
627
|
+
branch,
|
|
628
|
+
baseRef,
|
|
629
|
+
mergeBase,
|
|
630
|
+
createdAt,
|
|
631
|
+
tasks: taskIndex,
|
|
632
|
+
currentTask: 1,
|
|
633
|
+
currentImplementerTier: taskIndex[0].implementerTier,
|
|
634
|
+
contextAttempts: 0,
|
|
635
|
+
fixRound: 0,
|
|
636
|
+
finalFixUsed: false,
|
|
637
|
+
mode: null,
|
|
638
|
+
dispatch: null,
|
|
639
|
+
findings: [],
|
|
640
|
+
reviewOutcome: null,
|
|
641
|
+
blockedReason: null,
|
|
642
|
+
recoveryRulings: 0,
|
|
643
|
+
lastTransition: `${AUDIT_MARKER} revision=0 phase=CAPABILITY_CHECK event=run-initialized at=${createdAt} -->`,
|
|
644
|
+
updatedAt: createdAt,
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Assert a state document is well-formed.
|
|
650
|
+
*
|
|
651
|
+
* Called on the result of every transition, so a handler cannot produce a state
|
|
652
|
+
* that later loads as invalid.
|
|
653
|
+
*/
|
|
654
|
+
export function validateState(state) {
|
|
655
|
+
if (state === null || typeof state !== "object") fail("state must be an object");
|
|
656
|
+
if (state.version !== STATE_VERSION) {
|
|
657
|
+
fail(`unsupported state version ${String(state.version)}`);
|
|
658
|
+
}
|
|
659
|
+
if (!PHASE_SET.has(state.phase)) fail(`unknown phase ${String(state.phase)}`);
|
|
660
|
+
nonNegativeInteger(state.revision, "revision");
|
|
661
|
+
nonNegativeInteger(state.contextAttempts, "contextAttempts");
|
|
662
|
+
nonNegativeInteger(state.fixRound, "fixRound");
|
|
663
|
+
nonNegativeInteger(state.recoveryRulings, "recoveryRulings");
|
|
664
|
+
if (state.fixRound > MAX_FIX_ROUNDS) {
|
|
665
|
+
fail(`fixRound is capped at ${String(MAX_FIX_ROUNDS)}, got ${String(state.fixRound)}`);
|
|
666
|
+
}
|
|
667
|
+
if (state.contextAttempts > MAX_CONTEXT_ATTEMPTS) {
|
|
668
|
+
fail(`contextAttempts is capped at ${String(MAX_CONTEXT_ATTEMPTS)}`);
|
|
669
|
+
}
|
|
670
|
+
if (typeof state.finalFixUsed !== "boolean") fail("finalFixUsed must be a boolean");
|
|
671
|
+
|
|
672
|
+
const tasks = normalizeTasks(state.tasks);
|
|
673
|
+
if (
|
|
674
|
+
!Number.isInteger(state.currentTask) ||
|
|
675
|
+
state.currentTask < 1 ||
|
|
676
|
+
state.currentTask > tasks.length
|
|
677
|
+
) {
|
|
678
|
+
fail(`currentTask must name a task in the index, got ${String(state.currentTask)}`);
|
|
679
|
+
}
|
|
680
|
+
if (!TIER_SET.has(state.currentImplementerTier)) {
|
|
681
|
+
fail(`currentImplementerTier must be a known tier, got ${String(state.currentImplementerTier)}`);
|
|
682
|
+
}
|
|
683
|
+
if (state.mode !== null && state.mode !== "exact" && state.mode !== "tiered") {
|
|
684
|
+
fail(`mode must be null, "exact", or "tiered", got ${String(state.mode)}`);
|
|
685
|
+
}
|
|
686
|
+
if (!Array.isArray(state.findings)) fail("findings must be an array");
|
|
687
|
+
if (state.findings.length > MAX_FINDINGS) {
|
|
688
|
+
fail(`the finding ledger is bounded at ${String(MAX_FINDINGS)} entries`);
|
|
689
|
+
}
|
|
690
|
+
for (const [index, finding] of state.findings.entries()) {
|
|
691
|
+
if (!FINDING_ID.test(String(finding?.id))) {
|
|
692
|
+
fail(`findings[${String(index)}].id is malformed`);
|
|
693
|
+
}
|
|
694
|
+
enumerated(finding.severity, SEVERITIES, `findings[${String(index)}].severity`);
|
|
695
|
+
enumerated(
|
|
696
|
+
finding.disposition,
|
|
697
|
+
["open", ...RESOLVED_DISPOSITIONS],
|
|
698
|
+
`findings[${String(index)}].disposition`,
|
|
699
|
+
);
|
|
700
|
+
if (finding.disposition === "parked" && finding.loadBearing) {
|
|
701
|
+
fail(`findings[${String(index)}] is load-bearing and cannot be parked`);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
instant(state.updatedAt, "updatedAt");
|
|
706
|
+
if (typeof state.lastTransition !== "string") fail("lastTransition must be a string");
|
|
707
|
+
if (Buffer.byteLength(state.lastTransition, "utf8") > MAX_AUDIT_LINE_BYTES) {
|
|
708
|
+
fail(`lastTransition exceeds ${String(MAX_AUDIT_LINE_BYTES)} bytes (8 KiB)`);
|
|
709
|
+
}
|
|
710
|
+
if (/[\r\n]/u.test(state.lastTransition)) fail("lastTransition must be exactly one line");
|
|
711
|
+
if (Buffer.byteLength(JSON.stringify(state), "utf8") > MAX_STATE_BYTES) {
|
|
712
|
+
fail(`serialized state exceeds ${String(MAX_STATE_BYTES)} bytes (1 MiB)`);
|
|
713
|
+
}
|
|
714
|
+
return state;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** Record a review verdict: both axes must be explicit. */
|
|
718
|
+
const reviewOutcomeOf = (event) => ({
|
|
719
|
+
specStatus: enumerated(event.specStatus, ["PASS", "FAIL"], "specStatus"),
|
|
720
|
+
qualityStatus: enumerated(
|
|
721
|
+
event.qualityStatus,
|
|
722
|
+
["APPROVED", "CHANGES_REQUESTED"],
|
|
723
|
+
"qualityStatus",
|
|
724
|
+
),
|
|
725
|
+
pinned: false,
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
/** A result-pinned phase requires its result recorded first. */
|
|
729
|
+
const requirePinned = (state) => {
|
|
730
|
+
if (state.reviewOutcome?.pinned !== true) {
|
|
731
|
+
fail("this adjudication requires a recorded review result first");
|
|
732
|
+
}
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Complete a task.
|
|
737
|
+
*
|
|
738
|
+
* Both axes must pass and no load-bearing finding may remain open. This is the
|
|
739
|
+
* gate the baseline controls talked their way past.
|
|
740
|
+
*/
|
|
741
|
+
const approveTask = (state, event) => {
|
|
742
|
+
const outcome = state.reviewOutcome;
|
|
743
|
+
if (outcome === null) fail("task approval requires a recorded review result");
|
|
744
|
+
if (outcome.specStatus !== "PASS") {
|
|
745
|
+
fail(`task approval requires spec PASS, got ${outcome.specStatus}`);
|
|
746
|
+
}
|
|
747
|
+
if (outcome.qualityStatus !== "APPROVED") {
|
|
748
|
+
fail(`task approval requires quality APPROVED, got ${outcome.qualityStatus}`);
|
|
749
|
+
}
|
|
750
|
+
const findings = applyResolutions(state.findings, event.findingResolutions, "findingResolutions");
|
|
751
|
+
const residual = openLoadBearing(findings);
|
|
752
|
+
if (residual.length > 0) {
|
|
753
|
+
fail(
|
|
754
|
+
`task approval is blocked by ${String(residual.length)} open load-bearing finding(s), starting with ${residual[0].id}`,
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
return { phase: "TASK_COMPLETE", findings, statusPinned: false };
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
/** Open the next fix round, enforcing the 1..5 cap. */
|
|
761
|
+
const openFixRound = (state, event) => {
|
|
762
|
+
const round = state.fixRound + 1;
|
|
763
|
+
if (round > MAX_FIX_ROUNDS) {
|
|
764
|
+
fail(
|
|
765
|
+
`fix rounds are capped at ${String(MAX_FIX_ROUNDS)}; a load-bearing residual at round ${String(state.fixRound)} must block`,
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
const outcome = state.reviewOutcome;
|
|
769
|
+
if (outcome === null) fail("a fix round requires a recorded review result");
|
|
770
|
+
const needsWork =
|
|
771
|
+
outcome.specStatus === "FAIL" ||
|
|
772
|
+
outcome.qualityStatus === "CHANGES_REQUESTED" ||
|
|
773
|
+
openFindings(state.findings).length > 0;
|
|
774
|
+
if (!needsWork) {
|
|
775
|
+
fail("a fix round requires a spec failure, requested changes, or an open finding");
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// The tier escalates with the round, so fixRound must advance before the
|
|
779
|
+
// intent is validated against the formula.
|
|
780
|
+
const advanced = { ...state, fixRound: round };
|
|
781
|
+
const next = intendDispatch("FIX_DISPATCH_INTENT")(advanced, event);
|
|
782
|
+
return { ...next, fixRound: round, reviewOutcome: null };
|
|
783
|
+
};
|
|
784
|
+
|
|
785
|
+
/** Open the single permitted final-fix wave. */
|
|
786
|
+
const openFinalFix = (state, event) => {
|
|
787
|
+
requirePinned(state);
|
|
788
|
+
if (state.finalFixUsed) {
|
|
789
|
+
fail("exactly one final-fix wave is permitted; a second is not legal");
|
|
790
|
+
}
|
|
791
|
+
const next = intendDispatch("FINAL_FIX_DISPATCH_INTENT")(state, event);
|
|
792
|
+
return { ...next, finalFixUsed: true };
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
/** Complete the run. Requires final-review evidence and a reconciled ledger. */
|
|
796
|
+
const completeRun = (state, event) => {
|
|
797
|
+
requirePinned(state);
|
|
798
|
+
const outcome = state.reviewOutcome;
|
|
799
|
+
if (outcome === null) fail("completion requires a recorded review result");
|
|
800
|
+
if (outcome.specStatus !== "PASS" || outcome.qualityStatus !== "APPROVED") {
|
|
801
|
+
fail(
|
|
802
|
+
`completion requires final spec PASS and quality APPROVED, got ${outcome.specStatus}/${outcome.qualityStatus}`,
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
const findings = applyResolutions(state.findings, event.findingResolutions, "findingResolutions");
|
|
806
|
+
const residual = openFindings(findings);
|
|
807
|
+
if (residual.length > 0) {
|
|
808
|
+
fail(
|
|
809
|
+
`completion requires every finding adjudicated; ${String(residual.length)} remain open, starting with ${residual[0].id}`,
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
return { phase: "COMPLETE", findings };
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
/** Render the one-line audit record for a completed transition. */
|
|
816
|
+
const renderTransition = (state, eventType) =>
|
|
817
|
+
`${AUDIT_MARKER} revision=${String(state.revision)} phase=${state.phase} event=${eventType} at=${state.updatedAt} -->`;
|
|
818
|
+
|
|
819
|
+
/** Handlers shared by every dispatch-intent phase. */
|
|
820
|
+
const intentHandlers = (intentPhase) => {
|
|
821
|
+
const { running } = INTENT_PHASES[intentPhase];
|
|
822
|
+
return {
|
|
823
|
+
// Correlation only. The tool already ran; this records which child it made.
|
|
824
|
+
"dispatch-started": (state, event) => ({
|
|
825
|
+
phase: running,
|
|
826
|
+
dispatch: { ...state.dispatch, sessionId: sessionIdOf(event.sessionId) },
|
|
827
|
+
}),
|
|
828
|
+
"dispatch-mismatch": (state, event) => ({
|
|
829
|
+
phase: "DISPATCH_MISMATCH_BLOCKED",
|
|
830
|
+
blockedReason: bounded(event.reason, "reason"),
|
|
831
|
+
}),
|
|
832
|
+
// The window between the spawn call and the correlation write cannot be
|
|
833
|
+
// closed from here. It can only be made visible.
|
|
834
|
+
"dispatch-window-crossed": (state, event) => ({
|
|
835
|
+
phase: "DISPATCH_AMBIGUOUS",
|
|
836
|
+
blockedReason: bounded(event.reason, "reason"),
|
|
837
|
+
ambiguousIntentPhase: intentPhase,
|
|
838
|
+
}),
|
|
839
|
+
};
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
/** Build the dispatch-intent entry handler for one intent phase. */
|
|
843
|
+
const intendDispatch = (intentPhase) => (state, event) => ({
|
|
844
|
+
phase: intentPhase,
|
|
845
|
+
dispatch: buildIntent(state, event, intentPhase),
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Phase/event handlers.
|
|
850
|
+
*
|
|
851
|
+
* Exactly one handler is looked up per `(phase, event.type)` pair. A missing
|
|
852
|
+
* entry is an illegal transition, so the legal set is whatever appears here and
|
|
853
|
+
* nothing else. Each handler returns only the fields it changes.
|
|
854
|
+
*/
|
|
855
|
+
const HANDLERS = Object.freeze({
|
|
856
|
+
CAPABILITY_CHECK: {
|
|
857
|
+
"capability-confirmed": (state, event) => ({
|
|
858
|
+
phase: "PLAN_VALIDATE",
|
|
859
|
+
mode: enumerated(event.mode, ["exact", "tiered"], "mode"),
|
|
860
|
+
}),
|
|
861
|
+
"capability-missing": (state, event) => ({
|
|
862
|
+
phase: "CAPABILITY_BLOCKED",
|
|
863
|
+
blockedReason: bounded(event.reason, "reason"),
|
|
864
|
+
}),
|
|
865
|
+
},
|
|
866
|
+
PLAN_VALIDATE: {
|
|
867
|
+
// Validation pins evidence in place; it does not advance on its own. The
|
|
868
|
+
// preflight result is a separate observation.
|
|
869
|
+
"plan-valid": (state, event) => {
|
|
870
|
+
if (event.planDigest !== state.planDigest) {
|
|
871
|
+
fail("plan-valid digest does not match the pinned plan digest");
|
|
872
|
+
}
|
|
873
|
+
return { phase: "PLAN_VALIDATE", planValidated: true };
|
|
874
|
+
},
|
|
875
|
+
"plan-invalid": (state, event) => ({
|
|
876
|
+
phase: "PLAN_INVALID",
|
|
877
|
+
blockedReason: bounded(event.reason, "reason"),
|
|
878
|
+
}),
|
|
879
|
+
"plan-conflict": (state, event) => {
|
|
880
|
+
if (typeof event.planDigest !== "string" || !SHA256_HEX.test(event.planDigest)) {
|
|
881
|
+
fail("plan-conflict requires the observed planDigest");
|
|
882
|
+
}
|
|
883
|
+
if (event.planDigest === state.planDigest) {
|
|
884
|
+
fail("plan-conflict requires a digest differing from the pinned plan digest");
|
|
885
|
+
}
|
|
886
|
+
return {
|
|
887
|
+
phase: "PLAN_INVALID",
|
|
888
|
+
blockedReason: `plan digest changed under the run: observed ${event.planDigest.slice(0, 12)}`,
|
|
889
|
+
};
|
|
890
|
+
},
|
|
891
|
+
"preflight-clean": (state) => {
|
|
892
|
+
if (state.planValidated !== true) {
|
|
893
|
+
fail("preflight-clean requires a validated plan");
|
|
894
|
+
}
|
|
895
|
+
return { phase: "WORKSPACE_READY" };
|
|
896
|
+
},
|
|
897
|
+
"preflight-conflict": (state, event) => {
|
|
898
|
+
if (state.planValidated !== true) {
|
|
899
|
+
fail("preflight-conflict requires a validated plan");
|
|
900
|
+
}
|
|
901
|
+
return {
|
|
902
|
+
phase: "PREFLIGHT_DECISION_REQUIRED",
|
|
903
|
+
blockedReason: bounded(event.reason, "reason"),
|
|
904
|
+
};
|
|
905
|
+
},
|
|
906
|
+
},
|
|
907
|
+
PREFLIGHT_DECISION_REQUIRED: {
|
|
908
|
+
// A ruling must be persisted before work resumes. The reducer never infers
|
|
909
|
+
// one from the absence of an objection.
|
|
910
|
+
"preflight-approved": (state, event) => {
|
|
911
|
+
bounded(event.reason, "reason");
|
|
912
|
+
return { phase: "WORKSPACE_READY", blockedReason: null };
|
|
913
|
+
},
|
|
914
|
+
"preflight-rejected": (state, event) => ({
|
|
915
|
+
phase: "FINAL_BLOCKED",
|
|
916
|
+
blockedReason: `preflight rejected: ${bounded(event.reason, "reason")}`,
|
|
917
|
+
}),
|
|
918
|
+
},
|
|
919
|
+
WORKSPACE_READY: {
|
|
920
|
+
"implement-dispatch-intended": intendDispatch("IMPLEMENT_DISPATCH_INTENT"),
|
|
921
|
+
},
|
|
922
|
+
IMPLEMENT_DISPATCH_INTENT: intentHandlers("IMPLEMENT_DISPATCH_INTENT"),
|
|
923
|
+
DISPATCH_AMBIGUOUS: {
|
|
924
|
+
// The reducer never picks. Adoption names an observed child; reissue accepts
|
|
925
|
+
// a possible orphan. Both are recorded decisions, not inferences.
|
|
926
|
+
"dispatch-ruling-recorded": (state, event) => {
|
|
927
|
+
bounded(event.reason, "reason");
|
|
928
|
+
const intentPhase = state.ambiguousIntentPhase;
|
|
929
|
+
const definition = INTENT_PHASES[intentPhase];
|
|
930
|
+
if (definition === undefined) {
|
|
931
|
+
fail("the ambiguous dispatch does not name a known intent phase");
|
|
932
|
+
}
|
|
933
|
+
if (event.decision === "adopt") {
|
|
934
|
+
if (event.sessionId === undefined) {
|
|
935
|
+
fail("a dispatch ruling that adopts a child requires its sessionId");
|
|
936
|
+
}
|
|
937
|
+
return {
|
|
938
|
+
phase: definition.running,
|
|
939
|
+
blockedReason: null,
|
|
940
|
+
ambiguousIntentPhase: null,
|
|
941
|
+
dispatch: { ...state.dispatch, sessionId: sessionIdOf(event.sessionId) },
|
|
942
|
+
};
|
|
943
|
+
}
|
|
944
|
+
if (event.decision === "reissue") {
|
|
945
|
+
if (event.sessionId !== undefined) {
|
|
946
|
+
fail("a dispatch ruling that reissues must not name a sessionId");
|
|
947
|
+
}
|
|
948
|
+
return {
|
|
949
|
+
phase: intentPhase,
|
|
950
|
+
blockedReason: null,
|
|
951
|
+
ambiguousIntentPhase: null,
|
|
952
|
+
dispatch: { ...state.dispatch, sessionId: null, reissued: true },
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
return fail(
|
|
956
|
+
`dispatch-ruling-recorded requires decision "adopt" or "reissue", got ${String(event.decision)}`,
|
|
957
|
+
);
|
|
958
|
+
},
|
|
959
|
+
},
|
|
960
|
+
IMPLEMENT_RUNNING: {
|
|
961
|
+
// Recording only. Classification is the next, separate transition.
|
|
962
|
+
"implementer-finished": (state, event) => ({
|
|
963
|
+
phase: "IMPLEMENT_RESULT",
|
|
964
|
+
dispatch: pinResult(state, event),
|
|
965
|
+
}),
|
|
966
|
+
},
|
|
967
|
+
IMPLEMENT_RESULT: {
|
|
968
|
+
"implementer-status-recorded": (state, event) => {
|
|
969
|
+
enumerated(event.status, IMPLEMENTER_STATUSES, "status");
|
|
970
|
+
const concerns = normalizeConcerns(event.concerns);
|
|
971
|
+
|
|
972
|
+
// A hedged status with nothing to adjudicate is worse than no hedge: it
|
|
973
|
+
// costs the controller a decision point and gives it nothing to decide.
|
|
974
|
+
// The role contract states this three ways and a live role still returned
|
|
975
|
+
// DONE_WITH_CONCERNS with an empty concerns section, so it is enforced
|
|
976
|
+
// here. This is the project's own thesis applied to itself: prose is
|
|
977
|
+
// advice, a reducer is enforcement.
|
|
978
|
+
if (event.status === "DONE_WITH_CONCERNS" && concerns.length === 0) {
|
|
979
|
+
fail(
|
|
980
|
+
"DONE_WITH_CONCERNS requires at least one concern; record DONE when there is nothing to adjudicate",
|
|
981
|
+
);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const dispatch = { ...state.dispatch, status: event.status, concerns };
|
|
985
|
+
|
|
986
|
+
if (event.status === "BLOCKED") {
|
|
987
|
+
return {
|
|
988
|
+
phase: "TASK_BLOCKED",
|
|
989
|
+
dispatch,
|
|
990
|
+
blockedReason: bounded(event.reason, "reason"),
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
if (event.status === "NEEDS_CONTEXT") {
|
|
994
|
+
return { phase: "CONTEXT_REQUIRED", dispatch };
|
|
995
|
+
}
|
|
996
|
+
const blocking = concerns.filter((concern) => concern.kind !== OBSERVATIONAL_CONCERN);
|
|
997
|
+
if (blocking.length > 0) {
|
|
998
|
+
return { phase: "CONCERN_DECISION_REQUIRED", dispatch };
|
|
999
|
+
}
|
|
1000
|
+
return { phase: "IMPLEMENT_RESULT", dispatch, statusPinned: true };
|
|
1001
|
+
},
|
|
1002
|
+
"task-review-dispatch-intended": (state, event) => {
|
|
1003
|
+
if (state.statusPinned !== true) {
|
|
1004
|
+
fail("task review requires a pinned implementer status");
|
|
1005
|
+
}
|
|
1006
|
+
return intendDispatch("TASK_REVIEW_DISPATCH_INTENT")(state, event);
|
|
1007
|
+
},
|
|
1008
|
+
},
|
|
1009
|
+
CONTEXT_REQUIRED: {
|
|
1010
|
+
// Enrichment retries the same task at the planned tier. It is not a fix
|
|
1011
|
+
// round, so fixRound is untouched.
|
|
1012
|
+
"context-dispatch-intended": (state, event) => {
|
|
1013
|
+
if (state.contextAttempts >= MAX_CONTEXT_ATTEMPTS) {
|
|
1014
|
+
fail(
|
|
1015
|
+
`context enrichment is bounded at ${String(MAX_CONTEXT_ATTEMPTS)} attempts; the task must block instead`,
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
1018
|
+
const next = intendDispatch("IMPLEMENT_DISPATCH_INTENT")(
|
|
1019
|
+
{ ...state, contextAttempts: state.contextAttempts + 1 },
|
|
1020
|
+
event,
|
|
1021
|
+
);
|
|
1022
|
+
return { ...next, contextAttempts: state.contextAttempts + 1, statusPinned: false };
|
|
1023
|
+
},
|
|
1024
|
+
"context-limit-reached": (state, event) => {
|
|
1025
|
+
if (state.contextAttempts < MAX_CONTEXT_ATTEMPTS) {
|
|
1026
|
+
fail("context-limit-reached is only legal once enrichment is exhausted");
|
|
1027
|
+
}
|
|
1028
|
+
return { phase: "TASK_BLOCKED", blockedReason: bounded(event.reason, "reason") };
|
|
1029
|
+
},
|
|
1030
|
+
},
|
|
1031
|
+
CONCERN_DECISION_REQUIRED: {
|
|
1032
|
+
"concern-ruling-recorded": (state, event) => {
|
|
1033
|
+
bounded(event.reason, "reason");
|
|
1034
|
+
if (event.decision === "proceed") {
|
|
1035
|
+
return { phase: "IMPLEMENT_RESULT", statusPinned: true };
|
|
1036
|
+
}
|
|
1037
|
+
if (event.decision === "block") {
|
|
1038
|
+
return { phase: "TASK_BLOCKED", blockedReason: event.reason };
|
|
1039
|
+
}
|
|
1040
|
+
return fail(
|
|
1041
|
+
`concern-ruling-recorded requires decision "proceed" or "block", got ${String(event.decision)}`,
|
|
1042
|
+
);
|
|
1043
|
+
},
|
|
1044
|
+
},
|
|
1045
|
+
TASK_REVIEW_DISPATCH_INTENT: intentHandlers("TASK_REVIEW_DISPATCH_INTENT"),
|
|
1046
|
+
TASK_REVIEW_RUNNING: {
|
|
1047
|
+
// The reviewer records evidence. It cannot select the next phase.
|
|
1048
|
+
"task-review-finished": (state, event) => ({
|
|
1049
|
+
phase: "TASK_REVIEW_DECISION",
|
|
1050
|
+
dispatch: pinResult(state, event),
|
|
1051
|
+
reviewOutcome: reviewOutcomeOf(event),
|
|
1052
|
+
findings: mergeFindings(state.findings, event.findings, "findings"),
|
|
1053
|
+
}),
|
|
1054
|
+
},
|
|
1055
|
+
TASK_REVIEW_DECISION: {
|
|
1056
|
+
"review-approved": (state, event) => approveTask(state, event),
|
|
1057
|
+
"fix-dispatch-intended": (state, event) => openFixRound(state, event),
|
|
1058
|
+
"review-blocked": (state, event) => ({
|
|
1059
|
+
phase: "TASK_BLOCKED",
|
|
1060
|
+
blockedReason: bounded(event.reason, "reason"),
|
|
1061
|
+
}),
|
|
1062
|
+
},
|
|
1063
|
+
FIX_DISPATCH_INTENT: intentHandlers("FIX_DISPATCH_INTENT"),
|
|
1064
|
+
FIX_RUNNING: {
|
|
1065
|
+
"rereview-dispatch-intended": (state, event) => {
|
|
1066
|
+
const resolved = applyResolutions(state.findings, event.findingResolutions, "findingResolutions");
|
|
1067
|
+
const next = intendDispatch("REREVIEW_DISPATCH_INTENT")(state, event);
|
|
1068
|
+
return { ...next, findings: resolved };
|
|
1069
|
+
},
|
|
1070
|
+
"fixer-blocked": (state, event) => ({
|
|
1071
|
+
phase: "TASK_BLOCKED",
|
|
1072
|
+
blockedReason: bounded(event.reason, "reason"),
|
|
1073
|
+
}),
|
|
1074
|
+
},
|
|
1075
|
+
REREVIEW_DISPATCH_INTENT: intentHandlers("REREVIEW_DISPATCH_INTENT"),
|
|
1076
|
+
REREVIEW_RUNNING: {
|
|
1077
|
+
// Pins the result only. Adjudication is the next, separate transition.
|
|
1078
|
+
"rereview-finished": (state, event) => {
|
|
1079
|
+
if (state.reviewOutcome?.pinned === true) {
|
|
1080
|
+
fail("rereview-finished has already pinned a result for this round");
|
|
1081
|
+
}
|
|
1082
|
+
return {
|
|
1083
|
+
phase: "REREVIEW_RUNNING",
|
|
1084
|
+
dispatch: pinResult(state, event),
|
|
1085
|
+
reviewOutcome: { ...reviewOutcomeOf(event), pinned: true },
|
|
1086
|
+
findings: mergeFindings(state.findings, event.findings, "findings"),
|
|
1087
|
+
};
|
|
1088
|
+
},
|
|
1089
|
+
"rereview-approved": (state, event) => {
|
|
1090
|
+
requirePinned(state);
|
|
1091
|
+
return approveTask(state, event);
|
|
1092
|
+
},
|
|
1093
|
+
"task-park-ruling-recorded": (state, event) => {
|
|
1094
|
+
requirePinned(state);
|
|
1095
|
+
bounded(event.reason, "reason");
|
|
1096
|
+
return {
|
|
1097
|
+
phase: "REREVIEW_RUNNING",
|
|
1098
|
+
findings: applyResolutions(state.findings, event.findingResolutions, "findingResolutions"),
|
|
1099
|
+
};
|
|
1100
|
+
},
|
|
1101
|
+
"next-fix-dispatch-intended": (state, event) => {
|
|
1102
|
+
requirePinned(state);
|
|
1103
|
+
return openFixRound(state, event);
|
|
1104
|
+
},
|
|
1105
|
+
"rereview-blocked": (state, event) => {
|
|
1106
|
+
requirePinned(state);
|
|
1107
|
+
return { phase: "TASK_BLOCKED", blockedReason: bounded(event.reason, "reason") };
|
|
1108
|
+
},
|
|
1109
|
+
},
|
|
1110
|
+
TASK_COMPLETE: {
|
|
1111
|
+
"next-task-ready": (state) => {
|
|
1112
|
+
if (state.currentTask >= state.tasks.length) {
|
|
1113
|
+
fail("next-task-ready is not legal on the last task; final review is required");
|
|
1114
|
+
}
|
|
1115
|
+
const nextTask = state.currentTask + 1;
|
|
1116
|
+
return {
|
|
1117
|
+
phase: "WORKSPACE_READY",
|
|
1118
|
+
currentTask: nextTask,
|
|
1119
|
+
// Derived only from the immutable plan index captured at initialization.
|
|
1120
|
+
currentImplementerTier: state.tasks[nextTask - 1].implementerTier,
|
|
1121
|
+
contextAttempts: 0,
|
|
1122
|
+
fixRound: 0,
|
|
1123
|
+
statusPinned: false,
|
|
1124
|
+
reviewOutcome: null,
|
|
1125
|
+
dispatch: null,
|
|
1126
|
+
};
|
|
1127
|
+
},
|
|
1128
|
+
"final-review-dispatch-intended": (state, event) => {
|
|
1129
|
+
if (state.currentTask !== state.tasks.length) {
|
|
1130
|
+
fail("final review is only legal after the last task completes");
|
|
1131
|
+
}
|
|
1132
|
+
// reviewOutcome must be cleared in the returned changes, not just on the
|
|
1133
|
+
// input clone, or the task-level verdict stays pinned and satisfies the
|
|
1134
|
+
// completion guard without a final verdict of its own.
|
|
1135
|
+
return {
|
|
1136
|
+
...intendDispatch("FINAL_REVIEW_DISPATCH_INTENT")(state, event),
|
|
1137
|
+
reviewOutcome: null,
|
|
1138
|
+
};
|
|
1139
|
+
},
|
|
1140
|
+
},
|
|
1141
|
+
FINAL_REVIEW_DISPATCH_INTENT: intentHandlers("FINAL_REVIEW_DISPATCH_INTENT"),
|
|
1142
|
+
FINAL_REVIEW_RUNNING: {
|
|
1143
|
+
"final-review-finished": (state, event) => ({
|
|
1144
|
+
phase: "FINAL_REVIEW_RUNNING",
|
|
1145
|
+
dispatch: pinResult(state, event),
|
|
1146
|
+
reviewOutcome: { ...reviewOutcomeOf(event), pinned: true },
|
|
1147
|
+
findings: mergeFindings(state.findings, event.findings, "findings"),
|
|
1148
|
+
}),
|
|
1149
|
+
"final-complete": (state, event) => completeRun(state, event),
|
|
1150
|
+
"final-fix-dispatch-intended": (state, event) => openFinalFix(state, event),
|
|
1151
|
+
"final-blocked": (state, event) => {
|
|
1152
|
+
requirePinned(state);
|
|
1153
|
+
return { phase: "FINAL_BLOCKED", blockedReason: bounded(event.reason, "reason") };
|
|
1154
|
+
},
|
|
1155
|
+
},
|
|
1156
|
+
FINAL_FIX_DISPATCH_INTENT: intentHandlers("FINAL_FIX_DISPATCH_INTENT"),
|
|
1157
|
+
FINAL_FIX_RUNNING: {
|
|
1158
|
+
"final-rereview-dispatch-intended": (state, event) => {
|
|
1159
|
+
const resolved = applyResolutions(state.findings, event.findingResolutions, "findingResolutions");
|
|
1160
|
+
const next = intendDispatch("FINAL_REREVIEW_DISPATCH_INTENT")(state, event);
|
|
1161
|
+
return { ...next, findings: resolved, reviewOutcome: null };
|
|
1162
|
+
},
|
|
1163
|
+
"final-fixer-blocked": (state, event) => ({
|
|
1164
|
+
phase: "FINAL_BLOCKED",
|
|
1165
|
+
blockedReason: bounded(event.reason, "reason"),
|
|
1166
|
+
}),
|
|
1167
|
+
},
|
|
1168
|
+
FINAL_REREVIEW_DISPATCH_INTENT: intentHandlers("FINAL_REREVIEW_DISPATCH_INTENT"),
|
|
1169
|
+
FINAL_REREVIEW_RUNNING: {
|
|
1170
|
+
"final-rereview-finished": (state, event) => ({
|
|
1171
|
+
phase: "FINAL_REREVIEW_RUNNING",
|
|
1172
|
+
dispatch: pinResult(state, event),
|
|
1173
|
+
reviewOutcome: { ...reviewOutcomeOf(event), pinned: true },
|
|
1174
|
+
findings: mergeFindings(state.findings, event.findings, "findings"),
|
|
1175
|
+
}),
|
|
1176
|
+
"final-complete": (state, event) => completeRun(state, event),
|
|
1177
|
+
"final-park-ruling-recorded": (state, event) => {
|
|
1178
|
+
requirePinned(state);
|
|
1179
|
+
bounded(event.reason, "reason");
|
|
1180
|
+
return {
|
|
1181
|
+
phase: "FINAL_REREVIEW_RUNNING",
|
|
1182
|
+
findings: applyResolutions(state.findings, event.findingResolutions, "findingResolutions"),
|
|
1183
|
+
};
|
|
1184
|
+
},
|
|
1185
|
+
"final-blocked": (state, event) => {
|
|
1186
|
+
requirePinned(state);
|
|
1187
|
+
return { phase: "FINAL_BLOCKED", blockedReason: bounded(event.reason, "reason") };
|
|
1188
|
+
},
|
|
1189
|
+
},
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* Exported transition metadata.
|
|
1194
|
+
*
|
|
1195
|
+
* The reference document and the table-driven completeness tests both read this
|
|
1196
|
+
* rather than asserting on source text, so the doc cannot drift from the code
|
|
1197
|
+
* without a test noticing.
|
|
1198
|
+
*/
|
|
1199
|
+
export const TRANSITIONS = Object.freeze(
|
|
1200
|
+
Object.entries(HANDLERS).flatMap(([phase, events]) =>
|
|
1201
|
+
Object.keys(events).map((event) => Object.freeze({ phase, event })),
|
|
1202
|
+
),
|
|
1203
|
+
);
|
|
1204
|
+
|
|
1205
|
+
/** Every event name the reducer accepts anywhere. */
|
|
1206
|
+
export const EVENT_TYPES = Object.freeze([
|
|
1207
|
+
...new Set([...TRANSITIONS.map((entry) => entry.event), "recovery-ruling-recorded"]),
|
|
1208
|
+
]);
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* Apply one typed event to a state document.
|
|
1212
|
+
*
|
|
1213
|
+
* The input is never mutated: the result is built from a structural clone, so a
|
|
1214
|
+
* caller holding the previous state keeps a stable value.
|
|
1215
|
+
*/
|
|
1216
|
+
export function reduceState(state, event) {
|
|
1217
|
+
validateState(state);
|
|
1218
|
+
if (event === null || typeof event !== "object") fail("event must be an object");
|
|
1219
|
+
if (typeof event.type !== "string" || event.type.length === 0) {
|
|
1220
|
+
fail("event.type must be a non-empty string");
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
const at = instant(event.at, "at");
|
|
1224
|
+
if (at < state.updatedAt) {
|
|
1225
|
+
fail(`event timestamp ${at} precedes the last transition at ${state.updatedAt}`);
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
// Recovery is legal in any nonterminal phase and never changes phase. It
|
|
1229
|
+
// records that a repair or lock decision was ruled on, so the audit trail
|
|
1230
|
+
// shows the intervention instead of hiding it.
|
|
1231
|
+
if (event.type === "recovery-ruling-recorded") {
|
|
1232
|
+
if (TERMINAL_SET.has(state.phase)) {
|
|
1233
|
+
fail(`recovery-ruling-recorded is not legal in terminal phase ${state.phase}`);
|
|
1234
|
+
}
|
|
1235
|
+
bounded(event.reason, "reason");
|
|
1236
|
+
bounded(event.receipt, "receipt");
|
|
1237
|
+
const recovered = {
|
|
1238
|
+
...structuredClone(state),
|
|
1239
|
+
revision: state.revision + 1,
|
|
1240
|
+
recoveryRulings: state.recoveryRulings + 1,
|
|
1241
|
+
updatedAt: at,
|
|
1242
|
+
};
|
|
1243
|
+
recovered.lastTransition = renderTransition(recovered, event.type);
|
|
1244
|
+
return deepFreeze(validateState(recovered));
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
const byPhase = HANDLERS[state.phase];
|
|
1248
|
+
const handler = byPhase?.[event.type];
|
|
1249
|
+
if (!handler) {
|
|
1250
|
+
if (!EVENT_TYPES.includes(event.type)) fail(`unknown event type ${event.type}`);
|
|
1251
|
+
if (TERMINAL_SET.has(state.phase)) {
|
|
1252
|
+
fail(`phase ${state.phase} is terminal and accepts no continuation event`);
|
|
1253
|
+
}
|
|
1254
|
+
fail(`illegal transition: ${event.type} is not legal in phase ${state.phase}`);
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
const previous = structuredClone(state);
|
|
1258
|
+
const changes = handler(previous, event);
|
|
1259
|
+
|
|
1260
|
+
const next = { ...previous, ...changes, revision: previous.revision + 1, updatedAt: at };
|
|
1261
|
+
next.lastTransition = renderTransition(next, event.type);
|
|
1262
|
+
|
|
1263
|
+
return deepFreeze(validateState(next));
|
|
1264
|
+
}
|