@osovv/vv-opencode 1.3.7 → 1.4.0

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