diff --git a/docs/README.md b/docs/README.md index 9a1c586a..9d9065ce 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,6 +17,7 @@ Explore the engineering depth behind this plugin: - **[Configuration System](development/CONFIG_FLOW.md)** - How config loading and merging works - **[Config Fields Guide](development/CONFIG_FIELDS.md)** - Understanding config keys, `id`, and `name` - **[Testing Guide](development/TESTING.md)** - Test scenarios, verification procedures, integration testing +- **[OMX Team + Ralph Playbook](development/OMX_TEAM_RALPH_PLAYBOOK.md)** - WSL2-first atomic workflow, fallback routing, and completion evidence gates - **[TUI Parity Checklist](development/TUI_PARITY_CHECKLIST.md)** - Auth dashboard/UI parity requirements for future changes ## Key Architectural Decisions diff --git a/docs/development/OMX_TEAM_RALPH_PLAYBOOK.md b/docs/development/OMX_TEAM_RALPH_PLAYBOOK.md new file mode 100644 index 00000000..620c7473 --- /dev/null +++ b/docs/development/OMX_TEAM_RALPH_PLAYBOOK.md @@ -0,0 +1,287 @@ +# OMX Team + Ralph Reliability Playbook (WSL2-First) + +This runbook defines the repository-standard execution flow for high-rigor work using `omx team` plus `omx ralph`. + +## Scope + +- Repository-specific workflow for `oc-chatgpt-multi-auth`. +- Primary mode: team execution on WSL2 + tmux. +- Controlled fallback: single-agent Ralph execution. +- Completion requires parity quality gates in both modes. + +## Defaults and Guardrails + +- Default team topology: `6:executor`. +- Retry policy: fail-fast with at most `2` controlled retries per run. +- No normal shutdown when tasks are non-terminal. +- Mandatory completion gates: + - terminal state (`pending=0`, `in_progress=0`, `failed=0`) for team mode + - `npm run typecheck` + - `npm test` + - `npm run build` + - `npx tsc --noEmit --pretty false` diagnostics + - architect verification (`--architect-tier` and `--architect-ref`) +- Ralph completion requires explicit state cleanup (`omx cancel`). + +## Atomic Phases + +### Phase 0 - Intake Contract + +Lock execution contract for this run: + +- target task statement +- default worker topology (`6:executor`) +- gate policy and architect verification format + +### Phase 1 - Baseline Integrity Gate + +From repo root: + +```bash +git fetch origin --prune +git rev-parse origin/main +``` + +If working on an isolated branch/worktree, confirm: + +```bash +git status --short +git branch --show-current +``` + +### Phase 2 - Mainline Deep Audit + +Audit surfaces before mutation: + +- workflow docs (`docs/development`) +- scripts contract (`scripts`) +- package scripts (`package.json`) +- `.omx/tmux-hook.json` integrity + +### Phase 3 - Isolation Provisioning + +Create isolated worktree from synced `origin/main`: + +```bash +git worktree add -b origin/main +``` + +Never implement directly on `main`. + +### Phase 4 - Deterministic Routing + +Run preflight: + +```bash +npm run omx:preflight +``` + +JSON mode: + +```bash +npm run omx:preflight -- --json +``` + +Optional distro selection: + +```bash +npm run omx:preflight -- --distro Ubuntu +``` + +#### Preflight Exit Codes + +| Exit Code | Mode | Meaning | Required Action | +| --- | --- | --- | --- | +| `0` | `team_ready` | Team prerequisites are satisfied | Continue with team mode | +| `2` | `team_blocked` | Fixable blockers (for example hook config) | Fix blockers, rerun preflight | +| `3` | `fallback_ralph` | Team-only prerequisites failed | Execute controlled Ralph fallback | +| `4` | `blocked` | Fatal blocker for both team and fallback (for example `omx` missing in both host and WSL runtimes) | Stop and fix fatal prerequisite | +| `1` | script error | Invocation/runtime failure | Fix command/environment | + +### Phase 5 - Ralph Execution Loop + +#### Team Path (preferred) + +Inside WSL tmux session: + +```bash +omx team ralph 6:executor "execute task: " +``` + +Capture startup evidence: + +```bash +omx team status +tmux list-panes -F '#{pane_id}\t#{pane_current_command}\t#{pane_start_command}' +test -f ".omx/state/team//mailbox/leader-fixed.json" && echo "leader mailbox present" +``` + +Monitor until terminal: + +```bash +omx team status +``` + +Terminal gate for normal completion: + +- `pending=0` +- `in_progress=0` +- `failed=0` + +#### Controlled Fallback Path + +Use fallback only when preflight mode is `fallback_ralph`: + +```bash +omx ralph "execute task: " +``` + +### Phase 6 - Hardening and Evidence + +Capture evidence before shutdown/handoff: + +```bash +npm run omx:evidence -- --mode team --team --architect-tier standard --architect-ref "" --architect-note "" +``` + +Ralph cleanup before fallback evidence: + +```bash +omx cancel +``` + +Fallback evidence: + +```bash +npm run omx:evidence -- --mode ralph --architect-tier standard --architect-ref "" --architect-note "" +``` + +Ralph state cleanup (required for completion): + +```bash +omx cancel +``` + +### Phase 7 - Shutdown and Handoff + +For team mode, only after evidence passes: + +```bash +omx team shutdown +test ! -d ".omx/state/team/" && echo "team state cleaned" +``` + +Handoff package must include: + +- branch name and commit SHA +- gate evidence file path +- architect verification reference +- unresolved blockers (if any) + +## Fail-Fast Controlled Retry Contract + +Retry budget is `2` retries maximum for a single run. + +Retry triggers: + +- team task failures +- no-ACK startup condition +- non-reporting worker condition after triage + +Retry steps: + +1. Capture current status and error output. +2. Attempt resume: + - `omx team resume ` + - `omx team status ` +3. If unresolved, controlled restart: + - `omx team shutdown ` + - stale pane/state cleanup + - relaunch with same task +4. After second retry failure, stop and escalate as blocked. + +## Reliability Remediation + +### tmux-hook placeholder target + +If `.omx/tmux-hook.json` contains: + +```json +"value": "replace-with-tmux-pane-id" +``` + +Set a real pane id: + +```bash +tmux display-message -p '#{pane_id}' +``` + +Then validate: + +```bash +omx tmux-hook validate +omx tmux-hook status +``` + +### Stale pane and team state cleanup + +Inspect panes: + +```bash +tmux list-panes -F '#{pane_id}\t#{pane_current_command}\t#{pane_start_command}' +``` + +Kill stale worker panes only: + +```bash +tmux kill-pane -t % +``` + +Remove stale team state: + +```bash +rm -rf ".omx/state/team/" +``` + +## Failure Matrix + +| Symptom | Detection | Action | +| --- | --- | --- | +| `tmux_hook invalid_config` | `.omx/logs/tmux-hook-*.jsonl` | fix `.omx/tmux-hook.json`, revalidate | +| `omx team` fails on tmux/WSL prerequisites | command output | use preflight routing, fallback if mode `fallback_ralph` | +| startup without ACK | missing mailbox updates | resume/triage then controlled retry | +| non-terminal task counts at completion | `omx team status` | block shutdown until terminal gate | +| architect verification missing | no `--architect-*` evidence | block completion | +| fatal preflight blocker (`blocked`) | preflight exit code `4` | stop and fix prerequisite | + +## Done Checklist + +- [ ] Preflight routing executed and mode recorded. +- [ ] Team startup evidence captured (if team mode). +- [ ] Terminal task-state gate satisfied before shutdown. +- [ ] Fresh quality gates passed (`typecheck`, `test`, `build`, diagnostics). +- [ ] Architect verification recorded with tier + reference. +- [ ] Evidence file created under `.omx/evidence/`. +- [ ] Ralph cleanup state is inactive in evidence output (`omx cancel` done before final ralph evidence). +- [ ] Team shutdown + cleanup verified (team mode only). + +## Command Reference + +```bash +# Preflight +npm run omx:preflight + +# Team execution +omx team ralph 6:executor "execute task: " +omx team status +omx team resume +omx team shutdown + +# Ralph fallback +omx ralph "execute task: " +omx cancel + +# Evidence +npm run omx:evidence -- --mode team --team --architect-tier standard --architect-ref "" +npm run omx:evidence -- --mode ralph --architect-tier standard --architect-ref "" +``` diff --git a/package.json b/package.json index 99934cf4..156f103a 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,8 @@ "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage", "coverage": "vitest run --coverage", + "omx:preflight": "node scripts/omx-preflight-wsl2.js", + "omx:evidence": "node scripts/omx-capture-evidence.js", "audit:prod": "npm audit --omit=dev --audit-level=high", "audit:all": "npm audit --audit-level=high", "audit:dev:allowlist": "node scripts/audit-dev-allowlist.js", diff --git a/scripts/omx-capture-evidence.js b/scripts/omx-capture-evidence.js new file mode 100644 index 00000000..3ec38f54 --- /dev/null +++ b/scripts/omx-capture-evidence.js @@ -0,0 +1,453 @@ +#!/usr/bin/env node + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; + +const __filename = fileURLToPath(import.meta.url); +const REDACTION_PLACEHOLDER = "***REDACTED***"; +const WRITE_RETRY_ATTEMPTS = 6; +const WRITE_RETRY_BASE_DELAY_MS = 40; + +function normalizePathForCompare(path) { + const resolved = resolve(path); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} + +const isDirectRun = (() => { + if (!process.argv[1]) return false; + return normalizePathForCompare(process.argv[1]) === normalizePathForCompare(__filename); +})(); + +function resolveTool(toolName) { + if (process.platform !== "win32") return toolName; + if (toolName === "npm") return "npm.cmd"; + if (toolName === "npx") return "npx.cmd"; + return toolName; +} + +export function parseArgs(argv) { + const options = { + mode: "", + team: "", + architectTier: "", + architectRef: "", + architectNote: "", + output: "", + }; + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + const value = argv[index + 1] ?? ""; + if (token === "--mode") { + if (!value) throw new Error("Missing value for --mode"); + options.mode = value; + index += 1; + continue; + } + if (token === "--team") { + if (!value) throw new Error("Missing value for --team"); + options.team = value; + index += 1; + continue; + } + if (token === "--architect-tier") { + if (!value) throw new Error("Missing value for --architect-tier"); + options.architectTier = value; + index += 1; + continue; + } + if (token === "--architect-ref") { + if (!value) throw new Error("Missing value for --architect-ref"); + options.architectRef = value; + index += 1; + continue; + } + if (token === "--architect-note") { + if (!value) throw new Error("Missing value for --architect-note"); + options.architectNote = value; + index += 1; + continue; + } + if (token === "--output") { + if (!value) throw new Error("Missing value for --output"); + options.output = value; + index += 1; + continue; + } + throw new Error(`Unknown option: ${token}`); + } + + if (options.mode !== "team" && options.mode !== "ralph") { + throw new Error("`--mode` must be `team` or `ralph`."); + } + if (options.mode === "team" && !options.team) { + throw new Error("`--team` is required when --mode team."); + } + if (!options.architectTier) { + throw new Error("`--architect-tier` is required."); + } + if (!options.architectRef) { + throw new Error("`--architect-ref` is required."); + } + + return options; +} + +export function runCommand(command, args, overrides = {}) { + const result = spawnSync(command, args, { + encoding: "utf8", + shell: false, + stdio: ["ignore", "pipe", "pipe"], + ...overrides, + }); + + return { + command: `${command} ${args.join(" ")}`.trim(), + code: typeof result.status === "number" ? result.status : 1, + stdout: typeof result.stdout === "string" ? result.stdout.trim() : "", + stderr: typeof result.stderr === "string" ? result.stderr.trim() : "", + }; +} + +function nowStamp() { + const date = new Date(); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hour = String(date.getHours()).padStart(2, "0"); + const minute = String(date.getMinutes()).padStart(2, "0"); + const second = String(date.getSeconds()).padStart(2, "0"); + const millis = String(date.getMilliseconds()).padStart(3, "0"); + return `${year}${month}${day}-${hour}${minute}${second}-${millis}`; +} + +function clampText(text, maxLength = 12000) { + if (text.length <= maxLength) return text; + return `${text.slice(0, maxLength)}\n...[truncated]`; +} + +export function redactSensitiveText(text) { + let redacted = text; + const replacementRules = [ + { + pattern: /\b(Authorization\s*:\s*Bearer\s+)([^\s\r\n]+)/gi, + replace: (_match, prefix, _secret) => `${prefix}${REDACTION_PLACEHOLDER}`, + }, + { + pattern: /("(?:token|secret|password|api[_-]?key|authorization|access_token)"\s*:\s*")([^"]+)(")/gi, + replace: (_match, start, _secret, end) => `${start}${REDACTION_PLACEHOLDER}${end}`, + }, + { + pattern: /\b((?:token|secret|password|api[_-]?key|authorization|access_token)\b[^\S\r\n]*[:=][^\S\r\n]*)([^\s\r\n]+)/gi, + replace: (_match, prefix, _secret) => `${prefix}${REDACTION_PLACEHOLDER}`, + }, + { + pattern: /\b(Bearer\s+)([A-Za-z0-9._~+/=-]+)/gi, + replace: (_match, prefix, _secret) => `${prefix}${REDACTION_PLACEHOLDER}`, + }, + { + pattern: /([?&](?:token|api[_-]?key|access_token|password)=)([^&\s]+)/gi, + replace: (_match, prefix, _secret) => `${prefix}${REDACTION_PLACEHOLDER}`, + }, + { + pattern: /\bsk-[A-Za-z0-9]{20,}\b/g, + replace: REDACTION_PLACEHOLDER, + }, + { + pattern: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, + replace: REDACTION_PLACEHOLDER, + }, + { + pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, + replace: REDACTION_PLACEHOLDER, + }, + { + pattern: /\b(AWS_SECRET_ACCESS_KEY\b[^\S\r\n]*[:=][^\S\r\n]*)([A-Za-z0-9/+=]{40})\b/gi, + replace: (_match, prefix, _secret) => `${prefix}${REDACTION_PLACEHOLDER}`, + }, + ]; + + for (const rule of replacementRules) { + redacted = redacted.replace(rule.pattern, rule.replace); + } + return redacted; +} + +function parseCount(text, keyAliases) { + for (const key of keyAliases) { + const patterns = [ + new RegExp(`${key}\\s*[=:]\\s*(\\d+)`, "i"), + new RegExp(`"${key}"\\s*:\\s*(\\d+)`, "i"), + ]; + for (const pattern of patterns) { + const match = text.match(pattern); + if (match) return Number(match[1]); + } + } + return null; +} + +export function parseTeamCounts(statusOutput) { + try { + const parsed = JSON.parse(statusOutput); + if (parsed && typeof parsed === "object") { + const summary = + "task_counts" in parsed && parsed.task_counts && typeof parsed.task_counts === "object" + ? parsed.task_counts + : "tasks" in parsed && parsed.tasks && typeof parsed.tasks === "object" + ? parsed.tasks + : null; + if (summary) { + const pending = "pending" in summary && typeof summary.pending === "number" ? summary.pending : null; + const inProgress = "in_progress" in summary && typeof summary.in_progress === "number" ? summary.in_progress : null; + const failed = "failed" in summary && typeof summary.failed === "number" ? summary.failed : null; + if (pending !== null && inProgress !== null && failed !== null) { + return { pending, inProgress, failed }; + } + } + } + } catch { + // ignore and fallback to regex parse + } + + const pending = parseCount(statusOutput, ["pending"]); + const inProgress = parseCount(statusOutput, ["in_progress", "in-progress", "in progress"]); + const failed = parseCount(statusOutput, ["failed"]); + if (pending === null || inProgress === null || failed === null) return null; + return { pending, inProgress, failed }; +} + +function formatOutput(result) { + const combined = [result.stdout, result.stderr].filter((value) => value.length > 0).join("\n"); + if (!combined) return "(no output)"; + return clampText(redactSensitiveText(combined)); +} + +function getErrorCode(error) { + if (error && typeof error === "object" && "code" in error && typeof error.code === "string") { + return error.code; + } + return ""; +} + +function isRetryableWriteError(error) { + const code = getErrorCode(error); + return code === "EBUSY" || code === "EPERM"; +} + +function sleep(milliseconds) { + const waitMs = Number.isFinite(milliseconds) && milliseconds > 0 ? milliseconds : 0; + if (waitMs === 0) return Promise.resolve(); + return new Promise((resolve) => { + setTimeout(resolve, waitMs); + }); +} + +export async function writeFileWithRetry(outputPath, content, deps = {}) { + const writeFn = deps.writeFileSyncFn ?? writeFileSync; + const sleepFn = deps.sleepFn ?? sleep; + const maxAttempts = Number.isInteger(deps.maxAttempts) ? deps.maxAttempts : WRITE_RETRY_ATTEMPTS; + const baseDelayMs = Number.isFinite(deps.baseDelayMs) ? deps.baseDelayMs : WRITE_RETRY_BASE_DELAY_MS; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + writeFn(outputPath, content, "utf8"); + return; + } catch (error) { + const isRetryable = isRetryableWriteError(error); + if (!isRetryable || attempt === maxAttempts) throw error; + await sleepFn(baseDelayMs * attempt); + } + } +} + +function ensureRepoRoot(cwd) { + const packagePath = join(cwd, "package.json"); + if (!existsSync(packagePath)) { + throw new Error(`Expected package.json in current directory (${cwd}). Run this command from repo root.`); + } +} + +function checkRalphCleanup(cwd) { + const statePath = join(cwd, ".omx", "state", "ralph-state.json"); + if (!existsSync(statePath)) { + return { passed: true, detail: "ralph state file not present (treated as cleaned)." }; + } + + try { + const parsed = JSON.parse(readFileSync(statePath, "utf8")); + const active = parsed && typeof parsed === "object" && "active" in parsed ? parsed.active : undefined; + const phase = parsed && typeof parsed === "object" && "current_phase" in parsed ? parsed.current_phase : undefined; + if (active === false) { + return { passed: true, detail: `ralph state inactive${phase ? ` (${String(phase)})` : ""}.` }; + } + return { passed: false, detail: "ralph state is still active; run `omx cancel` before final evidence capture." }; + } catch { + return { passed: false, detail: "ralph state file unreadable; fix state file or run `omx cancel`." }; + } +} + +function buildOutputPath(options, cwd, runId) { + if (options.output) return options.output; + const filename = `${runId}-${options.mode}-evidence.md`; + return join(cwd, ".omx", "evidence", filename); +} + +export async function runEvidence(options, deps = {}) { + const cwd = deps.cwd ?? process.cwd(); + ensureRepoRoot(cwd); + + const run = deps.runCommand ?? runCommand; + const npm = resolveTool("npm"); + const npx = resolveTool("npx"); + const omx = resolveTool("omx"); + + const metadataBranch = run("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd }); + const metadataCommit = run("git", ["rev-parse", "HEAD"], { cwd }); + + const typecheck = run(npm, ["run", "typecheck"], { cwd }); + const tests = run(npm, ["test"], { cwd }); + const build = run(npm, ["run", "build"], { cwd }); + const diagnostics = run(npx, ["tsc", "--noEmit", "--pretty", "false"], { cwd }); + + let teamStatus = null; + let teamCounts = null; + if (options.mode === "team") { + teamStatus = run(omx, ["team", "status", options.team], { cwd }); + if (teamStatus.code === 0) { + teamCounts = parseTeamCounts(`${teamStatus.stdout}\n${teamStatus.stderr}`); + } + } + + const teamStatePassed = + options.mode === "team" + ? teamStatus !== null && + teamStatus.code === 0 && + teamCounts !== null && + teamCounts.pending === 0 && + teamCounts.inProgress === 0 && + teamCounts.failed === 0 + : true; + + const ralphCleanup = options.mode === "ralph" ? checkRalphCleanup(cwd) : { passed: true, detail: "Not applicable (mode=team)" }; + + const architectPassed = options.architectTier.trim().length > 0 && options.architectRef.trim().length > 0; + + const gates = [ + { name: "Typecheck", passed: typecheck.code === 0, detail: "npm run typecheck" }, + { name: "Tests", passed: tests.code === 0, detail: "npm test" }, + { name: "Build", passed: build.code === 0, detail: "npm run build" }, + { name: "Diagnostics", passed: diagnostics.code === 0, detail: "npx tsc --noEmit --pretty false" }, + { + name: "Team terminal state", + passed: teamStatePassed, + detail: + options.mode === "team" + ? teamCounts + ? `pending=${teamCounts.pending}, in_progress=${teamCounts.inProgress}, failed=${teamCounts.failed}` + : "Unable to parse team status counts." + : "Not applicable (mode=ralph)", + }, + { + name: "Architect verification", + passed: architectPassed, + detail: `tier=${options.architectTier}; ref=${options.architectRef}`, + }, + { + name: "Ralph cleanup state", + passed: ralphCleanup.passed, + detail: ralphCleanup.detail, + }, + ]; + + const overallPassed = + typecheck.code === 0 && + tests.code === 0 && + build.code === 0 && + diagnostics.code === 0 && + teamStatePassed && + architectPassed && + ralphCleanup.passed; + + const runId = nowStamp(); + const outputPath = buildOutputPath(options, cwd, runId); + mkdirSync(dirname(outputPath), { recursive: true }); + + const lines = []; + lines.push("# OMX Execution Evidence"); + lines.push(""); + lines.push("## Metadata"); + lines.push(`- Run ID: ${runId}`); + lines.push(`- Generated at: ${new Date().toISOString()}`); + lines.push(`- Mode: ${options.mode}`); + if (options.mode === "team") lines.push(`- Team name: ${options.team}`); + lines.push(`- Branch: ${metadataBranch.code === 0 ? metadataBranch.stdout : "unknown"}`); + lines.push(`- Commit: ${metadataCommit.code === 0 ? metadataCommit.stdout : "unknown"}`); + lines.push(""); + lines.push("## Gate Summary"); + lines.push("| Gate | Result | Detail |"); + lines.push("| --- | --- | --- |"); + for (const gate of gates) { + lines.push(`| ${gate.name} | ${gate.passed ? "PASS" : "FAIL"} | ${gate.detail.replace(/\|/g, "\\|")} |`); + } + lines.push(""); + lines.push(`## Overall Result: ${overallPassed ? "PASS" : "FAIL"}`); + lines.push(""); + lines.push("## Redaction Strategy"); + lines.push( + `- Command output is sanitized before writing evidence; token/secret/password/api key patterns, GitHub/OpenAI tokens, and AWS key formats are replaced with ${REDACTION_PLACEHOLDER}.`, + ); + lines.push(""); + lines.push("## Command Output"); + + const commandResults = [ + { name: "typecheck", result: typecheck }, + { name: "tests", result: tests }, + { name: "build", result: build }, + { name: "diagnostics", result: diagnostics }, + ]; + if (teamStatus) commandResults.push({ name: "team-status", result: teamStatus }); + + for (const item of commandResults) { + lines.push(`### ${item.name} (${item.result.code === 0 ? "PASS" : "FAIL"})`); + lines.push("```text"); + lines.push(`$ ${item.result.command}`); + lines.push(formatOutput(item.result)); + lines.push("```"); + lines.push(""); + } + + lines.push("## Architect Verification"); + lines.push("```text"); + lines.push(`tier=${options.architectTier}`); + lines.push(`ref=${options.architectRef}`); + if (options.architectNote) lines.push(`note=${options.architectNote}`); + lines.push("```"); + lines.push(""); + + await writeFileWithRetry(outputPath, lines.join("\n")); + return { overallPassed, outputPath }; +} + +export async function main(argv = process.argv.slice(2)) { + const options = parseArgs(argv); + const result = await runEvidence(options); + if (result.overallPassed) { + console.log(`Evidence captured at ${result.outputPath}`); + console.log("All gates passed."); + process.exit(0); + } + console.error(`Evidence captured at ${result.outputPath}`); + console.error("One or more gates failed."); + process.exit(1); +} + +if (isDirectRun) { + main().catch((error) => { + console.error("Failed to capture evidence."); + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); +} diff --git a/scripts/omx-preflight-wsl2.js b/scripts/omx-preflight-wsl2.js new file mode 100644 index 00000000..735a284e --- /dev/null +++ b/scripts/omx-preflight-wsl2.js @@ -0,0 +1,339 @@ +#!/usr/bin/env node + +import { existsSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; + +const PLACEHOLDER_PANE_ID = "replace-with-tmux-pane-id"; + +const __filename = fileURLToPath(import.meta.url); + +function normalizePathForCompare(path) { + const resolved = resolve(path); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} + +const isDirectRun = (() => { + if (!process.argv[1]) return false; + return normalizePathForCompare(process.argv[1]) === normalizePathForCompare(__filename); +})(); + +export function parseArgs(argv) { + const options = { + json: false, + distro: "", + }; + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (token === "--json") { + options.json = true; + continue; + } + if (token === "--distro") { + const value = argv[index + 1] ?? ""; + if (!value) throw new Error("Missing value for --distro"); + options.distro = value; + index += 1; + continue; + } + throw new Error(`Unknown option: ${token}`); + } + + return options; +} + +export function runProcess(command, args, overrides = {}) { + const result = spawnSync(command, args, { + encoding: "utf8", + shell: false, + ...overrides, + }); + + return { + code: typeof result.status === "number" ? result.status : 1, + stdout: typeof result.stdout === "string" ? result.stdout : "", + stderr: typeof result.stderr === "string" ? result.stderr : "", + }; +} + +function addCheck(checks, status, severity, name, detail) { + checks.push({ status, severity, name, detail }); +} + +export function parseDistroList(stdout) { + return stdout + .replace(/\u0000/g, "") + .split(/\r?\n/) + .map((value) => value.trim()) + .filter((value) => value.length > 0); +} + +function getShellCommand(toolName) { + if (process.platform !== "win32") return toolName; + if (toolName === "npm") return "npm.cmd"; + if (toolName === "npx") return "npx.cmd"; + return toolName; +} + +function checkOmxOnHost(checks, runner) { + const omxHelp = runner(getShellCommand("omx"), ["--help"]); + if (omxHelp.code === 0) { + addCheck(checks, "pass", "info", "omx host runtime", "omx is available in current host runtime."); + } else { + addCheck( + checks, + "fail", + "fatal", + "omx host runtime", + "omx is required for both team mode and fallback mode. Install/enable omx first.", + ); + } +} + +function checkOmxOnHostAdvisory(checks, runner) { + const omxHelp = runner(getShellCommand("omx"), ["--help"]); + if (omxHelp.code === 0) { + addCheck(checks, "pass", "info", "omx host runtime", "omx is available in current host runtime."); + return true; + } + addCheck( + checks, + "warn", + "info", + "omx host runtime", + "omx is not available on host. Team mode can still run in WSL; fallback should run via WSL omx.", + ); + return false; +} + +function checkHookConfig(checks, cwd, fsDeps) { + const hookPath = join(cwd, ".omx", "tmux-hook.json"); + if (!fsDeps.existsSync(hookPath)) { + addCheck(checks, "warn", "info", "tmux hook config", `${hookPath} not found (optional but recommended).`); + return; + } + + let parsed; + try { + parsed = JSON.parse(fsDeps.readFileSync(hookPath, "utf8")); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + addCheck(checks, "fail", "fixable", "tmux hook config parse", `Invalid JSON in ${hookPath}: ${message}`); + return; + } + + const target = + parsed && typeof parsed === "object" && "target" in parsed && parsed.target && typeof parsed.target === "object" + ? parsed.target + : null; + const value = target && "value" in target && typeof target.value === "string" ? target.value : ""; + if (value === PLACEHOLDER_PANE_ID) { + addCheck( + checks, + "fail", + "fixable", + "tmux hook pane target", + `Set .omx/tmux-hook.json target.value to a real pane id (for example %12), not ${PLACEHOLDER_PANE_ID}.`, + ); + return; + } + addCheck(checks, "pass", "info", "tmux hook pane target", "tmux hook target is not placeholder."); +} + +function runWindowsChecks(checks, requestedDistro, runner) { + const hostOmxAvailable = checkOmxOnHostAdvisory(checks, runner); + let wslOmxAvailable = false; + + const wsl = runner("wsl", ["-l", "-q"]); + if (wsl.code !== 0) { + addCheck(checks, "fail", "team_hard", "wsl availability", "WSL unavailable. Team mode requires WSL2 or Unix host."); + if (!hostOmxAvailable) { + addCheck(checks, "fail", "fatal", "omx runtime availability", "omx is unavailable in both host and WSL runtimes."); + } + return { distro: "" }; + } + + const allDistros = parseDistroList(wsl.stdout); + if (allDistros.length === 0) { + addCheck(checks, "fail", "team_hard", "wsl distros", "No WSL distro found."); + if (!hostOmxAvailable) { + addCheck(checks, "fail", "fatal", "omx runtime availability", "omx is unavailable in both host and WSL runtimes."); + } + return { distro: "" }; + } + + const usableDistros = allDistros.filter((name) => !/^docker-desktop(-data)?$/i.test(name)); + if (usableDistros.length === 0) { + addCheck(checks, "fail", "team_hard", "usable distro", "Only Docker Desktop distros found. Install Ubuntu or another Linux distro."); + if (!hostOmxAvailable) { + addCheck(checks, "fail", "fatal", "omx runtime availability", "omx is unavailable in both host and WSL runtimes."); + } + return { distro: "" }; + } + + let selectedDistro = usableDistros[0]; + if (requestedDistro) { + if (!allDistros.includes(requestedDistro)) { + addCheck(checks, "fail", "team_hard", "requested distro", `Requested distro '${requestedDistro}' not found.`); + return { distro: "" }; + } + selectedDistro = requestedDistro; + } + addCheck(checks, "pass", "info", "selected distro", `Using WSL distro: ${selectedDistro}`); + + function runInWsl(command) { + return runner("wsl", ["-d", selectedDistro, "--", "sh", "-lc", command]); + } + + const tmux = runInWsl("command -v tmux >/dev/null 2>&1"); + if (tmux.code === 0) { + addCheck(checks, "pass", "info", "tmux in WSL", "tmux is available in selected distro."); + } else { + addCheck(checks, "fail", "team_hard", "tmux in WSL", "Install tmux in selected distro."); + } + + const omx = runInWsl("command -v omx >/dev/null 2>&1"); + if (omx.code === 0) { + wslOmxAvailable = true; + addCheck(checks, "pass", "info", "omx in WSL", "omx is available in selected distro."); + } else { + addCheck(checks, "fail", "team_hard", "omx in WSL", "Install/enable omx inside selected distro."); + } + + const teamHelp = runInWsl("omx team --help >/dev/null 2>&1"); + if (teamHelp.code === 0) { + addCheck(checks, "pass", "info", "omx team in WSL", "omx team command is callable in selected distro."); + } else { + addCheck(checks, "fail", "team_hard", "omx team in WSL", "omx team --help failed in selected distro."); + } + + addCheck( + checks, + "warn", + "info", + "tmux leader session check", + "Windows preflight cannot reliably assert existing tmux attachment. Rerun preflight from inside WSL tmux session before team launch.", + ); + + if (!hostOmxAvailable && !wslOmxAvailable) { + addCheck(checks, "fail", "fatal", "omx runtime availability", "omx is unavailable in both host and WSL runtimes."); + } + + return { distro: selectedDistro }; +} + +function runUnixChecks(checks, runner) { + checkOmxOnHost(checks, runner); + + const tmux = runner("sh", ["-lc", "command -v tmux >/dev/null 2>&1"]); + if (tmux.code === 0) { + addCheck(checks, "pass", "info", "tmux installed", "tmux is available in current runtime."); + } else { + addCheck(checks, "fail", "team_hard", "tmux installed", "Install tmux to use team mode."); + } + + const teamHelp = runner("sh", ["-lc", "omx team --help >/dev/null 2>&1"]); + if (teamHelp.code === 0) { + addCheck(checks, "pass", "info", "omx team help", "omx team command is callable."); + } else { + addCheck(checks, "fail", "team_hard", "omx team help", "omx team --help failed in current runtime."); + } + + const tmuxSession = runner("sh", ["-lc", "[ -n \"${TMUX:-}\" ]"]); + if (tmuxSession.code === 0) { + addCheck(checks, "pass", "info", "tmux leader session", "Current shell is inside tmux."); + } else { + addCheck(checks, "fail", "fixable", "tmux leader session", "Enter a tmux session before running omx team."); + } +} + +export function decide(checks) { + const hasFatal = checks.some((entry) => entry.status === "fail" && entry.severity === "fatal"); + const hasTeamHard = checks.some((entry) => entry.status === "fail" && entry.severity === "team_hard"); + const hasFixable = checks.some((entry) => entry.status === "fail" && entry.severity === "fixable"); + + if (hasFatal) return { mode: "blocked", exitCode: 4 }; + if (hasTeamHard) return { mode: "fallback_ralph", exitCode: 3 }; + if (hasFixable) return { mode: "team_blocked", exitCode: 2 }; + return { mode: "team_ready", exitCode: 0 }; +} + +export function formatConsoleOutput(payload) { + const lines = []; + lines.push("OMX WSL2 Team Preflight"); + lines.push("======================="); + lines.push(`Decision: ${payload.mode}`); + if (payload.distro) lines.push(`Distro: ${payload.distro}`); + lines.push(""); + lines.push("Checks:"); + for (const check of payload.checks) { + let label = "PASS"; + if (check.status === "warn") label = "WARN"; + if (check.status === "fail" && check.severity === "fixable") label = "FAIL-FIX"; + if (check.status === "fail" && check.severity === "team_hard") label = "FAIL-TEAM"; + if (check.status === "fail" && check.severity === "fatal") label = "FAIL-FATAL"; + lines.push(`- [${label}] ${check.name}: ${check.detail}`); + } + lines.push(""); + if (payload.mode === "team_ready") { + lines.push("Next: run `omx team ralph 6:executor \"\"` inside tmux."); + } else if (payload.mode === "team_blocked") { + lines.push("Next: fix FAIL-FIX checks and rerun preflight."); + } else if (payload.mode === "fallback_ralph") { + lines.push("Next: run controlled fallback `omx ralph \"\"` while team prerequisites are unavailable."); + } else { + lines.push("Next: fix FAIL-FATAL prerequisites before continuing."); + } + return lines.join("\n"); +} + +export function runPreflight(options = {}, deps = {}) { + const checks = []; + const runner = deps.runProcess ?? runProcess; + const platform = deps.platform ?? process.platform; + const cwd = deps.cwd ?? process.cwd(); + const fsDeps = { + existsSync: deps.existsSync ?? existsSync, + readFileSync: deps.readFileSync ?? readFileSync, + }; + + let distro = ""; + if (platform === "win32") { + const winResult = runWindowsChecks(checks, options.distro ?? "", runner); + distro = winResult.distro; + } else { + runUnixChecks(checks, runner); + } + + checkHookConfig(checks, cwd, fsDeps); + const decision = decide(checks); + return { + mode: decision.mode, + exitCode: decision.exitCode, + distro, + checks, + }; +} + +export function main(argv = process.argv.slice(2)) { + const options = parseArgs(argv); + const result = runPreflight(options); + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(formatConsoleOutput(result)); + } + process.exit(result.exitCode); +} + +if (isDirectRun) { + try { + main(); + } catch (error) { + console.error("Preflight failed."); + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/test/omx-evidence.test.ts b/test/omx-evidence.test.ts new file mode 100644 index 00000000..4d16a277 --- /dev/null +++ b/test/omx-evidence.test.ts @@ -0,0 +1,255 @@ +import { describe, it, expect } from "vitest"; +import { writeFileSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +describe("omx-capture-evidence script", () => { + it("parses required args", async () => { + const mod = await import("../scripts/omx-capture-evidence.js"); + expect( + mod.parseArgs([ + "--mode", + "ralph", + "--architect-tier", + "standard", + "--architect-ref", + "architect://run/123", + ]), + ).toEqual({ + mode: "ralph", + team: "", + architectTier: "standard", + architectRef: "architect://run/123", + architectNote: "", + output: "", + }); + }); + + it("requires architect args", async () => { + const mod = await import("../scripts/omx-capture-evidence.js"); + expect(() => mod.parseArgs(["--mode", "ralph"])).toThrow("`--architect-tier` is required."); + }); + + it("parses team status counts from json and text", async () => { + const mod = await import("../scripts/omx-capture-evidence.js"); + expect(mod.parseTeamCounts('{"task_counts":{"pending":0,"in_progress":0,"failed":1}}')).toEqual({ + pending: 0, + inProgress: 0, + failed: 1, + }); + expect(mod.parseTeamCounts("pending=2 in_progress=1 failed=0")).toEqual({ + pending: 2, + inProgress: 1, + failed: 0, + }); + }); + + it("redacts sensitive command output before writing evidence", async () => { + const mod = await import("../scripts/omx-capture-evidence.js"); + const root = await mkdtemp(join(tmpdir(), "omx-evidence-redaction-")); + await writeFile(join(root, "package.json"), '{"name":"tmp"}', "utf8"); + + try { + const outputPath = join(root, ".omx", "evidence", "redacted.md"); + await mod.runEvidence( + { + mode: "ralph", + team: "", + architectTier: "standard", + architectRef: "architect://verdict/ok", + architectNote: "", + output: outputPath, + }, + { + cwd: root, + runCommand: (command: string, args: string[]) => { + if (command === "git" && args[0] === "rev-parse" && args[1] === "--abbrev-ref") { + return { command: "git rev-parse --abbrev-ref HEAD", code: 0, stdout: "feature/test", stderr: "" }; + } + if (command === "git" && args[0] === "rev-parse" && args[1] === "HEAD") { + return { command: "git rev-parse HEAD", code: 0, stdout: "abc123", stderr: "" }; + } + return { + command: `${command} ${args.join(" ")}`, + code: 0, + stdout: + "token=secret-value Authorization: Bearer bearer-value sk-1234567890123456789012 AKIA1234567890ABCDEF AWS_SECRET_ACCESS_KEY=abcdABCD0123abcdABCD0123abcdABCD0123abcd", + stderr: "", + }; + }, + }, + ); + + const markdown = await readFile(outputPath, "utf8"); + expect(markdown).toContain("***REDACTED***"); + expect(markdown).not.toContain("secret-value"); + expect(markdown).not.toContain("bearer-value"); + expect(markdown).not.toContain("AKIA1234567890ABCDEF"); + expect(markdown).not.toContain("abcdABCD0123abcdABCD0123abcdABCD0123abcd"); + expect(markdown).toContain("## Redaction Strategy"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("handles 100 concurrent retry-prone writes without EBUSY throw", async () => { + const mod = await import("../scripts/omx-capture-evidence.js"); + const root = await mkdtemp(join(tmpdir(), "omx-evidence-concurrency-")); + const sharedPath = join(root, "shared-evidence.md"); + const seenPayloadAttempts = new Map(); + + const makeBusyError = () => { + const error = new Error("file busy"); + Object.assign(error, { code: "EBUSY" }); + return error; + }; + + try { + const concurrencyCount = 100; + const writes = Array.from({ length: concurrencyCount }, (_value, index) => { + return new Promise((resolve) => { + setTimeout(resolve, 0); + }).then(() => + mod.writeFileWithRetry(sharedPath, `write-${index}`, { + writeFileSyncFn: (path: string, content: string, encoding: BufferEncoding) => { + const attempts = seenPayloadAttempts.get(content) ?? 0; + if (attempts === 0) { + seenPayloadAttempts.set(content, 1); + throw makeBusyError(); + } + seenPayloadAttempts.set(content, attempts + 1); + writeFileSync(path, content, encoding); + }, + sleepFn: async () => Promise.resolve(), + maxAttempts: 5, + baseDelayMs: 0, + }), + ); + }); + + await expect(Promise.all(writes)).resolves.toHaveLength(concurrencyCount); + const finalContent = await readFile(sharedPath, "utf8"); + expect(finalContent.startsWith("write-")).toBe(true); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("retries EBUSY with built-in sleep implementation", async () => { + const mod = await import("../scripts/omx-capture-evidence.js"); + const root = await mkdtemp(join(tmpdir(), "omx-evidence-sleep-")); + const outputPath = join(root, "retry-output.md"); + let calls = 0; + + const makeBusyError = () => { + const error = new Error("file busy"); + Object.assign(error, { code: "EBUSY" }); + return error; + }; + + try { + await expect( + mod.writeFileWithRetry(outputPath, "content", { + writeFileSyncFn: (path: string, content: string, encoding: BufferEncoding) => { + calls += 1; + if (calls === 1) throw makeBusyError(); + writeFileSync(path, content, encoding); + }, + maxAttempts: 3, + baseDelayMs: 1, + }), + ).resolves.toBeUndefined(); + + expect(calls).toBe(2); + const fileContent = await readFile(outputPath, "utf8"); + expect(fileContent).toBe("content"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("writes evidence markdown when gates pass in ralph mode", async () => { + const mod = await import("../scripts/omx-capture-evidence.js"); + const root = await mkdtemp(join(tmpdir(), "omx-evidence-")); + await writeFile(join(root, "package.json"), '{"name":"tmp"}', "utf8"); + + try { + const outputPath = join(root, ".omx", "evidence", "result.md"); + const result = await mod.runEvidence( + { + mode: "ralph", + team: "", + architectTier: "standard", + architectRef: "architect://verdict/ok", + architectNote: "approved", + output: outputPath, + }, + { + cwd: root, + runCommand: (command: string, args: string[]) => { + if (command === "git" && args[0] === "rev-parse" && args[1] === "--abbrev-ref") { + return { command: "git rev-parse --abbrev-ref HEAD", code: 0, stdout: "feature/test", stderr: "" }; + } + if (command === "git" && args[0] === "rev-parse" && args[1] === "HEAD") { + return { command: "git rev-parse HEAD", code: 0, stdout: "abc123", stderr: "" }; + } + return { command: `${command} ${args.join(" ")}`, code: 0, stdout: "ok", stderr: "" }; + }, + }, + ); + + expect(result.overallPassed).toBe(true); + const markdown = await readFile(outputPath, "utf8"); + expect(markdown).toContain("## Overall Result: PASS"); + expect(markdown).toContain("architect://verdict/ok"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("fails ralph mode evidence when cleanup state is still active", async () => { + const mod = await import("../scripts/omx-capture-evidence.js"); + const root = await mkdtemp(join(tmpdir(), "omx-evidence-active-")); + await writeFile(join(root, "package.json"), '{"name":"tmp"}', "utf8"); + await mkdir(join(root, ".omx", "state"), { recursive: true }); + await writeFile( + join(root, ".omx", "state", "ralph-state.json"), + JSON.stringify({ active: true, current_phase: "executing" }), + "utf8", + ); + + try { + const outputPath = join(root, ".omx", "evidence", "result-active.md"); + const result = await mod.runEvidence( + { + mode: "ralph", + team: "", + architectTier: "standard", + architectRef: "architect://verdict/ok", + architectNote: "", + output: outputPath, + }, + { + cwd: root, + runCommand: (command: string, args: string[]) => { + if (command === "git" && args[0] === "rev-parse" && args[1] === "--abbrev-ref") { + return { command: "git rev-parse --abbrev-ref HEAD", code: 0, stdout: "feature/test", stderr: "" }; + } + if (command === "git" && args[0] === "rev-parse" && args[1] === "HEAD") { + return { command: "git rev-parse HEAD", code: 0, stdout: "abc123", stderr: "" }; + } + return { command: `${command} ${args.join(" ")}`, code: 0, stdout: "ok", stderr: "" }; + }, + }, + ); + + expect(result.overallPassed).toBe(false); + const markdown = await readFile(outputPath, "utf8"); + expect(markdown).toContain("Ralph cleanup state"); + expect(markdown).toContain("FAIL"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/test/omx-preflight.test.ts b/test/omx-preflight.test.ts new file mode 100644 index 00000000..e69e49ce --- /dev/null +++ b/test/omx-preflight.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect } from "vitest"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +describe("omx-preflight-wsl2 script", () => { + it("parses cli args", async () => { + const mod = await import("../scripts/omx-preflight-wsl2.js"); + expect(mod.parseArgs(["--json", "--distro", "Ubuntu"])).toEqual({ + json: true, + distro: "Ubuntu", + }); + }); + + it("throws on unknown args", async () => { + const mod = await import("../scripts/omx-preflight-wsl2.js"); + expect(() => mod.parseArgs(["--wat"])).toThrow("Unknown option"); + }); + + it("normalizes WSL distro output that contains null chars", async () => { + const mod = await import("../scripts/omx-preflight-wsl2.js"); + const output = "d\u0000o\u0000c\u0000k\u0000e\u0000r\u0000-\u0000d\u0000e\u0000s\u0000k\u0000t\u0000o\u0000p\u0000\r\n\u0000Ubuntu\r\n"; + expect(mod.parseDistroList(output)).toEqual(["docker-desktop", "Ubuntu"]); + }); + + it("warns on missing host omx in windows mode when WSL checks pass", async () => { + const mod = await import("../scripts/omx-preflight-wsl2.js"); + + const result = mod.runPreflight( + { distro: "" }, + { + platform: "win32", + cwd: process.cwd(), + existsSync: () => false, + readFileSync: () => "", + runProcess: (command: string, args: string[]) => { + if (command === "omx") return { code: 1, stdout: "", stderr: "missing" }; + if (command === "wsl" && args[0] === "-l") return { code: 0, stdout: "Ubuntu\n", stderr: "" }; + if (command === "wsl" && args[0] === "-d") return { code: 0, stdout: "", stderr: "" }; + return { code: 0, stdout: "", stderr: "" }; + }, + }, + ); + + expect(result.mode).toBe("team_ready"); + expect(result.exitCode).toBe(0); + expect(result.checks.some((entry: { name: string; status: string }) => entry.name === "omx host runtime" && entry.status === "warn")).toBe(true); + }); + + it("routes to blocked when omx is missing on unix host", async () => { + const mod = await import("../scripts/omx-preflight-wsl2.js"); + + const result = mod.runPreflight( + { distro: "" }, + { + platform: "linux", + cwd: process.cwd(), + existsSync: () => false, + readFileSync: () => "", + runProcess: (command: string) => { + if (command === "omx") return { code: 1, stdout: "", stderr: "missing" }; + return { code: 0, stdout: "", stderr: "" }; + }, + }, + ); + + expect(result.mode).toBe("blocked"); + expect(result.exitCode).toBe(4); + }); + + it("routes to fallback when team-only prerequisites fail", async () => { + const mod = await import("../scripts/omx-preflight-wsl2.js"); + + const result = mod.runPreflight( + { distro: "" }, + { + platform: "win32", + cwd: process.cwd(), + existsSync: () => false, + readFileSync: () => "", + runProcess: (command: string, args: string[]) => { + if (command === "omx") return { code: 0, stdout: "ok", stderr: "" }; + if (command === "wsl" && args[0] === "-l") return { code: 0, stdout: "docker-desktop\n", stderr: "" }; + return { code: 0, stdout: "", stderr: "" }; + }, + }, + ); + + expect(result.mode).toBe("fallback_ralph"); + expect(result.exitCode).toBe(3); + }); + + it("routes to blocked on windows when omx is missing in host and WSL", async () => { + const mod = await import("../scripts/omx-preflight-wsl2.js"); + + const result = mod.runPreflight( + { distro: "" }, + { + platform: "win32", + cwd: process.cwd(), + existsSync: () => false, + readFileSync: () => "", + runProcess: (command: string, args: string[]) => { + if (command === "omx") return { code: 1, stdout: "", stderr: "missing" }; + if (command === "wsl" && args[0] === "-l") return { code: 0, stdout: "Ubuntu\n", stderr: "" }; + if (command === "wsl" && args[0] === "-d") { + if (args.join(" ").includes("command -v omx")) return { code: 1, stdout: "", stderr: "missing" }; + if (args.join(" ").includes("command -v tmux")) return { code: 0, stdout: "", stderr: "" }; + if (args.join(" ").includes("omx team --help")) return { code: 1, stdout: "", stderr: "missing" }; + } + return { code: 0, stdout: "", stderr: "" }; + }, + }, + ); + + expect(result.mode).toBe("blocked"); + expect(result.exitCode).toBe(4); + expect(result.checks.some((entry: { name: string; severity: string }) => entry.name === "omx runtime availability" && entry.severity === "fatal")).toBe(true); + }); + + it("detects placeholder tmux hook pane target as fixable", async () => { + const mod = await import("../scripts/omx-preflight-wsl2.js"); + const root = await mkdtemp(join(tmpdir(), "omx-preflight-")); + const omxDir = join(root, ".omx"); + await mkdir(omxDir, { recursive: true }); + await writeFile( + join(omxDir, "tmux-hook.json"), + JSON.stringify({ + enabled: true, + target: { type: "pane", value: "replace-with-tmux-pane-id" }, + }), + "utf8", + ); + + try { + const result = mod.runPreflight( + { distro: "" }, + { + platform: "linux", + cwd: root, + runProcess: (command: string, args: string[]) => { + if (command === "sh" && args.join(" ").includes("command -v tmux")) return { code: 0, stdout: "", stderr: "" }; + if (command === "sh" && args.join(" ").includes("omx team --help")) return { code: 0, stdout: "", stderr: "" }; + if (command === "sh" && args.join(" ").includes("${TMUX:-}")) return { code: 0, stdout: "", stderr: "" }; + if (command === "omx") return { code: 0, stdout: "ok", stderr: "" }; + return { code: 0, stdout: "", stderr: "" }; + }, + }, + ); + + expect(result.mode).toBe("team_blocked"); + expect(result.checks.some((entry: { name: string; status: string }) => entry.name === "tmux hook pane target" && entry.status === "fail")).toBe(true); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +});