@osovv/vv-opencode 1.3.8 → 1.4.1

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 (56) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +66 -14
  3. package/dist/commands/completion.js +10 -2
  4. package/dist/commands/completion.js.map +1 -1
  5. package/dist/commands/patch-provider.d.ts +148 -1
  6. package/dist/commands/patch-provider.js +151 -5
  7. package/dist/commands/patch-provider.js.map +1 -1
  8. package/dist/lib/orchestration.d.ts +2 -2
  9. package/dist/lib/orchestration.js +31 -2
  10. package/dist/lib/orchestration.js.map +1 -1
  11. package/dist/lib/spec-lint.d.ts +71 -1
  12. package/dist/lib/spec-lint.js +566 -8
  13. package/dist/lib/spec-lint.js.map +1 -1
  14. package/dist/lib/vvoc-config.d.ts +3 -3
  15. package/dist/lib/vvoc-preset-registry.d.ts +42 -30
  16. package/dist/lib/vvoc-preset-registry.js +41 -31
  17. package/dist/lib/vvoc-preset-registry.js.map +1 -1
  18. package/dist/plugins/hashline-edit/index.js.map +1 -1
  19. package/dist/plugins/workflow/checkpoint-io.d.ts +33 -0
  20. package/dist/plugins/workflow/checkpoint-io.js +224 -0
  21. package/dist/plugins/workflow/checkpoint-io.js.map +1 -0
  22. package/dist/plugins/workflow/checkpoints.d.ts +182 -0
  23. package/dist/plugins/workflow/checkpoints.js +1067 -0
  24. package/dist/plugins/workflow/checkpoints.js.map +1 -0
  25. package/dist/plugins/workflow/delegated.d.ts +182 -0
  26. package/dist/plugins/workflow/delegated.js +751 -0
  27. package/dist/plugins/workflow/delegated.js.map +1 -0
  28. package/dist/plugins/workflow/index.js +685 -23
  29. package/dist/plugins/workflow/index.js.map +1 -1
  30. package/dist/plugins/workflow/persistence.d.ts +32 -3
  31. package/dist/plugins/workflow/persistence.js +516 -70
  32. package/dist/plugins/workflow/persistence.js.map +1 -1
  33. package/dist/plugins/workflow/repair.js +3 -1
  34. package/dist/plugins/workflow/repair.js.map +1 -1
  35. package/dist/plugins/workflow/snapshots.d.ts +59 -0
  36. package/dist/plugins/workflow/snapshots.js +261 -0
  37. package/dist/plugins/workflow/snapshots.js.map +1 -0
  38. package/dist/plugins/workflow/state.d.ts +20 -2
  39. package/dist/plugins/workflow/state.js +127 -12
  40. package/dist/plugins/workflow/state.js.map +1 -1
  41. package/dist/plugins/workflow/tooling.d.ts +45 -0
  42. package/dist/plugins/workflow/tooling.js +344 -12
  43. package/dist/plugins/workflow/tooling.js.map +1 -1
  44. package/dist/plugins/workflow/transitions.js +2 -2
  45. package/dist/plugins/workflow/transitions.js.map +1 -1
  46. package/dist/tui/context/analyze.js +3 -1
  47. package/dist/tui/context/analyze.js.map +1 -1
  48. package/package.json +1 -1
  49. package/schemas/vvoc/v3.json +5 -3
  50. package/templates/agents/vv-code-reviewer.md +3 -1
  51. package/templates/agents/vv-implementer.md +5 -2
  52. package/templates/agents/vv-spec-reviewer.md +3 -0
  53. package/templates/skills/vv-execute/SKILL.md +62 -15
  54. package/templates/skills/vv-plan/SKILL.md +13 -3
  55. package/templates/skills/vv-plan/references/plan-template.xml +45 -0
  56. package/templates/skills/vv-review/SKILL.md +1 -0
@@ -1,46 +1,78 @@
1
1
  // FILE: src/plugins/workflow/persistence.ts
2
- // VERSION: 0.2.1
2
+ // VERSION: 0.3.0
3
3
  // START_MODULE_CONTRACT
4
4
  // PURPOSE: Hydrate and snapshot work-item workflow state from/to per-session JSON
5
5
  // files under $XDG_DATA_HOME/vvoc/workflow/<sessionId>/workflow-state.json.
6
- // SCOPE: Read/write WorkItemStoreData (nextId, records, keyIndexBySession) as
7
- // serializable JSON, including explicit work-item mode, review-round fields,
8
- // and optional bounded result excerpts.
9
- // Directory auto-creation on snapshot. Safe null return on missing, corrupt,
10
- // or incomplete persisted files.
11
- // DEPENDS: [node:fs, node:path, src/lib/vvoc-paths.ts, src/plugins/workflow/state.ts]
12
- // LINKS: M-WORKFLOW-PERSISTENCE, M-CONFIG-LAYERS, M-WORKFLOW-STATE, V-M-WORKFLOW-PERSISTENCE
6
+ // SCOPE: Read/write WorkItemStoreData (nextId, records, keyIndexBySession,
7
+ // planRuns) as serializable JSON. Version 2 snapshots additionally persist
8
+ // delegated attempts, decisions, acceptances, rework history, and registered
9
+ // plan runs with checkpoint generations through an atomic temporary-file
10
+ // replacement, including failed attempts carrying bounded host failure
11
+ // evidence. Version 1 files hydrate conservatively as legacy records with
12
+ // an empty plan-run registry and never synthesize acceptance or approval.
13
+ // Strict validation rejects malformed or contradictory new state instead of
14
+ // silently restarting a run. A checked loader distinguishes missing, valid,
15
+ // and invalid state and surfaces I/O failures.
16
+ // DEPENDS: [node:fs, node:fs/promises, node:path, src/lib/vvoc-paths.ts,
17
+ // src/plugins/workflow/checkpoints.ts (types), src/plugins/workflow/delegated.ts,
18
+ // src/plugins/workflow/state.ts]
19
+ // LINKS: M-WORKFLOW-PERSISTENCE, M-CONFIG-LAYERS, M-WORKFLOW-STATE, M-WORKFLOW-DELEGATED, M-WORKFLOW-CHECKPOINTS, V-M-WORKFLOW-PERSISTENCE
13
20
  // ROLE: RUNTIME
14
21
  // MAP_MODE: EXPORTS
15
22
  // END_MODULE_CONTRACT
16
23
  //
17
24
  // START_MODULE_MAP
25
+ // PERSISTED_WORKFLOW_STATE_VERSION - Current persisted snapshot version.
18
26
  // PersistedWorkflowState - JSON-serializable shape of a per-session workflow state.
27
+ // SerializedDelegatedPlanRun - JSON form of one registered plan run.
28
+ // HydratedWorkflowStateResult - Missing/valid/invalid triage returned by the checked loader.
29
+ // SnapshotWorkflowStateResult - Write outcome returned by the checked snapshot path.
19
30
  // getWorkflowSessionDir - Resolve per-session directory path.
20
- // hydrateWorkflowState - Read and parse per-session workflow-state.json.
21
- // snapshotWorkflowState - Write per-session workflow-state.json.
31
+ // hydrateWorkflowState - Legacy nullable hydrate kept for compatibility.
32
+ // hydrateWorkflowStateChecked - Checked loader distinguishing missing, valid, and invalid state.
33
+ // snapshotWorkflowState - Legacy fire-and-forget snapshot kept for compatibility.
34
+ // snapshotWorkflowStateChecked - Atomic temporary-file replacement snapshot surfacing failures.
22
35
  // deleteWorkflowSessionDir - Remove per-session workflow directory on session delete.
23
36
  // END_MODULE_MAP
24
37
  //
25
38
  // START_CHANGE_SUMMARY
26
- // LAST_CHANGE: [v0.2.2 - Validated optional bounded result excerpts during workflow state hydrate and snapshot round-trips.]
39
+ // LAST_CHANGE: [direct fix inFlightAttempt after failed worker launch - Version 2 validation accepts failed delegated attempts with bounded failure evidence and rejects contradictory failed/completed/in-flight attempt payloads.]
27
40
  // END_CHANGE_SUMMARY
28
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
41
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
29
42
  import { rm } from "node:fs/promises";
30
43
  import { join } from "node:path";
31
44
  import { getGlobalVvocDataDir } from "../../lib/vvoc-paths.js";
45
+ import { normalizeDeclaredScopePath } from "../../lib/spec-lint.js";
46
+ import { DELEGATED_BASE_ATTEMPTS, DELEGATED_EVIDENCE_MAX_CHARS, DELEGATED_EVIDENCE_MAX_REFS, DELEGATED_RATIONALE_MAX_CHARS, } from "./delegated.js";
47
+ // START_BLOCK_SERIALIZATION_TYPES
48
+ export const PERSISTED_WORKFLOW_STATE_VERSION = 2;
32
49
  // END_BLOCK_SERIALIZATION_TYPES
33
50
  const VALID_STATES = new Set([
34
51
  "open",
35
52
  "awaiting_implementer",
36
53
  "awaiting_reviews",
54
+ "awaiting_acceptance",
37
55
  "needs_context",
38
56
  "blocked",
39
57
  "ready_to_close",
40
58
  "closed",
41
59
  ]);
60
+ const DELEGATED_RESULT_STATUSES = new Set([
61
+ "DONE",
62
+ "DONE_WITH_CONCERNS",
63
+ "NEEDS_CONTEXT",
64
+ "BLOCKED",
65
+ ]);
66
+ const CHECKPOINT_OUTCOMES = new Set(["passed", "failed", "stale"]);
67
+ const LAST_OUTCOMES = new Set([
68
+ "passed",
69
+ "failed",
70
+ "stale",
71
+ "stopped",
72
+ "incomplete",
73
+ ]);
42
74
  function isWorkItemMode(value) {
43
- return value === "implementation" || value === "review_only";
75
+ return value === "implementation" || value === "review_only" || value === "delegated";
44
76
  }
45
77
  function isReviewerRole(value) {
46
78
  return value === "spec" || value === "code";
@@ -90,17 +122,204 @@ function isReviewRound(value) {
90
122
  (round.status === "active" || round.status === "completed") &&
91
123
  typeof round.createdAt === "string");
92
124
  }
93
- function isWorkItemRecord(value, sessionId) {
125
+ function isValidScopeList(value) {
126
+ if (!Array.isArray(value) || value.length === 0)
127
+ return false;
128
+ const seen = new Set();
129
+ for (const entry of value) {
130
+ if (typeof entry !== "string")
131
+ return false;
132
+ const normalized = normalizeDeclaredScopePath(entry);
133
+ if (!normalized.ok || normalized.path !== entry)
134
+ return false;
135
+ if (seen.has(entry))
136
+ return false;
137
+ seen.add(entry);
138
+ }
139
+ return true;
140
+ }
141
+ function validateDelegatedText(value, maxChars, label, errors) {
142
+ if (typeof value !== "string" || value.trim() === "") {
143
+ errors.push(`${label} must be a non-empty string`);
144
+ return false;
145
+ }
146
+ if (value.trim().length > maxChars) {
147
+ errors.push(`${label} exceeds ${maxChars} characters`);
148
+ return false;
149
+ }
150
+ return true;
151
+ }
152
+ function validateBoundedEvidence(value, label, errors) {
153
+ if (!Array.isArray(value) || value.length === 0 || value.length > DELEGATED_EVIDENCE_MAX_REFS) {
154
+ errors.push(`${label} must contain 1 to ${DELEGATED_EVIDENCE_MAX_REFS} references`);
155
+ return false;
156
+ }
157
+ for (const reference of value) {
158
+ if (typeof reference !== "string" || reference.trim() === "") {
159
+ errors.push(`${label} references must be non-empty strings`);
160
+ return false;
161
+ }
162
+ if (reference.trim().length > DELEGATED_EVIDENCE_MAX_CHARS) {
163
+ errors.push(`${label} references exceed ${DELEGATED_EVIDENCE_MAX_CHARS} characters`);
164
+ return false;
165
+ }
166
+ }
167
+ return true;
168
+ }
169
+ /** Validate the delegated-mode record extension; returns every contradiction found. */
170
+ function validateDelegatedState(record, sessionId, errors) {
171
+ const delegated = record.delegated;
172
+ if (!delegated || typeof delegated !== "object") {
173
+ errors.push(`${record.workItemId}: delegated records require a delegated state object`);
174
+ return;
175
+ }
176
+ if (record.requiredReviewers.length !== 0) {
177
+ errors.push(`${record.workItemId}: delegated records must persist an empty requiredReviewers array`);
178
+ }
179
+ if ((delegated.planRunId === undefined) !== (delegated.planTaskId === undefined)) {
180
+ errors.push(`${record.workItemId}: planRunId and planTaskId must be persisted together or absent`);
181
+ }
182
+ if (!isValidScopeList(delegated.writeScope)) {
183
+ errors.push(`${record.workItemId}: delegated writeScope must be a non-empty canonical path list`);
184
+ }
185
+ const attempts = delegated.attempts;
186
+ if (!Array.isArray(attempts)) {
187
+ errors.push(`${record.workItemId}: delegated attempts must be an array`);
188
+ return;
189
+ }
190
+ let inFlight = 0;
191
+ attempts.forEach((attempt, index) => {
192
+ if (attempt.attempt !== index + 1) {
193
+ errors.push(`${record.workItemId}: delegated attempts must sequence 1..n contiguously`);
194
+ }
195
+ if (typeof attempt.callId !== "string" || attempt.callId === "") {
196
+ errors.push(`${record.workItemId}: attempt ${attempt.attempt} requires a bound callId`);
197
+ }
198
+ if (attempt.status === "in_flight") {
199
+ inFlight += 1;
200
+ if (attempt.resultStatus !== undefined ||
201
+ attempt.completedAt !== undefined ||
202
+ attempt.failureExcerpt !== undefined) {
203
+ errors.push(`${record.workItemId}: in-flight attempt ${attempt.attempt} must not carry a result`);
204
+ }
205
+ }
206
+ else if (attempt.status === "completed") {
207
+ if (!DELEGATED_RESULT_STATUSES.has(attempt.resultStatus)) {
208
+ errors.push(`${record.workItemId}: completed attempt ${attempt.attempt} lacks a valid resultStatus`);
209
+ }
210
+ if (typeof attempt.completedAt !== "string") {
211
+ errors.push(`${record.workItemId}: completed attempt ${attempt.attempt} requires completedAt`);
212
+ }
213
+ if (attempt.failureExcerpt !== undefined) {
214
+ errors.push(`${record.workItemId}: completed attempt ${attempt.attempt} must not carry a failure excerpt`);
215
+ }
216
+ }
217
+ else if (attempt.status === "failed") {
218
+ if (typeof attempt.completedAt !== "string") {
219
+ errors.push(`${record.workItemId}: failed attempt ${attempt.attempt} requires completedAt`);
220
+ }
221
+ if (attempt.resultStatus !== undefined) {
222
+ errors.push(`${record.workItemId}: failed attempt ${attempt.attempt} must not carry a resultStatus`);
223
+ }
224
+ if (attempt.failureExcerpt === undefined ||
225
+ !isWorkflowResultExcerpt(attempt.failureExcerpt)) {
226
+ errors.push(`${record.workItemId}: failed attempt ${attempt.attempt} requires a bounded failure excerpt`);
227
+ }
228
+ }
229
+ else {
230
+ errors.push(`${record.workItemId}: attempt ${attempt.attempt} has invalid status ${attempt.status}`);
231
+ }
232
+ if (attempt.resultExcerpt !== undefined &&
233
+ (attempt.status !== "completed" || !isWorkflowResultExcerpt(attempt.resultExcerpt))) {
234
+ errors.push(`${record.workItemId}: attempt ${attempt.attempt} carries a malformed excerpt`);
235
+ }
236
+ });
237
+ if (inFlight > 1) {
238
+ errors.push(`${record.workItemId}: at most one delegated attempt may be in flight`);
239
+ }
240
+ if (delegated.reworkHistory.some((rework) => !rework.reworkId || !rework.authorizedByCheckpoint)) {
241
+ errors.push(`${record.workItemId}: rework history entries require ids and checkpoint authorization`);
242
+ }
243
+ if (attempts.length > DELEGATED_BASE_ATTEMPTS + delegated.reworkHistory.length) {
244
+ errors.push(`${record.workItemId}: attempts exceed the base budget plus authorized rework grants`);
245
+ }
246
+ const decisions = delegated.decisions;
247
+ if (!Array.isArray(decisions)) {
248
+ errors.push(`${record.workItemId}: delegated decisions must be an array`);
249
+ return;
250
+ }
251
+ const decidedAttempts = new Set();
252
+ for (const decision of decisions) {
253
+ if (decision.decision !== "accept" && decision.decision !== "request_changes") {
254
+ errors.push(`${record.workItemId}: decision ${decision.decisionId} has an invalid decision value`);
255
+ continue;
256
+ }
257
+ if (decision.decisionId !== `dec-${record.workItemId}-a${decision.attempt}`) {
258
+ errors.push(`${record.workItemId}: decision id ${decision.decisionId} does not match its attempt`);
259
+ }
260
+ if (decidedAttempts.has(decision.attempt)) {
261
+ errors.push(`${record.workItemId}: attempt ${decision.attempt} has more than one decision`);
262
+ }
263
+ decidedAttempts.add(decision.attempt);
264
+ if (!attempts.some((attempt) => attempt.attempt === decision.attempt && attempt.status === "completed")) {
265
+ errors.push(`${record.workItemId}: decision ${decision.decisionId} targets a non-completed attempt`);
266
+ }
267
+ validateDelegatedText(decision.rationale, DELEGATED_RATIONALE_MAX_CHARS, `${record.workItemId}: decision ${decision.decisionId} rationale`, errors);
268
+ validateBoundedEvidence(decision.evidence, `${record.workItemId}: decision ${decision.decisionId} evidence`, errors);
269
+ if (decision.concernsDisposition !== undefined) {
270
+ validateDelegatedText(decision.concernsDisposition, DELEGATED_RATIONALE_MAX_CHARS, `${record.workItemId}: decision ${decision.decisionId} concernsDisposition`, errors);
271
+ }
272
+ }
273
+ const acceptances = delegated.acceptances;
274
+ if (!Array.isArray(acceptances)) {
275
+ errors.push(`${record.workItemId}: delegated acceptances must be an array`);
276
+ return;
277
+ }
278
+ const reworkIds = new Set(delegated.reworkHistory.map((rework) => rework.reworkId));
279
+ const currentAcceptance = [...acceptances]
280
+ .reverse()
281
+ .find((acceptance) => !acceptance.revokedAt);
282
+ for (const acceptance of acceptances) {
283
+ const matchingDecision = decisions.find((decision) => decision.decisionId === acceptance.decisionId && decision.decision === "accept");
284
+ if (!matchingDecision) {
285
+ errors.push(`${record.workItemId}: acceptance ${acceptance.decisionId} has no matching accept decision`);
286
+ }
287
+ if (acceptance.revokedAt !== undefined &&
288
+ (acceptance.revokedByReworkId === undefined || !reworkIds.has(acceptance.revokedByReworkId))) {
289
+ errors.push(`${record.workItemId}: revoked acceptance ${acceptance.decisionId} references an unknown rework`);
290
+ }
291
+ if (acceptance.concernsDisposition !== undefined) {
292
+ validateDelegatedText(acceptance.concernsDisposition, DELEGATED_RATIONALE_MAX_CHARS, `${record.workItemId}: acceptance ${acceptance.decisionId} concernsDisposition`, errors);
293
+ }
294
+ }
295
+ const latestCompleted = [...attempts]
296
+ .filter((attempt) => attempt.status === "completed")
297
+ .sort((left, right) => right.attempt - left.attempt)[0];
298
+ if (record.state === "awaiting_acceptance") {
299
+ if (!latestCompleted) {
300
+ errors.push(`${record.workItemId}: awaiting_acceptance without a completed attempt`);
301
+ }
302
+ else if (decidedAttempts.has(latestCompleted.attempt)) {
303
+ errors.push(`${record.workItemId}: awaiting_acceptance but the latest attempt already has a decision`);
304
+ }
305
+ }
306
+ if (record.state === "ready_to_close" && !currentAcceptance) {
307
+ errors.push(`${record.workItemId}: ready_to_close without a currently applicable acceptance`);
308
+ }
309
+ if (sessionId !== record.sessionId) {
310
+ errors.push(`${record.workItemId}: record session mismatch`);
311
+ }
312
+ }
313
+ function isWorkItemRecord(value, sessionId, errors) {
94
314
  if (!value || typeof value !== "object")
95
315
  return false;
96
316
  const record = value;
97
- return (record.sessionId === sessionId &&
317
+ const baseValid = record.sessionId === sessionId &&
98
318
  typeof record.workItemId === "string" &&
99
319
  typeof record.key === "string" &&
100
320
  typeof record.title === "string" &&
101
321
  isWorkItemMode(record.mode) &&
102
322
  Array.isArray(record.requiredReviewers) &&
103
- record.requiredReviewers.length > 0 &&
104
323
  record.requiredReviewers.every(isReviewerRole) &&
105
324
  VALID_STATES.has(record.state) &&
106
325
  Number.isInteger(record.completedReviewRoundCount) &&
@@ -109,7 +328,158 @@ function isWorkItemRecord(value, sessionId) {
109
328
  (record.resultExcerpt === undefined || isWorkflowResultExcerpt(record.resultExcerpt)) &&
110
329
  typeof record.createdAt === "string" &&
111
330
  typeof record.updatedAt === "string" &&
112
- (record.currentRound === undefined || isReviewRound(record.currentRound)));
331
+ (record.currentRound === undefined || isReviewRound(record.currentRound));
332
+ if (!baseValid)
333
+ return false;
334
+ if (record.mode === "delegated") {
335
+ const recordErrors = [];
336
+ validateDelegatedState(record, sessionId, recordErrors);
337
+ errors.push(...recordErrors);
338
+ return recordErrors.length === 0;
339
+ }
340
+ if (record.requiredReviewers.length === 0) {
341
+ errors.push(`${record.workItemId}: ${record.mode} records require non-empty requiredReviewers`);
342
+ return false;
343
+ }
344
+ if (record.delegated !== undefined) {
345
+ errors.push(`${record.workItemId}: delegated state on a ${record.mode} record is contradictory`);
346
+ return false;
347
+ }
348
+ return true;
349
+ }
350
+ function validatePlanRun(run, recordsById, sessionId, errors) {
351
+ if (!run || typeof run !== "object") {
352
+ errors.push("plan run entries must be objects");
353
+ return false;
354
+ }
355
+ const candidate = run;
356
+ if (typeof candidate.runId !== "string" || candidate.runId === "") {
357
+ errors.push("plan run requires a runId");
358
+ return false;
359
+ }
360
+ if (candidate.sessionId !== sessionId) {
361
+ errors.push(`plan run ${candidate.runId} belongs to another session`);
362
+ return false;
363
+ }
364
+ if (typeof candidate.planPath !== "string" || typeof candidate.specPath !== "string") {
365
+ errors.push(`plan run ${candidate.runId} requires canonical plan and spec paths`);
366
+ return false;
367
+ }
368
+ if (!Array.isArray(candidate.tasks) || !Array.isArray(candidate.checkpoints)) {
369
+ errors.push(`plan run ${candidate.runId} requires task and checkpoint arrays`);
370
+ return false;
371
+ }
372
+ if (!candidate.definition || candidate.definition.mode !== "delegated") {
373
+ errors.push(`plan run ${candidate.runId} requires its delegated definition`);
374
+ return false;
375
+ }
376
+ if (candidate.status !== "active" && candidate.status !== "sealed") {
377
+ errors.push(`plan run ${candidate.runId} has invalid status ${candidate.status}`);
378
+ return false;
379
+ }
380
+ if (candidate.status === "sealed" && typeof candidate.sealedAt !== "string") {
381
+ errors.push(`plan run ${candidate.runId} is sealed without sealedAt`);
382
+ return false;
383
+ }
384
+ const taskIds = new Set();
385
+ for (const task of candidate.tasks) {
386
+ if (typeof task.taskId !== "string" || typeof task.workItemId !== "string") {
387
+ errors.push(`plan run ${candidate.runId} has a malformed task binding`);
388
+ continue;
389
+ }
390
+ if (taskIds.has(task.taskId)) {
391
+ errors.push(`plan run ${candidate.runId} binds task ${task.taskId} twice`);
392
+ }
393
+ taskIds.add(task.taskId);
394
+ const record = recordsById.get(task.workItemId);
395
+ if (!record ||
396
+ record.mode !== "delegated" ||
397
+ record.delegated?.planRunId !== candidate.runId ||
398
+ record.delegated?.planTaskId !== task.taskId) {
399
+ errors.push(`plan run ${candidate.runId} task ${task.taskId} is not bound to work item ${task.workItemId}`);
400
+ }
401
+ }
402
+ const checkpointIds = new Set();
403
+ for (const checkpoint of candidate.checkpoints) {
404
+ if (typeof checkpoint.checkpointId !== "string" ||
405
+ !checkpointIds.add(checkpoint.checkpointId)) {
406
+ errors.push(`plan run ${candidate.runId} has duplicate or malformed checkpoint ids`);
407
+ continue;
408
+ }
409
+ if (checkpoint.kind !== "milestone" && checkpoint.kind !== "final") {
410
+ errors.push(`checkpoint ${checkpoint.checkpointId} has invalid kind ${checkpoint.kind}`);
411
+ }
412
+ if (!Array.isArray(checkpoint.reviewers) ||
413
+ checkpoint.reviewers.length === 0 ||
414
+ !checkpoint.reviewers.every(isReviewerRole)) {
415
+ errors.push(`checkpoint ${checkpoint.checkpointId} requires a non-empty spec/code reviewer set`);
416
+ }
417
+ if (!["pending", "in_review", "passed", "failed"].includes(checkpoint.status)) {
418
+ errors.push(`checkpoint ${checkpoint.checkpointId} has invalid status ${checkpoint.status}`);
419
+ }
420
+ if (checkpoint.lastOutcome !== undefined && !LAST_OUTCOMES.has(checkpoint.lastOutcome)) {
421
+ errors.push(`checkpoint ${checkpoint.checkpointId} has invalid lastOutcome ${checkpoint.lastOutcome}`);
422
+ }
423
+ const history = checkpoint.history;
424
+ if (!Array.isArray(history)) {
425
+ errors.push(`checkpoint ${checkpoint.checkpointId} requires a history array`);
426
+ continue;
427
+ }
428
+ for (const entry of history) {
429
+ if (!CHECKPOINT_OUTCOMES.has(entry.outcome)) {
430
+ errors.push(`checkpoint ${checkpoint.checkpointId} history has invalid outcome ${entry.outcome}`);
431
+ }
432
+ if (typeof entry.generation !== "number" || typeof entry.fingerprint !== "string") {
433
+ errors.push(`checkpoint ${checkpoint.checkpointId} history entry is malformed`);
434
+ }
435
+ }
436
+ const review = checkpoint.currentReview;
437
+ if (checkpoint.status === "in_review") {
438
+ if (!review ||
439
+ review.generation !== checkpoint.attempts ||
440
+ review.generation !== history.length + 1) {
441
+ errors.push(`checkpoint ${checkpoint.checkpointId} in_review without a consistent current generation`);
442
+ }
443
+ else {
444
+ for (const reviewer of Object.keys(review.results ?? {})) {
445
+ if (!checkpoint.reviewers.includes(reviewer)) {
446
+ errors.push(`checkpoint ${checkpoint.checkpointId} recorded an undeclared reviewer ${reviewer}`);
447
+ }
448
+ }
449
+ if (Object.keys(review.reviewerCallIds ?? {}).some((reviewer) => !checkpoint.reviewers.includes(reviewer))) {
450
+ errors.push(`checkpoint ${checkpoint.checkpointId} bound an undeclared reviewer call`);
451
+ }
452
+ }
453
+ }
454
+ else if (review !== undefined) {
455
+ errors.push(`checkpoint ${checkpoint.checkpointId} is ${checkpoint.status} but still carries a current review`);
456
+ }
457
+ if (checkpoint.status === "passed" && history[history.length - 1]?.outcome !== "passed") {
458
+ errors.push(`checkpoint ${checkpoint.checkpointId} passed without a passing history entry`);
459
+ }
460
+ if (checkpoint.status === "failed" &&
461
+ !["failed", "stale"].includes(history[history.length - 1]?.outcome ?? "")) {
462
+ errors.push(`checkpoint ${checkpoint.checkpointId} failed without a failing history entry`);
463
+ }
464
+ if (checkpoint.attempts !== history.length + (checkpoint.status === "in_review" ? 1 : 0)) {
465
+ errors.push(`checkpoint ${checkpoint.checkpointId} attempts do not match its generations`);
466
+ }
467
+ }
468
+ const finals = candidate.checkpoints.filter((checkpoint) => checkpoint.kind === "final");
469
+ if (finals.length !== 1) {
470
+ errors.push(`plan run ${candidate.runId} must persist exactly one final checkpoint`);
471
+ }
472
+ else if (candidate.status === "sealed" && finals[0].status !== "passed") {
473
+ errors.push(`plan run ${candidate.runId} is sealed without a passed final checkpoint`);
474
+ }
475
+ return errors.length === 0;
476
+ }
477
+ function serializePlanRun(run) {
478
+ return {
479
+ ...run,
480
+ tasks: [...run.tasks.values()],
481
+ checkpoints: [...run.checkpoints.values()],
482
+ };
113
483
  }
114
484
  /**
115
485
  * Resolve the per-session workflow data directory.
@@ -124,76 +494,130 @@ export function getWorkflowSessionDir(sessionId) {
124
494
  function getWorkflowStatePath(sessionId) {
125
495
  return join(getWorkflowSessionDir(sessionId), "workflow-state.json");
126
496
  }
127
- // START_CONTRACT: hydrateWorkflowState
128
- // PURPOSE: Read and parse the per-session workflow-state.json, returning a
129
- // WorkItemStoreData suitable for restoring an in-memory store. Returns null
130
- // when the file is missing, corrupt, or incomplete. Never throws.
497
+ // START_CONTRACT: hydrateWorkflowStateChecked
498
+ // PURPOSE: Read and validate the per-session workflow state, distinguishing missing, valid, and invalid files and surfacing I/O failures.
131
499
  // INPUTS: { sessionId: string - OpenCode session identifier }
132
- // OUTPUTS: { WorkItemStoreData | null - restored store data or null }
133
- // SIDE_EFFECTS: [none]
134
- // LINKS: [M-WORKFLOW-PERSISTENCE]
135
- // END_CONTRACT: hydrateWorkflowState
136
- export function hydrateWorkflowState(sessionId) {
500
+ // OUTPUTS: { HydratedWorkflowStateResult - missing, validated store data, or collected validation errors }
501
+ // SIDE_EFFECTS: [Reads workflow-state.json; never writes and never throws]
502
+ // LINKS: [M-WORKFLOW-PERSISTENCE, M-WORKFLOW-STATE]
503
+ // END_CONTRACT: hydrateWorkflowStateChecked
504
+ export function hydrateWorkflowStateChecked(sessionId) {
505
+ const filePath = getWorkflowStatePath(sessionId);
506
+ let raw;
137
507
  try {
138
- const filePath = getWorkflowStatePath(sessionId);
139
508
  if (!existsSync(filePath)) {
140
- return null;
509
+ return { status: "missing" };
141
510
  }
142
- const raw = readFileSync(filePath, "utf-8");
143
- const parsed = JSON.parse(raw);
144
- // Validate minimal expected shape
145
- if (parsed.version !== 1 || !Array.isArray(parsed.records)) {
146
- return null;
147
- }
148
- if (!parsed.records.every((record) => isWorkItemRecord(record, sessionId))) {
149
- return null;
511
+ raw = readFileSync(filePath, "utf-8");
512
+ }
513
+ catch (error) {
514
+ return {
515
+ status: "invalid",
516
+ errors: [`workflow state could not be read: ${error.message}`],
517
+ };
518
+ }
519
+ let parsed;
520
+ try {
521
+ parsed = JSON.parse(raw);
522
+ }
523
+ catch (error) {
524
+ return {
525
+ status: "invalid",
526
+ errors: [`workflow state is not valid JSON: ${error.message}`],
527
+ };
528
+ }
529
+ if (parsed.version !== 1 && parsed.version !== PERSISTED_WORKFLOW_STATE_VERSION) {
530
+ return {
531
+ status: "invalid",
532
+ errors: [`unsupported persisted version ${String(parsed.version)}`],
533
+ };
534
+ }
535
+ if (!Array.isArray(parsed.records)) {
536
+ return { status: "invalid", errors: ["persisted records must be an array"] };
537
+ }
538
+ const errors = [];
539
+ const records = new Map();
540
+ for (const record of parsed.records) {
541
+ const recordErrors = [];
542
+ if (!isWorkItemRecord(record, sessionId, recordErrors)) {
543
+ errors.push(...(recordErrors.length > 0
544
+ ? recordErrors
545
+ : [
546
+ `record ${String(record?.workItemId ?? "(unknown)")} failed base validation`,
547
+ ]));
548
+ continue;
150
549
  }
151
- // Reconstruct Maps from serialized arrays
152
- const records = new Map();
153
- for (const record of parsed.records) {
154
- const lookupKey = `${sessionId}::${record.workItemId}`;
155
- records.set(lookupKey, record);
550
+ records.set(`${sessionId}::${record.workItemId}`, record);
551
+ }
552
+ const keyIndex = new Map();
553
+ for (const [key, workItemId] of Object.entries(parsed.keyIndex ?? {})) {
554
+ keyIndex.set(key, workItemId);
555
+ }
556
+ const keyIndexBySession = new Map();
557
+ keyIndexBySession.set(sessionId, keyIndex);
558
+ const planRuns = new Map();
559
+ if (parsed.version === PERSISTED_WORKFLOW_STATE_VERSION) {
560
+ if (!Array.isArray(parsed.planRuns)) {
561
+ return { status: "invalid", errors: ["version 2 state requires a planRuns array"] };
156
562
  }
157
- const keyIndex = new Map();
158
- for (const [key, workItemId] of Object.entries(parsed.keyIndex ?? {})) {
159
- keyIndex.set(key, workItemId);
563
+ const recordsByBareId = new Map([...records.values()].map((record) => [record.workItemId, record]));
564
+ for (const serialized of parsed.planRuns) {
565
+ const runErrors = [];
566
+ if (!validatePlanRun(serialized, recordsByBareId, sessionId, runErrors)) {
567
+ errors.push(...runErrors);
568
+ continue;
569
+ }
570
+ const run = {
571
+ ...serialized,
572
+ tasks: new Map(serialized.tasks.map((task) => [task.taskId, task])),
573
+ checkpoints: new Map(serialized.checkpoints.map((checkpoint) => [checkpoint.checkpointId, checkpoint])),
574
+ };
575
+ planRuns.set(run.runId, run);
160
576
  }
161
- const keyIndexBySession = new Map();
162
- keyIndexBySession.set(sessionId, keyIndex);
163
- return {
164
- nextId: parsed.nextId,
165
- records,
166
- keyIndexBySession,
167
- };
168
577
  }
169
- catch {
170
- // Corrupt file, missing permissions, etc. — start fresh
171
- return null;
578
+ if (errors.length > 0) {
579
+ return { status: "invalid", errors };
172
580
  }
581
+ return {
582
+ status: "valid",
583
+ data: {
584
+ nextId: typeof parsed.nextId === "number" ? parsed.nextId : records.size + 1,
585
+ records,
586
+ keyIndexBySession,
587
+ planRuns,
588
+ },
589
+ };
173
590
  }
174
- // START_CONTRACT: snapshotWorkflowState
175
- // PURPOSE: Serialize WorkItemStoreData to a per-session JSON file. Creates the
176
- // session directory if it does not exist. Logs warnings on write failure but
177
- // never throws so in-memory operations continue.
178
- // INPUTS: { sessionId: string, data: WorkItemStoreData }
179
- // OUTPUTS: { void }
180
- // SIDE_EFFECTS: [Writes JSON file to $XDG_DATA_HOME/vvoc/workflow/<sessionId>/workflow-state.json]
591
+ // START_CONTRACT: hydrateWorkflowState
592
+ // PURPOSE: Legacy nullable hydrate kept for compatibility with existing callers.
593
+ // INPUTS: { sessionId: string - OpenCode session identifier }
594
+ // OUTPUTS: { WorkItemStoreData | null - restored store data or null when missing/invalid }
595
+ // SIDE_EFFECTS: [none]
596
+ // LINKS: [M-WORKFLOW-PERSISTENCE, hydrateWorkflowStateChecked]
597
+ // END_CONTRACT: hydrateWorkflowState
598
+ export function hydrateWorkflowState(sessionId) {
599
+ const result = hydrateWorkflowStateChecked(sessionId);
600
+ return result.status === "valid" ? result.data : null;
601
+ }
602
+ // START_CONTRACT: snapshotWorkflowStateChecked
603
+ // PURPOSE: Persist WorkItemStoreData through an atomic temporary-file replacement, surfacing failures.
604
+ // INPUTS: { sessionId: string - session scope, data: WorkItemStoreData - store snapshot }
605
+ // OUTPUTS: { SnapshotWorkflowStateResult - write outcome with the failure reason }
606
+ // SIDE_EFFECTS: [Writes workflow-state.json via a temporary file and rename]
181
607
  // LINKS: [M-WORKFLOW-PERSISTENCE]
182
- // END_CONTRACT: snapshotWorkflowState
183
- export function snapshotWorkflowState(sessionId, data) {
608
+ // END_CONTRACT: snapshotWorkflowStateChecked
609
+ export function snapshotWorkflowStateChecked(sessionId, data) {
184
610
  try {
185
611
  const dir = getWorkflowSessionDir(sessionId);
186
612
  if (!existsSync(dir)) {
187
613
  mkdirSync(dir, { recursive: true });
188
614
  }
189
- // Convert records Map to array
190
615
  const records = [];
191
616
  for (const record of data.records.values()) {
192
617
  if (record.sessionId === sessionId) {
193
618
  records.push(record);
194
619
  }
195
620
  }
196
- // Convert keyIndex for this session to plain object
197
621
  const sessionKeyIndex = data.keyIndexBySession.get(sessionId);
198
622
  const keyIndex = {};
199
623
  if (sessionKeyIndex) {
@@ -201,21 +625,43 @@ export function snapshotWorkflowState(sessionId, data) {
201
625
  keyIndex[key] = workItemId;
202
626
  }
203
627
  }
628
+ const planRuns = [];
629
+ for (const run of data.planRuns.values()) {
630
+ if (run.sessionId === sessionId) {
631
+ planRuns.push(serializePlanRun(run));
632
+ }
633
+ }
204
634
  const persisted = {
205
- version: 1,
635
+ // Version 2 carries delegated attempts, decisions, and plan runs;
636
+ // PERSISTED_WORKFLOW_STATE_VERSION mirrors this literal for hydration.
637
+ version: 2,
206
638
  updatedAt: new Date().toISOString(),
207
639
  sessionId,
208
640
  nextId: data.nextId,
209
641
  records,
210
642
  keyIndex,
643
+ planRuns,
211
644
  };
212
- writeFileSync(getWorkflowStatePath(sessionId), JSON.stringify(persisted, null, 2), "utf-8");
645
+ const targetPath = getWorkflowStatePath(sessionId);
646
+ const temporaryPath = `${targetPath}.tmp-${process.pid}-${Date.now()}`;
647
+ writeFileSync(temporaryPath, JSON.stringify(persisted, null, 2), "utf-8");
648
+ renameSync(temporaryPath, targetPath);
649
+ return { ok: true };
213
650
  }
214
- catch {
215
- // Write failure — warn but do not block in-memory operations
216
- // The caller (index.ts) will log this via client.app.log
651
+ catch (error) {
652
+ return { ok: false, error: error.message };
217
653
  }
218
654
  }
655
+ // START_CONTRACT: snapshotWorkflowState
656
+ // PURPOSE: Legacy fire-and-forget snapshot kept for compatibility with existing callers.
657
+ // INPUTS: { sessionId: string, data: WorkItemStoreData }
658
+ // OUTPUTS: { void }
659
+ // SIDE_EFFECTS: [Delegates to snapshotWorkflowStateChecked and swallows failures]
660
+ // LINKS: [M-WORKFLOW-PERSISTENCE, snapshotWorkflowStateChecked]
661
+ // END_CONTRACT: snapshotWorkflowState
662
+ export function snapshotWorkflowState(sessionId, data) {
663
+ void snapshotWorkflowStateChecked(sessionId, data);
664
+ }
219
665
  // START_CONTRACT: deleteWorkflowSessionDir
220
666
  // PURPOSE: Remove the per-session workflow data directory. No-op if it does
221
667
  // not exist. Never throws.