From fc1caa23a127fa0dec932ca259fe2a53cfb521cb Mon Sep 17 00:00:00 2001 From: LZRS Date: Mon, 20 Apr 2026 10:49:33 -0700 Subject: [PATCH 001/108] chore: update review-adr workflow to renamed ADR-0004 filename Co-Authored-By: Claude Opus 4.7 (1M context) --- .loopx/review-adr/index.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.loopx/review-adr/index.sh b/.loopx/review-adr/index.sh index 4f70e93..d29342c 100755 --- a/.loopx/review-adr/index.sh +++ b/.loopx/review-adr/index.sh @@ -3,7 +3,7 @@ set -euo pipefail ROOT="$LOOPX_PROJECT_ROOT" ADR_0001="$ROOT/adr/0001-adr-process.md" -ADR_0004="$ROOT/adr/0004-tmpdir-and-args.md" +ADR_0004="$ROOT/adr/0004-tmpdir-and-env.md" SPEC="$ROOT/SPEC.md" SHARED_DIR="$ROOT/.loopx/shared" PROMPT_FILE="$SHARED_DIR/.prompt.tmp" @@ -20,7 +20,7 @@ if [[ ! -f "$ADR_0001" ]]; then fi if [[ ! -f "$ADR_0004" ]]; then - echo "Error: adr/0004-tmpdir-and-args.md not found" >&2 + echo "Error: adr/0004-tmpdir-and-env.md not found" >&2 exit 1 fi @@ -37,7 +37,7 @@ Review ADR 0001, ADR 0004, and SPEC.md holistically and let me know if I can mar adr/0001-adr-process.md: $(cat "$ADR_0001") -adr/0004-tmpdir-and-args.md: +adr/0004-tmpdir-and-env.md: $(cat "$ADR_0004") SPEC.md: From b39853da7cb19f3a3f87238e5ed9ea97c858d6e2 Mon Sep 17 00:00:00 2001 From: LZRS Date: Mon, 20 Apr 2026 10:50:46 -0700 Subject: [PATCH 002/108] chore: cleanup --- .loopx/shared/.caller.tmp | 1 + .loopx/shared/.prompt.tmp | 1238 +++++++++++++++++ loopx-backup/apply-adr/apply-answer.sh | 23 - loopx-backup/apply-adr/apply-feedback.sh | 28 - .../apply-adr/check-question.schema.json | 11 - loopx-backup/apply-adr/check-question.sh | 121 -- loopx-backup/apply-adr/index.sh | 55 - loopx-backup/apply-adr/lib/send-api.ts | 106 -- loopx-backup/apply-adr/lib/send-codex.sh | 43 - loopx-backup/apply-adr/lib/send-telegram.sh | 104 -- loopx-backup/apply-adr/package.json | 7 - loopx-backup/package.json | 1 - loopx-backup/ralph/check-ready.sh | 33 - loopx-backup/ralph/index.sh | 33 - loopx-backup/ralph/package.json | 1 - loopx-backup/review-adr/.claude-output.tmp | 36 - loopx-backup/review-adr/apply-answer.sh | 23 - loopx-backup/review-adr/apply-feedback.sh | 28 - .../review-adr/check-question.schema.json | 11 - loopx-backup/review-adr/check-question.sh | 121 -- loopx-backup/review-adr/index.sh | 58 - loopx-backup/review-adr/lib/send-api.ts | 106 -- loopx-backup/review-adr/lib/send-codex.sh | 43 - loopx-backup/review-adr/lib/send-telegram.sh | 104 -- loopx-backup/review-adr/package-lock.json | 545 -------- loopx-backup/review-adr/package.json | 7 - loopx-backup/spec-test-adr/apply-answer.sh | 23 - loopx-backup/spec-test-adr/apply-feedback.sh | 28 - .../spec-test-adr/check-question.schema.json | 11 - loopx-backup/spec-test-adr/check-question.sh | 121 -- loopx-backup/spec-test-adr/index.sh | 66 - loopx-backup/spec-test-adr/lib/send-api.ts | 106 -- loopx-backup/spec-test-adr/lib/send-codex.sh | 43 - .../spec-test-adr/lib/send-telegram.sh | 104 -- loopx-backup/spec-test-adr/package.json | 7 - loopx-workflows/ralph/check-ready.sh | 33 - loopx-workflows/ralph/package.json | 1 - loopx-workflows/ralph/run-ralph.sh | 33 - 38 files changed, 1239 insertions(+), 2224 deletions(-) create mode 100644 .loopx/shared/.caller.tmp create mode 100644 .loopx/shared/.prompt.tmp delete mode 100755 loopx-backup/apply-adr/apply-answer.sh delete mode 100755 loopx-backup/apply-adr/apply-feedback.sh delete mode 100644 loopx-backup/apply-adr/check-question.schema.json delete mode 100755 loopx-backup/apply-adr/check-question.sh delete mode 100755 loopx-backup/apply-adr/index.sh delete mode 100644 loopx-backup/apply-adr/lib/send-api.ts delete mode 100755 loopx-backup/apply-adr/lib/send-codex.sh delete mode 100755 loopx-backup/apply-adr/lib/send-telegram.sh delete mode 100644 loopx-backup/apply-adr/package.json delete mode 100644 loopx-backup/package.json delete mode 100755 loopx-backup/ralph/check-ready.sh delete mode 100755 loopx-backup/ralph/index.sh delete mode 100644 loopx-backup/ralph/package.json delete mode 100644 loopx-backup/review-adr/.claude-output.tmp delete mode 100755 loopx-backup/review-adr/apply-answer.sh delete mode 100755 loopx-backup/review-adr/apply-feedback.sh delete mode 100644 loopx-backup/review-adr/check-question.schema.json delete mode 100755 loopx-backup/review-adr/check-question.sh delete mode 100755 loopx-backup/review-adr/index.sh delete mode 100644 loopx-backup/review-adr/lib/send-api.ts delete mode 100755 loopx-backup/review-adr/lib/send-codex.sh delete mode 100755 loopx-backup/review-adr/lib/send-telegram.sh delete mode 100644 loopx-backup/review-adr/package-lock.json delete mode 100644 loopx-backup/review-adr/package.json delete mode 100755 loopx-backup/spec-test-adr/apply-answer.sh delete mode 100755 loopx-backup/spec-test-adr/apply-feedback.sh delete mode 100644 loopx-backup/spec-test-adr/check-question.schema.json delete mode 100755 loopx-backup/spec-test-adr/check-question.sh delete mode 100755 loopx-backup/spec-test-adr/index.sh delete mode 100644 loopx-backup/spec-test-adr/lib/send-api.ts delete mode 100755 loopx-backup/spec-test-adr/lib/send-codex.sh delete mode 100755 loopx-backup/spec-test-adr/lib/send-telegram.sh delete mode 100644 loopx-backup/spec-test-adr/package.json delete mode 100755 loopx-workflows/ralph/check-ready.sh delete mode 100644 loopx-workflows/ralph/package.json delete mode 100755 loopx-workflows/ralph/run-ralph.sh diff --git a/.loopx/shared/.caller.tmp b/.loopx/shared/.caller.tmp new file mode 100644 index 0000000..4e28f24 --- /dev/null +++ b/.loopx/shared/.caller.tmp @@ -0,0 +1 @@ +review-adr diff --git a/.loopx/shared/.prompt.tmp b/.loopx/shared/.prompt.tmp new file mode 100644 index 0000000..24a9a8e --- /dev/null +++ b/.loopx/shared/.prompt.tmp @@ -0,0 +1,1238 @@ +Review ADR 0001, ADR 0004, and SPEC.md holistically and let me know if I can mark ADR 0004 as accepted or if I need to improve it further. Ask me clarifying questions if you have any doubts about my intentions for ADR 0004. + +adr/0001-adr-process.md: +# ADR-0001: Establish ADR Process + +**Status:** Implemented + +--- + +## Context + +As the loopx project grows, we need a structured way to propose, evaluate, and track changes to the specification. Without a formal process, spec changes risk being ad-hoc, poorly documented, or inconsistently applied through the development lifecycle. + +## Decision + +We adopt an Architecture Decision Record (ADR) process to govern all changes to the loopx specification (`SPEC.md`). Each ADR describes a proposed modification to the spec and progresses through a defined lifecycle before the change is considered complete. + +### ADR Lifecycle + +Every ADR moves through these statuses in order: + +| Status | Meaning | +|---|---| +| **Proposed** | ADR has been created in the `adr/` directory and is open for review. | +| **Accepted** | The proposed change has been approved. | +| **Spec Updated** | `SPEC.md` has been modified to reflect the accepted change. The ADR is now complete as a decision document. | +| **Test Specified** | `TEST-SPEC.md` has been updated to account for the spec changes. | +| **Tested** | Tests have been written or updated. Some tests may intentionally fail because the implementation does not yet exist. | +| **Implemented** | The implementation is complete and all tests pass. This is the terminal status. | + +### ADR Format + +Each ADR is a Markdown file in the `adr/` directory, named with a zero-padded sequence number and a short slug: + +``` +adr/NNNN-short-description.md +``` + +An ADR must contain: + +- **Title** — `ADR-NNNN: Short Description` +- **Status** — current lifecycle status (see above) +- **Context** — why this change is being considered +- **Decision** — what the spec change is, described precisely enough to update `SPEC.md` +- **Consequences** — what follows from this decision (trade-offs, migration, etc.) +- **Test Recommendations** *(optional)* — edge cases or scenarios that should be covered when writing tests (e.g., "be sure to verify behavior when the input list is empty"). This is not an exhaustive test plan; it highlights cases that are easy to overlook. + +### Workflow + +1. **Create the ADR** — Author writes the ADR in `adr/` with status **Proposed**. +2. **Review and accept** — After review, status moves to **Accepted**. +3. **Update the spec** — `SPEC.md` is modified to incorporate the decision. Status becomes **Spec Updated**. +4. **Update the test spec** — `TEST-SPEC.md` is updated to cover the new or changed spec behavior. Status becomes **Test Specified**. +5. **Write/update tests** — Tests are added or modified. Newly added tests that depend on unimplemented behavior are expected to fail. Status becomes **Tested**. +6. **Implement** — The codebase is updated to satisfy the spec and pass all tests. Status becomes **Implemented**. + +### Key Principles + +- An ADR specifies a change to the spec, not a code change. Implementation details belong in the code, not the ADR. +- The spec (`SPEC.md`) is the single source of truth for what the system should do. ADRs are the record of how and why the spec evolved. +- Steps 3-6 happen sequentially. Each step must be completed before moving to the next. + +## Consequences + +- All spec changes are traceable back to a decision record. +- The development cycle is spec-first: spec change, then test spec, then tests, then implementation. +- ADRs accumulate as a historical log of project decisions in the `adr/` directory. + +## Test Recommendations + +N/A — this ADR is process-only and has no implementation to test. + +adr/0004-tmpdir-and-env.md: +# ADR-0004: Run-Scoped Temporary Directory and Programmatic Env Option + +**Status:** Proposed + +--- + +## Decision Summary + +- **`LOOPX_TMPDIR`.** Each `loopx run` gets a private `os.tmpdir()`-based directory (mode `0700`), injected into every script's env. Shared across iterations, cleaned up on terminal outcomes (normal completion, error exit, signal, abort). +- **`RunOptions.env`.** A new optional `Record` field on `RunOptions`, shallow-merged into the child env between env-file loading and loopx protocol variable injection. +- **No CLI named-argument syntax.** CLI callers who want to pass a per-run value use the standard shell env-var prefix (`key=value loopx run …`), which already flows through loopx's env inheritance. `-e` and `loopx env set` remain available for file-based config. +- **Cleanup safety.** Path-based best-effort: loopx does not follow symlinks and does not delete files or directories that replaced the tmpdir after creation. Not a sandbox against actively racing same-user processes. + +A later ADR may revisit a first-class CLI named-argument surface, a named-argument schema, or stronger race-resistant cleanup (e.g., `openat` / `unlinkat`) if evidence of need accumulates. + +--- + +## Context + +Two pain points have emerged as workflows have grown. + +1. **Passing intermediate data between scripts during a loop run.** Workflows like `review-adr`, `apply-adr`, and `spec-test-adr` stash hand-off files (`.feedback.tmp`, `.claude-output.tmp`, `.answer.tmp`, `.prompt.tmp`) either inside their own workflow directory or in a sibling `.loopx/shared/` directory — the latter so scripts reached via cross-workflow `goto` can find them at `$ROOT/.loopx/$LOOPX_WORKFLOW/…` after the goto. Both patterns rely on `rm -f` on the happy path with no cleanup on error, signal, or unexpected termination; leaked files accumulate and are indistinguishable from legitimate workflow content. The `ralph` workflow has the same issue with its hand-maintained `.loopx/.iteration.tmp` counter — intra-run state that has been leaking into the `.loopx/` directory because there is no per-run scratch space to hold it. + +2. **Per-run parameterization from programmatic callers.** Callers of `run()` / `runPromise()` currently have no way to inject per-invocation env vars without mutating `process.env` globally, which is racy under concurrent calls. The CLI already handles the parameterization case natively — `adr=0003 loopx run review-adr` works today because `src/env.ts` spreads inherited `process.env` into the child — but the programmatic API has no equivalent. + +An earlier draft of this ADR proposed a `loopx run … -- name=value` CLI surface with its own precedence tier and dedicated parser. It is intentionally not adopted: for the identified use case (passing a parameter like an ADR number into a script), shell env-var prefixing on the CLI is already sufficient, and the parser surface would add substantial implementation complexity (two-phase parsing, name grammar, duplicate detection, `LOOPX_*` reservation, precedence tier reordering) without matching value for v1. Scripts that need to validate a required value do so themselves (`: "${adr:?need adr}"` in Bash; `if (!process.env.adr) throw …` in TS). + +A generic session-state mechanism (first-class iteration counter, key-value store) was considered but deferred. Scripts that need such state maintain it inside `$LOOPX_TMPDIR` — including the iteration counter, which is a trivial read/increment/write against a file there. + +## Decision + +### 1. `LOOPX_TMPDIR` — run-scoped temporary directory + +For each `loopx run` (CLI) or `run()` / `runPromise()` (programmatic) invocation that reaches execution, loopx creates a unique temporary directory before the first child process spawns and injects its absolute path into every script's environment as `LOOPX_TMPDIR`. + +#### Location, naming, and mode + +The directory is created under Node's `os.tmpdir()` via `mkdtemp` with a `loopx-` prefix. Mode is `0700` (owner read/write/execute only). The exact name format beyond the prefix is implementation-defined and must not be relied on by scripts. + +#### Scope and lifecycle + +- **Created:** once per run, after pre-iteration validation (discovery, env loading, target resolution, version check) and immediately before the first child spawns. Pre-spawn failures, `-n 0` / `maxIterations: 0` early exits, and aborts observed before tmpdir creation do not create a tmpdir. +- **Shared across iterations.** All scripts in the run — the starting target, scripts reached via intra-workflow `goto`, scripts reached via cross-workflow `goto`, and re-executions of the starting target on loop reset — observe the same `LOOPX_TMPDIR` value. +- **Persisted within the run.** The directory is not cleared between iterations. Files written by one script remain visible to later scripts in the same run. +- **Concurrent runs are isolated.** Each `loopx run` invocation receives its own distinct directory. Parallel runs of the same workflow do not share temporary state. +- **Not created under `-n 0` / `maxIterations: 0`.** No child process is spawned, so no tmpdir is created and `LOOPX_TMPDIR` is not injected into any environment. + +#### Cleanup + +loopx runs cleanup of the tmpdir on every terminal outcome of a run that reached tmpdir creation: + +- **Normal completion:** `stop: true` from a script, or `-n` / `maxIterations` reached. +- **Error exit:** non-zero script exit, invalid `goto` target, missing workflow or missing script during a `goto` resolution. +- **SIGINT / SIGTERM to loopx:** if a child process group is active, cleanup runs after the process group exits (per SPEC §7.3, including `SIGKILL` escalation to the process group if required) and before loopx exits with the signal's exit code. If no child is active when the signal arrives (including the window after tmpdir creation but before the first child spawns, and the window between one child exiting and the next spawning), cleanup runs immediately. +- **Programmatic `AbortSignal` abort:** if a child process group is active, loopx first terminates it per SPEC §9.1; cleanup runs after the process group exits, before the generator throws or the promise rejects. If the abort fires while no child is active, cleanup runs eagerly from loopx's signal listener and the next generator interaction (or the outstanding `runPromise()` promise) settles with the abort error as soon as cleanup completes. +- **Consumer-driven cancellation under `run()`** (`break` from `for await`, explicit `generator.return()`, explicit `generator.throw(err)` after the first `next()`): loopx terminates any active child per SPEC §9.1, runs cleanup, then settles the generator (`{ done: true }` for `break` / `.return()`; throws `err` for `.throw(err)`). + +Cleanup does **not** run when loopx itself is killed via SIGKILL or the host crashes; leaked tmpdirs are expected to be reaped by OS temp-cleaning policy (`systemd-tmpfiles`, tmpfs reboot). loopx does not attempt to reap stale tmpdirs at startup. + +If cleanup fails, loopx prints a single warning to stderr. The CLI exit code, generator outcome, and promise rejection reason are unchanged. + +#### Cleanup safety + +Cleanup is path-based and best-effort — not a sandbox against actively racing same-user processes. loopx captures the created directory's device/inode pair at creation time, before any child is exposed to the directory. At cleanup time loopx `lstat`s the `LOOPX_TMPDIR` path and dispatches: + +1. **Path no longer exists:** no-op. +2. **Path is a symlink:** unlink the symlink entry; do not traverse or follow the target. Unlinking a symlink affects only the symlink entry itself. +3. **Path is a regular file, FIFO, socket, or other non-directory non-symlink:** leave in place with a single stderr warning. Unlinking such replacements is unsafe because a hard link would decrement a shared inode's `nlink`, and data renamed into the path has `nlink == 1` — in both cases `unlink` would mutate unrelated data. +4. **Path is a directory with matching device/inode identity:** recursively remove. Symlink entries encountered during the walk are unlinked but not traversed, so symlinks pointing outside the tmpdir do not collateral-delete their targets. +5. **Path is a directory whose identity does not match the recorded identity:** leave in place with a single stderr warning. loopx does not recursively remove a directory it did not create. + +A script that removes or renames its tmpdir during the run defeats automatic cleanup of the moved directory; loopx does not chase renamed tmpdirs. + +This guarantee covers script-introduced replacements that are quiescent by the time cleanup begins. It is not a race-resistant guarantee against a same-user process that mutates the path during cleanup itself; a stronger guarantee (fd-relative `openat` / `unlinkat` with `AT_SYMLINK_NOFOLLOW`) is out of scope for v1. + +#### Creation failure + +If the underlying `mkdtemp`, identity-capture, or mode-securing operation fails (e.g., `EACCES`, `ENOSPC`, `EMFILE`), loopx does not spawn any child. The CLI exits `1` with a stderr error; `run()` throws on the first iteration; `runPromise()` rejects. + +If a partial directory exists when the failure is detected, loopx attempts best-effort cleanup of it subject to the safety rules above (recursive cleanup requires a recorded identity; if identity capture itself failed, cleanup is limited to non-traversing actions). Failure of that best-effort cleanup prints an additional stderr warning but does not mask the original creation-failure error. + +If a SIGINT/SIGTERM or `AbortSignal` abort arrives concurrently with a creation failure, the signal/abort wins — the creation failure is not surfaced as the terminal outcome — and any partial directory is cleaned up under the same rules. + +#### Programmatic API (`run()` / `runPromise()`) + +Tmpdir creation is lazy under both APIs. Neither creates a tmpdir at the call site of `run()` or `runPromise()`. The pre-iteration sequence (field validation, discovery, env loading, target resolution, version check) runs on the first `next()` call for `run()` and asynchronously after return for `runPromise()`; tmpdir creation follows that sequence immediately before the first child spawns. + +A generator returned by `run()` that is never iterated (no `next()`, no `.return()`, no `.throw()`) performs no pre-iteration work and creates no tmpdir. Pre-first-`next()` `generator.return(value)` or `generator.throw(err)` settles the generator immediately per standard JS async-generator semantics without creating a tmpdir. + +Once created, the tmpdir is cleaned up whenever the generator settles terminally or the promise settles. Callers who drive `run()` via `for await (...)` or who use `runPromise()` observe cleanup automatically. Callers who manually consume `next()` and then abandon the generator without calling `.return()` or driving it to completion may leak a tmpdir until the generator is garbage-collected — a JS-language limitation, not a loopx guarantee. + +### 2. `RunOptions.env` + +`RunOptions` gains an optional `env` field: + +```typescript +interface RunOptions { + maxIterations?: number; + envFile?: string; + signal?: AbortSignal; + cwd?: string; + env?: Record; +} +``` + +- **Shape.** `env` must be omitted, `undefined`, or a non-null, non-array object whose own enumerable string-keyed entries all have string values. Invalid shapes (null, array, non-object, or an entry with a non-string value) are rejected via the standard pre-iteration error path: `run()` throws on the first `next()`; `runPromise()` rejects. Symbol-keyed, non-enumerable, and inherited properties are ignored — not iterated, not validated, not forwarded. +- **Merge position.** `env` entries are merged into the child environment *after* global and local env-file loading and *before* loopx-injected protocol variables (`LOOPX_BIN`, `LOOPX_PROJECT_ROOT`, `LOOPX_WORKFLOW`, `LOOPX_TMPDIR`). A `RunOptions.env` entry therefore overrides same-named values from `-e`, the global loopx env file, and inherited `process.env`, and is itself overridden by protocol variables. This slots into the existing merge in `src/execution.ts` between the merged-env layer and the loopx-injected layer. +- **Lifetime.** Entries are captured at call time via `Object.keys(env)` as a shallow copy. Mutating the original object after `run()` / `runPromise()` returns has no effect on the running loop. +- **Applies to every script in the run.** The starting target, `goto` destinations (intra- and cross-workflow), and loop resets to the starting target all receive the same `env` additions. +- **No CLI surface.** `env` is programmatic-only. CLI callers pass per-run values via the shell env prefix (`key=value loopx run …`), which flows through inherited `process.env`, or via `-e` / `loopx env set` for file-based config. +- **No name validation beyond "must be a string-to-string entry."** loopx does not enforce the POSIX `[A-Za-z_][A-Za-z0-9_]*` env-name pattern, does not reject `LOOPX_*` keys, and does not reject NUL-byte values. A key the OS rejects surfaces as a spawn failure; a `LOOPX_*` key that collides with a protocol variable is silently overridden by protocol injection (which runs after `env`). + +## Consequences + +- **Workflow directories stop accumulating temp files.** Existing workflows migrate `.feedback.tmp` / `.claude-output.tmp` / `.answer.tmp` / `.prompt.tmp` / `ralph`'s `.iteration.tmp` into `$LOOPX_TMPDIR/…` and drop their manual `rm -f` branches. Workflow directories contain only version-controlled scripts and static assets. +- **Cleanup is automatic on failure.** Loops that error out, are signaled, or are aborted no longer leak scratch files. Callers who relied on lingering `.tmp` files for post-mortem inspection must write those outside `$LOOPX_TMPDIR` (for example, under `$LOOPX_PROJECT_ROOT`). +- **Cross-run state is the caller's responsibility.** `LOOPX_TMPDIR` never persists across runs. A workflow that genuinely needs cross-run state (a long-lived counter, a cache surviving across invocations) uses `$LOOPX_PROJECT_ROOT`-relative storage. `ralph`'s `.iteration.tmp` is **not** an example of cross-run state — it is intra-run state leaking into the workflow directory, and should migrate to `$LOOPX_TMPDIR/iteration`. +- **Concurrent runs are safe.** Parallel `loopx run` invocations of the same workflow do not clobber each other's scratch files, which was previously possible with in-workflow `.tmp` files. +- **Programmatic callers get per-run env without mutating `process.env`.** Previously the only way to inject a per-run value programmatically was to mutate `process.env` before calling `run()`, which is racy under concurrent calls. `RunOptions.env` is a clean shallow copy scoped to the call. +- **CLI parameterization uses existing shell idioms.** `adr=0003 loopx run review-adr` works today and continues to work. Users who want override precedence over `-e` or global env for a single CLI invocation use `-e` or `loopx env set` explicitly; loopx does not provide a CLI-level override tier for one-shot values. +- **No named-argument schema.** Workflows do not declare expected input names, types, defaults, or requiredness. Scripts validate their own inputs. A schema mechanism may be introduced in a later ADR if the unchecked-input model proves insufficient. +- **No first-class iteration counter.** Workflows maintain one in `$LOOPX_TMPDIR/iteration` if needed. A first-class counter may be introduced later. +- **Migration is manual but straightforward.** Existing `review-adr`, `apply-adr`, and `spec-test-adr` workflows replace in-workflow `.tmp` paths with `$LOOPX_TMPDIR/…`, drop `rm -f` cleanup, and take their per-run parameter via shell env prefix (e.g., `adr=0003 loopx run review-adr`). No automated migration tooling is provided. + +## Affected SPEC Sections + +When this ADR is accepted, the following SPEC sections require updates: + +- **§3.2 / §7.1 — Pre-iteration sequence.** Insert `LOOPX_TMPDIR` creation between the starting workflow version check and the first child spawn. Under `-n 0` / `maxIterations: 0`, no tmpdir is created. +- **§7.2 — Error Handling.** On non-zero script exit or invalid / missing `goto` target, `LOOPX_TMPDIR` cleanup runs after the error is detected and before loopx exits `1`. +- **§7.3 — Signal Handling.** On SIGINT / SIGTERM, `LOOPX_TMPDIR` cleanup runs after any active child process group has exited (per the existing grace period) and before loopx exits with the signal's code. When no child is active when the signal arrives, cleanup runs immediately. Signals that arrive before tmpdir creation require no cleanup. +- **§8 — Environment Variables.** Add `LOOPX_TMPDIR` to the injected-variables table. Document that `RunOptions.env` merges after env files (global + local) and before loopx-injected protocol variables. +- **§9.1 / §9.2 — Programmatic API.** Document the new `env` option: its call-time shallow-copy, its merge position, its rejection on invalid shape, and that it is ignored when omitted. Document `LOOPX_TMPDIR` creation timing (lazy — on first `next()` for `run()`, asynchronous after return for `runPromise()`) and cleanup under abort and consumer-driven cancellation. +- **§9.5 — Types.** Add `env?: Record` to `RunOptions`. +- **§13 — Reserved Values.** Add `LOOPX_TMPDIR` as a reserved env var name. + +## Test Recommendations + +Non-exhaustive — these highlight edge cases that are easy to overlook. + +### `LOOPX_TMPDIR` + +- `LOOPX_TMPDIR` is injected with an absolute path; its basename begins with `loopx-` and lives under `os.tmpdir()`. +- Same value is observed across the starting target, intra-workflow `goto`, cross-workflow `goto`, and loop reset to the starting target. +- Files written by one script are visible to subsequent scripts in the same run. +- Parallel `loopx run` invocations receive distinct paths and distinct directories. +- Directory mode is `0700`. +- Cleanup runs on each terminal outcome: normal completion (`stop: true` and `maxIterations` reached), non-zero script exit, invalid `goto` target, SIGINT/SIGTERM (including between iterations and between tmpdir creation and first spawn), `AbortSignal` abort (including mid-iteration, between iterations, and after the final yield but before `{ done: true }`), `break` from `for await`, explicit `generator.return()` after first `next()`, explicit `generator.throw(err)` after first `next()`. +- Cleanup of a symlink inside `$LOOPX_TMPDIR` pointing outside: symlink entry is unlinked, target is untouched. +- Cleanup when the tmpdir path has been replaced: symlink replacement is unlinked (target untouched); regular-file / FIFO / socket replacement is left in place with a warning; mismatched-identity directory replacement is left in place with a warning; renamed-away tmpdir is left at its new path without being chased. +- Hard-link safety: a script that creates a hard link at the tmpdir path to unrelated data leaves the link count of the shared inode unchanged after cleanup, and the unrelated target file is untouched. +- Rename-into-path safety: a script that renames an unrelated file into the tmpdir path leaves the file's data intact after cleanup. +- Cleanup failure prints a single stderr warning and does not change the CLI exit code, generator outcome, or promise rejection reason. +- Under `-n 0` / `maxIterations: 0`, no tmpdir is created. +- When pre-spawn validation fails (discovery error, env-file error, target-resolution error) or an `AbortSignal` is already aborted before tmpdir creation, no tmpdir is created. +- When tmpdir creation itself fails after producing a partial directory, best-effort cleanup runs on the partial directory without masking the original creation error. +- A user-supplied `LOOPX_TMPDIR` in inherited env, the `-e` local env file, the global env file, or `RunOptions.env` is overridden by the injected protocol value. + +### `RunOptions.env` + +- `run(target, { env: { adr: "0003" } })` results in `process.env.adr === "0003"` inside the script, and `$adr == "0003"` in a Bash script. +- Same `env` applied to every iteration, every `goto` destination, and every loop reset. +- Overrides same-named entries from `-e` local env file, global loopx env file, and inherited `process.env`. +- Overridden by loopx protocol variables: `env: { LOOPX_TMPDIR: "/fake" }` is not observable in the child. +- `run(target, { env: undefined })`, `run(target, { env: {} })`, and `run(target, {})` all inject no additional entries. +- Invalid shapes reject on first iteration (for `run()`) or via promise rejection (for `runPromise()`): `env: null`, `env: []`, `env: "nope"`, `env: 42`, `env: { x: 42 }` (non-string value). +- Mutating the original `env` object after `run()` / `runPromise()` returns does not affect the running loop (call-time shallow copy). +- Symbol-keyed, non-enumerable, and inherited properties of `env` are ignored — neither validated nor forwarded. + +SPEC.md: +# Loop Extender (loopx) — Specification + +## 1. Overview + +loopx is a CLI tool that automates repeated execution ("loops") of scripts, primarily designed to wrap agent CLIs. It provides a scriptable loop engine with structured output, control flow between scripts, environment variable management, and a workflow installation mechanism. + +**Package name:** `loopx` +**Implementation language:** TypeScript +**Module format:** ESM-only +**Target runtimes:** Node.js ≥ 20.6, Bun ≥ 1.0 +**Platform support:** POSIX-only (macOS, Linux) for v1. Windows is not supported. + +> **Note:** The Node.js minimum was raised from 18 to 20.6 to support `module.register()`, which is required for the custom module loader used to resolve `import from "loopx"` in scripts (see section 3.3). + +--- + +## 2. Concepts + +### 2.1 Workflow and Script + +A **workflow** is a named subdirectory of `.loopx/` that contains one or more script files. Workflows are the primary organizational unit in loopx — scripts are not placed directly in `.loopx/` as loose files. + +**Supported script extensions:** + +- Bash (`.sh`) +- JavaScript (`.js` / `.jsx`) +- TypeScript (`.ts` / `.tsx`) + +`.mjs` and `.cjs` extensions are intentionally unsupported. All JS/TS scripts must be ESM (see section 6.3). + +``` +.loopx/ + ralph/ + index.sh ← default entry point + check-ready.sh + my-pipeline/ + index.ts ← default entry point + setup.ts ← another script (targeted as my-pipeline:setup) + lib/ + helpers.ts ← not discovered (subdirectory) + package.json ← optional (for dependencies, version pinning) +``` + +#### Workflow detection + +A subdirectory of `.loopx/` is recognized as a workflow if it contains at least one **top-level** file with a supported script extension. Only files directly inside the subdirectory are considered — the scan is not recursive. Subdirectories that contain no top-level script files are ignored during discovery. + +#### Workflow naming + +Workflow names must match `[a-zA-Z0-9_][a-zA-Z0-9_-]*`. Additionally, workflow names must not contain `:` (already excluded by the pattern, but called out explicitly since `:` is a syntactic delimiter — see section 4.1). + +#### Script naming within workflows + +Script names (the base name of a file without its extension) follow the same naming rules as workflow names: `[a-zA-Z0-9_][a-zA-Z0-9_-]*`, no `:`. + +#### Non-script files + +Files without supported extensions (e.g., `.json`, `.schema.json`, `.md`, `.txt`) inside a workflow directory are allowed and ignored by discovery. This supports patterns like schema files, documentation, or configuration that live alongside scripts. + +#### All top-level files with supported extensions are scripts + +Every file directly inside a workflow directory that has a supported script extension is a discovered script — there is no opt-out or exclusion mechanism. Reusable helper modules, configuration files, or shared utilities that happen to use a supported extension must be placed in subdirectories (e.g., `lib/`, `helpers/`, `config/`). Subdirectories within a workflow are not scanned during script discovery (see below), so files in subdirectories are invisible to loopx and available for internal use by the workflow's scripts. + +#### Nested directory scripts within workflows are not supported + +Scripts within a workflow must be files, not subdirectories. A subdirectory inside a workflow is ignored during script discovery within that workflow. + +#### Default entry point + +Each workflow has a **default entry point**: a script named `index` (i.e., `index.sh`, `index.js`, `index.jsx`, `index.ts`, or `index.tsx`). This is the script that runs when a workflow is invoked without specifying a script name. + +- `loopx run ralph` is equivalent to `loopx run ralph:index`. +- If a workflow has no script named `index`, invoking it without a script name is an error (exit code 1). The workflow is still valid — its other scripts can be targeted explicitly. + +`index` is not otherwise special. It can be the target of `goto`, it follows the same naming/collision rules as other scripts, and it can `goto` other scripts. + +#### Workflow-level `package.json` + +A workflow may include a `package.json` that serves two optional purposes: + +1. **Dependency management:** The workflow can declare its own dependencies. Users manage installation themselves (`npm install` / `bun install` within the workflow directory). loopx does not auto-install dependencies. If `node_modules/` is missing and the script fails to import a package, the resulting error is the active runtime's normal module resolution error. +2. **Version declaration:** The workflow can declare a `loopx` version requirement (see section 3.2). + +The `main` field is no longer used to determine the entry point. The entry point is always the `index` script by convention. If a `package.json` contains a `main` field, it is ignored by loopx. + +The `type` field (`"module"`) continues to be relevant for Node.js module resolution within the workflow. + +**Failure modes:** If a workflow's `package.json` is absent, unreadable, contains invalid JSON, or declares an invalid semver range for `loopx`, see section 3.2 for the defined behavior. In all cases, a broken `package.json` degrades version checking but does not prevent the workflow from being used or installed. + +### 2.2 Loop + +A loop is a repeated execution cycle modeled as a **state machine**. Each iteration runs a **target** (a specific script within a workflow), examines its structured output, and transitions: + +- **`goto` another script:** transition to that target for the next iteration (see below for bare vs. qualified goto). +- **No `goto`:** the cycle ends and the loop restarts from the **starting target**. +- **`stop`:** the machine halts. + +The **starting target** is the target specified when loopx was invoked (e.g., `ralph:index` from `loopx run ralph`). The `goto` mechanism is a **state transition, not a permanent reassignment.** When a target finishes without its own `goto`, execution returns to the starting target. The loop always resets to its initial state after a transition chain completes — regardless of which workflow the chain ended in. Cross-workflow `goto` does not change the starting target. + +**Self-referencing goto:** A script may `goto` itself. This is a normal transition and counts as an iteration. + +#### Goto semantics + +A bare name (no colon) means different things depending on context: + +- **In `run` (CLI or programmatic API):** A bare name is a **workflow name** and resolves to that workflow's `index` script. `loopx run ralph` and `run("ralph")` both mean `ralph:index`. +- **In `goto`:** A bare name is a **script name** within the current workflow. `{ "goto": "check-ready" }` from a script in the `ralph` workflow means `ralph:check-ready`, not `check-ready:index`. + +This distinction is fundamental to the invocation model: `run` addresses workflows, `goto` addresses scripts within the current workflow's scope. + +**Intra-workflow goto (bare name):** A `goto` value without a colon targets a script in the **same workflow as the currently executing script**. If the current script is in the `ralph` workflow, `{ "goto": "check-ready" }` transitions to `ralph:check-ready`. + +**Qualified goto:** A `goto` value with a colon targets a specific script in a named workflow. `{ "goto": "review-adr:request-feedback" }` transitions to the `request-feedback` script in the `review-adr` workflow. The qualified form is valid whether the target is a different workflow or the same workflow as the currently executing script — e.g., a script in `ralph` may use `{ "goto": "ralph:check-ready" }`, which is equivalent to the bare `{ "goto": "check-ready" }`. + +The target workflow must exist in the cached discovery results; otherwise it is an invalid `goto` target (error, exit code 1). + +**Bare goto from a cross-workflow context:** When a script reached via cross-workflow `goto` issues a bare (unqualified) `goto`, it targets a script in **its own workflow**, not the starting target's workflow. + +**Example:** +``` +Starting target: ralph:index + +Iteration 1: ralph:index → goto "check-ready" (intra-workflow → ralph:check-ready) +Iteration 2: ralph:check-ready → goto "review-adr:request-feedback" (cross-workflow) +Iteration 3: review-adr:request-feedback → goto "apply-feedback" + ↑ bare name → resolves to review-adr:apply-feedback +Iteration 4: review-adr:apply-feedback → (no goto) +Iteration 5: ralph:index → (back to starting target) +``` + +### 2.3 Structured Output + +Every iteration produces an output conforming to: + +```typescript +interface Output { + result?: string; + goto?: string; + stop?: boolean; +} +``` + +**Stdout is reserved for the structured output payload.** Any human-readable logs, progress messages, or debug output from scripts must go to stderr. + +**Parsing rules:** + +- Only a **top-level JSON object** can be treated as structured output. Arrays, primitives (strings, numbers, booleans), and `null` fall back to raw result treatment. +- If stdout is a valid JSON object containing at least one known field (`result`, `goto`, `stop`), it is parsed as structured output. +- If stdout is not valid JSON, is not an object, or is a valid JSON object but contains none of the known fields, the entire stdout content is treated as `{ result: }`. +- **Empty stdout** (0 bytes) is treated as `{ result: "" }`. This is the default case for scripts that produce no output, and causes the loop to reset (no `goto`, no `stop`). +- Extra JSON fields beyond `result`, `goto`, and `stop` are silently ignored. +- If `result` is present but not a string, it is coerced via `String(value)`. This includes `null`: `{"result": null}` produces result `"null"`. +- A string `goto` value is either a bare script name (targeting a script within the current workflow) or a qualified `workflow:script` name (targeting a specific workflow and script). See section 2.2 for full goto semantics. +- If `goto` is present but not a string, it is treated as absent. Target validation (section 4.1) applies only after a `goto` value has been parsed as a string. +- `stop` must be exactly `true` (boolean). Any other value (including truthy strings like `"true"`, numbers, etc.) is treated as absent. This prevents surprises like `{"stop": "false"}` halting the loop. + +**Field precedence:** + +- `stop: true` takes priority over `goto`. If both are set, the loop halts. +- `goto` with no `result` is valid: the target script receives empty stdin. +- **`result` is only piped to the next script when `goto` is present.** When the loop resets to the starting target (no `goto`), the starting target receives empty stdin regardless of whether the previous iteration produced a `result`. + +--- + +## 3. Installation & Module Resolution + +### 3.1 Global Install + +loopx is installed **globally** to provide the `loopx` CLI command: + +``` +npm install -g loopx +``` + +A global install is sufficient for all loopx functionality, including JS/TS scripts that `import { output, input } from "loopx"`. loopx uses a custom module loader to make its exports available to scripts regardless of install location (see section 3.3). + +### 3.2 Local Version Pinning + +A project may pin a specific loopx version by installing it as a local dependency: + +``` +npm install --save-dev loopx +``` + +A local install provides two guarantees: + +1. **CLI delegation:** When the globally installed `loopx` binary starts, it checks the project root for a local `loopx` dependency and delegates if found (see resolution order below). Delegation happens **before command parsing**, so the entire session — CLI behavior, script helpers, and all — uses the pinned version. + +2. **Importable library:** Application code can `import { run, runPromise } from "loopx"` when loopx is a local dependency. This is standard Node.js module resolution — no special mechanism required. + +#### Project root + +For loopx, the **project root** is always the invocation cwd. This is the same directory where `.loopx/` lives (when it exists), but the project root is determined by cwd alone — it does not depend on `.loopx/` existing. This means delegation, version pinning, and all project-root-relative behavior work regardless of whether `.loopx/` has been initialized. + +#### Resolution order (highest precedence first) + +1. **Project root `package.json`:** If the project root has a `package.json` that lists `loopx` as a dependency (in `dependencies`, `devDependencies`, or `optionalDependencies`) and a corresponding `node_modules/.bin/loopx` exists, the global binary delegates to it. +2. **Global install:** If no local version is found, the global install runs. + +Because delegation happens before command parsing, it is based on the project root only — not on the target workflow. Delegation works for all commands, including those that do not require `.loopx/` to exist (e.g., `loopx version`, `loopx install`, `loopx env`). + +#### Project-root `package.json` failure modes + +- **No `package.json` at project root:** No delegation. The global install runs. No warning. +- **Unreadable `package.json`** (e.g., permission denied): A warning is printed to stderr. Delegation is skipped and the global install runs. +- **Invalid JSON:** A warning is printed to stderr. Delegation is skipped and the global install runs. +- **Valid JSON, `loopx` declared in `dependencies`/`devDependencies`/`optionalDependencies`, but `node_modules/.bin/loopx` does not exist:** A warning is printed to stderr (the dependency is declared but the binary is missing — likely `npm install` has not been run). Delegation is skipped and the global install runs. +- **Valid JSON, `loopx` not declared in any dependency field, but `node_modules/.bin/loopx` exists:** No delegation. The dependency declaration is required for delegation — an undeclared binary is not used. No warning. + +In all cases, a problematic project-root `package.json` degrades delegation but does not prevent loopx from running. The global install is always the fallback. + +#### Recursion guard + +The delegated process is spawned with `LOOPX_DELEGATED=1` in its environment. If this variable is set when loopx starts, delegation is skipped. This prevents infinite delegation loops. After delegation, `LOOPX_BIN` contains the **resolved realpath** of the effective binary (the local version), not the original global launcher or any intermediate symlinks. + +#### Workflow-level version declaration (runtime validation) + +A workflow's `package.json` may declare a `loopx` version requirement in `dependencies` or `devDependencies`. `optionalDependencies` is intentionally not checked at the workflow level — a version requirement declared there is ignored. Workflow-level version declarations are compatibility assertions, not optional suggestions. (Project-root delegation checks `optionalDependencies` because it follows standard npm dependency semantics for locating a local binary.) + +If `loopx` is declared in **both** `dependencies` and `devDependencies` within the same workflow `package.json`, the `dependencies` range takes precedence for version checking and the `devDependencies` range is ignored. This precedence rule applies only to **version checking** (workflow-level runtime validation and install-time validation). At the project root level, delegation depends on declaration presence and binary existence — no range comparison is performed, so range precedence is not relevant to delegation. + +This declaration is **not used for delegation** — delegation always happens at project root level. Instead, after delegation and command parsing, the running loopx version is checked against the workflow's declared version range: + +- If the running version satisfies the declared range: execution proceeds normally. +- If the running version does **not** satisfy the declared range: loopx prints a warning to stderr and continues execution. This is a non-fatal warning, not an error — it alerts the user to a potential incompatibility without blocking work. + +#### Workflow `package.json` failure modes + +A workflow's `package.json` may be absent, unreadable, or malformed. The following failure-mode rules apply at both runtime and install time: + +- **No `package.json`:** No version check is performed. This is the normal case for workflows without dependencies or version requirements. +- **Unreadable `package.json`** (e.g., permission denied): A warning is printed to stderr. The version check is skipped (treated as no version declared). Execution / installation proceeds. +- **Invalid JSON:** A warning is printed to stderr. The version check is skipped. Execution / installation proceeds. +- **Valid JSON but `loopx` version field contains an invalid semver range:** A warning is printed to stderr. The version check is skipped. Execution / installation proceeds. +- **Valid JSON, no `loopx` dependency declared:** No version check is performed. + +In all warning cases, the workflow is still usable — a broken `package.json` degrades version checking but does not block execution or installation. + +**Warning timing differs by context:** + +- **Runtime:** `package.json` failure warnings follow the same "first entry only" rule as version mismatch warnings (see below). The version check — and any warnings it produces — runs once on first entry into a workflow during a loop run. Subsequent entries into the same workflow do not re-read `package.json` or repeat warnings. +- **Install:** Each workflow's `package.json` is checked once during the install operation. Warnings are emitted once per affected workflow. `package.json` failure warnings (unreadable, invalid JSON, invalid semver range) do not block installation — the workflow is still installable, just without version validation. Version *mismatches* (a valid range not satisfied by the running version) are blocking errors and are included in the aggregated preflight failure report (see section 10.7). + +#### Cross-workflow version checking + +When a loop enters a workflow — whether at loop start or via `goto` — the workflow's declared `loopx` version range (if any) is checked against the running version **on first entry only**. If the range is not satisfied, a warning is printed to stderr. Subsequent entries into the same workflow during the same loop run do not repeat the warning. + +This means: +- The starting workflow is checked once before the first iteration. +- A workflow reached via `goto` is checked on first transition into it. +- Re-entering a previously visited workflow (e.g., via loop reset or another `goto`) does not produce a second warning. + +**`-n 0` behavior:** When `-n 0` is specified, discovery, target resolution, and environment variable loading (global and `-e`) still run — the target workflow and script must exist and pass validation (name collisions, name restrictions), and env files must be readable and valid, consistent with section 4.2. However, workflow-level version checking is skipped because no workflow is entered for execution. `-n 0` validates that the target is runnable, but does not perform the runtime version compatibility check. + +### 3.3 Module Resolution for Scripts + +Scripts spawned by loopx need access to the `output` and `input` helpers via `import { output, input } from "loopx"`. + +**For Node.js / tsx:** loopx uses Node's `--import` flag to preload a registration module that installs a custom module resolve hook via `module.register()`. This hook intercepts bare specifier imports of `"loopx"` and resolves them to the running CLI's package exports. This approach works correctly with Node's ESM resolver, which does not support `NODE_PATH`. + +**For Bun:** Bun's module resolver supports `NODE_PATH` for both CJS and ESM. loopx sets `NODE_PATH` to include its own package directory when running under Bun. + +In both cases, the resolution **points to the post-delegation version** when no closer `node_modules/loopx` exists. If a local install triggered delegation, the helpers resolve to the local version's package. However, if a workflow has its own `node_modules/loopx`, standard module resolution applies and the closer package takes precedence over the CLI-provided one. This is a natural consequence of running scripts with the workflow directory as cwd (section 6.1). + +loopx does **not** override standard module resolution to force the CLI version. This means a workflow with a locally installed `loopx` may get different helper behavior than the running CLI provides. The workflow's `package.json` version declaration (section 3.2) serves as the intended mechanism for surfacing version mismatches. No warning is emitted for this scenario in v1. + +### 3.4 Bash Script Binary Access + +loopx injects a `LOOPX_BIN` environment variable into every script's execution environment. This variable contains the **resolved realpath** of the effective loopx binary (post-delegation), allowing bash scripts to call loopx subcommands reliably: + +```bash +#!/bin/bash +$LOOPX_BIN output --result "done" --goto "next-step" # intra-workflow goto +$LOOPX_BIN output --goto "review-adr:request-feedback" # cross-workflow goto +``` + +--- + +## 4. CLI Interface + +### 4.1 Running Scripts + +``` +loopx run [options] [: