@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,487 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+ import {
5
+ DURABLE_LEDGER_FILES,
6
+ LANE_OUTCOMES,
7
+ MAX_EVENTS,
8
+ MAX_EVENT_MESSAGE_CHARS,
9
+ MAX_JOURNAL_RECORDS,
10
+ } from "./constants.js";
11
+ import {
12
+ extractTextFromError,
13
+ hash,
14
+ hasFunction,
15
+ jsonLine,
16
+ redactDurableValue,
17
+ redactValue,
18
+ stableStringify,
19
+ } from "./text-json.js";
20
+ import { normalizeAgentOptions } from "./authority-policy.js";
21
+ import { classifyLaneError } from "./errors.js";
22
+ import { emitWorkflowDiagnostic } from "./diagnostics.js";
23
+ import { appendFilePrivate, ensurePrivateDir, writeFilePrivate } from "./run-store-fs.js";
24
+ import { notifyRunEventSink } from "./run-observability.js";
25
+
26
+ function laneOutcomeForError(error) {
27
+ if (error?.outcome && LANE_OUTCOMES.includes(error.outcome)) return error.outcome;
28
+ if (error?.code === "WORKFLOW_CANCELLED") return "cancelled";
29
+ if (error?.code === "WORKFLOW_TIMEOUT") return "timeout";
30
+ if (error?.code === "WORKFLOW_BUDGET_STOPPED") return "budget_stopped";
31
+ return "failure";
32
+ }
33
+
34
+ // Failure-class taxonomy carried alongside the LANE_OUTCOMES bucket so diagnostics and
35
+ // status can distinguish a transient-but-exhausted lane (rate-limit/overload that ran out
36
+ // of backed-off retries) or validation_exhausted lane (corrective turns spent) from a
37
+ // terminal one (bad model id, auth, schema with corrective retry disabled). The lane retry
38
+ // loop tags errors it gave up on with `error.laneFailureClass`.
39
+ // everything else is derived from the transient/terminal taxonomy in errors.js. Returns one
40
+ // of: "transient_exhausted" | "validation_exhausted" | "transient" | "terminal".
41
+ function laneFailureClassForError(error) {
42
+ if (typeof error?.laneFailureClass === "string") return error.laneFailureClass;
43
+ return classifyLaneError(error) === "transient" ? "transient" : "terminal";
44
+ }
45
+
46
+ function laneSignature(run, prompt, resolved) {
47
+ return hash(stableStringify({
48
+ // jbs3.3 (edit-and-resume / prefix reuse): the lane signature is CONTENT-ADDRESSED per lane —
49
+ // it hashes only this lane's own effective inputs (resolved prompt + model + agent/role/system +
50
+ // schema/outputFormat + permission policy + lane options + capability mode + runtimeArgs). The
51
+ // whole-file `run.sourceHash` is deliberately NOT mixed in: a lane's output is fully determined
52
+ // by the inputs above, so an edit to an UNRELATED part of the body must not invalidate this lane.
53
+ // This lets an operator edit a workflow body and resume reusing every lane whose resolved inputs
54
+ // are unchanged (served from the journal cache at zero re-spend) while only the edited lane and
55
+ // its dependents — whose resolved prompt changed because it incorporates the edited upstream
56
+ // output — re-run. Soundness: the journal is keyed by the deterministic callId (loadJournal),
57
+ // not by signature, so two lanes with identical inputs never collide; the signature is a
58
+ // per-callId VALIDATION field. The two-phase approval invariant is preserved independently — an
59
+ // edited body yields a new sourceHash, which changes approvalHash and forces re-approval before
60
+ // any lane (cached or re-run) executes.
61
+ runtimeArgs: run.runtimeArgs,
62
+ prompt,
63
+ resolvedModel: resolved.modelKey,
64
+ agent: resolved.agent,
65
+ role: resolved.role,
66
+ system: resolved.system,
67
+ outputFormat: resolved.outputFormat,
68
+ schema: resolved.schema,
69
+ permissionPolicy: resolved.policy,
70
+ laneOptions: normalizeAgentOptions(resolved.opts),
71
+ capabilityMode: {
72
+ permissions: run.capabilities.permissions,
73
+ structuredOutput: run.capabilities.structuredOutput,
74
+ structuredOutputField: run.capabilities.structuredOutputField,
75
+ },
76
+ // runtimeDiagnostics (SDK/plugin versions, server URL, client-shape booleans) is
77
+ // deliberately excluded: it does not affect a child agent's output and is volatile
78
+ // across processes (the server URL/port is randomized on each OpenCode start), so
79
+ // including it would invalidate the entire resume cache on every cross-process resume.
80
+ }));
81
+ }
82
+
83
+ async function appendEvent(run, event) {
84
+ if (run.eventCount >= MAX_EVENTS) return;
85
+ run.eventCount += 1;
86
+ const record = {
87
+ ts: new Date().toISOString(),
88
+ runId: run.id,
89
+ ...redactValue(event, { maxString: MAX_EVENT_MESSAGE_CHARS }),
90
+ };
91
+ run.lastEventAt = record.ts;
92
+ run.lastEventType = typeof event?.type === "string" ? event.type : undefined;
93
+ await appendFilePrivate(path.join(run.dir, "events.jsonl"), jsonLine(record), "utf8");
94
+ await emitWorkflowDiagnostic(run, event);
95
+ notifyRunEventSink(run, record);
96
+ }
97
+
98
+ async function appendJournal(run, record) {
99
+ if (run.journalRecords >= MAX_JOURNAL_RECORDS) {
100
+ throw new Error(`Workflow journal exceeded ${MAX_JOURNAL_RECORDS} records`);
101
+ }
102
+ run.journalRecords += 1;
103
+ await appendFilePrivate(path.join(run.dir, "journal.jsonl"), jsonLine(redactDurableValue(record)), "utf8");
104
+ }
105
+
106
+ async function loadJournal(runDir) {
107
+ const entries = new Map();
108
+ try {
109
+ const content = await fs.readFile(path.join(runDir, "journal.jsonl"), "utf8");
110
+ for (const line of content.split(/\r?\n/)) {
111
+ if (!line.trim()) continue;
112
+ let entry;
113
+ try {
114
+ entry = JSON.parse(line);
115
+ } catch {
116
+ // A crash during the non-atomic appendFile can leave a truncated final line;
117
+ // skip it rather than failing the whole resume.
118
+ continue;
119
+ }
120
+ if (entry.callId) entries.set(entry.callId, entry);
121
+ }
122
+ } catch (error) {
123
+ if (error.code !== "ENOENT") throw error;
124
+ }
125
+ return entries;
126
+ }
127
+
128
+ function signatureFallbackScope(callId) {
129
+ const value = String(callId ?? "");
130
+ const agentIndex = value.lastIndexOf("/agent:");
131
+ const laneScope = agentIndex === -1 ? value : value.slice(0, agentIndex);
132
+ const segments = laneScope.split("/").filter(Boolean);
133
+ const lastItemIndex = segments.findLastIndex((segment) => /^item:\d+$/.test(segment));
134
+ if (lastItemIndex > 0) return segments.slice(0, lastItemIndex).join("/");
135
+ return laneScope;
136
+ }
137
+
138
+ function buildResumeSignatureIndex(resumeJournal) {
139
+ const index = new Map();
140
+ if (!(resumeJournal instanceof Map)) return index;
141
+ for (const [mapCallId, entry] of resumeJournal.entries()) {
142
+ if (!entry || typeof entry !== "object") continue;
143
+ if (entry.outcome !== "success" || !entry.signatureHash) continue;
144
+ const callId = String(entry.callId ?? mapCallId ?? "");
145
+ if (!callId) continue;
146
+ if (!entry.callId) entry.callId = callId;
147
+ const scope = signatureFallbackScope(callId);
148
+ let byScope = index.get(entry.signatureHash);
149
+ if (!byScope) {
150
+ byScope = new Map();
151
+ index.set(entry.signatureHash, byScope);
152
+ }
153
+ let candidates = byScope.get(scope);
154
+ if (!candidates) {
155
+ candidates = [];
156
+ byScope.set(scope, candidates);
157
+ }
158
+ candidates.push(entry);
159
+ }
160
+ return index;
161
+ }
162
+
163
+ function markResumeSignatureClaimed(run, callId) {
164
+ if (!run || !callId) return;
165
+ run.resumeSignatureClaims ??= new Set();
166
+ run.resumeSignatureClaims.add(String(callId));
167
+ }
168
+
169
+ function claimResumeSignatureFallback(run, callId, signatureHash) {
170
+ if (!run || !signatureHash) return null;
171
+ const byScope = run.resumeSignatureIndex?.get(signatureHash);
172
+ if (!byScope) return null;
173
+ const candidates = byScope.get(signatureFallbackScope(callId));
174
+ if (!Array.isArray(candidates) || candidates.length === 0) return null;
175
+ run.resumeSignatureClaims ??= new Set();
176
+ for (const entry of candidates) {
177
+ const originalCallId = String(entry?.callId ?? "");
178
+ if (!originalCallId || originalCallId === String(callId)) continue;
179
+ if (run.resumeSignatureClaims.has(originalCallId)) continue;
180
+ run.resumeSignatureClaims.add(originalCallId);
181
+ return entry;
182
+ }
183
+ return null;
184
+ }
185
+
186
+ async function compactJournal(runDir, entries) {
187
+ const values = entries instanceof Map ? [...entries.values()] : [...(entries ?? [])];
188
+ const journalPath = path.join(runDir, "journal.jsonl");
189
+ const tmp = `${journalPath}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`;
190
+ try {
191
+ await writeFilePrivate(tmp, values.map((entry) => jsonLine(redactDurableValue(entry))).join(""), "utf8");
192
+ await fs.rename(tmp, journalPath);
193
+ return values.length;
194
+ } catch (error) {
195
+ await fs.rm(tmp, { force: true }).catch(() => {});
196
+ throw error;
197
+ }
198
+ }
199
+
200
+ async function countNonEmptyLines(filePath) {
201
+ try {
202
+ const content = await fs.readFile(filePath, "utf8");
203
+ let count = 0;
204
+ for (const line of content.split(/\r?\n/)) if (line.trim()) count += 1;
205
+ return count;
206
+ } catch (error) {
207
+ if (error.code === "ENOENT") return 0;
208
+ throw error;
209
+ }
210
+ }
211
+
212
+ function ledgerFilePath(runDir, fileName) {
213
+ if (!DURABLE_LEDGER_FILES.includes(fileName) && !/^[a-z0-9_.-]+\.jsonl$/.test(fileName)) {
214
+ throw new Error(`Invalid ledger file name: ${String(fileName)}`);
215
+ }
216
+ return path.join(runDir, fileName);
217
+ }
218
+
219
+ async function appendLedger(runOrDir, fileName, record) {
220
+ const runDir = typeof runOrDir === "string" ? runOrDir : runOrDir?.dir;
221
+ if (!runDir) throw new Error("appendLedger requires a run directory");
222
+ await ensurePrivateDir(runDir);
223
+ await appendFilePrivate(ledgerFilePath(runDir, fileName), jsonLine(redactDurableValue({ ts: new Date().toISOString(), ...record })), "utf8");
224
+ }
225
+
226
+ async function readJsonlLedger(filePath) {
227
+ const records = [];
228
+ try {
229
+ const content = await fs.readFile(filePath, "utf8");
230
+ for (const line of content.split(/\r?\n/)) {
231
+ if (!line.trim()) continue;
232
+ try {
233
+ records.push(JSON.parse(line));
234
+ } catch {
235
+ // A crash during appendFile can truncate the final line; prior complete lines are still valid.
236
+ }
237
+ }
238
+ } catch (error) {
239
+ if (error.code !== "ENOENT") throw error;
240
+ }
241
+ return records;
242
+ }
243
+
244
+ function ledgerHasCompleted(records, key, keyField = "mutationKey") {
245
+ return records.some((record) => record?.[keyField] === key && record.phase === "completed");
246
+ }
247
+
248
+ function completedLedgerRecord(records, key, keyField = "mutationKey") {
249
+ for (const record of [...records].reverse()) {
250
+ if (record?.[keyField] === key && record.phase === "completed") return record;
251
+ }
252
+ return undefined;
253
+ }
254
+
255
+ function latestLedgerRecord(records, key, phase, keyField = "mutationKey") {
256
+ for (const record of [...records].reverse()) {
257
+ if (record?.[keyField] === key && record.phase === phase) return record;
258
+ }
259
+ return undefined;
260
+ }
261
+
262
+ function incompleteLedgerKeys(records, keyField) {
263
+ const phases = new Map();
264
+ for (const record of records) {
265
+ const key = record?.[keyField];
266
+ if (!key) continue;
267
+ if (!phases.has(key)) phases.set(key, new Set());
268
+ phases.get(key).add(record.phase || record.status || "unknown");
269
+ }
270
+ return [...phases.entries()]
271
+ .filter(([, seen]) => !seen.has("completed") && !seen.has("failed"))
272
+ .map(([key]) => key);
273
+ }
274
+
275
+ async function durableLedgerSummary(runDir) {
276
+ const ledgers = {};
277
+ for (const fileName of DURABLE_LEDGER_FILES) {
278
+ const records = await readJsonlLedger(ledgerFilePath(runDir, fileName));
279
+ const phases = {};
280
+ for (const record of records) {
281
+ const phase = record.phase || record.status || "unknown";
282
+ phases[phase] = (phases[phase] || 0) + 1;
283
+ }
284
+ ledgers[fileName.replace(/\.jsonl$/, "")] = { records: records.length, phases };
285
+ }
286
+ return ledgers;
287
+ }
288
+
289
+ async function appendIntegrationLedger(run, record) {
290
+ await appendLedger(run, "integration-ledger.jsonl", record);
291
+ }
292
+
293
+ async function appendValidationLedger(run, record) {
294
+ await appendLedger(run, "validation-ledger.jsonl", record);
295
+ }
296
+
297
+ async function appendDomainLedger(run, record) {
298
+ await appendLedger(run, "domain-ledger.jsonl", record);
299
+ }
300
+
301
+ // R16: stable, fixed-length, prefixed idempotency key derived purely from the (already deterministic)
302
+ // mutationKey. Same mutationKey -> same key across crashes/resumes, with no dependence on wall clock,
303
+ // random, or attempt count, so a replay can recognize an already-applied bd mutation.
304
+ function domainMutationIdempotencyKey(mutationKey) {
305
+ return `ocw-idem-${hash(String(mutationKey)).slice(0, 24)}`;
306
+ }
307
+
308
+ async function runDomainMutation(run, options) {
309
+ const mutationKey = String(options?.mutationKey ?? "");
310
+ if (!mutationKey) throw new Error("runDomainMutation requires mutationKey");
311
+ if (!hasFunction(options, "execute")) throw new Error("runDomainMutation requires execute");
312
+ const ledgerPath = ledgerFilePath(run.dir, "domain-ledger.jsonl");
313
+ const prior = await readJsonlLedger(ledgerPath);
314
+ const completed = completedLedgerRecord(prior, mutationKey);
315
+ if (completed) return { replayed: true, result: completed.result, readback: completed.readback };
316
+ const executed = latestLedgerRecord(prior, mutationKey, "executed");
317
+ if (executed) {
318
+ const readback = hasFunction(options, "readback") ? await options.readback(executed.result) : undefined;
319
+ await appendDomainLedger(run, { phase: "completed", mutationKey, operation: executed.operation ?? options.operation ?? "domain-mutation", result: redactValue(executed.result), readback: redactValue(readback) });
320
+ return { replayed: true, result: executed.result, readback };
321
+ }
322
+ const operation = options.operation || "domain-mutation";
323
+ // R16: derive a deterministic client-side idempotency key from the mutationKey and persist it in
324
+ // the started record BEFORE execute runs. The window between execute returning (the bd mutation
325
+ // already happened) and the executed record being durably appended is a crash point: on resume the
326
+ // executed record is absent, so a naive replay re-runs execute and duplicates the bd resource
327
+ // (a non-idempotent domain create/append would duplicate the resource on replay). Passing this stable
328
+ // key into execute lets the adapter make the underlying create/append idempotent (external-ref
329
+ // dedupe on create; marker dedupe on append), so the re-run is a no-op that returns the existing
330
+ // resource instead of creating a duplicate.
331
+ const idempotencyKey = options.idempotencyKey ? String(options.idempotencyKey) : domainMutationIdempotencyKey(mutationKey);
332
+ await appendDomainLedger(run, { phase: "started", mutationKey, operation, idempotencyKey });
333
+ try {
334
+ const result = await options.execute(idempotencyKey);
335
+ await appendDomainLedger(run, { phase: "executed", mutationKey, operation, idempotencyKey, result: redactValue(result) });
336
+ const readback = hasFunction(options, "readback") ? await options.readback(result) : undefined;
337
+ await appendDomainLedger(run, { phase: "completed", mutationKey, operation, result: redactValue(result), readback: redactValue(readback) });
338
+ return { replayed: false, result, readback };
339
+ } catch (error) {
340
+ await appendDomainLedger(run, { phase: "failed", mutationKey, operation, error: extractTextFromError(error) });
341
+ throw error;
342
+ }
343
+ }
344
+
345
+ async function stageDomainMutation(run, options) {
346
+ const mutationKey = String(options?.mutationKey ?? "");
347
+ if (!mutationKey) throw new Error("stageDomainMutation requires mutationKey");
348
+ const operation = options.operation || "domain-mutation";
349
+ const ledgerPath = ledgerFilePath(run.dir, "domain-ledger.jsonl");
350
+ const prior = await readJsonlLedger(ledgerPath);
351
+ if (completedLedgerRecord(prior, mutationKey) || latestLedgerRecord(prior, mutationKey, "staged")) {
352
+ return { staged: true, replayed: true, mutationKey, operation, payload: options.payload };
353
+ }
354
+ const record = { phase: "staged", mutationKey, operation, payload: options.payload };
355
+ await appendDomainLedger(run, record);
356
+ return { staged: true, replayed: false, mutationKey, operation, payload: options.payload };
357
+ }
358
+
359
+ function stagedDomainMutations(records) {
360
+ const staged = new Map();
361
+ for (const record of records) {
362
+ const mutationKey = record?.mutationKey;
363
+ if (!mutationKey) continue;
364
+ if (record.phase === "staged") staged.set(mutationKey, record);
365
+ if (record.phase === "completed") staged.delete(mutationKey);
366
+ }
367
+ return [...staged.values()];
368
+ }
369
+
370
+ function formatMutationManifestEntries(records) {
371
+ return records
372
+ .map((record) => ({
373
+ mutationKey: record.mutationKey,
374
+ operation: record.operation,
375
+ payloadHash: hash(stableStringify(record.payload ?? null)),
376
+ }))
377
+ .sort((left, right) => String(left.mutationKey).localeCompare(String(right.mutationKey)) || String(left.operation).localeCompare(String(right.operation)));
378
+ }
379
+
380
+ function domainMutationManifest(records) {
381
+ return formatMutationManifestEntries(stagedDomainMutations(records));
382
+ }
383
+
384
+ function domainMutationApprovalManifest(records) {
385
+ const approved = new Map();
386
+ for (const record of records) {
387
+ const mutationKey = record?.mutationKey;
388
+ if (!mutationKey) continue;
389
+ if (record.phase === "staged") approved.set(mutationKey, record);
390
+ }
391
+ return formatMutationManifestEntries([...approved.values()]);
392
+ }
393
+
394
+ function computeDomainMutationHash(manifest) {
395
+ return hash(stableStringify(Array.isArray(manifest) ? manifest : []));
396
+ }
397
+
398
+ async function stagedDomainMutationManifest(runDir) {
399
+ return domainMutationManifest(await readJsonlLedger(ledgerFilePath(runDir, "domain-ledger.jsonl")));
400
+ }
401
+
402
+ async function domainMutationApprovalManifestForRun(runDir) {
403
+ return domainMutationApprovalManifest(await readJsonlLedger(ledgerFilePath(runDir, "domain-ledger.jsonl")));
404
+ }
405
+
406
+ async function executeStagedDomainMutation(record, idempotencyKey, resolveMutationHandler) {
407
+ const operation = String(record.operation ?? "");
408
+ // Domain mutation finalization is owned by trusted extensions, resolved by exact operation name.
409
+ // The resolver is threaded from the caller (which holds the per-pluginContext extension registry),
410
+ // so event-journal stays domain-neutral and no longer imports any domain adapter.
411
+ const handler = typeof resolveMutationHandler === "function" ? resolveMutationHandler(operation) : undefined;
412
+ if (typeof handler !== "function") throw new Error(`Unsupported staged domain mutation operation: ${operation}`);
413
+ // R16: forward the deterministic idempotency key so the adapter can dedupe a crash-resume re-run
414
+ // of a non-idempotent create/append against an already-applied mutation.
415
+ return await handler({ operation: record.operation, idempotencyKey, ...(record.payload ?? {}) });
416
+ }
417
+
418
+ async function finalizeStagedDomainMutations(runDir, state = {}, resolveMutationHandler) {
419
+ const records = await readJsonlLedger(ledgerFilePath(runDir, "domain-ledger.jsonl"));
420
+ const staged = stagedDomainMutations(records);
421
+ if (staged.length === 0) return { finalized: 0, pending: 0, results: [] };
422
+ const run = { id: state.id, dir: runDir };
423
+ const results = [];
424
+ for (const record of staged) {
425
+ const result = await runDomainMutation(run, {
426
+ mutationKey: record.mutationKey,
427
+ operation: record.operation,
428
+ execute: async (idempotencyKey) => await executeStagedDomainMutation(record, idempotencyKey, resolveMutationHandler),
429
+ });
430
+ results.push({ mutationKey: record.mutationKey, operation: record.operation, replayed: result.replayed, result: result.result });
431
+ }
432
+ return { finalized: results.length, pending: 0, results };
433
+ }
434
+
435
+ async function appendApplyLedger(runDir, record) {
436
+ await appendLedger(runDir, "apply-ledger.jsonl", record);
437
+ }
438
+
439
+ // Idempotency source-of-truth for apply: a whole, last-written `completed` record whose
440
+ // diffPlanHash matches the approved plan. The completed-ledger append precedes the
441
+ // state.json=applied write, so a crash in that window leaves on-disk status apply-running
442
+ // (reconciled to interrupted/stale-active). Detecting the completed record here is what
443
+ // lets the apply gate admit those recovery statuses onto the idempotent finalize path
444
+ // instead of wedging the run permanently out of workflow_apply.
445
+ async function applyLedgerHasCompleted(runDir, diffPlanHash) {
446
+ const records = await readJsonlLedger(ledgerFilePath(runDir, "apply-ledger.jsonl"));
447
+ return records.some((record) => record.phase === "completed" && record.diffPlanHash === diffPlanHash);
448
+ }
449
+
450
+ export {
451
+ laneOutcomeForError,
452
+ laneFailureClassForError,
453
+ laneSignature,
454
+ appendEvent,
455
+ appendJournal,
456
+ loadJournal,
457
+ signatureFallbackScope,
458
+ buildResumeSignatureIndex,
459
+ markResumeSignatureClaimed,
460
+ claimResumeSignatureFallback,
461
+ compactJournal,
462
+ countNonEmptyLines,
463
+ ledgerFilePath,
464
+ appendLedger,
465
+ readJsonlLedger,
466
+ ledgerHasCompleted,
467
+ completedLedgerRecord,
468
+ latestLedgerRecord,
469
+ incompleteLedgerKeys,
470
+ durableLedgerSummary,
471
+ appendIntegrationLedger,
472
+ appendValidationLedger,
473
+ appendDomainLedger,
474
+ domainMutationIdempotencyKey,
475
+ runDomainMutation,
476
+ stageDomainMutation,
477
+ stagedDomainMutations,
478
+ domainMutationManifest,
479
+ domainMutationApprovalManifest,
480
+ computeDomainMutationHash,
481
+ stagedDomainMutationManifest,
482
+ domainMutationApprovalManifestForRun,
483
+ executeStagedDomainMutation,
484
+ finalizeStagedDomainMutations,
485
+ appendApplyLedger,
486
+ applyLedgerHasCompleted,
487
+ };
@@ -0,0 +1,144 @@
1
+ import path from "node:path";
2
+ import { pathToFileURL } from "node:url";
3
+
4
+ // Trusted extension registry (Stage 1 of the harness extraction).
5
+ //
6
+ // One instance per pluginContext: opencode double-instantiates plugin factories, so keeping
7
+ // the registry per-instance (not a module-level singleton) avoids duplicate-name throws across
8
+ // instantiations. The registry confers TRUST on host-side capabilities — drain adapters and
9
+ // domain-mutation finalizers — and collects extension-owned asset dirs that the caller merges
10
+ // into the existing project/global/extension/bundled resolution. It does NOT resolve assets
11
+ // itself, and it is a strict import-leaf (only node:path) so it can be threaded into
12
+ // sandbox-executor / event-journal without creating an import cycle.
13
+
14
+ const ASSET_KINDS = ["workflows", "commands", "skills"];
15
+
16
+ function asPlainObject(value, label) {
17
+ if (value === undefined || value === null) return {};
18
+ if (typeof value !== "object" || Array.isArray(value)) throw new Error(`extension ${label} must be an object`);
19
+ return value;
20
+ }
21
+
22
+ export function createExtensionRegistry() {
23
+ const extensions = []; // { id, baseDir }
24
+ const adapters = new Map(); // name -> { ...adapter, __extId }
25
+ const handlers = new Map(); // operation -> { fn, __extId }
26
+ const toolFactories = new Map(); // extId -> tools object | (toolKit) => tools object
27
+ const assetDirs = { workflows: [], commands: [], skills: [] };
28
+
29
+ function register(def, opts = {}) {
30
+ if (!def || typeof def !== "object" || Array.isArray(def)) throw new Error("extension definition must be an object");
31
+ const id = def.id;
32
+ if (typeof id !== "string" || id.length === 0) throw new Error("extension definition requires a non-empty string `id`");
33
+
34
+ // Per-id idempotency: re-registering the same extension (e.g. the same module imported twice)
35
+ // is a no-op. Cross-extension name clashes below still throw.
36
+ if (extensions.some((e) => e.id === id)) return;
37
+
38
+ const baseDir = opts.baseDir;
39
+
40
+ const drainAdapters = asPlainObject(def.drainAdapters, `${id}.drainAdapters`);
41
+ for (const [name, adapter] of Object.entries(drainAdapters)) {
42
+ const existing = adapters.get(name);
43
+ if (existing && existing.__extId !== id) {
44
+ throw new Error(`duplicate drain adapter "${name}" (already registered by extension "${existing.__extId}")`);
45
+ }
46
+ }
47
+
48
+ const mutationHandlers = asPlainObject(def.mutationHandlers, `${id}.mutationHandlers`);
49
+ for (const [op, fn] of Object.entries(mutationHandlers)) {
50
+ if (typeof fn !== "function") throw new Error(`mutation handler "${op}" must be a function`);
51
+ const existing = handlers.get(op);
52
+ if (existing && existing.__extId !== id) {
53
+ throw new Error(`duplicate mutation operation "${op}" (already registered by extension "${existing.__extId}")`);
54
+ }
55
+ }
56
+
57
+ const declaredDirs = asPlainObject(def.assetDirs, `${id}.assetDirs`);
58
+ for (const kind of ASSET_KINDS) {
59
+ const rel = declaredDirs[kind];
60
+ if (rel === undefined || rel === null) continue;
61
+ if (typeof rel !== "string") throw new Error(`assetDirs.${kind} must be a string path`);
62
+ }
63
+
64
+ const toolsDef = def.tools;
65
+ if (toolsDef !== undefined && toolsDef !== null) {
66
+ const ok = typeof toolsDef === "function" || (typeof toolsDef === "object" && !Array.isArray(toolsDef));
67
+ if (!ok) throw new Error(`extension ${id}.tools must be an object map or a (toolKit) => object factory`);
68
+ }
69
+
70
+ // All validation passed — commit (freeze adapter definitions so guests cannot mutate them).
71
+ for (const [name, adapter] of Object.entries(drainAdapters)) {
72
+ adapters.set(name, Object.freeze({ ...adapter, __extId: id }));
73
+ }
74
+ for (const [op, fn] of Object.entries(mutationHandlers)) {
75
+ handlers.set(op, { fn, __extId: id });
76
+ }
77
+ for (const kind of ASSET_KINDS) {
78
+ const rel = declaredDirs[kind];
79
+ if (typeof rel !== "string") continue;
80
+ assetDirs[kind].push(path.isAbsolute(rel) ? rel : path.join(baseDir ?? ".", rel));
81
+ }
82
+ if (toolsDef !== undefined && toolsDef !== null) toolFactories.set(id, toolsDef);
83
+ extensions.push(Object.freeze({ id, baseDir }));
84
+ }
85
+
86
+ async function loadExtensions(paths = [], { configDir, importer = (p) => import(pathToFileURL(p).href) } = {}) {
87
+ for (const entry of paths) {
88
+ // Extension MODULE paths resolve relative to the opencode config dir (where opencode.json
89
+ // lives); the extension's own asset dirs resolve relative to the module's dir (baseDir below).
90
+ const modulePath = path.isAbsolute(entry) ? entry : path.join(configDir ?? ".", entry);
91
+ let mod;
92
+ try {
93
+ mod = await importer(modulePath);
94
+ } catch (cause) {
95
+ throw new Error(`failed to load workflow extension ${modulePath}: ${cause?.message ?? cause}`, { cause });
96
+ }
97
+ let def = mod?.default ?? mod;
98
+ if (typeof def === "function") def = await def();
99
+ try {
100
+ register(def, { baseDir: path.dirname(modulePath) });
101
+ } catch (cause) {
102
+ throw new Error(`invalid workflow extension ${modulePath}: ${cause?.message ?? cause}`, { cause });
103
+ }
104
+ }
105
+ }
106
+
107
+ return {
108
+ register,
109
+ loadExtensions,
110
+ drainAdapter(name) {
111
+ const adapter = adapters.get(name);
112
+ if (!adapter) return undefined;
113
+ const { __extId, ...rest } = adapter;
114
+ return rest;
115
+ },
116
+ mutationHandler: (op) => handlers.get(op)?.fn,
117
+ // Resolve every extension's contributed tools into one merged { name: ToolDefinition } map.
118
+ // A factory form is called with `toolKit` (the kernel injects tool/schema/pluginContext/guards
119
+ // so extensions need no @opencode-ai/plugin dependency). Fail closed on a reserved (core) name
120
+ // or a cross-extension duplicate. Import-leaf: no @opencode-ai/plugin import here.
121
+ tools: (toolKit, reservedNames = []) => {
122
+ const reserved = new Set(reservedNames);
123
+ const out = {};
124
+ for (const [id, def] of toolFactories) {
125
+ const resolved = typeof def === "function" ? def(toolKit) : def;
126
+ if (!resolved || typeof resolved !== "object" || Array.isArray(resolved)) {
127
+ throw new Error(`extension "${id}" tools factory must return an object map of tools`);
128
+ }
129
+ for (const [name, toolDef] of Object.entries(resolved)) {
130
+ if (reserved.has(name)) throw new Error(`extension "${id}" tool "${name}" collides with a reserved core tool name`);
131
+ if (Object.hasOwn(out, name)) throw new Error(`duplicate extension tool "${name}" (already contributed by another extension)`);
132
+ out[name] = toolDef;
133
+ }
134
+ }
135
+ return out;
136
+ },
137
+ assetDirs: () => ({
138
+ workflows: [...assetDirs.workflows],
139
+ commands: [...assetDirs.commands],
140
+ skills: [...assetDirs.skills],
141
+ }),
142
+ listExtensions: () => extensions.map((e) => ({ ...e })),
143
+ };
144
+ }