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//