diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index dbba7aa0..dea6e3fe 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -95,7 +95,7 @@ module.exports = { { name: "no-deep-imports-into-runtime", comment: - "Runtime is a deep module: code OUTSIDE the runtime package imports only its public entries — src/runtime/index.ts (production surface) or src/runtime/testing.ts (named test seams for cross-package *.test.ts) — never src/runtime/** internals (docker, docker-inplace, embedded-assets, kernel/*). Add a named re-export to src/runtime/index.ts (production) or src/runtime/testing.ts (test-only seams) instead of reaching in.", + "Runtime is a deep module: code OUTSIDE the runtime package imports only its public entries — src/runtime/index.ts (production surface) or src/runtime/testing.ts (named test seams for cross-package *.test.ts) — never src/runtime/** internals (embedded-assets, kernel/*). Add a named re-export to src/runtime/index.ts (production) or src/runtime/testing.ts (test-only seams) instead of reaching in.", severity: "error", from: { pathNot: "^src/runtime/" }, to: { path: "^src/runtime/", pathNot: "^src/runtime/(index|testing)\\.ts$" }, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6aefbc2b..4dc97a0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,60 +38,13 @@ jobs: VERSION="$(node -p "require('./package.json').version")" git ls-remote --exit-code https://github.com/jaiphlang/jaiph.git "refs/tags/v${VERSION}" - k8s-manifest: - name: Validate Kubernetes deploy manifest - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - # `kubectl apply --dry-run=client` still needs an API server for resource - # discovery (RESTMapper), so provision a throwaway kind cluster for it. - # The same cluster then backs the real deploy test below. - - name: Create kind cluster - uses: helm/kind-action@v1 - with: - cluster_name: jaiph-e2e - - # Fast schema gate — cheap, but only proves the manifest parses. - - name: Dry-run apply the standalone deploy manifest - run: kubectl apply --dry-run=client -f docs/deploy/k8s.yaml - - - name: Build runtime image for the deploy test - run: docker build -t jaiph-e2e-runtime:local -f runtime/Dockerfile . - - # Real deployment contract: external Secret gate, pod hardening - # (non-root, no privilege escalation, dropped caps, no SA token, - # read-only rootfs), an authenticated HTTP run, and its journal on the - # writable runs volume. - - name: Deploy and exercise the manifest on kind - run: | - JAIPH_E2E_SKIP_INSTALL=1 \ - JAIPH_E2E_KIND_CLUSTER=jaiph-e2e \ - JAIPH_E2E_DOCKER_IMAGE=jaiph-e2e-runtime:local \ - bash e2e/tests/150_k8s_deploy.sh - e2e: - name: E2E (${{ matrix.os }}, ${{ matrix.label }}) + name: E2E (${{ matrix.os }}) 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: - include: - - os: ubuntu-latest - label: docker - jaiph_unsafe: "" - - os: ubuntu-latest - label: host - jaiph_unsafe: "true" - - os: macos-latest - label: host - jaiph_unsafe: "true" + os: [ubuntu-latest, macos-latest] steps: - name: Checkout uses: actions/checkout@v4 @@ -101,12 +54,6 @@ 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 @@ -141,11 +88,6 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 - - 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 @@ -169,7 +111,7 @@ jobs: fi command -v cursor-agent - - name: Install Claude Code CLI (prompt backend parity with Docker image) + - name: Install Claude Code CLI run: | npm install -g @anthropic-ai/claude-code command -v claude @@ -273,7 +215,6 @@ jobs: $bashScript = @' set -euo pipefail export DEBIAN_FRONTEND=noninteractive - export JAIPH_UNSAFE=true # wsl.exe does not forward the parent runner's env into this Linux # session, so CI (which docs/install treats as "trusted toolchain, # checksum-only OK when minisign is absent") must be re-set here — @@ -382,53 +323,3 @@ jobs: run: | $env:JAIPH_TEST_WINDOWS_EXE = Join-Path $env:GITHUB_WORKSPACE "jaiph-windows-x64.exe" ./e2e/tests/windows_native_smoke.ps1 - - docker-publish: - name: Publish Docker runtime image - needs: [test, e2e, docs-local, e2e-wsl, installer-powershell, windows-native-smoke] - 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: | - TAG="$(echo '${{ steps.meta.outputs.tags }}' | cut -d',' -f1)" - docker run --rm --entrypoint sh "${TAG}" -lc "command -v jaiph && jaiph --version" - docker run --rm --cap-drop ALL --entrypoint sh "${TAG}" -lc "command -v jaiph" diff --git a/.gitignore b/.gitignore index 40e99fa3..b39a8cec 100644 --- a/.gitignore +++ b/.gitignore @@ -53,7 +53,6 @@ e2e/nested_run.sh e2e/nested_inner.sh e2e/log_keyword.sh e2e/fibonacci.sh -e2e/ensure_fail.sh e2e/current_branch.sh e2e/assign_capture.sh diff --git a/.jaiph/architect_review.jh b/.jaiph/architect_review.jh index 5bdf8765..eda0cbc8 100755 --- a/.jaiph/architect_review.jh +++ b/.jaiph/architect_review.jh @@ -12,8 +12,8 @@ config { script jaiph_review_body_file = `printf '%s\n' "$JAIPH_WORKSPACE/.jaiph/tmp/architect_review_body.txt"` # Packed as: first line = verdict, rest = updated_description (must stay top-level: -# const … = prompt """…""" is not supported inside ensure … catch — see parseRecoverStatement). -workflow architect_agent_review(task) { +# const … = prompt """…""" is not supported inside run … catch — see parseRecoverStatement). +def architect_agent_review(task) { const result = prompt """ You are a software architect reviewing a task from the Jaiph improvement queue. Jaiph is a TypeScript compiler and runtime @@ -70,12 +70,12 @@ workflow architect_agent_review(task) { """ } -workflow review_one_header(header) { +def review_one_header(header) { run common.arg_nonempty(header) catch (err) { return "" } const task = run queue.get_task_by_header(header) - ensure queue.task_is_dev_ready(task) catch (err) { + run queue.task_is_dev_ready(task) catch (err) { const packed = run architect_agent_review(task) const verdict = run common.first_line_str(packed) const updated_description = run common.rest_lines_str(packed) @@ -104,7 +104,7 @@ workflow review_one_header(header) { log "Already dev-ready: ${header}" } -workflow process_headers_recursive(header, remaining) { +def process_headers_recursive(header, remaining) { run review_one_header(header) run common.arg_nonempty(remaining) catch (err) { return "" @@ -114,19 +114,19 @@ workflow process_headers_recursive(header, remaining) { run process_headers_recursive(next, rest) } -workflow maybe_process_headers(first, rest) { +def maybe_process_headers(first, rest) { run common.arg_nonempty(first) catch (err) { return "" } run process_headers_recursive(first, rest) } -workflow default() { +export def main() { const headers = run queue.get_all_task_headers() const first = run common.first_line_str(headers) const rest = run common.rest_lines_str(headers) run maybe_process_headers(first, rest) - ensure queue.all_dev_ready() catch (err) { + run queue.all_dev_ready() catch (err) { fail "One or more tasks need work. Review the agent output above." } } diff --git a/.jaiph/docs_parity.jh b/.jaiph/docs_parity.jh index 7edec7c8..b028bb51 100755 --- a/.jaiph/docs_parity.jh +++ b/.jaiph/docs_parity.jh @@ -22,7 +22,7 @@ const role = """ - Prose must follow the plain-writing skill (.jaiph/skills/plain-writing/SKILL.md): everyday words, complete sentences, limited clause stacking, and no unnecessary jargon. Real - Jaiph terms (workflow, sandbox, MCP, …) are allowed; define them briefly + Jaiph terms (def, sandbox, MCP, …) are allowed; define them briefly on first use when a newcomer might not know them. """ @@ -49,7 +49,7 @@ script assert_newline_paths_are_files = ``` done <<< "$1" ``` -rule docs_files_present(list) { +def docs_files_present(list) { run assert_newline_paths_are_files(list) } @@ -70,7 +70,7 @@ script assert_worktree_clean_for_docs = ``` fi ``` -rule worktree_is_clean() { +def worktree_is_clean() { run assert_worktree_clean_for_docs() } @@ -94,7 +94,7 @@ script assert_only_allowed_changed = ``` done <<< "$after_changed_files" ``` -rule only_expected_docs_changed_after_prompt(allowed) { +def only_expected_docs_changed_after_prompt(allowed) { run assert_only_allowed_changed(allowed) } @@ -121,7 +121,7 @@ script build_allowed_paths_block = ``` printf '%s\n' "$out" ``` -workflow update_from_task(taskDesc) { +export def update_from_task(taskDesc) { prompt """ ${skills_preamble} @@ -148,7 +148,7 @@ workflow update_from_task(taskDesc) { """ } -workflow docs_page(path) { +def docs_page(path) { prompt """ ${skills_preamble} @@ -182,7 +182,7 @@ workflow docs_page(path) { """ } -workflow docs_overview(docPaths) { +def docs_overview(docPaths) { prompt """ ${skills_preamble} @@ -220,7 +220,7 @@ workflow docs_overview(docPaths) { 8. Ensure docs/index.html and README.md have links to getting-started.md page of the documentation or jaiph.org/getting-started, and the agent skill URL (https://raw.githubusercontent.com/jaiphlang/jaiph/refs/heads/main/docs/jaiph-skill.md). - 9. Knowing the whole documentation, ensure the agent skill is up to date + 9. Knowing the whole documentation, run the agent skill is up to date and coherent with the documentation. It should be a minimal workflow set that supports safe feature delivery, preflight checks, implementation workflow, verification workflow, and a default entrypoint that @@ -233,11 +233,11 @@ workflow docs_overview(docPaths) { """ } -workflow default() { +export def main() { run claude.ensure_usage() - ensure worktree_is_clean() + run worktree_is_clean() const allowed_list = run build_allowed_paths_block() - ensure docs_files_present(allowed_list) + run docs_files_present(allowed_list) const docs_md_list = run list_docs_md_paths() for path in docs_md_list { if path != "" { @@ -247,5 +247,5 @@ workflow default() { } run claude.ensure_usage() run docs_overview(docs_md_list) - ensure only_expected_docs_changed_after_prompt(allowed_list) + run only_expected_docs_changed_after_prompt(allowed_list) } diff --git a/.jaiph/engineer.jh b/.jaiph/engineer.jh index 9a45b823..ec736f56 100755 --- a/.jaiph/engineer.jh +++ b/.jaiph/engineer.jh @@ -8,7 +8,7 @@ # → default → implement_from_queue (first #dev-ready QUEUE.md task). # # Hub / serve / mcp (task parameter, no QUEUE.md): -# export workflow implement_from_task(task) — used by .jaiph/main.jh engineer(task) +# export def implement_from_task(task) — used by .jaiph/main.jh engineer(task) # import "jaiphlang/artifacts" as artifacts import "jaiphlang/claude" as claude @@ -205,7 +205,7 @@ const classification_prompt = """ ${role_stabilizer} """ -workflow select_role(role_name) { +def select_role(role_name) { return match role_name { "surgical" => role_surgical "reductionist" => role_reductionist @@ -225,7 +225,7 @@ script first_line_task = ``` printf '%s\n' "$line" ``` -workflow classify_role(task) { +def classify_role(task) { config { agent.model = "sonnet" } @@ -253,7 +253,7 @@ workflow classify_role(task) { } } -workflow implement(task, role_name) { +def implement(task, role_name) { config { agent.model = "opus" } @@ -307,7 +307,7 @@ workflow implement(task, role_name) { in the task/PR notes. Architecture invariants to preserve by default: - - Runtime owns workflow semantics (including channels/inbox dispatch). + - Runtime owns def semantics(including channels/inbox dispatch). - CLI owns orchestration/observation (compile, launch, progress, hooks). - Runtime -> CLI live contract is __JAIPH_EVENT__. - Runtime durable contract is .jaiph/runs + run_summary.jsonl artifacts. @@ -323,7 +323,7 @@ workflow implement(task, role_name) { # Shared post-implement path: CI, docs parity from the task text, commit, artifact. # Callers that touch QUEUE.md must do so before this (so the commit includes it). -workflow verify_docs_and_commit(task) { +def verify_docs_and_commit(task) { run ci.ensure_ci_passes() run docs.update_from_task(task) const patch_file = run git.commit(task) @@ -332,7 +332,7 @@ workflow verify_docs_and_commit(task) { } # Task-parameter entry for serve/mcp hub. Does not read or write QUEUE.md. -export workflow implement_from_task(task) { +export def implement_from_task(task) { run common.arg_nonempty(task) catch (err) { fail "engineer.implement_from_task requires a non-empty task parameter (markdown with a ## header)" } @@ -353,11 +353,11 @@ export workflow implement_from_task(task) { } # Queue-driven entry for CLI / overnight loops. Always auto-classifies the role. -export workflow implement_from_queue() { +export def implement_from_queue() { run claude.ensure_usage() const task = run queue.get_first_task() - ensure queue.task_is_dev_ready(task) + run queue.task_is_dev_ready(task) const task_header = run first_line_task(task) log "Implementing task: ${task_header}" @@ -373,6 +373,6 @@ export workflow implement_from_queue() { return patch_file } -workflow default() { +export def main() { return run implement_from_queue() } diff --git a/.jaiph/ensure_ci_passes.jh b/.jaiph/ensure_ci_passes.jh index 1518c82d..1326efb3 100755 --- a/.jaiph/ensure_ci_passes.jh +++ b/.jaiph/ensure_ci_passes.jh @@ -11,11 +11,8 @@ script npm_run_test_ci = ``` while IFS= read -r _v; do unset "$_v" 2>/dev/null || true done < <(compgen -e | grep '^JAIPH_' || true) -# Full Docker e2e (incl. kind) is required here — same as GitHub Actions. -# Do NOT re-add JAIPH_E2E_SKIP_DOCKER to dodge a flake; fix the harness -# (named waits, EXIT cleanup, probe flake classification, kind heartbeats). # Heartbeat so JAIPH_STEP_IDLE_KILL_SEC cannot kill a long-but-live test:ci -# when individual e2e scripts go quiet (kind pulls, large docker builds). +# when individual e2e scripts go quiet. ( elapsed=0 while true; do @@ -39,7 +36,7 @@ test -s "$1" || { } ``` -workflow ensure_ci_passes() { +export def ensure_ci_passes() { const ci_log_dir = ".jaiph/tmp" const ci_log_file = "${ci_log_dir}/ensure_ci_passes.last.log" run common.mkdir_p_simple(ci_log_dir) @@ -66,12 +63,6 @@ workflow ensure_ci_passes() { expectations, or removal of obsolete features. - Any test change must be minimal with a clear rationale. - Do NOT add speculative fixes. Fix only what the log shows is broken. - - Do NOT lengthen src/runtime/docker.ts probe timeouts to paper over - Docker Desktop load flakes; fix the e2e harness instead (named - jaiph-run container waits, EXIT cleanup of leftover containers, - surface stderr on failure, probe flake retries / E_DOCKER_PROBE_FAILED). - - Do NOT set JAIPH_E2E_SKIP_DOCKER here — overnight runs the full - suite like GitHub Actions. """ } @@ -79,6 +70,6 @@ workflow ensure_ci_passes() { run common.rm_file_simple(ci_log_file) } -workflow default() { +export def main() { run ensure_ci_passes() } diff --git a/.jaiph/gh_ci_passes.jh b/.jaiph/gh_ci_passes.jh index 3e6e5c7b..118a649f 100755 --- a/.jaiph/gh_ci_passes.jh +++ b/.jaiph/gh_ci_passes.jh @@ -5,8 +5,8 @@ # loop with an agent until CI is green (or run.recover_limit is hit). # # Run as: -# jaiph run --unsafe --env GITHUB_TOKEN .jaiph/gh_ci_passes.jh -# jaiph run --unsafe --env GITHUB_TOKEN .jaiph/gh_ci_passes.jh -- my-feature-branch +# jaiph run --env GITHUB_TOKEN .jaiph/gh_ci_passes.jh +# jaiph run --env GITHUB_TOKEN .jaiph/gh_ci_passes.jh -- my-feature-branch # # Requires: GH_TOKEN or GITHUB_TOKEN (via --env), jq, git, network access. # Each retry waits on HEAD after commit+push — do not pin an old commit here. @@ -27,7 +27,7 @@ script assert_nonempty_file_or_fail = ``` } ``` -workflow ensure_gh_ci_passes(branch, workflow_name) { +def ensure_gh_ci_passes(branch, workflow_name) { const ci_log_dir = ".jaiph/tmp" const ci_log_file = "${ci_log_dir}/gh_ci_passes.last.log" run common.mkdir_p_simple(ci_log_dir) @@ -36,7 +36,7 @@ workflow ensure_gh_ci_passes(branch, workflow_name) { # push instead of re-checking a stale SHA. const commit = "" - ensure gh.token_set() + run gh.token_set() run gh.check_ci(branch, commit, workflow_name, ci_log_file) recover (failure) { run assert_nonempty_file_or_fail(ci_log_file) @@ -64,7 +64,7 @@ workflow ensure_gh_ci_passes(branch, workflow_name) { - Do NOT invoke Jaiph orchestration workflows from .jaiph/. """ - ensure git.in_git_repo() + run git.in_git_repo() prompt """ Commit the CI fix locally so Jaiph can push it. Do NOT push — Jaiph performs the push in a trusted step after this. @@ -81,7 +81,7 @@ workflow ensure_gh_ci_passes(branch, workflow_name) { run common.rm_file_simple(ci_log_file) } -workflow pull_latest_logs(branch, workflow_name) { +def pull_latest_logs(branch, workflow_name) { const ci_log_dir = ".jaiph/tmp" const ci_log_file = "${ci_log_dir}/gh_ci.latest.log" run common.mkdir_p_simple(ci_log_dir) @@ -90,7 +90,7 @@ workflow pull_latest_logs(branch, workflow_name) { return ci_log_file } -workflow default(branch, workflow_name) { +export def main(branch, workflow_name) { const wf = match workflow_name { "" => "CI" _ => workflow_name diff --git a/.jaiph/libs/jaiphlang/artifacts.jh b/.jaiph/libs/jaiphlang/artifacts.jh index fb68a0cb..f80d5bc7 100644 --- a/.jaiph/libs/jaiphlang/artifacts.jh +++ b/.jaiph/libs/jaiphlang/artifacts.jh @@ -3,13 +3,12 @@ # # 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/. +# are readable at .jaiph/runs//artifacts/. # # Usage: # import "jaiphlang/artifacts" as artifacts # -# workflow default() { +# export def main() { # # Single file: # run artifacts.save("./build/output.bin") # @@ -61,6 +60,6 @@ script save_script = ``` # Each file is copied under the same relative name as in the list # (leading `./` stripped; absolute paths use basename only). # Returns the absolute destination paths, one per line, in the same order. -export workflow save(paths) { +export def save(paths) { return run save_script(paths) } diff --git a/.jaiph/libs/jaiphlang/claude.jh b/.jaiph/libs/jaiphlang/claude.jh index 60c1c87e..96f12139 100644 --- a/.jaiph/libs/jaiphlang/claude.jh +++ b/.jaiph/libs/jaiphlang/claude.jh @@ -35,12 +35,12 @@ script check_capacity = ``` (( percent <= 90 )) ``` -workflow probe_and_log() { +def probe_and_log() { const status = run check_capacity() log status } -export workflow ensure_usage() { +export def ensure_usage() { config { # At ten-minute intervals this permits roughly 7 days of capacity checks. run.recover_limit = 1000 diff --git a/.jaiph/libs/jaiphlang/gh_actions.jh b/.jaiph/libs/jaiphlang/gh_actions.jh index e9e8ce3d..aab0bec8 100644 --- a/.jaiph/libs/jaiphlang/gh_actions.jh +++ b/.jaiph/libs/jaiphlang/gh_actions.jh @@ -11,23 +11,23 @@ # import script "./gh_actions.sh" as gh -export rule token_set() { +export def token_set() { run gh("require-token") } -export workflow check_ci(branch, commit, workflow_name, log_file) { +export def check_ci(branch, commit, workflow_name, log_file) { return run gh("check-ci", branch, commit, workflow_name, log_file) } -export workflow wait_for_ci(branch, commit, workflow_name) { +export def wait_for_ci(branch, commit, workflow_name) { return run gh("wait-run", branch, commit, workflow_name) } -export workflow pull_logs(branch, commit, workflow_name, out_file, failed_only) { +export def pull_logs(branch, commit, workflow_name, out_file, failed_only) { return run gh("pull-logs", branch, commit, workflow_name, out_file, failed_only) } -workflow default(cmd, branch, commit, workflow_name, out_file) { +export def main(cmd, branch, commit, workflow_name, out_file) { const result = match cmd { "" => run gh("check-ci", branch, commit, workflow_name) "check" => run gh("check-ci", branch, commit, workflow_name) diff --git a/.jaiph/libs/jaiphlang/gh_actions.sh b/.jaiph/libs/jaiphlang/gh_actions.sh index d193f8cf..d0b43a22 100755 --- a/.jaiph/libs/jaiphlang/gh_actions.sh +++ b/.jaiph/libs/jaiphlang/gh_actions.sh @@ -72,7 +72,7 @@ require_gh_token() { export GH_TOKEN="$GITHUB_TOKEN" return 0 fi - die "GH_TOKEN or GITHUB_TOKEN is required; pass a token explicitly (e.g. jaiph run --unsafe --env GITHUB_TOKEN .jaiph/gh_ci_passes.jh)" + die "GH_TOKEN or GITHUB_TOKEN is required; pass a token explicitly (e.g. jaiph run --env GITHUB_TOKEN .jaiph/gh_ci_passes.jh)" } mark_git_workspace_safe() { diff --git a/.jaiph/libs/jaiphlang/git.jh b/.jaiph/libs/jaiphlang/git.jh index e767d9a8..7a97323b 100755 --- a/.jaiph/libs/jaiphlang/git.jh +++ b/.jaiph/libs/jaiphlang/git.jh @@ -25,31 +25,31 @@ script git_push_head = ``` fi ``` -rule in_git_repo() { +export def in_git_repo() { run git_mark_workspace_safe() run git_inside_worktree() catch (err) { fail "not inside a git repository" } } -rule branch_clean() { +export def branch_clean() { run git_porcelain_empty() catch (err) { fail "git working tree is not clean" } } -rule has_changes() { +export def has_changes() { run git_porcelain_nonempty() catch (err) { fail "git working tree has no changes" } } -rule is_clean() { - ensure in_git_repo() - ensure branch_clean() +export def is_clean() { + run in_git_repo() + run branch_clean() } -workflow commit(task) { +export def commit(task) { config { # agent.backend = "cursor" # agent.model = "composer-2" @@ -58,8 +58,8 @@ workflow commit(task) { agent.claude_flags = "--permission-mode bypassPermissions" } - ensure in_git_repo() - ensure has_changes() + run in_git_repo() + run has_changes() const response = prompt """ Please commit current changes and respond with a commit message and @@ -91,8 +91,8 @@ workflow commit(task) { # Like commit(), but no-ops when the worktree is clean (overnight loops). # Deletes the generated .patch from the worktree so the next loop starts clean. -workflow commit_if_changes(task) { - ensure has_changes() catch (err) { +export def commit_if_changes(task) { + run has_changes() catch (err) { log "No changes to commit." return "" } @@ -103,11 +103,11 @@ workflow commit_if_changes(task) { script git_rm_patch = `rm -f -- "$1"` -workflow push(branch) { - ensure in_git_repo() +export def push(branch) { + run in_git_repo() run git_push_head(branch) } -workflow default(task) { +export def main(task) { return run commit(task) } diff --git a/.jaiph/libs/jaiphlang/queue.jh b/.jaiph/libs/jaiphlang/queue.jh index 33c5b0ac..a384927c 100755 --- a/.jaiph/libs/jaiphlang/queue.jh +++ b/.jaiph/libs/jaiphlang/queue.jh @@ -14,67 +14,67 @@ import script "./queue.py" as queue # Dispatches CLI arguments to the queue Python script. -workflow default(cmd, arg1, arg2) { +export def main(cmd, arg1, arg2) { const result = run queue(cmd, arg1, arg2) log result } # Append ## tasks from a markdown file into QUEUE.md. Titles already present # are skipped. Missing #dev-ready tags are added automatically. -export workflow add_tasks_from_file(path) { +export def add_tasks_from_file(path) { run queue("add_from_file", path) } # Returns the full text block (header + body) of the first task. -export workflow get_first_task() { +export def get_first_task() { return run queue("get") } # Returns the first task whose header carries the given tag. -export workflow next_task(tag) { +export def next_task(tag) { return run queue("get", tag) } # Returns the full text block of a task identified by its title. # Accepts both clean titles and titles with #tags — tags are stripped # before matching so callers don't need to know the exact tag set. -export workflow get_task_by_header(header) { +export def get_task_by_header(header) { return run queue("get_by_header", header) } # Returns all task titles as newline-separated text (without ## prefix). # Pass an optional tag to filter (e.g. "dev-ready"). -export workflow get_all_task_headers() { +export def get_all_task_headers() { return run queue("headers") } # Adds #dev-ready to the header of the task matching the given title. -export workflow mark_task_dev_ready(header) { +export def mark_task_dev_ready(header) { run queue("mark", header, "dev-ready") } # Removes the task matching the given title from the queue file. -export workflow remove_completed_task(header) { +export def remove_completed_task(header) { run queue("complete_by_header", header) } # Replaces the body (markdown below the ## line) for the task matching the title. # `bodyPath` must be a UTF-8 file; header line and tags are unchanged. -export workflow set_task_description_from_file(header, bodyPath) { +export def set_task_description_from_file(header, bodyPath) { run queue("set_description", header, bodyPath) } # Passes when the queue has at least one task. -export rule has_tasks() { +export def has_tasks() { run queue("get") } # Passes when the task text has #dev-ready on its header line. -export rule task_is_dev_ready(task) { +export def task_is_dev_ready(task) { run queue("has_tag", task, "dev-ready") } # Passes only when every task in the queue carries #dev-ready. -export rule all_dev_ready() { +export def all_dev_ready() { run queue("check_all_tagged", "dev-ready") } diff --git a/.jaiph/main.jh b/.jaiph/main.jh index 72d0dcca..e74c95eb 100755 --- a/.jaiph/main.jh +++ b/.jaiph/main.jh @@ -2,16 +2,16 @@ # # Project orchestration hub — the serve/mcp entrypoint for Jaiph's own -# maintenance workflows. Exposes only the `export workflow` surface below; +# maintenance workflows. Exposes only the `export def` surface below; # helpers in the imported modules stay hidden. # # Serve (HTTP + MCP Streamable HTTP on the same port): -# jaiph serve --inplace -y --env ANTHROPIC_API_KEY --env GITHUB_TOKEN .jaiph/main.jh +# jaiph serve --env ANTHROPIC_API_KEY --env GITHUB_TOKEN .jaiph/main.jh # # MCP over stdio: -# jaiph mcp --inplace -y --env ANTHROPIC_API_KEY --env GITHUB_TOKEN .jaiph/main.jh +# jaiph mcp --env ANTHROPIC_API_KEY --env GITHUB_TOKEN .jaiph/main.jh # -# Optional args use "" for the workflow default (MCP/HTTP require every +# Optional args use "" for the export def main (MCP/HTTP require every # declared parameter as a string). gh_ci_passes needs GITHUB_TOKEN or GH_TOKEN. # import "./architect_review.jh" as arch_mod @@ -31,53 +31,53 @@ config { # Review every QUEUE.md task for clarity, architecture fit, and #dev-ready. # Marks ready tasks; fails if any still need work. -export workflow architect_review() { - run arch_mod.default() +export def architect_review() { + run arch_mod.main() } # Bring docs/ and CLI usage strings in line with the current TypeScript/Bash source. # Requires a clean worktree; edits docs and related usage surfaces only. -export workflow docs_parity() { - run docs_mod.default() +export def docs_parity() { + run docs_mod.main() } # Implement a task end-to-end: code, CI, docs, commit patch. Pass the full task # markdown (must start with a ## header). Does not read or write QUEUE.md — # queue-driven overnight runs use `.jaiph/engineer.jh` directly instead. -export workflow engineer(task) { +export def engineer(task) { return run eng_mod.implement_from_task(task) } # Run npm run test:ci and loop with an agent until it passes (or recover_limit). -export workflow ensure_ci_passes() { - run ci_mod.default() +export def ensure_ci_passes() { + run ci_mod.main() } # Wait for GitHub Actions CI on the branch, pull failure logs, and repair until green. # branch="" uses the current branch; workflow_name="" defaults to "CI". Needs GITHUB_TOKEN. -export workflow gh_ci_passes(branch, workflow_name) { - run gh_ci_mod.default(branch, workflow_name) +export def gh_ci_passes(branch, workflow_name) { + run gh_ci_mod.main(branch, workflow_name) } # OWASP ASI Top 10 security review; report under .jaiph/tmp/, HIGH/MEDIUM → # #dev-ready QUEUE.md tasks (committed when the queue changes). Overnight-safe. # scope: ""|"codebase"|"full" for whole tree, "diff" for uncommitted, or a git range. -export workflow security_review(scope) { - run sec_mod.default(scope) +export def security_review(scope) { + run sec_mod.main(scope) } # Find and apply safe simplifications (no test/e2e edits), CI, commit if changed. -export workflow simplifier() { - run simp_mod.default() +export def simplifier() { + run simp_mod.main() } # Stage a release: CHANGELOG review, version bump, installer ref, rebuild, registry. # version="" bumps the next patch from package.json; otherwise pass X.Y.Z. No tag/push. -export workflow prepare_release(version) { - return run rel_mod.default(version) +export def prepare_release(version) { + return run rel_mod.main(version) } # Find test-coverage gaps, write missing tests, CI, commit if changed. -export workflow qa() { - run qa_mod.default() +export def qa() { + run qa_mod.main() } diff --git a/.jaiph/prepare_release.jh b/.jaiph/prepare_release.jh index 8bf83798..b0d1e867 100755 --- a/.jaiph/prepare_release.jh +++ b/.jaiph/prepare_release.jh @@ -184,7 +184,7 @@ const changelog_reviewer_role = """ per commit-worthy change. Do not drop existing detail when merging. """ -workflow review_changelog(version) { +def review_changelog(version) { config { agent.backend = "claude" agent.model = "opus" @@ -225,14 +225,9 @@ workflow review_changelog(version) { combine Summary bullets (dedupe), append All changes (dedupe by title), keep chronological/newest-first within All changes. - Otherwise rename # Unreleased to # ${version} in place. - 4. Fix stale All-changes wording superseded by later work, especially: - - overlay / copy / fuse / JAIPH_DOCKER_NO_OVERLAY → snapshot | inplace - - MCP "in-place by default" → same sandbox truth table as jaiph run - (snapshot default; inplace only with JAIPH_INPLACE) - - Docker Ctrl+C / signal cleanup: snapshot and inplace modes only - 5. Ensure Summary covers the headline themes: sandbox snapshot + git-defined - content, trusted_envs, language sugar (else if, match |), logging, - security hardening, and anything from the merged 0.11.0 tranche (MCP, + 4. Fix stale All-changes wording superseded by later work. + 5. Ensure Summary covers the headline themes: trusted_envs, language sugar (else if, match |), logging, + and anything from the merged 0.11.0 tranche (MCP, Windows, --env, agent.model) when that section is being folded in. 6. Replace the top of CHANGELOG.md with a fresh empty scaffold: # Unreleased @@ -249,7 +244,7 @@ workflow review_changelog(version) { log "CHANGELOG.md stamped for v${version}" } -workflow resolve_version(arg) { +export def resolve_version(arg) { const pkg_version = run read_pkg_version() const resolved = match arg { "" => run compute_next_patch(pkg_version) @@ -259,22 +254,22 @@ workflow resolve_version(arg) { return resolved } -workflow preflight(version) { +export def preflight(version) { run assert_git_tree_clean() run assert_tag_does_not_exist(version) } -workflow apply_version_change(old_version, new_version) { +def apply_version_change(old_version, new_version) { run npm_version_no_tag(new_version) run update_install_release_ref(old_version, new_version) } -workflow check_displayed_version(version) { +export def check_displayed_version(version) { run run_npm_build() run assert_built_cli_version_equals(version) } -workflow default(arg) { +export def main(arg) { const version = run resolve_version(arg) const old_version = run read_pkg_version() log "Preparing release v${version} (current: v${old_version})" @@ -298,7 +293,7 @@ workflow default(arg) { 3. Confirm MINISIGN_SECRET_KEY is set in GitHub Actions secrets (see docs/contributing.md → Release signing). 4. Tag: git tag v${version} - 5. Push branch + tag (tag push triggers docker-publish and release.yml) + 5. Push branch + tag (tag push triggers release.yml) 6. Smoke check: jaiph use ${version} """ return version diff --git a/.jaiph/qa.jh b/.jaiph/qa.jh index 80f4079a..f67386d0 100755 --- a/.jaiph/qa.jh +++ b/.jaiph/qa.jh @@ -150,7 +150,7 @@ const analyze_gaps_prompt = """ """ -workflow analyze_gaps() { +def analyze_gaps() { config { agent.model = "opus" } @@ -167,7 +167,7 @@ workflow analyze_gaps() { log "Gap report saved to ${report_path}" } -workflow write_tests() { +def write_tests() { run gap_report_nonempty() catch (err) { fail "No gap report found (see .jaiph/tmp/qa_gap_report_active.txt). Run analyze_gaps first." } @@ -240,8 +240,8 @@ const commit_task = """ .jaiph/tmp/qa_gap_report_*.md. Production code unchanged. """ -workflow default() { - ensure git.is_clean() +export def main() { + run git.is_clean() run mkdir_tmp_jaiph_qa() run analyze_gaps() run write_tests() diff --git a/.jaiph/security_review.jh b/.jaiph/security_review.jh index fa0472a1..60996dbe 100755 --- a/.jaiph/security_review.jh +++ b/.jaiph/security_review.jh @@ -38,7 +38,7 @@ script security_review_tasks_path = `echo ".jaiph/tmp/security_review_queue_task const reviewer_role = """ You are a senior security engineer reviewing Jaiph — a workflow DSL, - TypeScript CLI/runtime, Docker sandbox, and agent-backend runner that + TypeScript CLI/runtime, and agent-backend runner that executes tools and scripts on behalf of users. Methodology: follow OWASP Agentic Security Initiative (ASI) Top 10 as @@ -50,14 +50,13 @@ const reviewer_role = """ Jaiph attack surface to prioritize: - Prompt / agent backends (injection into tool/shell execution) - Script and shell step execution (command injection, unsafe spawn) - - Docker sandbox (mount allowlist, env allowlist, caps, isolation escape) + - Host execution of workflows (no jaiph-managed sandbox; outer wrap is the operator's) - Secrets and credentials in env, logs, artifacts, run summaries - - Privilege / --unsafe / permission-mode bypass paths - Supply chain of binaries, installers, skills, and libraries - Auditability of runs (events, artifacts, tamper resistance) Severity scale: - - HIGH: directly exploitable; leads to RCE, sandbox escape, secret + - HIGH: directly exploitable; leads to RCE, host compromise, secret exfiltration, or auth/policy bypass. - MEDIUM: exploitable under specific conditions, significant impact. - LOW: defense-in-depth gaps or low-impact weaknesses. @@ -90,7 +89,7 @@ script worktree_fingerprint = `git status --porcelain | sort | cksum` script report_file_nonempty = `test -s "$1"` -workflow review_scope(mode, scope_detail, report_file) { +def review_scope(mode, scope_detail, report_file) { const result = prompt """ First read and follow the OWASP ASI compliance skill at .jaiph/skills/agent-owasp-compliance/SKILL.md — use its ASI-01..ASI-10 @@ -148,17 +147,17 @@ workflow review_scope(mode, scope_detail, report_file) { return result.verdict } -workflow review_codebase(report_file) { +def review_codebase(report_file) { const scope_detail = """ Review the ENTIRE repository against OWASP ASI Top 10. Explore agent - backends, prompt paths, script/shell execution, Docker sandbox, + backends, prompt paths, script/shell execution, host execution, env/secrets handling, artifacts/run logs, installers, and skills. Do not limit yourself to a diff — this is a full codebase scan. """ return run review_scope("codebase", scope_detail, report_file) } -workflow review_diff_text(mode, scope_label, diff_text, report_file) { +def review_diff_text(mode, scope_label, diff_text, report_file) { if diff_text == "" { log "Security review: no changes to review (${scope_label})." return "skip" @@ -175,7 +174,7 @@ workflow review_diff_text(mode, scope_label, diff_text, report_file) { return run review_scope(mode, scope_detail, report_file) } -workflow finish_report(verdict, report_file, fingerprint_before) { +def finish_report(verdict, report_file, fingerprint_before) { if verdict == "skip" { log "Security review skipped (nothing in scope)." return "" @@ -196,7 +195,7 @@ workflow finish_report(verdict, report_file, fingerprint_before) { script truncate_file = `: > "$1"` -workflow queue_findings(report_file) { +def queue_findings(report_file) { const tasks_file = run security_review_tasks_path() # Default to empty so a no-finding pass is a clean no-op for add_from_file. run truncate_file(tasks_file) @@ -252,7 +251,7 @@ const commit_task = """ from the latest .jaiph/tmp/security_review_*.md report. """ -workflow dispatch_review(mode, scope, report_file) { +def dispatch_review(mode, scope, report_file) { if mode == "codebase" { return run review_codebase(report_file) } else if mode == "diff" { @@ -264,8 +263,8 @@ workflow dispatch_review(mode, scope, report_file) { } } -workflow default(scope) { - ensure git.in_git_repo() +export def main(scope) { + run git.in_git_repo() const mode = match scope { "" | "codebase" | "full" => "codebase" @@ -277,7 +276,7 @@ workflow default(scope) { # commit only contains queued findings. Diff mode reviews an existing dirty # tree and updates QUEUE.md without committing. if mode != "diff" { - ensure git.branch_clean() + run git.branch_clean() } run common.mkdir_p_simple(".jaiph/tmp") @@ -295,7 +294,7 @@ workflow default(scope) { run queue_findings(report_file) if mode == "diff" { - ensure git.has_changes() catch (err) { + run git.has_changes() catch (err) { log "Security review finished (no QUEUE.md changes). Report: ${report_file}" return "" } diff --git a/.jaiph/simplifier.jh b/.jaiph/simplifier.jh index 80db3a62..e0272070 100755 --- a/.jaiph/simplifier.jh +++ b/.jaiph/simplifier.jh @@ -60,13 +60,13 @@ done < <( return $bad ``` -rule no_test_or_e2e_paths_changed() { +def no_test_or_e2e_paths_changed() { run assert_no_test_or_e2e_in_changed() } script report_file_nonempty = `test -s ".jaiph/tmp/simplifier_report.md"` -workflow find_simplifications() { +def find_simplifications() { const report = prompt """ You are a senior engineer focused on codebase simplification for the Jaiph @@ -104,14 +104,14 @@ workflow find_simplifications() { run save_simplifier_report(report) log "Simplification report saved to .jaiph/tmp/simplifier_report.md" - ensure no_test_or_e2e_paths_changed() + run no_test_or_e2e_paths_changed() } script save_simplifier_report = `printf '%s\n' "$1" > .jaiph/tmp/simplifier_report.md` script read_simplifier_report = `cat .jaiph/tmp/simplifier_report.md` -workflow apply_simplifications() { +def apply_simplifications() { run report_file_nonempty() catch (err) { fail "No report at .jaiph/tmp/simplifier_report.md. Run find_simplifications first." } @@ -143,7 +143,7 @@ workflow apply_simplifications() { """ - ensure no_test_or_e2e_paths_changed() + run no_test_or_e2e_paths_changed() } script mkdir_tmp_jaiph = `mkdir -p .jaiph/tmp` @@ -153,12 +153,12 @@ const commit_task = """ .jaiph/tmp/simplifier_report.md. Preserve behavior; no test/ or e2e/ edits. """ -workflow default() { - ensure git.is_clean() +export def main() { + run git.is_clean() run mkdir_tmp_jaiph() run find_simplifications() run apply_simplifications() run ci.ensure_ci_passes() - ensure no_test_or_e2e_paths_changed() + run no_test_or_e2e_paths_changed() run git.commit_if_changes(commit_task) } diff --git a/CHANGELOG.md b/CHANGELOG.md index 63b2df67..f163ac35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,33 +2,35 @@ ## Summary +- **Language: `def`, `main`, private-by-default.** One interpreted callable (`def`); `jaiph run` requires `export def main` in the input file (optional in libraries); names are private across `import` unless listed in `export`; `rule`, `ensure`, `workflow`, and `default` are removed. The public noun is `def` / `run`, not workflow. + +- **Removed: the first-party Docker sandbox.** `jaiph run`, `jaiph mcp`, and `jaiph serve` execute on the host. The `--unsafe` / `--inplace` / `--yes` consent flags, the `JAIPH_UNSAFE` / `JAIPH_INPLACE*` / `JAIPH_DOCKER_*` env vars, and in-file `runtime.docker_*` keys are gone (`runtime.docker_*` is now an unknown-key error). Isolation is an outer concern — wrap `jaiph` in your own container, pod, or CI runner ([Deploy jaiph](docs/deploy.md)). Secret isolation is unchanged: the `prompt` env scrub, journal redaction, `--env`, and `trusted_envs` all stay. + ## All changes +- **Breaking — Language:** `send -> channel`. `channel <- payload` is `E_PARSE`. Route targets declare 1 to 3 parameters (message, channel, sender). + +- **Fix — Docs / E2E:** Landing-page and `110_examples.sh` trees for `agent_inbox.jh` match the 1-parameter handlers (`msg=` only; no extra `log` lines). + +- **Breaking — Language:** `def name(params) { … }` is the only interpreted callable. `run` is the only call verb (`${run …}`, `return run …`, match-arm `run`); `recover` is legal on every `run`. `export` is required for cross-module names — zero exports means nothing is public. `main` is reserved as the run entry: if present it must be `export def main`. `jaiph run` fails before spawn when the input file has no `export def main`. MCP/serve expose exported defs only and skip `main` unless it is the sole export (then named after the file basename). Removed keywords `workflow`, `rule`, and `ensure` are hard parse errors that name the replacement (`def` / `run`). Mocks are `mock def`. AST uses `Def` / `mod.defs`; step events and artifacts use kind `def` (`def__.out`). + +- **Breaking — Run contracts:** journal events `RUN_START` / `RUN_END` (was `WORKFLOW_*`) with field `def`; hooks `run_start` / `run_end` with payload `run_id`; HTTP `/v1/defs` and `{ defs: [...] }` / run object field `def`; telemetry tag `jaiph.def` and root span `run `; MCP tool spec field `def`; operator-log mirror opt-in is `JAIPH_SERVER_LOG_RUNS` (was `JAIPH_SERVER_LOG_WORKFLOW`). + +- **Removed — first-party Docker sandbox:** no Docker driver, no digest-pinned runtime image, no snapshot/inplace modes, no `--unsafe` / `--inplace` / `--yes` sandbox consent, no `JAIPH_UNSAFE` / `JAIPH_INPLACE*` / `JAIPH_DOCKER_*`, no in-file `runtime.docker_*`. `prompt` env scrub, journal redaction, `--env`, and `trusted_envs` stay. + +- **Docs — `def` / `run` teaching and `/tutorials/first-run`:** the current docs (landing, README, CLI, MCP, serve, grammar, tutorials, [Write & run tests](docs/testing.md)) match the `def` / `run` contracts. The first tutorial permalink is `/tutorials/first-run` (`docs/first-run.md`, title **Your first run**); `/tutorials/first-workflow`, `/getting-started`, and `/getting-started.md` redirect to it. How-to titles are **Serve defs as MCP tools** and **Serve defs over HTTP**. Live trees document `def main` / `PASS def main`. MCP `serverInfo.title` is `"Jaiph"`. Test authoring uses `mock def` / `run ()`; `mock workflow` / `mock rule` are parse errors. The docs highlighter keywords match the live language (`def`, not `local` / `rule` / `workflow` / `ensure`). VS Code no longer paints `inbox` as a send keyword. + # 0.13.0 ## Summary -- **Shell steps no longer splice untrusted values into `sh -c`:** every value interpolated into a workflow shell step is shell-quoted first, so a workflow parameter, capture, `for` iterator, or channel payload that contains shell metacharacters is passed to the shell as data and cannot inject a command, including when the value is bound through `jaiph mcp` or `jaiph serve`. -- **`jaiph serve` and `jaiph mcp` no longer go host-only from an inherited `JAIPH_UNSAFE=true`:** the long-lived servers now require explicit consent for unsafe host-only execution on their own command line, so `--unsafe` (or `--yes`) must be passed to run every call on the host with no sandbox. An ambient `JAIPH_UNSAFE=true` inherited from the environment, for example a value left in a shell profile by an earlier host-only `jaiph run`, is refused at startup with `E_UNSAFE_NO_CONSENT` instead of silently disabling the sandbox. When consent is given, the server prints a loud multi-line startup banner that states sandboxing is disabled and every call runs on the host with full filesystem and credential access. Inside a container or Kubernetes pod the container is the sandbox, so the refusal is skipped and a factory VPS / standalone runtime-image deploy still runs host-only without `--unsafe` on the command line. -- **The `jaiph serve` operator token no longer crosses into workflow sandboxes:** the environment-forwarding allowlist now excludes the whole host-only `JAIPH_SERVE_*` family, so `JAIPH_SERVE_TOKEN` and the OIDC and server-config keys stay on the host instead of being forwarded into every Docker container and agent subprocess the server runs. -- **The run audit journal is now tamper-resistant and verified when it is read:** each `run_summary.jsonl` line is chained with a keyed HMAC under a per-run secret that never reaches the workflow's own script or agent subprocesses, so a workflow that rewrites, truncates, or deletes its journal can no longer forge a chain that verifies. The per-run key is now stored outside the workflow-writable run directory, in an operator-side store that no sandbox mount reaches, so a workflow can no longer squat the key path or delete its own tamper evidence, and a keyed run whose key later goes missing fails closed instead of silently passing. A completed journal must also end with a `WORKFLOW_END` marker, so deleting the last lines of a finished journal is rejected instead of leaving a shorter chain that still links and verifies. Run listing, the `GET /v1/runs/{id}/events` snapshot, and OTLP and Sentry export now verify the chain and reject a tampered journal instead of trusting it. -- **`jaiph install` and the library registry now verify integrity instead of trusting-on-first-use:** a remotely fetched registry index is signature-verified against a detached `.minisig` (minisign, `jaiph.pub` embedded as the trust anchor) and rejected when missing, unsigned, or tampered; remote registry and library URLs must use `https://`/`ssh://` (a `http://` or other disallowed scheme is refused before any fetch or clone); every shipped registry entry must now pin a `commit` that the cloned HEAD must match on the first install, `npm run registry:build` refuses to write an index with an unpinned entry, and `jaiph install` refuses a registry name whose entry has no pinned commit unless you pass `--allow-unpinned`; and an optional per-library detached signature is verified fail-closed. -- **A workflow file can no longer weaken the Docker sandbox it runs in:** the entry file's `runtime.docker_image` and any isolation-breaking `runtime.docker_network` value (`host`, `container:*`, `ns:*`) are now host-controlled. When Docker is the active sandbox, a file-declared image is rejected (`E_DOCKER_IMAGE_HOST_ONLY`) and a file-declared `host` / `container:*` / `ns:*` network is rejected (`E_DOCKER_NETWORK_HOST_ONLY`), so a repo- or model-supplied workflow can no longer point the sandbox at an arbitrary image or join the host network namespace while still appearing sandboxed. Host-safe in-file network values (`default`, `none`, a named bridge network) are still honoured, and only the operator's `JAIPH_DOCKER_IMAGE` / `JAIPH_DOCKER_NETWORK` can select an image or an isolation-breaking network. -- **The image presence check no longer runs unhardened image code:** the check that confirms a Docker image contains `jaiph` before a run now uses the same sandbox hardening as the run itself (every capability dropped, no new privileges, a non-root user, and no network) and a non-login shell, so it can no longer source or execute startup and profile scripts baked into a workflow-selected image at a higher privilege than the run. -- **The default sandbox image is now pinned by digest and verified on every run:** the official `ghcr.io/jaiphlang/jaiph-runtime` image ships an expected manifest digest with each release, and every Docker-backed `jaiph run` resolves and checks the local image's digest against it, including on a cache hit, so a re-pointed tag or a poisoned local image cache under the same tag can no longer swap the sandbox rootfs while the run still looks sandboxed. A mismatch fails closed with `E_DOCKER_DIGEST_MISMATCH` and a message that tells you how to re-pull the pinned image, and `JAIPH_DOCKER_IMAGE_DIGEST` lets you pin or override the digest for any image. -- **A workflow file can no longer pull arbitrary host secrets into the Docker sandbox by declaring them:** the entry file's `trusted_envs` keys cross the sandbox allowlist only when the operator opts in with `JAIPH_TRUSTED_ENVS=1`. Absent the opt-in, a file-declared `trusted_envs` is ignored under Docker with a pre-flight warning, so an untrusted or model-edited entry naming `AWS_SECRET_ACCESS_KEY` or `GITHUB_TOKEN` cannot forward that host secret across the allowlist on its own. Host modes have no allowlist to bypass, so they honour the declaration as before, and authoring the entry file is now a trust boundary equal to `--env`. -- **A `sub`-less OIDC token no longer collapses onto one shared identity:** the OIDC principal is the token `sub`, falling back to `client_id` for machine tokens (OAuth2 client-credentials) that omit `sub`, and a verified token carrying neither claim is rejected with `401` instead of authenticating as a shared `unknown` principal. Two machine callers on the same issuer can no longer share one run-visibility bucket or idempotency namespace, so neither can list, read, or cancel the other's runs. -- **Project-local `.jaiph/hooks.json` no longer runs on the host without a workspace-trust decision:** hook commands run in the host CLI process, before and outside any Docker sandbox, so a `/.jaiph/hooks.json` that arrives with a cloned or untrusted repository is now gated behind the operator opt-in `JAIPH_TRUST_PROJECT_HOOKS=1`. Absent the opt-in, `jaiph run`, `jaiph serve`, and `jaiph mcp` ignore the project file with a one-line stderr notice, so a cloned repo cannot execute arbitrary host commands on `workflow_start`. The global `~/.jaiph/hooks.json` is the operator's own and always runs. -- **Release install and the runtime image now verify every download instead of failing open:** the binary installer requires a valid minisign signature, so on a normal host a missing `minisign` aborts the install rather than degrading to checksum-only, an empty `JAIPH_MINISIGN_PUBLIC_KEY` fails closed, and only `JAIPH_ALLOW_UNSIGNED=1` proceeds on checksum alone (finding M-5 removed the earlier `CI` opt-out, so CI installs must make `minisign` available). The `jaiph run`, `jaiph init`, and `jaiph use` bootstraps fetch `docs/install` and its published `install.sha256`, verify the two match, and refuse to run a tampered script instead of piping `curl … | bash`. Every toolchain fetch in `runtime/Dockerfile` now goes through `runtime/fetch-verify.sh` with a required, pinned SHA-256, so a poisoned toolchain CDN fails the build. - -- **CI installs and the `setup-jaiph` action now require a verified release signature:** the installer no longer downgrades to a checksum-only install when `CI` is set, so a missing `minisign` aborts the install on every host, and only an explicit `JAIPH_ALLOW_UNSIGNED=1` proceeds on checksum alone with a prominent warning. The `setup-jaiph` GitHub Actions action installs `minisign` on the runner so the action path always verifies the signature, and the release build now fails when the signing key is unset instead of publishing unsigned artifacts. -- **Credential redaction now covers many more secret names and their encoded forms:** the run journal and every surface that reads it back (`GET /v1/runs/{id}/events`, the OTLP export, the Sentry export, and a failed call's returned `result_text`) redact the value of any env var whose name looks like a credential, which now includes names the earlier four-suffix rule missed such as `AWS_SECRET_ACCESS_KEY`, `STRIPE_SECRET_KEY`, `DB_PASSWORD`, `PASSPHRASE`, and `SSH_PRIVATE_KEY`, and each value is redacted in its base64, hex, and URL-encoded forms as well as its raw form. Redaction still works by literal-substring replacement, so a secret transformed some other way, such as split across output chunks or embedded inside an opaque connection string, is not guaranteed to be caught, and the raw per-step capture files stay sensitive. -- **`jaiph serve` now serves a self-contained Swagger UI:** `/docs` embeds the pinned `swagger-ui-dist` assets in the jaiph binary and serves them from same-origin `/docs/*` paths, so the built-in API UI renders and can invoke workflows with no browser internet access, including on an air-gapped network or behind a Content-Security-Policy that blocks third-party hosts. `JAIPH_SERVE_EXPOSE_DOCS=false` still returns `404` for `/docs`, `/openapi.json`, and the embedded assets. -- **OIDC token verification now pins an explicit signing-algorithm allowlist:** `jaiph serve` accepts an OIDC bearer JWT only when its header names one of the pinned asymmetric algorithms — the RSA (`RS*` / `PS*`), ECDSA (`ES256` / `ES384` / `ES512`), and EdDSA families that standard OIDC providers sign with — and rejects symmetric algorithms (`HS*`), `alg: none`, and the non-recommended secp256k1 curve (`ES256K`) even when the signing key is present in the JWKS. Pinning the allowlist means a future key-type or JWKS change can never make an algorithm-confusion or `alg: none` forgery reachable, though `jose` already rejected those cases today. -- **A host run can now be bounded by a wall-clock timeout and a max-step circuit breaker:** `JAIPH_RUN_TIMEOUT` (seconds) gives a host-mode run — a `jaiph run --unsafe` or host-only run, and the host spawn a `jaiph serve` or `jaiph mcp` call uses — a parent-enforced wall-clock cap that terminates the run's whole process group (`SIGTERM`, then `SIGKILL`) once the budget is reached, so it stops without a manual Ctrl-C where before Ctrl-C was the only automatic stop. Docker mode keeps using `JAIPH_DOCKER_TIMEOUT`. `JAIPH_MAX_STEPS` adds an optional circuit breaker in the runtime that counts every executed step across the whole run, including loop iterations and nested or recursive calls, and aborts a runaway workflow once the count exceeds the cap. Both are off by default. -- **A leaf script step that goes silent is now killed after an idle-output timeout:** when a script step's subprocess produces no stdout or stderr for `JAIPH_STEP_IDLE_KILL_SEC` (default 3600 seconds, one hour; `0` disables), the runtime records a `LOGERR` naming the step and how long it was silent, terminates the step's subprocess (SIGTERM, then SIGKILL), and fails the step, so an overnight run can no longer hang for hours on a stuck command that stopped producing output. Any new output resets the timer, the periodic idle warnings on `JAIPH_STEP_IDLE_WARN_SEC` are unchanged and run on their own independent cadence, and prompt steps still get warnings only. -- **Local-source builds now enforce the lockfile with `npm ci` and exact-pin the one runtime dependency:** the from-source installer (`docs/install`) runs `npm ci` when the checkout has a `package-lock.json`, so a clean-room install uses exactly the versions the lockfile pins instead of letting `npm install` re-resolve caret ranges, and it falls back to `npm install` only when no lockfile is present. The single runtime dependency `jose` is now exact-pinned to `5.10.0` in `package.json` (no `^` caret range); the dev dependencies keep their caret ranges. -- **The runtime sandbox image now pins its base images by digest and its global npm installs by exact version:** every `FROM` in `runtime/Dockerfile` references its base image by an `@sha256:` digest instead of a mutable tag, and the global `npm install -g` of pnpm, yarn, and the Claude Code CLI each pins an exact version through a build ARG, so the built image is reproducible and its registry-sourced layers are attested the same way the direct toolchain downloads already are. A CI check rejects any later edit that reintroduces a bare `FROM` tag or an unpinned global install. +- **Hardened install and supply chain:** release installs and the runtime image fail closed on missing signatures; registry entries are signature-verified and commit-pinned; the sandbox image is digest-pinned and checked on every run. +- **Workflows cannot weaken the sandbox:** a `.jh` file can no longer pick an arbitrary Docker image, join the host network, or pull host secrets via `trusted_envs` without an operator opt-in (`JAIPH_TRUSTED_ENVS=1`). +- **Secrets stay out of agents and logs:** shell steps quote interpolated values; broader credential redaction covers more secret names and encodings; `JAIPH_SERVE_*` tokens never enter workflow sandboxes; project-local hooks need `JAIPH_TRUST_PROJECT_HOOKS=1`. +- **Tamper-evident run journals:** `run_summary.jsonl` is HMAC-chained and verified on read; truncated or rewritten journals fail closed. +- **Safer long-lived servers:** bare-metal `jaiph serve` / `jaiph mcp` refuse ambient `JAIPH_UNSAFE` without `--unsafe` (container/k8s factory deploys unchanged); OIDC principals without `sub` no longer collide; idle script steps and optional host run timeouts stop runaway work. +- **Self-contained API docs:** `jaiph serve /docs` ships Swagger UI in-process — no CDN required. + ## All changes diff --git a/README.md b/README.md index 97e6012f..e6baa8c7 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # ![Jaiph](docs/logo.png) -[jaiph.org](https://jaiph.org) · [Your first workflow](docs/first-workflow.md) · [Your first agent + sandboxed run](docs/first-agent-run.md) · [Install & switch versions](docs/setup.md) · [Agent Skill](https://raw.githubusercontent.com/jaiphlang/jaiph/refs/heads/main/docs/jaiph-skill.md) · [Architecture](docs/architecture.md) · [CLI](docs/cli.md) · [Contributing](docs/contributing.md) +[jaiph.org](https://jaiph.org) · [Your first run](docs/first-run.md) · [Your first agent run](docs/first-agent-run.md) · [Install & switch versions](docs/setup.md) · [Agent Skill](https://raw.githubusercontent.com/jaiphlang/jaiph/refs/heads/main/docs/jaiph-skill.md) · [Architecture](docs/architecture.md) · [CLI](docs/cli.md) · [Contributing](docs/contributing.md) -> **Docs note:** The Jaiph documentation site follows the [Diátaxis](https://diataxis.fr/) framework. Tutorials: [Your first workflow](docs/first-workflow.md), [Your first agent + sandboxed run](docs/first-agent-run.md). How-to: [Install & switch versions](docs/setup.md), [Run in a Docker sandbox](docs/sandbox-run.md), [Authenticate agent backends](docs/agent-auth.md), [Configure backend & model](docs/configure-backend.md), [Add a hook](docs/hooks.md), [Use & publish a library](docs/libraries.md), [Save artifacts](docs/artifacts.md), [Write & run tests](docs/testing.md), [Serve workflows as MCP tools](docs/mcp.md), [Serve workflows over HTTP](docs/serve.md), [Export traces to an OTLP collector](docs/observability.md), [Deploy the runtime image standalone](docs/deploy.md). Reference: [CLI](docs/cli.md), [Configuration](docs/configuration.md), [Grammar](docs/grammar.md), [Language](docs/language.md), [Environment variables](docs/env-vars.md). Explanation: [Why Jaiph](docs/why-jaiph.md), [Architecture](docs/architecture.md), [Sandboxing](docs/sandboxing.md), [Inbox & Dispatch](docs/inbox.md), [Async Handles](docs/spec-async-handles.md). Contributor: [Contributing](docs/contributing.md), [Agent Skill](https://raw.githubusercontent.com/jaiphlang/jaiph/refs/heads/main/docs/jaiph-skill.md). +> **Docs note:** The Jaiph documentation site follows the [Diátaxis](https://diataxis.fr/) framework. Tutorials: [Your first run](docs/first-run.md), [Your first agent run](docs/first-agent-run.md). How-to: [Install & switch versions](docs/setup.md), [Authenticate agent backends](docs/agent-auth.md), [Configure backend & model](docs/configure-backend.md), [Add a hook](docs/hooks.md), [Use & publish a library](docs/libraries.md), [Save artifacts](docs/artifacts.md), [Write & run tests](docs/testing.md), [MCP server in 30 seconds](docs/mcp.md), [Serve defs over HTTP](docs/serve.md), [Export traces to an OTLP collector](docs/observability.md), [Deploy jaiph](docs/deploy.md). Reference: [CLI](docs/cli.md), [Configuration](docs/configuration.md), [Grammar](docs/grammar.md), [Language](docs/language.md), [Environment variables](docs/env-vars.md). Explanation: [Why Jaiph](docs/why-jaiph.md), [Architecture](docs/architecture.md), [Inbox & Dispatch](docs/inbox.md), [Async Handles](docs/spec-async-handles.md). Contributor: [Contributing](docs/contributing.md), [Agent Skill](https://raw.githubusercontent.com/jaiphlang/jaiph/refs/heads/main/docs/jaiph-skill.md). --- @@ -13,43 +13,42 @@ ## What is Jaiph? -**Jaiph** is a composable scripting language and runtime for defining and orchestrating AI agent workflows. You write **`.jh`** files that combine prompts, rules, scripts, and workflows into executable pipelines. The CLI parses source into an AST, validates references at compile time, and the Node workflow runtime interprets the AST directly. +**Jaiph** is a composable scripting language and runtime for defining and orchestrating AI agent programs. You write **`.jh`** files that combine `def`, `script`, and `prompt` into executable pipelines. The CLI parses source into an AST, validates references at compile time, and the Node runtime interprets the AST directly. > [!WARNING] > Jaiph is still in an early stage. Expect breaking changes. ## Features -- **Workflows** — Compose `prompt`, `run`, `ensure`, channel sends, conditionals, `run async` with implicit join, `catch`, and repair-and-retry `recover`. -- **Rules and scripts** — Rules stay structured (no raw shell lines); **`script`** steps run bash or polyglot code as subprocesses. +- **Defs** — Compose `prompt`, `run`, channel sends, conditionals, `run async` with implicit join, `catch`, and repair-and-retry `recover`. `jaiph run` enters at `export def main`. +- **Scripts** — **`script`** steps run bash or polyglot code as subprocesses. - **Agents** — Backends include Cursor, Claude, Codex (HTTP), or a custom `agent.command`. - **Testing** — `*.test.jh` files run in-process (`jaiph test`) with mocks and `expect_*` assertions ([Write & run tests](docs/testing.md)). -- **Safety and inspectability** — Docker-backed sandbox for **`jaiph run`** (env-controlled; see [Sandboxing](docs/sandboxing.md) and [Run in a Docker sandbox](docs/sandbox-run.md)); live **`__JAIPH_EVENT__`** on stderr and durable **`.jaiph/runs/`** artifacts ([Architecture](docs/architecture.md)). +- **Safety and inspectability** — live **`__JAIPH_EVENT__`** on stderr and durable **`.jaiph/runs/`** artifacts ([Architecture](docs/architecture.md)). Isolation of the process from the rest of the machine is an outer concern: wrap `jaiph` in your own container, pod, or CI runner if you want a sandbox ([Deploy jaiph](docs/deploy.md)). - **Tooling** — `jaiph compile`, `jaiph format`, `jaiph install` / `.jaiph/libs/` ([Use & publish a library](docs/libraries.md)), and optional `hooks.json` ([CLI](docs/cli.md), [Add a hook](docs/hooks.md)). -- **MCP server** — `jaiph mcp ./tools.jh` serves a file's workflows as [MCP](https://modelcontextprotocol.io/) tools over stdio, so any MCP client (Claude Code, Cursor) can call tested Jaiph workflows as tools ([Serve workflows as MCP tools](docs/mcp.md)). -- **HTTP API** — `jaiph serve ./tools.jh` serves the same workflows over HTTP with a generated OpenAPI 3.1 document and a browser Swagger UI at `/docs`, so any HTTP client (CI, Kubernetes, another service) can invoke them and inspect runs. Production auth is either a static single-operator bearer token or OIDC/JWT with per-user identity and `invoke` / `inspect` / `cancel` scope authorization, and every run is audit-attributed to its principal and correlation id ([Serve workflows over HTTP](docs/serve.md)). -- **OpenTelemetry** — set the standard `OTEL_EXPORTER_OTLP_ENDPOINT` and each run exports one span tree (workflow → steps → prompts) to any OTLP collector — Grafana Tempo, Honeycomb, Datadog. Host-side, end-of-run, credential-redacted, zero new dependencies, never load-bearing ([Export traces to an OTLP collector](docs/observability.md)). -- **Sentry error reporting** — set the standard `SENTRY_DSN` and every failed run (nonzero exit or a signal) is pushed to Sentry as one error event — workflow, failing step, a redacted output excerpt, and a run-dir pointer — so operators get alerting and grouping without scraping run dirs. Host-side, redacted, zero new dependencies, never load-bearing; successful runs send nothing ([Report failed runs to Sentry](docs/observability.md#report-failed-runs-to-sentry)). -- **Standalone deployment** — the published `ghcr.io/jaiphlang/jaiph-runtime` image bakes `JAIPH_UNSAFE=true`, so `docker run … jaiph run flow.jh` (or a Kubernetes pod) runs workflows directly — put credentials plus `.jh` files and go, no host jaiph process and no Docker daemon inside the container. Here the container/pod boundary *is* the sandbox — there is no jaiph-managed isolation ([Deploy the runtime image standalone](docs/deploy.md)). +- **MCP server** — `jaiph mcp ./tools.jh` serves a file's exported defs as [MCP](https://modelcontextprotocol.io/) tools over stdio, so any MCP client (Claude Code, Cursor) can call tested Jaiph defs as tools ([MCP server in 30 seconds](docs/mcp.md)). +- **HTTP API** — `jaiph serve ./tools.jh` serves the same defs over HTTP with a generated OpenAPI 3.1 document and a browser Swagger UI at `/docs`, so any HTTP client (CI, Kubernetes, another service) can invoke them and inspect runs. Production auth is either a static single-operator bearer token or OIDC/JWT with per-user identity and `invoke` / `inspect` / `cancel` scope authorization, and every run is audit-attributed to its principal and correlation id ([Serve defs over HTTP](docs/serve.md)). +- **OpenTelemetry** — set the standard `OTEL_EXPORTER_OTLP_ENDPOINT` and each run exports one span tree (run → steps → prompts) to any OTLP collector — Grafana Tempo, Honeycomb, Datadog. Host-side, end-of-run, credential-redacted, zero new dependencies, never load-bearing ([Export traces to an OTLP collector](docs/observability.md)). +- **Sentry error reporting** — set the standard `SENTRY_DSN` and every failed run (nonzero exit or a signal) is pushed to Sentry as one error event — def, failing step, a redacted output excerpt, and a run-dir pointer — so operators get alerting and grouping without scraping run dirs. Host-side, redacted, zero new dependencies, never load-bearing; successful runs send nothing ([Report failed runs to Sentry](docs/observability.md#report-failed-runs-to-sentry)). ## Core components -- **CLI** (`src/cli`) — `jaiph run` / `test` / `compile` / `format` / `init` / `install` / `use` / `mcp` / `serve`; prepares scripts, spawns the workflow runner (or in-process test runner), parses `__JAIPH_EVENT__` on stderr, runs hooks on `jaiph run` only. +- **CLI** (`src/cli`) — `jaiph run` / `test` / `compile` / `format` / `init` / `install` / `use` / `mcp` / `serve`; prepares scripts, spawns the def runner(or in-process test runner), parses `__JAIPH_EVENT__` on stderr, runs hooks on `jaiph run` only. - **Parser** (`src/parser.ts`, `src/parse/*`) — `.jh` / `.test.jh` → AST. - **Validator** (`src/transpile/validate.ts`) — imports and symbol references at compile time. -- **Transpiler** (`src/transpile/*`) — emits atomic `script` files under `scripts/` only (no workflow-level shell). -- **Node workflow runtime** (`src/runtime/kernel/node-workflow-runtime.ts`, `graph.ts`) — interprets the AST; `buildRuntimeGraph(graph)` consumes the `ModuleGraph` produced by `loadModuleGraph` (no filesystem reads). +- **Transpiler** (`src/transpile/*`) — emits atomic `script` files under `scripts/` only (no def-level shell). +- **Node runtime** (`src/runtime/kernel/node-workflow-runtime.ts`, `graph.ts`) — interprets the AST; `buildRuntimeGraph(graph)` consumes the `ModuleGraph` produced by `loadModuleGraph` (no filesystem reads). - **Node test runner** (`src/runtime/kernel/node-test-runner.ts`) — `*.test.jh` blocks with mocks. - **JS kernel** (`src/runtime/kernel/`) — prompts, managed scripts, `__JAIPH_EVENT__`, inbox, mocks. Diagrams, runtime contracts, on-disk artifact layout, and distribution: **[Architecture](docs/architecture.md)**. Test layers and E2E policy: **[Contributing](docs/contributing.md)**. ## Quick try -Run a sample workflow without installing anything first: +Run a sample program without installing anything first: ```bash curl -fsSL https://jaiph.org/run | bash -s ' -workflow default() { +export def main() { const response = prompt "Say: Hello I'\''m [model name]!" log response }' @@ -88,16 +87,16 @@ Verify: `jaiph --version`. Switch versions: `jaiph use nightly` or `jaiph use 0. Releases ship a `SHA256SUMS` file plus a detached [minisign](https://jedisct1.github.io/minisign/) signature (`SHA256SUMS.minisig`). The installer verifies the checksum and requires a valid signature. A missing `minisign` aborts the install on every host, including CI, rather than degrading to checksum-only. The `setup-jaiph` action installs `minisign` on the runner so CI installs stay signed. For a deliberate checksum-only install, set `JAIPH_ALLOW_UNSIGNED=1`. See [Verify the release signature](docs/setup.md#verify-the-release-signature). -Initialize a project (optional): `jaiph init` writes `.jaiph/` with bootstrap workflow, gitignore entries for runs/tmp, and **`SKILL.md`**. The CLI resolves the skill body in this order — `JAIPH_SKILL_PATH`, install-relative `jaiph-skill.md`, `docs/jaiph-skill.md` under cwd, then an **embedded copy baked into the binary** as the final fallback — so `jaiph init` always writes `SKILL.md` (see [Install & switch versions](docs/setup.md)). Canonical skill text for agents: `https://raw.githubusercontent.com/jaiphlang/jaiph/refs/heads/main/docs/jaiph-skill.md`. +Initialize a project (optional): `jaiph init` writes `.jaiph/` with a bootstrap file, gitignore entries for runs/tmp, and **`SKILL.md`**. The CLI resolves the skill body in this order — `JAIPH_SKILL_PATH`, install-relative `jaiph-skill.md`, `docs/jaiph-skill.md` under cwd, then an **embedded copy baked into the binary** as the final fallback — so `jaiph init` always writes `SKILL.md` (see [Install & switch versions](docs/setup.md)). Canonical skill text for agents: `https://raw.githubusercontent.com/jaiphlang/jaiph/refs/heads/main/docs/jaiph-skill.md`. ## Usage -- Run the default workflow: `jaiph run path/to/main.jh [args...]` or `./main.jh [args...]` with a `#!/usr/bin/env jaiph` shebang. +- Run `export def main`: `jaiph run path/to/main.jh [args...]` or `./main.jh [args...]` with a `#!/usr/bin/env jaiph` shebang. - Run tests: `jaiph test` (workspace), `jaiph test ./dir`, or `jaiph test path.test.jh`. - Validate without executing: `jaiph compile …` (runs the same compile-time validation as `jaiph run`, but collects every error at once instead of stopping at the first; no `scripts/` emission — see [Architecture](docs/architecture.md)). - Format sources: `jaiph format …` / `jaiph format --check …`. -Full flags and environment variables: [CLI](docs/cli.md), [Environment variables](docs/env-vars.md). New here? Start with [Your first workflow](docs/first-workflow.md). +Full flags and environment variables: [CLI](docs/cli.md), [Environment variables](docs/env-vars.md). New here? Start with [Your first run](docs/first-run.md). ## Example @@ -106,14 +105,14 @@ Full flags and environment variables: [CLI](docs/cli.md), [Environment variables script check_deps = `test -f "package.json"` -rule deps_exist() { +def deps_exist() { run check_deps() catch (err) { fail "Missing package.json" } } -workflow default(task) { - ensure deps_exist() +export def main(task) { + run deps_exist() const ts = run `date +%s`() prompt "Build the application: ${task}" } @@ -123,12 +122,12 @@ workflow default(task) { ./main.jh "add user authentication" ``` -For the full language reference, see [Grammar](docs/grammar.md) and [Language](docs/language.md). For install, libraries, sandboxing, hooks, testing, and artifacts, see the How-to quadrant: [Install & switch versions](docs/setup.md), [Use & publish a library](docs/libraries.md), [Run in a Docker sandbox](docs/sandbox-run.md), [Add a hook](docs/hooks.md), [Write & run tests](docs/testing.md), [Save artifacts](docs/artifacts.md). New to Jaiph? Start with the tutorials: [Your first workflow](docs/first-workflow.md) and [Your first agent + sandboxed run](docs/first-agent-run.md). Or visit [jaiph.org](https://jaiph.org). +For the full language reference, see [Grammar](docs/grammar.md) and [Language](docs/language.md). For install, libraries, hooks, testing, and artifacts, see the How-to quadrant: [Install & switch versions](docs/setup.md), [Use & publish a library](docs/libraries.md), [Add a hook](docs/hooks.md), [Write & run tests](docs/testing.md), [Save artifacts](docs/artifacts.md). New to Jaiph? Start with the tutorials: [Your first run](docs/first-run.md) and [Your first agent run](docs/first-agent-run.md). Or visit [jaiph.org](https://jaiph.org). ## Start here -- **AI agent** who wants to work in a predictable, structured way? Read the [Agent Skill](https://raw.githubusercontent.com/jaiphlang/jaiph/refs/heads/main/docs/jaiph-skill.md) — it teaches you how to author Jaiph workflows and makes your behavior verifiable and auditable. -- **Human** who manages agents and wants reliable, repeatable automation? See the [Samples](https://github.com/jaiphlang/jaiph/tree/main/examples) and [Your first workflow](docs/first-workflow.md). +- **AI agent** who wants to work in a predictable, structured way? Read the [Agent Skill](https://raw.githubusercontent.com/jaiphlang/jaiph/refs/heads/main/docs/jaiph-skill.md) — it teaches you how to author Jaiph programs and makes your behavior verifiable and auditable. +- **Human** who manages agents and wants reliable, repeatable automation? See the [Samples](https://github.com/jaiphlang/jaiph/tree/main/examples) and [Your first run](docs/first-run.md). - **Contributor** who wants to improve Jaiph itself? See [Contributing](docs/contributing.md). ## Contributing diff --git a/design/0001-jaiph-is-the-language.md b/design/0001-jaiph-is-the-language.md new file mode 100644 index 00000000..33a1c6f7 --- /dev/null +++ b/design/0001-jaiph-is-the-language.md @@ -0,0 +1,51 @@ +# ADR 0001 — Jaiph is the language, not a sandbox product + +*Status: accepted* +*Date (UTC): 2026-08-25* + +## Decision + +Jaiph is a small workflow language and orchestrator: `def`, `script`, `prompt`, compile, run, test, format, durable artifacts. + +Jaiph is not a sandbox product. It does not own a container runtime, a kernel policy engine, or a toolchain image. + +`jaiph run`, `jaiph mcp`, and `jaiph serve` execute on the host. There is no sandbox mode, no unsafe flag, and no sandbox environment variables. Isolation of the process from the rest of the machine is an **outer** concern (Docker, nono, k8s, CI, Codespaces). The operator wraps `jaiph`, or the environment already is the sandbox. + +## Why + +`docs/sandboxing.md` already said the sandbox is a deployment choice, not a programming model. The code ignored that: default-on Docker, a digest-pinned image, snapshot/inplace modes, interrupt teardown, and a test corpus that dwarfs several language features. + +That work has no language semantics. It made the repo hard to maintain and split the product vision: every `run` / `mcp` / `serve` change also had to be a sandbox change. + +The k8s/standalone path already set `JAIPH_UNSAFE=true` because the outer container is the real sandbox. `jaiph test` never used Docker. Native Windows never got a sandbox. The language already ran without one. + +A leftover “unsafe” flag or `JAIPH_DOCKER_*` family would keep the sandbox product alive as a ghost. Cut it. + +## What stays in Jaiph (invariants) + +These are orchestration, not kernel: + +- `prompt` subprocess env is fail-closed. Injected secrets (`--env`, `trusted_envs`) reach trusted `run` steps only. +- Run journal redaction. +- Compiler: `W_PROMPT_IN_SHELL`, a `.jh` file cannot disable host secret policy by itself. + +## What is out + +Removed, not deprecated: + +- Docker driver, runtime image, digest pin, snapshot/inplace, confirmation prompts +- `--unsafe`, `--inplace`, `--yes` as sandbox consent, `JAIPH_UNSAFE`, `JAIPH_INPLACE*`, `JAIPH_DOCKER_*`, in-file `runtime.docker_*` +- Agent credential proxy, hostname allowlists, nono/Landlock/Seatbelt adapters +- Any feature whose primary purpose is process isolation + +## Product filter + +A change lands only if it makes `.jh` files easier to compile, run, test, or understand. + +`jaiph mcp` and `jaiph serve` are adapters around `run`. Bugfixes only unless a later ADR says otherwise. Registry, install, and deploy stay as they are; they are not the vision. + +## Consequences + +- `why-jaiph.md` commitment “Sandbox by default” is deleted. +- Host execution is the language runtime. Document how to wrap with an outer sandbox if the operator wants one. +- Revisit a first-party driver only if operators will not wrap *and* bare-host `prompt` is an actual incident pattern. Speculation is not enough. diff --git a/design/0002-def-main-export.md b/design/0002-def-main-export.md new file mode 100644 index 00000000..8327146a --- /dev/null +++ b/design/0002-def-main-export.md @@ -0,0 +1,39 @@ +# ADR 0002 — `def`, `main`, private-by-default + +*Status: accepted* +*Date (UTC): 2026-08-25* + +## Decision + +Jaiph has one interpreted callable: `def`. The CLI entry is `export def main`. Names are private unless marked `export`. There is no `rule`, `ensure`, `workflow`, or `default`. + +## Language + +- `def name(params) { … }` is the interpreted body (prompts, sends, `run async`, other defs, scripts, `recover`). `script` / `prompt` / `channel` stay. +- One call verb: `run`, `${run …}`, `return run …`, match-arm `run`. `recover` is legal on every `run`. +- Same-file: all names. Across `import`: only names in the module's export list. Zero exports means nothing is public. +- `main` is optional. A library module has no `main`. `jaiph compile` succeeds without it. `jaiph run` requires `export def main` in the input file. +- If a symbol named `main` exists, it must be `export def main` (not a script, not unexported). +- `jaiph mcp` / `jaiph serve` expose exported defs only. Skip `main` unless it is the sole export; then expose it under the file basename. +- `mock def ref() { … }`. No `mock rule`. + +## Why + +`rule` was a second callable plus a second call verb. Its “purity” was a compiler costume: a rule could still `run` a mutating script. `ensure` existed only to target that kind. + +`workflow` named a product. `def` names a procedure. `default` did not mean entrypoint. `main` does. + +Zero-export-means-public inverted `export`: adding the first `export` silently hid everything else. Private by default is the boundary. + +`main` is three surfaces, not one: CLI entry, import/test API, MCP tool list. Exporting `main` for `jaiph run` and tests must not auto-publish it as an MCP tool next to other exports. + +## Consequences + +- Hard break. No aliases. Removed keywords parse as errors that name the replacement. +- Internal AST is `Def` / `mod.defs`. Source keyword is `def`. +- Journal: `RUN_START` / `RUN_END` with field `def`. Nested defs emit `STEP_START` kind `def`. +- Hooks: `run_start` / `run_end`; payload `run_id`. +- HTTP: `/v1/defs`, `{ defs: [...] }`, run object field `def`. +- Telemetry: `jaiph.def`; root span `run `. +- MCP: `McpToolSpec.def`. +- `Expr.ensure_call`, `RuleDef`, and rule-scope validation are gone. diff --git a/design/2026-05-12-agent-proxy.md b/design/2026-05-12-agent-proxy.md index a5d428e7..775bb62b 100644 --- a/design/2026-05-12-agent-proxy.md +++ b/design/2026-05-12-agent-proxy.md @@ -2,7 +2,7 @@ *Phantom Token credential proxy for the jaiph Docker sandbox. Container holds only a placeholder; real credentials live on the host and never cross the sandbox boundary.* -**Status:** design — ready for implementation +**Status:** rejected — see [ADR 0001](0001-jaiph-is-the-language.md). Jaiph does not own a sandbox; there is no credential proxy. **Date (UTC):** 2026-05-12 ## Problem diff --git a/design/2026-07-14-mcp-server.md b/design/2026-07-14-mcp-server.md index 2512ebb3..c672058d 100644 --- a/design/2026-07-14-mcp-server.md +++ b/design/2026-07-14-mcp-server.md @@ -2,7 +2,7 @@ *`jaiph mcp ` serves the file's workflows as MCP tools over stdio. Any MCP client (Claude Code, Claude Desktop, Cursor) can call tested, deterministic Jaiph workflows as tools — a `.jh` file becomes an MCP server with zero boilerplate.* -**Status:** design — ready for implementation (an MVP was spiked and verified end-to-end; this doc records the verified contracts) +**Status:** shipped. Sandbox posture in this doc is superseded by [ADR 0001](0001-jaiph-is-the-language.md) (host-only; no Docker driver). **Date (UTC):** 2026-07-14 ## Problem diff --git a/design/2026-07-23-serve-http-api.md b/design/2026-07-23-serve-http-api.md index c94b2bc8..113d9459 100644 --- a/design/2026-07-23-serve-http-api.md +++ b/design/2026-07-23-serve-http-api.md @@ -2,7 +2,7 @@ *`jaiph serve ` serves the file's workflows as an HTTP API with a generated OpenAPI 3.1 document and an embedded Swagger UI. Anything that speaks HTTP — a CI job, a Kubernetes deployment, another service, a human with a browser — can invoke tested workflows and inspect their runs, without an MCP client or a local jaiph install.* -**Status:** design — ready for implementation (tasks queued in QUEUE.md) +**Status:** shipped. Sandbox posture in this doc is superseded by [ADR 0001](0001-jaiph-is-the-language.md) (host-only; no Docker driver). **Date (UTC):** 2026-07-23 ## Problem diff --git a/docs/_config.yml b/docs/_config.yml index e8a530e6..11956bc2 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -1,7 +1,6 @@ -title: Jaiph — AI Workflow DSL +title: Jaiph — language for AI-assisted development description: >- - Jaiph — Open source AI workflow DSL. Powerful and friendly automation for - AI-assisted development. + Jaiph — Open source language for AI-assisted development. markdown: kramdown kramdown: diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index 68cba9e6..d9a485bc 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -7,7 +7,7 @@ + content="Jaiph — Open source language for AI-assisted development." /> {{ page.title | default: site.title }} — Jaiph diff --git a/docs/_layouts/docs.html b/docs/_layouts/docs.html index 64110341..74e687fd 100644 --- a/docs/_layouts/docs.html +++ b/docs/_layouts/docs.html @@ -7,7 +7,7 @@ + content="Jaiph — Open source language for AI-assisted development." /> {{ page.title | default: site.title }} — Jaiph @@ -43,21 +43,20 @@
  • jaiph.org
  • Tutorials
  • -
  • Your first workflow
  • -
  • Your first agent + sandboxed run
  • +
  • Your first run
  • +
  • Your first agent run
  • How-to guides
  • Install & switch versions
  • -
  • Run in a Docker sandbox
  • Authenticate agent backends
  • Configure backend & model
  • Add a hook
  • Use & publish a library
  • Save artifacts
  • Write & run tests
  • -
  • Serve workflows as MCP tools
  • -
  • Serve workflows over HTTP
  • +
  • MCP server in 30 seconds
  • +
  • Serve defs over HTTP
  • Export traces (OTLP)
  • -
  • Deploy the runtime image
  • +
  • Deploy jaiph
  • Reference
  • CLI
  • Configuration
  • @@ -67,7 +66,6 @@
  • Explanation
  • Architecture
  • Agent analyzability
  • -
  • Sandboxing
  • Inbox & Dispatch
  • Async Handles
  • Why Jaiph
  • diff --git a/docs/agent-analyzability.md b/docs/agent-analyzability.md index dcbc3b62..76b74c1b 100644 --- a/docs/agent-analyzability.md +++ b/docs/agent-analyzability.md @@ -8,13 +8,13 @@ redirect_from: # Agent analyzability -**Summary.** AI coding agents have a hard context budget. This decision makes analyzability a CI-enforced property of the repo: understanding any one file must require that file plus the small public interfaces of its direct dependencies — never the surrounding codebase. We keep the TypeScript import graph an acyclic, layered, low-fan-out DAG of deep modules, and we keep docs topic-scoped with summary-first headers. Violations fail the build. +**Summary.** AI coding agents have a limited context budget. This page makes analyzability a property that CI enforces. Understanding any one file must require only that file plus the small public interfaces of its direct dependencies, and never the surrounding codebase. We keep the TypeScript import graph acyclic, layered, and low in fan-out, built from deep modules, and we keep each docs page scoped to one topic with a summary first. Violations fail the build. For runtime/CLI contracts and pipelines, see [Architecture](architecture.md). For contributor workflow, see [Contributing](contributing.md). ## Decision -Treat **agent analyzability** as a formal, enforceable invariant of this codebase — the same property as human maintainability, measured strictly. +Treat **agent analyzability** as a formal, enforceable invariant of this codebase. It is the same property as human maintainability, measured strictly. **Invariant.** Understanding any single production file under `src/` requires loading only: @@ -25,7 +25,7 @@ and must **not** require paging in sibling implementations, unrelated feature sl ## Why -Agents do not fail first from “not being smart enough”; they fail when the import graph forces them to load more than their context window can hold. Tangled imports, cycles, deep reaches into other packages, and oversized files make the context cost of a local change grow with repo size. An unconstrained tree degrades toward “load everything or guess.” +Agents do not fail first from "not being smart enough". They fail when the import graph forces them to load more than their context window can hold. Tangled imports, cycles, deep reaches into other packages, and oversized files make the context cost of a local change grow with repo size. An unconstrained tree degrades toward "load everything or guess". Constraining structure keeps the context cost of a task **bounded and independent of repository growth**. Side effects we want anyway: no cycles, isolated slices, smaller modules, docs that stay navigable from headers. @@ -33,7 +33,7 @@ Constraining structure keeps the context cost of a task **bounded and independen - Not a freeze on features or a ban on large behavior changes. - Not a requirement that every helper be a separate package. -- Not “fewer files at any cost” — deep modules hide many private files behind one small interface; files still stay short. +- Not "fewer files at any cost". Deep modules hide many private files behind one small interface, and files still stay short. - Not weakening existing runtime/CLI contracts in [Architecture](architecture.md). ## Code structure @@ -45,14 +45,14 @@ Imports may point only **downward**. Lower layers never import higher ones. | Layer | Paths | May import from | |-------|--------|-----------------| | **4** CLI | `src/cli/**`, `src/cli.ts` | 3, 2, 1, 0 | -| **3** Runtime | `src/runtime/**` | 2 (only the transpile public entry `src/transpiler.ts`), 1, 0 — **not** CLI | -| **2** Compile | `src/transpile/**`, `src/transpiler.ts` | 1, 0 — **not** runtime | +| **3** Runtime | `src/runtime/**` | 2 (only the transpile public entry `src/transpiler.ts`), 1, 0, but **not** CLI | +| **2** Compile | `src/transpile/**`, `src/transpiler.ts` | 1, 0, but **not** runtime | | **1** Parse / format | `src/parse/**`, `src/parser.ts`, `src/format/**` | 0 only | | **0** Shared leaf | `src/types.ts`, `src/errors.ts`, `src/diagnostics.ts`, `src/version.ts`, `src/env-reserved.ts`, `src/inline-script-name.ts` | other layer-0 files only | **Already pinned today:** compile-time must not import runtime (`src/transpile/no-runtime-imports.test.ts`). The layer table generalizes that rule, and `npm run arch:check` now enforces the whole table (see [Enforcement (CI)](#enforcement-ci)). -**Allowlisted exception.** Runtime may depend on the **public** module-graph API from compile (`loadModuleGraph` / `readModuleGraph` / `writeModuleGraph` / `ModuleGraph`, …) because the runner reuses the same graph. That dependency must go through transpile’s **single** public entry `src/transpiler.ts` (which re-exports the module-graph API), never through `src/transpile/module-graph.ts` or any other internal. +**Allowlisted exception.** Runtime may depend on the **public** module-graph API from compile (`loadModuleGraph` / `readModuleGraph` / `writeModuleGraph` / `ModuleGraph`, …) because the runner reuses the same graph. That dependency must go through transpile's single public entry `src/transpiler.ts` (which re-exports the module-graph API), never through `src/transpile/module-graph.ts` or any other internal. ### Deep modules (public entry = contract) @@ -61,25 +61,25 @@ Each package is a **deep module**: narrow public surface, large private capabili | Package | Public entry (contract) | Private | |---------|-------------------------|---------| | Shared (0) | the listed `src/*.ts` leaf files themselves | n/a | -| Parse | `src/parser.ts` (sole external entry; re-exports the intentional public API — no `export *` barrel) | `src/parse/**` | +| Parse | `src/parser.ts` (sole external entry; re-exports the intentional public API, with no `export *` barrel) | `src/parse/**` | | Format | `src/format/index.ts` (sole external entry; re-exports the intentional formatter API, no `export *` barrel) | other `src/format/**` | | Transpile | `src/transpiler.ts` (sole external entry; re-exports the compile/validate surface plus the full module-graph API, no `export *` barrel) | `module-graph.ts`, `validate-*.ts`, emit internals, etc. | -| Runtime | `src/runtime/index.ts` (launch, docker, runner, `buildRuntimeGraph`, shared types/helpers intended for CLI); `src/runtime/testing.ts` is a second entry for named test seams that cross-package `*.test.ts` files reach (kept off the production entry) | `src/runtime/kernel/**` and other internals | +| Runtime | `src/runtime/index.ts` (launch, runner, `buildRuntimeGraph`, shared types/helpers intended for CLI); `src/runtime/testing.ts` is a second entry for named test seams that cross-package `*.test.ts` files reach (kept off the production entry) | `src/runtime/kernel/**` and other internals | | CLI | `src/cli/index.ts` plus per-slice entries under `src/cli//` as needed | slice-private files | -**Rule.** Code **outside** a package imports **only** that package’s public entry. Code **inside** a package may import siblings freely, subject to no-cycles, fan-out, and file-size caps. +**Rule.** Code **outside** a package imports **only** that package's public entry. Code **inside** a package may import siblings freely, subject to no-cycles, fan-out, and file-size caps. **Deep ≠ fat files.** Depth is interface/implementation asymmetry. Implementations stay split into short private files (prefer ≤ ~400 lines; see factory `code_philosophy` and ESLint `max-lines` below). -**Facades are curated.** Public entries export a small, intentional API. `export * from './everything'` is forbidden — it recreates shallow modules and blows fan-out. +**Facades are curated.** Public entries export a small, intentional API. `export * from './everything'` is forbidden, because it recreates shallow modules and raises fan-out. ### CLI slice isolation Treat these as vertical slices: `commands`, `run`, `serve`, `mcp`, `exec`, `telemetry`. -**`commands` is the composition root.** It wires the other slices together (each `jaiph` subcommand launches its feature), so `commands` **may** import any slice’s private tree — that is orchestration, not peer coupling. +**`commands` is the composition root.** It wires the other slices together (each `jaiph` subcommand launches its feature), so `commands` may import any slice's private tree, which is orchestration and not peer coupling. -**Peer slices must not import each other.** `run`, `serve`, `mcp`, `exec`, and `telemetry` must **not** import each other’s private trees. Peer coupling (e.g. `serve` → `mcp`) is the real analyzability problem: it makes one feature un-understandable without paging in another. Cross-slice reuse among peers goes through `src/cli/shared` (or layer 3/0 public entries). Same idea as “no cross-feature imports” in a feature-slice layout, with the composition root exempted. +**Peer slices must not import each other.** `run`, `serve`, `mcp`, `exec`, and `telemetry` must **not** import each other's private trees. Peer coupling, for example `serve` importing `mcp`, is the analyzability problem this rule targets, because it makes one feature impossible to understand without paging in another. Cross-slice reuse among peers goes through `src/cli/shared` (or layer 3/0 public entries). This is the same idea as "no cross-feature imports" in a feature-slice layout, with the composition root exempted. Enforced by `no-cross-cli-slice-imports` in `.dependency-cruiser.cjs`: its `from` set is the peer slices only (`commands` excluded), so a `commands` → slice import passes while a peer → peer private import fails. The former feature-composition edges (`serve` mounting `mcp`/`exec`, `exec` reusing `run`/`telemetry`) are **gone, not baselined**: the shared MCP-protocol engine (`shared/mcp-server`, `shared/mcp-tools`) and the workflow-call executor (`shared/workflow-call`) now live under `src/cli/shared`, so `serve`, the `jaiph mcp` subcommand, and `shared/generation` all reach them downward and no peer slice imports another peer's private tree. The baseline carries **zero** `no-cross-cli-slice-imports` entries. @@ -94,16 +94,16 @@ Turn a cap off for a file only with a per-file override in `eslint.config.mjs` ( ### Cycles -No circular dependencies anywhere under `src/`. Cycles destroy the “direct deps’ interfaces suffice” story: each side needs the other’s body. +No circular dependencies anywhere under `src/`. Cycles break the guarantee that a file's direct dependencies and their public interfaces are enough to understand it, because each side then needs the other's body. ## Documentation structure Docs obey the same budget discipline: 1. **One topic per file** (aligned with Diátaxis page types already in use). -2. **Size cap** — prefer pages agents can load whole; split when a page outgrows a single topic. Enforced: `integration/docs-structure.test.ts` fails any non-allowlisted `docs/*.md` whose body exceeds 500 lines (front matter excluded); an oversized single-topic page goes on the test's `DOC_SIZE_ALLOWLIST` with a justification rather than merging topics. -3. **Summary first** — every page opens with a short summary so an agent can skip the body from the header alone. Enforced: the same test requires the first body line after the H1 to be a prose lead paragraph (this page labels its lead `**Summary.**`), not a subheading, list, or table. -4. **Entry-point manifest** — nav in `docs/_layouts/docs.html` plus this page and [Architecture](architecture.md) as the structural maps; do not bury contracts only in chat history or `QUEUE.md`. +2. **Size cap.** Prefer pages agents can load whole, and split a page when it outgrows a single topic. This is enforced by `integration/docs-structure.test.ts`, which fails any non-allowlisted `docs/*.md` whose body exceeds 500 lines (front matter excluded). An oversized single-topic page goes on the test's `DOC_SIZE_ALLOWLIST` with a justification rather than merging topics. +3. **Summary first.** Every page opens with a short summary so an agent can skip the body from the header alone. The same test requires the first body line after the H1 to be a prose lead paragraph (this page labels its lead `**Summary.**`), not a subheading, list, or table. +4. **Entry-point manifest.** The nav in `docs/_layouts/docs.html`, plus this page and [Architecture](architecture.md), are the structural maps. Do not bury contracts only in chat history or `QUEUE.md`. ## Enforcement (CI) @@ -111,14 +111,33 @@ These are **guardrails**, not conventions. Violations fail CI. | Mechanism | What it enforces | |-----------|------------------| -| `dependency-cruiser` (`npm run arch:check`) | no cycles; the layer DAG (including `runtime` ↛ `cli`); deep imports past the parse public entry (`no-deep-imports-into-parse`), the transpile public entry (`no-deep-imports-into-transpile`), the runtime public entry (`no-deep-imports-into-runtime`), and the format public entry (`no-deep-imports-into-format`); cross-CLI-slice private imports (`no-cross-cli-slice-imports`). Every layer now sits behind a public-entry gate, and the committed known-violations baseline (`.dependency-cruiser-known-violations.json`) is **empty** — no cycles, upward imports, deep imports, or cross-slice edges remain tracked | +| `dependency-cruiser` (`npm run arch:check`) | no cycles; the layer DAG (including `runtime` ↛ `cli`); deep imports past the parse public entry (`no-deep-imports-into-parse`), the transpile public entry (`no-deep-imports-into-transpile`), the runtime public entry (`no-deep-imports-into-runtime`), and the format public entry (`no-deep-imports-into-format`); cross-CLI-slice private imports (`no-cross-cli-slice-imports`). Every layer now sits behind a public-entry gate, and the committed known-violations baseline (`.dependency-cruiser-known-violations.json`) is empty, so no cycles, upward imports, deep imports, or cross-slice edges remain tracked | | ESLint (`npm run lint`) | `import/max-dependencies` and `max-lines` on `src/**/*.ts`. Most former violators were split into sibling modules and now pass under the global caps with no override; the four largest remaining files keep a per-file override in `eslint.config.mjs`, each with a fresh justification | | Existing grep/shape tests | e.g. transpile ↛ runtime, trivia isolation, file-size caps on specific hot files | | Docs structure tests | Diátaxis front matter, nav bijection, link resolution, summary-first lead, and a 500-line body cap (`integration/docs-structure.test.ts`) | **Baseline policy.** If the tree already violates a new rule, do **not** weaken the rule. Commit a dependency-cruiser known-violations baseline (and an explicit ESLint grandfather list) so **new** violations fail while old ones are tracked. Follow-up work removes baseline entries; it does not relax severity. -**Landed today.** `.dependency-cruiser.cjs` and `npm run arch:check` now enforce `no-circular` and the layer DAG, meaning each layer's rule against upward imports, including the exception that lets runtime reuse compile only through the single public entry `src/transpiler.ts`. Orphan modules are reported as a warning. Pre-existing violations are grandfathered in `.dependency-cruiser-known-violations.json` and passed to the check with `--ignore-known`, so a new cycle or upward import fails the build; that baseline is now **empty** — every originally-grandfathered edge was fixed rather than kept, so no import-graph violations remain tracked. `eslint.config.mjs` and `npm run lint` now enforce the two caps below on `src/**/*.ts`: `import/max-dependencies` at 8 (type imports ignored) and `max-lines` at 400 (blank and comment lines skipped). Test files are out of scope, because they legitimately import many modules and run long. Most files that once exceeded a cap were split into sibling modules in the same directory and now pass under the global caps with no override. The four largest remaining files keep a per-file override in `eslint.config.mjs` that turns off only the rule they break, each with a justification naming why splitting it is larger follow-up work, and the global cap is never raised, so any new violation still fails. Deep imports past the parse public entry (`src/parser.ts`) are now enforced by the `no-deep-imports-into-parse` rule, and every production call site routes through the entry (the former `validate-string.ts` → `parse/core.ts` baseline is gone: the interpolation validator moved into parse, see below). Deep imports past the transpile public entry are now enforced by the `no-deep-imports-into-transpile` rule: code outside `src/transpile/` imports only the **single** public entry `src/transpiler.ts` (the compile/validate surface plus the full module-graph API: `buildScripts*`, `loadModuleGraph`/`readModuleGraph`/`writeModuleGraph`, `collectDiagnostics`, `walkjhFiles`, `ModuleGraph` types, …) — `src/transpile/module-graph.ts` is no longer a second door, so runtime reaches the graph API through `src/transpiler.ts` too and `layer3-runtime-only-transpile-public-graph` now forbids every `runtime` → `src/transpile/**` edge; the CLI `collectDiagnostics`/`walkjhFiles` call sites were retargeted to the entry, and the former parse→transpile leak is gone: `validateJaiphStringContent`/`extractInlineCaptures` (which need `parseCallRef`) moved down into `src/parse/validate-string-content.ts`, so `parse/metadata.ts` uses a parse sibling and `transpile/validate-string.ts` re-exports them through `src/parser.ts` — no production file under `src/parse/` imports `src/transpile/`. The runtime slice now has a public entry too: `src/runtime/index.ts` re-exports the curated CLI-facing surface (graph construction, launch/runner, the Docker sandbox, emit/redact/portability helpers, embedded assets, and run-tree param display) and `no-deep-imports-into-runtime` fails any outside import that reaches a `src/runtime/**` internal. The runtime→CLI leak is gone: `buildStepDisplayParamPairs` moved out of `src/cli/commands/format-params.ts` into `src/runtime/kernel/format-params.ts` (re-exported through the public entry), so no production runtime file imports `src/cli/**` and there are zero baselined `runtime`→`cli` edges. Every production CLI call site that reached a runtime internal (docker, emit, portability, redact, runner, launch, embedded-assets) was retargeted to `src/runtime/index.ts`; the former `src/config.ts` → `runtime/kernel/runtime-arg-parser` leak is gone too — the pure `interpolate` helper moved down into `src/config.ts` (which `runtime-arg-parser` now imports downward and re-exports), so `config.ts` imports nothing from `src/runtime/`. The former cross-package test-seam imports (`_dockerExec`, `_dockerSpawn`, `_inplacePrompt`, `CHAIN_GENESIS`, `chainHmac`, `RuntimeEventEmitter`) were retargeted to a second named public entry `src/runtime/testing.ts` (allowlisted beside `index.ts` in the `no-deep-imports-into-runtime` rule), so those seams stay off the production `index.ts` while no test reaches a raw `src/runtime/**` path; there are now **zero** baselined `no-deep-imports-into-runtime` edges. Two upward test edges were also cleared by moving the test to its correct layer rather than baselining: the parser-error snapshot test that needs `loadModuleGraph` moved `src/parse/` → `src/transpile/`, and the compile→runtime graph-reuse test that needs `buildRuntimeGraph` moved `src/transpile/` → `src/runtime/`. The format slice now has a public entry too: `src/format/index.ts` re-exports the formatter API (`emitModule` and the `EmitOptions` type) and `no-deep-imports-into-format` fails any outside import that reaches a `src/format/**` internal such as `emit.ts`. The one outside call site (`src/cli/commands/format.ts`) was retargeted to the entry, and format keeps importing only parse and types, so no format source imports `src/cli`, `src/runtime`, or `src/transpile`. CLI slice isolation is now enforced too: `no-cross-cli-slice-imports` fails any import from a **peer** slice (`run`, `serve`, `mcp`, `exec`, `telemetry`) into another slice's private tree, using a `$1` path-group backreference so same-slice imports and imports of `src/cli/shared/**` (or lower-layer public entries) stay allowed. `commands` is the **composition root** and is deliberately absent from the rule's `from` set: it wires the other slices together (each subcommand launches its feature), so `commands` → slice imports are allowed rather than baselined. The one back-edge that was a shared display helper (`run/display.ts` → `commands/format-params.ts`) was fixed earlier by moving `format-params.ts` into `src/cli/shared/`. There are now **zero** baselined `no-cross-cli-slice-imports` edges. The former 17 peer feature-composition edges (`serve` mounting `mcp` tools and `exec` over HTTP, `exec` reusing `run` lifecycle and `telemetry`) were not domain contracts of their home slices but shared CLI infrastructure misfiled inside peer slices — `shared/generation.ts` already reached up into `exec/call`, `mcp/tools`, and three `run/*` modules, an inverted dependency the baseline hid. The fix moves that infrastructure down into `src/cli/shared`: the MCP-protocol engine `mcp/server.ts` → `shared/mcp-server.ts` and `mcp/tools.ts` → `shared/mcp-tools.ts` (used by both the `jaiph mcp` stdio subcommand and `jaiph serve` over HTTP), and the workflow-call executor `exec/call.ts` → `shared/workflow-call.ts` (`callWorkflow`, `WorkflowCallResult`/`WorkflowCallContext`, used by `commands/mcp`, `commands/serve`, `serve/handler`, and `shared/generation`). Because `shared` is not in the rule's `from` set, `shared/workflow-call.ts` may import `run/*` and `telemetry/otlp` downward without a peer violation, so the run/telemetry primitives stay put. The `mcp` and `exec` slice directories no longer exist — their concerns became shared infrastructure — while the peer-slice regex still names them so a reintroduced private tree is still guarded. Zero baselined slice edges originate from `commands`. With parse, transpile, runtime, format, and the CLI slices all gated, no deep-import work remains queued: every layer sits behind a public-entry rule and the dependency-cruiser baseline is empty. +**Landed today.** Both gates run in CI, and every rule below is enforced now rather than left open. + +The import-graph gate lives in `.dependency-cruiser.cjs` and runs through `npm run arch:check`: + +- `no-circular` and the layer DAG are enforced. Each layer's rule forbids upward imports, including the exception that lets runtime reuse compile only through the single public entry `src/transpiler.ts`. Orphan modules are reported as a warning. +- Pre-existing violations were grandfathered in `.dependency-cruiser-known-violations.json` and passed to the check with `--ignore-known`, so a new cycle or upward import fails the build. That baseline is now empty, because every originally grandfathered edge was fixed rather than kept, so no import-graph violation remains tracked. +- Two upward test edges were also cleared by moving each test to its correct layer rather than baselining it. The parser-error snapshot test that needs `loadModuleGraph` moved from `src/parse/` to `src/transpile/`, and the compile-to-runtime graph-reuse test that needs `buildRuntimeGraph` moved from `src/transpile/` to `src/runtime/`. + +Every package now sits behind a public-entry rule that fails any outside import reaching an internal file: + +- **Parse.** `no-deep-imports-into-parse` guards `src/parser.ts`, and every production call site routes through the entry. The interpolation validators `validateJaiphStringContent` and `extractInlineCaptures` (which need `parseCallRef`) moved down into `src/parse/validate-string-content.ts`, so `parse/metadata.ts` uses a parse sibling and `transpile/validate-string.ts` re-exports them through `src/parser.ts`. No production file under `src/parse/` imports `src/transpile/`. +- **Transpile.** `no-deep-imports-into-transpile` guards the single public entry `src/transpiler.ts`, which re-exports the compile and validate surface plus the full module-graph API (`buildScripts*`, `loadModuleGraph` / `readModuleGraph` / `writeModuleGraph`, `collectDiagnostics`, `walkjhFiles`, and the `ModuleGraph` types). `src/transpile/module-graph.ts` is no longer a second door, so runtime reaches the graph API through `src/transpiler.ts` too, and `layer3-runtime-only-transpile-public-graph` forbids every `runtime` to `src/transpile/**` edge. The CLI `collectDiagnostics` and `walkjhFiles` call sites were retargeted to the entry. +- **Runtime.** `no-deep-imports-into-runtime` guards `src/runtime/index.ts`, which re-exports the CLI-facing surface (graph construction, launch and runner, emit, redact, and portability helpers, embedded assets, and run-tree param display). The runtime-to-CLI leak is gone, because `buildStepDisplayParamPairs` moved out of the CLI into `src/runtime/kernel/format-params.ts` (re-exported through the runtime public entry, with a thin CLI re-export at `src/cli/shared/format-params.ts`), so no production runtime file imports `src/cli/**`. The `src/config.ts` to `runtime-arg-parser` leak is gone too, because the pure `interpolate` helper moved down into `src/config.ts` (which `runtime-arg-parser` now imports downward and re-exports), so `config.ts` imports nothing from `src/runtime/`. The cross-package test seams `CHAIN_GENESIS`, `chainHmac`, and `RuntimeEventEmitter` moved to a second public entry, `src/runtime/testing.ts`, allowlisted beside `index.ts` in the rule, so those seams stay off the production entry while no test reaches a raw `src/runtime/**` path. +- **Format.** `no-deep-imports-into-format` guards `src/format/index.ts`, which re-exports the formatter API (`emitModule` and the `EmitOptions` type). The one outside call site, `src/cli/commands/format.ts`, routes through the entry, and format still imports only parse and types. + +CLI slice isolation is enforced by `no-cross-cli-slice-imports`. It fails any import from a peer slice (`run`, `serve`, `mcp`, `exec`, `telemetry`) into another slice's private tree, and a `$1` path-group backreference keeps same-slice imports and imports of `src/cli/shared/**` allowed. `commands` is the composition root and is deliberately absent from the rule's `from` set, so a `commands` to slice import passes while a peer to peer private import fails. + +The former 17 peer feature-composition edges were not domain contracts of their home slices. They were shared CLI infrastructure misfiled inside peer slices, and `shared/generation.ts` already reached up into `exec/call`, `mcp/tools`, and three `run/*` modules, an inverted dependency the baseline hid. The fix moves that infrastructure down into `src/cli/shared`: the MCP-protocol engine (`shared/mcp-server.ts` and `shared/mcp-tools.ts`, used by both the `jaiph mcp` stdio subcommand and `jaiph serve` over HTTP) and the workflow-call executor (`shared/workflow-call.ts`, used by `commands/mcp`, `commands/serve`, `serve/handler`, and `shared/generation`). Because `shared` is not in the rule's `from` set, `shared/workflow-call.ts` may import `run/*` and `telemetry/otlp` downward without a peer violation, so the run and telemetry primitives stay put. The `mcp` and `exec` slice directories no longer exist, while the peer-slice regex still names them so a reintroduced private tree stays guarded. Zero cross-slice edges remain in the baseline. + +The ESLint caps live in `eslint.config.mjs` and run through `npm run lint`. Both caps apply to `src/**/*.ts`: `import/max-dependencies` at 8 (type imports ignored) and `max-lines` at 400 (blank and comment lines skipped). Test files are out of scope, because they legitimately import many modules and run long. Most files that once exceeded a cap were split into sibling modules in the same directory and now pass with no override. The four largest remaining files keep a per-file override that turns off only the rule they break, each with a justification, and the global cap is never raised, so a new violation still fails. **Scripts.** The import-graph gate and the ESLint caps gate are both live and wired to their committed configs: @@ -140,7 +159,7 @@ Both `arch:check` and `lint` are required CI steps on the Compiler and unit test ## Consequences - New code must land in the correct layer and behind the correct public entry. -- Moving a helper “up” a layer to fix a convenience import is a design smell; move the helper down or widen the lower layer’s public API instead. +- Moving a helper "up" a layer to fix a convenience import is a design smell. Move the helper down or widen the lower layer's public API instead. - Fixing the remaining grandfathered ESLint hotspots (the four oversized / high-fan-out files with per-file overrides in `eslint.config.mjs`) is intentional follow-up work, not optional cleanup; the import-graph baseline is already empty (deep imports and the `runtime` → `cli` leak are fixed, not baselined). - Analyzability and human maintainability are the same invariant measured with tools. diff --git a/docs/agent-auth.md b/docs/agent-auth.md index c46d45e7..a1aabc36 100644 --- a/docs/agent-auth.md +++ b/docs/agent-auth.md @@ -6,23 +6,23 @@ diataxis: how-to # Authenticate agent backends -This recipe sets the credentials each agent backend needs so the CLI's credential pre-flight passes and `prompt` steps reach the model. +This guide shows how to set the credentials each agent backend needs, so the CLI's credential pre-flight passes and `prompt` steps can reach the model. -`jaiph run` runs a host-side credential pre-flight before it spawns the runner or the Docker container. The pre-flight is keyed to the backends the entry file declares. Missing credentials produce either `E_AGENT_CREDENTIALS`, which is a hard abort, or a `jaiph: warning:` on host-only runs for the `claude` and `cursor` backends (see the table below). Hard failures exit before any runner or container is launched. The behavior is implemented in `src/cli/run/preflight-credentials.ts`. +`jaiph run` runs a host-side credential pre-flight before it spawns the runner. The pre-flight checks the backends the entry file declares. Missing credentials produce one of two results. A missing `codex` credential is a hard failure with the error `E_AGENT_CREDENTIALS`, and the run stops before any runner is launched. A missing `claude` or `cursor` credential produces only a `jaiph: warning:` line and the run still proceeds (see the table below). The behavior is implemented in `src/cli/run/preflight-credentials.ts`. ## Prerequisites -- The entry `.jh` file declares a backend (`agent.backend = "claude" | "cursor" | "codex"`) at module or workflow scope, or uses a `prompt` step that consumes the default backend. +- The entry `.jh` file declares a backend in a `config { }` block (`agent.backend = "claude" | "cursor" | "codex"`) at module or def scope, or uses a `prompt` step that consumes the default backend. ## Pick the backend's credential -| Backend | Required credentials | Host run (no Docker) | Docker run (any mode incl. `inplace`) | -|---|---|---|---| -| `claude` | `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN` | warn only (a stored Claude CLI login may still work) | hard error `E_AGENT_CREDENTIALS` | -| `cursor` | `CURSOR_API_KEY` | warn only (a stored `cursor-agent login` may still work) | hard error `E_AGENT_CREDENTIALS` | -| `codex` | `OPENAI_API_KEY` | hard error `E_AGENT_CREDENTIALS` (no CLI-login fallback) | hard error `E_AGENT_CREDENTIALS` when `OPENAI_API_KEY` is unset on the host (`OPENAI_API_KEY` is forwarded into the container) | +| Backend | Required credentials | Host behaviour | +|---|---|---| +| `claude` | `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN` | warn only (a stored Claude CLI login may still work) | +| `cursor` | `CURSOR_API_KEY` | warn only (a stored `cursor-agent login` may still work) | +| `codex` | `OPENAI_API_KEY` | hard error `E_AGENT_CREDENTIALS` (no CLI-login fallback) | -Under Docker sandboxing the host-side stored logins (Keychain entries, `~/.claude`, `cursor-agent login`) do not cross the container boundary. Only `JAIPH_*` run-control keys plus the credential keys in the table above are forwarded, and credential keys only for the backends the entry file selects (see [Sandboxing](sandboxing.md#what-docker-protects-against)). Set credentials on the host so the allowlist can forward them into the container. Forward anything else one key at a time with `--env`, which is an intentional allowlist bypass. +Set credentials on the host. Forward anything else one key at a time with `--env`. ### Which backends get checked @@ -31,7 +31,7 @@ The pre-flight validates every backend the entry file could reach, which is each The default is deduplicated against your declarations, so where you set the backend decides whether the `cursor` default is also checked: - **Module scope.** Putting `config { agent.backend = "claude" }` at the top of the file makes `claude` the effective default, so only `claude` is checked. -- **Workflow scope only.** Putting `config { agent.backend = "claude" }` inside a workflow, with no module-level backend, leaves `cursor` as the default. The pre-flight then checks both `claude` and `cursor`. Under Docker that makes a missing `CURSOR_API_KEY` a hard error even when every prompt targets Claude. +- **Def scope only.** Putting `config { agent.backend = "claude" }` inside a def, with no module-level backend, leaves `cursor` as the default. The pre-flight then checks both `claude` and `cursor`. To check only the backend you intend to use, set it at module scope or export `JAIPH_AGENT_BACKEND`. Either one becomes the default and absorbs the extra check. See [Configure backend/model](configure-backend.md) for the config scopes. @@ -50,7 +50,7 @@ claude setup-token export CLAUDE_CODE_OAUTH_TOKEN="..." ``` -On host runs (no Docker), a stored `~/.claude` or macOS Keychain login from a previous interactive `claude` session also works, but in that case the pre-flight emits a warning rather than failing. +A stored `~/.claude` or macOS Keychain login from a previous interactive `claude` session also works, but in that case the pre-flight emits a warning rather than failing. ## 2. Authenticate Cursor @@ -66,9 +66,9 @@ For host runs only, an interactive `cursor-agent login` (stored on disk) also sa export OPENAI_API_KEY="sk-..." ``` -`OPENAI_API_KEY` is required on both host and Docker runs. The `codex` backend has no CLI-login fallback, so there is no warning path. Under Docker, export the key on the host. It crosses the container boundary via the env allowlist when the entry file selects `codex`, the same per-backend rule as `ANTHROPIC_API_KEY` and `CURSOR_API_KEY`. +`OPENAI_API_KEY` is required. The `codex` backend has no CLI-login fallback, so there is no warning path. -To target an OpenAI-compatible endpoint instead of the default, set `JAIPH_CODEX_API_URL` to the chat-completions URL (`JAIPH_*` is forwarded under Docker). +To target an OpenAI-compatible endpoint instead of the default, set `JAIPH_CODEX_API_URL` to the chat-completions URL. ## 4. Run the pre-flight @@ -76,26 +76,29 @@ To target an OpenAI-compatible endpoint instead of the default, set `JAIPH_CODEX jaiph run ./flow.jh ``` -The pre-flight runs before the banner. Hard failures print a stderr message naming the backend, the model (when `agent.model` is set), the entry `.jh` file, the config scope that picked the backend (`module config`, `workflow `, `JAIPH_AGENT_BACKEND env`, or `default`), and the concrete remedy. The error code is `E_AGENT_CREDENTIALS`. Host-only warnings for `claude` and `cursor` use the same header fields with a `jaiph: warning:` prefix. +The pre-flight runs before the banner. A hard failure (`codex` only) prints a stderr message naming the backend, the model (when `agent.model` is set), the entry `.jh` file, the config scope that picked the backend (`module config`, `def `, `JAIPH_AGENT_BACKEND env`, or `default`), and the remedy. The message is prefixed with `E_AGENT_CREDENTIALS`. Host-only warnings for `claude` and `cursor` use the same header fields with a `jaiph: warning:` prefix. -## Skip the pre-flight (escape hatch) +## Skip the pre-flight -`JAIPH_UNSAFE=true` (or `jaiph run --unsafe`) skips the pre-flight entirely. The host is in charge, a stored CLI login may work, and the runtime's per-backend guards remain as a backstop. The pre-flight is also skipped when the entry file neither declares an explicit backend nor uses any `prompt` step, because nothing would credential against. +The pre-flight is skipped when the entry file neither declares an explicit backend nor uses any `prompt` step, because nothing would credential against. -`jaiph run --raw` also skips the pre-flight. Raw mode is the passthrough the host uses to run the workflow inside the Docker container, so the outer `jaiph run` has already run the pre-flight before it spawns the inner raw run. +`jaiph run --raw` also skips the pre-flight. ## Verification -When every required credential is present, the pre-flight is silent, with no stderr before the banner. On host runs, missing `claude` or `cursor` env vars emit `jaiph: warning:` lines and the run still proceeds, because a stored CLI login may satisfy the runtime. A hard failure prints: +When every required credential is present, the pre-flight is silent, with no stderr before the banner. On host runs, missing `claude` or `cursor` env vars emit `jaiph: warning:` lines and the run still proceeds, because a stored CLI login may satisfy the runtime. A missing `claude` credential prints this warning: ``` -E_AGENT_CREDENTIALS: agent.backend "claude" selected by module config in /path/to/flow.jh — neither ANTHROPIC_API_KEY nor CLAUDE_CODE_OAUTH_TOKEN is set. Run `claude setup-token` and export CLAUDE_CODE_OAUTH_TOKEN, or set ANTHROPIC_API_KEY. +jaiph: warning: agent.backend "claude" selected by module config in /path/to/flow.jh — neither ANTHROPIC_API_KEY nor CLAUDE_CODE_OAUTH_TOKEN is set. Run `claude setup-token` and export CLAUDE_CODE_OAUTH_TOKEN, or set ANTHROPIC_API_KEY. A stored Claude CLI login may still work. ``` -Under Docker the message includes the suffix `(Docker is on — set the env var on the host so it is forwarded into the container.)`. +Only the `codex` backend hard-fails. When `OPENAI_API_KEY` is missing, the pre-flight prints this and the command stops before the banner: + +``` +E_AGENT_CREDENTIALS: agent.backend "codex" selected by module config in /path/to/flow.jh — OPENAI_API_KEY is not set. Set OPENAI_API_KEY to your OpenAI API key. +``` ## Related -- [Run a workflow in a Docker sandbox](sandbox-run.md) — how host env vars cross the container boundary. -- [Configure backend/model](configure-backend.md) — picking which backend a workflow uses. -- [Sandboxing — What Docker protects against](sandboxing.md#what-docker-protects-against) — env allowlist and what crosses the container boundary. +- [Configure backend/model](configure-backend.md) — picking which backend a def uses. +- [Environment variables](env-vars.md) — `--env` and credential names. diff --git a/docs/architecture.md b/docs/architecture.md index 985ddbf1..eda4214e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,6 +7,9 @@ redirect_from: - /spec-async-isolated - /target-design - /reporting + - /sandboxing + - /sandbox-run + - /how-to/sandbox-run --- # Architecture @@ -21,16 +24,16 @@ For how to contribute, see [Contributing](contributing.md), which covers branche ## System overview -Workflow authors write `.jh` / `.test.jh` modules. The toolchain turns those files into validated modules plus extracted script files, and then the same AST interpreter runs the workflows whether you use local `jaiph run`, Docker, or `jaiph test`. +Workflow authors write `.jh` / `.test.jh` modules. The toolchain turns those files into validated modules plus extracted script files, and then the same AST interpreter runs the workflows for `jaiph run` and `jaiph test`. -1. Parse source into AST. Every CLI path walks the entry plus its transitive `.jh` import closure **once** through **`loadModuleGraph`** (`src/transpile/module-graph.ts`) and reuses that **`ModuleGraph`** for the banner (`metadataToConfig`), validation (**`validateModule`** inside **`emitScriptsForModuleFromGraph`**, invoked by **`buildScriptsFromGraph`**), script-body extraction, and, across the parent to child process boundary on the default local `jaiph run`, for **`buildRuntimeGraph(graph)`** in the spawned runner (see [Local module graph](#local-module-graph) and the sequence diagram below). `parsejaiph(source, filePath)` is I/O-pure, and validation and script emit operate entirely on the in-memory graph and never re-read `.jh` files. Inside this compile-and-run graph pipeline, `loadModuleGraph` is the only routine that reads `.jh` sources from disk. A few paths outside the graph pipeline still read `.jh` directly, such as `runWorkflowRaw` on the `jaiph run --raw` path (`src/cli/commands/run.ts`) and the exported `loadImportedModules` helper (`src/cli/shared/paths.ts`). -2. **Compile-time** validation runs before script extraction. The validator consumes the in-memory graph; imported ASTs are looked up by absolute path and never re-read from disk. Three validation entry points share the same per-module walk via **`validateModuleInto`**: **`validateModule(ast, graph)`** is the per-module throwing form (used by **`emitScriptsForModuleFromGraph`** / **`buildScriptsFromGraph()`** so the existing single-error path stays intact), **`validateReferences(graph)`** validates every reachable module then throws the first sorted error, and **`collectDiagnostics(graph)`** returns a populated `Diagnostics` collector (`src/diagnostics.ts`) with **every** recoverable error from every reachable module. The **`jaiph compile`** command walks the same import closure but routes through `collectDiagnostics`: it builds a graph per entry, collects diagnostics, prints them all (sorted by file/line/col, in `path:line:col CODE message` form on stderr — or as a single JSON array on stdout with `--json`), and exits non-zero if any diagnostic was collected. It **does not** emit **`scripts/`**, **does not** invoke **`buildRuntimeGraph()`**, and never spawns the workflow runner (`src/cli/commands/compile.ts`). For a **directory** argument it discovers `*.jh` via `walkjhFiles`, which **skips** `*.test.jh`; to validate a test module, pass that file explicitly. Imported modules in the closure are still validated recursively either way. +1. Parse source into AST. Every CLI path walks the entry plus its transitive `.jh` import closure **once** through **`loadModuleGraph`** (`src/transpile/module-graph.ts`) and reuses that **`ModuleGraph`** for the runtime config (`metadataToConfig`), validation (**`validateModule`** inside **`emitScriptsForModuleFromGraph`**, invoked by **`buildScriptsFromGraph`**), script-body extraction, and, across the parent to child process boundary on the default local `jaiph run`, for **`buildRuntimeGraph(graph)`** in the spawned runner (see [Local module graph](#local-module-graph) and the sequence diagram below). `parsejaiph(source, filePath)` is I/O-pure, and validation and script emit operate entirely on the in-memory graph and never re-read `.jh` files. Inside this compile-and-run graph pipeline, `loadModuleGraph` is the only routine that reads `.jh` sources from disk. A few paths outside the graph pipeline still read `.jh` directly, such as `runWorkflowRaw` on the `jaiph run --raw` path (`src/cli/commands/run.ts`) and the exported `loadImportedModules` helper (`src/cli/shared/paths.ts`). +2. **Compile-time** validation runs before script extraction. The validator consumes the in-memory graph; imported ASTs are looked up by absolute path and never re-read from disk. Three validation entry points share the same per-module walk via **`validateModuleInto`**: **`validateModule(ast, graph)`** is the per-module throwing form (used by **`emitScriptsForModuleFromGraph`** / **`buildScriptsFromGraph()`** so the existing single-error path stays intact), **`validateReferences(graph)`** validates every reachable module then throws the first sorted error, and **`collectDiagnostics(graph)`** returns a populated `Diagnostics` collector (`src/diagnostics.ts`) with **every** recoverable error from every reachable module. The **`jaiph compile`** command walks the same import closure but routes through `collectDiagnostics`: it builds a graph per entry, collects diagnostics, and prints them all, with each entry's diagnostics sorted by file/line/col, in `path:line:col CODE message` form on stderr, or as a single JSON array on stdout with `--json`, and exits non-zero if any diagnostic was collected. It **does not** emit **`scripts/`**, **does not** invoke **`buildRuntimeGraph()`**, and never spawns the def runner (`src/cli/commands/compile.ts`). For a **directory** argument it discovers `*.jh` via `walkjhFiles`, which **skips** `*.test.jh`; to validate a test module, pass that file explicitly. Imported modules in the closure are still validated recursively either way. 3. **CLI** (`dist/src/cli.js` via npm, or a **Bun-compiled** `dist/jaiph` binary) prepares script executables (scripts-only), then spawns a **detached child** through the internal **`__workflow-runner`** argv marker (**`spawnJaiphWorkflowProcess`** in `src/runtime/kernel/workflow-launch.ts`). The child entrypoint is **`runWorkflowRunner`** (`src/runtime/kernel/node-workflow-runner.ts`), which loads or deserializes the module graph, calls **`buildRuntimeGraph()`**, then runs **`NodeWorkflowRuntime`**. Under Node the spawn is **`process.execPath`** + **`dist/src/cli.js`** + **`__workflow-runner`**; under the Bun standalone binary, **`process.execPath`** is the **`jaiph`** binary itself with the same marker. Script steps execute as managed subprocesses; prompt, inbox I/O, and event/summary emission are handled by the kernel under `src/runtime/kernel/`. 4. Stream live events to the CLI and persist durable run artifacts. -Interactive **`jaiph run`** parses **`__JAIPH_EVENT__`** lines from the runner's stderr, renders the progress tree, and runs hooks. **`jaiph run --raw`** skips that shell. The child uses inherited stdio, so events still land on stderr unchanged. Use `--raw` when you embed Jaiph or when the host wraps a container (see [CLI, `jaiph run`](cli.md#jaiph-run) and [Sandboxing](sandboxing.md)). +Interactive **`jaiph run`** parses **`__JAIPH_EVENT__`** lines from the runner's stderr, renders the progress tree, and runs hooks. **`jaiph run --raw`** skips that shell. The child uses inherited stdio, so events still land on stderr unchanged. Use `--raw` when you embed Jaiph (see [CLI, `jaiph run`](cli.md#jaiph-run)). -All orchestration uses the Node workflow runtime, which is the AST interpreter, whether you run local `jaiph run`, `jaiph test`, or **Docker `jaiph run`**. Docker containers run the same **`jaiph run --raw`** / **`__workflow-runner`** dispatch with the compiled JS source tree and scripts mounted read-only. +All orchestration uses the Node workflow runtime, which is the AST interpreter, whether you run `jaiph run` or `jaiph test`. ### Import-graph layering @@ -40,75 +43,70 @@ The `src/` import graph is an acyclic layered DAG: parse/format → transpile - **CLI (`src/cli`, invoked via compiled `src/cli.ts` → `dist/src/cli.js`)** - Entry point (`run`, `test`, `compile`, `init`, `install`, `use`, `format`, `mcp`, `serve`). Paths ending in `.jh` / `.test.jh` are also accepted as implicit commands (see `src/cli/index.ts`). - - **Workflow launch** is owned in TypeScript (`src/runtime/kernel/workflow-launch.ts` + `src/cli/run/lifecycle.ts`): spawns the runner via **`process.execPath`** and the **`__workflow-runner`** argv marker. **`runWorkflowRunner`** (`src/runtime/kernel/node-workflow-runner.ts`) handles that argv, loads or reads the module graph, calls **`buildRuntimeGraph()`**, then **`NodeWorkflowRuntime.runDefault()`**. The **`default`** workflow name is wired in **`buildRunModuleLaunch`** (`workflow-launch.ts`). `setupRunSignalHandlers` accepts an optional `onSignalCleanup` callback for Docker sandbox teardown on SIGINT/SIGTERM — for a Docker-backed run it is `stopDockerRunOnSignal`, which stops and removes the container (`docker kill` then `docker rm -f`) before deleting the host sandbox clone so an interrupt cannot orphan a running container (see [Docker runtime helper](#core-components)). + - **Workflow launch** is owned in TypeScript (`src/runtime/kernel/workflow-launch.ts` + `src/cli/run/lifecycle.ts`): spawns the runner via **`process.execPath`** and the **`__workflow-runner`** argv marker. **`runWorkflowRunner`** (`src/runtime/kernel/node-workflow-runner.ts`) handles that argv, loads or reads the module graph, calls **`buildRuntimeGraph()`**, then **`NodeWorkflowRuntime.runRoot()`**. **`runMain()`** is a thin wrapper that calls `runRoot("main", args)`. The fallback symbol in **`buildRunModuleLaunch`** (`workflow-launch.ts`) is **`main`**. `setupRunSignalHandlers` terminates the runner process tree on SIGINT/SIGTERM. - Parses runtime events and renders progress (except `--raw`); dispatches hooks. - **Parser (`src/parser.ts`, `src/parse/*`)** - Converts `.jh`/`.test.jh` into a **semantic AST** (`jaiphModule`) plus a parallel **`Trivia`** store of source-fidelity data. `parsejaiphWithTrivia(source, filePath)` returns `{ ast, trivia }`; the legacy `parsejaiph(source, filePath)` is a thin wrapper that returns only the `ast` for consumers that don't need round-trip data. Both entry points are I/O-pure. - **Public entry.** Code outside the parse package imports the parse slice only through `src/parser.ts`, which re-exports a curated public API (the two parse entry points plus named helpers such as `configValueHasInterpolation`, `canonicalizeTripleQuotedString`, `resolveInterpreterFromShebang`, and `createTrivia`). It is not an `export *` barrel of the tree. The `no-deep-imports-into-parse` rule in `.dependency-cruiser.cjs` fails any outside import that reaches a `src/parse/**` internal directly. Add a named re-export to `src/parser.ts` instead of reaching in. The string-content validators that both parse and transpile need (`validateJaiphStringContent`, `extractInlineCaptures`) live under the parse package in `src/parse/validate-string-content.ts` and are re-exported through `src/parser.ts`, so `src/transpile/validate-string.ts` imports them from the public entry and no deep import into the parse package is baselined. - Reusable primitives: `parseFencedBlock()` (`src/parse/fence.ts`) handles triple-backtick fenced bodies with optional lang tokens for scripts and inline scripts; `parseFencedScriptBlock()` wraps it with common-margin dedent for executable script bodies. `parseTripleQuoteBlock()` (`src/parse/triple-quote.ts`) handles `"""..."""` blocks for prompts, `const`, `log`, `logerr`, `fail`, `return`, and `send` — all positions where multiline strings appear. `canonicalizeTripleQuotedString()` (same file) reproduces the dedent + escape decoding that match-arm bodies still need (they carry an unprocessed `tripleQuoteBodyToRaw`-shaped string plus a `tripleQuotedBody` flag rather than being dedented at parse time); both the validator and the runtime call it, so "what the validator inspects" and "what the runtime executes" are bit-for-bit identical. - - **Unified `run` / `ensure` host parsing.** `run ref(...)`, `run async ref(...)`, and `ensure ref(...)`, optionally followed by `catch (binding) { ... }` (any host) or `recover(binding) { ... }` (`run` only), are parsed by a single helper `parseRunOrEnsure` in `src/parse/workflow-brace.ts`. The attached `catch` / `recover` clause — bindings, body shape (multi-line `{ … }`, inline `{ stmt[; stmt]* }`, or single-statement) — is parsed by **one** helper `parseAttachedBlock(filePath, lines, idx, …, keyword, textAfterKeyword, trivia)` in `src/parse/steps.ts`. There is no separate mini parser for catch/recover bodies: `parseAttachedBlock` delegates each body statement to the **same** `parseBlockStatement` (`src/parse/workflow-brace.ts`) that handles top-level statements, so every statement form accepted in a workflow / rule body is accepted identically inside a `catch` / `recover` body. "Is this statement allowed inside a catch/recover body?" is a validator concern (the `RULE_SCOPE` / `WORKFLOW_SCOPE` distinction in `validate-step.ts`), not enforced by which mini-parser branches happened to fire. `src/parse/steps.ts` is bounded at **≤200 lines** by `src/parse/parse-attached-block.test.ts`, which also asserts no function named `parse(Run)?(Catch|Recover|EnsureStep)` reappears. - - **Keyword dispatch table.** Inside `parseBlockStatement` (`src/parse/workflow-brace.ts`), every workflow / rule body line that does not begin with `#` is routed by a single `STATEMENT: Record` table keyed by the leading identifier — there is no longer a `startsWith` cascade where `"run async "` must be tested before `"run "` and `"prompt "` must be tested before a bare assignment. The dispatcher tokenizes the first identifier on the trimmed line, looks it up once, and invokes the matching handler (`tryParseIf` / `tryParseFor` / `tryParseConst` / `tryParseFail` / `tryParseEnsure` / `tryParseRun` / `tryParsePrompt` / `tryParseLog` / `tryParseLogerr` / `tryParseLogwarn` / `tryParseReturn` / `tryParseStandaloneMatch` / `tryParseElseError`, plus `tryParseWait` — a removal tombstone whose only job is to `fail` with `"wait" has been removed from the language`), which either returns a `{ step, nextIdx }` result, returns `null` to fall through, or calls `fail(...)` to abort. Two non-keyword fallbacks fire after the table lookup in order: `trySend` (matches `channel <- rhs` via `matchSendOperator`) then `shellFallthrough` (everything else becomes a shell `exec` step). Assignment-shape error guards (`name = prompt …`, `name = run …` without `const`, plus the `forRule` rejection of `prompt`) run once before dispatch in `applyAssignmentGuards(c)`. The per-line context (`filePath`, `lines`, `idx`, `innerRaw`, `inner`, `innerNo`, `trivia`, `forRule`, `opts`) is threaded through handlers as a single `BlockCtx` record. **Adding a new top-level keyword is a two-file change:** one row in `STATEMENT` (`workflow-brace.ts`) plus one entry in the `JAIPH_KEYWORDS` reserved set (`core.ts`) — pinned by `src/parse/parse-synthetic-keyword.test.ts`, which patches `STATEMENT` at runtime with a synthetic `zzznoop` handler, asserts dispatch fires, asserts the same input falls through to the shell handler when the row is removed, and greps both source files to confirm each symbol lives in exactly one place. Every existing parse-error message, line, and column is preserved bit-for-bit: `src/parse/parse-error-snapshot.test.ts` walks every `=== name` block in `test-fixtures/compiler-txtar/parse-errors.txt`, captures `{ file, line, col, code, message }` for each, and diffs against the snapshot stored at `test-fixtures/compiler-txtar/parse-errors-snapshot.json` (refreshable with `UPDATE_SNAPSHOTS=1` only after confirming the change is intentional). The wider tokenizer rewrite — the ad-hoc `inDoubleQuote` / `inTripleQuote` / `braceDepth` scanners replicated across `src/parse/`, the line-walking `{ step, nextIdx }` contract, and the per-handler regex bodies — is **not** part of this refactor and remains future work. + - **Unified `run` host parsing.** `run ref(...)` and `run async ref(...)`, optionally followed by `catch (binding) { ... }` or `recover(binding) { ... }`, are parsed by `parseRun` in `src/parse/workflow-brace.ts`. The attached `catch` / `recover` clause — bindings, body shape (multi-line `{ … }`, inline `{ stmt[; stmt]* }`, or single-statement) — is parsed by **one** helper `parseAttachedBlock(filePath, lines, idx, …, keyword, textAfterKeyword, trivia)` in `src/parse/workflow-brace.ts` (it was merged into that file to break the former `steps.ts` to `workflow-brace.ts` import cycle). There is no separate mini parser for catch/recover bodies: `parseAttachedBlock` delegates each body statement to the **same** `parseBlockStatement` (also in `src/parse/workflow-brace.ts`) that handles top-level statements, so every statement form accepted in a `def` body is accepted identically inside a `catch` / `recover` body. `src/parse/parse-attached-block.test.ts` asserts that catch/recover bodies parse identically to top-level statements and that no function named `parse(Run)?(Catch|Recover|EnsureStep)` reappears. A `STATEMENT` tombstone (`tryParseEnsureRemoved`) rejects leftover `ensure` with `'ensure' is not a keyword; use 'run'`. + - **Keyword dispatch table.** Inside `parseBlockStatement` (`src/parse/workflow-brace.ts`), every `def` body line that does not begin with `#` is routed by a single `STATEMENT: Record` table keyed by the leading identifier — there is no longer a `startsWith` cascade where `"run async "` must be tested before `"run "` and `"prompt "` must be tested before a bare assignment. The dispatcher tokenizes the first identifier on the trimmed line, looks it up once, and invokes the matching handler (`tryParseIf` / `tryParseFor` / `tryParseConst` / `tryParseFail` / `tryParseRun` / `tryParsePrompt` / `tryParseLog` / `tryParseLogerr` / `tryParseLogwarn` / `tryParseReturn` / `tryParseStandaloneMatch` / `tryParseSend` / `tryParseElseError`, plus tombstones `tryParseWait` (`"wait" has been removed from the language`) and `tryParseEnsureRemoved` (`'ensure' is not a keyword; use 'run'`)), which either returns a `{ step, nextIdx }` result, returns `null` to fall through, or calls `fail(...)` to abort. Two non-keyword fallbacks fire after the table lookup in order: `tryLegacySend` (removed `channel <- payload`) then `shellFallthrough` (everything else becomes a shell `exec` step). Assignment-shape error guards (`name = prompt …`, `name = run …` without `const`) run once before dispatch in `applyAssignmentGuards(c)`. The per-line context (`filePath`, `lines`, `idx`, `innerRaw`, `inner`, `innerNo`, `trivia`, `opts`) is threaded through handlers as a single `BlockCtx` record. **Adding a new top-level keyword is a two-file change:** one row in `STATEMENT` (`workflow-brace.ts`) plus one entry in the `JAIPH_KEYWORDS` reserved set (`core.ts`) — pinned by `src/parse/parse-synthetic-keyword.test.ts`, which patches `STATEMENT` at runtime with a synthetic `zzznoop` handler, asserts dispatch fires, asserts the same input falls through to the shell handler when the row is removed, and greps both source files to confirm each symbol lives in exactly one place. Every existing parse-error message, line, and column is preserved bit-for-bit: `src/parse/parse-error-snapshot.test.ts` walks every `=== name` block in `test-fixtures/compiler-txtar/parse-errors.txt`, captures `{ file, line, col, code, message }` for each, and diffs against the snapshot stored at `test-fixtures/compiler-txtar/parse-errors-snapshot.json` (refreshable with `UPDATE_SNAPSHOTS=1` only after confirming the change is intentional). The wider tokenizer rewrite — the ad-hoc `inDoubleQuote` / `inTripleQuote` / `braceDepth` scanners replicated across `src/parse/`, the line-walking `{ step, nextIdx }` contract, and the per-handler regex bodies — is **not** part of this refactor and remains future work. - **AST / Types (`src/types.ts`)** - Shared compile-time schema (`jaiphModule`, step defs, test defs, hook payload types). The semantic AST carries **only** what the validator, emitter, transpiler, and runtime need; surface-form data that exists purely to round-trip the formatter (leading comments on imports / channels / `const` / `test` blocks, top-level emit order, `config` body sequence, `"""..."""` flags on `literal` / `return` / `log` / `logerr` / `fail` / `send` / `const`, the `bareSource` of `return `, and prompt / script `bodyKind` discriminators) lives in **`Trivia`** instead — see [Trivia (CST layer)](#trivia-cst-layer). - - **One `Expr` for every value position.** Anywhere a value can appear — `const name = …`, `return …`, `send channel <- …`, `log` / `logerr` / `fail` arguments, and the body of an `exec` statement — the AST stores a single tagged union: `Expr = literal | call | ensure_call | inline_script | prompt | match | shell | bare_ref`. There is **no longer** a separate `ConstRhs` union, `SendRhsDef` union, or `managed:` sidecar on `return` / `log` / `logerr` (the placeholder strings `"__match__"` / `"run inline_script"` / `"__JAIPH_MANAGED__"` are gone too — a meta-test in `src/types-shape.test.ts` fails if any reappear under `src/`). The eight `Expr` kinds: `literal` (verbatim source text — quoted string, `$var` / `${var}` form, or post-dedent triple-quoted body), `call` (managed workflow/script call; `async: true` for `run async ref(...)` capture position), `ensure_call` (managed rule call), `inline_script` (`` `body`(args) `` or fenced), `prompt` (carries the JSON-quoted body and optional flat `returns` schema), `match` (a `match { ... }` evaluated for its value), `shell` (raw shell fragment used as a managed substitution on the send RHS), and `bare_ref` (bare symbol on a send RHS — always rejected by the validator, preserved so the error message can name the symbol). - - **Eight `WorkflowStepDef` variants** (down from fourteen): `exec` (side-effecting managed call statement — was `run` / `ensure` / `run_inline_script` / `prompt` / standalone `match` / inline `shell`; the discriminator now lives inside `body.kind`, with `captureName` / `catch` / `recover` as step-level attributes); `const`, `return`, `send` (bind, propagate, or emit an `Expr`); `say` (was `log` / `logerr` / `logwarn` / `fail` — `level: "fail"` aborts the workflow with the message, otherwise the message is written to the corresponding stream); `if` / `for_lines` (control flow, unchanged shape); `trivia` (formatter-only `comment` / `blank_line` slots — skipped by the runtime and validator). A type-level exhaustive `switch` in `src/types-shape.test.ts` pins both the step count at **8** and the `Expr` kind count at **8**. - - **Call arguments are a typed sum.** Every call-bearing `Expr` (`call`, `ensure_call`, `inline_script`) carries `args?: Arg[]` where `Arg = { kind: "literal"; raw: string } | { kind: "var"; name: string }`. The parser classifies each argument once (a bare identifier or bare `IDENT.IDENT` typed-prompt field access becomes `var`; everything else — quoted strings, nested `run …` / `ensure …` calls, inline-script bodies, and illicit unquoted `${…}` forms — is stored as `literal`). There is no separate `args: string` text payload or shadow `bareIdentifierArgs: string[]` field, and no downstream consumer re-parses call arguments: the validator walks the typed list to enforce arity, reject nested unmanaged calls inside literals, reject unquoted `${…}` call args (`E_VALIDATE` — interpolation belongs inside strings; use bare `name` / `result.role`), resolve `var` refs against in-scope bindings (and dotted `var` names against typed-prompt schemas), and check `${var.field}` embedded inside quoted literal args; the emitter renders by mapping each `Arg` to its source form; the runtime turns `Arg[]` back into a runtime string via `argsToRuntimeString` (`var` → `${name}`, `literal` → raw) so the existing handle-resolution / interpolation path is unchanged. + - **One `Expr` for every value position.** Anywhere a value can appear — `const name = …`, `return …`, `send … -> channel`, `log` / `logerr` / `fail` arguments, and the body of an `exec` statement — the AST stores a single tagged union: `Expr = literal | call | inline_script | prompt | match | shell | bare_ref`. There is **no longer** a separate `ConstRhs` union, `SendRhsDef` union, or `managed:` sidecar on `return` / `log` / `logerr` (the placeholder strings `"__match__"` / `"run inline_script"` / `"__JAIPH_MANAGED__"` are gone too — a meta-test in `src/types-shape.test.ts` fails if any reappear under `src/`). The seven `Expr` kinds: `literal` (verbatim source text — quoted string, `$var` / `${var}` form, or post-dedent triple-quoted body), `call` (managed def/script call; `async: true` for `run async ref(...)` capture position), `inline_script` (`` `body`(args) `` or fenced), `prompt` (carries the JSON-quoted body and optional flat `returns` schema), `match` (a `match { ... }` evaluated for its value), `shell` (raw shell fragment used as a managed substitution on the send RHS), and `bare_ref` (bare symbol on a send RHS — always rejected by the validator, preserved so the error message can name the symbol). + - **Eight `StepDef` variants** (down from fourteen): `exec` (side-effecting managed call statement — `run` / `prompt` / standalone `match` / inline `shell`; the discriminator now lives inside `body.kind`, with `captureName` / `catch` / `recover` as step-level attributes); `const`, `return`, `send` (bind, propagate, or emit an `Expr`); `say` (was `log` / `logerr` / `logwarn` / `fail` — `level: "fail"` aborts the workflow with the message, otherwise the message is written to the corresponding stream); `if` / `for_lines` (control flow, unchanged shape); `trivia` (formatter-only `comment` / `blank_line` slots — skipped by the runtime and validator). A type-level exhaustive `switch` in `src/types-shape.test.ts` pins both the step count at **8** and the `Expr` kind count at **7**. + - **Call arguments are a typed sum.** Every call-bearing `Expr` (`call`, `inline_script`) carries `args?: Arg[]` where `Arg = { kind: "literal"; raw: string } | { kind: "var"; name: string }`. The parser classifies each argument once (a bare identifier or bare `IDENT.IDENT` typed-prompt field access becomes `var`; everything else — quoted strings, nested `run …` / `run …` calls, inline-script bodies, and illicit unquoted `${…}` forms — is stored as `literal`). There is no separate `args: string` text payload or shadow `bareIdentifierArgs: string[]` field, and no downstream consumer re-parses call arguments: the validator walks the typed list to enforce arity, reject nested unmanaged calls inside literals, reject unquoted `${…}` call args (`E_VALIDATE` — interpolation belongs inside strings; use bare `name` / `result.role`), resolve `var` refs against in-scope bindings (and dotted `var` names against typed-prompt schemas), and check `${var.field}` embedded inside quoted literal args; the emitter renders by mapping each `Arg` to its source form; the runtime turns `Arg[]` back into a runtime string via `argsToRuntimeString` (`var` → `${name}`, `literal` → raw) so the existing handle-resolution / interpolation path is unchanged. - **Trivia / CST layer (`src/parse/trivia.ts`)** {: #trivia-cst-layer} - `Trivia` is a parallel store keyed by AST-node identity (per-node via `WeakMap`) and a small `ModuleTrivia` record for module-level data. The parser builds it alongside the AST; **only the formatter reads it**. Validator, emitter, transpiler, and runtime never import from `src/parse/trivia.ts` — a grep test (`src/parse/trivia-grep.test.ts`) pins this invariant by rejecting any reference to `Trivia` / `createTrivia` / `NodeTrivia` / `ModuleTrivia` from validator and emitter source files. - - A separate type-shape test (`src/parse/trivia-ast-shape.test.ts`) asserts at compile time that none of the formatter-only fields reappear on `jaiphModule`, `ImportDef`, `ScriptImportDef`, `ChannelDef`, `TestBlockDef`, `WorkflowMetadata`, `ScriptDef`, or any `WorkflowStepDef` / `Expr` variant. (`ConstRhs` / `SendRhsDef` no longer exist — their fields live inside `Expr` — and `src/types-shape.test.ts` fails if those symbols reappear as exports of `src/types.ts`.) + - A separate type-shape test (`src/parse/trivia-ast-shape.test.ts`) asserts at compile time that none of the formatter-only fields reappear on `jaiphModule`, `ImportDef`, `ScriptImportDef`, `ChannelDef`, `TestBlockDef`, `DefMetadata`, `ScriptDef`, or any `StepDef` / `Expr` variant. (`ConstRhs` / `SendRhsDef` no longer exist — their fields live inside `Expr` — and `src/types-shape.test.ts` fails if those symbols reappear as exports of `src/types.ts`.) - **Validator (`src/transpile/validate.ts` + `src/transpile/validate-step.ts`)** - - Resolves imports and symbol references; emits deterministic compile-time errors. Import resolution (`resolveImportPath` in `transpile/resolve.ts`) checks relative paths first, then falls back to project-scoped libraries under `/.jaiph/libs/` — the workspace root is threaded through all compilation call sites. Export visibility is enforced by `validateRef` in `validate-ref-resolution.ts`: if an imported module declares any `export`, only exported names are reachable through the import alias. - - **Two-file split.** `validate.ts` owns the **outer** layer: import / channel-route / test-block checks plus `walkStepTree` (the single descent that builds `{ knownVars, promptSchemas, flat }` for each workflow / rule). `validate-step.ts` owns the **per-step** visitor: one row per `WorkflowStepDef.type` in a `VALIDATORS: Record` table, a single `validateExpr` dispatcher over the 8 `Expr.kind` values, and the call-shape / channel / string-content helpers. `validate.ts` is bounded at **≤700 lines** (currently ~470) by a CI-style test in `src/transpile/validate-visitor.test.ts`; new validators belong in `validate-step.ts`. - - **Visitor table + scope.** Per-step validation has one entry point — `validateStep(step, ctx)` in `validate-step.ts`. It looks the step's `type` up in `VALIDATORS` (the dispatch table), then consults `ctx.scope.allowSteps` (a `Set`) once to decide whether this step is permitted in the current scope. Two scopes exist: `WORKFLOW_SCOPE` (allows every step variant including `send` and `prompt`) and `RULE_SCOPE` (rejects `send` outright; rejects `prompt` and `run async` from inside `exec` bodies). The scope also carries `runRefExpect` (`RUN_TARGET_REF_EXPECT` for workflows, `RUN_IN_RULE_REF_EXPECT` for rules) and `withPromptSchemas` (workflows collect prompt-returning bindings; rules skip schema collection). Adding a new step type requires exactly one row in `VALIDATORS` and, if the rule/workflow split needs to differ, an entry in `Scope.allowSteps` — an `AC4` test in `validate-visitor.test.ts` injects a synthetic step type and asserts it produces exactly one diagnostic with the documented `internal: no validator for step type "…"` message until the row is added. - - **Single managed-call-shape helper.** Every `call` / `ensure_call` site runs the same five checks against the typed `Arg[]` directly — shell-redirection rejection (only `literal` args are scanned), nested-unmanaged-call rejection inside `literal` raws, ref resolution (with the scope's `runRefExpect` for `call`, `RULE_REF_EXPECT` for `ensure_call`), arity (`args.length` vs declared params), and `var`-arg resolution against in-scope bindings via `validateArgVarRefs`. The sequence lives once in `validateCallable(expr, ctx)`; both `run` and `ensure` validators invoke it with a different ref expectation / target kind. There is no longer a separate `validateBareIdentifierArgs` helper, no per-site repetition of the five-step sequence, and no place re-parses an `args: string` payload by splitting on commas or rescanning quotes. - - **Diagnostics collector (recoverable errors).** The validator no longer fails fast on the first user-level error. Every recoverable check appends to a `Diagnostics` collector (`src/diagnostics.ts`) via `diag.error(file, line, col, code, msg)`, which records a `JaiphDiagnostic` and short-circuits the current validation unit through a `BailoutError`. Each top-level unit (per-import block, per-rule walk, per-rule step, per-workflow walk, per-workflow step, per-test-block step, per-channel route) is wrapped in `diag.capture(fn)`, which absorbs the bailout (and any thrown `jaiphError` from leaf helpers like `validate-ref-resolution.ts` / `validate-string.ts` / `validate-prompt-schema.ts` / `shell-jaiph-guard.ts` / `parse/validate-string-content.ts`) so the next sibling unit still runs. `collectDiagnostics(graph)` walks every module and returns the populated collector; the legacy **`validateReferences(graph)`** is now a thin wrapper that throws the first sorted diagnostic via **`jaiphError`** so graph-level callers and existing per-error tests keep working; **`emitScriptsForModuleFromGraph`** still calls **`validateModule(ast, graph)`** per module before emit. `Diagnostics.sorted()` returns errors ordered by `(file, line, col)`; `formatLines()` renders the standard `path:line:col CODE message` shape. A grep test (`src/transpile/diagnostics-collector.test.ts`) pins the migration: `validate.ts` + `validate-step.ts` hold **zero** `throw jaiphError(` sites, and the remaining `throw jaiphError(` call sites under `src/` are confined to a documented allowlist — fatal aborts in the parser (`src/parse/core.ts`), the loader (`src/transpile/module-graph.ts`), and the test-file shape check (`src/cli/commands/test.ts`); the legacy bridge in `src/diagnostics.ts`; and the five leaf validation helpers above, each of which has every caller wrapped in `diag.capture(...)`. - - The validator drives off `WorkflowStepDef.type` (8 variants) and `Expr.kind` (8 variants). For every value-bearing step (`const` / `return` / `send` / `say`) and for the body of every `exec` step, a single `validateExpr(expr, ...)` dispatcher handles the value: it routes `call` / `ensure_call` / `inline_script` to call-site validation (`validateCallable`), walks `match` arms, schema-checks `prompt`, and runs the substitution scanner on `literal` raws. There is no dual code path for "managed sidecar vs literal value" — that branch is gone. + - Resolves imports and symbol references; emits deterministic compile-time errors. Import resolution (`resolveImportPath` in `transpile/resolve.ts`) checks relative paths first, then falls back to project-scoped libraries under `/.jaiph/libs/` — the workspace root is threaded through all compilation call sites. Export visibility is enforced by `validateRef` in `validate-ref-resolution.ts`: names are private across `import` unless listed in `mod.exports`. Zero exports means nothing is public. Same-file names remain visible. `jaiph run` requires `export def main` in the input file before spawn. + - **Validator file split.** `validate.ts` owns the **outer** layer: import / channel-route / test-block checks plus `walkStepTree` (the single descent that builds `{ knownVars, promptSchemas, flat }` for each `def`). `validate-step.ts` owns the **per-step** visitor: one row per `StepDef.type` in a `VALIDATORS: Record` table. The value-level dispatcher `validateExpr` over the 7 `Expr.kind` values lives in `validate-expr.ts`, the call-shape / channel / string-content helpers live in `validate-step-helpers.ts`, and `validate-match.ts` and `validate-step-ctx.ts` sit alongside them. `validate.ts` is bounded at **≤700 lines** (currently ~383) by a CI-style test in `src/transpile/validate-visitor.test.ts`; new validators belong in `validate-step.ts`. + - **Visitor table + scope.** Per-step validation has one entry point — `validateStep(step, ctx)` in `validate-step.ts`. It looks the step's `type` up in `VALIDATORS` (the dispatch table), then consults `ctx.scope.allowSteps` (a `Set`) once to decide whether this step is permitted in the current scope. One scope exists: `DEF_SCOPE` (allows every step variant, including `send`; `prompt` is an `Expr.kind` inside an `exec` body, not a step type). The scope also carries `runRefExpect` (`RUN_TARGET_REF_EXPECT`) and `withPromptSchemas` (defs collect prompt-returning bindings). Adding a new step type requires exactly one row in `VALIDATORS` and, if the allowed set needs to differ, an entry in `Scope.allowSteps` — an `AC4` test in `validate-visitor.test.ts` injects a synthetic step type and asserts it produces exactly one diagnostic with the documented `internal: no validator for step type "…"` message until the row is added. + - **Single managed-call-shape helper.** Every `call` site runs the same five checks against the typed `Arg[]` directly — shell-redirection rejection (only `literal` args are scanned), nested-unmanaged-call rejection inside `literal` raws, ref resolution (with the scope's `runRefExpect`), arity (`args.length` vs declared params), and `var`-arg resolution against in-scope bindings via `validateArgVarRefs`. The sequence lives once in `validateCallable(expr, ctx)`. There is no longer a separate `validateBareIdentifierArgs` helper, no per-site repetition of the five-step sequence, and no place re-parses an `args: string` payload by splitting on commas or rescanning quotes. + - **Diagnostics collector (recoverable errors).** The validator no longer fails fast on the first user-level error. Every recoverable check appends to a `Diagnostics` collector (`src/diagnostics.ts`) via `diag.error(file, line, col, code, msg)`, which records a `JaiphDiagnostic` and short-circuits the current validation unit through a `BailoutError`. Each top-level unit (per-import block, per-def walk, per-def step, per-test-block step, per-channel route) is wrapped in `diag.capture(fn)`, which absorbs the bailout (and any thrown `jaiphError` from leaf helpers like `validate-ref-resolution.ts` / `validate-string.ts` / `validate-prompt-schema.ts` / `shell-jaiph-guard.ts` / `parse/validate-string-content.ts`) so the next sibling unit still runs. `collectDiagnostics(graph)` walks every module and returns the populated collector; the legacy **`validateReferences(graph)`** is now a thin wrapper that throws the first sorted diagnostic via **`jaiphError`** so graph-level callers and existing per-error tests keep working; **`emitScriptsForModuleFromGraph`** still calls **`validateModule(ast, graph)`** per module before emit. `Diagnostics.sorted()` returns errors ordered by `(file, line, col)`; `formatLines()` renders the standard `path:line:col CODE message` shape. A grep test (`src/transpile/diagnostics-collector.test.ts`) pins the migration: `validate.ts` + `validate-step.ts` hold **zero** `throw jaiphError(` sites, and the remaining `throw jaiphError(` call sites under `src/` are confined to a documented allowlist — fatal aborts in the parser (`src/parse/core.ts`), the loader (`src/transpile/module-graph.ts`), and the test-file shape check (`src/cli/commands/test.ts`); the legacy bridge in `src/diagnostics.ts`; and the five leaf validation helpers above, each of which has every caller wrapped in `diag.capture(...)`. + - The validator drives off `StepDef.type` (8 variants) and `Expr.kind` (7 variants). For every value-bearing step (`const` / `return` / `send` / `say`) and for the body of every `exec` step, a single `validateExpr(expr, ...)` dispatcher handles the value: it routes `call` / `inline_script` to call-site validation (`validateCallable`), walks `match` arms, schema-checks `prompt`, and runs the substitution scanner on `literal` raws. There is no dual code path for "managed sidecar vs literal value" — that branch is gone. - **No compile-time → runtime imports.** Nothing under `src/transpile/` may `import … from "…/runtime/…"`. Compile-time code must not depend on runtime semantics: when the validator needs the same canonical form the runtime will see (the dedented, escape-decoded view of a triple-quoted match-arm body), both sides import a parser-side helper (`canonicalizeTripleQuotedString` in `src/parse/triple-quote.ts`) rather than reaching across the layer. A grep test (`src/transpile/no-runtime-imports.test.ts`) scans every non-test `*.ts` under `src/transpile/` and fails if any `from "…/runtime/…"` import appears; a separate corpus test (`src/parse/canonicalize-triple-quoted.test.ts`) parses every `.jh` under `test-fixtures/` and `examples/`, collects every triple-quoted match-arm body, and asserts `canonicalizeTripleQuotedString` matches the pre-move `tripleQuotedRawForRuntime` output bit-for-bit. - - **Single workflow walk.** Each workflow / rule has its step tree descended exactly once by `walkStepTree` (in `validate.ts`), which simultaneously accumulates `knownVars` (env decls + params + every nested `const` / capture / `for_lines` iterator), `promptSchemas` (top-level prompt-returning bindings, gated by `options.withPromptSchemas` so rules skip schema collection), enforces immutable-binding / `script`-collision rules inline (mutating a shared `bindings` map and threading a fresh inner map under each `for_lines` so loop iterators only shadow inside the body), and emits a flat `FlatStepEntry[]` of every step in tree order with the enclosing `catch` / `recover` failure binding attached. The main per-step validator loop iterates that flat list non-recursively and calls `validateStep` once per entry, so `walkStepTree`'s internal `descend` is the **only** recursive helper in `validate.ts` that takes a `WorkflowStepDef[]`. A pair of grep / AST tests (`src/transpile/validate-single-walk.test.ts`) pins both invariants: the prior helpers (`collectKnownVars`, `collectPromptSchemas`, `validateImmutableBindings`) cannot reappear by name, and at most one recursive `WorkflowStepDef[]` walker may live in `validate.ts`. + - **Single def walk.** Each `def` has its step tree descended exactly once by `walkStepTree` (in `validate.ts`), which simultaneously accumulates `knownVars` (env decls + params + every nested `const` / capture / `for_lines` iterator), `promptSchemas` (top-level prompt-returning bindings, gated by `options.withPromptSchemas`), enforces immutable-binding / `script`-collision rules inline (mutating a shared `bindings` map and threading a fresh inner map under each `for_lines` so loop iterators only shadow inside the body), and emits a flat `FlatStepEntry[]` of every step in tree order with the enclosing `catch` / `recover` failure binding attached. The main per-step validator loop iterates that flat list non-recursively and calls `validateStep` once per entry, so `walkStepTree`'s internal `descend` is the **only** recursive helper in `validate.ts` that takes a `StepDef[]`. A pair of grep / AST tests (`src/transpile/validate-single-walk.test.ts`) pins both invariants: the prior helpers (`collectKnownVars`, `collectPromptSchemas`, `validateImmutableBindings`) cannot reappear by name, and at most one recursive `StepDef[]` walker may live in `validate.ts`. - **Transpiler (`src/transpiler.ts`, `src/transpile/*`)** - - **Public entry.** Code outside the transpile package imports the transpile slice only through `src/transpiler.ts`, the single public entry. It re-exports a curated public API (`buildScripts`, `buildScriptsFromGraph`, `emitScriptsForModule`, `emitScriptsForModuleFromGraph`, `collectDiagnostics`, `validateReferences`, `walkjhFiles`, `walkTestFiles`, `resolveImportPath`, `workflowSymbolForFile`, and the `ModuleGraph` / `ModuleNode` / `ScriptArtifact` types) plus the full module-graph API (`loadModuleGraph`, `readModuleGraph`, `writeModuleGraph`, `moduleGraphFromAsts`, `serializeModuleGraph`, `deserializeModuleGraph`). It is not an `export *` barrel of the tree. Runtime reuses the same graph and reaches the module-graph API through this entry too, so `src/transpile/module-graph.ts` is no longer a second door. The `no-deep-imports-into-transpile` rule in `.dependency-cruiser.cjs` fails any outside import that reaches a `src/transpile/**` internal directly, such as `module-graph.ts`, `validate.ts`, `build.ts`, or an `emit-*.ts` file. Add a named re-export to `src/transpiler.ts` instead of reaching in. No deep import into the transpile package is baselined: the string-content validators that `src/parse/metadata.ts` used to reach for now live under the parse package (`src/parse/validate-string-content.ts`), so no file under `src/parse/` imports `src/transpile/`. - - **`emitScriptsForModuleFromGraph`** validates one module against the graph and runs **`buildScriptFiles`** — the only compile path for `jaiph run` / `jaiph test` — **persists only atomic `script` files** under `scripts/`. **`buildScripts(input, outDir, ws?)`** is the path-based wrapper used by tests and the directory walk; it loads a `ModuleGraph` and delegates. **`buildScriptsFromGraph(graph, outDir)`** is the graph-based entry point used by `jaiph run` / `jaiph test`, which already loaded the graph. Inline scripts (`` run `body`(args) ``) are also emitted as `scripts/__inline_` with deterministic hash-based names (`inlineScriptName` in `src/inline-script-name.ts`). There is no workflow-level bash emission. + - **Public entry.** Code outside the transpile package imports the transpile slice only through `src/transpiler.ts`, the single public entry. It re-exports a curated public API (`buildScripts`, `buildScriptsFromGraph`, `emitScriptsForModule`, `emitScriptsForModuleFromGraph`, `collectDiagnostics`, `validateReferences`, `walkjhFiles`, `walkTestFiles`, `resolveImportPath`, `moduleSymbolForFile`, and the `ModuleGraph` / `ModuleNode` / `ScriptArtifact` types) plus the full module-graph API (`loadModuleGraph`, `readModuleGraph`, `writeModuleGraph`, `moduleGraphFromAsts`, `serializeModuleGraph`, `deserializeModuleGraph`). It is not an `export *` barrel of the tree. Runtime reuses the same graph and reaches the module-graph API through this entry too, so `src/transpile/module-graph.ts` is no longer a second door. The `no-deep-imports-into-transpile` rule in `.dependency-cruiser.cjs` fails any outside import that reaches a `src/transpile/**` internal directly, such as `module-graph.ts`, `validate.ts`, `build.ts`, or an `emit-*.ts` file. Add a named re-export to `src/transpiler.ts` instead of reaching in. No deep import into the transpile package is baselined: the string-content validators that `src/parse/metadata.ts` used to reach for now live under the parse package (`src/parse/validate-string-content.ts`), so no file under `src/parse/` imports `src/transpile/`. + - **`emitScriptsForModuleFromGraph`** validates one module against the graph and runs **`buildScriptFiles`** to produce that module's `script` artifacts. This is the only compile path for `jaiph run` / `jaiph test`. The caller (`emitGraphInto` in `build.ts`) persists **only atomic `script` files** under `scripts/`. **`buildScripts(input, outDir, ws?)`** is the path-based wrapper used by tests and the directory walk; it loads a `ModuleGraph` and delegates. **`buildScriptsFromGraph(graph, outDir)`** is the graph-based entry point used by `jaiph run` / `jaiph test`, which already loaded the graph. Inline scripts (`` run `body`(args) ``) are also emitted as `scripts/__inline_` with deterministic hash-based names (`inlineScriptName` in `src/inline-script-name.ts`). There is no def-level bash emission. - The pipeline contract is **`loadModuleGraph` → `buildScriptsFromGraph(graph, outDir)`**, which runs **`validateModule`** + **`buildScriptFiles`** per reachable module via **`emitScriptsForModuleFromGraph`**. `parsejaiph` is I/O-pure; validation and script emit never re-read `.jh` sources during graph work. Each reachable module is parsed exactly once per `jaiph run` (see [Local module graph](#local-module-graph)). - **Runtime public entry (`src/runtime/index.ts`)** - - **Public entry.** Code outside the runtime package imports the runtime slice only through `src/runtime/index.ts`, which re-exports a curated CLI-facing API: graph construction (`buildRuntimeGraph`, `RuntimeGraph`), the launch and runner entry points (`runWorkflowRunner`, `WORKFLOW_RUNNER_ARG`, `spawnJaiphWorkflowProcess`, `runTestFile`), the Docker sandbox surface (`spawnDockerProcess`, `resolveDockerConfig`, `prepareImage`, `selectSandboxMode`, the run-config env constants, and the `DockerRunConfig` / `SandboxMode` types, among others), the emit and audit-chain helpers the CLI reads after a run (`generateChainKey`, `verifyRunJournal`, `redactCredentials`), the terminal-portability helpers (`canUseAnsi`, `killProcessTree`, `resolveShell`), the embedded-asset accessors, and the run-tree param display helper (`buildStepDisplayParamPairs`). It is not an `export *` barrel of the tree. The runtime has two allowlisted public entries: `src/runtime/index.ts` (the production surface above) and `src/runtime/testing.ts` (test seams, described next). The `no-deep-imports-into-runtime` rule in `.dependency-cruiser.cjs` fails any outside import that reaches a `src/runtime/**` internal directly, such as a `kernel/*.ts` file or `docker.ts`, unless the import goes through one of those two entries. Add a named re-export to `src/runtime/index.ts` instead of reaching in. The baseline carries no `no-deep-imports-into-runtime` leftovers. The former `src/config.ts` → `src/runtime/kernel/runtime-arg-parser.ts` leak is gone: the pure `interpolate` helper moved down into `src/config.ts`, which `runtime-arg-parser.ts` imports downward and re-exports, so `src/config.ts` imports nothing from `src/runtime/`. - - **Test-seam entry (`src/runtime/testing.ts`).** A few runtime internals are private to production but needed by cross-package `*.test.ts` files that stub or inspect them: the Docker exec and spawn indirection (`_dockerExec`, `_dockerSpawn`), the in-place run prompt (`_inplacePrompt`), the audit-chain HMAC internals (`CHAIN_GENESIS`, `chainHmac`), and the live-event emitter (`RuntimeEventEmitter`). These are re-exported from a second public entry, `src/runtime/testing.ts`, which is allowlisted beside `src/runtime/index.ts` in the `no-deep-imports-into-runtime` rule. A cross-package test imports a seam from `src/runtime/testing.ts` and never a raw `src/runtime/**` path, so the seams stay off the production `index.ts` while no test deep-imports the tree. Keep this surface small: add a seam only when a cross-package test genuinely needs one. - - **No runtime → CLI edge.** The runtime is layer 3 and the CLI is layer 4, so imports may only point downward. The kernel emits the run-tree display param pairs on every managed step, so the `buildStepDisplayParamPairs` helper lives in the runtime at `src/runtime/kernel/format-params.ts`; `src/cli/commands/format-params.ts` re-exports it through the public entry so its CLI callers keep one import site. No production file under `src/runtime/` imports `src/cli/**`. + - **Public entry.** Code outside the runtime package imports the runtime slice only through `src/runtime/index.ts`, which re-exports a curated CLI-facing API: graph construction (`buildRuntimeGraph`, `RuntimeGraph`), the launch and runner entry points (`runWorkflowRunner`, `WORKFLOW_RUNNER_ARG`, `spawnJaiphWorkflowProcess`, `runTestFile`), the emit and audit-chain helpers the CLI reads after a run (`generateChainKey`, `verifyRunJournal`, `redactCredentials`), the terminal-portability helpers (`canUseAnsi`, `killProcessTree`, `resolveShell`), the embedded-asset accessors, and the run-tree param display helper (`buildStepDisplayParamPairs`). It is not an `export *` barrel of the tree. The runtime has two allowlisted public entries: `src/runtime/index.ts` (the production surface above) and `src/runtime/testing.ts` (test seams, described next). The `no-deep-imports-into-runtime` rule in `.dependency-cruiser.cjs` fails any outside import that reaches a `src/runtime/**` internal directly, such as a `kernel/*.ts` file, unless the import goes through one of those two entries. Add a named re-export to `src/runtime/index.ts` instead of reaching in. The baseline carries no `no-deep-imports-into-runtime` leftovers. The former `src/config.ts` → `src/runtime/kernel/runtime-arg-parser.ts` leak is gone: the pure `interpolate` helper moved down into `src/config.ts`, which `runtime-arg-parser.ts` imports downward and re-exports, so `src/config.ts` imports nothing from `src/runtime/`. + - **Test-seam entry (`src/runtime/testing.ts`).** A few runtime internals are private to production but needed by cross-package `*.test.ts` files that stub or inspect them: the audit-chain HMAC internals (`CHAIN_GENESIS`, `chainHmac`), and the live-event emitter (`RuntimeEventEmitter`). These are re-exported from a second public entry, `src/runtime/testing.ts`, which is allowlisted beside `src/runtime/index.ts` in the `no-deep-imports-into-runtime` rule. A cross-package test imports a seam from `src/runtime/testing.ts` and never a raw `src/runtime/**` path, so the seams stay off the production `index.ts` while no test deep-imports the tree. Keep this surface small: add a seam only when a cross-package test genuinely needs one. + - **No runtime → CLI edge.** The runtime is layer 3 and the CLI is layer 4, so imports may only point downward. The kernel emits the run-tree display param pairs on every managed step, so the `buildStepDisplayParamPairs` helper lives in the runtime at `src/runtime/kernel/format-params.ts`; `src/cli/shared/format-params.ts` re-exports it through the public entry so its CLI callers keep one import site. No production file under `src/runtime/` imports `src/cli/**`. - **Node Workflow Runtime (`src/runtime/kernel/node-workflow-runtime.ts`)** - `NodeWorkflowRuntime` interprets the AST directly: walks workflow steps, manages scope/variables, delegates prompt and script execution to kernel helpers, handles channels/inbox/dispatch, owns the frame stack and heartbeat, and writes run artifacts. - **Script steps execute via an explicit interpreter, not the shebang + exec bit.** `executeScript` reads the emitted script's shebang line, resolves the interpreter through **`resolveInterpreterFromShebang`** (`src/parse/script-bash.ts`) — `#!/usr/bin/env ` → spawn ``, an absolute-path shebang → spawn that path, a missing shebang → default `bash` — and spawns ` `. This is portable: it does not depend on the OS honoring the shebang (Windows honors neither shebang nor exec bit) or on the file's `0o755` bit (`noexec` mounts strip it). The shebang line is **still** written into every emitted script (they stay directly executable by hand on POSIX), but the runtime never relies on it being honored. A spawn `ENOENT` from a missing interpreter surfaces as a diagnosable Jaiph error naming the interpreter rather than a raw `ENOENT`. - **Inline shell lines resolve their shell through one portable seam.** A single-line shell step (`executeShLine`) and CLI hook commands (`src/cli/run/hooks.ts`) both run under POSIX `sh -c`, but the shell itself is resolved through **`resolveShell()`** (`src/runtime/kernel/portability.ts`) rather than a hardcoded `spawn("sh", …)`. On POSIX this is bare `sh`; on **`win32`**, where there is no `sh` on the default `PATH`, it discovers Git for Windows' bundled `sh.exe` — first on `PATH`, then in the standard install layouts (`/bin/sh.exe`, `/usr/bin/sh.exe`) under each known root — memoizes the result for the process, and throws a diagnosable **`E_NO_POSIX_SHELL`** error naming Git for Windows if none is found. Inline lines are **never** translated to `cmd`/PowerShell: Jaiph's shell semantics are POSIX `sh` on every platform, so the seam only ever chooses *which* `sh` to invoke, never rewrites the command — otherwise workflows would stop being portable. `resolveShell()` is the single call site for the POSIX shell; no other `spawn("sh", …)` remains in `src/`. - - One private `evaluateExpr(scope, expr, …)` dispatcher handles every value position — `const` / `return` / `send` / `say` step handlers and the body of every `exec` step delegate to it. It switches on `Expr.kind` to run the managed call (`call` / `ensure_call` / `inline_script`) or `prompt`, walks a `match` expression, or interpolates a `literal` value through `interpolateWithCaptures`. There is no fan-out across "managed sidecar vs literal value" because that branch is gone from the AST. `interpolateWithCaptures` takes an optional `quoteValue` escaper: shell-fallthrough lines pass **`shellQuote`** (`src/runtime/kernel/prompt.ts`, the single canonical escaper) so every interpolated value — parameter, capture, `for` iterator, channel payload, and inline `${run …}` / `${ensure …}` capture result — is shell-quoted before it reaches `sh -c`, while every other value position interpolates the raw value. This is the one `sh -c` interpolation sink, so a caller-controlled value bound through `jaiph mcp` / `jaiph serve` cannot inject a command (finding H-1). + - One private `evaluateExpr(scope, expr, …)` dispatcher handles every value position — `const` / `return` / `send` / `say` step handlers and the body of every `exec` step delegate to it. It switches on `Expr.kind` to run the managed call (`call` / `inline_script`) or `prompt`, walks a `match` expression, or interpolates a `literal` value through `interpolateWithCaptures`. There is no fan-out across "managed sidecar vs literal value" because that branch is gone from the AST. `interpolateWithCaptures` takes an optional `quoteValue` escaper: shell-fallthrough lines pass **`shellQuote`** (defined in `src/runtime/kernel/prompt-config.ts` and re-exported through `prompt.ts`, the single canonical escaper) so every interpolated value — parameter, capture, `for` iterator, channel payload, and inline `${run …}` / `${run …}` capture result — is shell-quoted before it reaches `sh -c`, while every other value position interpolates the raw value. This is the one `sh -c` interpolation sink, so a caller-controlled value bound through `jaiph mcp` / `jaiph serve` cannot inject a command (finding H-1). - **Prompt transport-failure retry.** `runPromptStep` wraps each `executePrompt` invocation in a retry loop driven by the schedule resolved through `src/runtime/kernel/prompt-retry.ts` (default `15s → 1m → 10m → 30m → 2h`, six total attempts; configurable via `JAIPH_PROMPT_RETRY` / `JAIPH_PROMPT_RETRY_DELAYS`). Only the transport path (non-zero exit from the backend) is retried; invalid JSON and schema-validation failures return `{ ok: false }` on the first attempt. Each attempt emits its own `PROMPT_START` / `PROMPT_END` and `STEP_START` / `STEP_END`; each failure (and the final termination) logs a `LOGERR` through `RuntimeEventEmitter.emitLog`. The backoff sleep is injectable (`sleep` constructor option) and interruptible via `runtime.abort()` / an internal `AbortController` so SIGINT and in-process aborts halt the loop without further backend calls. Retry composes **below** `recover` / `catch` — backoff is exhausted before the failure reaches the recover loop. See [Configuration — Prompt retry on transport failure](configuration.md#prompt-retry-on-transport-failure). - **Idle-step warnings and idle-step kill.** While a leaf step (script or prompt) produces no stdout/stderr, the runtime emits a `LOGWARN` on a fixed cadence — `JAIPH_STEP_IDLE_WARN_SEC` (default 180s, so 180s / 360s / 540s / …) — through `createStepIdleOutputWarn` (`src/runtime/kernel/step-idle-warn.ts`); the next output chunk resets the cadence. This surfaces a stalled backend or long-running command without failing the run. The same tracker also enforces a hard idle-kill threshold for `script` steps. After `JAIPH_STEP_IDLE_KILL_SEC` (default 3600s, `0` disables) of silence it emits a `LOGERR` naming the step and idle duration and aborts a kill signal the step passes to `spawnAndCapture`. Aborting that signal terminates the step's subprocess through `killProcessTreeEscalating` (SIGTERM, then SIGKILL) and settles the step as a failure at once, without waiting for `close`, because a hung descendant that outlived the child while holding the stdout pipe open would otherwise keep the run stuck forever. The warn and kill cadences run off one idle clock but fire independently, and the kill fires at most once. Prompt steps drive no subprocess, so they get warnings only. So a leaf that goes silent overnight fails the run instead of holding the loop open. See [Configuration — Leaf step idle output](configuration.md#leaf-step-idle-output). - **Max-step circuit breaker.** `JAIPH_MAX_STEPS` (parsed by `parseMaxSteps` in `src/runtime/kernel/max-steps.ts`, `0` / empty / invalid disables it) bounds a runaway workflow that the per-prompt idle watchdog cannot catch — an unbounded loop, a channel or recursion cycle, or a self-referential `run` chain. A single `stepsExecuted` counter on `NodeWorkflowRuntime` increments on every executed non-trivia step across the whole run, and loop iterations and nested or recursive calls share it. Once it exceeds the cap the runtime emits a `LOGERR` (`maxStepsTrippedMessage`, `E_MAX_STEPS`), calls `abort()`, and returns a failure step result, so the run stops without a manual signal. See [Configuration — Overall run timeout and step cap](configuration.md#overall-run-timeout-and-step-cap). - Three sibling modules under `src/runtime/kernel/` carry concerns that used to live inline in the runtime file. Dependency direction is one-way (orchestrator → helpers/emitter/mock); no circular imports back. - **`runtime-arg-parser.ts`** — stateless call-argument parsing (`parseInlineCaptureCall`, `commaArgsToInterpolated`, `parseArgsRaw`, `parseInlineScriptAt`, `parseManagedArgAt`, `parseArgTokens`, `stripOuterQuotes`, `parsePromptSchema`, `sanitizeName`, `nowIso`) plus shared constants and the `ParsedArgToken` / `PromptSchemaField` types. The `interpolate` helper lives one layer down in `src/config.ts` (config resolution reuses it) and is re-exported here so kernel callers keep a single import site. Direct unit tests live in `runtime-arg-parser.test.ts`. - **`runtime-event-emitter.ts`** — `RuntimeEventEmitter` owns **`__JAIPH_EVENT__`** writes on stderr (step/log traffic when not suppressed), **`run_summary.jsonl`** appends for the wider timeline (including workflow/prompt records that are summary-first), plus step/prompt sequence counters. Constructed with `{ runId, runDir, env, getFrameStack, getAsyncIndices, suppressLiveEvents? }`; the runtime delegates structured emission to it. The optional `suppressLiveEvents` flag (forwarded from `NodeWorkflowRuntime`'s `suppressLiveEvents` option) skips the live stderr **`__JAIPH_EVENT__`** lines while **`appendRunSummaryLine`** keeps updating **`run_summary.jsonl`** — used by in-process callers like the test runner that share stderr with `node --test` reporter output. The CLI's spawned **`__workflow-runner`** child does not set it, so production runs stream events to stderr as before. - - **`runtime-mock.ts`** — `executeMockBodyDef` and `executeMockShellBody` for `*.test.jh` workflow/rule/script mocks. Shell-kind mocks run `bash -c`; steps-kind mocks dispatch back into the runtime via an `executeStepsBack` callback so the body runs against the full step interpreter. + - **`runtime-mock.ts`** — `executeMockBodyDef` and `executeMockShellBody` for `*.test.jh` def/script mocks. Shell-kind mocks run `bash -c`; steps-kind mocks dispatch back into the runtime via an `executeStepsBack` callback so the body runs against the full step interpreter. - `buildRuntimeGraph()` (`graph.ts`) accepts either an entry file path (legacy) or an already-loaded `ModuleGraph` and returns the runtime-ready view by injecting `ScriptDef` stubs for **`import script`** declarations so reference resolution matches the validated compile path without re-reading external script bodies. Cross-module refs are resolved from that graph at runtime. `RuntimeGraph` is a type alias for `ModuleGraph` — there is one canonical "all reachable modules" representation. The stub-injection helper (`attachScriptImportStubs`) is idempotent. - **Node Test Runner (`src/runtime/kernel/node-test-runner.ts`)** - - Executes `*.test.jh` test blocks using `NodeWorkflowRuntime` with mock support (mock prompts, mock workflow/rule/script bodies). Pure Node harness — no Bash test transpilation. + - Executes `*.test.jh` test blocks using `NodeWorkflowRuntime` with mock support (mock prompts, mock def/script bodies). Pure Node harness — no Bash test transpilation. - **JS kernel (`src/runtime/kernel/`)** - - Prompt execution (`prompt.ts`), streaming parse (`stream-parser.ts`), schema (`schema.ts`), **`mock.ts`** (sequential prompt responses / mock-arm dispatch from test env JSON), **`runtime-mock.ts`** (mock workflow/rule/script **bodies** for `*.test.jh`), **`emit.ts`** (durable **`run_summary.jsonl`** helpers — `appendRunSummaryLine`, `formatUtcTimestamp` — consumed by `RuntimeEventEmitter`), **`workflow-launch.ts`** (spawn contract). **`RuntimeEventEmitter`** (`runtime-event-emitter.ts`) owns live **`__JAIPH_EVENT__`** lines on stderr and coordinates summary writes plus step/prompt sequence counters. Script subprocesses are launched directly from `NodeWorkflowRuntime`. + - Prompt execution (`prompt.ts`), streaming parse (`stream-parser.ts`), schema (`schema.ts`), **`mock.ts`** (sequential prompt responses / mock-arm dispatch from test env JSON), **`runtime-mock.ts`** (mock def/script **bodies** for `*.test.jh`), **`emit.ts`** (durable **`run_summary.jsonl`** helpers — `appendRunSummaryLine`, `formatUtcTimestamp` — consumed by `RuntimeEventEmitter`), **`workflow-launch.ts`** (spawn contract). **`RuntimeEventEmitter`** (`runtime-event-emitter.ts`) owns live **`__JAIPH_EVENT__`** lines on stderr and coordinates summary writes plus step/prompt sequence counters. Script subprocesses are launched directly from `NodeWorkflowRuntime`. - **Formatter (`src/format/index.ts`, `src/format/emit.ts`)** - **Public entry.** Code outside the format package imports the format slice only through `src/format/index.ts`, which re-exports the formatter API (`emitModule` and the `EmitOptions` type). It is not an `export *` barrel of the tree. The `no-deep-imports-into-format` rule in `.dependency-cruiser.cjs` fails any outside import that reaches a `src/format/**` internal directly, such as `emit.ts`. Add a named re-export to `src/format/index.ts` instead of reaching in. Format is layer 1 beside parse, so its sources import only parse and types, never `src/cli`, `src/runtime`, or `src/transpile`. - - `jaiph format` rewrites `.jh` / `.test.jh` files into canonical style. `emitModule(ast, trivia, opts?)` reads the semantic AST together with the parallel **`Trivia`** store ([Trivia (CST layer)](#trivia-cst-layer)) to round-trip leading comments, top-level order, `config` body sequence, `"""..."""` and `bareSource` forms, the original quotedness of top-level `const` values (`EnvDeclDef.wasQuoted` — `true` for `"…"` / `"""…"""` sources, `undefined` for bare tokens — so a quoted value is never silently rewritten as bare based on whether it contains a space), and prompt / script body discriminators. Step emission switches on `WorkflowStepDef.type` (8 variants) and an `emitExpr` helper switches on `Expr.kind` (8 kinds) — there are no dual code paths for "managed sidecar vs literal value" because that branch was removed from the AST. Call arguments render straight off the typed `Arg[]` — `var` → bare name, `literal` → raw — so the formatter no longer re-parses any args string or consults a `bareIdentifierArgs` shadow field. Pure data→text emitter; no side-effects beyond file writes. Round-trip is bit-for-bit on every fixture under `examples/` and `test-fixtures/golden-ast/fixtures/` — pinned by `src/format/roundtrip.test.ts`, which asserts `parse → format → parse → format` converges in one step on every fixture. - -- **Docker runtime helper (`src/runtime/docker.ts`)** - - Parses mount specs, resolves Docker config (image, network, timeout), and builds the `docker run` invocation when the CLI enables **Docker sandboxing** for `jaiph run` (environment-driven; there is no `jaiph run --docker` flag — see [Sandboxing](sandboxing.md)). **Host-controlled image/network (finding M-6):** an entry file is untrusted, so when Docker is the active sandbox `resolveDockerConfig` rejects a file-declared `runtime.docker_image` (`E_DOCKER_IMAGE_HOST_ONLY`) and a file-declared isolation-breaking `runtime.docker_network` — `host`, `container:*`, `ns:*`, anything that is not `default` / `none` / a plain named bridge network (`isHostSafeInFileNetwork`) — (`E_DOCKER_NETWORK_HOST_ONLY`). The operator's `JAIPH_DOCKER_IMAGE` / `JAIPH_DOCKER_NETWORK` remain trusted and are used verbatim (they may even select `host`); host-safe in-file network values are still honoured. When Docker is off these keys are inert and not enforced. On **`win32`** the Docker sandbox is out of scope: **`resolveDockerConfig`** forces host-only mode (same UX as an explicit **`JAIPH_UNSAFE=true`**) with a one-line notice, so the CLI never probes `docker` and never hard-fails on a missing daemon (`JAIPH_DOCKER_ENABLED=true` cannot override this). The container runs the same **`jaiph run --raw`** / **`__workflow-runner`** entry as local execution. The default image is the official `ghcr.io/jaiphlang/jaiph-runtime` GHCR image tagged with the CLI version (`ghcr.io/jaiphlang/jaiph-runtime:`); every selected image must already contain `jaiph` (no auto-install or derived-image build at runtime). Image preparation (`prepareImage`) runs before the CLI banner: it checks whether the image is local, pulls with `--quiet` if needed (short status lines on stderr instead of Docker's default pull UI), and verifies that `jaiph` exists in the image. **Digest pinning + fail-closed verification (finding M-6):** the mutable tag alone is not the trust boundary — a registry compromise, a re-pointed tag, or a poisoned local cache under the same tag would substitute the sandbox rootfs. The expected manifest digest ships with the release (`RUNTIME_IMAGE_DIGEST`, baked from `package.json`'s `runtimeImageDigest`) and an operator can override it with `JAIPH_DOCKER_IMAGE_DIGEST` (`resolveExpectedDigest`). When a digest is pinned, a cold pull resolves the digest-pinned reference (`repo@sha256:…`, content-addressed) and tags it back to the run reference (`pullPinnedImage`); then `verifyImageDigest` inspects the resolved local image's registry digest and fails closed (`E_DOCKER_DIGEST_MISMATCH`, with re-pull recovery guidance) on **every** run, including the cache-hit path, before the image is used. Enforcement is skipped when no digest is pinned (a custom operator image with no `JAIPH_DOCKER_IMAGE_DIGEST` / `@sha256:`, or the default image before the release bakes its digest). **Hardened presence probe (finding M-8):** the image is workflow-influenced and is pulled before the check, so the verification probe (`buildImageProbeArgs`) runs the image with the same hardening as a real run (`--cap-drop ALL`, `--security-opt no-new-privileges`, a non-root `--user`, and `--network none`) and a non-login `sh -c`, so `command -v jaiph` resolves only PATH and the check never sources `/etc/profile` or `/etc/profile.d/*` scripts baked into the image. `spawnDockerProcess` does not pull or verify — it receives a pre-resolved image. The spawn call uses `stdio: ["ignore", "pipe", "pipe"]` — stdin is ignored so the Docker CLI does not block on stdin EOF, which would stall event streaming and hang the host CLI after the container exits. - - **Workspace immutability:** By default Docker runs cannot modify the host workspace. In the default **snapshot** mode the host takes a writable point-in-time clone of the workspace at run start (`/sandbox`, via `cloneWorkspaceForSandbox` in `src/runtime/docker.ts`) and bind-mounts that clone read-write at `/jaiph/workspace`; the live host checkout is never mounted, and the clone is discarded on exit. The clone content is **git-defined**: for a git workspace it is exactly `git ls-files --cached --others --exclude-standard` plus `.git/` wholesale (gitignored files — `node_modules/`, `.env`, build output — are absent, never scanned); git is the sole ignore oracle (no reimplemented gitignore matcher). A non-git workspace (no `.git` at the root, or `git ls-files` fails) falls back to copying everything. See [Sandboxing — What the snapshot contains](sandboxing.md#snapshot-content). The only host-writable path is `/jaiph/run` (run artifacts), and the snapshot source under it is masked from the container with a tmpfs at `/jaiph/run/sandbox`. Workflows that need to capture workspace changes should write files (for example a `git diff` into a temp path) and publish them with `artifacts.save()`. The explicit opt-in **inplace** mode (truthy **`JAIPH_INPLACE`** — `1` or `true`, or `jaiph run --inplace`) breaks this contract on purpose — the host workspace itself is bind-mounted read-write so the run's edits persist live on the host, with the rest of the sandbox (caps, env allowlist, mount set) unchanged. See [Sandboxing](sandboxing.md) for the full contract and [Save artifacts](artifacts.md). - - **Container teardown on interrupt / timeout:** `spawnDockerProcess` assigns every container a deterministic `--name` (`jaiph-run-`, emitted immediately after `run --rm`) so it can be force-removed by name later. A `docker run --rm` container can outlive its host `docker` client (Docker Desktop / detached behaviour), so killing the client's process tree alone does not guarantee the container stops. On SIGINT/SIGTERM the run's `onSignalCleanup` calls **`stopDockerRunOnSignal`**, and the run-timeout kill (`E_TIMEOUT`) calls **`stopDockerContainer`** directly — both run `docker kill ` (bounded 5 s) then `docker rm -f ` (bounded 10 s), best-effort, so the `--rm` container disappears from `docker ps` within a bounded window. Splitting kill from rm avoids macOS Docker Desktop lock contention where a single `docker rm -f` on a still-running container can block for the full timeout. Order matters: the container is stopped **before** `cleanupDocker` removes the host workspace snapshot at `/sandbox`, because that snapshot is bind-mounted into the container. The per-call cancel path in the shared workflow-call executor (`src/cli/shared/workflow-call.ts`, which backs both `jaiph mcp` tool calls and `jaiph serve`) applies the same teardown — `stopDockerContainer` then `cancelRunProcess`. Both sandbox modes (snapshot, inplace) share this contract. See [Sandboxing — interrupting a Docker run](sandboxing.md#interrupting-a-docker-run). + - `jaiph format` rewrites `.jh` / `.test.jh` files into canonical style. `emitModule(ast, trivia, opts?)` reads the semantic AST together with the parallel **`Trivia`** store ([Trivia (CST layer)](#trivia-cst-layer)) to round-trip leading comments, top-level order, `config` body sequence, `"""..."""` and `bareSource` forms, the original quotedness of top-level `const` values (`EnvDeclDef.wasQuoted` — `true` for `"…"` / `"""…"""` sources, `undefined` for bare tokens — so a quoted value is never silently rewritten as bare based on whether it contains a space), and prompt / script body discriminators. Step emission switches on `StepDef.type` (8 variants) and an `emitExprFirstLine` helper switches on `Expr.kind` (7 kinds) — there are no dual code paths for "managed sidecar vs literal value" because that branch was removed from the AST. Call arguments render straight off the typed `Arg[]` — `var` → bare name, `literal` → raw — so the formatter no longer re-parses any args string or consults a `bareIdentifierArgs` shadow field. Pure data→text emitter; no side-effects beyond file writes. Round-trip is bit-for-bit on every fixture under `examples/` and `test-fixtures/golden-ast/fixtures/` — pinned by `src/format/roundtrip.test.ts`, which asserts `parse → format → parse → format` converges in one step on every fixture. ## Local module graph {: #local-module-graph} @@ -116,11 +114,10 @@ The `src/` import graph is an acyclic layered DAG: parse/format → transpile The toolchain has one canonical representation, **`ModuleGraph`**, for all `.jh` modules reachable from an entry point, parsed once. The same graph is used by the validator, the script emitter, and the runtime. On the default local `jaiph run` path the graph also crosses the parent CLI to child runner boundary, so each reachable `.jh` is parsed exactly once per run. - **`loadModuleGraph(entryFile, workspaceRoot?)`** (`src/transpile/module-graph.ts`) walks the entry plus its transitive `import` edges through `resolveImportPath` and returns `{ entryFile, workspaceRoot?, modules: Map }> }`. **`/`** imports (for example `jaiphlang/queue`) resolve through the workspace library fallback under `.jaiph/libs/` when a relative path does not exist. Within the graph pipeline this is the only routine that reads `.jh` sources from disk, and `parsejaiph(source, filePath)` itself is I/O-pure. A couple of paths outside the graph pipeline read `.jh` on their own, such as `runWorkflowRaw` (`jaiph run --raw`) and the exported `loadImportedModules` helper. -- **`src/cli/commands/run.ts`** calls `loadModuleGraph` once after path normalization. The entry AST is reused for the banner / `runtime` config via **`metadataToConfig(resolveModuleMetadata(mod, env))`** — `resolveModuleMetadata` resolves the `config { … }` block's interpolation before `metadataToConfig` flattens it. The same graph is passed to **`buildScriptsFromGraph(graph, outDir)`**, which calls **`emitScriptsForModuleFromGraph`** per reachable module; each call runs **`validateModule(ast, graph)`** against the in-memory ASTs. -- **Process boundary.** The CLI serializes the graph with **`writeModuleGraph`** to **`/.jaiph-module-graph.json`** (deterministic JSON: entries sorted by absolute path; ASTs included verbatim). It points the spawned **`__workflow-runner`** child at the file through the internal env var **`JAIPH_MODULE_GRAPH_FILE`**. The runner reads it back with **`readModuleGraph`** and passes the result to **`buildRuntimeGraph(graph)`**, which produces the runtime view (with **`import script`** stub injection) without touching disk. Cross-module workflow / rule / script resolution matches the on-disk load path. -- **Scope of the env-var hand-off.** `JAIPH_MODULE_GRAPH_FILE` is set on the host (non-Docker) execution paths that spawn the local **`__workflow-runner`** child: interactive **`jaiph run`** when Docker sandboxing is disabled (`dockerConfigForBanner.enabled === false`), and the shared workflow-call executor (`src/cli/shared/workflow-call.ts`) that backs `jaiph mcp` tool calls and `jaiph serve`. It is **not** set on these paths, which load the graph from disk inside the runner instead: +- **`src/cli/commands/run.ts`** calls `loadModuleGraph` once after path normalization. The entry AST is reused to resolve the `runtime` config via **`metadataToConfig(resolveModuleMetadata(mod, env))`**, which feeds **`resolveRuntimeEnv`** (the banner itself takes only the entry file's basename). `resolveModuleMetadata` resolves the `config { … }` block's interpolation before `metadataToConfig` flattens it. The same graph is passed to **`buildScriptsFromGraph(graph, outDir)`**, which calls **`emitScriptsForModuleFromGraph`** per reachable module; each call runs **`validateModule(ast, graph)`** against the in-memory ASTs. +- **Process boundary.** The CLI serializes the graph with **`writeModuleGraph`** to **`/.jaiph-module-graph.json`** (deterministic JSON: entries sorted by absolute path; ASTs included verbatim). It points the spawned **`__workflow-runner`** child at the file through the internal env var **`JAIPH_MODULE_GRAPH_FILE`**. The runner reads it back with **`readModuleGraph`** and passes the result to **`buildRuntimeGraph(graph)`**, which produces the runtime view (with **`import script`** stub injection) without touching disk. Cross-module def / script resolution matches the on-disk load path. +- **Scope of the env-var hand-off.** `JAIPH_MODULE_GRAPH_FILE` is set on the execution paths that spawn the local **`__workflow-runner`** child: interactive **`jaiph run`**, and the shared workflow-call executor (`callDefHost` in `src/cli/shared/workflow-call-exec.ts`) that backs `jaiph mcp` tool calls and `jaiph serve`. It is **not** set on these paths, which load the graph from disk inside the runner instead: - **`jaiph run --raw`** — `runWorkflowRaw` (`src/cli/commands/run.ts`) calls `buildScripts` directly without writing the graph file; the runner uses inherited stdio and falls back to `loadModuleGraph` from the source file. - - **Docker `jaiph run`** — the host writes the graph file under `outDir`, but skips the env var because the inner container command is `jaiph run --raw …` and the host bind-mount layout does not plumb the cache file inside the container. - **`jaiph test`** — `runSingleTestFile` builds the graph in `src/cli/commands/test.ts` and threads it through `runTestFile(graph, ...)` directly (no env var needed; same process). When the env var is absent, the runner falls back to the disk-walk parse path, which preserves the prior behavior. @@ -140,7 +137,7 @@ User-visible contracts (banner, hooks, run artifacts, `run_summary.jsonl`, `retu ### CLI responsibilities - Parse, validate, and launch workflows/tests. -- Own **process spawn** for `jaiph run` (detached workflow runner process group for signal propagation). Terminating a run means terminating the whole tree — the detached leader plus the agent backends and script children it spawned — routed through **`killProcessTree(pid, signal)`** (`src/runtime/kernel/portability.ts`), the single sanctioned home for group kills. On POSIX it signals the leader's process group with **`process.kill(-pid, signal)`**, falling back to a per-process kill if the group no longer exists (`ESRCH`). On **`win32`** a negative-PID group kill throws and a per-process kill would orphan the children, so it force-kills the tree with **`taskkill /pid /T /F`** (spawned, not shelled), degrading to a per-process kill if `taskkill` cannot be launched. Because `taskkill /F` is already forceful, a follow-up `SIGKILL` escalation after a `SIGTERM`/`SIGINT` is a **documented no-op** on `win32`. All group-kill call sites route through this helper: run teardown and the host run-timeout kill — `armRunTimeout`, the parent-enforced wall-clock cap for host mode (`JAIPH_RUN_TIMEOUT`; see [Configuration — Overall run timeout and step cap](configuration.md#overall-run-timeout-and-step-cap)) — both in `src/cli/run/lifecycle.ts`, the prompt watchdog (`src/runtime/kernel/prompt.ts`), and the Docker run-timeout kill (`src/runtime/docker.ts`). +- Own **process spawn** for `jaiph run` (detached workflow runner process group for signal propagation). Terminating a run means terminating the whole tree — the detached leader plus the agent backends and script children it spawned — routed through **`killProcessTree(pid, signal)`** (`src/runtime/kernel/portability.ts`), the single sanctioned home for group kills. On POSIX it signals the leader's process group with **`process.kill(-pid, signal)`**, falling back to a per-process kill if the group no longer exists (`ESRCH`). On **`win32`** a negative-PID group kill throws and a per-process kill would orphan the children, so it force-kills the tree with **`taskkill /pid /T /F`** (spawned, not shelled), degrading to a per-process kill if `taskkill` cannot be launched. Because `taskkill /F` is already forceful, a follow-up `SIGKILL` escalation after a `SIGTERM`/`SIGINT` is a **documented no-op** on `win32`. All group-kill call sites route through this helper: run teardown and the host run-timeout kill — `armRunTimeout`, the parent-enforced wall-clock cap (`JAIPH_RUN_TIMEOUT`; see [Configuration — Overall run timeout and step cap](configuration.md#overall-run-timeout-and-step-cap)) — both in `src/cli/run/lifecycle.ts`, and the prompt watchdog (`src/runtime/kernel/prompt.ts`). - Parse live runtime events; render terminal progress; trigger hooks — skipped in **`jaiph run --raw`** (child stdio inherited; see [CLI](cli.md#jaiph-run)). ## Contracts @@ -182,13 +179,13 @@ Step sequence numbers are monotonic and unique per run: `RuntimeEventEmitter` al Every line written to `run_summary.jsonl` by `RuntimeEventEmitter` carries a `prev_hash` field. The field holds a **keyed** HMAC-SHA256 (in hex) of the previous raw JSON line — `chainHmac(key, previousLine)` — with `chainHmac(key, CHAIN_GENESIS)` for the first line. The key is a per-run 256-bit secret (`generateChainKey`), so the chain is not reproducible from the public algorithm alone: rewriting a line, or dropping a line and re-linking the survivors, invalidates the chain and cannot be re-forged without the key. -**Key isolation (finding H-3).** The journal is written by the trusted kernel process, but the audited workflow — its `script` steps and prompt/agent subprocesses — must not be able to forge the chain. The key travels in the kernel process env under `JAIPH_CHAIN_KEY` (`CHAIN_KEY_ENV`) and is scrubbed from **every** subprocess env: `scrubTrustedKeys` (`node-workflow-runtime.ts`) removes it — along with the journal path `JAIPH_RUN_SUMMARY_FILE` — from every script scope, and `scrubPromptEnv` (`env-allowlist.ts`) drops it at the agent boundary even though the `JAIPH_` prefix otherwise forwards run-control keys into the Docker container (the in-container kernel legitimately needs it). The host (`src/cli/commands/run.ts`, `src/cli/shared/workflow-call.ts`) generates the key, forwards it to the runner, and — once the run is terminal — persists it in an operator-side store (`writeChainKey`) so read/export boundaries can verify it. +**Key isolation (finding H-3).** The journal is written by the trusted kernel process, but the audited workflow — its `script` steps and prompt/agent subprocesses — must not be able to forge the chain. The key travels in the kernel process env under `JAIPH_CHAIN_KEY` (`CHAIN_KEY_ENV`) and is scrubbed from **every** subprocess env: `scrubTrustedKeys` (`node-workflow-runtime.ts`) removes it — along with the journal path `JAIPH_RUN_SUMMARY_FILE` — from every script scope, and `scrubPromptEnv` (`env-allowlist.ts`) drops it at the agent boundary. The host (`src/cli/commands/run.ts`, `src/cli/shared/workflow-call.ts`) generates the key, forwards it to the runner, and — once the run is terminal — persists it in an operator-side store (`writeChainKey`) so read/export boundaries can verify it. -**Key storage outside the run directory (finding M-3).** The key is **not** written into the run directory, which is agent-writable (`$JAIPH_RUN_DIR` for script steps, bind-mounted `rw` at `/jaiph/run` under Docker). Storing it there let a workflow's first script step `mkdir "$JAIPH_RUN_DIR/.chain-key"` to squat the path so the host's write threw and was swallowed, then rewrite the journal freely with no integrity failure surfaced. Instead the key lives in an operator-side store — `resolveAuditKeyStore` (`emit.ts`), default `~/.jaiph/audit-keys`, override `JAIPH_AUDIT_KEY_DIR` — that is never mounted into a container. Each run gets one entry directory `/` holding the secret `key` file; the directory's existence is the durable "this run was keyed" marker. Persistence is a **hard error**: `writeChainKey` no longer swallows failures, and it creates the marker directory before the key, so even a partial write leaves the run marked keyed-but-keyless (which fails closed below) rather than silently unverifiable. +**Key storage outside the run directory (finding M-3).** The key is **not** written into the run directory, which is agent-writable (`$JAIPH_RUN_DIR` for script steps). Storing it there let a workflow's first script step `mkdir "$JAIPH_RUN_DIR/.chain-key"` to squat the path so the host's write threw and was swallowed, then rewrite the journal freely with no integrity failure surfaced. Instead the key lives in an operator-side store: `resolveAuditKeyStore` (`emit.ts`), default `~/.jaiph/audit-keys`, override `JAIPH_AUDIT_KEY_DIR`. Each run gets one entry directory `/` holding the secret `key` file; the directory's existence is the durable "this run was keyed" marker. Persistence is a **hard error**: `writeChainKey` no longer swallows failures, and it creates the marker directory before the key, so even a partial write leaves the run marked keyed-but-keyless (which fails closed below) rather than silently unverifiable. -**Verification at read/export boundaries.** `verifyRunSummaryChain(filePath, key, opts?)` walks each line, checks `prev_hash` against the recomputed keyed digest, and returns `{ ok: false, error }` at the first broken link (a missing/unreadable journal is a failure, not a silent pass). With `opts.requireTerminal` it additionally requires the journal to **end with the `WORKFLOW_END` terminal marker** (`TERMINAL_EVENT_TYPE`) — the chain commits to prefix integrity but not to length, so deleting the last *K* lines of a completed journal leaves a shorter-but-valid chain that would otherwise verify; requiring the terminal marker rejects any post-terminal tail truncation (finding L-3). `verifyRunJournal(runDir)` wraps it (always with `requireTerminal`, since a key is persisted only once the run is terminal): it looks up the run's store entry and returns `{ verified: false, ok: true }` when the run has **no** entry (an unkeyed/legacy run that cannot be verified — never blocked), `{ verified: true, ok: false }` when the run **was** keyed but the key is missing at verification time (**fail closed** — a keyed run whose key vanished must not downgrade to "not verified" and let a tampered journal through), or `{ verified: true, ok }` with the chain result otherwise. Every read/export boundary hard-fails when `verified && !ok`: run listing (`loadPersistedRuns` marks the run `failed` with `TAMPERED_RESULT_TEXT`), `GET /v1/runs/{id}/events` (`409 E_TAMPERED`), and OTLP/Sentry export (skip + warn, never POST a tampered journal). +**Verification at read/export boundaries.** `verifyRunSummaryChain(filePath, key, opts?)` walks each line, checks `prev_hash` against the recomputed keyed digest, and returns `{ ok: false, error }` at the first broken link (a missing/unreadable journal is a failure, not a silent pass). With `opts.requireTerminal` it additionally requires the journal to **end with the `RUN_END` terminal marker** (`TERMINAL_EVENT_TYPE`) — the chain commits to prefix integrity but not to length, so deleting the last *K* lines of a completed journal leaves a shorter-but-valid chain that would otherwise verify; requiring the terminal marker rejects any post-terminal tail truncation (finding L-3). `verifyRunJournal(runDir)` wraps it (always with `requireTerminal`, since a key is persisted only once the run is terminal): it looks up the run's store entry and returns `{ verified: false, ok: true }` when the run has **no** entry (an unkeyed/legacy run that cannot be verified — never blocked), `{ verified: true, ok: false }` when the run **was** keyed but the key is missing at verification time (**fail closed** — a keyed run whose key vanished must not downgrade to "not verified" and let a tampered journal through), or `{ verified: true, ok }` with the chain result otherwise. Every read/export boundary hard-fails when `verified && !ok`: run listing (`loadPersistedRuns` marks the run `failed` with `TAMPERED_RESULT_TEXT`), `GET /v1/runs/{id}/events` (`409 E_TAMPERED`), and OTLP/Sentry export (skip + warn, never POST a tampered journal). -**Scope of the guarantee.** A workflow script step cannot read the key or the journal path from its env, and cannot alter the journal in any way that verifies — any rewrite or omitted line is rejected, and any truncation *during* the run is caught because the kernel keeps appending under the pre-truncation head. A *post-run* clean truncation of a completed journal's tail is also rejected: the terminal-marker check (finding L-3) fails a keyed journal that no longer ends with `WORKFLOW_END`. Because a `.jh` host run and its `script` steps execute under the same OS user, a hash chain still cannot defend against a post-run same-user process that deletes the run's store entry, which makes the run unverifiable (`verified:false`) rather than a detectable tamper. Under Docker sandboxing the key never enters the container, and the key store lives outside every bind mount, so an in-sandbox workflow can neither read the key nor reach the store to squat or delete it. +**Scope of the guarantee.** A workflow script step cannot read the key or the journal path from its env, and cannot alter the journal in any way that verifies — any rewrite or omitted line is rejected, and any truncation *during* the run is caught because the kernel keeps appending under the pre-truncation head. A *post-run* clean truncation of a completed journal's tail is also rejected: the terminal-marker check (finding L-3) fails a keyed journal that no longer ends with `RUN_END`. Because a `.jh` host run and its `script` steps execute under the same OS user, a hash chain still cannot defend against a post-run same-user process that deletes the run's store entry, which makes the run unverifiable (`verified:false`) rather than a detectable tamper. A workflow cannot read the key from its env, and the key store lives outside the run directory. #### Secret redaction @@ -200,7 +197,7 @@ Before `RuntimeEventEmitter` writes an event line to `run_summary.jsonl`, it red - the `params` key/value pairs of every `STEP_END`, which hold the positional or named arguments passed to a `run`, tool, or `script` step, so a secret passed as an argument is redacted the same way as the step's captured output (`emitStep`), - the `message` field of every durable `LOG`, `LOGWARN`, and `LOGERR` event, so a value a workflow passes to `log`, `logwarn`, or `logerr` is redacted in the journal the same way as a step's captured output (`emitLog`). -The rule covers backend API keys such as `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and `CURSOR_API_KEY` (the same names on the [Docker env allowlist](sandboxing.md)). +The rule covers backend API keys such as `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and `CURSOR_API_KEY`. The same credential rule lives in one shared helper, **`redactCredentials`** (`src/runtime/kernel/redact.ts`). The helper is also the redaction boundary for returned call results. `composeResult` (`src/cli/shared/workflow-call.ts`) redacts a failed call's diagnostic capture (the failed-step detail, the raw stderr and stdout, and the collected `log` messages) before it becomes `jaiph serve`'s `result_text` or a `jaiph mcp` tool result. A successful workflow's return value is intentional API output rather than diagnostic capture, so it is returned verbatim. The journal that `redactCredentials` produces is what the OTLP export (`otlp.ts`), the Sentry export (`sentry.ts`), and `GET /v1/runs/{id}/events` (`handler.ts`) read back verbatim, so broadening the rule tightens all four surfaces at once. @@ -208,23 +205,23 @@ The same credential rule lives in one shared helper, **`redactCredentials`** (`s ## Channels and hooks in context -Channels are validated at compile time (`validateReferences` and the send RHS rules) and executed through an in-memory queue and dispatch in the Node runtime. Durable **`inbox/`** files under the run directory appear only for routed sends, as an audit copy (see [Inbox & Dispatch](inbox.md)). Hooks are CLI-only. They load from `hooks.json` and run as shell commands with JSON on stdin, driven by the same `__JAIPH_EVENT__` stream as the progress UI. One dispatch contract covers all three invocation modes: interactive `jaiph run` through the run emitter, and `jaiph serve` and `jaiph mcp` calls through `callWorkflow`'s shared event collector. `jaiph run --raw` and `jaiph test` are documented as no-hook lanes (see [Hooks](hooks.md)). +Channels are validated at compile time (`validateReferences` and the send RHS rules) and executed through an in-memory queue and dispatch in the Node runtime. Durable **`inbox/`** files under the run directory appear only for routed sends, as an audit copy (see [Inbox & Dispatch](inbox.md)). Hooks are CLI-only. They load from `hooks.json` and run as shell commands with JSON on stdin, driven by the same `__JAIPH_EVENT__` stream as the progress UI. One dispatch contract covers all three invocation modes: interactive `jaiph run` through the run emitter, and `jaiph serve` and `jaiph mcp` calls through `callDef`'s shared event collector. `jaiph run --raw` and `jaiph test` are documented as no-hook lanes (see [Hooks](hooks.md)). ## Test runner integration (`*.test.jh` in the kernel) -`jaiph test` wires into the same stack as `jaiph run`. `runSingleTestFile` (`src/cli/commands/test.ts`) calls `loadModuleGraph(testFileAbs, workspaceRoot)` once, then threads the resulting `ModuleGraph` through `buildScriptsFromGraph(graph, tmpDir)` and `runTestFile(graph, …)`. `runTestFile` calls `buildRuntimeGraph(graph)` once per file and the runtime view is reused across all blocks and `test_run_workflow` steps (the import closure is constant for a given test file within a single process run). Each `test_run_workflow` step resolves mocks against that runtime view, then constructs `NodeWorkflowRuntime` with `mockBodies` / mock prompt env, passing **`suppressLiveEvents: true`** so **`RuntimeEventEmitter`** skips writing **`__JAIPH_EVENT__`** lines to **stderr** while still appending **`run_summary.jsonl`** for that run. Without `suppressLiveEvents`, every workflow event would print to the test process's stderr and swamp the `node --test` reporter output. Mock prompts, workflows, rules, and scripts are supported through the runtime's mock infrastructure. +`jaiph test` wires into the same stack as `jaiph run`. `runSingleTestFile` (`src/cli/commands/test.ts`) calls `loadModuleGraph(testFileAbs, workspaceRoot)` once, then threads the resulting `ModuleGraph` through `buildScriptsFromGraph(graph, tmpDir)` and `runTestFile(graph, …)`. `runTestFile` calls `buildRuntimeGraph(graph)` once per file and the runtime view is reused across all blocks and `test_run_def` steps (the import closure is constant for a given test file within a single process run). Each `test_run_def` step resolves mocks against that runtime view, then constructs `NodeWorkflowRuntime` with `mockBodies` / mock prompt env, passing **`suppressLiveEvents: true`** so **`RuntimeEventEmitter`** skips writing **`__JAIPH_EVENT__`** lines to **stderr** while still appending **`run_summary.jsonl`** for that run. Without `suppressLiveEvents`, every run event would print to the test process's stderr and swamp the `node --test` reporter output. Mock prompts, defs, and scripts are supported through the runtime's mock infrastructure. -The `buildScriptsFromGraph` call writes `scripts/` so imported workflows have paths under `JAIPH_SCRIPTS`. Unrelated `*.jh` files elsewhere in the repo are not compiled unless imported. +The `buildScriptsFromGraph` call writes `scripts/` so imported modules have paths under `JAIPH_SCRIPTS`. Unrelated `*.jh` files elsewhere in the repo are not compiled unless imported. Authoring rules, fixtures, and mock syntax for `*.test.jh` are documented in [Testing](testing.md), not here. ## CLI progress reporting pipeline -The progress UI combines a **static** step tree derived from the workflow AST (`src/cli/run/progress.ts`) with **live** updates from the runtime event stream. Event wiring: `src/cli/run/events.ts` and `src/cli/run/stderr-handler.ts` parse `__JAIPH_EVENT__` lines; `src/cli/run/emitter.ts` bridges into the renderer. Line-oriented formatting (`formatStartLine`, `formatHeartbeatLine`, `formatCompletedLine`) lives primarily in `src/cli/run/display.ts`, which shares some display helpers with `progress.ts`. Async branch numbering (subscript ₁₂₃… prefixes) is driven by `async_indices` on step and log events — the runtime propagates a chain of 1-based branch indices through `AsyncLocalStorage`, and the stderr handler renders them at the appropriate indent level. Whether ANSI SGR colors are emitted is a single policy — **`canUseAnsi()`** (`src/runtime/kernel/portability.ts`) returns `isTTY && NO_COLOR` unset — and every color emission site (`src/cli/commands/run.ts`, `src/cli/run/progress.ts`, `src/cli/shared/errors.ts`) routes its gate through it rather than re-deriving `isTTY && NO_COLOR` locally. On Windows 10+ Node enables console VT processing automatically, so `isTTY` is a sufficient ANSI proxy with no extra win32 branch. `const` steps whose `Expr` value is `kind: "match"` are walked for nested `run` / `ensure` arms; matched targets appear as child items in the step tree (for example `▸ workflow my_flow` or `▸ rule my_rule` under the `const` row). This pipeline does not apply to **`jaiph run --raw`**. +The progress UI combines a **static** step tree derived from the def AST (`src/cli/run/progress.ts`) with **live** updates from the runtime event stream. Event wiring: `src/cli/run/events.ts` and `src/cli/run/stderr-handler.ts` parse `__JAIPH_EVENT__` lines; `src/cli/run/emitter.ts` bridges into the renderer. Line-oriented formatting (`formatStartLine`, `formatHeartbeatLine`, `formatCompletedLine`) lives primarily in `src/cli/run/display.ts`, which shares some display helpers with `progress.ts`. Async branch numbering (subscript ₁₂₃… prefixes) is driven by `async_indices` on step and log events — the runtime propagates a chain of 1-based branch indices through `AsyncLocalStorage`, and the stderr handler renders them at the appropriate indent level. Whether ANSI SGR colors are emitted is a single policy — **`canUseAnsi()`** (`src/runtime/kernel/portability.ts`) returns `isTTY && NO_COLOR` unset — and every color emission site (`src/cli/commands/run.ts`, `src/cli/run/progress.ts`, `src/cli/shared/errors.ts`, `src/cli/shared/server-log.ts`) routes its gate through it rather than re-deriving `isTTY && NO_COLOR` locally. On Windows 10+ Node enables console VT processing automatically, so `isTTY` is a sufficient ANSI proxy with no extra win32 branch. `const` steps whose `Expr` value is `kind: "match"` are walked for nested `run` arms; matched targets appear as child items in the step tree (for example `▸ def my_flow` under the `const` row). This pipeline does not apply to **`jaiph run --raw`**. ## Distribution: Node vs Bun standalone -- **Development / npm:** `npm run build` runs `npm run embed-assets` (regenerates **`src/runtime/embedded-assets.ts`** from `docs/jaiph-skill.md`, and **`src/version.ts`** from `package.json`'s `version` field), then `tsc`, and copies **`src/runtime/`** to **`dist/src/runtime/`** (kernel, `docker.ts`, etc.). The published `jaiph` bin is **`node dist/src/cli.js`**. +- **Development / npm:** `npm run build` runs `npm run embed-assets` (regenerates **`src/runtime/embedded-assets.ts`** from `docs/jaiph-skill.md`, and **`src/version.ts`** from `package.json`'s `version` field), then `tsc`, and copies **`src/runtime/`** to **`dist/src/runtime/`** (kernel JS for the compiled CLI). The published `jaiph` bin is **`node dist/src/cli.js`**. - **Standalone:** `npm run build:standalone` runs the same build, copies **`dist/src/runtime`** to **`dist/runtime`** beside the binary, then `bun build --compile ./src/cli.ts --outfile dist/jaiph`. Workflow launch self-spawns via **`process.execPath`** using the internal **`__workflow-runner`** argv marker (`src/runtime/kernel/workflow-launch.ts` + `src/cli/index.ts`): the node build invokes `node dist/src/cli.js __workflow-runner …`; the bun-compiled binary invokes itself, `jaiph __workflow-runner …`. The reserved marker is excluded from `--help`/usage and the file-shorthand path. `docs/jaiph-skill.md` is also embedded base64 inside the executable via **`src/runtime/embedded-assets.ts`**, so the standalone artifact is **fully self-contained** — no sibling `runtime/` or `docs/` files required. The displayed `jaiph --version` string is sourced from the generated **`src/version.ts`** (codegen'd from `package.json` by `embed-assets`), so the literal is statically baked into both the `tsc` and the `bun build --compile` outputs without a runtime read of `package.json`. **Bash** (or whatever shebang your `script` steps use) is still required on the host for script subprocesses. Ship **`dist/jaiph`** alone, or with **`dist/runtime`** alongside it for parity with the npm layout (table in [Contributing](contributing.md)). - **Release artifacts:** `.github/workflows/release.yml` cross-compiles the standalone binary for **darwin/linux × arm64/x64** plus **windows x64** (`jaiph-windows-x64.exe`; Bun has no windows arm64 target) on **`v*`** tag pushes and on pushes to the **`nightly`** branch, generates a `SHA256SUMS` covering the five binaries, signs it with **minisign** (`SHA256SUMS.minisig`), runs `--version` sanity gates on the linux-x64 and windows-x64 outputs, and uploads the seven assets (five binaries + `SHA256SUMS` + `SHA256SUMS.minisig`) to the matching GitHub Release (stable tag or rolling **`nightly`** prerelease). Asset filenames are fixed by the installer contract — see [Contributing — Release asset naming contract](contributing.md#release-asset-naming-contract). Two installers consume these assets: the POSIX **`docs/install`** (`curl … | bash`, darwin/linux; rejects Windows and points at the PowerShell one) and **`docs/install.ps1`** (`irm https://jaiph.org/install.ps1 | iex`, Windows x64), which downloads `jaiph-windows-x64.exe`, verifies it against `SHA256SUMS` with `Get-FileHash`, and installs to `%LOCALAPPDATA%\jaiph\bin`. @@ -250,19 +247,15 @@ flowchart TD BS2 --> Transpile LMG2 --> TR[Node Test Runner in-process] - Transpile -->|jaiph run local| RW[__workflow-runner child] - Transpile -->|jaiph run Docker| DC[Container: jaiph run --raw] - LMG1 -. JAIPH_MODULE_GRAPH_FILE (local non-Docker only) .-> RW + Transpile -->|jaiph run| RW[__workflow-runner child] + LMG1 -. JAIPH_MODULE_GRAPH_FILE (non --raw) .-> RW RW --> G[buildRuntimeGraph from graph] G --> GRAPH[RuntimeGraph] RW --> RT[NodeWorkflowRuntime] RT --> GRAPH - DC --> G - DC --> RT - - TR -->|test_run_workflow| G + TR -->|test_run_def| G TR --> RT RT -->|script steps| SCRIPT[Managed script subprocesses] @@ -278,7 +271,7 @@ flowchart TD HK --> HPROC[Hook shell commands] ``` -**Emit artifacts:** `buildScripts()` persists **only** extracted **`script`** bodies under `scripts/`. No workflow-level shell modules or `jaiph_stdlib.sh` are produced. +**Emit artifacts:** `buildScripts()` persists **only** extracted **`script`** bodies under `scripts/`. No def-level shell modules or `jaiph_stdlib.sh` are produced. ## Sequence diagram: regular flow (`*.jh`) @@ -305,26 +298,19 @@ sequenceDiagram Prep->>TF: loop: validateModule + emit (in-memory AST) TF-->>Prep: scripts/ atomic only Prep-->>CLI: scriptsDir + env JAIPH_SCRIPTS - alt local (non-Docker) - CLI->>CLI: writeModuleGraph(/.jaiph-module-graph.json) - Note over CLI: set JAIPH_MODULE_GRAPH_FILE on child env - CLI->>Runner: spawn detached __workflow-runner child - else Docker - CLI->>CLI: prepareImage (pull --quiet + verify jaiph) - Note over CLI: runs before banner so pull doesn't interleave - CLI->>Runner: spawn container running jaiph run --raw - Note over CLI: CLI parses events on stderr only - end - alt JAIPH_MODULE_GRAPH_FILE set (local non-Docker) + CLI->>CLI: writeModuleGraph(/.jaiph-module-graph.json) + Note over CLI: set JAIPH_MODULE_GRAPH_FILE on child env (skipped for --raw) + CLI->>Runner: spawn detached __workflow-runner child + alt JAIPH_MODULE_GRAPH_FILE set Runner->>Runner: readModuleGraph(file) Runner->>Graph: buildRuntimeGraph(graph) Note over Graph: no .jh re-reads - else absent (Docker / --raw / test runner) + else absent (--raw / test runner) Runner->>Runner: loadModuleGraph(sourceAbs, workspace) Runner->>Graph: buildRuntimeGraph(graph) end Graph-->>Runner: RuntimeGraph - Runner->>Runtime: runDefault(run args) + Runner->>Runtime: runMain(run args) Runtime->>Kernel: prompt / managed scripts / emit / inbox Runtime-->>CLI: __JAIPH_EVENT__ on stderr Runtime->>Report: run_summary.jsonl + step artifacts @@ -333,8 +319,6 @@ sequenceDiagram CLI-->>User: PASS/FAIL ``` -**Docker:** the inner container command is **`jaiph run --raw …`** (see [Sandboxing](sandboxing.md)): no banner or progress UI inside the container; **`__JAIPH_EVENT__`** lines still appear on stderr for the host CLI to parse. - ## Sequence diagram: `jaiph test` flow ```mermaid @@ -358,9 +342,9 @@ sequenceDiagram Graph-->>TestRunner: RuntimeGraph cached loop each test block TestRunner->>TestRunner: mocks / shell steps / expectations - opt test_run_workflow step + opt test_run_def step TestRunner->>Runtime: new runtime mockBodies from block (reuses cached graph) - Runtime->>Runtime: runNamedWorkflow(ref args) + Runtime->>Runtime: runNamedDef(ref args) Runtime-->>TestRunner: status output returnValue error end end @@ -373,8 +357,8 @@ sequenceDiagram - `.jh` / `*.test.jh` share parser/AST. The pipeline is **`loadModuleGraph` → `buildScriptsFromGraph(graph, outDir)`** (per-module **`validateModule`** + **`buildScriptFiles`** via **`emitScriptsForModuleFromGraph`**); `parsejaiph` is I/O-pure and graph-based validation / emit operate entirely in-memory. **`buildRuntimeGraph`** consumes the same `ModuleGraph` (loaded in the runner from disk or — on the default local **`jaiph run`** path — deserialized from the parent CLI's graph file via **`JAIPH_MODULE_GRAPH_FILE`**; see [Local module graph](#local-module-graph)). - **`jaiph compile`** walks import closures through **`collectDiagnostics(graph)`** (the multi-error sibling of **`validateReferences`**), prints the full diagnostic set sorted by `(file, line, col)`, and exits non-zero on any non-empty set — no **`scripts/`** emission (no **`buildScriptFiles`** / **`buildScripts`**), no **`buildRuntimeGraph()`**, no runner spawn. Directory discovery omits **`*.test.jh`** unless you pass a test file explicitly. -- **Node-only runtime:** all execution — local `jaiph run`, Docker `jaiph run`, and `jaiph test` — goes through `NodeWorkflowRuntime`. Docker containers run **`jaiph run --raw`** / **`__workflow-runner`** with the compiled JS tree and scripts mounted, using the same semantics as local execution. +- **Node-only runtime:** all execution — `jaiph run` and `jaiph test` — goes through `NodeWorkflowRuntime`. - **CLI** owns launch, observation, hooks (except **`jaiph run --raw`**), and runtime preparation (`buildScripts`). **`jaiph run --raw`** still emits **`__JAIPH_EVENT__`** on stderr from the runtime; the CLI does not attach the interactive progress/hooks pipeline. **`jaiph test`** passes **`suppressLiveEvents: true`** into **`NodeWorkflowRuntime`** so **`RuntimeEventEmitter`** skips writing those live stderr lines while **`run_summary.jsonl`** still records workflow traffic where the emitter appends it. - Workflow execution runs in **`NodeWorkflowRuntime`**, with **script steps** as managed subprocesses. -- No workflow-level `.sh` files or `jaiph_stdlib.sh` are produced or required. +- No def-level `.sh` files or `jaiph_stdlib.sh` are produced or required. - Contracts: `__JAIPH_EVENT__`, `.jaiph/runs`, `run_summary.jsonl`, hook payloads. diff --git a/docs/artifacts.md b/docs/artifacts.md index b48dbecc..68a875f4 100644 --- a/docs/artifacts.md +++ b/docs/artifacts.md @@ -9,9 +9,9 @@ redirect_from: # Save artifacts -This recipe publishes files from a workflow into the run's `artifacts/` directory under the run logs root (`.jaiph/runs/` by default). Copying files into `artifacts/` is the supported way to export them when Docker sandboxing is on. In the default snapshot mode, workspace edits are discarded when the container exits, but anything copied into `artifacts/` stays on the host. +This guide shows how to publish files from a def into the run's `artifacts/` directory. Each run gets its own directory under the runs root (`.jaiph/runs/` by default), and `artifacts/` sits inside that run directory. Copying files into `artifacts/` is the supported way to export them from a run. -The runtime always creates an `artifacts/` directory under the run log directory and exposes its absolute path as `JAIPH_ARTIFACTS_DIR`. The `jaiphlang/artifacts` library is the standard way to copy files into that directory, and you can also write there directly from a `script` step. +The runtime always creates the `artifacts/` directory and exposes its absolute path as `JAIPH_ARTIFACTS_DIR`. The `jaiphlang/artifacts` library is the standard way to copy files into that directory, and you can also write there directly from a `script` step. ## Prerequisites @@ -27,21 +27,21 @@ import "jaiphlang/artifacts" as artifacts ## 2. Save a single file ```jh -workflow default() { +export def main() { # ... produce ./build/output.bin somehow ... const dest = run artifacts.save("./build/output.bin") log "saved to ${dest}" } ``` -`save` copies the source path into `${JAIPH_ARTIFACTS_DIR}/...` preserving the relative layout (the leading `./` is stripped). Absolute source paths are copied using `basename` only. The workflow value is the absolute destination path. +`save` copies the source path into `${JAIPH_ARTIFACTS_DIR}/...` preserving the relative layout (the leading `./` is stripped). Absolute source paths are copied using `basename` only. The `run` step returns the absolute destination path. ## 3. Save several files at once `save` accepts a newline-separated list of paths. Blank or whitespace-only lines are ignored: ```jh -workflow default() { +export def main() { const paths = """ a.txt b/nested.txt @@ -63,12 +63,12 @@ script save_report = ``` cp ./report.html "$JAIPH_ARTIFACTS_DIR/reports/" ``` -workflow default() { +export def main() { run save_report() } ``` -The runtime also sets `JAIPH_RUN_DIR`, `JAIPH_RUN_SUMMARY_FILE`, and `JAIPH_RUN_ID` on script steps if you need those paths. +The runtime also sets `JAIPH_RUN_DIR`, `JAIPH_RUN_SUMMARY_FILE`, and `JAIPH_RUN_ID` for script steps, so you can read the run directory, the summary file, or the run id when you need them. ## Verification @@ -78,15 +78,15 @@ After the run, list the artifacts directory: ls //-/artifacts/ ``` -Replace `` with `.jaiph/runs` when `JAIPH_RUNS_DIR` is unset, or with your configured runs directory otherwise. The date and time segments are UTC, and `` is the entry-file basename (or `JAIPH_SOURCE_FILE` when set). You should see the files your workflow saved. Under Docker sandboxing the host path is the same. The run mount at `/jaiph/run` inside the container is bound to the host runs root, so artifacts land on the host even though the run executed inside the container. +Replace `` with `.jaiph/runs` when `JAIPH_RUNS_DIR` is unset, or with your configured runs directory otherwise. The date and time segments are UTC, and `` is the entry-file basename (or `JAIPH_SOURCE_FILE` when set). You should see the files your program saved. -`artifacts.save(...)` fails when the input list is empty after trimming, when any listed path is missing or not a regular file, or when `JAIPH_ARTIFACTS_DIR` is unset. Wrap the call in `recover` or `catch` if you want the workflow to tolerate that failure. +`artifacts.save(...)` fails when the input list is empty after trimming, when any listed path is missing or not a regular file, or when `JAIPH_ARTIFACTS_DIR` is unset. Wrap the call in `recover` or `catch` if you want the def to tolerate that failure. ## Verify a run's integrity chain -Every line the runtime appends to `run_summary.jsonl` carries a `prev_hash` field. The field holds a **keyed** HMAC-SHA256 of the previous raw line (keyed genesis for the first line), computed under a per-run secret the audited workflow never sees. Rewriting a line, or dropping a line and re-linking the survivors, breaks the chain and cannot be re-forged without the key, so you can detect tampering with a run's audit trail. The key is persisted once the run is terminal — **not** in the run directory (which the workflow can write to), but in an operator-side store outside every sandbox mount (`~/.jaiph/audit-keys` by default, override `JAIPH_AUDIT_KEY_DIR`), keyed by run-directory identity (finding M-3). See [Architecture — Keyed hash chain](architecture.md#hash-chain) for the full contract, including the key-isolation and read/export-boundary guarantees. +Every line the runtime appends to `run_summary.jsonl` carries a `prev_hash` field. The field holds a **keyed** HMAC-SHA256 of the previous raw line (keyed genesis for the first line), computed under a per-run secret the audited program never sees. Rewriting a line, or dropping a line and re-linking the survivors, breaks the chain and cannot be re-forged without the key, so you can detect tampering with a run's audit trail. The key is persisted once the run finishes. It is **not** stored in the run directory, which the program can write to. It is stored in an operator-side store instead (`~/.jaiph/audit-keys` by default, or the directory in `JAIPH_AUDIT_KEY_DIR`), keyed by the run directory's identity. See [Architecture, keyed hash chain](architecture.md#hash-chain) for the full contract, including the key-isolation and read/export-boundary guarantees. -To check a run directory, run this self-contained Node script. It resolves the run's key from the operator store, where the `sha256` of the run directory's canonical path names its entry. It then recomputes the keyed chain the same way the runtime does and confirms the journal still ends with its `WORKFLOW_END` terminal marker. No jaiph build is required: +To check a run directory, run this self-contained Node script. It resolves the run's key from the operator store, where the `sha256` of the run directory's canonical path names its entry. It then recomputes the keyed chain the same way the runtime does and confirms the journal still ends with its `RUN_END` terminal marker. No jaiph build is required: ```bash node -e ' @@ -105,17 +105,17 @@ node -e ' expected = hmac(lines[i]); } const lastType = lines.length ? JSON.parse(lines[lines.length - 1]).type : null; - if (lastType !== "WORKFLOW_END") { + if (lastType !== "RUN_END") { console.error(`journal not terminal: last event is ${lastType} (truncated after run end?)`); process.exit(1); } console.log(`chain intact and terminal (${lines.length} lines)`); ' //-/ ``` -A clean, complete journal prints `chain intact and terminal (N lines)` and exits `0`. A rewritten file prints the first broken line number and exits `1`. A completed journal whose last lines were deleted after the run ended prints that it is not terminal and exits `1`. The chain commits to prefix integrity but not to length, so a shorter journal that still links correctly is caught only by the missing `WORKFLOW_END` marker (finding L-3). Inside the repo you can call the exported `verifyRunSummaryChain(filePath, key, opts?)` helper (`src/runtime/kernel/emit.ts`) directly, or `verifyRunJournal(runDir)`, which resolves the key from the store for you, requires the terminal marker, and returns `{ verified, ok, error }`. A run with no store entry (an unkeyed/legacy run) cannot be verified and is never blocked; a run that **was** keyed but whose key is missing fails closed (`verified: true, ok: false`). +A clean, complete journal prints `chain intact and terminal (N lines)` and exits `0`. A rewritten file prints the first broken line number and exits `1`. A completed journal whose last lines were deleted after the run ended prints that it is not terminal and exits `1`. The chain commits to prefix integrity but not to length, so a shorter journal that still links correctly is caught only by the missing `RUN_END` marker (finding L-3). Inside the repo you can call the exported `verifyRunSummaryChain(filePath, key, opts?)` helper (`src/runtime/kernel/emit.ts`) directly, or `verifyRunJournal(runDir)`, which resolves the key from the store for you, requires the terminal marker, and returns `{ verified, ok, error }`. A run with no store entry (an unkeyed or legacy run) cannot be verified and is never blocked. A run that **was** keyed but whose key is missing fails closed (`verified: true, ok: false`). ## Related -- [Architecture — Durable artifact layout](architecture.md#durable-artifact-layout) — the full run directory tree, including where `artifacts/` sits, plus the hash chain and secret-redaction contracts for `run_summary.jsonl`. -- [Use & publish a library](libraries.md) — installing `jaiphlang/artifacts` and writing your own libraries. -- [Sandboxing — The two sandbox modes](sandboxing.md#the-two-sandbox-modes) — snapshot mode discards workspace edits; artifacts persist on the host in every mode. +- [Architecture, durable artifact layout](architecture.md#durable-artifact-layout). The full run directory tree, including where `artifacts/` sits, plus the hash chain and secret-redaction contracts for `run_summary.jsonl`. +- [Use & publish a library](libraries.md). Installing `jaiphlang/artifacts` and writing your own libraries. +- [CLI](cli.md). The `jaiph run` artifacts layout. diff --git a/docs/assets/css/style.css b/docs/assets/css/style.css index 9d325c73..e049ca25 100644 --- a/docs/assets/css/style.css +++ b/docs/assets/css/style.css @@ -180,7 +180,8 @@ body { } /* Landing logo hangs below sticky nav; keep in-page targets out from under it */ -#samples { +#samples, +#start-here { scroll-margin-top: 10rem; } @@ -665,6 +666,20 @@ pre code .code-line::before { margin-bottom: 0.75rem; } +.try-it-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem 1.25rem; + flex-wrap: wrap; + margin-bottom: 0.75rem; +} + +.try-it-toolbar .code-tab-list { + margin-bottom: 0; + flex: 1 1 auto; +} + .code-tab-button { border: 1px solid var(--tab-border); border-radius: 8px; @@ -720,6 +735,33 @@ pre code .code-line::before { color: var(--tab-active-color); } +/* Single "switch to the other OS" link. The active platform's button is hidden. */ +.os-link-switch { + flex: 0 0 auto; + margin-left: auto; +} + +.os-link-switch .os-switch-button { + border: none; + background: none; + color: var(--link); + font-family: inherit; + font-size: 0.85rem; + font-weight: 600; + padding: 0.25rem 0; + text-decoration: underline; + text-underline-offset: 0.18em; +} + +.os-link-switch .os-switch-button:hover { + color: var(--orange); + background: none; +} + +.os-link-switch .os-switch-button.is-active { + display: none; +} + /* Platform variants: static markup shows the POSIX (bash) variant; JS reveals the Windows variant on demand (and by default for Windows visitors). Without JS the POSIX variant stays visible and the Windows one remains in the markup. */ @@ -851,6 +893,57 @@ pre code .code-line::before { transition: color 0.25s ease; } +/* ── Start-here path cards ── */ + +.path-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + margin-top: 1rem; +} + +.path-card { + display: block; + text-decoration: none; + color: inherit; + background: var(--card-bg); + border: 1px solid var(--pre-border); + border-radius: 10px; + padding: 1.15rem 1.25rem; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04); + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.path-card:hover { + border-color: var(--tab-active-border); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08); +} + +[data-theme="dark"] .path-card { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +[data-theme="dark"] .path-card:hover { + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.45); +} + +.path-card h3 { + color: var(--navy); + margin: 0 0 0.4rem; + font-size: 1.05rem; +} + +.path-card p { + color: var(--muted); + margin: 0; + font-size: 0.92rem; + line-height: 1.45; +} + +.path-card:hover h3 { + color: var(--link); +} + /* ── Install section ── */ .install-section h2 { @@ -1216,6 +1309,12 @@ pre code .code-line::before { /* ── Responsive ── */ +@media (max-width: 900px) { + .path-grid { + grid-template-columns: repeat(2, 1fr); + } +} + @media (max-width: 768px) { .no-mobile { @@ -1332,6 +1431,10 @@ pre code .code-line::before { gap: 1rem; } + .path-grid { + grid-template-columns: 1fr; + } + .doc-header { padding: 0.4rem 1rem 0.4rem; } diff --git a/docs/assets/js/main.js b/docs/assets/js/main.js index 8521742e..6e9284fb 100644 --- a/docs/assets/js/main.js +++ b/docs/assets/js/main.js @@ -9,40 +9,39 @@ const STATEMENT_KEYWORDS = new Set([ "import", + "export", "as", "config", - "const", - "export", "channel", - "local", - "rule", - "workflow", "script", + "def", "test", - "ensure", - "catch", - "recover", + "const", "run", - "async", "prompt", - "returns", + "log", + "logerr", + "logwarn", + "fail", "return", + "send", + "recover", + "catch", + "if", + "else", + "for", + "in", "match", - "fail", + "async", + "returns", + "not", "mock", - "log", - "logerr", - "respond", - "contains", + "allow_failure", "expect_contain", "expect_not_contain", "expect_equal", - "allow_failure", - "if", - "elif", - "then", - "else", - "fi" + "true", + "false" ]); /** @@ -254,13 +253,20 @@ } }); - // Definition names after rule / workflow / script + // Definition names after def / script / channel (optional leading export) + let declAt = 0; + if (firstValue === "export" && significant[1] && significant[1].token.type === "identifier") { + declAt = 1; + } + const declKw = significant[declAt] && significant[declAt].token.type === "identifier" + ? significant[declAt].token.value + : ""; if ( - (firstValue === "rule" || firstValue === "workflow" || firstValue === "script" || firstValue === "channel") && - significant[1] && - significant[1].token.type === "identifier" + (declKw === "def" || declKw === "script" || declKw === "channel") && + significant[declAt + 1] && + significant[declAt + 1].token.type === "identifier" ) { - annotated[significant[1].index].kind = "definition"; + annotated[significant[declAt + 1].index].kind = "definition"; } function consumeRef(startAt) { @@ -319,30 +325,6 @@ } } - if (firstValue === "if") { - for (let i = 1; i < significant.length; i += 1) { - if ( - significant[i].token.type === "identifier" && - significant[i].token.value === "then" - ) { - annotated[significant[i].index].kind = "keyword"; - } - } - - for (let i = 1; i < significant.length - 1; i += 1) { - if ( - significant[i].token.type === "identifier" && - (significant[i].token.value === "ensure" || significant[i].token.value === "run") - ) { - annotated[significant[i].index].kind = "keyword"; - if (significant[i + 1].token.type === "identifier") { - annotated[significant[i + 1].index].kind = "identifier"; - } - break; - } - } - } - for (let i = 0; i < annotated.length - 2; i += 1) { if ( annotated[i].type === "identifier" && @@ -355,13 +337,17 @@ } } - if (firstValue === "ensure" || firstValue === "run") { - if (significant[1] && significant[1].token.type === "identifier") { - annotated[significant[1].index].kind = "identifier"; + if (firstValue === "run") { + let nameAt = 1; + if (significant[1] && significant[1].token.value === "async") { + nameAt = 2; + } + if (significant[nameAt] && significant[nameAt].token.type === "identifier") { + annotated[significant[nameAt].index].kind = "identifier"; } } - // -> workflow, workflow2 (route declaration) + // -> def, def2 (route declaration) const routeLeftRef = consumeRef(0); const routeArrowAt = routeLeftRef ? routeLeftRef.endAt + 1 : -1; if ( @@ -381,44 +367,11 @@ } } - // <- command (send operator) - const sendLeftRef = consumeRef(0); - const sendArrowAt = sendLeftRef ? sendLeftRef.endAt + 1 : -1; - if ( - sendLeftRef && - significant[sendArrowAt] && - significant[sendArrowAt].token.type === "send_arrow" - ) { - for (let j = sendLeftRef.startAt; j <= sendLeftRef.endAt; j += 1) { - if (significant[j].token.type === "identifier") { - annotated[significant[j].index].kind = "identifier"; - } - } - } - - // local name = value → definition for variable name - if (firstValue === "local") { - if (significant[1] && significant[1].token.type === "identifier") { - annotated[significant[1].index].kind = "definition"; - } - } - - if (firstValue === "ensure") { + if (firstValue === "send") { for (let i = 1; i < significant.length; i += 1) { - if ( - significant[i].token.type === "identifier" && - significant[i].token.value === "else" - ) { - annotated[significant[i].index].kind = "keyword"; - if ( - significant[i + 1] && - significant[i + 1].token.type === "identifier" && - significant[i + 1].token.value === "run" - ) { - annotated[significant[i + 1].index].kind = "keyword"; - if (significant[i + 2] && significant[i + 2].token.type === "identifier") { - annotated[significant[i + 2].index].kind = "identifier"; - } + if (significant[i].token.type === "arrow") { + if (significant[i + 1] && significant[i + 1].token.type === "identifier") { + annotated[significant[i + 1].index].kind = "identifier"; } break; } @@ -464,12 +417,19 @@ return; } + let declAt = 0; + if (firstValue === "export" && significant[1] && significant[1].token.type === "identifier") { + declAt = 1; + } + const declKw = significant[declAt] && significant[declAt].token.type === "identifier" + ? significant[declAt].token.value + : ""; if ( - firstValue === "script" && - significant[1] && - significant[1].token.type === "identifier" + declKw === "script" && + significant[declAt + 1] && + significant[declAt + 1].token.type === "identifier" ) { - knownSymbols.functionNames.add(significant[1].token.value); + knownSymbols.functionNames.add(significant[declAt + 1].token.value); } }); @@ -744,6 +704,8 @@ /** * Toggles the visible platform variant (posix / windows) across every * panel in the install card, and reflects the choice on the switch buttons. + * The landing page shows only the *other* platform as a top-right link + * (the active button is hidden via CSS). */ function setOsVariant(root, os) { root.querySelectorAll(".os-switch-button").forEach(function (button) { @@ -755,9 +717,9 @@ } /** - * Wires the platform sub-toggle in the install card and auto-selects the - * Windows variant for Windows visitors. Manual switching stays available; - * non-Windows visitors keep the static (POSIX) default with no layout shift. + * Wires the platform switch in the install card and auto-selects Windows + * for Windows visitors. The control is a single "other OS" link; non-Windows + * visitors keep the static POSIX default with no layout shift. */ function attachOsSwitch() { var root = document.querySelector("section.try-it-out .card"); diff --git a/docs/cli.md b/docs/cli.md index dfd5197b..a0d1d81c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -9,7 +9,7 @@ redirect_from: # CLI -This page is the authoritative inventory of the `jaiph` CLI: every subcommand, every flag, every exit-relevant behaviour. It does not explain how to choose between commands — see [Why Jaiph](why-jaiph.md) for context and the how-to pages for recipes. +This page is the complete inventory of the `jaiph` CLI: every subcommand, every flag, and every behavior that affects the exit code. It does not explain how to choose between commands — see [Why Jaiph](why-jaiph.md) for context and the how-to pages for recipes. The published `jaiph` bin is `node dist/src/cli.js` (npm) or the standalone `dist/jaiph` (Bun-compiled). Both dispatch through `src/cli/index.ts`. @@ -31,43 +31,40 @@ The reserved internal marker `__workflow-runner` is excluded from `--help`/usage | Subcommand | Purpose | |---|---| -| `run` | Compile, launch, and observe one workflow run (with optional Docker sandboxing). | +| `run` | Compile, launch, and observe one run on the host. | | `test` | Execute `*.test.jh` blocks in-process with mocks. | | `compile` | Multi-error validation pass — no `scripts/` emission, no runtime spawn. | | `format` | Rewrite `.jh` / `.test.jh` files into canonical style. | | `init` | Initialize `.jaiph/` directory layout in a workspace. | | `install` | Install project-scoped libraries from the registry or git URLs. | | `use` | Reinstall `jaiph` globally with a selected version or channel. | -| `mcp` | Serve a file's workflows as MCP tools over stdio (newline-delimited JSON-RPC). | -| `serve` | Serve a file's workflows as an HTTP API, with an OpenAPI document and an embedded Swagger UI. | +| `mcp` | Serve a file's exported defs as MCP tools over stdio (newline-delimited JSON-RPC). | +| `serve` | Serve a file's exported defs as an HTTP API, with an OpenAPI document and an embedded Swagger UI. | ## `jaiph run` {: #jaiph-run} -Compile and execute a workflow's `default` entrypoint. +Compile and execute `export def main` in the input file. ```text -jaiph run [--target ] [--raw] [--workspace ] [--inplace] [--unsafe] [--yes|-y] [--env KEY[=VALUE]]... [--] [args...] +jaiph run [--target ] [--raw] [--workspace ] [--env KEY[=VALUE]]... [--] [args...] ``` -Sandbox selection is environment-driven; there is no `--docker` flag. The boolean sandbox flags (`--inplace`, `--unsafe`, `--yes`) are CLI front-ends that mutate the launched runtime env for one run only, and are shared verbatim with `jaiph serve` and `jaiph mcp` (one execution-policy contract; precedence: CLI flags > `JAIPH_*` env vars > workflow config metadata > defaults) — see [Configuration — Precedence](configuration.md#precedence) and [Environment variables — Precedence](env-vars.md#precedence). Flags belonging to another command (`--host`, `--port`) and unknown flags are usage errors, never positionals. +Every run executes on the host. Isolation is an outer concern: wrap jaiph in a container, a pod, or a CI runner if wanted. Shared flags (`--workspace`, `--env`) mean the same thing on `jaiph run`, `jaiph serve`, and `jaiph mcp` (precedence: CLI flags > `JAIPH_*` env vars > module config metadata > defaults) — see [Configuration — Precedence](configuration.md#precedence) and [Environment variables — Precedence](env-vars.md#precedence). Flags belonging to another command (`--host`, `--port`) and unknown flags are usage errors, never positionals. ### Flags | Flag | Argument | Effect | |---|---|---| | `--target` | `` | Keep emitted script files and run metadata under `` instead of a temp directory. | -| `--raw` | — | Skip the banner, live progress tree, hooks, and PASS/FAIL footer. The runner child inherits stdio; `__JAIPH_EVENT__` JSON lines go to stderr unchanged. Host `--raw` never launches Docker even when `JAIPH_DOCKER_ENABLED=true`. | -| `--workspace` | `` | Override the workspace root used for library resolution and the Docker workspace mount. A missing value, missing path, or non-directory aborts with a specific message. There is no `JAIPH_WORKSPACE` env equivalent input — that name is reserved for the in-container remap output. | -| `--inplace` | — | Front-end for `JAIPH_INPLACE=1`. On a TTY, prints a destructive-edit warning that **leads with the access scope** (edits land in this workspace directory only — `` — while the rest of your machine stays inside the Docker sandbox) plus the git-tree recovery posture, then requires `Continue? [y/N]` (default **no**). Non-TTY requires `--yes` / `JAIPH_INPLACE_YES` or aborts with `E_DOCKER_INPLACE_NO_CONFIRM`. | -| `--unsafe` | — | Front-end for `JAIPH_UNSAFE=true`. Cannot be combined with `--inplace` (`E_FLAG_CONFLICT`). When this turns Docker off while it would otherwise be on, a **stronger** confirmation than `--inplace` fires: the warning states host-only / **no sandbox**, that filesystem access is your **entire machine** (not just the workspace), and that scripts and agent backends can read secrets from your environment and reach paths outside the project. On a TTY it requires `Continue? [y/N]` (default **no**); non-TTY requires `--yes` / `JAIPH_INPLACE_YES` or aborts with `E_UNSAFE_NO_CONFIRM`. No prompt fires when Docker is off for another reason (explicit `JAIPH_DOCKER_ENABLED=false`, or the Windows host-only override, which prints its own notice). `--raw` skips this prompt (embedding / Docker inner run). | -| `-y`, `--yes` | — | Front-end for `JAIPH_INPLACE_YES=1`. Skips **both** the `--inplace` and `--unsafe` confirmation prompts — required to use either mode non-interactively. | -| `--env` | `KEY=VALUE` or `KEY` | Repeatable per-key environment passthrough into the workflow process. `--env KEY=VALUE` defines `KEY` with that exact value (first `=` splits; the value may contain `=`; empty is allowed). `--env KEY` forwards the host's current value, aborting with `E_ENV_MISSING` before spawning if `KEY` is unset on the host. `KEY` must match `[A-Za-z_][A-Za-z0-9_]*` (else `E_ENV_INVALID`). Reserved sandbox-control keys (`JAIPH_UNSAFE`, `JAIPH_INPLACE`, `JAIPH_INPLACE_YES`, any `JAIPH_DOCKER_*`, the `JAIPH_TRUSTED_ENVS` opt-in, and the `JAIPH_TRUST_PROJECT_HOOKS` opt-in) and runtime-managed keys (`JAIPH_WORKSPACE`, `JAIPH_RUNS_DIR`, `JAIPH_RUN_ID`, `JAIPH_SCRIPTS`, `JAIPH_MODULE_GRAPH_FILE`, `JAIPH_SOURCE_ABS`, `JAIPH_META_FILE`, `JAIPH_AGENT_TRUSTED_WORKSPACE`, `JAIPH_RUN_WORKFLOW`) are rejected with `E_ENV_RESERVED` — use the sandbox flags or real env vars for those. **In a Docker sandbox `--env` is the per-key consent that crosses the fail-closed env allowlist verbatim** (added as explicit `-e KEY=VALUE` container args, winning over any allowlist-forwarded value); see [Sandboxing — Environment exposure](sandboxing.md#env-exposure). Values are never path-remapped. | -| `--` | — | End of Jaiph flags; remaining tokens are forwarded to `workflow default`. | +| `--raw` | — | Skip the banner, live progress tree, hooks, and PASS/FAIL footer. The runner child inherits stdio; `__JAIPH_EVENT__` JSON lines go to stderr unchanged. | +| `--workspace` | `` | Override the workspace root used for library resolution. A missing value, missing path, or non-directory aborts with a specific message. There is no `JAIPH_WORKSPACE` env equivalent input — that name is reserved for the runner. | +| `--env` | `KEY=VALUE` or `KEY` | Repeatable per-key environment passthrough into the run process. `--env KEY=VALUE` defines `KEY` with that exact value (first `=` splits; the value may contain `=`; empty is allowed). `--env KEY` forwards the host's current value, aborting with `E_ENV_MISSING` before spawning if `KEY` is unset on the host. `KEY` must match `[A-Za-z_][A-Za-z0-9_]*` (else `E_ENV_INVALID`). Runtime-managed keys (`JAIPH_WORKSPACE`, `JAIPH_RUNS_DIR`, `JAIPH_RUN_ID`, `JAIPH_SCRIPTS`, `JAIPH_MODULE_GRAPH_FILE`, `JAIPH_SOURCE_ABS`, `JAIPH_META_FILE`, `JAIPH_AGENT_TRUSTED_WORKSPACE`, `JAIPH_TRUST_PROJECT_HOOKS`) are rejected with `E_ENV_RESERVED`. Values are never path-remapped. | +| `--` | — | End of Jaiph flags; remaining tokens are forwarded to `export def main`. | ### Pre-flight -After module-graph load and Docker-mode resolution, before the runner / container is spawned, the host CLI runs a credential pre-flight (`src/cli/run/preflight-credentials.ts`). Missing credentials produce either `E_AGENT_CREDENTIALS` (hard error) or a warning depending on backend and Docker mode — see [Authenticate agent backends](agent-auth.md) and [Configuration — Credential pre-flight](configuration.md#credential-pre-flight). `jaiph run --raw` does not run the pre-flight. +After module-graph load, before the runner is spawned, the host CLI runs a credential pre-flight (`src/cli/run/preflight-credentials.ts`). Missing credentials produce either `E_AGENT_CREDENTIALS` (hard error) or a warning depending on backend — see [Authenticate agent backends](agent-auth.md) and [Configuration — Credential pre-flight](configuration.md#credential-pre-flight). `jaiph run --raw` does not run the pre-flight. ### Progress markers @@ -76,13 +73,13 @@ After module-graph load and Docker-mode resolution, before the runner / containe | `▸` | Step started. | | `✓` | Step completed successfully (with elapsed time). | | `✗` | Step failed (with elapsed time). | -| `ℹ` | `log` message (dim/gray, no marker timing). | +| `ℹ` | `log` message (blue marker and text; the tree prefix is dim; no elapsed time). | | `!` | `logerr` message (red; rendered on stdout with the progress tree). | | `⚠` | `logwarn` message and automatic leaf-step idle warnings (yellow; rendered on stdout with the progress tree). | | `·` | Continuation marker (heartbeat lines in non-TTY mode). | | ` ₁`, ` ₂`, … | Subscript prefix for `run async` branch numbering. | -PASS line: `✓ PASS workflow default (0.2s)`. TTY runs append a transient `▸ RUNNING workflow (X.Xs)` line that is replaced by the PASS/FAIL line on exit. `--raw` and non-TTY modes skip both. Disable color globally with `NO_COLOR=1`. +PASS line: `✓ PASS def main (0.2s)`. TTY runs append a transient `▸ RUNNING def (X.Xs)` line that is replaced by the PASS/FAIL line on exit. `--raw` and non-TTY modes skip both. Disable color globally with `NO_COLOR=1`. Non-TTY heartbeat cadence is controlled by `JAIPH_NON_TTY_HEARTBEAT_FIRST_SEC` (default `60`) and `JAIPH_NON_TTY_HEARTBEAT_INTERVAL_MS` (default `30000`, floor `250`). Leaf script and prompt steps emit a yellow `⚠` idle warning when they produce no stdout/stderr for `JAIPH_STEP_IDLE_WARN_SEC` (default `180`; `0` disables). @@ -90,11 +87,11 @@ A leaf script step whose subprocess produces no stdout/stderr for `JAIPH_STEP_ID ### Step display -Step lines include the kind (`workflow`, `prompt`, `script`, `rule`) and name. Parameterised invocations append `key="value"` pairs in parentheses (positional params use `1=…` / `2=…`); whitespace is collapsed; values are truncated to 32 characters. Prompt step lines additionally show the backend name (or custom command basename), the effective model, and the first 24 characters of the prompt body in quotes (full line capped at 96 characters): `▸ prompt claude sonnet "Classify this task…"` on start and `✓ prompt claude sonnet (5s)` on completion. The model is the value passed to the backend (`agent.model` / `JAIPH_AGENT_MODEL` / a `--model` flag); it is a bare token between the backend and the quoted preview. When a built-in backend (cursor, claude, codex) auto-selects its own model, the token shows the literal `default` (`▸ prompt cursor default "…"`); it is omitted only for custom agent commands, which have no model concept (`▸ prompt my-agent "…"`). +Step lines include the kind (`def`, `prompt`, `script`) and name. Parameterised invocations append `key="value"` pairs in parentheses (positional params use `1=…` / `2=…`); whitespace is collapsed; values are truncated to 32 characters. Prompt step lines additionally show the backend name (or custom command basename), the effective model, and the first 24 characters of the prompt body in quotes: `▸ prompt claude sonnet "Classify this task…"` on start and `✓ prompt claude sonnet (5s)` on completion. Any trailing `key="value"` parameter pairs shown after the preview are capped at 96 characters in total. The model is the value passed to the backend (`agent.model` / `JAIPH_AGENT_MODEL` / a `--model` flag); it is a bare token between the backend and the quoted preview. When a built-in backend (cursor, claude, codex) auto-selects its own model, the token shows the literal `default` (`▸ prompt cursor default "…"`); it is omitted only for custom agent commands, which have no model concept (`▸ prompt my-agent "…"`). ### Return values -When `workflow default` returns a value (success only), the runtime writes `return_value.txt` under the run directory. Interactive `jaiph run` prints that value on stdout after the PASS line, separated by a blank line. `jaiph run --raw` never prints it to stdout; the file alone is the contract. +When `export def main` returns a value (success only), the runtime writes `return_value.txt` under the run directory. Interactive `jaiph run` prints that value on stdout after the PASS line, separated by a blank line. `jaiph run --raw` never prints it to stdout; the file alone is the contract. ### Run artifacts @@ -104,11 +101,11 @@ Step `.out` files are written incrementally; consumers may `tail -f` them. `.out ### Failure footer -Interactive `jaiph run` only (`--raw` omits this block). On non-zero exit, the CLI emits a stderr footer with `Logs:`, `Summary:`, `out:` / `err:` paths, and an `Output of failed step:` excerpt. The fields are resolved from the last `STEP_END` object with non-zero `status` in `run_summary.jsonl`; `out_content` / `err_content` are preferred over `out_file` / `err_file`. In Docker mode, container-internal `/jaiph/run/*` paths are remapped to host paths. +Interactive `jaiph run` only (`--raw` omits this block). On non-zero exit, the CLI emits a stderr footer with `Logs:`, `Summary:`, `out:` / `err:` paths, and an `Output of failed step:` excerpt. The fields are resolved from the last `STEP_END` object with non-zero `status` in `run_summary.jsonl`; `out_content` / `err_content` are preferred over `out_file` / `err_file`. ### Hook events -Hooks load from `~/.jaiph/hooks.json` (global) and `/.jaiph/hooks.json` (project-local; project overrides global per event). Hooks run on the **host** CLI process even in Docker mode. The project-local file runs only when the operator trusts the workspace with `JAIPH_TRUST_PROJECT_HOOKS=1`; absent the opt-in it is ignored with a stderr notice while the global file still runs (finding M-10). See [Add a hook](hooks.md) and [`JAIPH_TRUST_PROJECT_HOOKS`](env-vars.md). +Hooks load from `~/.jaiph/hooks.json` (global) and `/.jaiph/hooks.json` (project-local; project overrides global per event). Hooks run on the **host** CLI process. The project-local file runs only when the operator trusts the workspace with `JAIPH_TRUST_PROJECT_HOOKS=1`; absent the opt-in it is ignored with a stderr notice while the global file still runs. See [Add a hook](hooks.md) and [`JAIPH_TRUST_PROJECT_HOOKS`](env-vars.md). ## `jaiph test` @@ -126,14 +123,14 @@ jaiph test # run a single test file | `jaiph test ` | Walk up from the resolved ``. | | `jaiph test ` | Walk up from the test file's directory. | -Zero matches with no arguments (or with a directory containing no `*.test.jh` files) writes `jaiph test: no *.test.jh files found (nothing to do)` to stderr and exits `0`. An explicit file path that does not exist or is not `*.test.jh` exits `1`. Plain workflow files (`*.jh` without `.test`) are not supported as test entries. Extra positional tokens after the path are accepted but ignored. +Zero matches with no arguments (or with a directory containing no `*.test.jh` files) writes `jaiph test: no *.test.jh files found (nothing to do)` to stderr and exits `0`. An explicit file path that does not exist or is not `*.test.jh` exits `1`. Plain def files(`*.jh` without `.test`) are not supported as test entries. Extra positional tokens after the path are accepted but ignored. Assertions: `expect_contain`, `expect_equal`, `expect_not_contain` — see [Write & run tests](testing.md). ## `jaiph compile` {: #jaiph-compile} -Parse modules and run `collectDiagnostics(graph)` — the same per-module validator as `jaiph run`, but collecting every recoverable error instead of stopping at the first — **without** writing `scripts/`, **without** calling `buildRuntimeGraph()`, and **without** spawning the workflow runner. +Parse modules and run `collectDiagnostics(graph)` — the same per-module validator as `jaiph run`, but collecting every recoverable error instead of stopping at the first — **without** writing `scripts/`, **without** calling `buildRuntimeGraph()`, and **without** spawning the run. ```text jaiph compile [--json] [--workspace ] ... @@ -168,11 +165,11 @@ Paths must end with `.jh`. Formatting is idempotent. Comments and shebangs are p | `--indent` | `` | `2` | Spaces per indent level. | | `--check` | — | — | Verify without writing. Exit `0` when files match canonical form, `1` when any file would change. | -Top-level ordering: the formatter hoists `import`, `config`, and `channel` declarations to the top (in that order, preserving relative source order within each group). Other top-level definitions (`const`, `rule`, `script`, `workflow`, `test`) keep their relative source order. Comments before a hoisted construct move with it; comments before non-hoisted definitions stay in place. +Top-level ordering: the formatter hoists `import`, `config`, and `channel` declarations to the top (in that order, preserving relative source order within each group). Other top-level definitions (`const`, `script`, `def`, `test`) keep their relative source order. Comments before a hoisted construct move with it; comments before non-hoisted definitions stay in place. -Top-level `const` quoting: the source delimiter is preserved per binding. Quoted values stay quoted; bare tokens stay bare; `"""…"""` values emit verbatim. The formatter does not toggle between styles based on value content. +Top-level `const` quoting: the source delimiter is preserved per binding. Bare tokens stay bare, `"""…"""` values emit verbatim, and a double-quoted value stays double-quoted. The one exception is a double-quoted value whose content contains a `"` or a `\`: the formatter emits it as a `"""…"""` block so the text needs no escaping. The formatter never rewrites a quoted value as bare, or a bare token as quoted, based on the value's content (for example, whether it contains a space). -Blank-line preservation: a single blank line between steps inside a workflow or rule body is preserved. Multiple consecutive blank lines collapse to one. Trailing blank lines before `}` are removed. +Blank-line preservation: a single blank line between steps inside a def body is preserved. Multiple consecutive blank lines collapse to one. Trailing blank lines before `}` are removed. ## `jaiph init` @@ -185,7 +182,7 @@ Creates the following under the target workspace: | File | Content | |---|---| | `.jaiph/.gitignore` | Two-line file listing `runs` and `tmp`. If the file exists and does not match, the command exits non-zero. | -| `.jaiph/bootstrap.jh` | Canonical bootstrap workflow; made executable. The body is a triple-quoted multiline `prompt` that asks the agent to scaffold workflows. | +| `.jaiph/bootstrap.jh` | Canonical bootstrap file, made executable. The body is a triple-quoted multiline `prompt` that asks the agent to scaffold `.jh` files. Like `.gitignore`, if the file already exists and does not match the canonical template, the command exits non-zero. | | `.jaiph/SKILL.md` | Copy of the skill markdown shipped with this `jaiph` build (see [`JAIPH_SKILL_PATH`](env-vars.md)). | SKILL.md resolution order: `JAIPH_SKILL_PATH` (if set and the path exists) → install-relative paths (`jaiph-skill.md` next to the package tree, then `docs/jaiph-skill.md` next to the package) → `docs/jaiph-skill.md` under the current working directory → the embedded copy baked into the binary. There is no "skip and warn" path; the file is always written. @@ -220,10 +217,10 @@ Remote registry and library URLs must use an allowed scheme. A value with an exp Each successful clone runs these checks before the lib counts as installed: - **`.jh` module check** — at least one `*.jh` file must exist under the clone (recursive, `.git` skipped). Failure removes the directory and aborts with `lib "" contains no .jh modules — not a jaiph library?`. No lock entry written. -- **Commit capture** — `git rev-parse HEAD` is recorded as the 40-char `commit` on the lock entry. -- **Pinned-commit check** — when the registry entry (or lock entry) carries a `commit`, the cloned HEAD must equal it, or the directory is removed and the install fails with the locked vs cloned SHAs and the remedy. This makes the *first* install from the registry authenticated, not just restore. +- **Commit capture** — when the clone has a `.git` directory, `git rev-parse HEAD` is recorded as the 40-char `commit` on the lock entry. A clone with no usable git checkout leaves `commit` unset. +- **`.git` strip** — `/.git` is removed recursively, right after the commit is captured and before the two checks below. +- **Pinned-commit check** — when the registry entry (or lock entry) carries a `commit`, the captured HEAD must equal it, or the directory is removed and the install fails with the locked vs cloned SHAs and the remedy. This makes the *first* install from the registry authenticated, not just restore. - **Detached signature check** — when the registry entry carries a `signature` (a detached minisign signature over the ASCII commit SHA), it is verified against the embedded `jaiph.pub` project key only, never a key supplied by the entry itself (a self-supplied key attests nothing an attacker controlling the entry could not forge). An invalid or unverifiable signature removes the directory and fails the install closed with `lib "" signature verification failed for commit `. -- **`.git` strip** — `/.git` is removed recursively. ### Restore-from-lockfile mode @@ -287,10 +284,10 @@ Implementation: with no `JAIPH_INSTALL_COMMAND` override, `jaiph use` downloads ## `jaiph mcp` {: #jaiph-mcp} -Serve a file's workflows as [MCP](https://modelcontextprotocol.io/) tools over stdio. See [Serve workflows as MCP tools](mcp.md) for the recipe and client-registration steps. +Serve a file's defs as [MCP](https://modelcontextprotocol.io/) tools over stdio. See [MCP server in 30 seconds](mcp.md) for the recipe and client-registration steps. ```text -jaiph mcp [--workspace ] [--inplace] [--unsafe] [--yes|-y] [--env KEY[=VALUE]]... +jaiph mcp [--workspace ] [--env KEY[=VALUE]]... ``` `jaiph --mcp ` is an equivalent alias, dispatched after `compile` in `src/cli/index.ts`. @@ -298,34 +295,31 @@ jaiph mcp [--workspace ] [--inplace] [--unsafe] [--yes|-y] [--env KEY[=VALU | Flag | Argument | Effect | |---|---|---| | `--workspace` | `` | Workspace root for import resolution (default: auto-detected from the file's directory). A missing value or non-directory path aborts with a specific message. | -| `--env` | `KEY=VALUE` or `KEY` | Same per-key passthrough as `jaiph run --env` (same forms, validation, and reserved-key rejection), resolved once at startup and applied to **every** tool call for the server's lifetime. A bare `--env KEY` unset on the host aborts server startup with `E_ENV_MISSING`. In Docker mode the pairs cross the container boundary as explicit `-e` args bypassing the allowlist, exactly as for `jaiph run --env`. | -| `--inplace` | — | Front-end for `JAIPH_INPLACE=1`: every tool call's Docker sandbox bind-mounts the host workspace read-write. Mutually exclusive with `--unsafe` (`E_FLAG_CONFLICT` at startup, before anything is spawned). No interactive prompt — launching the server with the flag (or env var) is the consent; the effective posture is printed once at startup and applied to every call. | -| `--unsafe` | — | Front-end for `JAIPH_UNSAFE=true`: every tool call runs on the host with no sandbox. Host-only mode requires explicit consent on this command line, so pass `--unsafe` (or `--yes`). An inherited `JAIPH_UNSAFE=true` with no such flag is refused at startup with `E_UNSAFE_NO_CONSENT` (skipped inside a container, where the container is the sandbox). Mutually exclusive with `--inplace` (`E_FLAG_CONFLICT`). When consent is given, the server prints a prominent SANDBOXING DISABLED banner at startup. | -| `-y`, `--yes` | — | Front-end for `JAIPH_INPLACE_YES=1` (recorded on every call's env; servers themselves never prompt). | +| `--env` | `KEY=VALUE` or `KEY` | Same per-key passthrough as `jaiph run --env` (same forms, validation, and reserved-key rejection), resolved once at startup and applied to **every** tool call for the server's lifetime. A bare `--env KEY` unset on the host aborts server startup with `E_ENV_MISSING`. | | `-h`, `--help` | — | Print the subcommand usage and exit `0`. | -Flags that belong to another command (for example `--raw` or `--port`) are usage errors naming the owning command — never silently ignored. Precedence across layers is the shared execution-policy order: CLI flags > `JAIPH_*` env vars > workflow config metadata > defaults (see [Environment variables — Precedence](env-vars.md#precedence)). +Flags that belong to another command (for example `--raw` or `--port`) are usage errors naming the owning command — never silently ignored. Precedence across layers is the shared execution-policy order: CLI flags > `JAIPH_*` env vars > module config metadata > defaults (see [Environment variables — Precedence](env-vars.md#precedence)). -### Startup and exit behaviour +### Startup and exit behavior - Loads the module graph and runs `collectDiagnostics` (the same compile-time pass as `jaiph compile`). Any diagnostic prints `file:line:col CODE message` lines to **stderr** and exits `1`. - A missing path, a non-`.jh` path, or a path that is not a file exits `1` with a message on stderr. -- On success the server runs until stdin closes or it receives `SIGINT` / `SIGTERM`. Shutdown is **drain-then-cancel**: stdin closing (or the first signal) stops accepting input and waits for in-flight calls to finish before cleaning up and exiting `0` — a draining call keeps its scripts until it settles. A **second** signal cancels the in-flight calls instead of waiting: each run's child process tree is terminated (`SIGINT`, then `SIGKILL` after a grace period) and, in Docker mode, its container is force-removed (`docker rm -f`), so no child process or container outlives the server; the killed calls settle with error results and the server still exits `0`. +- On success the server runs until stdin closes or it receives `SIGINT` / `SIGTERM`. Shutdown is **drain-then-cancel**: stdin closing (or the first signal) stops accepting input and waits for in-flight calls to finish before cleaning up and exiting `0` — a draining call keeps its scripts until it settles. A **second** signal cancels the in-flight calls instead of waiting: each run's child process tree is terminated (`SIGINT`, then `SIGKILL` after a grace period), so no child process outlives the server; the killed calls settle with error results and the server still exits `0`. ### stdout invariant -From the moment the server starts, **stdout carries only newline-delimited JSON-RPC**. Every banner, warning, workflow-exclusion notice, reload message, Docker notice, and credential-pre-flight warning goes to **stderr**. Each outbound protocol message is a single atomic write of `JSON.stringify(msg) + "\n"`. +From the moment the server starts, **stdout carries only newline-delimited JSON-RPC**. Every banner, warning, exclusion notice, reload message, and credential-pre-flight warning goes to **stderr**. Each outbound protocol message is a single atomic write of `JSON.stringify(msg) + "\n"`. ### Operator log (stderr) `jaiph mcp` and `jaiph serve` write an **operator log to stderr only**. They never write it to the protocol channel, so MCP stdout stays JSON-RPC and HTTP response bodies stay API payloads. The operator log is **not** a logging framework, and Jaiph adds no winston, pino, or bunyan for it. It is a thin labelled writer that prints one line at a time to stderr and reuses the same level and color formatting as the `jaiph run` progress tree. Colors are used only when the stderr sink is a terminal and `NO_COLOR` is unset. -On every tool call or run the operator log writes two lines. The start line names the workflow, the sandbox label, and the run id, for example `jaiph mcp: Running () run_id=…`. The sandbox label uses the same words as the startup banner, which are snapshot, in-place, unsafe, and no sandbox. The end line reports the terminal status, the exit code, the elapsed time, and the run dir when it is known, for example `jaiph mcp: Finished status=ok exit=0 elapsed_ms=… rundir=…`. On `jaiph serve` both lines also carry `principal=` and `correlation=`. +On every tool call or run the operator log writes two lines. The start line names the def and the run id, for example `jaiph mcp: Running run_id=…`. The end line reports the terminal status, the exit code, the elapsed time, and the run dir when it is known, for example `jaiph mcp: Finished status=ok exit=0 elapsed_ms=… rundir=…`. On `jaiph serve` both lines also carry `principal=` and `correlation=`. -You can change how much the operator log prints with two environment variables, both documented in [Environment variables](env-vars.md): +Two environment variables change how much the operator log prints, both documented in [Environment variables](env-vars.md): - `JAIPH_SERVER_LOG=debug` prints the servers' extra `debug` diagnostic lines. -- `JAIPH_SERVER_LOG_WORKFLOW=1` mirrors each workflow `log`, `logwarn`, and `logerr` event to the operator log. Each mirrored line is colored by level and carries `run_id=` and the same depth and async-branch subscript indent as the run tree. Mirroring is off by default, so an MCP host is not flooded and the tool-result text is not repeated. Mirrored lines go through the same credential redaction as the durable run journal, so a secret is never printed to stderr. +- `JAIPH_SERVER_LOG_RUNS=1` mirrors each `log`, `logwarn`, and `logerr` event to the operator log. Each mirrored line is colored by level and carries `run_id=` and the same depth and async-branch subscript indent as the run tree. Mirroring is off by default, so an MCP host is not flooded and the tool-result text is not repeated. Mirrored lines go through the same credential redaction as the durable run journal, so a secret is never printed to stderr. ### Protocol subset @@ -333,17 +327,17 @@ Newline-delimited JSON-RPC 2.0. Requests are handled concurrently (a long `tools | Method | Behaviour | |---|---| -| `initialize` | Replies with `protocolVersion`, `capabilities: {tools: {listChanged: true}}`, and `serverInfo: {name: "jaiph", title: "Jaiph workflows", version}`. Echoes the client's `protocolVersion` if it is one of `2024-11-05`, `2025-03-26`, `2025-06-18`; otherwise replies with the newest of that set. | +| `initialize` | Replies with `protocolVersion`, `capabilities: {tools: {listChanged: true}}`, and `serverInfo: {name: "jaiph", title: "Jaiph", version}`. Echoes the client's `protocolVersion` if it is one of `2024-11-05`, `2025-03-26`, `2025-06-18`; otherwise replies with the newest of that set. | | `ping` | Empty result. | | `tools/list` | `{tools: [{name, description, inputSchema}]}` from the current tool set (re-read per request, so hot reload needs no cache invalidation). | -| `tools/call` | Runs the workflow (Docker sandbox or host, per the env — see Execution below). Result: `{content: [{type: "text", text}], isError}`. When `params._meta.progressToken` is present, the run's `STEP_START` / `STEP_END` events stream as `notifications/progress` until the response is sent (see below). | -| `notifications/cancelled` | Cancels the matching in-flight `tools/call` (`params.requestId`): terminates the run's child process tree (SIGINT, then SIGKILL after a grace period) and, in Docker mode, force-removes the call's container (`docker rm -f`) so it cannot orphan; sends **no response** for that id, and keeps the server serving. A cancellation for an unknown or already-finished id is a no-op. | +| `tools/call` | Runs the def on the host. Result: `{content: [{type: "text", text}], isError}`. When `params._meta.progressToken` is present, the run's `STEP_START` / `STEP_END` events stream as `notifications/progress` until the response is sent (see below). | +| `notifications/cancelled` | Cancels the matching in-flight `tools/call` (`params.requestId`): terminates the run's child process tree (SIGINT, then SIGKILL after a grace period); sends **no response** for that id, and keeps the server serving. A cancellation for an unknown or already-finished id is a no-op. | | other notifications | Ignored (`notifications/initialized`, …); no response. | | unknown request | JSON-RPC error `-32601`. | The server emits `notifications/tools/list_changed` after a successful hot reload (only once `initialize` has happened). -When a `tools/call` carries a `progressToken`, the server also emits `notifications/progress` (`{progressToken, progress, message}`) for that call — one per step event, with a monotonically increasing `progress` counter and a `message` of `" "` (no `total`, since a workflow's step count is not known up front). Notifications stop the instant the call's response is sent; a call without a `progressToken` emits none. See [Serve workflows as MCP tools — Stream progress and cancel a long call](mcp.md#7-stream-progress-and-cancel-a-long-call). +When a `tools/call` carries a `progressToken`, the server also emits `notifications/progress` (`{progressToken, progress, message}`) for that call — one per step event, with a monotonically increasing `progress` counter and a `message` of `" "` (no `total`, since a def's step count is not known up front). Notifications stop the instant the call's response is sent; a call without a `progressToken` emits none. See [MCP server in 30 seconds — Stream progress and cancel a long call](mcp.md#7-stream-progress-and-cancel-a-long-call). ### Error mapping @@ -354,7 +348,7 @@ When a `tools/call` carries a `progressToken`, the server also emits `notificati | Unknown method | `-32601` | | Unknown tool, missing/non-string required argument, or unexpected argument key | `-32602` (the call never starts) | | Infrastructure crash while running a call | `-32603` (also logged to stderr) | -| **Workflow failure** | *not* a protocol error — a normal result with `isError: true` and a `run dir:` pointer | +| **Def failure** | *not* a protocol error — a normal result with `isError: true` and a `run dir:` pointer | ### Exposure and naming @@ -362,26 +356,25 @@ The tool surface is derived from the **entry file only** (imports are never expo | Rule | Behaviour | |---|---| -| `export workflow …` present | Exactly the exported workflows are exposed. | -| No exports | Every top-level workflow except channel route targets (skipped with a warning). | -| `default` | Exposed only when it is the sole candidate, named after the sanitized file basename (`.jh` stripped, non-`[A-Za-z0-9_-]` → `_`, truncated to 128); otherwise skipped. | +| `export def …` present | Exactly the exported defs are exposed. | +| No exports | No tools, plus a warning. | +| `main` | Exposed only when it is the sole export, named after the sanitized file basename (`.jh` stripped, non-`[A-Za-z0-9_-]` → `_`, truncated to 128; an empty result falls back to the literal `def`). Skipped when it is not the sole export, or when the sanitized name would collide with an already-exposed def. | -Tool descriptions come from the `#` comment lines directly above each workflow (shebang lines dropped, `#` prefix stripped); the fallback is `Run the "" workflow from .` Every parameter is a required string in the input schema. +Tool descriptions come from the `#` comment lines directly above each def (shebang lines dropped, `#` prefix stripped); the fallback is `Run the "" def from .` Every parameter is a required string in the input schema. ### Execution and hot reload -- Tool calls honor the same env-driven sandbox selection as `jaiph run` (`resolveDockerConfig`): Docker on macOS/Linux by default, and host-only on Windows or when you consent to unsafe mode with the explicit `--unsafe` (an inherited `JAIPH_UNSAFE=true` alone is refused; see the flag table). The image is prepared once at startup (`checkDockerAvailable` + `prepareImage`), not per call. Run artifacts land under `.jaiph/runs/` exactly as for `jaiph run`. -- **The workspace is isolated by default** for `jaiph mcp` — the same as `jaiph run`. Each tool call's container works on a writable point-in-time snapshot of the workspace, so edits are discarded on exit and the host tree is untouched. Pass `--inplace` (or set `JAIPH_INPLACE=1`) to bind the real workspace read-write so tool effects land live (opt-in), or `--unsafe` to run on the host with no sandbox (host-only mode needs the explicit flag; an inherited `JAIPH_UNSAFE=true` alone is refused, see the flag table). The posture is resolved and printed once at startup and applied to every call. +- Tool calls execute on the host, the same as `jaiph run`. Run artifacts land under `.jaiph/runs/` exactly as for `jaiph run`. Concurrent calls each get their own run id and run directory. Two calls that change the same files can race. - Source files in the module graph are watched (polling, ~750 ms). A valid edit re-derives tools and emits `notifications/tools/list_changed`; an edit that fails to compile keeps the previous tool set serving and logs diagnostics to stderr. - Calls bind to the generation (emitted scripts + serialized graph) live when they start; a superseded generation's scripts dir survives until its last in-flight call settles, so a call spanning a reload still runs its remaining steps — the same lease model `jaiph serve` uses for HTTP runs. ## `jaiph serve` {: #jaiph-serve} -Serve a file's workflows as an HTTP API with a generated OpenAPI 3.1 document and an embedded Swagger UI. Same exposure rules and execution layer as `jaiph mcp`, over HTTP instead of stdio. See [Serve workflows over HTTP](serve.md) for the recipe. +Serve a file's defs as an HTTP API with a generated OpenAPI 3.1 document and an embedded Swagger UI. Same exposure rules and execution layer as `jaiph mcp`, over HTTP instead of stdio. See [Serve defs over HTTP](serve.md) for the recipe. ```text -jaiph serve [--host ] [--port ] [--workspace ] [--allow-anonymous] [--inplace] [--unsafe] [--yes|-y] [--env KEY[=VALUE]]... +jaiph serve [--host ] [--port ] [--workspace ] [--allow-anonymous] [--env KEY[=VALUE]]... ``` | Flag | Argument | Effect | @@ -391,14 +384,11 @@ jaiph serve [--host ] [--port ] [--workspace ] [--allow-anonymous] | `--allow-anonymous` | — | Explicit opt-in to run open with no authentication on loopback. Without it, a loopback bind with no `JAIPH_SERVE_TOKEN` and no OIDC aborts startup, because anonymous mode authorizes every local principal with all capabilities over all runs (loopback guards the network, not other local users — finding M-2). For a single-user workstation only; shared hosts must set `JAIPH_SERVE_TOKEN` or configure OIDC. When passed, the server prints a startup warning that it is open to all local principals. Ignored (no-op) when a token or OIDC is configured, and it never permits a non-loopback bind. | | `--workspace` | `` | Workspace root for import resolution (default: auto-detected). | | `--env` | `KEY=VALUE` or `KEY` | Same per-key passthrough as `jaiph run --env`, resolved once at startup and applied to every run for the server's lifetime. | -| `--inplace` | — | Front-end for `JAIPH_INPLACE=1`: every run's Docker sandbox bind-mounts the host workspace read-write. Mutually exclusive with `--unsafe` (`E_FLAG_CONFLICT` at startup, before anything is spawned). No interactive prompt — launching the server with the flag (or env var) is the consent; the effective posture is printed once at startup and applied to every run. | -| `--unsafe` | — | Front-end for `JAIPH_UNSAFE=true`: every run executes on the host with no sandbox. Host-only mode requires explicit consent on this command line, so pass `--unsafe` (or `--yes`). An inherited `JAIPH_UNSAFE=true` with no such flag is refused at startup with `E_UNSAFE_NO_CONSENT` (skipped inside a container, where the container is the sandbox). Mutually exclusive with `--inplace` (`E_FLAG_CONFLICT`). When consent is given, the server prints a prominent SANDBOXING DISABLED banner at startup. | -| `-y`, `--yes` | — | Front-end for `JAIPH_INPLACE_YES=1` (recorded on every run's env; servers themselves never prompt). | | `-h`, `--help` | — | Print the subcommand usage and exit `0`. | -Flags that belong to another command (for example `--raw` or `--target`) are usage errors naming the owning command — never silently ignored. Precedence across layers is the shared execution-policy order: CLI flags > `JAIPH_*` env vars > workflow config metadata > defaults (see [Environment variables — Precedence](env-vars.md#precedence)). +Flags that belong to another command (for example `--raw` or `--target`) are usage errors naming the owning command — never silently ignored. Precedence across layers is the shared execution-policy order: CLI flags > `JAIPH_*` env vars > module config metadata > defaults (see [Environment variables — Precedence](env-vars.md#precedence)). -Startup mirrors `jaiph mcp`: graph load + `collectDiagnostics` (diagnostics to stderr, exit `1`), one-time Docker image preparation, credential pre-flight as warnings, and a sandbox-posture notice. All logs go to stderr; one startup line prints the listen URL and the `/docs` URL. Per-run operator lines (a start line `Running … run_id=` and an end line `Finished … status=… elapsed_ms=…`) and the optional workflow-log mirror follow the same stderr-only operator-log contract as `jaiph mcp`, and HTTP response bodies stay API payloads. See [Operator log (stderr)](#operator-log-stderr) above, and `JAIPH_SERVER_LOG` and `JAIPH_SERVER_LOG_WORKFLOW` in [Environment variables](env-vars.md). +Startup mirrors `jaiph mcp`: graph load + `collectDiagnostics` (diagnostics to stderr, exit `1`), credential pre-flight as warnings, and a host-execution notice. All logs go to stderr. Startup prints a line with the listen URL, the `/docs` and `/mcp` URLs, and the exposed-def count, followed by an authentication-mode line, a memory-bounds line, and, when terminal runs were rebuilt from disk, a line reporting how many were reconstructed. Per-run operator lines (a start line `Running … run_id=` and an end line `Finished … status=… elapsed_ms=…`) and the optional log mirror follow the same stderr-only operator-log contract as `jaiph mcp`, and HTTP response bodies stay API payloads. See [Operator log (stderr)](#operator-log-stderr) above, and `JAIPH_SERVER_LOG` and `JAIPH_SERVER_LOG_RUNS` in [Environment variables](env-vars.md). ### Endpoints @@ -411,8 +401,8 @@ The **Cap.** column names the capability an authenticated principal must hold to | `GET /openapi.json` | none | OpenAPI 3.1 document, regenerated per request (hot reload needs no cache invalidation). `404` when `JAIPH_SERVE_EXPOSE_DOCS=false`. | | `GET /docs` | none | Self-contained Swagger UI shell. The pinned `swagger-ui-dist` assets are embedded in the binary and served from same-origin `/docs/*` paths, so it needs no browser internet access. `404` when `JAIPH_SERVE_EXPOSE_DOCS=false`. | | `GET /docs/swagger-ui-bundle.js`, `GET /docs/swagger-ui.css` | none | The embedded Swagger UI assets, each stamped with a `sha384` Subresource Integrity hash over the served bytes. `404` when `JAIPH_SERVE_EXPOSE_DOCS=false`. | -| `GET /v1/workflows` | `inspect` | `{workflows: [{name, description, params}]}`. | -| `POST /v1/workflows/{name}/runs` | `invoke` | Start a run. Default `202` + `Location: /v1/runs/{id}`; `?wait=true` blocks for the terminal `200`. Send an `Idempotency-Key` header (scoped to the authenticated principal + workflow) to make retries safe: an identical repeat returns the original run (`200`, no second spawn); a reused key with different arguments is `409 E_IDEMPOTENCY_CONFLICT` and spawns nothing. | +| `GET /v1/defs` | `inspect` | `{defs: [{name, description, params}]}`. | +| `POST /v1/defs/{name}/runs` | `invoke` | Start a run. Default `202` + `Location: /v1/runs/{id}`; `?wait=true` blocks for the terminal `200`. Send an `Idempotency-Key` header (scoped to the authenticated principal + def) to make retries safe: an identical repeat returns the original run (`200`, no second spawn); a reused key with different arguments is `409 E_IDEMPOTENCY_CONFLICT` and spawns nothing. | | `GET /v1/runs` | `inspect` | Runs started by this process **plus runs reconstructed from disk on restart**, newest first, scoped to the caller's own runs (all runs for a static/open principal). Paginated: `?limit` (default `100`, clamped to `1000`), `?offset` (default `0`). Response is `{runs, total, limit, offset}` and never unbounded. | | `GET /v1/runs/{id}` | `inspect` | The run object. `404` unknown (a run the principal does not own is indistinguishable from nonexistent). | | `GET /v1/runs/{id}/events` | `inspect` | The run's `run_summary.jsonl`. Default `application/x-ndjson` snapshot, streamed from disk (never buffered whole); `Accept: text/event-stream` replays then follows it live, closing with `event: end` when terminal. The snapshot mode first verifies the journal's keyed integrity chain and returns `409 E_TAMPERED` when the chain does not verify (see [Architecture — Keyed hash chain](architecture.md#hash-chain)). Served verbatim (already credential-redacted); raw capture files are never exposed. `404` unknown. | @@ -420,26 +410,25 @@ The **Cap.** column names the capability an authenticated principal must hold to | `GET /v1/runs/{id}/artifacts/{path}` | `inspect` | Download one published file (`application/octet-stream`), streamed with backpressure — never buffered whole, so an arbitrarily large file costs no server memory and a client disconnect closes the file. Traversal-proof — `..`, absolute paths, and escaping symlinks are `404`. `413 E_ARTIFACT_TOO_LARGE` when the file exceeds `JAIPH_SERVE_MAX_ARTIFACT_BYTES`. | | `POST /v1/runs/{id}/cancel` | `cancel` | `202`; the run reaches `cancelled`. `409` if already terminal. | -The run object is `{run_id, workflow, status, started_at, ended_at, exit_status, signal, result_text, run_dir, principal, correlation_id}` where `status` is `running` \| `succeeded` \| `failed` \| `cancelled` \| `interrupted`. `principal` is the audit subject that created the run (`anonymous`/`operator` in open/static mode, the token `sub` or `client_id` in OIDC mode — never a token) and `correlation_id` is the request id attached at create time; both are `null` when unset. `interrupted` is the terminal state a run is reconciled to after a process death caught it mid-flight — its outcome is unknown, so it is neither `succeeded` nor `failed`, but it is never reported as permanently `running`. **A workflow failure is not an HTTP error** — the run object reports `status: "failed"` with the same failure narrative `jaiph mcp` returns, over HTTP `200`/`202`. Errors use `{error: {code, message}}` with `400 E_BAD_ARGS`, `401 E_UNAUTHORIZED` (missing/invalid static token), `401 E_TOKEN_EXPIRED` / `401 E_TOKEN_INVALID` (OIDC token expired, or bad audience/issuer/key/signature/algorithm), `403 E_FORBIDDEN` (principal lacks the required capability), `404 E_NOT_FOUND`, `409 E_RUN_TERMINAL`, `409 E_IDEMPOTENCY_CONFLICT` (idempotency key reused with different arguments), `409 E_TAMPERED` (the run's journal failed its keyed integrity chain), `413 E_BODY_TOO_LARGE` (1 MiB request-body cap), `413 E_ARTIFACT_TOO_LARGE` (artifact download over `JAIPH_SERVE_MAX_ARTIFACT_BYTES`), `415` (non-`application/json` body), `429 E_TOO_MANY_RUNS`, and `503 E_AUTH_UNAVAILABLE` (OIDC identity provider / JWKS unreachable). +The run object is `{run_id, def, status, started_at, ended_at, exit_status, signal, result_text, run_dir, principal, correlation_id}` where `status` is `running` \| `succeeded` \| `failed` \| `cancelled` \| `interrupted`. `principal` is the audit subject that created the run (`anonymous`/`operator` in open/static mode, the token `sub` or `client_id` in OIDC mode — never a token) and `correlation_id` is the request id attached at create time; both are `null` when unset. `interrupted` is the terminal state a run is reconciled to after a process death caught it mid-flight — its outcome is unknown, so it is neither `succeeded` nor `failed`, but it is never reported as permanently `running`. **A def failure is not an HTTP error** — the run object reports `status: "failed"` with the same failure narrative `jaiph mcp` returns, over HTTP `200`/`202`. Errors use `{error: {code, message}}` with `400 E_BAD_ARGS`, `401 E_UNAUTHORIZED` (missing or invalid static token; in OIDC mode, a request with no bearer token, or a verified token that carries neither `sub` nor `client_id`), `401 E_TOKEN_EXPIRED` / `401 E_TOKEN_INVALID` (OIDC token expired, or bad audience/issuer/key/signature/algorithm), `403 E_FORBIDDEN` (principal lacks the required capability), `404 E_NOT_FOUND`, `409 E_RUN_TERMINAL`, `409 E_IDEMPOTENCY_CONFLICT` (idempotency key reused with different arguments), `409 E_TAMPERED` (the run's journal failed its keyed integrity chain), `413 E_BODY_TOO_LARGE` (1 MiB request-body cap), `413 E_ARTIFACT_TOO_LARGE` (artifact download over `JAIPH_SERVE_MAX_ARTIFACT_BYTES`), `415` (non-`application/json` body), `429 E_TOO_MANY_RUNS`, and `503 E_AUTH_UNAVAILABLE` (OIDC identity provider / JWKS unreachable). Each run's public record is persisted beside its journal as `run.json` when it finishes, and reconstructed into the registry on startup — so `GET /v1/runs`, `/v1/runs/{id}`, `/events`, and `/artifacts` keep working for pre-restart terminal runs, and idempotency keys survive a restart. `jaiph serve` is a **single-replica** service: the run registry, concurrency cap, and idempotency index are per-process and not shared across replicas — run two behind one load balancer and each has its own view. See [Serve — deployment topology](serve.md#deployment-topology). ### Auth and limits -- **Authentication** has two production modes (credentials come from the environment, never argv) plus an anonymous mode that is an explicit opt-in for a single-user workstation (`--allow-anonymous`). **Static single-operator token:** `JAIPH_SERVE_TOKEN` is a shared secret required on every `/v1/*` and `/mcp` request (`Authorization: Bearer `, constant-time compared). It is a fail-closed gate for **one operator** — no per-user identity, revocation, or per-action authorization; the operator holds every capability and sees every run — not multi-tenant authentication. **OIDC/JWT (multi-tenant):** set `JAIPH_SERVE_OIDC_ISSUER` + `JAIPH_SERVE_OIDC_AUDIENCE` (takes precedence over the static token; setting only one is a startup error) to verify bearer JWTs against the issuer's JWKS (discovered from `/.well-known/openid-configuration`, or set `JAIPH_SERVE_OIDC_JWKS_URI`) with a maintained JWT library — signature, `exp`/`nbf`, `aud`, `iss`, `kid`, and an explicit allowlist of asymmetric signing algorithms (RSA, ECDSA, and EdDSA families; symmetric algorithms, `alg: none`, and `ES256K` are rejected). Each token is authorized by OAuth scopes: `jaiph:invoke` (run), `jaiph:inspect` (read workflows/runs/events/artifacts, MCP `tools/list`), `jaiph:cancel` (cancel a run); a missing capability is `403 E_FORBIDDEN`, and a principal (the token `sub`, or `client_id` for `sub`-less machine tokens; a verified token with neither is `401 E_UNAUTHORIZED`) may inspect or cancel **only the runs it created**. The authenticated subject and the request's correlation id (`X-Correlation-Id` / `X-Request-Id`, else a generated UUID) attach to run metadata, the invoke/cancel audit log lines, OTLP resource attributes, and Sentry tags — never a token or a claim value. +- **Authentication** has two production modes (credentials come from the environment, never argv) plus an anonymous mode that is an explicit opt-in for a single-user workstation (`--allow-anonymous`). **Static single-operator token:** `JAIPH_SERVE_TOKEN` is a shared secret required on every `/v1/*` and `/mcp` request (`Authorization: Bearer `, constant-time compared). It is a fail-closed gate for **one operator** — no per-user identity, revocation, or per-action authorization; the operator holds every capability and sees every run — not multi-tenant authentication. **OIDC/JWT (multi-tenant):** set `JAIPH_SERVE_OIDC_ISSUER` + `JAIPH_SERVE_OIDC_AUDIENCE` (takes precedence over the static token; setting only one is a startup error) to verify bearer JWTs against the issuer's JWKS (discovered from `/.well-known/openid-configuration`, or set `JAIPH_SERVE_OIDC_JWKS_URI`) with a maintained JWT library — signature, `exp`/`nbf`, `aud`, `iss`, `kid`, and an explicit allowlist of asymmetric signing algorithms (RSA, ECDSA, and EdDSA families; symmetric algorithms, `alg: none`, and `ES256K` are rejected). Each token is authorized by OAuth scopes: `jaiph:invoke` (run), `jaiph:inspect` (read defs/runs/events/artifacts, MCP `tools/list`), `jaiph:cancel` (cancel a run); a missing capability is `403 E_FORBIDDEN`, and a principal (the token `sub`, or `client_id` for `sub`-less machine tokens; a verified token with neither is `401 E_UNAUTHORIZED`) may inspect or cancel **only the runs it created**. The authenticated subject and the request's correlation id (`X-Correlation-Id` / `X-Request-Id`, else a generated UUID) attach to run metadata, the invoke/cancel audit log lines, OTLP resource attributes, and Sentry tags — never a token or a claim value. - Binding a non-loopback `--host` with **no** authentication is a startup error, and `--allow-anonymous` does not lift it. On loopback with no token or OIDC, startup is also refused unless you pass `--allow-anonymous` — anonymous mode makes every caller the `anonymous` principal with all capabilities over all runs, so it is for a single-user workstation only and prints a startup warning when enabled. - `JAIPH_SERVE_EXPOSE_DOCS` (default `true`) controls whether `/docs` and `/openapi.json` are served; set `false` (or `0`) to return `404` for both and hide the API surface. `/healthz` is always open and credential-free (liveness/readiness only — no tokens or sensitive detail). - `JAIPH_SERVE_MAX_CONCURRENT` (default `4`) caps simultaneous runs; requests beyond it get `429`. - `JAIPH_SERVE_MAX_ARTIFACT_BYTES` (default `0` = no cap) refuses artifact downloads larger than the limit with `413`. Downloads stream with backpressure regardless, so the default keeps server memory bounded no matter the file size; set a finite cap only to reject oversized downloads outright. -- Memory bounds keep a long-lived server from growing without limit: `JAIPH_SERVE_MAX_OUTPUT_BYTES` (default 1 MiB) caps collected stdout, stderr, log output, and the resident `result_text` per run (overflow dropped with a truncation marker); `JAIPH_SERVE_RETAIN_RUNS` (default `500`) and `JAIPH_SERVE_RETAIN_AGE_SEC` (default `86400`, `0` disables) bound how many completed runs stay in the in-memory registry, evicting the oldest terminal records first. **Active runs are never evicted**, and eviction drops only the in-memory record — durable `.jaiph/runs` journals and artifacts persist on disk and are the operator's to prune. See [Serve workflows over HTTP](serve.md#9-bound-memory-over-a-long-lived-server). +- Memory bounds keep a long-lived server from growing without limit: `JAIPH_SERVE_MAX_OUTPUT_BYTES` (default 1 MiB) caps collected stdout, stderr, log output, and the resident `result_text` per run (overflow dropped with a truncation marker); `JAIPH_SERVE_RETAIN_RUNS` (default `500`) and `JAIPH_SERVE_RETAIN_AGE_SEC` (default `86400`, `0` disables) bound how many completed runs stay in the in-memory registry, evicting the oldest terminal records first. **Active runs are never evicted**, and eviction drops only the in-memory record — durable `.jaiph/runs` journals and artifacts persist on disk and are the operator's to prune. See [Serve defs over HTTP](serve.md#9-bound-memory-over-a-long-lived-server). - Execution and hot reload are identical to `jaiph mcp`; a superseded generation's scripts dir survives until its in-flight HTTP runs finish. ## Environment variables -See [Environment variables](env-vars.md) for the complete inventory. The variables most relevant to CLI behaviour: +See [Environment variables](env-vars.md) for the complete inventory. The variables most relevant to CLI behavior: -- `JAIPH_DOCKER_ENABLED`, `JAIPH_UNSAFE`, `JAIPH_INPLACE`, `JAIPH_INPLACE_YES` — sandbox enablement and mode. -- `JAIPH_DOCKER_IMAGE`, `JAIPH_DOCKER_IMAGE_DIGEST`, `JAIPH_DOCKER_NETWORK`, `JAIPH_DOCKER_TIMEOUT` — Docker mode parameters. +- `JAIPH_RUN_TIMEOUT` — parent-enforced wall-clock cap for a run. - `JAIPH_NON_TTY_HEARTBEAT_FIRST_SEC`, `JAIPH_NON_TTY_HEARTBEAT_INTERVAL_MS` — non-TTY progress cadence. - `JAIPH_RUNS_DIR`, `JAIPH_WORKSPACE`, `JAIPH_SOURCE_FILE` — run-layout inputs. - `JAIPH_INSTALL_COMMAND`, `JAIPH_REGISTRY`, `JAIPH_SKILL_PATH` — install / init inputs. @@ -450,7 +439,7 @@ See [Environment variables](env-vars.md) for the complete inventory. The variabl - **Live contract** (runtime → CLI): `__JAIPH_EVENT__` JSON lines on **stderr** only. Hooks and the interactive progress tree consume this stream. Stdout carries plain script output forwarded as-is. - **Durable contract**: `.jaiph/runs/...` + `run_summary.jsonl` + `.out` / `.err` step artifacts + optional `return_value.txt`. See [Architecture — Durable artifact layout](architecture.md#durable-artifact-layout). -`run_summary.jsonl` event types: `WORKFLOW_START`, `WORKFLOW_END`, `STEP_START`, `STEP_END`, `LOG`, `LOGERR`, `LOGWARN`, `INBOX_ENQUEUE`, `INBOX_DISPATCH_START`, `INBOX_DISPATCH_COMPLETE`, `PROMPT_START`, `PROMPT_END`. Every object carries `type`, `ts` (UTC), `run_id`, and `event_version` (currently `1`). Step events also carry `id`, `parent_id`, `seq`, `depth`. See [Architecture — Contracts](architecture.md#contracts). +`run_summary.jsonl` event types: `RUN_START`, `RUN_END`, `STEP_START`, `STEP_END`, `LOG`, `LOGERR`, `LOGWARN`, `INBOX_ENQUEUE`, `INBOX_DISPATCH_START`, `INBOX_DISPATCH_COMPLETE`, `PROMPT_START`, `PROMPT_END`. Every object carries `type`, `ts` (UTC), `run_id`, and `event_version` (currently `1`). Step events also carry `id`, `parent_id`, `seq`, `depth`. See [Architecture — Contracts](architecture.md#contracts). ## File extension @@ -462,4 +451,4 @@ See [Environment variables](env-vars.md) for the complete inventory. The variabl - [Grammar](grammar.md) — syntax and validation catalog. - [Language](language.md) — step semantics and step-output contract. - [Environment variables](env-vars.md) — every variable Jaiph reads. -- [Serve workflows as MCP tools](mcp.md) — exposing a file's workflows to MCP clients via `jaiph mcp`. +- [MCP server in 30 seconds](mcp.md) — exposing a file's exported defs to MCP clients via `jaiph mcp`. diff --git a/docs/configuration.md b/docs/configuration.md index 7981b328..3676666f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -9,26 +9,25 @@ redirect_from: # Configuration -This page is the authoritative inventory of Jaiph configuration keys: every key, its value type, default, environment-variable equivalent, and precedence. For environment-variable details (defaults, scopes, sandbox forwarding) see [Environment variables](env-vars.md). For the CLI flags that front-end the same knobs see [CLI](cli.md). +This page is the authoritative inventory of Jaiph configuration keys: every key, its value type, default, environment-variable equivalent, and precedence. For environment-variable details (defaults, scopes) see [Environment variables](env-vars.md). For the CLI flags that front-end the same knobs see [CLI](cli.md). Configuration sources, in priority order: 1. **Environment variables** — locked once observed by the host CLI; see [Locked variables](#locked-variables). -2. **Workflow-level `config { … }`** — applies for the duration of that workflow. -3. **Module-level `config { … }`** — applies to all workflows in that file unless overridden. +2. **Def-level `config { … }`** — applies for the duration of that def. +3. **Module-level `config { … }`** — applies to all defs in that file unless overridden. 4. **Built-in defaults** — lowest priority. -Docker enablement uses a separate, env-only resolution; see [Docker enablement](#docker-enablement). ## Config block syntax | Aspect | Rule | |---|---| | Module-level | At most one `config { … }` block per `.jh` file. May appear anywhere among top-level constructs. | -| Workflow-level | At most one nested `config { … }` per workflow body. Must be the first non-comment construct in the body. | -| Allowed module keys | `agent.*`, `run.*`, `runtime.*`, `module.*`, and `trusted_envs`. | -| Allowed workflow keys | `agent.*`, `run.*`, and `trusted_envs`. `runtime.*` and `module.*` are `E_PARSE`. | -| Duplicate block | `E_PARSE duplicate config block (only one allowed per file)` / `E_PARSE duplicate config block inside workflow (only one allowed per workflow)`. | +| Def-level | At most one nested `config { … }` per def body. Must be the first non-comment construct in the body. | +| Allowed module-level keys | `agent.*`, `run.*`, `module.*`, and `trusted_envs`. | +| Allowed def-level keys | `agent.*`, `run.*`, and `trusted_envs`. A `module.*` key in a def-level block is `E_PARSE` (`module.* keys are not allowed in def-level config (only agent.* and run.* keys)`). Any other unrecognized key is the unknown-key error below. | +| Duplicate block | `E_PARSE duplicate config block (only one allowed per file)` / `E_PARSE duplicate config block inside def (only one allowed per def)`. | | Unknown key | `E_PARSE unknown config key: . Allowed: …` (lists every allowed key). | | Wrong value type | `E_PARSE`. | @@ -59,18 +58,18 @@ String config values support the same `${identifier}` interpolation as orchestra | Config level | Available identifiers | |---|---| | Module-level | Module `const` values and environment variables | -| Workflow-level | Module `const` values, environment variables, and that workflow's parameters | +| Def-level | Module `const` values, environment variables, and that def's parameters | -Interpolation runs when the config scope is applied (workflow entry for workflow-level keys; CLI startup for module-level keys). Environment variables still win over in-file config when locked. +Interpolation runs when the config scope is applied (def entry for def-level keys; CLI startup for module-level keys). Environment variables still win over in-file config when locked. ## Agent keys | Key | Type | Default | Env equivalent | Notes | |---|---|---|---|---| -| `agent.model` | string | — | `JAIPH_AGENT_MODEL` (env only) | Model for `prompt` steps in this scope. Resolved at each `prompt` invocation and passed as a per-call `--model` flag — it does **not** set `JAIPH_AGENT_MODEL` in the workflow environment, so scripts and other steps do not see it. Set `JAIPH_AGENT_MODEL` in the shell to override all prompts in a run. | +| `agent.model` | string | — | `JAIPH_AGENT_MODEL` (env only) | Model for `prompt` steps in this scope. Resolved at each `prompt` invocation and passed as a per-call `--model` flag — it does **not** set `JAIPH_AGENT_MODEL` in the run environment, so scripts and other steps do not see it. Set `JAIPH_AGENT_MODEL` in the shell to override all prompts in a run. | | `agent.command` | string | `cursor-agent` | `JAIPH_AGENT_COMMAND` | Cursor backend command. Basename other than `cursor-agent` enables custom-command mode (stdin → command → stdout). **Entry module only** — imported modules cannot set this key by default (see [Import trust boundary](#import-trust-boundary)). | | `agent.backend` | string (`cursor` \| `claude` \| `codex`) | `cursor` | `JAIPH_AGENT_BACKEND` | Backend selector. **Entry module only** — imported modules cannot set this key by default (see [Import trust boundary](#import-trust-boundary)). | -| `agent.trusted_workspace` | string (path) | workspace root | `JAIPH_AGENT_TRUSTED_WORKSPACE` | Directory passed to Cursor as `--trust`. When unset, defaults to `JAIPH_WORKSPACE`. A relative path in the **entry module's module-level** config is resolved against the workspace root to an absolute path at CLI startup. Values applied at runtime — a workflow-level block or an imported module — are assigned to the env var exactly as authored (not normalized). | +| `agent.trusted_workspace` | string (path) | workspace root | `JAIPH_AGENT_TRUSTED_WORKSPACE` | Directory passed to Cursor as `--trust`. When unset, defaults to `JAIPH_WORKSPACE`. A relative path in the **entry module's module-level** config is resolved against the workspace root to an absolute path at CLI startup. Values applied at runtime — a def-level block or an imported module — are assigned to the env var exactly as authored (not normalized). | | `agent.cursor_flags` | string | — | `JAIPH_AGENT_CURSOR_FLAGS` | Extra flags appended to Cursor invocations (whitespace-split). | | `agent.claude_flags` | string | — | `JAIPH_AGENT_CLAUDE_FLAGS` | Extra flags appended to Claude invocations (whitespace-split). | @@ -80,11 +79,11 @@ Interpolation runs when the config scope is applied (workflow entry for workflow |---|---|---|---|---| | `run.logs_dir` | string (path) | `.jaiph/runs` | `JAIPH_RUNS_DIR` | Step log directory. Relative paths join the workspace root; absolute paths are used as-is. | | `run.debug` | boolean | `false` | `JAIPH_DEBUG` | Enable debug tracing. | -| `run.recover_limit` | integer | `10` | — (no env override) | Maximum attempts for `run … recover` loops before the step fails. Resolves via workflow > module > default. | +| `run.recover_limit` | integer | `10` | — (no env override) | Maximum attempts for `run … recover` loops before the step fails. Resolves via def > module > default. | ## Module keys -Informational metadata only; does not affect execution. Allowed in module-level config only — any `module.*` key inside a workflow-level config is `E_PARSE`. +Informational metadata only; does not affect execution. Allowed in module-level config only — any `module.*` key inside a def-level config is `E_PARSE`. | Key | Type | Default | |---|---|---| @@ -95,18 +94,18 @@ Informational metadata only; does not affect execution. Allowed in module-level ## Trusted env keys (`trusted_envs`) {: #trusted-envs} -`trusted_envs = "GITHUB_TOKEN NPM_TOKEN"` declares which **host** environment variables a workflow's trusted `run` steps receive — the declarative alternative to remembering `jaiph run --env GITHUB_TOKEN …`. The value is a quoted, space-separated list of env var names. +`trusted_envs = "GITHUB_TOKEN NPM_TOKEN"` declares which **host** environment variables a def's trusted `run` steps receive — the declarative alternative to remembering `jaiph run --env GITHUB_TOKEN …`. The value is a quoted, space-separated list of env var names. | Scope | Effect | |---|---| -| Module-level `config` | Sugar: applies to every workflow in the file. | -| Workflow-level `config` | Scopes the keys to that workflow only. | +| Module-level `config` | Sugar: applies to every def in the file. | +| Def-level `config` | Scopes the keys to that def only. | | Imported (non-entry) module | **Ignored** (warned at pre-flight) — an imported module must not be able to pull arbitrary host secrets into its own steps. Mirrors the [import trust boundary](#import-trust-boundary) for `agent.command` / `agent.backend`. | ```jaiph -config { trusted_envs = "NPM_TOKEN" } # module-level: every workflow's run steps +config { trusted_envs = "NPM_TOKEN" } # module-level: every def's run steps -workflow publish { +def publish{ config { trusted_envs = "GITHUB_TOKEN" } # only publish's run steps also see GITHUB_TOKEN run release() } @@ -114,40 +113,11 @@ workflow publish { Semantics: -- Declared keys resolve from the **pristine host environment captured once at process start** — never from the calling workflow's scope env. A sub-workflow does not inherit a caller's keys by being called; it must declare `trusted_envs` itself. -- Resolved values are injected **only into `run`-step script subprocesses** of the declaring workflow. They are **never** forwarded to `prompt` agent subprocesses — the prompt env stays the fail-closed allowlist described in [Sandboxing](sandboxing.md), in every sandbox mode. -- Declaring a key anywhere in the file (or an imported module) also **scrubs** it from every workflow's ambient scope env, so only the declaring workflow's `run` steps see it. -- Pre-flight: a declared key with no value on the host (and no `--env` override) aborts before anything is spawned (`E_ENV_MISSING`). Reserved keys (the `--env` `E_ENV_RESERVED` set, including `JAIPH_DOCKER_*`) are rejected at parse time. +- Declared keys resolve from the **pristine host environment captured once at process start** — never from the calling def's scope env. A called def does not inherit a caller's keys by being called; it must declare `trusted_envs` itself. +- Resolved values are injected **only into `run`-step script subprocesses** of the declaring def. They are **never** forwarded to `prompt` agent subprocesses — the prompt env stays the fail-closed allowlist (base env, `JAIPH_*` control keys, and that backend's own credential keys). +- Declaring a key anywhere in the file (or an imported module) also **scrubs** it from every def's ambient scope env, so only the declaring def's `run` steps see it. +- Pre-flight: a declared key with no value on the host (and no `--env` override) aborts before anything is spawned (`E_ENV_MISSING`). Reserved keys (the `--env` `E_ENV_RESERVED` set) are rejected at parse time. - `--env KEY=VALUE` remains the imperative override: it wins over the host-snapshot value for the same key. -- Docker: the entry file's resolved keys cross the sandbox boundary through the same explicit `-e` channel as `--env` pairs — **but only when the operator opts in** with `JAIPH_TRUSTED_ENVS=1`. **Authoring the entry file is a trust boundary equal to `--env`:** an untrusted or model-edited entry could name arbitrary host secrets (`AWS_SECRET_ACCESS_KEY`, `GITHUB_TOKEN`) and pull them across the allowlist the sandbox exists to enforce (finding M-7). Absent the opt-in, the entry file's `trusted_envs` is ignored under Docker (with a pre-flight warning) and nothing is forwarded. Host modes have no allowlist to bypass (the runner inherits the host env directly), so they honour the declaration regardless. See [`JAIPH_TRUSTED_ENVS`](env-vars.md). - -## Runtime (Docker) keys - -These configure the Docker sandbox. Allowed in **module-level** config only. They are read by the host CLI when it considers a Docker launch (`resolveDockerConfig` in `src/runtime/docker.ts`) and never affect `NodeWorkflowRuntime` directly. **Docker on/off is not a `runtime.*` key** — see [Docker enablement](#docker-enablement). - -| Key | Type | Default | Env equivalent | Notes | -|---|---|---|---|---| -| `runtime.docker_image` | string | `ghcr.io/jaiphlang/jaiph-runtime:` | `JAIPH_DOCKER_IMAGE` | Container image. Must already contain `jaiph` (`E_DOCKER_NO_JAIPH` otherwise). **Host-controlled:** an in-file value is rejected (`E_DOCKER_IMAGE_HOST_ONLY`) when Docker is the active sandbox; set a non-default image only through `JAIPH_DOCKER_IMAGE`. | -| `runtime.docker_network` | string | `default` | `JAIPH_DOCKER_NETWORK` | `docker run --network` value. `none` disables egress. **Host-controlled for isolation-breaking values:** an in-file `host`, `container:*`, or `ns:*` is rejected (`E_DOCKER_NETWORK_HOST_ONLY`) when Docker is the active sandbox — these dissolve the sandbox network boundary. Host-safe in-file values (`default`, `none`, a named bridge network) are honoured; the operator may still select any value, including `host`, through `JAIPH_DOCKER_NETWORK`. | -| `runtime.docker_timeout_seconds` | integer | `14400` | `JAIPH_DOCKER_TIMEOUT` | Container execution timeout in seconds. `0` disables. Negative or invalid env value produces `E_DOCKER_TIMEOUT`. | - -In-file `runtime.docker_enabled` is not supported (`E_PARSE`); use the env-only enablement below. In the same spirit, `runtime.docker_image` and isolation-breaking `runtime.docker_network` values are host-controlled: a repo- or model-supplied entry file cannot point the sandbox at an arbitrary image or gut its network isolation (finding M-6). When Docker is off (host / `JAIPH_UNSAFE` mode) these keys are inert and not enforced. - -The default official image is also pinned by manifest digest. The expected digest ships with the release, and every run verifies the local image against it and fails closed on a mismatch. There is no config-file key for the digest, so set or override it with the [`JAIPH_DOCKER_IMAGE_DIGEST`](env-vars.md) environment variable, which also lets you pin a custom `JAIPH_DOCKER_IMAGE`. - -## Docker enablement - -Checks are applied top to bottom; the first match wins. - -| Check | Result | -|---|---| -| Platform is Windows (`win32`) | Docker off (host-only mode, with a one-line notice). Overrides everything below, including `JAIPH_DOCKER_ENABLED=true`. | -| `JAIPH_DOCKER_ENABLED` is set to exact `true` | Docker on. | -| `JAIPH_DOCKER_ENABLED` is set to any other value | Docker off. | -| `JAIPH_DOCKER_ENABLED` is unset and `JAIPH_UNSAFE=true` | Docker off. | -| Default (no env) | Docker on. | - -`CI=true` does not change this default. Host `jaiph run --raw` never consults this branch — the workflow runner is local in that path. On Windows the Docker sandbox is out of scope, so `jaiph run` resolves to host-only mode automatically without probing `docker` or failing on a missing daemon — see [Sandboxing — Windows runs host-only](sandboxing.md#windows-runs-host-only) for the full model. ## Precedence {: #precedence} @@ -156,31 +126,18 @@ Checks are applied top to bottom; the first match wins. | Layer | Effect | |---|---| -| Environment (`JAIPH_AGENT_*`, `JAIPH_RUNS_DIR`, `JAIPH_DEBUG`) | Locked when present in the parent env; cannot be overridden by module- or workflow-level config. | -| Workflow-level `config` | Applies for the workflow body; restored on exit. | -| Module-level `config` | Applies to workflows without their own block. | +| Environment (`JAIPH_AGENT_*`, `JAIPH_RUNS_DIR`, `JAIPH_DEBUG`) | Locked when present in the parent env; cannot be overridden by module- or def-level config. | +| Def-level `config` | Applies for the def body; restored on exit. | +| Module-level `config` | Applies to defs without their own block. | | Built-in defaults | Lowest priority. | -### Runtime (Docker) keys - -| Layer | Effect | -|---|---| -| CLI flags (`--inplace`, `--unsafe`, `--yes` on `jaiph run` / `jaiph serve` / `jaiph mcp`) | Set the corresponding `JAIPH_*` variable on the launched env for that process, so the env layer below stays the single source of truth. | -| Environment (`JAIPH_DOCKER_*`, `JAIPH_UNSAFE`, `JAIPH_INPLACE`) | Highest env-layer priority for `image`, `network`, `timeout`, and sandbox posture. | -| Module-level `config` (`runtime.*`) | Applies when no env override is set. | -| Built-in defaults | Lowest priority. | - -Workflow-level `config` cannot set `runtime.*` keys. Contradictory posture (`--inplace`/`JAIPH_INPLACE` together with `--unsafe`/`JAIPH_UNSAFE`) is rejected with `E_FLAG_CONFLICT` before anything is spawned rather than resolved by precedence — see [Environment variables — Precedence](env-vars.md#precedence). - ### Scoping across nested calls | Call type | Scope behaviour | |---|---| -| Root entry (`jaiph run file.jh`) | Full module + workflow metadata applied with normal precedence. | -| Same-module `run` | Callee's workflow-level `config` is layered on top of the caller's effective env. Module-level config is not re-applied. | -| Cross-module `run` (e.g. `run alias.default()`) | Callee's module-level config is layered, then workflow-level on top — same as root-entry precedence, respecting `${NAME}_LOCKED`. **`agent.command` and `agent.backend` are not applied from imported modules** (see [Import trust boundary](#import-trust-boundary)). | -| Same-module `ensure` | Caller's scope is reused verbatim. | -| Cross-module `ensure` | Callee module's `agent.*` / `run.*` are merged on top of the current env (respecting locks). Workflow-level config does not apply to rules. | +| Root entry (`jaiph run file.jh`) | Full module + def metadata applied with normal precedence. | +| Same-module `run` | Callee's def-level `config` is layered on top of the caller's effective env. Module-level config is not re-applied. | +| Cross-module `run` (e.g. `run alias.main()`) | Callee's module-level config is layered, then def-level on top — same as root-entry precedence, respecting `${NAME}_LOCKED`. **`agent.command` and `agent.backend` are not applied from imported modules** (see [Import trust boundary](#import-trust-boundary)). | After any nested call returns, the caller's scope is restored exactly as before. @@ -194,7 +151,7 @@ Locked names: `JAIPH_AGENT_BACKEND`, `JAIPH_AGENT_MODEL`, `JAIPH_AGENT_COMMAND`, ## Import trust boundary {: #import-trust-boundary} -`agent.command` and `agent.backend` are **execution-binary keys** — they determine which process runs `prompt` steps. To prevent a third-party `.jh` library from silently redirecting execution to a different binary, these two keys may only be set from the **entry module's** `config {}` block (module-level or workflow-level). Imported modules that declare `agent.command` or `agent.backend` in their `config {}` are silently ignored for these keys. +`agent.command` and `agent.backend` are **execution-binary keys** — they determine which process runs `prompt` steps. To prevent a third-party `.jh` library from silently redirecting execution to a different binary, these two keys may only be set from the **entry module's** `config {}` block (module-level or def-level). Imported modules that declare `agent.command` or `agent.backend` in their `config {}` are silently ignored for these keys. [`trusted_envs`](#trusted-envs) carries the same entry-only restriction: declarations in imported modules are ignored (with a pre-flight warning) so a library cannot pull host secrets into its own steps. @@ -222,9 +179,6 @@ The existing `JAIPH_AGENT_COMMAND_LOCKED=1` / `JAIPH_AGENT_BACKEND_LOCKED=1` fla | `run.logs_dir` | `JAIPH_RUNS_DIR` | | `run.debug` | `JAIPH_DEBUG` | | `run.recover_limit` | _(no env override)_ | -| `runtime.docker_image` | `JAIPH_DOCKER_IMAGE` | -| `runtime.docker_network` | `JAIPH_DOCKER_NETWORK` | -| `runtime.docker_timeout_seconds` | `JAIPH_DOCKER_TIMEOUT` | | `module.name` | _(no env override)_ | | `module.version` | _(no env override)_ | | `module.description` | _(no env override)_ | @@ -245,17 +199,17 @@ Backend-specific flags come from `agent.cursor_flags` / `agent.claude_flags` (or ### Credential pre-flight {: #credential-pre-flight} -Before `jaiph run` spawns the workflow runner or Docker container, the host CLI runs a credential pre-flight (`src/cli/run/preflight-credentials.ts`). It collects the distinct backend(s) declared in the entry file's module-level `config` block and each workflow-level block, plus the effective default (`JAIPH_AGENT_BACKEND` env, or `cursor` when unset). Deeper per-import overrides resolved at runtime are not followed. +Before `jaiph run` spawns the runner, the host CLI runs a credential pre-flight (`src/cli/run/preflight-credentials.ts`). It collects the distinct backend(s) declared in the entry file's module-level `config` block and each def-level block, plus the effective default (`JAIPH_AGENT_BACKEND` env, or `cursor` when unset). Deeper per-import overrides resolved at runtime are not followed. -| Backend | Required credential | Host run (no Docker) | Docker run (any mode incl. `inplace`) | -|---|---|---|---| -| `codex` | `OPENAI_API_KEY` | hard error (`E_AGENT_CREDENTIALS`) | hard error (`E_AGENT_CREDENTIALS`) | -| `claude` | `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN` | warn (CLI login may still work) | hard error (`E_AGENT_CREDENTIALS`) | -| `cursor` | `CURSOR_API_KEY` | warn (CLI login may still work) | hard error (`E_AGENT_CREDENTIALS`) | +| Backend | Required credential | Host behaviour | +|---|---|---| +| `codex` | `OPENAI_API_KEY` | hard error (`E_AGENT_CREDENTIALS`) | +| `claude` | `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN` | warn (CLI login may still work) | +| `cursor` | `CURSOR_API_KEY` | warn (CLI login may still work) | -Hard errors exit non-zero with no runner or container launched. Warnings go to stderr and the run proceeds. Skip cases: entry file declares no explicit backend and uses no `prompt` step → no pre-flight; `jaiph run --raw` → no pre-flight; `JAIPH_UNSAFE=true` / `--unsafe` → no pre-flight (host escape hatch — runtime backend guards remain). +Hard errors exit non-zero with no runner launched. Warnings go to stderr and the run proceeds. Skip cases: entry file declares no explicit backend and uses no `prompt` step → no pre-flight; `jaiph run --raw` → no pre-flight. -Every error and warning names: the backend; the model when `agent.model` is set; the entry `.jh` file; the config scope (`module config`, `workflow `, `JAIPH_AGENT_BACKEND env`, or `default`); and the concrete remedy. Docker-mode messages also note that the variable must be set on the host so it gets forwarded. +Every error and warning names the backend, the model when `agent.model` is set, the entry `.jh` file, the config scope, and the concrete remedy. The config scope is one of `module config`, `def `, `JAIPH_AGENT_BACKEND env`, or `default`. ## Model resolution {: #model-resolution} @@ -265,7 +219,7 @@ Resolution order for a `prompt` step: | Step | Source | Notes | |---|---|---| | 1 | User env — `JAIPH_AGENT_MODEL` non-empty. | `model_reason: explicit`. Applies to every `prompt` in the run. | -| 2 | In-file config — `agent.model` from workflow-level then module-level metadata (interpolated at prompt time). | `model_reason: explicit`. Applies to that `prompt` invocation only; passed as `--model` to the backend CLI without writing `JAIPH_AGENT_MODEL`. | +| 2 | In-file config — `agent.model` from def-level then module-level metadata (interpolated at prompt time). | `model_reason: explicit`. Applies to that `prompt` invocation only; passed as `--model` to the backend CLI without writing `JAIPH_AGENT_MODEL`. | | 3 | Flags model — `--model ` inside `agent.cursor_flags` / `agent.claude_flags`. | `model_reason: flags`. Codex has no flag channel; this step does not apply. | | 4 | Backend default — Cursor/Claude binaries pick their own. Codex defaults to `gpt-4o` in code. | `model_reason: backend-default`. | @@ -287,7 +241,7 @@ For the Claude backend, when `agent.model` is set and `agent.claude_flags` does | 5 | 30m | | 6 | 2h | -Total worst-case wall-clock: ~2h41m. Under Docker, `runtime.docker_timeout_seconds` caps this. +Total worst-case wall-clock: ~2h41m. Only transport failures are retried (non-zero exit from cursor/claude, codex HTTP error, spawn failure). Deterministic post-processing failures — invalid JSON, schema validation — fail on the first attempt and return `{ ok: false }`. @@ -298,7 +252,7 @@ Each attempt emits its own `PROMPT_START` / `PROMPT_END` and `STEP_START` / `STE | `JAIPH_PROMPT_RETRY=0` | Disable retry entirely (one attempt, fail on transport failure). | | `JAIPH_PROMPT_RETRY_DELAYS` | Comma-separated list of non-negative integer milliseconds. Invalid entries abort the prompt. | -`jaiph test` defaults `JAIPH_PROMPT_RETRY=0`. Backoff sleep is interruptible: workflow abort, SIGINT, or SIGTERM cancels the pending wait without further backend calls. +`jaiph test` defaults `JAIPH_PROMPT_RETRY=0`. Backoff sleep is interruptible: run abort, SIGINT, or SIGTERM cancels the pending wait without further backend calls. ## Prompt watchdog timeouts {: #prompt-watchdog-timeouts} @@ -313,16 +267,16 @@ The retry backoff above handles a backend that *fails*. A separate set of watchd Set any variable to `0` to disable that layer. The idle timer resets on every chunk of backend output, so a slow-but-active run is bounded only by the absolute cap. -The completion-grace layer specifically addresses the known `claude -p` failure mode where the CLI streams its final answer (and the terminal `result` event) but the process never exits — often because a descendant it spawned is still holding the output pipe open. When a watchdog fires it terminates the backend's whole process tree (via `killProcessTree`; see [Architecture](architecture.md)) with `SIGTERM`, escalating to `SIGKILL` after 5s, and tears down the runtime's handles on the child's stdio so a lingering descendant cannot keep the run alive. On Windows the tree is force-killed with `taskkill /T` on the first signal, so the `SIGKILL` escalation is a no-op. Under Docker, `runtime.docker_timeout_seconds` remains the outer backstop for the whole container. +The completion-grace layer specifically addresses the known `claude -p` failure mode where the CLI streams its final answer (and the terminal `result` event) but the process never exits — often because a descendant it spawned is still holding the output pipe open. When a watchdog fires it terminates the backend's whole process tree (via `killProcessTree`; see [Architecture](architecture.md)) with `SIGTERM`, escalating to `SIGKILL` after 5s, and tears down the runtime's handles on the child's stdio so a lingering descendant cannot keep the run alive. On Windows the tree is force-killed with `taskkill /T` on the first signal, so the `SIGKILL` escalation is a no-op. ## Overall run timeout and step cap {: #overall-run-timeout-and-step-cap} The prompt watchdogs above bound a single backend call. Jaiph also has two controls that bound the whole run, and both are off by default, so existing runs behave as before. -`JAIPH_RUN_TIMEOUT` sets a parent-enforced wall-clock cap, in seconds, for a host-mode run. Host mode means a `jaiph run --unsafe` or host-only run, and the host spawn that a `jaiph serve` or `jaiph mcp` call uses. Without this cap, the only automatic stop for a host run is a manual Ctrl-C, because the host spawn installs only SIGINT and SIGTERM handlers and the prompt watchdogs cover a single backend call. When the cap is reached, the parent terminates the run child's whole process group with `SIGTERM` and escalates to `SIGKILL` after a short grace period (via `killProcessTree`; see [Architecture](architecture.md)), so the run stops without a manual Ctrl-C, and the failure footer shows `E_RUN_TIMEOUT`. Set it to `0`, leave it empty, or give it an invalid value to disable it, which restores the earlier behaviour where only a manual SIGINT or SIGTERM stops a host run. Docker mode does not use this variable, because a Docker run is already bounded by `runtime.docker_timeout_seconds` (`JAIPH_DOCKER_TIMEOUT`) inside the container. +`JAIPH_RUN_TIMEOUT` sets a parent-enforced wall-clock cap, in seconds, for a run. Without this cap, the only automatic stop is a manual Ctrl-C, because the host spawn installs only SIGINT and SIGTERM handlers and the prompt watchdogs cover a single backend call. When the cap is reached, the parent terminates the run child's whole process group with `SIGTERM` and escalates to `SIGKILL` after a short grace period (via `killProcessTree`; see [Architecture](architecture.md)), so the run stops without a manual Ctrl-C, and the failure footer shows `E_RUN_TIMEOUT`. Set it to `0`, leave it empty, or give it an invalid value to disable it, which restores the earlier behaviour where only a manual SIGINT or SIGTERM stops a run. -`JAIPH_MAX_STEPS` sets an optional max-step circuit breaker in the runtime. When you set it to a positive integer, the runtime counts every executed step across the whole run, and it counts loop iterations and nested or recursive calls but skips trivia. Once the count goes past the cap, the runtime logs `E_MAX_STEPS`, aborts the run, and returns a failure, so a runaway workflow stops on its own without a manual signal. Set it to `0`, leave it empty, or give it an invalid value to disable the breaker. +`JAIPH_MAX_STEPS` sets an optional max-step circuit breaker in the runtime. When you set it to a positive integer, the runtime counts every executed step across the whole run, and it counts loop iterations and nested or recursive calls but skips trivia. Once the count goes past the cap, the runtime logs `E_MAX_STEPS`, aborts the run, and returns a failure, so a runaway program stops on its own without a manual signal. Set it to `0`, leave it empty, or give it an invalid value to disable the breaker. ## Leaf step idle output {: #leaf-step-idle-output} @@ -355,17 +309,16 @@ Custom commands still participate in `PROMPT_START` / `PROMPT_END`, write artifa ## Inspecting effective config at runtime -Agent and run settings are visible inside workflows, rules, and scripts as `JAIPH_*` environment variables. In orchestration strings, `${IDENT}` resolves against workflow bindings first, then against the process environment. +Agent and run settings are visible inside defs and scripts as `JAIPH_*` environment variables. In orchestration strings, `${IDENT}` resolves against def bindings first, then against the process environment. -`JAIPH_DOCKER_*` variables are not populated from in-file `runtime.*` inside the workflow runner. Docker config is consumed when the CLI spawns the runner (or container); if a script needs Docker-related variables in its environment, export them from the parent shell. ## Created by `jaiph init` -`jaiph init` creates `.jaiph/bootstrap.jh`, `.jaiph/SKILL.md`, and `.jaiph/.gitignore`. There is no separate config file — `config { … }` blocks live in workflow source. See [CLI — `jaiph init`](cli.md#jaiph-init). +`jaiph init` creates `.jaiph/bootstrap.jh`, `.jaiph/SKILL.md`, and `.jaiph/.gitignore`. There is no separate config file — `config { … }` blocks live in program source. See [CLI — `jaiph init`](cli.md#jaiph-init). ## Related - [Environment variables](env-vars.md) — every variable Jaiph reads. - [CLI](cli.md) — flags that front-end these config knobs. -- [Sandboxing](sandboxing.md) — Docker sandbox model. +- [Deploy jaiph](deploy.md) — wrap jaiph in an image or pod for outer isolation. - [Grammar](grammar.md) — `config` block syntax in the formal grammar. diff --git a/docs/configure-backend.md b/docs/configure-backend.md index b5f88c2b..ae2ed6c5 100644 --- a/docs/configure-backend.md +++ b/docs/configure-backend.md @@ -6,7 +6,7 @@ diataxis: how-to # Configure the agent backend and model -This guide shows how to pick which agent backend your `prompt` steps use (`cursor`, `claude`, or `codex`) and which model to request. You can set both in the workflow file with a `config { … }` block or in the environment. The environment wins over the in-file value when both are set. +This guide shows how to pick which agent backend your `prompt` steps use (`cursor`, `claude`, or `codex`) and which model to request. You can set both in the program file with a `config { … }` block or in the environment. The environment wins over the in-file value when both are set. For the full key/default/precedence reference, see [Configuration](configuration.md). For credential setup per backend, see [Authenticate agent backends](agent-auth.md). @@ -25,7 +25,7 @@ config { agent.model = "sonnet-4" } -workflow default() { +export def main() { const answer = prompt "Summarize this codebase" log "${answer}" } @@ -33,21 +33,23 @@ workflow default() { The valid backend values are `"cursor"` (the default), `"claude"`, and `"codex"`. The model string is forwarded to the backend, so use a name the backend recognizes (e.g. `gpt-4o` for codex, `sonnet-4` for claude). -## 2. Override per-workflow +Set `agent.backend` (and `agent.command`) only from the entry file. Jaiph ignores these two keys when an imported module sets them in its own `config { … }`, so a third-party module cannot redirect your `prompt` steps to a different binary. See [Import trust boundary](configuration.md#import-trust-boundary). -To use a different backend for one workflow in the same file, add a workflow-level `config { … }` block (it must be the first non-comment construct in the body): +## 2. Override per-def + +To use a different backend for one def in the same file, add a def-level `config { … }` block (it must be the first non-comment construct in the body): ```jh -workflow fast_check() { +def fast_check() { config { agent.backend = "cursor" agent.model = "gpt-3.5" } - ensure some_rule() + run review() } ``` -A workflow-level block can set `agent.*` and `run.*` keys. The `runtime.*` and `module.*` keys are module-only, so a workflow-level block cannot set them. +A def-level block can set `agent.*`, `run.*`, and `trusted_envs` keys. The `module.*` keys are module-only, so a def-level block cannot set them. ## 3. Override from the environment @@ -57,7 +59,7 @@ export JAIPH_AGENT_MODEL="sonnet-4" jaiph run ./flow.jh ``` -When set, `JAIPH_AGENT_BACKEND` (and the other mapped agent and run env vars) win over in-file `config` for the lifetime of that run. The CLI marks inherited agent and run env vars as locked (`JAIPH_AGENT_BACKEND_LOCKED=1`, and so on) so in-file overrides never silently take effect. The model works differently. In-file `agent.model` does not set `JAIPH_AGENT_MODEL`, and it applies per `prompt` step only. Set `JAIPH_AGENT_MODEL` in the shell to override the model for every prompt in a run. +When set, `JAIPH_AGENT_BACKEND` (and the other mapped agent and run env vars) win over in-file `config` for the lifetime of that run. The CLI marks each inherited agent and run env var as locked (`JAIPH_AGENT_BACKEND_LOCKED=1`, and so on), so an in-file override never silently takes effect. In-file `agent.model` does not set `JAIPH_AGENT_MODEL`. Instead it applies to each `prompt` step on its own, and Jaiph passes it as a per-call `--model` flag. To override the model for every prompt in a run, set `JAIPH_AGENT_MODEL` in the shell. ## 4. (Codex) Override the API URL @@ -80,11 +82,12 @@ The line includes `"backend":""`, `"model"` (the resolved string, or `n - `explicit` — from `agent.model` or `JAIPH_AGENT_MODEL`. - `flags` — from a `--model` embedded in `agent.cursor_flags` / `agent.claude_flags` (see [Configuration](configuration.md)). - `backend-default` — no model was requested, so the backend CLI picks its own. +- `none` — a [custom agent command](configuration.md#custom-agent-commands) that has no model concept. When `model_reason` is `backend-default`, codex still calls the API with `gpt-4o` even though `"model"` is `null` in the summary. ## Related - [Authenticate agent backends](agent-auth.md) — the credentials each backend needs. -- [Configuration — Precedence](configuration.md#precedence) — env vs module vs workflow layering, lock flags, and nested-call scoping. +- [Configuration — Precedence](configuration.md#precedence) — env vs module vs def layering, lock flags, and nested-call scoping. - [Configuration](configuration.md) — the full set of config keys, defaults, and env equivalents. diff --git a/docs/contributing.md b/docs/contributing.md index 867a8c41..a51b2472 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -10,9 +10,9 @@ redirect_from: Contributor docs answer a narrow question: **where changes belong**, **how to run the same checks CI runs**, and **which test layer** should encode a behavior change. -At a high level, Jaiph is built as described in [Architecture](architecture.md) — **`loadModuleGraph`** → per-module **`validateModule`** + script emit via **`buildScriptsFromGraph`** / **`emitScriptsForModuleFromGraph`**, the same graph consumed by **`buildRuntimeGraph(graph)`**, validate-only **`jaiph compile`** (**`collectDiagnostics`**), **`NodeWorkflowRuntime`**, artifact layout, and Docker helper contracts. Treat that page as authoritative for pipelines and boundaries; if anything here diverges from it or from the implementation, prefer **architecture + source**. +At a high level, Jaiph is built as described in [Architecture](architecture.md) — **`loadModuleGraph`** → per-module **`validateModule`** + script emit via **`buildScriptsFromGraph`** / **`emitScriptsForModuleFromGraph`**, the same graph consumed by **`buildRuntimeGraph(graph)`**, validate-only **`jaiph compile`** (**`collectDiagnostics`**), **`NodeWorkflowRuntime`**, artifact layout. Treat that page as authoritative for pipelines and boundaries; if anything here diverges from it or from the implementation, prefer **architecture + source**. -For workflow syntax, library usage, tooling setup, and grammar details, see [Language](language.md), [Install & switch versions](setup.md), [Grammar](grammar.md), and [Your first workflow](first-workflow.md). For the `*.test.jh` language and test blocks, see [Write & run tests](testing.md). +For language syntax, library usage, tooling setup, and grammar details, see [Language](language.md), [Install & switch versions](setup.md), [Grammar](grammar.md), and [Your first run](first-run.md). For the `*.test.jh` language and test blocks, see [Write & run tests](testing.md). ## Branching and pull requests @@ -38,11 +38,9 @@ jaiph --version jaiph --help ``` -The script builds the self-contained standalone binary via `docs/install` (`npm ci` when a lockfile is present, else `npm install`, plus `npm run build:standalone`, including uncommitted changes) and installs `dist/jaiph` to `~/.local/bin` by default (or `JAIPH_BIN_DIR` if set). It then builds `runtime/Dockerfile` from the same checkout and **retags it as the default sandbox image** — `ghcr.io/jaiphlang/jaiph-runtime:` plus `:nightly` — so Docker runs use your local build without setting `JAIPH_DOCKER_IMAGE`. The image build is **required** (the script exits if Docker is missing, the daemon is down, or the build fails). +The script builds the self-contained standalone binary via `docs/install` (`npm ci` when a lockfile is present, else `npm install`, plus `npm run build:standalone`, including uncommitted changes) and installs `dist/jaiph` to `~/.local/bin` by default (or `JAIPH_BIN_DIR` if set). -Set **`JAIPH_SKIP_DOCKER_BUILD=1`** only to skip the image build (installer acceptance tests). - -**From-source prerequisites:** **`npm`**, **[Bun](https://bun.sh)**, and a running **Docker** daemon (`docker info` must succeed). +**From-source prerequisites:** **`npm`** and **[Bun](https://bun.sh)**. ## Developing in the repository @@ -60,13 +58,13 @@ For day-to-day work on the compiler and CLI you usually stay inside the clone: i | `npm run build:standalone` | `npm run build`, then copies **`dist/src/runtime`** → **`dist/runtime`** and runs **`bun build --compile ./src/cli.ts --outfile ./dist/jaiph`**. Requires [Bun](https://bun.sh). The resulting **`dist/jaiph`** is **fully self-contained** — `jaiph-skill.md` is baked into the binary, and workflow launch self-spawns via the internal `__workflow-runner` argv marker, so the binary needs no sibling `runtime/` or `docs/` files and no `node` / `npm` on the host. The `dist/runtime` copy is kept for parity with the npm layout ([Architecture — Distribution](architecture.md#distribution-node-vs-bun-standalone)). | | `npm run arch:check` | Runs **dependency-cruiser** over `src/` with **`.dependency-cruiser.cjs`** to enforce the [Agent analyzability](agent-analyzability.md) import graph: no cycles, the layer DAG (each layer imports only downward, including `runtime` ↛ `cli`, and runtime reuses compile only through the single public entry `src/transpiler.ts`), `no-deep-imports-into-parse` (code outside the parse package imports only the public entry `src/parser.ts`, never a `src/parse/**` internal), `no-deep-imports-into-transpile` (code outside the transpile package imports only the single public entry `src/transpiler.ts`, which re-exports the module-graph API, never a `src/transpile/**` internal), `no-deep-imports-into-runtime` (code outside the runtime package imports only a public entry, `src/runtime/index.ts` for production or `src/runtime/testing.ts` for named test seams, never a `src/runtime/**` internal), `no-deep-imports-into-format` (code outside the format package imports only the public entry `src/format/index.ts`, never a `src/format/**` internal), and `no-cross-cli-slice-imports` (a file in one CLI slice such as `commands` or `serve` imports another slice's private tree only through `src/cli/shared/**` or a lower-layer public entry). Reads the TypeScript sources directly, so it needs no build. Pre-existing violations are grandfathered in **`.dependency-cruiser-known-violations.json`** (passed via `--ignore-known`), so old violations are tracked while a new cycle, upward import, parse, transpile, runtime, or format deep import, or cross-CLI-slice import fails the check. A required CI step on the Compiler and unit tests job. | | `npm run lint` | Runs **ESLint** over `src/` with **`eslint.config.mjs`** and `--max-warnings 0` to enforce the [Agent analyzability](agent-analyzability.md) fan-out and file-size caps: `import/max-dependencies` at 8 runtime imports per file (type imports ignored) and `max-lines` at 400 non-blank, non-comment lines. Test files are out of scope. Most files that once exceeded a cap were split into sibling modules and now pass the caps with no override; the four largest remaining files keep a per-file override in **`eslint.config.mjs`** that turns off only the rule they break, each with a fresh justification, and the global cap is never raised, so a new violation fails the check. A required CI step on the Compiler and unit tests job. | -| `npm test` | **`npm run clean`**, then **`npm run build`**, then the Node.js test runner with **`JAIPH_UNSAFE=true`**, **`NODE_OPTIONS`** including **`--enable-source-maps`** and a large heap limit, on every file under `dist/integration/` matching `*.test.js`, every file under `dist/src/` matching `*.test.js` or `*.acceptance.test.js` (via `find`), `scripts/build-registry.test.mjs`, `dist/test-infra/compiler-test-runner.js` (txtar compiler tests), and `dist/test-infra/golden-ast-runner.js` (golden AST tests). | +| `npm test` | **`npm run clean`**, then **`npm run build`**, then the Node.js test runner with **`NODE_OPTIONS`** including **`--enable-source-maps`** and a large heap limit, on every file under `dist/integration/` matching `*.test.js`, every file under `dist/src/` matching `*.test.js` or `*.acceptance.test.js` (via `find`), `scripts/build-registry.test.mjs`, `dist/test-infra/compiler-test-runner.js` (txtar compiler tests), and `dist/test-infra/golden-ast-runner.js` (golden AST tests). | | `npm run test:compiler` | **`npm run build`**, then **`node --test`** on `dist/test-infra/compiler-test-runner.js` — runs txtar-based compiler test fixtures from `test-fixtures/compiler-txtar/`. | | `npm run test:golden-ast` | **`npm run build`**, then **`node --test`** on `dist/test-infra/golden-ast-runner.js` — runs golden AST tests from `test-fixtures/golden-ast/`. Use `UPDATE_GOLDEN=1 npm run test:golden-ast` to regenerate goldens after intentional parser changes. | | `npm run test:acceptance:compiler` | **`npm run build`**, then **`node --test`** with only `*.acceptance.test.js` files under **`dist/src/`** — compiler acceptance tests without the full unit suite or E2E. | -| `npm run test:acceptance:runtime` | **`bash ./e2e/test_all.sh`** only — same E2E driver as below **without** an implicit rebuild; ensure `dist/` is up to date before running. | +| `npm run test:acceptance:runtime` | **`bash ./e2e/test_all.sh`** only — same E2E driver as below **without** an implicit rebuild; run `dist/` is up to date before running. | | `npm run test:acceptance` | **`npm run test:acceptance:compiler`** then **`npm run test:acceptance:runtime`**. | -| `npm run test:e2e` | **`npm run build`**, then **`bash ./e2e/test_all.sh`**. Prefer this when you want a fresh `dist/` before E2E. **`e2e::prepare_shared_context`** in `e2e/lib/common.sh` exports **`JAIPH_DOCKER_ENABLED=false`** after clearing most **`JAIPH_*`** variables, so typical tests run on the **host**; Docker coverage lives in scripts that set **`JAIPH_DOCKER_ENABLED=true`** — see [E2E testing](#e2e-testing) and **`resolveDockerConfig`** in `src/runtime/docker.ts` / [Architecture — Core components](architecture.md#core-components). | +| `npm run test:e2e` | **`npm run build`**, then **`bash ./e2e/test_all.sh`**. Prefer this when you want a fresh `dist/` before E2E. **`e2e::prepare_shared_context`** in `e2e/lib/common.sh` clears most inherited **`JAIPH_*`** variables before each test. Host-only. See [E2E testing](#e2e-testing). | | `npm run test:samples` | **`npx playwright test`** — Playwright suite for the docs landing page (`e2e/playwright/`). Uses `http://127.0.0.1:4000` (see `playwright.config.ts`); starts Jekyll via `webServer` or reuses one already on that port. Requires Playwright (`npx playwright install chromium` once). | | `npm run test:ci` | `npm test` followed by `npm run test:e2e` — useful before pushing when you want the full local picture. | @@ -112,11 +110,11 @@ Jaiph uses several test layers. Each layer catches a different class of bug. Use | **Compiler golden tests** | `src/transpile/compiler-golden.test.ts` (colocated) | Regressions in the parser, validation messages, and scripts-only extraction (`buildScriptFiles` in `emit-script.ts`) — expectations are inline in the test file | You changed the parser, validator, or script extraction and need to lock an exact error string, extracted script shape, or corpus behavior | | **Trivia / formatter round-trip** | `src/parse/trivia-ast-shape.test.ts`, `src/parse/trivia-grep.test.ts`, `src/format/roundtrip.test.ts` | Source-fidelity invariants: no trivia fields on semantic AST types (compile-time), validator/emitter sources do not reference `Trivia`, and `parse → format → parse → format` is bit-for-bit on every fixture under `examples/` and `test-fixtures/golden-ast/fixtures/` | You changed the parser, formatter, AST types, or anything that touches source-fidelity round-trip (see [Architecture — Trivia (CST layer)](architecture.md#trivia-cst-layer)) | | **Call-args AST shape** | `src/parse/arg-ast-shape.test.ts`, `src/parse/arg-grep.test.ts` | Pins the typed-`Arg[]` invariant: no `bareIdentifierArgs` field on any call-bearing AST type (compile-time), no `args.split(",")` or `bareIdentifierArgs` text in production `src/parse/` or `src/transpile/` sources, and no `validateBareIdentifierArgs` helper in the validator | You changed how call arguments flow through the parser, validator, or emitter | -| **`Expr` / step-variant shape** | `src/types-shape.test.ts` | Pins exactly 8 `WorkflowStepDef` variants and 8 `Expr` kinds, no AST placeholder strings (`"__match__"`, `"run inline_script"`, `"__JAIPH_MANAGED__"`) anywhere under `src/`, and `ConstRhs` / `SendRhsDef` no longer exported from `src/types.ts` | You added or renamed a step variant or `Expr` kind | +| **`Expr` / step-variant shape** | `src/types-shape.test.ts` | Pins exactly 8 `StepDef` variants and 7 `Expr` kinds (`literal`, `call`, `inline_script`, `prompt`, `match`, `shell`, `bare_ref`), no AST placeholder strings (`"__match__"`, `"run inline_script"`, `"__JAIPH_MANAGED__"`) anywhere under `src/`, and `ConstRhs` / `SendRhsDef` no longer exported from `src/types.ts` | You added or renamed a step variant or `Expr` kind | | **Validator single-walk shape** | `src/transpile/validate-single-walk.test.ts` | Pins the validator's "one descent per workflow / rule" invariant | You touched `walkStepTree` or added a new pre-pass over workflow steps | | **Validator visitor-table shape** | `src/transpile/validate-visitor.test.ts` | Caps `validate.ts` at **≤700 lines**; snapshot-pins `{ code, line, col, message }` from `validate-errors.txt` and `validate-errors-multi-module.txt` into `test-fixtures/compiler-txtar/validate-diagnostics-snapshot.json`; asserts unknown step types produce exactly one `internal: no validator for step type "…"` diagnostic | You touched the `VALIDATORS` table or changed `E_VALIDATE` message wording — refresh snapshots with `UPDATE_SNAPSHOTS=1` only after confirming the change is intentional | | **Statement-dispatch-table shape** | `src/parse/parse-synthetic-keyword.test.ts`, `src/parse/parse-error-snapshot.test.ts` | Pins the `STATEMENT` keyword-dispatch refactor of `parseBlockStatement`; snapshot-pins every parse error in `test-fixtures/compiler-txtar/parse-errors.txt` into `test-fixtures/compiler-txtar/parse-errors-snapshot.json` | You added a top-level keyword or changed any `E_PARSE` message — refresh snapshots with `UPDATE_SNAPSHOTS=1` only after confirming the change is intentional | -| **Attached-block parser shape** | `src/parse/parse-attached-block.test.ts` | Caps `src/parse/steps.ts` at **≤200 lines**; asserts `catch` / `recover` bodies share `parseBlockStatement` | You touched `parseAttachedBlock` / `parseRunOrEnsure` | +| **Attached-block parser shape** | `src/parse/parse-attached-block.test.ts` | Caps `src/parse/steps.ts` at **≤200 lines**; asserts `catch` / `recover` bodies share `parseBlockStatement` | You touched `parseAttachedBlock` / `parseRun` | | **Compile-time / runtime layering** | `src/transpile/no-runtime-imports.test.ts`, `src/parse/canonicalize-triple-quoted.test.ts` | No `from "…/runtime/…"` imports under `src/transpile/`; triple-quoted match-arm bodies match `canonicalizeTripleQuotedString` bit-for-bit | You added a helper used by both validator and runtime (it belongs in `src/parse/`) | | **Diagnostics collector shape** | `src/transpile/diagnostics-collector.test.ts` | `collectDiagnostics(graph)` returns all recoverable errors; `validate.ts` and `validate-step.ts` have zero `throw jaiphError(` sites; `jaiph compile --json` returns the full diagnostic array | You migrated checks to the collector or changed `jaiph compile` output | | **Compiler tests (txtar)** | `test-fixtures/compiler-txtar/*.txt` | Parse and validate outcomes using language-agnostic txtar fixtures | Portable test cases reusable by alternative compiler implementations | @@ -157,7 +155,7 @@ find src -type f \( -name '*.test.ts' -o -name '*.acceptance.test.ts' \) | sort | CLI and terminal UX | `src/cli/**/*.test.ts` | Commands, `jaiph run` lifecycle, progress, hooks, `resolve-env` | | Transpiler and validation | `src/transpile/*.test.ts` + `*.acceptance.test.ts` | `validateModule`, `emit`, golden compiler (`compiler-golden.test.ts`), cross-module edge cases (`compiler-edge.acceptance.test.ts`) | | Formatter | `src/format/*.test.ts` | `jaiph format` | -| Runtime and Docker | `src/runtime/kernel/*.test.ts`, `src/runtime/docker.test.ts` | Graph, emit, prompts, test runner, workflow launch, `docker` helper | +| Runtime | `src/runtime/kernel/*.test.ts` | Graph, emit, prompts, test runner, workflow launch | | Standalone root tests | e.g. `src/inline-script-name.test.ts` | Small colocated cases that are not under a feature subtree | When adding a new source module or extending an existing one, create or extend the corresponding `*.test.ts` in the same directory. For kernel internals, the compile path, and artifact contracts, see [Architecture](architecture.md). @@ -169,14 +167,14 @@ Tests that span multiple modules, require subprocess/PTY harnesses, exercise pro | Test file | Kind | What it covers | |-----------|------|----------------| | `integration/docs-structure.test.ts` | Integration | Diátaxis docs lint — valid `diataxis:` front matter, nav ↔ page bijection, internal link / permalink / `redirect_from` resolution | -| `integration/docs-explanation-task3.test.ts` | Integration | Four greenfield explanation pages (`why-jaiph`, `inbox`, `spec-async-handles`, `sandboxing`) — permalinks, nav placement; **`sandboxing.md`** shape (threat model present; no how-to procedure headings or config-key tables) | +| `integration/docs-explanation-task3.test.ts` | Integration | Three explanation pages (`why-jaiph`, `inbox`, `spec-async-handles`) — permalinks, nav placement | | `integration/docs-how-to-task4.test.ts` | Integration | How-to quadrant — permalinks, retired-path redirects, recipe shape, `agent-auth` credential / pre-flight error pinning | | `integration/docs-reference-task5.test.ts` | Integration | Reference quadrant — permalinks, nav placement, `env-vars.md` source parity against `src/`, anti-tutorial shape guards | -| `integration/docs-tutorials-task6.test.ts` | Integration | Tutorial quadrant — permalinks, `/getting-started` redirect absorption, runnable `first-workflow` snippet with documented output | +| `integration/docs-tutorials-task6.test.ts` | Integration | Tutorial quadrant — permalinks, `/getting-started` redirect absorption, runnable `first-run` snippet with documented output | | `integration/docs-nav-structure-task7.test.ts` | Integration | Nav spine — five Diátaxis section headings in documented order; every published page under its quadrant exactly once | | `integration/release-workflow.test.ts` | Integration | Release matrix / asset-naming contract — five-binary matrix (no windows-arm64), `SHA256SUMS` + upload lists include `jaiph-windows-x64.exe`, shared version-gate script, naming contract ↔ matrix ↔ installer parity | | `integration/installer-powershell.test.ts` | Integration | Windows PowerShell installer (`docs/install.ps1`) contract — download/verify/install steps, bash↔PowerShell lockstep release ref, and `docs/setup.md` / main-page one-liner parity | -| `integration/windows-native-smoke.test.ts` | Integration | Host-portable guards for the `windows-native-smoke` CI job and its `e2e/tests/windows_native_smoke.ps1` harness — job shape (windows-latest, `bun --compile` build, gate membership alongside `test`/`e2e`/`e2e-wsl`), stdout/exit-code assertions, cancellation orphan check, `prompt` pre-flight error, and no-WSL enforcement | +| `integration/windows-native-smoke.test.ts` | Integration | Host-portable guards for the `windows-native-smoke` CI job and its `e2e/tests/windows_native_smoke.ps1` harness — job exists on windows-latest, `bun --compile` build, harness run, stdout/exit-code assertions, cancellation orphan check, `prompt` pre-flight error, and no-WSL enforcement | | `integration/sample-build/build.test.ts` | Integration | Build/transpile behavior — `buildScripts`, script extraction | | `integration/sample-build/cli-tree.test.ts` | Integration | CLI tree output rendering for sample workflows | | `integration/sample-build/run-core.test.ts` | Integration | Core runtime execution — workflow runs, step sequencing, artifacts | @@ -193,25 +191,24 @@ Tests that span multiple modules, require subprocess/PTY harnesses, exercise pro | `integration/serve-restart.test.ts` | Integration | `jaiph serve` run recovery and idempotency across a real process restart | | `integration/otlp-export.test.ts` | Integration | OTLP trace export — a run with OTLP env sends exactly one well-formed POST to `/v1/traces`, and delivery is detached so a hanging collector does not block the terminal result | | `integration/sentry-export.test.ts` | Integration | Sentry error reporting — a failed run delivers exactly one envelope carrying the failing step and an excerpt across `jaiph run`, `jaiph run --raw`, and `jaiph serve`; a succeeding run delivers nothing | -| `integration/exec-policy.test.ts` | Integration | One execution-policy contract across `jaiph run`, `jaiph serve`, and `jaiph mcp` — the same sandbox / env cases produce the same effective child env, the same filesystem outcome, and the same fail-before-spawn behavior for a conflicting posture | +| `integration/exec-policy.test.ts` | Integration | One execution-policy contract across `jaiph run`, `jaiph serve`, and `jaiph mcp` — `--env` / `trusted_envs` produce the same effective child env | +| `integration/factory-code-philosophy.test.ts` | Integration | Keeps the overnight engineer factory in sync with the ADR — the `code_philosophy` string in `.jaiph/engineer.jh` still points at `docs/agent-analyzability.md` and the `arch:check` gate, and `AGENT.md` still points at the ADR | | `integration/tty-running-timer.test.ts` | Acceptance | In a TTY, verifies the “RUNNING workflow” line updates over time (requires Python 3 PTY harness) | The `integration/sample-build/` directory also has a shared `helpers.ts` module used by the sample-build tests. Shared test fixtures (`.jh` source files and expected output) live in `test-fixtures/sample-build/`. ## CI pipeline -The project uses GitHub Actions (`.github/workflows/ci.yml`). The workflow defines **eight** jobs. On a typical feature-branch push, **seven** of them run. The remaining job, **Publish Docker runtime image**, runs only on pushes to **`nightly`** and on **`v*`** version tags, after the test, E2E, docs, WSL, PowerShell-installer, and native-Windows-smoke jobs succeed, and it builds and pushes `ghcr.io/jaiphlang/jaiph-runtime` (the default `runtime.docker_image` / `JAIPH_DOCKER_IMAGE` when Docker sandboxing is on; see **Docker runtime helper** in [Architecture](architecture.md#core-components)). The **Validate Kubernetes deploy manifest** job is not one of those gates, so it does not block the image publish. +The project uses GitHub Actions (`.github/workflows/ci.yml`). The workflow defines **six** jobs. All six run on a typical feature-branch push. | Job | Runner | Purpose | |-----|--------|---------| | **Compiler and unit tests** | `ubuntu-latest` | `npm run arch:check` (the dependency-cruiser import-graph gate for no cycles and the layer DAG) then `npm run lint` (the ESLint fan-out and file-size caps) then `npm test` (TypeScript unit + acceptance + golden tests), plus a `curl` check that the public install URL responds and a git-tag verification on `main`. | -| **Validate Kubernetes deploy manifest** | `ubuntu-latest` | Provisions a throwaway `kind` cluster, dry-run applies `docs/deploy/k8s.yaml` as a schema gate, builds the local `jaiph-e2e-runtime:local` image, then runs `e2e/tests/150_k8s_deploy.sh` to deploy and exercise the manifest on the cluster: the external `jaiph-credentials` Secret gate, pod hardening (non-root, no privilege escalation, dropped capabilities, read-only root filesystem, no service-account token), an authenticated HTTP run, and its journal on the writable runs volume. | -| **E2E** | Matrix: **`ubuntu-latest` twice** + **`macos-latest`** | Job id `e2e`; in the Actions UI each leg appears as **`E2E (,