@osovv/vv-opencode 1.3.8 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +66 -14
  3. package/dist/commands/completion.js +10 -2
  4. package/dist/commands/completion.js.map +1 -1
  5. package/dist/commands/patch-provider.d.ts +148 -1
  6. package/dist/commands/patch-provider.js +151 -5
  7. package/dist/commands/patch-provider.js.map +1 -1
  8. package/dist/lib/orchestration.d.ts +2 -2
  9. package/dist/lib/orchestration.js +31 -2
  10. package/dist/lib/orchestration.js.map +1 -1
  11. package/dist/lib/spec-lint.d.ts +71 -1
  12. package/dist/lib/spec-lint.js +566 -8
  13. package/dist/lib/spec-lint.js.map +1 -1
  14. package/dist/lib/vvoc-config.d.ts +3 -3
  15. package/dist/lib/vvoc-preset-registry.d.ts +42 -30
  16. package/dist/lib/vvoc-preset-registry.js +41 -31
  17. package/dist/lib/vvoc-preset-registry.js.map +1 -1
  18. package/dist/plugins/hashline-edit/index.js.map +1 -1
  19. package/dist/plugins/workflow/checkpoint-io.d.ts +33 -0
  20. package/dist/plugins/workflow/checkpoint-io.js +224 -0
  21. package/dist/plugins/workflow/checkpoint-io.js.map +1 -0
  22. package/dist/plugins/workflow/checkpoints.d.ts +182 -0
  23. package/dist/plugins/workflow/checkpoints.js +1067 -0
  24. package/dist/plugins/workflow/checkpoints.js.map +1 -0
  25. package/dist/plugins/workflow/delegated.d.ts +182 -0
  26. package/dist/plugins/workflow/delegated.js +751 -0
  27. package/dist/plugins/workflow/delegated.js.map +1 -0
  28. package/dist/plugins/workflow/index.js +685 -23
  29. package/dist/plugins/workflow/index.js.map +1 -1
  30. package/dist/plugins/workflow/persistence.d.ts +32 -3
  31. package/dist/plugins/workflow/persistence.js +516 -70
  32. package/dist/plugins/workflow/persistence.js.map +1 -1
  33. package/dist/plugins/workflow/repair.js +3 -1
  34. package/dist/plugins/workflow/repair.js.map +1 -1
  35. package/dist/plugins/workflow/snapshots.d.ts +59 -0
  36. package/dist/plugins/workflow/snapshots.js +261 -0
  37. package/dist/plugins/workflow/snapshots.js.map +1 -0
  38. package/dist/plugins/workflow/state.d.ts +20 -2
  39. package/dist/plugins/workflow/state.js +127 -12
  40. package/dist/plugins/workflow/state.js.map +1 -1
  41. package/dist/plugins/workflow/tooling.d.ts +45 -0
  42. package/dist/plugins/workflow/tooling.js +344 -12
  43. package/dist/plugins/workflow/tooling.js.map +1 -1
  44. package/dist/plugins/workflow/transitions.js +2 -2
  45. package/dist/plugins/workflow/transitions.js.map +1 -1
  46. package/dist/tui/context/analyze.js +3 -1
  47. package/dist/tui/context/analyze.js.map +1 -1
  48. package/package.json +1 -1
  49. package/schemas/vvoc/v3.json +5 -3
  50. package/templates/agents/vv-code-reviewer.md +3 -1
  51. package/templates/agents/vv-implementer.md +5 -2
  52. package/templates/agents/vv-spec-reviewer.md +3 -0
  53. package/templates/skills/vv-execute/SKILL.md +62 -15
  54. package/templates/skills/vv-plan/SKILL.md +13 -3
  55. package/templates/skills/vv-plan/references/plan-template.xml +45 -0
  56. package/templates/skills/vv-review/SKILL.md +1 -0
@@ -1,32 +1,35 @@
1
1
  // FILE: src/plugins/workflow/index.ts
2
- // VERSION: 0.5.1
2
+ // VERSION: 0.6.0
3
3
  // START_MODULE_CONTRACT
4
- // PURPOSE: Register workflow tools and enforcement while injecting only startup-profile-compatible vv-controller guidance.
5
- // SCOPE: work_item_open/list/close registration, tracked launch validation, result normalization and repair, round aggregation with bounded excerpts, implementation round limits, persistence, and profile-selected chat.message guidance.
6
- // DEPENDS: [@opencode-ai/plugin, src/lib/config-layers.ts, src/lib/orchestration.ts, src/lib/plugin-toggle-config.ts, src/plugins/workflow/persistence.ts, src/plugins/workflow/protocol.ts, src/plugins/workflow/repair.ts, src/plugins/workflow/state.ts, src/plugins/workflow/transitions.ts, src/plugins/workflow/tooling.ts]
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, V-M-PLUGIN-WORKFLOW
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 repair, 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.
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
+ // 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
9
9
  // MAP_MODE: EXPORTS
10
10
  // END_MODULE_CONTRACT
11
11
  //
12
12
  // START_MODULE_MAP
13
- // WorkflowPlugin - Registers workflow work-item tools, tracked task protocol enforcement, 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, checkpoint linkage, and 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: [v0.5.1 - Fixed session.deleted cleanup to read the session id from properties.info.id (per the SDK event shape), drop the in-memory store, and remove a duplicate directory deletion.]
17
+ // LAST_CHANGE: [direct fix inFlightAttempt after failed worker launch - Added live host-call launch bindings so a confirmed foreground vv-implementer task failure is recorded as a failed delegated attempt instead of stranding inFlightAttempt until restart, with sticky exclusions for background/resume/cancellation/after-hook paths and staged checked persistence before retry is exposed.]
18
18
  // END_CHANGE_SUMMARY
19
19
  import { tool } from "@opencode-ai/plugin";
20
20
  import { attemptTrackedResultRepair, isTrackedResultRepairEligible, unwrapResumableTaskResult, } from "./repair.js";
21
21
  import { parseResultBlock, parseWorkItemHeader, TRACKED_SUBAGENT_NAMES, } from "./protocol.js";
22
- import { applyTrackedResult, createWorkflowResultExcerpt, beginTrackedLaunch, createWorkItemStore, getReviewRound, getWorkItem, revertReviewerLaunch, } from "./state.js";
23
- import { getAllowedNextAgents, getAttemptedImplementationRound, shouldBlockRound, } from "./transitions.js";
24
- import { createWorkItemCloseTool, createWorkItemListTool, createWorkItemOpenTool, } from "./tooling.js";
22
+ import { applyTrackedResult, createRecordLookupKey, createWorkflowResultExcerpt, beginTrackedLaunch, createWorkItemStore, getReviewRound, getWorkItem, revertReviewerLaunch, } from "./state.js";
23
+ import { applyDelegatedLaunchFailure, applyDelegatedResult, beginDelegatedLaunch, revertInFlightDelegatedLaunches, } from "./delegated.js";
24
+ import { checkpointBarrierUnsatisfied, findOverlappingInFlightReview, recordCheckpointReviewerLaunch, recordCheckpointReviewerResult, } from "./checkpoints.js";
25
+ import { getAllowedNextAgents, getAttemptedImplementationRound, getReviewerRoleForAgent, shouldBlockRound, } from "./transitions.js";
26
+ import { createWorkItemCloseTool, createWorkItemDecideTool, createWorkItemListTool, createWorkItemOpenTool, createWorkCheckpointTool, } from "./tooling.js";
25
27
  import workflowSystemInstructionTemplate from "./system-instruction.md?raw";
26
28
  import { loadVvocConfig } from "../../lib/config-layers.js";
27
29
  import { resolveOrchestrationPolicy, } from "../../lib/orchestration.js";
28
30
  import { isVvocPluginEnabled } from "../../lib/plugin-toggle-config.js";
29
- import { deleteWorkflowSessionDir, hydrateWorkflowState, snapshotWorkflowState, } from "./persistence.js";
31
+ import { deleteWorkflowSessionDir, hydrateWorkflowStateChecked, snapshotWorkflowStateChecked, } from "./persistence.js";
32
+ import { loadApprovedDelegatedPlan } from "./checkpoint-io.js";
30
33
  const z = tool.schema;
31
34
  const TRACKED_SUBAGENT_SET = new Set(TRACKED_SUBAGENT_NAMES);
32
35
  const WORK_ITEM_MISSING_MARKER = "__VVOC" + "_SECRET_BEARER_TOKEN_a6f582092f05__";
@@ -55,6 +58,28 @@ results are findings and do not route to an implementer. Treat BLOCKED and NEEDS
55
58
  stops, validate delegated results yourself, run fresh verification, and close completed work items.
56
59
  </workflow_protocol>
57
60
  `.trim();
61
+ const DELEGATED_WORKFLOW_SYSTEM_INSTRUCTION = `
62
+ <workflow_protocol>
63
+ Delegated execution mechanics are available in this session. Open implementation tasks with
64
+ work_item_open using mode "delegated" and an empty requiredReviewers array, then dispatch exactly
65
+ one vv-implementer per task with a bounded task packet and the returned VVOC_WORK_ITEM_ID. Let the
66
+ worker complete its local edit, test, and fix cycle before it reports.
67
+
68
+ A DONE worker result waits in awaiting_acceptance: it never closes the work item and the worker
69
+ never accepts its own result. Inspect the changed code and evidence yourself, then call
70
+ work_item_decide with the attempt number to accept or request changes. DONE_WITH_CONCERNS requires
71
+ an explicit concerns disposition. Controller retries are bounded to one correction attempt before
72
+ explicit recovery; BLOCKED and NEEDS_CONTEXT remain hard stops.
73
+
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:
78
+ delegate implementation edits, including fixes requested by reviewers, through bounded task
79
+ packets, while keeping planning artifacts, acceptance decisions, and verification commands in this
80
+ session.
81
+ </workflow_protocol>
82
+ `.trim();
58
83
  /** Returns the exact workflow instruction compatible with one resolved policy. */
59
84
  function getWorkflowSystemInstruction(policy) {
60
85
  switch (policy.workflowGuidance) {
@@ -64,6 +89,8 @@ function getWorkflowSystemInstruction(policy) {
64
89
  return SELECTIVE_WORKFLOW_SYSTEM_INSTRUCTION;
65
90
  case "tracked":
66
91
  return workflowSystemInstructionTemplate.trim();
92
+ case "delegated":
93
+ return DELEGATED_WORKFLOW_SYSTEM_INSTRUCTION;
67
94
  }
68
95
  }
69
96
  function isTrackedSubagent(value) {
@@ -90,6 +117,46 @@ function readTaskPrompt(args) {
90
117
  }
91
118
  return value;
92
119
  }
120
+ function delegatedLaunchBindingKey(sessionId, callId) {
121
+ return `${sessionId}::${callId}`;
122
+ }
123
+ /**
124
+ * Read the launch-critical task arguments that decide fresh-exclusive
125
+ * eligibility. Background, resume, and command/subtask launches are excluded;
126
+ * contradictory non-boolean or non-string values also fail closed.
127
+ */
128
+ function readDelegatedLaunchEligibility(args) {
129
+ if (!args || typeof args !== "object") {
130
+ return { eligible: false, reason: "missing_launch_args" };
131
+ }
132
+ const record = args;
133
+ if ("background" in record && record.background !== false) {
134
+ return { eligible: false, reason: "requested_background" };
135
+ }
136
+ if ("task_id" in record && record.task_id !== undefined) {
137
+ const taskId = record.task_id;
138
+ if (typeof taskId !== "string" || taskId.trim() === "") {
139
+ return { eligible: false, reason: "invalid_task_id" };
140
+ }
141
+ return { eligible: false, reason: "resume_task_id", resumedTaskId: taskId.trim() };
142
+ }
143
+ // The host subtask path always supplies a `command` key (even when its value
144
+ // is undefined), and a slash-command launch always supplies a value, so key
145
+ // presence alone is the reliable command/subtask exclusion.
146
+ if ("command" in record) {
147
+ const command = record.command;
148
+ if (command !== undefined && command !== null && typeof command !== "string") {
149
+ return { eligible: false, reason: "invalid_command" };
150
+ }
151
+ return { eligible: false, reason: "command_or_subtask_launch" };
152
+ }
153
+ return { eligible: true };
154
+ }
155
+ /** Exact current-host wrapper that precedes a returned child task error. */
156
+ function delegatedHostFailurePrefix(childSessionId) {
157
+ return `Subagent failed (task_id: ${childSessionId}): `;
158
+ }
159
+ // END_BLOCK_DELEGATED_FAILURE_BINDING_TYPES
93
160
  function canUseWorkflowTools(agentName) {
94
161
  return agentName === WORKFLOW_CONTROLLER_AGENT;
95
162
  }
@@ -177,36 +244,347 @@ function createHardStopMessage(options) {
177
244
  "Inspect work_item_list before retrying.",
178
245
  ].join("\n");
179
246
  }
180
- export const WorkflowPlugin = async ({ client, directory }) => {
247
+ export const WorkflowPlugin = async ({ client, directory, worktree }) => {
181
248
  const vvoc = await loadVvocConfig({ cwd: directory });
182
249
  if (!isVvocPluginEnabled(vvoc.config, "workflow"))
183
250
  return {};
184
- const workflowSystemInstruction = getWorkflowSystemInstruction(resolveOrchestrationPolicy(vvoc.config));
251
+ const resolvedPolicy = resolveOrchestrationPolicy(vvoc.config);
252
+ const workflowSystemInstruction = getWorkflowSystemInstruction(resolvedPolicy);
253
+ const delegatedProfileActive = resolvedPolicy.profile === "delegated";
254
+ const trustedWorkspaceRoot = worktree || directory;
185
255
  // START_BLOCK_PERSISTENCE_SETUP
186
256
  // Each session (main or subagent) gets its own isolated store.
187
257
  // This prevents subagent tool calls from interfering with the main session's work items.
258
+ // Hydration uses the checked loader: invalid persisted state is tracked so new
259
+ // control transactions and snapshot writes fail closed instead of silently
260
+ // resetting a malformed run.
188
261
  const stores = new Map();
262
+ const invalidHydrationSessions = new Set();
189
263
  function getOrCreateStore(sessionId) {
190
264
  let store = stores.get(sessionId);
191
265
  if (!store) {
192
- const hydrated = hydrateWorkflowState(sessionId);
193
- store = createWorkItemStore(hydrated);
266
+ const hydrated = hydrateWorkflowStateChecked(sessionId);
267
+ if (hydrated.status === "valid") {
268
+ store = createWorkItemStore(hydrated.data);
269
+ // A freshly hydrated session crossed a process-restart boundary: any
270
+ // persisted in-flight delegated attempt can never receive its host
271
+ // callback again, so reclaim it without consuming the attempt budget.
272
+ const reclaimed = revertInFlightDelegatedLaunches(store.getStoreData(), sessionId);
273
+ if (reclaimed.reverted > 0) {
274
+ void client.app
275
+ .log({
276
+ body: {
277
+ service: "workflow",
278
+ level: "info",
279
+ message: "[workflow][hydration][BLOCK_RECLAIM_ATTEMPTS] orphaned in-flight delegated attempts reverted",
280
+ extra: { sessionID: sessionId, workItemIds: reclaimed.workItemIds },
281
+ },
282
+ })
283
+ .catch(() => undefined);
284
+ }
285
+ }
286
+ else {
287
+ if (hydrated.status === "invalid") {
288
+ invalidHydrationSessions.add(sessionId);
289
+ }
290
+ store = createWorkItemStore();
291
+ }
194
292
  stores.set(sessionId, store);
195
293
  }
196
294
  return store;
197
295
  }
198
296
  function snapshotSession(sessionId) {
199
297
  const store = stores.get(sessionId);
200
- if (store) {
201
- try {
202
- snapshotWorkflowState(sessionId, store.getStoreData());
298
+ if (!store)
299
+ return { ok: true };
300
+ if (invalidHydrationSessions.has(sessionId)) {
301
+ return {
302
+ ok: false,
303
+ error: `persisted workflow state for session ${sessionId} is invalid; refusing to overwrite it`,
304
+ };
305
+ }
306
+ const result = snapshotWorkflowStateChecked(sessionId, store.getStoreData());
307
+ return result.ok ? { ok: true } : { ok: false, error: result.error };
308
+ }
309
+ // END_BLOCK_PERSISTENCE_SETUP
310
+ // START_BLOCK_DELEGATED_FAILURE_BINDING
311
+ // Live host-call bindings let the plugin consume confirmed host-terminal
312
+ // failures of foreground vv-implementer tasks without hydrating a store from
313
+ // an event. Every exclusion is sticky: once a binding is tainted it can never
314
+ // authorize a failure transition.
315
+ const delegatedLaunchBindings = new Map();
316
+ const resumedChildSessions = new Set();
317
+ const childPromptMessageIds = new Map();
318
+ function taintDelegatedLaunchBinding(binding, reason) {
319
+ if (binding.eligible) {
320
+ binding.eligible = false;
321
+ binding.ineligibleReason = reason;
322
+ }
323
+ }
324
+ function taintBindingsForChild(childSessionId, reason) {
325
+ for (const binding of delegatedLaunchBindings.values()) {
326
+ if (binding.childSessionId === childSessionId)
327
+ taintDelegatedLaunchBinding(binding, reason);
328
+ }
329
+ }
330
+ /**
331
+ * Track distinct user-prompt message ids per session: a child session
332
+ * prompted with more than one distinct message is shared or resumed, so it
333
+ * can no longer be a fresh-exclusive child.
334
+ */
335
+ function observeChildPrompt(sessionId, messageId) {
336
+ if (!messageId)
337
+ return;
338
+ let messageIds = childPromptMessageIds.get(sessionId);
339
+ if (!messageIds) {
340
+ messageIds = new Set();
341
+ childPromptMessageIds.set(sessionId, messageIds);
342
+ }
343
+ if (messageIds.has(messageId))
344
+ return;
345
+ messageIds.add(messageId);
346
+ if (messageIds.size > 1) {
347
+ resumedChildSessions.add(sessionId);
348
+ taintBindingsForChild(sessionId, "child_reprompted");
349
+ }
350
+ }
351
+ function observeTaskLaunchArgs(args) {
352
+ const launch = readDelegatedLaunchEligibility(args);
353
+ if (!launch.resumedTaskId)
354
+ return;
355
+ resumedChildSessions.add(launch.resumedTaskId);
356
+ taintBindingsForChild(launch.resumedTaskId, "child_resumed");
357
+ }
358
+ function observeAfterHookEntry(sessionId, callId) {
359
+ const binding = delegatedLaunchBindings.get(delegatedLaunchBindingKey(sessionId, callId));
360
+ if (binding) {
361
+ binding.afterHookEntered = true;
362
+ }
363
+ }
364
+ function observeAfterHookMetadata(sessionId, callId, metadata) {
365
+ const binding = delegatedLaunchBindings.get(delegatedLaunchBindingKey(sessionId, callId));
366
+ if (!binding || !metadata || typeof metadata !== "object")
367
+ return;
368
+ const record = metadata;
369
+ const child = record.sessionId;
370
+ if (typeof child === "string" && child.trim() !== "") {
371
+ if (binding.childSessionId === undefined) {
372
+ binding.childSessionId = child;
203
373
  }
204
- catch {
205
- // Snapshot failures are non-blocking
374
+ else if (binding.childSessionId !== child) {
375
+ taintDelegatedLaunchBinding(binding, "metadata_child_replaced");
206
376
  }
207
377
  }
378
+ if (record.background === true)
379
+ taintDelegatedLaunchBinding(binding, "metadata_background");
380
+ if (typeof record.jobId === "string" && record.jobId !== "") {
381
+ taintDelegatedLaunchBinding(binding, "metadata_job");
382
+ }
383
+ if (record.interrupted === true)
384
+ taintDelegatedLaunchBinding(binding, "metadata_interrupted");
208
385
  }
209
- // END_BLOCK_PERSISTENCE_SETUP
386
+ /**
387
+ * Stage the failed-attempt transition, persist a checked snapshot of the
388
+ * staged state, and only then commit it in memory. There is no async yield
389
+ * between the final identity/exclusion check and the commit; a checked write
390
+ * failure leaves the live in-flight attempt (and retry block) intact.
391
+ */
392
+ function commitDelegatedLaunchFailure(sessionId, binding, failureExcerpt) {
393
+ const liveStore = stores.get(sessionId);
394
+ if (!liveStore || invalidHydrationSessions.has(sessionId))
395
+ return;
396
+ const liveData = liveStore.getStoreData();
397
+ const stagedStore = createWorkItemStore(liveData);
398
+ const applied = applyDelegatedLaunchFailure(stagedStore, {
399
+ sessionId,
400
+ workItemId: binding.workItemId,
401
+ callId: binding.callId,
402
+ failureExcerpt,
403
+ });
404
+ if (!applied.ok) {
405
+ // Stale, duplicate, or already-transitioned: settle without mutation.
406
+ binding.settled = true;
407
+ return;
408
+ }
409
+ const stagedData = stagedStore.getStoreData();
410
+ const persisted = snapshotWorkflowStateChecked(sessionId, stagedData);
411
+ if (!persisted.ok) {
412
+ // Keep the live in-flight attempt and evidence so an unpersisted retry
413
+ // is never authorized; a later authoritative event can retry safely.
414
+ void client.app
415
+ .log({
416
+ body: {
417
+ service: "workflow",
418
+ level: "error",
419
+ message: "[workflow][delegatedFailure][BLOCK_DELEGATED_FAILURE] persistence failed",
420
+ extra: {
421
+ sessionID: sessionId,
422
+ workItemId: binding.workItemId,
423
+ attempt: binding.attempt,
424
+ error: persisted.error.slice(0, 300),
425
+ },
426
+ },
427
+ })
428
+ .catch(() => undefined);
429
+ return;
430
+ }
431
+ const lookupKey = createRecordLookupKey(sessionId, binding.workItemId);
432
+ const committed = stagedData.records.get(lookupKey);
433
+ if (!committed)
434
+ return;
435
+ liveData.records.set(lookupKey, committed);
436
+ binding.settled = true;
437
+ void client.app
438
+ .log({
439
+ body: {
440
+ service: "workflow",
441
+ level: "warn",
442
+ message: "[workflow][delegatedFailure][BLOCK_DELEGATED_FAILURE] host-terminal launch failure recorded",
443
+ extra: {
444
+ sessionID: sessionId,
445
+ workItemId: binding.workItemId,
446
+ attempt: applied.attempt,
447
+ consumedAttempts: applied.consumedAttempts,
448
+ attemptBudget: applied.attemptBudget,
449
+ retryAllowed: applied.retryAllowed,
450
+ },
451
+ },
452
+ })
453
+ .catch(() => undefined);
454
+ }
455
+ function handleDelegatedPartUpdated(properties) {
456
+ const part = properties.part;
457
+ if (!part || typeof part !== "object")
458
+ return;
459
+ if (part.type !== "tool" || part.tool !== "task")
460
+ return;
461
+ const parentSessionId = typeof part.sessionID === "string" ? part.sessionID : undefined;
462
+ const callId = typeof part.callID === "string" ? part.callID : undefined;
463
+ if (!parentSessionId || !callId)
464
+ return;
465
+ const binding = delegatedLaunchBindings.get(delegatedLaunchBindingKey(parentSessionId, callId));
466
+ if (!binding)
467
+ return;
468
+ const state = part.state;
469
+ if (!state || typeof state !== "object")
470
+ return;
471
+ const isError = state.status === "error";
472
+ const metadata = state.metadata && typeof state.metadata === "object"
473
+ ? state.metadata
474
+ : undefined;
475
+ if (!metadata) {
476
+ // A non-error part may simply not carry metadata yet; an error without
477
+ // metadata can never be bound and is latched blocked to stay fail-closed.
478
+ if (isError)
479
+ taintDelegatedLaunchBinding(binding, "metadata_missing_on_error");
480
+ return;
481
+ }
482
+ const metaParent = metadata.parentSessionId;
483
+ const metaChild = metadata.sessionId;
484
+ if (metaParent !== parentSessionId) {
485
+ taintDelegatedLaunchBinding(binding, "metadata_parent_mismatch");
486
+ return;
487
+ }
488
+ if (typeof metaChild !== "string" || metaChild.trim() === "") {
489
+ taintDelegatedLaunchBinding(binding, "metadata_child_missing");
490
+ return;
491
+ }
492
+ if (binding.childSessionId === undefined) {
493
+ binding.childSessionId = metaChild;
494
+ }
495
+ else if (binding.childSessionId !== metaChild) {
496
+ taintDelegatedLaunchBinding(binding, "metadata_child_replaced");
497
+ return;
498
+ }
499
+ if (metadata.background === true) {
500
+ taintDelegatedLaunchBinding(binding, "metadata_background");
501
+ return;
502
+ }
503
+ if (typeof metadata.jobId === "string" && metadata.jobId !== "") {
504
+ taintDelegatedLaunchBinding(binding, "metadata_job");
505
+ return;
506
+ }
507
+ if (metadata.interrupted === true) {
508
+ taintDelegatedLaunchBinding(binding, "metadata_interrupted");
509
+ return;
510
+ }
511
+ if (resumedChildSessions.has(metaChild)) {
512
+ taintDelegatedLaunchBinding(binding, "child_resumed");
513
+ return;
514
+ }
515
+ if (binding.afterHookEntered || !binding.eligible || binding.settled)
516
+ return;
517
+ if (!isError)
518
+ return;
519
+ const error = state.error;
520
+ if (typeof error !== "string")
521
+ return;
522
+ if (!error.startsWith(delegatedHostFailurePrefix(metaChild)))
523
+ return;
524
+ const failureExcerpt = createWorkflowResultExcerpt({
525
+ text: error,
526
+ source: "normalized_output",
527
+ });
528
+ if (!failureExcerpt)
529
+ return;
530
+ if (!stores.has(parentSessionId) || invalidHydrationSessions.has(parentSessionId))
531
+ return;
532
+ commitDelegatedLaunchFailure(parentSessionId, binding, failureExcerpt);
533
+ }
534
+ // END_BLOCK_DELEGATED_FAILURE_BINDING
535
+ // START_BLOCK_DELEGATED_AUTHORIZATION
536
+ // New control mutations require the primary vv-controller session: the calling
537
+ // agent must be vv-controller, the session must be a root session (no
538
+ // parentID), and the ToolContext workspace must match the plugin's trusted
539
+ // directory/worktree. None of this identity comes from tool arguments.
540
+ async function assertPrimaryControllerMutation(agent, sessionId, contextWorkspace, toolName) {
541
+ if (!canUseWorkflowTools(agent)) {
542
+ throw new Error(`CONTROL_DENIED: ${toolName} is only available to ${WORKFLOW_CONTROLLER_AGENT} sessions. Current agent: ${agent?.trim() || "unknown-agent"}.`);
543
+ }
544
+ if (contextWorkspace.worktree !== undefined &&
545
+ contextWorkspace.worktree !== worktree &&
546
+ contextWorkspace.worktree !== trustedWorkspaceRoot) {
547
+ throw new Error(`CONTROL_DENIED: ${toolName} workspace ${contextWorkspace.worktree} does not match the trusted plugin workspace.`);
548
+ }
549
+ if (contextWorkspace.worktree === undefined &&
550
+ contextWorkspace.directory !== undefined &&
551
+ contextWorkspace.directory !== directory) {
552
+ throw new Error(`CONTROL_DENIED: ${toolName} directory ${contextWorkspace.directory} does not match the trusted plugin directory.`);
553
+ }
554
+ if (invalidHydrationSessions.has(sessionId)) {
555
+ throw new Error(`CONTROL_DENIED: persisted workflow state for session ${sessionId} is invalid; resolve or remove it before new control mutations.`);
556
+ }
557
+ let parentID;
558
+ try {
559
+ const response = await client.session.get({ path: { id: sessionId } });
560
+ if (!response.data) {
561
+ throw new Error("missing session payload");
562
+ }
563
+ parentID = response.data.parentID;
564
+ }
565
+ catch (error) {
566
+ throw new Error(`CONTROL_DENIED: ${toolName} requires root-session identity for ${sessionId}, which could not be verified: ${error.message}`);
567
+ }
568
+ if (parentID !== undefined && parentID !== null && parentID !== "") {
569
+ throw new Error(`CONTROL_DENIED: ${toolName} may only run in the root session; session ${sessionId} is a child of ${parentID}.`);
570
+ }
571
+ }
572
+ // END_BLOCK_DELEGATED_AUTHORIZATION
573
+ // START_BLOCK_CHECKPOINT_LINKAGE
574
+ /** Find the in-flight checkpoint generation whose linked review item matches. */
575
+ function findCheckpointByReviewItem(sessionId, reviewWorkItemId) {
576
+ for (const run of getOrCreateStore(sessionId).getStoreData().planRuns.values()) {
577
+ if (run.sessionId !== sessionId)
578
+ continue;
579
+ for (const checkpoint of run.checkpoints.values()) {
580
+ if (checkpoint.currentReview?.reviewWorkItemId === reviewWorkItemId) {
581
+ return { run, checkpointId: checkpoint.checkpointId };
582
+ }
583
+ }
584
+ }
585
+ return undefined;
586
+ }
587
+ // END_BLOCK_CHECKPOINT_LINKAGE
210
588
  // START_BLOCK_PLUGIN_ENTRY
211
589
  // Tool wrappers still need a store reference for description/args shape
212
590
  // but execute handlers resolve the right store per-call
@@ -214,6 +592,8 @@ export const WorkflowPlugin = async ({ client, directory }) => {
214
592
  const workItemOpenTool = createWorkItemOpenTool(dummyStore);
215
593
  const workItemListTool = createWorkItemListTool(dummyStore);
216
594
  const workItemCloseTool = createWorkItemCloseTool(dummyStore);
595
+ const workItemDecideTool = createWorkItemDecideTool(dummyStore);
596
+ const workCheckpointTool = createWorkCheckpointTool(dummyStore);
217
597
  return {
218
598
  tool: {
219
599
  work_item_open: tool({
@@ -224,6 +604,9 @@ export const WorkflowPlugin = async ({ client, directory }) => {
224
604
  title: z.string(),
225
605
  mode: z.string(),
226
606
  requiredReviewers: z.array(z.string()),
607
+ writeScope: z.array(z.string()).optional(),
608
+ planRunId: z.string().optional(),
609
+ planTaskId: z.string().optional(),
227
610
  })),
228
611
  },
229
612
  async execute(args, context) {
@@ -258,11 +641,81 @@ export const WorkflowPlugin = async ({ client, directory }) => {
258
641
  return stringifyToolOutput(closed);
259
642
  },
260
643
  }),
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
+ }
671
+ }
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
+ }
705
+ }
706
+ return stringifyToolOutput(result);
707
+ },
708
+ }),
709
+ }
710
+ : {}),
261
711
  },
262
712
  "tool.execute.before": async (input, output) => {
263
713
  if (input.tool !== "task") {
264
714
  return;
265
715
  }
716
+ // Observe resume identities from every task launch, including untracked
717
+ // ones, before any early return so a resumed child cannot stay eligible.
718
+ observeTaskLaunchArgs(output.args);
266
719
  const subagentType = readTaskSubagentType(output.args);
267
720
  if (!isTrackedSubagent(subagentType)) {
268
721
  return;
@@ -362,6 +815,90 @@ export const WorkflowPlugin = async ({ client, directory }) => {
362
815
  });
363
816
  throw new Error(`${INVALID_NEXT_AGENT_MARKER} LAUNCH_REJECTED_INVALID_TRANSITION: ${workItem.workItemId} in state ${workItem.state} only allows ${launched.allowedAgents.join(", ") || "no tracked agent"}. ${launched.message}`);
364
817
  }
818
+ // START_BLOCK_DELEGATED_LAUNCH_BINDING
819
+ // Delegated implementer launches consume a callID-bound attempt after the
820
+ // checkpoint barrier and overlapping-write gates pass. Reviewer launches
821
+ // on a checkpoint-linked review item bind their call identity to the
822
+ // current generation.
823
+ if (workItem.mode === "delegated" && subagentType === "vv-implementer") {
824
+ const data = sessionStore.getStoreData();
825
+ const planRunId = workItem.delegated?.planRunId;
826
+ if (planRunId) {
827
+ const run = data.planRuns.get(planRunId);
828
+ const binding = run
829
+ ? [...run.tasks.values()].find((task) => task.workItemId === workItem.workItemId)
830
+ : undefined;
831
+ const taskDefinition = run?.definition.tasks.find((task) => task.taskId === binding?.taskId);
832
+ if (run && taskDefinition) {
833
+ const barrier = checkpointBarrierUnsatisfied(data, planRunId, taskDefinition.wave);
834
+ if (barrier.ok && barrier.blockers.length > 0) {
835
+ throw new Error(`LAUNCH_REJECTED_CHECKPOINT_BARRIER: ${workItem.workItemId} is in wave ${taskDefinition.wave} blocked by unpassed checkpoint(s) ${barrier.blockers.join(", ")}.`);
836
+ }
837
+ }
838
+ const overlapping = findOverlappingInFlightReview(data, planRunId, workItem.delegated?.writeScope ?? []);
839
+ if (overlapping) {
840
+ throw new Error(`LAUNCH_REJECTED_OVERLAPPING_REVIEW: declared write scope overlaps checkpoint ${overlapping} currently in review.`);
841
+ }
842
+ }
843
+ const delegatedLaunch = beginDelegatedLaunch(sessionStore, {
844
+ sessionId: input.sessionID,
845
+ workItemId: workItem.workItemId,
846
+ callId: input.callID,
847
+ });
848
+ if (!delegatedLaunch.ok) {
849
+ await client.app.log({
850
+ body: {
851
+ service: "workflow",
852
+ level: "warn",
853
+ message: "[workflow][launchValidation][BLOCK_VALIDATE_LAUNCH] launch rejected",
854
+ extra: {
855
+ sessionID: input.sessionID,
856
+ agent: subagentType,
857
+ workItemId: workItem.workItemId,
858
+ reason: delegatedLaunch.errorCode,
859
+ },
860
+ },
861
+ });
862
+ throw new Error(`LAUNCH_REJECTED_DELEGATED_${delegatedLaunch.errorCode}: ${delegatedLaunch.message}`);
863
+ }
864
+ // Bind the successful launch so a later confirmed host-terminal error
865
+ // for this exact call can be consumed as a failed attempt. Eligibility
866
+ // captures background/resume/command exclusions up front.
867
+ const eligibility = readDelegatedLaunchEligibility(output.args);
868
+ delegatedLaunchBindings.set(delegatedLaunchBindingKey(input.sessionID, input.callID), {
869
+ sessionId: input.sessionID,
870
+ callId: input.callID,
871
+ workItemId: workItem.workItemId,
872
+ attempt: delegatedLaunch.attempt,
873
+ eligible: eligibility.eligible,
874
+ ineligibleReason: eligibility.reason,
875
+ afterHookEntered: false,
876
+ settled: false,
877
+ });
878
+ }
879
+ else {
880
+ const reviewerRole = getReviewerRoleForAgent(subagentType);
881
+ if (reviewerRole) {
882
+ const linked = findCheckpointByReviewItem(input.sessionID, workItem.workItemId);
883
+ if (linked) {
884
+ const bound = recordCheckpointReviewerLaunch(sessionStore, {
885
+ runId: linked.run.runId,
886
+ checkpointId: linked.checkpointId,
887
+ reviewer: reviewerRole,
888
+ callId: input.callID,
889
+ });
890
+ if (!bound.ok) {
891
+ revertReviewerLaunch(sessionStore, {
892
+ sessionId: input.sessionID,
893
+ workItemId: workItem.workItemId,
894
+ agent: subagentType,
895
+ });
896
+ throw new Error(`LAUNCH_REJECTED_CHECKPOINT_REVIEW: ${bound.errorCode}: ${bound.message}`);
897
+ }
898
+ }
899
+ }
900
+ }
901
+ // END_BLOCK_DELEGATED_LAUNCH_BINDING
365
902
  snapshotSession(input.sessionID);
366
903
  await client.app.log({
367
904
  body: {
@@ -381,6 +918,13 @@ export const WorkflowPlugin = async ({ client, directory }) => {
381
918
  });
382
919
  },
383
920
  "tool.execute.after": async (input, output) => {
921
+ // Latch the ambiguous after-hook path synchronously before any logging,
922
+ // parsing, or repair so a later error event for this call cannot be
923
+ // classified as a terminal launch failure.
924
+ if (input.tool === "task") {
925
+ observeAfterHookEntry(input.sessionID, input.callID);
926
+ observeAfterHookMetadata(input.sessionID, input.callID, output?.metadata);
927
+ }
384
928
  if (input.tool !== "task") {
385
929
  return;
386
930
  }
@@ -540,6 +1084,99 @@ export const WorkflowPlugin = async ({ client, directory }) => {
540
1084
  formatResultExcerptForError(resultExcerpt),
541
1085
  ].join("\n"));
542
1086
  }
1087
+ // START_BLOCK_DELEGATED_RESULT_BINDING
1088
+ // Delegated implementer results apply to their matching callID-bound
1089
+ // attempt; reviewer results on checkpoint-linked review items are
1090
+ // recorded into the current generation before the legacy round applies.
1091
+ if (current.mode === "delegated" && subagentType === "vv-implementer") {
1092
+ const appliedDelegated = applyDelegatedResult(sessionStore, {
1093
+ sessionId: input.sessionID,
1094
+ workItemId: parsed.value.workItemId,
1095
+ callId: input.callID,
1096
+ resultStatus: parsed.value.status,
1097
+ resultExcerpt,
1098
+ });
1099
+ if (!appliedDelegated.ok) {
1100
+ await client.app.log({
1101
+ body: {
1102
+ service: "workflow",
1103
+ level: "warn",
1104
+ message: "[workflow][resultParsing][BLOCK_PARSE_RESULT] protocol error",
1105
+ extra: {
1106
+ sessionID: input.sessionID,
1107
+ agent: subagentType,
1108
+ workItemId: parsed.value.workItemId,
1109
+ reason: appliedDelegated.errorCode,
1110
+ },
1111
+ },
1112
+ });
1113
+ throw new Error([
1114
+ `RESULT_PROTOCOL_ERROR: ${appliedDelegated.message}`,
1115
+ formatResultExcerptForError(resultExcerpt),
1116
+ ].join("\n"));
1117
+ }
1118
+ await client.app.log({
1119
+ body: {
1120
+ service: "workflow",
1121
+ level: "info",
1122
+ message: "[workflow][stateTransition][BLOCK_TRANSITION_STATE] state transitioned",
1123
+ extra: {
1124
+ sessionID: input.sessionID,
1125
+ agent: subagentType,
1126
+ workItemId: parsed.value.workItemId,
1127
+ fromState: appliedDelegated.fromState,
1128
+ toState: appliedDelegated.toState,
1129
+ mode: "delegated",
1130
+ attempt: appliedDelegated.attempt,
1131
+ },
1132
+ },
1133
+ });
1134
+ snapshotSession(input.sessionID);
1135
+ if (appliedDelegated.record.state === "needs_context" ||
1136
+ appliedDelegated.record.state === "blocked") {
1137
+ throw new Error(createHardStopMessage({
1138
+ record: appliedDelegated.record,
1139
+ triggeringAgent: subagentType,
1140
+ triggeringStatus: parsed.value.status,
1141
+ triggeringExcerpt: resultExcerpt,
1142
+ }));
1143
+ }
1144
+ return;
1145
+ }
1146
+ const reviewerRoleForCheckpoint = getReviewerRoleForAgent(subagentType);
1147
+ if (reviewerRoleForCheckpoint) {
1148
+ const linked = findCheckpointByReviewItem(input.sessionID, parsed.value.workItemId);
1149
+ if (linked) {
1150
+ const recorded = recordCheckpointReviewerResult(sessionStore, {
1151
+ runId: linked.run.runId,
1152
+ checkpointId: linked.checkpointId,
1153
+ reviewer: reviewerRoleForCheckpoint,
1154
+ callId: input.callID,
1155
+ status: parsed.value.status,
1156
+ });
1157
+ if (!recorded.ok) {
1158
+ await client.app.log({
1159
+ body: {
1160
+ service: "workflow",
1161
+ level: "warn",
1162
+ message: "[workflow][resultParsing][BLOCK_PARSE_RESULT] protocol error",
1163
+ extra: {
1164
+ sessionID: input.sessionID,
1165
+ agent: subagentType,
1166
+ workItemId: parsed.value.workItemId,
1167
+ reason: recorded.errorCode,
1168
+ },
1169
+ },
1170
+ });
1171
+ revertLaunch();
1172
+ throw new Error([
1173
+ `RESULT_PROTOCOL_ERROR: checkpoint ${linked.checkpointId} rejected the reviewer result: ${recorded.message}`,
1174
+ formatResultExcerptForError(resultExcerpt),
1175
+ ].join("\n"));
1176
+ }
1177
+ }
1178
+ }
1179
+ // END_BLOCK_DELEGATED_RESULT_BINDING
543
1180
  const applied = applyTrackedResult(sessionStore, {
544
1181
  sessionId: input.sessionID,
545
1182
  workItemId: parsed.value.workItemId,
@@ -601,8 +1238,26 @@ export const WorkflowPlugin = async ({ client, directory }) => {
601
1238
  output.message.system = appendSystemInstruction(output.message.system, workflowSystemInstruction);
602
1239
  },
603
1240
  event: async (input) => {
604
- const eventType = input.event.type;
605
- const properties = input.event.properties ?? {};
1241
+ const event = input.event;
1242
+ const eventType = event.type;
1243
+ const properties = event.properties ?? {};
1244
+ // message.part.updated identifies its session through properties.part,
1245
+ // not properties.sessionID, and must never hydrate a store.
1246
+ if (eventType === "message.part.updated") {
1247
+ handleDelegatedPartUpdated(properties);
1248
+ return;
1249
+ }
1250
+ // A child session that receives more than one user prompt has been
1251
+ // resumed or re-prompted and is no longer a fresh-exclusive child.
1252
+ if (eventType === "message.updated") {
1253
+ const messageInfo = properties.info;
1254
+ if (messageInfo &&
1255
+ messageInfo.role === "user" &&
1256
+ typeof messageInfo.sessionID === "string") {
1257
+ observeChildPrompt(messageInfo.sessionID, typeof messageInfo.id === "string" ? messageInfo.id : undefined);
1258
+ }
1259
+ return;
1260
+ }
606
1261
  const info = properties.info;
607
1262
  const eventSessionId = properties.sessionID ?? info?.id;
608
1263
  if (!eventSessionId)
@@ -614,6 +1269,13 @@ export const WorkflowPlugin = async ({ client, directory }) => {
614
1269
  return;
615
1270
  }
616
1271
  if (eventType === "session.deleted") {
1272
+ for (const [key, binding] of delegatedLaunchBindings) {
1273
+ if (binding.sessionId === eventSessionId) {
1274
+ delegatedLaunchBindings.delete(key);
1275
+ }
1276
+ }
1277
+ childPromptMessageIds.delete(eventSessionId);
1278
+ resumedChildSessions.delete(eventSessionId);
617
1279
  stores.delete(eventSessionId);
618
1280
  await deleteWorkflowSessionDir(eventSessionId);
619
1281
  await client.app.log({