diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 00000000..91ebc673
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,17 @@
+# Force LF line endings on every text file in every working tree.
+#
+# Why global: the WSL CI job runs against a checkout on the windows-latest
+# runner (default core.autocrlf=true). Any text file without an explicit
+# attribute is normalised to CRLF on checkout, which has surfaced as a
+# series of unrelated CI failures:
+# * bash scripts: `$'\r': command not found` / `set: pipefail: invalid option name`
+# * .jh source: `jaiph format --check` reports "needs formatting"
+# because format normalises to LF
+# Per-extension whack-a-mole would keep finding new variants. `text=auto`
+# tells git to detect text vs binary per file; combined with `eol=lf` it
+# stores LF in the index and writes LF to working trees on every platform.
+* text=auto eol=lf
+
+# Belt-and-suspenders: explicitly mark binary assets so `text=auto` cannot
+# misclassify them and corrupt the bytes by line-ending normalisation.
+*.png binary
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 289bfaee..8d2c8470 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,6 +4,19 @@ on:
push:
jobs:
+ shellcheck:
+ name: ShellCheck
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Install shellcheck
+ run: sudo apt-get update && sudo apt-get install -y shellcheck
+
+ - name: Run shellcheck
+ run: shellcheck runtime/overlay-run.sh
+
test:
name: Compiler and unit tests
runs-on: ubuntu-latest
@@ -33,12 +46,26 @@ jobs:
git ls-remote --exit-code https://github.com/jaiphlang/jaiph.git "refs/tags/v${VERSION}"
e2e:
- name: E2E install and CLI workflow (${{ matrix.os }})
+ name: E2E (${{ matrix.os }}, ${{ matrix.label }})
runs-on: ${{ matrix.os }}
+ env:
+ # Host/safe split applies on Ubuntu only. macOS runners do not ship Docker the same way — keep host-only there.
+ # "docker": unset JAIPH_UNSAFE so resolveDockerConfig enables the sandbox (pulls ghcr.io/jaiphlang/jaiph-runtime).
+ # "host": explicit opt-out, same as a fast local `JAIPH_UNSAFE=true npm run test:e2e`.
+ JAIPH_UNSAFE: ${{ matrix.jaiph_unsafe }}
strategy:
fail-fast: false
matrix:
- os: [ubuntu-latest, macos-latest]
+ include:
+ - os: ubuntu-latest
+ label: docker
+ jaiph_unsafe: ""
+ - os: ubuntu-latest
+ label: host
+ jaiph_unsafe: "true"
+ - os: macos-latest
+ label: host
+ jaiph_unsafe: "true"
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -48,6 +75,12 @@ jobs:
with:
node-version: "20"
+ - name: Build runtime image for Docker E2E
+ if: matrix.label == 'docker'
+ run: |
+ docker build -t jaiph-ci-runtime:local -f runtime/Dockerfile .
+ echo "JAIPH_DOCKER_IMAGE=jaiph-ci-runtime:local" >> "$GITHUB_ENV"
+
- name: Run runtime acceptance E2E
run: |
npm ci
@@ -77,6 +110,11 @@ jobs:
node-version: "20"
cache: npm
+ - name: Build runtime image for docs sample Docker runs
+ run: |
+ docker build -t jaiph-ci-runtime:local -f runtime/Dockerfile .
+ echo "JAIPH_DOCKER_IMAGE=jaiph-ci-runtime:local" >> "$GITHUB_ENV"
+
- name: Install dependencies
run: npm ci
@@ -151,38 +189,133 @@ jobs:
id: detect_wsl
shell: pwsh
run: |
+ $ciDistro = "jaiph-ci-ubuntu"
$distros = @(wsl -l -q | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" })
if ($distros.Count -eq 0) {
- "distro=" >> $env:GITHUB_OUTPUT
- Write-Warning "No WSL distro is available on this runner. Skipping WSL E2E."
- exit 0
+ Write-Warning "No WSL distro is available on this runner. Importing Ubuntu rootfs for CI."
+ $archivePath = Join-Path $env:RUNNER_TEMP "ubuntu-base-amd64.tar.gz"
+ $installPath = Join-Path $env:RUNNER_TEMP "wsl-ubuntu"
+ $ubuntuBaseReleaseIndexUrl = "https://cdimage.ubuntu.com/ubuntu-base/releases/24.04/release/"
+
+ if (Test-Path $installPath) {
+ Remove-Item -Path $installPath -Recurse -Force
+ }
+ New-Item -ItemType Directory -Path $installPath -Force | Out-Null
+ $releaseIndex = Invoke-WebRequest -Uri $ubuntuBaseReleaseIndexUrl
+ $matches = [regex]::Matches($releaseIndex.Content, 'ubuntu-base-24\.04\.(\d+)-base-amd64\.tar\.gz')
+ $candidates = @($matches | ForEach-Object { $_.Value } | Sort-Object -Unique)
+ if ($candidates.Count -eq 0) {
+ Write-Error "Unable to resolve an Ubuntu Base 24.04 amd64 archive from $ubuntuBaseReleaseIndexUrl"
+ exit 1
+ }
+ $archiveName = $candidates |
+ Sort-Object {
+ [int]([regex]::Match($_, '24\.04\.(\d+)-').Groups[1].Value)
+ } -Descending |
+ Select-Object -First 1
+ $ubuntuBaseUrl = "$ubuntuBaseReleaseIndexUrl$archiveName"
+ Write-Host "Downloading Ubuntu base archive: $ubuntuBaseUrl"
+ try {
+ Invoke-WebRequest -Uri $ubuntuBaseUrl -OutFile $archivePath -MaximumRetryCount 3 -RetryIntervalSec 2
+ } catch {
+ Write-Error "Failed to download $ubuntuBaseUrl : $($_.Exception.Message)"
+ exit 1
+ }
+ wsl --import "$ciDistro" "$installPath" "$archivePath" --version 2
+ $distros = @("$ciDistro")
}
$ubuntu = $distros | Where-Object { $_ -match "^Ubuntu" } | Select-Object -First 1
$selected = if ($ubuntu) { $ubuntu } else { $distros[0] }
+ if (-not $selected) {
+ Write-Error "Failed to provision a WSL distro for CI."
+ exit 1
+ }
"distro=$selected" >> $env:GITHUB_OUTPUT
Write-Host "Using WSL distro: $selected"
- name: Install Node and run E2E tests in WSL
- if: steps.detect_wsl.outputs.distro != ''
shell: pwsh
run: |
$workspace = "${{ github.workspace }}"
$distro = "${{ steps.detect_wsl.outputs.distro }}"
- wsl -d "$distro" -- bash -lc "set -euo pipefail
+ $env:JAIPH_WORKSPACE = $workspace
+ $bashScript = @'
+ set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
- sudo apt-get update
- sudo apt-get install -y curl ca-certificates
+ export JAIPH_UNSAFE=true
+ SUDO=
+ if [ "$(id -u)" -ne 0 ]; then
+ SUDO=sudo
+ fi
+ $SUDO apt-get update
+ # git: required by feature-coverage tests (124_install_command,
+ # 129_artifacts_lib, the git-aware section of 10_basic_workflows).
+ # docs/install only needs git when not installing from a local source,
+ # so installing it here does not change the "no-git host" code path
+ # those tests already cover via JAIPH_FROM_LOCAL.
+ $SUDO apt-get install -y curl ca-certificates git
if ! command -v node >/dev/null 2>&1; then
- curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
- sudo apt-get install -y nodejs
+ if [ -n "$SUDO" ]; then
+ curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
+ else
+ curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
+ fi
+ $SUDO apt-get install -y nodejs
fi
- cd \"\$(wslpath '$workspace')\"
+ cd "$(wslpath "$JAIPH_WORKSPACE")"
npm ci
npm run test:e2e
- "
+ '@
+ # PowerShell on Windows can keep CRLF in here-strings; strip CR so bash does not see "pipefail\r".
+ $bashScript = $bashScript -replace "`r", ""
+ wsl -d "$distro" -- bash -lc "$bashScript"
- - name: WSL E2E skipped
- if: steps.detect_wsl.outputs.distro == ''
- shell: pwsh
+ docker-publish:
+ name: Publish Docker runtime image
+ needs: [test, e2e, docs-local, e2e-wsl]
+ if: github.ref == 'refs/heads/nightly' || startsWith(github.ref, 'refs/tags/v')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+ env:
+ REGISTRY: ghcr.io
+ IMAGE_NAME: jaiphlang/jaiph-runtime
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Log in to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Image tags
+ id: meta
+ run: |
+ if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
+ VERSION="${GITHUB_REF_NAME#v}"
+ echo "tags=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${VERSION},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" >> "$GITHUB_OUTPUT"
+ else
+ echo "tags=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:nightly" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Build and push
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: runtime/Dockerfile
+ push: true
+ platforms: linux/amd64,linux/arm64
+ tags: ${{ steps.meta.outputs.tags }}
+
+ - name: Verify pushed image contains jaiph
run: |
- Write-Host "No WSL distro found on this runner image; skipping WSL E2E."
+ TAG="$(echo '${{ steps.meta.outputs.tags }}' | cut -d',' -f1)"
+ docker run --rm --entrypoint sh "${TAG}" -lc "command -v jaiph && jaiph --version"
+ docker run --rm --user 0:0 --cap-drop ALL --cap-add SYS_ADMIN --entrypoint sh "${TAG}" -lc "command -v jaiph"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
deleted file mode 100644
index 1645aabe..00000000
--- a/.github/workflows/release.yml
+++ /dev/null
@@ -1,85 +0,0 @@
-name: Release
-
-on:
- push:
- tags:
- - "v*"
-
-permissions:
- contents: read
- id-token: write
-
-jobs:
- publish:
- name: Publish to npm
- runs-on: ubuntu-latest
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Setup Node
- uses: actions/setup-node@v4
- with:
- node-version: "22"
- cache: npm
- registry-url: "https://registry.npmjs.org"
-
- # Trusted publishing (OIDC) requires npm >= 11.5.1; Node's bundled npm can be older and
- # surfaces misleading E404 on publish — see https://github.com/npm/cli/issues/9088
- - name: Use npm with trusted publishing support
- run: npm install -g npm@^11.6.0
-
- - name: Install dependencies
- run: npm ci
-
- - name: Build
- run: npm run build
-
- - name: Verify tag matches package.json version
- run: |
- TAG_VERSION="${GITHUB_REF_NAME#v}"
- PKG_VERSION="$(node -p "require('./package.json').version")"
- if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
- echo "Tag version ($TAG_VERSION) does not match package.json version ($PKG_VERSION)"
- exit 1
- fi
-
- - name: Publish with provenance
- run: npm publish --provenance --access public
-
- smoke:
- name: Post-publish global install smoke
- needs: publish
- runs-on: ubuntu-latest
- steps:
- - name: Checkout (for version reference)
- uses: actions/checkout@v4
-
- - name: Setup Node
- uses: actions/setup-node@v4
- with:
- node-version: "22"
-
- - name: Determine published version
- id: version
- run: |
- VERSION="${GITHUB_REF_NAME#v}"
- echo "version=$VERSION" >> "$GITHUB_OUTPUT"
-
- - name: Install globally
- run: npm install -g "jaiph@${{ steps.version.outputs.version }}"
-
- - name: Verify jaiph is on PATH
- run: command -v jaiph
-
- - name: Verify --version matches
- run: |
- ACTUAL="$(jaiph --version)"
- EXPECTED="${{ steps.version.outputs.version }}"
- if [ "$ACTUAL" != "$EXPECTED" ]; then
- echo "Version mismatch: got '$ACTUAL', expected '$EXPECTED'"
- exit 1
- fi
-
- - name: Smoke test --help
- run: jaiph --help
diff --git a/.gitignore b/.gitignore
index 1673153f..b15d9eec 100644
--- a/.gitignore
+++ b/.gitignore
@@ -48,4 +48,12 @@ e2e/ensure_fail.sh
e2e/current_branch.sh
e2e/assign_capture.sh
-.obsidian/
\ No newline at end of file
+.obsidian/
+
+# debug / temp directories (never commit)
+docker-*/
+nested-*/
+overlay-*/
+local-*/
+.tmp*/
+QUEUE.md.tmp.*
\ No newline at end of file
diff --git a/.jaiph/Dockerfile b/.jaiph/Dockerfile
deleted file mode 100644
index 62d5531c..00000000
--- a/.jaiph/Dockerfile
+++ /dev/null
@@ -1,56 +0,0 @@
-FROM ubuntu:latest
-
-# Standard utilities + fuse-overlayfs for CoW sandbox
-RUN apt-get update && \
- apt-get install -y --no-install-recommends \
- bash \
- curl \
- git \
- ca-certificates \
- gnupg \
- fuse-overlayfs \
- fuse3 \
- rsync && \
- rm -rf /var/lib/apt/lists/*
-
-# Node.js latest LTS (required by jaiph::stream_json_to_text in prompt.sh)
-RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && \
- apt-get install -y --no-install-recommends nodejs && \
- rm -rf /var/lib/apt/lists/*
-
-# Non-root user: Claude Code (and similar tools) refuse --dangerously-skip-permissions
-# when running as root. Jaiph only passes --user on Linux hosts; on macOS the container
-# defaults to root unless the image sets USER.
-RUN useradd --create-home --uid 10001 --shell /bin/bash jaiph && \
- mkdir -p /jaiph/workspace /jaiph/workspace-ro /jaiph/run && \
- chown -R jaiph:jaiph /jaiph
-
-# Claude Code CLI (Anthropic) — global install for all users
-RUN npm install -g @anthropic-ai/claude-code
-
-USER jaiph
-ENV HOME=/home/jaiph
-ENV PATH="/home/jaiph/.local/bin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
-
-# cursor-agent (Cursor) — install as the runtime user so the binary is
-# reachable after switching away from root. The installer currently places
-# the CLI in ~/.local/bin and may name it "agent" or "cursor".
-RUN mkdir -p "$HOME/.local/bin" && \
- curl -fsSL https://cursor.com/install -o /tmp/install-cursor-agent.sh && \
- bash /tmp/install-cursor-agent.sh && \
- export PATH="$HOME/.local/bin:$PATH" && \
- if command -v cursor-agent >/dev/null 2>&1; then \
- true; \
- elif command -v agent >/dev/null 2>&1; then \
- ln -sf "$(command -v agent)" "$HOME/.local/bin/cursor-agent"; \
- elif command -v cursor >/dev/null 2>&1; then \
- ln -sf "$(command -v cursor)" "$HOME/.local/bin/cursor-agent"; \
- fi && \
- command -v cursor-agent >/dev/null 2>&1 && \
- rm -f /tmp/install-cursor-agent.sh
-
-# jaiph (official installer: https://jaiph.org/install)
-RUN curl -fsSL https://jaiph.org/install | bash
-RUN jaiph use nightly
-
-WORKDIR /jaiph/workspace
diff --git a/.jaiph/architect_review.jh b/.jaiph/architect_review.jh
index a85f59e0..22fa919b 100755
--- a/.jaiph/architect_review.jh
+++ b/.jaiph/architect_review.jh
@@ -102,7 +102,7 @@ workflow review_one_header(header) {
const verdict = run first_line_str(packed)
const updated_description = run rest_lines_str(packed)
const body_file = run jaiph_review_body_file()
- run mkdir_p_simple(run, jaiph_tmp_dir())
+ run mkdir_p_simple(run jaiph_tmp_dir())
run str_equals(verdict, "dev-ready") catch (err) {
run arg_nonempty(updated_description) catch (err) {
fail "needs-work requires a non-empty updated_description (questions for the author)."
diff --git a/.jaiph/docs_parity.jh b/.jaiph/docs_parity.jh
index 0b2967e8..10209f1c 100755
--- a/.jaiph/docs_parity.jh
+++ b/.jaiph/docs_parity.jh
@@ -75,8 +75,6 @@ rule only_expected_docs_changed_after_prompt(allowed) {
run assert_only_allowed_changed(allowed)
}
-script arg_nonempty = `[ -n "${1:-}" ]`
-
script first_line_str = `printf '%s\n' "$1" | head -n 1`
script rest_lines_str = `printf '%s\n' "$1" | tail -n +2`
@@ -210,7 +208,7 @@ workflow docs_overview(docPaths) {
workflow process_docs_md_recursive(file, remaining) {
run docs_page(file)
- run arg_nonempty(remaining) catch (err) {
+ if remaining == "" {
return
}
const next = run first_line_str(remaining)
@@ -219,7 +217,7 @@ workflow process_docs_md_recursive(file, remaining) {
}
workflow maybe_process_docs_md(first_doc, rest_docs) {
- run arg_nonempty(first_doc) catch (err) {
+ if first_doc == "" {
return
}
run process_docs_md_recursive(first_doc, rest_docs)
diff --git a/.jaiph/engineer.jh b/.jaiph/engineer.jh
index 17d868b1..3e3e5781 100755
--- a/.jaiph/engineer.jh
+++ b/.jaiph/engineer.jh
@@ -1,27 +1,26 @@
#!/usr/bin/env jaiph
#
-# Picks the first pending task from QUEUE.md, implements it,
-# verifies CI, updates docs, commits, and removes from queue.
+# Picks the first pending task from QUEUE.md, implements it, verifies CI,
+# updates docs, removes from queue, and publishes a workspace patch artifact.
#
-
import "jaiphlang/queue" as queue
+import "jaiphlang/artifacts" as artifacts
import "./docs_parity.jh" as docs
import "./ensure_ci_passes.jh" as ci
import "./git.jh" as git
config {
- # agent.backend = "cursor"
- # agent.default_model = "gpt-5.3-codex"
- # agent.cursor_flags = "--force"
- agent.backend = "claude"
- agent.claude_flags = "--permission-mode bypassPermissions"
+ agent.backend = "cursor"
+ agent.default_model = "gpt-5.3-codex"
+ agent.cursor_flags = "--force"
+ # agent.backend = "claude"
+ # agent.claude_flags = "--permission-mode bypassPermissions"
}
const code_philosophy = """
This codebase is maintained by both humans and AI agents. All code you write
must follow these principles strictly:
-
1. Plain functions with explicit arguments. Avoid classes and abstraction-
heavy generics; if the surrounding file already uses generics, follow
local style and keep additions minimal. No visitor patterns or dependency
@@ -48,151 +47,125 @@ const code_philosophy = """
documentation blindly.
"""
-script select_role = ```
-local oc
-oc="$(cat <<'OC'
-## Output
-
-1. Implementation (code changes)
-2. Self-check: review your diff — if it touches more than 5 files or adds
- more than 200 net lines, explain why the scope is necessary
-3. Short rationale (why this approach)
-4. Tradeoffs or risks (if any)
-OC
-)"
-local role_surgical="
-You are a surgical engineer.
-
-Your goal is to implement the task with the smallest safe change.
-
-## Mindset
-
-* Keep blast radius tiny and local
-* Prefer existing code paths over new abstractions
-* Optimize for fast verification and low regression risk
-
-## Rules
-
-* Follow acceptance criteria strictly
-* Default to touching as few files as possible
-* Do NOT redesign surrounding architecture
-* Do NOT add abstractions unless clearly required by acceptance criteria
-* Never invoke orchestration workflows (.jaiph/*.jh) or launch nested agent sessions
-
-$oc
-"
-
-local role_reductionist="
-You are a reductionist engineer.
-
-Your goal is to solve the task by reducing system complexity.
-
-## Mindset
-
-* Prefer deleting code over adding code
-* Execute incremental decommission in small, verifiable steps
-* Keep behavior stable while simplifying structure
-
-## Rules
-
-* Follow acceptance criteria strictly
-* Prioritize deletion-first changes before introducing new paths
-* Decommission one runtime responsibility at a time; prove parity before the next cut
-* Actively remove dead code, duplicate branches, and unnecessary indirection
-* Prefer net-negative or near-neutral code growth when feasible
-* If adding code is unavoidable, justify why deletion/simplification was insufficient
-* Never invoke orchestration workflows (.jaiph/*.jh) or launch nested agent sessions
-
-$oc
-"
-
-local role_optimizer="
-You are an optimization-oriented engineer.
-
-Your goal is to improve structure and flow when the task justifies it.
-
-## Mindset
-
-* Fix root causes and bottlenecks, not symptoms
-* Redesign control flow/data flow when it yields clear, measurable gains
-* Prefer long-term maintainability over local minimalism
-
-## Rules
-
-* Follow acceptance criteria strictly
-* You MAY rework related areas when the task explicitly requires structural change
-* Every structural change must have a concrete before/after justification
-* Do NOT rework areas outside the task's scope, even if they look improvable
-* Avoid speculative complexity that does not produce measurable benefit
-* Never invoke orchestration workflows (.jaiph/*.jh) or launch nested agent sessions
-
-$oc
-"
-
-local role_stabilizer="
-You are a stabilizer engineer.
-
-Your goal is to maximize correctness and regression safety.
-
-## Mindset
-
-* Prioritize reliability and explicit behavior contracts
-* Strengthen weak edges, invariants, and error handling
-* Prefer predictable code over clever code
-
-## Rules
-
-* Follow acceptance criteria strictly
-* Prioritize behavioral parity with existing runtime contracts before refactors
-* Preserve test contracts (especially e2e) and avoid weakening assertions
-* Add or improve tests for risky paths and boundary conditions
-* Keep implementation simple, defensive, and observable
-* Avoid structural rewrites unless strictly required to satisfy acceptance criteria
-* Never invoke orchestration workflows (.jaiph/*.jh) or launch nested agent sessions
-
-$oc
-"
-
-local role_writer="
-You are an expert technical writer for this project.
-
-Your goal is to produce clear, accurate, and human-readable documentation.
+const output_criteria = """
+ ## Output
+ 1. Implementation (code changes)
+ 2. Self-check: review your diff — if it touches more than 5 files or adds
+ more than 200 net lines, explain why the scope is necessary
+ 3. Short rationale (why this approach)
+ 4. Tradeoffs or risks (if any)
+"""
-## Mindset
+const role_surgical = """
+ You are a surgical engineer.
+
+ Your goal is to implement the task with the smallest safe change.
+
+ Mindset:
+ * Keep blast radius tiny and local
+ * Prefer existing code paths over new abstractions
+ * Optimize for fast verification and low regression risk
+
+ Rules:
+ * Follow acceptance criteria strictly
+ * Default to touching as few files as possible
+ * Do NOT redesign surrounding architecture
+ * Do NOT add abstractions unless clearly required by acceptance criteria
+ * Never invoke orchestration workflows (.jaiph/*.jh) or launch nested agent sessions
+"""
-* Source code and docs/architecture.md are the single source of truth
-* Write for a developer audience with clarity and practical examples
-* Be concise, specific, and value dense
-* Formulate generic context first, then drill into specifics
-* Write problem explanations and goals in a human-approachable way
+const role_reductionist = """
+ You are a reductionist engineer.
+
+ Your goal is to solve the task by reducing system complexity.
+
+ Mindset:
+ * Prefer deleting code over adding code
+ * Execute incremental decommission in small, verifiable steps
+ * Keep behavior stable while simplifying structure
+
+ Rules:
+ * Follow acceptance criteria strictly
+ * Prioritize deletion-first changes before introducing new paths
+ * Decommission one runtime responsibility at a time; prove parity before the next cut
+ * Actively remove dead code, duplicate branches, and unnecessary indirection
+ * Prefer net-negative or near-neutral code growth when feasible
+ * If adding code is unavoidable, justify why deletion/simplification was insufficient
+ * Never invoke orchestration workflows (.jaiph/*.jh) or launch nested agent sessions
+"""
-## Rules
+const role_optimizer = """
+ You are an optimization-oriented engineer.
+
+ Your goal is to improve structure and flow when the task justifies it.
+
+ Mindset:
+ * Fix root causes and bottlenecks, not symptoms
+ * Redesign control flow/data flow when it yields clear, measurable gains
+ * Prefer long-term maintainability over local minimalism
+
+ Rules:
+ * Follow acceptance criteria strictly
+ * You MAY rework related areas when the task explicitly requires structural change
+ * Every structural change must have a concrete before/after justification
+ * Do NOT rework areas outside the task's scope, even if they look improvable
+ * Avoid speculative complexity that does not produce measurable benefit
+ * Never invoke orchestration workflows (.jaiph/*.jh) or launch nested agent sessions
+"""
-* Follow acceptance criteria strictly
-* Every docs page must follow: # Top Header → overview paragraph (no sub-header) → ## Sections
-* Verify all content against source code — don't trust existing docs blindly
-* Keep examples executable and aligned with current behavior
-* Avoid too many emojis and AI-like language — keep it simple and clear
-* Only modify documentation files (docs/*.md, README.md, docs/index.html, docs/_layouts/*.html)
-* Do NOT modify source code, tests, or config files
-* Navigation links between docs pages are provided by the Jekyll template; do NOT add manual navigation blocks
-* Never invoke orchestration workflows (.jaiph/*.jh) or launch nested agent sessions
+const role_stabilizer = """
+ You are a stabilizer engineer.
+
+ Your goal is to maximize correctness and regression safety.
+
+ Mindset:
+ * Prioritize reliability and explicit behavior contracts
+ * Strengthen weak edges, invariants, and error handling
+ * Prefer predictable code over clever code
+
+ Rules:
+ * Follow acceptance criteria strictly
+ * Prioritize behavioral parity with existing runtime contracts before refactors
+ * Preserve test contracts (especially e2e) and avoid weakening assertions
+ * Add or improve tests for risky paths and boundary conditions
+ * Keep implementation simple, defensive, and observable
+ * Avoid structural rewrites unless strictly required to satisfy acceptance criteria
+ * Never invoke orchestration workflows (.jaiph/*.jh) or launch nested agent sessions
+"""
-$oc
-"
+const classification_prompt = """
+ Classify this task into the single best-fit engineer role.
+
+ ## Available roles
+ - surgical: Bug fixes, narrow-scope changes, specific error corrections.
+ - reductionist: Deduplication, simplification, dead code removal, unification.
+ - optimizer: Structural refactors, splitting large units, new subsystems.
+ - stabilizer: Test coverage, hardening, error handling, safety improvements.
+
+ ## Role description
+
+ ### Surgical
+ ${role_surgical}
+
+ ### Reductionist
+ ${role_reductionist}
+
+ ### Optimizer
+ ${role_optimizer}
+
+ ### Stabilizer
+ ${role_stabilizer}
+"""
-case "$1" in
- surgical) printf '%s\n' "$role_surgical" ;;
- reductionist) printf '%s\n' "$role_reductionist" ;;
- optimizer) printf '%s\n' "$role_optimizer" ;;
- stabilizer) printf '%s\n' "$role_stabilizer" ;;
- writer) printf '%s\n' "$role_writer" ;;
- *)
- echo "Error: Role must be one of: surgical, reductionist, optimizer, stabilizer, writer. Got: $1" >&2
- return 1
- ;;
-esac
-```
+workflow select_role(role_name) {
+ return match role_name {
+ "surgical" => role_surgical
+ "reductionist" => role_reductionist
+ "optimizer" => role_optimizer
+ "stabilizer" => role_stabilizer
+ _ => fail "Role must be one of: surgical, reductionist, optimizer, stabilizer. Got: ${role_name}"
+ }
+}
script arg_nonempty = `[ -n "${1:-}" ]`
@@ -208,53 +181,39 @@ printf '%s\n' "$line"
workflow classify_role(task) {
const result = prompt """
- Classify this task into the single best-fit engineer role.
-
- Roles:
- - surgical: Bug fixes, narrow-scope changes, specific error corrections.
- Keywords: fix, patch, correct, resolve, handle.
- - reductionist: Deduplication, simplification, dead code removal, unification.
- Keywords: deduplicate, unify, collapse, remove, simplify, merge.
- - optimizer: Structural refactors, splitting large units, new subsystems.
- Keywords: split, extract, redesign, refactor, add [new subsystem].
- - stabilizer: Test coverage, hardening, error handling, safety improvements.
- Keywords: test, coverage, harden, validate, assert, detect.
- - writer: Documentation updates, docs pages, README, getting started, content review.
- Keywords: docs, documentation, write, update docs, readme, revisit, interlink.
-
- Task:
+ ${classification_prompt}
+
+ ## Task
${task}
+ ## Output
Respond with exactly one role name.
- """ returns "{ role: string }"
+ """
+ returns "{ role: string }"
return result.role
}
-script save_string_to_file = `echo "$1" > "$2"`
-
-script mkdir_p_simple = `mkdir -p "$1"`
-
workflow implement(task, role_name) {
run task_text_has_header(task) catch (err) {
fail "Provided task does not contain a '## [text]' header"
}
+
const role = run select_role(role_name)
prompt """
+ ## Role
${role}
-
+ ## Context
You are working on the Jaiph codebase (https://github.com/jaiphlang/jaiph),
a TypeScript compiler and runtime for a DSL that transpiles to Bash.
docs/architecture.md is the source of truth for architecture and execution flow.
-
-
+ ## Code philosophy
${code_philosophy}
-
-
+ ## Task
Implement the following task by:
- Reading docs/architecture.md first and keeping architecture boundaries and
contracts intact unless task explicitly requires changing them.
@@ -302,7 +261,9 @@ workflow implement(task, role_name) {
Task description:
${task}
-
+
+ ## Output
+ ${output_criteria}
"""
}
@@ -314,8 +275,8 @@ workflow default(name) {
log "Implementing task: ${task_header}"
const role_name = match name {
- "" => "${run classify_role(task)}"
- _ => "${name}"
+ "" => run classify_role(task)
+ _ => name
}
log "Role: ${role_name}"
@@ -324,5 +285,8 @@ workflow default(name) {
run ci.ensure_ci_passes()
run docs.update_from_task(task)
run queue.remove_completed_task(task_header)
- run git.commit(task)
+
+ const patch_file = run git.patch(task)
+ const target_path = run artifacts.save(patch_file, patch_file)
+ return target_path
}
diff --git a/.jaiph/git.jh b/.jaiph/git.jh
index 48d3304b..2450aa5b 100755
--- a/.jaiph/git.jh
+++ b/.jaiph/git.jh
@@ -50,5 +50,31 @@ workflow commit(task) {
Changes were made for the following task:
${task}
-"""
+ """
+}
+
+# Writes a unified diff (HEAD vs working tree, excluding `.jaiph/`) to `dest`.
+# Returns `dest` (relative path). `task` is reserved for callers / future naming.
+script write_tree_patch = ```
+ set -euo pipefail
+ dest="$1"
+ mkdir -p "$(dirname "$dest")"
+ diff_out="$(git diff HEAD -- . ':!.jaiph/' 2>/dev/null || true)"
+ if [[ -z "${diff_out}" ]]; then
+ git add -N . -- ':!.jaiph/' 2>/dev/null || true
+ diff_out="$(git diff HEAD -- . ':!.jaiph/' 2>/dev/null || true)"
+ git reset HEAD -- . 2>/dev/null || true
+ fi
+ if [[ -n "${diff_out}" ]]; then
+ printf '%s\n' "${diff_out}" > "$dest"
+ else
+ : > "$dest"
+ fi
+ printf '%s' "$dest"
+```
+
+workflow patch(task) {
+ ensure in_git_repo()
+ const dest = ".jaiph/tmp/engineer-workspace.patch"
+ return run write_tree_patch(dest)
}
diff --git a/.jaiph/libs/jaiphlang/artifacts.jh b/.jaiph/libs/jaiphlang/artifacts.jh
new file mode 100644
index 00000000..e23b64d0
--- /dev/null
+++ b/.jaiph/libs/jaiphlang/artifacts.jh
@@ -0,0 +1,36 @@
+#!/usr/bin/env jaiph
+
+#
+# Artifact publishing for Jaiph workflows.
+# Copies files from the workspace into ${JAIPH_ARTIFACTS_DIR} so they
+# survive sandbox teardown and are readable on the host at
+# .jaiph/runs//artifacts/.
+#
+# Usage:
+# import "jaiphlang/artifacts" as artifacts
+#
+# workflow default() {
+# run artifacts.save("./build/output.bin", "build-output.bin")
+# }
+#
+
+script save_script = ```
+ set -euo pipefail
+ ARTIFACTS_DIR="${JAIPH_ARTIFACTS_DIR:?JAIPH_ARTIFACTS_DIR is not set}"
+ src="$1"
+ dest_name="$2"
+ if [[ ! -f "$src" ]]; then
+ printf 'artifacts save: file not found: %s\n' "$src" >&2
+ exit 1
+ fi
+ dest="${ARTIFACTS_DIR}/${dest_name}"
+ mkdir -p "$(dirname "$dest")"
+ cp -- "$src" "$dest"
+ printf '%s' "$dest"
+```
+
+# Copies the file at `local_path` into the artifacts directory under `name`.
+# Returns the absolute path of the saved artifact.
+export workflow save(local_path, name) {
+ return run save_script(local_path, name)
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index eda20425..eeb070f8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,48 @@
# Unreleased
+# 0.9.3
+
+## Summary
+
+- **Sandboxing:** Docker is always on with read-only workspace; use `jaiphlang/artifacts.save()` for file persistence. Improved image and security.
+- **Compiler:** All identifiers are checked and immutable; clearer errors; supports inline `run` inside `return` and `log`.
+- **Language/runtime:** Adds `Handle` for async, repair-and-retry with `recover`, optional module metadata.
+- **Tests:** Test blocks support `const`; must explicitly bind response values; name checking at compile time.
+- **CLI:** Shows workflow return values and improves output for prompts and Docker failures.
+
+## All changes
+
+- **Breaking — Runtime config:** `runtime.docker_timeout` renamed to `runtime.docker_timeout_seconds` to make the unit explicit. The old key produces an `E_PARSE` migration message. `DockerRunConfig.timeout` renamed to `timeoutSeconds` internally.
+- **Docker:** Default container execution timeout is **3600** seconds (one hour), up from 300, via `resolveDockerConfig` / `runtime.docker_timeout_seconds` when not overridden by `JAIPH_DOCKER_TIMEOUT` or in-file config.
+- **Docker:** `reportResult` fallback — when `discoverDockerRunDir` cannot match the expected `run_id`, the CLI now prints the sandbox runs root and the expected `run_id` instead of emitting just "Workflow execution failed." Paired with a rewritten `76_docker_failure_parity.sh` E2E that compares full normalized output between Docker and no-sandbox modes for both script-step and rule-match failures.
+- **Library:** `jaiphlang/artifacts` provides `save(local_path, name)` via a named `save_script` and drops the unpublished git-oriented helpers (`save_patch`, `apply_patch`) and standalone `artifacts.sh`.
+- **Language:** `return ` — bare identifiers are now accepted in return position. `return response` is sugar for `return "${response}"`, resolved against the same scope rules used for `${ident}` interpolation and bare-identifier call arguments (`const`, capture, or parameter). Unknown identifiers (`return missing_name` where `missing_name` is not in scope) produce a precise `E_VALIDATE` unknown-identifier error naming the missing binding. Previously, bare identifiers in return position fell through to the catch-all "inline shell steps are forbidden" diagnostic, which was incorrect — the user was not writing a shell statement, and the suggested fix (explicit script block) did not solve the problem. Both `return response` and `return "${response}"` are valid and equivalent; existing interpolated return forms are unchanged. Parser updated in all return-position paths (top-level workflow body, brace blocks, catch/recover bodies). Unit tests cover bare-identifier returns from `const`, parameters, and catch bindings; compiler tests cover acceptance and unknown-identifier rejection; E2E test covers end-to-end propagation.
+- **Language:** Immutable binding enforcement — `const`, parameter, capture, and `script` names are now immutable. Rebinding a parameter via `const`, declaring duplicate `const` names in the same scope, or colliding a `script` name with an existing immutable binding are all rejected at compile time with `E_VALIDATE: cannot rebind immutable name "…"`. The error names the conflicting binding and where it was first bound. Existing files that shadowed parameters (e.g. `workflow default(x) { const x = … }`) must use distinct names. `examples/say_hello.jh` migrated as a reference.
+- **Language:** `return run \`…\`(args)` and `log run \`…\`(args)` — inline scripts wrapped with explicit `run` now work in value positions (`return`, `log`, `logerr`). Bare inline scripts without `run` remain rejected at compile time with clear errors. Parser, validator, emitter, formatter, and runtime all updated. E2E and unit tests cover zero-arg and argument forms plus rejection paths.
+- **Language:** Bare unknown identifiers in `match` arm bodies (e.g. `_ => true`, `_ => blorp`) are now rejected at compile time with `E_VALIDATE: unknown identifier "…" in match arm body`. Previously, a bare word that was not an in-scope variable was silently treated as a string literal. Only bare in-scope identifiers (`const`, capture, or parameter names) are accepted; all other bare words must be quoted. The existing unknown-verb check (for words followed by arguments) and this new bare-word check together cover all unknown-identifier cases in arm bodies. Regression tests cover `true`, `false`, arbitrary unknown words, in-scope identifiers, and string literals.
+- **Language:** `match` arms are now strictly newline-delimited — trailing commas after arm bodies and comma separators between arms are rejected at parse time with the diagnostic `"commas are not allowed in match arms; use one arm per line"`. Previously, commas after arms were silently accepted. Parser and validation tests cover string-value, `fail`, and inline comma-separated forms.
+- **Language:** Unknown leading verbs in `match` arm bodies are rejected — `"" => error "msg"` now fails validation instead of silently treating `error "msg"` as a string literal (so a rule meant to `fail` was "passing" with a truthy value). Only `fail` / `run` / `ensure` are accepted as arm-body leading verbs, and the diagnostic suggests `fail` when the user typed `error`.
+- **Language:** `match` arm bodies resolve bare in-scope identifiers — `=> name_arg` now returns the variable's value, mirroring `return val` sugar. Previously fell through to string interpolation and returned the literal name.
+- **Formatter:** `return ` round-trips — the AST now stores `bareSource` on return steps so `return response` stays `return response` on re-emit instead of being rewritten to `return "${response}"`. Explicit `return "${var}"` is still preserved.
+- **Language:** `Handle` for `run async`; `recover` retry loops on `run`; nested `run` / `ensure` in call arguments; optional `module.name`, `module.version`, `module.description` in module `config`.
+- **Tests:** `const NAME = "literal"` bindings inside `test { … }` blocks. Only plain double-quoted literals in v1 (no interpolation, no `run`, no `match`). `mock prompt `, `expect_equal var `, `expect_contain var `, and `expect_not_contain var ` accept a bare identifier that resolves against test-scope `vars`. Order matters — the `const` must appear before references. AST keeps literal fields and adds optional `responseVar` / `expectedVar` / `substringVar` discriminators so the formatter round-trips whichever form was authored.
+- **Breaking — Tests:** Implicit `response` binding is gone. `run …` in a `test { … }` block no longer silently binds the return value to a magic `response` — write `const response = run hello.default("Alice")` explicitly. A new `validateTestBlocks` pass rejects `expect_*` LHS, `expect_* var ` RHS, and `mock prompt ` that reference an undeclared name at compile time (`E_VALIDATE`), wired into the existing `validateReferences` path so `jaiph test` fails before any test runs. Runtime has a matching fail-fast guard. `examples/say_hello.test.jh` updated.
+- **CLI / UX:** Run tree prints workflow return value — when a `jaiph run`'s default workflow exits successfully with a return value, the runtime writes it to `/return_value.txt` and the CLI prints it on its own line after `✓ PASS workflow default`, separated by a blank line. Workflows without a return statement produce no extra output.
+- **CLI / UX:** Prompt step preview keeps authored `${var}` placeholders — the run-tree preview now reads `prompt cursor "Say hello to ${name} and..."` with the raw source text instead of substituting the value. Concrete values still appear alongside in the params. The `declaredParamNames` block was removed so prompt steps list only the `${var}` references actually appearing in the prompt body.
+- **CLI:** `jaiph` usage output surfaces `--raw`, `jaiph install`, and `jaiph compile`. `jaiph use` accepts `git@host:path.git@ref` and refs containing slashes.
+- **Docs / examples:** `examples/recover_loop.jh` recast to use `run check_report_exists() recover(failure) { … }` with triple-backtick fenced scripts; landing-page samples and the Jaiph syntax highlighter extended to color `match` / `return` / `fail` keywords, `=>`, single- and triple-backtick scripts, and bare regex literals; channels paragraph rewritten with a concrete `findings -> analyst` example; async wording simplified ("resolves on the first read, or at the end of the embracing workflow").
+- **Repo / tests:** `examples/` is the single source of truth for showcase workflows — `e2e/agent_inbox.jh`, `e2e/async.jh`, `e2e/say_hello*.jh`, `examples/recover_loop.test.jh`, and `tmp-sandbox-doc-example.jh` removed; affected E2E tests now copy from `examples/`. Compiler test fixtures and e2e expectations refreshed; inline script names normalized to `__inline_` in e2e output.
+- **Release / CI:** `.github/workflows/release.yml` removed — the npm package name is locked, and npm publishing is no longer part of the release flow. Release = tag `v*` (triggers the GHCR runtime-image publish in `ci.yml`).
+- **Runtime / library:** `JAIPH_ARTIFACTS_DIR` and `artifacts.jh` (`save`, `save_patch`, `apply_patch`); removed dead per-call isolation export paths.
+- **Docker — contract:** `jaiph` required in the image (`E_DOCKER_NO_JAIPH`); default `ghcr.io/jaiphlang/jaiph-runtime:`; `jaiph init` Dockerfiles extend the official image; no runtime self-install into arbitrary base images.
+- **Docker — toggles:** Docker on by default unless `JAIPH_UNSAFE=true`; `JAIPH_DOCKER_ENABLED` for explicit control; `runtime.workspace` and `runtime.docker_enabled` removed (environment only).
+- **Docker — security / isolation:** Env allowlist (replacing a denylist); dangerous host mount paths rejected; `execFileSync` for `docker` and `id` calls; `cap-drop` and `no-new-privileges`.
+- **Docker — workspace:** Read-only host workspace with overlay or copy; removed automatic `workspace.patch` at teardown (use `artifacts.save_patch()`).
+- **Docker — robustness:** `runtime/overlay-run.sh` on disk; quiet image pre-pull; strict `JAIPH_DOCKER_TIMEOUT`; `E_DOCKER_UID` on failed Linux UID detection; per-invocation `JAIPH_RUN_ID` for run-dir discovery; sandbox cleanup on signals and process exit; `WorkspaceCloner` internal refactor.
+- **CLI / UX:** Docker failure footers and paths aligned with local runs; quoting fixes for step titles and channel payloads; doc updates (threat model, `KEEP_SANDBOX` copy).
+- **Tests / packaging:** ShellCheck for `overlay-run.sh`; PTY E2E for `run async` progress; official `runtime/Dockerfile` copies `overlay-run.sh` into the build context.
+- **Repo:** Removed committed debug/temp tree junk; stricter `.gitignore`.
+
# 0.9.2
## Summary
@@ -14,7 +57,7 @@
- **Breaking — Runtime:** Remove `JAIPH_LIB` — The Node runtime no longer sets `JAIPH_LIB`, and isolated script subprocesses no longer receive it (`run-step-exec.ts`). `resolveRuntimeEnv` still deletes inherited `JAIPH_LIB` so a parent shell cannot inject a stale path. Workflows that used `source "$JAIPH_LIB/…"` must use `JAIPH_WORKSPACE`-relative paths, `import script`, or inline bash. Project-scoped **`.jaiph/libs/`** (`jaiph install`) is unchanged.
- **Docs / E2E:** Documentation and tests no longer describe or assert `JAIPH_LIB` / `.jaiph/lib` (singular).
- **Feature — Runtime:** Heartbeat file in run directory — The runtime now writes a `heartbeat` file (containing epoch-ms timestamp) to the run directory (`.jaiph/runs//