diff --git a/.cursor/mcp.json b/.cursor/mcp.json
new file mode 100644
index 00000000..2a6e038e
--- /dev/null
+++ b/.cursor/mcp.json
@@ -0,0 +1,7 @@
+{
+ "mcpServers": {
+ "product-owner": {
+ "url": "http://127.0.0.1:5247/mcp"
+ }
+ }
+}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4dc97a0c..b8f1af4e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -71,7 +71,7 @@ jobs:
uses: actions/checkout@v4
- name: Setup Ruby
- uses: ruby/setup-ruby@v1
+ uses: ruby/setup-ruby@e8944e80fb94b20106697132f8c20c665fab29e9 # v1.325.0
with:
ruby-version: "3.2"
bundler-cache: true
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 87d714a6..9f083611 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -149,6 +149,95 @@ jobs:
bash ../scripts/release-version-check.sh \
"${{ steps.meta.outputs.channel }}" "${{ steps.meta.outputs.tag }}" "${got}"
+ publish-image:
+ name: Publish GHCR runner image
+ needs: [build, sanity-windows]
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+ 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)
+ 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: Resolve image tags
+ id: image
+ run: |
+ set -euo pipefail
+ owner="$(printf '%s' "${GITHUB_REPOSITORY_OWNER}" | tr '[:upper:]' '[:lower:]')"
+ image="ghcr.io/${owner}/jaiph-runtime"
+ echo "image=${image}" >> "${GITHUB_OUTPUT}"
+ case "${{ steps.meta.outputs.channel }}" in
+ stable)
+ tag="${{ steps.meta.outputs.tag }}"
+ ver="${tag#v}"
+ echo "tags=${image}:${ver},${image}:${tag},${image}:latest" >> "${GITHUB_OUTPUT}"
+ ;;
+ nightly)
+ echo "tags=${image}:nightly" >> "${GITHUB_OUTPUT}"
+ ;;
+ *)
+ echo "Unsupported channel: ${{ steps.meta.outputs.channel }}" >&2
+ exit 1
+ ;;
+ esac
+
+ - name: Download linux binary artifacts
+ uses: actions/download-artifact@v4
+ with:
+ pattern: jaiph-linux-*
+ path: runtime
+ merge-multiple: true
+
+ - name: Prepare linux binaries
+ working-directory: runtime
+ run: |
+ set -euo pipefail
+ test -f jaiph-linux-x64
+ test -f jaiph-linux-arm64
+ chmod +x jaiph-linux-x64 jaiph-linux-arm64
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build and push
+ uses: docker/build-push-action@v6
+ with:
+ context: runtime
+ file: runtime/Dockerfile
+ platforms: linux/amd64,linux/arm64
+ push: true
+ tags: ${{ steps.image.outputs.tags }}
+ labels: |
+ org.opencontainers.image.source=https://github.com/${{ github.repository }}
+ org.opencontainers.image.revision=${{ github.sha }}
+ org.opencontainers.image.version=${{ steps.meta.outputs.tag }}
+
release:
name: Publish release assets
needs: [build, sanity-windows]
diff --git a/.gitignore b/.gitignore
index b39a8cec..422a24af 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,7 +34,10 @@ jaiph.key
*.swp
*.vsix
+.jaiph/queue-state.md
+.jaiph/queue-state.md.lock
.jaiph/runs/
+.jaiph/product-owner/
.jaiph/*.jaiph.map
.jaiph/tmp/
.jaiph/scripts/
@@ -58,6 +61,12 @@ e2e/assign_capture.sh
.obsidian/
+# Playwright local output (last-run cache + failure artifacts)
+test-results/
+
+# Engineer `git format-patch` leftovers at repo root (also saved under .jaiph/runs)
+/*.patch
+
# debug / temp directories (never commit)
docker-*/
nested-*/
diff --git a/.jaiph/architect_review.jh b/.jaiph/architect_review.jh
index eda0cbc8..eb65cd8b 100755
--- a/.jaiph/architect_review.jh
+++ b/.jaiph/architect_review.jh
@@ -9,7 +9,9 @@ config {
agent.claude_flags = "--permission-mode bypassPermissions"
}
-script jaiph_review_body_file = `printf '%s\n' "$JAIPH_WORKSPACE/.jaiph/tmp/architect_review_body.txt"`
+script jaiph_review_body_file = '''
+printf '%s\n' "$JAIPH_WORKSPACE/.jaiph/tmp/architect_review_body.txt"
+'''
# Packed as: first line = verdict, rest = updated_description (must stay top-level:
# const … = prompt """…""" is not supported inside run … catch — see parseRecoverStatement).
@@ -71,33 +73,33 @@ def architect_agent_review(task) {
}
def review_one_header(header) {
- run common.arg_nonempty(header) catch (err) {
+ common.arg_nonempty(header) catch (err) {
return ""
}
- const task = run queue.get_task_by_header(header)
- run queue.task_is_dev_ready(task) catch (err) {
- const packed = run architect_agent_review(task)
- const verdict = run common.first_line_str(packed)
- const updated_description = run common.rest_lines_str(packed)
- const body_file = run jaiph_review_body_file()
- 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) {
+ const task = queue.get_task_by_header(header)
+ queue.task_is_dev_ready(task) catch (err) {
+ const packed = architect_agent_review(task)
+ const verdict = common.first_line_str(packed)
+ const updated_description = common.rest_lines_str(packed)
+ const body_file = jaiph_review_body_file()
+ common.mkdir_p_simple(common.jaiph_tmp_dir())
+ common.str_equals(verdict, "dev-ready") catch (err) {
+ common.arg_nonempty(updated_description) catch (err) {
fail "needs-work requires a non-empty updated_description (questions for the author)."
}
- run common.save_string_to_file(body_file, updated_description)
- run queue.set_task_description_from_file(header, body_file)
+ stdin "${updated_description}" -> common.save_string_to_file(body_file)
+ queue.set_task_description_from_file(header, body_file)
log "Needs work (description updated): ${header}"
return ""
}
- run common.arg_nonempty(updated_description) catch (err) {
- run queue.mark_task_dev_ready(header)
+ common.arg_nonempty(updated_description) catch (err) {
+ queue.mark_task_dev_ready(header)
log "Marked dev-ready: ${header}"
return ""
}
- 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)
+ stdin "${updated_description}" -> common.save_string_to_file(body_file)
+ queue.set_task_description_from_file(header, body_file)
+ queue.mark_task_dev_ready(header)
log "Marked dev-ready: ${header}"
return ""
}
@@ -105,28 +107,28 @@ def review_one_header(header) {
}
def process_headers_recursive(header, remaining) {
- run review_one_header(header)
- run common.arg_nonempty(remaining) catch (err) {
+ review_one_header(header)
+ common.arg_nonempty(remaining) catch (err) {
return ""
}
- const next = run common.first_line_str(remaining)
- const rest = run common.rest_lines_str(remaining)
- run process_headers_recursive(next, rest)
+ const next = common.first_line_str(remaining)
+ const rest = common.rest_lines_str(remaining)
+ process_headers_recursive(next, rest)
}
def maybe_process_headers(first, rest) {
- run common.arg_nonempty(first) catch (err) {
+ common.arg_nonempty(first) catch (err) {
return ""
}
- run process_headers_recursive(first, rest)
+ process_headers_recursive(first, rest)
}
export def main() {
- const headers = run queue.get_all_task_headers()
- const first = run common.first_line_str(headers)
- const rest = run common.rest_lines_str(headers)
- run maybe_process_headers(first, rest)
- run queue.all_dev_ready() catch (err) {
+ const headers = queue.get_all_task_headers()
+ const first = common.first_line_str(headers)
+ const rest = common.rest_lines_str(headers)
+ maybe_process_headers(first, rest)
+ 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 b028bb51..ea7affbb 100755
--- a/.jaiph/docs_parity.jh
+++ b/.jaiph/docs_parity.jh
@@ -12,9 +12,14 @@ 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.
+ - Source code is the source of truth for behavior. Verify claims against
+ the code before writing them down.
+ - docs/architecture.md is the source of truth for architecture boundaries
+ and execution flow.
+ - design/0003-docs-one-fact-one-owner.md is the source of truth for which
+ published page owns a contract. State a fact only on its owner page.
+ Every other page uses one sentence and a link. Do not paste an owned
+ rule onto a second page.
- 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
@@ -40,20 +45,19 @@ const skills_preamble = """
optional /tmp revision HTML artifact from that skill.
"""
-script assert_newline_paths_are_files = ```
+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"
-```
-
+'''
def docs_files_present(list) {
- run assert_newline_paths_are_files(list)
+ assert_newline_paths_are_files(list)
}
-script assert_worktree_clean_for_docs = ```
+script assert_worktree_clean_for_docs = '''
local current_changed_files
current_changed_files="$(
{
@@ -68,13 +72,12 @@ script assert_worktree_clean_for_docs = ```
echo "$current_changed_files" >&2
return 1
fi
-```
-
+'''
def worktree_is_clean() {
- run assert_worktree_clean_for_docs()
+ assert_worktree_clean_for_docs()
}
-script assert_only_allowed_changed = ```
+script assert_only_allowed_changed = '''
local allowed="$1"
local after_changed_files
after_changed_files="$(
@@ -92,13 +95,12 @@ script assert_only_allowed_changed = ```
echo "Unexpected file changed by docs prompt: $changed_file" >&2
return 1
done <<< "$after_changed_files"
-```
-
+'''
def only_expected_docs_changed_after_prompt(allowed) {
- run assert_only_allowed_changed(allowed)
+ assert_only_allowed_changed(allowed)
}
-script list_docs_md_paths = ```
+script list_docs_md_paths = '''
local out="" f
for f in docs/*.md; do
if [ -z "$out" ]; then
@@ -108,19 +110,17 @@ script list_docs_md_paths = ```
fi
done
printf '%s\n' "$out"
-```
-
+'''
# Docs surfaces the agent may edit: markdown/HTML, CHANGELOG, CLI usage/help
# strings in usage.ts and per-command USAGE constants (e.g. run.ts).
-script build_allowed_paths_block = ```
+script build_allowed_paths_block = '''
local out f
out="$(printf '%s\n' README.md CHANGELOG.md docs/index.html docs/_layouts/docs.html src/cli/shared/usage.ts)"
for f in docs/*.md src/cli/commands/*.ts; do
out="$out"$'\n'"$f"
done
printf '%s\n' "$out"
-```
-
+'''
export def update_from_task(taskDesc) {
prompt """
${skills_preamble}
@@ -139,6 +139,9 @@ export def update_from_task(taskDesc) {
src/cli/shared/usage.ts, and src/cli/commands/*.ts (help text / USAGE
constants only — do not change command behavior).
Do NOT modify tests, config, or unrelated source.
+ - Follow design/0003-docs-one-fact-one-owner.md. If the task already
+ states a fact on its owner page, do not copy that rule onto another
+ page. Link instead. Do not merge pages.
- Navigation links between docs pages are provided by the Jekyll template.
Do NOT add manual navigation blocks to individual markdown pages.
@@ -159,13 +162,17 @@ def docs_page(path) {
language, runtime or a set of features:
0. Use docs/architecture.md as the source of truth for architecture boundaries,
contracts, and execution flow. Keep docs consistent with it.
+ 0a. Use design/0003-docs-one-fact-one-owner.md as the source of truth for
+ which page owns a contract. If this page restates a rule that belongs
+ on another owner, replace the copy with one sentence and a link.
+ Do not add missing detail that the owner page already states.
1. Check if it has approachable structure with overview, describing high
level goals and concepts.
2. Jaiph source code is the single source of truth for the language. Verify
if the content is consistent with the source code and if it covers all
relevant features and concepts.
- 3. Add missing information, fix inconsistencies, errors, warnings,
- suggestions, improvements and other issues.
+ 3. Fix inconsistencies, errors, and stale claims. Do not grow the page
+ by pasting another page's inventory.
4. Keep examples (if present) executable and aligned with current behavior.
5. Ensure the content is approachable and understandable by a human reader.
After factual edits, revise the prose with the plain-writing skill
@@ -190,21 +197,25 @@ def docs_overview(docPaths) {
Read all ${docPaths}, they contain all the documentation for the
- project. Also read docs/architecture.md and treat it as architecture source of
- truth. Please update docs in the following way:
+ project. Also read docs/architecture.md (architecture) and
+ design/0003-docs-one-fact-one-owner.md (which page owns each contract).
+ Please update docs in the following way:
1. Ensure they are consistent with each other.
1a.Ensure they are consistent with docs/architecture.md, especially:
- runtime vs CLI responsibilities,
- runtime/CLI contracts (__JAIPH_EVENT__ vs run artifacts),
- channels and hooks behavior,
- Jaiph runtime test lane (*.test.jh via jaiph test).
- 2. Ensure they are organized in a logical way. If needed, add new files,
- merge existing files, or split them into smaller files, or move
- the content between files.
+ 1b.Ensure each contract is stated in full on its owner page only
+ (ADR 0003). Other pages keep one sentence and a link. Do not merge
+ pages to look smaller.
+ 2. Ensure they stay in their Diátaxis quadrant. Do not add a new how-to
+ whose fact already has an owner. Split a page only when one topic
+ outgrew the file.
3. Ensure they are interconnected and form a coherent whole.
- 4. Ensure there is no misleading or reduntant information. Typically you may
- detect some leftovers from previous versions or low level details that
- are no longer relevant or too detailed.
+ 4. Ensure there is no misleading or redundant information. Typically you may
+ detect leftovers from previous versions or a restated inventory that
+ belongs on another owner page.
5. Navigation links between docs pages are provided by the Jekyll template
(docs/_layouts/docs.html). Do NOT add manual navigation blocks (like
'More Documentation' sections) to individual markdown pages. Inline
@@ -234,18 +245,18 @@ def docs_overview(docPaths) {
}
export def main() {
- run claude.ensure_usage()
- run worktree_is_clean()
- const allowed_list = run build_allowed_paths_block()
- run docs_files_present(allowed_list)
- const docs_md_list = run list_docs_md_paths()
+ claude.ensure_usage()
+ worktree_is_clean()
+ const allowed_list = build_allowed_paths_block()
+ docs_files_present(allowed_list)
+ const docs_md_list = list_docs_md_paths()
for path in docs_md_list {
if path != "" {
- run claude.ensure_usage()
- run docs_page(path)
+ claude.ensure_usage()
+ docs_page(path)
}
}
- run claude.ensure_usage()
- run docs_overview(docs_md_list)
- run only_expected_docs_changed_after_prompt(allowed_list)
+ claude.ensure_usage()
+ docs_overview(docs_md_list)
+ only_expected_docs_changed_after_prompt(allowed_list)
}
diff --git a/.jaiph/engineer.jh b/.jaiph/engineer.jh
index ec736f56..db628443 100755
--- a/.jaiph/engineer.jh
+++ b/.jaiph/engineer.jh
@@ -1,7 +1,7 @@
#!/usr/bin/env jaiph
#
-# Implement a task: code, CI, docs, commit patch.
+# Implement a task: code, CI, docs, commit.
#
# CLI / overnight (queue-driven):
# jaiph run .jaiph/engineer.jh
@@ -10,7 +10,6 @@
# Hub / serve / mcp (task parameter, no QUEUE.md):
# export def implement_from_task(task) — used by .jaiph/main.jh engineer(task)
#
-import "jaiphlang/artifacts" as artifacts
import "jaiphlang/claude" as claude
import "jaiphlang/git" as git
import "jaiphlang/queue" as queue
@@ -35,6 +34,8 @@ const safety_constraints = """
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.
+ - Do not edit, format, or rewrite `.jaiph/**` (including `*.jh`). Parent owns
+ those files. Changing them can recurse into nested Claude sessions.
- 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
@@ -80,8 +81,8 @@ const code_philosophy = """
e2e::expect_run_file, or e2e::assert_equals). Use e2e::assert_contains
only when full equality is not feasible (nondeterministic output, unbounded
logs, or platform-dependent text) and add an inline comment explaining why.
- 9. Source code and docs/architecture.md are the single source of truth. Don't trust
- documentation blindly.
+ 9. Source code and docs/architecture.md are the single source of truth for
+ behavior and architecture. Don't trust documentation blindly.
10. Agent analyzability (import graph). Before changing any src/ import
structure, read docs/agent-analyzability.md. Import a package only through
its public entry point (e.g. src/parser.ts, src/format/index.ts,
@@ -93,6 +94,9 @@ const code_philosophy = """
raising a cap. When package.json defines them, run npm run arch:check and
npm run lint in addition to npm run build and npm test; fix any new
violations before continuing.
+ 11. Docs ownership (ADR 0003). Published docs follow
+ design/0003-docs-one-fact-one-owner.md: one fact, one owner page. Do not
+ paste an owned rule onto a second page. Link instead.
"""
const output_criteria = """
@@ -215,16 +219,17 @@ def select_role(role_name) {
}
}
-script task_text_has_header = `printf '%s\n' "$1" | grep -q '^## '`
+script task_text_has_header = '''
+printf '%s\n' "$1" | grep -q '^## '
+'''
-script first_line_task = ```
+script first_line_task = '''
local line
line="$(printf '%s\n' "$1" | awk 'NR==1 { print; exit }')"
line="${line#\#\# }"
line="$(printf '%s\n' "$line" | sed 's/ *#[A-Za-z0-9_-]*//g')"
printf '%s\n' "$line"
-```
-
+'''
def classify_role(task) {
config {
agent.model = "sonnet"
@@ -243,7 +248,7 @@ def classify_role(task) {
# 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)
+ const role_lc = common.to_lower(role_raw)
return match role_lc {
/surgical/ => "surgical"
/reduction/ => "reductionist"
@@ -257,11 +262,11 @@ def implement(task, role_name) {
config {
agent.model = "opus"
}
- run task_text_has_header(task) catch (err) {
+ task_text_has_header(task) catch (err) {
fail "Provided task does not contain a '## [text]' header"
}
- const role = run select_role(role_name)
+ const role = select_role(role_name)
prompt """
## Role
@@ -271,6 +276,8 @@ def implement(task, role_name) {
You are working on the Jaiph codebase (https://github.com/jaiphlang/jaiph),
a TypeScript compiler and runtime for a DSL that transpiles to Bash.
docs/architecture.md is the source of truth for architecture and execution flow.
+ design/0003-docs-one-fact-one-owner.md is the source of truth for which
+ published docs page owns a contract.
## Code philosophy
${code_philosophy}
@@ -279,6 +286,9 @@ def implement(task, role_name) {
Implement the following task by:
- Reading docs/architecture.md first and keeping architecture boundaries and
contracts intact unless task explicitly requires changing them.
+ - For documentation edits, reading design/0003-docs-one-fact-one-owner.md
+ and stating a fact only on its owner page (one sentence and a link
+ everywhere else).
- Following the codebase's existing style and conventions precisely.
- Following the code philosophy above for all new and modified code.
- Adding or updating tests as needed for acceptance criteria.
@@ -321,58 +331,52 @@ def implement(task, role_name) {
"""
}
-# Shared post-implement path: CI, docs parity from the task text, commit, artifact.
+# Shared post-implement path: CI, docs parity from the task text, commit.
# Callers that touch QUEUE.md must do so before this (so the commit includes it).
def verify_docs_and_commit(task) {
- run ci.ensure_ci_passes()
- run docs.update_from_task(task)
- const patch_file = run git.commit(task)
- run artifacts.save(patch_file)
- return patch_file
+ ci.ensure_ci_passes()
+ docs.update_from_task(task)
+ git.commit(task)
}
# Task-parameter entry for serve/mcp hub. Does not read or write QUEUE.md.
export def implement_from_task(task) {
- run common.arg_nonempty(task) catch (err) {
+ common.arg_nonempty(task) catch (err) {
fail "engineer.implement_from_task requires a non-empty task parameter (markdown with a ## header)"
}
- run claude.ensure_usage()
+ claude.ensure_usage()
- const task_header = run first_line_task(task)
+ const task_header = first_line_task(task)
log "Implementing task: ${task_header}"
- const role_name = run classify_role(task)
+ const role_name = classify_role(task)
log "Role: ${role_name}"
- run implement(task, role_name)
+ implement(task, role_name)
- const patch_file = run verify_docs_and_commit(task)
- log "Patch file: ${patch_file}"
- return patch_file
+ verify_docs_and_commit(task)
}
# Queue-driven entry for CLI / overnight loops. Always auto-classifies the role.
export def implement_from_queue() {
- run claude.ensure_usage()
+ claude.ensure_usage()
- const task = run queue.get_first_task()
- run queue.task_is_dev_ready(task)
- const task_header = run first_line_task(task)
+ const task = queue.get_first_task()
+ queue.task_is_dev_ready(task)
+ const task_header = first_line_task(task)
log "Implementing task: ${task_header}"
- const role_name = run classify_role(task)
+ const role_name = classify_role(task)
log "Role: ${role_name}"
- run implement(task, role_name)
+ implement(task, role_name)
- run queue.remove_completed_task(task_header)
+ queue.remove_completed_task(task_header)
- const patch_file = run verify_docs_and_commit(task)
- log "Patch file: ${patch_file}"
- return patch_file
+ verify_docs_and_commit(task)
}
export def main() {
- return run implement_from_queue()
+ implement_from_queue()
}
diff --git a/.jaiph/ensure_ci_passes.jh b/.jaiph/ensure_ci_passes.jh
index 1326efb3..03f5338e 100755
--- a/.jaiph/ensure_ci_passes.jh
+++ b/.jaiph/ensure_ci_passes.jh
@@ -7,46 +7,48 @@ config {
agent.claude_flags = "--permission-mode bypassPermissions"
}
-script npm_run_test_ci = ```
-while IFS= read -r _v; do
- unset "$_v" 2>/dev/null || true
-done < <(compgen -e | grep '^JAIPH_' || true)
-# Heartbeat so JAIPH_STEP_IDLE_KILL_SEC cannot kill a long-but-live test:ci
-# when individual e2e scripts go quiet.
-(
- elapsed=0
- while true; do
- sleep 30
- elapsed=$((elapsed + 30))
- printf 'ensure_ci_passes: test:ci still running (%ds elapsed)\n' "${elapsed}"
- done
-) &
-hb_pid=$!
-trap 'kill "${hb_pid}" >/dev/null 2>&1 || true' EXIT
-npm run test:ci
-rc=$?
-kill "${hb_pid}" >/dev/null 2>&1 || true
-exit "${rc}"
-```
-
-script assert_nonempty_file_or_fail = ```
-test -s "$1" || {
- echo "jaiph: ci failure log is empty at $1" >&2
- exit 1
-}
-```
+script npm_run_test_ci = '''
+ while IFS= read -r _v; do
+ unset "$_v" 2>/dev/null || true
+ done < <(compgen -e | grep '^JAIPH_' || true)
+ # Heartbeat so JAIPH_STEP_IDLE_KILL_SEC cannot kill a long-but-live test:ci
+ # when individual e2e scripts go quiet.
+ (
+ elapsed=0
+ while true; do
+ sleep 30
+ elapsed=$((elapsed + 30))
+ printf 'ensure_ci_passes: test:ci still running (%ds elapsed)\n' "${elapsed}"
+ done
+ ) &
+ hb_pid=$!
+ trap 'kill "${hb_pid}" >/dev/null 2>&1 || true' EXIT
+ npm run test:ci 2>&1
+ rc=$?
+ kill "${hb_pid}" >/dev/null 2>&1 || true
+ exit "${rc}"
+'''
+script assert_nonempty_file_or_fail = '''
+ test -s "$1" || {
+ echo "jaiph: ci failure log is empty at $1" >&2
+ exit 1
+ }
+'''
+# recover binds `failure` to the failed step's stdout as an output handle.
+# Stream it into dest — never argv (ARG_MAX), never treat it as a path.
+script save_ci_log = 'cat > "$1"'
export def ensure_ci_passes() {
const ci_log_dir = ".jaiph/tmp"
const ci_log_file = "${ci_log_dir}/ensure_ci_passes.last.log"
- run common.mkdir_p_simple(ci_log_dir)
+ common.mkdir_p_simple(ci_log_dir)
# 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)
+ npm_run_test_ci() recover (failure) {
+ stdin failure -> save_ci_log(ci_log_file)
+ assert_nonempty_file_or_fail(ci_log_file)
prompt """
You are a software engineer fixing a failing CI build.
@@ -67,9 +69,9 @@ export def ensure_ci_passes() {
"""
}
- run common.rm_file_simple(ci_log_file)
+ common.rm_file_simple(ci_log_file)
}
export def main() {
- run ensure_ci_passes()
+ ensure_ci_passes()
}
diff --git a/.jaiph/gh_ci_passes.jh b/.jaiph/gh_ci_passes.jh
index c617ac8f..f7016844 100755
--- a/.jaiph/gh_ci_passes.jh
+++ b/.jaiph/gh_ci_passes.jh
@@ -20,26 +20,42 @@ config {
agent.claude_flags = "--permission-mode bypassPermissions"
}
-script assert_nonempty_file_or_fail = ```
+script assert_nonempty_file_or_fail = '''
test -s "$1" || {
echo "jaiph: ci failure log is empty at $1" >&2
exit 1
}
-```
+'''
+
+# Prefer the log file written by check_ci. When log download fails (empty or
+# missing file), persist the failed step's stdout+stderr so recover still has
+# a run URL / job id / gh error for the agent.
+script persist_failure_if_log_empty = '''
+ dest="$1"
+ if test -s "$dest"; then
+ cat >/dev/null
+ exit 0
+ fi
+ cat >"$dest"
+ test -s "$dest" || {
+ echo "jaiph: ci failure log is empty at $dest" >&2
+ exit 1
+ }
+'''
def ensure_gh_ci_passes(branch, workflow_name) {
const ci_log_dir = ".jaiph/tmp"
const ci_log_file = "${ci_log_dir}/gh_ci_passes.last.log"
- run common.mkdir_p_simple(ci_log_dir)
+ common.mkdir_p_simple(ci_log_dir)
# Empty commit => always resolve HEAD, so each recover retry tracks the new
# push instead of re-checking a stale SHA.
const commit = ""
- run gh.token_set()
+ gh.token_set()
- run gh.check_ci(branch, commit, workflow_name, ci_log_file) recover (failure) {
- run assert_nonempty_file_or_fail(ci_log_file)
+ gh.check_ci(branch, commit, workflow_name, ci_log_file) recover (failure) {
+ stdin failure -> persist_failure_if_log_empty(ci_log_file)
log "CI failure summary: ${failure}"
prompt """
@@ -64,7 +80,7 @@ def ensure_gh_ci_passes(branch, workflow_name) {
- Do NOT invoke Jaiph orchestration workflows from .jaiph/.
"""
- run git.in_git_repo()
+ git.in_git_repo()
prompt """
Commit the CI fix locally so Jaiph can push it. Do NOT push — Jaiph
performs the push in a trusted step after this.
@@ -75,18 +91,18 @@ def ensure_gh_ci_passes(branch, workflow_name) {
3. Create exactly one commit with a clear imperative message.
4. Do NOT push, and do NOT amend commits already on the remote.
"""
- run git.push(branch)
+ git.push(branch)
}
- run common.rm_file_simple(ci_log_file)
+ common.rm_file_simple(ci_log_file)
}
def pull_latest_logs(branch, workflow_name) {
const ci_log_dir = ".jaiph/tmp"
const ci_log_file = "${ci_log_dir}/gh_ci.latest.log"
- run common.mkdir_p_simple(ci_log_dir)
- run gh.pull_logs(branch, "", workflow_name, ci_log_file, "1")
- run assert_nonempty_file_or_fail(ci_log_file)
+ common.mkdir_p_simple(ci_log_dir)
+ gh.pull_logs(branch, "", workflow_name, ci_log_file, "1")
+ assert_nonempty_file_or_fail(ci_log_file)
return ci_log_file
}
@@ -95,5 +111,5 @@ export def main(branch, workflow_name) {
"" => "CI"
_ => workflow_name
}
- run ensure_gh_ci_passes(branch, wf)
+ ensure_gh_ci_passes(branch, wf)
}
diff --git a/.jaiph/gh_ci_passes.test.jh b/.jaiph/gh_ci_passes.test.jh
new file mode 100644
index 00000000..f31a4c84
--- /dev/null
+++ b/.jaiph/gh_ci_passes.test.jh
@@ -0,0 +1,54 @@
+#!/usr/bin/env jaiph
+
+import "./gh_ci_passes.jh" as passes
+import "./lib_common.jh" as common
+import "jaiphlang/gh_actions" as actions
+import "jaiphlang/git" as git
+
+# check-ci can fail after writing nothing (gh: log not found). Recover must
+# persist the failure text and retry instead of dying on an empty log file.
+test "recover continues when check-ci leaves an empty log" {
+ mock script common.mkdir_p_simple() {
+ mkdir -p "$1"
+ rm -f "$1/gh_ci_passes.last.log" "$1/gh_ci_passes.last.log.attempted"
+ }
+ mock script actions.gh() {
+ case "$1" in
+ require-token)
+ exit 0
+ ;;
+ check-ci)
+ dest="${5:-}"
+ state="${dest}.attempted"
+ if [ -f "$state" ]; then
+ if grep -q "log not found: 106717181723" "$dest"; then
+ echo "CI succeeded after recover persisted failure log"
+ exit 0
+ fi
+ echo "recover left empty or wrong log at $dest" >&2
+ exit 1
+ fi
+ mkdir -p "$(dirname "$dest")"
+ : >"$dest"
+ : >"$state"
+ echo "log not found: 106717181723" >&2
+ exit 1
+ ;;
+ *)
+ echo "unexpected gh command: $*" >&2
+ exit 2
+ ;;
+ esac
+ }
+ mock def git.in_git_repo() {
+ return ""
+ }
+ mock def git.push() {
+ return ""
+ }
+ mock prompt {
+ _ => "fixed"
+ }
+ const out = passes.main("nightly", "CI")
+ expect_contain out "CI succeeded after recover persisted failure log"
+}
diff --git a/.jaiph/lib_common.jh b/.jaiph/lib_common.jh
index 20888a7b..0f9e45e8 100644
--- a/.jaiph/lib_common.jh
+++ b/.jaiph/lib_common.jh
@@ -4,30 +4,39 @@
# 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.
+# Writes UTF-8 text to a path: $1 = path, content = stdin.
+# Call as: stdin content -> common.save_string_to_file(path)
# 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"`
+# are written verbatim. Content arrives on stdin (not argv), so it is
+# not bounded by ARG_MAX and can be arbitrarily large.
+export script save_string_to_file = '''python3
+ import sys
+ if len(sys.argv) < 2:
+ sys.exit(2)
+ path = sys.argv[1]
+ content = sys.stdin.read()
+ 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/artifacts.jh b/.jaiph/libs/jaiphlang/artifacts.jh
index f80d5bc7..cc930ac8 100644
--- a/.jaiph/libs/jaiphlang/artifacts.jh
+++ b/.jaiph/libs/jaiphlang/artifacts.jh
@@ -10,18 +10,18 @@
#
# export def main() {
# # Single file:
-# run artifacts.save("./build/output.bin")
+# artifacts.save("./build/output.bin")
#
# # Or several files: newline-separated list of paths.
# const paths = """
# a.txt
# b/nested.txt
# """
-# run artifacts.save(paths)
+# artifacts.save(paths)
# }
#
-script save_script = ```
+script save_script = '''
set -euo pipefail
ARTIFACTS_DIR="${JAIPH_ARTIFACTS_DIR:?JAIPH_ARTIFACTS_DIR is not set}"
paths_list="$1"
@@ -54,12 +54,11 @@ script save_script = ```
exit 1
fi
printf '%s' "$out"
-```
-
+'''
# `paths` is a single file path or a newline-separated list of file paths.
# Each file is copied under the same relative name as in the list
# (leading `./` stripped; absolute paths use basename only).
# Returns the absolute destination paths, one per line, in the same order.
export def save(paths) {
- return run save_script(paths)
+ return save_script(paths)
}
diff --git a/.jaiph/libs/jaiphlang/claude.jh b/.jaiph/libs/jaiphlang/claude.jh
index 96f12139..df1243be 100644
--- a/.jaiph/libs/jaiphlang/claude.jh
+++ b/.jaiph/libs/jaiphlang/claude.jh
@@ -5,13 +5,13 @@
#
# Usage:
# import "jaiphlang/claude" as claude
-# run claude.ensure_usage()
+# claude.ensure_usage()
#
# Polls `claude -p "/usage"` until current session usage is at or below 90%,
# sleeping 10 minutes between recoveries (recover_limit 1000 ≈ 7 days).
#
-script check_capacity = ```
+script check_capacity = '''
local usage percent
if ! usage="$(claude -p "/usage")"; then
@@ -33,10 +33,9 @@ script check_capacity = ```
printf 'Claude current session usage is %s%% (threshold: 90%%).' "$percent"
(( percent <= 90 ))
-```
-
+'''
def probe_and_log() {
- const status = run check_capacity()
+ const status = check_capacity()
log status
}
@@ -46,8 +45,8 @@ export def ensure_usage() {
run.recover_limit = 1000
}
- run probe_and_log() recover (failure) {
+ probe_and_log() recover (failure) {
logwarn "${failure}. Checking again in 10 minutes."
- run `sleep 600`()
+ 'sleep 600'()
}
}
diff --git a/.jaiph/libs/jaiphlang/gh_actions.jh b/.jaiph/libs/jaiphlang/gh_actions.jh
index 5cf16b28..9495bc59 100644
--- a/.jaiph/libs/jaiphlang/gh_actions.jh
+++ b/.jaiph/libs/jaiphlang/gh_actions.jh
@@ -14,27 +14,27 @@
import script "./gh_actions.sh" as gh use GITHUB_TOKEN
export def token_set() {
- run gh("require-token")
+ gh("require-token")
}
export def check_ci(branch, commit, workflow_name, log_file) {
- return run gh("check-ci", branch, commit, workflow_name, log_file)
+ return gh("check-ci", branch, commit, workflow_name, log_file)
}
export def wait_for_ci(branch, commit, workflow_name) {
- return run gh("wait-run", branch, commit, workflow_name)
+ return gh("wait-run", branch, commit, workflow_name)
}
export def pull_logs(branch, commit, workflow_name, out_file, failed_only) {
- return run gh("pull-logs", branch, commit, workflow_name, out_file, failed_only)
+ return gh("pull-logs", branch, commit, workflow_name, out_file, failed_only)
}
export def main(cmd, branch, commit, workflow_name, out_file) {
const result = match cmd {
- "" => run gh("check-ci", branch, commit, workflow_name)
- "check" => run gh("check-ci", branch, commit, workflow_name)
- "wait" => run gh("wait-run", branch, commit, workflow_name)
- "pull" => run gh("pull-logs", branch, commit, workflow_name, out_file, "1")
+ "" => gh("check-ci", branch, commit, workflow_name)
+ "check" => gh("check-ci", branch, commit, workflow_name)
+ "wait" => gh("wait-run", branch, commit, workflow_name)
+ "pull" => gh("pull-logs", branch, commit, workflow_name, out_file, "1")
_ => fail "Unknown command: ${cmd}. Use: check | wait | pull"
}
log result
diff --git a/.jaiph/libs/jaiphlang/gh_actions.sh b/.jaiph/libs/jaiphlang/gh_actions.sh
index d0b43a22..31cf19d1 100755
--- a/.jaiph/libs/jaiphlang/gh_actions.sh
+++ b/.jaiph/libs/jaiphlang/gh_actions.sh
@@ -38,8 +38,10 @@ pull-logs options:
-f, --failed-only Fetch only failed job/step logs
check-ci options:
- -o, --out FILE Write full --log-failed output to FILE (default:
- ${JAIPH_WORKSPACE:-.}/.jaiph/tmp/gh_actions.check_ci.log)
+ -o, --out FILE Write --log-failed output to FILE (default:
+ ${JAIPH_WORKSPACE:-.}/.jaiph/tmp/gh_actions.check_ci.log).
+ If job logs are missing (gh: log not found), FILE
+ still gets the gh error and `gh run view` metadata.
Stdout gets only a short summary plus the last
GH_ACTIONS_LOG_TAIL lines (default: 10).
@@ -325,15 +327,12 @@ cmd_pull_logs() {
run_id="$(resolve_run_id "$workflow" "$branch" "$commit" 1)" \
|| die "no ${workflow} run found for the given ref"
fi
- local -a log_args=(run view "$run_id" --log)
- if [ "$failed_only" = "1" ]; then
- log_args=(run view "$run_id" --log-failed)
- fi
if [ -n "$out_file" ]; then
- gh_cmd "${log_args[@]}" >"$out_file"
+ : >"$out_file"
+ append_run_logs "$run_id" "$out_file" "$failed_only"
printf '%s\n' "$out_file"
else
- gh_cmd "${log_args[@]}"
+ append_run_logs "$run_id" /dev/stdout "$failed_only"
fi
return 0
fi
@@ -357,16 +356,12 @@ cmd_pull_logs() {
|| die "no ${workflow} run found for the given ref"
fi
- local -a log_args=(run view "$run_id" --log)
- if [ "$failed_only" = "1" ]; then
- log_args=(run view "$run_id" --log-failed)
- fi
-
if [ -n "$out_file" ]; then
- gh_cmd "${log_args[@]}" >"$out_file"
+ : >"$out_file"
+ append_run_logs "$run_id" "$out_file" "$failed_only"
printf '%s\n' "$out_file"
else
- gh_cmd "${log_args[@]}"
+ append_run_logs "$run_id" /dev/stdout "$failed_only"
fi
}
@@ -374,6 +369,38 @@ default_ci_log_file() {
printf '%s\n' "${JAIPH_WORKSPACE:-.}/.jaiph/tmp/gh_actions.check_ci.log"
}
+# Append workflow logs for $run_id to $dest. A runner that dies mid-step often
+# leaves no job logs (`gh run view --log-failed` → `log not found: `).
+# Write that error plus `gh run view` metadata so callers still have a nonempty
+# report (run URL, job names, conclusions) instead of an empty file.
+append_run_logs() {
+ local run_id="$1"
+ local dest="$2"
+ local failed_only="$3"
+ local err
+ local -a log_args=(run view "$run_id" --log)
+ if [ "$failed_only" = "1" ]; then
+ log_args=(run view "$run_id" --log-failed)
+ fi
+ err="$(mktemp)"
+ if gh_cmd "${log_args[@]}" >>"$dest" 2>"$err"; then
+ rm -f "$err"
+ return 0
+ fi
+ {
+ printf '\ngh could not download workflow logs for run %s:\n' "$run_id"
+ cat "$err"
+ printf '\n--- gh run view %s ---\n' "$run_id"
+ } >>"$dest"
+ if ! gh_cmd run view "$run_id" >>"$dest" 2>"$err"; then
+ {
+ printf 'gh run view %s also failed:\n' "$run_id"
+ cat "$err"
+ } >>"$dest"
+ fi
+ rm -f "$err"
+}
+
emit_failed_ci_report() {
local conclusion="$1"
local url="$2"
@@ -382,7 +409,11 @@ emit_failed_ci_report() {
local tail_lines="$GH_ACTIONS_LOG_TAIL"
mkdir -p "$(dirname "$log_file")"
- gh_cmd run view "$run_id" --log-failed >"$log_file"
+ {
+ printf 'CI failed (%s): %s (run %s)\n' "$conclusion" "$url" "$run_id"
+ printf 'Full log: %s\n' "$log_file"
+ } >"$log_file"
+ append_run_logs "$run_id" "$log_file" "1"
printf 'CI failed (%s): %s (run %s)\n' "$conclusion" "$url" "$run_id"
printf 'Full log: %s\n\n' "$log_file"
diff --git a/.jaiph/libs/jaiphlang/git.jh b/.jaiph/libs/jaiphlang/git.jh
index 7a97323b..76f10cf3 100755
--- a/.jaiph/libs/jaiphlang/git.jh
+++ b/.jaiph/libs/jaiphlang/git.jh
@@ -1,52 +1,48 @@
#!/usr/bin/env jaiph
-script git_inside_worktree = `git rev-parse --is-inside-work-tree 2>&1`
+script git_inside_worktree = 'git rev-parse --is-inside-work-tree 2>&1'
-script git_porcelain_empty = `test -z "$(git status --porcelain)"`
+script git_porcelain_empty = 'test -z "$(git status --porcelain)"'
-script git_porcelain_nonempty = `test -n "$(git status --porcelain)"`
+script git_porcelain_nonempty = 'test -n "$(git status --porcelain)"'
-script git_mark_workspace_safe = `git config --global --add safe.directory "$(pwd)"`
-
-# format-patch emits real diff to stdout only with --stdout; otherwise git writes *.patch files and stdout is only the path.
-script git_create_patch_from_commit = `git config --global --add safe.directory "$(pwd)" && git format-patch -1 HEAD --stdout > $1`
+script git_mark_workspace_safe = 'git config --global --add safe.directory "$(pwd)"'
# Push local HEAD to origin. This is a trusted, deterministic step so the push
# target is controlled by the caller, not by agent output. With an explicit
# branch ($1), force the remote ref (HEAD:refs/heads/) so the push
# cannot be retargeted by whatever branch happens to be checked out; with no
# branch, push HEAD to its tracking branch (matches `git push -u origin HEAD`).
-script git_push_head = ```
+script git_push_head = '''
git config --global --add safe.directory "$(pwd)"
if [ -n "$1" ]; then
git push -u origin "HEAD:refs/heads/$1"
else
git push -u origin HEAD
fi
-```
-
+'''
export def in_git_repo() {
- run git_mark_workspace_safe()
- run git_inside_worktree() catch (err) {
+ git_mark_workspace_safe()
+ git_inside_worktree() catch (err) {
fail "not inside a git repository"
}
}
export def branch_clean() {
- run git_porcelain_empty() catch (err) {
+ git_porcelain_empty() catch (err) {
fail "git working tree is not clean"
}
}
export def has_changes() {
- run git_porcelain_nonempty() catch (err) {
+ git_porcelain_nonempty() catch (err) {
fail "git working tree has no changes"
}
}
export def is_clean() {
- run in_git_repo()
- run branch_clean()
+ in_git_repo()
+ branch_clean()
}
export def commit(task) {
@@ -57,57 +53,43 @@ export def commit(task) {
agent.backend = "claude"
agent.claude_flags = "--permission-mode bypassPermissions"
}
+ in_git_repo()
+ has_changes()
- run in_git_repo()
- run has_changes()
+ prompt """
+ Please commit current changes.
- const response = prompt """
- Please commit current changes and respond with a commit message and
- suggested patch file name (excluding extension).
-
Requirements for commit message:
1. Write a commit message - first line in imperative mood, under 72 chars.
2. Start with the common prefix like 'Feat:', 'Fix:', 'Refactor:' etc.
3. Write a body paragraph with more details about the change.
-
+
Requirements for commit:
1. Review current git changes (git diff --stat, git status).
2. Stage all relevant changes with git add.
3. Create exactly one commit.
4. Do not push.
5. Remove files that are not relevant to the commit and not git ignored.
-
+
Changes were made for the following task:
${task}
"""
- returns "{ message: string, patch_file_name: string }"
-
- const patch_file_name = "${response.patch_file_name}.patch"
-
- run git_create_patch_from_commit(patch_file_name)
-
- return patch_file_name
}
# Like commit(), but no-ops when the worktree is clean (overnight loops).
-# Deletes the generated .patch from the worktree so the next loop starts clean.
export def commit_if_changes(task) {
- run has_changes() catch (err) {
+ has_changes() catch (err) {
log "No changes to commit."
return ""
}
- const patch_file = run commit(task)
- run git_rm_patch(patch_file)
- return patch_file
+ commit(task)
}
-script git_rm_patch = `rm -f -- "$1"`
-
export def push(branch) {
- run in_git_repo()
- run git_push_head(branch)
+ in_git_repo()
+ git_push_head(branch)
}
export def main(task) {
- return run commit(task)
+ commit(task)
}
diff --git a/.jaiph/libs/jaiphlang/queue.jh b/.jaiph/libs/jaiphlang/queue.jh
index a384927c..752a003b 100755
--- a/.jaiph/libs/jaiphlang/queue.jh
+++ b/.jaiph/libs/jaiphlang/queue.jh
@@ -1,80 +1,87 @@
#!/usr/bin/env jaiph
#
-# Task/queue management for QUEUE.md.
-# Reads/modifies ${JAIPH_WORKSPACE:-.}/QUEUE.md.
+# Task/queue management.
+# Canonical store: ${JAIPH_QUEUE_STATE} or ${JAIPH_WORKSPACE:-.}/.jaiph/queue-state.md
+# QUEUE.md and DONE.md are generated views (never parsed after bootstrap).
# Tags are #hashtags in ## headers (e.g. ## My Task #dev-ready).
#
# CLI usage:
# jaiph .jaiph/libs/jaiphlang/queue.jh headers
-# jaiph .jaiph/libs/jaiphlang/queue.jh get dev-ready
+# jaiph .jaiph/libs/jaiphlang/queue.jh get_available
# jaiph .jaiph/libs/jaiphlang/queue.jh json
-# jaiph .jaiph/libs/jaiphlang/queue.jh add_from_file path/to/tasks.md
#
import script "./queue.py" as queue
-# Dispatches CLI arguments to the queue Python script.
export def main(cmd, arg1, arg2) {
- const result = run queue(cmd, arg1, arg2)
+ const result = queue(cmd, arg1, arg2)
log result
}
-# Append ## tasks from a markdown file into QUEUE.md. Titles already present
-# are skipped. Missing #dev-ready tags are added automatically.
export def add_tasks_from_file(path) {
- run queue("add_from_file", path)
+ queue("add_from_file", path)
+}
+
+export def add_task_from_file(path) {
+ queue("add", path)
}
-# Returns the full text block (header + body) of the first task.
export def get_first_task() {
- return run queue("get")
+ return queue("get")
}
-# Returns the first task whose header carries the given tag.
export def next_task(tag) {
- return run queue("get", tag)
+ return queue("get", tag)
+}
+
+export def next_available() {
+ return queue("get_available")
}
-# Returns the full text block of a task identified by its title.
-# Accepts both clean titles and titles with #tags — tags are stripped
-# before matching so callers don't need to know the exact tag set.
export def get_task_by_header(header) {
- return run queue("get_by_header", header)
+ return queue("get_by_header", header)
}
-# Returns all task titles as newline-separated text (without ## prefix).
-# Pass an optional tag to filter (e.g. "dev-ready").
export def get_all_task_headers() {
- return run queue("headers")
+ return queue("headers")
}
-# Adds #dev-ready to the header of the task matching the given title.
export def mark_task_dev_ready(header) {
- run queue("mark", header, "dev-ready")
+ queue("mark", header, "dev-ready")
+}
+
+export def mark_task_in_progress(header) {
+ queue("mark", header, "in-progress")
+}
+
+export def unmark_task(header, tag) {
+ queue("unmark", header, tag)
}
-# Removes the task matching the given title from the queue file.
export def remove_completed_task(header) {
- run queue("complete_by_header", header)
+ queue("complete_by_header", header)
+}
+
+export def archive_to_done(header, notesPath) {
+ queue("archive_to_done", header, notesPath)
}
-# Replaces the body (markdown below the ## line) for the task matching the title.
-# `bodyPath` must be a UTF-8 file; header line and tags are unchanged.
export def set_task_description_from_file(header, bodyPath) {
- run queue("set_description", header, bodyPath)
+ queue("set_description", header, bodyPath)
+}
+
+export def state_json() {
+ return queue("json")
}
-# Passes when the queue has at least one task.
export def has_tasks() {
- run queue("get")
+ queue("get")
}
-# Passes when the task text has #dev-ready on its header line.
export def task_is_dev_ready(task) {
- run queue("has_tag", task, "dev-ready")
+ queue("has_tag", task, "dev-ready")
}
-# Passes only when every task in the queue carries #dev-ready.
export def all_dev_ready() {
- run queue("check_all_tagged", "dev-ready")
+ queue("check_all_tagged", "dev-ready")
}
diff --git a/.jaiph/libs/jaiphlang/queue.py b/.jaiph/libs/jaiphlang/queue.py
index 17ccb8bf..0751c862 100755
--- a/.jaiph/libs/jaiphlang/queue.py
+++ b/.jaiph/libs/jaiphlang/queue.py
@@ -1,8 +1,52 @@
#!/usr/bin/env python3
-import sys, os, re, json
+import fcntl, json, os, re, sys, time
+
+DONE_SEP = "\n\n"
+
+QUEUE_VIEW_PREAMBLE = """# Jaiph Improvement Queue (Hard Rewrite Track)
+
+This file is a generated view. Do not edit it. Do not agent-edit it.
+Use the product-owner defs (`propose_task`, `update_task`, `pick_task`,
+`report_completed_task`, `task_details`) via `jaiph serve` / MCP.
+
+Process rules:
+
+1. `pick_task` prefers the first `#dev-ready` task that is not `#in-progress`.
+ The product owner may claim a later available task instead.
+2. The first `##` section is the preferred next task in this view.
+3. `#dev-ready` means ready to implement. `#in-progress` means claimed by `pick_task`.
+4. Runtime mutations go through the product-owner defs only.
+5. Every task must be standalone: no hidden assumptions, no "read prior task".
+6. Hard rewrite semantics: breaking changes are allowed unless a task says otherwise.
+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.
+"""
+
+DONE_VIEW_PREAMBLE = """# Done
+
+Append-only archive. Generated view. Do not edit.
+Each section was accepted by the product-owner `report_completed_task` def.
+"""
+
+
+def workspace_root():
+ return os.environ.get("JAIPH_WORKSPACE", ".")
+
+
+def view_queue_path():
+ return os.path.join(workspace_root(), "QUEUE.md")
+
+
+def view_done_path():
+ return os.path.join(workspace_root(), "DONE.md")
+
+
+def state_path():
+ env = os.environ.get("JAIPH_QUEUE_STATE")
+ if env:
+ return env
+ return os.path.join(workspace_root(), ".jaiph", "queue-state.md")
-def queue_path():
- return os.path.join(os.environ.get("JAIPH_WORKSPACE", "."), "QUEUE.md")
def clean_header(h):
h = h.strip()
@@ -10,11 +54,10 @@ def clean_header(h):
h = h[3:]
return re.sub(r"\s*#[A-Za-z0-9_-]+", "", h).strip()
-def parse_queue(path):
- if not os.path.isfile(path):
+
+def parse_queue_text(text):
+ if not text or not text.strip():
return {"description": "", "tasks": []}
- with open(path) as f:
- text = f.read()
lines = text.split("\n")
desc_lines, tasks, current = [], [], None
for line in lines:
@@ -37,25 +80,42 @@ def parse_queue(path):
tasks.append(current)
return {"description": "\n".join(desc_lines).strip(), "tasks": tasks}
-def write_queue(path, q):
+
+def parse_queue(path):
+ if not os.path.isfile(path):
+ return {"description": "", "tasks": []}
+ with open(path, encoding="utf-8") as f:
+ return parse_queue_text(f.read())
+
+
+def emit_tasks(tasks):
lines = []
- if q["description"]:
- lines.append(q["description"])
- lines.append("")
- for t in q["tasks"]:
+ for t in tasks:
tag_s = " " + " ".join(f"#{x}" for x in t["tags"]) if t["tags"] else ""
lines.append(f"## {t['title']}{tag_s}")
- if t["description"]:
+ if t.get("description"):
lines.append("")
lines.append(t["description"])
lines.append("")
- with open(path, "w") as f:
+ return lines
+
+
+def write_markdown(path, preamble, tasks):
+ lines = []
+ if preamble:
+ lines.append(preamble.rstrip())
+ lines.append("")
+ lines.extend(emit_tasks(tasks))
+ os.makedirs(os.path.dirname(os.path.abspath(path)) or ".", exist_ok=True)
+ with open(path, "w", encoding="utf-8") as f:
f.write("\n".join(lines).rstrip() + "\n")
+
def fmt_task(t):
tag_s = " " + " ".join(f"#{x}" for x in t["tags"]) if t["tags"] else ""
h = f"## {t['title']}{tag_s}"
- return f"{h}\n\n{t['description']}" if t["description"] else h
+ return f"{h}\n\n{t['description']}" if t.get("description") else h
+
def find_task(tasks, header):
needle = clean_header(header)
@@ -64,59 +124,170 @@ def find_task(tasks, header):
return i
return -1
+
+def empty_state():
+ return {"description": "", "tasks": [], "done": []}
+
+
+def parse_state_text(text):
+ if DONE_SEP in text:
+ live, done_text = text.split(DONE_SEP, 1)
+ else:
+ live, done_text = text, ""
+ q = parse_queue_text(live)
+ d = parse_queue_text(done_text)
+ return {"description": q["description"], "tasks": q["tasks"], "done": d["tasks"]}
+
+
+def read_state_file(path):
+ if not os.path.isfile(path):
+ return None
+ with open(path, encoding="utf-8") as f:
+ return parse_state_text(f.read())
+
+
+def write_state_file(path, st):
+ lines = []
+ if st.get("description"):
+ lines.append(st["description"])
+ lines.append("")
+ lines.extend(emit_tasks(st["tasks"]))
+ body = "\n".join(lines).rstrip()
+ if st.get("done"):
+ done_body = "\n".join(emit_tasks(st["done"])).rstrip()
+ body = body + DONE_SEP + done_body
+ os.makedirs(os.path.dirname(os.path.abspath(path)) or ".", exist_ok=True)
+ with open(path, "w", encoding="utf-8") as f:
+ f.write(body.rstrip() + "\n")
+
+
+def dump_views(st):
+ write_markdown(view_queue_path(), QUEUE_VIEW_PREAMBLE, st["tasks"])
+ stripped = []
+ for t in st["done"]:
+ stripped.append({
+ "title": t["title"],
+ "tags": [],
+ "description": t.get("description") or "",
+ })
+ write_markdown(view_done_path(), DONE_VIEW_PREAMBLE, stripped)
+
+
+def bootstrap_state():
+ path = state_path()
+ existing = read_state_file(path)
+ if existing is not None:
+ return existing
+ view = view_queue_path()
+ if os.path.isfile(view):
+ q = parse_queue(view)
+ st = {"description": q["description"], "tasks": q["tasks"], "done": []}
+ else:
+ st = empty_state()
+ write_state_file(path, st)
+ dump_views(st)
+ return st
+
+
+def with_lock(fn):
+ path = state_path()
+ os.makedirs(os.path.dirname(os.path.abspath(path)) or ".", exist_ok=True)
+ lock_path = path + ".lock"
+ with open(lock_path, "a+", encoding="utf-8") as lf:
+ fcntl.lockf(lf, fcntl.LOCK_EX)
+ try:
+ return fn()
+ finally:
+ fcntl.lockf(lf, fcntl.LOCK_UN)
+
+
+def load_state():
+ return bootstrap_state()
+
+
+def save_state(st):
+ write_state_file(state_path(), st)
+ dump_views(st)
+
+
def cmd_get(args):
- tag = args[0] if args else None
- q = parse_queue(queue_path())
- for t in q["tasks"]:
- if tag is None or tag in t["tags"]:
- print(fmt_task(t))
- return
- sys.exit(1)
+ def go():
+ tag = args[0] if args else None
+ st = load_state()
+ for t in st["tasks"]:
+ if tag is None or tag in t["tags"]:
+ print(fmt_task(t))
+ return
+ sys.exit(1)
+ with_lock(go)
+
+
+def cmd_get_available(args):
+ def go():
+ st = load_state()
+ for t in st["tasks"]:
+ if "dev-ready" in t["tags"] and "in-progress" not in t["tags"]:
+ print(fmt_task(t))
+ return
+ print("no #dev-ready task is available", file=sys.stderr)
+ sys.exit(1)
+ with_lock(go)
+
def cmd_get_by_header(args):
if not args:
print("get_by_header: header required", file=sys.stderr)
sys.exit(1)
- q = parse_queue(queue_path())
- i = find_task(q["tasks"], args[0])
- if i < 0:
- print(f"task not found: {args[0]}", file=sys.stderr)
- sys.exit(1)
- print(fmt_task(q["tasks"][i]))
+ def go():
+ st = load_state()
+ i = find_task(st["tasks"], args[0])
+ if i < 0:
+ print(f"task not found: {args[0]}", file=sys.stderr)
+ sys.exit(1)
+ print(fmt_task(st["tasks"][i]))
+ with_lock(go)
+
def cmd_headers(args):
- tag = args[0] if args else None
- q = parse_queue(queue_path())
- for t in q["tasks"]:
- if tag is None or tag in t["tags"]:
- print(t["title"])
+ def go():
+ tag = args[0] if args else None
+ st = load_state()
+ for t in st["tasks"]:
+ if tag is None or tag in t["tags"]:
+ print(t["title"])
+ with_lock(go)
+
def cmd_complete(args):
tag = args[0] if args else None
- path = queue_path()
- q = parse_queue(path)
- for i, t in enumerate(q["tasks"]):
- if tag is None or tag in t["tags"]:
- removed = q["tasks"].pop(i)
- write_queue(path, q)
- print(f"Completed: {removed['title']}")
- return
- print("No matching task found", file=sys.stderr)
- sys.exit(1)
+ def go():
+ st = load_state()
+ for i, t in enumerate(st["tasks"]):
+ if tag is None or tag in t["tags"]:
+ removed = st["tasks"].pop(i)
+ save_state(st)
+ print(f"Completed: {removed['title']}")
+ return
+ print("No matching task found", file=sys.stderr)
+ sys.exit(1)
+ with_lock(go)
+
def cmd_complete_by_header(args):
if not args:
print("complete_by_header: header required", file=sys.stderr)
sys.exit(1)
- path = queue_path()
- q = parse_queue(path)
- i = find_task(q["tasks"], args[0])
- if i < 0:
- print(f"task not found: {args[0]}", file=sys.stderr)
- sys.exit(1)
- q["tasks"].pop(i)
- write_queue(path, q)
- print(f"Completed: {args[0]}")
+ def go():
+ st = load_state()
+ i = find_task(st["tasks"], args[0])
+ if i < 0:
+ print(f"task not found: {args[0]}", file=sys.stderr)
+ sys.exit(1)
+ st["tasks"].pop(i)
+ save_state(st)
+ print(f"Completed: {args[0]}")
+ with_lock(go)
+
def cmd_set_description(args):
if len(args) < 2:
@@ -128,44 +299,70 @@ def cmd_set_description(args):
sys.exit(1)
with open(body_path, encoding="utf-8") as f:
body = f.read()
- qpath = queue_path()
- q = parse_queue(qpath)
- i = find_task(q["tasks"], header)
- if i < 0:
- print(f"task not found: {header}", file=sys.stderr)
- sys.exit(1)
- q["tasks"][i]["description"] = body.rstrip()
- write_queue(qpath, q)
- print(f"Updated description: {q['tasks'][i]['title']}")
+ def go():
+ st = load_state()
+ i = find_task(st["tasks"], header)
+ if i < 0:
+ print(f"task not found: {header}", file=sys.stderr)
+ sys.exit(1)
+ st["tasks"][i]["description"] = body.rstrip()
+ save_state(st)
+ print(f"Updated description: {st['tasks'][i]['title']}")
+ with_lock(go)
+
def cmd_mark(args):
if len(args) < 2:
print("mark: header and tag required", file=sys.stderr)
sys.exit(1)
header, tag = args[0], args[1]
- path = queue_path()
- q = parse_queue(path)
- i = find_task(q["tasks"], header)
- if i < 0:
- print(f"task not found: {header}", file=sys.stderr)
- sys.exit(1)
- t = q["tasks"][i]
- if tag not in t["tags"]:
- t["tags"].append(tag)
- write_queue(path, q)
- print(f"Marked #{tag}: {t['title']}")
+ def go():
+ st = load_state()
+ i = find_task(st["tasks"], header)
+ if i < 0:
+ print(f"task not found: {header}", file=sys.stderr)
+ sys.exit(1)
+ t = st["tasks"][i]
+ if tag not in t["tags"]:
+ t["tags"].append(tag)
+ save_state(st)
+ print(f"Marked #{tag}: {t['title']}")
+ with_lock(go)
+
+
+def cmd_unmark(args):
+ if len(args) < 2:
+ print("unmark: header and tag required", file=sys.stderr)
+ sys.exit(1)
+ header, tag = args[0], args[1]
+ def go():
+ st = load_state()
+ i = find_task(st["tasks"], header)
+ if i < 0:
+ print(f"task not found: {header}", file=sys.stderr)
+ sys.exit(1)
+ t = st["tasks"][i]
+ if tag in t["tags"]:
+ t["tags"] = [x for x in t["tags"] if x != tag]
+ save_state(st)
+ print(f"Unmarked #{tag}: {t['title']}")
+ with_lock(go)
+
def cmd_check_all_tagged(args):
if not args:
print("check_all_tagged: tag required", file=sys.stderr)
sys.exit(1)
tag = args[0]
- q = parse_queue(queue_path())
- if not q["tasks"]:
- sys.exit(1)
- for t in q["tasks"]:
- if tag not in t["tags"]:
+ def go():
+ st = load_state()
+ if not st["tasks"]:
sys.exit(1)
+ for t in st["tasks"]:
+ if tag not in t["tags"]:
+ sys.exit(1)
+ with_lock(go)
+
def cmd_has_tag(args):
if len(args) < 2:
@@ -175,40 +372,26 @@ def cmd_has_tag(args):
if f"#{args[1]}" not in first_line:
sys.exit(1)
+
def cmd_json(args):
- print(json.dumps(parse_queue(queue_path()), indent=2))
+ def go():
+ print(json.dumps(load_state(), indent=2))
+ with_lock(go)
-def cmd_add_from_file(args):
- """Append tasks from a markdown file. Skips titles that already exist.
- The file is parsed like QUEUE.md (## Title #tags + body). Existing titles
- in QUEUE.md are left untouched. Returns how many tasks were added.
- """
- if not args:
- print("add_from_file: path required", file=sys.stderr)
- sys.exit(1)
- src = args[0]
- if not os.path.isfile(src):
- print(f"add_from_file: file not found: {src}", file=sys.stderr)
- sys.exit(1)
- incoming = parse_queue(src)
- if not incoming["tasks"]:
- print("Added 0 tasks (file had no ## sections)")
- return
- path = queue_path()
- q = parse_queue(path)
- existing = {t["title"] for t in q["tasks"]}
+def append_tasks_from_parsed(incoming, force_dev_ready):
+ st = load_state()
+ existing = {t["title"] for t in st["tasks"]}
added = 0
skipped = 0
for t in incoming["tasks"]:
if t["title"] in existing:
skipped += 1
continue
- # Overnight / engineer loops require #dev-ready on the header.
tags = list(t["tags"])
- if "dev-ready" not in tags:
+ if force_dev_ready and "dev-ready" not in tags:
tags.append("dev-ready")
- q["tasks"].append({
+ st["tasks"].append({
"title": t["title"],
"tags": tags,
"description": t["description"],
@@ -216,16 +399,101 @@ def cmd_add_from_file(args):
existing.add(t["title"])
added += 1
if added:
- write_queue(path, q)
+ save_state(st)
print(f"Added {added} tasks" + (f" (skipped {skipped} existing)" if skipped else ""))
+
+def cmd_add_from_file(args):
+ if not args:
+ print("add_from_file: path required", file=sys.stderr)
+ sys.exit(1)
+ src = args[0]
+ if not os.path.isfile(src):
+ print(f"add_from_file: file not found: {src}", file=sys.stderr)
+ sys.exit(1)
+ incoming = parse_queue(src)
+ if not incoming["tasks"]:
+ print("Added 0 tasks (file had no ## sections)")
+ return
+ def go():
+ append_tasks_from_parsed(incoming, True)
+ with_lock(go)
+
+
+def cmd_add(args):
+ if not args:
+ print("add: path required", file=sys.stderr)
+ sys.exit(1)
+ src = args[0]
+ if not os.path.isfile(src):
+ print(f"add: file not found: {src}", file=sys.stderr)
+ sys.exit(1)
+ incoming = parse_queue(src)
+ if not incoming["tasks"]:
+ print("Added 0 tasks (file had no ## sections)")
+ return
+ def go():
+ append_tasks_from_parsed(incoming, False)
+ with_lock(go)
+
+
+def cmd_archive_to_done(args):
+ if not args:
+ print("archive_to_done: header required", file=sys.stderr)
+ sys.exit(1)
+ header = args[0]
+ notes = ""
+ if len(args) >= 2 and args[1] and os.path.isfile(args[1]):
+ with open(args[1], encoding="utf-8") as f:
+ notes = f.read().strip()
+ def go():
+ st = load_state()
+ i = find_task(st["tasks"], header)
+ if i < 0:
+ print(f"task not found: {header}", file=sys.stderr)
+ sys.exit(1)
+ t = st["tasks"].pop(i)
+ tags = [x for x in t["tags"] if x not in ("dev-ready", "in-progress")]
+ parts = [f"Completed: {time.strftime('%Y-%m-%d')}"]
+ if notes:
+ parts.extend(["", "### PO notes", "", notes])
+ if t.get("description"):
+ parts.extend(["", t["description"]])
+ st["done"].append({
+ "title": t["title"],
+ "tags": tags,
+ "description": "\n".join(parts).strip(),
+ })
+ save_state(st)
+ print(f"Archived: {t['title']}")
+ with_lock(go)
+
+
+def cmd_dump_views(args):
+ def go():
+ st = load_state()
+ dump_views(st)
+ print("Dumped views")
+ with_lock(go)
+
+
cmds = {
- "get": cmd_get, "get_by_header": cmd_get_by_header,
- "headers": cmd_headers, "complete": cmd_complete,
- "complete_by_header": cmd_complete_by_header, "mark": cmd_mark,
+ "get": cmd_get,
+ "get_available": cmd_get_available,
+ "get_by_header": cmd_get_by_header,
+ "headers": cmd_headers,
+ "complete": cmd_complete,
+ "complete_by_header": cmd_complete_by_header,
+ "mark": cmd_mark,
+ "unmark": cmd_unmark,
"set_description": cmd_set_description,
- "has_tag": cmd_has_tag, "check_all_tagged": cmd_check_all_tagged,
- "json": cmd_json, "add_from_file": cmd_add_from_file,
+ "has_tag": cmd_has_tag,
+ "check_all_tagged": cmd_check_all_tagged,
+ "json": cmd_json,
+ "add_from_file": cmd_add_from_file,
+ "add": cmd_add,
+ "archive_to_done": cmd_archive_to_done,
+ "dump_views": cmd_dump_views,
}
argv = [a for a in sys.argv[1:] if a]
diff --git a/.jaiph/libs/jaiphlang/queue_selftest.jh b/.jaiph/libs/jaiphlang/queue_selftest.jh
new file mode 100644
index 00000000..7e50ad0b
--- /dev/null
+++ b/.jaiph/libs/jaiphlang/queue_selftest.jh
@@ -0,0 +1,7 @@
+#!/usr/bin/env jaiph
+
+import script "./queue_test.py" as queue_selftest
+
+export def main() {
+ return queue_selftest()
+}
diff --git a/.jaiph/libs/jaiphlang/queue_test.py b/.jaiph/libs/jaiphlang/queue_test.py
new file mode 100644
index 00000000..23dbc9f6
--- /dev/null
+++ b/.jaiph/libs/jaiphlang/queue_test.py
@@ -0,0 +1,114 @@
+#!/usr/bin/env python3
+"""Fixture tests for queue.py state + views. Run from any cwd."""
+import os, subprocess, sys, tempfile, textwrap
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+_LOCAL = os.path.join(HERE, "queue.py")
+_WS = os.path.join(os.environ.get("JAIPH_WORKSPACE", "."), ".jaiph", "libs", "jaiphlang", "queue.py")
+QUEUE_PY = _LOCAL if os.path.isfile(_LOCAL) else _WS
+
+
+def run_cmd(env, *args):
+ r = subprocess.run(
+ [sys.executable, QUEUE_PY, *args],
+ cwd=env["JAIPH_WORKSPACE"],
+ env={**os.environ, **env},
+ capture_output=True,
+ text=True,
+ )
+ return r
+
+
+def main():
+ with tempfile.TemporaryDirectory() as tmp:
+ ws = os.path.join(tmp, "ws")
+ os.makedirs(os.path.join(ws, ".jaiph"))
+ state = os.path.join(tmp, "hidden", "queue-state.md")
+ os.makedirs(os.path.dirname(state))
+ env = {"JAIPH_WORKSPACE": ws, "JAIPH_QUEUE_STATE": state}
+
+ qview = os.path.join(ws, "QUEUE.md")
+ with open(qview, "w", encoding="utf-8") as f:
+ f.write(textwrap.dedent("""\
+ # Old preamble
+
+ ## First #dev-ready
+
+ Do the first thing.
+
+ ## Second #dev-ready
+
+ Do the second thing.
+ """))
+
+ r = run_cmd(env, "get_available")
+ assert r.returncode == 0, r.stderr
+ assert "First" in r.stdout
+ assert os.path.isfile(state), "bootstrap must write state"
+
+ r = run_cmd(env, "mark", "First", "in-progress")
+ assert r.returncode == 0, r.stderr
+
+ r = run_cmd(env, "get_available")
+ assert r.returncode == 0, r.stderr
+ assert "Second" in r.stdout
+ assert "First" not in r.stdout.split("\n")[0]
+
+ with open(qview, "w", encoding="utf-8") as f:
+ f.write("# corrupted by agent\n")
+
+ r = run_cmd(env, "get_by_header", "First")
+ assert r.returncode == 0, r.stderr
+ assert "Do the first thing" in r.stdout
+
+ notes = os.path.join(tmp, "notes.md")
+ with open(notes, "w", encoding="utf-8") as f:
+ f.write("looks good")
+ r = run_cmd(env, "archive_to_done", "First", notes)
+ assert r.returncode == 0, r.stderr
+
+ r = run_cmd(env, "get_by_header", "First")
+ assert r.returncode != 0
+
+ done = open(os.path.join(ws, "DONE.md"), encoding="utf-8").read()
+ assert "First" in done
+ assert "looks good" in done
+ assert "corrupted by agent" not in open(qview, encoding="utf-8").read()
+ assert "Second" in open(qview, encoding="utf-8").read()
+
+ addf = os.path.join(tmp, "add.md")
+ with open(addf, "w", encoding="utf-8") as f:
+ f.write("## Third\n\nNo ready tag.\n")
+ r = run_cmd(env, "add", addf)
+ assert r.returncode == 0, r.stderr
+ r = run_cmd(env, "get_by_header", "Third")
+ assert r.returncode == 0
+ assert "#dev-ready" not in r.stdout.split("\n")[0]
+
+ r = run_cmd(env, "archive_to_done", "Second")
+ assert r.returncode == 0
+ done2 = open(os.path.join(ws, "DONE.md"), encoding="utf-8").read()
+ assert "First" in done2 and "Second" in done2
+
+ r = run_cmd(env, "unmark", "Third", "in-progress")
+ assert r.returncode == 0, r.stderr
+
+ other_state = os.path.join(tmp, "other-state.md")
+ env2 = {**env, "JAIPH_QUEUE_STATE": other_state}
+ with open(os.path.join(ws, "QUEUE.md"), "w", encoding="utf-8") as f:
+ f.write("## OnlyInOverride #dev-ready\n\nbody\n")
+ # Override path is empty: bootstrap from current QUEUE.md view.
+ r = run_cmd(env2, "get_available")
+ assert r.returncode == 0, r.stderr
+ assert "OnlyInOverride" in r.stdout
+
+ print("queue_test.py: ok")
+
+
+if __name__ == "__main__":
+ try:
+ main()
+ except Exception:
+ import traceback
+ traceback.print_exc()
+ sys.exit(1)
diff --git a/.jaiph/main.jh b/.jaiph/main.jh
index e74c95eb..06579c1c 100755
--- a/.jaiph/main.jh
+++ b/.jaiph/main.jh
@@ -32,52 +32,52 @@ config {
# Review every QUEUE.md task for clarity, architecture fit, and #dev-ready.
# Marks ready tasks; fails if any still need work.
export def architect_review() {
- run arch_mod.main()
+ arch_mod.main()
}
# Bring docs/ and CLI usage strings in line with the current TypeScript/Bash source.
# Requires a clean worktree; edits docs and related usage surfaces only.
export def docs_parity() {
- run docs_mod.main()
+ docs_mod.main()
}
# Implement a task end-to-end: code, CI, docs, commit patch. Pass the full task
# markdown (must start with a ## header). Does not read or write QUEUE.md —
# queue-driven overnight runs use `.jaiph/engineer.jh` directly instead.
export def engineer(task) {
- return run eng_mod.implement_from_task(task)
+ return eng_mod.implement_from_task(task)
}
# Run npm run test:ci and loop with an agent until it passes (or recover_limit).
export def ensure_ci_passes() {
- run ci_mod.main()
+ ci_mod.main()
}
# Wait for GitHub Actions CI on the branch, pull failure logs, and repair until green.
# branch="" uses the current branch; workflow_name="" defaults to "CI". Needs GITHUB_TOKEN.
export def gh_ci_passes(branch, workflow_name) {
- run gh_ci_mod.main(branch, workflow_name)
+ gh_ci_mod.main(branch, workflow_name)
}
# OWASP ASI Top 10 security review; report under .jaiph/tmp/, HIGH/MEDIUM →
# #dev-ready QUEUE.md tasks (committed when the queue changes). Overnight-safe.
# scope: ""|"codebase"|"full" for whole tree, "diff" for uncommitted, or a git range.
export def security_review(scope) {
- run sec_mod.main(scope)
+ sec_mod.main(scope)
}
# Find and apply safe simplifications (no test/e2e edits), CI, commit if changed.
export def simplifier() {
- run simp_mod.main()
+ simp_mod.main()
}
# Stage a release: CHANGELOG review, version bump, installer ref, rebuild, registry.
# version="" bumps the next patch from package.json; otherwise pass X.Y.Z. No tag/push.
export def prepare_release(version) {
- return run rel_mod.main(version)
+ return rel_mod.main(version)
}
# Find test-coverage gaps, write missing tests, CI, commit if changed.
export def qa() {
- run qa_mod.main()
+ qa_mod.main()
}
diff --git a/.jaiph/prepare_release.jh b/.jaiph/prepare_release.jh
index b0d1e867..41311858 100755
--- a/.jaiph/prepare_release.jh
+++ b/.jaiph/prepare_release.jh
@@ -13,9 +13,11 @@
# 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 read_pkg_version = '''
+node -p "require('./package.json').version"
+'''
-script assert_version_format = ```
+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 ;;
@@ -24,9 +26,8 @@ script assert_version_format = ```
printf 'version must match X.Y.Z (digits only); got: %s\n' "${v}" >&2
exit 1
}
-```
-
-script compute_next_patch = ```python3
+'''
+script compute_next_patch = '''python3
import sys
v = sys.argv[1]
parts = v.split('.')
@@ -35,27 +36,24 @@ script compute_next_patch = ```python3
sys.exit(1)
parts[-1] = str(int(parts[-1]) + 1)
print('.'.join(parts))
-```
-
-script assert_git_tree_clean = ```
+'''
+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 = ```
+'''
+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 npm_version_no_tag = `npm version "$1" --no-git-tag-version --allow-same-version >/dev/null`
-
-script update_install_release_ref = ```python3
+script update_install_release_ref = '''python3
import sys
old, new = sys.argv[1], sys.argv[2]
needle = f"v{old}"
@@ -72,11 +70,10 @@ script update_install_release_ref = ```python3
f.write(src.replace(needle, f"v{new}"))
total += count
print(total)
-```
-
-script run_npm_build = `npm run build >&2`
+'''
+script run_npm_build = 'npm run build >&2'
-script assert_built_cli_version_equals = ```
+script assert_built_cli_version_equals = '''
v="$1"
expected="jaiph ${v}"
actual="$(node dist/src/cli.js --version)"
@@ -84,22 +81,22 @@ script assert_built_cli_version_equals = ```
printf 'displayed --version mismatch\nexpected: %s\nactual: %s\n' "${expected}" "${actual}" >&2
exit 1
fi
-```
+'''
+script run_registry_build = 'node scripts/build-registry.mjs >&2'
-script run_registry_build = `node scripts/build-registry.mjs >&2`
+script latest_release_tag = '''
+git tag -l 'v*' | sort -V | tail -1
+'''
-script latest_release_tag = `git tag -l 'v*' | sort -V | tail -1`
-
-script commits_since_latest_tag = ```
+script commits_since_latest_tag = '''
tag="$(git tag -l 'v*' | sort -V | tail -1)"
if [ -z "${tag}" ]; then
git log --oneline
else
git log "${tag}"..HEAD --oneline
fi
-```
-
-script read_changelog_unreleased = ```python3
+'''
+script read_changelog_unreleased = '''python3
import re
import sys
@@ -120,9 +117,8 @@ script read_changelog_unreleased = ```python3
out.append(line)
sys.stdout.write("".join(out))
-```
-
-script read_changelog_version_section = ```python3
+'''
+script read_changelog_version_section = '''python3
import re
import sys
@@ -147,9 +143,8 @@ script read_changelog_version_section = ```python3
out.append(line)
sys.stdout.write("".join(out))
-```
-
-script assert_changelog_stamped = ```
+'''
+script assert_changelog_stamped = '''
v="$1"
grep -Eq '^# Unreleased$' CHANGELOG.md || {
echo "CHANGELOG.md: missing # Unreleased header at top" >&2
@@ -159,8 +154,7 @@ script assert_changelog_stamped = ```
echo "CHANGELOG.md: missing stamped # ${v} section" >&2
exit 1
}
-```
-
+'''
const changelog_reviewer_role = """
You are a release engineer preparing Jaiph for publication. You edit
CHANGELOG.md in the repo root — the operator runs prepare_release next
@@ -190,10 +184,10 @@ def review_changelog(version) {
agent.model = "opus"
agent.claude_flags = "--permission-mode bypassPermissions"
}
- const latest_tag = run latest_release_tag()
- const commit_list = run commits_since_latest_tag()
- const unreleased_block = run read_changelog_unreleased()
- const existing_section = run read_changelog_version_section(version)
+ const latest_tag = latest_release_tag()
+ const commit_list = commits_since_latest_tag()
+ const unreleased_block = read_changelog_unreleased()
+ const existing_section = read_changelog_version_section(version)
prompt """
${changelog_reviewer_role}
@@ -240,45 +234,45 @@ def review_changelog(version) {
"""
- run assert_changelog_stamped(version)
+ assert_changelog_stamped(version)
log "CHANGELOG.md stamped for v${version}"
}
export def resolve_version(arg) {
- const pkg_version = run read_pkg_version()
+ const pkg_version = read_pkg_version()
const resolved = match arg {
- "" => run compute_next_patch(pkg_version)
+ "" => compute_next_patch(pkg_version)
_ => arg
}
- run assert_version_format(resolved)
+ assert_version_format(resolved)
return resolved
}
export def preflight(version) {
- run assert_git_tree_clean()
- run assert_tag_does_not_exist(version)
+ assert_git_tree_clean()
+ assert_tag_does_not_exist(version)
}
def apply_version_change(old_version, new_version) {
- run npm_version_no_tag(new_version)
- run update_install_release_ref(old_version, new_version)
+ npm_version_no_tag(new_version)
+ update_install_release_ref(old_version, new_version)
}
export def check_displayed_version(version) {
- run run_npm_build()
- run assert_built_cli_version_equals(version)
+ run_npm_build()
+ assert_built_cli_version_equals(version)
}
export def main(arg) {
- const version = run resolve_version(arg)
- const old_version = run read_pkg_version()
+ const version = resolve_version(arg)
+ const old_version = read_pkg_version()
log "Preparing release v${version} (current: v${old_version})"
- run preflight(version)
- run review_changelog(version)
- run apply_version_change(old_version, version)
- run check_displayed_version(version)
- run run_registry_build()
+ preflight(version)
+ review_changelog(version)
+ apply_version_change(old_version, version)
+ check_displayed_version(version)
+ run_registry_build()
log """
prepare_release: staged release v${version}
diff --git a/.jaiph/prepare_release.test.jh b/.jaiph/prepare_release.test.jh
index 55d729ae..e7d22550 100644
--- a/.jaiph/prepare_release.test.jh
+++ b/.jaiph/prepare_release.test.jh
@@ -4,12 +4,11 @@ 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("")
+ const out = pr.resolve_version("")
expect_equal out "1.2.4"
}
@@ -17,7 +16,7 @@ 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")
+ const out = pr.resolve_version("9.8.7")
expect_equal out "9.8.7"
}
@@ -25,7 +24,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
+ const out = pr.resolve_version("not-a-version") allow_failure
expect_contain out "version must match X.Y.Z"
expect_contain out "not-a-version"
}
@@ -34,7 +33,7 @@ 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
+ const out = 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"
}
@@ -42,7 +41,6 @@ test "resolve_version: extra-segment arg fails with offending value" {
# 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() {
:
@@ -53,14 +51,13 @@ test "check_displayed_version: mismatch fails with both values in output" {
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
+ const out = 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
@@ -69,7 +66,7 @@ test "preflight: dirty git tree fails before any side effects" {
mock script pr.assert_tag_does_not_exist() {
:
}
- const out = run pr.preflight("9.9.9") allow_failure
+ const out = pr.preflight("9.9.9") allow_failure
expect_contain out "git tree is dirty"
}
@@ -81,6 +78,6 @@ test "preflight: existing tag v fails" {
printf 'tag v%s already exists\n' "$1" >&2
exit 1
}
- const out = run pr.preflight("9.9.9") allow_failure
+ const out = pr.preflight("9.9.9") allow_failure
expect_contain out "tag v9.9.9 already exists"
}
diff --git a/.jaiph/product_owner.jh b/.jaiph/product_owner.jh
new file mode 100644
index 00000000..f4102574
--- /dev/null
+++ b/.jaiph/product_owner.jh
@@ -0,0 +1,280 @@
+#!/usr/bin/env jaiph
+
+#
+# Product owner — sole runtime writer of the task queue.
+# Serve (HTTP + MCP on one port):
+# ./start-product-owner.sh
+# or: jaiph serve .jaiph/product_owner.jh
+# Claude creds (ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN) come from
+# the process env. They are not `use` keys — do not --env them.
+#
+import "jaiphlang/queue" as queue
+import "./lib_common.jh" as common
+
+config {
+ agent.backend = "claude"
+ agent.model = "sonnet"
+ agent.claude_flags = "--permission-mode bypassPermissions"
+}
+
+const po_file_rule = """
+ You are given the queue as text in this prompt. That text is complete.
+ Do not read or write QUEUE.md, DONE.md, .jaiph/queue-state.md,
+ .jaiph/product-owner/, or any other queue file. Do not launch nested
+ jaiph workflows or agent sessions.
+"""
+
+script first_line_task = '''
+ local line
+ line="$(printf '%s\n' "$1" | awk 'NR==1 { print; exit }')"
+ line="${line#\#\# }"
+ line="$(printf '%s\n' "$line" | sed 's/ *#[A-Za-z0-9_-]*//g')"
+ printf '%s\n' "$line"
+'''
+script git_evidence = '''
+ git log -5 --oneline 2>/dev/null || true
+ echo "---"
+ git status --porcelain 2>/dev/null || true
+ echo "---"
+ git log -1 --format=fuller 2>/dev/null || true
+ echo "---"
+ git diff --stat HEAD~1 2>/dev/null || true
+'''
+script format_task_file = '''
+ title="$1"
+ tags="$2"
+ body="$3"
+ dest="$4"
+ out="## $title"
+ for t in $tags; do
+ t="${t#\#}"
+ [ -n "$t" ] && out="$out #$t"
+ done
+ {
+ printf '%s\n\n' "$out"
+ printf '%s\n' "$body"
+ } > "$dest"
+'''
+# Claim one available task. Prefers the first #dev-ready that is not
+# #in-progress; may claim a later one. HTTP 200 either way — empty queue is fail.
+export def pick_task() {
+ const preferred = queue.next_available()
+ const state = queue.state_json()
+ const result = prompt """
+ ${po_file_rule}
+
+ You are the product owner. Pick one available task for an implementer.
+
+ Available means #dev-ready and not #in-progress. Prefer the first
+ such task (given below). Pick a later available task only with a
+ reason: blocked preferred task, sequencing, or a dependency.
+ Do not invent a title. Do not pick #in-progress.
+
+ Preferred (first available):
+ ${preferred}
+
+ Full queue state (JSON):
+ ${state}
+
+ Respond with title (exact task title, no #tags) and notes
+ (empty if you took the preferred task; required if you skipped it).
+ """
+ returns "{ title: string, notes: string }"
+
+ common.arg_nonempty("${result.title}") catch (err) {
+ fail "pick_task requires a title"
+ }
+ const task = queue.get_task_by_header("${result.title}")
+ const header = first_line_task(task)
+ queue.mark_task_in_progress(header)
+ return queue.get_task_by_header(header)
+}
+
+# Propose a task from a brief or markdown spec. Reject leaves the queue unchanged.
+# Verdict is in the return text (accepted: / rejected:). HTTP stays 200.
+# fail is only a broken call (empty spec, accepted with no title).
+export def propose_task(spec) {
+ common.arg_nonempty(spec) catch (err) {
+ fail "propose_task requires a non-empty spec"
+ }
+ const state = queue.state_json()
+ const result = prompt """
+ ${po_file_rule}
+
+ You are the product owner. Decide whether to accept this proposal
+ onto the queue.
+
+ Rules: standalone task, testable acceptance, no title clash, no
+ "see prior task", no stealing scope from an existing task.
+ Set tags to #dev-ready only if the spec is implementable as-is.
+ Otherwise leave tags empty.
+
+ Current queue state (JSON):
+ ${state}
+
+ Spec:
+ ${spec}
+
+ Respond with verdict accepted or rejected, title, body (markdown
+ below the heading, no ## heading line), and tags (space-separated
+ tag names without #, or empty).
+ """
+ returns "{ verdict: string, title: string, body: string, tags: string }"
+
+ common.str_equals("${result.verdict}", "accepted") catch (err) {
+ return "rejected: ${result.body}"
+ }
+ common.arg_nonempty("${result.title}") catch (err) {
+ fail "accepted propose_task requires a title"
+ }
+ const tmpdir = common.jaiph_tmp_dir()
+ common.mkdir_p_simple(tmpdir)
+ const spec_file = "${tmpdir}/po_propose_task.md"
+ format_task_file("${result.title}", "${result.tags}", "${result.body}", spec_file)
+ queue.add_task_from_file(spec_file)
+ return "accepted: ${result.title}"
+}
+
+# Report a claimed task as done. Archive or send back (needs-work).
+# Verdict is in the return text. HTTP stays 200. fail is a broken call.
+export def report_completed_task(header, notes) {
+ common.arg_nonempty(header) catch (err) {
+ fail "report_completed_task requires a task header"
+ }
+ const task = queue.get_task_by_header(header)
+ const state = queue.state_json()
+ const evidence = git_evidence()
+ const result = prompt """
+ ${po_file_rule}
+
+ You are the product owner. Decide if this task is done.
+
+ Rough check only: git evidence vs acceptance criteria. Look for
+ missing behavior, missing tests, or an unmerged main.
+
+ Task:
+ ${task}
+
+ Caller notes:
+ ${notes}
+
+ Git evidence:
+ ${evidence}
+
+ Full queue state (JSON):
+ ${state}
+
+ Respond with verdict accepted or needs-work, and notes
+ (empty if accepted and nothing to record; required if needs-work).
+ """
+ returns "{ verdict: string, notes: string }"
+
+ const tmpdir = common.jaiph_tmp_dir()
+ common.mkdir_p_simple(tmpdir)
+ const notes_file = "${tmpdir}/po_complete_notes.md"
+ common.str_equals("${result.verdict}", "accepted") catch (err) {
+ common.arg_nonempty("${result.notes}") catch (err) {
+ fail "needs-work requires notes"
+ }
+ const rest = common.rest_lines_str(task)
+ const updated = "${rest}\n\n### PO notes\n\n${result.notes}\n"
+ stdin "${updated}" -> common.save_string_to_file(notes_file)
+ const clean = first_line_task(task)
+ queue.set_task_description_from_file(clean, notes_file)
+ return "needs-work: ${result.notes}"
+ }
+ stdin "${result.notes}" -> common.save_string_to_file(notes_file)
+ const title = first_line_task(task)
+ queue.archive_to_done(title, notes_file)
+ return "accepted: ${title}"
+}
+
+# Answer a question about a task. Persist Q and A on the task body.
+export def task_details(header, question) {
+ common.arg_nonempty(header) catch (err) {
+ fail "task_details requires a task header"
+ }
+ common.arg_nonempty(question) catch (err) {
+ fail "task_details requires a question"
+ }
+ const task = queue.get_task_by_header(header)
+ const state = queue.state_json()
+ const result = prompt """
+ ${po_file_rule}
+
+ You are the product owner. Answer the question from the queue
+ state and the task text. You may use the codebase if needed.
+
+ Task:
+ ${task}
+
+ Question:
+ ${question}
+
+ Full queue state (JSON):
+ ${state}
+
+ Respond with verdict ok and notes equal to the answer.
+ """
+ returns "{ verdict: string, notes: string }"
+
+ common.arg_nonempty("${result.notes}") catch (err) {
+ fail "task_details requires an answer"
+ }
+ const tmpdir = common.jaiph_tmp_dir()
+ common.mkdir_p_simple(tmpdir)
+ const body_file = "${tmpdir}/po_details_body.md"
+ const rest = common.rest_lines_str(task)
+ const updated = "${rest}\n\n### PO note\n\nQ: ${question}\n\nA: ${result.notes}\n"
+ stdin "${updated}" -> common.save_string_to_file(body_file)
+ const title = first_line_task(task)
+ queue.set_task_description_from_file(title, body_file)
+ return "${result.notes}"
+}
+
+# Apply a requested change to a task after validation. Reject leaves the file unchanged.
+# Verdict is in the return text. HTTP stays 200.
+export def update_task(header, request) {
+ common.arg_nonempty(header) catch (err) {
+ fail "update_task requires a task header"
+ }
+ common.arg_nonempty(request) catch (err) {
+ fail "update_task requires a request"
+ }
+ const task = queue.get_task_by_header(header)
+ const state = queue.state_json()
+ const result = prompt """
+ ${po_file_rule}
+
+ You are the product owner. Decide whether to apply this change
+ to the task. Keep the task standalone, with testable acceptance,
+ and do not steal scope from other tasks.
+
+ Task:
+ ${task}
+
+ Request:
+ ${request}
+
+ Full queue state (JSON):
+ ${state}
+
+ If accepted, body is the full new task body (markdown below the
+ heading; no ## heading line). If rejected, body is the reason.
+ """
+ returns "{ verdict: string, body: string }"
+
+ common.str_equals("${result.verdict}", "accepted") catch (err) {
+ return "rejected: ${result.body}"
+ }
+ common.arg_nonempty("${result.body}") catch (err) {
+ fail "accepted update_task requires a body"
+ }
+ const tmpdir = common.jaiph_tmp_dir()
+ common.mkdir_p_simple(tmpdir)
+ const body_file = "${tmpdir}/po_update_body.md"
+ stdin "${result.body}" -> common.save_string_to_file(body_file)
+ const title = first_line_task(task)
+ queue.set_task_description_from_file(title, body_file)
+ return "accepted: ${title}"
+}
diff --git a/.jaiph/product_owner.test.jh b/.jaiph/product_owner.test.jh
new file mode 100644
index 00000000..27da403d
--- /dev/null
+++ b/.jaiph/product_owner.test.jh
@@ -0,0 +1,156 @@
+#!/usr/bin/env jaiph
+
+import "./product_owner.jh" as po
+import "jaiphlang/queue" as queue
+
+test "pick_task claims the preferred first available task" {
+ mock def queue.next_available() {
+ return "## First #dev-ready\n\nDo first."
+ }
+ mock def queue.state_json() {
+ return "{}"
+ }
+ mock def queue.mark_task_in_progress() {
+ return ""
+ }
+ mock def queue.get_task_by_header() {
+ return "## First #dev-ready #in-progress\n\nDo first."
+ }
+ mock prompt {
+ _ => "{\"title\":\"First\",\"notes\":\"\"}"
+ }
+ const out = po.pick_task()
+ expect_contain out "First"
+ expect_contain out "in-progress"
+}
+
+test "pick_task may claim a later available task" {
+ mock def queue.next_available() {
+ return "## First #dev-ready\n\nDo first."
+ }
+ mock def queue.state_json() {
+ return "{}"
+ }
+ mock def queue.mark_task_in_progress() {
+ return ""
+ }
+ mock def queue.get_task_by_header() {
+ return "## Second #dev-ready #in-progress\n\nDo second."
+ }
+ mock prompt {
+ _ => "{\"title\":\"Second\",\"notes\":\"First is blocked on a merge\"}"
+ }
+ const out = po.pick_task()
+ expect_contain out "Second"
+ expect_contain out "in-progress"
+}
+
+test "pick_task fails when the queue has no available task" {
+ mock def queue.next_available() {
+ fail "no #dev-ready task is available"
+ }
+ const out = po.pick_task() allow_failure
+ expect_contain out "no #dev-ready task is available"
+}
+
+test "propose_task reject does not write" {
+ mock def queue.state_json() {
+ return "{}"
+ }
+ mock def queue.add_task_from_file() {
+ fail "SHOULD_NOT_WRITE"
+ }
+ mock prompt {
+ _ => "{\"verdict\":\"rejected\",\"title\":\"\",\"body\":\"needs acceptance\",\"tags\":\"\"}"
+ }
+ const out = po.propose_task("vague idea")
+ expect_contain out "rejected"
+}
+
+test "propose_task accept writes via add_task_from_file" {
+ mock def queue.state_json() {
+ return "{}"
+ }
+ mock def queue.add_task_from_file() {
+ return "wrote"
+ }
+ mock prompt {
+ _ => "{\"verdict\":\"accepted\",\"title\":\"New Task\",\"body\":\"Do it.\",\"tags\":\"dev-ready\"}"
+ }
+ const out = po.propose_task("## New Task
+
+Do it.")
+ expect_contain out "accepted: New Task"
+}
+
+test "report_completed_task needs-work does not archive" {
+ mock def queue.get_task_by_header() {
+ return "## Claimed #dev-ready #in-progress\n\nBuild it."
+ }
+ mock def queue.state_json() {
+ return "{}"
+ }
+ mock def queue.archive_to_done() {
+ fail "SHOULD_NOT_ARCHIVE"
+ }
+ mock def queue.set_task_description_from_file() {
+ return "updated"
+ }
+ mock prompt {
+ _ => "{\"verdict\":\"needs-work\",\"notes\":\"merge main\"}"
+ }
+ const out = po.report_completed_task("Claimed", "")
+ expect_contain out "needs-work"
+ expect_contain out "merge main"
+}
+
+test "report_completed_task accepted archives" {
+ mock def queue.get_task_by_header() {
+ return "## Claimed #dev-ready #in-progress\n\nBuild it."
+ }
+ mock def queue.state_json() {
+ return "{}"
+ }
+ mock def queue.archive_to_done() {
+ return "archived"
+ }
+ mock prompt {
+ _ => "{\"verdict\":\"accepted\",\"notes\":\"ok\"}"
+ }
+ const out = po.report_completed_task("Claimed", "done")
+ expect_contain out "accepted: Claimed"
+}
+
+test "update_task reject does not write" {
+ mock def queue.get_task_by_header() {
+ return "## Claimed #dev-ready\n\nBuild it."
+ }
+ mock def queue.state_json() {
+ return "{}"
+ }
+ mock def queue.set_task_description_from_file() {
+ fail "SHOULD_NOT_WRITE"
+ }
+ mock prompt {
+ _ => "{\"verdict\":\"rejected\",\"body\":\"steals later task\"}"
+ }
+ const out = po.update_task("Claimed", "also do the next task")
+ expect_contain out "rejected"
+}
+
+test "task_details persists an answer" {
+ mock def queue.get_task_by_header() {
+ return "## Claimed #dev-ready\n\nBuild it."
+ }
+ mock def queue.state_json() {
+ return "{}"
+ }
+ mock def queue.set_task_description_from_file() {
+ return "updated"
+ }
+ mock prompt {
+ _ => "{\"verdict\":\"ok\",\"notes\":\"use the public entry\"}"
+ }
+ const out = po.task_details("Claimed", "which file?")
+ expect_contain out "use the public entry"
+}
diff --git a/.jaiph/qa.jh b/.jaiph/qa.jh
index f67386d0..3b006e49 100755
--- a/.jaiph/qa.jh
+++ b/.jaiph/qa.jh
@@ -14,7 +14,7 @@ config {
agent.claude_flags = "--permission-mode bypassPermissions"
}
-script read_contributing_docs = ```
+script read_contributing_docs = '''
test -f docs/contributing.md || {
echo "docs/contributing.md not found (run from repo root)" >&2
return 1
@@ -27,41 +27,40 @@ script read_contributing_docs = ```
sec == 1 { next }
sec == 2 { print }
' docs/contributing.md
-```
+'''
+script read_txtar_format_spec = 'cat test-fixtures/compiler-txtar/README.md'
-script read_txtar_format_spec = `cat test-fixtures/compiler-txtar/README.md`
-
-script read_txtar_fixture_names = ```
+script read_txtar_fixture_names = '''
for f in test-fixtures/compiler-txtar/*.txt; do
echo "=== FILE: $f ==="
grep '^=== ' "$f" | sed 's/^=== / /'
done
-```
-
-script read_txtar_fixtures = ```
+'''
+script read_txtar_fixtures = '''
for f in test-fixtures/compiler-txtar/*.txt; do
echo "========== $f =========="
cat "$f"
echo ""
done
-```
-
-script new_qa_gap_report_path = `echo ".jaiph/tmp/qa_gap_report_$(date +%Y-%m-%d_%H-%M-%S).md"`
+'''
+script new_qa_gap_report_path = 'echo ".jaiph/tmp/qa_gap_report_$(date +%Y-%m-%d_%H-%M-%S).md"'
-script write_gap_report_pointer = `printf '%s\n' "$1" > .jaiph/tmp/qa_gap_report_active.txt`
+script write_gap_report_pointer = '''
+printf '%s\n' "$1" > .jaiph/tmp/qa_gap_report_active.txt
+'''
-script gap_report_nonempty = ```
+script gap_report_nonempty = '''
p="$(cat .jaiph/tmp/qa_gap_report_active.txt 2>/dev/null)" || return 1
[ -z "$p" ] && return 1
test -s "$p"
-```
-
-script read_gap_report = ```
+'''
+script read_gap_report = '''
p="$(cat .jaiph/tmp/qa_gap_report_active.txt)"
cat "$p"
-```
-
-script save_gap_report_file = `printf '%s\n' "$1" > "$2"`
+'''
+script save_gap_report_file = '''
+printf '%s\n' "$1" > "$2"
+'''
const analyze_gaps_prompt = """
@@ -154,27 +153,26 @@ def analyze_gaps() {
config {
agent.model = "opus"
}
-
- const report_path = run new_qa_gap_report_path()
- const contributing_docs = run read_contributing_docs()
- const txtar_format = run read_txtar_format_spec()
- const txtar_names = run read_txtar_fixture_names()
+ const report_path = new_qa_gap_report_path()
+ const contributing_docs = read_contributing_docs()
+ const txtar_format = read_txtar_format_spec()
+ const txtar_names = read_txtar_fixture_names()
const gap_report = prompt analyze_gaps_prompt
- run save_gap_report_file(gap_report, report_path)
- run write_gap_report_pointer(report_path)
+ save_gap_report_file(gap_report, report_path)
+ write_gap_report_pointer(report_path)
log "Gap report saved to ${report_path}"
}
def write_tests() {
- run gap_report_nonempty() catch (err) {
+ gap_report_nonempty() catch (err) {
fail "No gap report found (see .jaiph/tmp/qa_gap_report_active.txt). Run analyze_gaps first."
}
- const gap_report = run read_gap_report()
- const contributing_docs = run read_contributing_docs()
- const txtar_format = run read_txtar_format_spec()
- const txtar_fixtures = run read_txtar_fixtures()
+ const gap_report = read_gap_report()
+ const contributing_docs = read_contributing_docs()
+ const txtar_format = read_txtar_format_spec()
+ const txtar_fixtures = read_txtar_fixtures()
prompt """
@@ -230,10 +228,10 @@ def write_tests() {
${gap_report}
- """
+ """
}
-script mkdir_tmp_jaiph_qa = `mkdir -p .jaiph/tmp`
+script mkdir_tmp_jaiph_qa = 'mkdir -p .jaiph/tmp'
const commit_task = """
QA pass: add missing tests from the gap report under
@@ -241,10 +239,10 @@ const commit_task = """
"""
export def main() {
- run git.is_clean()
- run mkdir_tmp_jaiph_qa()
- run analyze_gaps()
- run write_tests()
- run ci.ensure_ci_passes()
- run git.commit_if_changes(commit_task)
+ git.is_clean()
+ mkdir_tmp_jaiph_qa()
+ analyze_gaps()
+ write_tests()
+ ci.ensure_ci_passes()
+ git.commit_if_changes(commit_task)
}
diff --git a/.jaiph/queue_ops.test.jh b/.jaiph/queue_ops.test.jh
new file mode 100644
index 00000000..f35702ca
--- /dev/null
+++ b/.jaiph/queue_ops.test.jh
@@ -0,0 +1,8 @@
+#!/usr/bin/env jaiph
+
+import "./libs/jaiphlang/queue_selftest.jh" as qs
+
+test "queue state ignores QUEUE.md after bootstrap and archives to DONE" {
+ const out = qs.main()
+ expect_contain out "queue_test.py: ok"
+}
diff --git a/.jaiph/security_review.jh b/.jaiph/security_review.jh
index 60996dbe..307fbd83 100755
--- a/.jaiph/security_review.jh
+++ b/.jaiph/security_review.jh
@@ -30,11 +30,13 @@ config {
agent.claude_flags = "--permission-mode bypassPermissions"
}
-script new_security_review_report_path = `echo ".jaiph/tmp/security_review_$(date +%Y-%m-%d_%H-%M-%S).md"`
+script new_security_review_report_path = 'echo ".jaiph/tmp/security_review_$(date +%Y-%m-%d_%H-%M-%S).md"'
-script write_security_review_pointer = `printf '%s\n' "$1" > .jaiph/tmp/security_review_active.txt`
+script write_security_review_pointer = '''
+printf '%s\n' "$1" > .jaiph/tmp/security_review_active.txt
+'''
-script security_review_tasks_path = `echo ".jaiph/tmp/security_review_queue_tasks.md"`
+script security_review_tasks_path = 'echo ".jaiph/tmp/security_review_queue_tasks.md"'
const reviewer_role = """
You are a senior security engineer reviewing Jaiph — a workflow DSL,
@@ -72,22 +74,21 @@ const reviewer_role = """
- Findings without a concrete exploit scenario.
"""
-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 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`
+script worktree_fingerprint = 'git status --porcelain | sort | cksum'
-script report_file_nonempty = `test -s "$1"`
+script report_file_nonempty = 'test -s "$1"'
def review_scope(mode, scope_detail, report_file) {
const result = prompt """
@@ -154,7 +155,7 @@ def review_codebase(report_file) {
env/secrets handling, artifacts/run logs, installers, and skills.
Do not limit yourself to a diff — this is a full codebase scan.
"""
- return run review_scope("codebase", scope_detail, report_file)
+ return review_scope("codebase", scope_detail, report_file)
}
def review_diff_text(mode, scope_label, diff_text, report_file) {
@@ -171,7 +172,7 @@ def review_diff_text(mode, scope_label, diff_text, report_file) {
Code changes under review:
${diff_text}
"""
- return run review_scope(mode, scope_detail, report_file)
+ return review_scope(mode, scope_detail, report_file)
}
def finish_report(verdict, report_file, fingerprint_before) {
@@ -181,24 +182,24 @@ def finish_report(verdict, report_file, fingerprint_before) {
}
# 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) {
+ const fingerprint_after = worktree_fingerprint()
+ 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 report_file_nonempty(report_file) catch (err) {
+ report_file_nonempty(report_file) catch (err) {
fail "Security review did not write a report at ${report_file}."
}
- run artifacts.save(report_file)
+ artifacts.save(report_file)
log "Security review report ready: ${report_file}"
}
-script truncate_file = `: > "$1"`
+script truncate_file = ': > "$1"'
def queue_findings(report_file) {
- const tasks_file = run security_review_tasks_path()
+ const tasks_file = security_review_tasks_path()
# Default to empty so a no-finding pass is a clean no-op for add_from_file.
- run truncate_file(tasks_file)
+ truncate_file(tasks_file)
prompt """
@@ -243,7 +244,7 @@ def queue_findings(report_file) {
"""
- run queue.add_tasks_from_file(tasks_file)
+ queue.add_tasks_from_file(tasks_file)
}
const commit_task = """
@@ -253,18 +254,18 @@ const commit_task = """
def dispatch_review(mode, scope, report_file) {
if mode == "codebase" {
- return run review_codebase(report_file)
+ return review_codebase(report_file)
} else if mode == "diff" {
- const uncommitted = run git_diff_uncommitted()
- return run review_diff_text("diff", "uncommitted changes", uncommitted, report_file)
+ const uncommitted = git_diff_uncommitted()
+ return review_diff_text("diff", "uncommitted changes", uncommitted, report_file)
} else {
- const ranged = run git_diff_range(scope)
- return run review_diff_text("range", scope, ranged, report_file)
+ const ranged = git_diff_range(scope)
+ return review_diff_text("range", scope, ranged, report_file)
}
}
export def main(scope) {
- run git.in_git_repo()
+ git.in_git_repo()
const mode = match scope {
"" | "codebase" | "full" => "codebase"
@@ -276,31 +277,31 @@ export def main(scope) {
# commit only contains queued findings. Diff mode reviews an existing dirty
# tree and updates QUEUE.md without committing.
if mode != "diff" {
- run git.branch_clean()
+ git.branch_clean()
}
- run common.mkdir_p_simple(".jaiph/tmp")
- const report_file = run new_security_review_report_path()
- run write_security_review_pointer(report_file)
- const fingerprint_before = run worktree_fingerprint()
+ common.mkdir_p_simple(".jaiph/tmp")
+ const report_file = new_security_review_report_path()
+ write_security_review_pointer(report_file)
+ const fingerprint_before = worktree_fingerprint()
- const verdict = run dispatch_review(mode, scope, report_file)
- run finish_report(verdict, report_file, fingerprint_before)
+ const verdict = dispatch_review(mode, scope, report_file)
+ finish_report(verdict, report_file, fingerprint_before)
if verdict == "skip" {
return ""
}
- run queue_findings(report_file)
+ queue_findings(report_file)
if mode == "diff" {
- run git.has_changes() catch (err) {
+ git.has_changes() catch (err) {
log "Security review finished (no QUEUE.md changes). Report: ${report_file}"
return ""
}
log "QUEUE.md updated; commit skipped in diff mode. Report: ${report_file}"
} else {
- run git.commit_if_changes(commit_task)
+ git.commit_if_changes(commit_task)
if verdict == "fail" {
log "Security review found HIGH findings — queued as #dev-ready tasks (see QUEUE.md and ${report_file})."
} else {
diff --git a/.jaiph/simplifier.jh b/.jaiph/simplifier.jh
index e0272070..8cfa73b2 100755
--- a/.jaiph/simplifier.jh
+++ b/.jaiph/simplifier.jh
@@ -42,29 +42,28 @@ const simplifier_principles = """
existing tests unchanged.
"""
-script assert_no_test_or_e2e_in_changed = ```
-local bad=0
-while IFS= read -r f; do
- [ -z "$f" ] && continue
- if [[ "$f" == test/* ]] || [[ "$f" == e2e/* ]]; then
- echo "Simplifier workflow must not modify tests or e2e: $f" >&2
- bad=1
- fi
-done < <(
- {
- git diff --name-only --cached
- git diff --name-only
- git ls-files --others --exclude-standard
- } | sort -u
-)
-return $bad
-```
-
+script assert_no_test_or_e2e_in_changed = '''
+ local bad=0
+ while IFS= read -r f; do
+ [ -z "$f" ] && continue
+ if [[ "$f" == test/* ]] || [[ "$f" == e2e/* ]]; then
+ echo "Simplifier workflow must not modify tests or e2e: $f" >&2
+ bad=1
+ fi
+ done < <(
+ {
+ git diff --name-only --cached
+ git diff --name-only
+ git ls-files --others --exclude-standard
+ } | sort -u
+ )
+ return $bad
+'''
def no_test_or_e2e_paths_changed() {
- run assert_no_test_or_e2e_in_changed()
+ assert_no_test_or_e2e_in_changed()
}
-script report_file_nonempty = `test -s ".jaiph/tmp/simplifier_report.md"`
+script report_file_nonempty = 'test -s ".jaiph/tmp/simplifier_report.md"'
def find_simplifications() {
const report = prompt """
@@ -100,22 +99,24 @@ def find_simplifications() {
Sort by (risk ascending, then impact descending). Skip cosmetic-only edits.
-"""
+ """
- run save_simplifier_report(report)
+ save_simplifier_report(report)
log "Simplification report saved to .jaiph/tmp/simplifier_report.md"
- run no_test_or_e2e_paths_changed()
+ no_test_or_e2e_paths_changed()
}
-script save_simplifier_report = `printf '%s\n' "$1" > .jaiph/tmp/simplifier_report.md`
+script save_simplifier_report = '''
+printf '%s\n' "$1" > .jaiph/tmp/simplifier_report.md
+'''
-script read_simplifier_report = `cat .jaiph/tmp/simplifier_report.md`
+script read_simplifier_report = 'cat .jaiph/tmp/simplifier_report.md'
def apply_simplifications() {
- run report_file_nonempty() catch (err) {
+ report_file_nonempty() catch (err) {
fail "No report at .jaiph/tmp/simplifier_report.md. Run find_simplifications first."
}
- const report = run read_simplifier_report()
+ const report = read_simplifier_report()
prompt """
@@ -141,12 +142,12 @@ def apply_simplifications() {
Report:
${report}
-"""
+ """
- run no_test_or_e2e_paths_changed()
+ no_test_or_e2e_paths_changed()
}
-script mkdir_tmp_jaiph = `mkdir -p .jaiph/tmp`
+script mkdir_tmp_jaiph = 'mkdir -p .jaiph/tmp'
const commit_task = """
Simplifier pass: apply safe code simplifications from
@@ -154,11 +155,11 @@ const commit_task = """
"""
export def main() {
- run git.is_clean()
- run mkdir_tmp_jaiph()
- run find_simplifications()
- run apply_simplifications()
- run ci.ensure_ci_passes()
- run no_test_or_e2e_paths_changed()
- run git.commit_if_changes(commit_task)
+ git.is_clean()
+ mkdir_tmp_jaiph()
+ find_simplifications()
+ apply_simplifications()
+ ci.ensure_ci_passes()
+ no_test_or_e2e_paths_changed()
+ git.commit_if_changes(commit_task)
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7dd5fa58..f9fe5bb8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,15 +2,57 @@
## Summary
-- **CLI:** `claude` no longer warns when `ANTHROPIC_API_KEY` and `CLAUDE_CODE_OAUTH_TOKEN` are unset. A stored Claude CLI login is the host path.
-- **Language:** compact `return match … { … }` and `return prompt …` parse as those expressions. A bad `return` is `E_PARSE`, not a shell line.
-- **Language:** using a `const` before its declaration is `E_VALIDATE` in a `${…}` interpolation, a bare `run` / `prompt` call argument, and an `if` / `match` subject, not only in a `run` / `prompt` target.
-- **Language:** nested `const` / `script` / `def` / named `prompt` declared inside an `if` / `else` / `for` / `catch` / `recover` body are block-scoped to that body. A reference after the branch is `E_VALIDATE`, not a name that validates and then interpolates empty at runtime when the branch was not taken.
-- **Language:** a nested `def` may `run` itself. Self-recursion validates and is bounded only by the runtime recursion-depth cap of 256; a `run` of a sibling nested `def` declared later in the same body stays `E_VALIDATE` (declarations are sequential, not hoisted).
-- **Docs:** Spawn-env and journal rules are guardrails, not a sandbox. [Why Jaiph](docs/why-jaiph.md) states the limit. [Deploy jaiph](docs/deploy.md) has the dedicated-user / two-run operator recipe.
-- **Docs:** [Pass a host key to a script](docs/script-env.md) is the operator recipe for sterile script env, `use`, and `--env`.
-- **Editors:** VS Code and Zed now scope a named prompt call site (`prompt analyze(log)`) as a function and highlight the field on a dotted `if` subject (`if answer.risk == "ok"`). Neither editor paints the invalid reverse arrow `<-` as a send operator.
-- **Security — spawn env:** `--env` values no longer sit on the runner (workflow-leader) process environment. Jaiph builds the runner env from an allowlist (process basics, `JAIPH_*` control keys, backend credentials), so an ungranted host key is absent from it, and a granted value reaches only a subprocess whose declaration `use`s the key.
+## All changes
+
+# 0.15.0
+
+## Summary
+
+- **Scripts in Markdown:** Bodies are `'…'` and `'''…'''`, so examples no longer break CommonMark fences. Backticks are gone.
+- **Removal of `run`:** `run` is no longer a keyword. Call a workflow or script by writing its name: `save(path)`.
+- **Large output stays off the heap:** A call's bytes stay on disk until you use them. Ignoring a result, printing it, or sending it to an agent does not load the whole thing into Jaiph.
+- **Streaming pipelines:** `stdin gen() -> upper() -> count()` runs the stages together and passes bytes through pipes. A large payload does not sit in RAM between steps.
+- **Serve:** HTTP paths drop `/v1` (`POST /{name}`). No auth unless you set a token or OIDC — same as `jaiph mcp`. `--allow-anonymous` is only needed to bind off-loopback without auth. The runtime image publishes to GHCR.
+- **Editors:** VS Code, Zed, and the docs highlighter follow the new call and pipeline syntax.
+
+## All changes
+
+- **Security — imported modules cannot set the agent trust, argv, or run-dir keys:** `agent.trusted_workspace`, `agent.cursor_flags`, `agent.claude_flags`, and `run.logs_dir` now carry the same entry-module-only restriction as `agent.command` and `agent.backend`. Before, `applyMetadataScope` in `src/runtime/kernel/node-workflow-runtime.ts` copied these four keys onto the workflow env from any module, so an imported `.jh` library could change the Cursor `--trust` path, append flags to the agent argv (`buildBackendArgs` passes `cursor_flags` / `claude_flags` through), or move the run directory for its own `prompt` steps. The runtime now applies each key from an imported module only when the call is from the entry module or the matching `JAIPH_AGENT_TRUSTED_WORKSPACE_IMPORT_UNLOCK` / `JAIPH_AGENT_CURSOR_FLAGS_IMPORT_UNLOCK` / `JAIPH_AGENT_CLAUDE_FLAGS_IMPORT_UNLOCK` / `JAIPH_RUNS_DIR_IMPORT_UNLOCK` opt-in is set, parallel to the existing `JAIPH_AGENT_COMMAND_IMPORT_UNLOCK`. The entry module still sets these keys with no unlock var, and the `${NAME}_LOCKED` flags still win over an unlock. Docs: [Configuration — Import trust boundary](docs/configuration.md#import-trust-boundary), [Environment variables](docs/env-vars.md). Tests: `src/runtime/kernel/node-workflow-runtime.artifacts.test.ts`.
+- **Security — `jaiph install` restore requires a commit pin:** `jaiph install` with no arguments restores every entry from `.jaiph/libs.lock` and never re-reads the registry. Before, `specToLockEntry` in `src/cli/commands/install.ts` never wrote the `signature`, and a lock entry with no `commit` cloned the mutable ref with no post-clone check, so a moved ref could restore arbitrary code. Restore now refuses any entry that has no `commit` before cloning anything — the run fails and no lib directory is left behind; re-run `jaiph install ` to re-pin it. The lock entry persists the `signature` when the install spec carried one (from a signed registry entry), and restore re-verifies it against the cloned commit with the same embedded `jaiph.pub` project key as a named install, failing closed on mismatch. An entry with a `commit` and no `signature` still restores only when the cloned commit matches. Docs: [CLI — `jaiph install`](docs/cli.md#jaiph-install), [Use & publish a library](docs/libraries.md). Tests: `src/cli/commands/install.test.ts`.
+- **Fix — `jaiph serve` OIDC subject isolation:** an OIDC principal's audit/isolation identity is now namespaced by its claim type — `sub:` for a token `sub`, `client_id:` for a `sub`-less machine token — so a token whose `sub` equals another token's `client_id`, or whose subject equals the `operator` (static) or `anonymous` (open) sentinels, can no longer share that other principal's runs, artifacts, or idempotency namespace. `principalSubject` in `src/cli/serve/auth.ts` returned the raw claim text, which `lookupRun` and the idempotency composite in `src/cli/serve/handler.ts` compared directly against `record.principal`; both now compare the namespaced value, and OIDC stays non-owning of all runs. The `principal` field on the run object and persisted `run.json`, the invoke/cancel audit log lines, and the `jaiph.principal` OTLP resource attribute and Sentry tag therefore carry the `sub:` / `client_id:` prefix in OIDC mode (open and static stay `anonymous` / `operator`). Docs: [CLI — `jaiph serve`](docs/cli.md#jaiph-serve). Tests: `src/cli/serve/auth.test.ts`, `src/cli/serve/handler.test.ts`, `integration/serve-auth.test.ts`.
+- **Fix — Installer:** the local-source copy (`docs/install` / `docs/install-from-local.sh`) skips `.jaiph/runs`, `.git`, and `node_modules` instead of `cp -R` of the whole checkout. Host run artifacts are not build inputs and can be hundreds of megabytes. Tests: `e2e/tests/06_bootstrap_integrity.sh`.
+- **Breaking — Language:** script bodies are a single quote family. One-liners are `'…'` / `'…'(args)` (no escapes: the first `'` closes; a body that needs `'` uses a block). Multiline bodies stay `'''…'''` / `'''lang…'''(args)`. One-line backticks and triple backticks are `E_PARSE`. `${identifier}` in a one-liner is `E_PARSE`; `${…}` in a block is copied into the guest. The formatter emits `'` and `'''`. Docs: [Grammar](docs/grammar.md), [Language — Inline scripts](docs/language.md#inline-scripts). Tests: `src/parse/parse-removed-oneline.test.ts`, `src/parse/parse-removed-fence.test.ts`, `src/format/emit.test.ts`.
+- **Fix — Language / Runtime — pipeline stages tee stdout to `.out`:** a middle stage of a `stdin` pipeline (`stdin foo() -> bar() -> baz()`) now writes its stdout to its `.out` capture while still piping the same bytes into the next stage. The previous inherit-fd path left `bar.out` empty so the next child could take the pipe alone. The tee is chunk-by-chunk with backpressure — not a full-body string — so peak RSS still does not track the payload. Tests: `src/runtime/kernel/node-workflow-runtime.stdin.test.ts`, `src/runtime/kernel/node-workflow-runtime.slurp.test.ts`, `e2e/tests/153_stdin_pipeline_stream.sh`, `e2e/tests/110_examples.sh`.
+- **Language / Runtime — recover handle merges stdout and stderr:** a `recover` / `catch` binding is now an output handle for the failed step's **stdout then stderr**, merged into one on-disk capture, instead of stdout only. A force site (`logerr "${failure}"`, a call argument, an `if` subject) slurps the merged contents (trimmed the same way a stdout handle is), and `stdin failure -> script()` streams them; the recovery body still never sees a `.jaiph/runs/…/*.out` path. A typical Unix failure that writes its useful text to stderr therefore yields a non-empty `${failure}` even when the producer never did `2>&1` (that redirect stays legal and interleaves the two streams chronologically when the author wants it). A **successful** call's handle is unchanged — still stdout only — so `const x = foo()` and `stdin foo() -> bar()` do not grow stderr. `NodeWorkflowRuntime.runRecoverBody` writes the merged capture via a new `writeRecoverMerge` (concatenated on disk in bounded chunks, never slurped into one JS string), and there is no new `.jh` keyword, no `read()`, and no size cap. Docs: [Language — catch and recover](docs/language.md#catch-and-recover). Tests: `src/runtime/kernel/node-workflow-runtime.slurp.test.ts`, `src/runtime/kernel/node-workflow-runtime.artifacts.test.ts`, `src/runtime/kernel/node-workflow-runtime.spawn-failure.test.ts`, `e2e/tests/101_ensure_recover_output_contract.sh`, `e2e/tests/102_engineer_recover_contract.sh`.
+- **Language / Runtime — `prompt x` streams a handle:** a `prompt` body that is exactly an output handle — the identifier form `prompt x` or the bare-ref form `prompt ${x}` — now keeps the handle and streams its on-disk bytes into the agent transport instead of reading the whole file into a JavaScript string. The agent receives the handle's **contents**, never a `.jaiph/runs/…/*.out` path, so a multi-megabyte handle no longer grows jaiph's resident memory or risks an out-of-memory before the backend runs. This is the same keep-as-handle rule as `stdin -> script()`, not a new syntax. An interpolated body still forces the slurp: `prompt "… ${x} …"` and `prompt """… ${x} …"""` build a string, and a named-prompt argument `prompt analyze(x)` is a call argument, so each reads the handle into a string as before. The stdin backends (claude and custom commands) pipe the handle file straight into the child; the codex HTTP backend reads the file once at its own send site because it must buffer a JSON body; only real `cursor-agent`, which takes the prompt on argv, materializes the handle like any argv value (bounded by `ARG_MAX`). The `Prompt:` transcript writes `` for a handle-sourced body instead of the body or the capture path. `prompt-config.ts` gains a `PromptSource` type and `promptBodyOffArgv`; `executePrompt` and `runBackend` take an optional `promptSource`. There is no new `.jh` keyword, no `read()`, and no size cap. Docs: [Language — Value types](docs/language.md#value-types), [Language — `async` concurrent execution](docs/language.md#run-async-concurrent-execution-with-handles). Tests: `src/runtime/kernel/node-workflow-runtime.prompt-handle.test.ts`.
+- **Language / Runtime — `stdin` pipelines stream:** the script stages of a `stdin` pipeline (`stdin gen() -> upper() -> count()`) now run **concurrently** through bounded, backpressured buffers, instead of running one stage to completion, collecting its whole stdout into a JavaScript string, and only then spawning the next stage. Every script stage spawns in one tick, and each stage's live stdout is piped into the next stage's stdin, so producer and consumer overlap — a producer that prints `start`, sleeps, then prints `end` lets the consumer see `start` during the sleep. A def producer is not a single process, so it still runs to completion first and streams its on-disk output handle into the chain; a value producer resolves to bytes. No uncaptured stage's body is slurped into memory or spooled to a runtime-owned temp file and reread, so a 64 MiB (or larger) payload flows through the pipeline without growing jaiph's resident memory by the payload size, and a stage's `.out` capture is written straight to disk for audit, never also held as a string. A `const` or `return` still slurps the last stage (a reduce), so `const n = stdin gen() -> count()` yields the count as before. Because the stages overlap, the progress tree now nests each downstream stage under the one feeding it instead of listing them one after another. `runPipelineStage` gains an `onSpawn` hook that `spawnAndCapture`, `executeScript`, and `executeInlineScript` forward, and `StdinSource` gains a `stream` kind for a live upstream stdout. There is no stringify size cap; size does not change whether a pipeline succeeds. Docs: [Language — Arguments and stdin](docs/language.md#arguments-and-stdin). Tests: `src/runtime/kernel/node-workflow-runtime.stdin.test.ts`, `src/runtime/kernel/node-workflow-runtime.slurp.test.ts`, `e2e/tests/110_examples.sh`, `e2e/tests/153_stdin_pipeline_stream.sh`.
+- **Language / Runtime — `stdin` pipelines:** a `stdin` connect can now chain stages with `->` into a pipeline, as in `stdin gen() -> upper() -> count()`. The producer (left of the first `->`) is a value or a call to a def or script, and every stage after the first `->` is a script (named or inline). Each stage's output handle streams into the next stage's stdin, and each stage is its own step in the progress tree. If any stage exits non-zero the pipeline fails with that stage's error. A `const` or `return` slurps the last stage, so the last stage should reduce. A pipeline (a call producer, or two or more `->` stages) rejects `recover` (`E_PARSE`) but accepts a one-shot `catch`; a def in a consumer slot is `E_VALIDATE`, and `async` with `stdin` stays `E_PARSE`. A plain `stdin -> script()` connect (one value, one script) keeps `recover` and is unchanged. The landing page gains an agent-free `examples/stdin.jh` sample that generates lines, uppercases them, and reduces to a line count. Docs: [Language — Arguments and stdin](docs/language.md#arguments-and-stdin), [Grammar](docs/grammar.md). Tests: `src/parse/parse-run-stdin.test.ts`, `src/transpile/validate-run-stdin.test.ts`, `src/runtime/kernel/node-workflow-runtime.stdin.test.ts`, `e2e/tests/110_examples.sh`.
+- **Breaking — Language:** `run` is no longer a keyword; a managed call is a bare call. `save(path)` (was `run save(path)`), `const x = save(path)` / `return save(path)` (was `const x = run save(path)` / `return run save(path)`), `async save(path)` and `const h = async save(path)` (was `run async …`), `'echo hi'()` (was `` run `echo hi`() ``), nested `foo(bar())` (was `foo(run bar())`), interpolation capture `${greet()}` (was `${run greet()}`), and `send save(path) -> channel` (was `send run save(path) -> channel`). `catch` / `recover` attach to the bare call unchanged. `run save(path)` is now `E_PARSE` (the message tells the author to call the target directly, not to "use a script block"), while a symbol may still be named `run` so `run()` is a call of that symbol. `jaiph run` and `run.recover_limit` are unrelated and unchanged. The `stdin` connect form replaces the trailing `stdin` clause: `stdin content -> save(path)` (was `run save(path) stdin content`), also as `const out = stdin content -> save(path)` and `stdin content -> 'cat'()`, with the formatter canonicalizing the operand to `stdin "${content}" -> save(path)`. `async` combined with `stdin` (either order) is `E_PARSE`; a `stdin` destination that is a def or prompt is `E_VALIDATE`. Docs: [Language](docs/language.md), [Grammar](docs/grammar.md). Tests: `src/parse/parse-bare-call.test.ts`, `src/parse/parse-run-stdin.test.ts`, `src/transpile/validate-run-stdin.test.ts`, `src/format/emit.test.ts`, `src/runtime/kernel/node-workflow-runtime.stdin.test.ts`.
+- **Language / Runtime:** a standalone call of a **script** (named or inline) accepts a leading `stdin -> ` connect clause: `stdin content -> save_string_to_file(path)`. The evaluated string (a bare identifier, `${…}` interpolation, or double-quoted literal) is written to the child's stdin as UTF-8 instead of argv, so a payload larger than `ARG_MAX` (~1 MB on macOS) can be passed to a script for the first time. `stdin` is `E_VALIDATE` on a def / non-script target and `E_PARSE` when combined with `async`. `spawnAndCapture` grows a stdin parameter (default `ignore`); argv stays the default channel and is never auto-promoted. `.jaiph/lib_common.jh` `save_string_to_file` now reads `path = sys.argv[1]; content = sys.stdin.read()`, and its in-repo callers (`.jaiph/architect_review.jh`, `.jaiph/product_owner.jh`) pass the body via `stdin`. Editor grammars highlight `stdin` as a connect clause. Docs: [Language — Arguments and stdin](docs/language.md#arguments-and-stdin), [Grammar](docs/grammar.md), [Jaiph skill](docs/jaiph-skill.md). Tests: `src/parse/parse-run-stdin.test.ts`, `src/transpile/validate-run-stdin.test.ts`, `src/format/emit.test.ts`, `src/runtime/kernel/node-workflow-runtime.stdin.test.ts`.
+- **Plugins / Editors — every arrow in a stdin pipeline highlights:** a `stdin` pipeline is `stdin -> stage() -> stage()`, and all three highlighters now paint **every** connect `->` and **every** stage callee, not only the first hop. The VS Code `stdin-connect` rule was one match that captured the first `->` and the first consumer, so on `stdin gen() -> upper() -> count()` the second `->` was left unstyled; it is now a `begin` / `end` block that spans the rest of the line, so every `->` scopes `keyword.operator.send.jaiph`, every stage callee (a name before `(`) scopes `entity.name.function.jaiph`, and `stdin` stays `keyword.control.command.jaiph`. Zed (where `->` is `@operator` and a name before `(` is `@function`) and the landing highlighter (`docs/assets/js/main.js`, where every `->` is an arrow token and a name before `(` is a call) already painted later hops, and each now has a test that pins the multi-hop line so it cannot regress. The one-hop `stdin status -> shout(task)` keeps today's scopes, and `send "x" -> inbox` and `channel findings -> handler` still use the send and route scopes rather than the stdin-connect ones. This is highlight-only, with no change to the parser, validator, or runtime. Tests: `plugins/vscode/test/grammar.test.ts`, `plugins/zed/test/highlights.test.mjs`, `docs/assets/js/main.test.mjs`.
+- **Plugins / Editors:** the VS Code TextMate grammar, the Zed Tree-sitter queries, and the `tree-sitter-jaiph` grammar now highlight a managed call as a bare call — `save(path)`, `async save(path)`, and `'echo hello'()` — and the `stdin content -> save(path)` connect form, where `stdin` is a keyword and the connect `->` shares the same operator class as `send … ->`. `run` is no longer a keyword in any of the three grammars, so `run` in a `.jh` file tokenizes as an ordinary identifier rather than `keyword.control`; `jaiph run` inside a fenced bash or markdown block still highlights as a CLI command where those injections highlight the CLI. `tree-sitter-jaiph` gains corpus tests under `test/corpus/` and an `npm test` script (`tree-sitter generate && tree-sitter test`) that pin the bare-call, `async` bare-call, inline-script call, `stdin` connect, and `run`-as-identifier token shapes. Tests: `plugins/vscode/test/grammar.test.ts`, `plugins/zed/test/highlights.test.mjs`, `grammars/tree-sitter-jaiph/test/corpus/bare-calls.txt`.
+- **Plugins / Editors:** all three highlighters now paint the callee of every call as a function, including calls that are not at the start of a statement. A call is a name immediately followed by `(`, so a def call (`check_deps("package.json")`), a qualified call (`helpers.scan()`, where the last segment is the function), and an expression-position call (`const status = setup_env()`) all mark the callee. VS Code scopes the callee `entity.name.function.jaiph`; a qualified callee also gets `entity.name.namespace.jaiph` on the alias and `entity.name.function.jaiph` on the last segment. Zed captures the callee `@function` instead of `@variable`. The docs highlighter (`docs/assets/js/main.js`) applies the same paren rule anywhere on a line and wraps the callee in its `ralph-identifier` span. `run` is still not a keyword and never picks up call or function scope on its own, so `run save()` marks only `save`, and a lone `run` or `run.recover_limit` stays an identifier. A keyword before `(` such as `catch (err)` stays a keyword. The docs highlighter gains its first unit test, which drives the pure highlighter over `.jh` snippets under Node. Tests: `plugins/vscode/test/grammar.test.ts`, `plugins/zed/test/highlights.test.mjs`, `docs/assets/js/main.test.mjs`.
+- **Breaking — Language / Runtime:** every call result is now an **output handle**, not a string. A call still runs at its call site — this is lazy *slurp*, not lazy execution. The result's bytes stay on disk (or in a pipe) until a **force site** reads them into a JavaScript string, and `stdin` and an unused result do not force. Force sites slurp the handle: `const x = call()`, an `if` / `match` subject, `${x}` interpolation, a call argument, `log` / `logerr` / `logwarn`, and a `prompt` body that references a handle. These keep the handle with no in-memory string: a statement call whose result is unused (`'echo hi'()`), `stdin -> script()`, the one-hop producer form `stdin () -> script()` (the producer may be a def or a script), and printing an entry def's `return ()` (its bytes are copied to `return_value.txt` at the OS level, never pulled through V8). `const x = 'echo hi'()` is the string `hi` — there is no `read()` keyword and no byte cap. A statement call or a `stdin` stream that moves 64 MiB does not grow jaiph's resident memory by 64 MiB, because `spawnAndCapture` no longer concatenates each chunk into a JS string (`output += chunk` is gone); `const x = big()` does slurp the whole result and may exhaust memory, which is you asking for the bytes.
+ **Recover / catch** bindings are the same output handle, now over the failed step's stdout capture, replacing the run-dir **path string** the binding held before (`NNNNNN-*.out` under `JAIPH_RUN_DIR`). `logerr "${failure}"` slurps the failed stdout **contents**, a call argument or `if` subject slurps the same way, and `stdin failure -> tail_log()` streams the bytes, so a multi-megabyte failed log never overflows the next script's `execve` argv (`ARG_MAX`). The recovery body never sees a `.jaiph/runs/…/NNNNNN-*.out` path. **Migration:** a body that treated the binding as a path and read it (`cat "${err}"` / `tail -n 200 "${err}"`) now gets the log contents directly from `${err}`; stream a large log into a script with `stdin err -> tail_log()` instead of passing the path. `StepResult` in `src/runtime/kernel/runtime-mock.ts` gains `valueFile` (the on-disk byte source a force site reads through the new `forceValue`) and a `streamed` flag; `NodeWorkflowRuntime` in `src/runtime/kernel/node-workflow-runtime.ts` gains the handle registry, `forceValue`, `createResolvedHandle` (the resolved recover handle), and a `StdinSource` that feeds a `stdin -> script()` child from `valueFile` on disk. The ban on `run foo() > file` and the absence of a `capture_to` form are unchanged. Docs: [Language — Value types](docs/language.md#value-types), [Language — catch and recover](docs/language.md#catch-and-recover), [Jaiph skill](docs/jaiph-skill.md), [Why Jaiph](docs/why-jaiph.md). Tests: `src/runtime/kernel/node-workflow-runtime.slurp.test.ts`, `src/runtime/kernel/node-workflow-runtime.artifacts.test.ts`, `src/transpile/validate-run-stdin.test.ts`, `integration/docs-output-handle.test.ts`, `integration/docs-skill-checklist.test.ts`, `e2e/tests/101_ensure_recover_output_contract.sh`, `e2e/tests/102_engineer_recover_contract.sh`, `e2e/tests/98_ensure_recover_value.sh`, `e2e/tests/137_inline_script_catch_recover.sh`.
+- **Fix — Runtime:** a `script` step whose spawn fails is now a failed step, not a vanished run. `spawnAndCapture` catches a synchronous throw from `spawn` and settles it the same way as the asynchronous `'error'` event, as a `status: 1` step with the error on stderr, so the Promise never rejects. An `E2BIG` failure (argv plus env exceed the OS `ARG_MAX`) maps to a stable `E_ARGV_TOO_LARGE: bytes (ARG_MAX exceeded)` marker that reports the attempted size, while other spawn failures keep their existing text, including the missing-interpreter message. `executeManagedStep` converts a throw from the step body into a `status: 1` step and still writes the capture files and `STEP_END`, and `runRoot` emits `RUN_END` and stops the heartbeat in a `finally`, so a run that throws while running the def body still ends with a terminal `RUN_END`. Because the failed step ends `status: 1`, a `recover` on that `run` now runs and `run.recover_limit` applies. Docs: [Architecture](docs/architecture.md). Tests: `src/runtime/kernel/node-workflow-runtime.spawn-failure.test.ts`.
+- **Factory — product owner:** `.jaiph/product_owner.jh` exposes `propose_task`, `pick_task`, `report_completed_task`, `task_details`, and `update_task` for `jaiph serve` / MCP. `pick_task` prefers the first `#dev-ready` task that is not `#in-progress` and may claim a later one. A reject or needs-work is a successful return (`accepted:` / `rejected:` / `needs-work:`), not an HTTP error. Canonical queue state is `${JAIPH_QUEUE_STATE}` or `.jaiph/queue-state.md`. `QUEUE.md` and `DONE.md` are generated views. `./start-product-owner.sh` runs serve in Docker with volume `jaiph-po-state` at `.jaiph/product-owner/` (queue + runs); host `.jaiph/runs` is tmpfs-masked and forwards `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN` (no `--env`; those are backend credentials, not `use` grants). Default is `--allow-anonymous`; set `JAIPH_SERVE_TOKEN` to require a bearer. Tests: `.jaiph/product_owner.test.jh`, `.jaiph/queue_ops.test.jh`.
+- **CLI — `jaiph serve` / `jaiph mcp`:** no authentication by default. `jaiph mcp` stdio has none (the parent client is the only caller). `jaiph serve` on loopback is open: every REST and `/mcp` caller is the `anonymous` principal. Set `JAIPH_SERVE_TOKEN` or OIDC to require a bearer. `--allow-anonymous` is only required to bind a non-loopback address with no token and no OIDC (and then prints a warning). Docs: [Serve defs over HTTP](docs/serve.md), [CLI](docs/cli.md), [MCP](docs/mcp.md). Tests: `integration/serve-server.test.ts`.
+- **Breaking — CLI — `jaiph serve`:** HTTP paths drop `/v1`. Invoke is `POST /{name}`. List and inspect are `GET /defs`, `GET /runs`, `GET /runs/{id}` (events, artifacts, cancel stay under `/runs/{id}/…`). `Location` is `/runs/{id}`. `POST /v1/defs/{name}/runs` and every `/v1/…` path are gone. OpenAPI and `/docs` match. Docs: [Serve defs over HTTP](docs/serve.md), [CLI](docs/cli.md). Tests: `src/cli/serve/handler.test.ts`, `src/cli/serve/openapi.test.ts`.
+- **CLI — `jaiph serve` / `jaiph mcp`:** startup logs `loading module graph…` / `module graph ready in Nms` and `reconstructed N run(s) … in Nms`. Operator log writes with `writeSync` so Docker (no TTY) does not buffer the lines. Docs: [CLI](docs/cli.md).
+- **Docs:** `./docs/build-jaiph-dev-image.sh` compiles the linux standalones and tags a local `ghcr.io/jaiphlang/jaiph-runtime`. `docs/install-from-local.sh` still installs the host binary only.
+- **Release:** each `v*` tag and `nightly` push publishes `ghcr.io//jaiph-runtime` (`:` / `:latest` on stable, `:nightly` on nightly) from `runtime/Dockerfile`. The image has `jaiph`, `python3`, `git`, and `curl` as uid `10001`. It is not a GitHub Release asset. Docs: [Deploy jaiph](docs/deploy.md). Tests: `integration/release-workflow.test.ts`.
+
+# 0.14.0
+
+## Summary
+
+- **Language:** `def` and `run` replace `workflow` / `rule` / `ensure`. Names are private unless `export`. `jaiph run` needs `export def main`.
+- **Runtime:** No first-party Docker sandbox. Runs execute on the host. Isolate with your own container or CI runner ([Deploy jaiph](docs/deploy.md)).
+- **Scripts:** Sterile env. A script sees process basics, `JAIPH_*` contract keys, and host keys listed in `use` and granted with `--env`. Host presence alone is not a grant. `--env` values do not sit on the runner; a granted key reaches only a subprocess whose declaration `use`s it.
+- **Prompts:** Named, reusable `prompt name(params) [use KEY] = "…"`. A def may declare a local `script`, `def`, `prompt`, or `const`. Nested decls in `if` / `for` / `catch` / `recover` are block-scoped. A nested `def` may `run` itself.
+- **Channels:** `send payload -> channel`. The old `channel <- payload` form is gone.
+- **CLI:** `--env KEY` names a missing host value and how to pass it. `use` / `--env` cannot name the audit-chain key or journal path.
+- **Editors:** VS Code, Zed, and the docs highlighter cover `use`, `import script`, and named prompts.
## All changes
@@ -31,20 +73,6 @@
- **Fix — Language:** sequential `const` visibility now rejects use-before-declaration in every position, not only a `run` / `prompt` target. A `${…}` interpolation, a bare `run` / `prompt` call argument, and an `if` / `match` subject that names a `const` declared later in the same def are `E_VALIDATE` (unknown identifier). A nested `def` or named `prompt` body sees an enclosing `const` only when it was declared before that nested declaration; a `${…}` of a later enclosing `const` is `E_VALIDATE`, while the enclosing def's params and module-level `const`s stay visible. Runtime interpolation of a genuinely missing variable is still empty — the change is compile-time rejection. Previously these forms compiled and interpolated empty at runtime. Docs: [Language — `const`](docs/language.md#const--bind-a-value), [Language — Nested declarations](docs/language.md#nested-declarations). Tests: `src/transpile/validate-nested-decl.test.ts`, `e2e/tests/148_nested_decls.sh`.
-# 0.14.0
-
-## Summary
-
-- **Language:** `def` and `run` replace `workflow` / `rule` / `ensure`. Names are private unless `export`. `jaiph run` needs `export def main`.
-- **Runtime:** No first-party Docker sandbox. Runs execute on the host. Isolate with your own container or CI runner ([Deploy jaiph](docs/deploy.md)).
-- **Scripts:** Sterile env. A script sees process basics, `JAIPH_*` contract keys, and host keys listed in `use` and granted with `--env`. Host presence alone is not a grant.
-- **Prompts:** Named, reusable `prompt name(params) [use KEY] = "…"`. A def may declare a local `script`, `def`, `prompt`, or `const`.
-- **Channels:** `send payload -> channel`. The old `channel <- payload` form is gone.
-- **CLI:** `--env KEY` names a missing host value and how to pass it. `use` / `--env` cannot name the audit-chain key or journal path.
-- **Editors:** VS Code, Zed, and the docs highlighter cover `use`, `import script`, and named prompts.
-
-## All changes
-
- **UX — CLI:** a bare `--env KEY` whose value is unset on the host now says Jaiph requires that key and how to pass it (`--env KEY` or `--env KEY=VALUE`), instead of `no value given and KEY is not set on the host`.
- **Fix — Reserved keys:** `use` and `--env` reject `JAIPH_CHAIN_KEY` and `JAIPH_RUN_SUMMARY_FILE` (`E_ENV_RESERVED`). Those keys stay with the runner; a `use` grant can no longer put them on a script or agent after the prompt scrub.
- **Docs:** operator recipe for `run async` at [Run work concurrently](docs/async.md). The value model stays at [Async Handles](docs/spec-async-handles.md).
diff --git a/DONE.md b/DONE.md
new file mode 100644
index 00000000..f85b8a6f
--- /dev/null
+++ b/DONE.md
@@ -0,0 +1,4 @@
+# Done
+
+Append-only archive. Generated view. Do not edit.
+Each section was accepted by the product-owner `report_completed_task` def.
diff --git a/QUEUE.md b/QUEUE.md
index 31305541..cd48f693 100644
--- a/QUEUE.md
+++ b/QUEUE.md
@@ -1,15 +1,17 @@
# Jaiph Improvement Queue (Hard Rewrite Track)
-Process rules:
+This file is a generated view. Do not edit it. Do not agent-edit it.
+Use the product-owner defs (`propose_task`, `update_task`, `pick_task`,
+`report_completed_task`, `task_details`) via `jaiph serve` / MCP.
-1. Tasks are executed top-to-bottom.
-2. The first `##` section is always the current task.
-3. Task that is ready for implementation is marked with `#dev-ready` at the end of the header.
-4. When a task is completed, **orchestration** removes that section (`queue.remove_completed_task` in `.jaiph/engineer.jh`). Agents and humans implementing a task must **not** edit `QUEUE.md` to delete or rewrite the current task — leave queue updates to the workflow.
-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.
-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.
+Process rules:
-***
+1. `pick_task` prefers the first `#dev-ready` task that is not `#in-progress`.
+ The product owner may claim a later available task instead.
+2. The first `##` section is the preferred next task in this view.
+3. `#dev-ready` means ready to implement. `#in-progress` means claimed by `pick_task`.
+4. Runtime mutations go through the product-owner defs only.
+5. Every task must be standalone: no hidden assumptions, no "read prior task".
+6. Hard rewrite semantics: breaking changes are allowed unless a task says otherwise.
+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.
diff --git a/README.md b/README.md
index 1df2db98..e30aba04 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@
## Features
-- **Defs** — Compose `prompt`, `run`, channel sends, conditionals, `run async` with implicit join, `catch`, and repair-and-retry `recover`. `jaiph run` enters at `export def main`.
+- **Defs** — Compose `prompt`, bare calls, channel sends, conditionals, `async` calls with implicit join, `catch`, and repair-and-retry `recover`. `jaiph run` enters at `export def main`.
- **Scripts** — **`script`** steps run bash or polyglot code as subprocesses.
- **Agents** — Backends include Cursor, Claude, Codex (HTTP), or a custom `agent.command`.
- **Testing** — `*.test.jh` files run in-process (`jaiph test`) with mocks and `expect_*` assertions ([Write & run tests](docs/testing.md)).
@@ -77,13 +77,13 @@ npm install -g jaiph
In GitHub Actions, install a pinned CLI with the [`setup-jaiph`](actions/setup-jaiph/) composite action (same release binaries, no Node required on the runner):
```yaml
-- uses: jaiphlang/jaiph/actions/setup-jaiph@v0.13.0
+- uses: jaiphlang/jaiph/actions/setup-jaiph@v0.15.0
with:
- version: 0.13.0 # semver, a release tag, or 'nightly'
+ version: 0.15.0 # semver, a release tag, or 'nightly'
- run: jaiph --version # jaiph is now on PATH for later steps
```
-Verify: `jaiph --version`. Switch versions: `jaiph use nightly` or `jaiph use 0.13.0`.
+Verify: `jaiph --version`. Switch versions: `jaiph use nightly` or `jaiph use 0.15.0`.
Releases ship a `SHA256SUMS` file plus a detached [minisign](https://jedisct1.github.io/minisign/) signature (`SHA256SUMS.minisig`). The installer verifies the checksum and requires a valid signature. A missing `minisign` aborts the install on every host, including CI, rather than degrading to checksum-only. The `setup-jaiph` action installs `minisign` on the runner so CI installs stay signed. For a deliberate checksum-only install, set `JAIPH_ALLOW_UNSIGNED=1`. See [Verify the release signature](docs/setup.md#verify-the-release-signature).
@@ -103,17 +103,17 @@ Full flags and environment variables: [CLI](docs/cli.md), [Environment variables
```jaiph
#!/usr/bin/env jaiph
-script check_deps = `test -f "package.json"`
+script check_deps = 'test -f "package.json"'
def deps_exist() {
- run check_deps() catch (err) {
+ check_deps() catch (err) {
fail "Missing package.json"
}
}
export def main(task) {
- run deps_exist()
- const ts = run `date +%s`()
+ deps_exist()
+ const ts = 'date +%s'()
prompt "Build the application: ${task}"
}
```
diff --git a/actions/setup-jaiph/README.md b/actions/setup-jaiph/README.md
index 66e83bf4..d5a7446b 100644
--- a/actions/setup-jaiph/README.md
+++ b/actions/setup-jaiph/README.md
@@ -12,13 +12,13 @@ release artifacts.
```yaml
steps:
- - uses: jaiphlang/jaiph/actions/setup-jaiph@v0.13.0
+ - uses: jaiphlang/jaiph/actions/setup-jaiph@v0.15.0
with:
- version: 0.13.0 # semver, a release tag (v0.13.0), or 'nightly'
+ version: 0.15.0 # semver, a release tag (v0.15.0), or 'nightly'
- run: jaiph --version # jaiph is now on PATH for every later step
```
-Pin both the action (`@v0.13.0`) and the `version` input to an exact release for
+Pin both the action (`@v0.15.0`) and the `version` input to an exact release for
reproducible CI. Use `nightly` to track the rolling prerelease:
```yaml
@@ -31,7 +31,7 @@ reproducible CI. Use `nightly` to track the rolling prerelease:
| Input | Required | Default | Description |
|-----------|----------|-----------|-------------|
-| `version` | no | `nightly` | Version to install: a bare semver (`0.13.0`), a release tag (`v0.13.0`), or `nightly`. |
+| `version` | no | `nightly` | Version to install: a bare semver (`0.15.0`), a release tag (`v0.15.0`), or `nightly`. |
## Outputs
diff --git a/design/0003-docs-one-fact-one-owner.md b/design/0003-docs-one-fact-one-owner.md
new file mode 100644
index 00000000..2b32bd53
--- /dev/null
+++ b/design/0003-docs-one-fact-one-owner.md
@@ -0,0 +1,58 @@
+# ADR 0003 — One fact, one owner (docs)
+
+*Status: accepted*
+*Date (UTC): 2026-09-15*
+
+## Decision
+
+Each contract in the published docs has one owner page. That page states the full rule. Every other page that needs the fact uses one sentence and a link.
+
+The docs are not too many pages. They are too many copies of the same contract. A one-line behavior change must not require a sweep of eight files.
+
+## Why
+
+The published set is about 6,100 lines across 27 pages. Three pages teach the language (`language.md`, `grammar.md`, `jaiph-skill.md`). How-to pages such as `mcp.md` and `serve.md` grew into inventories. `--env` grant rules are restated in `cli.md`, `env-vars.md`, `why-jaiph.md`, `language.md`, `script-env.md`, `jaiph-skill.md`, `agent-auth.md`, and `testing.md`.
+
+`integration/docs-structure.test.ts` caps a page at 500 body lines, which is today's maximum, so it does not prevent the copies. The task-N docs tests freeze the current page list. Merging pages would hide the owner. It would not remove the copies.
+
+## Owners
+
+| Fact | Owner |
+|---|---|
+| Syntax and EBNF | `docs/grammar.md` |
+| What a construct means | `docs/language.md` |
+| CLI flags and invocation | `docs/cli.md` |
+| Environment variable inventory | `docs/env-vars.md` |
+| Config keys and scopes | `docs/configuration.md` |
+| Why and trade-offs | `docs/why-jaiph.md` |
+| Agent authoring decisions and verification loop | `docs/jaiph-skill.md` |
+| How to do one job | the matching how-to |
+
+A how-to is numbered steps plus links. It does not restate an inventory. `async.md` and `spec-async-handles.md` stay a pair (recipe and model). `configure-backend.md` and `configuration.md` stay a pair (recipe and keys).
+
+`docs/jaiph-skill.md` owns the agent decision procedure: how to divide work among native Jaiph, scripts, prompts, and channels, then how to verify the result. It may carry one compact example and high-value guardrails, but it is not a third language book. Syntax and construct semantics stay on their owner pages. The skill ships inside the binary (`src/runtime/embedded-assets.ts`), so its size is paid by every `jaiph init` and every standalone build.
+
+`docs/architecture.md` is the implementation map for contributors. Validator internals, visitor tables, and file-size justifications belong there or in `docs/contributing.md`, not on user how-tos.
+
+## What is out
+
+- Merging pages so the tree looks smaller
+- Another accuracy sweep that retouches every page that mentions a rule
+- A new how-to whose fact already has an owner
+- Growing `architecture.md` with more validator essays
+- Lowering the 500-line cap in the same change as a content cut. Lower the cap after the copies are gone (how-to 150, reference 350).
+
+## Product filter
+
+New docs prose lands only if it states a fact that has no owner yet, or it is a step in a how-to, or it is a one-sentence link to the owner.
+
+A change that restates an owned fact is a reject. Point at the owner instead.
+
+## Consequences
+
+- `docs/agent-analyzability.md` points here as the docs ownership rule.
+- `docs/jaiph-skill.md` stays a compact, operational checklist and keeps syntax and semantic detail in `language.md` and `grammar.md`.
+- `docs/grammar.md` keeps EBNF, lexical rules, and the validation catalog. Semantic tables move out or become a sentence plus a link to `language.md`.
+- How-tos such as `mcp.md`, `serve.md`, `observability.md`, and `agent-auth.md` drop restated inventories.
+- `--env` has one essay, on `docs/env-vars.md`. `cli.md` and `why-jaiph.md` keep one sentence and a link.
+- Queue tasks that implement this ADR are standalone. Each task names the files it may edit and the files it must not edit.
diff --git a/docs/Gemfile b/docs/Gemfile
index a01dbe4c..6a0e47f0 100644
--- a/docs/Gemfile
+++ b/docs/Gemfile
@@ -1,7 +1,7 @@
source "https://rubygems.org"
# Ruby 4.0+ bundles some stdlib as gems; Jekyll 3 / Liquid / safe_yaml still require them.
gem "base64"
-gem "bigdecimal"
+gem "bigdecimal", '>= 4.1.3'
gem "jekyll", "~> 3.9"
gem "kramdown-parser-gfm"
gem "jekyll-relative-links"
diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock
index f79d99a2..906daa0c 100644
--- a/docs/Gemfile.lock
+++ b/docs/Gemfile.lock
@@ -4,7 +4,7 @@ GEM
addressable (2.8.9)
public_suffix (>= 2.0.2, < 8.0)
base64 (0.3.0)
- bigdecimal (4.1.2)
+ bigdecimal (4.1.3)
colorator (1.1.0)
concurrent-ruby (1.3.8)
csv (3.3.5)
@@ -77,7 +77,7 @@ PLATFORMS
DEPENDENCIES
base64
- bigdecimal
+ bigdecimal (>= 4.1.3)
concurrent-ruby (>= 1.3.7)
jekyll (~> 3.9)
jekyll-redirect-from
diff --git a/docs/agent-analyzability.md b/docs/agent-analyzability.md
index 76b74c1b..e38539e8 100644
--- a/docs/agent-analyzability.md
+++ b/docs/agent-analyzability.md
@@ -75,7 +75,7 @@ Each package is a **deep module**: narrow public surface, large private capabili
### CLI slice isolation
-Treat these as vertical slices: `commands`, `run`, `serve`, `mcp`, `exec`, `telemetry`.
+Treat these as vertical slices: `commands`, `run`, `serve`, `mcp`, `exec`, `telemetry`. The `mcp` and `exec` slices no longer have their own directories (their code moved into `src/cli/shared`, see below), but the rule still reserves both names so a reintroduced private tree stays guarded.
**`commands` is the composition root.** It wires the other slices together (each `jaiph` subcommand launches its feature), so `commands` may import any slice's private tree, which is orchestration and not peer coupling.
@@ -101,9 +101,10 @@ No circular dependencies anywhere under `src/`. Cycles break the guarantee that
Docs obey the same budget discipline:
1. **One topic per file** (aligned with Diátaxis page types already in use).
-2. **Size cap.** Prefer pages agents can load whole, and split a page when it outgrows a single topic. This is enforced by `integration/docs-structure.test.ts`, which fails any non-allowlisted `docs/*.md` whose body exceeds 500 lines (front matter excluded). An oversized single-topic page goes on the test's `DOC_SIZE_ALLOWLIST` with a justification rather than merging topics.
+2. **Size cap.** Prefer pages agents can load whole, and split a page when it outgrows a single topic. This is enforced by `integration/docs-structure.test.ts`, which caps a page's body (front matter excluded) by its Diátaxis type: a how-to at 150 lines, a reference at 350, and a tutorial, explanation, or contributor page at 500. A single-owner reference that cannot fit its cap goes on the test's `DOC_SIZE_ALLOWLIST` with a justification rather than merging topics.
3. **Summary first.** Every page opens with a short summary so an agent can skip the body from the header alone. The same test requires the first body line after the H1 to be a prose lead paragraph (this page labels its lead `**Summary.**`), not a subheading, list, or table.
4. **Entry-point manifest.** The nav in `docs/_layouts/docs.html`, plus this page and [Architecture](architecture.md), are the structural maps. Do not bury contracts only in chat history or `QUEUE.md`.
+5. **One fact, one owner.** Each contract has one page that states the full rule. Every other page uses one sentence and a link. The owner table and the reject rule live in [ADR 0003](https://github.com/jaiphlang/jaiph/blob/main/design/0003-docs-one-fact-one-owner.md). New prose that restates an owned fact is a reject.
## Enforcement (CI)
@@ -114,7 +115,7 @@ These are **guardrails**, not conventions. Violations fail CI.
| `dependency-cruiser` (`npm run arch:check`) | no cycles; the layer DAG (including `runtime` ↛ `cli`); deep imports past the parse public entry (`no-deep-imports-into-parse`), the transpile public entry (`no-deep-imports-into-transpile`), the runtime public entry (`no-deep-imports-into-runtime`), and the format public entry (`no-deep-imports-into-format`); cross-CLI-slice private imports (`no-cross-cli-slice-imports`). Every layer now sits behind a public-entry gate, and the committed known-violations baseline (`.dependency-cruiser-known-violations.json`) is empty, so no cycles, upward imports, deep imports, or cross-slice edges remain tracked |
| ESLint (`npm run lint`) | `import/max-dependencies` and `max-lines` on `src/**/*.ts`. Most former violators were split into sibling modules and now pass under the global caps with no override; the four largest remaining files keep a per-file override in `eslint.config.mjs`, each with a fresh justification |
| Existing grep/shape tests | e.g. transpile ↛ runtime, trivia isolation, file-size caps on specific hot files |
-| Docs structure tests | Diátaxis front matter, nav bijection, link resolution, summary-first lead, and a 500-line body cap (`integration/docs-structure.test.ts`) |
+| Docs structure tests | Diátaxis front matter, nav bijection, link resolution, summary-first lead, and per-Diátaxis body-line caps of 150 (how-to), 350 (reference), and 500 (tutorial, explanation, contributor) (`integration/docs-structure.test.ts`) |
**Baseline policy.** If the tree already violates a new rule, do **not** weaken the rule. Commit a dependency-cruiser known-violations baseline (and an explicit ESLint grandfather list) so **new** violations fail while old ones are tracked. Follow-up work removes baseline entries; it does not relax severity.
diff --git a/docs/agent-auth.md b/docs/agent-auth.md
index 2b76af07..c8668d49 100644
--- a/docs/agent-auth.md
+++ b/docs/agent-auth.md
@@ -6,34 +6,11 @@ diataxis: how-to
# Authenticate agent backends
-This guide shows how to set the credentials each agent backend needs, so the CLI's credential pre-flight passes and `prompt` steps can reach the model.
-
-`jaiph run` runs a host-side credential pre-flight before it spawns the runner. The pre-flight checks the backends the entry file declares. A missing `codex` credential is a hard failure with the error `E_AGENT_CREDENTIALS`, and the run stops before any runner is launched. A missing `cursor` credential produces only a `jaiph: warning:` line and the run still proceeds. `claude` is not checked — a stored Claude CLI login is the host path. The behavior is implemented in `src/cli/run/preflight-credentials.ts`.
+This guide sets the credentials each agent backend needs, so the CLI's credential pre-flight passes and `prompt` steps can reach the model. `jaiph run` runs a host-side pre-flight before it spawns the runner: a missing `codex` credential is a hard failure (`E_AGENT_CREDENTIALS`) that stops the run, a missing `cursor` credential only warns, and `claude` is not checked. The full pre-flight scope-and-dedup rules live in [Configuration — Credential pre-flight](configuration.md#credential-pre-flight), and the credential names live in [Environment variables — Agent credentials](env-vars.md#agent-credentials).
## Prerequisites
-- The entry `.jh` file declares a backend in a `config { }` block (`agent.backend = "claude" | "cursor" | "codex"`) at module or def scope, or uses a `prompt` step that consumes the default backend.
-
-## Pick the backend's credential
-
-| Backend | Required credentials | Host behaviour |
-|---|---|---|
-| `claude` | `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN` (or stored Claude CLI login) | not checked |
-| `cursor` | `CURSOR_API_KEY` | warn only (a stored `cursor-agent login` may still work) |
-| `codex` | `OPENAI_API_KEY` | hard error `E_AGENT_CREDENTIALS` (no CLI-login fallback) |
-
-Set credentials on the host. Forward anything else one key at a time with `--env`.
-
-### Which backends get checked
-
-The pre-flight collects every backend the entry file could reach, which is each backend the entry file declares plus the effective default backend. The default is `cursor` unless `JAIPH_AGENT_BACKEND` overrides it, and it is always included because `prompt` steps that name no backend fall back to it. Each collected backend is then checked independently (`codex` errors, `cursor` warns, `claude` is skipped), so a file that reaches more than one checked backend can emit more than one warning or error in a single pre-flight.
-
-The default is deduplicated against your declarations, so where you set the backend decides whether the `cursor` default is also checked:
-
-- **Module scope.** Putting `config { agent.backend = "claude" }` at the top of the file makes `claude` the effective default, so only `claude` is selected — and Claude is not credential-checked.
-- **Def scope only.** Putting `config { agent.backend = "claude" }` inside a def, with no module-level backend, leaves `cursor` as the default. The pre-flight then checks `cursor` and skips `claude`.
-
-To check only the backend you intend to use, set it at module scope or export `JAIPH_AGENT_BACKEND`. Either one becomes the default and absorbs the extra check. See [Configure backend/model](configure-backend.md) for the config scopes.
+- The entry `.jh` file declares a backend in a `config { }` block (`agent.backend = "claude" | "cursor" | "codex"`) or uses a `prompt` step that consumes the default backend.
## 1. Authenticate Claude
@@ -50,7 +27,7 @@ claude setup-token
export CLAUDE_CODE_OAUTH_TOKEN="..."
```
-A stored `~/.claude` or macOS Keychain login from a previous interactive `claude` session also works. The pre-flight does not check Claude credentials.
+A stored `~/.claude` or macOS Keychain login from a previous interactive `claude` session also works. Claude credentials are not checked by the pre-flight.
## 2. Authenticate Cursor
@@ -58,7 +35,7 @@ A stored `~/.claude` or macOS Keychain login from a previous interactive `claude
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.
+For host runs, an interactive `cursor-agent login` (stored on disk) also satisfies the runtime, but the pre-flight warns unless `CURSOR_API_KEY` is set.
## 3. Authenticate Codex (OpenAI)
@@ -66,9 +43,7 @@ For host runs only, an interactive `cursor-agent login` (stored on disk) also sa
export OPENAI_API_KEY="sk-..."
```
-`OPENAI_API_KEY` is required. The `codex` backend has no CLI-login fallback, so there is no warning path.
-
-To target an OpenAI-compatible endpoint instead of the default, set `JAIPH_CODEX_API_URL` to the chat-completions URL.
+`OPENAI_API_KEY` is required; the `codex` backend has no CLI-login fallback, so there is no warning path. To target an OpenAI-compatible endpoint, set `JAIPH_CODEX_API_URL` to the chat-completions URL.
## 4. Run the pre-flight
@@ -76,25 +51,17 @@ To target an OpenAI-compatible endpoint instead of the default, set `JAIPH_CODEX
jaiph run ./flow.jh
```
-The pre-flight runs before the banner. A hard failure (`codex` only) prints a stderr message naming the backend, the model (when `agent.model` is set), the entry `.jh` file, the config scope that picked the backend (`module config`, `def `, `JAIPH_AGENT_BACKEND env`, or `default`), and the remedy. The message is prefixed with `E_AGENT_CREDENTIALS`. The host-only warning for `cursor` uses the same header fields with a `jaiph: warning:` prefix. `claude` produces neither.
-
-## Skip the pre-flight
-
-The pre-flight is skipped when the entry file neither declares an explicit backend nor uses any `prompt` step, because nothing would credential against.
-
-`jaiph run --raw` also skips the pre-flight.
+The pre-flight runs before the banner. A hard failure (`codex` only) prints a stderr message prefixed with `E_AGENT_CREDENTIALS`, naming the backend, the model when set, the entry file, the config scope that picked the backend, and the remedy. The `cursor` warning uses the same fields with a `jaiph: warning:` prefix; `claude` prints neither. `jaiph run --raw`, and a file that neither declares a backend nor uses a `prompt` step, skip the pre-flight entirely. `jaiph serve` and `jaiph mcp` run the same pre-flight once at startup but print every result — including the `codex` case — as a warning, so a server can start before its credentials are set.
## Verification
-When every required credential is present, the pre-flight is silent, with no stderr before the banner. A missing `cursor` env var emits a `jaiph: warning:` line and the run still proceeds, because a stored `cursor-agent login` may satisfy the runtime:
+When every required credential is present, the pre-flight is silent, with no stderr before the banner. A missing `cursor` env var emits a warning and the run still proceeds:
```
jaiph: warning: agent.backend "cursor" selected by module config in /path/to/flow.jh — CURSOR_API_KEY is not set. Set CURSOR_API_KEY (or run `cursor-agent login`). A stored cursor-agent login may still work.
```
-`claude` is silent even when `ANTHROPIC_API_KEY` and `CLAUDE_CODE_OAUTH_TOKEN` are unset.
-
-Only the `codex` backend hard-fails. When `OPENAI_API_KEY` is missing, the pre-flight prints this and the command stops before the banner:
+Only `codex` hard-fails. When `OPENAI_API_KEY` is missing, the pre-flight prints this and the command stops before the banner:
```
E_AGENT_CREDENTIALS: agent.backend "codex" selected by module config in /path/to/flow.jh — OPENAI_API_KEY is not set. Set OPENAI_API_KEY to your OpenAI API key.
diff --git a/docs/architecture.md b/docs/architecture.md
index 2ca89a87..c5d6061d 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -49,15 +49,15 @@ The `src/` import graph is an acyclic layered DAG: parse/format → transpile
- **Parser (`src/parser.ts`, `src/parse/*`)**
- Converts `.jh`/`.test.jh` into a **semantic AST** (`jaiphModule`) plus a parallel **`Trivia`** store of source-fidelity data. `parsejaiphWithTrivia(source, filePath)` returns `{ ast, trivia }`; the legacy `parsejaiph(source, filePath)` is a thin wrapper that returns only the `ast` for consumers that don't need round-trip data. Both entry points are I/O-pure.
- **Public entry.** Code outside the parse package imports the parse slice only through `src/parser.ts`, which re-exports a curated public API (the two parse entry points plus named helpers such as `configValueHasInterpolation`, `canonicalizeTripleQuotedString`, `resolveInterpreterFromShebang`, and `createTrivia`). It is not an `export *` barrel of the tree. The `no-deep-imports-into-parse` rule in `.dependency-cruiser.cjs` fails any outside import that reaches a `src/parse/**` internal directly. Add a named re-export to `src/parser.ts` instead of reaching in. The string-content validators that both parse and transpile need (`validateJaiphStringContent`, `extractInlineCaptures`) live under the parse package in `src/parse/validate-string-content.ts` and are re-exported through `src/parser.ts`, so `src/transpile/validate-string.ts` imports them from the public entry and no deep import into the parse package is baselined.
- - Reusable primitives: `parseFencedBlock()` (`src/parse/fence.ts`) handles triple-backtick fenced bodies with optional lang tokens for scripts and inline scripts; `parseFencedScriptBlock()` wraps it with common-margin dedent for executable script bodies. `parseTripleQuoteBlock()` (`src/parse/triple-quote.ts`) handles `"""..."""` blocks for prompts, `const`, `log`, `logerr`, `fail`, `return`, and `send` — all positions where multiline strings appear. `canonicalizeTripleQuotedString()` (same file) reproduces the dedent + escape decoding that match-arm bodies still need (they carry an unprocessed `tripleQuoteBodyToRaw`-shaped string plus a `tripleQuotedBody` flag rather than being dedented at parse time); both the validator and the runtime call it, so "what the validator inspects" and "what the runtime executes" are bit-for-bit identical.
- - **Unified `run` host parsing.** `run ref(...)` and `run async ref(...)`, optionally followed by `catch (binding) { ... }` or `recover(binding) { ... }`, are parsed by `parseRun` in `src/parse/workflow-brace.ts`. The attached `catch` / `recover` clause — bindings, body shape (multi-line `{ … }`, inline `{ stmt[; stmt]* }`, or single-statement) — is parsed by **one** helper `parseAttachedBlock(filePath, lines, idx, …, keyword, textAfterKeyword, trivia)` in `src/parse/workflow-brace.ts` (it was merged into that file to break the former `steps.ts` to `workflow-brace.ts` import cycle). There is no separate mini parser for catch/recover bodies: `parseAttachedBlock` delegates each body statement to the **same** `parseBlockStatement` (also in `src/parse/workflow-brace.ts`) that handles top-level statements, so every statement form accepted in a `def` body is accepted identically inside a `catch` / `recover` body. `src/parse/parse-attached-block.test.ts` asserts that catch/recover bodies parse identically to top-level statements and that no function named `parse(Run)?(Catch|Recover|EnsureStep)` reappears. A `STATEMENT` tombstone (`tryParseEnsureRemoved`) rejects leftover `ensure` with `'ensure' is not a keyword; use 'run'`.
- - **Keyword dispatch table.** Inside `parseBlockStatement` (`src/parse/workflow-brace.ts`), every `def` body line that does not begin with `#` is routed by a single `STATEMENT: Record` table keyed by the leading identifier — there is no longer a `startsWith` cascade where `"run async "` must be tested before `"run "` and `"prompt "` must be tested before a bare assignment. The dispatcher tokenizes the first identifier on the trimmed line, looks it up once, and invokes the matching handler (`tryParseIf` / `tryParseFor` / `tryParseConst` / `tryParseFail` / `tryParseRun` / `tryParsePrompt` / `tryParseLog` / `tryParseLogerr` / `tryParseLogwarn` / `tryParseReturn` / `tryParseStandaloneMatch` / `tryParseSend` / `tryParseElseError`, plus tombstones `tryParseWait` (`"wait" has been removed from the language`) and `tryParseEnsureRemoved` (`'ensure' is not a keyword; use 'run'`)), which either returns a `{ step, nextIdx }` result, returns `null` to fall through, or calls `fail(...)` to abort. Two non-keyword fallbacks fire after the table lookup in order: `tryLegacySend` (removed `channel <- payload`) then `shellFallthrough` (everything else becomes a shell `exec` step). Assignment-shape error guards (`name = prompt …`, `name = run …` without `const`) run once before dispatch in `applyAssignmentGuards(c)`. The per-line context (`filePath`, `lines`, `idx`, `innerRaw`, `inner`, `innerNo`, `trivia`, `opts`) is threaded through handlers as a single `BlockCtx` record. **Adding a new top-level keyword is a two-file change:** one row in `STATEMENT` (`workflow-brace.ts`) plus one entry in the `JAIPH_KEYWORDS` reserved set (`core.ts`) — pinned by `src/parse/parse-synthetic-keyword.test.ts`, which patches `STATEMENT` at runtime with a synthetic `zzznoop` handler, asserts dispatch fires, asserts the same input falls through to the shell handler when the row is removed, and greps both source files to confirm each symbol lives in exactly one place. Every existing parse-error message, line, and column is preserved bit-for-bit: `src/parse/parse-error-snapshot.test.ts` walks every `=== name` block in `test-fixtures/compiler-txtar/parse-errors.txt`, captures `{ file, line, col, code, message }` for each, and diffs against the snapshot stored at `test-fixtures/compiler-txtar/parse-errors-snapshot.json` (refreshable with `UPDATE_SNAPSHOTS=1` only after confirming the change is intentional). The wider tokenizer rewrite — the ad-hoc `inDoubleQuote` / `inTripleQuote` / `braceDepth` scanners replicated across `src/parse/`, the line-walking `{ step, nextIdx }` contract, and the per-handler regex bodies — is **not** part of this refactor and remains future work.
+ - Reusable primitives: `parseFencedBlock()` (`src/parse/fence.ts`) handles triple-single-quote fenced bodies (`'''`…`'''`) with optional lang tokens for scripts and inline scripts; `parseFencedScriptBlock()` wraps it with common-margin dedent for executable script bodies. Triple backticks and one-line backticks are `E_PARSE`. One-line script bodies use `'…'`. `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 call host parsing.** The invoke form is a bare call. `ref(...)`, `async ref(...)`, and the `stdin -> ref()` connect form, optionally followed by `catch (binding) { ... }` or `recover(binding) { ... }`, are parsed by `parseCallStatement` in `src/parse/workflow-brace.ts` (`parseStdinConnect` handles the connect prefix). The attached `catch` / `recover` clause — bindings, body shape (multi-line `{ … }`, inline `{ stmt[; stmt]* }`, or single-statement) — is parsed by **one** helper `parseAttachedBlock(filePath, lines, idx, …, keyword, textAfterKeyword, trivia)` in `src/parse/workflow-brace.ts` (it was merged into that file to break the former `steps.ts` to `workflow-brace.ts` import cycle). There is no separate mini parser for catch/recover bodies: `parseAttachedBlock` delegates each body statement to the **same** `parseBlockStatement` (also in `src/parse/workflow-brace.ts`) that handles top-level statements, so every statement form accepted in a `def` body is accepted identically inside a `catch` / `recover` body. `src/parse/parse-attached-block.test.ts` asserts that catch/recover bodies parse identically to top-level statements and that no function named `parse(Run)?(Catch|Recover|EnsureStep)` reappears. Two `STATEMENT` tombstones reject the removed keywords: `tryParseEnsureRemoved` rejects leftover `ensure` with `'ensure' is not a keyword; call the target directly (e.g. name(args))`, and `tryParseRunKeywordRemoved` rejects a leading `run` (`run name(args)`) with `'run' is not a keyword; call the target directly (e.g. name(args)) instead of 'run name(args)'`, while a bare `run(...)` still calls a symbol literally named `run`.
+ - **Keyword dispatch table.** Inside `parseBlockStatement` (`src/parse/workflow-brace.ts`), every `def` body line that does not begin with `#` is routed by a single `STATEMENT: Record` table keyed by the leading identifier — there is no longer a `startsWith` cascade where `"async "` had to be tested before a bare call and `"prompt "` 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` / `tryParseAsync` / `tryParseStdin` / `tryParsePrompt` / `tryParseLog` / `tryParseLogerr` / `tryParseLogwarn` / `tryParseReturn` / `tryParseStandaloneMatch` / `tryParseSend` / `tryParseElseError`, plus tombstones `tryParseWait` (`"wait" has been removed from the language`), `tryParseEnsureRemoved` (`'ensure' is not a keyword; …`), and `tryParseRunKeywordRemoved` (`'run' is not a keyword; …`)), which either returns a `{ step, nextIdx }` result, returns `null` to fall through, or calls `fail(...)` to abort. Three non-keyword fallbacks fire after the table lookup in order: `tryBareCall` (a bare `ref(args)` / inline-script call), `tryLegacySend` (removed `channel <- payload`), then `shellFallthrough` (everything else becomes a shell `exec` step). Assignment-shape error guards (`name = prompt …`, and a non-`const` capture whose right-hand side looks like an invoke such as `name = ref(...)`) run once before dispatch in `applyAssignmentGuards(c)`. The per-line context (`filePath`, `lines`, `idx`, `innerRaw`, `inner`, `innerNo`, `trivia`, `opts`) is threaded through handlers as a single `BlockCtx` record. **Adding a new top-level keyword is a two-file change:** one row in `STATEMENT` (`workflow-brace.ts`) plus one entry in the `JAIPH_KEYWORDS` reserved set (`core.ts`) — pinned by `src/parse/parse-synthetic-keyword.test.ts`, which patches `STATEMENT` at runtime with a synthetic `zzznoop` handler, asserts dispatch fires, asserts the same input falls through to the shell handler when the row is removed, and greps both source files to confirm each symbol lives in exactly one place. Every existing parse-error message, line, and column is preserved bit-for-bit: `src/parse/parse-error-snapshot.test.ts` walks every `=== name` block in `test-fixtures/compiler-txtar/parse-errors.txt`, captures `{ file, line, col, code, message }` for each, and diffs against the snapshot stored at `test-fixtures/compiler-txtar/parse-errors-snapshot.json` (refreshable with `UPDATE_SNAPSHOTS=1` only after confirming the change is intentional). The wider tokenizer rewrite — the ad-hoc `inDoubleQuote` / `inTripleQuote` / `braceDepth` scanners replicated across `src/parse/`, the line-walking `{ step, nextIdx }` contract, and the per-handler regex bodies — is **not** part of this refactor and remains future work.
- **AST / Types (`src/types.ts`)**
- Shared compile-time schema (`jaiphModule`, step defs, test defs, hook payload types). The semantic AST carries **only** what the validator, emitter, transpiler, and runtime need; surface-form data that exists purely to round-trip the formatter (leading comments on imports / channels / `const` / `test` blocks, top-level emit order, `config` body sequence, `"""..."""` flags on `literal` / `return` / `log` / `logerr` / `fail` / `send` / `const`, the `bareSource` of `return `, and prompt / script `bodyKind` discriminators) lives in **`Trivia`** instead — see [Trivia (CST layer)](#trivia-cst-layer).
- - **One `Expr` for every value position.** Anywhere a value can appear — `const name = …`, `return …`, `send … -> channel`, `log` / `logerr` / `fail` arguments, and the body of an `exec` statement — the AST stores a single tagged union: `Expr = literal | call | inline_script | prompt | match | shell | bare_ref`. There is **no longer** a separate `ConstRhs` union, `SendRhsDef` union, or `managed:` sidecar on `return` / `log` / `logerr` (the placeholder strings `"__match__"` / `"run inline_script"` / `"__JAIPH_MANAGED__"` are gone too — a meta-test in `src/types-shape.test.ts` fails if any reappear under `src/`). The seven `Expr` kinds: `literal` (verbatim source text — quoted string, `$var` / `${var}` form, or post-dedent triple-quoted body), `call` (managed def/script call; `async: true` for `run async ref(...)` capture position), `inline_script` (`` `body`(args) `` or fenced), `prompt` (carries the JSON-quoted body and optional flat `returns` schema), `match` (a `match { ... }` evaluated for its value), `shell` (raw shell fragment used as a managed substitution on the send RHS), and `bare_ref` (bare symbol on a send RHS — always rejected by the validator, preserved so the error message can name the symbol).
- - **Nine `StepDef` variants**: `exec` (side-effecting managed call statement — `run` / `prompt` / standalone `match` / inline `shell`; the discriminator now lives inside `body.kind`, with `captureName` / `catch` / `recover` as step-level attributes); `const`, `return`, `send` (bind, propagate, or emit an `Expr`); `say` (was `log` / `logerr` / `logwarn` / `fail` — `level: "fail"` aborts the workflow with the message, otherwise the message is written to the corresponding stream); `if` / `for_lines` (control flow, unchanged shape); `local_decl` (a nested `script` / `def` / named `prompt` declaration local to the enclosing def — a nested `const` stays a `const` step); `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 the step count at **9** and the `Expr` kind count at **7**.
- - **Call arguments are a typed sum.** Every call-bearing `Expr` (`call`, `inline_script`) carries `args?: Arg[]` where `Arg = { kind: "literal"; raw: string } | { kind: "var"; name: string }`. The parser classifies each argument once (a bare identifier or bare `IDENT.IDENT` typed-prompt field access becomes `var`; everything else — quoted strings, nested `run …` / `run …` calls, inline-script bodies, and illicit unquoted `${…}` forms — is stored as `literal`). There is no separate `args: string` text payload or shadow `bareIdentifierArgs: string[]` field, and no downstream consumer re-parses call arguments: the validator walks the typed list to enforce arity, reject nested unmanaged calls inside literals, reject unquoted `${…}` call args (`E_VALIDATE` — interpolation belongs inside strings; use bare `name` / `result.role`), resolve `var` refs against in-scope bindings (and dotted `var` names against typed-prompt schemas), and check `${var.field}` embedded inside quoted literal args; the emitter renders by mapping each `Arg` to its source form; the runtime turns `Arg[]` back into a runtime string via `argsToRuntimeString` (`var` → `${name}`, `literal` → raw) so the existing handle-resolution / interpolation path is unchanged.
+ - **One `Expr` for every value position.** Anywhere a value can appear — `const name = …`, `return …`, `send … -> channel`, `log` / `logerr` / `fail` arguments, and the body of an `exec` statement — the AST stores a single tagged union: `Expr = literal | call | inline_script | prompt | match | shell | bare_ref`. There is **no longer** a separate `ConstRhs` union, `SendRhsDef` union, or `managed:` sidecar on `return` / `log` / `logerr` (the placeholder strings `"__match__"` / `"run inline_script"` / `"__JAIPH_MANAGED__"` are gone too — a meta-test in `src/types-shape.test.ts` fails if any reappear under `src/`). The seven `Expr` kinds: `literal` (verbatim source text — quoted string, `$var` / `${var}` form, or post-dedent triple-quoted body), `call` (managed def/script call; `async: true` for the `async ref(...)` capture position), `inline_script` (`` 'body'(args) `` or fenced), `prompt` (carries the JSON-quoted body and optional flat `returns` schema), `match` (a `match { ... }` evaluated for its value), `shell` (raw shell fragment used as a managed substitution on the send RHS), and `bare_ref` (bare symbol on a send RHS — always rejected by the validator, preserved so the error message can name the symbol).
+ - **Nine `StepDef` variants**: `exec` (side-effecting managed call statement — a bare call / `prompt` / standalone `match` / inline `shell`; the discriminator now lives inside `body.kind`, with `captureName` / `catch` / `recover` as step-level attributes); `const`, `return`, `send` (bind, propagate, or emit an `Expr`); `say` (was `log` / `logerr` / `logwarn` / `fail` — `level: "fail"` aborts the workflow with the message, otherwise the message is written to the corresponding stream); `if` / `for_lines` (control flow, unchanged shape); `local_decl` (a nested `script` / `def` / named `prompt` declaration local to the enclosing def — a nested `const` stays a `const` step); `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 the step count at **9** and the `Expr` kind count at **7**.
+ - **Call arguments are a typed sum.** Every call-bearing `Expr` (`call`, `inline_script`) carries `args?: Arg[]` where `Arg = { kind: "literal"; raw: string } | { kind: "var"; name: string }`. The parser classifies each argument once (a bare identifier or bare `IDENT.IDENT` typed-prompt field access becomes `var`; everything else — quoted strings, nested `ref(...)` calls, inline-script bodies, and illicit unquoted `${…}` forms — is stored as `literal`). There is no separate `args: string` text payload or shadow `bareIdentifierArgs: string[]` field, and no downstream consumer re-parses call arguments: the validator walks the typed list to enforce arity, reject nested unmanaged calls inside literals, reject unquoted `${…}` call args (`E_VALIDATE` — interpolation belongs inside strings; use bare `name` / `result.role`), resolve `var` refs against in-scope bindings (and dotted `var` names against typed-prompt schemas), and check `${var.field}` embedded inside quoted literal args; the emitter renders by mapping each `Arg` to its source form; the runtime turns `Arg[]` back into a runtime string via `argsToRuntimeString` (`var` → `${name}`, `literal` → raw) so the existing handle-resolution / interpolation path is unchanged.
- **Trivia / CST layer (`src/parse/trivia.ts`)**
{: #trivia-cst-layer}
@@ -69,14 +69,14 @@ The `src/` import graph is an acyclic layered DAG: parse/format → transpile
- **Validator file split.** `validate.ts` owns the **outer** layer: import / channel-route / test-block checks plus the per-module loop that calls `validateDef` once per `def`. `validate-def-scope.ts` owns that recursive, block-scoped descent (see **Block-scoped def walk** below), and `validate-local-decl.ts` holds its nested-declaration helpers (`localDeclName`, `localSymFromDecl`, `refCtxWithLocals`). `validate-step.ts` owns the **per-step** visitor: one row per `StepDef.type` in a `VALIDATORS: Record` table. The value-level dispatcher `validateExpr` over the 7 `Expr.kind` values lives in `validate-expr.ts`, the call-shape / channel / string-content helpers live in `validate-step-helpers.ts`, and `validate-match.ts` and `validate-step-ctx.ts` sit alongside them. `validate.ts` is bounded at **≤700 lines** (currently ~207) by a CI-style test in `src/transpile/validate-visitor.test.ts`; new validators belong in `validate-step.ts`.
- **Visitor table + scope.** Per-step validation has one entry point — `validateStep(step, ctx)` in `validate-step.ts`. It looks the step's `type` up in `VALIDATORS` (the dispatch table), then consults `ctx.scope.allowSteps` (a `Set`) once to decide whether this step is permitted in the current scope. One scope exists: `DEF_SCOPE` (allows every step variant, including `send`; `prompt` is an `Expr.kind` inside an `exec` body, not a step type). The scope also carries `runRefExpect` (`RUN_TARGET_REF_EXPECT`) and `withPromptSchemas` (defs collect prompt-returning bindings). Adding a new step type requires exactly one row in `VALIDATORS` and, if the allowed set needs to differ, an entry in `Scope.allowSteps` — an `AC4` test in `validate-visitor.test.ts` injects a synthetic step type and asserts it produces exactly one diagnostic with the documented `internal: no validator for step type "…"` message until the row is added.
- **Single managed-call-shape helper.** Every `call` site runs the same five checks against the typed `Arg[]` directly — shell-redirection rejection (only `literal` args are scanned), nested-unmanaged-call rejection inside `literal` raws, ref resolution (with the scope's `runRefExpect`), arity (`args.length` vs declared params), and `var`-arg resolution against in-scope bindings via `validateArgVarRefs`. The sequence lives once in `validateCallable(expr, ctx)`. There is no longer a separate `validateBareIdentifierArgs` helper, no per-site repetition of the five-step sequence, and no place re-parses an `args: string` payload by splitting on commas or rescanning quotes.
- - **Diagnostics collector (recoverable errors).** The validator no longer fails fast on the first user-level error. Every recoverable check appends to a `Diagnostics` collector (`src/diagnostics.ts`) via `diag.error(file, line, col, code, msg)`, which records a `JaiphDiagnostic` and short-circuits the current validation unit through a `BailoutError`. Each top-level unit (per-import block, per-def walk, per-def step, per-test-block step, per-channel route) is wrapped in `diag.capture(fn)`, which absorbs the bailout (and any thrown `jaiphError` from leaf helpers like `validate-ref-resolution.ts` / `validate-string.ts` / `validate-prompt-schema.ts` / `shell-jaiph-guard.ts` / `parse/validate-string-content.ts`) so the next sibling unit still runs. `collectDiagnostics(graph)` walks every module and returns the populated collector; the legacy **`validateReferences(graph)`** is now a thin wrapper that throws the first sorted diagnostic via **`jaiphError`** so graph-level callers and existing per-error tests keep working; **`emitScriptsForModuleFromGraph`** still calls **`validateModule(ast, graph)`** per module before emit. `Diagnostics.sorted()` returns errors ordered by `(file, line, col)`; `formatLines()` renders the standard `path:line:col CODE message` shape. A grep test (`src/transpile/diagnostics-collector.test.ts`) pins the migration: `validate.ts` + `validate-step.ts` hold **zero** `throw jaiphError(` sites, and the remaining `throw jaiphError(` call sites under `src/` are confined to a documented allowlist — fatal aborts in the parser (`src/parse/core.ts`), the loader (`src/transpile/module-graph.ts`), and the test-file shape check (`src/cli/commands/test.ts`); the legacy bridge in `src/diagnostics.ts`; and the five leaf validation helpers above, each of which has every caller wrapped in `diag.capture(...)`.
- - The validator drives off `StepDef.type` (8 variants) and `Expr.kind` (7 variants). For every value-bearing step (`const` / `return` / `send` / `say`) and for the body of every `exec` step, a single `validateExpr(expr, ...)` dispatcher handles the value: it routes `call` / `inline_script` to call-site validation (`validateCallable`), walks `match` arms, schema-checks `prompt`, and runs the substitution scanner on `literal` raws. There is no dual code path for "managed sidecar vs literal value" — that branch is gone.
+ - **Diagnostics collector (recoverable errors).** The validator no longer fails fast on the first user-level error. Every recoverable check appends to a `Diagnostics` collector (`src/diagnostics.ts`) via `diag.error(file, line, col, code, msg)`, which records a `JaiphDiagnostic` and short-circuits the current validation unit through a `BailoutError`. Each top-level unit (per-import block, per-def walk, per-def step, per-test-block step, per-channel route) is wrapped in `diag.capture(fn)`, which absorbs the bailout (and any thrown `jaiphError` from leaf helpers like `validate-ref-resolution.ts` / `validate-string.ts` / `validate-prompt-schema.ts` / `shell-jaiph-guard.ts` / `parse/validate-string-content.ts`) so the next sibling unit still runs. `collectDiagnostics(graph)` walks every module and returns the populated collector; the legacy **`validateReferences(graph)`** is now a thin wrapper that throws the first sorted diagnostic via **`jaiphError`** so graph-level callers and existing per-error tests keep working; **`emitScriptsForModuleFromGraph`** still calls **`validateModule(ast, graph)`** per module before emit. `Diagnostics.sorted()' returns errors ordered by '(file, line, col)`; `formatLines()` renders the standard `path:line:col CODE message` shape. A grep test (`src/transpile/diagnostics-collector.test.ts`) pins the migration: `validate.ts` + `validate-step.ts` hold **zero** `throw jaiphError(` sites, and the remaining `throw jaiphError(` call sites under `src/` are confined to a documented allowlist — fatal aborts in the parser (`src/parse/core.ts`), the loader (`src/transpile/module-graph.ts`), and the test-file shape check (`src/cli/commands/test.ts`); the legacy bridge in `src/diagnostics.ts`; and the five leaf validation helpers above, each of which has every caller wrapped in `diag.capture(...)`.
+ - The validator drives off `StepDef.type` (9 variants) and `Expr.kind` (7 variants). For every value-bearing step (`const` / `return` / `send` / `say`) and for the body of every `exec` step, a single `validateExpr(expr, ...)` dispatcher handles the value: it routes `call` / `inline_script` to call-site validation (`validateCallable`), walks `match` arms, schema-checks `prompt`, and runs the substitution scanner on `literal` raws. There is no dual code path for "managed sidecar vs literal value" — that branch is gone.
- **No compile-time → runtime imports.** Nothing under `src/transpile/` may `import … from "…/runtime/…"`. Compile-time code must not depend on runtime semantics: when the validator needs the same canonical form the runtime will see (the dedented, escape-decoded view of a triple-quoted match-arm body), both sides import a parser-side helper (`canonicalizeTripleQuotedString` in `src/parse/triple-quote.ts`) rather than reaching across the layer. A grep test (`src/transpile/no-runtime-imports.test.ts`) scans every non-test `*.ts` under `src/transpile/` and fails if any `from "…/runtime/…"` import appears; a separate corpus test (`src/parse/canonicalize-triple-quoted.test.ts`) parses every `.jh` under `test-fixtures/` and `examples/`, collects every triple-quoted match-arm body, and asserts `canonicalizeTripleQuotedString` matches the pre-move `tripleQuotedRawForRuntime` output bit-for-bit.
- - **Block-scoped def walk.** Each `def` is validated by `validateDef` in `validate-def-scope.ts`, which descends the step tree once through a recursive `descend` helper. Every `if` / `else` / `else if` / `for` / `catch` / `recover` body is its own lexical scope (`LexScope`): a child scope holds only the `const`s, captures, `for_lines` iterators, and nested `script` / `def` / `prompt` decls declared so far in that body, and the set visible at a step is the union up the parent chain, with an inner scope shadowing an ancestor. A name declared inside a branch is therefore out of scope after the branch, so a later `run` / `prompt` / `${name}` / bare arg that names it is `E_VALIDATE` rather than a silent runtime miss when the branch is not taken. Immutable-binding and `script`-collision rules are enforced inline per scope (a nested decl may shadow a module script; a `const` or capture may not), the `if` and `else` bodies are two independent child scopes (so a name in each is two separate locals, not a rebind), and a nested `def` / `prompt` body closes over only the enclosing names visible at its declaration point. A `returns` prompt captured inside a branch types `${r.field}` only within that branch. `descend` is the **only** recursive `StepDef[]` walker, and it now lives in `validate-def-scope.ts`, not `validate.ts`. A pair of grep / AST tests (`src/transpile/validate-single-walk.test.ts`) still pin that the prior pre-pass helpers (`collectKnownVars`, `collectPromptSchemas`, `validateImmutableBindings`) cannot reappear in `validate.ts` and that at most one recursive `StepDef[]` walker lives there.
+ - **Block-scoped def walk.** Each `def` is validated by `validateDef` in `validate-def-scope.ts`, which descends the step tree once through a recursive `descend` helper. Every `if` / `else` / `else if` / `for` / `catch` / `recover` body is its own lexical scope (`LexScope`): a child scope holds only the `const`s, captures, `for_lines` iterators, and nested `script` / `def` / `prompt` decls declared so far in that body, and the set visible at a step is the union up the parent chain, with an inner scope shadowing an ancestor. A name declared inside a branch is therefore out of scope after the branch, so a later call / `prompt` / `${name}` / bare arg that names it is `E_VALIDATE` rather than a silent runtime miss when the branch is not taken. Immutable-binding and `script`-collision rules are enforced inline per scope (a nested decl may shadow a module script; a `const` or capture may not), the `if` and `else` bodies are two independent child scopes (so a name in each is two separate locals, not a rebind), and a nested `def` / `prompt` body closes over only the enclosing names visible at its declaration point. A `returns` prompt captured inside a branch types `${r.field}` only within that branch. `descend` is the **only** recursive `StepDef[]` walker, and it now lives in `validate-def-scope.ts`, not `validate.ts`. A pair of grep / AST tests (`src/transpile/validate-single-walk.test.ts`) still pin that the prior pre-pass helpers (`collectKnownVars`, `collectPromptSchemas`, `validateImmutableBindings`) cannot reappear in `validate.ts` and that at most one recursive `StepDef[]` walker lives there.
- **Transpiler (`src/transpiler.ts`, `src/transpile/*`)**
- **Public entry.** Code outside the transpile package imports the transpile slice only through `src/transpiler.ts`, the single public entry. It re-exports a curated public API (`buildScripts`, `buildScriptsFromGraph`, `emitScriptsForModule`, `emitScriptsForModuleFromGraph`, `collectDiagnostics`, `validateReferences`, `walkjhFiles`, `walkTestFiles`, `resolveImportPath`, `moduleSymbolForFile`, and the `ModuleGraph` / `ModuleNode` / `ScriptArtifact` types) plus the full module-graph API (`loadModuleGraph`, `readModuleGraph`, `writeModuleGraph`, `moduleGraphFromAsts`, `serializeModuleGraph`, `deserializeModuleGraph`). It is not an `export *` barrel of the tree. Runtime reuses the same graph and reaches the module-graph API through this entry too, so `src/transpile/module-graph.ts` is no longer a second door. The `no-deep-imports-into-transpile` rule in `.dependency-cruiser.cjs` fails any outside import that reaches a `src/transpile/**` internal directly, such as `module-graph.ts`, `validate.ts`, `build.ts`, or an `emit-*.ts` file. Add a named re-export to `src/transpiler.ts` instead of reaching in. No deep import into the transpile package is baselined: the string-content validators that `src/parse/metadata.ts` used to reach for now live under the parse package (`src/parse/validate-string-content.ts`), so no file under `src/parse/` imports `src/transpile/`.
- - **`emitScriptsForModuleFromGraph`** validates one module against the graph and runs **`buildScriptFiles`** to produce that module's `script` artifacts. This is the only compile path for `jaiph run` / `jaiph test`. The caller (`emitGraphInto` in `build.ts`) persists **only atomic `script` files** under `scripts/`. **`buildScripts(input, outDir, ws?)`** is the path-based wrapper used by tests and the directory walk; it loads a `ModuleGraph` and delegates. **`buildScriptsFromGraph(graph, outDir)`** is the graph-based entry point used by `jaiph run` / `jaiph test`, which already loaded the graph. Inline scripts (`` run `body`(args) ``) are also emitted as `scripts/__inline_` with deterministic hash-based names (`inlineScriptName` in `src/inline-script-name.ts`). There is no def-level bash emission.
+ - **`emitScriptsForModuleFromGraph`** validates one module against the graph and runs **`buildScriptFiles`** to produce that module's `script` artifacts. This is the only compile path for `jaiph run` / `jaiph test`. The caller (`emitGraphInto` in `build.ts`) persists **only atomic `script` files** under `scripts/`. **`buildScripts(input, outDir, ws?)`** is the path-based wrapper used by tests and the directory walk; it loads a `ModuleGraph` and delegates. **`buildScriptsFromGraph(graph, outDir)`** is the graph-based entry point used by `jaiph run` / `jaiph test`, which already loaded the graph. Inline scripts (`` 'body'(args) ``) are also emitted as `scripts/__inline_` with deterministic hash-based names (`inlineScriptName` in `src/inline-script-name.ts`). There is no def-level bash emission.
- The pipeline contract is **`loadModuleGraph` → `buildScriptsFromGraph(graph, outDir)`**, which runs **`validateModule`** + **`buildScriptFiles`** per reachable module via **`emitScriptsForModuleFromGraph`**. `parsejaiph` is I/O-pure; validation and script emit never re-read `.jh` sources during graph work. Each reachable module is parsed exactly once per `jaiph run` (see [Local module graph](#local-module-graph)).
- **Runtime public entry (`src/runtime/index.ts`)**
@@ -86,9 +86,9 @@ The `src/` import graph is an acyclic layered DAG: parse/format → transpile
- **Node Workflow Runtime (`src/runtime/kernel/node-workflow-runtime.ts`)**
- `NodeWorkflowRuntime` interprets the AST directly: walks workflow steps, manages scope/variables, delegates prompt and script execution to kernel helpers, handles channels/inbox/dispatch, owns the frame stack and heartbeat, and writes run artifacts.
- - **Script steps execute via an explicit interpreter, not the shebang + exec bit.** `executeScript` reads the emitted script's shebang line, resolves the interpreter through **`resolveInterpreterFromShebang`** (`src/parse/script-bash.ts`) — `#!/usr/bin/env ` → spawn ``, an absolute-path shebang → spawn that path, a missing shebang → default `bash` — and spawns ``. This is portable: it does not depend on the OS honoring the shebang (Windows honors neither shebang nor exec bit) or on the file's `0o755` bit (`noexec` mounts strip it). The shebang line is **still** written into every emitted script (they stay directly executable by hand on POSIX), but the runtime never relies on it being honored. A spawn `ENOENT` from a missing interpreter surfaces as a diagnosable Jaiph error naming the interpreter rather than a raw `ENOENT`.
+ - **Script steps execute via an explicit interpreter, not the shebang + exec bit.** `executeScript` reads the emitted script's shebang line, resolves the interpreter through **`resolveInterpreterFromShebang`** (`src/parse/script-bash.ts`) — `#!/usr/bin/env ` → spawn ``, an absolute-path shebang → spawn that path, a missing shebang → default `bash` — and spawns ``. This is portable: it does not depend on the OS honoring the shebang (Windows honors neither shebang nor exec bit) or on the file's `0o755` bit (`noexec` mounts strip it). The shebang line is **still** written into every emitted script (they stay directly executable by hand on POSIX), but the runtime never relies on it being honored. A spawn `ENOENT` from a missing interpreter surfaces as a diagnosable Jaiph error naming the interpreter rather than a raw `ENOENT`. A spawn can also fail before the child starts, either as a synchronous throw from `spawn` or as an asynchronous `'error'` event, and `spawnAndCapture` settles both the same way, as a `status: 1` failed step with the error on stderr, so the Promise never rejects and the run never aborts. When argv plus env exceed the OS `ARG_MAX` the spawn fails with `E2BIG`, and the step's stderr carries a stable `E_ARGV_TOO_LARGE: bytes (ARG_MAX exceeded)` marker that reports the attempted argv plus env size. Other spawn failures keep their existing text, including the missing-interpreter message.
- **Inline shell lines resolve their shell through one portable seam.** A single-line shell step (`executeShLine`) and CLI hook commands (`src/cli/run/hooks.ts`) both run under POSIX `sh -c`, but the shell itself is resolved through **`resolveShell()`** (`src/runtime/kernel/portability.ts`) rather than a hardcoded `spawn("sh", …)`. On POSIX this is bare `sh`; on **`win32`**, where there is no `sh` on the default `PATH`, it discovers Git for Windows' bundled `sh.exe` — first on `PATH`, then in the standard install layouts (`/bin/sh.exe`, `/usr/bin/sh.exe`) under each known root — memoizes the result for the process, and throws a diagnosable **`E_NO_POSIX_SHELL`** error naming Git for Windows if none is found. Inline lines are **never** translated to `cmd`/PowerShell: Jaiph's shell semantics are POSIX `sh` on every platform, so the seam only ever chooses *which* `sh` to invoke, never rewrites the command — otherwise workflows would stop being portable. `resolveShell()` is the single call site for the POSIX shell; no other `spawn("sh", …)` remains in `src/`.
- - One private `evaluateExpr(scope, expr, …)` dispatcher handles every value position — `const` / `return` / `send` / `say` step handlers and the body of every `exec` step delegate to it. It switches on `Expr.kind` to run the managed call (`call` / `inline_script`) or `prompt`, walks a `match` expression, or interpolates a `literal` value through `interpolateWithCaptures`. There is no fan-out across "managed sidecar vs literal value" because that branch is gone from the AST. `interpolateWithCaptures` takes an optional `quoteValue` escaper: shell-fallthrough lines pass **`shellQuote`** (defined in `src/runtime/kernel/prompt-config.ts` and re-exported through `prompt.ts`, the single canonical escaper) so every interpolated value — parameter, capture, `for` iterator, channel payload, and inline `${run …}` / `${run …}` capture result — is shell-quoted before it reaches `sh -c`, while every other value position interpolates the raw value. This is the one `sh -c` interpolation sink, so a caller-controlled value bound through `jaiph mcp` / `jaiph serve` cannot inject a command (finding H-1).
+ - One private `evaluateExpr(scope, expr, …)` dispatcher handles every value position — `const` / `return` / `send` / `say` step handlers and the body of every `exec` step delegate to it. It switches on `Expr.kind` to run the managed call (`call` / `inline_script`) or `prompt`, walks a `match` expression, or interpolates a `literal` value through `interpolateWithCaptures`. There is no fan-out across "managed sidecar vs literal value" because that branch is gone from the AST. `interpolateWithCaptures` takes an optional `quoteValue` escaper: shell-fallthrough lines pass **`shellQuote`** (defined in `src/runtime/kernel/prompt-config.ts` and re-exported through `prompt.ts`, the single canonical escaper) so every interpolated value — parameter, capture, `for` iterator, channel payload, and inline `${ref(...)}` capture result — is shell-quoted before it reaches `sh -c`, while every other value position interpolates the raw value. This is the one `sh -c` interpolation sink, so a caller-controlled value bound through `jaiph mcp` / `jaiph serve` cannot inject a command (finding H-1).
- **Prompt transport-failure retry.** `runPromptStep` wraps each `executePrompt` invocation in a retry loop driven by the schedule resolved through `src/runtime/kernel/prompt-retry.ts` (default `15s → 1m → 10m → 30m → 2h`, six total attempts; configurable via `JAIPH_PROMPT_RETRY` / `JAIPH_PROMPT_RETRY_DELAYS`). Only the transport path (non-zero exit from the backend) is retried; invalid JSON and schema-validation failures return `{ ok: false }` on the first attempt. Each attempt emits its own `PROMPT_START` / `PROMPT_END` and `STEP_START` / `STEP_END`; each failure (and the final termination) logs a `LOGERR` through `RuntimeEventEmitter.emitLog`. The backoff sleep is injectable (`sleep` constructor option) and interruptible via `runtime.abort()` / an internal `AbortController` so SIGINT and in-process aborts halt the loop without further backend calls. Retry composes **below** `recover` / `catch` — backoff is exhausted before the failure reaches the recover loop. See [Configuration — Prompt retry on transport failure](configuration.md#prompt-retry-on-transport-failure).
- **Idle-step warnings and idle-step kill.** While a leaf step (script or prompt) produces no stdout/stderr, the runtime emits a `LOGWARN` on a fixed cadence — `JAIPH_STEP_IDLE_WARN_SEC` (default 180s, so 180s / 360s / 540s / …) — through `createStepIdleOutputWarn` (`src/runtime/kernel/step-idle-warn.ts`); the next output chunk resets the cadence. This surfaces a stalled backend or long-running command without failing the run. The same tracker also enforces a hard idle-kill threshold for `script` steps. After `JAIPH_STEP_IDLE_KILL_SEC` (default 3600s, `0` disables) of silence it emits a `LOGERR` naming the step and idle duration and aborts a kill signal the step passes to `spawnAndCapture`. Aborting that signal terminates the step's subprocess through `killProcessTreeEscalating` (SIGTERM, then SIGKILL) and settles the step as a failure at once, without waiting for `close`, because a hung descendant that outlived the child while holding the stdout pipe open would otherwise keep the run stuck forever. The warn and kill cadences run off one idle clock but fire independently, and the kill fires at most once. Prompt steps drive no subprocess, so they get warnings only. So a leaf that goes silent overnight fails the run instead of holding the loop open. See [Configuration — Leaf step idle output](configuration.md#leaf-step-idle-output).
- **Max-step circuit breaker.** `JAIPH_MAX_STEPS` (parsed by `parseMaxSteps` in `src/runtime/kernel/max-steps.ts`, `0` / empty / invalid disables it) bounds a runaway workflow that the per-prompt idle watchdog cannot catch — an unbounded loop, a channel or recursion cycle, or a self-referential `run` chain. A single `stepsExecuted` counter on `NodeWorkflowRuntime` increments on every executed non-trivia step across the whole run, and loop iterations and nested or recursive calls share it. Once it exceeds the cap the runtime emits a `LOGERR` (`maxStepsTrippedMessage`, `E_MAX_STEPS`), calls `abort()`, and returns a failure step result, so the run stops without a manual signal. See [Configuration — Overall run timeout and step cap](configuration.md#overall-run-timeout-and-step-cap).
@@ -106,7 +106,7 @@ The `src/` import graph is an acyclic layered DAG: parse/format → transpile
- **Formatter (`src/format/index.ts`, `src/format/emit.ts`)**
- **Public entry.** Code outside the format package imports the format slice only through `src/format/index.ts`, which re-exports the formatter API (`emitModule` and the `EmitOptions` type). It is not an `export *` barrel of the tree. The `no-deep-imports-into-format` rule in `.dependency-cruiser.cjs` fails any outside import that reaches a `src/format/**` internal directly, such as `emit.ts`. Add a named re-export to `src/format/index.ts` instead of reaching in. Format is layer 1 beside parse, so its sources import only parse and types, never `src/cli`, `src/runtime`, or `src/transpile`.
- - `jaiph format` rewrites `.jh` / `.test.jh` files into canonical style. `emitModule(ast, trivia, opts?)` reads the semantic AST together with the parallel **`Trivia`** store ([Trivia (CST layer)](#trivia-cst-layer)) to round-trip leading comments, top-level order, `config` body sequence, `"""..."""` and `bareSource` forms, the original quotedness of top-level `const` values (`EnvDeclDef.wasQuoted` — `true` for `"…"` / `"""…"""` sources, `undefined` for bare tokens — so a quoted value is never silently rewritten as bare based on whether it contains a space), and prompt / script body discriminators. Step emission switches on `StepDef.type` (8 variants) and an `emitExprFirstLine` helper switches on `Expr.kind` (7 kinds) — there are no dual code paths for "managed sidecar vs literal value" because that branch was removed from the AST. Call arguments render straight off the typed `Arg[]` — `var` → bare name, `literal` → raw — so the formatter no longer re-parses any args string or consults a `bareIdentifierArgs` shadow field. Pure data→text emitter; no side-effects beyond file writes. Round-trip is bit-for-bit on every fixture under `examples/` and `test-fixtures/golden-ast/fixtures/` — pinned by `src/format/roundtrip.test.ts`, which asserts `parse → format → parse → format` converges in one step on every fixture.
+ - `jaiph format` rewrites `.jh` / `.test.jh` files into canonical style. `emitModule(ast, trivia, opts?)` reads the semantic AST together with the parallel **`Trivia`** store ([Trivia (CST layer)](#trivia-cst-layer)) to round-trip leading comments, top-level order, `config` body sequence, `"""..."""` and `bareSource` forms, the original quotedness of top-level `const` values (`EnvDeclDef.wasQuoted` — `true` for `"…"` / `"""…"""` sources, `undefined` for bare tokens — so a quoted value is never silently rewritten as bare based on whether it contains a space), and prompt / script body discriminators. Step emission switches on `StepDef.type` (9 variants) and an `emitExprFirstLine` helper switches on `Expr.kind` (7 kinds) — there are no dual code paths for "managed sidecar vs literal value" because that branch was removed from the AST. Call arguments render straight off the typed `Arg[]` — `var` → bare name, `literal` → raw — so the formatter no longer re-parses any args string or consults a `bareIdentifierArgs` shadow field. Pure data→text emitter; no side-effects beyond file writes. Round-trip is bit-for-bit on every fixture under `examples/` and `test-fixtures/golden-ast/fixtures/` — pinned by `src/format/roundtrip.test.ts`, which asserts `parse → format → parse → format` converges in one step on every fixture.
## Local module graph
{: #local-module-graph}
@@ -145,6 +145,8 @@ User-visible contracts (banner, hooks, run artifacts, `run_summary.jsonl`, `retu
- **Live contract (runtime → observing process):** `__JAIPH_EVENT__` JSON lines on **stderr only** — the structured event channel. Hooks and the interactive CLI consume that stream; see [Hooks](hooks.md).
- **Durable contract:** `.jaiph/runs/...` + `run_summary.jsonl` (layout below).
+A step and a run always reach a terminal marker, even on an unhandled throw. If a step's execution throws, including a spawn that fails synchronously, `executeManagedStep` converts it to a `status: 1` failed step, writes the capture files, and still emits `STEP_END`. `runRoot` emits `RUN_END` and stops the heartbeat in a `finally`, so a run that throws while running the def body still ends with a terminal `RUN_END` instead of vanishing with no terminal marker. Because the failed step ends `status: 1`, a `recover` on that `run` still runs under [`run.recover_limit`](language.md#catch-and-recover).
+
Channel transport remains file/queue based in runtime inbox logic.
### Durable artifact layout
@@ -183,7 +185,7 @@ Every line written to `run_summary.jsonl` by `RuntimeEventEmitter` carries a `pr
**Key storage outside the run directory (finding M-3).** The key is **not** written into the run directory, which is agent-writable (`$JAIPH_RUN_DIR` for script steps). Storing it there let a workflow's first script step `mkdir "$JAIPH_RUN_DIR/.chain-key"` to squat the path so the host's write threw and was swallowed, then rewrite the journal freely with no integrity failure surfaced. Instead the key lives in an operator-side store: `resolveAuditKeyStore` (`emit.ts`), default `~/.jaiph/audit-keys`, override `JAIPH_AUDIT_KEY_DIR`. Each run gets one entry directory `/` holding the secret `key` file; the directory's existence is the durable "this run was keyed" marker. Persistence is a **hard error**: `writeChainKey` no longer swallows failures, and it creates the marker directory before the key, so even a partial write leaves the run marked keyed-but-keyless (which fails closed below) rather than silently unverifiable.
-**Verification at read/export boundaries.** `verifyRunSummaryChain(filePath, key, opts?)` walks each line, checks `prev_hash` against the recomputed keyed digest, and returns `{ ok: false, error }` at the first broken link (a missing/unreadable journal is a failure, not a silent pass). With `opts.requireTerminal` it additionally requires the journal to **end with the `RUN_END` terminal marker** (`TERMINAL_EVENT_TYPE`) — the chain commits to prefix integrity but not to length, so deleting the last *K* lines of a completed journal leaves a shorter-but-valid chain that would otherwise verify; requiring the terminal marker rejects any post-terminal tail truncation (finding L-3). `verifyRunJournal(runDir)` wraps it (always with `requireTerminal`, since a key is persisted only once the run is terminal): it looks up the run's store entry and returns `{ verified: false, ok: true }` when the run has **no** entry (an unkeyed/legacy run that cannot be verified — never blocked), `{ verified: true, ok: false }` when the run **was** keyed but the key is missing at verification time (**fail closed** — a keyed run whose key vanished must not downgrade to "not verified" and let a tampered journal through), or `{ verified: true, ok }` with the chain result otherwise. Every read/export boundary hard-fails when `verified && !ok`: run listing (`loadPersistedRuns` marks the run `failed` with `TAMPERED_RESULT_TEXT`), `GET /v1/runs/{id}/events` (`409 E_TAMPERED`), and OTLP/Sentry export (skip + warn, never POST a tampered journal).
+**Verification at read/export boundaries.** `verifyRunSummaryChain(filePath, key, opts?)` walks each line, checks `prev_hash` against the recomputed keyed digest, and returns `{ ok: false, error }` at the first broken link (a missing/unreadable journal is a failure, not a silent pass). With `opts.requireTerminal` it additionally requires the journal to **end with the `RUN_END` terminal marker** (`TERMINAL_EVENT_TYPE`) — the chain commits to prefix integrity but not to length, so deleting the last *K* lines of a completed journal leaves a shorter-but-valid chain that would otherwise verify; requiring the terminal marker rejects any post-terminal tail truncation (finding L-3). `verifyRunJournal(runDir)` wraps it (always with `requireTerminal`, since a key is persisted only once the run is terminal): it looks up the run's store entry and returns `{ verified: false, ok: true }` when the run has **no** entry (an unkeyed/legacy run that cannot be verified — never blocked), `{ verified: true, ok: false }` when the run **was** keyed but the key is missing at verification time (**fail closed** — a keyed run whose key vanished must not downgrade to "not verified" and let a tampered journal through), or `{ verified: true, ok }` with the chain result otherwise. Every read/export boundary hard-fails when `verified && !ok`: run listing (`loadPersistedRuns` marks the run `failed` with `TAMPERED_RESULT_TEXT`), `GET /runs/{id}/events` (`409 E_TAMPERED`), and OTLP/Sentry export (skip + warn, never POST a tampered journal).
**Scope of the guarantee.** A workflow script step cannot read the key or the journal path from its env, and cannot alter the journal in any way that verifies — any rewrite or omitted line is rejected, and any truncation *during* the run is caught because the kernel keeps appending under the pre-truncation head. A *post-run* clean truncation of a completed journal's tail is also rejected: the terminal-marker check (finding L-3) fails a keyed journal that no longer ends with `RUN_END`. Because a `.jh` host run and its `script` steps execute under the same OS user, a hash chain still cannot defend against a post-run same-user process that deletes the run's store entry, which makes the run unverifiable (`verified:false`) rather than a detectable tamper. A workflow cannot read the key from its env, and the key store lives outside the run directory.
@@ -194,12 +196,12 @@ Before `RuntimeEventEmitter` writes an event line to `run_summary.jsonl`, it red
- the reconstructed prompt body (`prompt_text`) and the resolved values of `${var}` references persisted alongside it (`emitPromptStepStart`),
- the `preview` field of `PROMPT_START` / `PROMPT_END` events (`emitPromptEvent`),
- the embedded stdout/stderr excerpts (`out_content` / `err_content`) of every `STEP_END` — script and prompt steps alike (`emitStep`),
-- the `params` key/value pairs of every `STEP_END`, which hold the positional or named arguments passed to a `run`, tool, or `script` step, so a secret passed as an argument is redacted the same way as the step's captured output (`emitStep`),
+- the `params` key/value pairs of every `STEP_END`, which hold the positional or named arguments passed to a call, tool, or `script` step, so a secret passed as an argument is redacted the same way as the step's captured output (`emitStep`),
- the `message` field of every durable `LOG`, `LOGWARN`, and `LOGERR` event, so a value a workflow passes to `log`, `logwarn`, or `logerr` is redacted in the journal the same way as a step's captured output (`emitLog`).
The rule covers backend API keys such as `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and `CURSOR_API_KEY`.
-The same credential rule lives in one shared helper, **`redactCredentials`** (`src/runtime/kernel/redact.ts`). The helper is also the redaction boundary for returned call results. `composeResult` (`src/cli/shared/workflow-call.ts`) redacts a failed call's diagnostic capture (the failed-step detail, the raw stderr and stdout, and the collected `log` messages) before it becomes `jaiph serve`'s `result_text` or a `jaiph mcp` tool result. A successful workflow's return value is intentional API output rather than diagnostic capture, so it is returned verbatim. The journal that `redactCredentials` produces is what the OTLP export (`otlp.ts`), the Sentry export (`sentry.ts`), and `GET /v1/runs/{id}/events` (`handler.ts`) read back verbatim, so broadening the rule tightens all four surfaces at once.
+The same credential rule lives in one shared helper, **`redactCredentials`** (`src/runtime/kernel/redact.ts`). The helper is also the redaction boundary for returned call results. `composeResult` (`src/cli/shared/workflow-call.ts`) redacts a failed call's diagnostic capture (the failed-step detail, the raw stderr and stdout, and the collected `log` messages) before it becomes `jaiph serve`'s `result_text` or a `jaiph mcp` tool result. A successful workflow's return value is intentional API output rather than diagnostic capture, so it is returned verbatim. The journal that `redactCredentials` produces is what the OTLP export (`otlp.ts`), the Sentry export (`sentry.ts`), and `GET /runs/{id}/events` (`handler.ts`) read back verbatim, so broadening the rule tightens all four surfaces at once.
**Explicit non-guarantee.** Redaction is literal-substring replacement of the value and the base64 / base64url / hex / URL-encoded encodings listed above. A secret transformed some other way — split across two output chunks, JSON-string-escaped, gzipped, re-chunked, or embedded as the password inside an opaque connection string (e.g. a `DATABASE_URL`, whose key name does not itself look like a credential) — is **not** guaranteed to be redacted. Beyond the two redaction boundaries (journal copies and returned call results), redaction is not applied at all: the per-step raw capture files (`%06d-.out` / `.err`) are streamed to disk verbatim. Treat them, and the run directory as a whole, as sensitive. Redaction also covers only the durable journal copy of each event, not the live `__JAIPH_EVENT__` progress stream on the runner's stderr that the progress UI and hooks read, so a hook that reads a `LOG` or `STEP_END` line off that stream sees the field as the workflow authored it.
@@ -217,7 +219,7 @@ Authoring rules, fixtures, and mock syntax for `*.test.jh` are documented in [Te
## CLI progress reporting pipeline
-The progress UI combines a **static** step tree derived from the def AST (`src/cli/run/progress.ts`) with **live** updates from the runtime event stream. Event wiring: `src/cli/run/events.ts` and `src/cli/run/stderr-handler.ts` parse `__JAIPH_EVENT__` lines; `src/cli/run/emitter.ts` bridges into the renderer. Line-oriented formatting (`formatStartLine`, `formatHeartbeatLine`, `formatCompletedLine`) lives primarily in `src/cli/run/display.ts`, which shares some display helpers with `progress.ts`. Async branch numbering (subscript ₁₂₃… prefixes) is driven by `async_indices` on step and log events — the runtime propagates a chain of 1-based branch indices through `AsyncLocalStorage`, and the stderr handler renders them at the appropriate indent level. Whether ANSI SGR colors are emitted is a single policy — **`canUseAnsi()`** (`src/runtime/kernel/portability.ts`) returns `isTTY && NO_COLOR` unset — and every color emission site (`src/cli/commands/run.ts`, `src/cli/run/progress.ts`, `src/cli/shared/errors.ts`, `src/cli/shared/server-log.ts`) routes its gate through it rather than re-deriving `isTTY && NO_COLOR` locally. On Windows 10+ Node enables console VT processing automatically, so `isTTY` is a sufficient ANSI proxy with no extra win32 branch. `const` steps whose `Expr` value is `kind: "match"` are walked for nested `run` arms; matched targets appear as child items in the step tree (for example `▸ def my_flow` under the `const` row). This pipeline does not apply to **`jaiph run --raw`**.
+The progress UI combines a **static** step tree derived from the def AST (`src/cli/run/progress.ts`) with **live** updates from the runtime event stream. Event wiring: `src/cli/run/events.ts` and `src/cli/run/stderr-handler.ts` parse `__JAIPH_EVENT__` lines; `src/cli/run/emitter.ts` bridges into the renderer. Line-oriented formatting (`formatStartLine`, `formatHeartbeatLine`, `formatCompletedLine`) lives primarily in `src/cli/run/display.ts`, which shares some display helpers with `progress.ts`. Async branch numbering (subscript ₁₂₃… prefixes) is driven by `async_indices` on step and log events — the runtime propagates a chain of 1-based branch indices through `AsyncLocalStorage`, and the stderr handler renders them at the appropriate indent level. Whether ANSI SGR colors are emitted is a single policy — **`canUseAnsi()`** (`src/runtime/kernel/portability.ts`) returns `isTTY && NO_COLOR` unset — and every color emission site (`src/cli/commands/run.ts`, `src/cli/run/progress.ts`, `src/cli/shared/errors.ts`, `src/cli/shared/server-log.ts`) routes its gate through it rather than re-deriving `isTTY && NO_COLOR` locally. On Windows 10+ Node enables console VT processing automatically, so `isTTY` is a sufficient ANSI proxy with no extra win32 branch. `const` steps whose `Expr` value is `kind: "match"` are walked for nested call arms; matched targets appear as child items in the step tree (for example `▸ def my_flow` under the `const` row). This pipeline does not apply to **`jaiph run --raw`**.
## Distribution: Node vs Bun standalone
diff --git a/docs/artifacts.md b/docs/artifacts.md
index 68a875f4..849c56df 100644
--- a/docs/artifacts.md
+++ b/docs/artifacts.md
@@ -29,12 +29,12 @@ import "jaiphlang/artifacts" as artifacts
```jh
export def main() {
# ... produce ./build/output.bin somehow ...
- const dest = run artifacts.save("./build/output.bin")
+ const dest = artifacts.save("./build/output.bin")
log "saved to ${dest}"
}
```
-`save` copies the source path into `${JAIPH_ARTIFACTS_DIR}/...` preserving the relative layout (the leading `./` is stripped). Absolute source paths are copied using `basename` only. The `run` step returns the absolute destination path.
+`save` copies the source path into `${JAIPH_ARTIFACTS_DIR}/...` preserving the relative layout (the leading `./` is stripped). Absolute source paths are copied using `basename` only. The `save` call returns the absolute destination path.
## 3. Save several files at once
@@ -46,7 +46,7 @@ export def main() {
a.txt
b/nested.txt
"""
- const dests = run artifacts.save(paths)
+ const dests = artifacts.save(paths)
log "${dests}"
}
```
@@ -58,17 +58,17 @@ The returned value is the newline-separated list of absolute destination paths,
If you need full control of layout or names, write to `$JAIPH_ARTIFACTS_DIR` from a `script` step:
```jh
-script save_report = ```
+script save_report = '''
mkdir -p "$JAIPH_ARTIFACTS_DIR/reports"
cp ./report.html "$JAIPH_ARTIFACTS_DIR/reports/"
-```
+'''
export def main() {
- run save_report()
+ save_report()
}
```
-The runtime also sets `JAIPH_RUN_DIR`, `JAIPH_RUN_SUMMARY_FILE`, and `JAIPH_RUN_ID` for script steps, so you can read the run directory, the summary file, or the run id when you need them.
+The runtime also sets `JAIPH_RUN_DIR` for script steps, so you can read the run directory when you need it. Script steps do not receive `JAIPH_RUN_SUMMARY_FILE` or `JAIPH_RUN_ID`. Both are held back from the sterile script environment so a script step cannot read the audit journal path (see [Architecture, keyed hash chain](architecture.md#hash-chain)). See [Environment variables](env-vars.md#script-env) for the full list of variables a script step receives.
## Verification
@@ -86,33 +86,11 @@ Replace `` with `.jaiph/runs` when `JAIPH_RUNS_DIR` is unset, or with
Every line the runtime appends to `run_summary.jsonl` carries a `prev_hash` field. The field holds a **keyed** HMAC-SHA256 of the previous raw line (keyed genesis for the first line), computed under a per-run secret the audited program never sees. Rewriting a line, or dropping a line and re-linking the survivors, breaks the chain and cannot be re-forged without the key, so you can detect tampering with a run's audit trail. The key is persisted once the run finishes. It is **not** stored in the run directory, which the program can write to. It is stored in an operator-side store instead (`~/.jaiph/audit-keys` by default, or the directory in `JAIPH_AUDIT_KEY_DIR`), keyed by the run directory's identity. See [Architecture, keyed hash chain](architecture.md#hash-chain) for the full contract, including the key-isolation and read/export-boundary guarantees.
-To check a run directory, run this self-contained Node script. It resolves the run's key from the operator store, where the `sha256` of the run directory's canonical path names its entry. It then recomputes the keyed chain the same way the runtime does and confirms the journal still ends with its `RUN_END` terminal marker. No jaiph build is required:
+To check a run directory, use the exported helpers in `src/runtime/kernel/emit.ts`. `verifyRunJournal(runDir)` resolves the run's key from the operator store, recomputes the keyed chain the same way the runtime does, requires the journal to end with its `RUN_END` terminal marker, and returns `{ verified, ok, error }`. `verifyRunSummaryChain(filePath, key, opts?)` is the lower-level form when you already hold the key.
-```bash
-node -e '
- const fs = require("fs"), crypto = require("crypto"), path = require("path"), os = require("os");
- const dir = fs.realpathSync(process.argv[1]);
- const store = process.env.JAIPH_AUDIT_KEY_DIR || path.join(os.homedir(), ".jaiph", "audit-keys");
- const id = crypto.createHash("sha256").update(dir, "utf8").digest("hex");
- const key = fs.readFileSync(path.join(store, id, "key"), "utf8").trim();
- const hmac = (s) => crypto.createHmac("sha256", key).update(s, "utf8").digest("hex");
- const lines = fs.readFileSync(path.join(dir, "run_summary.jsonl"), "utf8").split("\n").filter(l => l.trim());
- let expected = hmac("0".repeat(64));
- for (let i = 0; i < lines.length; i++) {
- if (JSON.parse(lines[i]).prev_hash !== expected) {
- console.error(`line ${i + 1}: chain broken`); process.exit(1);
- }
- expected = hmac(lines[i]);
- }
- const lastType = lines.length ? JSON.parse(lines[lines.length - 1]).type : null;
- if (lastType !== "RUN_END") {
- console.error(`journal not terminal: last event is ${lastType} (truncated after run end?)`); process.exit(1);
- }
- console.log(`chain intact and terminal (${lines.length} lines)`);
-' //-/
-```
+A run with no store entry (an unkeyed or legacy run) cannot be verified and is never blocked, so `verifyRunJournal` returns `{ verified: false, ok: true }`. A run that **was** keyed but whose key is missing fails closed and returns `{ verified: true, ok: false }`. Every read and export boundary that Jaiph itself controls (run listing, `GET /runs/{id}/events`, and the OTLP and Sentry exporters) hard-fails a run when `verified && !ok`.
-A clean, complete journal prints `chain intact and terminal (N lines)` and exits `0`. A rewritten file prints the first broken line number and exits `1`. A completed journal whose last lines were deleted after the run ended prints that it is not terminal and exits `1`. The chain commits to prefix integrity but not to length, so a shorter journal that still links correctly is caught only by the missing `RUN_END` marker (finding L-3). Inside the repo you can call the exported `verifyRunSummaryChain(filePath, key, opts?)` helper (`src/runtime/kernel/emit.ts`) directly, or `verifyRunJournal(runDir)`, which resolves the key from the store for you, requires the terminal marker, and returns `{ verified, ok, error }`. A run with no store entry (an unkeyed or legacy run) cannot be verified and is never blocked. A run that **was** keyed but whose key is missing fails closed (`verified: true, ok: false`).
+The full algorithm lives in one place, [Architecture, keyed hash chain](architecture.md#hash-chain). It covers the genesis value, the keyed HMAC-SHA256 recomputation, the key-store layout, the entry id derived from the run directory's canonical path, and the terminal-marker rule. Read that page before you reimplement the check outside the repo, so an external verifier stays in step with the runtime.
## Related
diff --git a/docs/assets/js/main.js b/docs/assets/js/main.js
index 4890885e..074ad9a1 100644
--- a/docs/assets/js/main.js
+++ b/docs/assets/js/main.js
@@ -18,7 +18,7 @@
"use",
"test",
"const",
- "run",
+ "stdin",
"prompt",
"log",
"logerr",
@@ -60,13 +60,17 @@
if (state.inFence) {
const trimmed = line.trim();
- if (trimmed === "```") {
+ if (trimmed.startsWith("'''")) {
state.inFence = false;
const leading = line.match(/^(\s*)/);
if (leading && leading[1]) {
tokens.push({ type: "whitespace", value: leading[1], kind: "plain" });
}
- tokens.push({ type: "fence", value: "```", kind: "string" });
+ tokens.push({ type: "fence", value: "'''", kind: "string" });
+ const after = trimmed.slice(3);
+ if (after) {
+ tokens.push({ type: "string", value: after, kind: "string" });
+ }
return tokens;
}
tokens.push({ type: "string", value: line, kind: "string" });
@@ -192,17 +196,17 @@
continue;
}
- if (ch === "`" && line[i + 1] === "`" && line[i + 2] === "`") {
- tokens.push({ type: "fence", value: "```", kind: "string" });
+ if (ch === "'" && line[i + 1] === "'" && line[i + 2] === "'") {
+ tokens.push({ type: "fence", value: "'''", kind: "string" });
i += 3;
state.inFence = true;
continue;
}
- if (ch === "`") {
+ if (ch === "'") {
const start = i;
i += 1;
- while (i < line.length && line[i] !== "`") {
+ while (i < line.length && line[i] !== "'") {
i += 1;
}
if (i < line.length) {
@@ -338,13 +342,48 @@
}
}
- if (firstValue === "run") {
- let nameAt = 1;
- if (significant[1] && significant[1].token.value === "async") {
- nameAt = 2;
+ // Connect-statement payload: `stdin PAYLOAD -> call(...)`. Mark the
+ // payload identifiers before the arrow as references; the callee after
+ // the arrow is a call, handled by the mechanical paren rule below.
+ if (firstValue === "stdin") {
+ let arrowAt = -1;
+ for (let i = 1; i < significant.length; i += 1) {
+ if (significant[i].token.type === "arrow") {
+ arrowAt = i;
+ break;
+ }
}
- if (significant[nameAt] && significant[nameAt].token.type === "identifier") {
- annotated[significant[nameAt].index].kind = "identifier";
+ if (arrowAt >= 0) {
+ for (let i = 1; i < arrowAt; i += 1) {
+ if (
+ significant[i].token.type === "identifier" &&
+ annotated[significant[i].index].kind === "plainIdentifier"
+ ) {
+ annotated[significant[i].index].kind = "identifier";
+ }
+ }
+ }
+ }
+
+ // Mechanical call rule: any identifier immediately followed by `(` is a
+ // callee → paint as a function/identifier. Applies anywhere on the line
+ // (statement start `setup_env()`, `const x = valid_name(arg)`,
+ // `async helpers.scan()` where the qualified callee's last segment sits
+ // before `(`, and `stdin … -> shout(task)` connect targets). `run` is
+ // not a keyword, so `run save()` paints only `save`. Keywords before `(`
+ // (`catch (err)`, `recover (err)`) keep their keyword kind, and
+ // declaration names keep their definition kind.
+ for (let i = 0; i < significant.length - 1; i += 1) {
+ const curr = significant[i];
+ const next = significant[i + 1];
+ if (
+ curr.token.type === "identifier" &&
+ annotated[curr.index].kind !== "keyword" &&
+ annotated[curr.index].kind !== "definition" &&
+ next.token.type === "symbol" &&
+ next.token.value === "("
+ ) {
+ annotated[curr.index].kind = "identifier";
}
}
@@ -979,9 +1018,22 @@
}
}
- // Auto-run on DOM ready
- if (document.readyState === "loading") {
- document.addEventListener("DOMContentLoaded", function () {
+ // Auto-run on DOM ready (browser only; skipped under Node so the highlighter
+ // can be imported and unit-tested without a DOM).
+ if (typeof document !== "undefined") {
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", function () {
+ restructureDocSections();
+ wrapTablesInScrollContainer();
+ startComparisonDistinctPulse();
+ highlightAll();
+ attachCopyButtons();
+ attachCodeTabs();
+ attachOsSwitch();
+ attachDocsNavToggle();
+ attachThemeToggle();
+ });
+ } else {
restructureDocSections();
wrapTablesInScrollContainer();
startComparisonDistinctPulse();
@@ -991,17 +1043,13 @@
attachOsSwitch();
attachDocsNavToggle();
attachThemeToggle();
- });
- } else {
- restructureDocSections();
- wrapTablesInScrollContainer();
- startComparisonDistinctPulse();
- highlightAll();
- attachCopyButtons();
- attachCodeTabs();
- attachOsSwitch();
- attachDocsNavToggle();
- attachThemeToggle();
+ }
+ }
+
+ // Expose the pure highlighter to Node test runners; harmless in the browser
+ // (no CommonJS `module`).
+ if (typeof module !== "undefined" && module.exports) {
+ module.exports = { highlightJaiphWithParser: highlightJaiphWithParser };
}
})();
diff --git a/docs/assets/js/main.test.mjs b/docs/assets/js/main.test.mjs
new file mode 100644
index 00000000..b5f16c20
--- /dev/null
+++ b/docs/assets/js/main.test.mjs
@@ -0,0 +1,93 @@
+// Unit test for the docs syntax highlighter (docs/assets/js/main.js).
+//
+// Drives the pure `highlightJaiphWithParser` (no DOM) over small .jh snippets
+// and asserts the mechanical call rule: an identifier or qualified callee
+// immediately followed by `(` is painted as a function/identifier span, while
+// keywords before `(` stay keywords. Run with `node --test`.
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { createRequire } from "node:module";
+
+const require = createRequire(import.meta.url);
+const { highlightJaiphWithParser } = require("./main.js");
+
+// True when `text` is wrapped in a (the
+// function/identifier span the callee uses).
+function isCall(html, text) {
+ return html.includes(`${text}`);
+}
+
+// True when `text` is wrapped in a .
+function isKeyword(html, text) {
+ return html.includes(`${text}`);
+}
+
+test("a name immediately followed by `(` paints the callee as a function", () => {
+ // Statement-start bare call.
+ assert.ok(isCall(highlightJaiphWithParser("setup_env()"), "setup_env"),
+ "statement-start `setup_env()` callee must be a function span");
+
+ // Expression-position call: this is the case the old known-symbol set missed.
+ const expr = highlightJaiphWithParser("const name = valid_name(name_arg)");
+ assert.ok(isCall(expr, "valid_name"),
+ "`const name = valid_name(name_arg)` must paint valid_name as a function");
+
+ // Def call with a string argument.
+ assert.ok(isCall(highlightJaiphWithParser('check_deps("package.json")'), "check_deps"),
+ "def call `check_deps(\"package.json\")` callee must be a function");
+
+ // Qualified call: the last segment sits before `(`.
+ assert.ok(isCall(highlightJaiphWithParser("async helpers.scan()"), "scan"),
+ "qualified call `async helpers.scan()` last segment must be a function");
+});
+
+// Count the `->` arrow/operator spans in rendered HTML (`>` is escaped).
+function arrowCount(html) {
+ return (html.match(/-><\/span>/g) || []).length;
+}
+
+test("a multi-hop stdin pipeline paints every arrow and every stage callee", () => {
+ // `stdin gen() -> upper() -> count()`: BOTH `->` are arrow/operator spans and
+ // EVERY stage callee is a function/identifier span, as a plain statement and
+ // as a `const … =` binding. Fails if only the first hop is painted.
+ for (const line of [
+ "stdin gen() -> upper() -> count()",
+ "const n = stdin gen() -> upper() -> count()",
+ ]) {
+ const html = highlightJaiphWithParser(line);
+ assert.equal(arrowCount(html), 2, `both arrows on \`${line}\` must be operator spans`);
+ for (const callee of ["gen", "upper", "count"]) {
+ assert.ok(isCall(html, callee), `stage callee \`${callee}\` on \`${line}\` must be a function span`);
+ }
+ }
+});
+
+test("keywords before `(` stay keywords, not calls", () => {
+ const html = highlightJaiphWithParser("check_deps() catch (failure) {");
+ assert.ok(isKeyword(html, "catch"), "`catch` before `(` must stay a keyword");
+ assert.ok(!isCall(html, "catch"), "`catch (failure)` must not paint catch as a call");
+});
+
+test("one-line `'…'` and fenced `'''` script bodies paint as strings", () => {
+ const one = highlightJaiphWithParser("'echo hello'()");
+ assert.ok(
+ one.includes(''echo hello'') ||
+ one.includes(`'echo hello'`),
+ "one-line script body `'echo hello'` must be a string span",
+ );
+
+ const fence = highlightJaiphWithParser("script greet = '''bash");
+ assert.ok(
+ fence.includes(''''') ||
+ fence.includes(`'''`),
+ "opening `'''` fence must be a string span",
+ );
+});
+
+test("`run` is not a keyword and does not pick up call/function scope", () => {
+ // A lone `run` before `(` in `run save()` must not become a call; only save is.
+ const html = highlightJaiphWithParser("run save()");
+ assert.ok(!isKeyword(html, "run"), "`run` must not be a keyword");
+ assert.ok(!isCall(html, "run"), "`run` standing before an identifier is not a call");
+ assert.ok(isCall(html, "save"), "`run save()` must paint save as a call");
+});
diff --git a/docs/async.md b/docs/async.md
index 48d0b927..18dfcfbb 100644
--- a/docs/async.md
+++ b/docs/async.md
@@ -6,54 +6,61 @@ diataxis: how-to
# Run work concurrently
-Use `run async` when two defs or named scripts do not depend on each other and you want them to overlap. The runtime starts each call immediately and gives you a handle. The handle becomes a string on the first read that needs the value, or at the end of the current step list.
+Use `async` when two defs or named scripts do not depend on each other and you want them to overlap. The runtime starts each call immediately and gives you a handle. The handle becomes a string on the first read that needs the value, or at the end of the current step list.
-This page is a recipe. The value model lives in [Async Handles](spec-async-handles.md). The syntax table lives in [Language, `run async`](language.md#run-async-concurrent-execution-with-handles).
+This page is a recipe. The value model lives in [Async Handles](spec-async-handles.md). The syntax table lives in [Language, `async`](language.md#run-async-concurrent-execution-with-handles).
## Prerequisites
- An entry file with `export def main`.
-- Two independent callees (defs or named scripts). Inline `` run `…`() `` cannot be `run async` — move the body into a named `script`.
+- Two independent callees (defs or named scripts). Inline `` '…'() `` cannot be `async` — move the body into a named `script`.
## 1. Start both sides, read late
-Hold each handle in its original binding. Do not interpolate, pass as a `run` argument, or use as an `if` / `match` subject until you need the value. An early read waits there and removes the overlap.
+Hold each handle in its original binding. Any read that needs the string resolves the handle, so avoid these until you want the value:
+
+- interpolating it, such as `log "${lint_h}"`;
+- passing it as a call argument, since a bare argument is rewritten to `${lint_h}` before the call;
+- using it as an `if` or `match` subject;
+- copying it with `const copy = lint_h`, since a bare copy is rewritten to `"${lint_h}"` and resolves too.
+
+An early read makes the def wait at that point, which removes the overlap.
```jaiph
def lint() {
- return run check_lint()
+ return check_lint()
}
def unit_tests() {
- return run check_tests()
+ return check_tests()
}
export def main() {
- const lint_h = run async lint()
- const test_h = run async unit_tests()
+ const lint_h = async lint()
+ const test_h = async unit_tests()
log "lint: ${lint_h}"
log "tests: ${test_h}"
}
```
-A bare `run async lint()` with no capture still starts the work. The implicit join at the end of the step list waits for it.
+A bare `async lint()` with no capture still starts the work. The implicit join at the end of the step list waits for it.
## 2. Recover a failing async branch
-`catch` and `recover` attach only to the statement form. A captured `const h = run async foo()` cannot carry those blocks. Wrap the target in a def if you need both a handle and a retry loop.
+`catch` and `recover` attach only to the statement form. A captured `const h = async foo()` cannot carry those blocks. Wrap the target in a def if you need both a handle and a retry loop.
```jaiph
export def main() {
- run async deploy() recover (err) {
- log "repair: ${err}"
- run auto_repair()
+ async deploy() recover (err) {
+ logerr "repair; see ${err}"
+ auto_repair()
}
}
```
`recover` retries inside that one branch. `catch` runs once; a successful catch counts the branch as joined-ok. A `catch` `return` becomes the parent def's return when the join adopts it.
-## 3. Avoid the `for` footgun
+## 3. Resolve a handle before a `for` loop
`for line in h` does **not** resolve a handle. The loop iterates the token as one line, so you get one pass over `__JAIPH_HANDLE__…` instead of one pass per result line. Resolve first:
@@ -66,7 +73,7 @@ for line in text {
## Verification
-1. Run a file that starts two `run async` calls and reads the handles only at the end. The live tree prefixes each branch with a subscript (`₁`, `₂`).
+1. Run a file that starts two `async` calls and reads the handles only at the end. The live tree prefixes each branch with a subscript (`₁`, `₂`).
2. Confirm an unread handle still finishes: drop the `log` lines and the run still exits `0` after both branches complete.
3. Confirm `for line in h` runs once (the token is one line). After `const text = "${h}"`, `for line in text` runs once per result line.
@@ -75,5 +82,5 @@ for line in text {
## Related
- [Async Handles](spec-async-handles.md) — eager start, lazy resolve, implicit join, and why there is no `await`.
-- [Language, `run async`](language.md#run-async-concurrent-execution-with-handles) — surface syntax.
+- [Language, `async`](language.md#run-async-concurrent-execution-with-handles) — surface syntax.
- [Inbox](inbox.md) — channel drain runs after the entry def's implicit join.
diff --git a/docs/build-jaiph-dev-image.sh b/docs/build-jaiph-dev-image.sh
new file mode 100755
index 00000000..8e6f1679
--- /dev/null
+++ b/docs/build-jaiph-dev-image.sh
@@ -0,0 +1,50 @@
+#!/usr/bin/env bash
+# Build a local linux runner image from this clone.
+# docs/install-from-local.sh does not do this — that script installs the host
+# binary only. This compiles the linux standalones and docker-builds
+# runtime/Dockerfile.
+#
+# Requires: npm, bun, docker
+#
+# Usage:
+# ./docs/build-jaiph-dev-image.sh
+# JAIPH_DEV_IMAGE=my/jaiph ./docs/build-jaiph-dev-image.sh
+#
+# Tags ${JAIPH_DEV_IMAGE:-ghcr.io/jaiphlang/jaiph-runtime} and :latest
+# for linux/${host-arch}. The Dockerfile COPYs both linux binaries.
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
+IMAGE="${JAIPH_DEV_IMAGE:-ghcr.io/jaiphlang/jaiph-runtime}"
+
+case "$(uname -m)" in
+ arm64|aarch64) DOCKER_ARCH=arm64 ;;
+ x86_64|amd64) DOCKER_ARCH=amd64 ;;
+ *)
+ echo "build-jaiph-dev-image: unsupported uname -m: $(uname -m)" >&2
+ exit 1
+ ;;
+esac
+
+for cmd in npm bun docker; do
+ if ! command -v "${cmd}" >/dev/null 2>&1; then
+ echo "build-jaiph-dev-image: ${cmd} is required" >&2
+ exit 1
+ fi
+done
+
+cd "${REPO_ROOT}"
+npm run build
+bun build --compile --target=bun-linux-arm64 ./src/cli.ts --outfile runtime/jaiph-linux-arm64
+bun build --compile --target=bun-linux-x64 ./src/cli.ts --outfile runtime/jaiph-linux-x64
+chmod +x runtime/jaiph-linux-arm64 runtime/jaiph-linux-x64
+
+docker build --platform "linux/${DOCKER_ARCH}" \
+ -f runtime/Dockerfile \
+ -t "${IMAGE}:latest" \
+ -t "${IMAGE}" \
+ runtime/
+
+echo "Tagged ${IMAGE} and ${IMAGE}:latest (linux/${DOCKER_ARCH})"
diff --git a/docs/cli.md b/docs/cli.md
index b6aaf970..54042286 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -59,7 +59,7 @@ Every run executes on the host. Isolation is an outer concern: wrap jaiph in a c
| `--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. |
| `--workspace` | `` | Override the workspace root used for library resolution. A missing value, missing path, or non-directory aborts with a specific message. There is no `JAIPH_WORKSPACE` env equivalent input — that name is reserved for the runner. |
-| `--env` | `KEY=VALUE` or `KEY` | Repeatable per-key flag, and the **only grant** for `use` clauses on scripts and [named prompts](language.md#named-prompts): a script runs in a sterile env, a named prompt's agent runs with the prompt env scrub, and each receives a host key iff its declaration `use`s it *and* `--env` names it (see [Environment variables — Script subprocess environment](env-vars.md#script-env)). The granted value is injected only into a subprocess whose declaration `use`s the key; it is not placed on the runner (workflow-leader) process environment, which Jaiph builds from an allowlist (process basics, `JAIPH_*` control keys, backend credentials) rather than a copy of the host environment, so an ungranted host key is absent from it. Pre-flight collects every `use` key in the import graph and aborts with `E_ENV_MISSING` if one was not granted; extra keys nothing `use`s are fine. `--env KEY=VALUE` defines `KEY` with that exact value (first `=` splits; the value may contain `=`; empty is allowed). `--env KEY` forwards the host's current value, aborting with `E_ENV_MISSING` before spawning if `KEY` is unset on the host. `KEY` must match `[A-Za-z_][A-Za-z0-9_]*` (else `E_ENV_INVALID`). Runtime-managed keys (`JAIPH_WORKSPACE`, `JAIPH_RUNS_DIR`, `JAIPH_RUN_ID`, `JAIPH_SCRIPTS`, `JAIPH_MODULE_GRAPH_FILE`, `JAIPH_SOURCE_ABS`, `JAIPH_META_FILE`, `JAIPH_ENV_GRANT`, `JAIPH_ENV_GRANT_FILE`, `JAIPH_AGENT_TRUSTED_WORKSPACE`, `JAIPH_TRUST_PROJECT_HOOKS`, `JAIPH_CHAIN_KEY`, `JAIPH_RUN_SUMMARY_FILE`) are rejected with `E_ENV_RESERVED`. Values are never path-remapped. `jaiph run --raw` applies the same grant but skips the graph-wide pre-flight. |
+| `--env` | `KEY=VALUE` or `KEY` | The only grant for `use` clauses on scripts and named prompts: a host key reaches a `use`ing subprocess only when `--env KEY=VALUE` or `--env KEY` names it. Ungranted, reserved, or invalid names fail with `E_ENV_MISSING`, `E_ENV_RESERVED`, or `E_ENV_INVALID` — see [Environment variables](env-vars.md#script-env). |
| `--` | — | End of Jaiph flags; remaining tokens are forwarded to `export def main`. |
### Pre-flight
@@ -77,11 +77,11 @@ After module-graph load, before the runner is spawned, the host CLI runs a crede
| `!` | `logerr` message (red; rendered on stdout with the progress tree). |
| `⚠` | `logwarn` message and automatic leaf-step idle warnings (yellow; rendered on stdout with the progress tree). |
| `·` | Continuation marker (heartbeat lines in non-TTY mode). |
-| ` ₁`, ` ₂`, … | Subscript prefix for `run async` branch numbering. |
+| ` ₁`, ` ₂`, … | Subscript prefix for `async` branch numbering. |
PASS line: `✓ PASS def main (0.2s)`. TTY runs append a transient `▸ RUNNING def (X.Xs)` line that is replaced by the PASS/FAIL line on exit. `--raw` and non-TTY modes skip both. Disable color globally with `NO_COLOR=1`.
-Non-TTY heartbeat cadence is controlled by `JAIPH_NON_TTY_HEARTBEAT_FIRST_SEC` (default `60`) and `JAIPH_NON_TTY_HEARTBEAT_INTERVAL_MS` (default `30000`, floor `250`). Leaf script and prompt steps emit a yellow `⚠` idle warning when they produce no stdout/stderr for `JAIPH_STEP_IDLE_WARN_SEC` (default `180`; `0` disables).
+Non-TTY heartbeat cadence is controlled by `JAIPH_NON_TTY_HEARTBEAT_FIRST_SEC` (default `60`) and `JAIPH_NON_TTY_HEARTBEAT_INTERVAL_MS` (default `30000`, minimum `250`). A value below `250`, or a value that is not a number, is not clamped up to `250`. Jaiph uses the `30000` default instead. Leaf script and prompt steps emit a yellow `⚠` idle warning when they produce no stdout/stderr for `JAIPH_STEP_IDLE_WARN_SEC` (default `180`; `0` disables).
A leaf script step whose subprocess produces no stdout/stderr for `JAIPH_STEP_IDLE_KILL_SEC` (default `3600`, one hour; `0` disables) is terminated and fails. The runtime emits a red `LOGERR` line naming the step and how long it was silent, then kills the step's subprocess, so a stuck script cannot hold an overnight run open indefinitely. New output resets both the warn clock and the kill clock. The kill applies to script steps only; prompt steps get idle warnings but are not killed.
@@ -160,14 +160,14 @@ Reformat `.jh` / `.test.jh` files into canonical style.
jaiph format [--check] [--indent ]
```
-Paths must end with `.jh`. Formatting is idempotent. Comments and shebangs are preserved. Triple-quoted bodies and prompt blocks emit verbatim (author margin preserved via trivia). Fenced script bodies are stored dedented in the AST; the formatter re-indents inner lines by one level relative to the surrounding scope.
+Paths must end with `.jh`. Formatting is idempotent. Comments and shebangs are preserved. Non-prompt triple-quoted bodies (`const` / `log` / …) emit verbatim (author margin preserved via trivia). Triple-quoted **prompt** bodies and fenced script bodies are stored dedented in the AST; the formatter re-indents inner lines by one level relative to the surrounding scope. A bare `stdin name` / `stdin name.field` operand is preserved (not rewritten to `"${…}"`); an already-quoted operand stays quoted.
| 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. |
-Top-level ordering: the formatter hoists `import`, `config`, and `channel` declarations to the top (in that order, preserving relative source order within each group). Other top-level definitions (`const`, `script`, `def`, `test`) keep their relative source order. Comments before a hoisted construct move with it; comments before non-hoisted definitions stay in place.
+Top-level ordering: the formatter hoists `import`, `config`, and `channel` declarations to the top (in that order, preserving relative source order within each group). Other top-level definitions (`const`, `script`, `prompt`, `def`, `test`) keep their relative source order. Comments before a hoisted construct move with it; comments before non-hoisted definitions stay in place.
Top-level `const` quoting: the source delimiter is preserved per binding. Bare tokens stay bare, `"""…"""` values emit verbatim, and a double-quoted value stays double-quoted. The one exception is a double-quoted value whose content contains a `"` or a `\`: the formatter emits it as a `"""…"""` block so the text needs no escaping. The formatter never rewrites a quoted value as bare, or a bare token as quoted, based on the value's content (for example, whether it contains a space).
@@ -226,7 +226,7 @@ Each successful clone runs these checks before the lib counts as installed:
### Restore-from-lockfile mode
-`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.
+`jaiph install` with no positional args reads `.jaiph/libs.lock` and clones each entry. The registry is never contacted. Every entry must carry a `commit`; the cloned HEAD must match it, and on mismatch the directory is removed and the run fails with the locked vs cloned SHAs and the remedy. An entry without a `commit` (an older, unpinned lockfile) is refused before any clone — the run fails and nothing is cloned; re-run `jaiph install ` to re-pin it. When an entry also carries a `signature`, it is re-verified against the cloned commit with the same embedded project key as a named install, failing closed on mismatch.
### Parallel clones
@@ -260,13 +260,14 @@ Missing libraries are cloned with bounded concurrency (default **4 in flight**).
"name": "queue-lib",
"url": "https://github.com/you/queue-lib.git",
"version": "v1.0",
- "commit": "fedcba9876543210fedcba9876543210fedcba98"
+ "commit": "fedcba9876543210fedcba9876543210fedcba98",
+ "signature": "RUR...=="
}
]
}
```
-The lock entry stores the resolved clone URL so restore works without the registry. `commit` is written automatically after each successful clone.
+The lock entry stores the resolved clone URL so restore works without the registry. `commit` is written automatically after each successful clone. When the install spec carried a `signature` (from a signed registry entry), it is persisted on the entry too, so restore can re-verify it.
## `jaiph use`
@@ -279,14 +280,14 @@ jaiph use
| Argument | Effect |
|---|---|
| `nightly` | Reinstalls from the rolling `nightly` prerelease. |
-| `` (e.g. `0.13.0`) | Reinstalls the release binary for tag `v`. |
+| `` (e.g. `0.15.0`) | Reinstalls the release binary for tag `v`. |
Implementation: with no `JAIPH_INSTALL_COMMAND` override, `jaiph use` downloads the install script from `${JAIPH_SITE}/install` (default `https://jaiph.org`), verifies it against the published `${JAIPH_SITE}/install.sha256`, and only then runs it with `JAIPH_REPO_REF` set to `nightly` or `v`. A mismatched or missing checksum fails closed rather than piping an unverified script to `bash`. Setting `JAIPH_INSTALL_COMMAND` overrides this with a verbatim command (forks, offline bundles, local scripts). The installer then downloads the matching per-platform binary plus `SHA256SUMS` (and its signature), verifies them, and replaces `~/.local/bin/jaiph` (or `JAIPH_BIN_DIR`).
## `jaiph mcp`
{: #jaiph-mcp}
-Serve a file's defs as [MCP](https://modelcontextprotocol.io/) tools over stdio. See [MCP server in 30 seconds](mcp.md) for the recipe and client-registration steps.
+Serve a file's defs as [MCP](https://modelcontextprotocol.io/) tools over stdio. No authentication — the parent MCP client is the only caller. For HTTP MCP with optional bearer/OIDC, use [`jaiph serve`](#jaiph-serve) (`POST /mcp`). See [MCP server in 30 seconds](mcp.md) for the recipe and client-registration steps.
```text
jaiph mcp [--workspace ] [--env KEY[=VALUE]]...
@@ -381,16 +382,16 @@ jaiph serve [--host ] [--port ] [--workspace ] [--allow-anonymous]
| Flag | Argument | Effect |
|---|---|---|
-| `--host` | `` | Listen address (default `127.0.0.1`). Binding a non-loopback host with no authentication (neither `JAIPH_SERVE_TOKEN` nor OIDC configured) aborts startup, even with `--allow-anonymous`. |
+| `--host` | `` | Listen address (default `127.0.0.1`). A non-loopback bind with no `JAIPH_SERVE_TOKEN` and no OIDC aborts startup unless `--allow-anonymous` is passed. |
| `--port` | `` | Listen port (default `5247`). `0` picks a free port. |
-| `--allow-anonymous` | — | Explicit opt-in to run open with no authentication on loopback. Without it, a loopback bind with no `JAIPH_SERVE_TOKEN` and no OIDC aborts startup, because anonymous mode authorizes every local principal with all capabilities over all runs (loopback guards the network, not other local users — finding M-2). For a single-user workstation only; shared hosts must set `JAIPH_SERVE_TOKEN` or configure OIDC. When passed, the server prints a startup warning that it is open to all local principals. Ignored (no-op) when a token or OIDC is configured, and it never permits a non-loopback bind. |
+| `--allow-anonymous` | — | Bind a non-loopback address with no authentication (Docker must listen on `0.0.0.0`). The default loopback bind is already open. Every caller is the `anonymous` principal with all capabilities over all runs. Prints a startup warning on a non-loopback bind. Ignored (no-op) when a token or OIDC is configured. Shared or network-exposed hosts should set `JAIPH_SERVE_TOKEN` or configure OIDC. |
| `--workspace` | `` | Workspace root for import resolution (default: auto-detected). |
| `--env` | `KEY=VALUE` or `KEY` | Same per-key passthrough as `jaiph run --env`, resolved once at startup and applied to every run for the server's lifetime. |
| `-h`, `--help` | — | Print the subcommand usage and exit `0`. |
Flags that belong to another command (for example `--raw` or `--target`) are usage errors naming the owning command — never silently ignored. Precedence across layers is the shared execution-policy order: CLI flags > `JAIPH_*` env vars > module config metadata > defaults (see [Environment variables — Precedence](env-vars.md#precedence)).
-Startup mirrors `jaiph mcp`: graph load + `collectDiagnostics` (diagnostics to stderr, exit `1`), credential pre-flight as warnings, and a host-execution notice. All logs go to stderr. Startup prints a line with the listen URL, the `/docs` and `/mcp` URLs, and the exposed-def count, followed by an authentication-mode line, a memory-bounds line, and, when terminal runs were rebuilt from disk, a line reporting how many were reconstructed. Per-run operator lines (a start line `Running … run_id=` and an end line `Finished … status=… elapsed_ms=…`) and the optional log mirror follow the same stderr-only operator-log contract as `jaiph mcp`, and HTTP response bodies stay API payloads. See [Operator log (stderr)](#operator-log-stderr) above, and `JAIPH_SERVER_LOG` and `JAIPH_SERVER_LOG_RUNS` in [Environment variables](env-vars.md).
+Startup mirrors `jaiph mcp`: graph load + `collectDiagnostics` (diagnostics to stderr, exit `1`), credential pre-flight as warnings, and a host-execution notice. All logs go to stderr. Startup prints timed lines for module-graph load and run reconstruction (`… in Nms`), then a line with the listen URL, the `/docs` and `/mcp` URLs, and the exposed-def count, followed by an authentication-mode line and a memory-bounds line. Per-run operator lines (a start line `Running … run_id=` and an end line `Finished … status=… elapsed_ms=…`) and the optional log mirror follow the same stderr-only operator-log contract as `jaiph mcp`, and HTTP response bodies stay API payloads. See [Operator log (stderr)](#operator-log-stderr) above, and `JAIPH_SERVER_LOG` and `JAIPH_SERVER_LOG_RUNS` in [Environment variables](env-vars.md).
### Endpoints
@@ -403,23 +404,23 @@ The **Cap.** column names the capability an authenticated principal must hold to
| `GET /openapi.json` | none | OpenAPI 3.1 document, regenerated per request (hot reload needs no cache invalidation). `404` when `JAIPH_SERVE_EXPOSE_DOCS=false`. |
| `GET /docs` | none | Self-contained Swagger UI shell. The pinned `swagger-ui-dist` assets are embedded in the binary and served from same-origin `/docs/*` paths, so it needs no browser internet access. `404` when `JAIPH_SERVE_EXPOSE_DOCS=false`. |
| `GET /docs/swagger-ui-bundle.js`, `GET /docs/swagger-ui.css` | none | The embedded Swagger UI assets, each stamped with a `sha384` Subresource Integrity hash over the served bytes. `404` when `JAIPH_SERVE_EXPOSE_DOCS=false`. |
-| `GET /v1/defs` | `inspect` | `{defs: [{name, description, params}]}`. |
-| `POST /v1/defs/{name}/runs` | `invoke` | Start a run. Default `202` + `Location: /v1/runs/{id}`; `?wait=true` blocks for the terminal `200`. Send an `Idempotency-Key` header (scoped to the authenticated principal + def) to make retries safe: an identical repeat returns the original run (`200`, no second spawn); a reused key with different arguments is `409 E_IDEMPOTENCY_CONFLICT` and spawns nothing. |
-| `GET /v1/runs` | `inspect` | Runs started by this process **plus runs reconstructed from disk on restart**, newest first, scoped to the caller's own runs (all runs for a static/open principal). Paginated: `?limit` (default `100`, clamped to `1000`), `?offset` (default `0`). Response is `{runs, total, limit, offset}` and never unbounded. |
-| `GET /v1/runs/{id}` | `inspect` | The run object. `404` unknown (a run the principal does not own is indistinguishable from nonexistent). |
-| `GET /v1/runs/{id}/events` | `inspect` | The run's `run_summary.jsonl`. Default `application/x-ndjson` snapshot, streamed from disk (never buffered whole); `Accept: text/event-stream` replays then follows it live, closing with `event: end` when terminal. The snapshot mode first verifies the journal's keyed integrity chain and returns `409 E_TAMPERED` when the chain does not verify (see [Architecture — Keyed hash chain](architecture.md#hash-chain)). Served verbatim (already credential-redacted); raw capture files are never exposed. `404` unknown. |
-| `GET /v1/runs/{id}/artifacts` | `inspect` | `{artifacts: [{path, size, mtime}]}` for files published under the run's `artifacts/` (empty when none). `404` unknown. |
-| `GET /v1/runs/{id}/artifacts/{path}` | `inspect` | Download one published file (`application/octet-stream`), streamed with backpressure — never buffered whole, so an arbitrarily large file costs no server memory and a client disconnect closes the file. Traversal-proof — `..`, absolute paths, and escaping symlinks are `404`. `413 E_ARTIFACT_TOO_LARGE` when the file exceeds `JAIPH_SERVE_MAX_ARTIFACT_BYTES`. |
-| `POST /v1/runs/{id}/cancel` | `cancel` | `202`; the run reaches `cancelled`. `409` if already terminal. |
+| `GET /defs` | `inspect` | `{defs: [{name, description, params}]}`. |
+| `POST /{name}` | `invoke` | Start a run. Default `202` + `Location: /runs/{id}`; `?wait=true` blocks for the terminal `200`. Send an `Idempotency-Key` header (scoped to the authenticated principal + def) to make retries safe: an identical repeat returns the original run (`200`, no second spawn); a reused key with different arguments is `409 E_IDEMPOTENCY_CONFLICT` and spawns nothing. |
+| `GET /runs` | `inspect` | Runs started by this process **plus runs reconstructed from disk on restart**, newest first, scoped to the caller's own runs (all runs for a static/open principal). Paginated: `?limit` (default `100`, clamped to `1000`), `?offset` (default `0`). Response is `{runs, total, limit, offset}` and never unbounded. |
+| `GET /runs/{id}` | `inspect` | The run object. `404` unknown (a run the principal does not own is indistinguishable from nonexistent). |
+| `GET /runs/{id}/events` | `inspect` | The run's `run_summary.jsonl`. Default `application/x-ndjson` snapshot, streamed from disk (never buffered whole); `Accept: text/event-stream` replays then follows it live, closing with `event: end` when terminal. The snapshot mode first verifies the journal's keyed integrity chain and returns `409 E_TAMPERED` when the chain does not verify (see [Architecture — Keyed hash chain](architecture.md#hash-chain)). Served verbatim (already credential-redacted); raw capture files are never exposed. `404` unknown. |
+| `GET /runs/{id}/artifacts` | `inspect` | `{artifacts: [{path, size, mtime}]}` for files published under the run's `artifacts/` (empty when none). `404` unknown. |
+| `GET /runs/{id}/artifacts/{path}` | `inspect` | Download one published file (`application/octet-stream`), streamed with backpressure — never buffered whole, so an arbitrarily large file costs no server memory and a client disconnect closes the file. Traversal-proof — `..`, absolute paths, and escaping symlinks are `404`. `413 E_ARTIFACT_TOO_LARGE` when the file exceeds `JAIPH_SERVE_MAX_ARTIFACT_BYTES`. |
+| `POST /runs/{id}/cancel` | `cancel` | `202`; the run reaches `cancelled`. `409` if already terminal. |
-The run object is `{run_id, def, status, started_at, ended_at, exit_status, signal, result_text, run_dir, principal, correlation_id}` where `status` is `running` \| `succeeded` \| `failed` \| `cancelled` \| `interrupted`. `principal` is the audit subject that created the run (`anonymous`/`operator` in open/static mode, the token `sub` or `client_id` in OIDC mode — never a token) and `correlation_id` is the request id attached at create time; both are `null` when unset. `interrupted` is the terminal state a run is reconciled to after a process death caught it mid-flight — its outcome is unknown, so it is neither `succeeded` nor `failed`, but it is never reported as permanently `running`. **A def failure is not an HTTP error** — the run object reports `status: "failed"` with the same failure narrative `jaiph mcp` returns, over HTTP `200`/`202`. Errors use `{error: {code, message}}` with `400 E_BAD_ARGS`, `401 E_UNAUTHORIZED` (missing or invalid static token; in OIDC mode, a request with no bearer token, or a verified token that carries neither `sub` nor `client_id`), `401 E_TOKEN_EXPIRED` / `401 E_TOKEN_INVALID` (OIDC token expired, or bad audience/issuer/key/signature/algorithm), `403 E_FORBIDDEN` (principal lacks the required capability), `404 E_NOT_FOUND`, `409 E_RUN_TERMINAL`, `409 E_IDEMPOTENCY_CONFLICT` (idempotency key reused with different arguments), `409 E_TAMPERED` (the run's journal failed its keyed integrity chain), `413 E_BODY_TOO_LARGE` (1 MiB request-body cap), `413 E_ARTIFACT_TOO_LARGE` (artifact download over `JAIPH_SERVE_MAX_ARTIFACT_BYTES`), `415` (non-`application/json` body), `429 E_TOO_MANY_RUNS`, and `503 E_AUTH_UNAVAILABLE` (OIDC identity provider / JWKS unreachable).
+The run object is `{run_id, def, status, started_at, ended_at, exit_status, signal, result_text, run_dir, principal, correlation_id}` where `status` is `running` \| `succeeded` \| `failed` \| `cancelled` \| `interrupted`. `principal` is the audit subject that created the run (`anonymous`/`operator` in open/static mode, the token `sub` or `client_id` in OIDC mode, namespaced by claim type as `sub:` / `client_id:` so no OIDC subject can collide with another claim type or with the open/static sentinels — never a token) and `correlation_id` is the request id attached at create time; both are `null` when unset. `interrupted` is the terminal state a run is reconciled to after a process death caught it mid-flight — its outcome is unknown, so it is neither `succeeded` nor `failed`, but it is never reported as permanently `running`. **A def failure is not an HTTP error** — the run object reports `status: "failed"` with the same failure narrative `jaiph mcp` returns, over HTTP `200`/`202`. Errors use `{error: {code, message}}` with `400 E_BAD_ARGS`, `401 E_UNAUTHORIZED` (missing or invalid static token; in OIDC mode, a request with no bearer token, or a verified token that carries neither `sub` nor `client_id`), `401 E_TOKEN_EXPIRED` / `401 E_TOKEN_INVALID` (OIDC token expired, or bad audience/issuer/key/signature/algorithm), `403 E_FORBIDDEN` (principal lacks the required capability), `404 E_NOT_FOUND`, `409 E_RUN_TERMINAL`, `409 E_IDEMPOTENCY_CONFLICT` (idempotency key reused with different arguments), `409 E_TAMPERED` (the run's journal failed its keyed integrity chain), `413 E_BODY_TOO_LARGE` (1 MiB request-body cap), `413 E_ARTIFACT_TOO_LARGE` (artifact download over `JAIPH_SERVE_MAX_ARTIFACT_BYTES`), `415` (non-`application/json` body), `429 E_TOO_MANY_RUNS`, and `503 E_AUTH_UNAVAILABLE` (OIDC identity provider / JWKS unreachable).
-Each run's public record is persisted beside its journal as `run.json` when it finishes, and reconstructed into the registry on startup — so `GET /v1/runs`, `/v1/runs/{id}`, `/events`, and `/artifacts` keep working for pre-restart terminal runs, and idempotency keys survive a restart. `jaiph serve` is a **single-replica** service: the run registry, concurrency cap, and idempotency index are per-process and not shared across replicas — run two behind one load balancer and each has its own view. See [Serve — deployment topology](serve.md#deployment-topology).
+Each run's public record is persisted beside its journal as `run.json` when it finishes, and reconstructed into the registry on startup — so `GET /runs`, `/runs/{id}`, `/events`, and `/artifacts` keep working for pre-restart terminal runs, and idempotency keys survive a restart. `jaiph serve` is a **single-replica** service: the run registry, concurrency cap, and idempotency index are per-process and not shared across replicas — run two behind one load balancer and each has its own view. See [Serve — deployment topology](serve.md#deployment-topology).
### Auth and limits
-- **Authentication** has two production modes (credentials come from the environment, never argv) plus an anonymous mode that is an explicit opt-in for a single-user workstation (`--allow-anonymous`). **Static single-operator token:** `JAIPH_SERVE_TOKEN` is a shared secret required on every `/v1/*` and `/mcp` request (`Authorization: Bearer `, constant-time compared). It is a fail-closed gate for **one operator** — no per-user identity, revocation, or per-action authorization; the operator holds every capability and sees every run — not multi-tenant authentication. **OIDC/JWT (multi-tenant):** set `JAIPH_SERVE_OIDC_ISSUER` + `JAIPH_SERVE_OIDC_AUDIENCE` (takes precedence over the static token; setting only one is a startup error) to verify bearer JWTs against the issuer's JWKS (discovered from `/.well-known/openid-configuration`, or set `JAIPH_SERVE_OIDC_JWKS_URI`) with a maintained JWT library — signature, `exp`/`nbf`, `aud`, `iss`, `kid`, and an explicit allowlist of asymmetric signing algorithms (RSA, ECDSA, and EdDSA families; symmetric algorithms, `alg: none`, and `ES256K` are rejected). Each token is authorized by OAuth scopes: `jaiph:invoke` (run), `jaiph:inspect` (read defs/runs/events/artifacts, MCP `tools/list`), `jaiph:cancel` (cancel a run); a missing capability is `403 E_FORBIDDEN`, and a principal (the token `sub`, or `client_id` for `sub`-less machine tokens; a verified token with neither is `401 E_UNAUTHORIZED`) may inspect or cancel **only the runs it created**. The authenticated subject and the request's correlation id (`X-Correlation-Id` / `X-Request-Id`, else a generated UUID) attach to run metadata, the invoke/cancel audit log lines, OTLP resource attributes, and Sentry tags — never a token or a claim value.
-- Binding a non-loopback `--host` with **no** authentication is a startup error, and `--allow-anonymous` does not lift it. On loopback with no token or OIDC, startup is also refused unless you pass `--allow-anonymous` — anonymous mode makes every caller the `anonymous` principal with all capabilities over all runs, so it is for a single-user workstation only and prints a startup warning when enabled.
+- **Authentication** is off by default (same as `jaiph mcp` stdio). Two production modes sit on top of that (credentials come from the environment, never argv). **Static single-operator token:** `JAIPH_SERVE_TOKEN` is a shared secret required on every REST and `/mcp` request (`Authorization: Bearer `, constant-time compared). It is a fail-closed gate for **one operator** — no per-user identity, revocation, or per-action authorization; the operator holds every capability and sees every run — not multi-tenant authentication. **OIDC/JWT (multi-tenant):** set `JAIPH_SERVE_OIDC_ISSUER` + `JAIPH_SERVE_OIDC_AUDIENCE` (takes precedence over the static token; setting only one is a startup error) to verify bearer JWTs against the issuer's JWKS (discovered from `/.well-known/openid-configuration`, or set `JAIPH_SERVE_OIDC_JWKS_URI`) with a maintained JWT library — signature, `exp`/`nbf`, `aud`, `iss`, `kid`, and an explicit allowlist of asymmetric signing algorithms (RSA, ECDSA, and EdDSA families; symmetric algorithms, `alg: none`, and `ES256K` are rejected). Each token is authorized by OAuth scopes: `jaiph:invoke` (run), `jaiph:inspect` (read defs/runs/events/artifacts, MCP `tools/list`), `jaiph:cancel` (cancel a run); a missing capability is `403 E_FORBIDDEN`, and a principal (the token `sub`, or `client_id` for `sub`-less machine tokens; a verified token with neither is `401 E_UNAUTHORIZED`) may inspect or cancel **only the runs it created**. The authenticated subject and the request's correlation id (`X-Correlation-Id` / `X-Request-Id`, else a generated UUID) attach to run metadata, the invoke/cancel audit log lines, OTLP resource attributes, and Sentry tags — never a token or a claim value.
+- With **no** authentication, every caller is the `anonymous` principal with all capabilities over all runs. Loopback starts in that mode with no flag. `--allow-anonymous` is only required to bind a non-loopback address without a token or OIDC, and then the server prints a startup warning. Shared or network-exposed hosts should set `JAIPH_SERVE_TOKEN` or configure OIDC.
- `JAIPH_SERVE_EXPOSE_DOCS` (default `true`) controls whether `/docs` and `/openapi.json` are served; set `false` (or `0`) to return `404` for both and hide the API surface. `/healthz` is always open and credential-free (liveness/readiness only — no tokens or sensitive detail).
- `JAIPH_SERVE_MAX_CONCURRENT` (default `4`) caps simultaneous runs; requests beyond it get `429`.
- `JAIPH_SERVE_MAX_ARTIFACT_BYTES` (default `0` = no cap) refuses artifact downloads larger than the limit with `413`. Downloads stream with backpressure regardless, so the default keeps server memory bounded no matter the file size; set a finite cap only to reject oversized downloads outright.
@@ -432,7 +433,7 @@ See [Environment variables](env-vars.md) for the complete inventory. The variabl
- `JAIPH_RUN_TIMEOUT` — parent-enforced wall-clock cap for a run.
- `JAIPH_NON_TTY_HEARTBEAT_FIRST_SEC`, `JAIPH_NON_TTY_HEARTBEAT_INTERVAL_MS` — non-TTY progress cadence.
-- `JAIPH_RUNS_DIR`, `JAIPH_WORKSPACE`, `JAIPH_SOURCE_FILE` — run-layout inputs.
+- `JAIPH_RUNS_DIR`, `JAIPH_SOURCE_FILE` — run-layout inputs. `JAIPH_WORKSPACE` is runner-managed; set it with `--workspace`, not directly.
- `JAIPH_INSTALL_COMMAND`, `JAIPH_REGISTRY`, `JAIPH_SKILL_PATH` — install / init inputs.
- `NO_COLOR` — disable ANSI colour output.
diff --git a/docs/configuration.md b/docs/configuration.md
index d5c6f7ff..a79814de 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -97,14 +97,14 @@ Informational metadata only; does not affect execution. Allowed in module-level
The `trusted_envs` config key is **removed** (`E_PARSE unknown config key`). Scripts are sterile by default: a script subprocess receives only process mechanics, the `JAIPH_*` script contract keys, and the host keys its own declaration requests with a `use` clause — and a `use` key crosses only when the operator granted it with `--env KEY[=VALUE]`:
```jaiph
-script release use GITHUB_TOKEN NPM_TOKEN = `gh release create …`
+script release use GITHUB_TOKEN NPM_TOKEN = 'gh release create …'
```
```sh
jaiph run --env GITHUB_TOKEN --env NPM_TOKEN publish.jh
```
-The `use` request lives on a script declaration (named `script`, `export script`, or `import script … as alias use KEY`) or on a [named prompt](language.md#named-prompts) definition (`prompt analyze(log) use GITHUB_TOKEN = …`) — never on defs, `run` / `prompt` call sites, or anonymous `prompt` steps. Anonymous prompt subprocesses keep the fail-closed `scrubPromptEnv` allowlist and never receive `--env` secrets in their child `env`; a named prompt's granted `use` keys are injected into its agent subprocess on top of that scrub. That is spawn-env, not a sandbox — see [Why Jaiph](why-jaiph.md). Recipe: [Pass a host key to a script](script-env.md). Also [Language — Subprocess environment](language.md#subprocess-environment) and [Environment variables](env-vars.md#script-env).
+The `use` request lives on a script declaration (named `script`, `export script`, or `import script … as alias use KEY`) or on a [named prompt](language.md#named-prompts) definition (`prompt analyze(log) use GITHUB_TOKEN = …`) — never on defs, call or `prompt` sites, or anonymous `prompt` steps. Anonymous prompt subprocesses keep the fail-closed `scrubPromptEnv` allowlist and never receive `--env` secrets in their child `env`; a named prompt's granted `use` keys are injected into its agent subprocess on top of that scrub. That is spawn-env, not a sandbox — see [Why Jaiph](why-jaiph.md). Recipe: [Pass a host key to a script](script-env.md). Also [Language — Subprocess environment](language.md#subprocess-environment) and [Environment variables](env-vars.md#script-env).
## Precedence
{: #precedence}
@@ -123,8 +123,8 @@ The `use` request lives on a script declaration (named `script`, `export script`
| Call type | Scope behaviour |
|---|---|
| Root entry (`jaiph run file.jh`) | Full module + def metadata applied with normal precedence. |
-| Same-module `run` | Callee's def-level `config` is layered on top of the caller's effective env. Module-level config is not re-applied. |
-| Cross-module `run` (e.g. `run alias.main()`) | Callee's module-level config is layered, then def-level on top — same as root-entry precedence, respecting `${NAME}_LOCKED`. **`agent.command` and `agent.backend` are not applied from imported modules** (see [Import trust boundary](#import-trust-boundary)). |
+| Same-module call | Callee's def-level `config` is layered on top of the caller's effective env. Module-level config is not re-applied. |
+| Cross-module call (e.g. `alias.main()`) | Callee's module-level config is layered, then def-level on top — same as root-entry precedence, respecting `${NAME}_LOCKED`. **The entry-module-only keys (`agent.command`, `agent.backend`, `agent.trusted_workspace`, `agent.cursor_flags`, `agent.claude_flags`, `run.logs_dir`) are not applied from imported modules** (see [Import trust boundary](#import-trust-boundary)). |
After any nested call returns, the caller's scope is restored exactly as before.
@@ -140,18 +140,24 @@ Locked names: `JAIPH_AGENT_BACKEND`, `JAIPH_AGENT_MODEL`, `JAIPH_AGENT_COMMAND`,
`agent.command` and `agent.backend` are **execution-binary keys** — they determine which process runs `prompt` steps. To prevent a third-party `.jh` library from silently redirecting execution to a different binary, these two keys may only be set from the **entry module's** `config {}` block (module-level or def-level). Imported modules that declare `agent.command` or `agent.backend` in their `config {}` are silently ignored for these keys.
+`agent.trusted_workspace`, `agent.cursor_flags`, `agent.claude_flags`, and `run.logs_dir` carry the same **entry-module-only** restriction. They shape the agent trust path (passed to Cursor as `--trust`), the agent argv (`cursor_flags` / `claude_flags` are appended to the invocation), and the run directory — so an imported module setting them could redirect trust, alter the agent command line, or move run logs for its own `prompt` steps. Imported modules that declare these keys in their `config {}` are silently ignored for them.
+
Host secrets carry a related boundary: a script — imported or local — receives a host key only through its own `use` clause **and** an explicit operator `--env` grant, so a library cannot pull host secrets into its steps without the operator naming each key on the command line (see [Script env keys](#trusted-envs)).
-All other config keys (`agent.model`, `agent.trusted_workspace`, `agent.cursor_flags`, `agent.claude_flags`, `run.logs_dir`, `run.debug`) are not restricted and follow the normal scoping rules for cross-module calls.
+The remaining config keys (`agent.model`, `run.debug`) are not restricted and follow the normal scoping rules for cross-module calls.
-**Advanced unlock (use with caution):** to allow an imported module to override these keys, set one or both of the following environment variables before the run:
+**Advanced unlock (use with caution):** to allow an imported module to override an entry-module-only key, set the matching environment variable before the run:
| Variable | Effect |
|---|---|
| `JAIPH_AGENT_COMMAND_IMPORT_UNLOCK=1` | Allow any imported module to set `agent.command`. |
| `JAIPH_AGENT_BACKEND_IMPORT_UNLOCK=1` | Allow any imported module to set `agent.backend`. |
+| `JAIPH_AGENT_TRUSTED_WORKSPACE_IMPORT_UNLOCK=1` | Allow any imported module to set `agent.trusted_workspace`. |
+| `JAIPH_AGENT_CURSOR_FLAGS_IMPORT_UNLOCK=1` | Allow any imported module to set `agent.cursor_flags`. |
+| `JAIPH_AGENT_CLAUDE_FLAGS_IMPORT_UNLOCK=1` | Allow any imported module to set `agent.claude_flags`. |
+| `JAIPH_RUNS_DIR_IMPORT_UNLOCK=1` | Allow any imported module to set `run.logs_dir`. |
-The existing `JAIPH_AGENT_COMMAND_LOCKED=1` / `JAIPH_AGENT_BACKEND_LOCKED=1` flags still apply on top — a locked key cannot be changed regardless of the source.
+The matching `${NAME}_LOCKED=1` flag still applies on top — a locked key cannot be changed regardless of the source.
## Config-to-env mapping
@@ -210,7 +216,7 @@ Resolution order for a `prompt` step:
| 3 | Flags model — `--model ` inside `agent.cursor_flags` / `agent.claude_flags`. | `model_reason: flags`. Codex has no flag channel; this step does not apply. |
| 4 | Backend default — Cursor/Claude binaries pick their own. Codex defaults to `gpt-4o` in code. | `model_reason: backend-default`. |
-For the Claude backend, when `agent.model` is set and `agent.claude_flags` does not already contain `--model`, Jaiph passes `--model ` to the Claude CLI automatically. If both are set, the value in `agent.claude_flags` wins (appended last).
+For the Claude backend, when `agent.model` is set and `agent.claude_flags` does not already contain `--model`, Jaiph passes `--model ` to the Claude CLI automatically. When `agent.claude_flags` already contains its own `--model`, Jaiph does not add a second one, so the model in `agent.claude_flags` is the one the Claude CLI receives. When both are set this way, the `PROMPT_START` / `PROMPT_END` records still carry `model_reason: explicit` with the `agent.model` value, even though the Claude CLI ran with the model from `agent.claude_flags`.
`PROMPT_START` / `PROMPT_END` records in `run_summary.jsonl` carry `model` (resolved string, or null when the backend auto-selects) and `model_reason` (`explicit`, `flags`, `backend-default`, or `none` for a [custom agent command](#custom-agent-commands), which has no model concept).
diff --git a/docs/configure-backend.md b/docs/configure-backend.md
index d62067ac..33fbe3a9 100644
--- a/docs/configure-backend.md
+++ b/docs/configure-backend.md
@@ -22,7 +22,7 @@ Add a module-level `config { … }` block at the top of your `.jh` file:
```jh
config {
agent.backend = "claude"
- agent.model = "sonnet-4"
+ agent.model = "sonnet"
}
export def main() {
@@ -31,9 +31,9 @@ export def main() {
}
```
-The valid backend values are `"cursor"` (the default), `"claude"`, and `"codex"`. The model string is forwarded to the backend, so use a name the backend recognizes (e.g. `gpt-4o` for codex, `sonnet-4` for claude).
+The valid backend values are `"cursor"` (the default), `"claude"`, and `"codex"`. The model string is forwarded to the backend, so use a name the backend recognizes (e.g. `gpt-4o` for codex, `sonnet` for claude).
-Set `agent.backend` (and `agent.command`) only from the entry file. Jaiph ignores these two keys when an imported module sets them in its own `config { … }`, so a third-party module cannot redirect your `prompt` steps to a different binary. See [Import trust boundary](configuration.md#import-trust-boundary).
+Set `agent.backend` (and `agent.command`) only from the entry file. By default, Jaiph ignores these two keys when an imported module sets them in its own `config { … }`, so a third-party module cannot redirect your `prompt` steps to a different binary. See [Import trust boundary](configuration.md#import-trust-boundary).
## 2. Override per-def
@@ -45,7 +45,7 @@ def fast_check() {
agent.backend = "cursor"
agent.model = "gpt-3.5"
}
- run review()
+ review()
}
```
@@ -55,7 +55,7 @@ A def-level block can set `agent.*` and `run.*` keys. The `module.*` keys are mo
```bash
export JAIPH_AGENT_BACKEND="claude"
-export JAIPH_AGENT_MODEL="sonnet-4"
+export JAIPH_AGENT_MODEL="sonnet"
jaiph run ./flow.jh
```
@@ -74,9 +74,11 @@ export JAIPH_CODEX_API_URL="https://api.example.com/v1/chat/completions"
Each `prompt` step records the resolved backend and model in `run_summary.jsonl`. After the run, inspect the first `PROMPT_START` line:
```bash
-jq -c 'select(.type=="PROMPT_START")' .jaiph/runs//