diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..ab8d24d6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,204 @@ +name: Release + +on: + push: + tags: + - "v*" + branches: + - nightly + workflow_dispatch: + +jobs: + ci-gate: + name: Wait for CI to pass on this ref + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Wait for CI workflow conclusion on ${{ github.sha }} + run: | + set -euo pipefail + # Poll up to 60 minutes for the matching CI run on this SHA. + for i in $(seq 1 120); do + json="$(gh run list --workflow CI --commit "${GITHUB_SHA}" --limit 1 --json status,conclusion 2>/dev/null || echo '[]')" + count="$(echo "${json}" | jq 'length')" + if [ "${count}" -eq 0 ]; then + echo "[${i}/120] No CI run yet for ${GITHUB_SHA}" + sleep 30 + continue + fi + status="$(echo "${json}" | jq -r '.[0].status')" + conclusion="$(echo "${json}" | jq -r '.[0].conclusion')" + if [ "${status}" = "completed" ]; then + if [ "${conclusion}" = "success" ]; then + echo "CI succeeded for ${GITHUB_SHA}." + exit 0 + fi + echo "CI for ${GITHUB_SHA} completed with conclusion: ${conclusion}" + exit 1 + fi + echo "[${i}/120] CI status=${status}" + sleep 30 + done + echo "Timed out waiting for CI on ${GITHUB_SHA}." + exit 1 + + build: + name: Build ${{ matrix.os }}-${{ matrix.arch }} + needs: ci-gate + runs-on: ubuntu-latest + strategy: + fail-fast: true + matrix: + include: + - target: bun-darwin-arm64 + os: darwin + arch: arm64 + - target: bun-darwin-x64 + os: darwin + arch: x64 + - target: bun-linux-x64 + os: linux + arch: x64 + - target: bun-linux-arm64 + os: linux + arch: arm64 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: npm ci + + - name: Build TypeScript + embed assets + run: npm run build + + - name: Cross-compile standalone binary for ${{ matrix.target }} + run: | + set -euo pipefail + bun build --compile --target=${{ matrix.target }} ./src/cli.ts --outfile "jaiph-${{ matrix.os }}-${{ matrix.arch }}" + ls -la "jaiph-${{ matrix.os }}-${{ matrix.arch }}" + + - name: Upload binary artifact + uses: actions/upload-artifact@v4 + with: + name: jaiph-${{ matrix.os }}-${{ matrix.arch }} + path: jaiph-${{ matrix.os }}-${{ matrix.arch }} + if-no-files-found: error + retention-days: 7 + + release: + name: Publish release assets + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Resolve tag and channel + id: meta + run: | + set -euo pipefail + case "${GITHUB_REF}" in + refs/tags/v*) + tag="${GITHUB_REF_NAME}"; channel="stable" ;; + refs/heads/nightly|refs/tags/nightly) + tag="nightly"; channel="nightly" ;; + *) + echo "Unsupported ref for release: ${GITHUB_REF}" >&2; exit 1 ;; + esac + echo "tag=${tag}" >> "${GITHUB_OUTPUT}" + echo "channel=${channel}" >> "${GITHUB_OUTPUT}" + + - name: Download binary artifacts + uses: actions/download-artifact@v4 + with: + path: release-assets + merge-multiple: true + + - name: Generate SHA256SUMS + working-directory: release-assets + run: | + set -euo pipefail + ls -la + rm -f SHA256SUMS + sha256sum jaiph-darwin-arm64 jaiph-darwin-x64 jaiph-linux-x64 jaiph-linux-arm64 > SHA256SUMS + cat SHA256SUMS + + - name: Sanity gate (linux-x64 --version) + working-directory: release-assets + run: | + set -euo pipefail + chmod +x jaiph-linux-x64 + got="$(./jaiph-linux-x64 --version)" + echo "got: ${got}" + if [ "${{ steps.meta.outputs.channel }}" = "stable" ]; then + tag="${{ steps.meta.outputs.tag }}" + expected="jaiph ${tag#v}" + if [ "${got}" != "${expected}" ]; then + echo "Version sanity check failed: expected '${expected}', got '${got}'" >&2 + exit 1 + fi + else + if ! printf '%s\n' "${got}" | grep -Eq '^jaiph [0-9]+\.[0-9]+\.[0-9]+'; then + echo "Version sanity check failed: '${got}' does not look like a jaiph version" >&2 + exit 1 + fi + fi + + - name: Publish stable release ${{ steps.meta.outputs.tag }} + if: steps.meta.outputs.channel == 'stable' + working-directory: release-assets + run: | + set -euo pipefail + tag="${{ steps.meta.outputs.tag }}" + if gh release view "${tag}" >/dev/null 2>&1; then + gh release upload "${tag}" --clobber \ + jaiph-darwin-arm64 jaiph-darwin-x64 \ + jaiph-linux-x64 jaiph-linux-arm64 \ + SHA256SUMS + else + gh release create "${tag}" \ + --title "${tag}" \ + --notes "Jaiph ${tag} — standalone binaries (darwin/linux × arm64/x64) plus SHA256SUMS." \ + jaiph-darwin-arm64 jaiph-darwin-x64 \ + jaiph-linux-x64 jaiph-linux-arm64 \ + SHA256SUMS + fi + + - name: Publish nightly prerelease + if: steps.meta.outputs.channel == 'nightly' + working-directory: release-assets + run: | + set -euo pipefail + if gh release view nightly >/dev/null 2>&1; then + gh release upload nightly --clobber \ + jaiph-darwin-arm64 jaiph-darwin-x64 \ + jaiph-linux-x64 jaiph-linux-arm64 \ + SHA256SUMS + else + gh release create nightly \ + --title "Nightly" \ + --notes "Rolling nightly prerelease — standalone binaries built from the latest \`nightly\` branch." \ + --prerelease \ + --target "${GITHUB_SHA}" \ + jaiph-darwin-arm64 jaiph-darwin-x64 \ + jaiph-linux-x64 jaiph-linux-arm64 \ + SHA256SUMS + fi diff --git a/.gitignore b/.gitignore index b15d9eec..0b049cf9 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,10 @@ docs/.bundle/ dist/ .tmp/ +# Generated by tools/embed-assets.js from package.json's `version` field, so +# the CLI version lives in only one place in tree (package.json). +src/version.ts + # logs *.log npm-debug.log* diff --git a/.jaiph/architect_review.jh b/.jaiph/architect_review.jh index 22fa919b..552663cd 100755 --- a/.jaiph/architect_review.jh +++ b/.jaiph/architect_review.jh @@ -1,6 +1,7 @@ #!/usr/bin/env jaiph import "jaiphlang/queue" as queue +import "./lib_common.jh" as common config { agent.backend = "cursor" @@ -10,29 +11,8 @@ config { # agent.claude_flags = "--permission-mode bypassPermissions" } -script first_line_str = `printf '%s\n' "$1" | head -n 1` - -script rest_lines_str = `printf '%s\n' "$1" | tail -n +2` - -script arg_nonempty = `[ -n "$1" ]` - -script str_equals = `[ "$1" = "$2" ]` - -script mkdir_p_simple = `mkdir -p "$1"` - -script jaiph_tmp_dir = `printf '%s\n' "$JAIPH_WORKSPACE/.jaiph/tmp"` - script jaiph_review_body_file = `printf '%s\n' "$JAIPH_WORKSPACE/.jaiph/tmp/architect_review_body.txt"` -# Writes UTF-8 text to a path (path, then content). -script save_string_to_file = ```python3 -import sys -if len(sys.argv) < 3: - sys.exit(2) -path, content = sys.argv[1], sys.argv[2] -open(path, "w", encoding="utf-8").write(content) -``` - # Packed as: first line = verdict, rest = updated_description (must stay top-level: # const … = prompt """…""" is not supported inside ensure … catch — see parseRecoverStatement). workflow architect_agent_review(task) { @@ -93,31 +73,31 @@ workflow architect_agent_review(task) { } workflow review_one_header(header) { - run arg_nonempty(header) catch (err) { + run common.arg_nonempty(header) catch (err) { return "" } const task = run queue.get_task_by_header(header) ensure queue.task_is_dev_ready(task) catch (err) { const packed = run architect_agent_review(task) - const verdict = run first_line_str(packed) - const updated_description = run rest_lines_str(packed) + const verdict = run common.first_line_str(packed) + const updated_description = run common.rest_lines_str(packed) const body_file = run jaiph_review_body_file() - run mkdir_p_simple(run jaiph_tmp_dir()) - run str_equals(verdict, "dev-ready") catch (err) { - run arg_nonempty(updated_description) catch (err) { + run common.mkdir_p_simple(run common.jaiph_tmp_dir()) + run common.str_equals(verdict, "dev-ready") catch (err) { + run common.arg_nonempty(updated_description) catch (err) { fail "needs-work requires a non-empty updated_description (questions for the author)." } - run save_string_to_file(body_file, updated_description) + run common.save_string_to_file(body_file, updated_description) run queue.set_task_description_from_file(header, body_file) log "Needs work (description updated): ${header}" return "" } - run arg_nonempty(updated_description) catch (err) { + run common.arg_nonempty(updated_description) catch (err) { run queue.mark_task_dev_ready(header) log "Marked dev-ready: ${header}" return "" } - run save_string_to_file(body_file, updated_description) + run common.save_string_to_file(body_file, updated_description) run queue.set_task_description_from_file(header, body_file) run queue.mark_task_dev_ready(header) log "Marked dev-ready: ${header}" @@ -128,16 +108,16 @@ workflow review_one_header(header) { workflow process_headers_recursive(header, remaining) { run review_one_header(header) - run arg_nonempty(remaining) catch (err) { + run common.arg_nonempty(remaining) catch (err) { return "" } - const next = run first_line_str(remaining) - const rest = run rest_lines_str(remaining) + const next = run common.first_line_str(remaining) + const rest = run common.rest_lines_str(remaining) run process_headers_recursive(next, rest) } workflow maybe_process_headers(first, rest) { - run arg_nonempty(first) catch (err) { + run common.arg_nonempty(first) catch (err) { return "" } run process_headers_recursive(first, rest) @@ -145,8 +125,8 @@ workflow maybe_process_headers(first, rest) { workflow default() { const headers = run queue.get_all_task_headers() - const first = run first_line_str(headers) - const rest = run rest_lines_str(headers) + const first = run common.first_line_str(headers) + const rest = run common.rest_lines_str(headers) run maybe_process_headers(first, rest) ensure queue.all_dev_ready() catch (err) { fail "One or more tasks need work. Review the agent output above." diff --git a/.jaiph/docs_parity.jh b/.jaiph/docs_parity.jh index 30bf62cc..143911e6 100755 --- a/.jaiph/docs_parity.jh +++ b/.jaiph/docs_parity.jh @@ -1,22 +1,16 @@ #!/usr/bin/env jaiph const role = """ - You are an expert technical writer for this project. - 1. You are fluent in Markdown and can read TypeScript code and Bash - 2. You write for a developer audience, focusing on clarity and practical - examples. - 3. You are concise, specific, and value dense - 4. Write so that a new developer to this codebase can understand your - writing, but don't assume your audience are experts in the topic/area you - are writing about. - 5. You are good in formulating generic context and describing the problem - starting from the generic part, leaving the specific details for the - last step, once the audience is aware of the generic context and the - problem. - 6. You write problem explanation and goals in a human approachable way, - while keeping details dense in separate sections, so both human and AI - 7. Source code and docs/architecture.md are the single source of truth. You don't - trust the existing documentation blindly. + Project-specific context for documenting Jaiph: + - You read TypeScript and Bash fluently so you can verify documentation + against the implementation. + - Source code and docs/architecture.md are the single source of truth. + Do not trust existing documentation blindly; verify claims against the + code before reproducing them. + - Navigation links between docs pages are provided by the Jekyll template + (docs/_layouts/docs.html). Do not add manual navigation blocks (e.g. + "More Documentation" sections) to individual markdown pages — inline + contextual links to other docs are fine. """ script assert_newline_paths_are_files = ``` @@ -100,6 +94,11 @@ script build_allowed_paths_block = ``` workflow update_from_task(taskDesc) { prompt """ + Before doing anything else, read and follow the documentation skill at + .jaiph/skills/documentation-writer/SKILL.md. It defines the Diátaxis + framework, the four document types, the clarify -> outline -> write + workflow, and the four guiding principles (clarity, accuracy, + user-centricity, consistency) you must apply to this task. ${role} @@ -123,6 +122,11 @@ workflow update_from_task(taskDesc) { workflow docs_page(path) { prompt """ + Before doing anything else, read and follow the documentation skill at + .jaiph/skills/documentation-writer/SKILL.md. It defines the Diátaxis + framework, the four document types, the clarify -> outline -> write + workflow, and the four guiding principles (clarity, accuracy, + user-centricity, consistency) you must apply to this task. ${role} @@ -149,11 +153,16 @@ workflow docs_page(path) { individual markdown pages. Inline contextual links to other docs are fine. -""" + """ } workflow docs_overview(docPaths) { prompt """ + Before doing anything else, read and follow the documentation skill at + .jaiph/skills/documentation-writer/SKILL.md. It defines the Diátaxis + framework, the four document types, the clarify -> outline -> write + workflow, and the four guiding principles (clarity, accuracy, + user-centricity, consistency) you must apply to this task. ${role} @@ -197,7 +206,7 @@ workflow docs_overview(docPaths) { 10.Ensure src/cli/shared/usage.ts is up to date with the latest CLI commands and options. It should be a single source of truth for the CLI usage. -""" + """ } workflow default() { diff --git a/.jaiph/docs_parity_redesign.jh b/.jaiph/docs_parity_redesign.jh new file mode 100755 index 00000000..42df461a --- /dev/null +++ b/.jaiph/docs_parity_redesign.jh @@ -0,0 +1,202 @@ +#!/usr/bin/env jaiph + +# Redesign-aware variant of docs_parity.jh, meant to be run BY HAND after the +# Diátaxis docs redesign (QUEUE.md "Docs redesign" tasks 1-7) has landed. +# +# Differences from docs_parity.jh: +# - Lists docs recursively and EXCLUDES docs/_legacy/ (the build-excluded +# quarantine of the pre-redesign pages). The stock workflow globs only the +# flat docs/*.md and would both miss nested pages and risk touching legacy. +# - The overview pass VERIFIES and tightens the new Diátaxis structure against +# the source code; it does NOT merge / split / move / re-quadrant pages the +# way the stock docs_overview does (that would undo the redesign). +# - Docs-only: it never edits src/ or usage.ts. +# +# Run on a clean worktree: jaiph run .jaiph/docs_parity_redesign.jh + +const role = """ + Project-specific context for documenting Jaiph: + - You read TypeScript and Bash fluently so you can verify documentation + against the implementation. + - Source code and docs/architecture.md are the single source of truth. + Do not trust existing documentation blindly; verify claims against the + code before reproducing them. + - Navigation links between docs pages are provided by the Jekyll template + (docs/_layouts/docs.html). Do not add manual navigation blocks (e.g. + "More Documentation" sections) to individual markdown pages — inline + contextual links to other docs are fine. + - docs/_legacy/ is a build-excluded quarantine of the OLD documentation. + Never read it as a source, never edit it, never resurrect its pages. +""" + +script assert_worktree_clean_for_docs = ``` + local current_changed_files + current_changed_files="$( + { + git diff --name-only --cached + git diff --name-only + git ls-files --others --exclude-standard + } | sort -u + )" + if [ -n "$current_changed_files" ]; then + echo "Refusing to run docs parity workflow on a dirty worktree." >&2 + echo "Please commit, stash, or discard these files first:" >&2 + echo "$current_changed_files" >&2 + return 1 + fi +``` + +rule worktree_is_clean() { + run assert_worktree_clean_for_docs() +} + +script assert_newline_paths_are_files = ``` + while IFS= read -r f; do + f="${f#"${f%%[![:space:]]*}"}" + f="${f%"${f##*[![:space:]]}"}" + [ -z "$f" ] && continue + test -f "$f" || return 1 + done <<< "$1" +``` + +rule docs_files_present(list) { + run assert_newline_paths_are_files(list) +} + +# Pattern-based allowlist (not a frozen snapshot): permit the doc entry points +# and ANY docs/**/*.md page — so pages the prompt legitimately CREATES still +# pass — while rejecting source, tests, .jaiph/, scratch files, and the +# quarantine / vendored / generated trees. +script assert_only_allowed_changed = ``` + local after_changed_files + after_changed_files="$( + { + git diff --name-only --cached + git diff --name-only + git ls-files --others --exclude-standard + } | sort -u + )" + while IFS= read -r changed_file; do + [ -z "$changed_file" ] && continue + case "$changed_file" in + README.md|CHANGELOG.md|docs/index.html|docs/_layouts/docs.html|docs/_config.yml) + continue ;; + esac + if printf '%s\n' "$changed_file" | grep -qE '^docs/.*\.md$' \ + && ! printf '%s\n' "$changed_file" | grep -qE '(^|/)docs/(_legacy|vendor|_site)/'; then + continue + fi + echo "Unexpected file changed by docs prompt: $changed_file" >&2 + return 1 + done <<< "$after_changed_files" +``` + +rule only_expected_docs_changed_after_prompt() { + run assert_only_allowed_changed() +} + +# Recursive list of published docs pages, excluding quarantine, Jekyll output, +# and Bundler's docs/vendor/ tree. BSD/macOS find treats * in -path as not +# crossing '/', so prune -path 'docs/vendor/*' misses nested gem READMEs; use +# grep instead of case (POSIX case patterns do not let * match '/' either). +script list_docs_md_paths = ``` + find docs -type f -name '*.md' -print \ + | grep -vE '(^|/)docs/(_legacy|vendor|_site)/' \ + | sort +``` + +# Files the parity prompts are permitted to change (docs only — never src). +script build_allowed_paths_block = ``` + { + printf '%s\n' README.md CHANGELOG.md docs/index.html docs/_layouts/docs.html docs/_config.yml + find docs -type f -name '*.md' -print \ + | grep -vE '(^|/)docs/(_legacy|vendor|_site)/' + } | sort -u +``` + +workflow docs_page(path) { + prompt """ + Before doing anything else, read and follow the documentation skill at + .jaiph/skills/documentation-writer/SKILL.md. It defines the Diátaxis + framework, the four document types, the clarify -> outline -> write + workflow, and the four guiding principles (clarity, accuracy, + user-centricity, consistency) you must apply to this task. + + ${role} + + + Verify ${path} against the Jaiph source code (the single source of truth) + and docs/architecture.md. + 0. This page belongs to a fixed Diátaxis quadrant declared in its + `diataxis:` front matter (tutorial / how-to / reference / explanation / + contributor). KEEP that type. Do NOT move content to or from other pages, + do NOT change the permalink, do NOT merge or split the page. + 1. Verify every factual claim, example, flag, config key, env var, and error + code against the source. Fix drift in the page to match the code. + 2. Repair cross-type bleed WITHIN the page only (e.g. delete a tutorial-style + walkthrough that crept into a reference page) — relocating it is out of + scope for this pass. + 3. Keep examples executable and aligned with current behavior. Keep prose + approachable, concise, and free of AI-like filler and excess emojis. + 4. Inline contextual links to other docs are fine; do NOT add manual + navigation blocks. Never touch docs/_legacy/. + 5. Edit ONLY this documentation page. Never edit source, tests + (*.test.ts), config, or anything under .jaiph/, and never create helper + or scratch scripts (e.g. *.mjs, *.sh) — make every change directly in + the documentation file. + + """ +} + +workflow docs_redesign_overview(docPaths) { + prompt """ + Before doing anything else, read and follow the documentation skill at + .jaiph/skills/documentation-writer/SKILL.md (Diátaxis: tutorials, how-to, + reference, explanation). + + ${role} + + + The docs were DELIBERATELY restructured into Diátaxis quadrants. Your job is + to VERIFY and tighten that structure — NOT to reorganize it. Read all + ${docPaths} (these already exclude docs/_legacy/). Treat docs/architecture.md + as the architecture source of truth. + + PRESERVE the structure. Do NOT merge, split, move, rename, or re-quadrant + pages; do NOT change permalinks; do NOT restructure the nav in + docs/_layouts/docs.html beyond fixing an outright error. Specifically: + 1. Cross-page consistency: terminology, tone, and overlapping facts agree + across pages, and every page is consistent with docs/architecture.md + (runtime vs CLI responsibilities, __JAIPH_EVENT__ vs run artifacts, + channels/hooks, the jaiph test lane). + 2. Each page stays within its `diataxis:` type; flag (do not relocate) + any remaining cross-type bleed. + 3. Reference pages (cli, configuration, grammar, language, env-vars) match + the source exactly — every flag, key, env var, error code. Fix the docs. + 4. README.md and docs/index.html lead with the tutorials / how-to entry + points and link to getting-started (or the first tutorial) and the agent + skill URL, hardcoded as + https://raw.githubusercontent.com/jaiphlang/jaiph/refs/heads/main/docs/jaiph-skill.md. + Markdown-to-markdown links end with .md; index.html links do not. + 5. Edit documentation files ONLY (docs/**/*.md, README.md, CHANGELOG.md, + docs/index.html, docs/_layouts/docs.html, docs/_config.yml). Never edit + src/, tests (*.test.ts), or anything under .jaiph/, and never create + helper or scratch scripts (e.g. *.mjs, *.sh) — make every change + directly in the documentation files. Never touch docs/_legacy/. + + """ +} + +workflow default() { + ensure worktree_is_clean() + const allowed_list = run build_allowed_paths_block() + ensure docs_files_present(allowed_list) + const docs_md_list = run list_docs_md_paths() + for path in docs_md_list { + if path != "" { + run docs_page(path) + } + } + run docs_redesign_overview(docs_md_list) + ensure only_expected_docs_changed_after_prompt() +} diff --git a/.jaiph/engineer.jh b/.jaiph/engineer.jh index e2dc4c11..96280304 100755 --- a/.jaiph/engineer.jh +++ b/.jaiph/engineer.jh @@ -4,11 +4,12 @@ # Picks the first pending task from QUEUE.md, implements it, verifies CI, # updates docs, removes from queue, and publishes a workspace patch artifact. # -import "jaiphlang/queue" as queue import "jaiphlang/artifacts" as artifacts +import "jaiphlang/git" as git +import "jaiphlang/queue" as queue import "./docs_parity.jh" as docs import "./ensure_ci_passes.jh" as ci -import "jaiphlang/git" as git +import "./lib_common.jh" as common config { # agent.backend = "cursor" @@ -18,6 +19,30 @@ config { agent.claude_flags = "--permission-mode bypassPermissions" } +const no_nested_orchestration = "Never invoke orchestration workflows (.jaiph/*.jh) or launch nested agent sessions" + +const safety_constraints = """ + Hard safety constraint (non-negotiable): + - NEVER invoke Jaiph workflows from the .jaiph directory. + - Forbidden examples: jaiph .jaiph/engineer.jh, jaiph run .jaiph/engineer.jh, + jaiph .jaiph/docs_parity.jh, or any jaiph command targeting .jaiph/*.jh. + - Treat .jaiph/*.jh as orchestration-only workflows that must not be called + from inside this implementation prompt. + - NEVER launch a nested Claude/Cursor agent session from inside this workflow. + Nested sessions share runtime resources and can crash active sessions. + - Do not attempt to bypass nested-session guards (for example by unsetting + environment variables such as CLAUDECODE). + - Any violation of these constraints is an immediate task failure; stop and report. +""" + +const definition_of_done = """ + Definition of done (QUEUE.md rule 7, verbatim): + "Acceptance criteria are non-negotiable. A task is not done until every + acceptance bullet is verified by a test that fails when the contract is + violated. 'It works on my machine' or 'the existing tests pass' is not + acceptance." +""" + const code_philosophy = """ This codebase is maintained by both humans and AI agents. All code you write must follow these principles strictly: @@ -71,7 +96,7 @@ const role_surgical = """ * Default to touching as few files as possible * Do NOT redesign surrounding architecture * Do NOT add abstractions unless clearly required by acceptance criteria - * Never invoke orchestration workflows (.jaiph/*.jh) or launch nested agent sessions + * ${no_nested_orchestration} """ const role_reductionist = """ @@ -91,7 +116,7 @@ const role_reductionist = """ * Actively remove dead code, duplicate branches, and unnecessary indirection * Prefer net-negative or near-neutral code growth when feasible * If adding code is unavoidable, justify why deletion/simplification was insufficient - * Never invoke orchestration workflows (.jaiph/*.jh) or launch nested agent sessions + * ${no_nested_orchestration} """ const role_optimizer = """ @@ -110,7 +135,7 @@ const role_optimizer = """ * Every structural change must have a concrete before/after justification * Do NOT rework areas outside the task's scope, even if they look improvable * Avoid speculative complexity that does not produce measurable benefit - * Never invoke orchestration workflows (.jaiph/*.jh) or launch nested agent sessions + * ${no_nested_orchestration} """ const role_stabilizer = """ @@ -130,7 +155,7 @@ const role_stabilizer = """ * Add or improve tests for risky paths and boundary conditions * Keep implementation simple, defensive, and observable * Avoid structural rewrites unless strictly required to satisfy acceptance criteria - * Never invoke orchestration workflows (.jaiph/*.jh) or launch nested agent sessions + * ${no_nested_orchestration} """ const classification_prompt = """ @@ -167,8 +192,6 @@ workflow select_role(role_name) { } } -script arg_nonempty = `[ -n "${1:-}" ]` - script task_text_has_header = `printf '%s\n' "$1" | grep -q '^## '` script first_line_task = ``` @@ -191,7 +214,17 @@ workflow classify_role(task) { """ returns "{ role: string }" - return result.role + # Normalize the free-text classifier answer (case, extra words like + # "surgical engineer") to a canonical role name before select_role. + const role_raw = "${result.role}" + const role_lc = run common.to_lower(role_raw) + return match role_lc { + /surgical/ => "surgical" + /reduction/ => "reductionist" + /optimi/ => "optimizer" + /stabili/ => "stabilizer" + _ => fail "Classifier returned unrecognized role: ${role_lc}" + } } workflow implement(task, role_name) { @@ -224,23 +257,14 @@ workflow implement(task, role_name) { before continuing. - Ensuring all acceptance criteria in the task are met. + ${definition_of_done} + Tests and validation: - Unit/integration: npm test - End-to-end: npm run test:e2e - Build check: npm run build - Hard safety constraint (non-negotiable): - - NEVER invoke Jaiph workflows from the .jaiph directory. - - Forbidden examples: jaiph .jaiph/engineer.jh, jaiph run .jaiph/engineer.jh, - jaiph .jaiph/main.jh, jaiph .jaiph/docs_parity.jh, or any jaiph command - targeting .jaiph/*.jh. - - Treat .jaiph/*.jh as orchestration-only workflows that must not be called - from inside this implementation prompt. - - NEVER launch a nested Claude/Cursor agent session from inside this workflow. - Nested sessions share runtime resources and can crash active sessions. - - Do not attempt to bypass nested-session guards (for example by unsetting - environment variables such as CLAUDECODE). - - Any violation of these constraints is an immediate task failure; stop and report. + ${safety_constraints} Test stability policy: - e2e/tests/* and acceptance JS tests are behavior contracts and should be diff --git a/.jaiph/ensure_ci_passes.jh b/.jaiph/ensure_ci_passes.jh index 5165d227..21765b68 100755 --- a/.jaiph/ensure_ci_passes.jh +++ b/.jaiph/ensure_ci_passes.jh @@ -1,18 +1,14 @@ #!/usr/bin/env jaiph +import "./lib_common.jh" as common + config { agent.backend = "cursor" agent.cursor_flags = "--force" } -rule ci_passes() { - run npm_run_test_ci() -} - script npm_run_test_ci = `npm run test:ci` -script save_string_to_file = `echo "$1" > "$2"` - script assert_nonempty_file_or_fail = ``` test -s "$1" || { echo "jaiph: ci failure log is empty at $1" >&2 @@ -23,42 +19,37 @@ test -s "$1" || { workflow ensure_ci_passes() { const ci_log_dir = ".jaiph/tmp" const ci_log_file = "${ci_log_dir}/ensure_ci_passes.last.log" - run mkdir_p_simple(ci_log_dir) + run common.mkdir_p_simple(ci_log_dir) - ensure ci_passes() catch (failure) { - run save_string_to_file(failure, ci_log_file) + # recover = repair-and-retry loop: run the CI script, on failure save the + # log and prompt for a fix, then retry — bounded by run.recover_limit + # (default 10) instead of unbounded workflow recursion. + run npm_run_test_ci() recover (failure) { + run common.save_string_to_file(ci_log_file, failure) run assert_nonempty_file_or_fail(ci_log_file) - prompt """ You are a software engineer fixing a failing CI build. - Fix failing CI so npm run test:ci passes. Failure output was saved to: - ${ci_log_file}. Start by inspecting the tail of the log (for example: - tail -n 200 '${ci_log_file}') and then apply the smallest safe fix. - Constraints: - e2e/tests/* and acceptance JS tests are behavior + Fix failing CI so npm run test:ci passes. Failure output was saved to: + ${ci_log_file}. Start by inspecting the tail of the log (for example: + tail -n 200 '${ci_log_file}') and then apply the smallest safe fix. + Constraints: - e2e/tests/* and acceptance JS tests are behavior contracts. - - Default approach: change production code to satisfy existing tests, + - Default approach: change production code to satisfy existing tests, not vice versa. - - Modify tests only for intentional behavior changes, incorrect + - Modify tests only for intentional behavior changes, incorrect expectations, or removal of obsolete features. - Any test change must be minimal with a clear rationale. - Do NOT add speculative fixes. Fix only what the log shows is broken. """ - - # recursively call this workflow to keep trying until the CI passes - run ensure_ci_passes() } - run rm_file_simple(ci_log_file) + run common.rm_file_simple(ci_log_file) } -script mkdir_p_simple = `mkdir -p "$1"` - -script rm_file_simple = `rm -f "$1"` - workflow default() { run ensure_ci_passes() } diff --git a/.jaiph/language_redesign_spec.md b/.jaiph/language_redesign_spec.md deleted file mode 100644 index d33cbb83..00000000 --- a/.jaiph/language_redesign_spec.md +++ /dev/null @@ -1,800 +0,0 @@ -# Execution-Boundary Rework Specification - -## Core Problem - -Jaiph blends declarative orchestration with raw shell in workflows and rules. That blurs side-effect boundaries, blocks runtime portability (Go/Rust), and weakens sandbox control. - -Target: one strict boundary. Orchestration constructs orchestrate. A dedicated script construct executes. No exceptions. - -## Design Decisions (Locked) - -These are not options. Implementation starts from this table. - -| # | Decision | -|---|----------| -| 1 | Orchestration constructs (`workflow`, `rule`) contain **zero raw shell**. | -| 2 | Execution construct (`script`) is a **standalone executable** — bash by default, any language via custom shebang. | -| 3 | Construct name is **`script`** (not `function` or `bash`). | -| 4 | Variable declarations use **`const`** in orchestration, **`local`** in scripts. | -| 5 | Rules get **structured keyword parsing** (same model as workflows, restricted subset). | -| 6 | Every shell operation requires a **named `script`**. No anonymous bash blocks. | -| 7 | Scripts: **standard exit semantics** (exit code via `return N`/`exit N`, values via stdout). | -| 8 | Workflows/rules: **`return "value"`** for values, **`fail "reason"`** for explicit failures. | -| 9 | **One-shot cutover.** No compatibility mode, no deprecation warnings. | -| 10 | Scripts run in **full isolation** — only positional args, no inherited variables. | -| 11 | **No script-to-script calls.** Scripts are atomic. Composition happens in orchestration. | -| 12 | Shared utility code lives in **shared bash libraries** (sourced explicitly in bash scripts), not in Jaiph script cross-calls. | -| 13 | `if` uses **brace syntax** (`if ... { } else { }`), **`not`** for negation, **`else if`** for chaining. No `then`/`fi`/`elif`. | -| 14 | Scripts transpile to **separate executable files** with `+x` permission. | -| 15 | Default shebang is `#!/usr/bin/env bash`. User can provide a custom shebang as the first line of the script body (e.g. `#!/usr/bin/env node`). | -| 16 | Workflows, rules, and scripts support **named parameters** in declarations. Positional `$1`/`$2` boilerplate is eliminated. | - -## Legality Matrix - -### `workflow` - -| Construct | Allowed | Syntax | -|-----------|---------|--------| -| config | Yes | `config { key = "value" }` | -| const | Yes | `const name = "value"` / `const name = run ref` / `const name = ensure ref` / `const name = prompt "text"` | -| run | Yes | `run ref [args]` / `run ref [args] &` (async) | -| ensure | Yes | `ensure ref [args]` / `ensure ref [args] recover { ... }` | -| prompt | Yes | `prompt "text"` / `const name = prompt "text"` / `const name = prompt "text" returns '{ ... }'` | -| log | Yes | `log "message"` | -| logerr | Yes | `logerr "message"` | -| return | Yes | `return "value"` / `return $var` | -| fail | Yes | `fail "reason"` | -| if | Yes | `if [not] ensure ref { ... } [else if ...] [else { ... }]` / `if [not] run ref { ... }` | -| route | Yes | `channel -> ref1, ref2` | -| send | Yes | `channel <- "value"` / `channel <- $var` / `channel <- run ref` | -| wait | Yes | `wait` (waits for async `run` steps) | -| Raw shell | **No** | Hard parser error with rewrite guidance | - -### `rule` - -| Construct | Allowed | Syntax | -|-----------|---------|--------| -| const | Yes | `const name = "value"` / `const name = run ref` / `const name = ensure ref` (no `prompt` capture) | -| ensure | Yes | `ensure ref [args]` — other rules only, **no `recover`** | -| run | Yes | `run ref [args]` — **scripts only**, not workflows | -| log | Yes | `log "message"` | -| logerr | Yes | `logerr "message"` | -| return | Yes | `return "value"` / `return $var` | -| fail | Yes | `fail "reason"` | -| if | Yes | `if [not] ensure ref { ... }` / `if [not] run ref { ... }` (run targets scripts only) | -| prompt | **No** | Rules don't interact with AI | -| route / send | **No** | Rules don't use channels | -| async (`&`, `wait`) | **No** | | -| recover (in `ensure`) | **No** | Not in rule-to-rule calls | -| Raw shell | **No** | Hard parser error | - -### `script` - -| Construct | Allowed | Syntax | -|-----------|---------|--------| -| Custom shebang | Yes | `#!/usr/bin/env node` (first line of body; omit for default `#!/usr/bin/env bash`) | -| All body content | Yes | Full language content matching the shebang (bash by default) | -| Nested bash functions | Yes (bash) | `helper() { ... }` (internal to the script body) | -| Shared bash via workspace lib dir | **No** | Use `import script`, a sibling module, or inline bash in a `script` block — `JAIPH_LIB` is not provided | -| `return N` / `exit N` | Yes (bash) | Exit code (integer only) | -| stdout (`echo`, `printf`) | Yes | Value output mechanism | -| `local` | Yes (bash) | Bash variable declarations | -| Other Jaiph script calls | **No** | Scripts are atomic; compose in orchestration | -| `run`, `ensure`, `prompt` | **No** | Hard parser error (bash scripts only; skipped for custom shebangs) | -| `return "value"` | **No** | Use `echo` for values, `return 0` for success (bash scripts only) | -| `fail`, `const`, `log`, `logerr` | **No** | Jaiph keywords, not available in scripts (bash scripts only; skipped for custom shebangs) | -| Parent scope variables | **No** | Full isolation — only positional args | - -**Jaiph keyword guard**: for bash scripts (no shebang or `#!/usr/bin/env bash`), the parser rejects Jaiph-level keywords (`run`, `ensure`, `fail`, `const`, `log`, `logerr`, `prompt`) in the body. For custom shebangs (e.g. `#!/usr/bin/env node`), the guard is skipped — the user owns the body entirely. - -## Named Parameters - -All constructs support named parameters in their declarations: - -``` -workflow implement(task, role_name) { ... } -rule ensure_is_number(value) { ... } -script check_hash(file_path, expected_hash) { ... } -``` - -**Semantics:** - -- Parameters are available as named local variables inside the construct body. -- For workflows/rules: the transpiler emits `local task="$1"; local role_name="$2"` at the top of the function body. -- For bash scripts: the transpiler prepends `local file_path="$1"; local expected_hash="$2"` to the script file. For non-bash shebangs, named params are documentary only (the language uses its own argv mechanism). -- **Optional/default parameters**: `workflow deploy(env, version, dry_run = "false")` transpiles to `local dry_run="${3:-false}"`. -- Both positional and named calling conventions are valid at call sites: - - `run implement "$task" "$role_name"` — positional, mapped by declaration order. - - `run implement task="$task" role_name="$role_name"` — named (already partially supported via `parseParamKeysFromArgs`). -- **Arity validation**: the validator can check call sites against the declaration. `run implement` with zero args when `implement` declares two required params is a validation error. -- **Parentheses are optional**: `workflow default() { ... }` (no params) remains valid. Constructs with params use `name(params) { ... }`. - -## Script Isolation and Transpilation Model - -Scripts execute in **full isolation**. They receive only their positional arguments. No inherited variables from the orchestration scope, module-level constants, or other scripts' state. - -### Transpilation to separate files - -Each `script` block transpiles to a **standalone executable file** in the build output: - -``` -build/ - scripts/ - check_is_number # #!/usr/bin/env bash, +x - check_json_schema # #!/usr/bin/env node, +x - select_role # #!/usr/bin/env bash, +x - module_name.sh # orchestration (workflows + rules) -``` - -The transpiler: -1. Extracts each `script` body verbatim -2. Prepends the shebang (user-provided or default `#!/usr/bin/env bash`) -3. Writes to `build/scripts/` with `chmod +x` -4. In the module `.sh`, script calls become: `"$JAIPH_SCRIPTS/" "$@"` - -The runtime sets `$JAIPH_SCRIPTS` to the build output scripts directory. - -### Shebang syntax - -The first non-empty line of the script body is checked for `#!`. If present, it becomes the file's shebang. If absent, `#!/usr/bin/env bash` is used. - -``` -script check_json() { - #!/usr/bin/env node - const data = JSON.parse(process.argv[2]); - process.exit(data.valid ? 0 : 1); -} - -script check_is_number() { - [[ "$1" =~ ^[0-9]+$ ]] -} -``` - -### Data flow - -**Data flow is always explicit**: -- **Input**: named parameters (declared in signature) or positional arguments (`$1`, `$2`, ...). Named params are syntactic sugar — they transpile to positional arg assignments. -- **Output**: stdout (value), stderr (diagnostics), exit code (success/failure) -- **No side channel**: scripts cannot read `const` variables from workflows/rules - -### Shared utility code (bash scripts only) - -Scripts cannot call other Jaiph scripts. Factor repeated bash into **`import script "./helper.sh" as helper`** (path relative to the `.jh` file), another `.jh` module, or a small extra `script` in the same module. Do not use a workspace-wide bash drop directory outside the compiler model. - -Non-bash scripts use their language's own module system for shared code. - -## Semantics: Values, Returns, Failures - -### Scripts (isolated, standalone executables) - -Values are passed via **stdout**. Caller captures with `const result = run script_name`. - -Exit code determines success/failure: `return 0` / `exit 0` = success, `return 1` / `exit 1` = failure. - -The existing `jaiph::set_return_value` mechanism is **removed** from script transpilation. `return "$string"` in a bash script body is a **parser error** (bash `return` only accepts integers). - -### Workflows - -`return "value"` passes a value to the caller via the Jaiph runtime (not stdout). - -`fail "reason"` terminates the workflow with a non-zero exit and logs the reason to stderr. An unrecovered `ensure` failure also terminates the workflow. - -Exit code: 0 on natural completion or `return`. Non-zero on `fail` or unrecovered failure. - -### Rules - -`return "value"` passes a value to the caller. Captured by `const result = ensure rule_name`. - -`fail "reason"` causes the rule to fail. In the caller, this triggers a `recover` block (if present) or aborts. - -A rule that completes without hitting `fail` passes. - -### `fail` vs script failure - -| Context | How to fail | How to return a value | -|---------|-------------|----------------------| -| `script` | `return 1` / `exit 1` | `echo "value"` (stdout) | -| `workflow` | `fail "reason"` | `return "value"` | -| `rule` | `fail "reason"` | `return "value"` | - -## Migration Examples - -### Rule: raw shell → structured - -Before: - -``` -rule ensure_is_number() { - if ! [[ "$1" =~ ^[0-9]+$ ]]; then - echo "Expected a non-negative integer, got: $1" >&2 - exit 1 - fi -} -``` - -After: - -``` -script check_is_number(value) { - [[ "$value" =~ ^[0-9]+$ ]] -} - -rule ensure_is_number(value) { - if not run check_is_number "$value" { - fail "Expected a non-negative integer, got: $value" - } -} -``` - -### Workflow: inline shell → named script - -Before: - -``` -workflow default() { - n="${1:-10}" - ensure ensure_is_number "$n" - result = run fib "$n" - log "$result" -} -``` - -After: - -``` -workflow default(n = "10") { - ensure ensure_is_number "$n" - const result = run fib "$n" - log "$result" -} -``` - -### Script: return value via stdout (not `jaiph::set_return_value`) - -Before: - -``` -function fib() { - local result - result="$(fib_impl "$n")" - return "$result" -} -``` - -After: - -``` -script fib() { - fib_impl() { - local x="$1" - if [ "$x" -le 1 ]; then - echo "$x" - return 0 - fi - local a b - a="$(fib_impl "$((x - 1))")" - b="$(fib_impl "$((x - 2))")" - echo "$((a + b))" - } - fib_impl "$1" -} -``` - -All data is internal. Caller captures via `const result = run fib "$n"`. - -### Polyglot script: Node.js validation - -``` -script validate_json_schema(schema_path, data_path) { - #!/usr/bin/env node - const Ajv = require('ajv'); - const fs = require('fs'); - const ajv = new Ajv(); - const schema = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); - const data = JSON.parse(fs.readFileSync(process.argv[3], 'utf8')); - const valid = ajv.validate(schema, data); - if (!valid) { - console.error(JSON.stringify(ajv.errors)); - process.exit(1); - } -} - -workflow validate_config() { - ensure config_file_exists - const result = run validate_json_schema "schema.json" "config.json" - log "Config validated successfully" -} -``` - -### Prompt with `returns` + value dispatch (engineer.jh pattern) - -Before: - -``` -local role_surgical = "..." -local role_reductionist = "..." - -workflow implement() { - local role_name="$2" - local role - if [ "$role_name" = "surgical" ]; then - role="$role_surgical" - elif [ "$role_name" = "reductionist" ]; then - role="$role_reductionist" - fi - prompt "$role ..." -} -``` - -After: - -``` -script select_role(role_name) { - local role_surgical=' - You are a surgical engineer. ... - ' - local role_reductionist=' - You are a reductionist engineer. ... - ' - - case "$role_name" in - surgical) echo "$role_surgical" ;; - reductionist) echo "$role_reductionist" ;; - *) echo "Unknown role: $role_name" >&2; return 1 ;; - esac -} - -workflow implement(task, role_name) { - const role = run select_role "$role_name" - - prompt " - $role - ... - $task - " -} -``` - -Role data is internal to the script. Orchestration only passes the role name and receives the resolved text. Full isolation — script has zero knowledge of caller scope. - -### Send operator - -Before: - -``` -workflow scanner() { - findings <- echo "Found 3 issues in auth module" -} -``` - -After: - -``` -workflow scanner() { - findings <- "Found 3 issues in auth module" -} -``` - -### Rule with value return - -Before: - -``` -rule echo_line() { - echo "this goes to logs only" - return "captured-value" -} -``` - -After: - -``` -script echo_impl() { - echo "this goes to logs only" >&2 -} - -rule echo_line() { - run echo_impl - return "captured-value" -} -``` - -## Pattern Catalog: .jaiph/ and e2e/ audit - -Every `.jh` file was scanned. Below are all patterns found that require migration, grouped by category. - -### P1: Raw shell in workflows (every .jaiph/ file) - -**Files**: queue.jh, docs_parity.jh, simplifier.jh, architect_review.jh, ensure_ci_passes.jh, qa.jh, git.jh, log_keyword.jh, nested_run.jh, workflow_greeting.jh, prompt_unmatched.jh, rule_pass.jh, assign_capture.jh - -**Examples**: `echo "..."`, `printf`, `mkdir -p`, `rm -f`, `exit 0`, `exit 1`, `test -n`, bare assignment (`dataset="testdata"`) - -**Migration**: each becomes a named `script` or a `const` declaration. `exit 0` → `return` (early success). `exit 1` → `fail "reason"`. - -### P2: Raw shell in rules (every rule) - -**Files**: git.jh (`git rev-parse`, `test -z "$(git status)"`), queue.jh (`echo | grep -q`), ensure_ci_passes.jh (`npm run test:ci`), docs_parity.jh (`test -f`, `while IFS= read`), simplifier.jh, say_hello.jh, say_hello_json.jh, current_branch.jh - -**Migration**: shell logic moves to scripts. Rules become structured: `run` the script, `if`/`fail` on the result. - -### P3: Iteration in workflows - -**Files**: architect_review.jh (`while IFS= read -r header; do ... done <<< "$headers"`), docs_parity.jh (`for f in docs/*.md`, `for f in "${docs_md_files[@]}"`). - -**Problem**: the loop body contains orchestration keywords (`run`, `ensure`, `prompt`, `log`). Cannot be pushed to a script. - -**Resolution**: use **workflow recursion**. Extract per-item logic into a workflow, then recurse over the list. Split newline-delimited lists with tiny `script` steps (e.g. `printf '%s\n' "$1" | head -n 1` / `tail -n +2`) or `import script`. - -``` -script list_docs_files() { - for f in docs/*.md; do - echo "$f" - done -} - -workflow process_docs_recursive(file, remaining) { - run docs_page "$file" - - if run has_value "$remaining" { - const next = run first_line "$remaining" - const rest = run rest_lines "$remaining" - run process_docs_recursive "$next" "$rest" - } -} - -workflow default() { - const docs_files = run list_docs_files - const first = run first_line "$docs_files" - const rest = run rest_lines "$docs_files" - run process_docs_recursive "$first" "$rest" -} -``` - -**Future feature: `each` modifier.** Planned syntax sugar that replaces the recursion boilerplate: - -``` -run docs_page each $docs_files -``` - -`each` is a modifier on `run`/`ensure` that calls the target once per newline-delimited item. No loop body, no mutable state, no break/continue. Backward-compatible addition — does not block v1. - -### P4: Bash arrays in workflows - -**File**: docs_parity.jh — builds arrays dynamically (`local files=()`, `files+=("$f")`), passes them as args (`"${files[@]}"`). - -**Resolution**: avoid arrays in orchestration. Represent lists as newline-delimited strings. Scripts that need to process multiple items receive them as a single string argument. Glob expansion (`docs/*.md`) stays in scripts. - -### P5: Mutable variables in workflows - -**File**: architect_review.jh — `local failed=0` then `failed=1` inside a loop to track whether any task failed. - -**Resolution**: restructure to avoid mutable state. The per-item workflow performs side effects (marking tasks). After recursion completes, re-check the final state: - -``` -workflow review_single_task(header) { - const task = run queue.get_task_by_header "$header" - - if run is_dev_ready "$task" { - log "Already dev-ready: $header" - return - } - - const verdict = run review_task "$task" - if run matches "$verdict" "dev-ready" { - run queue.mark_task_dev_ready "$header" - log "Marked dev-ready: $header" - } else { - log "Needs work: $header" - } -} - -workflow default() { - const headers = run queue.get_all_task_headers - # recurse over headers (or use `each` when available) - ... - - const remaining = run queue.count_not_ready - if not run is_zero "$remaining" { - fail "One or more tasks need work" - } -} -``` - -No mutable counter. The source of truth is the queue state, not a variable. - -### P6: String comparison in workflows (SPEC GAP) - -**Files**: architect_review.jh (`[[ "$verdict" == "dev-ready" ]]`), engineer.jh (role name dispatch), git.jh (`[ -z "$role_name" ]`). - -**Resolution**: push to scripts. - -``` -script matches(a, b) { - [ "$a" = "$b" ] -} - -script has_value(val) { - [ -n "$val" ] -} - -if run matches "$verdict" "dev-ready" { - ... -} -``` - -These are small, reusable utility scripts in the same module (or behind `import script`). - -### P7: `return "$(command)"` in scripts (Jaiph value return) - -**Files**: queue.jh (`return "$(awk ...)"`), docs_parity.jh (`return "$(git diff ...)"`), simplifier.jh (same pattern). - -**Migration**: replace `return "$(command)"` with direct stdout passthrough: - -Before: `return "$(awk '/^## /{print}' "$queue_file")"` - -After: `awk '/^## /{print}' "$queue_file"` (just let stdout flow) - -### P8: `logerr` in rules - -**Files**: say_hello.jh, say_hello_json.jh — `logerr "message"` inside raw shell rule body. - -**Migration**: under structured rules, `logerr` becomes a Jaiph keyword (already in legality matrix): - -``` -rule name_was_provided(name) { - if not run has_value "$name" { - logerr "You didn't provide your name :(" - fail "name argument required" - } -} -``` - -### P9: `ensure` with `recover` containing shell - -**File**: ensure_ci_passes.jh — `recover` block contains `echo "$1" > "$ci_log_file"`, shell conditionals, and a `prompt`. - -**Migration**: shell in recover body moves to scripts. `prompt` stays (recover body follows workflow rules): - -``` -script save_ci_log(content, path) { - echo "$content" > "$path" -} - -script ci_log_exists(path) { - [ -s "$path" ] -} - -workflow ensure_ci_passes() { - const ci_log_file = ".jaiph/tmp/ensure_ci_passes.last.log" - run mkdir_p ".jaiph/tmp" - - ensure ci_passes recover { - run save_ci_log "$1" "$ci_log_file" - if not run ci_log_exists "$ci_log_file" { - fail "ci failure log is empty at $ci_log_file" - } - prompt "Fix failing CI... log at: $ci_log_file" - } - - run rm_file "$ci_log_file" -} -``` - -### P10: Shell variable expansion in `const` RHS - -**Files**: multiple — `"${1:-10}"`, `"${1:-}"`, `"${task%%$'\n'*}"`. - -**Ruling**: simple interpolation (`$var`, `"${var:-default}"`) is allowed in `const` RHS — these are value lookups, not computation. Bash string operations (`${var%%pattern}`, `${var//old/new}`) are computation — push to a script. - -| Allowed in `const` RHS | Not allowed (use script) | -|------------------------|---------------------------| -| `"$var"` | `"${var%%pattern}"` | -| `"${var:-default}"` | `"${var//old/new}"` | -| `"${var:+alt}"` | `"${#var}"` | -| `"literal"` | `$(command)` | - -### P11: Script-to-script calls - -**File**: docs_parity.jh — rule `only_expected_docs_changed_after_prompt` calls script `is_allowed_file` directly. - -**Migration**: under full isolation + no script-to-script calls, inline the logic or add a dedicated `import script` helper: - -``` -script check_only_expected_changed(allowed, changed) { - while IFS= read -r f; do - [ -z "$f" ] && continue - if [[ $'\n'"$allowed"$'\n' != *$'\n'"$f"$'\n'* ]]; then - echo "Unexpected file changed: $f" >&2 - return 1 - fi - done <<< "$changed" -} -``` - -## Implementation Plan - -### Phase 0: Architectural prep (before breaking changes) - -**0a. Refactor `validate.ts` — collapse duplicate ref resolution** -- Merge `validateRuleRef`, `validateWorkflowRef`, `validateRunInRuleRef`, `validateRunTargetRef`, `validateBareSendSymbol` into one generic `validateRef(ref, allowedKinds, context)` function -- Target: 788 → ~400 lines -- Zero behavior change - -**0b. Split `emit-workflow.ts` — separate emitters** -- Extract script emission into `emit-script.ts` -- Extract rule emission into `emit-rule.ts` -- `emit-workflow.ts` becomes orchestration-only assembly -- Creates natural seam for Phase 3 (separate script files) - -### Phase 1: Language additions (no breaking changes) - -**1a. Add `fail` keyword** -- AST: new `WorkflowStepDef` variant `{ type: "fail"; message: string; loc: SourceLoc }` -- Parser: recognize `fail "reason"` in `workflows.ts` -- Transpiler: emit `echo "reason" >&2; exit 1` - -**1b. Add `const` declaration** -- AST: new step type `{ type: "const"; name: string; value: ConstValue; loc: SourceLoc }` where `ConstValue` is string-expr | run-capture | ensure-capture | prompt-capture -- Parser: `const name = ...` with RHS dispatch -- Transpiler: emit `local name; name="value"` or appropriate capture form - -**1c. Formalize `wait` as keyword** -- AST: new variant `{ type: "wait"; loc: SourceLoc }` -- Parser: recognize `wait` in workflows (currently falls through to shell) -- Transpiler: emit `wait` - -**1d. Switch `if` to brace syntax** -- Parser: recognize `if [not] ensure/run ref { ... } [else if ...] [else { ... }]` -- Keep old `if ... then ... fi` working during Phase 1 (dual parsing) -- Transpiler: both forms emit the same bash - -### Phase 2: Rule parser rewrite - -**2a. Restructure `RuleDef`** -- Change `RuleDef.commands: string[]` → `RuleDef.steps: RuleStepDef[]` (or reuse `WorkflowStepDef` subset) -- Rewrite `rules.ts` with keyword-aware parsing (mirror `workflows.ts` structure) -- Port existing rule tests first, then validate structured output - -**2b. Update rule emission** -- `emit-workflow.ts`: handle structured rule steps instead of opaque command strings - -### Phase 3: `function` → `script` rename and separate file transpilation - -**3a. Rename keyword** -- Parser: accept `script` keyword instead of `function` -- AST: rename `FunctionDef` → `ScriptDef`, add `shebang?: string` field -- `jaiphModule`: rename `functions` → `scripts` -- Update all validator references - -**3b. Add shebang extraction** -- Parser: check first non-empty line of script body for `#!` -- If present, store in `ScriptDef.shebang` and exclude from body commands -- If absent, `shebang` remains `undefined` (default `#!/usr/bin/env bash`) - -**3c. Conditional keyword guard** -- For bash scripts (no shebang or bash shebang): keep existing Jaiph keyword rejection -- For custom shebangs: skip keyword guard entirely - -**3d. Emit scripts as separate files** -- Change `emitWorkflow` return type: `{ module: string; scripts: ScriptFile[] }` where `ScriptFile = { name: string; content: string; shebang: string }` -- Module `.sh` calls scripts via `"$JAIPH_SCRIPTS/" "$@"` -- `build.ts`: write script files with `chmod +x`, set `$JAIPH_SCRIPTS` - -**3e. Update all first-party `.jh` files** -- Rename `function` → `script` in all `.jaiph/*.jh` files -- Rename in all `e2e/*.jh` fixtures -- Update test fixtures and golden outputs - -**3f. Named parameters** -- Parser: recognize `name(param1, param2)` and `name(param1, param2 = "default")` in workflow, rule, and script declarations -- AST: add `params?: Array<{ name: string; default?: string }>` to `WorkflowDef`, `RuleDef`, `ScriptDef` -- Transpiler: for workflows/rules, emit `local param1="$1"; local param2="$2"` (or `"${2:-default}"` for defaults) at the top of the function body. For bash scripts, prepend the same to the script file. For non-bash scripts, params are documentary only. -- Validator: check call-site arity against declared params. Missing required args = validation error. Extra args beyond declared params = validation warning. -- Update all first-party `.jh` files to use named params where applicable -- Parentheses optional when no params: `workflow default() { ... }` remains valid - -### Phase 4: Script isolation - -**4a. Implement full isolation for script execution** -- Scripts run as separate processes (inherent from separate files + exec) -- Only positional args available (inherent from separate executable) -- Set `$JAIPH_SCRIPTS` and `$JAIPH_WORKSPACE` for script steps (no workspace bash lib dir) - -**4b. Reject script-to-script calls** -- Parser/validator: detect when a script body references another Jaiph script name -- Error: `"scripts cannot call other Jaiph scripts; use import script, inline bash, or compose in a workflow"` - -### Phase 5: Remove shell (breaking changes) - -**5a. Remove shell fallback from workflow parser** -- `workflows.ts`: delete the catch-all `type: "shell"` codepath -- Remove `shellAccumulator` / `braceDepthDelta` shell accumulation -- Emit parser error: `"raw shell is not allowed in workflow; extract to a script"` - -**5b. Remove shell fallback from rule parser** -- Same treatment after Phase 2 - -**5c. Remove old `if` syntax** -- Drop `if ... then ... fi` / `elif` parsing -- Only accept brace syntax with `not` / `else if` - -**5d. Enforce pure output in scripts** -- `scripts.ts`: reject `return "value"` (non-integer return) -- Remove `jaiph::set_return_value` from script transpilation - -**5e. Update send operator** -- Accept `"value"` / `$var` / `run ref` as RHS -- Reject raw shell command as RHS - -### Phase 6: Migrate all first-party code - -- Rewrite all `e2e/*.jh` fixtures -- Rewrite all `.jaiph/*.jh` workflows -- Factor repeated bash into `import script` or extra `script` blocks in the same module (P6, P11) -- Update test fixtures and golden transpilation outputs -- Update docs and README examples - -### Phase 7: Ship - -- Hard parser errors on all legacy syntax -- Error messages include rewrite examples -- Full e2e + golden snapshot CI gate -- Zero P0 parser/runtime failures before merge - -## Code Changes Required - -| File | Change | -|------|--------| -| `src/types.ts` | Rename `FunctionDef` → `ScriptDef`, add `shebang?: string`, add `params?: ParamDef[]`. Rename `jaiphModule.functions` → `jaiphModule.scripts`. Add `params?: ParamDef[]` to `WorkflowDef`, `RuleDef`. Add `fail`, `wait`, `const` step types. Change `RuleDef.commands` → `RuleDef.steps`. Remove `shell` condition kind from `if`. Add `not` / brace-style `if` AST. | -| `src/parser.ts` | Replace `function` keyword detection with `script`. Rename `parseFunctionBlock` → `parseScriptBlock`. | -| `src/parse/functions.ts` → `src/parse/scripts.ts` | Rename file. Update regex to match `script` keyword. Add shebang extraction. Conditional keyword guard (skip for custom shebangs). Parse named params in signature. | -| `src/parse/workflows.ts` | Remove shell fallback, shell accumulator. Add `fail`, `const`, `wait` parsing. Replace `if ... then ... fi` with brace syntax. | -| `src/parse/rules.ts` | Full rewrite: keyword-aware structured parser mirroring workflow parser. | -| `src/transpile/emit-workflow.ts` | Split: extract script emission to `emit-script.ts`, rule emission to `emit-rule.ts`. Change return type to include script files. Remove `jaiph::set_return_value` from script paths. | -| `src/transpile/emit-script.ts` | **New file.** Emit standalone script files with shebang + body. | -| `src/transpile/emit-rule.ts` | **New file.** Rule emission extracted from `emit-workflow.ts`. | -| `src/transpile/emit-steps.ts` | Remove `emitShellStep` for workflows. Add `emitFailStep`, `emitConstStep`, `emitWaitStep`. | -| `src/transpile/build.ts` | Handle new `emitWorkflow` return shape. Write script files with `chmod +x`. Set `$JAIPH_SCRIPTS` path. | -| `src/transpile/validate.ts` | Collapse duplicate ref resolution. Rename `function` → `script` in errors/lookups. Allow `run` in rules (scripts only). Remove shell-condition validation. Add script isolation validation. | -| `src/transpile/shell-jaiph-guard.ts` | Scope down — only applies to bash scripts now. | -| `e2e/*.jh` | Rewrite all fixtures to new syntax. | -| `.jaiph/*.jh` | Rewrite all workflows to new syntax. | -| `test/fixtures/**` | Update golden transpilation outputs. | -| `docs/*` | Update grammar, getting-started, CLI docs for `script` keyword and shebang. | - -## Risks - -| Risk | Impact | Mitigation | -|------|--------|------------| -| Wide breakage: all raw-shell workflows/rules fail at parse time | High | Single branch, full e2e gate, no merge without 100% pass | -| Rule parser rewrite introduces regressions | High | Port existing rule tests before rewriting parser | -| Ergonomic cost of named scripts for trivial shell | Medium | Accepted tradeoff — boundary clarity > brevity | -| `fail` interacts badly with `recover` | Medium | Explicit test: `ensure rule_with_fail recover { ... }` must trigger recover | -| `const` scoping conflicts with bash `local` | Low | `const` is parser-level immutability; transpiles to `local` | -| Return semantics confusion during migration | Medium | Parser errors guide users: `"return 'value' not allowed in script; use echo"` | -| Script isolation perf overhead (fork+exec per call) | Medium | Measure fork cost; scripts are already logically isolated. Optimize hot paths if needed | -| Users want a global bash grab-bag | Medium | `import script` + small modules; no `JAIPH_LIB` | -| `.jaiph/` workflow migration is large (9 files) | High | Migrate in parallel with parser changes; each file is independently testable | -| Separate file management complexity | Medium | Deterministic naming (`scripts/`), cleanup on rebuild | -| Custom shebang scripts may have missing dependencies | Low | Not Jaiph's problem — user owns their runtime. Document clearly | - -## Success Criteria - -- 100% first-party `.jh` files parse under new grammar -- 100% e2e pass under new runtime -- Zero `type: "shell"` steps in workflow/rule AST output -- `fail` triggers `recover` correctly in `ensure` blocks -- Script bodies reject `return "value"`, `fail`, `const`, other Jaiph keywords (bash scripts only) -- Script bodies reject calls to other Jaiph scripts -- Scripts execute as separate files with correct shebang and `+x` -- Custom shebang scripts (e.g. `#!/usr/bin/env node`) work end-to-end -- Scripts execute in full isolation (no inherited variables) -- `const` declarations work in workflows and rules with all RHS forms -- `if` brace syntax works with `not` and `else if` -- Parser errors for raw shell include actionable rewrite examples -- `jaiph::set_return_value` removed from script transpilation paths -- `validate.ts` under 500 lines after dedup -- `emit-workflow.ts` handles only orchestration; script/rule emission in separate files -- Named parameters work in workflow, rule, and script declarations -- Default parameter values work: `workflow deploy(env, dry_run = "false")` -- Arity validation catches missing required args at call sites diff --git a/.jaiph/lib_common.jh b/.jaiph/lib_common.jh new file mode 100644 index 00000000..20888a7b --- /dev/null +++ b/.jaiph/lib_common.jh @@ -0,0 +1,33 @@ +#!/usr/bin/env jaiph + +# +# Shared string/file helpers for the .jaiph orchestration workflows. +# Import as: import "./lib_common.jh" as common +# +# Writes UTF-8 text to a path: $1 = path, $2 = content. +# python3 instead of `echo`, so backslashes and dash-leading content +# are written verbatim. Content still travels through argv, so it is +# subject to the OS ARG_MAX limit (~1 MB on macOS). +export script save_string_to_file = ```python3 +import sys +if len(sys.argv) < 3: + sys.exit(2) +path, content = sys.argv[1], sys.argv[2] +open(path, "w", encoding="utf-8").write(content) +``` + +export script first_line_str = `printf '%s\n' "$1" | head -n 1` + +export script rest_lines_str = `printf '%s\n' "$1" | tail -n +2` + +export script arg_nonempty = `[ -n "$1" ]` + +export script str_equals = `[ "$1" = "$2" ]` + +export script to_lower = `printf '%s' "$1" | tr '[:upper:]' '[:lower:]'` + +export script mkdir_p_simple = `mkdir -p "$1"` + +export script rm_file_simple = `rm -f "$1"` + +export script jaiph_tmp_dir = `printf '%s\n' "$JAIPH_WORKSPACE/.jaiph/tmp"` diff --git a/.jaiph/libs/jaiphlang/git.jh b/.jaiph/libs/jaiphlang/git.jh index 8cf01eea..ba94635a 100755 --- a/.jaiph/libs/jaiphlang/git.jh +++ b/.jaiph/libs/jaiphlang/git.jh @@ -37,9 +37,11 @@ rule is_clean() { workflow commit(task) { config { - agent.backend = "cursor" - agent.cursor_flags = "--force" - agent.default_model = "auto" + # agent.backend = "cursor" + # agent.default_model = "composer-2" + # agent.cursor_flags = "--force" + agent.backend = "claude" + agent.claude_flags = "--permission-mode bypassPermissions" } ensure in_git_repo() diff --git a/.jaiph/main.jh b/.jaiph/main.jh deleted file mode 100755 index aaf143f7..00000000 --- a/.jaiph/main.jh +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env jaiph - -# -# Full pipeline: architect review → implement first queue task. -# For periodic docs audit, run docs_parity.jh separately. -# - -import "./engineer.jh" as implement -import "./architect_review.jh" as architect -import "jaiphlang/git" as git - -workflow default() { - ensure git.is_clean() - - run architect.default() - run implement.default("queue") -} \ No newline at end of file diff --git a/.jaiph/prepare_release.jh b/.jaiph/prepare_release.jh new file mode 100755 index 00000000..a110429c --- /dev/null +++ b/.jaiph/prepare_release.jh @@ -0,0 +1,139 @@ +#!/usr/bin/env jaiph + +# +# Release-prep workflow. Single-sources the CLI version: bumps package.json, +# refreshes the installer's hardcoded ref, rebuilds the CLI, verifies that +# `jaiph --version` matches package.json, and regenerates docs/registry. +# +# Run as: +# jaiph run .jaiph/prepare_release.jh -- 0.9.5 # explicit version +# jaiph run .jaiph/prepare_release.jh # next patch version +# +# The workflow never creates a commit or git tag — it stages edits for the +# operator to review, commit, tag, and push manually. +# + +script read_pkg_version = `node -p "require('./package.json').version"` + +script assert_version_format = ``` + v="$1" + case "${v}" in + *[!0-9.]*) printf 'version must match X.Y.Z (digits only); got: %s\n' "${v}" >&2; exit 1 ;; + esac + printf '%s' "${v}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' || { + printf 'version must match X.Y.Z (digits only); got: %s\n' "${v}" >&2 + exit 1 + } +``` + +script compute_next_patch = ```python3 +import sys +v = sys.argv[1] +parts = v.split('.') +if len(parts) != 3 or not all(p.isdigit() for p in parts): + sys.stderr.write(f"invalid current version in package.json: {v}\n") + sys.exit(1) +parts[-1] = str(int(parts[-1]) + 1) +print('.'.join(parts)) +``` + +script assert_git_tree_clean = ``` + if [ -n "$(git status --porcelain)" ]; then + echo "git tree is dirty; commit or stash before running prepare_release" >&2 + git status --short >&2 + exit 1 + fi +``` + +script assert_tag_does_not_exist = ``` + v="$1" + if git rev-parse -q --verify "refs/tags/v${v}" >/dev/null 2>&1; then + printf 'tag v%s already exists\n' "${v}" >&2 + exit 1 + fi +``` + +script npm_version_no_tag = `npm version "$1" --no-git-tag-version --allow-same-version >/dev/null` + +script update_install_release_ref = ```python3 +import sys +old, new = sys.argv[1], sys.argv[2] +path = "docs/install" +with open(path, "r", encoding="utf-8") as f: + src = f.read() +needle = f"v{old}" +count = src.count(needle) +if count == 0: + sys.stderr.write(f"docs/install: hardcoded ref v{old} not found\n") + sys.exit(1) +new_src = src.replace(needle, f"v{new}") +with open(path, "w", encoding="utf-8") as f: + f.write(new_src) +print(count) +``` + +script run_npm_build = `npm run build >&2` + +script assert_built_cli_version_equals = ``` + v="$1" + expected="jaiph ${v}" + actual="$(node dist/src/cli.js --version)" + if [ "${expected}" != "${actual}" ]; then + printf 'displayed --version mismatch\nexpected: %s\nactual: %s\n' "${expected}" "${actual}" >&2 + exit 1 + fi +``` + +script run_registry_build = `npm run registry:build >&2` + +workflow resolve_version(arg) { + const pkg_version = run read_pkg_version() + const resolved = match arg { + "" => run compute_next_patch(pkg_version) + _ => arg + } + run assert_version_format(resolved) + return resolved +} + +workflow preflight(version) { + run assert_git_tree_clean() + run assert_tag_does_not_exist(version) +} + +workflow apply_version_change(old_version, new_version) { + run npm_version_no_tag(new_version) + run update_install_release_ref(old_version, new_version) +} + +workflow check_displayed_version(version) { + run run_npm_build() + run assert_built_cli_version_equals(version) +} + +workflow default(arg) { + const version = run resolve_version(arg) + const old_version = run read_pkg_version() + log "Preparing release v${version} (current: v${old_version})" + + run preflight(version) + run apply_version_change(old_version, version) + run check_displayed_version(version) + run run_registry_build() + + log """ + prepare_release: staged release v${version} + - package.json + package-lock.json (npm version ${version}) + - docs/install (release ref v${old_version} -> v${version}) + - docs/registry (regenerated) + - dist/ (rebuilt; jaiph --version == jaiph ${version}) + + Remaining manual steps: + 1. Review the diff (git diff) + 2. Commit the staged changes + 3. Tag: git tag v${version} + 4. Push branch + tag (tag push triggers docker-publish and release.yml) + 5. Smoke check: jaiph use ${version} + """ + return version +} diff --git a/.jaiph/prepare_release.test.jh b/.jaiph/prepare_release.test.jh new file mode 100644 index 00000000..55d729ae --- /dev/null +++ b/.jaiph/prepare_release.test.jh @@ -0,0 +1,86 @@ +#!/usr/bin/env jaiph + +import "./prepare_release.jh" as pr + +# resolve_version handles the empty-arg / explicit-arg branches and the +# X.Y.Z format check; tests use mock script to pin the package.json version. + +test "resolve_version: empty arg returns next patch from package.json" { + mock script pr.read_pkg_version() { + echo "1.2.3" + } + const out = run pr.resolve_version("") + expect_equal out "1.2.4" +} + +test "resolve_version: explicit X.Y.Z arg is accepted verbatim" { + mock script pr.read_pkg_version() { + echo "0.0.0" + } + const out = run pr.resolve_version("9.8.7") + expect_equal out "9.8.7" +} + +test "resolve_version: non-X.Y.Z arg fails with offending value" { + mock script pr.read_pkg_version() { + echo "0.0.0" + } + const out = run pr.resolve_version("not-a-version") allow_failure + expect_contain out "version must match X.Y.Z" + expect_contain out "not-a-version" +} + +test "resolve_version: extra-segment arg fails with offending value" { + mock script pr.read_pkg_version() { + echo "0.0.0" + } + const out = run pr.resolve_version("1.2.3.4") allow_failure + expect_contain out "version must match X.Y.Z" + expect_contain out "1.2.3.4" +} + +# check_displayed_version: builds the CLI and compares its --version output +# against the expected literal. On mismatch the script must print both the +# expected and actual strings before the workflow fails. + +test "check_displayed_version: mismatch fails with both values in output" { + mock script pr.run_npm_build() { + : + } + mock script pr.assert_built_cli_version_equals() { + expected="jaiph $1" + actual="jaiph 0.0.0" + printf 'displayed --version mismatch\nexpected: %s\nactual: %s\n' "${expected}" "${actual}" >&2 + exit 1 + } + const out = run pr.check_displayed_version("9.9.9") allow_failure + expect_contain out "expected: jaiph 9.9.9" + expect_contain out "actual: jaiph 0.0.0" +} + +# preflight: refuses to start if the working tree is dirty so the workflow's +# own edits are the only diff a reviewer sees. + +test "preflight: dirty git tree fails before any side effects" { + mock script pr.assert_git_tree_clean() { + echo "git tree is dirty; commit or stash before running prepare_release" >&2 + exit 1 + } + mock script pr.assert_tag_does_not_exist() { + : + } + const out = run pr.preflight("9.9.9") allow_failure + expect_contain out "git tree is dirty" +} + +test "preflight: existing tag v fails" { + mock script pr.assert_git_tree_clean() { + : + } + mock script pr.assert_tag_does_not_exist() { + printf 'tag v%s already exists\n' "$1" >&2 + exit 1 + } + const out = run pr.preflight("9.9.9") allow_failure + expect_contain out "tag v9.9.9 already exists" +} diff --git a/.jaiph/security_review.jh b/.jaiph/security_review.jh new file mode 100644 index 00000000..1d403389 --- /dev/null +++ b/.jaiph/security_review.jh @@ -0,0 +1,138 @@ +#!/usr/bin/env jaiph + +# +# Security review of code changes. Reviews uncommitted changes by default, +# or a git diff range passed as the first argument: +# jaiph run .jaiph/security_review.jh # staged + unstaged + untracked +# jaiph run .jaiph/security_review.jh "main..HEAD" # a ref range +# Writes a markdown report to .jaiph/tmp and publishes it as a run artifact. +# Fails when any HIGH severity finding is confirmed. +# +# Review methodology adapted from anthropics/claude-code-security-review +# (claudecode/prompts.py): high-confidence findings only, explicit +# false-positive exclusions, severity + confidence scoring. +# +import "./lib_common.jh" as common +import "jaiphlang/artifacts" as artifacts + +config { + agent.backend = "claude" + agent.claude_flags = "--permission-mode bypassPermissions" +} + +const report_file = .jaiph/tmp/security_review_report.md + +const reviewer_role = """ + You are a senior security engineer conducting a focused security review. + Identify HIGH-CONFIDENCE security vulnerabilities with real exploitation + potential. Minimize false positives: flag only issues where you are more + than 80% confident of actual exploitability in this codebase. + + Vulnerability classes to examine: + 1. Input validation: SQL/command/template/NoSQL injection, XXE, + path traversal. + 2. Authentication & authorization: bypass logic, privilege escalation, + session flaws, JWT issues, insecure direct object references. + 3. Crypto & secrets: hardcoded credentials, weak algorithms, improper key + storage, certificate validation bypasses, insecure randomness. + 4. Code execution: unsafe deserialization, eval/exec on untrusted input, + unsafe YAML/pickle loading, XSS (reflected, stored, DOM-based). + 5. Data exposure: secrets or PII in logs, debug info leaks, overly + revealing error messages, sensitive data written to artifacts. + + Severity scale: + - HIGH: directly exploitable; leads to RCE, data breach, or auth bypass. + - MEDIUM: exploitable under specific conditions, significant impact. + - LOW: defense-in-depth gaps or low-impact weaknesses. + + Do NOT report (out of scope, treated as noise): + - Denial of service, rate limiting, memory/CPU exhaustion. + - Missing input validation on non-security-critical fields without a + demonstrated security impact. + - Any finding you cannot back with a concrete exploit scenario. + - Style, performance, or general code-quality issues. +""" + +script git_diff_uncommitted = ``` +{ + git diff --cached + git diff + git ls-files --others --exclude-standard | while IFS= read -r f; do + [ -z "$f" ] && continue + git diff --no-index -- /dev/null "$f" || true + done +} +``` + +script git_diff_range = `git diff "$1"` + +script worktree_fingerprint = `git status --porcelain | sort | cksum` + +workflow review_diff(diff_text) { + const result = prompt """ + + ${reviewer_role} + + + Review the following code changes for security vulnerabilities. You have + read access to the full repository — read surrounding source files + whenever needed to confirm whether a finding is actually exploitable; + do not judge from the diff alone. + + Write a full markdown report to ${report_file} (overwrite if present) + with one section per finding: title, severity (HIGH/MEDIUM/LOW), + confidence (0.7-1.0; discard anything below 0.7), file and line, + a concrete exploit scenario, and a specific remediation. If there are + no findings, write a short report stating what was reviewed and that + nothing was found. + + Do not modify any file in the repository other than ${report_file}. + + Respond with JSON fields: + - verdict: the string "fail" if there is at least one HIGH finding, + otherwise the string "pass". + - highs, mediums, lows: finding counts by severity. + - summary: 1-3 sentences describing the overall result. + + Code changes under review: + ${diff_text} + + """ + returns "{ verdict: string, highs: number, mediums: number, lows: number, summary: string }" + + log "Security review: ${result.summary}" + log "Findings: high=${result.highs} medium=${result.mediums} low=${result.lows} (report: ${report_file})" + return result.verdict +} + +workflow default(scope) { + run common.mkdir_p_simple(".jaiph/tmp") + const fingerprint_before = run worktree_fingerprint() + + const diff_text = match scope { + "" => run git_diff_uncommitted() + _ => run git_diff_range(scope) + } + if diff_text == "" { + log "Security review: no changes to review." + return "" + } + + const verdict = run review_diff(diff_text) + + # The reviewer must be read-only apart from the (gitignored) report file. + const fingerprint_after = run worktree_fingerprint() + run common.str_equals(fingerprint_before, fingerprint_after) catch (err) { + fail "Security review must not modify the worktree, but git status changed during review. Inspect git status before trusting this run." + } + + run artifacts.save(report_file) + + run common.str_equals(verdict, "pass") catch (err) { + fail """ + Security review found HIGH severity issues. + See ${report_file} (also published to the run artifacts directory). + """ + } + log "Security review passed." +} diff --git a/.jaiph/skills.lock b/.jaiph/skills.lock new file mode 100644 index 00000000..403fd4d3 --- /dev/null +++ b/.jaiph/skills.lock @@ -0,0 +1,11 @@ +{ + "version": 1, + "skills": { + "documentation-writer": { + "source": "github/awesome-copilot", + "sourceType": "github", + "skillPath": "skills/documentation-writer/SKILL.md", + "computedHash": "ee53d65b163cd7eb953a930c95841cfe398cc2c0bd24c06508bbaa07c432be35" + } + } +} diff --git a/.jaiph/skills/documentation-writer/SKILL.md b/.jaiph/skills/documentation-writer/SKILL.md new file mode 100644 index 00000000..1921e864 --- /dev/null +++ b/.jaiph/skills/documentation-writer/SKILL.md @@ -0,0 +1,53 @@ + +--- +name: documentation-writer +description: 'Diátaxis Documentation Expert. An expert technical writer specializing in creating high-quality software documentation, guided by the principles and structure of the Diátaxis technical documentation authoring framework.' +--- + +# Diátaxis Documentation Expert + +You are an expert technical writer specializing in creating high-quality software documentation. +Your work is strictly guided by the principles and structure of the Diátaxis Framework (https://diataxis.fr/). + +## GUIDING PRINCIPLES + +1. **Clarity:** Write in simple, clear, and unambiguous language. +2. **Accuracy:** Ensure all information, especially code snippets and technical details, is correct and up-to-date. +3. **User-Centricity:** Always prioritize the user's goal. Every document must help a specific user achieve a specific task. +4. **Consistency:** Maintain a consistent tone, terminology, and style across all documentation. + +## YOUR TASK: The Four Document Types + +You will create documentation across the four Diátaxis quadrants. You must understand the distinct purpose of each: + +- **Tutorials:** Learning-oriented, practical steps to guide a newcomer to a successful outcome. A lesson. +- **How-to Guides:** Problem-oriented, steps to solve a specific problem. A recipe. +- **Reference:** Information-oriented, technical descriptions of machinery. A dictionary. +- **Explanation:** Understanding-oriented, clarifying a particular topic. A discussion. + +## WORKFLOW + +You will follow this process for every documentation request: + +1. **Acknowledge & Clarify:** Acknowledge my request and ask clarifying questions to fill any gaps in the information I provide. You MUST determine the following before proceeding: + - **Document Type:** (Tutorial, How-to, Reference, or Explanation) + - **Target Audience:** (e.g., novice developers, experienced sysadmins, non-technical users) + - **User's Goal:** What does the user want to achieve by reading this document? + - **Scope:** What specific topics should be included and, importantly, excluded? + +2. **Propose a Structure:** Based on the clarified information, propose a detailed outline (e.g., a table of contents with brief descriptions) for the document. Await my approval before writing the full content. + +3. **Generate Content:** Once I approve the outline, write the full documentation in well-formatted Markdown. Adhere to all guiding principles. + +## CONTEXTUAL AWARENESS + +- When I provide other markdown files, use them as context to understand the project's existing tone, style, and terminology. +- DO NOT copy content from them unless I explicitly ask you to. +- You may not consult external websites or other sources unless I provide a link and instruct you to do so. diff --git a/.jaiph/testing.jh b/.jaiph/testing.jh deleted file mode 100755 index 50c15386..00000000 --- a/.jaiph/testing.jh +++ /dev/null @@ -1,10 +0,0 @@ -#! /usr/bin/env jaiph - -script test_runner = ``` -cd "${JAIPH_WORKSPACE:?}" -bash e2e/tests/72_docker_run_artifacts.sh -``` - -workflow default() { - run test_runner() -} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index be0622c6..f26e9ad0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,23 +1,80 @@ # Unreleased -- **Language:** `for in { … }` in workflows and rules iterates newline-delimited lines of a string binding. Newlines normalize `\r\n` to `\n`; a single trailing empty segment from a final newline is omitted. Lines are not trimmed and empty interior lines are still iterated unless the body skips them (e.g. `if line != "" { … }`). Documented in `docs/language.md`. -- **Tests / QA:** Unit tests for string line splitting (`src/runtime/string-lines.test.ts`); E2E `e2e/tests/135_for_string_lines.sh`. +- **Fix — Prompt watchdog: never hang on a backend that finishes but does not exit:** `runBackend` (`src/runtime/kernel/prompt.ts`) previously gated the entire prompt result on the child process closing — `parseStream` resolves only on its readline `close`, the merged stream ends only on the child's `close` event, so a `claude -p` that streamed its final answer but never exited (commonly because a descendant it spawned is still holding the stdout pipe open) blocked the runtime forever: no `PROMPT_END`, no commit, no queue progress, no retry. Three independent watchdog layers now wrap every subprocess backend (claude / cursor / custom command) via a new exported `installPromptWatchdog`: **(1) completion grace** — `parseStream` now takes an `onComplete` callback that fires when the backend's terminal `result` event is parsed (tracked by a new `sawResult` flag on `StreamState` in `src/runtime/kernel/stream-parser.ts`); once seen, the process is given `JAIPH_PROMPT_COMPLETION_GRACE_SECONDS` (default 30s) to exit before it is terminated and the prompt returns **success** with the captured answer; **(2) idle timeout** — no stdout/stderr for `JAIPH_PROMPT_IDLE_TIMEOUT_SECONDS` (default 900s / 15m, reset on every output chunk) terminates the process and returns **failure**, feeding the existing prompt-retry backoff; **(3) absolute cap** — total wall-clock over `JAIPH_PROMPT_MAX_SECONDS` (default 7200s / 2h) terminates and fails likewise. Each layer is disabled with `0`. On expiry the watchdog sends `SIGTERM`, escalating to `SIGKILL` after 5s. A single-settle guard in `runBackend` ensures the normal-exit path and the watchdogs cannot double-resolve, and on settle the runtime now destroys its handles on the child's `stdin`/`stdout`/`stderr` (and the claude `merged` PassThrough) so a lingering descendant holding a pipe can no longer keep the event loop — and thus the whole run — alive after the prompt has resolved. The three knobs are read in `resolveConfig` via literal `env.JAIPH_PROMPT_*` access so the env-var source-parity harness (`integration/docs-reference-task5.test.ts`) pins them, and are documented in `docs/env-vars.md` (parity table) and a new "Prompt watchdog timeouts" section in `docs/configuration.md`. New tests in `src/runtime/kernel/prompt.test.ts` cover the watchdog unit (idle fires, `bump()` resets idle, absolute cap ignores `bump()`, completion grace settles success with the captured final, `clear()` cancels, fires-at-most-once) plus two end-to-end `executePrompt` cases through a fake cursor-agent that hangs (recovers with success after a `result` event; recovers with failure on a silent hang). Under Docker, `runtime.docker_timeout_seconds` remains the outer backstop. +- **Docs — Post-parity cleanup: retire the `docs/_legacy/` quarantine (docs redesign 8/8):** Final task in the [Diátaxis](https://diataxis.fr/) docs rewrite — the agent-doable cleanup that runs after the maintainer's redesign-aware parity pass (`jaiph run .jaiph/docs_parity_redesign.jh`, a redesign-aware copy of `docs_parity.jh` that lists docs recursively, excludes `docs/_legacy/`, and verifies the Diátaxis structure against the TypeScript/Bash source + `docs/architecture.md` instead of re-consolidating it). With the greenfield pages (tasks 3–7) confirmed to match source on run-dir naming, env-var lists, flag tables, config keys, and error codes, the `docs/_legacy/` quarantine is now obsolete — its content has been fully superseded by the live pages and is recoverable from git history. All 14 quarantined pages (`artifacts.md`, `cli.md`, `configuration.md`, `contributing.md`, `getting-started.md`, `grammar.md`, `hooks.md`, `inbox.md`, `language.md`, `libraries.md`, `sandboxing.md`, `setup.md`, `spec-async-handles.md`, `testing.md`) are deleted from the worktree, and the now-unneeded `- _legacy` entry (plus its explanatory comment) is removed from the Jekyll `exclude:` list in `docs/_config.yml` so the build config no longer carries a vestige of the quarantine. The README's "Docs note" trailing sentence ("Pre-redesign pages that have not been recreated yet stay quarantined under [`docs/_legacy/`](docs/_legacy/) (in git, not published).") is dropped because there are no longer any quarantined pages to disclose. The env-forwarding parity check in `src/runtime/docker.test.ts` is repointed from `docs/_legacy/sandboxing.md` to the published reference page `docs/env-vars.md` (asserting that the `ENV_ALLOW_PREFIXES` allowlist and the `ENV_ALLOW_EXCLUDE_PREFIX` exclude prefix appear verbatim in the live page), and the companion cross-link assertion that required `configuration.md` and `cli.md` to link to the `sandboxing.md#environment-variable-forwarding` section is dropped — the env-vars reference is now self-contained, so the legacy cross-link contract no longer applies. The dedicated quarantine harness `integration/docs-legacy-quarantine.test.ts` is deleted because its invariants ("`_legacy` is build-excluded", "live pages exist alongside quarantined reference copies", "nav never targets a quarantined permalink") no longer have a `_legacy` directory to police, and the matching row is removed from the test-suite table in `docs/contributing.md`. In its place, `integration/docs-structure.test.ts` replaces the prior "pages under `docs/_legacy/` are exempt from publish-side checks" lint with a one-line `docs-lint: docs/_legacy/ no longer exists (post-redesign cleanup)` assertion that fails if anyone re-introduces the directory — the cleanup is now a hard invariant rather than a tolerated state. The docs-lint, internal-link, redirect-coverage, env-var source-parity (`integration/docs-reference-task5.test.ts`), and nav-structure (`integration/docs-nav-structure-task7.test.ts`) tests from tasks 2–7 stay green, and `bundle exec jekyll build` exits 0 with no missing-link / front-matter warnings. No runtime, CLI, language, or behavior changes — this task is docs cleanup only, closing out the eight-task Diátaxis redesign queue. +- **Docs — Diátaxis IA finalization: nav regrouping, landing entry points, redirect sweep (docs redesign 7/8):** Fifth content task in the [Diátaxis](https://diataxis.fr/) docs rewrite — the structural wiring task that ties together the greenfield Explanation (task 3), How-to (task 4), Reference (task 5), and Tutorials (task 6) pages into the target IA. The Jekyll nav in `docs/_layouts/docs.html` is regrouped into **five labeled `
  • ` sections in the documented Diátaxis order — Tutorials → How-to guides → Reference → Explanation → Contributing** — each containing exactly the published pages whose `diataxis:` front-matter matches the section (`tutorial` → Tutorials, `how-to` → How-to guides, `reference` → Reference, `explanation` → Explanation, `contributor` → Contributing). The active-page highlighting (`{% if page.permalink == '/...' %} class="docs-nav-active" aria-current="page"{% endif %}`) is preserved on every entry, and the contributor Agent Skill link continues to point at the in-site permalink `/jaiph-skill` (the raw-`jaiph-skill.md` URL stays in `README.md` and `docs/index.html` because those are the entry points agents themselves consume and they need the unrendered Markdown — that contract is unchanged from task 2). Tutorials lead the panel because they are the entry point for newcomers; Contributing trails because it is in-repo developer surface, not user-facing. The landing page (`docs/index.html`) entry points are repointed to lead with the **first tutorial** and the **how-to index** (not a flat page list): the top-nav `Docs` link is split into `Tutorial` (→ `/tutorials/first-workflow`) and `How-to` (→ `/how-to/install`), and the footer `Architecture` link is replaced with the same `Tutorial` + `How-to` pair so the landing page guides newcomers down the tutorial path and operators down the how-to path rather than dumping them on an explanation page. The live contributor page `docs/contributing.md` (permalink `/contributing`, `diataxis: contributor`, `redirect_from: /contributing.md`) owns the `/contributing` slug alongside `docs/jaiph-skill.md` at `/jaiph-skill` — both under the Contributing nav section (22 published `docs/*.md` pages with `diataxis:` front-matter in total). Every URL in the pre-redesign nav (`/getting-started`, `/setup`, `/libraries`, `/artifacts`, `/language`, `/grammar`, `/cli`, `/configuration`, `/testing`, `/spec-async-handles`, `/inbox`, `/hooks`, `/sandboxing`, `/architecture`, `/contributing`) now resolves to its new home — either directly (the slug is unchanged on a live page) or via a single `jekyll-redirect-from` stub emitted from the absorbing page's `redirect_from:` list. `bundle exec jekyll build` exits 0 with no missing-link / front-matter warnings and emits no page from `docs/_legacy/` (already build-excluded via the `_config.yml` `exclude:` list from task 1). A new integration test `integration/docs-nav-structure-task7.test.ts` (Node `--test`, auto-picked up by `npm test`) graders this task end-to-end as two checks: (1) the nav layout's `
  • ` headings are exactly `["Tutorials", "How-to guides", "Reference", "Explanation", "Contributing"]` in that order — drift in heading text or ordering fails the test; (2) every published `docs/*.md` with a `diataxis:` front-matter value appears under the matching section exactly once (no miss / no miscategorisation / no cross-section duplicate), and every section's link list equals the set of permalinks for its diataxis bucket — so adding a new how-to page without nav-wiring it, or accidentally listing a tutorial under Explanation, fails the test. The existing docs-lint harness from task 2 (`integration/docs-structure.test.ts`) continues to enforce the historical-permalink resolution check (`every historical nav permalink still resolves`) — that test mines every `'' | relative_url` reference from `git log -p --all -- docs/_layouts/docs.html` and asserts the URL still resolves via a published page or a `redirect_from:` alias, which is the redirect-coverage backstop for this task. With this task in, the IA is complete: all four user-facing Diátaxis quadrants are nav-grouped under their section heading, plus the in-repo Contributing bucket; the remaining task 8 work is the README/landing sweep (`docs/contributing.md` and nav wiring are in). No runtime, CLI, or language behavior changes; the edits are to `docs/_layouts/docs.html`, `docs/index.html`, `docs/contributing.md`, and `docs/jaiph-skill.md` front-matter. +- **Docs — Diátaxis Tutorials pass: guided first-success paths (docs redesign 6/8):** Fourth content task in the [Diátaxis](https://diataxis.fr/) docs rewrite. Two learning-oriented pages now land in `docs/` as published Diátaxis tutorials, each authored greenfield from the TypeScript/Bash source plus `docs/architecture.md` first and only then reconciled against `docs/_legacy/getting-started.md` (per the anti-bias protocol in `.jaiph/skills/documentation-writer/SKILL.md`). Both pages walk a newcomer from "have nothing" to a working first-success outcome, with every command copy-pasteable and one happy path only — branching/optional knobs link out to the relevant How-to or Reference page rather than expanding inline: `docs/first-workflow.md` (permalink `/tutorials/first-workflow`, `redirect_from: /getting-started`, `/getting-started.md`) — install → write a five-line script-only `.jh` with one `script` step and one `workflow default(who)` that returns the script's stdout → run with `jaiph run ./hello.jh "Adam"` → read the live progress tree, the printed return value, and the durable files under `.jaiph/runs//
  • Tutorials
  • ` group heading; (3) `/getting-started` is absorbed by `first-workflow.md`'s `redirect_from:` and **not** by any other live page (the test also asserts `architecture.md` no longer claims the slug so two redirect stubs cannot conflict at build time); (4) the first ```jh fenced block in `first-workflow.md` is *executable* — the test extracts it, writes it to a temp `hello.jh`, runs `node dist/src/cli.js run Adam` in a clean env with `JAIPH_UNSAFE=true` / `NO_COLOR=1` / `TERM=dumb`, asserts exit 0, and asserts the normalised stdout (with `(\d+(\.\d+)?(s|ms))` timings collapsed to `