@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,91 @@
1
+ // free-text-redactor.js — content-based (value) secret masking for user-visible text.
2
+ //
3
+ // The kernel's redactValue (text-json.js) combines key-based redaction for sensitive object
4
+ // keys with this value-based scanner for prose strings. Domain workflows that can discover
5
+ // secrets still run their own in-guest masking before returning report envelopes, so raw
6
+ // values are removed before both durable persistence and operator display.
7
+ //
8
+ // This module is the SHARED, pure, idempotent free-text masker applied at the user-visible
9
+ // DISPLAY boundaries (salvage preview snippet, lane taskSummary/title/errorSummary
10
+ // derivation, compact status text, and notification toast text) BEFORE truncation. It masks
11
+ // common credential-like VALUES embedded in prose so a model that pasted a raw token into
12
+ // assistant text, a lane label, or an error summary cannot leak through a preview/status/toast.
13
+ //
14
+ // Scope note: raw transcripts remain local-sensitive by design; durable controller artifacts
15
+ // and display projections use this scanner through redactValue/redactDurableValue. Approval,
16
+ // source, and diff-plan HASHES are computed from raw approved material and are never fed masked
17
+ // text, so this masking cannot perturb a hash.
18
+ //
19
+ // All regexes are linear (no nested/overlapping quantifiers) to avoid catastrophic
20
+ // backtracking. The fixed placeholder [REDACTED:secret] is deliberately composed of characters
21
+ // ([ ] : and the word "secret"/"redacted") that are NOT present in any secret-value character
22
+ // class below, so a second pass finds nothing to match — the function is idempotent by
23
+ // construction (redact(redact(x)) === redact(x)).
24
+
25
+ // Fixed, non-reversible replacement. The brackets/colons keep it out of every value char class.
26
+ const PLACEHOLDER = "[REDACTED:secret]";
27
+
28
+ // Well-known credential SHAPES (whole-token match → mask the entire token).
29
+ //
30
+ // AWS access key id: AKIA + 16+ uppercase alphanumerics (real keys are exactly 20 chars).
31
+ const AWS_ACCESS_KEY_RE = /\bAKIA[0-9A-Z]{16,}\b/g;
32
+ // Provider tokens with a known prefix, allowing internal hyphen-separated segments so the
33
+ // OpenAI `sk-proj-<long>` shape is matched (the prior in-guest regex missed embedded hyphens).
34
+ // Segment form `(?:[A-Za-z0-9]+-)*[A-Za-z0-9]{8,}` is unambiguous (hyphen is the sole
35
+ // delimiter and is not in the segment class) → linear, no catastrophic backtracking.
36
+ const PROVIDER_TOKEN_RE = /\b(sk|pk|ghp|gho|github_pat|xox[baprs])[-_](?:[A-Za-z0-9]+[-_])*[A-Za-z0-9]{8,}/g;
37
+ // PEM private key blocks (lazy up to the matching END marker, or EOF for truncated blocks).
38
+ const PEM_KEY_RE = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/g;
39
+
40
+ // `Bearer <token>` / `Authorization: Bearer <token>` — mask only the token portion, keeping the
41
+ // keyword for readability. Capture group 1 is the "bearer " prefix; the trailing value run is the
42
+ // token. Value class excludes whitespace/brackets so it cannot run past the token.
43
+ const BEARER_RE = /(\bbearer\s+)[A-Za-z0-9._~+/=-]{8,}/gi;
44
+ // HTTP Basic credentials are base64-ish and commonly appear in diagnostics/log errors.
45
+ const BASIC_RE = /(\bbasic\s+)[A-Za-z0-9+/=]{8,}/gi;
46
+
47
+ // Generic key/value ASSIGNMENTS in env-ish, colon-ish, and JSON-ish contexts:
48
+ // secret=value api_key: value "token":"value" password = "value"
49
+ // Anchored on the key NAME (so common prose words are not redacted unless they appear as an
50
+ // assignment key), with an optional quote between key and separator to absorb JSON's `"key":`.
51
+ // Only the VALUE (group 2) is masked; the key + separator are preserved so the surrounding text
52
+ // stays readable. The value minimum (8 chars) avoids masking short common words.
53
+ const ASSIGN_RE = /\b(api[_-]?key|apikey|access[_-]?token|refresh[_-]?token|secret[_-]?key|secret|password|passwd|auth[_-]?token|authorization|aws[_-]?secret[_-]?access[_-]?key|aws[_-]?access[_-]?key[_-]?id|client[_-]?secret|private[_-]?key|token)\b["']?\s*[:=]\s*["']?([^\s"'`,;]{8,})/gi;
54
+
55
+ function maskWhole() {
56
+ return PLACEHOLDER;
57
+ }
58
+
59
+ function maskValueOnly(match, key, value) {
60
+ // `key` is capture group 1 (the key name); `value` is capture group 2 (the secret).
61
+ // Preserve everything before the value (key + separator + optional quotes); mask only the
62
+ // value run so the surrounding assignment stays readable.
63
+ return match.slice(0, match.length - value.length) + PLACEHOLDER;
64
+ }
65
+
66
+ function maskBearer(match, prefix) {
67
+ return prefix + PLACEHOLDER;
68
+ }
69
+
70
+ // Mask common credential-like values embedded in free text. Pure and idempotent.
71
+ //
72
+ // @param {string} text - the source text to mask.
73
+ // @param {object} [options] - reserved for future use (accepted but currently ignored; the
74
+ // placeholder is fixed so the idempotency guarantee always holds).
75
+ // @returns {string} the text with detected secrets replaced by [REDACTED:secret].
76
+ export function redactFreeTextSecrets(text, options) {
77
+ if (typeof text !== "string" || text.length === 0) return text;
78
+ // `options` is intentionally accepted but not consumed: the placeholder is fixed to preserve
79
+ // the idempotency guarantee, so no knob may change it. Kept on the signature per contract.
80
+ void options;
81
+ let out = text;
82
+ out = out.replace(PROVIDER_TOKEN_RE, maskWhole);
83
+ out = out.replace(AWS_ACCESS_KEY_RE, maskWhole);
84
+ out = out.replace(PEM_KEY_RE, maskWhole);
85
+ out = out.replace(BEARER_RE, maskBearer);
86
+ out = out.replace(BASIC_RE, maskBearer);
87
+ out = out.replace(ASSIGN_RE, maskValueOnly);
88
+ return out;
89
+ }
90
+
91
+ export { PLACEHOLDER as REDACTED_PLACEHOLDER };
@@ -0,0 +1,82 @@
1
+ // Live-gate result shape constructors. These pure helpers build the canonical
2
+ // `{ state, verified, evidence, evidenceStrength? }` gate objects consumed by the
3
+ // CapabilityAdapter / liveGateReport orchestrator and the live-gate probes. Kept in a
4
+ // focused leaf module (no probe or adapter dependencies) so both the adapter and the
5
+ // probe functions can share them without an import cycle.
6
+ import { MAX_STATUS_STRING_CHARS } from "./constants.js";
7
+ import { extractTextFromError, truncateText } from "./text-json.js";
8
+ import {
9
+ WorkflowCancelledError,
10
+ WorkflowProbeStructuralError,
11
+ WorkflowTimeoutError,
12
+ } from "./errors.js";
13
+
14
+ function gateBlocked(evidence) {
15
+ return { state: "blocked", verified: false, evidence };
16
+ }
17
+
18
+ function gateAvailableUnverified(evidence) {
19
+ return { state: "available-unverified", verified: false, evidence };
20
+ }
21
+
22
+ // Evidence contract for verified gates: `evidenceStrength` distinguishes directly-observed
23
+ // target behavior ("observed", the default) from a compatibility fallback that verifies only
24
+ // via retained deny rules plus an absent tool attempt ("no-attempt-fallback"). The latter is
25
+ // not equivalent to an observed denial and must be explicitly accepted before release-
26
+ // readiness messaging treats it as enforcement proof.
27
+ function gateVerified(evidence, evidenceStrength = "observed") {
28
+ return { state: "verified", verified: true, evidence, evidenceStrength };
29
+ }
30
+
31
+ function gateFailed(evidence) {
32
+ return { state: "failed-with-evidence", verified: false, evidence };
33
+ }
34
+
35
+ function forcedGate(value) {
36
+ if (value && typeof value === "object") {
37
+ const verified = value.verified === true;
38
+ const result = {
39
+ state: value.state || (verified ? "verified" : "available-unverified"),
40
+ verified,
41
+ evidence: value.evidence || "forced test input",
42
+ };
43
+ if (verified) result.evidenceStrength = value.evidenceStrength ?? "observed";
44
+ return result;
45
+ }
46
+ if (value === "verified" || value === "passed") return gateVerified("forced test input");
47
+ if (typeof value === "string") return { state: value, verified: false, evidence: "forced test input" };
48
+ return undefined;
49
+ }
50
+
51
+ function shapeGate(forcedValue, available, evidence) {
52
+ return forcedGate(forcedValue) ?? (available ? gateAvailableUnverified(evidence) : gateBlocked(evidence));
53
+ }
54
+
55
+ // A transport/structural probe failure is NOT denial evidence: the probe could not
56
+ // be run, so it must never verify the gate. We discriminate these by typed error
57
+ // (code/instanceof) BEFORE the denial-text regex, because the regex also matches
58
+ // the probe's own label text (e.g. a "denied-bash probe ... timed out" message),
59
+ // which would otherwise silently escalate authority on latency / API-shape anomalies.
60
+ function transportFailureGate(error, label) {
61
+ const evidence = truncateText(extractTextFromError(error), MAX_STATUS_STRING_CHARS);
62
+ if (error instanceof WorkflowTimeoutError || error?.code === "WORKFLOW_TIMEOUT") {
63
+ return gateFailed(`${label} could not be verified: probe timed out before observing enforcement: ${evidence}`);
64
+ }
65
+ if (error instanceof WorkflowCancelledError || error?.code === "WORKFLOW_CANCELLED") {
66
+ return gateBlocked(`${label} could not be verified: probe was cancelled before observing enforcement: ${evidence}`);
67
+ }
68
+ if (error instanceof WorkflowProbeStructuralError || error?.code === "WORKFLOW_PROBE_STRUCTURAL") {
69
+ return gateBlocked(`${label} could not be verified: ${evidence}`);
70
+ }
71
+ return undefined;
72
+ }
73
+
74
+ export {
75
+ gateBlocked,
76
+ gateAvailableUnverified,
77
+ gateVerified,
78
+ gateFailed,
79
+ forcedGate,
80
+ shapeGate,
81
+ transportFailureGate,
82
+ };
@@ -0,0 +1,45 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { DEFAULT_SUBPROCESS_MAX_BUFFER, DEFAULT_SUBPROCESS_TIMEOUT_MS } from "./constants.js";
4
+
5
+ const execFileAsync = promisify(execFile);
6
+
7
+ export const DEFAULT_GIT_TIMEOUT_MS = DEFAULT_SUBPROCESS_TIMEOUT_MS;
8
+ export const DEFAULT_GIT_MAX_BUFFER = DEFAULT_SUBPROCESS_MAX_BUFFER;
9
+
10
+ async function execGit(directory, args, options = {}) {
11
+ return await execFileAsync("git", args, {
12
+ cwd: directory,
13
+ encoding: "utf8",
14
+ timeout: DEFAULT_GIT_TIMEOUT_MS,
15
+ maxBuffer: DEFAULT_GIT_MAX_BUFFER,
16
+ ...options,
17
+ });
18
+ }
19
+
20
+ export async function git(directory, args, options = {}) {
21
+ try {
22
+ return await execGit(directory, args, options);
23
+ } catch (error) {
24
+ const detail = error.stderr || error.stdout || error.message || String(error);
25
+ throw new Error(`git ${args.join(" ")} failed in ${directory}: ${detail.trim()}`);
26
+ }
27
+ }
28
+
29
+ export async function gitSucceeds(directory, args, options = {}) {
30
+ try {
31
+ await execGit(directory, args, options);
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ export async function gitCapture(directory, args, options = {}) {
39
+ try {
40
+ const result = await execGit(directory, args, options);
41
+ return { ok: true, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
42
+ } catch (error) {
43
+ return { ok: false, stdout: error.stdout ?? "", stderr: error.stderr ?? "", message: error.message ?? String(error) };
44
+ }
45
+ }
@@ -0,0 +1,72 @@
1
+ // Kernel barrel. Real modules own their symbols; the workflow-plugin orchestrator
2
+ // exposes only its core (sandbox/run/child/apply/git/lane) symbols.
3
+ import WorkflowPlugin from "./workflow-plugin.js";
4
+ import * as kernel from "./index.js";
5
+ export { default } from "./workflow-plugin.js";
6
+ export {
7
+ acquireAgentSlot,
8
+ addEditPlanFromResult,
9
+ applyWorkflow,
10
+ approvalSummary,
11
+ assertGitCleanAtBase,
12
+ cleanupWorktrees,
13
+ configureWorkflowEntrypoints,
14
+ createEditWorktree,
15
+ executeSandbox,
16
+ gitHead,
17
+ gitOutput,
18
+ gitPathTracked,
19
+ isRunAborted,
20
+ normalizePatches,
21
+ planWorkflowEnvelope,
22
+ releaseAgentSlot,
23
+ rollbackPatches,
24
+ runChildAgent,
25
+ runNestedWorkflow,
26
+ runWorkflowExecution,
27
+ salvageRun,
28
+ startWorkflow,
29
+ throwIfAborted,
30
+ validatePatchTargets,
31
+ } from "./workflow-plugin.js";
32
+ export * from "./approval-hashing.js";
33
+ export * from "./async-util.js";
34
+ export * from "./audited-shell-policy.js";
35
+ export * from "./authority-policy.js";
36
+ export * from "./budget-accounting.js";
37
+ export * from "./capability-adapter.js";
38
+ export * from "./child-agent-runner.js";
39
+ export * from "./constants.js";
40
+ export * from "./drain-runtime.js";
41
+ export * from "./errors.js";
42
+ export * from "./event-journal.js";
43
+ export * from "./extension-registry.js";
44
+ export * from "./git-util.js";
45
+ export * from "./integration-mode.js";
46
+ export * from "./lifecycle-control.js";
47
+ export * from "./lane-effort-policy.js";
48
+ export * from "./notification-toast.js";
49
+ export * from "./notification-toast-cards.js";
50
+ export * from "./notification-toast-policy.js";
51
+ export * from "./notification-toast-scope.js";
52
+ export * from "./path-policy.js";
53
+ export * from "./role-template-loading.js";
54
+ export * from "./result-readback.js";
55
+ export * from "./run-context.js";
56
+ export * from "./run-observability.js";
57
+ export * from "./run-store-status.js";
58
+ export * from "./sandbox-executor.js";
59
+ export * from "./session-access.js";
60
+ export * from "./structured-output.js";
61
+ export * from "./test-fix-drain-adapter.js";
62
+ export * from "./text-json.js";
63
+ export * from "./workflow-source.js";
64
+ export * from "./worktree-adapter.js";
65
+
66
+ // Test-only surface. The orchestrator's WorkflowPlugin.__test carries only its core
67
+ // (sandbox/run/child/apply/git/lane) symbols; the extracted modules own everything else.
68
+ // Aggregate the kernel barrel (this module's own namespace) onto __test so test suites
69
+ // reach module internals via this barrel's default export without importing the entry
70
+ // (opencode-workflows.js). The orchestrator's own __test wins on conflict. Not part of the runtime
71
+ // plugin contract.
72
+ WorkflowPlugin.__test = { ...kernel, ...WorkflowPlugin.__test };
@@ -0,0 +1,155 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { git } from "./git-util.js";
4
+ import { protectedPathReason } from "./path-policy.js";
5
+
6
+ function normalizeRelativePath(filePath) {
7
+ const normalized = String(filePath ?? "").replace(/\\/g, "/");
8
+ if (!normalized || normalized.startsWith("/") || normalized.split("/").includes("..")) {
9
+ throw new Error(`Unsafe integration path: ${filePath}`);
10
+ }
11
+ return normalized;
12
+ }
13
+
14
+ async function changedPathsSinceBase(directory, baseCommit, options = {}) {
15
+ const { stdout } = await git(directory, ["diff", "--name-status", baseCommit, "HEAD", "--"], { signal: options.signal });
16
+ return stdout.split(/\r?\n/).filter(Boolean).map((line) => {
17
+ const [status, ...parts] = line.split(/\t/);
18
+ const filePath = normalizeRelativePath(parts.at(-1));
19
+ return { status, path: filePath, supported: /^[AMTUXB]/.test(status) };
20
+ });
21
+ }
22
+
23
+ function detectPathConflicts(laneChanges) {
24
+ const seen = new Map();
25
+ const conflicts = [];
26
+ for (const lane of laneChanges) {
27
+ for (const change of lane.paths ?? []) {
28
+ const filePath = normalizeRelativePath(typeof change === "string" ? change : change.path);
29
+ const previous = seen.get(filePath);
30
+ if (previous && previous.callId !== lane.callId) {
31
+ conflicts.push({ path: filePath, lanes: [previous.callId, lane.callId] });
32
+ } else {
33
+ seen.set(filePath, { callId: lane.callId });
34
+ }
35
+ }
36
+ }
37
+ return conflicts;
38
+ }
39
+
40
+ async function buildPatchesFromIntegration({ integrationPath, baseCommit, paths, secretGlobs, signal }) {
41
+ const changes = paths?.length ? paths : await changedPathsSinceBase(integrationPath, baseCommit, { signal });
42
+ const patches = [];
43
+ const unsupported = [];
44
+ for (const change of changes) {
45
+ const filePath = normalizeRelativePath(typeof change === "string" ? change : change.path);
46
+ const status = typeof change === "string" ? "M" : change.status;
47
+ if (status && !/^[AMTUXB]/.test(status)) {
48
+ unsupported.push({ path: filePath, status, reason: "unsupported-change-kind" });
49
+ continue;
50
+ }
51
+ const protectedReason = protectedPathReason(filePath, { secretGlobs });
52
+ if (protectedReason) {
53
+ unsupported.push({ path: filePath, status, reason: protectedReason });
54
+ continue;
55
+ }
56
+ const absolute = path.join(integrationPath, filePath);
57
+ const stat = await fs.lstat(absolute).catch(() => undefined);
58
+ if (!stat || !stat.isFile()) {
59
+ unsupported.push({ path: filePath, status, reason: stat?.isSymbolicLink() ? "symlink" : "not-regular-file" });
60
+ continue;
61
+ }
62
+ let content;
63
+ try {
64
+ content = await fs.readFile(absolute, "utf8");
65
+ } catch {
66
+ unsupported.push({ path: filePath, status, reason: "non-utf8-or-unreadable" });
67
+ continue;
68
+ }
69
+ patches.push({ path: filePath, content, mode: "replace" });
70
+ }
71
+ return { patches, unsupported };
72
+ }
73
+
74
+ function normalizeIntegrationValidationResult(result) {
75
+ if (result === true) return { accepted: true, status: "passed" };
76
+ if (result === false) return { accepted: false, status: "failed", reason: "integration validation rejected" };
77
+ if (!result || typeof result !== "object") return { accepted: false, status: "failed", reason: "integration validation returned no explicit result" };
78
+ const status = typeof result.status === "string" ? result.status : undefined;
79
+ const accepted = result.accepted === true
80
+ || result.ok === true
81
+ || ["ok", "pass", "passed", "success", "validated"].includes(status);
82
+ return {
83
+ ...result,
84
+ accepted,
85
+ status: status ?? (accepted ? "passed" : "failed"),
86
+ reason: accepted ? result.reason : (result.reason ?? result.error ?? "integration validation rejected"),
87
+ };
88
+ }
89
+
90
+ async function integrateLaneCommits({ adapter, runId, baseCommit, lanes, secretGlobs, signal }) {
91
+ const laneChanges = [];
92
+ for (const lane of lanes) {
93
+ laneChanges.push({ ...lane, paths: lane.paths?.length ? lane.paths : await changedPathsSinceBase(lane.path, baseCommit, { signal }) });
94
+ }
95
+ const unsupportedLane = laneChanges.find((lane) => lane.paths.some((change) => {
96
+ const filePath = typeof change === "string" ? change : change.path;
97
+ return change.supported === false || protectedPathReason(filePath, { secretGlobs });
98
+ }));
99
+ if (unsupportedLane) {
100
+ return { status: "review-required", culpritLane: unsupportedLane.callId, reason: "unsupported-lane-change", lanes: laneChanges };
101
+ }
102
+ const conflicts = detectPathConflicts(laneChanges);
103
+ if (conflicts.length > 0) {
104
+ return { status: "review-required", conflicts, reason: "path-conflict", lanes: laneChanges };
105
+ }
106
+
107
+ const integration = await adapter.createIntegrationWorktree({ runId, baseRef: baseCommit });
108
+ const mergedLanes = [];
109
+ try {
110
+ for (const lane of laneChanges.sort((a, b) => String(a.callId).localeCompare(String(b.callId)))) {
111
+ try {
112
+ await adapter.merge({ directory: integration.path, ref: lane.branch, message: `merge ${lane.callId}` });
113
+ mergedLanes.push(lane.callId);
114
+ } catch (error) {
115
+ return { status: "review-required", integrationWorktree: integration, mergedLanes, culpritLane: lane.callId, reason: "merge-failed", error: error.message, lanes: laneChanges };
116
+ }
117
+ }
118
+ let validation;
119
+ if (typeof adapter.validateIntegrationWorktree === "function") {
120
+ try {
121
+ validation = normalizeIntegrationValidationResult(await adapter.validateIntegrationWorktree({
122
+ runId,
123
+ directory: integration.path,
124
+ path: integration.path,
125
+ baseCommit,
126
+ lanes: laneChanges,
127
+ mergedLanes,
128
+ signal,
129
+ }));
130
+ } catch (error) {
131
+ validation = { accepted: false, status: "failed", reason: "integration validation threw", error: error.message };
132
+ }
133
+ if (validation.accepted !== true) {
134
+ return { status: "review-required", integrationWorktree: integration, mergedLanes, reason: "integration-validation-failed", validation, lanes: laneChanges };
135
+ }
136
+ }
137
+ const allChanges = await changedPathsSinceBase(integration.path, baseCommit, { signal });
138
+ const built = await buildPatchesFromIntegration({ integrationPath: integration.path, baseCommit, paths: allChanges, secretGlobs, signal });
139
+ if (built.unsupported.length > 0) {
140
+ return { status: "review-required", integrationWorktree: integration, mergedLanes, reason: "unsupported-integration-change", unsupported: built.unsupported, lanes: laneChanges };
141
+ }
142
+ return { status: "awaiting-diff-approval", integrationWorktree: integration, mergedLanes, validation, lanes: laneChanges, patches: built.patches };
143
+ } catch (error) {
144
+ return { status: "review-required", integrationWorktree: integration, mergedLanes, reason: "integration-failed", error: error.message, lanes: laneChanges };
145
+ }
146
+ }
147
+
148
+ export {
149
+ buildPatchesFromIntegration,
150
+ changedPathsSinceBase,
151
+ detectPathConflicts,
152
+ integrateLaneCommits,
153
+ normalizeIntegrationValidationResult,
154
+ normalizeRelativePath,
155
+ };
@@ -0,0 +1,134 @@
1
+ import { WorkflowAuthorityError } from "./errors.js";
2
+
3
+ export const LANE_EFFORT_VALUES = Object.freeze(["minimal", "low", "medium", "high"]);
4
+ export const LANE_EFFORT_PROVIDER_OPTIONS = Object.freeze({
5
+ openai: Object.freeze({
6
+ providerOptionsKey: "openai",
7
+ optionKey: "reasoningEffort",
8
+ values: LANE_EFFORT_VALUES,
9
+ }),
10
+ });
11
+ export const LANE_EFFORT_POLICY_MAX = 1000;
12
+
13
+ class BoundedLaneEffortMap extends Map {
14
+ constructor(max = LANE_EFFORT_POLICY_MAX) {
15
+ super();
16
+ this.max = max;
17
+ }
18
+
19
+ get(key) {
20
+ const value = super.get(key);
21
+ if (value !== undefined && super.delete(key)) super.set(key, value);
22
+ return value;
23
+ }
24
+
25
+ set(key, value) {
26
+ if (super.has(key)) super.delete(key);
27
+ super.set(key, value);
28
+ while (this.size > this.max) {
29
+ const oldest = super.keys().next().value;
30
+ if (oldest === undefined) break;
31
+ super.delete(oldest);
32
+ }
33
+ return this;
34
+ }
35
+ }
36
+
37
+ export const laneEffortPolicies = new BoundedLaneEffortMap();
38
+
39
+ function normalizedID(value) {
40
+ if (typeof value !== "string") return undefined;
41
+ const trimmed = value.trim();
42
+ if (!trimmed) return undefined;
43
+ return trimmed.toLowerCase();
44
+ }
45
+
46
+ export function providerID(value) {
47
+ if (typeof value === "string") return normalizedID(value.split("/")[0]);
48
+ if (!value || typeof value !== "object") return undefined;
49
+ return normalizedID(value.providerID)
50
+ ?? normalizedID(value.providerId)
51
+ ?? normalizedID(value.id)
52
+ ?? normalizedID(value.name);
53
+ }
54
+
55
+ export function modelProviderID(model) {
56
+ if (typeof model === "string") return providerID(model);
57
+ if (!model || typeof model !== "object") return undefined;
58
+ return normalizedID(model.providerID)
59
+ ?? normalizedID(model.providerId)
60
+ ?? providerID(model.provider);
61
+ }
62
+
63
+ export function normalizeLaneEffort(value) {
64
+ if (value === undefined || value === null || value === false) return undefined;
65
+ if (typeof value !== "string" || value.trim() === "") {
66
+ throw new WorkflowAuthorityError(`agent() option effort must be one of ${LANE_EFFORT_VALUES.join(", ")}`);
67
+ }
68
+ const effort = value.trim().toLowerCase();
69
+ if (!LANE_EFFORT_VALUES.includes(effort)) {
70
+ throw new WorkflowAuthorityError(`Invalid agent() option effort: ${value}. Expected one of ${LANE_EFFORT_VALUES.join(", ")}.`);
71
+ }
72
+ return effort;
73
+ }
74
+
75
+ export function laneEffortPolicyForModel(effort, model) {
76
+ const normalized = normalizeLaneEffort(effort);
77
+ if (!normalized) return undefined;
78
+ const modelProvider = modelProviderID(model);
79
+ const mapping = LANE_EFFORT_PROVIDER_OPTIONS[modelProvider];
80
+ if (!mapping) {
81
+ throw new WorkflowAuthorityError(
82
+ `agent() option effort is currently supported only for OpenAI providers via chat.params; ` +
83
+ `model provider ${modelProvider ? `"${modelProvider}"` : "could not be determined"}.`,
84
+ );
85
+ }
86
+ if (!mapping.values.includes(normalized)) {
87
+ throw new WorkflowAuthorityError(`Provider ${modelProvider} does not support effort "${normalized}"`);
88
+ }
89
+ return {
90
+ effort: normalized,
91
+ providerID: modelProvider,
92
+ providerOptionsKey: mapping.providerOptionsKey,
93
+ optionKey: mapping.optionKey,
94
+ };
95
+ }
96
+
97
+ export function registerLaneEffort(childID, policy) {
98
+ if (!childID || !policy) return;
99
+ laneEffortPolicies.set(String(childID), { ...policy });
100
+ }
101
+
102
+ export function clearLaneEffort(childID) {
103
+ if (!childID) return false;
104
+ return laneEffortPolicies.delete(String(childID));
105
+ }
106
+
107
+ export function laneEffortPolicyForChild(childID) {
108
+ return childID ? laneEffortPolicies.get(String(childID)) : undefined;
109
+ }
110
+
111
+ export function applyLaneEffortParams(input = {}, output = {}) {
112
+ const policy = laneEffortPolicyForChild(input.sessionID);
113
+ if (!policy) return false;
114
+
115
+ const existingOptions = output.options && typeof output.options === "object" ? output.options : {};
116
+ const existingProviderOptions = existingOptions.providerOptions && typeof existingOptions.providerOptions === "object"
117
+ ? existingOptions.providerOptions
118
+ : {};
119
+ const existingProvider = existingProviderOptions[policy.providerOptionsKey] && typeof existingProviderOptions[policy.providerOptionsKey] === "object"
120
+ ? existingProviderOptions[policy.providerOptionsKey]
121
+ : {};
122
+
123
+ output.options = {
124
+ ...existingOptions,
125
+ providerOptions: {
126
+ ...existingProviderOptions,
127
+ [policy.providerOptionsKey]: {
128
+ ...existingProvider,
129
+ [policy.optionKey]: policy.effort,
130
+ },
131
+ },
132
+ };
133
+ return true;
134
+ }