@mcrescenzo/opencode-workflows 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/CODE_OF_CONDUCT.md +134 -0
  3. package/CONTRIBUTING.md +95 -0
  4. package/LICENSE +21 -0
  5. package/README.md +746 -0
  6. package/SECURITY.md +38 -0
  7. package/commands/repo-bughunt.md +94 -0
  8. package/commands/repo-review.md +148 -0
  9. package/commands/workflow-live-gates-release-check.md +143 -0
  10. package/docs/workflow-plugin.md +400 -0
  11. package/opencode-workflows.js +5 -0
  12. package/package.json +86 -0
  13. package/skills/opencode-workflow-authoring/SKILL.md +180 -0
  14. package/skills/repo-review-command-protocol/SKILL.md +56 -0
  15. package/skills/workflow-model-tiering/SKILL.md +57 -0
  16. package/skills/workflow-plan-review/SKILL.md +91 -0
  17. package/workflow-kernel/approval-hashing.js +39 -0
  18. package/workflow-kernel/async-util.js +33 -0
  19. package/workflow-kernel/audited-shell-policy.js +200 -0
  20. package/workflow-kernel/authority-policy.js +670 -0
  21. package/workflow-kernel/budget-accounting.js +142 -0
  22. package/workflow-kernel/capability-adapter.js +753 -0
  23. package/workflow-kernel/child-agent-runner.js +1264 -0
  24. package/workflow-kernel/constants.js +117 -0
  25. package/workflow-kernel/diagnostics.js +152 -0
  26. package/workflow-kernel/drain-runtime.js +421 -0
  27. package/workflow-kernel/errors.js +181 -0
  28. package/workflow-kernel/event-journal.js +487 -0
  29. package/workflow-kernel/extension-registry.js +144 -0
  30. package/workflow-kernel/free-text-redactor.js +91 -0
  31. package/workflow-kernel/gate-shapes.js +82 -0
  32. package/workflow-kernel/git-util.js +45 -0
  33. package/workflow-kernel/index.js +72 -0
  34. package/workflow-kernel/integration-mode.js +155 -0
  35. package/workflow-kernel/lane-effort-policy.js +134 -0
  36. package/workflow-kernel/lifecycle-control.js +608 -0
  37. package/workflow-kernel/live-gate-probes.js +916 -0
  38. package/workflow-kernel/notification-toast-cards.js +393 -0
  39. package/workflow-kernel/notification-toast-policy.js +179 -0
  40. package/workflow-kernel/notification-toast-scope.js +100 -0
  41. package/workflow-kernel/notification-toast.js +287 -0
  42. package/workflow-kernel/path-policy.js +219 -0
  43. package/workflow-kernel/result-readback.js +106 -0
  44. package/workflow-kernel/role-template-loading.js +606 -0
  45. package/workflow-kernel/run-context.js +139 -0
  46. package/workflow-kernel/run-observability.js +43 -0
  47. package/workflow-kernel/run-store-fs.js +231 -0
  48. package/workflow-kernel/run-store-locks.js +180 -0
  49. package/workflow-kernel/run-store-projections.js +421 -0
  50. package/workflow-kernel/run-store-rehydrate.js +68 -0
  51. package/workflow-kernel/run-store-state.js +147 -0
  52. package/workflow-kernel/run-store-status-format.js +1154 -0
  53. package/workflow-kernel/run-store-status.js +107 -0
  54. package/workflow-kernel/sandbox-executor.js +1131 -0
  55. package/workflow-kernel/session-access.js +56 -0
  56. package/workflow-kernel/structured-output.js +143 -0
  57. package/workflow-kernel/test-fix-drain-adapter.js +119 -0
  58. package/workflow-kernel/text-json.js +136 -0
  59. package/workflow-kernel/workflow-plugin.js +3017 -0
  60. package/workflow-kernel/workflow-source.js +444 -0
  61. package/workflow-kernel/worktree-adapter.js +256 -0
  62. package/workflow-kernel/worktree-git.js +147 -0
  63. package/workflows/repo-bughunt.js +362 -0
  64. package/workflows/repo-cleanup.js +383 -0
  65. package/workflows/repo-complexity.js +404 -0
  66. package/workflows/repo-deps.js +457 -0
  67. package/workflows/repo-modernize.js +395 -0
  68. package/workflows/repo-perf.js +394 -0
  69. package/workflows/repo-review.js +831 -0
  70. package/workflows/repo-security-audit.js +466 -0
  71. package/workflows/repo-test-gaps.js +377 -0
@@ -0,0 +1,400 @@
1
+ # Workflow Plugin: Source-Of-Truth Hierarchy And Transcript Fallback
2
+
3
+ > Status: **active technical contract**. This is the deep reference for the
4
+ > shipped workflow tool surface and recovery/source-of-truth behavior.
5
+
6
+ This document is the reference for how the opencode-workflows plugin decides what
7
+ to trust about a workflow run, and for the transcript-fallback / salvage
8
+ architecture that recovers orphaned lane results after a crash. It mirrors
9
+ shipped behavior; the README's "Source Of Truth And Transcript Fallback" section
10
+ is the operator-facing summary, and this file is the deeper architecture note.
11
+
12
+ ## Workflow Tool Reference
13
+
14
+ | Tool | Mutability | Approval / Hash Requirements | Safe Next Readback |
15
+ | --- | --- | --- | --- |
16
+ | `workflow_run` | Preview is read-only; approved execution creates run state and may launch lanes or approved domain mutations. | Default path: first call returns `approvalHash`; execution requires `approve: true` plus the matching `approvalHash`. With configured `options.autoApprove`, eligible `readOnly` / `worktree` / `all` tier runs can launch on the first call; `args.autoApprove` can only narrow the configured ceiling. Resume preserves the approved envelope unless changed. | `workflow_status({ runId, detail: "compact" })`, then `workflow_status({ runId, detail: "result" })` at terminal state. |
17
+ | `workflow_status` | Read-only. | None; `detail: "result"` requires a `runId`. | This is the authoritative readback surface. |
18
+ | `workflow_events` | Read-only. | None; requires a `runId`. | Use this for redacted `events.jsonl` access with `typePrefix`, `limit`, `offset`, and timestamp filters. |
19
+ | `workflow_reconcile` | Mutating recovery; persists stale-run recovery state and clears stale locks. | No approval hash; write-permission gated. | `workflow_status({ runId, detail: "full" })`. |
20
+ | `workflow_cancel` | Mutating lifecycle request; asks active or durable runs to cancel. | No approval hash; write-permission gated. | `workflow_status({ runId, detail: "full" })`. |
21
+ | `workflow_pause` | Mutating lifecycle request; asks active or durable runs to pause. | No approval hash; write-permission gated. | `workflow_status({ runId, detail: "full" })`; resume with `workflow_run({ resumeRunId })`. |
22
+ | `workflow_kill` | Mutating force-interrupt request for wedged runs. | No approval hash; write-permission gated. | `workflow_status({ runId, detail: "full" })`; resume only after interrupted/stale-lock state is settled. |
23
+ | `workflow_save` | Writes saved workflow source. | No approval hash; write-permission gated. | `workflow_list({ format: "json" })`. |
24
+ | `workflow_list` | Read-only. | None. | This is the machine-canonical workflow discovery surface. |
25
+ | `workflow_cleanup` | Dry-run is read-only; non-dry deletes safe terminal run directories. | No approval hash; write-permission gated for deletion. | Run with `dryRun: true` first; then `workflow_status({ limit, detail: "compact" })`. |
26
+ | `workflow_apply` | Mutates the primary tree and finalizes staged domain mutations. | Requires `approvalIntent: "apply"`, `approvedSourceHash`, `baseCommit`, `diffPlanHash`, and `domainMutationHash` from the reviewed run status. | `workflow_status({ runId, detail: "full" })` or `workflow_status({ runId, detail: "result" })` after apply. |
27
+ | `workflow_salvage` | Preview is read-only; approved salvage writes tagged synthetic journal entries for recovered read-only lanes. | Preview returns `approvalHash`; write requires `approve: true` plus matching `approvalHash`. | `workflow_status({ runId, detail: "full" })`. |
28
+ | `workflow_roles` | Read-only. | None. | This is the role prompt/hash/defaults readback surface. |
29
+ | `workflow_models` | Read-only. | None. | This is the model availability readback surface. |
30
+ | `workflow_templates` | Read-only; source retrieval is explicit. | None. | This is the shipped-template readback surface. |
31
+ | `workflow_template_save` | Writes saved workflow source from a shipped template. | No approval hash; write-permission gated. | `workflow_list({ format: "json" })`. |
32
+ | `workflow_live_gates` | No-probe report is read-only; probes/reset can create child sessions, scratch worktrees, and mutate probe cache. | Probe/reset flags require `approvalIntent: "probe"`; no hash. | `workflow_live_gates({ format: "json" })`; use `/workflow-live-gates-release-check` for full release evidence. |
33
+
34
+ ## Role Prompts And Defaults
35
+
36
+ Workflow roles keep prompt text in user-editable `.md` files. A sibling
37
+ `roles.json` file may declare typed defaults per role, including `model`,
38
+ `tier`, `tools`, `readOnly`, `retryCount`, `correctiveRetries`, `timeoutMs`,
39
+ `mcpPolicy`, `secretGlobs`, and `effort`. The defaults are merged before
40
+ explicit per-lane `agent()` opts, so explicit opts win, and the existing lane
41
+ authority policy still rejects any tool or policy escalation beyond the approved
42
+ run authority. `workflow_roles({ format: "json" })` reports each role's prompt
43
+ hash provenance and typed defaults.
44
+
45
+ ## How work moves between agents
46
+
47
+ The plugin passes work between subagents via **controller-owned, in-memory
48
+ return values** (`agent()`, `parallel()`, `pipeline()`), validated structured
49
+ outputs, and durable run artifacts. Subagents never hand off directly to each
50
+ other; the controller — the trusted kernel — is always the hub. Transcripts are
51
+ not the handoff substrate; they are a fallback for a narrow crash window only.
52
+
53
+ ## Source-of-truth hierarchy
54
+
55
+ Stronger evidence always wins. Weaker evidence is recovery/diagnostic only and
56
+ may never finalize work (close Beads, apply diffs, merge integration lanes):
57
+
58
+ 1. **Controller-owned run artifacts (authoritative).**
59
+ The append-only `journal.jsonl`, the durable `result.json`, the domain and
60
+ integration ledgers, and integration worktrees under
61
+ `.opencode/workflows/runs/<runId>/`. These are captured directly by the
62
+ controller and are the only evidence that may finalize domain mutations or
63
+ primary-tree writes. `journal.jsonl` is the authoritative resume cache.
64
+ 2. **`workflow_status` (persisted inspection).**
65
+ The authoritative read-only inspection and recovery surface over those
66
+ artifacts. `detail: "result"` returns final workflow output; `detail: "full"`
67
+ is for diagnostics/apply internals. `workflow_status` never mutates state.
68
+ Foreground `workflow_run` also includes the redacted workflow return value
69
+ inline when it fits `MAX_INLINE_RESULT_BYTES`; larger inline payloads fall back
70
+ to the persisted result readback command. `detail: "result"` uses
71
+ full-fidelity, secrets-only redaction while the readback fits
72
+ `MAX_RESULT_READBACK_BYTES`; above that it returns a partial projection plus
73
+ `resultReadback.truncated` metadata instead of refusing the result file.
74
+ `workflow_events` is the matching sanctioned event-level reader for
75
+ redacted lifecycle evidence from `events.jsonl`; use it instead of raw file
76
+ reads when you need cache, retry, fanout, debug-capture, or lifecycle events.
77
+ 3. **Session transcripts (diagnostic / fallback only).**
78
+ Child-session message history persisted in OpenCode's session store.
79
+ Transcript evidence is strictly weaker than a controller-captured structured
80
+ result. It is used only to surface and salvage orphaned lanes — never as the
81
+ primary substrate, never to finalize work.
82
+
83
+ Summarized: `journal / result / ledgers / worktrees` > `workflow_status` >
84
+ `workflow_events` > `session transcripts (diagnostic only)`.
85
+
86
+ ## Debug capture mode
87
+
88
+ Debug capture is off by default. Enable it for one run with
89
+ `workflow_run({ debugCapture: true })` or for the process with
90
+ `OPENCODE_WORKFLOWS_DEBUG_CAPTURE=1`.
91
+
92
+ When enabled, each completed child lane writes private artifacts under
93
+ `.opencode/workflows/runs/<runId>/debug/<lane>/`:
94
+
95
+ - `prompt.md`: rendered system prompt plus task prompt after free-text secret
96
+ redaction.
97
+ - `schema.json`: the lane schema after durable secret redaction.
98
+ - `transcript.jsonl`: child-session messages fetched via the SDK
99
+ `session.messages` path after durable secret redaction.
100
+
101
+ Each file is written with private file mode and a bounded size. Capture failures
102
+ are recorded as `debug_capture.*` events and do not fail the lane. This mode
103
+ increases local sensitive evidence and disk usage, so the default flag-off
104
+ behavior creates no `debug/` directory and makes no extra `session.messages`
105
+ calls.
106
+
107
+ `MAX_RESULT_BYTES` remains the guest return cap enforced before persistence.
108
+ It is intentionally coupled to the 32 MB QuickJS heap in
109
+ `workflow-kernel/sandbox-executor.js`; raising `MAX_RESULT_BYTES` or
110
+ `MAX_SOURCE_BYTES` should be reviewed with that heap limit, otherwise clean size
111
+ errors can become guest out-of-memory failures.
112
+
113
+ ## The crash window
114
+
115
+ `runChildAgent` persists the running lane projection (`lanes/<callId>.json`,
116
+ including `childID` and `signatureHash`) before `session.prompt`, but the
117
+ authoritative journal entry is written by `recordLaneOutcome` only after the
118
+ prompt returns, structured-output validation, and integration steps. If the
119
+ owning OpenCode process dies in that window, a completed child lane's result is
120
+ absent from workflow state even though the child's transcript still contains it.
121
+
122
+ A Phase 0 spike verified that child-session transcripts (including assistant
123
+ final replies) persist to OpenCode's session store and are readable via the SDK
124
+ by a fresh process pointing at the same data dir after the creating process has
125
+ died. That makes transcript salvage technically viable, but the recovered
126
+ evidence is still weaker than a controller-captured result, so salvage is opt-in,
127
+ schema-checked, tagged, and never auto-applies.
128
+
129
+ ## `workflow_salvage` (preview/approve, read-only scope)
130
+
131
+ `workflow_salvage` is the explicit, hash-gated recovery path for orphaned
132
+ read-only lanes. It is a mutating tool gated like `workflow_reconcile`
133
+ (`assertWriteWorkflowAllowed`) and is denied in plan mode.
134
+
135
+ Tool arguments:
136
+
137
+ | Arg | Meaning |
138
+ | --- | --- |
139
+ | `runId` (required) | The interrupted run to salvage from. |
140
+ | `callIds` (optional) | Narrow salvage to specific lane call ids. |
141
+ | `approve` (optional) | `true` to write; otherwise preview only. |
142
+ | `approvalHash` (optional) | Must match the preview's recomputed hash to write. |
143
+
144
+ ### Preview mode
145
+
146
+ A call without `approve` (or with a non-matching `approvalHash`) returns
147
+ `mode: "preview"` and writes nothing. For each candidate lane it reports:
148
+
149
+ - `callId`, `childID`
150
+ - `parseVerdict` (`valid` / `invalid`) and `validationKind: "json-parse"` with
151
+ `originalSchemaAvailable: false`
152
+ - `finalMessageFound`, `finalMessageLength`
153
+ - `resumeSignatureAvailable` (whether the running projection captured a
154
+ `signatureHash` that the resume path can match)
155
+ - a length-truncated `redactedSnippet` of the final assistant message, with
156
+ free-text secret masking (`redactFreeTextSecrets`) applied before truncation so a
157
+ credential pasted into the assistant reply is masked out of the preview (the raw
158
+ transcript under the run directory remains local-sensitive)
159
+ - `skipped` reason for non-salvageable lanes
160
+
161
+ It also returns an `approvalHash` computed over the per-candidate preview state,
162
+ so the operator approves exactly the transcript state they previewed.
163
+
164
+ ### Approve mode
165
+
166
+ Re-running with `approve: true` and the matching `approvalHash` writes a
167
+ synthetic journal entry for each non-skipped read-only lane, via
168
+ `writeSalvagedLaneOutcome` (a journal append plus lane-projection update on a
169
+ durable interrupted run directory). It never touches in-memory counters,
170
+ `state.json`, worktrees, or integration ledgers.
171
+
172
+ Validation is conservative JSON-parse only. The original per-lane AJV schema is
173
+ not durably persisted (it lives only in the in-memory resolved lane context
174
+ derived from the workflow script at runtime), so it cannot be reconstructed for
175
+ an existing orphan. Outcome is `success` only when the final assistant message
176
+ parses as JSON; otherwise the entry is written with outcome `failure` and an
177
+ error summary, and carries no `result`.
178
+
179
+ ## The `salvagedFromTranscript` tag
180
+
181
+ Every salvaged journal entry is tagged:
182
+
183
+ - `salvagedFromTranscript: true`
184
+ - `salvageValidation: { kind: "json-parse", originalSchemaAvailable: false }`
185
+
186
+ so a transcript-recovered result is never mistaken for a normally-captured,
187
+ schema-validated result. The tag is the provenance marker the resume path and the
188
+ integration gates read.
189
+
190
+ ## Read-only scope and the no-auto-apply rule
191
+
192
+ - **Read-only/report lanes only.** Edit/integration lanes (those carrying a
193
+ worktree path or integration-lane marker) are reported as
194
+ `salvage-skipped: edit-lane-without-commit` and are never salvaged. A salvaged
195
+ lane has no worktree commit by construction, and `integrate()` already requires
196
+ `lane.committed`. Unreadable transcripts are reported `transcript-unreadable:*`
197
+ and skipped.
198
+ - **Never finalizes domain mutations or primary writes.** Salvage never calls
199
+ `integrate()` or `runAutoApply`, never closes Beads, and never applies a diff.
200
+ It only appends a tagged synthetic journal entry and updates a lane projection.
201
+ - **No auto-apply / always explicit.** Salvage is never automatic on resume. It
202
+ requires an explicit preview, then an explicit approve with a matching hash.
203
+
204
+ ## Resume reuse and the code-enforced read-only-vs-edit asymmetry
205
+
206
+ On a later resume:
207
+
208
+ - A salvaged read-only result is reused as a cache hit (no re-run, no spend
209
+ re-accumulation) and emits a distinct `cache.salvaged_hit` event — separate
210
+ from `cache.hit` (normal journal replay) and `cache.checkpoint_hit` (lane
211
+ checkpoint replay) — so the weaker provenance stays observable. This works
212
+ because the running lane projection persists `signatureHash` and the salvaged
213
+ entry copies it, so the existing signature-based cache-hit predicate
214
+ (`classifyResumeCacheHit`) fires naturally. Legacy orphans interrupted before
215
+ the signature was persisted report `resumeSignatureAvailable: false` and are
216
+ re-run rather than trusted.
217
+ - If an edited workflow body inserts or reorders `agent()` calls, the new
218
+ positional `callId` may no longer match the prior journal entry. When the
219
+ lane's content signature still matches a prior successful journal entry in the
220
+ same fan-out scope, resume claim-once reuses that entry and emits
221
+ `cache.signature_hit` with `originalCallId`. Edit and integration plan entries
222
+ copied from the prior state are retagged in place to the new `callId`, avoiding
223
+ duplicate patch or lane entries.
224
+ - Integration lanes are filtered through `isLaneIntegrable`, which requires a
225
+ real worktree commit and rejects any `salvagedFromTranscript` lane even if it
226
+ somehow carried `committed: true`. The host-owned integrate closure adds
227
+ defense-in-depth, rejecting salvaged lanes with reason
228
+ `lane-salvaged-from-transcript`. So a salvaged claim can never reach
229
+ `integrate()` or `runAutoApply` — the asymmetry is enforced in code, not just
230
+ documented.
231
+
232
+ ## Raw SDK transcript reads
233
+
234
+ The controller reads child transcripts via the **raw SDK `session.messages`
235
+ API** (`sessionApi(pluginContext).messages({ sessionID })`), unredacted. It does
236
+ **not** route through the redacting `session_read` wrapper from the separate,
237
+ independently published [`@mcrescenzo/opencode-sessions`](https://github.com/mcrescenzo/opencode-sessions)
238
+ plugin (not a dependency of this package — named here only as a point of
239
+ comparison). This is intentional and consistent with the journal already storing
240
+ unredacted lane results; salvage recovers that same class of content. The preview
241
+ `redactedSnippet` is free-text secret-masked (`redactFreeTextSecrets`, detecting
242
+ Bearer/provider-token/AWS-key and common `key=value` secret assignments) and then
243
+ length-truncated for operator diagnosis. The masking governs only what is rendered
244
+ back to the operator at this display boundary; the raw transcript under the run
245
+ directory and the approval/source hashes (computed from `finalMessageHash`, never
246
+ from the masked snippet) remain local-sensitive and unaffected.
247
+
248
+ ## Narrowing the crash window: lane checkpoints
249
+
250
+ Workflow-native lane checkpointing narrows the crash window so transcript salvage
251
+ is rarely needed. The controller writes:
252
+
253
+ - `lanes/<callId>.request.json` before `session.prompt` (prompt-time intent), and
254
+ - `lanes/<callId>.result.json` immediately after the prompt returns.
255
+
256
+ On resume, a same-signature `result.json` checkpoint is consulted **before** the
257
+ authoritative journal check and reused without re-running the lane, emitting a
258
+ distinct `cache.checkpoint_hit` event. Checkpoint files are a narrower, earlier,
259
+ transcript-independent own-store capture; `journal.jsonl` remains the source of
260
+ truth and supersedes them (a superseded checkpoint is removed). Unlike transcript
261
+ salvage, checkpoint recovery is not approval-gated, because it is a
262
+ controller-owned own-store capture rather than weaker transcript evidence. A
263
+ failed checkpoint write degrades to re-running or to ordinary journal replay on
264
+ the next resume.
265
+
266
+ ## Background execution is not durable across process death
267
+
268
+ Transcript fallback recovers a completed child's result; it does **not** make
269
+ background execution durable.
270
+
271
+ A background run (`workflow_run({ background: true })`) executes inside the
272
+ owning OpenCode process and dies with it. When `background` is omitted, the
273
+ kernel defaults wide/deep/long runs to background when requested fan-out or
274
+ declared/requested duration trips the heuristic (per-call `maxAgents >= 8`, at
275
+ least three serialized concurrency waves from per-call `maxAgents` and
276
+ `concurrency`, or `maxRuntimeMs >= 600000`). Declared/default `maxAgents`
277
+ ceilings are not treated as predictions of actual fan-out. Explicit
278
+ `background: true` / `background: false` always wins, and resume keeps the
279
+ original pinned mode. If the host lacks `session.promptAsync`, background launch
280
+ still works but `workflow_run` warns that no completion prompt can be delivered;
281
+ use `workflow_status` polling for completion and final result readback.
282
+
283
+ There is no detached supervisor, no respawn, and no attach. After process death
284
+ the run directory is left behind and surfaces as stale until `workflow_reconcile`
285
+ marks it interrupted. Only then can `workflow_salvage` recover orphaned
286
+ read-only lane results that completed in the crash window. Completing the
287
+ underlying workflow work after process death remains out of scope until a
288
+ supervisor exists (tracked separately in `docs/claude-parity-roadmap.md`).
289
+
290
+ Notification recovery (re-enqueuing an already-persisted completion notice on
291
+ `session.idle`) is likewise only in-process recovery, not durable execution; see
292
+ the README's "Notification recovery is not durable execution" section.
293
+
294
+ ## Sizing `maxAgents`: child-agent accounting
295
+
296
+ `maxAgents` is a hard cap on the number of **child agent lanes a run launches**,
297
+ not on phases, on `parallel()` calls, or on JavaScript work in the workflow body.
298
+ The kernel tracks one counter, `agentsStarted`, and every `agent()` launch
299
+ increments it; a launch is refused with `WorkflowBudgetStoppedError` once
300
+ `agentsStarted >= maxAgents` (`workflow-kernel/child-agent-runner.js`:
301
+ `run.agentsStarted >= run.maxAgents`). The remaining slots are visible to the
302
+ workflow body as `await budget.remainingAgents()`
303
+ (`maxAgents - agentsStarted`, floored at 0).
304
+
305
+ **What counts as one agent:**
306
+
307
+ - Each `agent()` call counts **once**. A `parallel()` of five lanes is five
308
+ `agent()` launches and consumes five slots; a `pipeline()` of three stages is
309
+ three launches and consumes three slots.
310
+ - A lane that **replaces a prior lane on resume** (a cache-hit replay) does
311
+ **not** re-increment `agentsStarted` — only genuinely new launches consume
312
+ budget — so a resumed run does not double-count work it already paid for.
313
+
314
+ **What does NOT count:**
315
+
316
+ - **Pure-JavaScript aggregation / synthesis inside the workflow body.** Reducing,
317
+ merging, ranking, deduping, or formatting the structured return values of
318
+ lanes in plain JS (no `agent()` call) consumes **zero** agent slots. The
319
+ controller already owns those return values in memory; combining them is host
320
+ code, not a child lane.
321
+ - Phases, `log()`, `phase()`, and `budget` reads. These are control-flow and
322
+ bookkeeping, not child launches.
323
+
324
+ `budget.ceilings()` returns the approved `{ maxCost, maxTokens }` object, with
325
+ unset ceilings omitted. `budget.remaining()` returns
326
+ `{ cost: number|null, tokens: number|null }`; `null` means that ceiling is unset,
327
+ and numeric values include live spend, replayed spend, and in-flight
328
+ reservations. Use it with `budget.remainingAgents()` for loop-until-budget
329
+ workflows that should stop before launching another lane.
330
+
331
+ ### Synthesis: pure-JS versus agent-based
332
+
333
+ Whether a synthesis/aggregation step consumes a slot depends entirely on **how it
334
+ is implemented**, not on what it is called:
335
+
336
+ - **Pure-JS synthesis** (combine lane outputs in the workflow body): does not
337
+ call `agent()`, so it needs **no extra slot**.
338
+ - **Agent-based synthesis** (hand the lane outputs to one more `agent()` lane —
339
+ e.g. an LLM that writes a narrative report): that lane is a child launch and
340
+ needs **one more slot**.
341
+
342
+ ### Worked examples
343
+
344
+ | Topology | Agent lanes | `maxAgents` needed |
345
+ | --- | --- | --- |
346
+ | Five research lanes, results merged in JS | 5 | `>= 5` |
347
+ | Five research lanes + one agent-based synthesis lane | 6 | `>= 6` |
348
+ | Three pipeline stages, JS post-processing | 3 | `>= 3` |
349
+
350
+ Size `maxAgents` to **(number of `agent()` lanes you will launch) + (1 per
351
+ agent-based synthesis/aggregation lane)**. Pure-JS synthesis adds nothing.
352
+
353
+ ### Nested workflows share the parent budget
354
+
355
+ A `workflow()` call (nested workflow) does **not** get its own agent budget. The
356
+ nested body executes against the **same `RunContext`** as the parent
357
+ (`workflow-kernel/sandbox-executor.js`: `runNestedWorkflow` → `executeSandbox`
358
+ with the parent `run`), so every `agent()` launched inside the nested workflow
359
+ increments the **parent run's** `agentsStarted` and draws down the same
360
+ `maxAgents` ceiling. The nested workflow's own declared `meta.maxAgents` is
361
+ **ignored at runtime** — only the parent run's `maxAgents` (fixed at approval)
362
+ governs the combined launch count. Only one level of nesting is permitted, so the
363
+ budget you approve at the top is the budget for the whole tree: size it to cover
364
+ the parent lanes **plus** every lane any nested workflow will launch.
365
+
366
+ Nested workflow references must be statically visible at approval time. Use
367
+ `workflow("saved-name", args)` for saved workflows, or the explicit inline form
368
+ `workflow({ source: "return 1;", args })` for inline sources. The legacy string
369
+ source shorthand remains compatible for source strings containing a newline or
370
+ `export const meta`, but tiny inline sources should use the object form so they
371
+ are not mistaken for workflow names. Dynamic nested workflow names or sources are
372
+ rejected before approval because they cannot be snapshotted safely.
373
+
374
+ ## Per-lane model effort
375
+
376
+ Workflow lanes may request an OpenAI reasoning-effort hint with
377
+ `agent(prompt, { effort: "minimal" | "low" | "medium" | "high" })`. The kernel
378
+ validates the option before child-session creation, includes the resolved effort
379
+ policy in the lane signature, registers the live child session in a bounded
380
+ childID map, and applies the setting through the plugin `chat.params` hook as:
381
+
382
+ ```js
383
+ output.options.providerOptions.openai.reasoningEffort = "<effort>";
384
+ ```
385
+
386
+ The hook merge preserves any existing `output.options` and provider option bags.
387
+ The childID mapping is cleared on retry teardown and final lane cleanup, so it is
388
+ bounded to active child sessions.
389
+
390
+ This is deliberately provider-specific. Today `effort` is supported only when
391
+ the resolved lane model provider is `openai`; requesting it for another provider
392
+ fails before launching the child instead of silently ignoring the option. Extend
393
+ the provider table only after verifying the provider's concrete `chat.params`
394
+ option key.
395
+
396
+ The native variant-selector path was inspected but is not a shipped contract for
397
+ this plugin. The pinned v1 `session.prompt` type used by the wrapper does not
398
+ expose `variant`, while the v2 generated type does; without a behavioral probe
399
+ proving `session.prompt` accepts a variant selector in this lane path, effort is
400
+ implemented only through the proven `chat.params` provider-options mechanism.
@@ -0,0 +1,5 @@
1
+ // Plugin entry. The kernel barrel (workflow-kernel/index.js) owns the orchestrator
2
+ // export and the aggregated __test surface; the entry just re-exports the plugin factory
3
+ // so opencode loads exactly one factory here. Unit tests import the barrel (or the real
4
+ // modules) directly rather than this entry, so the entry no longer wires __test.
5
+ export { default } from "./workflow-kernel/index.js";
package/package.json ADDED
@@ -0,0 +1,86 @@
1
+ {
2
+ "name": "@mcrescenzo/opencode-workflows",
3
+ "version": "0.1.0",
4
+ "description": "opencode plugin providing durable, resumable multi-agent workflow orchestration (repo-review, a generic drain harness, and related workflows).",
5
+ "type": "module",
6
+ "main": "./opencode-workflows.js",
7
+ "homepage": "https://github.com/mcrescenzo/opencode-workflows#readme",
8
+ "bugs": {
9
+ "url": "https://github.com/mcrescenzo/opencode-workflows/issues"
10
+ },
11
+ "author": "Michael Crescenzo",
12
+ "exports": {
13
+ ".": "./opencode-workflows.js"
14
+ },
15
+ "files": [
16
+ "opencode-workflows.js",
17
+ "workflow-kernel/",
18
+ "workflows/",
19
+ "commands/",
20
+ "skills/",
21
+ "docs/workflow-plugin.md",
22
+ "README.md",
23
+ "CONTRIBUTING.md",
24
+ "CODE_OF_CONDUCT.md",
25
+ "CHANGELOG.md",
26
+ "SECURITY.md"
27
+ ],
28
+ "license": "MIT",
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/mcrescenzo/opencode-workflows"
35
+ },
36
+ "keywords": [
37
+ "opencode",
38
+ "opencode-plugin",
39
+ "workflow",
40
+ "orchestration"
41
+ ],
42
+ "engines": {
43
+ "node": ">=20.11.0"
44
+ },
45
+ "scripts": {
46
+ "test": "node --test tests/*.test.mjs",
47
+ "test:workflow-kernel": "node --test tests/drain-runtime.test.mjs tests/durable-state.test.mjs tests/multi-process-durability.test.mjs tests/notification-toast.test.mjs tests/run-store-status-salvage.test.mjs tests/workflow-checkpoint.test.mjs tests/workflow-salvage-resume.test.mjs tests/workflow-salvage.test.mjs",
48
+ "test:workflow-adapters": "node --test tests/beads-drain-adapter.test.mjs tests/test-fix-drain-adapter.test.mjs",
49
+ "test:beads-drain": "node --test tests/beads-drain-workflow.test.mjs tests/beads-drain-adapter.test.mjs tests/beads-drain-scratch.test.mjs tests/beads-drain-assets.test.mjs",
50
+ "test:extension-seam": "node --test tests/extension-registry.test.mjs tests/extension-wiring.test.mjs tests/extension-tool-contribution.test.mjs tests/extension-dir-resolution.test.mjs tests/extension-auto-apply-trust.test.mjs tests/extension-command-skill-registration.test.mjs",
51
+ "test:live-gates": "node --test tests/live-gate-probes.test.mjs tests/live-gates-integration.test.mjs tests/live-gates-permission.test.mjs tests/live-gates-runtime.test.mjs tests/live-gates-worktree.test.mjs",
52
+ "test:repo-bughunt": "node --test tests/repo-bughunt.test.mjs",
53
+ "test:repo-bughunt-command": "node --test tests/repo-bughunt-command-assets.test.mjs",
54
+ "test:repo-review-contract": "node --test tests/repo-review-leaf-contract.test.mjs",
55
+ "test:repo-review-command": "node --test tests/repo-review-command-assets.test.mjs",
56
+ "test:repo-review-cost-model-tier": "node --test tests/repo-review-cost-model-tier.test.mjs",
57
+ "test:repo-review-degraded-coverage": "node --test tests/repo-review-degraded-coverage.test.mjs",
58
+ "test:repo-review-meta-args": "node --test tests/repo-review-meta-arg-contract.test.mjs tests/repo-review-meta-smoke.test.mjs",
59
+ "test:repo-review-meta": "node --test tests/repo-review-meta.test.mjs",
60
+ "test:repo-review-meta-smoke": "node --test tests/repo-review-meta-smoke.test.mjs",
61
+ "test:repo-review-merge-determinism": "node --test tests/repo-review-merge-determinism.test.mjs",
62
+ "test:repo-review-no-mutation": "node --test tests/repo-review-no-mutation.test.mjs",
63
+ "test:repo-review-secret-containment": "node --test tests/repo-review-secret-containment.test.mjs",
64
+ "test:repo-security-audit": "node --test tests/repo-security-audit.test.mjs",
65
+ "test:repo-test-gaps": "node --test tests/repo-test-gaps.test.mjs",
66
+ "test:repo-cleanup": "node --test tests/repo-cleanup.test.mjs",
67
+ "test:repo-modernize": "node --test tests/repo-modernize.test.mjs",
68
+ "test:repo-perf": "node --test tests/repo-perf.test.mjs",
69
+ "test:repo-complexity": "node --test tests/repo-complexity.test.mjs",
70
+ "test:repo-deps": "node --test tests/repo-deps.test.mjs",
71
+ "test:lockfile-sync": "node scripts/check-lockfile-sync.mjs",
72
+ "test:workflows": "node --test tests/workflow-run.test.mjs tests/workflow-apply.test.mjs tests/model-tiering.test.mjs tests/repo-bughunt.test.mjs tests/repo-bughunt-command-assets.test.mjs tests/repo-review-leaf-contract.test.mjs tests/repo-security-audit.test.mjs tests/repo-test-gaps.test.mjs tests/repo-cleanup.test.mjs tests/repo-modernize.test.mjs tests/repo-perf.test.mjs tests/repo-complexity.test.mjs tests/repo-deps.test.mjs tests/repo-review-meta-arg-contract.test.mjs tests/repo-review-meta-defaults.test.mjs tests/repo-review-domain-scope.test.mjs tests/repo-review-lane-coverage.test.mjs tests/repo-review-artifacts.test.mjs tests/repo-review-inventory.test.mjs tests/repo-review-scalable-merge.test.mjs tests/repo-review-observability.test.mjs tests/repo-review-deep-modes.test.mjs tests/repo-review-docs-parity.test.mjs tests/repo-review-final-gate.test.mjs tests/repo-review-secret-containment.test.mjs tests/repo-review-meta-smoke.test.mjs tests/repo-review-meta.test.mjs tests/repo-review-merge-determinism.test.mjs tests/repo-review-no-mutation.test.mjs tests/repo-review-cost-model-tier.test.mjs tests/repo-review-degraded-coverage.test.mjs tests/repo-review-command-assets.test.mjs",
73
+ "release:no-token": "node scripts/release-no-token.mjs",
74
+ "release:child-system-smoke": "node scripts/child-system-smoke.mjs",
75
+ "release:system-smoke-required": "node scripts/child-system-smoke.mjs --required",
76
+ "test:parent-integration": "node scripts/parent-integration.mjs"
77
+ },
78
+ "dependencies": {
79
+ "@opencode-ai/plugin": "^1.17.13",
80
+ "@opencode-ai/sdk": "^1.17.13",
81
+ "acorn": "8.17.0",
82
+ "ajv": "8.20.0",
83
+ "quickjs-emscripten": "0.32.0",
84
+ "quickjs-emscripten-core": "0.32.0"
85
+ }
86
+ }