@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,8 +1,8 @@
1
1
  // FILE: src/plugins/workflow/index.ts
2
- // VERSION: 0.6.0
2
+ // VERSION: 0.7.0
3
3
  // START_MODULE_CONTRACT
4
- // PURPOSE: Register workflow tools and enforcement while injecting only startup-profile-compatible vv-controller guidance, including delegated control tools, host-call-bound attempts, and checkpoint reviewer linkage.
5
- // SCOPE: work_item_open/list/close registration, delegated-only work_item_decide and work_checkpoint registration with root-session authorization, tracked launch validation with delegated barriers and overlapping-write gates, live host-call bindings that convert supported foreground vv-implementer task launches into failed delegated attempts on confirmed host-terminal errors, result normalization and bounded same-session continuation with explicit hard-stop suppression, callID-bound delegated attempt results and checkpoint reviewer bookkeeping, round aggregation with bounded excerpts, implementation round limits, checked persistence, and profile-selected chat.message guidance.
4
+ // PURPOSE: Register workflow tools and enforcement while injecting only startup-profile-compatible vv-controller guidance, including delegated control tools with bounded recovery, host-call-bound attempts, terminal report-rejection settlement, and checkpoint reviewer linkage.
5
+ // SCOPE: work_item_open/list/close registration, delegated-only work_item_decide and work_checkpoint registration with root-session authorization and an SDK-backed read-only authorization-message lookup for user-authorized recovery, tracked launch validation with delegated barriers and overlapping-write gates, live host-call bindings that convert supported foreground vv-implementer task launches into failed delegated attempts on confirmed host-terminal errors, result normalization and bounded same-session continuation with explicit hard-stop suppression, callID-bound delegated attempt results, terminal settlement of protocol-invalid reports as report_rejected attempts through staged persistence, checkpoint reviewer bookkeeping, round aggregation with bounded excerpts, implementation round limits, durably committed recovery with synchronous persist and rollback so launch permissions appear only after a durable write, checked persistence, and profile-selected chat.message guidance.
6
6
  // DEPENDS: [@opencode-ai/plugin, src/lib/config-layers.ts, src/lib/orchestration.ts, src/lib/plugin-toggle-config.ts, src/plugins/workflow/checkpoint-io.ts, src/plugins/workflow/checkpoints.ts, src/plugins/workflow/delegated.ts, src/plugins/workflow/persistence.ts, src/plugins/workflow/protocol.ts, src/plugins/workflow/repair.ts, src/plugins/workflow/state.ts, src/plugins/workflow/tooling.ts, src/plugins/workflow/transitions.ts]
7
7
  // LINKS: M-PLUGIN-WORKFLOW, M-ORCHESTRATION-PROFILES, M-WORKFLOW-PROTOCOL, M-WORKFLOW-REPAIR, M-WORKFLOW-STATE, M-WORKFLOW-TRANSITIONS, M-WORKFLOW-TOOLING, M-WORKFLOW-PERSISTENCE, M-WORKFLOW-DELEGATED, M-WORKFLOW-CHECKPOINTS, V-M-PLUGIN-WORKFLOW
8
8
  // ROLE: RUNTIME
@@ -10,17 +10,17 @@
10
10
  // END_MODULE_CONTRACT
11
11
  //
12
12
  // START_MODULE_MAP
13
- // WorkflowPlugin - Registers workflow work-item tools, delegated control tools under the delegated profile, tracked task protocol enforcement with callID-bound delegated attempts, checkpoint linkage, and live host-call failure bindings, and primary-session workflow guidance injection.
13
+ // WorkflowPlugin - Registers workflow work-item tools, delegated control tools under the delegated profile, tracked task protocol enforcement with callID-bound delegated attempts, bounded recovery with durable persist-and-rollback commits, terminal report-rejection settlement, checkpoint linkage, live host-call failure bindings, and primary-session workflow guidance injection.
14
14
  // END_MODULE_MAP
15
15
  //
16
16
  // START_CHANGE_SUMMARY
17
- // LAST_CHANGE: [direct fix bounded result continuation - Replaced format-only repair wiring with one bounded same-session continuation that sends no prompt `tools` override, preserves persistent session permissions, and suppresses continuation for explicit malformed BLOCKED/NEEDS_CONTEXT output while keeping strict reparse and the original protocol-error excerpt path.]
17
+ // LAST_CHANGE: [C-WORKFLOW-BOUNDED-RECOVERY-R1 - Added decision/checkpoint recover wiring with an SDK-backed authorization lookup and durable persist-then-rollback commits, settled terminal protocol-invalid delegated reports as report_rejected attempts, and calibrated the delegated instruction for bounded recovery and native plan registration.]
18
18
  // END_CHANGE_SUMMARY
19
19
  import { tool } from "@opencode-ai/plugin";
20
- import { attemptTrackedResultRepair, hasExplicitHardStopStatus, isTrackedResultRepairEligible, unwrapResumableTaskResult, } from "./repair.js";
20
+ import { attemptTrackedResultRepair, detectExplicitHardStopStatus, isTrackedResultRepairEligible, unwrapResumableTaskResult, } from "./repair.js";
21
21
  import { parseResultBlock, parseWorkItemHeader, TRACKED_SUBAGENT_NAMES, } from "./protocol.js";
22
- import { applyTrackedResult, createRecordLookupKey, createWorkflowResultExcerpt, beginTrackedLaunch, createWorkItemStore, getReviewRound, getWorkItem, revertReviewerLaunch, } from "./state.js";
23
- import { applyDelegatedLaunchFailure, applyDelegatedResult, beginDelegatedLaunch, revertInFlightDelegatedLaunches, } from "./delegated.js";
22
+ import { applyTrackedResult, createRecordLookupKey, createWorkflowResultExcerpt, beginTrackedLaunch, createWorkItemStore, createWorkItemStoreView, getReviewRound, getWorkItem, revertReviewerLaunch, } from "./state.js";
23
+ import { applyDelegatedLaunchFailure, applyDelegatedReportRejection, applyDelegatedResult, beginDelegatedLaunch, revertInFlightDelegatedLaunches, summarizeDelegatedProgress, } from "./delegated.js";
24
24
  import { checkpointBarrierUnsatisfied, findOverlappingInFlightReview, recordCheckpointReviewerLaunch, recordCheckpointReviewerResult, } from "./checkpoints.js";
25
25
  import { getAllowedNextAgents, getAttemptedImplementationRound, getReviewerRoleForAgent, shouldBlockRound, } from "./transitions.js";
26
26
  import { createWorkItemCloseTool, createWorkItemDecideTool, createWorkItemListTool, createWorkItemOpenTool, createWorkCheckpointTool, } from "./tooling.js";
@@ -30,6 +30,8 @@ import { resolveOrchestrationPolicy, } from "../../lib/orchestration.js";
30
30
  import { isVvocPluginEnabled } from "../../lib/plugin-toggle-config.js";
31
31
  import { deleteWorkflowSessionDir, hydrateWorkflowStateChecked, snapshotWorkflowStateChecked, } from "./persistence.js";
32
32
  import { loadApprovedDelegatedPlan } from "./checkpoint-io.js";
33
+ import { isTaskLaunchableInStore } from "./execution.js";
34
+ import { runWorkflowTransaction, WorkflowTransactionQueue } from "./transactions.js";
33
35
  const z = tool.schema;
34
36
  const TRACKED_SUBAGENT_SET = new Set(TRACKED_SUBAGENT_NAMES);
35
37
  const WORK_ITEM_MISSING_MARKER = "__VVOC" + "_SECRET_BEARER_TOKEN_a6f582092f05__";
@@ -71,10 +73,31 @@ work_item_decide with the attempt number to accept or request changes. DONE_WITH
71
73
  an explicit concerns disposition. Controller retries are bounded to one correction attempt before
72
74
  explicit recovery; BLOCKED and NEEDS_CONTEXT remain hard stops.
73
75
 
74
- Register an approved plan once with work_checkpoint register, start each declared review checkpoint
75
- only after its prerequisite tasks are accepted, and run the due checkpoint before dependent waves.
76
- A checkpoint passes only when every declared reviewer passes against the pinned snapshot; a closed
77
- review-only FAIL report is a findings result, not approval. Do not write source files yourself:
76
+ Bounded recovery: when a delegated task stops (BLOCKED or NEEDS_CONTEXT) or exhausts its two
77
+ ordinary attempts without acceptance, diagnose it yourself and call work_item_decide with decision
78
+ "recover", the terminal attempt number, a diagnosis, the changed condition or approach, the
79
+ required verification references, and a stable recoveryId. A recovery resumes the same work item
80
+ and its ordinary budget; when a further attempt is necessary it grants exactly one. The single
81
+ autonomous grant per target is consumed on first use. After that, a further unit requires either a
82
+ fresh root-user message referenced by userMessageId — validated for role, session identity, and
83
+ timing, never wording — or a recorded advance authority referenced by runId and authorityId whose
84
+ finite shared reserve still has a unit. Recovery never accepts a result, replaces a reviewer, or
85
+ changes declared scope. A rejected report (a worker execution that finished with a protocol-invalid
86
+ result) is recorded with bounded diagnostics and has the same recovery path; it never becomes DONE.
87
+
88
+ Requirements may come from the supported approved native spec/plan package under .vvoc/specs/, a
89
+ provided plan reference, or the current conversation. Register a native package once with
90
+ work_checkpoint register (planPath); start and verify declared checkpoints only after their
91
+ prerequisite tasks are accepted. A provided-plan or conversation-scoped run is registered once with
92
+ work_item_open carrying an execution descriptor (executionKey, source, goal, boundary) and its
93
+ first task batch; later tasks append with the same runId plus amendmentId and rationale, and generic
94
+ review obligations run through work_checkpoint start/review/complete. A selected source stays
95
+ authoritative: a failed native package is never silently reopened as weaker generic execution, and
96
+ a provided document is never converted or executed as commands. A checkpoint passes only when
97
+ every declared reviewer passes against the pinned snapshot; a closed review-only FAIL report is a
98
+ findings result, not approval. A checkpoint generation that stopped or exhausted its ordinary
99
+ generations recovers through work_checkpoint action "recover" with the same bounded fields and the
100
+ same authority options. Do not write source files yourself:
78
101
  delegate implementation edits, including fixes requested by reviewers, through bounded task
79
102
  packets, while keeping planning artifacts, acceptance decisions, and verification commands in this
80
103
  session.
@@ -250,7 +273,6 @@ export const WorkflowPlugin = async ({ client, directory, worktree }) => {
250
273
  return {};
251
274
  const resolvedPolicy = resolveOrchestrationPolicy(vvoc.config);
252
275
  const workflowSystemInstruction = getWorkflowSystemInstruction(resolvedPolicy);
253
- const delegatedProfileActive = resolvedPolicy.profile === "delegated";
254
276
  const trustedWorkspaceRoot = worktree || directory;
255
277
  // START_BLOCK_PERSISTENCE_SETUP
256
278
  // Each session (main or subagent) gets its own isolated store.
@@ -452,6 +474,76 @@ export const WorkflowPlugin = async ({ client, directory, worktree }) => {
452
474
  })
453
475
  .catch(() => undefined);
454
476
  }
477
+ /**
478
+ * Settle a confirmed terminal protocol-invalid delegated report: the domain
479
+ * reducer applies to a staged copy, the staged state persists, and only then
480
+ * the settled record commits to the live store, returning the settled
481
+ * attempt number. A failed write keeps the live in-flight attempt so an
482
+ * unpersisted settlement never frees a launch slot.
483
+ */
484
+ function commitDelegatedReportRejection(sessionId, workItemId, callId, protocolErrorCode, excerpt, explicitHardStop) {
485
+ const liveStore = stores.get(sessionId);
486
+ if (!liveStore || invalidHydrationSessions.has(sessionId))
487
+ return undefined;
488
+ const liveData = liveStore.getStoreData();
489
+ const stagedStore = createWorkItemStore(liveData);
490
+ const applied = applyDelegatedReportRejection(stagedStore, {
491
+ sessionId,
492
+ workItemId,
493
+ callId,
494
+ protocolErrorCode,
495
+ excerpt,
496
+ ...(explicitHardStop ? { explicitHardStop } : {}),
497
+ });
498
+ if (!applied.ok) {
499
+ // Stale, duplicate, or already-transitioned: nothing to settle.
500
+ return undefined;
501
+ }
502
+ const stagedData = stagedStore.getStoreData();
503
+ const persisted = snapshotWorkflowStateChecked(sessionId, stagedData);
504
+ if (!persisted.ok) {
505
+ void client.app
506
+ .log({
507
+ body: {
508
+ service: "workflow",
509
+ level: "error",
510
+ message: "[workflow][reportRejection][BLOCK_REPORT_REJECTION] persistence failed",
511
+ extra: {
512
+ sessionID: sessionId,
513
+ workItemId,
514
+ attempt: applied.attempt,
515
+ error: persisted.error.slice(0, 300),
516
+ },
517
+ },
518
+ })
519
+ .catch(() => undefined);
520
+ return undefined;
521
+ }
522
+ const lookupKey = createRecordLookupKey(sessionId, workItemId);
523
+ const committed = stagedData.records.get(lookupKey);
524
+ if (!committed)
525
+ return undefined;
526
+ liveData.records.set(lookupKey, committed);
527
+ void client.app
528
+ .log({
529
+ body: {
530
+ service: "workflow",
531
+ level: "warn",
532
+ message: "[workflow][reportRejection][BLOCK_REPORT_REJECTION] terminal report rejected and settled",
533
+ extra: {
534
+ sessionID: sessionId,
535
+ workItemId,
536
+ attempt: applied.attempt,
537
+ protocolErrorCode,
538
+ observedHardStop: explicitHardStop,
539
+ consumedAttempts: applied.consumedAttempts,
540
+ attemptBudget: applied.attemptBudget,
541
+ },
542
+ },
543
+ })
544
+ .catch(() => undefined);
545
+ return applied.attempt;
546
+ }
455
547
  function handleDelegatedPartUpdated(properties) {
456
548
  const part = properties.part;
457
549
  if (!part || typeof part !== "object")
@@ -570,6 +662,185 @@ export const WorkflowPlugin = async ({ client, directory, worktree }) => {
570
662
  }
571
663
  }
572
664
  // END_BLOCK_DELEGATED_AUTHORIZATION
665
+ // START_BLOCK_RECOVERY_SUPPORT
666
+ // Read-only SDK message lookup for user-authorized recovery. Only identity
667
+ // and timing metadata (role, sessionID, id, time.created) are retained;
668
+ // message bodies never enter validation, persistence, or logs. Transport
669
+ // failures throw so the domain reports AUTHORIZATION_LOOKUP_FAILED instead
670
+ // of conflating an unreachable lookup with a nonexistent message.
671
+ const lookupRecoveryUserMessage = async (sessionId, messageId) => {
672
+ const response = await client.session.message({
673
+ path: { id: sessionId, messageID: messageId },
674
+ query: { directory },
675
+ });
676
+ if (response.error || !response.data) {
677
+ const errorName = response.error?.name;
678
+ if (errorName && errorName !== "NotFound") {
679
+ throw new Error(`session message lookup failed: ${errorName}`);
680
+ }
681
+ return undefined;
682
+ }
683
+ const info = response.data.info;
684
+ return {
685
+ role: info.role,
686
+ sessionID: info.sessionID,
687
+ id: info.id,
688
+ timeCreatedMs: typeof info.time?.created === "number" && Number.isFinite(info.time.created)
689
+ ? info.time.created
690
+ : undefined,
691
+ };
692
+ };
693
+ // Advance-authority provenance uses the same pinned SDK message response.
694
+ // Only verified identity/timing/eligibility metadata leaves this lookup; raw
695
+ // user text is never persisted or logged.
696
+ const lookupAuthorityMessage = async (input) => {
697
+ const response = await client.session.message({
698
+ path: { id: input.sessionId, messageID: input.messageId },
699
+ query: { directory },
700
+ });
701
+ if (response.error || !response.data) {
702
+ const errorName = response.error?.name;
703
+ if (errorName && errorName !== "NotFound") {
704
+ throw new Error(`authority message lookup failed: ${errorName}`);
705
+ }
706
+ return undefined;
707
+ }
708
+ const info = response.data.info;
709
+ const parts = Array.isArray(response.data.parts) ? response.data.parts : [];
710
+ const textParts = parts
711
+ .map((part) => part)
712
+ .filter((part) => part.type === "text" && typeof part.text === "string")
713
+ .map((part) => part.text);
714
+ return {
715
+ messageId: info.id ?? input.messageId,
716
+ sessionId: info.sessionID ?? input.sessionId,
717
+ role: info.role === "user" ? "user" : "assistant",
718
+ createdMs: typeof info.time?.created === "number" && Number.isFinite(info.time.created)
719
+ ? info.time.created
720
+ : 0,
721
+ ignored: info.ignored === true,
722
+ syntheticOnly: false,
723
+ textParts,
724
+ };
725
+ };
726
+ // Per-session serialization for generic mutating tool calls. The staged
727
+ // snapshot persists before the committed state is published, so a failed
728
+ // write never exposes new obligations, authority, or launch permissions.
729
+ const workflowTransactions = new WorkflowTransactionQueue();
730
+ async function commitGenericToolResult(sessionId, run) {
731
+ const liveStore = stores.get(sessionId);
732
+ if (!liveStore) {
733
+ return {
734
+ ok: false,
735
+ errorCode: "SESSION_MISMATCH",
736
+ message: `no live workflow store for session ${sessionId}`,
737
+ };
738
+ }
739
+ if (invalidHydrationSessions.has(sessionId)) {
740
+ return {
741
+ ok: false,
742
+ errorCode: "INVALID_STATE",
743
+ message: `persisted workflow state for session ${sessionId} is invalid`,
744
+ };
745
+ }
746
+ const outcome = await runWorkflowTransaction({
747
+ queue: workflowTransactions,
748
+ sessionId,
749
+ getData: () => liveStore.getStoreData(),
750
+ operation: async (staged) => {
751
+ const result = await run(createWorkItemStoreView(staged));
752
+ if (result.ok !== true) {
753
+ // Validation failed: persist nothing and publish nothing.
754
+ return { result, skipPersist: true };
755
+ }
756
+ return { result };
757
+ },
758
+ });
759
+ if (!outcome.ok) {
760
+ void client.app
761
+ .log({
762
+ body: {
763
+ service: "workflow",
764
+ level: "error",
765
+ message: "[workflow][generic][BLOCK_GENERIC_COMMIT] persistence failed",
766
+ extra: { sessionID: sessionId, error: outcome.error.slice(0, 300) },
767
+ },
768
+ })
769
+ .catch(() => undefined);
770
+ return {
771
+ ok: false,
772
+ errorCode: "PERSISTENCE_FAILED",
773
+ message: `generic workflow mutation could not be persisted: ${outcome.error}`,
774
+ };
775
+ }
776
+ return outcome.result;
777
+ }
778
+ /**
779
+ * Execute one recovery mutation durably on the live store: the domain
780
+ * reducer applies to the live entry (re-prechecking after any authorization
781
+ * await), and the whole live store is then persisted synchronously. If the
782
+ * write fails, the captured prior entry is restored so no unpersisted launch
783
+ * permission is ever exposed — there is no await between mutation, persist,
784
+ * and rollback, so no concurrent actor can observe the intermediate state,
785
+ * and no stale whole-store snapshot can regress concurrent transitions.
786
+ */
787
+ async function executeCommittedRecovery(sessionId, runRecovery, captureRestore, wasApplied) {
788
+ const liveStore = stores.get(sessionId);
789
+ if (!liveStore || invalidHydrationSessions.has(sessionId)) {
790
+ throw new Error(`CONTROL_DENIED: persisted workflow state for session ${sessionId} is invalid; resolve or remove it before new control mutations.`);
791
+ }
792
+ const restore = captureRestore();
793
+ const result = await runRecovery(liveStore);
794
+ if (!wasApplied(result)) {
795
+ return result;
796
+ }
797
+ const persisted = snapshotWorkflowStateChecked(sessionId, liveStore.getStoreData());
798
+ if (!persisted.ok) {
799
+ restore();
800
+ void client.app
801
+ .log({
802
+ body: {
803
+ service: "workflow",
804
+ level: "error",
805
+ message: "[workflow][recovery][BLOCK_RECOVERY_COMMIT] recovery persistence failed",
806
+ extra: { sessionID: sessionId, error: persisted.error.slice(0, 300) },
807
+ },
808
+ })
809
+ .catch(() => undefined);
810
+ throw new Error(`PERSISTENCE_FAILED: recovery applied in memory but could not be persisted and was rolled back: ${persisted.error}`);
811
+ }
812
+ return result;
813
+ }
814
+ /** Capture and restore one work-item record entry for recovery rollback. */
815
+ function captureRecordRestore(sessionId, workItemId) {
816
+ const liveStore = stores.get(sessionId);
817
+ if (!liveStore)
818
+ return () => undefined;
819
+ const liveData = liveStore.getStoreData();
820
+ const lookupKey = createRecordLookupKey(sessionId, workItemId);
821
+ const prior = liveData.records.get(lookupKey);
822
+ return () => {
823
+ if (prior) {
824
+ liveData.records.set(lookupKey, prior);
825
+ }
826
+ };
827
+ }
828
+ /** Capture and restore one checkpoint entry for recovery rollback. */
829
+ function captureCheckpointRestore(sessionId, runId, checkpointId) {
830
+ const liveStore = stores.get(sessionId);
831
+ if (!liveStore)
832
+ return () => undefined;
833
+ const run = liveStore.getStoreData().planRuns.get(runId);
834
+ if (!run)
835
+ return () => undefined;
836
+ const priorCheckpoint = run.checkpoints.get(checkpointId);
837
+ return () => {
838
+ if (priorCheckpoint) {
839
+ run.checkpoints.set(checkpointId, priorCheckpoint);
840
+ }
841
+ };
842
+ }
843
+ // END_BLOCK_RECOVERY_SUPPORT
573
844
  // START_BLOCK_CHECKPOINT_LINKAGE
574
845
  /** Find the in-flight checkpoint generation whose linked review item matches. */
575
846
  function findCheckpointByReviewItem(sessionId, reviewWorkItemId) {
@@ -592,8 +863,13 @@ export const WorkflowPlugin = async ({ client, directory, worktree }) => {
592
863
  const workItemOpenTool = createWorkItemOpenTool(dummyStore);
593
864
  const workItemListTool = createWorkItemListTool(dummyStore);
594
865
  const workItemCloseTool = createWorkItemCloseTool(dummyStore);
595
- const workItemDecideTool = createWorkItemDecideTool(dummyStore);
596
- const workCheckpointTool = createWorkCheckpointTool(dummyStore);
866
+ const workItemDecideTool = createWorkItemDecideTool(dummyStore, {
867
+ lookupUserMessage: lookupRecoveryUserMessage,
868
+ });
869
+ const workCheckpointTool = createWorkCheckpointTool(dummyStore, {
870
+ lookupUserMessage: lookupRecoveryUserMessage,
871
+ lookupAuthorityMessage,
872
+ });
597
873
  return {
598
874
  tool: {
599
875
  work_item_open: tool({
@@ -607,10 +883,42 @@ export const WorkflowPlugin = async ({ client, directory, worktree }) => {
607
883
  writeScope: z.array(z.string()).optional(),
608
884
  planRunId: z.string().optional(),
609
885
  planTaskId: z.string().optional(),
886
+ taskId: z.string().optional(),
887
+ goal: z.string().optional(),
888
+ acceptanceCriteria: z.array(z.string()).optional(),
889
+ verification: z.array(z.string()).optional(),
890
+ dependsOn: z.array(z.string()).optional(),
891
+ blockedBy: z.array(z.string()).optional(),
610
892
  })),
893
+ execution: z
894
+ .object({
895
+ executionKey: z.string(),
896
+ source: z.record(z.string(), z.unknown()),
897
+ goal: z.string(),
898
+ boundary: z.object({
899
+ files: z.array(z.string()),
900
+ directories: z.array(z.string()),
901
+ }),
902
+ checkpoints: z.array(z.record(z.string(), z.unknown())).optional(),
903
+ })
904
+ .optional(),
905
+ runId: z.string().optional(),
906
+ amendmentId: z.string().optional(),
907
+ rationale: z.string().optional(),
611
908
  },
612
909
  async execute(args, context) {
613
910
  assertWorkflowToolAccess(context.agent, "work_item_open");
911
+ const isGeneric = args.execution !== undefined || args.runId !== undefined;
912
+ if (isGeneric) {
913
+ // Hydrate/validate the session store before the transaction boundary
914
+ // reads it, matching every other tool entry point.
915
+ getOrCreateStore(context.sessionID);
916
+ const result = await commitGenericToolResult(context.sessionID, (view) => workItemOpenTool.execute(args, {
917
+ sessionId: context.sessionID,
918
+ workspaceRoot: trustedWorkspaceRoot,
919
+ }, view));
920
+ return stringifyToolOutput(result);
921
+ }
614
922
  const sessionStore = getOrCreateStore(context.sessionID);
615
923
  const opened = workItemOpenTool.execute(args, { sessionId: context.sessionID }, sessionStore);
616
924
  snapshotSession(context.sessionID);
@@ -641,73 +949,153 @@ export const WorkflowPlugin = async ({ client, directory, worktree }) => {
641
949
  return stringifyToolOutput(closed);
642
950
  },
643
951
  }),
644
- // The delegated control tools are registered only under the delegated
645
- // startup profile; other profiles keep their current tool-schema footprint.
646
- ...(delegatedProfileActive
647
- ? {
648
- work_item_decide: tool({
649
- description: workItemDecideTool.description,
650
- args: {
651
- workItemId: z.string(),
652
- attempt: z.number().int().min(1),
653
- decision: z.enum(["accept", "request_changes", "rework"]),
654
- rationale: z.string(),
655
- evidence: z.array(z.string()),
656
- concernsDisposition: z.string().optional(),
657
- runId: z.string().optional(),
658
- checkpointId: z.string().optional(),
659
- },
660
- async execute(args, context) {
661
- // Resolve the store first so invalid persisted state is detected
662
- // before the authorization check reports it as a control denial.
663
- const sessionStore = getOrCreateStore(context.sessionID);
664
- await assertPrimaryControllerMutation(context.agent, context.sessionID, { directory: context.directory, worktree: context.worktree }, "work_item_decide");
665
- const decided = workItemDecideTool.execute(args, { sessionId: context.sessionID }, sessionStore);
666
- if (decided.ok) {
667
- const persisted = snapshotSession(context.sessionID);
668
- if (!persisted.ok) {
669
- throw new Error(`PERSISTENCE_FAILED: decision applied in memory but could not be persisted: ${persisted.error}`);
670
- }
952
+ // Control tools are registered independent of the startup profile; they
953
+ // remain root-session, agent, workspace, and invalid-hydration gated at
954
+ // execution time.
955
+ ...{
956
+ work_item_decide: tool({
957
+ description: workItemDecideTool.description,
958
+ args: {
959
+ workItemId: z.string(),
960
+ attempt: z.number().int().min(1),
961
+ decision: z.enum(["accept", "request_changes", "rework", "recover"]),
962
+ rationale: z.string().optional(),
963
+ evidence: z.array(z.string()).optional(),
964
+ concernsDisposition: z.string().optional(),
965
+ runId: z.string().optional(),
966
+ checkpointId: z.string().optional(),
967
+ diagnosis: z.string().optional(),
968
+ changedCondition: z.string().optional(),
969
+ verification: z.array(z.string()).optional(),
970
+ recoveryId: z.string().optional(),
971
+ userMessageId: z.string().optional(),
972
+ authorityId: z.string().optional(),
973
+ },
974
+ async execute(args, context) {
975
+ // Resolve the store first so invalid persisted state is detected
976
+ // before the authorization check reports it as a control denial.
977
+ const sessionStore = getOrCreateStore(context.sessionID);
978
+ await assertPrimaryControllerMutation(context.agent, context.sessionID, { directory: context.directory, worktree: context.worktree }, "work_item_decide");
979
+ // Recovery is the only decision family that must persist
980
+ // before it exposes new launch permissions. Authority-bearing
981
+ // recovery stages the work-item recovery and the reserve debit in
982
+ // one serialized transaction so they commit or fail together.
983
+ if (args.decision === "recover") {
984
+ if (args.authorityId !== undefined) {
985
+ const staged = await commitGenericToolResult(context.sessionID, (view) => workItemDecideTool.execute(args, { sessionId: context.sessionID }, view));
986
+ return stringifyToolOutput(staged);
671
987
  }
672
- return stringifyToolOutput(decided);
673
- },
674
- }),
675
- work_checkpoint: tool({
676
- description: workCheckpointTool.description,
677
- args: {
678
- action: z.enum(["register", "start", "verify"]),
679
- planPath: z.string().optional(),
680
- runId: z.string().optional(),
681
- checkpointId: z.string().optional(),
682
- complete: z.boolean().optional(),
683
- },
684
- async execute(args, context) {
685
- // Resolve the store first so invalid persisted state is detected
686
- // before the authorization check reports it as a control denial.
687
- const sessionStore = getOrCreateStore(context.sessionID);
688
- await assertPrimaryControllerMutation(context.agent, context.sessionID, { directory: context.directory, worktree: context.worktree }, "work_checkpoint");
689
- const toolContext = {
690
- sessionId: context.sessionID,
691
- workspaceRoot: trustedWorkspaceRoot,
692
- loadPlan: async (planPath, workspaceRoot) => {
693
- const loaded = await loadApprovedDelegatedPlan({ workspaceRoot, planPath });
694
- return loaded.ok
695
- ? loaded.plan
696
- : { loadError: `${loaded.code}: ${loaded.message}` };
697
- },
698
- };
699
- const result = await workCheckpointTool.execute({ ...args }, toolContext, sessionStore);
700
- if (result.ok) {
701
- const persisted = snapshotSession(context.sessionID);
702
- if (!persisted.ok) {
703
- throw new Error(`PERSISTENCE_FAILED: checkpoint change applied in memory but could not be persisted: ${persisted.error}`);
704
- }
988
+ const workItemId = String(args.workItemId ?? "");
989
+ const recovered = await executeCommittedRecovery(context.sessionID, (liveStore) => workItemDecideTool.execute(args, { sessionId: context.sessionID }, liveStore), () => captureRecordRestore(context.sessionID, workItemId), (result) => result.ok === true);
990
+ return stringifyToolOutput(recovered);
991
+ }
992
+ const decided = await workItemDecideTool.execute(args, { sessionId: context.sessionID }, sessionStore);
993
+ if (decided.ok) {
994
+ const persisted = snapshotSession(context.sessionID);
995
+ if (!persisted.ok) {
996
+ throw new Error(`PERSISTENCE_FAILED: decision applied in memory but could not be persisted: ${persisted.error}`);
705
997
  }
998
+ }
999
+ return stringifyToolOutput(decided);
1000
+ },
1001
+ }),
1002
+ work_checkpoint: tool({
1003
+ description: workCheckpointTool.description,
1004
+ args: {
1005
+ action: z.enum([
1006
+ "register",
1007
+ "start",
1008
+ "verify",
1009
+ "recover",
1010
+ "review",
1011
+ "bind",
1012
+ "complete",
1013
+ "amend",
1014
+ "authorize",
1015
+ "record_approval",
1016
+ "revoke_authority",
1017
+ ]),
1018
+ planPath: z.string().optional(),
1019
+ runId: z.string().optional(),
1020
+ checkpointId: z.string().optional(),
1021
+ complete: z.boolean().optional(),
1022
+ diagnosis: z.string().optional(),
1023
+ changedCondition: z.string().optional(),
1024
+ verification: z.array(z.string()).optional(),
1025
+ recoveryId: z.string().optional(),
1026
+ userMessageId: z.string().optional(),
1027
+ checkpoints: z.array(z.record(z.string(), z.unknown())).optional(),
1028
+ tasks: z.array(z.record(z.string(), z.unknown())).optional(),
1029
+ amendmentId: z.string().optional(),
1030
+ rationale: z.string().optional(),
1031
+ startFingerprint: z.string().optional(),
1032
+ reviewer: z.string().optional(),
1033
+ authorityId: z.string().optional(),
1034
+ messageId: z.string().optional(),
1035
+ approvalId: z.string().optional(),
1036
+ stage: z.string().optional(),
1037
+ stages: z.array(z.string()).optional(),
1038
+ decisionScope: z.string().optional(),
1039
+ fileBoundary: z.array(z.string()).optional(),
1040
+ reservedStops: z.array(z.string()).optional(),
1041
+ artifactPath: z.string().optional(),
1042
+ artifactSha256: z.string().optional(),
1043
+ revocationId: z.string().optional(),
1044
+ },
1045
+ async execute(args, context) {
1046
+ // Resolve the store first so invalid persisted state is detected
1047
+ // before the authorization check reports it as a control denial.
1048
+ const sessionStore = getOrCreateStore(context.sessionID);
1049
+ await assertPrimaryControllerMutation(context.agent, context.sessionID, { directory: context.directory, worktree: context.worktree }, "work_checkpoint");
1050
+ const toolContext = {
1051
+ sessionId: context.sessionID,
1052
+ workspaceRoot: trustedWorkspaceRoot,
1053
+ loadPlan: async (planPath, workspaceRoot) => {
1054
+ const loaded = await loadApprovedDelegatedPlan({ workspaceRoot, planPath });
1055
+ return loaded.ok ? loaded.plan : { loadError: `${loaded.code}: ${loaded.message}` };
1056
+ },
1057
+ };
1058
+ // Generic (non-native) executions route through the atomic
1059
+ // transaction boundary: staged persist, then publish.
1060
+ const runIdArg = String(args.runId ?? "");
1061
+ const liveExecution = runIdArg
1062
+ ? sessionStore.getStoreData().executions.get(runIdArg)
1063
+ : undefined;
1064
+ const authorityAction = args.action === "authorize" ||
1065
+ args.action === "record_approval" ||
1066
+ args.action === "revoke_authority";
1067
+ const genericAction = args.action !== "register" &&
1068
+ liveExecution !== undefined &&
1069
+ (liveExecution.source.kind !== "native-package" || authorityAction);
1070
+ const nativeAuthorityRecover = args.action === "recover" &&
1071
+ args.authorityId !== undefined &&
1072
+ liveExecution !== undefined;
1073
+ const genericRegister = args.action === "register" && !args.planPath;
1074
+ if (genericAction || genericRegister || nativeAuthorityRecover) {
1075
+ const result = await commitGenericToolResult(context.sessionID, (view) => workCheckpointTool.execute(args, toolContext, view));
706
1076
  return stringifyToolOutput(result);
707
- },
708
- }),
709
- }
710
- : {}),
1077
+ }
1078
+ // Checkpoint recovery commits durably on the live store and
1079
+ // rolls the checkpoint entry back on write failure, so a
1080
+ // granted generation is never exposed before its state is
1081
+ // durably recorded.
1082
+ if (args.action === "recover") {
1083
+ const runId = String(args.runId ?? "");
1084
+ const checkpointId = String(args.checkpointId ?? "");
1085
+ const recovered = await executeCommittedRecovery(context.sessionID, (liveStore) => workCheckpointTool.execute(args, toolContext, liveStore), () => captureCheckpointRestore(context.sessionID, runId, checkpointId), (result) => result.ok === true);
1086
+ return stringifyToolOutput(recovered);
1087
+ }
1088
+ const result = await workCheckpointTool.execute({ ...args }, toolContext, sessionStore);
1089
+ if (result.ok) {
1090
+ const persisted = snapshotSession(context.sessionID);
1091
+ if (!persisted.ok) {
1092
+ throw new Error(`PERSISTENCE_FAILED: checkpoint change applied in memory but could not be persisted: ${persisted.error}`);
1093
+ }
1094
+ }
1095
+ return stringifyToolOutput(result);
1096
+ },
1097
+ }),
1098
+ },
711
1099
  },
712
1100
  "tool.execute.before": async (input, output) => {
713
1101
  if (input.tool !== "task") {
@@ -754,6 +1142,41 @@ export const WorkflowPlugin = async ({ client, directory, worktree }) => {
754
1142
  });
755
1143
  throw new Error(`${WORK_ITEM_MISSING_MARKER} LAUNCH_REJECTED_UNKNOWN_WORK_ITEM: no open work item ${header.value} exists in this session. Use work_item_open first or check state with work_item_list.`);
756
1144
  }
1145
+ // Generic executions enforce declared task dependencies and checkpoint
1146
+ // barriers at launch, not only at completion.
1147
+ if (subagentType === "vv-implementer") {
1148
+ const data = sessionStore.getStoreData();
1149
+ for (const execution of data.executions.values()) {
1150
+ if (execution.sessionId !== input.sessionID)
1151
+ continue;
1152
+ const boundTask = [...execution.tasks.values()].find((binding) => binding.workItemId === workItem.workItemId);
1153
+ if (!boundTask)
1154
+ continue;
1155
+ const launchable = isTaskLaunchableInStore(data, {
1156
+ sessionId: input.sessionID,
1157
+ runId: execution.runId,
1158
+ taskId: boundTask.taskId,
1159
+ });
1160
+ if (!launchable.ok) {
1161
+ await client.app.log({
1162
+ body: {
1163
+ service: "workflow",
1164
+ level: "warn",
1165
+ message: "[workflow][launchValidation][BLOCK_VALIDATE_LAUNCH] dependency gate",
1166
+ extra: {
1167
+ sessionID: input.sessionID,
1168
+ workItemId: workItem.workItemId,
1169
+ runId: execution.runId,
1170
+ taskId: boundTask.taskId,
1171
+ reason: launchable.reason,
1172
+ },
1173
+ },
1174
+ });
1175
+ throw new Error(`${INVALID_NEXT_AGENT_MARKER} LAUNCH_REJECTED_DEPENDENCY: task ${boundTask.taskId} cannot start yet: ${launchable.message}`);
1176
+ }
1177
+ break;
1178
+ }
1179
+ }
757
1180
  const allowedNextAgents = getAllowedNextAgents(workItem);
758
1181
  const reviewRound = getReviewRound(workItem);
759
1182
  const attemptedRound = subagentType === "vv-implementer" ? getAttemptedImplementationRound(workItem) : reviewRound;
@@ -984,7 +1407,7 @@ export const WorkflowPlugin = async ({ client, directory, worktree }) => {
984
1407
  expectedWorkItemId: header.value,
985
1408
  });
986
1409
  if (!parsed.ok) {
987
- const explicitHardStop = hasExplicitHardStopStatus(unwrapped.normalizedOutput);
1410
+ const explicitHardStop = detectExplicitHardStopStatus(unwrapped.normalizedOutput);
988
1411
  if (unwrapped.envelope &&
989
1412
  isTrackedResultRepairEligible(parsed.error.code) &&
990
1413
  !explicitHardStop) {
@@ -1042,8 +1465,44 @@ export const WorkflowPlugin = async ({ client, directory, worktree }) => {
1042
1465
  },
1043
1466
  });
1044
1467
  revertLaunch();
1468
+ // A confirmed terminal delegated execution whose report stays
1469
+ // protocol-invalid settles as a completed execution with a rejected
1470
+ // report: never in flight, never DONE, with the observed substantive
1471
+ // hard stop preserved and a supported recovery path remaining.
1472
+ // Unknown (non-string) or mismatched outputs never reach here.
1473
+ const currentForSettlement = getWorkItem(getOrCreateStore(input.sessionID), input.sessionID, header.value);
1474
+ const settledAttempt = (() => {
1475
+ if (!protocolFailureExcerpt || !currentForSettlement)
1476
+ return undefined;
1477
+ if (currentForSettlement.state === "closed" ||
1478
+ currentForSettlement.mode !== "delegated" ||
1479
+ subagentType !== "vv-implementer") {
1480
+ return undefined;
1481
+ }
1482
+ if (!currentForSettlement.delegated?.attempts.some((attempt) => attempt.status === "in_flight" && attempt.callId === input.callID)) {
1483
+ return undefined;
1484
+ }
1485
+ const observedHardStop = detectExplicitHardStopStatus(unwrapped.normalizedOutput);
1486
+ return commitDelegatedReportRejection(input.sessionID, header.value, input.callID, parsed.error.code, protocolFailureExcerpt, observedHardStop);
1487
+ })();
1488
+ const settlementLines = settledAttempt
1489
+ ? [
1490
+ `Report rejected: attempt ${settledAttempt} of ${header.value} settled as report_rejected with bounded diagnostics retained.`,
1491
+ (() => {
1492
+ const settledRecord = getWorkItem(getOrCreateStore(input.sessionID), input.sessionID, header.value);
1493
+ const progress = settledRecord
1494
+ ? summarizeDelegatedProgress(settledRecord)
1495
+ : undefined;
1496
+ return `Next action: ${progress?.nextAction ?? "inspect work_item_list"}${progress?.nextAction === "recover" ||
1497
+ progress?.nextAction === "recover_with_user_authorization"
1498
+ ? ' through work_item_decide decision "recover"'
1499
+ : ""}.`;
1500
+ })(),
1501
+ ]
1502
+ : [];
1045
1503
  throw new Error([
1046
1504
  `RESULT_PROTOCOL_ERROR: ${parsed.error.message}`,
1505
+ ...settlementLines,
1047
1506
  formatResultExcerptForError(protocolFailureExcerpt),
1048
1507
  ].join("\n"));
1049
1508
  }