@osovv/vv-opencode 1.4.2 → 1.4.3-rc.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 (57) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +20 -6
  3. package/dist/lib/orchestration.js +12 -3
  4. package/dist/lib/orchestration.js.map +1 -1
  5. package/dist/lib/spec-lint.d.ts +2 -64
  6. package/dist/lib/spec-lint.js +47 -45
  7. package/dist/lib/spec-lint.js.map +1 -1
  8. package/dist/lib/workflow-contract.d.ts +277 -0
  9. package/dist/lib/workflow-contract.js +636 -0
  10. package/dist/lib/workflow-contract.js.map +1 -0
  11. package/dist/plugins/system-context-injection/index.js +15 -7
  12. package/dist/plugins/system-context-injection/index.js.map +1 -1
  13. package/dist/plugins/workflow/authority.d.ts +99 -0
  14. package/dist/plugins/workflow/authority.js +435 -0
  15. package/dist/plugins/workflow/authority.js.map +1 -0
  16. package/dist/plugins/workflow/checkpoint-io.d.ts +10 -1
  17. package/dist/plugins/workflow/checkpoint-io.js +22 -3
  18. package/dist/plugins/workflow/checkpoint-io.js.map +1 -1
  19. package/dist/plugins/workflow/checkpoints.d.ts +58 -1
  20. package/dist/plugins/workflow/checkpoints.js +362 -34
  21. package/dist/plugins/workflow/checkpoints.js.map +1 -1
  22. package/dist/plugins/workflow/delegated.d.ts +150 -2
  23. package/dist/plugins/workflow/delegated.js +551 -15
  24. package/dist/plugins/workflow/delegated.js.map +1 -1
  25. package/dist/plugins/workflow/execution.d.ts +361 -0
  26. package/dist/plugins/workflow/execution.js +1807 -0
  27. package/dist/plugins/workflow/execution.js.map +1 -0
  28. package/dist/plugins/workflow/index.js +539 -80
  29. package/dist/plugins/workflow/index.js.map +1 -1
  30. package/dist/plugins/workflow/persistence.d.ts +18 -4
  31. package/dist/plugins/workflow/persistence.js +439 -42
  32. package/dist/plugins/workflow/persistence.js.map +1 -1
  33. package/dist/plugins/workflow/repair.d.ts +2 -0
  34. package/dist/plugins/workflow/repair.js +16 -8
  35. package/dist/plugins/workflow/repair.js.map +1 -1
  36. package/dist/plugins/workflow/snapshots.js +3 -3
  37. package/dist/plugins/workflow/snapshots.js.map +1 -1
  38. package/dist/plugins/workflow/state.d.ts +13 -0
  39. package/dist/plugins/workflow/state.js +37 -7
  40. package/dist/plugins/workflow/state.js.map +1 -1
  41. package/dist/plugins/workflow/system-instruction.md +15 -1
  42. package/dist/plugins/workflow/tooling.d.ts +81 -6
  43. package/dist/plugins/workflow/tooling.js +956 -23
  44. package/dist/plugins/workflow/tooling.js.map +1 -1
  45. package/dist/plugins/workflow/transactions.d.ts +54 -0
  46. package/dist/plugins/workflow/transactions.js +147 -0
  47. package/dist/plugins/workflow/transactions.js.map +1 -0
  48. package/package.json +1 -1
  49. package/schemas/vvoc/v3.json +1 -1
  50. package/templates/agents/vv-code-reviewer.md +1 -0
  51. package/templates/agents/vv-controller.md +26 -13
  52. package/templates/agents/vv-implementer.md +1 -0
  53. package/templates/agents/vv-spec-reviewer.md +1 -0
  54. package/templates/skills/vv-execute/SKILL.md +6 -5
  55. package/templates/skills/vv-plan/SKILL.md +1 -1
  56. package/templates/skills/vv-review/SKILL.md +1 -0
  57. package/templates/skills/vv-spec/SKILL.md +2 -2
@@ -1,10 +1,10 @@
1
1
  // FILE: src/plugins/workflow/tooling.ts
2
- // VERSION: 0.3.0
2
+ // VERSION: 0.4.0
3
3
  // START_MODULE_CONTRACT
4
4
  // PURPOSE: Provide work-item tooling handlers that wrap explicit workflow state operations with structured protocol-friendly responses.
5
- // SCOPE: work_item_open, work_item_list, and work_item_close tool definitions with delegated-mode open validation and mode-specific serialization; work_item_decide and work_checkpoint control-tool definitions wrapping delegated decisions, plan registration, checkpoint start/verify, and failed-checkpoint rework authorization.
6
- // DEPENDS: [src/plugins/workflow/checkpoint-io.ts, src/plugins/workflow/checkpoints.ts, src/plugins/workflow/delegated.ts, src/plugins/workflow/state.ts]
7
- // LINKS: M-WORKFLOW-TOOLING, M-WORKFLOW-DELEGATED, M-WORKFLOW-CHECKPOINTS, M-PLUGIN-WORKFLOW
5
+ // SCOPE: work_item_open, work_item_list, and work_item_close tool definitions with delegated-mode open validation and mode-specific serialization including recovery-aware progress summaries; generic execution registration/append through an optional execution descriptor or runId; work_item_decide and work_checkpoint control-tool definitions wrapping delegated decisions, native plan registration, checkpoint start/verify, failed-checkpoint rework authorization, bounded recover for stopped or exhausted targets with optional root-user message authorization through a read-only lookup, and the generic (non-native) checkpoint, completion, amendment, advance-authority, stage-approval, and revocation actions.
6
+ // DEPENDS: [src/plugins/workflow/checkpoint-io.ts, src/plugins/workflow/checkpoints.ts, src/plugins/workflow/delegated.ts, src/plugins/workflow/execution.ts, src/plugins/workflow/authority.ts, src/lib/workflow-contract.ts, src/plugins/workflow/state.ts]
7
+ // LINKS: M-WORKFLOW-TOOLING, M-WORKFLOW-DELEGATED, M-WORKFLOW-CHECKPOINTS, M-WORKFLOW-EXECUTION, M-WORKFLOW-AUTHORITY, M-PLUGIN-WORKFLOW
8
8
  // ROLE: RUNTIME
9
9
  // MAP_MODE: EXPORTS
10
10
  // END_MODULE_CONTRACT
@@ -12,11 +12,13 @@
12
12
  // START_MODULE_MAP
13
13
  // WorkflowToolContext - Minimal execution context required by workflow tools.
14
14
  // WorkflowToolDefinition - Deterministic tool definition shape with an optionally async execute handler.
15
+ // DelegatedControlOptions - Optional read-only authorization lookup bound to the plugin SDK client.
15
16
  // createWorkItemOpenTool - Creates work_item_open tool wrapper around explicit openWorkItem contract including standalone delegated tasks.
16
- // createWorkItemListTool - Creates work_item_list tool wrapper with mode, round metadata, delegated acceptance state, and registered plan runs.
17
+ // createWorkItemListTool - Creates work_item_list tool wrapper with mode, round metadata, delegated acceptance and recovery state, and registered plan runs.
17
18
  // createWorkItemCloseTool - Creates work_item_close tool wrapper with ready_to_close gating responses.
18
- // createWorkItemDecideTool - Creates work_item_decide control wrapper around decideDelegatedWorkItem and rework authorization.
19
- // createWorkCheckpointTool - Creates work_checkpoint control wrapper around plan registration, checkpoint start, and verify.
19
+ // createWorkItemDecideTool - Creates work_item_decide control wrapper around decideDelegatedWorkItem, rework authorization, and bounded recovery.
20
+ // createWorkCheckpointTool - Creates work_checkpoint control wrapper around plan registration, checkpoint start, verify, checkpoint recovery, and generic checkpoint/completion/amendment/authority actions.
21
+ // DelegatedControlOptions - Optional read-only authorization lookups bound to the plugin SDK client.
20
22
  // DecideArgs - work_item_decide tool argument shape.
21
23
  // CheckpointArgs - work_checkpoint tool argument shape.
22
24
  // WorkCheckpointRegisterInput - Register-action input discriminator.
@@ -25,11 +27,13 @@
25
27
  // END_MODULE_MAP
26
28
  //
27
29
  // START_CHANGE_SUMMARY
28
- // LAST_CHANGE: [C-DELEGATED-WORKFLOW-ASTRA-PRESETS - Added delegated open validation, list metadata, and the two delegated control tool wrappers.]
30
+ // LAST_CHANGE: [C-WORKFLOW-PLAN-INDEPENDENCE - Added generic execution registration/append through work_item_open and generic checkpoint, completion, amendment, advance-authority, stage-approval, and revocation actions through work_checkpoint, plus the SDK-backed authority-message lookup seam.]
29
31
  // END_CHANGE_SUMMARY
30
32
  import { closeWorkItem, getReviewRound, listWorkItems, openWorkItem, } from "./state.js";
31
- import { authorizeReworkFromFailedCheckpoint, getDelegatedRunView, registerDelegatedPlan, startDelegatedCheckpoint, verifyDelegatedCheckpoint, } from "./checkpoints.js";
32
- import { currentDelegatedAcceptance, decideDelegatedWorkItem } from "./delegated.js";
33
+ import { authorizeReworkFromFailedCheckpoint, getDelegatedRunView, recoverDelegatedCheckpoint, registerDelegatedPlan, startDelegatedCheckpoint, verifyDelegatedCheckpoint, } from "./checkpoints.js";
34
+ import { currentDelegatedAcceptance, decideDelegatedWorkItem, recoverDelegatedWorkItem, summarizeDelegatedProgress, } from "./delegated.js";
35
+ import { addAuthorityInStore, addReserveDebitInStore, addStageApprovalInStore, appendExecutionWorkInStore, completeExecutionInStore, findExecution, getExecutionView, recordGenericReviewerResultInStore, recoverGenericCheckpointInStore, putAuthorityInStore, registerExecutionInStore, startGenericCheckpointInStore, } from "./execution.js";
36
+ import { advanceUnitsAvailable, effectiveAuthorityStages, extendAdvanceAuthority, grantAdvanceAuthority, proposeReserveDebit, proposeStageApproval, revokeAdvanceAuthority, narrowAdvanceAuthority, } from "./authority.js";
33
37
  function coerceNonEmptyString(value) {
34
38
  if (typeof value !== "string")
35
39
  return undefined;
@@ -56,6 +60,89 @@ function canonicalizeReviewers(value) {
56
60
  return left === "spec" ? -1 : 1;
57
61
  });
58
62
  }
63
+ // START_BLOCK_GENERIC_NORMALIZATION
64
+ function isWorkflowReviewer(value) {
65
+ return value === "spec" || value === "code";
66
+ }
67
+ function stringList(value) {
68
+ return Array.isArray(value) ? value.map(String) : [];
69
+ }
70
+ /** Normalize one tool item into a bounded generic task contract. */
71
+ function normalizeTaskContract(item) {
72
+ const taskId = coerceNonEmptyString(item.taskId) ?? coerceNonEmptyString(item.key);
73
+ const title = coerceNonEmptyString(item.title) ?? taskId;
74
+ if (!taskId || !title) {
75
+ return { ok: false, message: "each task item requires a non-empty taskId (or key) and title" };
76
+ }
77
+ if (!Array.isArray(item.requiredReviewers)) {
78
+ return {
79
+ ok: false,
80
+ message: "requiredReviewers must be declared explicitly for each task (use [] for no independent review)",
81
+ };
82
+ }
83
+ const reviewers = item.requiredReviewers;
84
+ if (!reviewers.every(isWorkflowReviewer) || new Set(reviewers).size !== reviewers.length) {
85
+ return { ok: false, message: "requiredReviewers must be a unique spec/code array" };
86
+ }
87
+ return {
88
+ ok: true,
89
+ contract: {
90
+ taskId,
91
+ title,
92
+ goal: coerceNonEmptyString(item.goal) ?? title,
93
+ acceptanceCriteria: stringList(item.acceptanceCriteria),
94
+ verification: stringList(item.verification),
95
+ writeScope: stringList(item.writeScope),
96
+ dependsOn: stringList(item.dependsOn),
97
+ blockedBy: stringList(item.blockedBy),
98
+ requiredReviewers: [...reviewers],
99
+ },
100
+ };
101
+ }
102
+ function normalizeExecutionSource(raw) {
103
+ if (raw === null || typeof raw !== "object")
104
+ return undefined;
105
+ const candidate = raw;
106
+ if (candidate.kind === "conversation-scoped")
107
+ return { kind: "conversation-scoped" };
108
+ if (candidate.kind === "provided-plan") {
109
+ const reference = coerceNonEmptyString(candidate.reference);
110
+ if (!reference)
111
+ return undefined;
112
+ const sha256 = coerceNonEmptyString(candidate.sha256);
113
+ return { kind: "provided-plan", reference, ...(sha256 ? { sha256 } : {}) };
114
+ }
115
+ // Native packages must register through work_checkpoint register with a
116
+ // planPath; the generic descriptor never fabricates a native source.
117
+ return undefined;
118
+ }
119
+ function normalizeBoundary(raw) {
120
+ if (raw === null || typeof raw !== "object")
121
+ return undefined;
122
+ const candidate = raw;
123
+ const files = stringList(candidate.files);
124
+ const directories = stringList(candidate.directories);
125
+ if (files.length === 0 && directories.length === 0)
126
+ return undefined;
127
+ return { files, directories };
128
+ }
129
+ function normalizeCheckpointContracts(raw) {
130
+ return Array.isArray(raw) ? raw : [];
131
+ }
132
+ function taskContractsFromItems(items) {
133
+ const contracts = [];
134
+ for (const item of items) {
135
+ const normalized = normalizeTaskContract(item);
136
+ if (!normalized.ok)
137
+ return { ok: false, message: normalized.message };
138
+ contracts.push(normalized.contract);
139
+ }
140
+ if (contracts.length === 0) {
141
+ return { ok: false, message: "at least one task item is required" };
142
+ }
143
+ return { ok: true, contracts };
144
+ }
145
+ // END_BLOCK_GENERIC_NORMALIZATION
59
146
  function normalizeOpenInputItem(item, sessionId) {
60
147
  const key = coerceNonEmptyString(item.key);
61
148
  const title = coerceNonEmptyString(item.title);
@@ -171,11 +258,24 @@ function serializeWorkItem(record) {
171
258
  accepted: currentDelegatedAcceptance(record) !== undefined,
172
259
  acceptedAttempt: currentDelegatedAcceptance(record)?.attempt,
173
260
  reworkCount: record.delegated.reworkHistory.length,
261
+ ...serializeProgress(record),
174
262
  },
175
263
  }
176
264
  : {}),
177
265
  };
178
266
  }
267
+ /** Recovery-aware progress fields shared by work-item serialization. */
268
+ function serializeProgress(record) {
269
+ const progress = summarizeDelegatedProgress(record);
270
+ return {
271
+ attemptBudget: progress.attemptBudget,
272
+ remainingAttempts: progress.remainingAttempts,
273
+ recoveryCount: progress.recoveryGrants,
274
+ autonomousGrantConsumed: progress.autonomousGrantConsumed,
275
+ reportRejectionCount: progress.reportRejectedAttempts,
276
+ nextAction: progress.nextAction,
277
+ };
278
+ }
179
279
  // START_CONTRACT: createWorkItemOpenTool
180
280
  // PURPOSE: Build work_item_open handler that supports deterministic batch idempotent open operations with explicit workflow intent.
181
281
  // INPUTS: { store: WorkItemStore - workflow in-memory store }
@@ -189,6 +289,114 @@ export function createWorkItemOpenTool(store) {
189
289
  description: "Open one or more workflow work items idempotently with explicit mode and requiredReviewers.",
190
290
  execute: (args, context, overrideStore) => {
191
291
  const inputItems = Array.isArray(args.items) ? args.items : [];
292
+ const runIdArg = coerceNonEmptyString(args.runId);
293
+ const executionArg = args.execution;
294
+ if (executionArg !== undefined && runIdArg !== undefined) {
295
+ return {
296
+ tool: "work_item_open",
297
+ sessionId: context.sessionId,
298
+ ok: false,
299
+ errorCode: "INVALID_INPUT",
300
+ message: "INVALID_INPUT: execution and runId are mutually exclusive",
301
+ };
302
+ }
303
+ if (executionArg !== undefined || runIdArg !== undefined) {
304
+ const s = overrideStore ?? store;
305
+ const data = s.getStoreData();
306
+ const contracts = taskContractsFromItems(inputItems);
307
+ if (!contracts.ok) {
308
+ return {
309
+ tool: "work_item_open",
310
+ sessionId: context.sessionId,
311
+ ok: false,
312
+ errorCode: "INVALID_INPUT",
313
+ message: `INVALID_INPUT: ${contracts.message}`,
314
+ };
315
+ }
316
+ if (executionArg !== undefined) {
317
+ const descriptor = executionArg !== null && typeof executionArg === "object"
318
+ ? executionArg
319
+ : {};
320
+ const executionKey = coerceNonEmptyString(descriptor.executionKey);
321
+ const source = normalizeExecutionSource(descriptor.source);
322
+ const boundary = normalizeBoundary(descriptor.boundary);
323
+ const goal = coerceNonEmptyString(descriptor.goal);
324
+ const workspaceRoot = coerceNonEmptyString(context.workspaceRoot);
325
+ if (!executionKey || !source || !boundary || !goal || !workspaceRoot) {
326
+ return {
327
+ tool: "work_item_open",
328
+ sessionId: context.sessionId,
329
+ ok: false,
330
+ errorCode: "INVALID_INPUT",
331
+ message: "INVALID_INPUT: execution requires executionKey, source, goal, boundary, and the trusted workspace root",
332
+ };
333
+ }
334
+ const registered = registerExecutionInStore(data, {
335
+ sessionId: context.sessionId,
336
+ workspaceRoot,
337
+ executionKey,
338
+ source,
339
+ goal,
340
+ boundary,
341
+ tasks: contracts.contracts.map((contract) => ({ contract })),
342
+ checkpoints: normalizeCheckpointContracts(descriptor.checkpoints),
343
+ });
344
+ if (!registered.ok) {
345
+ return {
346
+ tool: "work_item_open",
347
+ sessionId: context.sessionId,
348
+ ok: false,
349
+ errorCode: registered.errorCode,
350
+ message: registered.message,
351
+ };
352
+ }
353
+ return {
354
+ tool: "work_item_open",
355
+ sessionId: context.sessionId,
356
+ ok: true,
357
+ action: "register",
358
+ runId: registered.runId,
359
+ reused: registered.reused,
360
+ execution: getExecutionView(registered.execution),
361
+ };
362
+ }
363
+ const amendmentId = coerceNonEmptyString(args.amendmentId);
364
+ const rationale = coerceNonEmptyString(args.rationale);
365
+ if (!runIdArg || !amendmentId || !rationale) {
366
+ return {
367
+ tool: "work_item_open",
368
+ sessionId: context.sessionId,
369
+ ok: false,
370
+ errorCode: "INVALID_INPUT",
371
+ message: "INVALID_INPUT: runId append requires amendmentId and rationale",
372
+ };
373
+ }
374
+ const appended = appendExecutionWorkInStore(data, {
375
+ sessionId: context.sessionId,
376
+ runId: runIdArg,
377
+ amendmentId,
378
+ rationale,
379
+ tasks: contracts.contracts.map((contract) => ({ contract })),
380
+ });
381
+ if (!appended.ok) {
382
+ return {
383
+ tool: "work_item_open",
384
+ sessionId: context.sessionId,
385
+ ok: false,
386
+ errorCode: appended.errorCode,
387
+ message: appended.message,
388
+ };
389
+ }
390
+ return {
391
+ tool: "work_item_open",
392
+ sessionId: context.sessionId,
393
+ ok: true,
394
+ action: "amend",
395
+ runId: runIdArg,
396
+ revision: appended.revision,
397
+ execution: getExecutionView(appended.execution),
398
+ };
399
+ }
192
400
  const results = inputItems.map((item) => {
193
401
  const normalized = normalizeOpenInputItem(item, context.sessionId);
194
402
  if (!normalized.ok) {
@@ -299,17 +507,17 @@ export function createWorkItemCloseTool(store) {
299
507
  };
300
508
  }
301
509
  // START_CONTRACT: createWorkItemDecideTool
302
- // PURPOSE: Build work_item_decide handler wrapping explicit controller acceptance, change requests, and checkpoint-authorized rework.
303
- // INPUTS: { store: WorkItemStore - workflow in-memory store }
304
- // OUTPUTS: { WorkflowToolDefinition<DecideArgs, Record<string, unknown>> - executable control tool definition }
510
+ // PURPOSE: Build work_item_decide handler wrapping explicit controller acceptance, change requests, checkpoint-authorized rework, and bounded recovery.
511
+ // INPUTS: { store: WorkItemStore - workflow in-memory store, options?: DelegatedControlOptions - optional read-only authorization lookup }
512
+ // OUTPUTS: { WorkflowToolDefinition<DecideArgs, Promise<Record<string, unknown>>> - async executable control tool definition }
305
513
  // SIDE_EFFECTS: [Mutates delegated work-item state through the domain layer]
306
514
  // LINKS: [M-WORKFLOW-TOOLING, M-WORKFLOW-DELEGATED, M-WORKFLOW-CHECKPOINTS]
307
515
  // END_CONTRACT: createWorkItemDecideTool
308
- export function createWorkItemDecideTool(store) {
516
+ export function createWorkItemDecideTool(store, options) {
309
517
  return {
310
518
  name: "work_item_decide",
311
- description: "Accept or request changes for the current completed delegated attempt, or authorize bounded rework of an accepted task from a failed checkpoint.",
312
- execute: (args, context, overrideStore) => {
519
+ description: "Accept or request changes for the current completed delegated attempt, authorize bounded rework of an accepted task from a failed checkpoint, or recover a stopped or exhausted unaccepted task with a bounded diagnosis and changed condition.",
520
+ async execute(args, context, overrideStore) {
313
521
  const s = overrideStore ?? store;
314
522
  const workItemId = coerceNonEmptyString(args.workItemId);
315
523
  const decision = args.decision;
@@ -363,13 +571,148 @@ export function createWorkItemDecideTool(store) {
363
571
  state: s.getWorkItem(context.sessionId, workItemId)?.state,
364
572
  };
365
573
  }
574
+ if (decision === "recover") {
575
+ const recoveryId = coerceNonEmptyString(args.recoveryId);
576
+ const diagnosis = typeof args.diagnosis === "string" ? args.diagnosis : "";
577
+ const changedCondition = typeof args.changedCondition === "string" ? args.changedCondition : "";
578
+ const verification = Array.isArray(args.verification) ? args.verification.map(String) : [];
579
+ const userMessageId = coerceNonEmptyString(args.userMessageId);
580
+ const authorityId = coerceNonEmptyString(args.authorityId);
581
+ if (!recoveryId) {
582
+ return {
583
+ tool: "work_item_decide",
584
+ sessionId: context.sessionId,
585
+ ok: false,
586
+ errorCode: "INVALID_INPUT",
587
+ message: "INVALID_INPUT: recover requires a stable recoveryId, diagnosis, changedCondition, and verification references",
588
+ };
589
+ }
590
+ let advanceGrantApproved = false;
591
+ let advanceRunId;
592
+ if (authorityId) {
593
+ advanceRunId = coerceNonEmptyString(args.runId);
594
+ if (!advanceRunId) {
595
+ return {
596
+ tool: "work_item_decide",
597
+ sessionId: context.sessionId,
598
+ ok: false,
599
+ errorCode: "INVALID_INPUT",
600
+ message: "INVALID_INPUT: authorityId recovery requires the owning runId",
601
+ };
602
+ }
603
+ const execution = findExecution(s.getStoreData(), advanceRunId);
604
+ const authority = execution?.authority.find((entry) => entry.authorityId === authorityId);
605
+ if (!execution || !authority) {
606
+ return {
607
+ tool: "work_item_decide",
608
+ sessionId: context.sessionId,
609
+ ok: false,
610
+ errorCode: "AUTHORITY_NOT_FOUND",
611
+ message: `AUTHORITY_NOT_FOUND: no recorded authority ${authorityId} for ${advanceRunId}`,
612
+ };
613
+ }
614
+ // Scope binding: the recovered item must be a task of the execution
615
+ // whose reserve funds the recovery.
616
+ if (![...execution.tasks.values()].some((task) => task.workItemId === workItemId)) {
617
+ return {
618
+ tool: "work_item_decide",
619
+ sessionId: context.sessionId,
620
+ ok: false,
621
+ errorCode: "INVALID_INPUT",
622
+ message: `INVALID_INPUT: work item ${workItemId} is not a task of execution ${advanceRunId}`,
623
+ };
624
+ }
625
+ const proposed = proposeReserveDebit({
626
+ authority,
627
+ debits: execution.reserveDebits,
628
+ recoveryId,
629
+ targetKind: "task",
630
+ targetId: workItemId,
631
+ });
632
+ // A failed proposal only means no advance unit is available; the
633
+ // recovery may still be a cost-free resume or use an ordinary
634
+ // allowance, so let the domain reducer decide.
635
+ advanceGrantApproved = proposed.ok;
636
+ }
637
+ const recovered = await recoverDelegatedWorkItem(s, {
638
+ sessionId: context.sessionId,
639
+ workItemId,
640
+ attempt: args.attempt,
641
+ diagnosis,
642
+ changedCondition,
643
+ verification,
644
+ recoveryId,
645
+ ...(advanceGrantApproved ? { advanceGrantApproved: true } : {}),
646
+ ...(userMessageId !== undefined
647
+ ? { userMessageId, lookupUserMessage: options?.lookupUserMessage }
648
+ : {}),
649
+ });
650
+ if (!recovered.ok) {
651
+ return {
652
+ tool: "work_item_decide",
653
+ sessionId: context.sessionId,
654
+ ok: false,
655
+ errorCode: recovered.errorCode,
656
+ message: recovered.message,
657
+ };
658
+ }
659
+ if (recovered.kind === "advance_grant" && advanceRunId !== undefined) {
660
+ const execution = findExecution(s.getStoreData(), advanceRunId);
661
+ const authority = execution?.authority.find((entry) => entry.authorityId === authorityId);
662
+ const proposed = authority
663
+ ? proposeReserveDebit({
664
+ authority,
665
+ debits: execution?.reserveDebits ?? [],
666
+ recoveryId,
667
+ targetKind: "task",
668
+ targetId: workItemId,
669
+ })
670
+ : undefined;
671
+ if (!proposed?.ok) {
672
+ return {
673
+ tool: "work_item_decide",
674
+ sessionId: context.sessionId,
675
+ ok: false,
676
+ errorCode: proposed?.code ?? "RESERVE_EXHAUSTED",
677
+ message: proposed?.message ?? "advance recovery debit could not be recorded with the grant",
678
+ };
679
+ }
680
+ const stored = addReserveDebitInStore(s.getStoreData(), {
681
+ sessionId: context.sessionId,
682
+ runId: advanceRunId,
683
+ debit: proposed.value,
684
+ });
685
+ if (!stored.ok) {
686
+ return {
687
+ tool: "work_item_decide",
688
+ sessionId: context.sessionId,
689
+ ok: false,
690
+ errorCode: stored.errorCode,
691
+ message: stored.message,
692
+ };
693
+ }
694
+ }
695
+ return {
696
+ tool: "work_item_decide",
697
+ sessionId: context.sessionId,
698
+ ok: true,
699
+ action: "recover",
700
+ workItemId,
701
+ recoveryId: recovered.recoveryId,
702
+ kind: recovered.kind,
703
+ attemptBudget: recovered.attemptBudget,
704
+ remainingAttempts: recovered.remainingAttempts,
705
+ state: recovered.record.state,
706
+ nextAction: summarizeDelegatedProgress(recovered.record).nextAction,
707
+ };
708
+ }
366
709
  if (decision !== "accept" && decision !== "request_changes") {
367
710
  return {
368
711
  tool: "work_item_decide",
369
712
  sessionId: context.sessionId,
370
713
  ok: false,
371
714
  errorCode: "INVALID_INPUT",
372
- message: "INVALID_INPUT: decision must be accept, request_changes, or rework",
715
+ message: "INVALID_INPUT: decision must be accept, request_changes, rework, or recover",
373
716
  };
374
717
  }
375
718
  const concernsDisposition = typeof args.concernsDisposition === "string" ? args.concernsDisposition : undefined;
@@ -404,21 +747,479 @@ export function createWorkItemDecideTool(store) {
404
747
  },
405
748
  };
406
749
  }
750
+ // START_CONTRACT: executeGenericCheckpoint
751
+ // PURPOSE: Execute one generic (non-native) checkpoint/authority action against the common execution registry.
752
+ // INPUTS: { data, sessionId, runId, args, control? }
753
+ // OUTPUTS: { Promise<Record<string, unknown>> - structured tool result }
754
+ // SIDE_EFFECTS: [Mutates the common execution registry and may consult the SDK-backed authority lookup]
755
+ // LINKS: [M-WORKFLOW-TOOLING, M-WORKFLOW-EXECUTION, M-WORKFLOW-AUTHORITY]
756
+ // END_CONTRACT: executeGenericCheckpoint
757
+ async function executeGenericCheckpoint(options) {
758
+ const { data, sessionId, runId, args } = options;
759
+ const base = { tool: "work_checkpoint", sessionId, runId };
760
+ const execution = findExecution(data, runId);
761
+ if (!execution) {
762
+ return {
763
+ ...base,
764
+ ok: false,
765
+ errorCode: "EXECUTION_NOT_FOUND",
766
+ message: `no execution ${runId}`,
767
+ };
768
+ }
769
+ if (execution.sessionId !== sessionId) {
770
+ return {
771
+ ...base,
772
+ ok: false,
773
+ errorCode: "SESSION_MISMATCH",
774
+ message: "execution belongs to another session",
775
+ };
776
+ }
777
+ const amendmentId = coerceNonEmptyString(args.amendmentId);
778
+ const rationale = coerceNonEmptyString(args.rationale);
779
+ switch (args.action) {
780
+ case "register":
781
+ case "amend": {
782
+ if (!amendmentId || !rationale) {
783
+ return {
784
+ ...base,
785
+ ok: false,
786
+ errorCode: "INVALID_INPUT",
787
+ message: "generic register/amend requires amendmentId and rationale",
788
+ };
789
+ }
790
+ const items = Array.isArray(args.tasks) ? args.tasks : [];
791
+ const contracts = items.length > 0 ? taskContractsFromItems(items) : { ok: true, contracts: [] };
792
+ if (!contracts.ok) {
793
+ return { ...base, ok: false, errorCode: "INVALID_INPUT", message: contracts.message };
794
+ }
795
+ const appended = appendExecutionWorkInStore(data, {
796
+ sessionId,
797
+ runId,
798
+ amendmentId,
799
+ rationale,
800
+ tasks: contracts.contracts.map((contract) => ({ contract })),
801
+ checkpoints: normalizeCheckpointContracts(args.checkpoints),
802
+ });
803
+ if (!appended.ok) {
804
+ return { ...base, ok: false, errorCode: appended.errorCode, message: appended.message };
805
+ }
806
+ return {
807
+ ...base,
808
+ ok: true,
809
+ action: "amend",
810
+ revision: appended.revision,
811
+ execution: getExecutionView(appended.execution),
812
+ };
813
+ }
814
+ case "start": {
815
+ const checkpointId = coerceNonEmptyString(args.checkpointId);
816
+ if (!checkpointId) {
817
+ return {
818
+ ...base,
819
+ ok: false,
820
+ errorCode: "INVALID_INPUT",
821
+ message: "start requires checkpointId",
822
+ };
823
+ }
824
+ const startFingerprint = coerceNonEmptyString(args.startFingerprint);
825
+ const started = startGenericCheckpointInStore(data, {
826
+ sessionId,
827
+ runId,
828
+ checkpointId,
829
+ ...(startFingerprint ? { startFingerprint } : {}),
830
+ });
831
+ if (!started.ok) {
832
+ return { ...base, ok: false, errorCode: started.errorCode, message: started.message };
833
+ }
834
+ return {
835
+ ...base,
836
+ ok: true,
837
+ action: "start",
838
+ checkpointId,
839
+ generation: started.checkpoint.currentReview?.generation,
840
+ reviewWorkItemId: started.reviewWorkItemId,
841
+ header: started.header,
842
+ reviewersToLaunch: started.reviewers,
843
+ coveredAttemptIds: started.coveredAttemptIds,
844
+ };
845
+ }
846
+ case "review":
847
+ case "bind":
848
+ case "verify": {
849
+ const checkpointId = coerceNonEmptyString(args.checkpointId);
850
+ if (!checkpointId) {
851
+ return {
852
+ ...base,
853
+ ok: false,
854
+ errorCode: "INVALID_INPUT",
855
+ message: "checkpointId is required",
856
+ };
857
+ }
858
+ const reviewer = coerceNonEmptyString(args.reviewer);
859
+ if (reviewer !== undefined && reviewer !== "spec" && reviewer !== "code") {
860
+ return {
861
+ ...base,
862
+ ok: false,
863
+ errorCode: "INVALID_INPUT",
864
+ message: "reviewer must be spec or code",
865
+ };
866
+ }
867
+ // The reviewer status is read from the linked review_only work item's
868
+ // recorded round; callers cannot assert a reviewer outcome directly.
869
+ const recorded = recordGenericReviewerResultInStore(data, {
870
+ sessionId,
871
+ runId,
872
+ checkpointId,
873
+ ...(reviewer ? { reviewer } : {}),
874
+ });
875
+ if (!recorded.ok) {
876
+ return { ...base, ok: false, errorCode: recorded.errorCode, message: recorded.message };
877
+ }
878
+ return {
879
+ ...base,
880
+ ok: true,
881
+ action: args.action,
882
+ checkpointId,
883
+ ...(reviewer ? { reviewer } : {}),
884
+ outcome: recorded.outcome,
885
+ checkpointStatus: recorded.checkpoint.status,
886
+ };
887
+ }
888
+ case "recover": {
889
+ const checkpointId = coerceNonEmptyString(args.checkpointId);
890
+ const recoveryId = coerceNonEmptyString(args.recoveryId);
891
+ const diagnosis = typeof args.diagnosis === "string" ? args.diagnosis : "";
892
+ const changedCondition = typeof args.changedCondition === "string" ? args.changedCondition : "";
893
+ const verification = stringList(args.verification);
894
+ const authorityId = coerceNonEmptyString(args.authorityId);
895
+ if (!checkpointId || !recoveryId) {
896
+ return {
897
+ ...base,
898
+ ok: false,
899
+ errorCode: "INVALID_INPUT",
900
+ message: "generic checkpoint recovery requires checkpointId and recoveryId",
901
+ };
902
+ }
903
+ const checkpointBinding = execution.checkpoints.get(checkpointId);
904
+ const stopped = checkpointBinding?.stoppedAtGeneration !== undefined;
905
+ // Validate and reserve the advance unit BEFORE mutating the checkpoint,
906
+ // so a failed debit can never leave a granted generation behind.
907
+ let reservedDebit;
908
+ if (!stopped) {
909
+ if (!authorityId) {
910
+ return {
911
+ ...base,
912
+ ok: false,
913
+ errorCode: "INVALID_INPUT",
914
+ message: "an exhausted generic checkpoint requires an authorityId to recover",
915
+ };
916
+ }
917
+ const authority = execution.authority.find((entry) => entry.authorityId === authorityId);
918
+ if (!authority) {
919
+ return {
920
+ ...base,
921
+ ok: false,
922
+ errorCode: "AUTHORITY_NOT_FOUND",
923
+ message: `AUTHORITY_NOT_FOUND: no recorded authority ${authorityId}`,
924
+ };
925
+ }
926
+ const proposed = proposeReserveDebit({
927
+ authority,
928
+ debits: execution.reserveDebits,
929
+ recoveryId,
930
+ targetKind: "checkpoint",
931
+ targetId: checkpointId,
932
+ });
933
+ if (!proposed.ok) {
934
+ return { ...base, ok: false, errorCode: proposed.code, message: proposed.message };
935
+ }
936
+ reservedDebit = proposed.value;
937
+ }
938
+ const recovered = recoverGenericCheckpointInStore(data, {
939
+ sessionId,
940
+ runId,
941
+ checkpointId,
942
+ recoveryId,
943
+ diagnosis,
944
+ changedCondition,
945
+ verification,
946
+ authorityGrant: reservedDebit !== undefined,
947
+ });
948
+ if (!recovered.ok) {
949
+ return { ...base, ok: false, errorCode: recovered.errorCode, message: recovered.message };
950
+ }
951
+ if (recovered.kind === "advance_grant" && reservedDebit) {
952
+ const stored = addReserveDebitInStore(data, {
953
+ sessionId,
954
+ runId,
955
+ debit: reservedDebit,
956
+ });
957
+ if (!stored.ok) {
958
+ return { ...base, ok: false, errorCode: stored.errorCode, message: stored.message };
959
+ }
960
+ }
961
+ return {
962
+ ...base,
963
+ ok: true,
964
+ action: "recover",
965
+ checkpointId,
966
+ recoveryId,
967
+ kind: recovered.kind,
968
+ checkpointStatus: recovered.checkpoint.status,
969
+ };
970
+ }
971
+ case "complete": {
972
+ const completed = completeExecutionInStore(data, {
973
+ sessionId,
974
+ runId,
975
+ rationale: rationale ?? "Generic execution completed with controller acceptance.",
976
+ evidence: stringList(args.verification),
977
+ });
978
+ if (!completed.ok) {
979
+ return { ...base, ok: false, errorCode: completed.errorCode, message: completed.message };
980
+ }
981
+ return {
982
+ ...base,
983
+ ok: true,
984
+ action: "complete",
985
+ reviewStatus: completed.reviewStatus,
986
+ execution: getExecutionView(completed.execution),
987
+ };
988
+ }
989
+ case "authorize": {
990
+ const authorityId = coerceNonEmptyString(args.authorityId);
991
+ const messageId = coerceNonEmptyString(args.messageId);
992
+ const stagesArg = Array.isArray(args.stages) ? args.stages : [];
993
+ const stages = stagesArg.filter((stage) => stage === "specification" ||
994
+ stage === "planning" ||
995
+ stage === "implementation" ||
996
+ stage === "verification");
997
+ if (!authorityId || !messageId || stages.length === 0) {
998
+ return {
999
+ ...base,
1000
+ ok: false,
1001
+ errorCode: "INVALID_INPUT",
1002
+ message: "authorize requires authorityId, messageId, and explicit stages",
1003
+ };
1004
+ }
1005
+ const lookup = options.control?.lookupAuthorityMessage;
1006
+ if (!lookup) {
1007
+ return {
1008
+ ...base,
1009
+ ok: false,
1010
+ errorCode: "INVALID_INPUT",
1011
+ message: "authorize requires the SDK-backed authorization lookup",
1012
+ };
1013
+ }
1014
+ const message = await lookup({ sessionId, runId, messageId });
1015
+ if (!message) {
1016
+ return {
1017
+ ...base,
1018
+ ok: false,
1019
+ errorCode: "AUTHORITY_DENIED",
1020
+ message: "authorization message could not be verified as an eligible root-user instruction",
1021
+ };
1022
+ }
1023
+ const existingAuthority = execution.authority.find((entry) => entry.authorityId === authorityId);
1024
+ if (existingAuthority) {
1025
+ if (existingAuthority.grantedByMessageId === messageId) {
1026
+ // Idempotent replay of the originating registration.
1027
+ return {
1028
+ ...base,
1029
+ ok: true,
1030
+ action: "authorize",
1031
+ authorityId,
1032
+ reused: true,
1033
+ units: existingAuthority.initialUnits,
1034
+ availableUnits: advanceUnitsAvailable(existingAuthority, execution.reserveDebits),
1035
+ };
1036
+ }
1037
+ const extended = extendAdvanceAuthority({
1038
+ authority: existingAuthority,
1039
+ extensionId: `ext-${messageId}`,
1040
+ sessionId,
1041
+ message,
1042
+ messageClaims: data.messageClaims,
1043
+ });
1044
+ if (!extended.ok) {
1045
+ return { ...base, ok: false, errorCode: extended.code, message: extended.message };
1046
+ }
1047
+ const updated = {
1048
+ ...existingAuthority,
1049
+ extensions: [...existingAuthority.extensions, extended.value],
1050
+ };
1051
+ const storedExtension = putAuthorityInStore(data, { sessionId, runId, authority: updated });
1052
+ if (!storedExtension.ok) {
1053
+ return {
1054
+ ...base,
1055
+ ok: false,
1056
+ errorCode: storedExtension.errorCode,
1057
+ message: storedExtension.message,
1058
+ };
1059
+ }
1060
+ return {
1061
+ ...base,
1062
+ ok: true,
1063
+ action: "authorize",
1064
+ authorityId,
1065
+ extensions: updated.extensions.length,
1066
+ availableUnits: advanceUnitsAvailable(updated, execution.reserveDebits),
1067
+ };
1068
+ }
1069
+ const granted = grantAdvanceAuthority({
1070
+ authorityId,
1071
+ runId,
1072
+ sessionId,
1073
+ message,
1074
+ scope: {
1075
+ stages,
1076
+ decisionScope: coerceNonEmptyString(args.decisionScope) ?? "",
1077
+ fileBoundary: stringList(args.fileBoundary),
1078
+ reservedStops: stringList(args.reservedStops).filter((stage) => stage === "specification" ||
1079
+ stage === "planning" ||
1080
+ stage === "implementation" ||
1081
+ stage === "verification"),
1082
+ },
1083
+ existingAuthorities: execution.authority,
1084
+ messageClaims: data.messageClaims,
1085
+ });
1086
+ if (!granted.ok) {
1087
+ return { ...base, ok: false, errorCode: granted.code, message: granted.message };
1088
+ }
1089
+ const stored = addAuthorityInStore(data, {
1090
+ sessionId,
1091
+ runId,
1092
+ authority: granted.value.record,
1093
+ claim: granted.value.claim,
1094
+ });
1095
+ if (!stored.ok) {
1096
+ return { ...base, ok: false, errorCode: stored.errorCode, message: stored.message };
1097
+ }
1098
+ return {
1099
+ ...base,
1100
+ ok: true,
1101
+ action: "authorize",
1102
+ authorityId,
1103
+ units: granted.value.record.initialUnits,
1104
+ availableUnits: advanceUnitsAvailable(granted.value.record, []),
1105
+ };
1106
+ }
1107
+ case "record_approval": {
1108
+ const authorityId = coerceNonEmptyString(args.authorityId);
1109
+ const approvalId = coerceNonEmptyString(args.approvalId);
1110
+ const stage = coerceNonEmptyString(args.stage);
1111
+ const artifactPath = coerceNonEmptyString(args.artifactPath);
1112
+ const artifactSha256 = coerceNonEmptyString(args.artifactSha256);
1113
+ const authority = execution.authority.find((entry) => entry.authorityId === authorityId);
1114
+ if (!authority || !approvalId || !stage || !artifactPath || !artifactSha256) {
1115
+ return {
1116
+ ...base,
1117
+ ok: false,
1118
+ errorCode: "INVALID_INPUT",
1119
+ message: "record_approval requires a recorded authorityId, approvalId, stage, artifactPath, and artifactSha256",
1120
+ };
1121
+ }
1122
+ const approval = proposeStageApproval({
1123
+ authority,
1124
+ approvalId,
1125
+ stage,
1126
+ artifactPath,
1127
+ artifactSha256,
1128
+ provenance: "controller_delegated",
1129
+ });
1130
+ if (!approval.ok) {
1131
+ return { ...base, ok: false, errorCode: approval.code, message: approval.message };
1132
+ }
1133
+ const stored = addStageApprovalInStore(data, {
1134
+ sessionId,
1135
+ runId,
1136
+ approval: approval.value,
1137
+ });
1138
+ if (!stored.ok) {
1139
+ return { ...base, ok: false, errorCode: stored.errorCode, message: stored.message };
1140
+ }
1141
+ return {
1142
+ ...base,
1143
+ ok: true,
1144
+ action: "record_approval",
1145
+ approvalId,
1146
+ stage,
1147
+ provenance: approval.value.provenance,
1148
+ };
1149
+ }
1150
+ case "revoke_authority": {
1151
+ const authorityId = coerceNonEmptyString(args.authorityId);
1152
+ const revocationId = coerceNonEmptyString(args.revocationId);
1153
+ const authority = execution.authority.find((entry) => entry.authorityId === authorityId);
1154
+ if (!authority || !revocationId) {
1155
+ return {
1156
+ ...base,
1157
+ ok: false,
1158
+ errorCode: "INVALID_INPUT",
1159
+ message: "revoke_authority requires a recorded authorityId and a revocationId",
1160
+ };
1161
+ }
1162
+ const stagesArg = Array.isArray(args.stages) ? args.stages : undefined;
1163
+ const revoked = stagesArg === undefined || stagesArg.length === 0
1164
+ ? revokeAdvanceAuthority({
1165
+ authority,
1166
+ revocationId,
1167
+ reason: coerceNonEmptyString(args.rationale) ?? "revoked",
1168
+ })
1169
+ : narrowAdvanceAuthority({
1170
+ authority,
1171
+ revocationId,
1172
+ reason: coerceNonEmptyString(args.rationale) ?? "narrowed",
1173
+ narrowedStages: stagesArg.filter((stage) => stage === "specification" ||
1174
+ stage === "planning" ||
1175
+ stage === "implementation" ||
1176
+ stage === "verification"),
1177
+ });
1178
+ if (!revoked.ok) {
1179
+ return { ...base, ok: false, errorCode: revoked.code, message: revoked.message };
1180
+ }
1181
+ const updated = {
1182
+ ...authority,
1183
+ revocations: [...authority.revocations, revoked.value],
1184
+ };
1185
+ const stored = putAuthorityInStore(data, { sessionId, runId, authority: updated });
1186
+ if (!stored.ok) {
1187
+ return { ...base, ok: false, errorCode: stored.errorCode, message: stored.message };
1188
+ }
1189
+ return {
1190
+ ...base,
1191
+ ok: true,
1192
+ action: "revoke_authority",
1193
+ authorityId,
1194
+ kind: revoked.value.kind,
1195
+ availableUnits: advanceUnitsAvailable(updated, data.executions.get(runId)?.reserveDebits ?? []),
1196
+ stages: effectiveAuthorityStages(updated),
1197
+ };
1198
+ }
1199
+ default:
1200
+ return {
1201
+ ...base,
1202
+ ok: false,
1203
+ errorCode: "INVALID_INPUT",
1204
+ message: `unsupported generic checkpoint action ${String(args.action)}`,
1205
+ };
1206
+ }
1207
+ }
407
1208
  // START_CONTRACT: createWorkCheckpointTool
408
- // PURPOSE: Build work_checkpoint handler wrapping plan registration, checkpoint start, and fingerprint-verified outcomes.
409
- // INPUTS: { store: WorkItemStore - workflow in-memory store }
1209
+ // PURPOSE: Build work_checkpoint handler wrapping plan registration, checkpoint start, fingerprint-verified outcomes, and bounded checkpoint recovery.
1210
+ // INPUTS: { store: WorkItemStore - workflow in-memory store, options?: DelegatedControlOptions - optional read-only authorization lookup }
410
1211
  // OUTPUTS: { WorkflowToolDefinition<CheckpointArgs, Promise<Record<string, unknown>>> - async executable control tool definition }
411
1212
  // SIDE_EFFECTS: [Registers plan runs and mutates checkpoint state through the domain layer]
412
1213
  // LINKS: [M-WORKFLOW-TOOLING, M-WORKFLOW-CHECKPOINTS, M-WORKFLOW-DELEGATED]
413
1214
  // END_CONTRACT: createWorkCheckpointTool
414
- export function createWorkCheckpointTool(store) {
1215
+ export function createWorkCheckpointTool(store, options) {
415
1216
  return {
416
1217
  name: "work_checkpoint",
417
- description: "Register an approved delegated plan, start a declared review checkpoint, or verify checkpoint outcomes; verify with complete: true seals a finished final checkpoint.",
1218
+ description: "Register an approved delegated plan, start a declared review checkpoint, verify checkpoint outcomes, or recover a stopped or generation-exhausted checkpoint; verify with complete: true seals a finished final checkpoint.",
418
1219
  async execute(args, context, overrideStore) {
419
1220
  const s = overrideStore ?? store;
420
1221
  const action = args.action;
421
- if (action === "register") {
1222
+ if (action === "register" && coerceNonEmptyString(args.planPath)) {
422
1223
  const planPath = coerceNonEmptyString(args.planPath);
423
1224
  const workspaceRoot = coerceNonEmptyString(context.workspaceRoot);
424
1225
  if (!planPath || !workspaceRoot) {
@@ -474,6 +1275,23 @@ export function createWorkCheckpointTool(store) {
474
1275
  checkpoints: registered.run.checkpoints.size,
475
1276
  };
476
1277
  }
1278
+ const genericRunId = coerceNonEmptyString(args.runId);
1279
+ if (genericRunId) {
1280
+ const data = s.getStoreData();
1281
+ const execution = findExecution(data, genericRunId);
1282
+ // Authority actions apply to any execution, including native runs;
1283
+ // other generic actions stay on non-native executions.
1284
+ const authorityAction = action === "authorize" || action === "record_approval" || action === "revoke_authority";
1285
+ if (execution && (execution.source.kind !== "native-package" || authorityAction)) {
1286
+ return executeGenericCheckpoint({
1287
+ data,
1288
+ sessionId: context.sessionId,
1289
+ runId: genericRunId,
1290
+ args,
1291
+ ...(options ? { control: options } : {}),
1292
+ });
1293
+ }
1294
+ }
477
1295
  const runId = coerceNonEmptyString(args.runId);
478
1296
  const checkpointId = coerceNonEmptyString(args.checkpointId);
479
1297
  if (!runId || !checkpointId) {
@@ -541,12 +1359,127 @@ export function createWorkCheckpointTool(store) {
541
1359
  ...(verified.sealedRun !== undefined ? { sealedRun: verified.sealedRun } : {}),
542
1360
  };
543
1361
  }
1362
+ if (action === "recover") {
1363
+ const recoveryId = coerceNonEmptyString(args.recoveryId);
1364
+ const diagnosis = typeof args.diagnosis === "string" ? args.diagnosis : "";
1365
+ const changedCondition = typeof args.changedCondition === "string" ? args.changedCondition : "";
1366
+ const verification = Array.isArray(args.verification) ? args.verification.map(String) : [];
1367
+ const userMessageId = coerceNonEmptyString(args.userMessageId);
1368
+ if (!recoveryId) {
1369
+ return {
1370
+ tool: "work_checkpoint",
1371
+ sessionId: context.sessionId,
1372
+ ok: false,
1373
+ errorCode: "INVALID_INPUT",
1374
+ message: "INVALID_INPUT: recover requires a stable recoveryId, diagnosis, changedCondition, and verification references",
1375
+ };
1376
+ }
1377
+ const authorityId = coerceNonEmptyString(args.authorityId);
1378
+ let advanceGrantApproved = false;
1379
+ if (authorityId) {
1380
+ const execution = findExecution(s.getStoreData(), runId);
1381
+ const authority = execution?.authority.find((entry) => entry.authorityId === authorityId);
1382
+ if (!execution || !authority) {
1383
+ return {
1384
+ tool: "work_checkpoint",
1385
+ sessionId: context.sessionId,
1386
+ ok: false,
1387
+ errorCode: "AUTHORITY_NOT_FOUND",
1388
+ message: `AUTHORITY_NOT_FOUND: no recorded authority ${authorityId} for ${runId}`,
1389
+ };
1390
+ }
1391
+ const proposed = proposeReserveDebit({
1392
+ authority,
1393
+ debits: execution.reserveDebits,
1394
+ recoveryId,
1395
+ targetKind: "checkpoint",
1396
+ targetId: checkpointId,
1397
+ });
1398
+ // A failed proposal only means no advance unit is available; the
1399
+ // recovery may still be a cost-free resume or an ordinary grant.
1400
+ advanceGrantApproved = proposed.ok;
1401
+ }
1402
+ const recovered = await recoverDelegatedCheckpoint(s, {
1403
+ sessionId: context.sessionId,
1404
+ runId,
1405
+ checkpointId,
1406
+ diagnosis,
1407
+ changedCondition,
1408
+ verification,
1409
+ recoveryId,
1410
+ ...(advanceGrantApproved ? { advanceGrantApproved: true } : {}),
1411
+ ...(userMessageId !== undefined
1412
+ ? { userMessageId, lookupUserMessage: options?.lookupUserMessage }
1413
+ : {}),
1414
+ });
1415
+ if (!recovered.ok) {
1416
+ return {
1417
+ tool: "work_checkpoint",
1418
+ sessionId: context.sessionId,
1419
+ ok: false,
1420
+ errorCode: recovered.errorCode,
1421
+ message: recovered.message,
1422
+ };
1423
+ }
1424
+ if (recovered.kind === "advance_grant" && authorityId) {
1425
+ const execution = findExecution(s.getStoreData(), runId);
1426
+ const authority = execution?.authority.find((entry) => entry.authorityId === authorityId);
1427
+ const proposed = authority
1428
+ ? proposeReserveDebit({
1429
+ authority,
1430
+ debits: execution?.reserveDebits ?? [],
1431
+ recoveryId,
1432
+ targetKind: "checkpoint",
1433
+ targetId: checkpointId,
1434
+ })
1435
+ : undefined;
1436
+ if (!proposed?.ok) {
1437
+ return {
1438
+ tool: "work_checkpoint",
1439
+ sessionId: context.sessionId,
1440
+ ok: false,
1441
+ errorCode: proposed?.code ?? "RESERVE_EXHAUSTED",
1442
+ message: proposed?.message ?? "advance checkpoint recovery debit could not be recorded",
1443
+ };
1444
+ }
1445
+ const stored = addReserveDebitInStore(s.getStoreData(), {
1446
+ sessionId: context.sessionId,
1447
+ runId,
1448
+ debit: proposed.value,
1449
+ });
1450
+ if (!stored.ok) {
1451
+ return {
1452
+ tool: "work_checkpoint",
1453
+ sessionId: context.sessionId,
1454
+ ok: false,
1455
+ errorCode: stored.errorCode,
1456
+ message: stored.message,
1457
+ };
1458
+ }
1459
+ }
1460
+ return {
1461
+ tool: "work_checkpoint",
1462
+ sessionId: context.sessionId,
1463
+ ok: true,
1464
+ action: "recover",
1465
+ runId,
1466
+ checkpointId,
1467
+ recoveryId: recovered.recoveryId,
1468
+ kind: recovered.kind,
1469
+ generationBudget: recovered.generationBudget,
1470
+ ...(recovered.settledStoppedGeneration !== undefined
1471
+ ? { settledStoppedGeneration: recovered.settledStoppedGeneration }
1472
+ : {}),
1473
+ checkpointStatus: recovered.checkpoint.status,
1474
+ lastOutcome: recovered.checkpoint.lastOutcome,
1475
+ };
1476
+ }
544
1477
  return {
545
1478
  tool: "work_checkpoint",
546
1479
  sessionId: context.sessionId,
547
1480
  ok: false,
548
1481
  errorCode: "INVALID_INPUT",
549
- message: "INVALID_INPUT: action must be register, start, or verify",
1482
+ message: "INVALID_INPUT: action must be register, start, verify, or recover",
550
1483
  };
551
1484
  },
552
1485
  };