diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 00000000..ab8d24d6
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,204 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - "v*"
+ branches:
+ - nightly
+ workflow_dispatch:
+
+jobs:
+ ci-gate:
+ name: Wait for CI to pass on this ref
+ runs-on: ubuntu-latest
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Wait for CI workflow conclusion on ${{ github.sha }}
+ run: |
+ set -euo pipefail
+ # Poll up to 60 minutes for the matching CI run on this SHA.
+ for i in $(seq 1 120); do
+ json="$(gh run list --workflow CI --commit "${GITHUB_SHA}" --limit 1 --json status,conclusion 2>/dev/null || echo '[]')"
+ count="$(echo "${json}" | jq 'length')"
+ if [ "${count}" -eq 0 ]; then
+ echo "[${i}/120] No CI run yet for ${GITHUB_SHA}"
+ sleep 30
+ continue
+ fi
+ status="$(echo "${json}" | jq -r '.[0].status')"
+ conclusion="$(echo "${json}" | jq -r '.[0].conclusion')"
+ if [ "${status}" = "completed" ]; then
+ if [ "${conclusion}" = "success" ]; then
+ echo "CI succeeded for ${GITHUB_SHA}."
+ exit 0
+ fi
+ echo "CI for ${GITHUB_SHA} completed with conclusion: ${conclusion}"
+ exit 1
+ fi
+ echo "[${i}/120] CI status=${status}"
+ sleep 30
+ done
+ echo "Timed out waiting for CI on ${GITHUB_SHA}."
+ exit 1
+
+ build:
+ name: Build ${{ matrix.os }}-${{ matrix.arch }}
+ needs: ci-gate
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: true
+ matrix:
+ include:
+ - target: bun-darwin-arm64
+ os: darwin
+ arch: arm64
+ - target: bun-darwin-x64
+ os: darwin
+ arch: x64
+ - target: bun-linux-x64
+ os: linux
+ arch: x64
+ - target: bun-linux-arm64
+ os: linux
+ arch: arm64
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: "20"
+ cache: npm
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Build TypeScript + embed assets
+ run: npm run build
+
+ - name: Cross-compile standalone binary for ${{ matrix.target }}
+ run: |
+ set -euo pipefail
+ bun build --compile --target=${{ matrix.target }} ./src/cli.ts --outfile "jaiph-${{ matrix.os }}-${{ matrix.arch }}"
+ ls -la "jaiph-${{ matrix.os }}-${{ matrix.arch }}"
+
+ - name: Upload binary artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: jaiph-${{ matrix.os }}-${{ matrix.arch }}
+ path: jaiph-${{ matrix.os }}-${{ matrix.arch }}
+ if-no-files-found: error
+ retention-days: 7
+
+ release:
+ name: Publish release assets
+ needs: build
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Resolve tag and channel
+ id: meta
+ run: |
+ set -euo pipefail
+ case "${GITHUB_REF}" in
+ refs/tags/v*)
+ tag="${GITHUB_REF_NAME}"; channel="stable" ;;
+ refs/heads/nightly|refs/tags/nightly)
+ tag="nightly"; channel="nightly" ;;
+ *)
+ echo "Unsupported ref for release: ${GITHUB_REF}" >&2; exit 1 ;;
+ esac
+ echo "tag=${tag}" >> "${GITHUB_OUTPUT}"
+ echo "channel=${channel}" >> "${GITHUB_OUTPUT}"
+
+ - name: Download binary artifacts
+ uses: actions/download-artifact@v4
+ with:
+ path: release-assets
+ merge-multiple: true
+
+ - name: Generate SHA256SUMS
+ working-directory: release-assets
+ run: |
+ set -euo pipefail
+ ls -la
+ rm -f SHA256SUMS
+ sha256sum jaiph-darwin-arm64 jaiph-darwin-x64 jaiph-linux-x64 jaiph-linux-arm64 > SHA256SUMS
+ cat SHA256SUMS
+
+ - name: Sanity gate (linux-x64 --version)
+ working-directory: release-assets
+ run: |
+ set -euo pipefail
+ chmod +x jaiph-linux-x64
+ got="$(./jaiph-linux-x64 --version)"
+ echo "got: ${got}"
+ if [ "${{ steps.meta.outputs.channel }}" = "stable" ]; then
+ tag="${{ steps.meta.outputs.tag }}"
+ expected="jaiph ${tag#v}"
+ if [ "${got}" != "${expected}" ]; then
+ echo "Version sanity check failed: expected '${expected}', got '${got}'" >&2
+ exit 1
+ fi
+ else
+ if ! printf '%s\n' "${got}" | grep -Eq '^jaiph [0-9]+\.[0-9]+\.[0-9]+'; then
+ echo "Version sanity check failed: '${got}' does not look like a jaiph version" >&2
+ exit 1
+ fi
+ fi
+
+ - name: Publish stable release ${{ steps.meta.outputs.tag }}
+ if: steps.meta.outputs.channel == 'stable'
+ working-directory: release-assets
+ run: |
+ set -euo pipefail
+ tag="${{ steps.meta.outputs.tag }}"
+ if gh release view "${tag}" >/dev/null 2>&1; then
+ gh release upload "${tag}" --clobber \
+ jaiph-darwin-arm64 jaiph-darwin-x64 \
+ jaiph-linux-x64 jaiph-linux-arm64 \
+ SHA256SUMS
+ else
+ gh release create "${tag}" \
+ --title "${tag}" \
+ --notes "Jaiph ${tag} — standalone binaries (darwin/linux × arm64/x64) plus SHA256SUMS." \
+ jaiph-darwin-arm64 jaiph-darwin-x64 \
+ jaiph-linux-x64 jaiph-linux-arm64 \
+ SHA256SUMS
+ fi
+
+ - name: Publish nightly prerelease
+ if: steps.meta.outputs.channel == 'nightly'
+ working-directory: release-assets
+ run: |
+ set -euo pipefail
+ if gh release view nightly >/dev/null 2>&1; then
+ gh release upload nightly --clobber \
+ jaiph-darwin-arm64 jaiph-darwin-x64 \
+ jaiph-linux-x64 jaiph-linux-arm64 \
+ SHA256SUMS
+ else
+ gh release create nightly \
+ --title "Nightly" \
+ --notes "Rolling nightly prerelease — standalone binaries built from the latest \`nightly\` branch." \
+ --prerelease \
+ --target "${GITHUB_SHA}" \
+ jaiph-darwin-arm64 jaiph-darwin-x64 \
+ jaiph-linux-x64 jaiph-linux-arm64 \
+ SHA256SUMS
+ fi
diff --git a/.gitignore b/.gitignore
index b15d9eec..0b049cf9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,10 @@ docs/.bundle/
dist/
.tmp/
+# Generated by tools/embed-assets.js from package.json's `version` field, so
+# the CLI version lives in only one place in tree (package.json).
+src/version.ts
+
# logs
*.log
npm-debug.log*
diff --git a/.jaiph/architect_review.jh b/.jaiph/architect_review.jh
index 22fa919b..552663cd 100755
--- a/.jaiph/architect_review.jh
+++ b/.jaiph/architect_review.jh
@@ -1,6 +1,7 @@
#!/usr/bin/env jaiph
import "jaiphlang/queue" as queue
+import "./lib_common.jh" as common
config {
agent.backend = "cursor"
@@ -10,29 +11,8 @@ config {
# agent.claude_flags = "--permission-mode bypassPermissions"
}
-script first_line_str = `printf '%s\n' "$1" | head -n 1`
-
-script rest_lines_str = `printf '%s\n' "$1" | tail -n +2`
-
-script arg_nonempty = `[ -n "$1" ]`
-
-script str_equals = `[ "$1" = "$2" ]`
-
-script mkdir_p_simple = `mkdir -p "$1"`
-
-script jaiph_tmp_dir = `printf '%s\n' "$JAIPH_WORKSPACE/.jaiph/tmp"`
-
script jaiph_review_body_file = `printf '%s\n' "$JAIPH_WORKSPACE/.jaiph/tmp/architect_review_body.txt"`
-# Writes UTF-8 text to a path (path, then content).
-script save_string_to_file = ```python3
-import sys
-if len(sys.argv) < 3:
- sys.exit(2)
-path, content = sys.argv[1], sys.argv[2]
-open(path, "w", encoding="utf-8").write(content)
-```
-
# 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) {
@@ -93,31 +73,31 @@ workflow architect_agent_review(task) {
}
workflow review_one_header(header) {
- run arg_nonempty(header) catch (err) {
+ 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) {
const packed = run architect_agent_review(task)
- const verdict = run first_line_str(packed)
- const updated_description = run rest_lines_str(packed)
+ const verdict = run common.first_line_str(packed)
+ const updated_description = run common.rest_lines_str(packed)
const body_file = run jaiph_review_body_file()
- run mkdir_p_simple(run jaiph_tmp_dir())
- run str_equals(verdict, "dev-ready") catch (err) {
- run arg_nonempty(updated_description) catch (err) {
+ run common.mkdir_p_simple(run common.jaiph_tmp_dir())
+ run common.str_equals(verdict, "dev-ready") catch (err) {
+ run common.arg_nonempty(updated_description) catch (err) {
fail "needs-work requires a non-empty updated_description (questions for the author)."
}
- run save_string_to_file(body_file, updated_description)
+ run common.save_string_to_file(body_file, updated_description)
run queue.set_task_description_from_file(header, body_file)
log "Needs work (description updated): ${header}"
return ""
}
- run arg_nonempty(updated_description) catch (err) {
+ run common.arg_nonempty(updated_description) catch (err) {
run queue.mark_task_dev_ready(header)
log "Marked dev-ready: ${header}"
return ""
}
- run save_string_to_file(body_file, updated_description)
+ run common.save_string_to_file(body_file, updated_description)
run queue.set_task_description_from_file(header, body_file)
run queue.mark_task_dev_ready(header)
log "Marked dev-ready: ${header}"
@@ -128,16 +108,16 @@ workflow review_one_header(header) {
workflow process_headers_recursive(header, remaining) {
run review_one_header(header)
- run arg_nonempty(remaining) catch (err) {
+ run common.arg_nonempty(remaining) catch (err) {
return ""
}
- const next = run first_line_str(remaining)
- const rest = run rest_lines_str(remaining)
+ const next = run common.first_line_str(remaining)
+ const rest = run common.rest_lines_str(remaining)
run process_headers_recursive(next, rest)
}
workflow maybe_process_headers(first, rest) {
- run arg_nonempty(first) catch (err) {
+ run common.arg_nonempty(first) catch (err) {
return ""
}
run process_headers_recursive(first, rest)
@@ -145,8 +125,8 @@ workflow maybe_process_headers(first, rest) {
workflow default() {
const headers = run queue.get_all_task_headers()
- const first = run first_line_str(headers)
- const rest = run rest_lines_str(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) {
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 30bf62cc..143911e6 100755
--- a/.jaiph/docs_parity.jh
+++ b/.jaiph/docs_parity.jh
@@ -1,22 +1,16 @@
#!/usr/bin/env jaiph
const role = """
- You are an expert technical writer for this project.
- 1. You are fluent in Markdown and can read TypeScript code and Bash
- 2. You write for a developer audience, focusing on clarity and practical
- examples.
- 3. You are concise, specific, and value dense
- 4. Write so that a new developer to this codebase can understand your
- writing, but don't assume your audience are experts in the topic/area you
- are writing about.
- 5. You are good in formulating generic context and describing the problem
- starting from the generic part, leaving the specific details for the
- last step, once the audience is aware of the generic context and the
- problem.
- 6. You write problem explanation and goals in a human approachable way,
- while keeping details dense in separate sections, so both human and AI
- 7. Source code and docs/architecture.md are the single source of truth. You don't
- trust the existing documentation blindly.
+ Project-specific context for documenting Jaiph:
+ - You read TypeScript and Bash fluently so you can verify documentation
+ against the implementation.
+ - Source code and docs/architecture.md are the single source of truth.
+ Do not trust existing documentation blindly; verify claims against the
+ code before reproducing them.
+ - Navigation links between docs pages are provided by the Jekyll template
+ (docs/_layouts/docs.html). Do not add manual navigation blocks (e.g.
+ "More Documentation" sections) to individual markdown pages — inline
+ contextual links to other docs are fine.
"""
script assert_newline_paths_are_files = ```
@@ -100,6 +94,11 @@ script build_allowed_paths_block = ```
workflow update_from_task(taskDesc) {
prompt """
+ Before doing anything else, read and follow the documentation skill at
+ .jaiph/skills/documentation-writer/SKILL.md. It defines the Diátaxis
+ framework, the four document types, the clarify -> outline -> write
+ workflow, and the four guiding principles (clarity, accuracy,
+ user-centricity, consistency) you must apply to this task.
${role}
@@ -123,6 +122,11 @@ workflow update_from_task(taskDesc) {
workflow docs_page(path) {
prompt """
+ Before doing anything else, read and follow the documentation skill at
+ .jaiph/skills/documentation-writer/SKILL.md. It defines the Diátaxis
+ framework, the four document types, the clarify -> outline -> write
+ workflow, and the four guiding principles (clarity, accuracy,
+ user-centricity, consistency) you must apply to this task.
${role}
@@ -149,11 +153,16 @@ workflow docs_page(path) {
individual markdown pages. Inline contextual links to other docs are
fine.
-"""
+ """
}
workflow docs_overview(docPaths) {
prompt """
+ Before doing anything else, read and follow the documentation skill at
+ .jaiph/skills/documentation-writer/SKILL.md. It defines the Diátaxis
+ framework, the four document types, the clarify -> outline -> write
+ workflow, and the four guiding principles (clarity, accuracy,
+ user-centricity, consistency) you must apply to this task.
${role}
@@ -197,7 +206,7 @@ workflow docs_overview(docPaths) {
10.Ensure src/cli/shared/usage.ts is up to date with the latest CLI commands
and options. It should be a single source of truth for the CLI usage.
-"""
+ """
}
workflow default() {
diff --git a/.jaiph/docs_parity_redesign.jh b/.jaiph/docs_parity_redesign.jh
new file mode 100755
index 00000000..42df461a
--- /dev/null
+++ b/.jaiph/docs_parity_redesign.jh
@@ -0,0 +1,202 @@
+#!/usr/bin/env jaiph
+
+# Redesign-aware variant of docs_parity.jh, meant to be run BY HAND after the
+# Diátaxis docs redesign (QUEUE.md "Docs redesign" tasks 1-7) has landed.
+#
+# Differences from docs_parity.jh:
+# - Lists docs recursively and EXCLUDES docs/_legacy/ (the build-excluded
+# quarantine of the pre-redesign pages). The stock workflow globs only the
+# flat docs/*.md and would both miss nested pages and risk touching legacy.
+# - The overview pass VERIFIES and tightens the new Diátaxis structure against
+# the source code; it does NOT merge / split / move / re-quadrant pages the
+# way the stock docs_overview does (that would undo the redesign).
+# - Docs-only: it never edits src/ or usage.ts.
+#
+# Run on a clean worktree: jaiph run .jaiph/docs_parity_redesign.jh
+
+const role = """
+ Project-specific context for documenting Jaiph:
+ - You read TypeScript and Bash fluently so you can verify documentation
+ against the implementation.
+ - Source code and docs/architecture.md are the single source of truth.
+ Do not trust existing documentation blindly; verify claims against the
+ code before reproducing them.
+ - Navigation links between docs pages are provided by the Jekyll template
+ (docs/_layouts/docs.html). Do not add manual navigation blocks (e.g.
+ "More Documentation" sections) to individual markdown pages — inline
+ contextual links to other docs are fine.
+ - docs/_legacy/ is a build-excluded quarantine of the OLD documentation.
+ Never read it as a source, never edit it, never resurrect its pages.
+"""
+
+script assert_worktree_clean_for_docs = ```
+ local current_changed_files
+ current_changed_files="$(
+ {
+ git diff --name-only --cached
+ git diff --name-only
+ git ls-files --others --exclude-standard
+ } | sort -u
+ )"
+ if [ -n "$current_changed_files" ]; then
+ echo "Refusing to run docs parity workflow on a dirty worktree." >&2
+ echo "Please commit, stash, or discard these files first:" >&2
+ echo "$current_changed_files" >&2
+ return 1
+ fi
+```
+
+rule worktree_is_clean() {
+ run assert_worktree_clean_for_docs()
+}
+
+script assert_newline_paths_are_files = ```
+ while IFS= read -r f; do
+ f="${f#"${f%%[![:space:]]*}"}"
+ f="${f%"${f##*[![:space:]]}"}"
+ [ -z "$f" ] && continue
+ test -f "$f" || return 1
+ done <<< "$1"
+```
+
+rule docs_files_present(list) {
+ run assert_newline_paths_are_files(list)
+}
+
+# Pattern-based allowlist (not a frozen snapshot): permit the doc entry points
+# and ANY docs/**/*.md page — so pages the prompt legitimately CREATES still
+# pass — while rejecting source, tests, .jaiph/, scratch files, and the
+# quarantine / vendored / generated trees.
+script assert_only_allowed_changed = ```
+ local after_changed_files
+ after_changed_files="$(
+ {
+ git diff --name-only --cached
+ git diff --name-only
+ git ls-files --others --exclude-standard
+ } | sort -u
+ )"
+ while IFS= read -r changed_file; do
+ [ -z "$changed_file" ] && continue
+ case "$changed_file" in
+ README.md|CHANGELOG.md|docs/index.html|docs/_layouts/docs.html|docs/_config.yml)
+ continue ;;
+ esac
+ if printf '%s\n' "$changed_file" | grep -qE '^docs/.*\.md$' \
+ && ! printf '%s\n' "$changed_file" | grep -qE '(^|/)docs/(_legacy|vendor|_site)/'; then
+ continue
+ fi
+ echo "Unexpected file changed by docs prompt: $changed_file" >&2
+ return 1
+ done <<< "$after_changed_files"
+```
+
+rule only_expected_docs_changed_after_prompt() {
+ run assert_only_allowed_changed()
+}
+
+# Recursive list of published docs pages, excluding quarantine, Jekyll output,
+# and Bundler's docs/vendor/ tree. BSD/macOS find treats * in -path as not
+# crossing '/', so prune -path 'docs/vendor/*' misses nested gem READMEs; use
+# grep instead of case (POSIX case patterns do not let * match '/' either).
+script list_docs_md_paths = ```
+ find docs -type f -name '*.md' -print \
+ | grep -vE '(^|/)docs/(_legacy|vendor|_site)/' \
+ | sort
+```
+
+# Files the parity prompts are permitted to change (docs only — never src).
+script build_allowed_paths_block = ```
+ {
+ printf '%s\n' README.md CHANGELOG.md docs/index.html docs/_layouts/docs.html docs/_config.yml
+ find docs -type f -name '*.md' -print \
+ | grep -vE '(^|/)docs/(_legacy|vendor|_site)/'
+ } | sort -u
+```
+
+workflow docs_page(path) {
+ prompt """
+ Before doing anything else, read and follow the documentation skill at
+ .jaiph/skills/documentation-writer/SKILL.md. It defines the Diátaxis
+ framework, the four document types, the clarify -> outline -> write
+ workflow, and the four guiding principles (clarity, accuracy,
+ user-centricity, consistency) you must apply to this task.
+
+ ${role}
+
+
+ Verify ${path} against the Jaiph source code (the single source of truth)
+ and docs/architecture.md.
+ 0. This page belongs to a fixed Diátaxis quadrant declared in its
+ `diataxis:` front matter (tutorial / how-to / reference / explanation /
+ contributor). KEEP that type. Do NOT move content to or from other pages,
+ do NOT change the permalink, do NOT merge or split the page.
+ 1. Verify every factual claim, example, flag, config key, env var, and error
+ code against the source. Fix drift in the page to match the code.
+ 2. Repair cross-type bleed WITHIN the page only (e.g. delete a tutorial-style
+ walkthrough that crept into a reference page) — relocating it is out of
+ scope for this pass.
+ 3. Keep examples executable and aligned with current behavior. Keep prose
+ approachable, concise, and free of AI-like filler and excess emojis.
+ 4. Inline contextual links to other docs are fine; do NOT add manual
+ navigation blocks. Never touch docs/_legacy/.
+ 5. Edit ONLY this documentation page. Never edit source, tests
+ (*.test.ts), config, or anything under .jaiph/, and never create helper
+ or scratch scripts (e.g. *.mjs, *.sh) — make every change directly in
+ the documentation file.
+
+ """
+}
+
+workflow docs_redesign_overview(docPaths) {
+ prompt """
+ Before doing anything else, read and follow the documentation skill at
+ .jaiph/skills/documentation-writer/SKILL.md (Diátaxis: tutorials, how-to,
+ reference, explanation).
+
+ ${role}
+
+
+ The docs were DELIBERATELY restructured into Diátaxis quadrants. Your job is
+ to VERIFY and tighten that structure — NOT to reorganize it. Read all
+ ${docPaths} (these already exclude docs/_legacy/). Treat docs/architecture.md
+ as the architecture source of truth.
+
+ PRESERVE the structure. Do NOT merge, split, move, rename, or re-quadrant
+ pages; do NOT change permalinks; do NOT restructure the nav in
+ docs/_layouts/docs.html beyond fixing an outright error. Specifically:
+ 1. Cross-page consistency: terminology, tone, and overlapping facts agree
+ across pages, and every page is consistent with docs/architecture.md
+ (runtime vs CLI responsibilities, __JAIPH_EVENT__ vs run artifacts,
+ channels/hooks, the jaiph test lane).
+ 2. Each page stays within its `diataxis:` type; flag (do not relocate)
+ any remaining cross-type bleed.
+ 3. Reference pages (cli, configuration, grammar, language, env-vars) match
+ the source exactly — every flag, key, env var, error code. Fix the docs.
+ 4. README.md and docs/index.html lead with the tutorials / how-to entry
+ points and link to getting-started (or the first tutorial) and the agent
+ skill URL, hardcoded as
+ https://raw.githubusercontent.com/jaiphlang/jaiph/refs/heads/main/docs/jaiph-skill.md.
+ Markdown-to-markdown links end with .md; index.html links do not.
+ 5. Edit documentation files ONLY (docs/**/*.md, README.md, CHANGELOG.md,
+ docs/index.html, docs/_layouts/docs.html, docs/_config.yml). Never edit
+ src/, tests (*.test.ts), or anything under .jaiph/, and never create
+ helper or scratch scripts (e.g. *.mjs, *.sh) — make every change
+ directly in the documentation files. Never touch docs/_legacy/.
+
+ """
+}
+
+workflow default() {
+ ensure worktree_is_clean()
+ const allowed_list = run build_allowed_paths_block()
+ ensure docs_files_present(allowed_list)
+ const docs_md_list = run list_docs_md_paths()
+ for path in docs_md_list {
+ if path != "" {
+ run docs_page(path)
+ }
+ }
+ run docs_redesign_overview(docs_md_list)
+ ensure only_expected_docs_changed_after_prompt()
+}
diff --git a/.jaiph/engineer.jh b/.jaiph/engineer.jh
index e2dc4c11..96280304 100755
--- a/.jaiph/engineer.jh
+++ b/.jaiph/engineer.jh
@@ -4,11 +4,12 @@
# Picks the first pending task from QUEUE.md, implements it, verifies CI,
# updates docs, removes from queue, and publishes a workspace patch artifact.
#
-import "jaiphlang/queue" as queue
import "jaiphlang/artifacts" as artifacts
+import "jaiphlang/git" as git
+import "jaiphlang/queue" as queue
import "./docs_parity.jh" as docs
import "./ensure_ci_passes.jh" as ci
-import "jaiphlang/git" as git
+import "./lib_common.jh" as common
config {
# agent.backend = "cursor"
@@ -18,6 +19,30 @@ config {
agent.claude_flags = "--permission-mode bypassPermissions"
}
+const no_nested_orchestration = "Never invoke orchestration workflows (.jaiph/*.jh) or launch nested agent sessions"
+
+const safety_constraints = """
+ Hard safety constraint (non-negotiable):
+ - NEVER invoke Jaiph workflows from the .jaiph directory.
+ - Forbidden examples: jaiph .jaiph/engineer.jh, jaiph run .jaiph/engineer.jh,
+ jaiph .jaiph/docs_parity.jh, or any jaiph command targeting .jaiph/*.jh.
+ - Treat .jaiph/*.jh as orchestration-only workflows that must not be called
+ from inside this implementation prompt.
+ - NEVER launch a nested Claude/Cursor agent session from inside this workflow.
+ Nested sessions share runtime resources and can crash active sessions.
+ - Do not attempt to bypass nested-session guards (for example by unsetting
+ environment variables such as CLAUDECODE).
+ - Any violation of these constraints is an immediate task failure; stop and report.
+"""
+
+const definition_of_done = """
+ Definition of done (QUEUE.md rule 7, verbatim):
+ "Acceptance criteria are non-negotiable. A task is not done until every
+ acceptance bullet is verified by a test that fails when the contract is
+ violated. 'It works on my machine' or 'the existing tests pass' is not
+ acceptance."
+"""
+
const code_philosophy = """
This codebase is maintained by both humans and AI agents. All code you write
must follow these principles strictly:
@@ -71,7 +96,7 @@ const role_surgical = """
* Default to touching as few files as possible
* Do NOT redesign surrounding architecture
* Do NOT add abstractions unless clearly required by acceptance criteria
- * Never invoke orchestration workflows (.jaiph/*.jh) or launch nested agent sessions
+ * ${no_nested_orchestration}
"""
const role_reductionist = """
@@ -91,7 +116,7 @@ const role_reductionist = """
* Actively remove dead code, duplicate branches, and unnecessary indirection
* Prefer net-negative or near-neutral code growth when feasible
* If adding code is unavoidable, justify why deletion/simplification was insufficient
- * Never invoke orchestration workflows (.jaiph/*.jh) or launch nested agent sessions
+ * ${no_nested_orchestration}
"""
const role_optimizer = """
@@ -110,7 +135,7 @@ const role_optimizer = """
* Every structural change must have a concrete before/after justification
* Do NOT rework areas outside the task's scope, even if they look improvable
* Avoid speculative complexity that does not produce measurable benefit
- * Never invoke orchestration workflows (.jaiph/*.jh) or launch nested agent sessions
+ * ${no_nested_orchestration}
"""
const role_stabilizer = """
@@ -130,7 +155,7 @@ const role_stabilizer = """
* Add or improve tests for risky paths and boundary conditions
* Keep implementation simple, defensive, and observable
* Avoid structural rewrites unless strictly required to satisfy acceptance criteria
- * Never invoke orchestration workflows (.jaiph/*.jh) or launch nested agent sessions
+ * ${no_nested_orchestration}
"""
const classification_prompt = """
@@ -167,8 +192,6 @@ workflow select_role(role_name) {
}
}
-script arg_nonempty = `[ -n "${1:-}" ]`
-
script task_text_has_header = `printf '%s\n' "$1" | grep -q '^## '`
script first_line_task = ```
@@ -191,7 +214,17 @@ workflow classify_role(task) {
"""
returns "{ role: string }"
- return result.role
+ # Normalize the free-text classifier answer (case, extra words like
+ # "surgical engineer") to a canonical role name before select_role.
+ const role_raw = "${result.role}"
+ const role_lc = run common.to_lower(role_raw)
+ return match role_lc {
+ /surgical/ => "surgical"
+ /reduction/ => "reductionist"
+ /optimi/ => "optimizer"
+ /stabili/ => "stabilizer"
+ _ => fail "Classifier returned unrecognized role: ${role_lc}"
+ }
}
workflow implement(task, role_name) {
@@ -224,23 +257,14 @@ workflow implement(task, role_name) {
before continuing.
- Ensuring all acceptance criteria in the task are met.
+ ${definition_of_done}
+
Tests and validation:
- Unit/integration: npm test
- End-to-end: npm run test:e2e
- Build check: npm run build
- Hard safety constraint (non-negotiable):
- - NEVER invoke Jaiph workflows from the .jaiph directory.
- - Forbidden examples: jaiph .jaiph/engineer.jh, jaiph run .jaiph/engineer.jh,
- jaiph .jaiph/main.jh, jaiph .jaiph/docs_parity.jh, or any jaiph command
- targeting .jaiph/*.jh.
- - Treat .jaiph/*.jh as orchestration-only workflows that must not be called
- from inside this implementation prompt.
- - NEVER launch a nested Claude/Cursor agent session from inside this workflow.
- Nested sessions share runtime resources and can crash active sessions.
- - Do not attempt to bypass nested-session guards (for example by unsetting
- environment variables such as CLAUDECODE).
- - Any violation of these constraints is an immediate task failure; stop and report.
+ ${safety_constraints}
Test stability policy:
- e2e/tests/* and acceptance JS tests are behavior contracts and should be
diff --git a/.jaiph/ensure_ci_passes.jh b/.jaiph/ensure_ci_passes.jh
index 5165d227..21765b68 100755
--- a/.jaiph/ensure_ci_passes.jh
+++ b/.jaiph/ensure_ci_passes.jh
@@ -1,18 +1,14 @@
#!/usr/bin/env jaiph
+import "./lib_common.jh" as common
+
config {
agent.backend = "cursor"
agent.cursor_flags = "--force"
}
-rule ci_passes() {
- run npm_run_test_ci()
-}
-
script npm_run_test_ci = `npm run test:ci`
-script save_string_to_file = `echo "$1" > "$2"`
-
script assert_nonempty_file_or_fail = ```
test -s "$1" || {
echo "jaiph: ci failure log is empty at $1" >&2
@@ -23,42 +19,37 @@ test -s "$1" || {
workflow ensure_ci_passes() {
const ci_log_dir = ".jaiph/tmp"
const ci_log_file = "${ci_log_dir}/ensure_ci_passes.last.log"
- run mkdir_p_simple(ci_log_dir)
+ run common.mkdir_p_simple(ci_log_dir)
- ensure ci_passes() catch (failure) {
- run save_string_to_file(failure, ci_log_file)
+ # recover = repair-and-retry loop: run the CI script, on failure save the
+ # log and prompt for a fix, then retry — bounded by run.recover_limit
+ # (default 10) instead of unbounded workflow recursion.
+ run npm_run_test_ci() recover (failure) {
+ run common.save_string_to_file(ci_log_file, failure)
run assert_nonempty_file_or_fail(ci_log_file)
-
prompt """
You are a software engineer fixing a failing CI build.
- Fix failing CI so npm run test:ci passes. Failure output was saved to:
- ${ci_log_file}. Start by inspecting the tail of the log (for example:
- tail -n 200 '${ci_log_file}') and then apply the smallest safe fix.
- Constraints: - e2e/tests/* and acceptance JS tests are behavior
+ Fix failing CI so npm run test:ci passes. Failure output was saved to:
+ ${ci_log_file}. Start by inspecting the tail of the log (for example:
+ tail -n 200 '${ci_log_file}') and then apply the smallest safe fix.
+ Constraints: - e2e/tests/* and acceptance JS tests are behavior
contracts.
- - Default approach: change production code to satisfy existing tests,
+ - Default approach: change production code to satisfy existing tests,
not vice versa.
- - Modify tests only for intentional behavior changes, incorrect
+ - Modify tests only for intentional behavior changes, incorrect
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.
"""
-
- # recursively call this workflow to keep trying until the CI passes
- run ensure_ci_passes()
}
- run rm_file_simple(ci_log_file)
+ run common.rm_file_simple(ci_log_file)
}
-script mkdir_p_simple = `mkdir -p "$1"`
-
-script rm_file_simple = `rm -f "$1"`
-
workflow default() {
run ensure_ci_passes()
}
diff --git a/.jaiph/language_redesign_spec.md b/.jaiph/language_redesign_spec.md
deleted file mode 100644
index d33cbb83..00000000
--- a/.jaiph/language_redesign_spec.md
+++ /dev/null
@@ -1,800 +0,0 @@
-# Execution-Boundary Rework Specification
-
-## Core Problem
-
-Jaiph blends declarative orchestration with raw shell in workflows and rules. That blurs side-effect boundaries, blocks runtime portability (Go/Rust), and weakens sandbox control.
-
-Target: one strict boundary. Orchestration constructs orchestrate. A dedicated script construct executes. No exceptions.
-
-## Design Decisions (Locked)
-
-These are not options. Implementation starts from this table.
-
-| # | Decision |
-|---|----------|
-| 1 | Orchestration constructs (`workflow`, `rule`) contain **zero raw shell**. |
-| 2 | Execution construct (`script`) is a **standalone executable** — bash by default, any language via custom shebang. |
-| 3 | Construct name is **`script`** (not `function` or `bash`). |
-| 4 | Variable declarations use **`const`** in orchestration, **`local`** in scripts. |
-| 5 | Rules get **structured keyword parsing** (same model as workflows, restricted subset). |
-| 6 | Every shell operation requires a **named `script`**. No anonymous bash blocks. |
-| 7 | Scripts: **standard exit semantics** (exit code via `return N`/`exit N`, values via stdout). |
-| 8 | Workflows/rules: **`return "value"`** for values, **`fail "reason"`** for explicit failures. |
-| 9 | **One-shot cutover.** No compatibility mode, no deprecation warnings. |
-| 10 | Scripts run in **full isolation** — only positional args, no inherited variables. |
-| 11 | **No script-to-script calls.** Scripts are atomic. Composition happens in orchestration. |
-| 12 | Shared utility code lives in **shared bash libraries** (sourced explicitly in bash scripts), not in Jaiph script cross-calls. |
-| 13 | `if` uses **brace syntax** (`if ... { } else { }`), **`not`** for negation, **`else if`** for chaining. No `then`/`fi`/`elif`. |
-| 14 | Scripts transpile to **separate executable files** with `+x` permission. |
-| 15 | Default shebang is `#!/usr/bin/env bash`. User can provide a custom shebang as the first line of the script body (e.g. `#!/usr/bin/env node`). |
-| 16 | Workflows, rules, and scripts support **named parameters** in declarations. Positional `$1`/`$2` boilerplate is eliminated. |
-
-## Legality Matrix
-
-### `workflow`
-
-| Construct | Allowed | Syntax |
-|-----------|---------|--------|
-| config | Yes | `config { key = "value" }` |
-| const | Yes | `const name = "value"` / `const name = run ref` / `const name = ensure ref` / `const name = prompt "text"` |
-| run | Yes | `run ref [args]` / `run ref [args] &` (async) |
-| ensure | Yes | `ensure ref [args]` / `ensure ref [args] recover { ... }` |
-| prompt | Yes | `prompt "text"` / `const name = prompt "text"` / `const name = prompt "text" returns '{ ... }'` |
-| log | Yes | `log "message"` |
-| logerr | Yes | `logerr "message"` |
-| return | Yes | `return "value"` / `return $var` |
-| fail | Yes | `fail "reason"` |
-| if | Yes | `if [not] ensure ref { ... } [else if ...] [else { ... }]` / `if [not] run ref { ... }` |
-| route | Yes | `channel -> ref1, ref2` |
-| send | Yes | `channel <- "value"` / `channel <- $var` / `channel <- run ref` |
-| wait | Yes | `wait` (waits for async `run` steps) |
-| Raw shell | **No** | Hard parser error with rewrite guidance |
-
-### `rule`
-
-| Construct | Allowed | Syntax |
-|-----------|---------|--------|
-| const | Yes | `const name = "value"` / `const name = run ref` / `const name = ensure ref` (no `prompt` capture) |
-| ensure | Yes | `ensure ref [args]` — other rules only, **no `recover`** |
-| run | Yes | `run ref [args]` — **scripts only**, not workflows |
-| log | Yes | `log "message"` |
-| logerr | Yes | `logerr "message"` |
-| return | Yes | `return "value"` / `return $var` |
-| fail | Yes | `fail "reason"` |
-| if | Yes | `if [not] ensure ref { ... }` / `if [not] run ref { ... }` (run targets scripts only) |
-| prompt | **No** | Rules don't interact with AI |
-| route / send | **No** | Rules don't use channels |
-| async (`&`, `wait`) | **No** | |
-| recover (in `ensure`) | **No** | Not in rule-to-rule calls |
-| Raw shell | **No** | Hard parser error |
-
-### `script`
-
-| Construct | Allowed | Syntax |
-|-----------|---------|--------|
-| Custom shebang | Yes | `#!/usr/bin/env node` (first line of body; omit for default `#!/usr/bin/env bash`) |
-| All body content | Yes | Full language content matching the shebang (bash by default) |
-| Nested bash functions | Yes (bash) | `helper() { ... }` (internal to the script body) |
-| Shared bash via workspace lib dir | **No** | Use `import script`, a sibling module, or inline bash in a `script` block — `JAIPH_LIB` is not provided |
-| `return N` / `exit N` | Yes (bash) | Exit code (integer only) |
-| stdout (`echo`, `printf`) | Yes | Value output mechanism |
-| `local` | Yes (bash) | Bash variable declarations |
-| Other Jaiph script calls | **No** | Scripts are atomic; compose in orchestration |
-| `run`, `ensure`, `prompt` | **No** | Hard parser error (bash scripts only; skipped for custom shebangs) |
-| `return "value"` | **No** | Use `echo` for values, `return 0` for success (bash scripts only) |
-| `fail`, `const`, `log`, `logerr` | **No** | Jaiph keywords, not available in scripts (bash scripts only; skipped for custom shebangs) |
-| Parent scope variables | **No** | Full isolation — only positional args |
-
-**Jaiph keyword guard**: for bash scripts (no shebang or `#!/usr/bin/env bash`), the parser rejects Jaiph-level keywords (`run`, `ensure`, `fail`, `const`, `log`, `logerr`, `prompt`) in the body. For custom shebangs (e.g. `#!/usr/bin/env node`), the guard is skipped — the user owns the body entirely.
-
-## Named Parameters
-
-All constructs support named parameters in their declarations:
-
-```
-workflow implement(task, role_name) { ... }
-rule ensure_is_number(value) { ... }
-script check_hash(file_path, expected_hash) { ... }
-```
-
-**Semantics:**
-
-- Parameters are available as named local variables inside the construct body.
-- For workflows/rules: the transpiler emits `local task="$1"; local role_name="$2"` at the top of the function body.
-- For bash scripts: the transpiler prepends `local file_path="$1"; local expected_hash="$2"` to the script file. For non-bash shebangs, named params are documentary only (the language uses its own argv mechanism).
-- **Optional/default parameters**: `workflow deploy(env, version, dry_run = "false")` transpiles to `local dry_run="${3:-false}"`.
-- Both positional and named calling conventions are valid at call sites:
- - `run implement "$task" "$role_name"` — positional, mapped by declaration order.
- - `run implement task="$task" role_name="$role_name"` — named (already partially supported via `parseParamKeysFromArgs`).
-- **Arity validation**: the validator can check call sites against the declaration. `run implement` with zero args when `implement` declares two required params is a validation error.
-- **Parentheses are optional**: `workflow default() { ... }` (no params) remains valid. Constructs with params use `name(params) { ... }`.
-
-## Script Isolation and Transpilation Model
-
-Scripts execute in **full isolation**. They receive only their positional arguments. No inherited variables from the orchestration scope, module-level constants, or other scripts' state.
-
-### Transpilation to separate files
-
-Each `script` block transpiles to a **standalone executable file** in the build output:
-
-```
-build/
- scripts/
- check_is_number # #!/usr/bin/env bash, +x
- check_json_schema # #!/usr/bin/env node, +x
- select_role # #!/usr/bin/env bash, +x
- module_name.sh # orchestration (workflows + rules)
-```
-
-The transpiler:
-1. Extracts each `script` body verbatim
-2. Prepends the shebang (user-provided or default `#!/usr/bin/env bash`)
-3. Writes to `build/scripts/` with `chmod +x`
-4. In the module `.sh`, script calls become: `"$JAIPH_SCRIPTS/" "$@"`
-
-The runtime sets `$JAIPH_SCRIPTS` to the build output scripts directory.
-
-### Shebang syntax
-
-The first non-empty line of the script body is checked for `#!`. If present, it becomes the file's shebang. If absent, `#!/usr/bin/env bash` is used.
-
-```
-script check_json() {
- #!/usr/bin/env node
- const data = JSON.parse(process.argv[2]);
- process.exit(data.valid ? 0 : 1);
-}
-
-script check_is_number() {
- [[ "$1" =~ ^[0-9]+$ ]]
-}
-```
-
-### Data flow
-
-**Data flow is always explicit**:
-- **Input**: named parameters (declared in signature) or positional arguments (`$1`, `$2`, ...). Named params are syntactic sugar — they transpile to positional arg assignments.
-- **Output**: stdout (value), stderr (diagnostics), exit code (success/failure)
-- **No side channel**: scripts cannot read `const` variables from workflows/rules
-
-### Shared utility code (bash scripts only)
-
-Scripts cannot call other Jaiph scripts. Factor repeated bash into **`import script "./helper.sh" as helper`** (path relative to the `.jh` file), another `.jh` module, or a small extra `script` in the same module. Do not use a workspace-wide bash drop directory outside the compiler model.
-
-Non-bash scripts use their language's own module system for shared code.
-
-## Semantics: Values, Returns, Failures
-
-### Scripts (isolated, standalone executables)
-
-Values are passed via **stdout**. Caller captures with `const result = run script_name`.
-
-Exit code determines success/failure: `return 0` / `exit 0` = success, `return 1` / `exit 1` = failure.
-
-The existing `jaiph::set_return_value` mechanism is **removed** from script transpilation. `return "$string"` in a bash script body is a **parser error** (bash `return` only accepts integers).
-
-### Workflows
-
-`return "value"` passes a value to the caller via the Jaiph runtime (not stdout).
-
-`fail "reason"` terminates the workflow with a non-zero exit and logs the reason to stderr. An unrecovered `ensure` failure also terminates the workflow.
-
-Exit code: 0 on natural completion or `return`. Non-zero on `fail` or unrecovered failure.
-
-### Rules
-
-`return "value"` passes a value to the caller. Captured by `const result = ensure rule_name`.
-
-`fail "reason"` causes the rule to fail. In the caller, this triggers a `recover` block (if present) or aborts.
-
-A rule that completes without hitting `fail` passes.
-
-### `fail` vs script failure
-
-| Context | How to fail | How to return a value |
-|---------|-------------|----------------------|
-| `script` | `return 1` / `exit 1` | `echo "value"` (stdout) |
-| `workflow` | `fail "reason"` | `return "value"` |
-| `rule` | `fail "reason"` | `return "value"` |
-
-## Migration Examples
-
-### Rule: raw shell → structured
-
-Before:
-
-```
-rule ensure_is_number() {
- if ! [[ "$1" =~ ^[0-9]+$ ]]; then
- echo "Expected a non-negative integer, got: $1" >&2
- exit 1
- fi
-}
-```
-
-After:
-
-```
-script check_is_number(value) {
- [[ "$value" =~ ^[0-9]+$ ]]
-}
-
-rule ensure_is_number(value) {
- if not run check_is_number "$value" {
- fail "Expected a non-negative integer, got: $value"
- }
-}
-```
-
-### Workflow: inline shell → named script
-
-Before:
-
-```
-workflow default() {
- n="${1:-10}"
- ensure ensure_is_number "$n"
- result = run fib "$n"
- log "$result"
-}
-```
-
-After:
-
-```
-workflow default(n = "10") {
- ensure ensure_is_number "$n"
- const result = run fib "$n"
- log "$result"
-}
-```
-
-### Script: return value via stdout (not `jaiph::set_return_value`)
-
-Before:
-
-```
-function fib() {
- local result
- result="$(fib_impl "$n")"
- return "$result"
-}
-```
-
-After:
-
-```
-script fib() {
- fib_impl() {
- local x="$1"
- if [ "$x" -le 1 ]; then
- echo "$x"
- return 0
- fi
- local a b
- a="$(fib_impl "$((x - 1))")"
- b="$(fib_impl "$((x - 2))")"
- echo "$((a + b))"
- }
- fib_impl "$1"
-}
-```
-
-All data is internal. Caller captures via `const result = run fib "$n"`.
-
-### Polyglot script: Node.js validation
-
-```
-script validate_json_schema(schema_path, data_path) {
- #!/usr/bin/env node
- const Ajv = require('ajv');
- const fs = require('fs');
- const ajv = new Ajv();
- const schema = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
- const data = JSON.parse(fs.readFileSync(process.argv[3], 'utf8'));
- const valid = ajv.validate(schema, data);
- if (!valid) {
- console.error(JSON.stringify(ajv.errors));
- process.exit(1);
- }
-}
-
-workflow validate_config() {
- ensure config_file_exists
- const result = run validate_json_schema "schema.json" "config.json"
- log "Config validated successfully"
-}
-```
-
-### Prompt with `returns` + value dispatch (engineer.jh pattern)
-
-Before:
-
-```
-local role_surgical = "... "
-local role_reductionist = "... "
-
-workflow implement() {
- local role_name="$2"
- local role
- if [ "$role_name" = "surgical" ]; then
- role="$role_surgical"
- elif [ "$role_name" = "reductionist" ]; then
- role="$role_reductionist"
- fi
- prompt "$role ..."
-}
-```
-
-After:
-
-```
-script select_role(role_name) {
- local role_surgical='
- You are a surgical engineer. ...
- '
- local role_reductionist='
- You are a reductionist engineer. ...
- '
-
- case "$role_name" in
- surgical) echo "$role_surgical" ;;
- reductionist) echo "$role_reductionist" ;;
- *) echo "Unknown role: $role_name" >&2; return 1 ;;
- esac
-}
-
-workflow implement(task, role_name) {
- const role = run select_role "$role_name"
-
- prompt "
- $role
- ...
- $task
- "
-}
-```
-
-Role data is internal to the script. Orchestration only passes the role name and receives the resolved text. Full isolation — script has zero knowledge of caller scope.
-
-### Send operator
-
-Before:
-
-```
-workflow scanner() {
- findings <- echo "Found 3 issues in auth module"
-}
-```
-
-After:
-
-```
-workflow scanner() {
- findings <- "Found 3 issues in auth module"
-}
-```
-
-### Rule with value return
-
-Before:
-
-```
-rule echo_line() {
- echo "this goes to logs only"
- return "captured-value"
-}
-```
-
-After:
-
-```
-script echo_impl() {
- echo "this goes to logs only" >&2
-}
-
-rule echo_line() {
- run echo_impl
- return "captured-value"
-}
-```
-
-## Pattern Catalog: .jaiph/ and e2e/ audit
-
-Every `.jh` file was scanned. Below are all patterns found that require migration, grouped by category.
-
-### P1: Raw shell in workflows (every .jaiph/ file)
-
-**Files**: queue.jh, docs_parity.jh, simplifier.jh, architect_review.jh, ensure_ci_passes.jh, qa.jh, git.jh, log_keyword.jh, nested_run.jh, workflow_greeting.jh, prompt_unmatched.jh, rule_pass.jh, assign_capture.jh
-
-**Examples**: `echo "..."`, `printf`, `mkdir -p`, `rm -f`, `exit 0`, `exit 1`, `test -n`, bare assignment (`dataset="testdata"`)
-
-**Migration**: each becomes a named `script` or a `const` declaration. `exit 0` → `return` (early success). `exit 1` → `fail "reason"`.
-
-### P2: Raw shell in rules (every rule)
-
-**Files**: git.jh (`git rev-parse`, `test -z "$(git status)"`), queue.jh (`echo | grep -q`), ensure_ci_passes.jh (`npm run test:ci`), docs_parity.jh (`test -f`, `while IFS= read`), simplifier.jh, say_hello.jh, say_hello_json.jh, current_branch.jh
-
-**Migration**: shell logic moves to scripts. Rules become structured: `run` the script, `if`/`fail` on the result.
-
-### P3: Iteration in workflows
-
-**Files**: architect_review.jh (`while IFS= read -r header; do ... done <<< "$headers"`), docs_parity.jh (`for f in docs/*.md`, `for f in "${docs_md_files[@]}"`).
-
-**Problem**: the loop body contains orchestration keywords (`run`, `ensure`, `prompt`, `log`). Cannot be pushed to a script.
-
-**Resolution**: use **workflow recursion**. Extract per-item logic into a workflow, then recurse over the list. Split newline-delimited lists with tiny `script` steps (e.g. `printf '%s\n' "$1" | head -n 1` / `tail -n +2`) or `import script`.
-
-```
-script list_docs_files() {
- for f in docs/*.md; do
- echo "$f"
- done
-}
-
-workflow process_docs_recursive(file, remaining) {
- run docs_page "$file"
-
- if run has_value "$remaining" {
- const next = run first_line "$remaining"
- const rest = run rest_lines "$remaining"
- run process_docs_recursive "$next" "$rest"
- }
-}
-
-workflow default() {
- const docs_files = run list_docs_files
- const first = run first_line "$docs_files"
- const rest = run rest_lines "$docs_files"
- run process_docs_recursive "$first" "$rest"
-}
-```
-
-**Future feature: `each` modifier.** Planned syntax sugar that replaces the recursion boilerplate:
-
-```
-run docs_page each $docs_files
-```
-
-`each` is a modifier on `run`/`ensure` that calls the target once per newline-delimited item. No loop body, no mutable state, no break/continue. Backward-compatible addition — does not block v1.
-
-### P4: Bash arrays in workflows
-
-**File**: docs_parity.jh — builds arrays dynamically (`local files=()`, `files+=("$f")`), passes them as args (`"${files[@]}"`).
-
-**Resolution**: avoid arrays in orchestration. Represent lists as newline-delimited strings. Scripts that need to process multiple items receive them as a single string argument. Glob expansion (`docs/*.md`) stays in scripts.
-
-### P5: Mutable variables in workflows
-
-**File**: architect_review.jh — `local failed=0` then `failed=1` inside a loop to track whether any task failed.
-
-**Resolution**: restructure to avoid mutable state. The per-item workflow performs side effects (marking tasks). After recursion completes, re-check the final state:
-
-```
-workflow review_single_task(header) {
- const task = run queue.get_task_by_header "$header"
-
- if run is_dev_ready "$task" {
- log "Already dev-ready: $header"
- return
- }
-
- const verdict = run review_task "$task"
- if run matches "$verdict" "dev-ready" {
- run queue.mark_task_dev_ready "$header"
- log "Marked dev-ready: $header"
- } else {
- log "Needs work: $header"
- }
-}
-
-workflow default() {
- const headers = run queue.get_all_task_headers
- # recurse over headers (or use `each` when available)
- ...
-
- const remaining = run queue.count_not_ready
- if not run is_zero "$remaining" {
- fail "One or more tasks need work"
- }
-}
-```
-
-No mutable counter. The source of truth is the queue state, not a variable.
-
-### P6: String comparison in workflows (SPEC GAP)
-
-**Files**: architect_review.jh (`[[ "$verdict" == "dev-ready" ]]`), engineer.jh (role name dispatch), git.jh (`[ -z "$role_name" ]`).
-
-**Resolution**: push to scripts.
-
-```
-script matches(a, b) {
- [ "$a" = "$b" ]
-}
-
-script has_value(val) {
- [ -n "$val" ]
-}
-
-if run matches "$verdict" "dev-ready" {
- ...
-}
-```
-
-These are small, reusable utility scripts in the same module (or behind `import script`).
-
-### P7: `return "$(command)"` in scripts (Jaiph value return)
-
-**Files**: queue.jh (`return "$(awk ...)"`), docs_parity.jh (`return "$(git diff ...)"`), simplifier.jh (same pattern).
-
-**Migration**: replace `return "$(command)"` with direct stdout passthrough:
-
-Before: `return "$(awk '/^## /{print}' "$queue_file")"`
-
-After: `awk '/^## /{print}' "$queue_file"` (just let stdout flow)
-
-### P8: `logerr` in rules
-
-**Files**: say_hello.jh, say_hello_json.jh — `logerr "message"` inside raw shell rule body.
-
-**Migration**: under structured rules, `logerr` becomes a Jaiph keyword (already in legality matrix):
-
-```
-rule name_was_provided(name) {
- if not run has_value "$name" {
- logerr "You didn't provide your name :("
- fail "name argument required"
- }
-}
-```
-
-### P9: `ensure` with `recover` containing shell
-
-**File**: ensure_ci_passes.jh — `recover` block contains `echo "$1" > "$ci_log_file"`, shell conditionals, and a `prompt`.
-
-**Migration**: shell in recover body moves to scripts. `prompt` stays (recover body follows workflow rules):
-
-```
-script save_ci_log(content, path) {
- echo "$content" > "$path"
-}
-
-script ci_log_exists(path) {
- [ -s "$path" ]
-}
-
-workflow ensure_ci_passes() {
- const ci_log_file = ".jaiph/tmp/ensure_ci_passes.last.log"
- run mkdir_p ".jaiph/tmp"
-
- ensure ci_passes recover {
- run save_ci_log "$1" "$ci_log_file"
- if not run ci_log_exists "$ci_log_file" {
- fail "ci failure log is empty at $ci_log_file"
- }
- prompt "Fix failing CI... log at: $ci_log_file"
- }
-
- run rm_file "$ci_log_file"
-}
-```
-
-### P10: Shell variable expansion in `const` RHS
-
-**Files**: multiple — `"${1:-10}"`, `"${1:-}"`, `"${task%%$'\n'*}"`.
-
-**Ruling**: simple interpolation (`$var`, `"${var:-default}"`) is allowed in `const` RHS — these are value lookups, not computation. Bash string operations (`${var%%pattern}`, `${var//old/new}`) are computation — push to a script.
-
-| Allowed in `const` RHS | Not allowed (use script) |
-|------------------------|---------------------------|
-| `"$var"` | `"${var%%pattern}"` |
-| `"${var:-default}"` | `"${var//old/new}"` |
-| `"${var:+alt}"` | `"${#var}"` |
-| `"literal"` | `$(command)` |
-
-### P11: Script-to-script calls
-
-**File**: docs_parity.jh — rule `only_expected_docs_changed_after_prompt` calls script `is_allowed_file` directly.
-
-**Migration**: under full isolation + no script-to-script calls, inline the logic or add a dedicated `import script` helper:
-
-```
-script check_only_expected_changed(allowed, changed) {
- while IFS= read -r f; do
- [ -z "$f" ] && continue
- if [[ $'\n'"$allowed"$'\n' != *$'\n'"$f"$'\n'* ]]; then
- echo "Unexpected file changed: $f" >&2
- return 1
- fi
- done <<< "$changed"
-}
-```
-
-## Implementation Plan
-
-### Phase 0: Architectural prep (before breaking changes)
-
-**0a. Refactor `validate.ts` — collapse duplicate ref resolution**
-- Merge `validateRuleRef`, `validateWorkflowRef`, `validateRunInRuleRef`, `validateRunTargetRef`, `validateBareSendSymbol` into one generic `validateRef(ref, allowedKinds, context)` function
-- Target: 788 → ~400 lines
-- Zero behavior change
-
-**0b. Split `emit-workflow.ts` — separate emitters**
-- Extract script emission into `emit-script.ts`
-- Extract rule emission into `emit-rule.ts`
-- `emit-workflow.ts` becomes orchestration-only assembly
-- Creates natural seam for Phase 3 (separate script files)
-
-### Phase 1: Language additions (no breaking changes)
-
-**1a. Add `fail` keyword**
-- AST: new `WorkflowStepDef` variant `{ type: "fail"; message: string; loc: SourceLoc }`
-- Parser: recognize `fail "reason"` in `workflows.ts`
-- Transpiler: emit `echo "reason" >&2; exit 1`
-
-**1b. Add `const` declaration**
-- AST: new step type `{ type: "const"; name: string; value: ConstValue; loc: SourceLoc }` where `ConstValue` is string-expr | run-capture | ensure-capture | prompt-capture
-- Parser: `const name = ...` with RHS dispatch
-- Transpiler: emit `local name; name="value"` or appropriate capture form
-
-**1c. Formalize `wait` as keyword**
-- AST: new variant `{ type: "wait"; loc: SourceLoc }`
-- Parser: recognize `wait` in workflows (currently falls through to shell)
-- Transpiler: emit `wait`
-
-**1d. Switch `if` to brace syntax**
-- Parser: recognize `if [not] ensure/run ref { ... } [else if ...] [else { ... }]`
-- Keep old `if ... then ... fi` working during Phase 1 (dual parsing)
-- Transpiler: both forms emit the same bash
-
-### Phase 2: Rule parser rewrite
-
-**2a. Restructure `RuleDef`**
-- Change `RuleDef.commands: string[]` → `RuleDef.steps: RuleStepDef[]` (or reuse `WorkflowStepDef` subset)
-- Rewrite `rules.ts` with keyword-aware parsing (mirror `workflows.ts` structure)
-- Port existing rule tests first, then validate structured output
-
-**2b. Update rule emission**
-- `emit-workflow.ts`: handle structured rule steps instead of opaque command strings
-
-### Phase 3: `function` → `script` rename and separate file transpilation
-
-**3a. Rename keyword**
-- Parser: accept `script` keyword instead of `function`
-- AST: rename `FunctionDef` → `ScriptDef`, add `shebang?: string` field
-- `jaiphModule`: rename `functions` → `scripts`
-- Update all validator references
-
-**3b. Add shebang extraction**
-- Parser: check first non-empty line of script body for `#!`
-- If present, store in `ScriptDef.shebang` and exclude from body commands
-- If absent, `shebang` remains `undefined` (default `#!/usr/bin/env bash`)
-
-**3c. Conditional keyword guard**
-- For bash scripts (no shebang or bash shebang): keep existing Jaiph keyword rejection
-- For custom shebangs: skip keyword guard entirely
-
-**3d. Emit scripts as separate files**
-- Change `emitWorkflow` return type: `{ module: string; scripts: ScriptFile[] }` where `ScriptFile = { name: string; content: string; shebang: string }`
-- Module `.sh` calls scripts via `"$JAIPH_SCRIPTS/" "$@"`
-- `build.ts`: write script files with `chmod +x`, set `$JAIPH_SCRIPTS`
-
-**3e. Update all first-party `.jh` files**
-- Rename `function` → `script` in all `.jaiph/*.jh` files
-- Rename in all `e2e/*.jh` fixtures
-- Update test fixtures and golden outputs
-
-**3f. Named parameters**
-- Parser: recognize `name(param1, param2)` and `name(param1, param2 = "default")` in workflow, rule, and script declarations
-- AST: add `params?: Array<{ name: string; default?: string }>` to `WorkflowDef`, `RuleDef`, `ScriptDef`
-- Transpiler: for workflows/rules, emit `local param1="$1"; local param2="$2"` (or `"${2:-default}"` for defaults) at the top of the function body. For bash scripts, prepend the same to the script file. For non-bash scripts, params are documentary only.
-- Validator: check call-site arity against declared params. Missing required args = validation error. Extra args beyond declared params = validation warning.
-- Update all first-party `.jh` files to use named params where applicable
-- Parentheses optional when no params: `workflow default() { ... }` remains valid
-
-### Phase 4: Script isolation
-
-**4a. Implement full isolation for script execution**
-- Scripts run as separate processes (inherent from separate files + exec)
-- Only positional args available (inherent from separate executable)
-- Set `$JAIPH_SCRIPTS` and `$JAIPH_WORKSPACE` for script steps (no workspace bash lib dir)
-
-**4b. Reject script-to-script calls**
-- Parser/validator: detect when a script body references another Jaiph script name
-- Error: `"scripts cannot call other Jaiph scripts; use import script, inline bash, or compose in a workflow"`
-
-### Phase 5: Remove shell (breaking changes)
-
-**5a. Remove shell fallback from workflow parser**
-- `workflows.ts`: delete the catch-all `type: "shell"` codepath
-- Remove `shellAccumulator` / `braceDepthDelta` shell accumulation
-- Emit parser error: `"raw shell is not allowed in workflow; extract to a script"`
-
-**5b. Remove shell fallback from rule parser**
-- Same treatment after Phase 2
-
-**5c. Remove old `if` syntax**
-- Drop `if ... then ... fi` / `elif` parsing
-- Only accept brace syntax with `not` / `else if`
-
-**5d. Enforce pure output in scripts**
-- `scripts.ts`: reject `return "value"` (non-integer return)
-- Remove `jaiph::set_return_value` from script transpilation
-
-**5e. Update send operator**
-- Accept `"value"` / `$var` / `run ref` as RHS
-- Reject raw shell command as RHS
-
-### Phase 6: Migrate all first-party code
-
-- Rewrite all `e2e/*.jh` fixtures
-- Rewrite all `.jaiph/*.jh` workflows
-- Factor repeated bash into `import script` or extra `script` blocks in the same module (P6, P11)
-- Update test fixtures and golden transpilation outputs
-- Update docs and README examples
-
-### Phase 7: Ship
-
-- Hard parser errors on all legacy syntax
-- Error messages include rewrite examples
-- Full e2e + golden snapshot CI gate
-- Zero P0 parser/runtime failures before merge
-
-## Code Changes Required
-
-| File | Change |
-|------|--------|
-| `src/types.ts` | Rename `FunctionDef` → `ScriptDef`, add `shebang?: string`, add `params?: ParamDef[]`. Rename `jaiphModule.functions` → `jaiphModule.scripts`. Add `params?: ParamDef[]` to `WorkflowDef`, `RuleDef`. Add `fail`, `wait`, `const` step types. Change `RuleDef.commands` → `RuleDef.steps`. Remove `shell` condition kind from `if`. Add `not` / brace-style `if` AST. |
-| `src/parser.ts` | Replace `function` keyword detection with `script`. Rename `parseFunctionBlock` → `parseScriptBlock`. |
-| `src/parse/functions.ts` → `src/parse/scripts.ts` | Rename file. Update regex to match `script` keyword. Add shebang extraction. Conditional keyword guard (skip for custom shebangs). Parse named params in signature. |
-| `src/parse/workflows.ts` | Remove shell fallback, shell accumulator. Add `fail`, `const`, `wait` parsing. Replace `if ... then ... fi` with brace syntax. |
-| `src/parse/rules.ts` | Full rewrite: keyword-aware structured parser mirroring workflow parser. |
-| `src/transpile/emit-workflow.ts` | Split: extract script emission to `emit-script.ts`, rule emission to `emit-rule.ts`. Change return type to include script files. Remove `jaiph::set_return_value` from script paths. |
-| `src/transpile/emit-script.ts` | **New file.** Emit standalone script files with shebang + body. |
-| `src/transpile/emit-rule.ts` | **New file.** Rule emission extracted from `emit-workflow.ts`. |
-| `src/transpile/emit-steps.ts` | Remove `emitShellStep` for workflows. Add `emitFailStep`, `emitConstStep`, `emitWaitStep`. |
-| `src/transpile/build.ts` | Handle new `emitWorkflow` return shape. Write script files with `chmod +x`. Set `$JAIPH_SCRIPTS` path. |
-| `src/transpile/validate.ts` | Collapse duplicate ref resolution. Rename `function` → `script` in errors/lookups. Allow `run` in rules (scripts only). Remove shell-condition validation. Add script isolation validation. |
-| `src/transpile/shell-jaiph-guard.ts` | Scope down — only applies to bash scripts now. |
-| `e2e/*.jh` | Rewrite all fixtures to new syntax. |
-| `.jaiph/*.jh` | Rewrite all workflows to new syntax. |
-| `test/fixtures/**` | Update golden transpilation outputs. |
-| `docs/*` | Update grammar, getting-started, CLI docs for `script` keyword and shebang. |
-
-## Risks
-
-| Risk | Impact | Mitigation |
-|------|--------|------------|
-| Wide breakage: all raw-shell workflows/rules fail at parse time | High | Single branch, full e2e gate, no merge without 100% pass |
-| Rule parser rewrite introduces regressions | High | Port existing rule tests before rewriting parser |
-| Ergonomic cost of named scripts for trivial shell | Medium | Accepted tradeoff — boundary clarity > brevity |
-| `fail` interacts badly with `recover` | Medium | Explicit test: `ensure rule_with_fail recover { ... }` must trigger recover |
-| `const` scoping conflicts with bash `local` | Low | `const` is parser-level immutability; transpiles to `local` |
-| Return semantics confusion during migration | Medium | Parser errors guide users: `"return 'value' not allowed in script; use echo"` |
-| Script isolation perf overhead (fork+exec per call) | Medium | Measure fork cost; scripts are already logically isolated. Optimize hot paths if needed |
-| Users want a global bash grab-bag | Medium | `import script` + small modules; no `JAIPH_LIB` |
-| `.jaiph/` workflow migration is large (9 files) | High | Migrate in parallel with parser changes; each file is independently testable |
-| Separate file management complexity | Medium | Deterministic naming (`scripts/`), cleanup on rebuild |
-| Custom shebang scripts may have missing dependencies | Low | Not Jaiph's problem — user owns their runtime. Document clearly |
-
-## Success Criteria
-
-- 100% first-party `.jh` files parse under new grammar
-- 100% e2e pass under new runtime
-- Zero `type: "shell"` steps in workflow/rule AST output
-- `fail` triggers `recover` correctly in `ensure` blocks
-- Script bodies reject `return "value"`, `fail`, `const`, other Jaiph keywords (bash scripts only)
-- Script bodies reject calls to other Jaiph scripts
-- Scripts execute as separate files with correct shebang and `+x`
-- Custom shebang scripts (e.g. `#!/usr/bin/env node`) work end-to-end
-- Scripts execute in full isolation (no inherited variables)
-- `const` declarations work in workflows and rules with all RHS forms
-- `if` brace syntax works with `not` and `else if`
-- Parser errors for raw shell include actionable rewrite examples
-- `jaiph::set_return_value` removed from script transpilation paths
-- `validate.ts` under 500 lines after dedup
-- `emit-workflow.ts` handles only orchestration; script/rule emission in separate files
-- Named parameters work in workflow, rule, and script declarations
-- Default parameter values work: `workflow deploy(env, dry_run = "false")`
-- Arity validation catches missing required args at call sites
diff --git a/.jaiph/lib_common.jh b/.jaiph/lib_common.jh
new file mode 100644
index 00000000..20888a7b
--- /dev/null
+++ b/.jaiph/lib_common.jh
@@ -0,0 +1,33 @@
+#!/usr/bin/env jaiph
+
+#
+# Shared string/file helpers for the .jaiph orchestration workflows.
+# Import as: import "./lib_common.jh" as common
+#
+# Writes UTF-8 text to a path: $1 = path, $2 = content.
+# python3 instead of `echo`, so backslashes and dash-leading content
+# are written verbatim. Content still travels through argv, so it is
+# subject to the OS ARG_MAX limit (~1 MB on macOS).
+export script save_string_to_file = ```python3
+import sys
+if len(sys.argv) < 3:
+ sys.exit(2)
+path, content = sys.argv[1], sys.argv[2]
+open(path, "w", encoding="utf-8").write(content)
+```
+
+export script first_line_str = `printf '%s\n' "$1" | head -n 1`
+
+export script rest_lines_str = `printf '%s\n' "$1" | tail -n +2`
+
+export script arg_nonempty = `[ -n "$1" ]`
+
+export script str_equals = `[ "$1" = "$2" ]`
+
+export script to_lower = `printf '%s' "$1" | tr '[:upper:]' '[:lower:]'`
+
+export script mkdir_p_simple = `mkdir -p "$1"`
+
+export script rm_file_simple = `rm -f "$1"`
+
+export script jaiph_tmp_dir = `printf '%s\n' "$JAIPH_WORKSPACE/.jaiph/tmp"`
diff --git a/.jaiph/libs/jaiphlang/git.jh b/.jaiph/libs/jaiphlang/git.jh
index 8cf01eea..ba94635a 100755
--- a/.jaiph/libs/jaiphlang/git.jh
+++ b/.jaiph/libs/jaiphlang/git.jh
@@ -37,9 +37,11 @@ rule is_clean() {
workflow commit(task) {
config {
- agent.backend = "cursor"
- agent.cursor_flags = "--force"
- agent.default_model = "auto"
+ # agent.backend = "cursor"
+ # agent.default_model = "composer-2"
+ # agent.cursor_flags = "--force"
+ agent.backend = "claude"
+ agent.claude_flags = "--permission-mode bypassPermissions"
}
ensure in_git_repo()
diff --git a/.jaiph/main.jh b/.jaiph/main.jh
deleted file mode 100755
index aaf143f7..00000000
--- a/.jaiph/main.jh
+++ /dev/null
@@ -1,17 +0,0 @@
-#!/usr/bin/env jaiph
-
-#
-# Full pipeline: architect review → implement first queue task.
-# For periodic docs audit, run docs_parity.jh separately.
-#
-
-import "./engineer.jh" as implement
-import "./architect_review.jh" as architect
-import "jaiphlang/git" as git
-
-workflow default() {
- ensure git.is_clean()
-
- run architect.default()
- run implement.default("queue")
-}
\ No newline at end of file
diff --git a/.jaiph/prepare_release.jh b/.jaiph/prepare_release.jh
new file mode 100755
index 00000000..a110429c
--- /dev/null
+++ b/.jaiph/prepare_release.jh
@@ -0,0 +1,139 @@
+#!/usr/bin/env jaiph
+
+#
+# Release-prep workflow. Single-sources the CLI version: bumps package.json,
+# refreshes the installer's hardcoded ref, rebuilds the CLI, verifies that
+# `jaiph --version` matches package.json, and regenerates docs/registry.
+#
+# Run as:
+# jaiph run .jaiph/prepare_release.jh -- 0.9.5 # explicit version
+# jaiph run .jaiph/prepare_release.jh # next patch version
+#
+# The workflow never creates a commit or git tag — it stages edits for the
+# operator to review, commit, tag, and push manually.
+#
+
+script read_pkg_version = `node -p "require('./package.json').version"`
+
+script assert_version_format = ```
+ v="$1"
+ case "${v}" in
+ *[!0-9.]*) printf 'version must match X.Y.Z (digits only); got: %s\n' "${v}" >&2; exit 1 ;;
+ esac
+ printf '%s' "${v}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' || {
+ printf 'version must match X.Y.Z (digits only); got: %s\n' "${v}" >&2
+ exit 1
+ }
+```
+
+script compute_next_patch = ```python3
+import sys
+v = sys.argv[1]
+parts = v.split('.')
+if len(parts) != 3 or not all(p.isdigit() for p in parts):
+ sys.stderr.write(f"invalid current version in package.json: {v}\n")
+ sys.exit(1)
+parts[-1] = str(int(parts[-1]) + 1)
+print('.'.join(parts))
+```
+
+script assert_git_tree_clean = ```
+ if [ -n "$(git status --porcelain)" ]; then
+ echo "git tree is dirty; commit or stash before running prepare_release" >&2
+ git status --short >&2
+ exit 1
+ fi
+```
+
+script assert_tag_does_not_exist = ```
+ v="$1"
+ if git rev-parse -q --verify "refs/tags/v${v}" >/dev/null 2>&1; then
+ printf 'tag v%s already exists\n' "${v}" >&2
+ exit 1
+ fi
+```
+
+script npm_version_no_tag = `npm version "$1" --no-git-tag-version --allow-same-version >/dev/null`
+
+script update_install_release_ref = ```python3
+import sys
+old, new = sys.argv[1], sys.argv[2]
+path = "docs/install"
+with open(path, "r", encoding="utf-8") as f:
+ src = f.read()
+needle = f"v{old}"
+count = src.count(needle)
+if count == 0:
+ sys.stderr.write(f"docs/install: hardcoded ref v{old} not found\n")
+ sys.exit(1)
+new_src = src.replace(needle, f"v{new}")
+with open(path, "w", encoding="utf-8") as f:
+ f.write(new_src)
+print(count)
+```
+
+script run_npm_build = `npm run build >&2`
+
+script assert_built_cli_version_equals = ```
+ v="$1"
+ expected="jaiph ${v}"
+ actual="$(node dist/src/cli.js --version)"
+ if [ "${expected}" != "${actual}" ]; then
+ printf 'displayed --version mismatch\nexpected: %s\nactual: %s\n' "${expected}" "${actual}" >&2
+ exit 1
+ fi
+```
+
+script run_registry_build = `npm run registry:build >&2`
+
+workflow resolve_version(arg) {
+ const pkg_version = run read_pkg_version()
+ const resolved = match arg {
+ "" => run compute_next_patch(pkg_version)
+ _ => arg
+ }
+ run assert_version_format(resolved)
+ return resolved
+}
+
+workflow preflight(version) {
+ run assert_git_tree_clean()
+ run assert_tag_does_not_exist(version)
+}
+
+workflow 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) {
+ run run_npm_build()
+ run assert_built_cli_version_equals(version)
+}
+
+workflow default(arg) {
+ const version = run resolve_version(arg)
+ const old_version = run read_pkg_version()
+ log "Preparing release v${version} (current: v${old_version})"
+
+ run preflight(version)
+ run apply_version_change(old_version, version)
+ run check_displayed_version(version)
+ run run_registry_build()
+
+ log """
+ prepare_release: staged release v${version}
+ - package.json + package-lock.json (npm version ${version})
+ - docs/install (release ref v${old_version} -> v${version})
+ - docs/registry (regenerated)
+ - dist/ (rebuilt; jaiph --version == jaiph ${version})
+
+ Remaining manual steps:
+ 1. Review the diff (git diff)
+ 2. Commit the staged changes
+ 3. Tag: git tag v${version}
+ 4. Push branch + tag (tag push triggers docker-publish and release.yml)
+ 5. Smoke check: jaiph use ${version}
+ """
+ return version
+}
diff --git a/.jaiph/prepare_release.test.jh b/.jaiph/prepare_release.test.jh
new file mode 100644
index 00000000..55d729ae
--- /dev/null
+++ b/.jaiph/prepare_release.test.jh
@@ -0,0 +1,86 @@
+#!/usr/bin/env jaiph
+
+import "./prepare_release.jh" as pr
+
+# resolve_version handles the empty-arg / explicit-arg branches and the
+# X.Y.Z format check; tests use mock script to pin the package.json version.
+
+test "resolve_version: empty arg returns next patch from package.json" {
+ mock script pr.read_pkg_version() {
+ echo "1.2.3"
+ }
+ const out = run pr.resolve_version("")
+ expect_equal out "1.2.4"
+}
+
+test "resolve_version: explicit X.Y.Z arg is accepted verbatim" {
+ mock script pr.read_pkg_version() {
+ echo "0.0.0"
+ }
+ const out = run pr.resolve_version("9.8.7")
+ expect_equal out "9.8.7"
+}
+
+test "resolve_version: non-X.Y.Z arg fails with offending value" {
+ mock script pr.read_pkg_version() {
+ echo "0.0.0"
+ }
+ const out = run pr.resolve_version("not-a-version") allow_failure
+ expect_contain out "version must match X.Y.Z"
+ expect_contain out "not-a-version"
+}
+
+test "resolve_version: extra-segment arg fails with offending value" {
+ mock script pr.read_pkg_version() {
+ echo "0.0.0"
+ }
+ const out = run pr.resolve_version("1.2.3.4") allow_failure
+ expect_contain out "version must match X.Y.Z"
+ expect_contain out "1.2.3.4"
+}
+
+# check_displayed_version: builds the CLI and compares its --version output
+# against the expected literal. On mismatch the script must print both the
+# expected and actual strings before the workflow fails.
+
+test "check_displayed_version: mismatch fails with both values in output" {
+ mock script pr.run_npm_build() {
+ :
+ }
+ mock script pr.assert_built_cli_version_equals() {
+ expected="jaiph $1"
+ actual="jaiph 0.0.0"
+ printf 'displayed --version mismatch\nexpected: %s\nactual: %s\n' "${expected}" "${actual}" >&2
+ exit 1
+ }
+ const out = run pr.check_displayed_version("9.9.9") allow_failure
+ expect_contain out "expected: jaiph 9.9.9"
+ expect_contain out "actual: jaiph 0.0.0"
+}
+
+# preflight: refuses to start if the working tree is dirty so the workflow's
+# own edits are the only diff a reviewer sees.
+
+test "preflight: dirty git tree fails before any side effects" {
+ mock script pr.assert_git_tree_clean() {
+ echo "git tree is dirty; commit or stash before running prepare_release" >&2
+ exit 1
+ }
+ mock script pr.assert_tag_does_not_exist() {
+ :
+ }
+ const out = run pr.preflight("9.9.9") allow_failure
+ expect_contain out "git tree is dirty"
+}
+
+test "preflight: existing tag v fails" {
+ mock script pr.assert_git_tree_clean() {
+ :
+ }
+ mock script pr.assert_tag_does_not_exist() {
+ printf 'tag v%s already exists\n' "$1" >&2
+ exit 1
+ }
+ const out = run pr.preflight("9.9.9") allow_failure
+ expect_contain out "tag v9.9.9 already exists"
+}
diff --git a/.jaiph/security_review.jh b/.jaiph/security_review.jh
new file mode 100644
index 00000000..1d403389
--- /dev/null
+++ b/.jaiph/security_review.jh
@@ -0,0 +1,138 @@
+#!/usr/bin/env jaiph
+
+#
+# Security review of code changes. Reviews uncommitted changes by default,
+# or a git diff range passed as the first argument:
+# jaiph run .jaiph/security_review.jh # staged + unstaged + untracked
+# jaiph run .jaiph/security_review.jh "main..HEAD" # a ref range
+# Writes a markdown report to .jaiph/tmp and publishes it as a run artifact.
+# Fails when any HIGH severity finding is confirmed.
+#
+# Review methodology adapted from anthropics/claude-code-security-review
+# (claudecode/prompts.py): high-confidence findings only, explicit
+# false-positive exclusions, severity + confidence scoring.
+#
+import "./lib_common.jh" as common
+import "jaiphlang/artifacts" as artifacts
+
+config {
+ agent.backend = "claude"
+ agent.claude_flags = "--permission-mode bypassPermissions"
+}
+
+const report_file = .jaiph/tmp/security_review_report.md
+
+const reviewer_role = """
+ You are a senior security engineer conducting a focused security review.
+ Identify HIGH-CONFIDENCE security vulnerabilities with real exploitation
+ potential. Minimize false positives: flag only issues where you are more
+ than 80% confident of actual exploitability in this codebase.
+
+ Vulnerability classes to examine:
+ 1. Input validation: SQL/command/template/NoSQL injection, XXE,
+ path traversal.
+ 2. Authentication & authorization: bypass logic, privilege escalation,
+ session flaws, JWT issues, insecure direct object references.
+ 3. Crypto & secrets: hardcoded credentials, weak algorithms, improper key
+ storage, certificate validation bypasses, insecure randomness.
+ 4. Code execution: unsafe deserialization, eval/exec on untrusted input,
+ unsafe YAML/pickle loading, XSS (reflected, stored, DOM-based).
+ 5. Data exposure: secrets or PII in logs, debug info leaks, overly
+ revealing error messages, sensitive data written to artifacts.
+
+ Severity scale:
+ - HIGH: directly exploitable; leads to RCE, data breach, or auth bypass.
+ - MEDIUM: exploitable under specific conditions, significant impact.
+ - LOW: defense-in-depth gaps or low-impact weaknesses.
+
+ Do NOT report (out of scope, treated as noise):
+ - Denial of service, rate limiting, memory/CPU exhaustion.
+ - Missing input validation on non-security-critical fields without a
+ demonstrated security impact.
+ - Any finding you cannot back with a concrete exploit scenario.
+ - Style, performance, or general code-quality issues.
+"""
+
+script git_diff_uncommitted = ```
+{
+ git diff --cached
+ git diff
+ git ls-files --others --exclude-standard | while IFS= read -r f; do
+ [ -z "$f" ] && continue
+ git diff --no-index -- /dev/null "$f" || true
+ done
+}
+```
+
+script git_diff_range = `git diff "$1"`
+
+script worktree_fingerprint = `git status --porcelain | sort | cksum`
+
+workflow review_diff(diff_text) {
+ const result = prompt """
+
+ ${reviewer_role}
+
+
+ Review the following code changes for security vulnerabilities. You have
+ read access to the full repository — read surrounding source files
+ whenever needed to confirm whether a finding is actually exploitable;
+ do not judge from the diff alone.
+
+ Write a full markdown report to ${report_file} (overwrite if present)
+ with one section per finding: title, severity (HIGH/MEDIUM/LOW),
+ confidence (0.7-1.0; discard anything below 0.7), file and line,
+ a concrete exploit scenario, and a specific remediation. If there are
+ no findings, write a short report stating what was reviewed and that
+ nothing was found.
+
+ Do not modify any file in the repository other than ${report_file}.
+
+ Respond with JSON fields:
+ - verdict: the string "fail" if there is at least one HIGH finding,
+ otherwise the string "pass".
+ - highs, mediums, lows: finding counts by severity.
+ - summary: 1-3 sentences describing the overall result.
+
+ Code changes under review:
+ ${diff_text}
+
+ """
+ returns "{ verdict: string, highs: number, mediums: number, lows: number, summary: string }"
+
+ log "Security review: ${result.summary}"
+ log "Findings: high=${result.highs} medium=${result.mediums} low=${result.lows} (report: ${report_file})"
+ return result.verdict
+}
+
+workflow default(scope) {
+ run common.mkdir_p_simple(".jaiph/tmp")
+ const fingerprint_before = run worktree_fingerprint()
+
+ const diff_text = match scope {
+ "" => run git_diff_uncommitted()
+ _ => run git_diff_range(scope)
+ }
+ if diff_text == "" {
+ log "Security review: no changes to review."
+ return ""
+ }
+
+ const verdict = run review_diff(diff_text)
+
+ # The reviewer must be read-only apart from the (gitignored) report file.
+ const fingerprint_after = run worktree_fingerprint()
+ run common.str_equals(fingerprint_before, fingerprint_after) catch (err) {
+ fail "Security review must not modify the worktree, but git status changed during review. Inspect git status before trusting this run."
+ }
+
+ run artifacts.save(report_file)
+
+ run common.str_equals(verdict, "pass") catch (err) {
+ fail """
+ Security review found HIGH severity issues.
+ See ${report_file} (also published to the run artifacts directory).
+ """
+ }
+ log "Security review passed."
+}
diff --git a/.jaiph/skills.lock b/.jaiph/skills.lock
new file mode 100644
index 00000000..403fd4d3
--- /dev/null
+++ b/.jaiph/skills.lock
@@ -0,0 +1,11 @@
+{
+ "version": 1,
+ "skills": {
+ "documentation-writer": {
+ "source": "github/awesome-copilot",
+ "sourceType": "github",
+ "skillPath": "skills/documentation-writer/SKILL.md",
+ "computedHash": "ee53d65b163cd7eb953a930c95841cfe398cc2c0bd24c06508bbaa07c432be35"
+ }
+ }
+}
diff --git a/.jaiph/skills/documentation-writer/SKILL.md b/.jaiph/skills/documentation-writer/SKILL.md
new file mode 100644
index 00000000..1921e864
--- /dev/null
+++ b/.jaiph/skills/documentation-writer/SKILL.md
@@ -0,0 +1,53 @@
+
+---
+name: documentation-writer
+description: 'Diátaxis Documentation Expert. An expert technical writer specializing in creating high-quality software documentation, guided by the principles and structure of the Diátaxis technical documentation authoring framework.'
+---
+
+# Diátaxis Documentation Expert
+
+You are an expert technical writer specializing in creating high-quality software documentation.
+Your work is strictly guided by the principles and structure of the Diátaxis Framework (https://diataxis.fr/).
+
+## GUIDING PRINCIPLES
+
+1. **Clarity:** Write in simple, clear, and unambiguous language.
+2. **Accuracy:** Ensure all information, especially code snippets and technical details, is correct and up-to-date.
+3. **User-Centricity:** Always prioritize the user's goal. Every document must help a specific user achieve a specific task.
+4. **Consistency:** Maintain a consistent tone, terminology, and style across all documentation.
+
+## YOUR TASK: The Four Document Types
+
+You will create documentation across the four Diátaxis quadrants. You must understand the distinct purpose of each:
+
+- **Tutorials:** Learning-oriented, practical steps to guide a newcomer to a successful outcome. A lesson.
+- **How-to Guides:** Problem-oriented, steps to solve a specific problem. A recipe.
+- **Reference:** Information-oriented, technical descriptions of machinery. A dictionary.
+- **Explanation:** Understanding-oriented, clarifying a particular topic. A discussion.
+
+## WORKFLOW
+
+You will follow this process for every documentation request:
+
+1. **Acknowledge & Clarify:** Acknowledge my request and ask clarifying questions to fill any gaps in the information I provide. You MUST determine the following before proceeding:
+ - **Document Type:** (Tutorial, How-to, Reference, or Explanation)
+ - **Target Audience:** (e.g., novice developers, experienced sysadmins, non-technical users)
+ - **User's Goal:** What does the user want to achieve by reading this document?
+ - **Scope:** What specific topics should be included and, importantly, excluded?
+
+2. **Propose a Structure:** Based on the clarified information, propose a detailed outline (e.g., a table of contents with brief descriptions) for the document. Await my approval before writing the full content.
+
+3. **Generate Content:** Once I approve the outline, write the full documentation in well-formatted Markdown. Adhere to all guiding principles.
+
+## CONTEXTUAL AWARENESS
+
+- When I provide other markdown files, use them as context to understand the project's existing tone, style, and terminology.
+- DO NOT copy content from them unless I explicitly ask you to.
+- You may not consult external websites or other sources unless I provide a link and instruct you to do so.
diff --git a/.jaiph/testing.jh b/.jaiph/testing.jh
deleted file mode 100755
index 50c15386..00000000
--- a/.jaiph/testing.jh
+++ /dev/null
@@ -1,10 +0,0 @@
-#! /usr/bin/env jaiph
-
-script test_runner = ```
-cd "${JAIPH_WORKSPACE:?}"
-bash e2e/tests/72_docker_run_artifacts.sh
-```
-
-workflow default() {
- run test_runner()
-}
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index be0622c6..f26e9ad0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,23 +1,80 @@
# Unreleased
-- **Language:** `for in { … }` in workflows and rules iterates newline-delimited lines of a string binding. Newlines normalize `\r\n` to `\n`; a single trailing empty segment from a final newline is omitted. Lines are not trimmed and empty interior lines are still iterated unless the body skips them (e.g. `if line != "" { … }`). Documented in `docs/language.md`.
-- **Tests / QA:** Unit tests for string line splitting (`src/runtime/string-lines.test.ts`); E2E `e2e/tests/135_for_string_lines.sh`.
+- **Fix — Prompt watchdog: never hang on a backend that finishes but does not exit:** `runBackend` (`src/runtime/kernel/prompt.ts`) previously gated the entire prompt result on the child process closing — `parseStream` resolves only on its readline `close`, the merged stream ends only on the child's `close` event, so a `claude -p` that streamed its final answer but never exited (commonly because a descendant it spawned is still holding the stdout pipe open) blocked the runtime forever: no `PROMPT_END`, no commit, no queue progress, no retry. Three independent watchdog layers now wrap every subprocess backend (claude / cursor / custom command) via a new exported `installPromptWatchdog`: **(1) completion grace** — `parseStream` now takes an `onComplete` callback that fires when the backend's terminal `result` event is parsed (tracked by a new `sawResult` flag on `StreamState` in `src/runtime/kernel/stream-parser.ts`); once seen, the process is given `JAIPH_PROMPT_COMPLETION_GRACE_SECONDS` (default 30s) to exit before it is terminated and the prompt returns **success** with the captured answer; **(2) idle timeout** — no stdout/stderr for `JAIPH_PROMPT_IDLE_TIMEOUT_SECONDS` (default 900s / 15m, reset on every output chunk) terminates the process and returns **failure**, feeding the existing prompt-retry backoff; **(3) absolute cap** — total wall-clock over `JAIPH_PROMPT_MAX_SECONDS` (default 7200s / 2h) terminates and fails likewise. Each layer is disabled with `0`. On expiry the watchdog sends `SIGTERM`, escalating to `SIGKILL` after 5s. A single-settle guard in `runBackend` ensures the normal-exit path and the watchdogs cannot double-resolve, and on settle the runtime now destroys its handles on the child's `stdin`/`stdout`/`stderr` (and the claude `merged` PassThrough) so a lingering descendant holding a pipe can no longer keep the event loop — and thus the whole run — alive after the prompt has resolved. The three knobs are read in `resolveConfig` via literal `env.JAIPH_PROMPT_*` access so the env-var source-parity harness (`integration/docs-reference-task5.test.ts`) pins them, and are documented in `docs/env-vars.md` (parity table) and a new "Prompt watchdog timeouts" section in `docs/configuration.md`. New tests in `src/runtime/kernel/prompt.test.ts` cover the watchdog unit (idle fires, `bump()` resets idle, absolute cap ignores `bump()`, completion grace settles success with the captured final, `clear()` cancels, fires-at-most-once) plus two end-to-end `executePrompt` cases through a fake cursor-agent that hangs (recovers with success after a `result` event; recovers with failure on a silent hang). Under Docker, `runtime.docker_timeout_seconds` remains the outer backstop.
+- **Docs — Post-parity cleanup: retire the `docs/_legacy/` quarantine (docs redesign 8/8):** Final task in the [Diátaxis](https://diataxis.fr/) docs rewrite — the agent-doable cleanup that runs after the maintainer's redesign-aware parity pass (`jaiph run .jaiph/docs_parity_redesign.jh`, a redesign-aware copy of `docs_parity.jh` that lists docs recursively, excludes `docs/_legacy/`, and verifies the Diátaxis structure against the TypeScript/Bash source + `docs/architecture.md` instead of re-consolidating it). With the greenfield pages (tasks 3–7) confirmed to match source on run-dir naming, env-var lists, flag tables, config keys, and error codes, the `docs/_legacy/` quarantine is now obsolete — its content has been fully superseded by the live pages and is recoverable from git history. All 14 quarantined pages (`artifacts.md`, `cli.md`, `configuration.md`, `contributing.md`, `getting-started.md`, `grammar.md`, `hooks.md`, `inbox.md`, `language.md`, `libraries.md`, `sandboxing.md`, `setup.md`, `spec-async-handles.md`, `testing.md`) are deleted from the worktree, and the now-unneeded `- _legacy` entry (plus its explanatory comment) is removed from the Jekyll `exclude:` list in `docs/_config.yml` so the build config no longer carries a vestige of the quarantine. The README's "Docs note" trailing sentence ("Pre-redesign pages that have not been recreated yet stay quarantined under [`docs/_legacy/`](docs/_legacy/) (in git, not published).") is dropped because there are no longer any quarantined pages to disclose. The env-forwarding parity check in `src/runtime/docker.test.ts` is repointed from `docs/_legacy/sandboxing.md` to the published reference page `docs/env-vars.md` (asserting that the `ENV_ALLOW_PREFIXES` allowlist and the `ENV_ALLOW_EXCLUDE_PREFIX` exclude prefix appear verbatim in the live page), and the companion cross-link assertion that required `configuration.md` and `cli.md` to link to the `sandboxing.md#environment-variable-forwarding` section is dropped — the env-vars reference is now self-contained, so the legacy cross-link contract no longer applies. The dedicated quarantine harness `integration/docs-legacy-quarantine.test.ts` is deleted because its invariants ("`_legacy` is build-excluded", "live pages exist alongside quarantined reference copies", "nav never targets a quarantined permalink") no longer have a `_legacy` directory to police, and the matching row is removed from the test-suite table in `docs/contributing.md`. In its place, `integration/docs-structure.test.ts` replaces the prior "pages under `docs/_legacy/` are exempt from publish-side checks" lint with a one-line `docs-lint: docs/_legacy/ no longer exists (post-redesign cleanup)` assertion that fails if anyone re-introduces the directory — the cleanup is now a hard invariant rather than a tolerated state. The docs-lint, internal-link, redirect-coverage, env-var source-parity (`integration/docs-reference-task5.test.ts`), and nav-structure (`integration/docs-nav-structure-task7.test.ts`) tests from tasks 2–7 stay green, and `bundle exec jekyll build` exits 0 with no missing-link / front-matter warnings. No runtime, CLI, language, or behavior changes — this task is docs cleanup only, closing out the eight-task Diátaxis redesign queue.
+- **Docs — Diátaxis IA finalization: nav regrouping, landing entry points, redirect sweep (docs redesign 7/8):** Fifth content task in the [Diátaxis](https://diataxis.fr/) docs rewrite — the structural wiring task that ties together the greenfield Explanation (task 3), How-to (task 4), Reference (task 5), and Tutorials (task 6) pages into the target IA. The Jekyll nav in `docs/_layouts/docs.html` is regrouped into **five labeled `` sections in the documented Diátaxis order — Tutorials → How-to guides → Reference → Explanation → Contributing** — each containing exactly the published pages whose `diataxis:` front-matter matches the section (`tutorial` → Tutorials, `how-to` → How-to guides, `reference` → Reference, `explanation` → Explanation, `contributor` → Contributing). The active-page highlighting (`{% if page.permalink == '/...' %} class="docs-nav-active" aria-current="page"{% endif %}`) is preserved on every entry, and the contributor Agent Skill link continues to point at the in-site permalink `/jaiph-skill` (the raw-`jaiph-skill.md` URL stays in `README.md` and `docs/index.html` because those are the entry points agents themselves consume and they need the unrendered Markdown — that contract is unchanged from task 2). Tutorials lead the panel because they are the entry point for newcomers; Contributing trails because it is in-repo developer surface, not user-facing. The landing page (`docs/index.html`) entry points are repointed to lead with the **first tutorial** and the **how-to index** (not a flat page list): the top-nav `Docs` link is split into `Tutorial` (→ `/tutorials/first-workflow`) and `How-to` (→ `/how-to/install`), and the footer `Architecture` link is replaced with the same `Tutorial` + `How-to` pair so the landing page guides newcomers down the tutorial path and operators down the how-to path rather than dumping them on an explanation page. The live contributor page `docs/contributing.md` (permalink `/contributing`, `diataxis: contributor`, `redirect_from: /contributing.md`) owns the `/contributing` slug alongside `docs/jaiph-skill.md` at `/jaiph-skill` — both under the Contributing nav section (22 published `docs/*.md` pages with `diataxis:` front-matter in total). Every URL in the pre-redesign nav (`/getting-started`, `/setup`, `/libraries`, `/artifacts`, `/language`, `/grammar`, `/cli`, `/configuration`, `/testing`, `/spec-async-handles`, `/inbox`, `/hooks`, `/sandboxing`, `/architecture`, `/contributing`) now resolves to its new home — either directly (the slug is unchanged on a live page) or via a single `jekyll-redirect-from` stub emitted from the absorbing page's `redirect_from:` list. `bundle exec jekyll build` exits 0 with no missing-link / front-matter warnings and emits no page from `docs/_legacy/` (already build-excluded via the `_config.yml` `exclude:` list from task 1). A new integration test `integration/docs-nav-structure-task7.test.ts` (Node `--test`, auto-picked up by `npm test`) graders this task end-to-end as two checks: (1) the nav layout's ` ` headings are exactly `["Tutorials", "How-to guides", "Reference", "Explanation", "Contributing"]` in that order — drift in heading text or ordering fails the test; (2) every published `docs/*.md` with a `diataxis:` front-matter value appears under the matching section exactly once (no miss / no miscategorisation / no cross-section duplicate), and every section's link list equals the set of permalinks for its diataxis bucket — so adding a new how-to page without nav-wiring it, or accidentally listing a tutorial under Explanation, fails the test. The existing docs-lint harness from task 2 (`integration/docs-structure.test.ts`) continues to enforce the historical-permalink resolution check (`every historical nav permalink still resolves`) — that test mines every `'' | relative_url` reference from `git log -p --all -- docs/_layouts/docs.html` and asserts the URL still resolves via a published page or a `redirect_from:` alias, which is the redirect-coverage backstop for this task. With this task in, the IA is complete: all four user-facing Diátaxis quadrants are nav-grouped under their section heading, plus the in-repo Contributing bucket; the remaining task 8 work is the README/landing sweep (`docs/contributing.md` and nav wiring are in). No runtime, CLI, or language behavior changes; the edits are to `docs/_layouts/docs.html`, `docs/index.html`, `docs/contributing.md`, and `docs/jaiph-skill.md` front-matter.
+- **Docs — Diátaxis Tutorials pass: guided first-success paths (docs redesign 6/8):** Fourth content task in the [Diátaxis](https://diataxis.fr/) docs rewrite. Two learning-oriented pages now land in `docs/` as published Diátaxis tutorials, each authored greenfield from the TypeScript/Bash source plus `docs/architecture.md` first and only then reconciled against `docs/_legacy/getting-started.md` (per the anti-bias protocol in `.jaiph/skills/documentation-writer/SKILL.md`). Both pages walk a newcomer from "have nothing" to a working first-success outcome, with every command copy-pasteable and one happy path only — branching/optional knobs link out to the relevant How-to or Reference page rather than expanding inline: `docs/first-workflow.md` (permalink `/tutorials/first-workflow`, `redirect_from: /getting-started`, `/getting-started.md`) — install → write a five-line script-only `.jh` with one `script` step and one `workflow default(who)` that returns the script's stdout → run with `jaiph run ./hello.jh "Adam"` → read the live progress tree, the printed return value, and the durable files under `.jaiph/runs//-/` (`000001-workflow__default.out|.err`, `000002-script__greet.out|.err`, `return_value.txt`, `run_summary.jsonl`, `heartbeat`) → re-run with `exit 7` swapped into the script body to observe the failure footer (`✗ FAIL`, `Logs:` / `Summary:` / `out:` / `err:`, `Output of failed step:` excerpt; no `return_value.txt` on failure); `docs/first-agent-run.md` (permalink `/tutorials/first-agent-run`, no retired slug — genuinely new) — credential prerequisites stated up front per backend (claude → `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN`; cursor → `CURSOR_API_KEY`; codex → `OPENAI_API_KEY`; Docker is hard-failing for missing env vars because stored CLI logins do not cross the container boundary) → Docker prerequisite (`docker info`, no `--docker` flag, overlay vs copy mode picked from `/dev/fuse`) → add `rule valid_name(name_arg)` with `match` arms plus a triple-quoted `prompt """ … """` step → run `jaiph run ./greet.jh "Adam"`, observing the `(Docker sandbox, fusefs)` / `(tmp workspace)` banner, the backend-and-preview prompt line, and the return value → re-run with empty `name_arg` so `ensure valid_name(name_arg)` aborts the workflow and the `prompt` step is never reached → inspect the `PROMPT_START` record in `run_summary.jsonl` with `jq` for the resolved `backend` / `model` / `model_reason`. Each page declares `diataxis: tutorial` and the matching `permalink: /tutorials/`. The Jekyll nav in `docs/_layouts/docs.html` gains a new **Tutorials** group at the top of the panel (above Why Jaiph / Architecture, since tutorials are the entry point for newcomers) with one entry per page; the previously flat Explanation block now sits under an explicit **Explanation** group label so the nav reads as four named Diátaxis sections (Tutorials → Explanation → How-to → Reference) ahead of the final IA regrouping in task 7. `docs/architecture.md` is tightened only at the link surface: `/getting-started` is removed from its `redirect_from:` list because the new tutorial owns it now (jekyll-redirect-from emits one canonical stub from the tutorial's `redirect_from:`). A new integration test `integration/docs-tutorials-task6.test.ts` (Node `--test`, auto-picked up by `npm test`) grades this task end-to-end as four checks: (1) each of the two pages declares `diataxis: tutorial` and the expected `permalink: /tutorials/`; (2) every tutorial permalink is reachable from the nav exactly once, and the nav surfaces a `Tutorials ` group heading; (3) `/getting-started` is absorbed by `first-workflow.md`'s `redirect_from:` and **not** by any other live page (the test also asserts `architecture.md` no longer claims the slug so two redirect stubs cannot conflict at build time); (4) the first ```jh fenced block in `first-workflow.md` is *executable* — the test extracts it, writes it to a temp `hello.jh`, runs `node dist/src/cli.js run Adam` in a clean env with `JAIPH_UNSAFE=true` / `NO_COLOR=1` / `TERM=dumb`, asserts exit 0, and asserts the normalised stdout (with `(\d+(\.\d+)?(s|ms))` timings collapsed to ``) equals the first ```text block on the page after trimming. This is the "happy path is executable, not aspirational" contract — if a future change to the CLI banner / progress tree / return-value-printing path drifts the actual output away from the tutorial, this test fails. README.md is updated to surface the two new tutorials in the top-line link bar and in the "Docs note" callout (now reading "Tutorials, Explanation, How-to, and Reference quadrants have landed"), to drop `getting-started` from the legacy index (the slug is now owned by the live tutorial), and to repoint the three remaining `docs/_legacy/getting-started.md` references (Usage doc-map, Example trailer, Start-here "Human" bullet) at `docs/first-workflow.md` so a reader who lands on README from search keeps reaching live pages. `docs/index.html`'s Language section similarly repoints its `Getting started ` link (and the `jaiph.org/getting-started` external link next to it) at `/tutorials/first-workflow`, so the landing page stops naming a slug that now exists only as a redirect stub. With this task in, all four user-facing Diátaxis quadrants are live (Explanation from task 3, How-to from task 4, Reference from task 5, Tutorials here); the final IA / nav regrouping is task 7 and the contributor pages plus the broader README/landing sweep is task 8. No runtime, CLI, or language behavior changes.
+- **Docs — Diátaxis Reference pass: pure lookup pages, code-verified (docs redesign 5/8):** Third content task in the [Diátaxis](https://diataxis.fr/) docs rewrite. Five lookup-oriented pages now land in `docs/` as published Diátaxis reference pages, each authored greenfield from the TypeScript/Bash source plus `docs/architecture.md` first and only then reconciled against its `docs/_legacy/` copy (per the anti-bias protocol in `.jaiph/skills/documentation-writer/SKILL.md`). Each page is information-oriented and table-driven — exhaustive, neutral, no second-person tutorial prose and no how-to recipes (the *how* belongs to the How-to quadrant from task 4 and the *why* belongs to the Explanation quadrant from task 3): `docs/cli.md` (permalink `/reference/cli`, `redirect_from: /cli`, `/cli.md`) — the authoritative inventory of `jaiph` invocation forms (`jaiph`, `jaiph --help`, `jaiph --version`, file-shorthand routing of `*.test.jh` → `jaiph test` / other `*.jh` → `jaiph run`, unknown-command stderr text + exit `1`, the internal `__workflow-runner` marker that is excluded from help/usage), every subcommand (`run`, `test`, `compile`, `format`, `init`, `install`, `use`) with its full flag table and exit behaviour, the progress-marker glyphs (`▸ ✓ ✗ ℹ ! ·` plus the `₁ ₂ …` subscript prefix for `run async` branches), the credential pre-flight skip-on-`--raw` rule, the non-TTY heartbeat env-var pair (`JAIPH_NON_TTY_HEARTBEAT_FIRST_SEC` / `JAIPH_NON_TTY_HEARTBEAT_INTERVAL_MS`), the `__JAIPH_EVENT__`-on-stderr live contract, the `.jaiph/runs//-/` durable layout, and the `run_summary.jsonl` event types (`WORKFLOW_START` / `WORKFLOW_END` / `STEP_START` / `STEP_END` / `LOG` / `LOGERR` / `INBOX_*` / `PROMPT_START` / `PROMPT_END`); `docs/configuration.md` (permalink `/reference/configuration`, `redirect_from: /configuration`, `/configuration.md`) — the authoritative key inventory: every `agent.*` / `run.*` / `module.*` / `runtime.*` key with its value type, default, environment-variable equivalent, and notes (`agent.default_model`, `agent.command`, `agent.backend`, `agent.trusted_workspace`, `agent.cursor_flags`, `agent.claude_flags`, `run.logs_dir`, `run.debug`, `run.recover_limit`, `module.name`/`version`/`description`, `runtime.docker_image`/`network`/`docker_timeout_seconds`), the precedence ladder (env > workflow-level > module-level > defaults; runtime-level Docker on/off is env-only — in-file `runtime.docker_enabled` is `E_PARSE`), the `${NAME}_LOCKED=1` mechanism (locked names: `JAIPH_AGENT_BACKEND/MODEL/COMMAND/TRUSTED_WORKSPACE/CURSOR_FLAGS/CLAUDE_FLAGS`, `JAIPH_RUNS_DIR`, `JAIPH_DEBUG`), the scoping table across nested calls (root entry / same-module `run` / cross-module `run` / same-module `ensure` / cross-module `ensure`), the credential pre-flight matrix per backend (`cursor`, `claude`, `codex`), the model-resolution order (explicit → flags → backend-default with the special Claude `--model` injection), and the prompt-retry backoff schedule with `JAIPH_PROMPT_RETRY` / `JAIPH_PROMPT_RETRY_DELAYS` overrides; `docs/grammar.md` (permalink `/reference/grammar`, `redirect_from: /grammar`, `/grammar.md`) — the authoritative syntactic reference: lexical rules (identifier shape, comment / blank-line / shebang handling, single-line `"…"` vs triple-quoted `"""…"""`, backtick vs fenced script bodies, required-parentheses rule), top-level EBNF (`file = { top_level }`, the `import` / `import script` / `channel` / `env_decl` / `rule` / `script` / `workflow` productions), the formatter hoist ordering (`import` → `config` → `channel`, other top-level definitions keep source order), the per-statement EBNF (`run_stmt` / `run_catch_stmt` / `run_recover_stmt` / `run_async_stmt` / `ensure_stmt` / `prompt_stmt` / `const_decl_step` / `return_stmt` / `send_stmt` / `match_stmt` / `if_stmt` / `for_lines_stmt`), the inline-script form (`scripts/__inline_<12-hex>` deterministic emission), the typed-prompt `returns "{ field: type, … }"` flat schema rules, the validation catalog (`E_PARSE` / `E_SCHEMA` / `E_VALIDATE` / `E_IMPORT_NOT_FOUND` with each trigger), and the build-artifact table (only per-`script` files emit; workflows/rules/prompts/channels/control flow are interpreted from the AST); `docs/language.md` (permalink `/reference/language`, `redirect_from: /language`, `/language.md`) — the per-step reference: the eight `WorkflowStepDef` variants (`exec`, `const`, `return`, `send`, `say`, `if`, `for_lines`, `trivia`), the eight `Expr` kinds (`literal`, `call`, `ensure_call`, `inline_script`, `prompt`, `match`, `shell`, `bare_ref`), each step's allowed positions and capture rules (workflow callees yield explicit `return`; named/inline scripts yield trimmed stdout; rule `ensure` yields explicit `return`), `run async` resolution semantics (eager start / lazy resolve, the implicit join at end-of-step-list, passthrough vs resolving reads, the `recover` / `catch` interaction), the rule-scope restriction table (`prompt` / `send` / `run async` / `run` to workflow / raw shell are all forbidden in rules), the subprocess-environment contract (runner `process.env` augmented with `JAIPH_WORKSPACE` / `JAIPH_SCRIPTS` / `JAIPH_RUN_DIR` / `JAIPH_ARTIFACTS_DIR` / `JAIPH_RUN_ID` / `JAIPH_RUN_SUMMARY_FILE` plus `JAIPH_AGENT_*` and config-derived keys; module `const` values **not** auto-exported), the step-output contract (status / capture / logs per step type), and the `JAIPH_RECURSION_DEPTH_LIMIT` runtime cap (default `256`); `docs/env-vars.md` (permalink `/reference/env-vars`, `redirect_from: /env-vars`, `/env-vars.md`, NEW page) — a consolidated environment-variable inventory absorbing the sandboxing config/failure-mode tables explicitly excluded from the task-3 explanation page. Three tables: (1) every `JAIPH_*` name read in `src/` (`JAIPH_AGENT_*` / `JAIPH_DOCKER_*` / `JAIPH_INPLACE` / `JAIPH_INPLACE_YES` / `JAIPH_RUNS_DIR` / `JAIPH_RUN_DIR` / `JAIPH_RUN_ID` / `JAIPH_RUN_SUMMARY_FILE` / `JAIPH_ARTIFACTS_DIR` / `JAIPH_SCRIPTS` / `JAIPH_SOURCE_FILE` / `JAIPH_SOURCE_ABS` / `JAIPH_META_FILE` / `JAIPH_MODULE_GRAPH_FILE` / `JAIPH_WORKSPACE` / `JAIPH_TEST_MODE` / `JAIPH_MOCK_*_JSON` / `JAIPH_INBOX_MAX_DISPATCH` / `JAIPH_INBOX_PARALLEL` / `JAIPH_NON_TTY_HEARTBEAT_*` / `JAIPH_CODEX_API_URL` / `JAIPH_PROMPT_RETRY` / `JAIPH_PROMPT_RETRY_DELAYS` / `JAIPH_PROMPT_FINAL_FILE` / `JAIPH_INSTALL_COMMAND` / `JAIPH_REGISTRY` / `JAIPH_SKILL_PATH` / `JAIPH_DEBUG` / `JAIPH_UNSAFE` plus the `*_LOCKED` lock-flags and the stripped-on-launch deprecated names `JAIPH_LIB` / `JAIPH_STDLIB` / `JAIPH_PRECEDING_FILES`) with scope (`host` / `runtime` / `internal`), type, default, related config key, and role columns — the table is delimited by `` / `` HTML markers so the docs-lint harness can pin it bidirectionally against `src/`; (2) the agent-credential matrix (`ANTHROPIC_API_KEY` / `CLAUDE_CODE_OAUTH_TOKEN` / `CURSOR_API_KEY` / `OPENAI_API_KEY`) with host vs Docker behaviour and the `JAIPH_*` / `ANTHROPIC_*` / `CLAUDE_*` / `CURSOR_*` Docker-forwarding allowlist; (3) the installer / `jaiph use` variables (`JAIPH_REPO_REF` / `JAIPH_BIN_DIR` / `JAIPH_RELEASE_BASE_URL` / `JAIPH_REPO_URL`) — host-shell consumers, **not** read from `src/`, so they sit outside the parity-pinned block; plus the consolidated Docker-failure-mode table (`E_DOCKER_NOT_FOUND` / `E_DOCKER_PULL` / `E_DOCKER_NO_JAIPH` / `E_DOCKER_RUNS_DIR` / `E_DOCKER_OVERLAY` / `E_DOCKER_TIMEOUT` / `E_DOCKER_UID` / `E_DOCKER_SANDBOX_COPY` / `E_DOCKER_INPLACE_NO_CONFIRM` / `E_FLAG_CONFLICT` / `E_CLI_SETUP` / `E_VALIDATE_MOUNT` / `E_TIMEOUT` / `E_AGENT_CREDENTIALS`) with trigger and behaviour columns. Each new page declares `diataxis: reference` and the matching `permalink: /reference/` in its front-matter, picks up `redirect_from:` entries for its retired permalink (`/cli`, `/configuration`, `/grammar`, `/language`, `/env-vars`) plus the `.md` form, and links inward to other reference pages and to the relevant How-to / Explanation pages for `Related` reading (with no manual "More Documentation" block — nav is provided by `docs/_layouts/docs.html`). The Jekyll nav in `docs/_layouts/docs.html` gains a new **Reference** group with one entry per page (`/reference/cli`, `/reference/configuration`, `/reference/grammar`, `/reference/language`, `/reference/env-vars`) sitting between the existing How-to quadrant and the Agent Skill contributor link; final IA regrouping still happens in task 7. `docs/architecture.md` is tightened only at the link surface: `/language`, `/grammar`, `/cli`, and `/configuration` are removed from its `redirect_from:` list because those slugs are now absorbed by the new live reference pages' own `redirect_from:`. The legacy quarantine list in `integration/docs-legacy-quarantine.test.ts` graduates `cli.md`, `configuration.md`, `grammar.md`, and `language.md` from `QUARANTINED_PAGES` to `RECREATED_WITH_LEGACY` (env-vars.md is genuinely new — no legacy predecessor — and is not listed in either bucket). A new integration test `integration/docs-reference-task5.test.ts` (Node `--test`, auto-picked up by `npm test`) graders this task end-to-end as four checks: (1) each of the five pages declares `diataxis: reference` and the expected `permalink: /reference/`; (2) every reference permalink is reachable from the nav exactly once; (3) the env-var reference is source-parity-pinned against `src/` — every `JAIPH_*` name read via `env.JAIPH_*` / `process.env.JAIPH_*` / `process.env["JAIPH_*"]` in `src/` appears between the `` markers, and no name appears between the markers that is absent from `src/` (drift in either direction fails the test); (4) reference pages contain no tutorial-shaped numbered `## N.` / `### N.` section headings, no `## Verification` / `## Verify` terminal section, no second-person imperative leads (`You will…`, `Now you…`, `Next, you…`), and at most 12 second-person pronouns total (heuristic upper bound against drift back into open-ended prose). With this task in, three of the four Diátaxis quadrants are now live (Explanation from task 3, How-to from task 4, Reference here); the two Tutorials (`first-workflow`, `first-agent-run`) land as task 6, the final IA / nav regrouping is task 7, and contributor pages plus the README/landing sweep are task 8. No runtime, CLI, or language behavior changes.
+- **Docs — Diátaxis How-to pass: task-oriented recipes (docs redesign 4/8):** Second content task in the [Diátaxis](https://diataxis.fr/) docs rewrite. Eight problem-oriented recipes now land in `docs/` as published Diátaxis how-to pages, each authored greenfield from the TypeScript/Bash source plus `docs/architecture.md` first and only then reconciled against its `docs/_legacy/` copy (per the anti-bias protocol in `.jaiph/skills/documentation-writer/SKILL.md`). Each recipe is shaped goal → prerequisites → numbered steps → `Verification` → `Related`, with no conceptual digressions (the *why* belongs to the Explanation quadrant from task 3 and the *what-it-is* belongs to Reference in task 5): `docs/setup.md` (permalink `/how-to/install`, `redirect_from: /setup`) — install the standalone binary via `curl -fsSL https://jaiph.org/install | bash` (downloads the matching `jaiph-{darwin|linux}-{arm64|x64}` plus `SHA256SUMS` from the current stable Release, verifies the checksum, installs to `~/.local/bin/jaiph`, with `JAIPH_BIN_DIR` overriding the install dir), the npm alternative for Node-on-the-host setups, the `jaiph use [` switch (`JAIPH_REPO_REF` + `JAIPH_INSTALL_COMMAND` overrides for forks/offline mirrors), and `jaiph --version` as the verification step; `docs/sandbox-run.md` (permalink `/how-to/sandbox-run`, no retired slug) — the enabling steps that were explicitly out of scope for the task-3 sandboxing explanation: `jaiph run ./flow.jh` is Docker-on by default, overlay mode picks itself when `/dev/fuse` exists on the host and copy mode otherwise (or under `JAIPH_DOCKER_NO_OVERLAY=1`), `--inplace` / `JAIPH_INPLACE=1` opts into live host edits and triggers the per-git-state confirmation prompt (default-no on empty input or EOF), `-y` / `--yes` / `JAIPH_INPLACE_YES=1` skips the prompt in non-TTY environments (without one of these the run aborts with `E_DOCKER_INPLACE_NO_CONFIRM`), `--unsafe` disables Docker entirely, and `--unsafe` + `--inplace` is rejected with `E_FLAG_CONFLICT`. The verification step grounds the recipe in the exact CLI banner lines (`Docker sandbox, fusefs` / `Docker sandbox, tmp workspace` / `Docker sandbox, in-place (live host edits)`) emitted by `src/cli/run/display.ts`; `docs/agent-auth.md` (permalink `/how-to/agent-auth`, NEW page) — the credential pre-flight implemented in `src/cli/run/preflight-credentials.ts` written up as a recipe per backend: claude needs `ANTHROPIC_API_KEY` *or* `CLAUDE_CODE_OAUTH_TOKEN` (with `claude setup-token` as the documented path for the OAuth token), cursor needs `CURSOR_API_KEY`, codex needs `OPENAI_API_KEY`. The host-vs-Docker matrix is explicit: under Docker, stored CLI logins (`~/.claude` / macOS Keychain / `cursor-agent login`) do **not** cross the container boundary so only the env vars on the host's forwarding allowlist reach the agent — claude and cursor are warn-only on host runs but hard `E_AGENT_CREDENTIALS` errors under Docker, while codex is a hard error on both because it has no CLI-login fallback. The page also covers the two escape hatches the pre-flight observes: `JAIPH_UNSAFE=true` skips the check entirely, and a file that neither declares a backend nor uses any `prompt` step is skipped because there is nothing to credential against. `JAIPH_CODEX_API_URL` is named once as the OpenAI-compatible endpoint override; `docs/configure-backend.md` (permalink `/how-to/configure-backend`, no retired slug) — how-to slice of the legacy `configuration.md`: module-level `config { agent.backend = "claude" / agent.default_model = "sonnet-4" }`, the workflow-level `config { … }` override (must be the first non-comment construct, only `agent.*` and `run.*` allowed at workflow scope — `runtime.*` is module-only), and the `JAIPH_AGENT_BACKEND` / `JAIPH_AGENT_MODEL` env overrides that win over both (the CLI marks `JAIPH_AGENT_BACKEND_LOCKED=1` for the run so in-file overrides cannot silently take effect later). The verification step is a copy-paste `jq -c 'select(.type=="PROMPT_START")' .jaiph/runs/]/-/run_summary.jsonl` so the reader can confirm the resolved backend, model, and `model_reason`; `docs/hooks.md` (permalink `/how-to/hooks`, `redirect_from: /hooks`) — recipe form of the legacy hooks page: the two config locations (`~/.jaiph/hooks.json` global, `/.jaiph/hooks.json` project, project overrides global per event), the exact four supported events (`workflow_start` / `workflow_end` / `step_start` / `step_end`), the schema (object → event-name → array of shell commands), the fire-and-forget execution model (`sh -c ''` with the JSON payload on stdin, stdout discarded, stderr copied to CLI stderr, failures never change the workflow exit code), the commands hooks do NOT fire for (`jaiph test`, `jaiph compile`, `jaiph format`, `jaiph init`, `jaiph install`, `jaiph use`, `jaiph run --raw`), and the no-op `{"workflow_end": ["true"]}` pattern for disabling a global hook per project. The page anchors the full payload shape to `HookPayload` / `HookEventName` in `src/types.ts` rather than republishing the schema; `docs/libraries.md` (permalink `/how-to/libraries`, `redirect_from: /libraries`) — split into Part A (use) and Part B (publish). Part A covers all four input shapes of `jaiph install` (registry name, registry name `@version`, git URL, git URL `@ref`), the regex that decides registry-vs-URL (`/^[A-Za-z0-9_-]+(@…)?$/` with no `/` and no `:`), the clone destination `/.jaiph/libs//` with the nested `.git` directory stripped, the `.jaiph/libs.lock` lockfile recording the resolved URL, version, and 40-char commit, and the bare `jaiph install` restore-from-lock path (registry never read). Part B covers the publishing model — public git repo with top-level `.jh` modules, the import-prefix-from-clone-name rule, the `export` surface, git tag releases, and the optional `jaiph.org/registry` PR. The verification rejection message is reproduced verbatim from source (`lib "" contains no .jh modules — not a jaiph library?`) so the reader can grep an actual error back to this page; `docs/artifacts.md` (permalink `/how-to/artifacts`, `redirect_from: /artifacts`) — recipe form of the legacy artifacts page: `import "jaiphlang/artifacts" as artifacts` followed by `artifacts.save("./path/to/file")` (single path) or `artifacts.save(newline_separated_list)` (multiple paths, blank lines ignored, returned value is a newline-separated list of absolute destinations), the `./`-prefix-stripped / absolute-paths-flattened-to-basename copy semantics, and the lower-level `script` step alternative writing directly to `$JAIPH_ARTIFACTS_DIR`. The page also names the sibling env vars the runtime always sets (`JAIPH_RUN_DIR`, `JAIPH_RUN_SUMMARY_FILE`, `JAIPH_RUN_ID`) and the three failure modes of `artifacts.save(...)` (empty list after trim / missing source / `JAIPH_ARTIFACTS_DIR` unset) so the reader knows where to wrap the call in `recover` / `catch`; `docs/testing.md` (permalink `/how-to/testing`, `redirect_from: /testing`) — how-to slice of the legacy testing page: write `*.test.jh` files with `import "./under-test.jh" as w` + `test "..." { … }` blocks, queue mock prompt responses (`mock prompt "..."` consumed FIFO, one per `prompt` call; bare-identifier form refers to test-block `const`), the pattern form (`mock prompt { /re/ => "...", _ => "..." }`, mutually exclusive with the queue form in the same test block), `mock workflow` / `mock rule` / `mock script` body-stubs (parentheses required), the `run w.default()` / `run w.default("arg")` / `run … allow_failure` capture forms, the `expect_contain` / `expect_not_contain` / `expect_equal` assertions, and the `jaiph test` discovery rules (`*.test.jh` recursive, zero-matches prints `jaiph test: no *.test.jh files found (nothing to do)` and exits 0, single-file path shorthand). Each new page declares `diataxis: how-to` and the matching `permalink:` in its front-matter, picks up `redirect_from:` entries for any retired permalink the legacy page held (`/hooks`, `/libraries`, `/artifacts`, `/setup`, `/testing`), and links inward to `architecture.md` / `sandboxing.md` / `/how-to/agent-auth` / `/how-to/sandbox-run` for `Related` reading (with no manual "More Documentation" block — nav is provided by `docs/_layouts/docs.html`). The Jekyll nav in `docs/_layouts/docs.html` gains a new **How-to** group with one entry per recipe (`/how-to/install`, `/how-to/sandbox-run`, `/how-to/agent-auth`, `/how-to/configure-backend`, `/how-to/hooks`, `/how-to/libraries`, `/how-to/artifacts`, `/how-to/testing`) sitting between the existing Explanation quadrant (Why Jaiph / Architecture / Sandboxing / Inbox & Dispatch / Async Handles) and the Agent Skill contributor link; final IA regrouping still happens in task 7. `docs/architecture.md` is tightened only at the link surface: `/setup`, `/libraries`, `/artifacts`, `/testing`, and `/hooks` are removed from its `redirect_from:` list because those slugs are now absorbed by the new live how-to pages' own `redirect_from:`. The legacy quarantine list in `integration/docs-legacy-quarantine.test.ts` graduates `artifacts.md`, `hooks.md`, `libraries.md`, `setup.md`, and `testing.md` from `QUARANTINED_PAGES` to `RECREATED_WITH_LEGACY`, the redirect-stub probe is repointed from `/hooks` (no longer quarantined) to `/getting-started` (still quarantined; replacement tutorial lands in task 6), and the internal-link check is upgraded to resolve nav entries against each docs/*.md front-matter `permalink:` rather than path-from-slug — so a how-to at `docs/setup.md` with permalink `/how-to/install` now resolves cleanly. A new integration test `integration/docs-how-to-task4.test.ts` (Node `--test`, auto-picked up by `npm test`) graders this task end-to-end as five checks: (1) each of the eight pages declares `diataxis: how-to` and the expected `permalink: /how-to/`; (2) every retired permalink is absorbed by the new how-to page's `redirect_from:` (so external links to `/hooks`, `/libraries`, `/artifacts`, `/setup`, `/testing` keep resolving via `jekyll-redirect-from`); (3) every how-to permalink is reachable from the nav exactly once; (4) `docs/agent-auth.md` contains the literal credential names checked by `src/cli/run/preflight-credentials.ts` (`ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN`, `CURSOR_API_KEY`, `OPENAI_API_KEY`), the stable error code (`E_AGENT_CREDENTIALS`), and the `claude setup-token` instruction — so a user who hits the error in their terminal can grep for the literal and land on this page; (5) every how-to page stays recipe-shaped (at least one numbered `## N.` / `### N.` step heading plus a `## Verification` or `## Verify` section), guarding against future drift back into open-ended prose without a verifiable conclusion. With this task in, two of the four Diátaxis quadrants are now live (Explanation from task 3 plus How-to here); Reference lands next as task 5 (`cli`, `configuration`, `grammar`, `language`, env-vars), and the two Tutorials (`first-workflow`, `first-agent-run`) land as task 6. No runtime, CLI, or language behavior changes.
+- **Docs — Diátaxis Explanation pass: concentrate understanding-oriented pages (docs redesign 3/8):** First content task in the [Diátaxis](https://diataxis.fr/) docs rewrite. Four understanding-oriented pages now land in `docs/` as published Diátaxis explanation pages, each authored greenfield from the TypeScript/Bash source plus `docs/architecture.md` first and only then reconciled against its `docs/_legacy/` copy (per the anti-bias protocol in `.jaiph/skills/documentation-writer/SKILL.md`): `docs/why-jaiph.md` (permalink `/why-jaiph`) — short design/philosophy page that explains the orchestration-as-language framing, the four primitives (`rule` / `script` / `prompt` / `workflow`), the three commitments (strict structure around AI steps, sandbox by default, no vendor lock-in), and the deliberate non-goals; `docs/sandboxing.md` (permalink `/sandboxing`) — the sandboxing *model*: the two layers (compile-time `rule` validation vs runtime Docker isolation), the three workspace-presentation modes (overlay, copy, inplace), and an explicit threat model split into "what Docker protects against" (filesystem reach, process isolation, mount safety, env exposure, shell injection safety) and "what Docker does **not** protect against" (default-on network egress, forwarded agent credentials, hooks running on the host, image supply chain, container-escape limits, inplace's opt-out). The enabling procedure and the full `runtime.docker_*` config-key reference are explicitly **out of scope** for this page — they belong to the How-to (task 4) and Reference (task 5); `docs/inbox.md` (permalink `/inbox`) — the channels *model*: drain-driven delivery (not file-watched), routes-on-the-channel-not-on-workflows, sequential dispatch as the only mode, the routed-vs-unrouted distinction, and the three-parameter trigger contract; `docs/spec-async-handles.md` (permalink `/spec-async-handles`) — the `Handle` *value model*: eager start / lazy resolve, passthrough vs resolving reads (and the `for_lines` exception), the implicit join at the end of every step list, how `recover` / `catch` compose on `run async` statement form, and why there is no `await` keyword. Each new page declares `diataxis: explanation` and the matching `permalink:` in its front-matter, picks up `redirect_from: /.md` where the legacy slug differs, and links inward to `architecture.md` / `language.md` / `grammar.md` for implementation and surface-syntax details (with no manual "More Documentation" block — nav is provided by `docs/_layouts/docs.html`). `docs/architecture.md` stays live and is tightened only at the link surface: `/sandboxing`, `/inbox`, and `/spec-async-handles` are removed from its `redirect_from:` list because those slugs are now owned by the new live pages, and three transitional `…#docker-container-isolation` deep links are simplified to bare `sandboxing.md` links since the new explanation page does not republish the old anchor. The Jekyll nav in `docs/_layouts/docs.html` adds four entries (Why Jaiph, Sandboxing, Inbox & Dispatch, Async Handles) alongside the existing Architecture / Agent Skill links so all four published explanation pages plus the contributor skill are reachable; the final IA regrouping happens in task 7. A new integration test `integration/docs-explanation-task3.test.ts` (Node `--test`, auto-picked up by `npm test`) graders this task end-to-end as five checks: (1) each of the four pages declares `diataxis: explanation` and the expected permalink; (2) the nav links to each new permalink exactly once; (3) `sandboxing.md` contains real threat-model content — both the `What Docker protects against` and `What Docker does **not** protect against` headings, plus concrete claims about dropped capabilities, the env-var allowlist, hooks-on-host, and default-on network egress; (4) `sandboxing.md` has no `Enabling Docker` heading and no numbered enabling procedure under any `enabl*` heading (those move to a how-to in task 4); (5) `sandboxing.md` has no `| Key | …` reference table, no row keyed by ``runtime.docker_*``, and no `Configuration keys` / `Failure modes` heading (reference content lives in task 5). The legacy-quarantine harness `integration/docs-legacy-quarantine.test.ts` is updated in the same step to model the new "recreated-with-legacy-reference" state: `inbox.md`, `sandboxing.md`, and `spec-async-handles.md` move from the `QUARANTINED_PAGES` list to a new `RECREATED_WITH_LEGACY` list (a live page exists at `docs/.md` and the legacy copy stays at `docs/_legacy/.md` for reconciliation), and the publish-side leak check switches its canonical quarantined probe from `/sandboxing` to `/hooks` (which is still quarantined and is now asserted to exist as a redirect stub but never as page content). The structure harness `integration/docs-structure.test.ts` loosens the "legacy pages exempt" assertion to permit a live counterpart at `docs/.md` (the recreated case), since the live + legacy invariant is pinned by the legacy-quarantine harness above. README.md is updated to surface the four new explanation pages in the top-line link bar and in the "Docs note" callout, and to drop `inbox`, `sandboxing`, `spec-async-handles` from the legacy index (the still-quarantined slugs — Getting Started, Setup, Libraries, Language, Grammar, CLI, Configuration, Testing, Hooks, Runtime artifacts, Contributing — stay listed). The Safety-and-inspectability bullet repoints its `Sandboxing` link from `docs/_legacy/sandboxing.md` to `docs/sandboxing.md`. `docs/index.html` is unchanged: its in-site links already use bare relative slugs (`href="inbox"`, `href="sandboxing"`) which now resolve to the live explanation pages instead of bouncing through `architecture.md`'s `redirect_from`. No runtime, CLI, or language behavior changes.
+- **Docs — Diátaxis foundation: front-matter convention + machine-checkable docs-lint harness (docs redesign 2/8):** Lays the backbone the remaining tasks of the [Diátaxis](https://diataxis.fr/) docs redesign (3–8) are graded against. A new front-matter key **`diataxis:`** is defined for every published `docs/*.md` page, valued `tutorial | how-to | reference | explanation | contributor` (the five quadrants of the vendored `.jaiph/skills/documentation-writer/SKILL.md` plus a `contributor` bucket for in-repo contributor docs that fall outside the four user-facing quadrants). The two currently live pages declare their type: `docs/architecture.md` → `diataxis: explanation`, `docs/jaiph-skill.md` → `diataxis: contributor`. A new integration test `integration/docs-structure.test.ts` (Node `--test`, picked up automatically by `npm test` through the existing `find dist/integration -name '*.test.js'` glob in `package.json` — no script change required) wires this into CI as six docs-lint checks: (1) every published `docs/*.md` must carry a valid `diataxis:` value from the allowed set; (2) every `` entry in `docs/_layouts/docs.html` must correspond to a published page whose `permalink:` matches; (3) every published page must be linked from nav **exactly once** (catches both dangling nav entries and missing ones); (4) every internal Markdown link, `permalink:`, and `redirect_from:` value must resolve to a known route (a published `permalink:` or a `redirect_from:` alias), and any `#anchor` on a link to a live page must match a heading slug in that page (kramdown / GFM heading slug rules, with kramdown `{:#explicit-id}` / `{: #id}` IALs honored); and (5) every historical nav permalink mined from `git log -p --all -- docs/_layouts/docs.html` must still resolve via a current page or a `redirect_from:` alias — this is the **redirect-coverage** check that prevents removing a page or renaming a permalink without a stub. A sixth test pins the legacy-quarantine boundary by asserting that `docs/_legacy/*.md` are **exempt** from all of the above (they are read from a sibling directory, never enumerated as published pages) and that none of the quarantined filenames also appear at `docs/.md`. Pages excluded from publishing by `docs/_config.yml` (the `_legacy` entry) are likewise outside the lint scope, which keeps the historical pre-redesign prose in git without making it count as "published". Because tasks 3–8 will replace the historical permalinks (`/getting-started`, `/setup`, `/libraries`, `/artifacts`, `/language`, `/grammar`, `/cli`, `/configuration`, `/testing`, `/spec-async-handles`, `/spec-async-isolated`, `/target-design`, `/inbox`, `/hooks`, `/sandboxing`, `/reporting`, `/contributing`) gradually, and they would otherwise fail check 5 the moment the page is quarantined, `architecture.md` and `jaiph-skill.md` now declare every one of them under `redirect_from:` so each historical URL routes to a live destination until the new Diátaxis page owns it (architecture.md absorbs the 16 explanation/reference/how-to slugs as its transitional landing target; jaiph-skill.md picks up `/contributing` since contributor docs co-locate there for now). `jekyll-redirect-from` (already declared in `docs/_config.yml`) emits the per-alias meta-refresh stubs at build time so external links keep working; the docs-lint harness only checks the alias→page mapping, not the generated HTML. The Jekyll nav itself (`docs/_layouts/docs.html`) flips the "Agent Skill" entry from the raw GitHub URL to the in-site permalink `{{ '/jaiph-skill' | relative_url }}` now that the page is a first-class published Diátaxis quadrant; the raw GitHub URL stays in `README.md` and `docs/index.html` because those are the entry points agents themselves consume and they need the unrendered Markdown. The legacy-quarantine integration test `integration/docs-legacy-quarantine.test.ts` is loosened in the same step to reflect the new redirect-stub policy: previously it asserted that quarantined slugs (e.g., `/sandboxing`) **must not** appear in `_site/`; it now permits the small meta-refresh stub emitted by `jekyll-redirect-from` and only fails if the original quarantined prose (`Docker container isolation`, `sandbox mount`) leaks into the stub. The embedded `JAIPH_SKILL_MD_BASE64` in `src/runtime/embedded-assets.ts` (the in-binary fallback for `jaiph init`) is regenerated so the embedded copy includes the new `diataxis: contributor` / `redirect_from: /contributing` front-matter; no behavior change, just a re-encoding to match `docs/jaiph-skill.md` byte-for-byte. No runtime, CLI, or language changes; this is the verification harness the next six redesign tasks will be measured against.
+- **Docs — Quarantine pre-redesign pages under `docs/_legacy/` ahead of the Diátaxis rewrite:** The existing 14 flat-mix pages (Tutorial/How-to/Reference/Explanation blended per page) have been **moved verbatim** from `docs/*.md` into `docs/_legacy/*.md` with `git mv` so the redesign (tasks 3–8 of the docs-redesign queue) can be authored greenfield against the source code without paraphrasing or in-place edits of the old prose. `docs/architecture.md` and `docs/jaiph-skill.md` stay at their original paths — `architecture.md` is a declared source of truth read by `.jaiph/docs_parity.jh` and `jaiph-skill.md` is fetched raw by agents via the canonical GitHub URL. `docs/_config.yml` adds `_legacy` to the Jekyll `exclude:` list so the quarantined pages stay in git but are **not published**; `bundle exec jekyll build` no longer emits `_site/_legacy/**` and the old permalinks (`/cli`, `/sandboxing`, `/getting-started`, etc.) did not yet resolve on `jaiph.org` until greenfield replacements added `redirect_from` stubs (tasks 3–7). The docs sidebar in `docs/_layouts/docs.html` is trimmed to only the still-live pages (landing, Architecture, and the raw Agent Skill link) so the built site has no dangling nav entries. `README.md` doc links are repointed at `docs/_legacy/.md` so the GitHub README still resolves until tasks 3–8 rebuild each quadrant; the env-forwarding parity check in `src/runtime/docker.test.ts` now reads `docs/_legacy/sandboxing.md` and asserts the cross-link from `docs/_legacy/configuration.md` and `docs/_legacy/cli.md`. A new integration test (`integration/docs-legacy-quarantine.test.ts`) asserts the move (live pages still present, every quarantined page moved), that no nav entry points at a quarantined permalink, and — when Bundler is available — that `bundle exec jekyll build` exits 0 with no `_site/_legacy/` directory and no `/sandboxing` page generated, while `/architecture` is still built. No runtime, CLI, or language behavior changes; this is purely a docs-site reorganization to prevent agent anchoring during the redesign.
+
+# 0.10.0
+
+## Summary
+
+- Docker sandbox **`inplace` mode** (edits land live on the host while the machine stays isolated) plus `jaiph run` CLI flags `--workspace`, `--inplace`, `--unsafe`, `-y`/`--yes`.
+- Agent reliability: **prompt retry** with escalating backoff, a **fail-fast credential pre-flight** keyed to the backend, and the default sandbox timeout raised to **4 hours**.
+- **`jaiph install`** gains registry name resolution and commit-SHA pinning; new per-platform **standalone release binaries** + installer and a release-prep workflow that single-sources the CLI version.
+- Parser/compiler **simplification refactors**, language features (`else` branches, dot-notation `if`/`match` subjects, `catch`/`recover` on inline-script `run`), and assorted fixes.
+
+## All changes
+
+- **Feat — Fail fast on missing agent credentials with a host-side pre-flight keyed to the entry file's backend(s):** Before this release, only the **codex** backend checked for a credential, and the check happened **at runtime inside the prompt** (`runCodexBackend` in `src/runtime/kernel/prompt.ts`) — the workflow runner spun up, executed steps, and only blew up at the first `prompt`. The `claude` and `cursor` backends had no credential check at all; missing keys surfaced as opaque CLI failures deep inside the runtime, and under Docker the failure mode was worse because interactive CLI logins (`cursor-agent login`, `claude` interactive auth) do not cross the container boundary (fresh `$HOME`, no Keychain). This release adds a **host-side credential pre-flight** in `runWorkflow` (`src/cli/commands/run.ts`) that runs after the module graph + effective config + Docker mode are resolved but **before** the workflow runner or the container is launched (the `--raw` embedded path is intentionally skipped). The pre-flight scans the **entry `.jh` file's** module-level `config` and each of its workflow-level `config` blocks plus the effective default (`JAIPH_AGENT_BACKEND` env, or `cursor` when unset) to collect the distinct backend(s) the run could reach, then evaluates each against the env that will actually arrive at the agent — `runtimeEnv` on host, or the **forwarded allowlisted** env when Docker is on (via `isEnvAllowed`), so a credential present on the host but stripped by the allowlist counts as missing. The per-backend rule is asymmetric to match the login-friendly host story and the strict container story: **codex** requires `OPENAI_API_KEY` and is a hard error on both host and Docker (no CLI-login fallback exists); **claude** requires `ANTHROPIC_API_KEY` **or** `CLAUDE_CODE_OAUTH_TOKEN` (the latter from `claude setup-token`) — Docker is a hard error, host is a warning (a stored Claude CLI login may still work); **cursor** requires `CURSOR_API_KEY` with the same host/Docker split as claude. In **unsafe mode** (`JAIPH_UNSAFE` / `--unsafe`) the pre-flight is skipped entirely — that is the explicit "run on the host, trust my environment" escape hatch, so a stored CLI login is trusted and even the codex hard error defers to the runtime backend guard (covered by `src/cli/run/preflight-credentials.test.ts`). Every error and warning names (a) the backend, (b) the model when `agent.default_model` is set, (c) the **entry `.jh` file path** and the config scope that selected the backend (`module config`, `workflow `, `JAIPH_AGENT_BACKEND env`, or `default`), and (d) the concrete remedy — e.g. ``Run `claude setup-token` and export CLAUDE_CODE_OAUTH_TOKEN, or set ANTHROPIC_API_KEY.`` For Docker runs the message also notes the var must be set on the **host** so it gets forwarded into the container. Hard failures use a stable error code, `E_AGENT_CREDENTIALS`, and exit non-zero with **no runner or container launched**; warnings stream to stderr and the run proceeds. The pre-flight is **silent** when credentials are present (including when only one of claude's two accepted vars is set) and is **skipped entirely** when the entry file neither declares an explicit backend nor uses any `prompt` step — so workflows that do no agent work get no false-positive warning for an unused default backend. The pre-flight runs for **all Docker modes including `inplace`**, and the late codex-only check inside `runCodexBackend` is kept as defense in depth (the pre-flight catches it first with the better message). Deeper per-import-module backend overrides resolved at runtime are out of scope — entry-file scan is the documented contract. New: `src/cli/run/preflight-credentials.ts` and `src/cli/run/preflight-credentials.test.ts`; new E2E `e2e/tests/139_agent_credentials_preflight.sh`; CLI wiring in `src/cli/commands/run.ts` ahead of the Docker check and runner spawn. `e2e/lib/common.sh` now seeds dummy `CURSOR_API_KEY` / `ANTHROPIC_API_KEY` values for the shared E2E context so existing tests using mock agent binaries stay silent under the new warn-only host path (tests that exercise the missing-key contract explicitly unset them via `env -u`). Docs updated in `docs/configuration.md` (new **Credential pre-flight** subsection under **Backend selection** with the per-backend / per-mode rule matrix, the message-content contract, and the `E_AGENT_CREDENTIALS` code; the **Backend selection** intro now names the accepted credentials and CLI-login alternatives per backend; **Codex setup** cross-references the pre-flight), `docs/sandboxing.md` (a new **Credential pre-flight** callout under **Enabling Docker** explains why claude / cursor fail hard under Docker — the CLI login does not cross the container boundary — and that the check uses the post-forwarding env via `isEnvAllowed`), and `docs/cli.md` (the `jaiph run` synopsis gains a **Credential pre-flight** paragraph; the env-var list updates `JAIPH_AGENT_BACKEND` / `OPENAI_API_KEY` with the pre-flight semantics and adds `ANTHROPIC_API_KEY` / `CLAUDE_CODE_OAUTH_TOKEN` / `CURSOR_API_KEY` entries naming the host-warn vs Docker-hard-fail split).
+- **Feat — Retry `prompt` steps on transient transport failure with an escalating backoff schedule:** A backend invocation in `runPromptStep` (`src/runtime/kernel/node-workflow-runtime.ts`) used to surface `result.status !== 0` from `executePrompt` as an immediate step failure, so any rate limit, API outage, network blip, or agent-CLI crash aborted the step (and the workflow, absent `recover` / `catch`). This release adds an **automatic retry loop around the prompt execution** with a fixed escalating backoff: after the initial attempt fails, the runtime waits `15s → 1m → 10m → 30m → 2h` between subsequent attempts and then gives up — five retry delays, **six total attempts**, ~2h41m wall-clock for a full failure run. Only the **transport** path is retried (`result.status !== 0` — spawn failure, non-zero backend exit, codex HTTP error); deterministic post-processing failures in the same function — invalid JSON (`prompt returned invalid JSON`) and schema validation (`prompt response failed schema validation`) — keep returning `{ ok: false }` immediately, because retrying them would just repeat the same parse with the same captured output. Retry composes **below** `recover` / `catch`: backoff is exhausted first, then the failure reaches the enclosing recover loop. Each attempt is a fresh `executePrompt` call with its own `PROMPT_START` / `PROMPT_END` and surrounding `STEP_START` / `STEP_END` pair (live `__JAIPH_EVENT__` and durable `run_summary.jsonl`). Every failed attempt and the final termination emit a `LOGERR` through `RuntimeEventEmitter.emitLog` (the same facility as the `logerr` keyword — live stderr event plus a durable summary line), carrying the attempt number, the backend, a single-line error summary, and — for retries — the human delay before the next attempt (`retrying in 15s`, `retrying in 2h`); the termination line notes that retries are exhausted before the step fails. Logging happens regardless of whether the step is wrapped in `recover` / `catch`. The schedule, sleep, and abort surfaces are all **testable and cancellable**: the new module `src/runtime/kernel/prompt-retry.ts` exports `DEFAULT_PROMPT_RETRY_DELAYS_MS`, `resolvePromptRetryDelays(env)`, `defaultPromptSleep(ms, signal)` (a `setTimeout` that races against an `AbortSignal` and rejects with a `PromptRetryAbortError` on abort), and `formatRetryDelay` / `summarizeError` helpers; `NodeWorkflowRuntime` accepts `sleep` and `promptRetryDelays` constructor options so tests can pass a recording stub and a short delay array to assert the exact sequence with zero real wall-clock wait. The runtime owns an internal `AbortController` and exposes `abort()` / `isAborted()`; aborting clears the pending timer and halts the loop without further `executePrompt` calls. Both knobs are environment-only — there is no in-file key. **`JAIPH_PROMPT_RETRY=0`** disables retry entirely (one attempt, fail on transport failure exactly as before; the sleep is never called). **`JAIPH_PROMPT_RETRY_DELAYS`** is a comma-separated list of non-negative integer milliseconds (e.g. `"500,1000,5000"`) that replaces the default schedule; invalid entries (non-numeric, negative, empty list, trailing junk) abort the prompt with a clear error rather than silently falling back to the default. The validated schedule (or its parse error) is resolved once per run and cached on the runtime so a misconfiguration surfaces the same way for every prompt and is not re-thrown per attempt. `jaiph test` (`src/runtime/kernel/node-test-runner.ts`) defaults `JAIPH_PROMPT_RETRY=0` for every test block so a failing mock prompt (e.g. no matching arm, or no queued response) fails fast instead of waiting through the ~2h41m production schedule; tests that intentionally exercise the retry loop can opt in by setting the variable explicitly. Two test-only orthogonal touch-ups for the same reason: `integration/sample-build/run-prompt-agent.test.ts` (the missing-`claude` pre-flight check) and `src/runtime/kernel/node-workflow-runtime.artifacts.test.ts` (failed-prompt stderr capture) both set `JAIPH_PROMPT_RETRY=0` for their single-attempt failure assertions — neither test belongs to the retry feature, but each would otherwise wait through the default schedule on a non-zero exit. Under Docker, the full default backoff (~2h41m) fits inside the default container timeout (`runtime.docker_timeout_seconds = 14400` = 4h) with thin headroom; the schedule constant in `prompt-retry.ts` carries an inline note pointing at `JAIPH_DOCKER_TIMEOUT` for workflows that need the full retry budget plus their own workload time. Tests in `src/runtime/kernel/prompt-retry.test.ts` cover the env parser (default schedule, disable, custom comma list, every invalid-input variant) and the abort-aware sleep; tests in `src/runtime/kernel/node-workflow-runtime.prompt-retry.test.ts` pin the loop end-to-end with mock backends — exact sleep sequence equals the schedule, six-attempts-then-fail terminates with the final error, mid-loop success short-circuits, recover/catch composes below retry, invalid-JSON / schema failures are not retried (one call, sleep never invoked), `JAIPH_PROMPT_RETRY=0` produces a single attempt, custom delays are honored, invalid delays surface as a step error with a `LOGERR`, and `runtime.abort()` during a pending backoff exits without a further `executePrompt` call. Docs updated in `docs/configuration.md` (new **Prompt retry on transport failure** subsection under **Backend selection** documenting the default schedule, what is and is not retried, per-attempt eventing, logging, cancellation, both env knobs, and the `jaiph test` default; the env-only table at the end of the page lists `JAIPH_PROMPT_RETRY` and `JAIPH_PROMPT_RETRY_DELAYS`), `docs/cli.md` (the **Agent and prompt configuration** env-var list gains both names with the default schedule and the `jaiph test` carve-out), `docs/architecture.md` (the **Node Workflow Runtime** bullet documents the transport-failure retry, the injectable sleep and abort seams, and the compose-below-`recover` ordering), `docs/testing.md` (the harness env-var table lists the default `JAIPH_PROMPT_RETRY=0` and the parent-env opt-out for retry-exercising tests), and `docs/sandboxing.md` (the **Timeout** paragraph gains a note that a full default backoff fits inside the default container timeout with thin headroom and points at the configuration knobs for raising it or shortening the schedule).
+- **Feat — `jaiph run` flags `--workspace`, `--inplace`, `--unsafe`, `-y` / `--yes` as CLI front-ends for the sandbox env switches:** Sandbox/runtime selection has historically been configured purely by env vars — `JAIPH_UNSAFE=true` turns Docker off entirely (read in `resolveDockerConfig` in `src/runtime/docker.ts`), `JAIPH_INPLACE=1`/`true` opts into the in-place sandbox mode (read in `selectSandboxMode`), and `JAIPH_INPLACE_YES=1` auto-confirms the in-place destructive-edit prompt — with no CLI surface. Workspace root was likewise auto-detected only: `runWorkflow` in `src/cli/commands/run.ts` called `detectWorkspaceRoot(dirname(inputAbs))` with no override path (while the sibling `jaiph compile` already exposed a `--workspace ` flag). This task gives `jaiph run` a first-class CLI surface for those four switches so users do not have to set env vars for one-off runs, while keeping env vars working unchanged. `parseArgs` in `src/cli/shared/usage.ts` (and its exported `ParsedArgs` type) now also recognizes `--workspace ` (requires a value; missing value throws `--workspace requires a directory path`, matching the existing `--target` style), `--inplace`, `--unsafe`, and `--yes` / `-y` — all four stop at `--` exactly like the existing flags, so `run --inplace -- --inplace` parses as `inplace:true` plus the literal `--inplace` in `positional`. `runWorkflow` resolves the workspace via `workspace ? resolve(workspace) : detectWorkspaceRoot(dirname(inputAbs))` (explicit path wins; the path must exist and be a directory or the run fails with `--workspace path does not exist: ` / `--workspace path is not a directory: ` before any container starts) and threads the three boolean flags through a new helper `applySandboxFlags(env, flags)` in `src/cli/run/env.ts` that **mutates the local runtime env map only** — never `process.env`, which would leak flag choices into every child process globally — setting `JAIPH_INPLACE=1` / `JAIPH_UNSAFE=true` / `JAIPH_INPLACE_YES=1` on for the duration of one run. The mutation happens right after `resolveRuntimeEnv` (which already returns a fresh spread of `process.env`) and **before** `resolveDockerConfig` / `selectSandboxMode` consume the env — i.e. the env layer remains the **single source of truth** for sandbox mode and there is no parameter threading through `spawnDockerProcess` / `buildDockerArgs` or duplication of the mode-selection logic. Flag and env agree on enablement (both paths just turn the env var ON), so setting only the env var still works (regression-tested) and setting both flag and env is not an error. The two contradictory enabling paths — `--inplace` (sandbox on, workspace mounted rw) and `--unsafe` (sandbox off entirely) — fail fast inside `applySandboxFlags` with a new `E_FLAG_CONFLICT` error (the same check catches the mixed `--inplace + JAIPH_UNSAFE=true` and `--unsafe + JAIPH_INPLACE=1` cases) before any container is launched. The new flags are an **intentional ergonomic asymmetry**: they only affect `jaiph run`, while the corresponding env vars still influence other entry points (e.g. `jaiph test`) — documented in both `printUsage` and the per-command `RUN_USAGE`, plus an explicit `Note:` line in both. A new `--workspace` *env* equivalent is **out of scope**: the name `JAIPH_WORKSPACE` is already taken as the in-container remap **output** in `remapDockerEnv` and is not repurposed as an input. The raw `--raw` code path (`runWorkflowRaw`) accepts the same flags via a `sandboxFlags` parameter so the env normalization runs there too. `printUsage` in `src/cli/shared/usage.ts` and `RUN_USAGE` in `src/cli/commands/run.ts` both list the four new options under `jaiph run` with at least one example (`jaiph run --inplace --workspace ./app ./flows/fix.jh`). New tests in `src/cli/shared/usage.test.ts` pin the parser contract (each new field returned correctly, `--workspace` value-required error, post-`--` routing unchanged, regression for `--target` / `--raw` / `--`), `src/cli/run/sandbox-flags.test.ts` pins the env-normalization contract (each flag sets the matching env var; flag-only is sufficient for `selectSandboxMode === "inplace"` and `resolveDockerConfig().enabled === false`; flag and env agree; `E_FLAG_CONFLICT` thrown for the mixed cases), and `src/cli/commands/run.test.ts` pins the end-to-end behavior (missing `--workspace` value error, non-existent / non-directory `--workspace` errors, `--inplace --unsafe` fails before launching any container, `--yes` skips the in-place prompt). Docs updated in `docs/cli.md` (the `jaiph run` synopsis and **Flags** section list the four new options with the env-var equivalence noted on each, an explicit asymmetry note about `jaiph test`, and a new example; the env-var entries for `JAIPH_UNSAFE`, `JAIPH_INPLACE`, and `JAIPH_INPLACE_YES` now cross-reference the matching CLI front-end) and `docs/sandboxing.md` (the **Enabling Docker** section gains a top-of-section pointer at the new CLI flags; the **Inplace mode** *Enabling* and *Skipping the prompt* bullets name `--inplace` / `-y` / `--yes` as alternatives to the env vars; the **Failure modes** table gains an `E_FLAG_CONFLICT` row for the contradictory-flag case).
+- **Fix — `jaiph run` accepts the `--flag=value` form, not just `--flag value`:** `parseArgs` in `src/cli/shared/usage.ts` matched flags by exact token, so `--workspace=/path` (and `--target=/path`) fell through to `positional` and was misread as the workflow file / an argument — the symptom being a path that looked like it "lost" everything after the `=`. Long options are now split on the **first** `=` (so values may themselves contain `=`), accepting both `--flag value` and `--flag=value` for the value-taking flags (`--target`, `--workspace`); boolean flags (`--raw`, `--inplace`, `--unsafe`, `--yes`) reject an `=value` form with a clear ` does not take a value` error, and `--workspace=` with an empty value still errors. `--` passthrough is unchanged (a `--flag=value` token after `--` stays a literal workflow arg). New regression tests in `src/cli/shared/usage.test.ts`.
+- **Feat — Docker sandbox `inplace` mode: live host edits with the machine still isolated:** Docker sandboxing previously had exactly two modes (`SandboxMode = "overlay" | "copy"` in `src/runtime/docker.ts`), both of which **protect the host workspace from the run** — overlay mounts the workspace read-only and uses `fuse-overlayfs` so edits die with the container; copy clones the workspace and mounts the disposable clone read-write. Neither persists workflow edits to the real checkout. This release adds a **third mode**, `inplace`, for the iterate-on-real-files dev loop: the host workspace is bind-mounted `:rw` directly at `${CONTAINER_WORKSPACE}` so the run's edits land **live on the host**, while the container boundary still prevents access to the rest of the machine (only the workspace + runs dir mounts, `--cap-drop ALL`, `--security-opt no-new-privileges`, and the `JAIPH_*` / `ANTHROPIC_*` / `CURSOR_*` / `CLAUDE_*` env allowlist remain in place). The posture is **"trusted workspace, untrusted machine"** — a different axis from `JAIPH_UNSAFE` (which turns the sandbox off entirely): `JAIPH_INPLACE` keeps the sandbox **on** and only removes workspace isolation, so `resolveDockerConfig`'s `enabled` logic is unchanged. Mode selection is **explicit opt-in only** — `selectSandboxMode(env)` returns `"inplace"` iff `JAIPH_INPLACE` is `1`/`true`, and that opt-in **takes precedence over** both `JAIPH_DOCKER_NO_OVERLAY` and the `/dev/fuse` heuristic; without the env var, overlay/copy selection is byte-for-byte unchanged. `buildDockerArgs` in `inplace` mode bind-mounts `resolve(opts.workspaceRoot)` at `${CONTAINER_WORKSPACE}:rw` via `validateMountHostPath`, omits `overlay-run.sh`, `--device /dev/fuse`, and the overlay-only capability set (`SYS_ADMIN`, `SETUID`, `SETGID`, `CHOWN`, `DAC_READ_SEARCH`, `apparmor=unconfined`), and on Linux runs as `--user ${hostUid}:${hostGid}` (reusing `_uidDetect.getHostUidGid()` and the existing `E_DOCKER_UID` failure) so files created by the run are owned by the user, not root. `spawnDockerProcess` in `inplace` mode does **not** call `allocateSandboxWorkspaceDir`/`cloneWorkspaceForSandbox` and does not require `sandboxWorkspaceDir`; the `.jaiph/runs` mount (`${CONTAINER_RUN_DIR}:rw`) is still created independently so the nested-under-workspace case keeps working. A new test seam `_dockerSpawn.run(args, opts)` (a thin wrapper around `spawn("docker", …)`) lets spawn-level tests assert that the clone path is never taken in `inplace` mode. **Destructive-edit safeguard.** Because a crashed/killed `inplace` run leaves the real workspace half-mutated with no rollback, the CLI must **warn + interactively confirm** before launching the container. The new helper module `src/runtime/docker-inplace.ts` exposes `detectGitTreeState(workspaceRoot)` (returns `"clean" | "dirty" | "no-repo"`, never throws — `git` missing on PATH, the directory not being a git repo, and permission errors all collapse to `"no-repo"`), `formatInplaceWarning(workspaceRoot, state)` (three friendly, developer-oriented warning variants — *clean git tree* → reversible via `git restore .` / `git reset --hard`; *dirty git tree* → edits mix in with uncommitted work and cannot be cleanly undone, suggest committing or stashing first; *no git repo* → no safety net, suggest `git init` and a baseline commit — each variant names the actual workspace directory and ends with the "Everything outside this directory stays sandboxed" reassurance), a minimal readline-based yes/no prompt (`defaultPromptYesNo`, defaults to "no" on empty input / EOF / any non-`y` answer), and the orchestrator `confirmInplaceRun(workspaceRoot, env, isTTY)`. `runWorkflow` in `src/cli/commands/run.ts` calls `confirmInplaceRun` between `prepareImage` and the banner when `selectSandboxMode === "inplace"`; on a "no" answer the run aborts cleanly with a non-zero exit and `jaiph in-place mode: aborted by user.` on stderr — no container is launched. The prompt is **skippable** via `JAIPH_INPLACE_YES=1` / `"true"` (CI / automation path); when set, `_inplacePrompt.ask` is never called. **Non-TTY behavior.** When stdin is not a TTY (`isTTY=false`, already threaded through `run.ts`), the run requires `JAIPH_INPLACE_YES`; if it is absent, `confirmInplaceRun` throws `E_DOCKER_INPLACE_NO_CONFIRM jaiph in-place mode requires interactive confirmation, but stdin is not a TTY. Set JAIPH_INPLACE_YES=1 to auto-confirm.` and the run exits before the container is launched — inplace mode never silently proceeds unconfirmed. **Env-leak prevention.** `JAIPH_INPLACE` and `JAIPH_INPLACE_YES` are `JAIPH_`-prefixed and would otherwise pass `isEnvAllowed` (the only built-in exclusion prefix is `JAIPH_DOCKER_`). A new explicit name set `ENV_ALLOW_EXCLUDE_NAMES = Set(["JAIPH_INPLACE", "JAIPH_INPLACE_YES"])` is checked in `isEnvAllowed` so neither variable is forwarded into the container — this prevents a nested `jaiph run` inside the workflow from re-triggering inplace mode and keeps host control flags out of script environments. **Run banner surfacing.** `formatJaiphRunningBannerLines` in `src/cli/run/display.ts` now distinguishes all three modes: overlay → `Docker sandbox, fusefs`, copy → `Docker sandbox, tmp workspace`, **inplace → `Docker sandbox, in-place (live host edits)`**, and `no sandbox` when Docker is disabled. **macOS performance note (informational, no code change):** the `:rw` bind-mount goes through Docker Desktop's virtiofs file-sharing layer, so write throughput is slower than the APFS `cp -cR` clone copy mode uses; acceptable for the dev-loop use case. **Known sharp edges (called out in code comments, not implemented in this change):** concurrent runs on the same workspace in `inplace` mode are not locked — two parallel runs will write into the same directory simultaneously. Tests live in `src/runtime/docker.test.ts` (regression coverage that `selectSandboxMode` is unchanged without the env var, `inplace` precedence over `JAIPH_DOCKER_NO_OVERLAY` and `/dev/fuse`, `buildDockerArgs` produces `:rw` workspace mount with no `:ro` mount / no `--device /dev/fuse` / no `overlay-run.sh` / none of the overlay-only `--cap-add` flags while still emitting `--cap-drop ALL`, `--security-opt no-new-privileges`, `${CONTAINER_RUN_DIR}:rw`, and Linux `--user ${uid}:${gid}`; spawn-level proof via `_dockerSpawn.run` spy that `cloneWorkspaceForSandbox`/`allocateSandboxWorkspaceDir` are never invoked; a filesystem-level test that a write inside `inplace` mode is visible at the host path while overlay/copy leave the host path unchanged; the env-leak assertion that `-e JAIPH_INPLACE` / `-e JAIPH_INPLACE_YES` never appear in `buildDockerArgs` output and `isEnvAllowed` returns `false` for both names) and a new file `src/runtime/docker-inplace.test.ts` (covers all three warning variants — clean / dirty / no-repo — assert each names the directory and states the correct recovery posture; TTY + "no" answer aborts with no container launched; TTY + "yes" answer proceeds; `JAIPH_INPLACE_YES=1` proceeds without calling the prompt; non-TTY without the flag throws `E_DOCKER_INPLACE_NO_CONFIRM`; banner test in `src/cli/run/display.test.ts` asserts the `in-place (live host edits)` paren-string). Docs in `docs/sandboxing.md` updated with a dedicated **Inplace mode (trusted workspace, untrusted machine)** section (when to use it, what the warning does on clean / dirty / no-git states, automation/CI auto-confirm via `JAIPH_INPLACE_YES`, non-TTY abort with `E_DOCKER_INPLACE_NO_CONFIRM`, env-leak protection, banner distinction, macOS virtiofs caveat, and the concurrent-runs / no-rollback sharp edges); the threat-model **Filesystem access** and **Process isolation** bullets now name all three modes; **What Docker does NOT protect against** explicitly calls out the inplace opt-out; the sandbox-primitive selection in **Runtime behavior** describes the three-rule precedence (`JAIPH_INPLACE` → `inplace`; `JAIPH_DOCKER_NO_OVERLAY` or no `/dev/fuse` → `copy`; otherwise `overlay`); the **Workspace immutability contract** names `inplace` as the explicit opt-out; the **Failure modes** table gains `E_DOCKER_INPLACE_NO_CONFIRM`; **Environment variable forwarding** documents the new explicit-name exclusions; and a third **Container layout** block shows the inplace mount layout. `docs/cli.md` gains `JAIPH_INPLACE` and `JAIPH_INPLACE_YES` entries under the **Docker sandbox** env-var section (with the env-leak and non-TTY rules), and the banner-format reference now lists all four parenthetical strings. `docs/architecture.md`'s Docker-runtime-helper **Workspace immutability** bullet now names `inplace` as the explicit opt-out.
+- **Change — Default Docker container timeout raised to 4 hours:** `DEFAULTS.timeoutSeconds` in `src/runtime/docker.ts` goes from `3600` (one hour) to `14400` (four hours), so long agent-backed sandboxed runs — including a full prompt-retry backoff (~2h41m) — no longer hit the host-side kill timer by default. Still overridable via `runtime.docker_timeout_seconds` (in-file) or `JAIPH_DOCKER_TIMEOUT` (env); `0` disables it. Docs in `docs/configuration.md` and `docs/sandboxing.md` updated to "four hours".
+- **Tooling — `.jaiph/prepare_release.jh` workflow single-sources the CLI version through `package.json` and drives a clean release-prep with displayed-version check + registry build:** The CLI version was declared twice — once as `package.json` `"version"` and once as a **hardcoded string** in `src/cli/index.ts` (`process.stdout.write("jaiph 0.9.4\n")`) — with nothing keeping them in sync, so a release that bumped `package.json` but missed the source literal would silently ship the old display string. Standalone bun-compiled builds (`bun build --compile`) cannot read `package.json` from disk at runtime, so the version literal must be resolved at **build time** in both build forms. The build step `tools/embed-assets.js` (run by `npm run build` and by `npm install` via a new `prepare` lifecycle script in `package.json` so `src/version.ts` exists on a fresh checkout before `tsc` runs) now codegens **`src/version.ts`** — an auto-generated module exporting `export const VERSION = "";` — alongside the existing `src/runtime/embedded-assets.ts` regeneration; `src/cli/index.ts` imports `VERSION` from `"../version"` and prints `` `jaiph ${VERSION}\n` `` for `--version` / `-v`, so the literal is statically baked into both the `tsc` and `bun build --compile` outputs without a runtime `package.json` read. `src/version.ts` is added to `.gitignore` with a top-of-file comment naming the generator and explaining why it is single-sourced through `package.json`. A new release-prep workflow `.jaiph/prepare_release.jh` (callable as `jaiph run .jaiph/prepare_release.jh -- 0.9.5` or with no arg to auto-bump the next patch) runs the full pre-tag pipeline as a single command: (1) **resolve version** — empty arg → next patch computed from `package.json` (e.g. `0.9.4` → `0.9.5`), non-empty arg must match `^[0-9]+\.[0-9]+\.[0-9]+$` (digits only); anything else fails with the offending value in the error; (2) **preflight** — fails if the git tree is dirty (the workflow's own edits must be the only diff a reviewer sees) or if tag `v` already exists; (3) **apply the version change** — `npm version --no-git-tag-version` (updates `package.json` + `package-lock.json`) and replaces the hardcoded `v` default in `docs/install`'s `REPO_REF` fallback with `v` (the installer's curl-pipe entry point must work standalone and cannot single-source); (4) **displayed-version check** — `npm run build` then `node dist/src/cli.js --version`; output must equal `jaiph ` exactly, on mismatch the script prints both expected and actual strings before failing — an **end-to-end check on the built artifact** that catches a stale build or a broken version import/codegen, not just literal drift; (5) **build the registry** — `npm run registry:build` so the release ships a current `docs/registry`; (6) **summary** — logs the changed files and the remaining manual steps (review the diff, commit, `git tag v`, push branch + tag — tag push triggers the docker-publish CI job and `release.yml`'s standalone-binary upload, then `jaiph use ` for a smoke check). The workflow itself **creates no commits and no tags** — every edit is left staged for the operator to review. A companion test file `.jaiph/prepare_release.test.jh` (discovered by `jaiph test`) covers the load-bearing branches via mocked scripts: next-patch default from a pinned `package.json` (`1.2.3` → `1.2.4`); explicit `X.Y.Z` arg accepted verbatim; non-`X.Y.Z` arg fails with the offending value (`not-a-version`, `1.2.3.4`); the `check_displayed_version` workflow prints both expected and actual strings on mismatch (`expected: jaiph 9.9.9` / `actual: jaiph 0.0.0`); preflight rejects a dirty git tree; and preflight rejects an existing `v` tag. The standalone-binary E2E (`e2e/tests/210_standalone_binary.sh`) now reads the expected `--version` literal from `package.json` (`node -p "require('${ROOT_DIR}/package.json').version"`) instead of pinning `jaiph 0.9.4`, so the test does not need an update on every bump and a divergence between the displayed string and `package.json` fails the E2E. `src/cli/commands/use.ts` — whose error message previously named `0.9.4` as the example version — is updated to use the generic `X.Y.Z` placeholder. The `--version` unit test in `src/cli/index.test.ts` asserts the displayed output equals `jaiph ` + `package.json`'s `version` field, so a future bump of `package.json` without rebuilding `src/version.ts` fails CI immediately. Docs updated in `docs/contributing.md` (the **Version tags, releases, and npm** section gains a top paragraph naming `.jaiph/prepare_release.jh` as the supported release-prep path with the explicit / next-patch invocation forms, the preflight / version-bump / displayed-version-check / registry-build contract, the **no commits or tags** guarantee, and the single-sourcing note that `src/version.ts` is codegen'd by `npm run embed-assets`; the **Typical commands** table rows for `npm run build` and `npm run embed-assets` are updated to call out the `src/version.ts` generation and the new `prepare` lifecycle hook so fresh checkouts type-check before the first `npm run build`) and `docs/architecture.md` (the **Distribution: Node vs Bun standalone** section's npm-build bullet now names `src/version.ts` as an embed-assets output and the standalone-build bullet documents that the displayed `jaiph --version` literal is statically baked into both build forms via the generated module — no runtime `package.json` read).
+- **Tooling — `npm run registry:build` regenerates `docs/registry` from the upstream `jaiphlang/registry` repo, with a shipped-file unit test that fails on schema drift:** `jaiph install ` loads its registry index from `https://jaiph.org/registry` (or `JAIPH_REGISTRY`), but `jaiph.org` is GitHub Pages serving the static files under `docs/` in this repo while the **source of truth** for the index lives in the separate `jaiphlang/registry` repo — maintained manually, with package PRs landing there, not here. There was no path to refresh `docs/registry` from upstream: a maintainer who merged a registry PR had to hand-copy the JSON into this repo, with no validation that it still matched the shape the CLI's `loadRegistryIndex` accepts. A new build script `scripts/build-registry.mjs` does the copy in one step: it fetches the index from `https://raw.githubusercontent.com/jaiphlang/registry/main/registry.json` (overridable via the first positional argv or `JAIPH_REGISTRY_SOURCE` so tests can point at a local fixture), writes the bytes to a sibling tmp file alongside `docs/registry`, runs them through the built `loadRegistryIndex` (imported from `dist/src/cli/commands/registry.js`, so the script requires `npm run build` first), and only on success renames the tmp file onto `docs/registry`. On any failure — unreachable source, invalid JSON, schema mismatch — the script exits non-zero and the previous `docs/registry` is left untouched, and the tmp file is removed so no stale `*.tmp-build-*` siblings accumulate. An npm script **`registry:build`** chains `npm run build` and the script call. This is a **regular command callable anytime**: after merging a registry PR in `jaiphlang/registry`, run `npm run registry:build`, commit the regenerated `docs/registry`, push — Pages redeploys jaiph.org automatically. `docs/registry` is seeded with an initial index (entry `jaiphlang` → `https://github.com/jaiphlang/jaiphlang.git`, description "Jaiph standard library: artifacts, git, queue") so `jaiph.org/registry` serves a valid document before the first build run. Two new tests pin the contract: `src/cli/commands/registry.test.ts` loads the **shipped** `docs/registry` through `loadRegistryIndex` (schema drift between the file and the CLI fails the test) and asserts the file parses as JSON with no Jekyll front matter (a leading `---\n…\n---` block would make Pages treat the file as a Jekyll page and break `loadRegistryIndex` on the live URL); `scripts/build-registry.test.mjs` exercises `buildRegistry` against local fixtures — valid JSON produces a byte-identical output, invalid JSON / schema mismatch / missing source all reject without touching the existing `docs/registry`, and no stale `*.tmp-build-*` siblings are left on failure. `package.json` adds the `registry:build` npm script and threads `scripts/build-registry.test.mjs` into the `test` script so the build-script contract runs alongside the rest of the suite. `docs/libraries.md` gains a **Publishing a library** section covering repo layout (top-level `.jh` modules, `export` visibility, companion scripts like `queue.py`), git-tag-based versioning, installing by name (`jaiph install jaiphlang@v0.1.0`), the publishing flow (PR to `jaiphlang/registry` → maintainer runs `npm run registry:build` → entry live on jaiph.org at the latest with the next release), plus a separate **Lockfile semantics** section calling out commit pinning and an **Overriding the registry source** section documenting the `JAIPH_REGISTRY` override. `docs/cli.md` already shows the bare-name install form and lists `JAIPH_REGISTRY` in the env-var table; no further changes were needed there.
+- **Feature — `jaiph install` pins commit SHAs in `.jaiph/libs.lock`, strips `.git` from installed libs, and rejects clones with no `.jh` modules:** `gitCloneRunner` in `src/cli/commands/install.ts` shallow-cloned each lib into `.jaiph/libs//` and left `.git` in place, so every installed library showed up as a nested git repo inside the consumer workspace (noisy `git status`, IDE confusion about which tree was being viewed, accidental commits inside the lib's repo). The lockfile recorded only `{ name, url, version }`, where `version` is a git ref — and refs can be moved — so "restore from lockfile" was not actually reproducible: a re-tag in the upstream repo silently swapped which commit the consumer pulled. And nothing validated that the cloned tree was a jaiph library at all, so a typoed URL pointing at an unrelated repo happily landed in `.jaiph/libs/` and got a lock entry written for it. The CLI now runs a **post-clone hygiene** step (`postCloneHygiene` in `src/cli/commands/install.ts`) after every successful clone: (1) walk the cloned tree (skipping `.git`) via `hasJhFileRecursive` and assert at least one `*.jh` file exists — if not, the lib dir is removed and the command fails with `lib "" contains no .jh modules — not a jaiph library?` and **no lock entry is written for it**; (2) run `git -C rev-parse HEAD` (via `revParseHead`) and capture the resulting 40-char SHA; (3) delete `/.git` recursively, so the installed lib is plain files on disk and the lockfile is the source of truth for what was cloned. The captured SHA is added to the `LockEntry` shape as a new optional `commit?: string` field and written via `specToLockEntry(spec, commit)`. The clone runner is wrapped in `runInstall` (`wrappedRunner`) so the SHA is collected from every successful clone before lock entries are upserted; warm-skipped libs preserve any prior `commit` via the existing-entry lookup before `upsertLockEntry`. **Restore verification.** Each lock entry now also seeds `InstallSpec.expectedCommit` on the way back into the clone path. When restore re-clones a lib whose lock entry carries `commit`, the same `postCloneHygiene` step compares the freshly cloned HEAD SHA against `spec.expectedCommit` after stripping `.git`; on mismatch the lib dir is removed and the command fails non-zero with a message naming both SHAs and the explicit remedy — ``lib "" commit mismatch: locked , cloned — the ref may have moved; re-run `jaiph install @` explicitly to accept the new commit``. Lock entries without `commit` (older lockfiles) restore without the comparison, so existing checkouts keep working unchanged. New unit tests in `src/cli/commands/install.test.ts` use **local fixture repos** (a small `makeFixtureRepo(parent, name, opts)` helper that `git init`s a temp dir, commits a seed `main.jh` file or a non-`.jh` file depending on `opts.withJh`, and optionally `git tag`s the seed commit) and exercise the contract against real `git clone` invocations — no network. The four new tests pin the acceptance criteria: (1) installing from a local fixture removes `.git` from the installed lib dir and records a 40-char `commit` in the lockfile that equals the fixture's `git rev-parse HEAD`; (2) tag-moved scenario — install at `@v1`, retag `v1` to a new commit upstream, remove the local lib dir, restore — fails non-zero with **both SHAs** present in the error message and removes the lib dir; (3) cloning a repo with no `.jh` files exits non-zero with `lib "" contains no .jh modules — not a jaiph library?`, leaves no `.jaiph/libs//` dir, and writes no lock entry; (4) a hand-written lockfile **without** a `commit` field still restores (no `.git` after restore, exit 0) — backward-compatibility contract. Existing tests that supply a mock `CloneRunner` were updated to drop a placeholder `lib.jh` file alongside the mocked clone so the `.jh` validation passes (the tests already mock the clone step itself; the hygiene step still inspects the on-disk tree the mock produces). Docs updated in `docs/cli.md` (the `## jaiph install` section gains a **Post-clone hygiene** explainer covering the `.jh` check, commit capture, and `.git` strip; the **Without arguments** subsection gains a **Commit verification on restore** paragraph with the mismatch error template; the **Lockfile** subsection now shows `commit` in the JSON example and documents the backward-compat behavior for older lockfiles) and `docs/libraries.md` (the "Installing third-party libraries" section now calls out that installed libs are plain files with no nested `.git`, names the `.jh`-module validation, and points at the new CLI hygiene contract for the commit-mismatch error).
+- **Feature — `jaiph install [@version]` resolves through a lib registry:** `runInstall` in `src/cli/commands/install.ts` only accepted git clone URLs, and the lib directory name was derived from the URL's last path segment via `deriveLibName(url)`. Because the import resolver (`src/transpile/resolve.ts`) maps `import "/"` to `.jaiph/libs//.jh`, the directory name **is** the import prefix — which silently required every git repo to be named exactly like the import prefix the lib exposes, and there was no way to write `jaiph install jaiphlang` without first knowing the underlying URL. The CLI now accepts a **registry name** form alongside the existing URL form. A new module `src/cli/commands/registry.ts` defines the **registry index format** — a single JSON document `{ "libs": { "": { "url": "", "description": "" } } }` where names must match `/^[A-Za-z0-9_-]+$/` (single path segment, since the name becomes the `.jaiph/libs/` directory and the import prefix) — and exports `loadRegistryIndex(source)`, `isRegistryNameArg(arg)`, `parseNameArg(arg)`, `registrySource(env)`, and a `DEFAULT_REGISTRY_URL` constant of `https://jaiph.org/registry`. `runInstall` classifies each positional arg by shape: an arg matching `/^[A-Za-z0-9_-]+(@[A-Za-z0-9._+/-]+)?$/` with no `/` and no `:` is a **registry name** (with optional `@version`); everything else takes the existing URL path unchanged. When at least one bare-name arg is present the index is loaded at most once per invocation from `JAIPH_REGISTRY` (or `DEFAULT_REGISTRY_URL` when the env var is unset or empty); when the source has no `://` prefix or uses `file://` it is read from disk (enables unit tests and air-gapped use), otherwise it is fetched with global `fetch`. The **registry key** (not `deriveLibName(url)`) names the `.jaiph/libs/` directory and the lock entry, so a registry entry like `{ "mylib": { "url": "https://example.com/some-other-repo-name.git", … } }` installs into `.jaiph/libs/mylib/` and is imported as `import "mylib/…"`. The lock entry stores the resolved clone URL exactly as today, so **restore-from-lock (`jaiph install` with no args) never contacts the registry** — even when the registry source is unreachable or has changed. Errors all exit non-zero with a message naming the registry source: unknown name → `lib "" not found in registry `; read/fetch/parse failures → `failed to read registry : ` / `failed to fetch registry : HTTP ` / `failed to parse registry : ` (including shape errors like `entry "" missing string "url"` and `invalid name ""`). `INSTALL_USAGE` and the global `printUsage` overview in `src/cli/shared/usage.ts` document the name form, the `JAIPH_REGISTRY` env var, and the new examples. New unit tests in `src/cli/commands/install.test.ts` pin the acceptance criteria: (1) installing `mylib` via a path-based `JAIPH_REGISTRY` whose entry points at a repo whose URL last segment is *different* from `mylib` installs into `.jaiph/libs/mylib/` and writes lock entry name `mylib` with the resolved URL; (2) `mylib@v1.2` forwards `v1.2` as the version to the clone runner and records it in the lock entry; (3) unknown registry name fails with `lib "missing" not found in registry `; (4) unreadable registry source fails with `failed to read registry : …`; (5) invalid registry JSON fails with `failed to parse registry : …`; (6) restore-from-lock succeeds with `JAIPH_REGISTRY` pointing at a nonexistent path (proves restore never reads the registry). URL-based installs behave exactly as before — existing install tests pass unmodified. Docs updated in `docs/cli.md` (the `## jaiph install` section gains an **Argument dispatch** explainer, a **Registry** subsection with the index format and source-resolution rules, an **Errors** list, and clarifies that the lock entry stores the **resolved** clone URL; the env-var entry for `JAIPH_REGISTRY` is added under **Install and `jaiph use`**), `docs/libraries.md` (the **Installing third-party libraries** section now shows both name and URL forms, names the shape-based dispatch, calls out that restore never reads the registry, and replaces the "directory name is `deriveLibName(url)`" line with the registry-key-vs-URL-last-segment split), and `docs/jaiph-skill.md` (the `jaiph install` row in the commands table covers both arg forms and points at `JAIPH_REGISTRY`).
+- **Installer — Rewrite `docs/install` to download a per-platform release binary; update `docs/install-from-local.sh` to build the standalone binary from source:** The previous installer required `git`, `node`, and `npm` on every user host: `docs/install` cloned the repo at a tag, ran `npm install` + `npm run build`, and installed a small `node …` shim at `~/.local/bin/jaiph` plus a `~/.local/bin/.jaiph/` (`LIB_DIR`) tree containing the compiled CLI tree (`src/`), `package.json`, and `jaiph-skill.md`. Now that `npm run build:standalone` produces a fully self-contained bun-compiled binary (overlay-run.sh + jaiph-skill.md embedded; self-spawn via the `__workflow-runner` argv marker) and `.github/workflows/release.yml` publishes per-platform binaries + `SHA256SUMS` under the fixed asset contract (`jaiph-{darwin|linux}-{arm64|x64}`, see [Contributing — Release asset naming contract](docs/contributing.md#release-asset-naming-contract)), the installer no longer needs to build anything on the user's machine. `docs/install` now detects the host via `uname -s` / `uname -m` (mapping `Darwin`→`darwin`, `Linux`→`linux`, `arm64|aarch64`→`arm64`, `x86_64|x64`→`x64`); unsupported platforms exit non-zero with `Unsupported platform: ` and a pointer at the from-source instructions in `docs/contributing.md#installing-from-source`. The ref is resolved as first arg → `JAIPH_REPO_REF` env → default `v0.9.4` (current stable tag); `nightly` resolves to the rolling prerelease published by `release.yml`. The installer downloads `https://github.com/jaiphlang/jaiph/releases/download/[/jaiph-]-` and `SHA256SUMS`, computes the digest with whichever of `sha256sum` / `shasum -a 256` is present (one is required; the installer fails fast otherwise), and **verifies the checksum against the matching `SHA256SUMS` entry** — a mismatch fails hard with `Checksum mismatch for ` printing both expected and actual hashes and **leaves no file on disk**. On success the binary is installed to `${JAIPH_BIN_DIR:-$HOME/.local/bin}/jaiph` with mode 755; the PATH-hint UX at the end is preserved. End-user prerequisites shrink from `git` + Node.js 20 + `npm` to `curl` + (`sha256sum` or `shasum`). A new env var **`JAIPH_RELEASE_BASE_URL`** overrides the GitHub Release base URL — useful for mirrors, offline bundles, or `file://` paths in tests; documented under [CLI — Install and `jaiph use`](docs/cli.md#install-and-jaiph-use). **Local-source parity.** The `JAIPH_FROM_LOCAL` branch (taken when either `JAIPH_REPO_URL` env or the first positional arg is a directory containing `package.json` — the path `docs/install-from-local.sh` and the E2E install tests use, since neither can depend on GitHub Releases) now **builds the standalone binary from source** instead of installing a node shim: `cp -R ` (excludes `.git` and `node_modules`), then `npm install`, then `npm run build:standalone` (requires `bun`), then `cp /dist/jaiph ${TARGET}` + `chmod 755`. The result is byte-identical in *shape* to the release-asset path — a single executable at `${JAIPH_BIN_DIR:-$HOME/.local/bin}/jaiph`, no shim script, no `LIB_DIR`, no runtime tree; only the origin of the binary differs (compiled locally vs. downloaded). `docs/install-from-local.sh` is updated accordingly (top-of-file comment names the new build + install contract and the bun prerequisite). **Removed:** the `LIB_DIR` constant, the `JAIPH_LIB_DIR` env var, the release-path `npm install` / `npm run build` invocations, the `node -p "require(...).version"` version probe, the `git clone --depth 1` for the network case, and the node-shim heredoc that used to write `#!/usr/bin/env bash` + `exec node "${LIB_DIR}/src/cli.js" "$@"` to `${TARGET}`. `grep -n "npm run build" docs/install` now matches only the local-source branch (acceptance criterion). New e2e `e2e/tests/07_installer_binary.sh` (registered in `e2e/test_all.sh`) pins three acceptance properties without touching the network: (1) **checksum mismatch** — points the installer at a `file://` release directory via `JAIPH_RELEASE_BASE_URL` with a hand-crafted `SHA256SUMS` whose hash does not match the binary; asserts non-zero exit, the output contains `Checksum mismatch`, and the install target is left empty; (2) **unsupported platform** — prepends a fake `uname` shim to `PATH` reporting `AIX powerpc`; asserts non-zero exit, the error names the detected platform, contains a `contributing` token, and leaves no binary; (3) **local-install parity** (skipped when `bun` is absent on the host) — runs `docs/install-from-local.sh`, asserts the install dir contains exactly one entry named `jaiph`, the installed file is not a `#!` shebang shim, then strips `node`/`npm`/`bun` from `PATH` and asserts `jaiph --version` returns `jaiph ` and `jaiph run sample.jh` prints `hello-from-local` against a deterministic mock-free workflow. `e2e/tests/05_jaiph_use_pinned_version.sh` is updated so `jaiph use` exercises the binary path (no longer the build-on-host path). Docs updated in `docs/install` (the new installer logic with a top-comment pointer at the **Release asset naming contract** as the source of asset names), `docs/install-from-local.sh` (top-of-file comment documents the build-from-source contract and the single-binary outcome), `docs/setup.md` (prerequisites list now names `curl` + sha tooling and notes Node/npm are **not** required to run `jaiph`; the "what `curl -fsSL … | bash` does" paragraph, the `jaiph use` paragraph, the `jaiph init` SKILL.md resolution paragraph, and the "Building from source" paragraph all describe the download-and-verify flow), `docs/contributing.md` (the **Installing from source** section names the new `npm install` + `npm run build:standalone` pipeline and lists `bun` as a from-source prerequisite; the **Developing in the repository** prerequisites note that end-user installs need only `curl` + sha tooling), and `docs/cli.md` (the `jaiph use` reference now describes "downloads the matching per-platform binary, verifies the checksum, replaces `~/.local/bin/jaiph`"; the **Install and `jaiph use`** env-var list documents `JAIPH_RELEASE_BASE_URL` and tightens the wording of `JAIPH_REPO_URL` and `JAIPH_REPO_REF` to mark which path each applies to — local-source vs. binary-download).
+- **CI — Add per-platform standalone-binary release workflow (`.github/workflows/release.yml`):** `npm run build:standalone` (bun-compiled binary) had no publish path — nothing in `.github/workflows/` cross-compiled or uploaded the artifact. The forthcoming binary-installer rewrite downloads release assets by fixed name, so without a release pipeline `jaiph use nightly` would break the moment that installer landed. A new `release.yml` workflow now runs on `v*` tag pushes, pushes to the **`nightly`** branch, and `workflow_dispatch`. Using `oven-sh/setup-bun`, it cross-compiles four `bun build --compile --target=…` outputs — `bun-darwin-arm64`, `bun-darwin-x64`, `bun-linux-x64`, `bun-linux-arm64` — into assets named **`jaiph-darwin-arm64`**, **`jaiph-darwin-x64`**, **`jaiph-linux-x64`**, **`jaiph-linux-arm64`** (the **fixed naming contract** the installer depends on verbatim — written down in [`docs/contributing.md`](docs/contributing.md) under **Release asset naming contract**). The `release` job downloads all four binary artifacts, runs `sha256sum` over them into a fifth asset **`SHA256SUMS`**, then executes a sanity gate on the runner: `chmod +x jaiph-linux-x64 && ./jaiph-linux-x64 --version`; for stable channel (`v*` tag) the output must equal `jaiph `, for the nightly channel it only asserts the output matches `^jaiph [0-9]+\.[0-9]+\.[0-9]+`. On stable, `gh release create` (or `gh release upload --clobber` if the tag already has a release) attaches all five assets to the tag; on `nightly`, the same five assets are pushed to a **rolling prerelease** tagged `nightly` via `gh release upload nightly --clobber` (created with `--prerelease --target ${GITHUB_SHA}` on first run). A separate `ci-gate` job in the same workflow polls `gh run list --workflow CI --commit "${GITHUB_SHA}"` every 30 s for up to 60 minutes and only declares success when that run's `conclusion=success`; both `build` and `release` declare `needs: ci-gate`, so a broken main-CI run on the same SHA cannot publish binaries. Full first-time release verification still happens on the first real `v*` tag — until then, `workflow_dispatch` on a test tag is the documented dry-run path.
+- **Feature — `bun --compile` standalone binary is fully self-contained (self-spawn + embedded assets):** `npm run build:standalone` (`bun build --compile ./src/cli.ts --outfile ./dist/jaiph`) used to produce a single-file executable that could not actually run a workflow. Two reasons. (1) `buildRunModuleLaunch` in `src/runtime/kernel/workflow-launch.ts` launched the workflow leader as `spawn(process.execPath, [join(__dirname, "node-workflow-runner.js"), …])`. Under node `execPath` is the node binary and the runner script ran; in a bun-compiled executable `process.execPath` is the **jaiph binary itself**, which always runs its embedded `cli.ts` entrypoint — the runner path was interpreted as CLI argv and the workflow leader never started. (2) The CLI read assets relative to its install at runtime: `runtime/overlay-run.sh` (`src/runtime/docker.ts`, Docker overlay sandboxing) and `docs/jaiph-skill.md` (resolved by `jaiph init`, see `src/cli/commands/init.ts` and the install-relative lookup in `docs/cli.md`). A bare binary has no such siblings on disk. Both problems are now fixed. (1) An internal argv marker — exported as **`WORKFLOW_RUNNER_ARG`** = `"__workflow-runner"` from `src/runtime/kernel/node-workflow-runner.ts` — is recognized at the top of `main` in `src/cli/index.ts` **before** help / version / file-shorthand parsing; when the second argument equals `__workflow-runner`, the CLI dispatches the remaining positional args to a new exported `runWorkflowRunner(positional: string[])` (extracted from what used to be the runner script's `main`). `buildRunModuleLaunch` now spawns `process.execPath` with `[WORKFLOW_RUNNER_ARG, metaFile, sourceAbs, builtScript, "default", ...runArgs]` argv for the bun-compiled standalone (detected by `typeof globalThis.Bun !== "undefined"`) and `[join(__dirname, "..", "..", "cli.js"), WORKFLOW_RUNNER_ARG, …]` for the tsc/node build, so both forms route through the same dispatcher. The reserved marker is excluded from `printUsage` (so `jaiph --help` and the overview never mention it) and the file-shorthand resolver (so it cannot be mistaken for a `.jh` path). (2) A new build step `npm run embed-assets` (driven by `tools/embed-assets.js`, registered in `package.json` and run automatically by `npm run build`) generates **`src/runtime/embedded-assets.ts`** — a TS module exporting `OVERLAY_RUN_SH_BASE64` and `JAIPH_SKILL_MD_BASE64` plus a `decodeEmbeddedAsset` helper — from the on-disk `runtime/overlay-run.sh` and `docs/jaiph-skill.md`. `loadOverlayScript` in `src/runtime/docker.ts` now resolves the overlay script in order: (a) sibling `overlay-run.sh` next to the compiled module (npm `dist/src/runtime/`), (b) repo-root `runtime/overlay-run.sh` three hops up (dev `src/runtime/`), (c) the embedded base64 baked into the executable — so the bun-compiled binary works with no sibling files and the npm/disk install behavior is byte-for-byte unchanged. A new `loadInstalledSkillContent` in `src/cli/shared/paths.ts` does the same for the skill: disk lookup (`JAIPH_SKILL_PATH`, install-relative `jaiph-skill.md` / `docs/jaiph-skill.md`, then `docs/jaiph-skill.md` under cwd) wins, with the embedded copy as fallback. `runInit` in `src/cli/commands/init.ts` now always writes `.jaiph/SKILL.md` (the old "skill file not found; set `JAIPH_SKILL_PATH` and run again" warn-and-skip branch is removed — there is no longer a case where the body cannot be resolved). The earlier lazy-load `E_CLI_SETUP` error contract for `overlay-run.sh` remains for the **node** build when the script is genuinely missing from a broken install; the **standalone** binary never hits that path because the embedded fallback always succeeds. New unit test `src/cli/index.test.ts` asserts the `__workflow-runner` argv dispatch routes to `runWorkflowRunner` and that the reserved marker does not appear in `--help` output; `src/runtime/embedded-assets.test.ts` asserts the generated base64 constants decode bit-for-bit to the on-disk `runtime/overlay-run.sh` and `docs/jaiph-skill.md` (any drift between source and embedded copy fails the test). New CI-runnable e2e `e2e/tests/210_standalone_binary.sh` (registered in `e2e/test_all.sh`; skipped when `bun` is not installed on the host) builds `dist/jaiph`, stages **only** the binary in an isolated temp dir (deliberately no sibling `runtime/` or `docs/`), strips `node` / `npm` / `bun` from `PATH`, and runs `jaiph --version`, `jaiph init` (asserting `.jaiph/SKILL.md` matches `docs/jaiph-skill.md` byte-for-byte), `jaiph compile sample.jh`, and `jaiph run sample.jh` against a deterministic mock-free workflow to completion. Docs updated in `docs/architecture.md` (the **Distribution: Node vs Bun standalone** section now describes the `embed-assets` build step, the `__workflow-runner` self-spawn marker, and the fully-self-contained standalone artifact — `dist/runtime` next to `dist/jaiph` is kept for npm-layout parity but no longer required), `docs/cli.md` (the `jaiph init` SKILL.md resolution paragraph adds the embedded-copy fallback and notes that the command always writes the file — no more skip-and-warn — and the `JAIPH_SKILL_PATH` env-var entry under **Environment variables** lists the embedded fallback alongside the existing search), `docs/setup.md` (the npm-install caveat that warned users to set `JAIPH_SKILL_PATH` when `jaiph init` "did not write" SKILL.md is replaced with the new always-writes contract and the same disk-then-embedded resolution order), `docs/contributing.md` (the **Typical commands** table gains a row for `npm run embed-assets` and the `npm run build:standalone` row describes the self-contained binary and the `__workflow-runner` self-spawn), and `README.md` (the `jaiph init` paragraph under **Install** matches the new contract).
+- **Docs — Document Docker env-var allowlist workarounds and pin the doc list to the source constants:** Users hitting Docker sandboxing were surprised when custom host environment variables (e.g. `MY_TOOL_TOKEN`, `AWS_*`, `SSH_*`) vanished inside the container with no diagnostic. `isEnvAllowed` in `src/runtime/docker.ts` forwards only variables matching the `ENV_ALLOW_PREFIXES` allowlist (`JAIPH_`, `ANTHROPIC_`, `CURSOR_`, `CLAUDE_`) and excludes `JAIPH_DOCKER_*` to prevent nested Docker config from leaking in. `docs/sandboxing.md` already named those prefixes in **Environment variable forwarding**, but it gave no actionable workaround when the host variable a script needs falls outside the allowlist. The section now ends with a **Workarounds for variables outside the allowlist** subsection that lists two supported approaches — (1) `export` the value inside the `script` body so it never has to cross the host/container boundary (with a small `script run_with_token = \`\`\`…\`\`\`` example), (2) bake the value into a derived image via `ENV` / `RUN` in a `FROM ghcr.io/jaiphlang/jaiph-runtime:nightly` Dockerfile (cross-links to [Extending the official image](#extending-the-official-image)) — and notes that variables that *must* come from the host on each run should be renamed with a `JAIPH_` prefix so the allowlist forwards them, with the existing "treat anything forwarded as fully disclosed" caveat preserved via a link to the threat-model section. Cross-links added: `docs/configuration.md` (the "Inspecting effective config at runtime" section gains a paragraph pointing readers to the allowlist explanation when a custom host variable goes missing inside the container) and `docs/cli.md` (the **Docker sandbox** env-var section gains a one-line summary of the allow-prefix list and a link to the sandboxing section). To prevent the doc list from drifting away from the source, `ENV_ALLOW_PREFIXES` and `ENV_ALLOW_EXCLUDE_PREFIX` in `src/runtime/docker.ts` are now exported (previously module-private) and a new docs-parity test in `src/runtime/docker.test.ts` reads `docs/sandboxing.md`, locates the `### Environment variable forwarding` section, and asserts that every prefix from the source constants appears as a `` `*` `` bullet and that the excluded prefix is mentioned; a second test asserts that both `docs/configuration.md` and `docs/cli.md` contain the `sandboxing.md#environment-variable-forwarding` cross-link. A reviewer diff between the doc list and the source constants is no longer necessary — the test fails first.
+- **Refactor — Remove dead `formatDiagnosticLine` indirection in the stderr parser:** `handleLine` in `src/cli/run/stderr-handler.ts` took a `formatDiagnosticLine: (line: string) => string` parameter that only the `emitter.emit("stderr_line", { line: formatDiagnosticLine(line) })` call applied, and the single caller (`createStderrParser` in the same file) always passed the identity function `(ln) => ln`. The parameter was never wired to any non-identity transform — pure dead indirection. Removed: `handleLine`'s fourth parameter is gone (`line` is now passed straight into `emitter.emit("stderr_line", { line })`), and the `const formatDiagnosticLine = (ln: string) => ln;` line in `createStderrParser` is deleted along with the corresponding argument at the `handleLine` call site. No other call sites existed (`handleLine` is module-private), and `createStderrParser`'s exported signature `(emitter: RunEmitter) => (line: string) => void` is unchanged, so every consumer (the `run` command, the parser unit tests in `src/cli/run/stderr-handler.test.ts`) compiles and behaves identically. Behavior is byte-for-byte preserved: `stderr_line` events still carry the raw line, so `capturedStderr` accumulation in `registerStateSubscriber`, TTY passthrough in `registerTTYSubscriber`, and the failed-run summary path that reads the last non-empty stderr line are all untouched. Verified by `grep -rn "formatDiagnosticLine" src/` returning nothing and `npm test` passing; existing integration tests cover the stderr passthrough behavior. No docs change is needed — `formatDiagnosticLine` was never named in user-facing documentation (the references to `src/cli/run/stderr-handler.ts` in `docs/architecture.md` and `docs/hooks.md` describe `RunState.workflowRunId`, `resolveEventId`, and the `isRoot` skip in `registerTTYSubscriber`, none of which involve this parameter).
+- **Fix — Docker overlay script loads lazily so non-Docker commands survive a broken install:** `src/runtime/docker.ts` previously read `runtime/overlay-run.sh` with `readFileSync` at **module load time** (top-level `const OVERLAY_SCRIPT = readFileSync(...)`). The docker module is imported transitively by non-Docker CLI paths — `jaiph compile` and `jaiph format` pull in symbols like `CONTAINER_RUN_DIR` from this module via `src/cli/shared/errors.ts` — so a missing `overlay-run.sh` in the installed package crashed **every** CLI invocation with a raw `ENOENT` stack trace, even commands that never touch Docker. The read is now lazy and lives in a new exported `loadOverlayScript()` helper called only from `writeOverlayScript()` (which itself runs only on the overlay-mode branch of `spawnDockerProcess`, i.e. when Docker is enabled **and** `/dev/fuse` is present). The first successful read is cached in a module-level `overlayScriptCache`; on a missing file the helper wraps the read in `try` / `catch` and rethrows `` `E_CLI_SETUP: runtime/overlay-run.sh not found at — the Jaiph installation is incomplete; reinstall with "jaiph use "` ``, where `` is the resolved candidate path (either the dist-layout `dist/src/runtime/overlay-run.sh` or the source-layout `runtime/overlay-run.sh`). Importing the docker module no longer touches the filesystem at all; `jaiph compile`, `jaiph format`, and other non-Docker subcommands now run unaffected when `overlay-run.sh` is missing from the install. Copy mode (`/dev/fuse` absent, or `JAIPH_DOCKER_NO_OVERLAY=1`) also no longer needs the script and is unaffected. The new error code surfaces only on the overlay path. New unit test in `src/runtime/docker.test.ts` (`loadOverlayScript: import does not read overlay-run.sh; writeOverlayScript throws E_CLI_SETUP when missing`) spawns a child Node process via `spawnSync(process.execPath, ["-e", script], …)` that temporarily renames `overlay-run.sh` at **both** candidate paths (the dist-layout `dist/src/runtime/overlay-run.sh` next to the compiled module **and** the source-layout `runtime/overlay-run.sh` reached via `resolve(__dirname, "..", "..", "..", "runtime", "overlay-run.sh")`), then `require()`s the docker module to prove the import itself succeeds (asserts the re-exported `CONTAINER_RUN_DIR === "/jaiph/run"` constant — the same symbol non-Docker CLI paths pull in transitively), then calls `mod.writeOverlayScript()` and asserts the thrown message starts with `E_CLI_SETUP` and contains one of the two candidate paths; the test restores the renamed files in a `finally` block on both pass and fail paths. Docker e2e overlay flow is unchanged when the file exists (the existing `writeOverlayScript: creates executable script with fuse-overlayfs setup` / `mounts as root and then drops to host uid via setpriv` / `contains no in-container rsync/cp fallback` tests still pass, and the module-load-time `const TEST_OVERLAY = writeOverlayScript()` shared by other tests still succeeds because the file is present in the build output). Docs updated in `docs/sandboxing.md` — the **Image contract** subsection's `overlay-run.sh` paragraph now describes the lazy-load behavior and the `E_CLI_SETUP` error contract; the **Failure modes** table gains an `E_CLI_SETUP` row and the table intro notes the new code alongside the existing non-`E_DOCKER_*` entries (`E_TIMEOUT`, `E_VALIDATE_MOUNT`).
+- **Fix — Three runtime error messages now name the missing context users need to act on:** Three failure paths returned messages that told the user *something* went wrong without saying *what* or *where*. (1) `resolveHandleResult` in `src/runtime/kernel/node-workflow-runtime.ts` returned `error: "invalid handle"` with no handle id — the user had no way to grep `run_summary.jsonl` for the offending step. (2) The Docker timeout path in `src/cli/commands/run.ts` appended the literal string `E_TIMEOUT container execution exceeded timeout` with no duration and no remedy — the user did not know which timeout had been exceeded or how to raise it. (3) `summarizeError` in `src/cli/shared/errors.ts` fell back to `"Workflow execution failed."` when stderr was empty, hiding the run directory and exit code that were already known to the caller. Each message now carries the missing context: (1) `invalid async handle "" — the handle was never created or was already consumed` via a new exported `formatInvalidAsyncHandleError(handleId)` in `node-workflow-runtime.ts`; (2) `` `E_TIMEOUT container execution exceeded s — increase runtime.docker_timeout_seconds or JAIPH_DOCKER_TIMEOUT` `` via a new exported `formatDockerTimeoutMessage(timeoutSeconds)` in `src/cli/shared/errors.ts`, where `` is the **actual** `activeDockerConfig.timeoutSeconds` value (the same value the timer was armed with), so the message is self-consistent with the config; (3) `summarizeError(stderr, fallback?, opts?)` and `resolveFailureDetails(stderr, summaryPath?, opts?)` gain an optional `{ code?, runDir? }` argument — when stderr is empty and at least one of the two is known, the summary is now `Workflow execution failed (exit ) with no error output; inspect run_summary.jsonl and step artifacts under `, with each clause omitted gracefully when its field is missing (code-only → `(exit N) with no error output`, run-dir-only → `Workflow execution failed with no error output; inspect … under …`); the old `"Workflow execution failed."` text remains only as the terminal fallback when neither field is known. `reportResult` in `src/cli/commands/run.ts` threads `{ code: exitStatus, runDir }` through to `resolveFailureDetails`. New unit tests in `src/cli/shared/errors.test.ts` pin the four summary shapes (empty-stderr with both fields, code only, runDir only, and non-empty stderr ignoring both opts) and the formatted timeout message (configured seconds + both env-var / config-key remedies present); new kernel tests in `src/runtime/kernel/node-workflow-runtime.handle.test.ts` (added by this change) pin the handle-id-in-message shape for both `resolveHandleResult` and `formatInvalidAsyncHandleError`. The single existing e2e expectation that asserted the old empty-stderr text (`e2e/tests/61_ensure_recover.sh`) is updated to assert the new exit-code + run-dir form. Docs updated in `docs/sandboxing.md` (the **Timeout** paragraph under **Docker runs** now quotes the new message shape with the `s` placeholder and the `runtime.docker_timeout_seconds` / `JAIPH_DOCKER_TIMEOUT` remedy clause) and `docs/cli.md` (the **Failed run summary (stderr)** section gains a new paragraph describing the summary-line source: last non-empty trimmed stderr line, the new empty-stderr fallback that names exit code and run directory, and the terminal `"Workflow execution failed."` fallback when neither is known).
+- **Fix — `jaiph format` preserves quotes on top-level `const` string values:** `jaiph format` rewrote `const x = ".jaiph/tmp/x.md"` to the unquoted bare-token form `const x = .jaiph/tmp/x.md` whenever the value contained no whitespace; values with a space kept their quotes. The rewrite was value-preserving and idempotent, but it silently changed the author's chosen delimiter and produced inconsistent output within one file — quoted and unquoted `const` declarations side by side, depending on whether each value happened to contain a space. A formatter should canonicalize to one stable form, not toggle forms based on value content. The canonical rule is now: a top-level `const` value written as a **double-quoted string** in the source is emitted **double-quoted**, always — regardless of whether the value contains spaces. Values written as **bare tokens** (e.g. `const MAX = 3`) stay bare. Values written as `"""…"""` are emitted verbatim as before. `EnvDeclDef` (`src/types.ts`) gains an optional `wasQuoted?: boolean` field; `parseEnvDecl` (`src/parse/env.ts`) sets it to `true` on both the single-line `"…"` and triple-quoted `"""…"""` paths and leaves it `undefined` on the bare path; `emitEnvDecl` (`src/format/emit.ts`) checks `wasQuoted` first — when set, the value is re-emitted with `JSON.stringify` (or as `"""…"""` if the value contains a `"` or `\` that would need escaping), and the value-content shape check (`^[A-Za-z0-9_./@+#%^&=*:~?-]+$`) that drove the bare emission only runs on the bare branch. New formatter tests in `src/format/emit.test.ts` pin the invariants: a quoted no-space value (`const q = ".jaiph/tmp/x.md"`) survives `jaiph format` with quotes intact, a quoted value with spaces also survives, a bare numeric token (`const MAX = 3`) stays bare, formatting twice produces identical output for all three cases (idempotency), and a parse-then-format-then-parse round-trip preserves `envDecls[i].value` bit-for-bit so `${q}` interpolation yields the same value before and after formatting. No existing golden AST fixture under `test-fixtures/golden-ast/fixtures/` carries a top-level `const`, so the `wasQuoted` field addition does not require regenerating goldens. Docs updated in `docs/cli.md` (new "Top-level `const` quoting" paragraph under `## jaiph format` describing the canonical rule), `docs/grammar.md` (the **Top-Level `const`** section and the trailing formatter-impact bullet list both note that the source delimiter is preserved), `docs/language.md` (the Constants section gains the same preservation note), and `docs/architecture.md` (the formatter description adds the original quotedness of top-level `const` values to the list of round-tripped discriminators alongside `"""…"""` and `bareSource`).
+- **Fix — Mixing `mock prompt { … }` with queued `mock prompt "…"` is now a compile-time error:** When a single `test "…" { … }` block contained both a pattern-dispatch `mock prompt { … }` and one or more queue-style `mock prompt "…"` / `mock prompt ` lines, the queue entries were **silently ignored** at runtime — the block won, the queued lines did nothing, and the test could pass for the wrong reason. `docs/testing.md` documented this as a limitation ("Do not combine … ignored") rather than the compiler enforcing it. `validateTestBlocks` in `src/transpile/validate.ts` now scans each `TestBlockDef`'s steps for the first `test_mock_prompt_block` and the first `test_mock_prompt`; when both are present in the same block, it emits a single `E_VALIDATE` diagnostic — `cannot mix "mock prompt { … }" with queued "mock prompt …" in one test block; choose one style` — at the location of whichever offending mock appears **second** in source order (so the message points at the line the author should remove or convert). The check is per test block: separate `test "…" { … }` blocks in the same file may still use different styles. Surfaced through the standard validation path, so `jaiph compile path/to/file.test.jh` (test files are validated when passed explicitly) and `jaiph test path/to/file.test.jh` (loads + validates before running) both fail with the new diagnostic instead of silently dropping the queued mocks. New txtar fixtures pin the invariants: `test-fixtures/compiler-txtar/validate-errors.txt` adds two cases — block-first then queue, and queue-first then block — each asserting the `E_VALIDATE` code and the exact message via `validate-diagnostics-snapshot.json`; `test-fixtures/compiler-txtar/valid.txt` adds a "block and queued mock prompt in separate test blocks" case proving the per-block scope. Docs updated in `docs/testing.md` (the "Mock prompt (content-based dispatch)" section and the **Limitations (v1)** bullet now describe the compile-time error and the per-block scope rule instead of the "silently ignored" caveat) and `docs/jaiph-skill.md` (the test-authoring bullet that said "Don't mix queued … and a `mock prompt { … }` block in one test" now states the error and quotes the message).
+- **Feature — `jaiph test` discovery with zero matches exits 0:** Previously, `jaiph test` (no args) and `jaiph test ` exited **1** with `jaiph test: no *.test.jh files found` when discovery matched nothing (`src/cli/commands/test.ts:25,43`), forcing every CI pipeline and agent loop to guard the call (`run jaiph test only if test files exist`) and obliging the bootstrap skill doc (`docs/jaiph-skill.md`) to carry a matching caveat. In **discovery mode** (no path, or a directory path), zero matches now write `jaiph test: no *.test.jh files found (nothing to do)` to stderr and exit **0**, so `jaiph test` is safe to call unconditionally. Passing an explicit **file** path that does not exist or is not a `*.test.jh` file remains an error (exit **1**) — a named target must exist. Implementation: the two zero-match branches in `runTest` in `src/cli/commands/test.ts` now share a single `(nothing to do)` notice and `return 0` instead of returning `1`; the explicit-file branch is unchanged so `fs.statSync` still throws ENOENT on a missing named target. Tests: `e2e/tests/125_test_discovery_errors.sh` is restructured into three sections — empty directory exits 0 with the notice (assert_equals), `jaiph test` with no args in a workspace without test files exits 0 with the notice, and `jaiph test missing.test.jh` (nonexistent file) still exits non-zero with an error message referencing the missing path. `e2e/tests/50_cli_and_parse_guards.sh` is updated to expect exit 0 and the new notice for the empty-directory case it covered. Docs: `docs/cli.md` and `docs/testing.md` describe the new discovery-vs-named-target split; `docs/jaiph-skill.md` drops the "only if `*.test.jh` files exist" caveat from both "Your authoring loop" (step 4) and the final commands block.
+- **Feature — Per-subcommand `-h` / `--help`:** Only `jaiph compile -h` / `jaiph compile --help` printed command-specific usage; for every other subcommand the help flag fell through to positional / file-path resolution and produced confusing errors. `jaiph run --help` tried to resolve `--help` as a `.jh` file (`requires a .jh file` / ENOENT path); `jaiph test --help`, `jaiph format --help`, `jaiph init --help`, `jaiph install --help`, and `jaiph use --help` were similarly parsed as ignored tokens or stray paths. The CLI dispatcher (`src/cli/index.ts`) recognized `-h` / `--help` only as the **first** token after `jaiph`, and `docs/cli.md` documented this limitation instead of fixing it. Every subcommand now recognizes `-h` / `--help` anywhere in its argument list **before positional processing**, prints its own usage block (flags + one example) to stdout, and exits **0**. `jaiph --help` and bare `jaiph` still print the overview; the change is additive at the subcommand layer. Each subcommand owns its own usage string: `RUN_USAGE` in `src/cli/commands/run.ts`, `TEST_USAGE` in `src/cli/commands/test.ts`, `FORMAT_USAGE` in `src/cli/commands/format.ts`, `INIT_USAGE` in `src/cli/commands/init.ts`, `INSTALL_USAGE` in `src/cli/commands/install.ts`, `USE_USAGE` in `src/cli/commands/use.ts`, and a renamed `COMPILE_USAGE` in `src/cli/commands/compile.ts` (the existing `printUsage` helper there was split into the constant plus a `printUsageError` wrapper that keeps writing the same text to stderr for argument-shape errors). A shared `hasHelpFlag(args: string[]): boolean` helper in `src/cli/shared/usage.ts` scans the argument list for `-h` / `--help`, stopping at `--` so `jaiph run flow.jh -- --help` still forwards `--help` to `workflow default` instead of intercepting it. Each subcommand entry function (`runWorkflow`, `runTest`, `runFormat`, `runInit`, `runInstall`, `runUse`) calls `hasHelpFlag(rest)` as its first step and short-circuits to `process.stdout.write(_USAGE)` + `return 0` when true; the `compile` entry keeps its existing inline `--help` / `-h` check (now writing to stdout for the help case and falling through to `printUsageError` only on actual argument-shape errors). The overview usage text in `printUsage()` (`src/cli/shared/usage.ts`) drops the "only as the first argument" caveat from the global-options section and notes that each subcommand also accepts `-h` / `--help`. New integration test `integration/subcommand-help.test.ts` iterates all seven subcommands × both flag forms (14 cases) asserting exit **0** and that stdout contains both the substring `Usage` and the subcommand name; a dedicated case for `jaiph run --help` further asserts that stderr does **not** contain `ENOENT`, `no such file`, or `requires a .jh file` — pinning the regression that the old code would attempt to resolve `--help` as a path. Docs updated in `docs/cli.md`: the **Global options** paragraph is rewritten to describe the per-subcommand help flag (overview vs. command-specific usage, listing all seven subcommands) instead of documenting the old first-token-only limitation.
+- **Feature — `if` and `match` accept `IDENT.IDENT` dot-notation subjects on typed prompt captures:** Typed prompt captures already exposed their fields via dot notation in interpolation contexts (`${r.verdict}` resolved against the `returns "{ verdict: string, … }"` schema), but `if` and `match` subjects had to be plain identifiers — `if r.verdict == "reject" { … }` failed with `E_PARSE invalid if syntax; expected: if …`, forcing every typed-prompt-then-branch workflow to insert a boilerplate `const verdict = "${r.verdict}"` rebind before the condition. The single most common typed-prompt pattern (ask for a verdict, branch on it) paid that tax on every use. The parser now accepts `IDENT.IDENT` as the subject of `if` and `match` (both statement and expression forms): the `if`-line regex in `tryParseIf` (`src/parse/workflow-brace.ts`) is widened from `[A-Za-z_][A-Za-z0-9_]*` to `[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?`, and `validateMatchSubject` (`src/parse/match.ts`) now accepts a new `DOT_IDENT_RE` (`^IDENT\.IDENT$`) alongside the existing bare-ident regex. The `$` / `${}`-as-subject parse error is unchanged. Compile-time validation reuses the same dot-notation schema check already implemented for `${var.field}` interpolation: a new `validateDotSubject(subject, loc, ctx)` in `src/transpile/validate-step.ts` parses the subject, and a new shared `validateDotFieldRef(varName, fieldName, loc, ctx)` (extracted from the body of `validateDotFieldRefs`) emits the same two `E_VALIDATE` diagnostics — `"" is not a typed prompt capture; dot notation requires a prompt with "returns" schema` when the base is not a `const … = prompt … returns "{ … }"` capture, and `field "" is not defined in the returns schema for ""; available fields: …` when the base is typed but the field is not in its schema. So a plain unknown `a.b` subject (non-typed-capture base, or unknown field) is now a structured `E_VALIDATE` at the same line/col as `${a.b}` would be — not the old `E_PARSE` "invalid if syntax". Runtime semantics mirror `${var.field}` interpolation exactly: a new private `resolveSubjectValue(scope, subject)` in `src/runtime/kernel/node-workflow-runtime.ts` splits the subject on the first `.`, resolves any handle on the base variable (same `isHandle` / `resolveHandleVar` path the bare-identifier subject already used), then for the dot form parses the base value as JSON and reads the field — falling back to the empty string when the JSON parse fails or the field is missing (the same fallback `${var.field}` uses). The `if` step in `runStep` and `runMatchExpr` both now route their subject resolution through `resolveSubjectValue` (the two previously-duplicated handle-resolution + scope-lookup blocks collapse into one helper); bare-identifier behavior is preserved byte-for-byte. AST: no new variant — `subject` on the `if` step and `MatchExprDef` stays a `string` that now optionally contains a single `.`. New txtar fixtures (`test-fixtures/compiler-txtar/valid.txt`) cover `if r.verdict == "ok" { … }` and `const x = match r.verdict { "ok" => "approved"; _ => "rejected" }` on a typed prompt capture; four new `validate-errors.txt` cases pin the `E_VALIDATE` shape for `if` and `match` dot subjects on both a non-typed-capture variable (`const r = "plain"`) and a typed capture with an unknown field, with the diagnostic snapshot (`validate-diagnostics-snapshot.json`) recording the exact `{ line, col, code, message }`. A new golden AST fixture pair (`test-fixtures/golden-ast/fixtures/if-dot-subject.jh` + `expected/if-dot-subject.json`) pins the tree shape for an `if` with a dot-notation subject (the AST node carries `"subject": "r.verdict"`). New runtime e2e `e2e/tests/138_if_match_dot_subject.sh` exercises both `if` and `match` against the same typed-capture workflow under three mocked prompt JSON payloads (`{"verdict":"ok"}`, `"reject"`, `"maybe"`), asserting the `if`/`else` arms and the matching `match` arms all select correctly by field value; `e2e/test_all.sh` registers the test. Docs updated in `docs/grammar.md` (the `if` and `match` subject bullets now describe the dot-notation form and the typed-capture / `returns` schema requirement, with a forward reference to the `E_VALIDATE` parity with `${var.field}`; the EBNF gains a `subject_ref = IDENT | IDENT "." IDENT` production used by `if_stmt`, `match_stmt`, and `match_expr`), `docs/language.md` (the `match` and `if` subject sentences in §`match` / §`if — Conditional Guard` now describe both forms inline with the same `E_VALIDATE` parity caveat), `docs/jaiph-skill.md` (the control-flow bullet that previously said "a dot-notation field (`if r.verdict == …`) is a parse error; rebind first" is replaced with the new rule — dot subjects work on typed prompt captures without rebinding — and the prompt-driven example with the typed verdict capture drops its boilerplate `const verdict = "${r.verdict}"` rebind; a workflow-triage example also drops the same rebind), and `docs/index.html` (the `match` primitive entry in the orchestration overview notes the new dot-subject form and that `if` accepts the same shape).
+- **Feature — `catch` / `recover` on inline-script `run` steps:** Named-ref `run` calls already supported failure handling (`run deploy() catch (err) { … }` / `run deploy() recover(err) { … }`), but inline scripts did not: `` run `test -z "$(git status --porcelain)"`() catch (err) { … } `` failed with `E_PARSE unexpected content after anonymous inline script: 'catch (err) {'`, forcing authors to declare a named `script` solely to attach failure handling to a one-liner. The grammar EBNF in `docs/grammar.md` previously showed `run_catch_stmt = "run" call_ref "catch" …` (call_ref only); the inline-script parse path rejected any trailing tokens after the closing `)`. The inline-script `run` parse path (single-backtick and fenced forms) now accepts the same optional `catch (name) ` / `recover (name) ` suffix as named-ref `run`, with identical semantics — `catch` runs the body once on failure, `recover` retries the inline script up to `run.recover_limit` (default 10), and the two remain mutually exclusive on a single step. The restriction that `run async` does not combine with inline scripts is unchanged. Parser entry: `parseAnonymousInlineScript` in `src/parse/inline-script.ts` gains an `allowTrailing = false` parameter and exposes `closingLineIdx` / `trailing` on its result so callers can resume parsing after the closing `)`. `tryParseRun` in `src/parse/workflow-brace.ts` calls `parseAnonymousInlineScript` with `allowTrailing: true` and delegates to a new `parseInlineScriptTail` helper that recognizes a leading `recover` or `catch` keyword on the trailing text and hands the bindings/body off to the existing `parseAttachedBlock` (same helper named-ref `run` / `ensure` already use) — so every body shape (`{ … }`, inline `{ stmt[; stmt]* }`, single-statement) and every binding/body parse-error message is shared with the named-ref path. AST: no new variant — the inline-script `exec` step's existing `catch?` / `recover?` step-level attributes (added with the `Expr` collapse refactor) are now populated for inline-script bodies, matching named-ref `run`. Runtime: the inline-script branch of `runStep` in `src/runtime/kernel/node-workflow-runtime.ts` now mirrors the named-ref `runOnce` / `runRecoverBody` loop: on `step.recover`, retry the inline script up to `resolveRecoverLimit(scope.filePath)` times with the merged stdout+stderr bound to the recover binding between attempts; on `step.catch`, run once and on non-zero status invoke the catch body once with the merged output bound; a `return` from the catch/recover body propagates through the existing `mergeStepResult` path. The `run async` rejection path is untouched (the inline-script `run async` form still fails parse before reaching this code). New txtar fixtures (`test-fixtures/compiler-txtar/valid.txt`) cover inline-script `catch` and `recover` in both workflow and rule bodies, the fenced (triple-backtick) form with `catch`, an inline script with positional args plus `catch`, and the single-statement `catch` body shape. The same `recover` + `catch` mutual-exclusion guard is asserted by a new `parse-errors.txt` case where `run \`false\`() recover(e) { log "r" } catch (e) { log "c" }` fails with the existing "unexpected content after log string" parse error (the recover clause consumes through its body, and the trailing `catch` falls through to the next-statement parser). Runtime e2e `e2e/tests/137_inline_script_catch_recover.sh` covers (a) a failing inline script's `catch` body runs once with the merged output bound (`run \`echo "bad" 1>&2; exit 3\`() catch (err) { log "caught: ${err}" }` logs `caught: bad` and the workflow passes), (b) a failing inline script under `recover` retries until a counter-file-based repair makes it pass (`run \`test -f .done\`() recover(err) { run \`\`\`…\`\`\`() }` runs the repair body exactly **2** times before the check passes), and (c) the same `catch` shape works in a rule body via `ensure`. Docs updated in `docs/grammar.md` (new "**`catch` / `recover` on inline scripts**" paragraph and example under **Inline Scripts**; EBNF `run_catch_stmt` / `run_recover_stmt` now read `( call_ref | inline_script )`; an aside on `inline_script` clarifies that the optional catch/recover suffix is permitted only via `run_catch_stmt` / `run_recover_stmt`, not via `log` / `logerr` / `return` / `const` RHS), `docs/language.md` (new "**`catch` / `recover` on inline scripts**" paragraph and example under **Inline Scripts**, plus the explicit note that `const … = run \`…\`()`, `return run \`…\`()`, and `log run \`…\`()` / `logerr run \`…\`()` do *not* accept these suffixes), and `docs/jaiph-skill.md` (replaces the "they do **not** accept `catch`/`recover` suffixes" caveat with the new rule and points to wrapping in a standalone `run` step for the other inline-script positions).
+- **Feature — `if` now supports an `else` branch:** `if subject { … }` previously had no `else` arm. The documented workarounds — `match` (which forces a wildcard arm and value-shaped bodies) or a `catch`-as-failure-branch — were the single biggest ergonomic gap agents hit when authoring workflows and rules. `if` now accepts an optional `else { … }` clause that runs when the condition is false. Surface syntax: `} else {` must sit on the **same line** as the closing `}` of the `if` body — `else` on its own line is `E_PARSE` (`"else" must appear on the same line as the closing "}" of an "if" block`); `else` without a preceding `if` is the same `E_PARSE`; and `else if` chaining is rejected with a dedicated `E_PARSE` (`"else if" chaining is not supported; nest an "if" inside the "else" block, or use "match" for multi-way branching`) — a bare `else` containing a nested `if` is fine. `if` / `else` remains a **statement** (no value production); `const x = if …` and `return if …` are still parse errors, and value-shaped branching is still expressed with `match`. The `else` body uses a brace block of the same step forms allowed in the surrounding workflow / rule body — rule scope still rejects `prompt`, channel sends, `run async`, and `run` to a workflow inside `else`, exactly as it does inside the `if` body. The parser entry is the `if` row of the `STATEMENT` dispatch table in `src/parse/workflow-brace.ts`: `tryParseIf` calls `parseBraceBlockBody` with a new `allowElseTerminator: true` opt that recognizes `} else {` as a block terminator and signals it via a new `closedWithElse` field on the return tuple, then `tryParseIf` parses the else body with a second `parseBraceBlockBody` call (without the opt, so the else body terminates only on `}`). A dedicated `tryParseElseError` row on `else` produces the same two error messages when a stray `else` appears as a top-level statement (outside an `if` body), so the diagnostic doesn't depend on which side of the `}` the parser hits. AST: `WorkflowStepDef`'s `if` variant gains an optional `elseBody?: WorkflowStepDef[]` (absent when there is no `else`, preserving the old shape byte-for-byte). Validator (`src/transpile/validate.ts`'s `walkStepTree`) descends into `elseBody` with the same scope as `body`, so binding rules and per-step rule/workflow gates apply uniformly to both arms. Runtime (`src/runtime/kernel/node-workflow-runtime.ts`): the `if` case picks `step.body` when the condition is met and `step.elseBody` otherwise, executes only the chosen branch's steps in order, and propagates any non-zero status or `return` value through the existing `mergeStepResult` path — the false-with-no-else path is still a no-op. Formatter (`src/format/emit.ts`'s `emitStep`): emits canonical `} else {` between the two arms when `elseBody` is set; `jaiph format` is idempotent on `if/else` (formatter test in `src/format/emit.test.ts`). New txtar fixtures cover (a) `if/else` in a workflow, in a rule, and a nested `if` inside an `else` block (`test-fixtures/compiler-txtar/valid.txt`); (b) the three parse-error shapes — `else` on its own line, bare `else` without a preceding `if`, and `else if (…)` chaining (`test-fixtures/compiler-txtar/parse-errors.txt`); and (c) rule-scope validation rejecting `const … = prompt …` inside an `else` block in a rule (`test-fixtures/compiler-txtar/validate-errors.txt`). A golden AST fixture pair (`test-fixtures/golden-ast/fixtures/if-else.jh` + `test-fixtures/golden-ast/expected/if-else.json`) pins the tree shape for an `if/else` statement. New runtime E2E `e2e/tests/136_if_else_branch.sh` runs the same `.jh` source twice: with `status="ok"` the then-branch logs `healthy` and `done`, with `status="bad"` the else-branch logs `unhealthy: bad` and `done` — proving only the chosen arm executes — plus the same shape inside a rule (then-branch fails the workflow on empty input; else-branch passes the rule and the workflow continues to `log "validated"`). Docs updated in `docs/grammar.md` (rewrote the `if` section to document `else`, added the `else_clause` production to the EBNF), `docs/language.md` (updated the **`if` — Conditional Guard** section: dropped the "No `else` branch" claim and added a `} else {` example), and `docs/jaiph-skill.md` (replaced the "`if` has **no `else`**" bullet with the new `} else {` rules and the no-`else if` chaining caveat); `docs/contributing.md` bumps the golden AST fixture count from 9 to 10 and names the new `if-else` fixture.
+- **Fix — Workflow-level `run.recover_limit` now applies:** A workflow body may open with a `config { … }` block that overrides `agent.*` and `run.*` keys, and the precedence chain (workflow-level > module-level > defaults) was already documented for every other run key. But `resolveRecoverLimit` in `src/runtime/kernel/node-workflow-runtime.ts` read only `moduleMeta?.run?.recoverLimit ?? 10`, so a workflow-level `run.recover_limit = 3` parsed and validated fine but was silently ignored at retry time — a `run … recover` step inside that workflow still used the module-level cap (or the default of 10). `docs/configuration.md` documented this as an explicit exception ("`run.recover_limit` is an exception: only **module-level** values affect `run … recover`"), making it a trap: config that validates but does nothing. `NodeWorkflowRuntime` now resolves `run.recover_limit` through the same precedence as other run keys: it consults the **active workflow's** metadata scope first (the top of `workflowCtxStack`, whose `workflowMeta` is captured when the workflow frame is pushed), then falls back to the module-level metadata of the file owning the current step's scope, then to the default of `10`. The workflow-frame side of the wiring is a new `workflowMeta?: WorkflowMetadata` field on `WorkflowContext` populated at frame-creation time from `resolved.workflow.metadata`, so cross-module `run` calls correctly see the callee workflow's own config (the cross-module call already pushes a new frame). New tests in `integration/sample-build/recover-handle.test.ts` pin the invariants: (a) a workflow with `config { run.recover_limit = 2 }` calling a failing script via `run failing() recover(e) { … }`, with module-level `run.recover_limit = 50` set as a deliberately wrong fallback, executes the script exactly **3 times** (1 initial + 2 retries) — verified by reading a counter file that the failing script increments and exits non-zero on every attempt; (b) a sibling workflow in the same module without its own `config` block still uses the module-level value (`config { run.recover_limit = 2 }` at module level → 3 attempts), proving workflow-level config is correctly scoped per-workflow and does not bleed into siblings (an unrelated sibling workflow's own `config { run.recover_limit = 50 }` is also present in the fixture to prove it does not leak into `default`). Both tests run `dist/src/cli.js` end-to-end under `JAIPH_DOCKER_ENABLED=false`. Docs updated to delete the exception text from `docs/configuration.md` (three places: the "Three ways to configure" intro, the "Run keys" table row, and the "Workflow-level config" rules) and to refresh `docs/grammar.md`, `docs/jaiph-skill.md`, `docs/language.md`, and `docs/spec-async-handles.md` so the retry-limit override description matches the now-standard precedence (workflow-level > module-level > default 10). `grep -rn "workflow-level run.recover_limit" docs/` returns nothing stale.
+- **Feature — Inbox dispatch iteration cap:** `drainWorkflowQueue` in `src/runtime/kernel/node-workflow-runtime.ts` walked the in-memory channel queue with `while (cursor < queue.length)` and had no upper bound — dispatched targets that sent on the same (or a routed) channel could append to the same queue indefinitely, so a circular send (A routes to B, B sends back to A's channel) looped until the host OOM'd. `docs/inbox.md` previously *documented* the footgun ("Avoid unbounded circular sends") rather than the runtime enforcing a bound. The runtime now caps the number of messages a single workflow frame may drain. The default cap is **1000**; override via the environment variable **`JAIPH_INBOX_MAX_DISPATCH`** (positive integer; non-numeric, empty, or non-positive values fall back to the default — resolved by a new `resolveInboxDispatchLimit(env)` helper at the top of `node-workflow-runtime.ts`). When the cap is exceeded `drainWorkflowQueue` aborts the owning workflow with status `1` and the error message `E_INBOX_DISPATCH_LIMIT: drained messages without quiescing — likely a circular send (channel ""); raise JAIPH_INBOX_MAX_DISPATCH if intentional`, where `` is the next un-drained message's channel (typically the channel involved in the cycle). New kernel tests in `src/runtime/kernel/node-workflow-runtime.artifacts.test.ts` pin the invariants: (a) a two-workflow circular send (`on_ping` enqueues on `pong`; `on_pong` enqueues on `ping`) fails the workflow with `E_INBOX_DISPATCH_LIMIT` instead of hanging, and the error names one of the cycle channels and the limit; (b) `JAIPH_INBOX_MAX_DISPATCH=5` against a self-loop triggers the cap after **exactly 5** `INBOX_DISPATCH_START` records in `run_summary.jsonl`; (c) multi-message fan-out below the cap (one producer enqueues 3 messages on a channel with 3 routed targets, cap = 5) still succeeds with no `E_INBOX_DISPATCH_LIMIT` in the summary. Existing inbox tests pass unchanged. Docs updated in `docs/inbox.md` (new **Dispatch cap** paragraph under [Dispatch loop](docs/inbox.md#dispatch-loop); the circular-sends bullet under [Error semantics](docs/inbox.md#error-semantics) now describes `E_INBOX_DISPATCH_LIMIT` and the env override instead of the old "no built-in iteration cap" warning), `docs/cli.md` (new `JAIPH_INBOX_MAX_DISPATCH` entry under **Execution behavior**), and `docs/jaiph-skill.md` (the inbox paragraph now states the 1000-message default cap and the env override).
+- **Fix — Imported-channel sends now dispatch:** Channel routes are registered in `NodeWorkflowRuntime`'s `ctx.routes` keyed by the **bare** channel name from `channel -> …` lines, but the send step looked the channel up with `this.workflowCtxStack[i].routes.has(step.channel)` where `step.channel` was the **verbatim token** left of `<-` (`src/runtime/kernel/node-workflow-runtime.ts`). So a validated cross-module send like `lib.topic <- "msg"` never matched the route registered as `topic` — the message was enqueued unrouted and silently dropped. `docs/inbox.md` previously documented this as a known footgun ("Module scope" paragraph) and steered users to bare-channel sends from the entry module. The send step in `node-workflow-runtime.ts` now normalizes `step.channel` once, at send time: after the validator (`validateChannelRef` in `src/transpile/validate.ts`) has already proven that an `alias.name` token refers to an existing imported channel, the runtime strips the `alias.` prefix and uses the bare name as `channelKey` for both the `routes.has(channelKey)` walk up the workflow stack and the `InboxMsg.channel` field. `lib.topic <-` and a bare `topic <-` therefore resolve to the same route key. The `INBOX_ENQUEUE` record in `run_summary.jsonl` carries the bare channel name; the audit copy is written to `inbox/NNN-.txt`. New E2E coverage in `e2e/tests/91_inbox_dispatch.sh` ("Imported channel send: lib.topic normalizes to topic for routing") writes the failing-today scenario as a test first — entry module declares `channel topic -> handler` and imports `lib`, `lib` declares `channel topic`, the entry workflow sends `lib.topic <- "x"` — then asserts `handler` is invoked with payload `"x"`, the inbox audit file is `inbox/001-topic.txt` containing `x`, and the `INBOX_ENQUEUE` line in `run_summary.jsonl` records `channel: "topic"` (bare, alias prefix stripped). Docs updated in `docs/inbox.md` — the "Module scope" paragraph under [Who registers routes and who drains](docs/inbox.md#who-registers-routes-and-who-drains) is rewritten to describe the normalized behavior (and references `validateChannelRef` as the guarantee that any `alias.` prefix the runtime strips already names a real imported channel), the imported-channel bullet under **Send operator** drops the "literal token" caveat, and the [Send operator](docs/inbox.md#send-operator--channel_ref-rhs) paragraph clarifies that `sendChannel` is the bare channel name used for both the route lookup and the audit filename.
+- **Fix — Docker-run path no longer leaks the `process.on("exit")` cleanup guard:** In `src/cli/commands/run.ts` (`runWorkflow`), when `spawnExec` returned a `dockerResult`, an `exitGuard` callback was registered with `process.on("exit", exitGuard)` so that, if the host CLI crashed before the run finished, `cleanupDocker(dockerResult)` would still tear down the copy-mode sandbox directory and clear the timeout timer. The matching `process.removeListener("exit", exitGuard)` only ran inside the `if (dockerResult)` block *after* `await waitForRunExit(...)` resolved normally — so if anything between registration and removal threw (stream wiring, the awaited child exit, buffer draining), the listener stayed on the `process` object for the rest of the host CLI's lifetime and `cleanupDocker` would fire again at process exit on an already-cleaned container, also fattening the `exit`-listener list across nested or repeated runs. Registration and removal are now paired in a `try { … } finally { … }` block via a new helper `withDockerExitGuard(dockerResult, body)` exported from `src/runtime/docker.ts`. The helper registers the guard, runs the spawn-to-exit body inside `try`, and in `finally` — on both normal return *and* on throw — removes the listener and calls `cleanupDocker(dockerResult)`. The guard itself stays registered for the abnormal-exit case (that is its purpose); only the normal and thrown-body paths now deterministically remove it. When `dockerResult` is `undefined` (non-Docker run), no listener is registered at all. `cleanupDocker` was already idempotent through the `cleaned` flag on `DockerSpawnResult` (so both the finally path and any surviving guard / signal handler can call it without double-`rmSync` warnings); its JSDoc now states that contract explicitly because the exit-guard + finally pairing relies on it. New tests in `src/runtime/docker.test.ts` pin the invariants: (a) `cleanupDocker` invoked twice on the same `DockerSpawnResult` is a no-op the second time — sentinel files recreated under the overlay tempdir and sandbox tempdir after the first call survive the second call, and the cleared timeout timer never fires; (b) after a successful `withDockerExitGuard` body, `process.listenerCount("exit")` returns to its pre-run value and no new listener identity remains in `process.listeners("exit")`; (c) the same holds when the body throws — `await assert.rejects(...)` confirms the listener is still removed and `cleanupDocker` ran exactly once; (d) when `dockerResult` is `undefined`, the helper registers no listener and just returns the body's value. Existing E2E / run tests pass unchanged. Docs updated in `docs/sandboxing.md` (extended the **Signal-safe cleanup** paragraph under **Runtime behavior** to describe the `withDockerExitGuard` try/finally pairing, the abnormal-exit role of the guard, the no-op behavior for non-Docker runs, and `cleanupDocker`'s idempotency contract).
+- **Fix — Cross-module `run` applies the callee module's config:** Previously, when a workflow in module A reached a callee in module B via `run alias.workflow()`, both module B's module-level `config { … }` and the callee workflow's `config { … }` block were silently ignored — the caller's effective env carried through as-is. This was inconsistent with the other three call types (root entry, same-module `run`, cross-module `ensure`) and bit `.jaiph/ensure_ci_passes.jh` in particular: that module declares `agent.backend = "cursor"`, but when `engineer.jh` (backend `claude`) called `run ci.ensure_ci_passes()`, the CI-fix prompts silently ran on `claude`. A module's `config` should describe how *that* module's workflows run, regardless of who called them. `NodeWorkflowRuntime` now layers the callee's module-level metadata then the callee's workflow-level metadata on top of the caller's effective env when entering a cross-module `run` — same mechanics as the root-entry path, respecting `${NAME}_LOCKED` env flags (environment still always wins). The caller's scope is restored exactly when the call returns, so sibling isolation still holds. New / updated tests: `src/runtime/kernel/node-workflow-runtime.artifacts.test.ts` adds three cases — (a) module A (`agent.default_model = "model-a"`) runs `run b.show()` where module B sets `agent.default_model = "model-b"` and `show` logs the model: callee logs `model-b`, the next step in A's workflow logs `model-a` again (scope restored); (b) callee workflow-level config wins over callee module-level config on the cross-module path; (c) with `JAIPH_AGENT_MODEL` exported in the environment (locked), the callee's config does NOT override it. `e2e/tests/86_metadata_scope_nested.sh` and `e2e/tests/87_workflow_config.sh` are updated where they previously asserted the old (ignore) behavior — the nested call now sees the callee's backend during execution and the caller's backend is restored after. The now-stale `NOTE` comment at the top of `.jaiph/ensure_ci_passes.jh` (which warned that cross-module callers' config would win) is removed. Docs updated in `docs/configuration.md` ("Scoping across nested calls" table — the cross-module row no longer says the callee's config is ignored; "Module-level config" paragraph rewritten to describe nested `run` as same- *or* cross-module and to flag same-module `ensure` as the one remaining caller's-scope-as-is case).
+- **Tooling — Documentation prompts follow a vendored Diátaxis skill:** The three prompts in `.jaiph/docs_parity.jh` (`update_from_task`, `docs_page`, `docs_overview`) used to inline the same ad-hoc "expert technical writer" `role` const and repeat its guiding principles in prose. Each prompt now opens with an instruction to read and follow `.jaiph/skills/documentation-writer/SKILL.md` **before doing anything else**; the skill is referenced by explicit path so both the Claude and Cursor backends can `Read` it directly without depending on agent-specific skill auto-discovery directories. The skill is vendored from `github/awesome-copilot` at `.jaiph/skills/documentation-writer/SKILL.md` (committed, not gitignored; the file header records the upstream URL, blob SHA, and copy date so it can be re-synced) — vendoring rather than `npx skills add` at runtime keeps docs runs offline-safe and reproducible. It supplies the **Diátaxis** framework's four document types (tutorial / how-to / reference / explanation), the clarify → outline → write workflow, and the four guiding principles (clarity, accuracy, user-centricity, consistency). The inline `role` const is slimmed to project-specific context the skill does not cover — TypeScript / Bash fluency for verifying docs against the implementation, `docs/architecture.md` as the single source of truth (do not trust existing docs blindly), and the constraint that navigation between docs pages is provided by the Jekyll template in `docs/_layouts/docs.html` (no manual "More Documentation" blocks). `jaiph compile .jaiph` and `jaiph format --check .jaiph/docs_parity.jh` stay green.
+- **Refactor — Replace the `parseBlockStatement` keyword cascade with a `STATEMENT` dispatch table:** `parseBlockStatement` in `src/parse/workflow-brace.ts` used to dispatch each statement form via a long ordered cascade of `startsWith` + regex tests (`"run async "` before `"run "`, `"prompt "` before bare assignment, etc.), so adding a new keyword meant finding the right slot in the cascade and any reordering risked changing which branch fired. The cascade is replaced by a `STATEMENT: Record` table keyed by the leading keyword: the dispatcher tokenizes the first identifier on the trimmed line, looks it up in the table, and invokes the matching handler — which returns a `{ step, nextIdx }` result, returns `null` to fall through, or calls `fail(...)` to abort. The current rows are `if`, `for`, `const`, `fail`, `wait`, `ensure`, `run`, `prompt`, `log`, `logerr`, `return`, and `match`; each handler (`tryParseIf`, `tryParseFor`, `tryParseConst`, `tryParseFail`, `tryParseWait`, `tryParseEnsure`, `tryParseRun`, `tryParsePrompt`, `tryParseLog`, `tryParseLogerr`, `tryParseReturn`, `tryParseStandaloneMatch`) carries the same regex / `startsWith` checks that used to live inline in the cascade — body shapes are unchanged. After dispatch, two non-keyword fallbacks fire in order: `trySend` (matches `channel <- rhs` via `matchSendOperator`) and `shellFallthrough` (everything else becomes a shell `exec` step). Assignment-shape error guards (`name = prompt …`, `name = run …` without `const`) live in a separate `applyAssignmentGuards(c)` helper that runs before the table lookup and either calls `fail(...)` or returns; the `forRule` rejection of `prompt …` inside rules also moves here. The shared per-line context (`filePath`, `lines`, `idx`, `innerRaw`, `inner`, `innerNo`, `trivia`, `forRule`, `opts`) is now a `BlockCtx` record threaded into every handler, so handlers take one argument instead of nine. Surface syntax is unchanged, every existing parse-error message / line / col is preserved, and the full golden corpus passes byte-for-byte. New tests pin the invariants: `src/parse/parse-error-snapshot.test.ts` walks every `=== name` block in `test-fixtures/compiler-txtar/parse-errors.txt`, parses each via `loadModuleGraph`, and asserts the captured `{ file, line, col, code, message }` matches the snapshot stored at `test-fixtures/compiler-txtar/parse-errors-snapshot.json` bit-for-bit — any drift in parser error wording or location fails the test (refreshable with `UPDATE_SNAPSHOTS=1` only after confirming the change is intentional). `src/parse/parse-synthetic-keyword.test.ts` pins the two-file extension contract: it patches `STATEMENT` at runtime with a synthetic `zzznoop` handler, asserts `parseBlockStatement` dispatches to it, asserts the same input falls through to the shell handler when the row is absent, and greps `src/parse/workflow-brace.ts` and `src/parse/core.ts` to confirm the `STATEMENT` table and the `JAIPH_KEYWORDS` reserved set each live in exactly one file. Adding a new top-level keyword is now a two-place change: one row in `STATEMENT` (`workflow-brace.ts`) and one entry in `JAIPH_KEYWORDS` (`core.ts`). `BlockCtx`, `BlockResult`, `BlockHandler`, and `STATEMENT` are exported so external test files can stage synthetic handlers without forking the parser. Out of scope: the wider tokenizer rewrite (the seven independent `inDoubleQuote` / `inTripleQuote` / `braceDepth` scanners across `src/parse/`, the line-walking `{ step, nextIdx }` contract, and the per-handler regex bodies are deferred — this refactor only changes the *dispatch shape* inside `parseBlockStatement`, not the scanning underneath). User-visible contracts — surface syntax, CLI behavior, `jaiph format` round-trip, run artifacts, banner, hooks, exit codes, `__JAIPH_EVENT__` streaming — are unchanged. Docs updated in `docs/architecture.md` (extended **Parser** bullet with a new **Keyword dispatch table** paragraph), `docs/contributing.md` (new **Statement-dispatch-table shape** row in the test-layer table), and `docs/grammar.md` (extended the EBNF aside to name the `STATEMENT` table). Implements `design/2026-05-15-parser-compiler-simplification.md` § Refactor 1 AC3 / AC4 / AC5 (the full tokenizer rewrite remains future work).
+- **Refactor — Unify `catch` / `recover` parsing into a single attached-block routine sharing the top-level statement parser:** `src/parse/steps.ts` used to contain three near-identical 100+ line functions — `parseEnsureStep`, `parseRunCatchStep`, and `parseRunRecoverStep` — that parsed the same syntactic shape (` (binding) { body } | single-stmt`) and differed only in which host step they decorated (`ensure` vs `run`) and the literal keyword (`catch` vs `recover`). Their body parser, `parseCatchStatement` (~280 lines), was a stripped-down copy of `parseBlockStatement` that recognized only a fixed subset of statement forms (e.g. a `for … in …` head fell through to a shell command) and diverged in subtle ways — the same fix had to land in two places, and divergence wasn't always caught by tests. All four functions and every helper that existed only to serve them are deleted from `src/parse/steps.ts`. The file drops from **757 → ~140 lines**. The new shape: one entry point `parseAttachedBlock(filePath, lines, idx, innerNo, innerRaw, keyword: "catch" | "recover", textAfterKeyword, trivia)` in `src/parse/steps.ts` parses the bindings (`()` — exactly one identifier, with the same too-many / too-few / non-identifier errors as before) and dispatches on the body shape: a `{` at end of host line walks the existing brace-block scanner and delegates each body statement to `parseBraceBlockBody`, an inline `{ stmt[; stmt]* }` splits on `;` via the shared `splitStatementsOnSemicolons` and dispatches each fragment, and a bare single statement is parsed in-place. In all three cases the body statements run through the **same** `parseBlockStatement` (`src/parse/workflow-brace.ts`) that handles top-level statements — there is no mini parser for catch/recover bodies anymore. The host side moves to one helper `parseRunOrEnsure(filePath, lines, idx, …, host: "run" | "ensure", hostBody, isAsync, captureName, trivia)` in `src/parse/workflow-brace.ts`, called from `parseBlockStatement`'s three call sites (`ensure ref(...)`, `run ref(...)`, `run async ref(...)`). It scans `hostBody` once for a trailing ` recover` (run-only) then ` catch ` segment, parses the host call before the keyword, and delegates the attached clause to `parseAttachedBlock`. "Is this statement allowed inside a catch/recover body?" is now a validator concern — `WORKFLOW_SCOPE` and `RULE_SCOPE` in `validate-step.ts` already gate which step types are accepted in each scope, so rules still reject unstructured shell inside `catch` / `recover` bodies; workflows still accept it. New tests in `src/parse/parse-attached-block.test.ts` pin the invariants: AC1 — an LoC test caps `src/parse/steps.ts` at **≤200 lines** and a grep test fails if any function named `parse(Run)?(Catch|Recover|EnsureStep)` reappears; AC2 — a `for line in items { log "$line" }` statement (a `parseBlockStatement`-only form historically) is parsed as a `for_lines` step at the top level, inside `ensure check() catch (e) { … }`, and inside `run target() recover(e) { … }` — proving `parseBlockStatement` is the single entry point for any statement inside a catch / recover body and there is no separate mini parser; AC3 — a 10-case error-snapshot battery asserts every existing parse error message and column (bindings missing, too many bindings, empty inline / multiline block, unterminated multiline block, missing-paren for both `catch` and `recover` on both `run` and `ensure` hosts) is preserved bit-for-bit. The full parser / validator / emitter golden corpus (`src/transpile/compiler-golden.test.ts`, `src/transpile/compiler-edge.acceptance.test.ts`, `parse-steps.test.ts`, `parse-bare-call.test.ts`, `parse-run-async.test.ts`, and the txtar / golden-AST fixtures) passes byte-for-byte (AC4). User-visible contracts — surface syntax for `catch` / `recover`, CLI behavior, `jaiph format` round-trip, run artifacts, banner, hooks, exit codes, `__JAIPH_EVENT__` streaming — are unchanged. Out of scope: the wider tokenizer rewrite (Refactor 1, deferred); validator changes beyond the per-keyword scope rules that already exist. Docs updated in `docs/architecture.md` (extended **Parser** bullet with a new **Unified `run` / `ensure` host parsing** paragraph), `docs/contributing.md` (new **Attached-block parser shape** row in the test-layer table), and `docs/grammar.md` (replaced the stale `parseCatchStatement` reference in the EBNF aside with a note that `parseAttachedBlock` delegates to `parseBlockStatement`). Implements `design/2026-05-15-parser-compiler-simplification.md` § Refactor 2.
+- **Refactor — Decouple the validator from runtime semantics:** `src/transpile/validate.ts` (now `validate-step.ts`) used to `import { tripleQuotedRawForRuntime } from "../runtime/orchestration-text"` so it could compute "what the runtime will see" when checking the content of a triple-quoted `match`-arm body. That was a one-way dependency from compile-time on runtime semantics — a layering inversion that would have kept biting as the runtime grew more such helpers. The canonicalization helper moves into the parser layer as `canonicalizeTripleQuotedString` in `src/parse/triple-quote.ts` (same algorithm: validate the outer `"…"` shape, unescape DSL-quoted inner with `\"` → `"` and `\\` → `\`, then re-wrap via `tripleQuoteBodyToRaw(dedentCommonLeadingWhitespace(inner))`). Both the validator (`validate-step.ts`'s `validateMatchExpr`) and the runtime (`src/runtime/kernel/node-workflow-runtime.ts`'s match-arm dispatch in `runMatchExpr`) now import that helper from `src/parse/`; the wrapper file `src/runtime/orchestration-text.ts` is deleted. New tests pin the invariants: `src/transpile/no-runtime-imports.test.ts` (AC1) greps every non-test `*.ts` under `src/transpile/` and fails if any `from "…/runtime/…"` import reappears, so compile-time code can no longer reach into runtime semantics; `src/parse/canonicalize-triple-quoted.test.ts` (AC2) parses every `.jh` under `test-fixtures/` and `examples/`, collects every triple-quoted `match`-arm body across workflow / rule step trees, and asserts `canonicalizeTripleQuotedString(body) === legacyTripleQuotedRawForRuntime(body)` bit-for-bit (the legacy implementation is inlined in the test as the parity baseline). Existing `validate-string.test.ts` cases and the golden corpus pass unchanged (AC3); `npm run build` passes with zero TypeScript strict-mode errors (AC4). User-visible contracts — CLI behavior, `jaiph format` round-trip, run artifacts, banner, hooks, exit codes, `__JAIPH_EVENT__` streaming, and the full golden corpus — are unchanged byte-for-byte. Out of scope: rethinking what the canonical form *is* — this refactor only relocates the helper. Docs updated in `docs/architecture.md` (new **No compile-time → runtime imports** bullet under **Validator**; extended **Parser** bullet to document `canonicalizeTripleQuotedString` alongside `parseTripleQuoteBlock`) and `docs/contributing.md` (new **Compile-time / runtime layering** row in the test-layer table). Implements `design/2026-05-15-parser-compiler-simplification.md` § Appendix E.
+- **Refactor — Replace the 1,441-line validator switch with a per-step visitor table indexed by scope:** `src/transpile/validate.ts` used to be one ~1,441-LoC function with two near-identical inner walkers (`validateRuleStep` ~250 lines, `validateStep` ~350 lines): every step type's validation was written twice with subtle differences, and the five-check call-shape sequence (`validateNoShellRedirection` → `validateNestedManagedCallArgs` → `validateRef` → `validateArity` → `validateBareIdentifierArgs`) was repeated by hand at 6+ sites per side — at least 12 places to keep in sync. Both inner walkers and every duplicated check site are gone. The validator now spans two files. `validate.ts` (~430 LoC) keeps the **outer** layer: import / channel-route / test-block checks and `walkStepTree` (the single descent that builds `{ knownVars, promptSchemas, flat }`). `validate-step.ts` (~1,025 LoC) holds the **per-step** visitor: a single `validateStep(step, ctx)` entry, a `VALIDATORS: Record` table with one row per step variant (`trivia`, `const`, `return`, `send`, `say`, `exec`, `if`, `for_lines`), one `validateExpr(expr, …)` dispatcher over the 8 `Expr.kind` values, and one `validateCallable(expr, ctx)` helper that runs the five managed-call-shape checks once for both `call` (`run`) and `ensure_call` (`ensure`) — parameterized by the scope's `runRefExpect` and the target kind. Rule-vs-workflow differences are captured in a `Scope` value (`WORKFLOW_SCOPE` / `RULE_SCOPE`) with three fields: `allowSteps: Set` (single set-lookup gate at the top of `validateStep` — rules reject `send` outright; rules also reject `prompt` and `run async` from inside `exec` bodies), `runRefExpect: RefExpectMessages` (workflow vs rule semantics for `run ref(…)`), and `withPromptSchemas: boolean` (workflows collect prompt-returning bindings, rules skip schema collection). `ValidatorCtx` threads the scope plus the precomputed `knownVars`, `promptSchemas`, and `recoverBindings` into every visitor — none of which are re-derived per step. Every existing `E_VALIDATE` error message and source location is preserved bit-for-bit: the entire `validate-*.test.ts` suite, `src/transpile/compiler-golden.test.ts`, `src/transpile/compiler-edge.acceptance.test.ts`, and the txtar / golden-AST corpora all pass unchanged. New acceptance tests in `src/transpile/validate-visitor.test.ts` pin the invariants: an LoC test caps `validate.ts` at **≤700 lines** so new per-step logic lands in `validate-step.ts` (AC1); a JSON snapshot over every `validate-*` txtar fixture (`test-fixtures/compiler-txtar/validate-errors.txt` + `validate-errors-multi-module.txt`) stored in `test-fixtures/compiler-txtar/validate-diagnostics-snapshot.json` asserts each diagnostic's `{ code, line, col, message }` against `collectDiagnostics(graph)` bit-for-bit (AC3 — refreshable via `UPDATE_SNAPSHOTS=1` only after confirming the message change is intentional); and an "unknown step type" test casts a synthetic step variant into `WorkflowStepDef`, runs `validateStep` in both `WORKFLOW_SCOPE` and `RULE_SCOPE`, and asserts each call produces exactly one diagnostic with the documented `internal: no validator for step type "…"` message (AC4 — proving that adding a new step type costs exactly one row in `VALIDATORS`). The existing `src/transpile/validate-single-walk.test.ts` still passes — `walkStepTree`'s internal `descend` remains the only recursive `WorkflowStepDef[]` walker in `validate.ts` (AC2). The `diagnostics-collector.test.ts` "fatal allowlist" scan now sums `throw jaiphError(` counts across `validate.ts` + `validate-step.ts` (both files are zero) and `diag.error(` counts likewise (≥40). User-visible contracts — CLI behavior, `jaiph format` round-trip, run artifacts, banner, hooks, exit codes, `__JAIPH_EVENT__` streaming, and the full golden corpus — are unchanged byte-for-byte. Out of scope: changes to validation rules (the *what* — this refactor only changes the *how*), parser changes, AST changes. Docs updated in `docs/architecture.md` (rewrote the **Validator** section to describe the two-file split, `VALIDATORS` table, `Scope` value, and the single `validateCallable` helper), `docs/contributing.md` (new **Validator visitor-table shape** row in the test-layer table), and `docs/grammar.md` (refreshed two stale `validateRuleStep` references to point at the new visitor / `RULE_SCOPE`). Implements `design/2026-05-15-parser-compiler-simplification.md` § Refactor 4.
+- **Refactor — Replace fail-fast errors with a `Diagnostics` collector that aggregates every recoverable error per compile:** Today `fail()` (in `src/parse/core.ts`) and `jaiphError()` (in `src/errors.ts`) both throw on the first error, so a user fixed one error, recompiled, hit the next, recompiled, and so on. The validator also pre-ordered some checks defensively because it knew it would only get to surface one error per run. That model is replaced. A new `Diagnostics` class lives in `src/diagnostics.ts` and exposes `add(d)`, `error(file, line, col, code, message)` (records the diagnostic and short-circuits the current unit through a `BailoutError`), `capture(fn)` (runs `fn` and absorbs both `BailoutError` and any thrown legacy `jaiphError` whose message parses as `path:line:col CODE message` — turning the throw into a recoverable entry without re-throwing), `hasErrors()` / `hasFatal()`, `sorted()` (stable order by file, then line, then column), `formatLines()` (one `path:line:col CODE message` per line), and a legacy `throwFirstIfAny()` bridge that throws the first sorted diagnostic via `jaiphError` so existing single-error call sites and per-error tests are unchanged. `src/transpile/validate.ts` exposes a new `collectDiagnostics(graph): Diagnostics` entry that walks the import closure and never throws on user-level errors; the previous `validateReferences(graph)` is now a thin wrapper that calls `collectDiagnostics` and then `throwFirstIfAny()`, preserving the throw-on-first contract for `emitScriptsForModuleFromGraph` / `buildScriptsFromGraph` and for every existing `parse-*.test.ts` / `validate-*.test.ts` fixture that asserts one specific `{ message, line, col, code }`. Inside `validate.ts` every `throw jaiphError(...)` site at user-level (~50 sites across import resolution, channel-route validation, per-rule and per-workflow step walks, prompt schema checks, and `validateTestBlocks`) is migrated to `diag.error(...)`; each top-level unit is wrapped in `diag.capture(...)` (per-import block, per-channel route, per-rule walk, per-rule step, per-workflow walk, per-workflow step, per-test-block step) so the bailout from one error unwinds only that unit and the next sibling still runs. The four leaf validation helpers (`validate-ref-resolution.ts`, `validate-string.ts`, `validate-prompt-schema.ts`, `shell-jaiph-guard.ts`) still throw via `jaiphError`, but every caller wraps them in `diag.capture(...)`, which converts the thrown error into a recoverable diagnostic and returns. The CLI command `jaiph compile` (`src/cli/commands/compile.ts`) is rewritten to route through `collectDiagnostics`: it accumulates every error from every entry's import closure, sorts them by `(file, line, col)`, and prints the full set — as a single JSON array on stdout under `--json`, or as one `path:line:col CODE message` line per diagnostic on stderr otherwise — exiting **1** on any non-empty diagnostic set. Fatal aborts during graph load or parsing (unterminated triple-quote, unterminated brace block, missing imports during graph build) are reported as a single diagnostic for the affected entry; the command then continues with the next entry. New tests in `src/transpile/diagnostics-collector.test.ts` pin the invariants: a fixture with three independent errors (duplicate import alias, undefined channel, unknown `run` target in one workflow body) asserts `collectDiagnostics(graph)` returns all three in source order (AC1); a source-tree scan asserts `validate.ts` holds **zero** `throw jaiphError(` sites and **≥40** `diag.error(` sites, and that every remaining `throw jaiphError(` under `src/` lives in the documented fatal allowlist — `src/diagnostics.ts` (legacy bridge), `src/parse/core.ts` (parser `fail()`), `src/cli/commands/test.ts` (test-file shape fatal), `src/transpile/module-graph.ts` (loader), `src/transpile/validate-string.ts`, `src/transpile/validate-prompt-schema.ts`, `src/transpile/validate-ref-resolution.ts`, `src/transpile/shell-jaiph-guard.ts` (leaf helpers, each captured) (AC3); and a CLI test runs `jaiph compile --json` against the same fixture and asserts the returned array has all three diagnostics and `status !== 0` (AC4). Existing single-error tests (every `parse-*.test.ts` and `validate-*.test.ts` that pins one specific `{ message, line, col, code }`) still pass because `validateReferences` continues to throw the first sorted diagnostic (AC2); `npm test` and `npm run build` pass (AC5). User-visible contracts on the `jaiph run` / `jaiph test` paths — banner, hooks, run artifacts, exit codes, `__JAIPH_EVENT__` streaming, and golden corpus — are unchanged. Out of scope: changing what counts as an error (this refactor only changes the *how*); LSP integration follows in a separate task. Docs updated in `docs/architecture.md` (new **Diagnostics collector (recoverable errors)** bullet under **Validator**; updated **System overview** to describe the two entry points and the new `jaiph compile` behavior), `docs/cli.md` (new **Multiple-error reporting** paragraph and refined **`--json`** description under **`jaiph compile`**), and `docs/contributing.md` (new **Diagnostics collector shape** row in the test-layer table). Implements `design/2026-05-15-parser-compiler-simplification.md` § Appendix B.
+- **Refactor — Fold the validator's three workflow pre-passes into a single step-tree walk:** `src/transpile/validate.ts` used to descend each workflow's / rule's step tree four times before its main check loop finished — `collectKnownVars`, `collectPromptSchemas`, `validateImmutableBindings`, and the per-step validator itself — each re-implementing the same recursion over `if` / `for_lines` / `catch` / `recover` with subtly different rules, so "what counts as a binding here" fixes had to land in two or three walkers. The three pre-pass helpers are deleted. One new helper `walkStepTree(filePath, steps, envDecls, params, declLoc, moduleScripts, parseSchemaFieldNames, { withPromptSchemas })` descends the tree once and returns `{ knownVars, promptSchemas, flat }`: it accumulates `knownVars` (env decls + params + every nested `const` / capture / `for_lines` iterator), `promptSchemas` (top-level prompt-returning bindings — workflow walks set `withPromptSchemas: true`, rule walks set it `false`), enforces immutable-binding and `script`-collision rules inline through a shared `bindings` map (with a fresh inner map under each `for_lines` body 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 (`recoverBindings: Set | undefined`) attached. The per-workflow and per-rule validator loops now iterate that flat list non-recursively — the `if` / `for_lines` / `catch` / `recover` recursion that used to live inside `validateStep` / `validateRuleStep` is gone. `walkStepTree`'s internal `descend` is the only recursive helper in the file that takes a `WorkflowStepDef[]`. Failure order matches the prior "binding errors first, then per-step errors" behavior because binding checks fire during the descent, before any flat-list iteration starts. Every existing `E_VALIDATE` error message and location is preserved bit-for-bit: the full `validate-*.test.ts` suite, `src/transpile/compiler-golden.test.ts`, `src/transpile/compiler-edge.acceptance.test.ts`, and the txtar / golden-AST corpora all pass unchanged. New tests pin the invariants: `src/transpile/validate-single-walk.test.ts` greps `validate.ts` and fails if any of `collectKnownVars`, `collectPromptSchemas`, or `validateImmutableBindings` reappear by name (AC1), and a textual AST scan asserts that at most one recursive helper whose parameter list mentions `WorkflowStepDef[]` exists in the file (AC2). User-visible contracts — CLI behavior, `jaiph format` round-trip, run artifacts, banner, hooks, exit codes, `__JAIPH_EVENT__` streaming, and the full golden corpus — are unchanged. Out of scope: the visitor-table refactor (Refactor 4) and any change to validation rules. Docs updated in `docs/architecture.md` (new **Single workflow walk** bullet under **Validator**) and `docs/contributing.md` (new **Validator single-walk shape** row in the test-layer table). Implements `design/2026-05-15-parser-compiler-simplification.md` § Appendix C.
+- **Refactor — Collapse the AST around a single `Expr` type, eliminating the three "managed call" encodings:** The same concept "a managed call that yields a value" used to be encoded three different ways: as a statement (`{ type: "run", workflow, args }`), as a const RHS (`{ kind: "run_capture", ref, args }`), and as a `managed:` sidecar on `return` / `log` / `logerr` whose `value` / `message` carried a placeholder string (`"__match__"`, `"run inline_script"`, etc.). Inline scripts added a fourth (`run_inline_script_capture`); `prompt`, `match`, and `ensure` captures repeated the same dual representation. The validator, formatter, emitter, and runtime each had to handle both branches at every site. All three encodings are gone. The semantic AST now has a single `Expr` tagged union — `literal | call | ensure_call | inline_script | prompt | match | shell | bare_ref` — used everywhere a value can appear: `const name = `, `return `, `send channel <- `, the message of `log` / `logerr` / `fail`, and the body of an `exec` step (the new statement-form managed call, where the value is consumed for its side effects plus optional capture). `ConstRhs` and `SendRhsDef` are deleted as separate types. The `managed:` sidecar field is deleted from `WorkflowStepDef`. The placeholder strings `"__match__"`, `"run inline_script"`, and `"__JAIPH_MANAGED__"` no longer appear anywhere under `src/`. `WorkflowStepDef` collapses from 14 variants to **8** (`exec`, `const`, `return`, `send`, `say`, `if`, `for_lines`, `trivia`): `exec` is the new managed-statement form covering the prior `run` / `ensure` / `run_inline_script` / `prompt` / `shell` / standalone `match` cases (the discriminator now lives inside `body.kind`, with `captureName` / `catch` / `recover` as step-level attributes); `say` covers the prior `log` / `logerr` / `fail` cases (`level: "fail"` aborts the workflow with the message, otherwise the message is written to the corresponding stream); `comment` / `blank_line` collapse into a single `trivia` variant (formatter-only, skipped by validator and runtime). The parser builds `Expr` nodes directly: `parseConstRhs` returns `{ value: Expr }`; `parseSendRhs` returns `{ value: Expr }`; `parsePromptStep` returns an `exec` step whose `body` is an `Expr.prompt`; `return run …` / `return ensure …` / `return match …` / `return run \`…\`(…)` build `Expr.call` / `Expr.ensure_call` / `Expr.match` / `Expr.inline_script` directly with no sidecar; `log run \`…\`(…)` and `logerr run \`…\`(…)` build `say` steps whose `message` is an `Expr.inline_script`. Downstream consumers compress accordingly: the validator switches on the 8-variant `WorkflowStepDef.type` and the 8-kind `Expr.kind` with no "literal value vs managed sidecar" fork; the formatter renders each `Expr` through one `emitExpr` helper instead of branching on a sidecar; the runtime has one private `evaluateExpr(scope, expr, …)` dispatcher that `const` / `return` / `send` / `say` / `exec` all delegate to (which runs the managed call for `call` / `ensure_call` / `inline_script`, walks `match` arms, schema-checks `prompt`, and interpolates `literal` via `interpolateWithCaptures`); the script-emit walk in `src/transpile/emit-script.ts` finds inline-script bodies by recursing into each step's `Expr` payload rather than enumerating the four legacy carriers. New tests pin the invariants: `src/types-shape.test.ts` is a compile-time exhaustive `switch` plus runtime tuple assertion that `WorkflowStepDef` has exactly **8** variants and `Expr` has exactly **8** kinds (AC2), a `grep` over every non-test `.ts` file under `src/` that fails if any of the placeholder strings (`"__match__"`, `"run inline_script"`, `"__JAIPH_MANAGED__"`) reappear (AC1), and an export-surface check that fails if `ConstRhs` or `SendRhsDef` are re-exported from `src/types.ts` (AC3). Updated parser tests in `src/parse/parse-return.test.ts`, `src/parse/parse-const-rhs.test.ts`, `src/parse/parse-prompt.test.ts`, `src/parse/parse-send-rhs.test.ts`, `src/parse/parse-steps.test.ts`, `src/parse/parse-inline-script.test.ts`, and `src/parse/parse-bare-call.test.ts` assert the new `Expr` shape directly for `return run …`, `return ensure …`, `return match … { … }`, `return run \`…\`(…)`, `log run \`…\`(…)`, and `const x = prompt …` (AC4). The golden corpus (`src/transpile/compiler-golden.test.ts`, `src/transpile/compiler-edge.acceptance.test.ts`) passes byte-for-byte against the emitted bash output; `src/format/roundtrip.test.ts` round-trips bit-for-bit on every fixture; `npm run build` passes with zero TypeScript strict-mode errors (AC5 / AC6). Golden AST fixtures under `test-fixtures/golden-ast/expected/` are regenerated to reflect the new step shapes (`exec` wrapping every managed call, `say` replacing `log` / `logerr` / `fail`, `trivia` replacing `comment` / `blank_line`, `Expr` value/message/body payloads). User-visible contracts — CLI behavior, `jaiph format` round-trip, run artifacts, banner, hooks, exit codes, `__JAIPH_EVENT__` streaming, and the full golden corpus — are unchanged byte-for-byte. Out of scope: surface syntax, the validator's deeper structural rewrite (Refactor 4), and parser internals (Refactors 1 & 2). Docs updated in `docs/architecture.md` (rewrote the **AST / Types** bullet to describe the single `Expr` sum and the 8-variant `WorkflowStepDef`; updated **Validator**, **Formatter**, **Node Workflow Runtime**, and **Trivia / CST layer** bullets to drop the dual-representation language; rewrote the `match_expr` mention in **CLI progress reporting pipeline** to use `Expr.kind === "match"`) and `docs/contributing.md` (new **`Expr` / step-variant shape** row in the test-layer table). Implements `design/2026-05-15-parser-compiler-simplification.md` § Refactor 3.
+- **Refactor — Collapse `bareIdentifierArgs` into a typed `Arg[]` on every call site:** Every call-bearing AST node used to carry the call arguments twice — `args: string` (the raw source between the parens) and `bareIdentifierArgs?: string[]` (a re-parse of which of those arguments happened to be bare identifiers). The validator had to remember to check both fields and call a hand-rolled `validateBareIdentifierArgs` helper at every site; the emitter re-parsed `args` from scratch because it didn't trust either field on its own. Both fields are gone. The parser now classifies each argument once, at parse time, into a new typed sum `type Arg = { kind: "literal"; raw: string } | { kind: "var"; name: string }` and stores it on every call-bearing node as `args?: Arg[]`. Affected nodes: `run` / `ensure` workflow steps, `run_inline_script` steps, the `managed` sidecar on `return` / `log` / `logerr` (in all four shapes — `run`, `ensure`, `run_inline_script`, `match`), the `run_capture` / `ensure_capture` / `run_inline_script_capture` const RHS variants, and the `run` send RHS. Downstream consumers walk the typed list directly: the validator's per-call check sequence is now arity (`args.length`), shell-redirection rejection on `literal` raws, nested-unmanaged-call rejection on `literal` raws, ref resolution, and `var`-arg resolution against in-scope bindings via a new `validateArgVarRefs` (the standalone `validateBareIdentifierArgs` helper is deleted); the formatter renders each `Arg` directly (`var` → bare name, `literal` → raw) instead of re-tokenizing a `${ident}`-rewritten string; the runtime turns `Arg[]` back into the space-separated argv string via `argsToRuntimeString` in `src/parse/core.ts` (`var` → `${name}`, `literal` → raw) so the existing handle-resolution / interpolation path is unchanged. New tests pin the invariants: `src/parse/arg-ast-shape.test.ts` is a compile-time assertion that `bareIdentifierArgs` does not appear on `WorkflowStepDef` (`ensure`, `run`, `run_inline_script`, `log.managed`, `logerr.managed`, `return.managed` in `run` / `ensure` / `run_inline_script` shapes), `ConstRhs` (`run_capture`, `ensure_capture`, `run_inline_script_capture`), or the `run` `SendRhsDef` variant (AC1); `src/parse/arg-grep.test.ts` walks every non-test `.ts` under `src/parse/` and `src/transpile/` and fails if any production file matches `args.split(",")` or the bare token `bareIdentifierArgs` (AC2), and separately fails if any file under `src/transpile/` references `validateBareIdentifierArgs` (AC3). The golden compiler corpus, `validate-*.test.ts` files, and the golden AST corpus pass byte-for-byte (AC4); `npm run build` passes with zero TypeScript strict-mode errors (AC5). User-visible contracts — CLI behavior, `jaiph format` round-trip, run artifacts, banner, hooks, exit codes, `__JAIPH_EVENT__` streaming, and the full golden corpus — are unchanged. Out of scope: the full `Expr` collapse (next task) and surface syntax. Docs updated in `docs/architecture.md` (extended **AST / Types** bullet documenting the typed `Arg` sum; updated **Validator** and **Formatter** bullets to drop the dual representation), `docs/contributing.md` (new **Call-args AST shape** row in the test-layer table), and `docs/spec-async-handles.md` (replaces the stale `commaArgsToSpaced` reference with `argsToRuntimeString`). Implements `design/2026-05-15-parser-compiler-simplification.md` § Appendix D.
+- **Refactor — Split source-fidelity data from the semantic AST into a `Trivia` (CST) layer:** Around ten fields whose only consumer was the formatter — `leadingComments` on imports / script imports / channels / `const` decls / `test` blocks, `configLeadingComments`, `trailingTopLevelComments`, `configBodySequence` (both module- and workflow-scoped), `topLevelOrder`, `bareSource` on `return`, the `tripleQuoted` flags on `literal` / `return` / `log` / `logerr` / `fail` / `send` / `const`, and the prompt / script `bodyKind` / `bodyIdentifier` discriminators — are removed from `jaiphModule`, `WorkflowStepDef`, `ConstRhs`, `SendRhsDef`, `WorkflowMetadata`, `ImportDef`, `ScriptImportDef`, `ChannelDef`, `ScriptDef`, and `TestBlockDef`, and re-homed in a new parallel `Trivia` store (`src/parse/trivia.ts`) keyed by AST-node identity (per-node `WeakMap`) plus a small `ModuleTrivia` record for module-level data. The parser exposes `parsejaiphWithTrivia(source, filePath) → { ast, trivia }`; the legacy `parsejaiph(source, filePath)` is now a thin wrapper that drops trivia for callers that don't care (validator, transpiler, runtime, `loadModuleGraph`). The formatter (`emitModule(ast, trivia, opts?)`) is the only consumer of `Trivia`; validator, emitter, transpiler, and runtime never import from `src/parse/trivia.ts`. New tests pin the invariants: `src/parse/trivia-ast-shape.test.ts` is a compile-time assertion (with runtime echo) that none of the listed fields reappear on any semantic AST type (AC1); `src/parse/trivia-grep.test.ts` greps validator and emitter source files and fails if any of them references `Trivia` / `createTrivia` / `NodeTrivia` / `ModuleTrivia` or imports from `parse/trivia` (AC2); `src/format/roundtrip.test.ts` walks every `.jh` under `examples/` and `test-fixtures/golden-ast/fixtures/` and asserts `parse → format → parse → format` converges bit-for-bit (AC3). Golden AST fixtures under `test-fixtures/golden-ast/expected/` are regenerated to drop the moved fields. User-visible contracts (CLI behavior, `jaiph format` round-trip, run artifacts, banner, hooks, exit codes, `__JAIPH_EVENT__` streaming) are unchanged. `npm test` and `npm run build` pass with zero TypeScript strict-mode errors (AC4 / AC5). Out of scope: the `Expr` collapse — this refactor only relocates source-fidelity fields without changing the semantic AST's shape. Docs updated in `docs/architecture.md` (new **Trivia / CST layer** section with anchor `#trivia-cst-layer`, plus updated **Parser**, **AST / Types**, and **Formatter** bullets) and `docs/contributing.md` (new row in the test-layer table). Implements `design/2026-05-15-parser-compiler-simplification.md` § Appendix A.
+- **Refactor — `ModuleGraph` is the single representation of "all `.jh` modules reachable from an entry point, parsed once":** The previous three traversal strategies for compile-time module discovery (validator re-reading imports through `ValidateContext`, `emitScriptsForModule` re-wrapping the same callbacks with an optional `prep` cache, and `buildScripts` walking the filesystem directly) collapse to one path. `parsejaiph(source, filePath)` is now strictly I/O-pure — it can no longer reach `fs`. The single discovery routine `loadModuleGraph(entry, workspaceRoot?)` (`src/transpile/module-graph.ts`) walks the entry plus its transitive `import` closure and returns `{ entryFile, workspaceRoot?, modules: Map }`; every other compile-time consumer takes the graph and never re-reads `.jh` from disk. `validateReferences(graph)` and `emitScriptsForModuleFromGraph(graph, file, rootDir)` operate entirely in-memory. The `ValidateContext` interface (`resolveImportPath` / `existsSync` / `readFile` / `parse` / `workspaceRoot` callbacks) is deleted from `src/transpile/validate.ts`; the validator consumes the graph and uses `existsSync` only to resolve `import script` paths (non-`.jh` bodies). `CompilePrep` / `prepareCompile` / `writeCompilePrep` / `readCompilePrep` and the optional `prep?` parameter on `emitScriptsForModule` / `buildScripts` are gone; `buildScripts(input, outDir, ws?)` now loads a graph internally and `buildScriptsFromGraph(graph, outDir, rootDir?)` is the entry point for callers that already loaded one. `buildRuntimeGraph` accepts either an entry file path (legacy) or an already-loaded `ModuleGraph` — `RuntimeGraph` is a type alias for `ModuleGraph` (the only "all reachable modules" representation in the codebase). The cross-process cache file moves to `/.jaiph-module-graph.json` (deterministic JSON: entries sorted by absolute path, ASTs included verbatim) via `writeModuleGraph` / `readModuleGraph`, and the internal env var the spawned `node-workflow-runner.js` reads is renamed `JAIPH_MODULE_GRAPH_FILE` (replacing `JAIPH_COMPILE_PREP_FILE`). Scope of the env-var hand-off is unchanged: set only for the default local non-Docker `jaiph run` path; `jaiph run --raw`, `jaiph test`, and Docker launches fall back to `loadModuleGraph` from the source file. User-visible contracts — banner, hooks, run artifacts, `run_summary.jsonl`, `return_value.txt`, exit codes, `__JAIPH_EVENT__` streaming, CLI usage, and the full golden corpus (`compiler-golden.test.ts`, `compiler-edge.acceptance.test.ts`) — are unchanged byte-for-byte. New tests (`src/transpile/module-graph.test.ts`, `src/transpile/pipeline-io-purity.test.ts`) stub `node:fs` to throw on any `.jh` read and run the full pipeline against `test-fixtures/` to pin the I/O-purity invariant; another test instruments `parsejaiph` with a call counter to assert no duplicate parses across `loadModuleGraph` → `validateReferences` → `emit` → `buildRuntimeGraph` for fixtures with transitive imports. `src/transpile/compile-prep.ts` and `compile-prep.test.ts` are removed. Docs updated in `docs/architecture.md`, `docs/cli.md`, and `docs/testing.md`. Implements `design/2026-05-15-parser-compiler-simplification.md` § Refactor 5.
+- **Performance — `jaiph install` parallelism:** Missing-library clones now run in parallel through a small bounded-concurrency executor (default 4 in flight), replacing the previous sequential `execSync` loop. The user contract is unchanged: warm-path libraries (target directory exists and `--force` is absent) still skip without invoking `git` for both explicit args and restore-from-lock; failed clones still exit non-zero and do not produce a lock entry; restore-from-lock still does not invent new lock entries. The default clone runner now uses `spawn("git", ["clone", "--depth", "1", …])` so multiple clones can overlap network and process latency. `runInstall` is now `async` and exposes injectable `CloneRunner` / `concurrency` options for testing. Tests cover concurrent overlap (peak in-flight ≥ 2), warm-path skipping for explicit args and restore, invalid-remote and unknown-ref failure paths, mixed success/failure lockfile bookkeeping, and the existing corrupt/missing-lockfile behavior. Docs updated in `docs/cli.md` and `docs/libraries.md`.
# 0.9.4
## Summary
-Maintenance and simplification:
-- **Breaking:** Inbox dispatch is sequential only (parallel config/env removed). Stricter grammar: multiline `config` blocks only; no one-line braced workflows; no semicolon-separated statements in workflow/rule bodies.
-- **Runtime:** Single-line shell steps run in the Node runtime (`sh -c`); script capture only on success; async `run` + `recover` return propagation fixed; mock prompts use JSON arm dispatch and an in-memory response queue; inbox artifact files are written only when a route consumes the channel.
-- **CLI / install:** Failure footers use the **last** failed step in `run_summary.jsonl`; curl install ships `package.json` so stable installs resolve the correct default Docker image tag.
-- **Language:** RHS bare identifiers and bare dotted identifiers are treated as interpolation sugar where applicable.
-- **Library:** `artifacts.save(paths)` in single-argument form (path or newline-separated list); `git format-patch` workflows use `--stdout` so patch bytes are captured.
-- **Repo:** `node-workflow-runtime` split into arg-parser, event-emitter, and mock modules; test directories consolidated under `integration/`, `test-fixtures/`, `test-infra/`; `JAIPH_TEST_MODE` no longer suppresses stderr events in runtime code (constructor option instead).
-- **Docs / DX:** Agent-proxy design note; explicit parse error for `test` blocks outside `*.test.jh`; architecture/inbox corrections; getting-started shortened.
+- Feature: `for in { ... }` loop.
+- Simplifying: Sequential inbox only; stricter grammar (multiline `config`, no one-line braced workflows, no `;` in workflow/rule bodies).
+- Hardening, test refactoring and bug fixes
## All changes
+- **Language:** `for in { … }` in workflows and rules iterates newline-delimited lines of a string binding. Newlines normalize `\r\n` to `\n`; a single trailing empty segment from a final newline is omitted. Lines are not trimmed and empty interior lines are still iterated unless the body skips them (e.g. `if line != "" { … }`). Documented in `docs/language.md`.
+- **Tests / QA:** Unit tests for string line splitting (`src/runtime/string-lines.test.ts`); E2E `e2e/tests/135_for_string_lines.sh`.
- **Breaking — Language:** Inline one-line `config { k = v }` is removed — only the multiline `config {\n … \n}` form parses (matches documented grammar). The formatter no longer emits compact inline `config`, which would be invalid input. Examples such as `examples/async.jh` were migrated.
- **Breaking — Language:** Single-line `workflow name() { stmt }` braced form removed; workflow and rule bodies require one statement per line as in the grammar.
- **Breaking — Language:** Semicolons no longer separate statements in workflow/rule bodies (`splitStatementsOnSemicolons` remains for `match` arms). Multiple statements on one line joined by `;` must be split across lines.
diff --git a/QUEUE.md b/QUEUE.md
index 72264987..64f890c3 100644
--- a/QUEUE.md
+++ b/QUEUE.md
@@ -4,47 +4,12 @@ Process rules:
1. Tasks are executed top-to-bottom.
2. The first `##` section is always the current task.
-3. When a task is completed, remove that section entirely.
-4. Every task must be standalone: no hidden assumptions, no "read prior task" dependency.
-5. This queue assumes **hard rewrite semantics**:
+3. Task that is ready for implementation is marked with `#dev-ready` at the end of the header.
+4. When a task is completed, remove that section entirely.
+5. Every task must be standalone: no hidden assumptions, no "read prior task" dependency.
+6. This queue assumes **hard rewrite semantics**:
* breaking changes are allowed,
* backward compatibility is **not** a design goal unless a task explicitly says otherwise.
-6. **Acceptance criteria are non-negotiable.** A task is not done until every acceptance bullet is verified by a test that fails when the contract is violated. "It works on my machine" or "the existing tests pass" is not acceptance.
-
-***
-
-## Performance — investigate and fix slow installation
-
-**Goal**
-`jaiph install` (and related dependency or bootstrap steps) feels unreasonably slow; find the dominant cost and improve it without weakening reproducibility (lockfile, shallow clone behavior, etc.).
-
-**Scope**
-
-* Profile or instrument the install path (git clone, lockfile I/O, post-install) and document the top 1–3 contributors to latency.
-* Implement targeted fixes (e.g. avoid redundant work, reduce subprocess churn, cache safely) and verify wall-clock improvement on a cold and warm run where applicable.
-
-**Acceptance criteria**
-
-* A short note in the commit or PR description states what was slow and what changed, with before/after rough timings on the same machine.
-* `jaiph install` behavior remains correct: same lockfile semantics and failure modes for bad URLs or missing refs.
-* `npm test` passes.
-
-***
-
-## Performance — investigate and fix slow workflow start (initial 2–4 s lag)
-
-**Goal**
-When starting workflows (e.g. `jaiph run` / first step), users observe a 2–4 second delay before useful work; reduce that lag or explain and eliminate unnecessary startup work (JIT, imports, process spawn, discovery).
-
-**Scope**
-
-* Reproduce the lag with a minimal `.jh` workflow; trace Node startup, module load, and runtime init (`NodeWorkflowRuntime` and friends).
-* Address fixable costs (e.g. defer heavy work, lazy imports, avoid redundant file scans) without changing user-visible workflow semantics.
-
-**Acceptance criteria**
-
-* Documented repro (command + minimal file) and what was measured (time to first event / first step).
-* Measurable reduction in the cold-start path on a representative case, or a clear justification if the lag is irreducible (e.g. external subprocess).
-* `npm test` passes.
+7. **Acceptance criteria are non-negotiable.** A task is not done until every acceptance bullet is verified by a test that fails when the contract is violated. "It works on my machine" or "the existing tests pass" is not acceptance.
***
diff --git a/README.md b/README.md
index baeb4b2c..82fb3d97 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,8 @@
# 
-[jaiph.org](https://jaiph.org) · [Getting Started](docs/getting-started.md) ([jaiph.org/getting-started](https://jaiph.org/getting-started)) · [Setup](docs/setup.md) · [Libraries](docs/libraries.md) · [Language](docs/language.md) · [Grammar](docs/grammar.md) · [CLI](docs/cli.md) · [Configuration](docs/configuration.md) · [Testing](docs/testing.md) · [Hooks](docs/hooks.md) · [Inbox & Dispatch](docs/inbox.md) · [Sandboxing](docs/sandboxing.md) · [Runtime artifacts](docs/artifacts.md) · [Async Handles](docs/spec-async-handles.md) · [Architecture](docs/architecture.md) · [Contributing](docs/contributing.md)
+[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)
+
+> **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). 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).
---
@@ -21,9 +23,9 @@
- **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.
- **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 ([Testing](docs/testing.md)).
-- **Safety and inspectability** — Docker-backed sandbox for **`jaiph run`** (env-controlled; see [Sandboxing](docs/sandboxing.md)); live **`__JAIPH_EVENT__`** on stderr and durable **`.jaiph/runs/`** artifacts ([Architecture](docs/architecture.md)).
-- **Tooling** — `jaiph compile`, `jaiph format`, `jaiph install` / `.jaiph/libs/`, and optional `hooks.json` ([CLI](docs/cli.md), [Hooks](docs/hooks.md)).
+- **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)).
+- **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)).
## Core components
@@ -31,7 +33,7 @@
- **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()` is parse-only across imports.
+- **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).
- **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)**.
@@ -62,9 +64,9 @@ Or install from npm:
npm install -g jaiph
```
-Verify: `jaiph --version`. Switch versions: `jaiph use nightly` or `jaiph use 0.9.4`.
+Verify: `jaiph --version`. Switch versions: `jaiph use nightly` or `jaiph use 0.10.0`.
-Initialize a project (optional): `jaiph init` writes `.jaiph/` with bootstrap workflow, gitignore entries for runs/tmp, and **`SKILL.md`** when the CLI resolves a skill file on disk (`JAIPH_SKILL_PATH`, install-relative `jaiph-skill.md`, or `docs/jaiph-skill.md` under cwd — see [Setup](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 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`.
## Usage
@@ -73,7 +75,7 @@ Initialize a project (optional): `jaiph init` writes `.jaiph/` with bootstrap wo
- Validate without executing: `jaiph compile …` (same `validateReferences` checks as before `jaiph run`; no `scripts/` emission — see [Architecture](docs/architecture.md)).
- Format sources: `jaiph format …` / `jaiph format --check …`.
-Full flags and environment variables: [CLI reference](docs/cli.md). Doc map: [Getting Started](docs/getting-started.md).
+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).
## Example
@@ -102,12 +104,12 @@ workflow default(task) {
./main.jh "add user authentication"
```
-For the full language reference, see [Grammar](docs/grammar.md). For install, workspace layout, libraries, CLI commands, configuration, testing, sandboxing, hooks, inbox dispatch, and on-disk run output, see [Getting Started](docs/getting-started.md) (map), [Setup](docs/setup.md), and [Runtime artifacts](docs/artifacts.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, 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).
## 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 [Getting Started](docs/getting-started.md).
+- **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).
- **Contributor** who wants to improve Jaiph itself? See [Contributing](docs/contributing.md).
## Contributing
diff --git a/design/2026-05-15-parser-compiler-simplification.md b/design/2026-05-15-parser-compiler-simplification.md
new file mode 100644
index 00000000..f2d2d09d
--- /dev/null
+++ b/design/2026-05-15-parser-compiler-simplification.md
@@ -0,0 +1,347 @@
+# Parser & Compiler Simplification — design doc
+
+*Five refactors to compress `src/parse/` and `src/transpile/` by roughly a third, make the AST a clean sum type, and turn "add a new step or keyword" into a one-place change.*
+
+**Status:** design — ready for implementation
+**Date (UTC):** 2026-05-15
+
+---
+
+## Problem
+
+The parser and compiler work, and the golden-test corpus (`src/transpile/compiler-golden.test.ts`, `src/transpile/compiler-edge.acceptance.test.ts`) pins their behavior tightly. But the code has accumulated:
+
+- Parallel cascades of `startsWith` + regex dispatch (`src/parse/workflow-brace.ts`, 615 lines).
+- Seven independent copies of the same quote-aware scanner (`splitCatchStatements`, `splitStatementsOnSemicolons`, `matchSendOperator`, `hasUnquotedSendArrow`, `indexOfClosingDoubleQuote`, `stripQuotedArgContent`, the scanner inside `parseSendRhs`).
+- Three near-identical 100+ line catch/recover parsers (`parseEnsureStep`, `parseRunCatchStep`, `parseRunRecoverStep` in `src/parse/steps.ts`) plus a mini parser (`parseCatchStatement`) that re-implements `parseBlockStatement`.
+- An AST in which "managed call that yields a value" has **three different encodings** (`run_capture` const RHS; statement form; `managed:` sidecar on `return`/`log`/`logerr` with a placeholder `value: "__match__"` string).
+- A 1,441-line `validate.ts` with two near-identical step walkers (`validateRuleStep`, `validateStep`) that each manually repeat the 5-check sequence (`validateNoShellRedirection` → `validateNestedManagedCallArgs` → `validateRef` → `validateArity` → `validateBareIdentifierArgs`) at ~6 sites per side.
+- Three different traversal strategies for "the set of modules in this build": the validator recursively re-reads + re-parses imports via `ValidateContext` callbacks; `emitScriptsForModule` wraps the same callbacks with a `prep` cache; `buildScripts` walks the file system directly.
+
+None of this is broken. All of it makes the code expensive to change and easy to break in subtle ways (e.g. a fix to triple-quote-aware splitting has to be applied in 2–4 places, and divergence between them isn't always caught by the existing tests).
+
+The five refactors below address the structural issues, in the order I recommend implementing them.
+
+---
+
+## Refactor 1 — Real tokenizer instead of line-walking + regex cascades
+
+**Touches:** `src/parser.ts`, `src/parse/workflow-brace.ts` (615 lines), `src/parse/steps.ts` (757 lines), `src/parse/statement-split.ts` (304 lines), `src/parse/core.ts` (scanner helpers).
+
+### Current shape
+
+The parser walks `lines: string[]` and every routine returns `{ step, nextIdx }`. Statement dispatch is a long cascade of `startsWith` + regex in `parseBlockStatement` (`src/parse/workflow-brace.ts:102-615`). Order matters — `"run async "` must be tested before `"run "`, `"prompt "` before bare assignment, etc. Adding a new keyword means finding the right slot in the cascade.
+
+Quote-aware string scanning is re-implemented from scratch in at least seven places (grep `inDoubleQuote`, `inTripleQuote`, `braceDepth` across `src/parse/`). Each copy has slightly different rules for escaping, triple-quotes, and brace nesting.
+
+```ts
+// Today (src/parse/workflow-brace.ts):
+if (inner.startsWith("run async ")) { /* 40 lines */ }
+if (inner.startsWith("run ")) { /* 50 lines */ }
+if (inner.startsWith("ensure ")) { ... }
+if (inner.startsWith("log ")) { ... }
+// ... 14 more branches
+```
+
+### Proposed shape
+
+A tokenizer that owns string/triple-quote/backtick/fence/comment/brace state, plus a recursive-descent parser that consumes a token stream and dispatches via table lookup.
+
+```ts
+// Proposed:
+const tokens = tokenize(source); // single source of truth for scanning
+const ast = parseModule(tokens); // recursive descent
+
+const STATEMENT: Record = {
+ run: parseRunStatement,
+ ensure: parseEnsureStatement,
+ log: parseLogStatement,
+ // ...
+};
+```
+
+### Net effect
+
+- One canonical scanner instead of seven.
+- A new statement form becomes a one-file change (add a row to `STATEMENT`).
+- Expected reduction: **~1,500 lines** in `src/parse/`.
+
+### Constraints
+
+- Must pass the full existing golden test corpus byte-for-byte.
+- Staged behind a flag (run both parsers, diff ASTs in CI) during transition is acceptable.
+
+---
+
+## Refactor 2 — Unify `catch` / `recover` / inline-block parsing
+
+**Touches:** `src/parse/steps.ts` — `parseEnsureStep` (130 lines), `parseRunCatchStep` (110 lines), `parseRunRecoverStep` (110 lines), `parseCatchStatement` (280 lines).
+
+### Current shape
+
+Three near-identical 100+ line functions parse the same syntactic shape:
+
+```
+ (binding) { body } | single-stmt
+```
+
+They differ in only two things: which host step they decorate (`ensure` vs `run`) and the literal keyword (`catch` vs `recover`).
+
+The body parser inside them, `parseCatchStatement` (`src/parse/steps.ts:89-389`), is itself a stripped-down copy of `parseBlockStatement`. The two diverge in subtle ways — e.g. `parseCatchStatement` handles return/fail/run/ensure/prompt/log via slightly different regexes than the main path.
+
+### Proposed shape
+
+```ts
+function parseAttachedBlock(
+ keyword: "catch" | "recover",
+ host: WorkflowStepDef,
+): { bindings: { failure: string }; body: WorkflowStepDef[] };
+
+// Body parsed by the SAME parseStatement used at the top level.
+```
+
+### Net effect
+
+- One body parser instead of two.
+- "Is this statement allowed inside a catch?" becomes a validator concern (Refactor 4), not something the parser enforces by what each mini-routine happens to recognize.
+- Expected reduction: **~400 lines**.
+
+---
+
+## Refactor 3 — One `Call` / `Expr` shape, not three "managed" encodings
+
+**Touches:** `src/types.ts` — `WorkflowStepDef` (14 variants), `ConstRhs` (6 kinds), `SendRhsDef` (5 kinds).
+
+### Current shape
+
+The same concept — "a managed call that yields a value" — is encoded three different ways depending on where it appears:
+
+```ts
+// As a statement:
+{ type: "run", workflow, args, ... }
+
+// As a const RHS:
+{ kind: "run_capture", ref, args, ... }
+
+// As a return / log / logerr value:
+{
+ type: "return",
+ value: "__match__", // placeholder string for the formatter
+ managed: { kind: "match", match },
+}
+```
+
+The `return + managed` form is the worst offender. It stores placeholder strings (`"__match__"`, `"run inline_script"`, `"run foo(...)"`) so the formatter has something to print, while the real semantic payload lives in `managed`. Validator and emitter both have to know about the dual representation. Inline scripts add a fourth variant — `run_inline_script_capture` — that is yet another form of the same idea.
+
+### Proposed shape
+
+```ts
+type Expr =
+ | { kind: "literal"; raw: string; tripleQuoted?: boolean }
+ | { kind: "var"; name: string; field?: string }
+ | { kind: "call"; callee: Ref; args: Arg[]; bareIdentifierArgs?: string[] }
+ | { kind: "ensure_call"; callee: Ref; args: Arg[]; bareIdentifierArgs?: string[] }
+ | { kind: "inline_script"; lang?: string; body: string; args?: string }
+ | { kind: "prompt"; body: Expr; returns?: Schema }
+ | { kind: "match"; subject: Expr; arms: MatchArm[] };
+
+// Everywhere a value can appear, it is now an Expr:
+type ConstRhs = Expr;
+type SendRhs = Expr | ChannelArrow;
+type ReturnStep = { type: "return"; value: Expr; loc: SourceLoc };
+type LogStep = { type: "log"; message: Expr; loc: SourceLoc };
+```
+
+### Net effect
+
+- `WorkflowStepDef` drops from ~14 → ~7 variants.
+- Validator's per-step duplication of "is there a managed call here?" disappears — one `validateExpr` recursion handles it.
+- The placeholder-string + sidecar pattern goes away entirely.
+
+### Migration note
+
+This is a breaking AST change, but the on-disk surface syntax does not move. The hard-rewrite policy (per `QUEUE.md`) allows this. Golden tests must pass byte-for-byte against the emitted bash output; the AST shape they pin (if any) is internal and is allowed to change.
+
+---
+
+## Refactor 4 — Validator as a visitor table, not a 1,441-line switch
+
+**Touches:** `src/transpile/validate.ts` (1,441 lines, one function).
+
+### Current shape
+
+`validateReferences` contains two near-identical inner functions — `validateRuleStep` (~250 lines) and `validateStep` (~350 lines) — each a big switch over step types. They differ in three things:
+
+1. Which step types are allowed (`prompt` / `send` are rejected in rules).
+2. Which ref-expectation spec is used (`RULE_REF_EXPECT` vs `RUN_TARGET_REF_EXPECT`).
+3. Whether the scope is workflow-wide or rule-wide.
+
+Each step type's validation is written twice with subtle differences. The 5-check sequence (`validateNoShellRedirection` → `validateNestedManagedCallArgs` → `validateRef` → `validateArity` → `validateBareIdentifierArgs`) is repeated by hand at 6+ sites per side, which means at least 12 places to keep in sync.
+
+### Proposed shape
+
+```ts
+const VALIDATORS: Record = {
+ ensure: validateCallStep("ensure"),
+ run: validateCallStep("run"),
+ prompt: validatePrompt,
+ log: validateMessageStep("log"),
+ send: validateSend,
+ // ...
+};
+
+const SCOPE = {
+ workflow: { allow: ALL, refSpec: workflowRefs },
+ rule: { allow: ALL.minus(["prompt","send"]), refSpec: ruleRefs },
+};
+
+walk(ast, (step, ctx) => {
+ if (!ctx.scope.allow.has(step.type)) reject(step);
+ VALIDATORS[step.type](step, ctx);
+});
+```
+
+### Net effect
+
+- Each check (redirection, nested-managed, ref, arity, bare-args) is written once.
+- "Is this step allowed here?" is a one-line set lookup, not three throw sites.
+- Expected reduction: **~500–700 lines**.
+
+---
+
+## Refactor 5 — Promote `CompilePrep` to a first-class `ModuleGraph`
+
+**Touches:** `src/transpile/compile-prep.ts`, `src/transpiler.ts`, `src/transpile/build.ts`, `src/transpile/validate.ts`.
+
+### Current shape
+
+The parser is intended to be pure (`source → AST`), but in practice the validator takes a `ValidateContext`:
+
+```ts
+interface ValidateContext {
+ resolveImportPath: (fromFile, importPath, ws?) => string;
+ existsSync: (path) => boolean;
+ readFile: (path) => string;
+ parse: (content, filePath) => jaiphModule;
+ workspaceRoot?: string;
+}
+```
+
+…so it can recursively read + re-parse imported modules. `emitScriptsForModule` then re-wraps those same callbacks with an optional `prep` cache. `buildScripts` walks the file system on its own. There are three different traversal strategies for "the set of modules in this build."
+
+`compile-prep` already proved the right model — pre-parse all reachable modules once, hand them to validator and emitter. It just isn't the only path.
+
+### Proposed shape
+
+```ts
+// Pipeline:
+const graph = loadModuleGraph(entry, workspaceRoot); // discover + parse-all
+validate(graph); // pure, in-memory
+emit(graph, outDir); // pure, in-memory
+
+// parsejaiph(source, file): jaiphModule — now I/O-pure.
+// validate, emit never touch disk.
+```
+
+### Net effect
+
+- Parser becomes I/O-pure (easier to fuzz, easier to test).
+- Validator drops its `ValidateContext` shape.
+- Build, validate, and emit all read from one place.
+- Same path serves single-file LSP edits (graph rooted at one file) and full compile (graph rooted at workspace root).
+- Expected reduction: **~300 lines**.
+
+---
+
+## Ordering rationale
+
+1. **Refactor 5 (ModuleGraph) first.** Mechanical, low-risk, unblocks the rest by making the parser pure. Existing acceptance tests pin behavior.
+2. **Refactor 3 (Expr collapse) next.** Doing this before tokenizing means the new parser only has to target one expression shape.
+3. **Refactor 4 (visitor-table validator).** With a simpler AST, this is straight refactoring against the golden corpus.
+4. **Refactor 2 (unify catch/recover).** Cheap win, drops ~400 lines.
+5. **Refactor 1 (tokenizer + RD parser) last.** Biggest change. Should sit on top of a cleaned-up AST and a pure pipeline so it can be staged behind a flag and run side-by-side with the old parser against the golden corpus.
+
+## Out of scope
+
+- **Parser generator.** The grammar is small and the line-oriented sensibility of the language (triple-quoted blocks, fence blocks, comments-on-their-own-line) maps cleanly to a hand-written tokenizer.
+- **Surface syntax changes.** None of these refactors are user-visible. The golden test corpus pins behavior.
+- **Runtime.** The bash emitter and `runtime/` stay put.
+
+---
+
+## Appendix — Secondary improvements (A–E)
+
+The five refactors above are the load-bearing changes. The five below are smaller in scope but each addresses a real structural issue that the top 5 do not fully solve on their own. Where a secondary item is coupled to a top-5 refactor, the ordering rationale below makes the dependency explicit.
+
+### A — Split source-fidelity data from the semantic AST (CST / trivia layer)
+
+**Touches:** `src/types.ts`, plus every parser/formatter/validator/emitter consumer.
+
+`WorkflowStepDef` and `jaiphModule` today carry roughly ten fields that exist *only* so the formatter can round-trip: `leadingComments`, `configLeadingComments`, `trailingTopLevelComments`, `configBodySequence`, `topLevelOrder`, `bareSource`, the `tripleQuoted` flags on `literal`/`return`/`log`/`fail`/`send`/`const`, `bodyKind`, `bodyIdentifier`. Every consumer that does *not* care about formatting (validator, emitter) has to either ignore them or thread them through unchanged.
+
+**Proposed:** introduce a parallel `Trivia` map (keyed by node id) or a separate CST layer that owns the source-fidelity data. The semantic AST stops carrying it; formatter reads from `Trivia` alongside the AST.
+
+**Why it is appendix-only:** it changes most of the AST consumers, but the change is mechanical once the boundary is drawn. Biggest payoff if scheduled **before** Refactor 3, so the `Expr` shape is decided after the source-fidelity fields have been pulled out and the semantic core is visible.
+
+### B — Diagnostics collector instead of fail-fast error reporting
+
+**Touches:** `src/parse/core.ts` (`fail`), `src/errors.ts` (`jaiphError`), every call site in `src/parse/` and `src/transpile/`.
+
+Today `fail()` and `jaiphError()` both throw on the first error. A user fixes one error, recompiles, fixes the next, recompiles, etc. This is also the reason for some defensive ordering inside the validator — it tries to surface the "most useful" error first because it knows it will only get to surface one.
+
+**Proposed:** introduce a `Diagnostics` collector. Parser and validator append errors instead of throwing; the compile run reports the full set at the end (sorted by file/line). A "fatal" tier still exists for cases where continuing would produce garbage.
+
+**Why it is appendix-only:** almost zero marginal cost if done as part of Refactor 4 (visitor-table validator), since the new visitor already needs a unified entry/exit per step. Doing it standalone is also fine but touches more files.
+
+### C — Single-pass workflow walk
+
+**Touches:** `src/transpile/validate.ts`.
+
+The validator walks each workflow's step tree at least three times before its main check loop runs: `collectKnownVars`, `collectPromptSchemas`, `validateImmutableBindings`. Each walks the same nested step structure (if/for_lines/catch/recover) with subtly different recursion rules. Bug-fixes to "what counts as a binding here" land in 2–3 walkers.
+
+**Proposed:** one visitor that accumulates `{knownVars, promptSchemas, bindings}` as it descends, and the main per-step validator runs after (or during) that single descent.
+
+**Why it is appendix-only:** falls out naturally inside Refactor 4. Doing it separately is a fine ~50-line refactor.
+
+### D — Collapse `bareIdentifierArgs` into a typed `Arg[]`
+
+**Touches:** `src/types.ts`, `src/parse/core.ts` (`parseCallRef`), validator and emitter.
+
+Today every call-bearing node carries both `args: string` (raw text) and `bareIdentifierArgs: string[]` (a re-parse of which arguments happened to be bare identifiers). The validator must remember to check `bareIdentifierArgs` exists at each call site. The emitter has to do its own re-parse of `args` because it doesn't trust either field alone.
+
+**Proposed:**
+
+```ts
+type Arg =
+ | { kind: "literal"; raw: string }
+ | { kind: "var"; name: string };
+
+// Calls carry args: Arg[]. No second field. No re-parsing downstream.
+```
+
+**Why it is appendix-only:** can be done inside Refactor 3 (it is part of the same "single AST shape per concept" story) or as a standalone task. Standalone is cleaner if Refactor 3 is otherwise too large.
+
+### E — Decouple the validator from the runtime
+
+**Touches:** `src/transpile/validate.ts` (the `import { tripleQuotedRawForRuntime } from "../runtime/orchestration-text"` at the top), `src/runtime/orchestration-text.ts`.
+
+The validator imports a runtime helper (`tripleQuotedRawForRuntime`) so it can compute "what the runtime will see" when reporting errors. That is a one-way dependency from compile-time on runtime semantics. The right direction is the opposite: the parser/validator decides the canonical string, and the runtime consumes that decision.
+
+**Proposed:** move the canonicalization into a parser-side helper (e.g. `src/parse/triple-quote.ts:canonicalizeTripleQuotedString`). The runtime imports *that* instead of the validator importing a runtime function.
+
+**Why it is appendix-only:** small surface (one helper, ~30 lines), but it removes a layering inversion that will keep biting if the runtime grows more such helpers.
+
+### Ordering with the top 5
+
+```
+1. Refactor 5 (ModuleGraph)
+2. A (CST/trivia split) ← before Refactor 3 to settle AST shape
+3. D (typed Arg[]) ← can fold into Refactor 3 if scoped slightly wider
+4. Refactor 3 (Expr collapse)
+5. C (single-pass workflow walk) ← prep for validator
+6. B (Diagnostics collector) ← prep for validator
+7. Refactor 4 (visitor-table validator)
+8. E (decouple validator/runtime)
+9. Refactor 2 (unify catch/recover)
+10. Refactor 1 (tokenizer + RD parser)
+```
diff --git a/docs/_layouts/docs.html b/docs/_layouts/docs.html
index bb4f5fd2..51694220 100644
--- a/docs/_layouts/docs.html
+++ b/docs/_layouts/docs.html
@@ -43,23 +43,33 @@
diff --git a/docs/agent-auth.md b/docs/agent-auth.md
new file mode 100644
index 00000000..166afece
--- /dev/null
+++ b/docs/agent-auth.md
@@ -0,0 +1,90 @@
+---
+title: Authenticate agent backends
+permalink: /how-to/agent-auth
+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.
+
+`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 backend(s) declared in the entry file. Missing credentials produce `E_AGENT_CREDENTIALS` (hard abort) or a `jaiph: warning:` (host-only, for `claude` and `cursor` — 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`.
+
+## 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.
+
+## 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` — `OPENAI_*` is **not** on the Docker env allowlist, so a host-only key is treated as missing |
+
+Under Docker sandboxing the host-side stored logins (Keychain entries, `~/.claude`, `cursor-agent login`) do **not** cross the container boundary. Only allowlisted host env vars are forwarded (`JAIPH_*`, `ANTHROPIC_*`, `CLAUDE_*`, `CURSOR_*`; see [Sandboxing](sandboxing.md#what-docker-protects-against)). Set credentials on the **host** so the allowlist can forward them into the container.
+
+## 1. Authenticate Claude
+
+Either set the API key directly:
+
+```bash
+export ANTHROPIC_API_KEY="sk-ant-..."
+```
+
+Or obtain a long-lived OAuth token through the Claude CLI:
+
+```bash
+claude setup-token
+export CLAUDE_CODE_OAUTH_TOKEN="..."
+```
+
+On host runs (no Docker), a stored `~/.claude` / 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
+
+```bash
+export CURSOR_API_KEY="..."
+```
+
+For host runs only, an interactive `cursor-agent login` (stored on disk) also satisfies the runtime — but the pre-flight emits a warning unless the env var is set.
+
+## 3. Authenticate Codex (OpenAI)
+
+```bash
+export OPENAI_API_KEY="sk-..."
+```
+
+`OPENAI_API_KEY` is required on **both** host and Docker runs. The `codex` backend has no CLI-login fallback — there is no warning path.
+
+Under Docker, `OPENAI_*` is outside the forwarding allowlist, so preflight treats a host-only `OPENAI_API_KEY` as missing even when you export it. Codex workflows need `jaiph run --unsafe` (host execution) or a different backend inside the sandbox.
+
+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).
+
+## 4. Run the pre-flight
+
+```bash
+jaiph run ./flow.jh
+```
+
+The pre-flight runs before the banner. Hard failures print a stderr message naming the backend, the model (when `agent.default_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.
+
+## Skip the pre-flight (escape hatch)
+
+`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 (nothing would credential against).
+
+## Verification
+
+When every required credential is present, preflight is silent — no stderr before the banner. On host runs, missing `claude` or `cursor` env vars emit `jaiph: warning:` lines and the run still proceeds (a stored CLI login may satisfy the runtime). A hard failure prints:
+
+```
+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.
+```
+
+Under Docker the message includes the suffix `(Docker is on — set the env var on the host so it is forwarded into the container.)`.
+
+## Related
+
+- [Run a workflow in a Docker sandbox](/how-to/sandbox-run) — how host env vars cross the container boundary.
+- [Configure backend/model](/how-to/configure-backend) — 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.
diff --git a/docs/architecture.md b/docs/architecture.md
index 55e9ff50..6dc87d33 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1,8 +1,12 @@
---
title: Architecture
permalink: /architecture
+diataxis: explanation
redirect_from:
- /architecture.md
+ - /spec-async-isolated
+ - /target-design
+ - /reporting
---
# Architecture
@@ -19,42 +23,62 @@ For **how to contribute** — branches, test layers, E2E assertion policy, and b
Workflow authors write `.jh` / `.test.jh` modules. The toolchain turns those files into **validated** modules plus **extracted script files**, then the **same AST interpreter** runs workflows whether you use local `jaiph run`, Docker, or `jaiph test`.
-1. Parse source into AST (the CLI parses once up front for `jaiph run` metadata such as `runtime` config; `buildRuntimeGraph` and transpilation use the same parser on disk contents).
-2. **Compile-time** validation (`validateReferences`, invoked from **`emitScriptsForModule`** / **`buildScripts()`**) runs before script extraction, not inside `buildRuntimeGraph()` (the graph loader only parses modules and follows imports). The **`jaiph compile`** command walks the same import closure but runs **`validateReferences` only**: it parses each reachable module on disk and **does not** emit **`scripts/`** (no **`buildScriptFiles`** / **`buildScripts`**), **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.
-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** that loads **`node-workflow-runner.js`**. That child calls `buildRuntimeGraph()` and runs **`NodeWorkflowRuntime`**. The child’s interpreter is **`process.execPath`** of the CLI process (Node when you run `node dist/src/cli.js`, the standalone Bun binary when you run `dist/jaiph`). Script steps execute as managed subprocesses; prompt, inbox I/O, and event/summary emission are handled by the kernel under `src/runtime/kernel/`.
+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 → 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; validation and script emit operate entirely on the in-memory graph and never re-read `.jh` files. The only fs entry point that reads `.jh` sources is `loadModuleGraph`.
+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.
+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 — used when embedding Jaiph or when the host wraps a container (see [CLI — `jaiph run`](cli.md#jaiph-run) and [Sandboxing — Docker container isolation](sandboxing.md#docker-container-isolation)).
+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 — used when embedding Jaiph or when the host wraps a container (see [CLI — `jaiph run`](cli.md#jaiph-run) and [Sandboxing](sandboxing.md)).
-All orchestration — local `jaiph run`, `jaiph test`, and **Docker `jaiph run`** — uses the **Node workflow runtime** (AST interpreter). Docker containers run the same `node-workflow-runner` process with the compiled JS source tree and scripts mounted read-only.
+All orchestration — local `jaiph run`, `jaiph test`, and **Docker `jaiph run`** — uses the **Node workflow runtime** (AST interpreter). Docker containers run the same **`jaiph run --raw`** / **`__workflow-runner`** dispatch with the compiled JS source tree and scripts mounted read-only.
## Core components
- **CLI (`src/cli`, invoked via compiled `src/cli.ts` → `dist/src/cli.js`)**
- Entry point (`run`, `test`, `compile`, `init`, `install`, `use`, `format`). 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 **`node-workflow-runner.js`** with `process.execPath`, which calls `buildRuntimeGraph()` then `NodeWorkflowRuntime`. The **`jaiph run`** path always launches the **`default`** workflow via argv wired in `workflow-launch.ts` (`node-workflow-runner` calls `runDefault`). `setupRunSignalHandlers` accepts an optional `onSignalCleanup` callback for Docker sandbox teardown on SIGINT/SIGTERM.
+ - **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.
- Parses runtime events and renders progress (except `--raw`); dispatches hooks.
- **Parser (`src/parser.ts`, `src/parse/*`)**
- - Converts `.jh`/`.test.jh` into `jaiphModule` AST.
- - Reusable primitives: `parseFencedBlock()` (`src/parse/fence.ts`) handles triple-backtick fenced bodies with optional lang tokens for scripts and inline scripts. `parseTripleQuoteBlock()` (`src/parse/triple-quote.ts`) handles `"""..."""` blocks for prompts, `const`, `log`, `logerr`, `fail`, `return`, and `send` — all positions where multiline strings appear.
+ - 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.
+ - Reusable primitives: `parseFencedBlock()` (`src/parse/fence.ts`) handles triple-backtick fenced bodies with optional lang tokens for scripts and inline scripts. `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` / `tryParseWait` / `tryParseEnsure` / `tryParseRun` / `tryParsePrompt` / `tryParseLog` / `tryParseLogerr` / `tryParseReturn` / `tryParseStandaloneMatch`), 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.
- **AST / Types (`src/types.ts`)**
- - Shared compile-time schema (`jaiphModule`, step defs, test defs, hook payload types).
+ - 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` / `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 in-scope-style identifier becomes `var`; everything else — quoted strings, `${…}` interpolations, nested `run …` / `ensure …` calls, inline-script bodies — is stored verbatim 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, and resolve `var` refs against in-scope bindings; 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.
-- **Validator (`src/transpile/validate.ts`)**
+- **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`.)
+
+- **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 ~450) 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`) 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 four 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.
+ - **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`.
- **Transpiler (`src/transpiler.ts`, `src/transpile/*`)**
- - **`emitScriptsForModule`** parses, runs **`validateReferences`**, and **`buildScriptFiles`** — the only compile path for `jaiph run` / `jaiph test` — **persists only atomic `script` files** under `scripts/`. **`buildScripts()`** can also take a **directory** of non-test `*.jh` modules (`src/transpile/build.ts` uses `walkjhFiles`); the **`jaiph run`** and **`jaiph test`** commands always pass a **single entry file** (`.jh` or `*.test.jh`). 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.
+ - **`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.
+ - 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)).
- **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.
+ - 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.
+ - **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).
- 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 interpolation and call-argument parsing (`interpolate`, `parseInlineCaptureCall`, `commaArgsToInterpolated`, `parseArgsRaw`, `parseInlineScriptAt`, `parseManagedArgAt`, `parseArgTokens`, `stripOuterQuotes`, `parsePromptSchema`, `sanitizeName`, `nowIso`) plus shared constants and the `ParsedArgToken` / `PromptSchemaField` types. 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 `node-workflow-runner` child does not set it, so production runs stream events to stderr as before.
+ - **`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.
- - `buildRuntimeGraph()` (`graph.ts`) loads reachable modules with **`parsejaiph` only** (import closure); it does **not** run `validateReferences`. Cross-module refs are resolved from that graph at runtime. For **`script import`** declarations, `buildRuntimeGraph()` injects synthetic `ScriptDef` stubs (`graph.ts`) so reference resolution matches the validated compile path without re-reading external script bodies at graph-build time.
+ - `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.
@@ -63,11 +87,28 @@ All orchestration — local `jaiph run`, `jaiph test`, and **Docker `jaiph run`*
- 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`.
- **Formatter (`src/format/emit.ts`)**
- - `jaiph format` rewrites `.jh` / `.test.jh` files into canonical style. Pure AST→text emitter; no side-effects beyond file writes.
+ - `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)). The container runs the same `node-workflow-runner` entry as local execution. The default image is the official `ghcr.io/jaiphlang/jaiph-runtime` GHCR image; 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. `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:** Docker runs cannot modify the host workspace. The host checkout is mounted read-only; `/jaiph/workspace` is a sandbox-local copy-on-write overlay discarded on exit. The only host-writable path is `/jaiph/run` (run artifacts). 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()`. See [Sandboxing](sandboxing.md) for the full contract and [Libraries — `jaiphlang/artifacts`](libraries.md#jaiphlangartifacts--publishing-files-out-of-the-sandbox).
+ - 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)). 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; 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. `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. The host checkout is mounted read-only (overlay) or as a disposable clone (copy); `/jaiph/workspace` is sandbox-local and discarded on exit. The only host-writable path is `/jaiph/run` (run artifacts). 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).
+
+## Local module graph
+{: #local-module-graph}
+
+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 it also crosses the parent CLI → 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. This is the **only** routine that reads `.jh` sources from disk; `parsejaiph(source, filePath)` itself is I/O-pure.
+- **`src/cli/commands/run.ts`** calls `loadModuleGraph` once after path normalization. The entry AST is reused for **`metadataToConfig(mod.metadata)`** (banner / `runtime` config). 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 **only** when the host CLI spawns the local **`__workflow-runner`** child with Docker sandboxing disabled (`dockerConfigForBanner.enabled === false`). 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, preserving prior behavior.
+
+User-visible contracts (banner, hooks, run artifacts, `run_summary.jsonl`, `return_value.txt`, exit codes, `__JAIPH_EVENT__` streaming) are unchanged.
## Runtime vs CLI responsibilities
@@ -119,20 +160,21 @@ Channels are validated at compile time (`validateReferences` / send RHS rules) a
## Test runner integration (`*.test.jh` in the kernel)
-**How** `jaiph test` wires into the same stack as `jaiph run`: `*.test.jh` files are parsed in the CLI; `runTestFile()` drives blocks in-process. **`buildRuntimeGraph(testFile)`** is called **once per `runTestFile` invocation** and the resulting graph 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 cached graph, 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 this flag, every workflow event would print to the test process's stderr and swamp `node --test` reporter output. Mock prompts, workflows, rules, and scripts are supported through the runtime's mock infrastructure.
+**How** `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 this flag, every workflow event would print to the test process's stderr and swamp `node --test` reporter output. Mock prompts, workflows, rules, and scripts are supported through the runtime's mock infrastructure.
-Before that, the CLI prepares script executables via **`buildScripts(testFileAbs, tmpDir, workspaceRoot)`** — the same **`buildScripts`** helper as `jaiph run`, with the **test file as the entrypoint**. That walks the test module and its **import closure** (transitive `import` edges), runs **`validateReferences`** / **`emitScriptsForModule`** per reachable file, and 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 workflows 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. `const` steps whose value is a `match_expr` are walked for nested `run`/`ensure` arms; matched targets appear as child items in the step tree (e.g. `▸ script safe_name` under the `const` row). This pipeline does not apply to **`jaiph run --raw`**.
+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. `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`**.
## Distribution: Node vs Bun standalone
-- **Development / npm:** `npm run build` runs `tsc`, copies **`src/runtime/`** to **`dist/src/runtime/`** (kernel, `docker.ts`, etc.), then copies **`runtime/overlay-run.sh`** from the repo root into **`dist/src/runtime/overlay-run.sh`**. 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 still spawns `node-workflow-runner.js` using **`process.execPath`**, so the standalone artifact is **self-contained** (no separate Node install) when end users run that binary. **Bash** (or whatever shebang your `script` steps use) is still required on the host for script subprocesses. Ship **`dist/jaiph`** with **`dist/runtime`** alongside it so kernel paths resolve (same layout as `npm run build:standalone`; table in [Contributing](contributing.md)).
+- **Development / npm:** `npm run build` runs `npm run embed-assets` (regenerates **`src/runtime/embedded-assets.ts`** from `runtime/overlay-run.sh` and `docs/jaiph-skill.md`, and **`src/version.ts`** from `package.json`'s `version` field), then `tsc`, copies **`src/runtime/`** to **`dist/src/runtime/`** (kernel, `docker.ts`, etc.), and copies **`runtime/overlay-run.sh`** from the repo root into **`dist/src/runtime/overlay-run.sh`**. 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. `overlay-run.sh` and `docs/jaiph-skill.md` are 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** on **`v*`** tag pushes and on pushes to the **`nightly`** branch, generates a `SHA256SUMS` covering the four binaries, runs a `--version` sanity gate on the linux-x64 output, and uploads the five assets 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).
## Mermaid architecture diagram
@@ -140,26 +182,27 @@ The progress UI combines a **static** step tree derived from the workflow AST (`
flowchart TD
U[User / CI] --> CLI[CLI: Node or Bun jaiph]
- subgraph Transpile["Per-module: emitScriptsForModule()"]
- PARSE[parsejaiph]
- VAL[validateReferences]
+ subgraph Transpile["Per-module: emitScriptsForModuleFromGraph()"]
+ VAL[validateModule]
EMIT[Emit atomic script files under scripts/]
- PARSE --> VAL
VAL -->|compile errors| ERR[Deterministic compile errors]
VAL --> EMIT
end
- CLI -->|jaiph run| BS1[buildScripts]
+ CLI -->|jaiph run| LMG1[loadModuleGraph entry + closure]
+ LMG1 --> BS1[buildScriptsFromGraph]
BS1 --> Transpile
- CLI -->|jaiph test| BS2[buildScripts(entry .test.jh)]
+ CLI -->|jaiph test| LMG2[loadModuleGraph(entry .test.jh)]
+ LMG2 --> BS2[buildScriptsFromGraph]
BS2 --> Transpile
- BS2 --> TR[Node Test Runner in-process]
+ LMG2 --> TR[Node Test Runner in-process]
- Transpile -->|jaiph run local| RW[Node workflow runner child]
- Transpile -->|jaiph run Docker| DC[Container runs node-workflow-runner]
+ 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
- RW --> G[buildRuntimeGraph parse-only + imports]
+ RW --> G[buildRuntimeGraph from graph]
G --> GRAPH[RuntimeGraph]
RW --> RT[NodeWorkflowRuntime]
RT --> GRAPH
@@ -193,29 +236,41 @@ Interactive **`jaiph run`** (no **`--raw`**): banner, progress tree, hooks, and
sequenceDiagram
participant User
participant CLI as CLI jaiph run
- participant Prep as buildScripts
- participant TF as emitScriptsForModule per module
- participant Runner as node-workflow-runner
- participant Graph as buildRuntimeGraph
+ participant Load as loadModuleGraph
+ participant Prep as buildScriptsFromGraph
+ participant TF as emitScriptsForModuleFromGraph per module
+ participant Runner as __workflow-runner child
+ participant Graph as buildRuntimeGraph(graph)
participant Runtime as NodeWorkflowRuntime
participant Kernel as JS kernel
participant Report as Artifacts (.jaiph/runs)
User->>CLI: jaiph run main.jh args...
- Note over CLI: parse once for metadata config only
- CLI->>Prep: buildScripts(input)
- Prep->>TF: loop: parse + validateReferences + emit
+ CLI->>Load: loadModuleGraph(entry, workspace)
+ Load-->>CLI: ModuleGraph (modules map)
+ Note over CLI: reuse entry AST for metadataToConfig / banner
+ CLI->>Prep: buildScriptsFromGraph(graph, outDir)
+ Prep->>TF: loop: validateModule + emit (in-memory AST)
TF-->>Prep: scripts/ atomic only
Prep-->>CLI: scriptsDir + env JAIPH_SCRIPTS
- alt local
- CLI->>Runner: spawn detached node-workflow-runner
+ 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 node-workflow-runner
+ CLI->>Runner: spawn container running jaiph run --raw
Note over CLI: CLI parses events on stderr only
end
- Runner->>Graph: buildRuntimeGraph(sourceAbs) parse-only
+ alt JAIPH_MODULE_GRAPH_FILE set (local non-Docker)
+ Runner->>Runner: readModuleGraph(file)
+ Runner->>Graph: buildRuntimeGraph(graph)
+ Note over Graph: no .jh re-reads
+ else absent (Docker / --raw / test runner)
+ Runner->>Runner: loadModuleGraph(sourceAbs, workspace)
+ Runner->>Graph: buildRuntimeGraph(graph)
+ end
Graph-->>Runner: RuntimeGraph
Runner->>Runtime: runDefault(run args)
Runtime->>Kernel: prompt / managed scripts / emit / inbox
@@ -226,7 +281,7 @@ sequenceDiagram
CLI-->>User: PASS/FAIL
```
-**Docker:** the inner container command is **`jaiph run --raw …`** (see [Sandboxing](sandboxing.md#docker-container-isolation)): no banner or progress UI inside the container; **`__JAIPH_EVENT__`** lines still appear on stderr for the host CLI to parse.
+**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
@@ -234,20 +289,20 @@ sequenceDiagram
sequenceDiagram
participant User
participant CLI as CLI jaiph test
- participant Parser as parsejaiph
- participant Prep as buildScripts(test file)
+ participant Load as loadModuleGraph
+ participant Prep as buildScriptsFromGraph
participant TestRunner as runTestFile / runTestBlock
- participant Graph as buildRuntimeGraph
+ participant Graph as buildRuntimeGraph(graph)
participant Runtime as NodeWorkflowRuntime
participant Report as Artifacts
User->>CLI: jaiph test flow.test.jh
- CLI->>Parser: parse test file
- Parser-->>CLI: jaiphModule + tests[] blocks
- CLI->>Prep: buildScripts(test path, tmp) import closure
+ CLI->>Load: loadModuleGraph(test file, workspace)
+ Load-->>CLI: ModuleGraph (entry + import closure)
+ CLI->>Prep: buildScriptsFromGraph(graph, tmp)
Prep-->>CLI: scriptsDir
- CLI->>TestRunner: runTestFile(test path workspace scriptsDir blocks)
- TestRunner->>Graph: buildRuntimeGraph(test file) once per file
+ CLI->>TestRunner: runTestFile(graph, workspace, scriptsDir, blocks)
+ TestRunner->>Graph: buildRuntimeGraph(graph) once per file
Graph-->>TestRunner: RuntimeGraph cached
loop each test block
TestRunner->>TestRunner: mocks / shell steps / expectations
@@ -264,9 +319,9 @@ sequenceDiagram
## Summary
-- `.jh` / `*.test.jh` share parser/AST; **compile-time** validation runs in **`emitScriptsForModule`** during **`buildScripts`**. **`buildRuntimeGraph`** loads modules with **parse-only** imports.
-- **`jaiph compile`** walks import closures with **`validateReferences` only**, and exits — 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 `node-workflow-runner` with the compiled JS tree and scripts mounted, using the same semantics as local execution.
+- `.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.
- **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.
diff --git a/docs/artifacts.md b/docs/artifacts.md
index 397bff1a..70952d01 100644
--- a/docs/artifacts.md
+++ b/docs/artifacts.md
@@ -1,51 +1,89 @@
---
-title: Runtime artifacts
-permalink: /artifacts
+title: Save artifacts
+permalink: /how-to/artifacts
+diataxis: how-to
redirect_from:
+ - /artifacts
- /artifacts.md
---
-# Runtime artifacts
+# Save artifacts
-Long-running orchestration tools usually split **telemetry you watch while something runs** from **evidence you keep after it stops**. The first answers “what is happening now?”; the second answers “what happened, in enough detail to debug or audit later?” Jaiph does the same.
+This recipe publishes files from a workflow into the run's `artifacts/` directory under the run logs root (`.jaiph/runs/` by default). That is the supported export path when Docker sandboxing is on — in the default overlay and copy modes, workspace edits are discarded at container exit, but anything copied into `artifacts/` remains on the host.
-For Jaiph, **live** observation is the `__JAIPH_EVENT__` JSON line protocol on the workflow runner’s **stderr** (what the interactive CLI and [Hooks](hooks.md) consume). **Durable** observation is a directory tree on disk: step captures, an append-only summary timeline, optional inbox copies, and a writable `artifacts/` folder for anything workflows publish explicitly.
+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 canonical way to copy files into that directory; you can also write there directly from a `script` step.
-When you run a workflow, or `jaiph test` executes workflows inside test blocks, **`NodeWorkflowRuntime`** materializes that durable tree. **`jaiph run`** defaults to `/.jaiph/runs/`; override with `run.logs_dir` or **`JAIPH_RUNS_DIR`** (see [Configuration — Run keys](configuration.md#run-keys)). The test runner uses its own ephemeral runs root under **`JAIPH_RUNS_DIR`** so normal workspace runs are not overwritten — see [Configuration — Testing with `jaiph test`](configuration.md#testing-with-jaiph-test). The layout below matches what the runtime creates in the constructor (see [Architecture — Durable artifact layout](architecture.md#durable-artifact-layout)). In Docker mode, paths inside recorded events may use container prefixes (`/jaiph/run/…`); the CLI maps them to host paths when reporting failures — see [Sandboxing — Path remapping](sandboxing.md#path-remapping).
+## Prerequisites
-## Run directory layout
+- A workspace with `.jaiph/libs/jaiphlang/` installed (`jaiph install jaiphlang`) if you want to use the library — see [Use & publish a library](/how-to/libraries).
+- The file(s) you want to save exist by the time the `artifacts.save(...)` step runs.
-The runtime uses a UTC-dated hierarchy. Each run gets its own folder: UTC date, then UTC time plus a **basename** used only for naming (not a path): **`JAIPH_SOURCE_FILE`** when set in the environment (the CLI and `node-workflow-runner` set this to the entry file basename), otherwise `basename(graph.entryFile)` from the parsed graph.
+## 1. Import the library
+```jh
+import "jaiphlang/artifacts" as artifacts
```
-.jaiph/runs/
- / # UTC date (see NodeWorkflowRuntime)
- -/ # UTC time + basename (see above)
- 000001-module__step.out # stdout capture per step (6-digit seq prefix)
- 000001-module__step.err # stderr capture (may be empty)
- artifacts/ # user-published files (`jaiphlang/artifacts`); `JAIPH_ARTIFACTS_DIR`
- inbox/ # audit copies of routed channel payloads (optional)
- heartbeat # liveness: epoch ms, refreshed about every 10s
- return_value.txt # `runDefault` only: status 0 and `returnValue` defined (may be "")
- run_summary.jsonl # durable event timeline (JSON Lines)
+
+## 2. Save a single file
+
+```jh
+workflow default() {
+ # ... produce ./build/output.bin somehow ...
+ const dest = run artifacts.save("./build/output.bin")
+ log "saved to ${dest}"
+}
```
-Sequence numbers in those filenames are **monotonic and unique** per run. `RuntimeEventEmitter` owns a single in-memory counter (`allocStepSeq`) that advances whenever a step allocates paired capture files: **`executeManagedStep`** (nested **`workflow`** / **`rule`**, **`script`** references, inline scripts, and **`shell`** lines run via `sh -c`) plus **`prompt`** steps (which call `allocStepSeq` inside `emitPromptStepStart`). Ordinary **`log`**, **`logerr`**, **`fail`**, **`send`**, and most **`const`** bindings do **not** open new numbered `.out`/`.err` pairs — they still emit **`LOG`/`LOGERR`** or **`INBOX_ENQUEUE`** records (and related lines) into **`run_summary.jsonl`** where applicable. There is **no** `.seq` file in the run directory. For the live vs durable split, see [Architecture — Contracts](architecture.md#contracts): `__JAIPH_EVENT__` on stderr is the streaming path; `run_summary.jsonl` is the durable timeline.
+`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.
-## What each artifact is for
+## 3. Save several files at once
-- **`*.out` / `*.err`** — Paired capture files for steps that record subprocess or prompt I/O. The runtime creates both paths at **`STEP_START`**. For **managed** steps (extracted scripts, nested workflows/rules, single-line `shell`, and similar), stdout/stderr are **streamed** into the files during execution, then **rewritten** with the final aggregated strings at step end — so a long-running step’s `.out` can be tailed while it runs (see [CLI — Run artifacts and live output](cli.md#run-artifacts-and-live-output)). **Prompt** steps stream the model transcript into `.out`; `.err` is only overwritten when stderr from the backend is non-empty (otherwise the placeholder file stays zero-length). **Errors and CLI progress** still use the live `__JAIPH_EVENT__` stream on stderr; these files are the on-disk record.
+`save` accepts a **newline-separated** list of paths. Blank or whitespace-only lines are ignored:
+
+```jh
+workflow default() {
+ const paths = """
+ a.txt
+ b/nested.txt
+ """
+ const dests = run artifacts.save(paths)
+ log "${dests}"
+}
+```
-- **`run_summary.jsonl`** — Append-only JSON Lines timeline: workflow boundaries, step start/end, `LOG` / `LOGERR`, prompt lifecycle, inbox events, and the same step payload fields as the live stream. It is **truncated to empty at runtime startup**, then each event appends a line via `appendRunSummaryLine` as execution proceeds. The in-process test runner can set `suppressLiveEvents`, which **stops** `__JAIPH_EVENT__` lines from going to stderr while **`run_summary.jsonl` keeps updating** (see [Architecture — Core components](architecture.md#core-components), `RuntimeEventEmitter`).
+The returned value is the newline-separated list of absolute destination paths, in the same order.
-- **`inbox/`** — When channels are used, a **`send`** may persist a copy of the payload here (`NNN-.txt`) for audit. The runtime walks ancestor workflow contexts and writes a file **only when it finds a matching route for that channel** on the stack (same condition as “routed” dispatch — see [Inbox & Dispatch](inbox.md)); unrouted sends enqueue without creating `inbox/` files. Delivery stays in-memory; this directory is not a mailbox API.
+## 4. (Alternative) Write directly from a script step
+
+If you need full control of layout or names, write to `$JAIPH_ARTIFACTS_DIR` from a `script` step:
+
+```jh
+script save_report = ```
+ mkdir -p "$JAIPH_ARTIFACTS_DIR/reports"
+ cp ./report.html "$JAIPH_ARTIFACTS_DIR/reports/"
+```
-- **`heartbeat`** — Best-effort file containing a wall-clock millisecond timestamp, rewritten on a timer (~10s). Liveness for external watchdogs; not required for normal CLI use.
+workflow default() {
+ 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.
+
+## Verification
+
+After the run, list the artifacts directory:
+
+```bash
+ls //-/artifacts/
+```
-- **`return_value.txt`** — Written only from **`runDefault`** (the normal **`jaiph run`** entry path) when the top-level workflow finishes with **exit status 0** and the aggregated result has **`returnValue !== undefined`** (empty string is allowed and produces a zero-byte file; **`undefined`** means the file is omitted — typically “fell off the end” of the workflow without a **`return`**). **`runNamedWorkflow`** (`test_run_workflow`, nested named runs, etc.) returns the value to the caller but does **not** write this file.
+Replace `` with `.jaiph/runs` when `JAIPH_RUNS_DIR` is unset, or with your configured runs directory otherwise. Date and time segments are UTC; `` 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.
-- **`artifacts/`** — Created in the constructor together with the empty **`run_summary.jsonl`** (truncated file). The runtime sets **`JAIPH_ARTIFACTS_DIR`**, **`JAIPH_RUN_DIR`**, **`JAIPH_RUN_SUMMARY_FILE`**, and **`JAIPH_RUN_ID`**: if **`JAIPH_RUN_ID`** is already set in the incoming environment it is preserved; otherwise a new UUID is generated. User workflows usually publish into **`artifacts/`** through **`jaiphlang/artifacts`** (`artifacts.save`). In Docker mode it sits under the **host-writable** run mount (`/jaiph/run/...` inside the container), not the read-only workspace overlay. See [Libraries — `jaiphlang/artifacts`](libraries.md#jaiphlangartifacts--publishing-files-out-of-the-sandbox) and [Sandboxing](sandboxing.md).
+`artifacts.save(...)` exits with a failure 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` / `catch` if you want the workflow to tolerate that.
-## Keeping runs out of git
+## Related
-Run `jaiph init` to add `.jaiph/.gitignore` entries for `runs` and `tmp` under `.jaiph/`. You can mirror those paths in a root `.gitignore` if you prefer.
+- [Architecture — Durable artifact layout](architecture.md#durable-artifact-layout) — the full run directory tree, including where `artifacts/` sits.
+- [Use & publish a library](/how-to/libraries) — installing `jaiphlang/artifacts` and writing your own libraries.
+- [Sandboxing — The three sandbox modes](sandboxing.md#the-three-sandbox-modes) — overlay and copy discard workspace edits; artifacts persist on the host in every mode.
diff --git a/docs/cli.md b/docs/cli.md
index b956872f..2bc3fd07 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -1,474 +1,298 @@
---
-title: CLI Reference
-permalink: /cli
+title: CLI
+permalink: /reference/cli
+diataxis: reference
redirect_from:
+ - /cli
- /cli.md
---
-# Jaiph CLI Reference
+# CLI
-Jaiph is a workflow system: authors write `.jh` modules, and a **TypeScript CLI** prepares scripts, launches a **Node workflow runtime**, and surfaces progress while the **JavaScript kernel** executes the AST in process (no separate workflow shell). The CLI is what you install as the `jaiph` binary — it is the boundary between your terminal or CI and the interpreter.
+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.
-At a high level, the CLI does four things: **compile** script bodies from your module graph (`buildScripts`), **spawn** the detached workflow runner (`node-workflow-runner`) for `jaiph run`, **observe** `__JAIPH_EVENT__` lines on stderr to render progress and drive hooks (unless `--raw`), and **leave** durable artifacts under `.jaiph/runs`. `jaiph test` reuses the same compilation step and runtime kernel but executes test blocks in-process with mocks — see [Architecture](architecture.md) for the full pipeline.
+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`.
-This page lists **commands**, important **flags**, and **environment variables**. It focuses on how the tool behaves, not on the language itself. For semantics and the overall language model, see [Language](language.md). For concrete syntax rules (imports, orchestration strings, managed calls, …), see [Grammar](grammar.md). For repository layout, pipelines, and contracts (`__JAIPH_EVENT__`, artifacts, Docker vs local), see [Architecture](architecture.md).
+## Invocation forms
-**Commands:** `run`, `test`, `compile`, `format`, `init`, `install`, `use`.
+| Form | Effect |
+|---|---|
+| `jaiph` | Print the overview and exit `0`. |
+| `jaiph --help` / `-h` | Print the overview and exit `0`. |
+| `jaiph --version` / `-v` | Print the CLI version and exit `0`. |
+| `jaiph [-h \| --help]` | Print the subcommand's usage (flags + one example) and exit `0`. Recognised anywhere in the arg list before `--` (except `compile`: help flags must precede path arguments). |
+| `jaiph ` | File shorthand. Paths ending in `*.test.jh` route to `jaiph test`; other `*.jh` paths route to `jaiph run`. Non-existent paths fall through to normal command parsing. |
+| `jaiph ` | Print `Unknown command: `, repeat the overview, exit `1`. |
-**Global options:** `-h` / `--help` and `-v` / `--version` are recognized only as the **first token after `jaiph`** (e.g. `jaiph --help`). They are not treated as global flags after a subcommand or a file path (`jaiph run --help` is **not** usage — use `jaiph --help`, or **`jaiph compile -h`** / **`jaiph compile --help`** for compile-specific usage — the `compile` command parses `-h` / `--help` after the subcommand). Running **`jaiph`** with no arguments prints the same overview and exits **0**.
+The reserved internal marker `__workflow-runner` is excluded from `--help`/usage and from the file-shorthand path; it is used by `process.execPath` self-spawn (see [Architecture — Distribution: Node vs Bun standalone](architecture.md#distribution-node-vs-bun-standalone)).
-Any other unknown first token prints `Unknown command: …`, repeats the overview, and exits **1**.
+## Subcommand summary
-## File shorthand
-
-If the **first argument after `jaiph`** is an **existing path** (resolved relative to the current working directory), Jaiph routes it automatically based on the extension. Files ending in **`*.test.jh`** are run as tests (same as `jaiph test `). Other paths ending in **`.jh`** are run as workflows (same as `jaiph run `). The `*.test.jh` check happens first, so test modules are never mistaken for workflows. Paths that do not exist fall through to normal command parsing (e.g. you cannot rely on shorthand for a not-yet-created file).
-
-Additional positional tokens after a **workflow** shorthand are forwarded to **`workflow default`**, matching `jaiph run`. Tokens after a **test** shorthand are accepted but **ignored** (same as `jaiph test ` with extra arguments).
-
-```bash
-# Workflow shorthand
-jaiph ./flows/review.jh "review this diff"
-# equivalent to: jaiph run ./flows/review.jh "review this diff"
-
-# Test shorthand
-jaiph ./e2e/say_hello.test.jh
-# equivalent to: jaiph test ./e2e/say_hello.test.jh
-```
+| Subcommand | Purpose |
+|---|---|
+| `run` | Compile, launch, and observe one workflow run (with optional Docker sandboxing). |
+| `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. |
## `jaiph run`
{: #jaiph-run}
-Parse, validate, and run a Jaiph workflow file. Requires a `workflow default` entrypoint.
-
-```bash
-jaiph run [--target ] [--raw] [--] [args...]
-```
-
-Any path ending in `.jh` is accepted (including `*.test.jh`, since the extension is still `.jh`). For files that only contain test blocks, use `jaiph test` instead.
-
-**Sandboxing:** whether the workflow runs in a **Docker container** or **directly on the host** is decided from environment variables and the workflow’s `runtime` metadata — there is no `jaiph run --docker` flag. Defaults and mounts are documented in [Sandboxing](sandboxing.md).
-
-**Flags:**
-
-- **`--target `** — keep emitted script files and run metadata under `` instead of a temp directory (useful for debugging).
-- **`--raw`** — skip the banner, live progress tree, hooks, and CLI failure footer. The workflow runner child uses **inherited stdio** so `__JAIPH_EVENT__` JSON lines go to **stderr** unchanged. When **Docker sandboxing** is used, the **host** runs interactive `jaiph run` and the **container** runs `jaiph run --raw …` so the host can parse events from the container’s stderr ([Architecture](architecture.md), [Sandboxing](sandboxing.md)). **Important:** if you invoke `jaiph run --raw` yourself on the host, the CLI takes a separate code path that **never starts Docker** — workflow execution runs locally in that process even when `JAIPH_DOCKER_ENABLED=true`. Use `--raw` for embedding or piping; use interactive `jaiph run` (no `--raw`) when you want the CLI to apply sandbox env rules. There is no PASS/FAIL line, **`return_value.txt` is not printed to stdout**, and the process exit code alone reflects success or failure. See [Sandboxing — Runtime behavior](sandboxing.md#runtime-behavior).
-- **`--`** — end of Jaiph flags; remaining args are passed to `workflow default` (e.g. `jaiph run file.jh -- --verbose`).
-
-**Examples:**
-
-```bash
-jaiph run ./.jaiph/bootstrap.jh
-jaiph run ./flows/review.jh "review this diff"
-```
+Compile and execute a workflow's `default` entrypoint.
-### Argument passing
-
-Positional arguments are available inside `script` bodies as standard bash `$1`, `$2`, `"$@"`. In Jaiph orchestration strings (`log`, `prompt`, `fail`, `return`, `send`, `run`/`ensure` args), use **named parameters** (e.g. `workflow default(task)` → `${task}`) — only `${identifier}` forms are supported (no shell parameter expansion). The same rule applies to `prompt` text and to `const` RHS strings where orchestration applies.
-
-Rules receive forwarded arguments through `ensure`:
-
-```jaiph
-script check_branch = `test "$(git branch --show-current)" = "$1"`
-
-rule current_branch(expected) {
- run check_branch("${expected}")
-}
-
-workflow default() {
- ensure current_branch("main")
-}
+```text
+jaiph run [--target ] [--raw] [--workspace ] [--inplace] [--unsafe] [--yes|-y] [--] [args...]
```
-**Rule** bodies are **managed steps only** — no raw shell lines; use `run` to a `script` for shell execution. **Workflow** bodies may include **inline shell** lines that do not parse as a Jaiph step (the compiler still validates them); for anything non-trivial, prefer a top-level `script` and `run`. In bash-bearing contexts (mainly `script` bodies, and restricted `const` / send RHS forms), `$(...)` and the first command word are validated: they must not invoke Jaiph rules, workflows, or scripts, contain inbox send (`<-`), or use `run` / `ensure` as shell commands (`E_VALIDATE`). See [Grammar — Language concepts](grammar.md#language-concepts) and [Grammar — Managed calls vs command substitution](grammar.md#managed-calls-vs-command-substitution).
-
-For `const` in those bodies, a reference plus arguments on the RHS must be written as `const name = run ref([args...])` (or `ensure` for rule capture), not as `const name = ref([args...])` — the latter is `E_PARSE` with text that explains the fix.
-
-### Shebang execution
+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 — see [Configuration — Precedence](configuration.md#precedence) and [Environment variables](env-vars.md).
-If a `.jh` file is executable and has `#!/usr/bin/env jaiph`, you can run it directly:
+### Flags
-```bash
-./.jaiph/bootstrap.jh "task details"
-./flows/review.jh "review this diff"
-```
-
-### Compile-time and process model
+| 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`. |
+| `--unsafe` | — | Front-end for `JAIPH_UNSAFE=true`. Cannot be combined with `--inplace` (`E_FLAG_CONFLICT`). |
+| `-y`, `--yes` | — | Front-end for `JAIPH_INPLACE_YES=1`. Required to use `--inplace` non-interactively. |
+| `--` | — | End of Jaiph flags; remaining tokens are forwarded to `workflow default`. |
-The CLI runs `buildScripts()`, which walks the entry file and its import closure. Each reachable module is parsed and `validateReferences` runs before script files are written. Unrelated `.jh` files on disk are not read.
+### Pre-flight
-After validation, the CLI spawns the Node workflow runner as a detached child. The runner loads the graph with `buildRuntimeGraph()` (parse-only imports; no `validateReferences` here) and executes `NodeWorkflowRuntime`. Prompt steps, script subprocesses, inbox dispatch, and event emission are handled in the runtime kernel — workflows and rules are interpreted in-process; only `script` steps spawn a managed shell. The CLI listens on stderr for `__JAIPH_EVENT__` JSON lines, the single event channel for all execution modes. Stdout carries only plain script output, forwarded to the terminal as-is.
+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](/how-to/agent-auth) and [Configuration — Credential pre-flight](configuration.md#credential-pre-flight). `jaiph run --raw` does not run the pre-flight.
-### Run progress and tree output
+### Progress markers
-During `jaiph run`, the CLI renders a live tree of steps. Each step appears as a line with a marker, the step kind (`workflow`, `prompt`, `script`, `rule`), and the step name:
+| Marker | Meaning |
+|---|---|
+| `▸` | Step started. |
+| `✓` | Step completed successfully (with elapsed time). |
+| `✗` | Step failed (with elapsed time). |
+| `ℹ` | `log` message (dim/gray, no marker timing). |
+| `!` | `logerr` message (red; rendered on stdout with the progress tree). |
+| `·` | Continuation marker (heartbeat lines in non-TTY mode). |
+| ` ₁`, ` ₂`, … | Subscript prefix for `run async` branch numbering. |
-- **`▸`** — step started
-- **`✓`** / **`✗`** — step completed (pass/fail), with elapsed time (e.g. `✓ workflow scanner (0s)`, `✗ rule ci_passes (11s)`)
-- **`ℹ`** — `log` message (dim/gray, inline at the correct depth; no marker, spinner, or timing)
-- **`!`** — `logerr` message (red, writes to stderr)
+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`.
-The root PASS/FAIL summary uses the format `✓ PASS workflow default (0.2s)`. Completion lines include the step kind and name so each line is self-identifying even when multiple steps run concurrently.
+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`).
-**`log` / `logerr` and backslash escapes:** The displayed text follows `echo -e` semantics — a literal `\n` or `\t` in the message becomes a newline or tab. `LOG` / `LOGERR` JSON on stderr (and the `message` field in `run_summary.jsonl`) carries the unexpanded shell string.
+### Step display
-**TTY mode:** one extra line at the bottom shows the running workflow and elapsed time: `▸ RUNNING workflow (X.Xs)` — updated in place every second. When the run completes, it is replaced by the final PASS/FAIL line.
+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) and the first 24 characters of the prompt body in quotes (full line capped at 96 characters).
-**Successful exit:** when the default workflow exits **0**, the CLI prints `✓ PASS workflow default (...)` plus elapsed time (see above). If the workflow **returns** a value, the runtime writes `return_value.txt` under the run directory; the CLI prints that value on stdout **after** the PASS line, separated by a blank line (host paths are unchanged; Docker runs remap container paths when reading the file). See [Architecture — Durable artifact layout](architecture.md#durable-artifact-layout).
+### Return values
-**Non-TTY mode** (CI, pipes, log capture): no RUNNING line and no in-place updates. Step start (▸) and completion (✓/✗) lines still print as they occur. Long-running steps additionally print **heartbeat** lines to avoid looking like a hang:
+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.
-- Format: `· (running s)` — entire line dim/gray (plain text with `NO_COLOR`).
-- Cadence: first heartbeat after `JAIPH_NON_TTY_HEARTBEAT_FIRST_SEC` seconds (default **60**), then every `JAIPH_NON_TTY_HEARTBEAT_INTERVAL_MS` milliseconds (default **30000**; minimum **250**). Short steps emit no heartbeats.
-- Nested steps: heartbeats describe the innermost (deepest active) step.
+### Run artifacts
-**Event stream:** on stderr, the runtime emits `__JAIPH_EVENT__` lines (JSON). The CLI parses them to drive the tree, hooks, and failure summaries. Other stderr text is forwarded to the terminal. If a payload is not valid JSON, the CLI treats it as plain stderr.
+Each run directory is `//-/`, UTC. `` is `JAIPH_SOURCE_FILE` if set, otherwise the entry-file basename. Layout pinned in [Architecture — Durable artifact layout](architecture.md#durable-artifact-layout).
-**Parameterized invocations** show argument values inline in gray after the step name:
+Step `.out` files are written incrementally; consumers may `tail -f` them. `.out` / `.err` pairs are allocated at `STEP_START` with monotonic per-run sequence numbers (`%06d-.out|.err`).
-- All parameters use `key="value"` format in parentheses. Internal refs (`::impl`) and empty values are omitted.
-- Positional parameters display as `1="value"`, `2="value"`. Named parameters display as `name="value"`.
-- Whitespace in values is collapsed to a single space. Values are truncated to 32 characters (with `...`).
-- Prompt steps show the backend name (or custom command basename) and a preview (first 24 characters of prompt text) in quotes: `prompt cursor "summarize the..."` or `prompt my-agent.sh "summarize the..."`, followed by parameters (capped at 96 characters total).
+### Failure footer
-Example lines:
+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.
-- `▸ workflow docs_page (1="docs/cli.md", 2="strict")`
-- `· prompt cursor (running 60s)`
-- `· ▸ prompt cursor "${role} does ${task}" (role="engineer", task="Fix bugs")`
-- `· ▸ script fib (1="3")`
-- `· ▸ rule check_arg (1="Alice")`
+### Hook events
-If no parameters are passed, the line is unchanged (e.g. `▸ workflow default`). Disable color with `NO_COLOR=1`.
+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. See [Add a hook](/how-to/hooks).
-**Async branch numbering.** When a workflow contains multiple `run async` steps, each branch is prefixed with a **subscript number** (₁₂₃…) at the async call site's indentation level. Numbers use Unicode subscript digits (U+2080–U+2089) and are assigned in **dispatch order** within the parent workflow (first `run async` = ₁, second = ₂, etc.). The subscript number is always rendered with a leading space (` ₁`, ` ₂`, ` ₁₂`) and in dim/grey (same style as `·` continuation markers); in non-TTY or `NO_COLOR` mode it is emitted without ANSI codes. Non-async lines (root workflow, final PASS/FAIL) have no prefix.
+## `jaiph test`
-If a nested workflow also uses `run async`, those branches get their own numbering scope at the nested indent level:
+Execute `*.test.jh` blocks using the same `NodeWorkflowRuntime` as `jaiph run`, in-process, with mock support.
```text
-workflow default
- ₁▸ workflow parallel_suite
- ₂▸ workflow lint_check
- ₁· ₁▸ workflow test_unit
- ₁· ₂▸ workflow test_integration
- ₁· ₁✓ workflow test_unit (2s)
- ₁· ₂✓ workflow test_integration (5s)
- ₁✓ workflow parallel_suite (5s)
- ₂✓ workflow lint_check (1s)
-
-✓ PASS workflow default (5s)
+jaiph test # discover all *.test.jh under the workspace root
+jaiph test # discover all *.test.jh recursively under
+jaiph test # run a single test file
```
-All async branches render as siblings at the same indentation level. Inner steps within each branch appear one level deeper. The runtime isolates each async branch's frame stack, so `depth` on events is relative to the branch's own call depth. The `async_indices` array on events carries the chain of 1-based branch indices (one per nested `run async` level) so the display layer can map lines to branches.
+| Invocation | Workspace root detection |
+|---|---|
+| `jaiph test` | Walk up from `process.cwd()` until `.jaiph` or `.git`; falls back to `process.cwd()`. |
+| `jaiph test ` | Walk up from the resolved ``. |
+| `jaiph test ` | Walk up from the test file's directory. |
-**Prompt transcript replay.** The progress renderer shows only ▸ / ✓ lines for a `prompt` step — not a nested subtree. After the step completes (on terminal stdout, non-test runs), the runtime replays the step's `.out` artifact if stdout was not already streamed live. Replay is skipped when stdout is a pipe or when the prompt already streamed via tee. `jaiph test` does not use this replay path.
+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.
-To surface the agent answer inline in the tree, use `log` explicitly:
+Assertions: `expect_contain`, `expect_equal`, `expect_not_contain` — see [Write & run tests](/how-to/testing).
-```jaiph
-const response = prompt "Summarize the report"
-log response
-```
-
-### Failed run summary (stderr)
-
-On non-zero exit, the CLI may print a footer with the path to `run_summary.jsonl`, `out:` / `err:` artifact paths, and `Output of failed step:` plus a trimmed excerpt. These are resolved from the **last** `STEP_END` object in the summary with `status` != 0, using `out_content` / `err_content` when present and otherwise the `out_file` / `err_file` fields (last matches terminal failure after `catch`/`ensure` retries and stray earlier failures). If no failed `STEP_END` is found, the CLI falls back to a run-directory artifact heuristic.
-
-In Docker mode, artifact paths recorded by the container use container-internal prefixes (`/jaiph/run/…`). The CLI remaps these to host paths and discovers the run directory from the bind-mounted runs directory by matching the `JAIPH_RUN_ID` in each `run_summary.jsonl` when the container meta file is inaccessible. This run-id-based lookup is safe under concurrent `jaiph run` invocations sharing the same runs directory. The failure summary therefore displays identically to local (no-sandbox) runs — same structure, same host-resolvable paths, same "Output of failed step" excerpt. See [Sandboxing — Path remapping](sandboxing.md#path-remapping).
-
-### Run artifacts and live output
-
-Each run directory is `//-/`, where date and time are UTC and `` is `JAIPH_SOURCE_FILE` if set, otherwise the entry file basename. Steps that allocate captures open **paired** `NNNNNN-.out` and `.err` files at **`STEP_START`** (see [Architecture — Durable artifact layout](architecture.md#durable-artifact-layout) and [Runtime artifacts — What each artifact is for](artifacts.md#what-each-artifact-is-for)).
-
-Step **stdout** artifacts are written **incrementally during execution**, so you can tail a running step's output in real time:
+## `jaiph compile`
+{: #jaiph-compile}
-```bash
-# In one terminal — run a long workflow
-jaiph run ./flows/deploy.jh
+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.
-# In another terminal — watch a step's output as it executes
-tail -f .jaiph/runs/2026-03-22/14-30-00-deploy.jh/000003-deploy__run_migrations.out
+```text
+jaiph compile [--json] [--workspace ] ...
```
-Which steps get numbered `.out`/`.err` pairs, how prompts differ from managed scripts, and when empty files are removed are spelled out in [Runtime artifacts](artifacts.md); the durable timeline either way is **`run_summary.jsonl`**.
-
-### Run summary (`run_summary.jsonl`) {#run-summary-jsonl}
-
-Each run directory also contains `run_summary.jsonl`: one JSON object per line, appended in execution order. It is the canonical append-only record of runtime events (lifecycle, logs, inbox flow, and step boundaries). Tooling can tail the file by byte offset and process new lines idempotently. For a single run, lines follow execution order; inbox routes always drain **sequentially**, so inbox lifecycle events stay aligned with dispatch order. Summary lines are still appended atomically under a lock shared with other concurrent writers on the same run directory (for example `run async` branches appending step events).
-
-**Versioning.** Every object includes `event_version` (currently `1`). New fields may be added; consumers should tolerate unknown keys.
-
-**Common fields.** All lines include `type`, `ts` (UTC timestamp), `run_id`, and `event_version`. Step-related types also carry `id`, `parent_id`, `seq`, and `depth` (matching the `__JAIPH_EVENT__` stream on stderr).
-
-**Correlation rules:**
-
-- **`run_id`:** same across all lines in a given run's file.
-- **Workflow boundaries:** for each workflow name, `WORKFLOW_START` count equals `WORKFLOW_END` count.
-- **Steps:** `STEP_START` and `STEP_END` share the same `id`. Use `parent_id`, `seq`, and `depth` to rebuild the tree.
-- **Inbox:** one `INBOX_ENQUEUE` per `send` with a unique `inbox_seq` (zero-padded, e.g. `001`). Each routed target gets one `INBOX_DISPATCH_START` and one `INBOX_DISPATCH_COMPLETE` sharing the same `inbox_seq`, `channel`, `target`, and `sender`.
-- **Ordering:** lines are valid JSONL (one object per line, atomic append). Inbox dispatch is sequential; `ts` order matches dispatch order for inbox lifecycle events on a single run.
-
-**Event taxonomy (schema `event_version` 1):**
-
-| Field | `WORKFLOW_START` | `WORKFLOW_END` | `STEP_START` | `STEP_END` | `LOG` | `LOGERR` | `INBOX_ENQUEUE` | `INBOX_DISPATCH_START` | `INBOX_DISPATCH_COMPLETE` |
-|-------|------------------|----------------|--------------|------------|-------|----------|-----------------|------------------------|---------------------------|
-| `type` | required | required | required | required | required | required | required | required | required |
-| `ts` | required | required | required | required | required | required | required | required | required |
-| `run_id` | required | required | required | required | required | required | required | required | required |
-| `event_version` | required (`1`) | required (`1`) | required (`1`) | required (`1`) | required (`1`) | required (`1`) | required (`1`) | required (`1`) | required (`1`) |
-| `workflow` | required (name) | required (name) | — | — | — | — | — | — | — |
-| `source` | required (basename or empty) | required (basename or empty) | — | — | — | — | — | — | — |
-| `func`, `kind`, `name` | — | — | required | required | — | — | — | — | — |
-| `status`, `elapsed_ms` (step) | — | — | null on start | required numbers when ended | — | — | — | — | — |
-| `out_file`, `err_file` | — | — | required strings | required strings | — | — | — | — | — |
-| `id`, `parent_id`, `seq`, `depth` | — | — | required | required | — | — | — | — | — |
-| `params` | — | — | optional JSON array | optional JSON array | — | — | — | — | — |
-| `dispatched`, `channel`, `sender` | — | — | optional (inbox dispatch) | optional (inbox dispatch) | — | — | — | — | — |
-| `out_content`, `err_content` | — | — | — | optional on `STEP_END` | — | — | — | — | — |
-| `async_indices` | — | — | optional `number[]` | optional `number[]` | optional `number[]` | optional `number[]` | — | — | — |
-| `message`, `depth` | — | — | — | — | required | required | — | — | — |
-| `inbox_seq`, `channel`, `sender` | — | — | — | — | — | — | required | required | required |
-| `payload_preview`, `payload_ref` | — | — | — | — | — | — | required | — | — |
-| `target` | — | — | — | — | — | — | — | required | required |
-| `status`, `elapsed_ms` (dispatch) | — | — | — | — | — | — | — | — | required (exit code and ms) |
-
-`PROMPT_START` / `PROMPT_END` (not in the table): include `backend`, optional `model`, optional `model_reason`, optional `status`, optional `preview`, `depth`, and optional `step_id` / `step_name` tying the prompt to the enclosing step frame. `model` is the resolved model name (or `null` when the backend auto-selects). `model_reason` is one of `explicit`, `flags`, or `backend-default` — see [Configuration — Model resolution](configuration.md#model-resolution).
-
-**Event semantics:**
-
-- **`WORKFLOW_START` / `WORKFLOW_END`:** mark entry and exit of a workflow body. `workflow` is the declared name; `source` is the `.jh` basename.
-- **`STEP_START` / `STEP_END`:** mirror stderr step events. `STEP_END` may include `out_content` / `err_content` (embedded artifact text, size-capped).
-- **`LOG` / `LOGERR`:** emitted by `log` / `logerr` keywords. `depth` is the step-stack depth. `message` is the shell string before `echo -e` expansion.
-- **`INBOX_ENQUEUE`:** recorded when a message is queued. `payload_preview` is UTF-8-safe JSON (up to 4096 bytes; truncated with `...`). `payload_ref` is `null` when the full body fits, otherwise a run-relative path.
-- **`INBOX_DISPATCH_START` / `INBOX_DISPATCH_COMPLETE`:** wrap one invocation of a route target. `status` is exit code; `elapsed_ms` is wall time.
-
-Together with step `.out` / `.err` files, `run_summary.jsonl` is enough to reconstruct the step tree, log timelines, inbox flow, and workflow boundaries.
-
-### Hooks
-
-You can run custom commands at workflow/step lifecycle events via hooks. Config lives in `~/.jaiph/hooks.json` (global) and `/.jaiph/hooks.json` (project-local); project-local overrides global per event. See [Hooks](hooks.md) for schema, events, payload, and examples.
-
-## `jaiph test`
-
-Run tests from `*.test.jh` files that contain `test "..." { ... }` blocks. Test files can import workflows and use `mock prompt` to simulate agent responses without calling the real backend.
+At least one path is required. `-h` / `--help` must appear before the first path (they are not scanned after a path token, unlike other subcommands).
-The test runner uses the same Node workflow runtime as `jaiph run`. For each test file, the CLI runs **`buildScripts`** with that file as the **entrypoint** (the test module plus its **import closure** only — not every `*.jh` in the repo), so imported workflow modules get emitted scripts under `JAIPH_SCRIPTS`. It then builds the runtime graph **once** per file and reuses it across all blocks and `test_run_workflow` steps. Each block runs through the AST interpreter with mock support and assertion evaluation (`expect_contain`, `expect_equal`, `expect_not_contain`).
+| Argument shape | Behaviour |
+|---|---|
+| File path (`*.jh` or `*.test.jh`) | Expanded to the transitive import closure. Each module in the union is parsed and validated once. |
+| Directory path | Tree scanned for `*.jh` files; `*.test.jh` is **skipped** (use an explicit file path to validate a test module). Each non-test `*.jh` is treated as an entrypoint and its closure merged into the validation set. |
-**Usage:**
+| Flag | Effect |
+|---|---|
+| `--json` | On success, print `[]` to stdout. On failure, print one JSON array of `{ file, line, col, code, message }` diagnostics to stdout and exit `1`. |
+| `--workspace ` | Override library resolution root for all reached modules. Without it, the workspace is auto-detected per path. |
-- `jaiph test` — discover and run all `*.test.jh` under the workspace root. The workspace root is found by walking up from the **current working directory** until a directory with `.jaiph` or `.git` is found; if neither exists, the current directory is used (same `detectWorkspaceRoot` algorithm as `jaiph run` / `jaiph install`).
-- `jaiph test ` — run all `*.test.jh` files recursively under the given directory. Workspace root for script compilation is detected by walking up from **that directory** (resolved), not necessarily from your shell cwd.
-- `jaiph test ` — run a single test file; workspace root is detected from the test file’s directory.
+Within each entry's import closure, diagnostics are sorted by `(file, line, col)`; when multiple entry points are supplied, those batches are appended in discovery order (not re-sorted globally). Without `--json`, the same set is written to stderr as `path:line:col CODE message` lines. Any non-empty diagnostic set exits `1`. Parser/loader failures abort the affected entry's closure with a single diagnostic for that entry; siblings continue.
-With no arguments, or with a directory that contains no test files, the command exits with status **1** and prints an error.
-
-Passing a plain workflow file (e.g. `say_hello.jh`) is not supported; the test file imports the workflow and declares mocks. Extra arguments after the path are accepted but ignored. See [Testing](testing.md) for test block syntax and assertions.
+## `jaiph format`
-**Examples:**
+Reformat `.jh` / `.test.jh` files into canonical style.
-```bash
-jaiph test
-jaiph test ./e2e
-jaiph test e2e/workflow_greeting.test.jh
-jaiph test e2e/say_hello.test.jh
+```text
+jaiph format [--check] [--indent ]
```
-## `jaiph compile`
+Paths must end with `.jh`. Formatting is idempotent. Comments and shebangs are preserved. Triple-quoted bodies, prompt blocks, and fenced script blocks emit verbatim — inner lines are not re-indented relative to the surrounding scope.
-Parse modules and run **`validateReferences`** (the same compile-time checks as before `jaiph run`) **without** writing `scripts/`, **without** calling **`buildRuntimeGraph`**, and **without** spawning the workflow runner. Use this for CI gates, pre-commit hooks, or editor diagnostics.
+| Flag | Argument | Default | Effect |
+|---|---|---|---|
+| `--indent` | `` | `2` | Spaces per indent level. |
+| `--check` | — | — | Verify without writing. Exit `0` when files match canonical form, `1` when any file would change. |
-```bash
-jaiph compile [--json] [--workspace ] ...
-```
+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.
-At least one path is required. **`jaiph compile -h`** or **`jaiph compile --help`** prints command-specific usage and exits **0**.
+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.
-**File arguments** — Each `*.jh` file is expanded to its **transitive import closure**; every module in the union is parsed and validated once.
+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.
-**Directory arguments** — The tree is scanned for `*.jh` files whose basename is **not** `*.test.jh` (same rule as `walkjhFiles` in the transpiler: files like `foo.test.jh` are skipped). Each non-test `*.jh` under the tree is treated as an entrypoint and its closure merged into the same validation set. To validate a test module’s graph explicitly, pass that **`*.test.jh` file** as a path (directories never pick up `*.test.jh` as roots).
+## `jaiph init`
-**Flags:**
+```text
+jaiph init [workspace-path]
+```
-- **`--json`** — On success, print `[]` to stdout. On failure, print one JSON **array** of objects `{ "file", "line", "col", "code", "message" }` to stdout and exit **1** (non-JSON errors use a synthetic `E_COMPILE` object when the message is not in `file:line:col CODE …` form).
-- **`--workspace `** — Override the workspace root used for **library import resolution** (`/.jaiph/libs/`, etc.) for **all** modules reached from the given paths. When omitted, the workspace is **auto-detected** from each path’s location (`detectWorkspaceRoot` — same algorithm as `jaiph run`, starting from the file’s directory or from a directory argument).
+Creates the following under the target workspace:
-## `jaiph format`
+| 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/SKILL.md` | Copy of the skill markdown shipped with this `jaiph` build (see [`JAIPH_SKILL_PATH`](env-vars.md)). |
-Reformat Jaiph source files to a canonical style. Paths must end with **`.jh`**, which includes **`*.test.jh`** test modules. The formatter parses each file into an AST and re-emits it with consistent whitespace and indentation. Formatting is idempotent — running it twice produces the same output. Comments and shebangs are preserved. Multiline string bodies (`"""…"""`), prompt blocks, and fenced script blocks are emitted verbatim — inner lines are not re-indented relative to the surrounding scope, so repeated formatting never shifts embedded content deeper.
+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.
-**Blank-line preservation:** A single blank line between steps inside a workflow or rule body is preserved — use it for visual grouping of related calls. Multiple consecutive blank lines are collapsed to one; trailing blank lines before `}` are removed. This applies to all block-level steps (calls, `log`, `const`, `if`, etc.).
+## `jaiph install`
-**Top-level ordering:** The formatter hoists `import`, `config`, and `channel` declarations to the top of the file (in that order, preserving source order within each group). All other top-level definitions — `const`, `rule`, `script`, `workflow`, and `test` blocks — keep their original relative order from the source file. Comments immediately before an `import`, `config`, or `channel` move with that construct when hoisted; comments before non-hoisted definitions stay in place.
+Install project-scoped libraries into `.jaiph/libs//` under the workspace root. The workspace root is detected from `process.cwd()` (`detectWorkspaceRoot` — walks up until `.jaiph` or `.git`, with temp-directory guards).
-```bash
-jaiph format [--check] [--indent ]
+```text
+jaiph install [--force] [ | ...]
+jaiph install [--force] # restore from lockfile
```
-One or more file paths are required (each path must end with `.jh`, e.g. `flow.jh` or `e2e/flow.test.jh`). Paths that do not end with `.jh` are rejected. If a file cannot be parsed, the command exits immediately with status 1 and a parse error on stderr.
+| Flag | Effect |
+|---|---|
+| `--force` | Delete and re-clone existing libraries. Accepted anywhere in the argument list. |
-**Flags:**
+### Argument classification
-- **`--indent `** — spaces per indent level (default: `2`).
-- **`--check`** — verify formatting without writing. Exits 0 when all files are already formatted; exits 1 when any file needs changes, printing the file name to stderr. No files are modified in check mode.
+| Argument shape | Resolution |
+|---|---|
+| Bare registry name matching `^[A-Za-z0-9_-]+(@[A-Za-z0-9._+/-]+)?$` (no `/`, no `:`) | Looked up in the registry index. Examples: `jaiphlang`, `mylib@v1.2`. |
+| Anything else | Parsed as a git URL with optional trailing `@`. Examples: `https://github.com/you/queue-lib.git`, `git@github.com:org/repo.git@main`. |
-**Examples:**
+### Post-clone hygiene
-```bash
-# Rewrite files in place
-jaiph format flow.jh utils.jh
+Each successful clone runs three checks before the lib counts as installed:
-# Check formatting in CI (non-zero exit on drift); ensure globs expand to real paths
-jaiph format --check src/**/*.jh
+- **`.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.
+- **`.git` strip** — `/.git` is removed recursively.
-# Use 4-space indentation
-jaiph format --indent 4 flow.jh
-```
+### Restore-from-lockfile mode
-## `jaiph init`
+`jaiph install` with no positional args reads `.jaiph/libs.lock` and clones each entry. The registry is never contacted. If a lock entry carries a `commit`, the cloned HEAD must match it; on mismatch the directory is removed and the run fails with the locked vs cloned SHAs and the remedy. Lock entries without `commit` (older lockfiles) restore without the check.
-Initialize Jaiph files in a workspace directory.
+### Parallel clones
-```bash
-jaiph init [workspace-path]
-```
+Missing libraries are cloned with bounded concurrency (default **4 in flight**). The warm-skip pass runs before any clone. Independent clone failures still propagate; failed libraries are not added to the lockfile.
-Creates:
+### Registry
-- `.jaiph/.gitignore` — lists `runs` and `tmp`. If the file already exists and does not match this exact list, `jaiph init` exits with a non-zero status.
-- `.jaiph/bootstrap.jh` — canonical bootstrap workflow; made executable. The template uses a triple-quoted multiline prompt body (`prompt """ ... """`) so the generated file parses and compiles as valid Jaiph. It asks the agent to scaffold workflows under `.jaiph/` and ends by logging a summary (`WHAT CHANGED` + `WHY`). Docker sandboxing uses the default `ghcr.io/jaiphlang/jaiph-runtime` image unless you set `runtime.docker_image` or `JAIPH_DOCKER_IMAGE`.
-- `.jaiph/SKILL.md` — copied when the CLI can resolve a skill markdown file: if **`JAIPH_SKILL_PATH`** is set **and** that path exists, it wins; otherwise the CLI tries install-relative paths (`jaiph-skill.md` beside the packaged tree, then `docs/jaiph-skill.md` beside the package), then **`docs/jaiph-skill.md` under the current working directory**. If none of these exist, `SKILL.md` is not written and a note is printed.
+| Aspect | Value |
+|---|---|
+| Source | `JAIPH_REGISTRY` (default `https://jaiph.org/registry`). |
+| Loading | Loaded once per invocation when at least one positional argument is a bare name. URL-form installs and restore-from-lock never read the registry. |
+| Disk paths | Values without a `://` scheme, or starting with `file://`, are read from disk. Everything else is fetched via global `fetch`. |
+| Index format | `{ "libs": { "": { "url": "", "description": "" } } }`. Each key must match `^[A-Za-z0-9_-]+$`. Unknown per-entry keys are accepted and ignored. |
+| Lookup errors | `lib "" not found in registry `, `failed to read registry : `, `failed to fetch registry : HTTP `, `failed to parse registry : `, `failed to parse registry : invalid name ""`. |
-## `jaiph install`
+### Lockfile
-Install project-scoped libraries. Libraries are git repos cloned into `.jaiph/libs//` under the **workspace root**. The workspace is determined from the **current working directory** (`detectWorkspaceRoot(process.cwd())` — walk upward until `.jaiph` or `.git`, with the same temp-directory guards as `jaiph run`). A lockfile (`.jaiph/libs.lock`) under that root tracks installed libraries for reproducible setups.
-
-```bash
-jaiph install [--force] ...
-jaiph install [--force]
-```
-
-**With arguments** — clone each repo into `.jaiph/libs//` (shallow: `--depth 1`) and upsert the entry in `.jaiph/libs.lock`. The library name is derived from the URL: last path segment, stripped of `.git` suffix (e.g. `github.com/you/queue-lib.git` → `queue-lib`). Version pinning is usually written as **`https://…/name.git@`**; other URL shapes with a trailing **`@ref`** are also accepted when the parser can split URL and version unambiguously.
-
-**Without arguments** — restore all libraries from `.jaiph/libs.lock`. Useful after cloning a project or in CI. If the lockfile exists but lists **no** libraries, the command prints `No libs in lockfile.` and exits **0**.
-
-If `.jaiph/libs//` already exists, the library is skipped. Use **`--force`** (anywhere in the argument list) to delete and re-clone.
-
-**Lockfile format** (`.jaiph/libs.lock`):
+`.jaiph/libs.lock` shape:
```json
{
"libs": [
- { "name": "queue-lib", "url": "https://github.com/you/queue-lib.git", "version": "v1.0" }
+ {
+ "name": "jaiphlang",
+ "url": "https://github.com/jaiphlang/jaiphlang.git",
+ "commit": "1a2b3c4d5e6f7890abcdef1234567890abcdef12"
+ },
+ {
+ "name": "queue-lib",
+ "url": "https://github.com/you/queue-lib.git",
+ "version": "v1.0",
+ "commit": "fedcba9876543210fedcba9876543210fedcba98"
+ }
]
}
```
-**Examples:**
-
-```bash
-# Install a library
-jaiph install https://github.com/you/queue-lib.git
-
-# Install at a specific version
-jaiph install https://github.com/you/queue-lib.git@v1.0
-
-# Re-clone an existing library
-jaiph install --force https://github.com/you/queue-lib.git
-
-# Restore all libraries from lockfile
-jaiph install
-```
-
-After installation, import library modules using the `/