Jaiph is a workflow system with a TypeScript CLI and a JavaScript kernel (src/runtime/kernel/) that interprets the workflow AST in process. There is no separate “workflow shell” emitted for execution.
This page describes how Jaiph is built: the repository layout of the major subsystems, the core components, the compile and run pipelines, and the runtime contracts (events, artifacts on disk, and distribution). It stays on the implementation boundaries. For workflow syntax and semantics, see the Language guide. For the CI-enforced import-graph and deep-module rules that keep src/ analyzable by AI agents (and humans) under a bounded context budget, see Agent analyzability.
Jaiph separates script-file emission from workflow execution on purpose. The transpiler turns each script block, and each inline script body, into real files under scripts/ with a stable layout and the JAIPH_SCRIPTS variable. NodeWorkflowRuntime still always executes from the AST through buildRuntimeGraph. Writing the script files this way keeps the bash entrypoints predictable for subprocesses, and it avoids duplicating workflow logic in a second language.
For how to contribute, see Contributing, which covers branches, test layers, the E2E assertion policy, and the bash harness. For the *.test.jh language and test blocks, see Testing.
Workflow authors write .jh / .test.jh modules. The toolchain turns those files into validated modules plus extracted script files, and then the same AST interpreter runs the workflows whether you use local jaiph run, Docker, or jaiph test.
.jh import closure once through loadModuleGraph (src/transpile/module-graph.ts) and reuses that ModuleGraph for the banner (metadataToConfig), validation (validateModule inside emitScriptsForModuleFromGraph, invoked by buildScriptsFromGraph), script-body extraction, and, across the parent to child process boundary on the default local jaiph run, for buildRuntimeGraph(graph) in the spawned runner (see Local module graph and the sequence diagram below). parsejaiph(source, filePath) is I/O-pure, and validation and script emit operate entirely on the in-memory graph and never re-read .jh files. Inside this compile-and-run graph pipeline, loadModuleGraph is the only routine that reads .jh sources from disk. A few paths outside the graph pipeline still read .jh directly, such as runWorkflowRaw on the jaiph run --raw path (src/cli/commands/run.ts) and the exported loadImportedModules helper (src/cli/shared/paths.ts).validateModuleInto: validateModule(ast, graph) is the per-module throwing form (used by emitScriptsForModuleFromGraph / buildScriptsFromGraph() so the existing single-error path stays intact), validateReferences(graph) validates every reachable module then throws the first sorted error, and collectDiagnostics(graph) returns a populated Diagnostics collector (src/diagnostics.ts) with every recoverable error from every reachable module. The jaiph compile command walks the same import closure but routes through collectDiagnostics: it builds a graph per entry, collects diagnostics, prints them all (sorted by file/line/col, in path:line:col CODE message form on stderr — or as a single JSON array on stdout with --json), and exits non-zero if any diagnostic was collected. It does not emit scripts/, does not invoke buildRuntimeGraph(), and never spawns the workflow runner (src/cli/commands/compile.ts). For a directory argument it discovers *.jh via walkjhFiles, which skips *.test.jh; to validate a test module, pass that file explicitly. Imported modules in the closure are still validated recursively either way.dist/src/cli.js via npm, or a Bun-compiled dist/jaiph binary) prepares script executables (scripts-only), then spawns a detached child through the internal __workflow-runner argv marker (spawnJaiphWorkflowProcess in src/runtime/kernel/workflow-launch.ts). The child entrypoint is runWorkflowRunner (src/runtime/kernel/node-workflow-runner.ts), which loads or deserializes the module graph, calls buildRuntimeGraph(), then runs NodeWorkflowRuntime. Under Node the spawn is process.execPath + dist/src/cli.js + __workflow-runner; under the Bun standalone binary, process.execPath is the jaiph binary itself with the same marker. Script steps execute as managed subprocesses; prompt, inbox I/O, and event/summary emission are handled by the kernel under src/runtime/kernel/.Interactive jaiph run parses __JAIPH_EVENT__ lines from the runner’s stderr, renders the progress tree, and runs hooks. jaiph run --raw skips that shell. The child uses inherited stdio, so events still land on stderr unchanged. Use --raw when you embed Jaiph or when the host wraps a container (see CLI, jaiph run and Sandboxing).
All orchestration uses the Node workflow runtime, which is the AST interpreter, whether you run local jaiph run, jaiph test, or Docker jaiph run. Docker containers run the same jaiph run --raw / __workflow-runner dispatch with the compiled JS source tree and scripts mounted read-only.
The src/ import graph is an acyclic layered DAG: parse/format → transpile → runtime → CLI, over a shared leaf. The layer table in Agent analyzability is authoritative for which paths belong to each layer and what each may import. npm run arch:check (dependency-cruiser, .dependency-cruiser.cjs) enforces it in CI — no-circular, the per-layer “no upward import” rules (including the exception that lets runtime reuse compile only through the single public entry src/transpiler.ts), no-deep-imports-into-parse, which fails when code outside the parse package imports a src/parse/** file instead of the public entry src/parser.ts, no-deep-imports-into-transpile, which fails when code outside the transpile package imports a src/transpile/** internal instead of the single public entry src/transpiler.ts (which re-exports the module-graph API that runtime reuses), no-deep-imports-into-runtime, which fails when code outside the runtime package imports a src/runtime/** internal instead of one of the two public entries src/runtime/index.ts (production) or src/runtime/testing.ts (named test seams for cross-package tests), and no-deep-imports-into-format, which fails when code outside the format package imports a src/format/** internal instead of the public entry src/format/index.ts, and no-cross-cli-slice-imports, which fails when a file in one CLI slice (commands, run, serve, mcp, exec, or telemetry) imports another slice’s private tree instead of going through src/cli/shared/** or a lower-layer public entry. The runtime ↛ cli direction is enforced too: no production file under src/runtime/ imports src/cli/**. Pre-existing violations are grandfathered in .dependency-cruiser-known-violations.json; new cycles, upward imports, parse, transpile, runtime, or format deep imports, or cross-CLI-slice imports fail the build.
src/cli, invoked via compiled src/cli.ts → dist/src/cli.js)
run, test, compile, init, install, use, format, mcp, serve). Paths ending in .jh / .test.jh are also accepted as implicit commands (see src/cli/index.ts).src/runtime/kernel/workflow-launch.ts + src/cli/run/lifecycle.ts): spawns the runner via process.execPath and the __workflow-runner argv marker. runWorkflowRunner (src/runtime/kernel/node-workflow-runner.ts) handles that argv, loads or reads the module graph, calls buildRuntimeGraph(), then NodeWorkflowRuntime.runDefault(). The default workflow name is wired in buildRunModuleLaunch (workflow-launch.ts). setupRunSignalHandlers accepts an optional onSignalCleanup callback for Docker sandbox teardown on SIGINT/SIGTERM — for a Docker-backed run it is stopDockerRunOnSignal, which stops and removes the container (docker kill then docker rm -f) before deleting the host sandbox clone so an interrupt cannot orphan a running container (see Docker runtime helper).--raw); dispatches hooks.src/parser.ts, src/parse/*)
.jh/.test.jh into a semantic AST (jaiphModule) plus a parallel Trivia store of source-fidelity data. parsejaiphWithTrivia(source, filePath) returns { ast, trivia }; the legacy parsejaiph(source, filePath) is a thin wrapper that returns only the ast for consumers that don’t need round-trip data. Both entry points are I/O-pure.src/parser.ts, which re-exports a curated public API (the two parse entry points plus named helpers such as configValueHasInterpolation, canonicalizeTripleQuotedString, resolveInterpreterFromShebang, and createTrivia). It is not an export * barrel of the tree. The no-deep-imports-into-parse rule in .dependency-cruiser.cjs fails any outside import that reaches a src/parse/** internal directly. Add a named re-export to src/parser.ts instead of reaching in. The string-content validators that both parse and transpile need (validateJaiphStringContent, extractInlineCaptures) live under the parse package in src/parse/validate-string-content.ts and are re-exported through src/parser.ts, so src/transpile/validate-string.ts imports them from the public entry and no deep import into the parse package is baselined.parseFencedBlock() (src/parse/fence.ts) handles triple-backtick fenced bodies with optional lang tokens for scripts and inline scripts; parseFencedScriptBlock() wraps it with common-margin dedent for executable script bodies. parseTripleQuoteBlock() (src/parse/triple-quote.ts) handles """...""" blocks for prompts, const, log, logerr, fail, return, and send — all positions where multiline strings appear. canonicalizeTripleQuotedString() (same file) reproduces the dedent + escape decoding that match-arm bodies still need (they carry an unprocessed tripleQuoteBodyToRaw-shaped string plus a tripleQuotedBody flag rather than being dedented at parse time); both the validator and the runtime call it, so “what the validator inspects” and “what the runtime executes” are bit-for-bit identical.run / ensure host parsing. run ref(...), run async ref(...), and ensure ref(...), optionally followed by catch (binding) { ... } (any host) or recover(binding) { ... } (run only), are parsed by a single helper parseRunOrEnsure in src/parse/workflow-brace.ts. The attached catch / recover clause — bindings, body shape (multi-line { … }, inline { stmt[; stmt]* }, or single-statement) — is parsed by one helper parseAttachedBlock(filePath, lines, idx, …, keyword, textAfterKeyword, trivia) in src/parse/steps.ts. There is no separate mini parser for catch/recover bodies: parseAttachedBlock delegates each body statement to the same parseBlockStatement (src/parse/workflow-brace.ts) that handles top-level statements, so every statement form accepted in a workflow / rule body is accepted identically inside a catch / recover body. “Is this statement allowed inside a catch/recover body?” is a validator concern (the RULE_SCOPE / WORKFLOW_SCOPE distinction in validate-step.ts), not enforced by which mini-parser branches happened to fire. src/parse/steps.ts is bounded at ≤200 lines by src/parse/parse-attached-block.test.ts, which also asserts no function named parse(Run)?(Catch|Recover|EnsureStep) reappears.parseBlockStatement (src/parse/workflow-brace.ts), every workflow / rule body line that does not begin with # is routed by a single STATEMENT: Record<string, BlockHandler> table keyed by the leading identifier — there is no longer a startsWith cascade where "run async " must be tested before "run " and "prompt " must be tested before a bare assignment. The dispatcher tokenizes the first identifier on the trimmed line, looks it up once, and invokes the matching handler (tryParseIf / tryParseFor / tryParseConst / tryParseFail / tryParseEnsure / tryParseRun / tryParsePrompt / tryParseLog / tryParseLogerr / tryParseLogwarn / tryParseReturn / tryParseStandaloneMatch / tryParseElseError, plus tryParseWait — a removal tombstone whose only job is to fail with "wait" has been removed from the language), which either returns a { step, nextIdx } result, returns null to fall through, or calls fail(...) to abort. Two non-keyword fallbacks fire after the table lookup in order: trySend (matches channel <- rhs via matchSendOperator) then shellFallthrough (everything else becomes a shell exec step). Assignment-shape error guards (name = prompt …, name = run … without const, plus the forRule rejection of prompt) run once before dispatch in applyAssignmentGuards(c). The per-line context (filePath, lines, idx, innerRaw, inner, innerNo, trivia, forRule, opts) is threaded through handlers as a single BlockCtx record. Adding a new top-level keyword is a two-file change: one row in STATEMENT (workflow-brace.ts) plus one entry in the JAIPH_KEYWORDS reserved set (core.ts) — pinned by src/parse/parse-synthetic-keyword.test.ts, which patches STATEMENT at runtime with a synthetic zzznoop handler, asserts dispatch fires, asserts the same input falls through to the shell handler when the row is removed, and greps both source files to confirm each symbol lives in exactly one place. Every existing parse-error message, line, and column is preserved bit-for-bit: src/parse/parse-error-snapshot.test.ts walks every === name block in test-fixtures/compiler-txtar/parse-errors.txt, captures { file, line, col, code, message } for each, and diffs against the snapshot stored at test-fixtures/compiler-txtar/parse-errors-snapshot.json (refreshable with UPDATE_SNAPSHOTS=1 only after confirming the change is intentional). The wider tokenizer rewrite — the ad-hoc inDoubleQuote / inTripleQuote / braceDepth scanners replicated across src/parse/, the line-walking { step, nextIdx } contract, and the per-handler regex bodies — is not part of this refactor and remains future work.src/types.ts)
jaiphModule, step defs, test defs, hook payload types). The semantic AST carries only what the validator, emitter, transpiler, and runtime need; surface-form data that exists purely to round-trip the formatter (leading comments on imports / channels / const / test blocks, top-level emit order, config body sequence, """...""" flags on literal / return / log / logerr / fail / send / const, the bareSource of return <ident>, and prompt / script bodyKind discriminators) lives in Trivia instead — see Trivia (CST layer).Expr for every value position. Anywhere a value can appear — const name = …, return …, send channel <- …, log / logerr / fail arguments, and the body of an exec statement — the AST stores a single tagged union: Expr = literal | call | ensure_call | inline_script | prompt | match | shell | bare_ref. There is no longer a separate ConstRhs union, SendRhsDef union, or managed: sidecar on return / log / logerr (the placeholder strings "__match__" / "run inline_script" / "__JAIPH_MANAGED__" are gone too — a meta-test in src/types-shape.test.ts fails if any reappear under src/). The eight Expr kinds: literal (verbatim source text — quoted string, $var / ${var} form, or post-dedent triple-quoted body), call (managed workflow/script call; async: true for run async ref(...) capture position), ensure_call (managed rule call), inline_script (`body`(args) or fenced), prompt (carries the JSON-quoted body and optional flat returns schema), match (a match <subject> { ... } evaluated for its value), shell (raw shell fragment used as a managed substitution on the send RHS), and bare_ref (bare symbol on a send RHS — always rejected by the validator, preserved so the error message can name the symbol).WorkflowStepDef variants (down from fourteen): exec (side-effecting managed call statement — was run / ensure / run_inline_script / prompt / standalone match / inline shell; the discriminator now lives inside body.kind, with captureName / catch / recover as step-level attributes); const, return, send (bind, propagate, or emit an Expr); say (was log / logerr / logwarn / fail — level: "fail" aborts the workflow with the message, otherwise the message is written to the corresponding stream); if / for_lines (control flow, unchanged shape); trivia (formatter-only comment / blank_line slots — skipped by the runtime and validator). A type-level exhaustive switch in src/types-shape.test.ts pins both the step count at 8 and the Expr kind count at 8.Expr (call, ensure_call, inline_script) carries args?: Arg[] where Arg = { kind: "literal"; raw: string } | { kind: "var"; name: string }. The parser classifies each argument once (a bare identifier or bare IDENT.IDENT typed-prompt field access becomes var; everything else — quoted strings, nested run … / ensure … calls, inline-script bodies, and illicit unquoted ${…} forms — is stored as literal). There is no separate args: string text payload or shadow bareIdentifierArgs: string[] field, and no downstream consumer re-parses call arguments: the validator walks the typed list to enforce arity, reject nested unmanaged calls inside literals, reject unquoted ${…} call args (E_VALIDATE — interpolation belongs inside strings; use bare name / result.role), resolve var refs against in-scope bindings (and dotted var names against typed-prompt schemas), and check ${var.field} embedded inside quoted literal args; the emitter renders by mapping each Arg to its source form; the runtime turns Arg[] back into a runtime string via argsToRuntimeString (var → ${name}, literal → raw) so the existing handle-resolution / interpolation path is unchanged.src/parse/trivia.ts)
Trivia is a parallel store keyed by AST-node identity (per-node via WeakMap) and a small ModuleTrivia record for module-level data. The parser builds it alongside the AST; only the formatter reads it. Validator, emitter, transpiler, and runtime never import from src/parse/trivia.ts — a grep test (src/parse/trivia-grep.test.ts) pins this invariant by rejecting any reference to Trivia / createTrivia / NodeTrivia / ModuleTrivia from validator and emitter source files.src/parse/trivia-ast-shape.test.ts) asserts at compile time that none of the formatter-only fields reappear on jaiphModule, ImportDef, ScriptImportDef, ChannelDef, TestBlockDef, WorkflowMetadata, ScriptDef, or any WorkflowStepDef / Expr variant. (ConstRhs / SendRhsDef no longer exist — their fields live inside Expr — and src/types-shape.test.ts fails if those symbols reappear as exports of src/types.ts.)src/transpile/validate.ts + src/transpile/validate-step.ts)
resolveImportPath in transpile/resolve.ts) checks relative paths first, then falls back to project-scoped libraries under <workspace>/.jaiph/libs/ — the workspace root is threaded through all compilation call sites. Export visibility is enforced by validateRef in validate-ref-resolution.ts: if an imported module declares any export, only exported names are reachable through the import alias.validate.ts owns the outer layer: import / channel-route / test-block checks plus walkStepTree (the single descent that builds { knownVars, promptSchemas, flat } for each workflow / rule). validate-step.ts owns the per-step visitor: one row per WorkflowStepDef.type in a VALIDATORS: Record<WorkflowStepDef["type"], StepValidator> table, a single validateExpr dispatcher over the 8 Expr.kind values, and the call-shape / channel / string-content helpers. validate.ts is bounded at ≤700 lines (currently ~470) by a CI-style test in src/transpile/validate-visitor.test.ts; new validators belong in validate-step.ts.validateStep(step, ctx) in validate-step.ts. It looks the step’s type up in VALIDATORS (the dispatch table), then consults ctx.scope.allowSteps (a Set<StepType>) once to decide whether this step is permitted in the current scope. Two scopes exist: WORKFLOW_SCOPE (allows every step variant including send and prompt) and RULE_SCOPE (rejects send outright; rejects prompt and run async from inside exec bodies). The scope also carries runRefExpect (RUN_TARGET_REF_EXPECT for workflows, RUN_IN_RULE_REF_EXPECT for rules) and withPromptSchemas (workflows collect prompt-returning bindings; rules skip schema collection). Adding a new step type requires exactly one row in VALIDATORS and, if the rule/workflow split needs to differ, an entry in Scope.allowSteps — an AC4 test in validate-visitor.test.ts injects a synthetic step type and asserts it produces exactly one diagnostic with the documented internal: no validator for step type "…" message until the row is added.call / ensure_call site runs the same five checks against the typed Arg[] directly — shell-redirection rejection (only literal args are scanned), nested-unmanaged-call rejection inside literal raws, ref resolution (with the scope’s runRefExpect for call, RULE_REF_EXPECT for ensure_call), arity (args.length vs declared params), and var-arg resolution against in-scope bindings via validateArgVarRefs. The sequence lives once in validateCallable(expr, ctx); both run and ensure validators invoke it with a different ref expectation / target kind. There is no longer a separate validateBareIdentifierArgs helper, no per-site repetition of the five-step sequence, and no place re-parses an args: string payload by splitting on commas or rescanning quotes.Diagnostics collector (src/diagnostics.ts) via diag.error(file, line, col, code, msg), which records a JaiphDiagnostic and short-circuits the current validation unit through a BailoutError. Each top-level unit (per-import block, per-rule walk, per-rule step, per-workflow walk, per-workflow step, per-test-block step, per-channel route) is wrapped in diag.capture(fn), which absorbs the bailout (and any thrown jaiphError from leaf helpers like validate-ref-resolution.ts / validate-string.ts / validate-prompt-schema.ts / shell-jaiph-guard.ts / parse/validate-string-content.ts) so the next sibling unit still runs. collectDiagnostics(graph) walks every module and returns the populated collector; the legacy validateReferences(graph) is now a thin wrapper that throws the first sorted diagnostic via jaiphError so graph-level callers and existing per-error tests keep working; emitScriptsForModuleFromGraph still calls validateModule(ast, graph) per module before emit. Diagnostics.sorted() returns errors ordered by (file, line, col); formatLines() renders the standard path:line:col CODE message shape. A grep test (src/transpile/diagnostics-collector.test.ts) pins the migration: validate.ts + validate-step.ts hold zero throw jaiphError( sites, and the remaining throw jaiphError( call sites under src/ are confined to a documented allowlist — fatal aborts in the parser (src/parse/core.ts), the loader (src/transpile/module-graph.ts), and the test-file shape check (src/cli/commands/test.ts); the legacy bridge in src/diagnostics.ts; and the five leaf validation helpers above, each of which has every caller wrapped in diag.capture(...).WorkflowStepDef.type (8 variants) and Expr.kind (8 variants). For every value-bearing step (const / return / send / say) and for the body of every exec step, a single validateExpr(expr, ...) dispatcher handles the value: it routes call / ensure_call / inline_script to call-site validation (validateCallable), walks match arms, schema-checks prompt, and runs the substitution scanner on literal raws. There is no dual code path for “managed sidecar vs literal value” — that branch is gone.src/transpile/ may import … from "…/runtime/…". Compile-time code must not depend on runtime semantics: when the validator needs the same canonical form the runtime will see (the dedented, escape-decoded view of a triple-quoted match-arm body), both sides import a parser-side helper (canonicalizeTripleQuotedString in src/parse/triple-quote.ts) rather than reaching across the layer. A grep test (src/transpile/no-runtime-imports.test.ts) scans every non-test *.ts under src/transpile/ and fails if any from "…/runtime/…" import appears; a separate corpus test (src/parse/canonicalize-triple-quoted.test.ts) parses every .jh under test-fixtures/ and examples/, collects every triple-quoted match-arm body, and asserts canonicalizeTripleQuotedString matches the pre-move tripleQuotedRawForRuntime output bit-for-bit.walkStepTree (in validate.ts), which simultaneously accumulates knownVars (env decls + params + every nested const / capture / for_lines iterator), promptSchemas (top-level prompt-returning bindings, gated by options.withPromptSchemas so rules skip schema collection), enforces immutable-binding / script-collision rules inline (mutating a shared bindings map and threading a fresh inner map under each for_lines so loop iterators only shadow inside the body), and emits a flat FlatStepEntry[] of every step in tree order with the enclosing catch / recover failure binding attached. The main per-step validator loop iterates that flat list non-recursively and calls validateStep once per entry, so walkStepTree’s internal descend is the only recursive helper in validate.ts that takes a WorkflowStepDef[]. A pair of grep / AST tests (src/transpile/validate-single-walk.test.ts) pins both invariants: the prior helpers (collectKnownVars, collectPromptSchemas, validateImmutableBindings) cannot reappear by name, and at most one recursive WorkflowStepDef[] walker may live in validate.ts.src/transpiler.ts, src/transpile/*)
src/transpiler.ts, the single public entry. It re-exports a curated public API (buildScripts, buildScriptsFromGraph, emitScriptsForModule, emitScriptsForModuleFromGraph, collectDiagnostics, validateReferences, walkjhFiles, walkTestFiles, resolveImportPath, workflowSymbolForFile, and the ModuleGraph / ModuleNode / ScriptArtifact types) plus the full module-graph API (loadModuleGraph, readModuleGraph, writeModuleGraph, moduleGraphFromAsts, serializeModuleGraph, deserializeModuleGraph). It is not an export * barrel of the tree. Runtime reuses the same graph and reaches the module-graph API through this entry too, so src/transpile/module-graph.ts is no longer a second door. The no-deep-imports-into-transpile rule in .dependency-cruiser.cjs fails any outside import that reaches a src/transpile/** internal directly, such as module-graph.ts, validate.ts, build.ts, or an emit-*.ts file. Add a named re-export to src/transpiler.ts instead of reaching in. No deep import into the transpile package is baselined: the string-content validators that src/parse/metadata.ts used to reach for now live under the parse package (src/parse/validate-string-content.ts), so no file under src/parse/ imports src/transpile/.emitScriptsForModuleFromGraph validates one module against the graph and runs buildScriptFiles — the only compile path for jaiph run / jaiph test — persists only atomic script files under scripts/. buildScripts(input, outDir, ws?) is the path-based wrapper used by tests and the directory walk; it loads a ModuleGraph and delegates. buildScriptsFromGraph(graph, outDir) is the graph-based entry point used by jaiph run / jaiph test, which already loaded the graph. Inline scripts (run `body`(args)) are also emitted as scripts/__inline_<hash> with deterministic hash-based names (inlineScriptName in src/inline-script-name.ts). There is no workflow-level bash emission.loadModuleGraph → buildScriptsFromGraph(graph, outDir), which runs validateModule + buildScriptFiles per reachable module via emitScriptsForModuleFromGraph. parsejaiph is I/O-pure; validation and script emit never re-read .jh sources during graph work. Each reachable module is parsed exactly once per jaiph run (see Local module graph).src/runtime/index.ts)
src/runtime/index.ts, which re-exports a curated CLI-facing API: graph construction (buildRuntimeGraph, RuntimeGraph), the launch and runner entry points (runWorkflowRunner, WORKFLOW_RUNNER_ARG, spawnJaiphWorkflowProcess, runTestFile), the Docker sandbox surface (spawnDockerProcess, resolveDockerConfig, prepareImage, selectSandboxMode, the run-config env constants, and the DockerRunConfig / SandboxMode types, among others), the emit and audit-chain helpers the CLI reads after a run (generateChainKey, verifyRunJournal, redactCredentials), the terminal-portability helpers (canUseAnsi, killProcessTree, resolveShell), the embedded-asset accessors, and the run-tree param display helper (buildStepDisplayParamPairs). It is not an export * barrel of the tree. The runtime has two allowlisted public entries: src/runtime/index.ts (the production surface above) and src/runtime/testing.ts (test seams, described next). The no-deep-imports-into-runtime rule in .dependency-cruiser.cjs fails any outside import that reaches a src/runtime/** internal directly, such as a kernel/*.ts file or docker.ts, unless the import goes through one of those two entries. Add a named re-export to src/runtime/index.ts instead of reaching in. The baseline carries no no-deep-imports-into-runtime leftovers. The former src/config.ts → src/runtime/kernel/runtime-arg-parser.ts leak is gone: the pure interpolate helper moved down into src/config.ts, which runtime-arg-parser.ts imports downward and re-exports, so src/config.ts imports nothing from src/runtime/.src/runtime/testing.ts). A few runtime internals are private to production but needed by cross-package *.test.ts files that stub or inspect them: the Docker exec and spawn indirection (_dockerExec, _dockerSpawn), the in-place run prompt (_inplacePrompt), the audit-chain HMAC internals (CHAIN_GENESIS, chainHmac), and the live-event emitter (RuntimeEventEmitter). These are re-exported from a second public entry, src/runtime/testing.ts, which is allowlisted beside src/runtime/index.ts in the no-deep-imports-into-runtime rule. A cross-package test imports a seam from src/runtime/testing.ts and never a raw src/runtime/** path, so the seams stay off the production index.ts while no test deep-imports the tree. Keep this surface small: add a seam only when a cross-package test genuinely needs one.buildStepDisplayParamPairs helper lives in the runtime at src/runtime/kernel/format-params.ts; src/cli/commands/format-params.ts re-exports it through the public entry so its CLI callers keep one import site. No production file under src/runtime/ imports src/cli/**.src/runtime/kernel/node-workflow-runtime.ts)
NodeWorkflowRuntime interprets the AST directly: walks workflow steps, manages scope/variables, delegates prompt and script execution to kernel helpers, handles channels/inbox/dispatch, owns the frame stack and heartbeat, and writes run artifacts.executeScript reads the emitted script’s shebang line, resolves the interpreter through resolveInterpreterFromShebang (src/parse/script-bash.ts) — #!/usr/bin/env <lang> → spawn <lang>, an absolute-path shebang → spawn that path, a missing shebang → default bash — and spawns <interpreter> <scriptPath> <args…>. This is portable: it does not depend on the OS honoring the shebang (Windows honors neither shebang nor exec bit) or on the file’s 0o755 bit (noexec mounts strip it). The shebang line is still written into every emitted script (they stay directly executable by hand on POSIX), but the runtime never relies on it being honored. A spawn ENOENT from a missing interpreter surfaces as a diagnosable Jaiph error naming the interpreter rather than a raw ENOENT.executeShLine) and CLI hook commands (src/cli/run/hooks.ts) both run under POSIX sh -c, but the shell itself is resolved through resolveShell() (src/runtime/kernel/portability.ts) rather than a hardcoded spawn("sh", …). On POSIX this is bare sh; on win32, where there is no sh on the default PATH, it discovers Git for Windows’ bundled sh.exe — first on PATH, then in the standard install layouts (<Git>/bin/sh.exe, <Git>/usr/bin/sh.exe) under each known root — memoizes the result for the process, and throws a diagnosable E_NO_POSIX_SHELL error naming Git for Windows if none is found. Inline lines are never translated to cmd/PowerShell: Jaiph’s shell semantics are POSIX sh on every platform, so the seam only ever chooses which sh to invoke, never rewrites the command — otherwise workflows would stop being portable. resolveShell() is the single call site for the POSIX shell; no other spawn("sh", …) remains in src/.evaluateExpr(scope, expr, …) dispatcher handles every value position — const / return / send / say step handlers and the body of every exec step delegate to it. It switches on Expr.kind to run the managed call (call / ensure_call / inline_script) or prompt, walks a match expression, or interpolates a literal value through interpolateWithCaptures. There is no fan-out across “managed sidecar vs literal value” because that branch is gone from the AST. interpolateWithCaptures takes an optional quoteValue escaper: shell-fallthrough lines pass shellQuote (src/runtime/kernel/prompt.ts, the single canonical escaper) so every interpolated value — parameter, capture, for iterator, channel payload, and inline ${run …} / ${ensure …} capture result — is shell-quoted before it reaches sh -c, while every other value position interpolates the raw value. This is the one sh -c interpolation sink, so a caller-controlled value bound through jaiph mcp / jaiph serve cannot inject a command (finding H-1).runPromptStep wraps each executePrompt invocation in a retry loop driven by the schedule resolved through src/runtime/kernel/prompt-retry.ts (default 15s → 1m → 10m → 30m → 2h, six total attempts; configurable via JAIPH_PROMPT_RETRY / JAIPH_PROMPT_RETRY_DELAYS). Only the transport path (non-zero exit from the backend) is retried; invalid JSON and schema-validation failures return { ok: false } on the first attempt. Each attempt emits its own PROMPT_START / PROMPT_END and STEP_START / STEP_END; each failure (and the final termination) logs a LOGERR through RuntimeEventEmitter.emitLog. The backoff sleep is injectable (sleep constructor option) and interruptible via runtime.abort() / an internal AbortController so SIGINT and in-process aborts halt the loop without further backend calls. Retry composes below recover / catch — backoff is exhausted before the failure reaches the recover loop. See Configuration — Prompt retry on transport failure.LOGWARN on a fixed cadence — JAIPH_STEP_IDLE_WARN_SEC (default 180s, so 180s / 360s / 540s / …) — through createStepIdleOutputWarn (src/runtime/kernel/step-idle-warn.ts); the next output chunk resets the cadence. This surfaces a stalled backend or long-running command without failing the run. The same tracker also enforces a hard idle-kill threshold for script steps. After JAIPH_STEP_IDLE_KILL_SEC (default 3600s, 0 disables) of silence it emits a LOGERR naming the step and idle duration and aborts a kill signal the step passes to spawnAndCapture. Aborting that signal terminates the step’s subprocess through killProcessTreeEscalating (SIGTERM, then SIGKILL) and settles the step as a failure at once, without waiting for close, because a hung descendant that outlived the child while holding the stdout pipe open would otherwise keep the run stuck forever. The warn and kill cadences run off one idle clock but fire independently, and the kill fires at most once. Prompt steps drive no subprocess, so they get warnings only. So a leaf that goes silent overnight fails the run instead of holding the loop open. See Configuration — Leaf step idle output.JAIPH_MAX_STEPS (parsed by parseMaxSteps in src/runtime/kernel/max-steps.ts, 0 / empty / invalid disables it) bounds a runaway workflow that the per-prompt idle watchdog cannot catch — an unbounded loop, a channel or recursion cycle, or a self-referential run chain. A single stepsExecuted counter on NodeWorkflowRuntime increments on every executed non-trivia step across the whole run, and loop iterations and nested or recursive calls share it. Once it exceeds the cap the runtime emits a LOGERR (maxStepsTrippedMessage, E_MAX_STEPS), calls abort(), and returns a failure step result, so the run stops without a manual signal. See Configuration — Overall run timeout and step cap.src/runtime/kernel/ carry concerns that used to live inline in the runtime file. Dependency direction is one-way (orchestrator → helpers/emitter/mock); no circular imports back.
runtime-arg-parser.ts — stateless call-argument parsing (parseInlineCaptureCall, commaArgsToInterpolated, parseArgsRaw, parseInlineScriptAt, parseManagedArgAt, parseArgTokens, stripOuterQuotes, parsePromptSchema, sanitizeName, nowIso) plus shared constants and the ParsedArgToken / PromptSchemaField types. The interpolate helper lives one layer down in src/config.ts (config resolution reuses it) and is re-exported here so kernel callers keep a single import site. Direct unit tests live in runtime-arg-parser.test.ts.runtime-event-emitter.ts — RuntimeEventEmitter owns __JAIPH_EVENT__ writes on stderr (step/log traffic when not suppressed), run_summary.jsonl appends for the wider timeline (including workflow/prompt records that are summary-first), plus step/prompt sequence counters. Constructed with { runId, runDir, env, getFrameStack, getAsyncIndices, suppressLiveEvents? }; the runtime delegates structured emission to it. The optional suppressLiveEvents flag (forwarded from NodeWorkflowRuntime’s suppressLiveEvents option) skips the live stderr __JAIPH_EVENT__ lines while appendRunSummaryLine keeps updating run_summary.jsonl — used by in-process callers like the test runner that share stderr with node --test reporter output. The CLI’s spawned __workflow-runner child does not set it, so production runs stream events to stderr as before.runtime-mock.ts — executeMockBodyDef and executeMockShellBody for *.test.jh workflow/rule/script mocks. Shell-kind mocks run bash -c; steps-kind mocks dispatch back into the runtime via an executeStepsBack callback so the body runs against the full step interpreter.buildRuntimeGraph() (graph.ts) accepts either an entry file path (legacy) or an already-loaded ModuleGraph and returns the runtime-ready view by injecting ScriptDef stubs for import script declarations so reference resolution matches the validated compile path without re-reading external script bodies. Cross-module refs are resolved from that graph at runtime. RuntimeGraph is a type alias for ModuleGraph — there is one canonical “all reachable modules” representation. The stub-injection helper (attachScriptImportStubs) is idempotent.src/runtime/kernel/node-test-runner.ts)
*.test.jh test blocks using NodeWorkflowRuntime with mock support (mock prompts, mock workflow/rule/script bodies). Pure Node harness — no Bash test transpilation.src/runtime/kernel/)
prompt.ts), streaming parse (stream-parser.ts), schema (schema.ts), mock.ts (sequential prompt responses / mock-arm dispatch from test env JSON), runtime-mock.ts (mock workflow/rule/script bodies for *.test.jh), emit.ts (durable run_summary.jsonl helpers — appendRunSummaryLine, formatUtcTimestamp — consumed by RuntimeEventEmitter), workflow-launch.ts (spawn contract). RuntimeEventEmitter (runtime-event-emitter.ts) owns live __JAIPH_EVENT__ lines on stderr and coordinates summary writes plus step/prompt sequence counters. Script subprocesses are launched directly from NodeWorkflowRuntime.src/format/index.ts, src/format/emit.ts)
src/format/index.ts, which re-exports the formatter API (emitModule and the EmitOptions type). It is not an export * barrel of the tree. The no-deep-imports-into-format rule in .dependency-cruiser.cjs fails any outside import that reaches a src/format/** internal directly, such as emit.ts. Add a named re-export to src/format/index.ts instead of reaching in. Format is layer 1 beside parse, so its sources import only parse and types, never src/cli, src/runtime, or src/transpile.jaiph format rewrites .jh / .test.jh files into canonical style. emitModule(ast, trivia, opts?) reads the semantic AST together with the parallel Trivia store (Trivia (CST layer)) to round-trip leading comments, top-level order, config body sequence, """...""" and bareSource forms, the original quotedness of top-level const values (EnvDeclDef.wasQuoted — true for "…" / """…""" sources, undefined for bare tokens — so a quoted value is never silently rewritten as bare based on whether it contains a space), and prompt / script body discriminators. Step emission switches on WorkflowStepDef.type (8 variants) and an emitExpr helper switches on Expr.kind (8 kinds) — there are no dual code paths for “managed sidecar vs literal value” because that branch was removed from the AST. Call arguments render straight off the typed Arg[] — var → bare name, literal → raw — so the formatter no longer re-parses any args string or consults a bareIdentifierArgs shadow field. Pure data→text emitter; no side-effects beyond file writes. Round-trip is bit-for-bit on every fixture under examples/ and test-fixtures/golden-ast/fixtures/ — pinned by src/format/roundtrip.test.ts, which asserts parse → format → parse → format converges in one step on every fixture.src/runtime/docker.ts)
docker run invocation when the CLI enables Docker sandboxing for jaiph run (environment-driven; there is no jaiph run --docker flag — see Sandboxing). Host-controlled image/network (finding M-6): an entry file is untrusted, so when Docker is the active sandbox resolveDockerConfig rejects a file-declared runtime.docker_image (E_DOCKER_IMAGE_HOST_ONLY) and a file-declared isolation-breaking runtime.docker_network — host, container:*, ns:*, anything that is not default / none / a plain named bridge network (isHostSafeInFileNetwork) — (E_DOCKER_NETWORK_HOST_ONLY). The operator’s JAIPH_DOCKER_IMAGE / JAIPH_DOCKER_NETWORK remain trusted and are used verbatim (they may even select host); host-safe in-file network values are still honoured. When Docker is off these keys are inert and not enforced. On win32 the Docker sandbox is out of scope: resolveDockerConfig forces host-only mode (same UX as an explicit JAIPH_UNSAFE=true) with a one-line notice, so the CLI never probes docker and never hard-fails on a missing daemon (JAIPH_DOCKER_ENABLED=true cannot override this). The container runs the same jaiph run --raw / __workflow-runner entry as local execution. The default image is the official ghcr.io/jaiphlang/jaiph-runtime GHCR image tagged with the CLI version (ghcr.io/jaiphlang/jaiph-runtime:<version>); every selected image must already contain jaiph (no auto-install or derived-image build at runtime). Image preparation (prepareImage) runs before the CLI banner: it checks whether the image is local, pulls with --quiet if needed (short status lines on stderr instead of Docker’s default pull UI), and verifies that jaiph exists in the image. Digest pinning + fail-closed verification (finding M-6): the mutable tag alone is not the trust boundary — a registry compromise, a re-pointed tag, or a poisoned local cache under the same tag would substitute the sandbox rootfs. The expected manifest digest ships with the release (RUNTIME_IMAGE_DIGEST, baked from package.json’s runtimeImageDigest) and an operator can override it with JAIPH_DOCKER_IMAGE_DIGEST (resolveExpectedDigest). When a digest is pinned, a cold pull resolves the digest-pinned reference (repo@sha256:…, content-addressed) and tags it back to the run reference (pullPinnedImage); then verifyImageDigest inspects the resolved local image’s registry digest and fails closed (E_DOCKER_DIGEST_MISMATCH, with re-pull recovery guidance) on every run, including the cache-hit path, before the image is used. Enforcement is skipped when no digest is pinned (a custom operator image with no JAIPH_DOCKER_IMAGE_DIGEST / @sha256:, or the default image before the release bakes its digest). Hardened presence probe (finding M-8): the image is workflow-influenced and is pulled before the check, so the verification probe (buildImageProbeArgs) runs the image with the same hardening as a real run (--cap-drop ALL, --security-opt no-new-privileges, a non-root --user, and --network none) and a non-login sh -c, so command -v jaiph resolves only PATH and the check never sources /etc/profile or /etc/profile.d/* scripts baked into the image. spawnDockerProcess does not pull or verify — it receives a pre-resolved image. The spawn call uses stdio: ["ignore", "pipe", "pipe"] — stdin is ignored so the Docker CLI does not block on stdin EOF, which would stall event streaming and hang the host CLI after the container exits.<run dir>/sandbox, via cloneWorkspaceForSandbox in src/runtime/docker.ts) and bind-mounts that clone read-write at /jaiph/workspace; the live host checkout is never mounted, and the clone is discarded on exit. The clone content is git-defined: for a git workspace it is exactly git ls-files --cached --others --exclude-standard plus .git/ wholesale (gitignored files — node_modules/, .env, build output — are absent, never scanned); git is the sole ignore oracle (no reimplemented gitignore matcher). A non-git workspace (no .git at the root, or git ls-files fails) falls back to copying everything. See Sandboxing — What the snapshot contains. The only host-writable path is /jaiph/run (run artifacts), and the snapshot source under it is masked from the container with a tmpfs at /jaiph/run/sandbox. Workflows that need to capture workspace changes should write files (for example a git diff into a temp path) and publish them with artifacts.save(). The explicit opt-in inplace mode (truthy JAIPH_INPLACE — 1 or true, or jaiph run --inplace) breaks this contract on purpose — the host workspace itself is bind-mounted read-write so the run’s edits persist live on the host, with the rest of the sandbox (caps, env allowlist, mount set) unchanged. See Sandboxing for the full contract and Save artifacts.spawnDockerProcess assigns every container a deterministic --name (jaiph-run-<hex>, emitted immediately after run --rm) so it can be force-removed by name later. A docker run --rm container can outlive its host docker client (Docker Desktop / detached behaviour), so killing the client’s process tree alone does not guarantee the container stops. On SIGINT/SIGTERM the run’s onSignalCleanup calls stopDockerRunOnSignal, and the run-timeout kill (E_TIMEOUT) calls stopDockerContainer directly — both run docker kill <name> (bounded 5 s) then docker rm -f <name> (bounded 10 s), best-effort, so the --rm container disappears from docker ps within a bounded window. Splitting kill from rm avoids macOS Docker Desktop lock contention where a single docker rm -f on a still-running container can block for the full timeout. Order matters: the container is stopped before cleanupDocker removes the host workspace snapshot at <run dir>/sandbox, because that snapshot is bind-mounted into the container. The per-call cancel path in the shared workflow-call executor (src/cli/shared/workflow-call.ts, which backs both jaiph mcp tool calls and jaiph serve) applies the same teardown — stopDockerContainer then cancelRunProcess. Both sandbox modes (snapshot, inplace) share this contract. See Sandboxing — interrupting a Docker run.The toolchain has one canonical representation, ModuleGraph, for all .jh modules reachable from an entry point, parsed once. The same graph is used by the validator, the script emitter, and the runtime. On the default local jaiph run path the graph also crosses the parent CLI to child runner boundary, so each reachable .jh is parsed exactly once per run.
loadModuleGraph(entryFile, workspaceRoot?) (src/transpile/module-graph.ts) walks the entry plus its transitive import edges through resolveImportPath and returns { entryFile, workspaceRoot?, modules: Map<absPath, { filePath, ast, imports: Map<alias, absPath> }> }. <lib>/<path> imports (for example jaiphlang/queue) resolve through the workspace library fallback under .jaiph/libs/ when a relative path does not exist. Within the graph pipeline this is the only routine that reads .jh sources from disk, and parsejaiph(source, filePath) itself is I/O-pure. A couple of paths outside the graph pipeline read .jh on their own, such as runWorkflowRaw (jaiph run --raw) and the exported loadImportedModules helper.src/cli/commands/run.ts calls loadModuleGraph once after path normalization. The entry AST is reused for the banner / runtime config via metadataToConfig(resolveModuleMetadata(mod, env)) — resolveModuleMetadata resolves the config { … } block’s interpolation before metadataToConfig flattens it. The same graph is passed to buildScriptsFromGraph(graph, outDir), which calls emitScriptsForModuleFromGraph per reachable module; each call runs validateModule(ast, graph) against the in-memory ASTs.writeModuleGraph to <outDir>/.jaiph-module-graph.json (deterministic JSON: entries sorted by absolute path; ASTs included verbatim). It points the spawned __workflow-runner child at the file through the internal env var JAIPH_MODULE_GRAPH_FILE. The runner reads it back with readModuleGraph and passes the result to buildRuntimeGraph(graph), which produces the runtime view (with import script stub injection) without touching disk. Cross-module workflow / rule / script resolution matches the on-disk load path.JAIPH_MODULE_GRAPH_FILE is set on the host (non-Docker) execution paths that spawn the local __workflow-runner child: interactive jaiph run when Docker sandboxing is disabled (dockerConfigForBanner.enabled === false), and the shared workflow-call executor (src/cli/shared/workflow-call.ts) that backs jaiph mcp tool calls and jaiph serve. It is not set on these paths, which load the graph from disk inside the runner instead:
jaiph run --raw — runWorkflowRaw (src/cli/commands/run.ts) calls buildScripts directly without writing the graph file; the runner uses inherited stdio and falls back to loadModuleGraph from the source file.jaiph run — the host writes the graph file under outDir, but skips the env var because the inner container command is jaiph run --raw … and the host bind-mount layout does not plumb the cache file inside the container.jaiph test — runSingleTestFile builds the graph in src/cli/commands/test.ts and threads it through runTestFile(graph, ...) directly (no env var needed; same process).When the env var is absent, the runner falls back to the disk-walk parse path, which preserves the prior behavior.
User-visible contracts (banner, hooks, run artifacts, run_summary.jsonl, return_value.txt, exit codes, __JAIPH_EVENT__ streaming) are unchanged.
NodeWorkflowRuntime).send, routes, queue drain) through kernel logic.runTestFile() in node-test-runner.ts).jaiph run (detached workflow runner process group for signal propagation). Terminating a run means terminating the whole tree — the detached leader plus the agent backends and script children it spawned — routed through killProcessTree(pid, signal) (src/runtime/kernel/portability.ts), the single sanctioned home for group kills. On POSIX it signals the leader’s process group with process.kill(-pid, signal), falling back to a per-process kill if the group no longer exists (ESRCH). On win32 a negative-PID group kill throws and a per-process kill would orphan the children, so it force-kills the tree with taskkill /pid <pid> /T /F (spawned, not shelled), degrading to a per-process kill if taskkill cannot be launched. Because taskkill /F is already forceful, a follow-up SIGKILL escalation after a SIGTERM/SIGINT is a documented no-op on win32. All group-kill call sites route through this helper: run teardown and the host run-timeout kill — armRunTimeout, the parent-enforced wall-clock cap for host mode (JAIPH_RUN_TIMEOUT; see Configuration — Overall run timeout and step cap) — both in src/cli/run/lifecycle.ts, the prompt watchdog (src/runtime/kernel/prompt.ts), and the Docker run-timeout kill (src/runtime/docker.ts).jaiph run --raw (child stdio inherited; see CLI).__JAIPH_EVENT__ JSON lines on stderr only — the structured event channel. Hooks and the interactive CLI consume that stream; see Hooks..jaiph/runs/... + run_summary.jsonl (layout below).Channel transport remains file/queue based in runtime inbox logic.
For an onboarding-style description of the same paths (what to expect in a repo, what to ignore in git), see Runtime artifacts.
The runtime persists step captures and the event timeline under a UTC-dated hierarchy:
.jaiph/runs/
<YYYY-MM-DD>/ # UTC date (see NodeWorkflowRuntime)
<HH-MM-SS>-<source-basename>/ # UTC time + JAIPH_SOURCE_FILE or entry basename
000001-module__step.out # stdout capture per step (6-digit seq prefix)
000001-module__step.err # stderr capture (may be empty)
artifacts/ # user-published files (JAIPH_ARTIFACTS_DIR); created at run start
inbox/ # audit copies of routed channel payloads (optional)
heartbeat # liveness: epoch ms, refreshed about every 10s
return_value.txt # when `jaiph run` default workflow returns a value (success only)
run_summary.jsonl # durable event timeline
Two runs of the same source landing in the same UTC second (e.g. concurrent
jaiph mcp / jaiph serve calls) would otherwise collide on this directory
name and clobber each other’s artifacts; when that happens the second run’s
directory gets an -<run id prefix> suffix appended instead of reusing the
first run’s directory.
Step sequence numbers are monotonic and unique per run: RuntimeEventEmitter allocates them in memory (allocStepSeq) when opening each step’s capture files (%06d-<safe_name>.out|.err). There is no .seq file in the run directory.
Every line written to run_summary.jsonl by RuntimeEventEmitter carries a prev_hash field. The field holds a keyed HMAC-SHA256 (in hex) of the previous raw JSON line — chainHmac(key, previousLine) — with chainHmac(key, CHAIN_GENESIS) for the first line. The key is a per-run 256-bit secret (generateChainKey), so the chain is not reproducible from the public algorithm alone: rewriting a line, or dropping a line and re-linking the survivors, invalidates the chain and cannot be re-forged without the key.
Key isolation (finding H-3). The journal is written by the trusted kernel process, but the audited workflow — its script steps and prompt/agent subprocesses — must not be able to forge the chain. The key travels in the kernel process env under JAIPH_CHAIN_KEY (CHAIN_KEY_ENV) and is scrubbed from every subprocess env: scrubTrustedKeys (node-workflow-runtime.ts) removes it — along with the journal path JAIPH_RUN_SUMMARY_FILE — from every script scope, and scrubPromptEnv (env-allowlist.ts) drops it at the agent boundary even though the JAIPH_ prefix otherwise forwards run-control keys into the Docker container (the in-container kernel legitimately needs it). The host (src/cli/commands/run.ts, src/cli/shared/workflow-call.ts) generates the key, forwards it to the runner, and — once the run is terminal — persists it in an operator-side store (writeChainKey) so read/export boundaries can verify it.
Key storage outside the run directory (finding M-3). The key is not written into the run directory, which is agent-writable ($JAIPH_RUN_DIR for script steps, bind-mounted rw at /jaiph/run under Docker). Storing it there let a workflow’s first script step mkdir "$JAIPH_RUN_DIR/.chain-key" to squat the path so the host’s write threw and was swallowed, then rewrite the journal freely with no integrity failure surfaced. Instead the key lives in an operator-side store — resolveAuditKeyStore (emit.ts), default ~/.jaiph/audit-keys, override JAIPH_AUDIT_KEY_DIR — that is never mounted into a container. Each run gets one entry directory <store>/<sha256(canonical run dir)> holding the secret key file; the directory’s existence is the durable “this run was keyed” marker. Persistence is a hard error: writeChainKey no longer swallows failures, and it creates the marker directory before the key, so even a partial write leaves the run marked keyed-but-keyless (which fails closed below) rather than silently unverifiable.
Verification at read/export boundaries. verifyRunSummaryChain(filePath, key, opts?) walks each line, checks prev_hash against the recomputed keyed digest, and returns { ok: false, error } at the first broken link (a missing/unreadable journal is a failure, not a silent pass). With opts.requireTerminal it additionally requires the journal to end with the WORKFLOW_END terminal marker (TERMINAL_EVENT_TYPE) — the chain commits to prefix integrity but not to length, so deleting the last K lines of a completed journal leaves a shorter-but-valid chain that would otherwise verify; requiring the terminal marker rejects any post-terminal tail truncation (finding L-3). verifyRunJournal(runDir) wraps it (always with requireTerminal, since a key is persisted only once the run is terminal): it looks up the run’s store entry and returns { verified: false, ok: true } when the run has no entry (an unkeyed/legacy run that cannot be verified — never blocked), { verified: true, ok: false } when the run was keyed but the key is missing at verification time (fail closed — a keyed run whose key vanished must not downgrade to “not verified” and let a tampered journal through), or { verified: true, ok } with the chain result otherwise. Every read/export boundary hard-fails when verified && !ok: run listing (loadPersistedRuns marks the run failed with TAMPERED_RESULT_TEXT), GET /v1/runs/{id}/events (409 E_TAMPERED), and OTLP/Sentry export (skip + warn, never POST a tampered journal).
Scope of the guarantee. A workflow script step cannot read the key or the journal path from its env, and cannot alter the journal in any way that verifies — any rewrite or omitted line is rejected, and any truncation during the run is caught because the kernel keeps appending under the pre-truncation head. A post-run clean truncation of a completed journal’s tail is also rejected: the terminal-marker check (finding L-3) fails a keyed journal that no longer ends with WORKFLOW_END. Because a .jh host run and its script steps execute under the same OS user, a hash chain still cannot defend against a post-run same-user process that deletes the run’s store entry, which makes the run unverifiable (verified:false) rather than a detectable tamper. Under Docker sandboxing the key never enters the container, and the key store lives outside every bind mount, so an in-sandbox workflow can neither read the key nor reach the store to squat or delete it.
Before RuntimeEventEmitter writes an event line to run_summary.jsonl, it redacts the values of credential environment variables. A credential environment variable is a key whose name (case-insensitive) either contains one of the substrings SECRET, PASSWORD, PASSPHRASE, TOKEN, PRIVATE_KEY, ACCESS_KEY, API_KEY, CREDENTIAL or ends in _PAT / _DSN, and whose value is at least 4 characters. Substring (not just suffix) matching is deliberate: it catches AWS_SECRET_ACCESS_KEY, AWS_ACCESS_KEY_ID, STRIPE_SECRET_KEY, DB_PASSWORD, PASSPHRASE, SSH_PRIVATE_KEY, and SERVICE_CREDENTIALS, which the earlier four-suffix rule (_API_KEY / _TOKEN / _SECRET / _API_TOKEN) silently missed. For each matching value, both the raw value and its base64 / base64url / hex / URL-encoded re-encodings are replaced with [REDACTED] wherever they appear in:
prompt_text) and the resolved values of ${var} references persisted alongside it (emitPromptStepStart),preview field of PROMPT_START / PROMPT_END events (emitPromptEvent),out_content / err_content) of every STEP_END — script and prompt steps alike (emitStep),params key/value pairs of every STEP_END, which hold the positional or named arguments passed to a run, tool, or script step, so a secret passed as an argument is redacted the same way as the step’s captured output (emitStep),message field of every durable LOG, LOGWARN, and LOGERR event, so a value a workflow passes to log, logwarn, or logerr is redacted in the journal the same way as a step’s captured output (emitLog).The rule covers backend API keys such as ANTHROPIC_API_KEY, OPENAI_API_KEY, and CURSOR_API_KEY (the same names on the Docker env allowlist).
The same credential rule lives in one shared helper, redactCredentials (src/runtime/kernel/redact.ts). The helper is also the redaction boundary for returned call results. composeResult (src/cli/shared/workflow-call.ts) redacts a failed call’s diagnostic capture (the failed-step detail, the raw stderr and stdout, and the collected log messages) before it becomes jaiph serve’s result_text or a jaiph mcp tool result. A successful workflow’s return value is intentional API output rather than diagnostic capture, so it is returned verbatim. The journal that redactCredentials produces is what the OTLP export (otlp.ts), the Sentry export (sentry.ts), and GET /v1/runs/{id}/events (handler.ts) read back verbatim, so broadening the rule tightens all four surfaces at once.
Explicit non-guarantee. Redaction is literal-substring replacement of the value and the base64 / base64url / hex / URL-encoded encodings listed above. A secret transformed some other way — split across two output chunks, JSON-string-escaped, gzipped, re-chunked, or embedded as the password inside an opaque connection string (e.g. a DATABASE_URL, whose key name does not itself look like a credential) — is not guaranteed to be redacted. Beyond the two redaction boundaries (journal copies and returned call results), redaction is not applied at all: the per-step raw capture files (%06d-<name>.out / .err) are streamed to disk verbatim. Treat them, and the run directory as a whole, as sensitive. Redaction also covers only the durable journal copy of each event, not the live __JAIPH_EVENT__ progress stream on the runner’s stderr that the progress UI and hooks read, so a hook that reads a LOG or STEP_END line off that stream sees the field as the workflow authored it.
Channels are validated at compile time (validateReferences and the send RHS rules) and executed through an in-memory queue and dispatch in the Node runtime. Durable inbox/ files under the run directory appear only for routed sends, as an audit copy (see Inbox & Dispatch). Hooks are CLI-only. They load from hooks.json and run as shell commands with JSON on stdin, driven by the same __JAIPH_EVENT__ stream as the progress UI. One dispatch contract covers all three invocation modes: interactive jaiph run through the run emitter, and jaiph serve and jaiph mcp calls through callWorkflow’s shared event collector. jaiph run --raw and jaiph test are documented as no-hook lanes (see Hooks).
*.test.jh in the kernel)jaiph test wires into the same stack as jaiph run. runSingleTestFile (src/cli/commands/test.ts) calls loadModuleGraph(testFileAbs, workspaceRoot) once, then threads the resulting ModuleGraph through buildScriptsFromGraph(graph, tmpDir) and runTestFile(graph, …). runTestFile calls buildRuntimeGraph(graph) once per file and the runtime view is reused across all blocks and test_run_workflow steps (the import closure is constant for a given test file within a single process run). Each test_run_workflow step resolves mocks against that runtime view, then constructs NodeWorkflowRuntime with mockBodies / mock prompt env, passing suppressLiveEvents: true so RuntimeEventEmitter skips writing __JAIPH_EVENT__ lines to stderr while still appending run_summary.jsonl for that run. Without suppressLiveEvents, every workflow event would print to the test process’s stderr and swamp the node --test reporter output. Mock prompts, workflows, rules, and scripts are supported through the runtime’s mock infrastructure.
The buildScriptsFromGraph call writes scripts/ so imported workflows have paths under JAIPH_SCRIPTS. Unrelated *.jh files elsewhere in the repo are not compiled unless imported.
Authoring rules, fixtures, and mock syntax for *.test.jh are documented in Testing, not here.
The progress UI combines a static step tree derived from the workflow AST (src/cli/run/progress.ts) with live updates from the runtime event stream. Event wiring: src/cli/run/events.ts and src/cli/run/stderr-handler.ts parse __JAIPH_EVENT__ lines; src/cli/run/emitter.ts bridges into the renderer. Line-oriented formatting (formatStartLine, formatHeartbeatLine, formatCompletedLine) lives primarily in src/cli/run/display.ts, which shares some display helpers with progress.ts. Async branch numbering (subscript ₁₂₃… prefixes) is driven by async_indices on step and log events — the runtime propagates a chain of 1-based branch indices through AsyncLocalStorage, and the stderr handler renders them at the appropriate indent level. Whether ANSI SGR colors are emitted is a single policy — canUseAnsi() (src/runtime/kernel/portability.ts) returns isTTY && NO_COLOR unset — and every color emission site (src/cli/commands/run.ts, src/cli/run/progress.ts, src/cli/shared/errors.ts) routes its gate through it rather than re-deriving isTTY && NO_COLOR locally. On Windows 10+ Node enables console VT processing automatically, so isTTY is a sufficient ANSI proxy with no extra win32 branch. const steps whose Expr value is kind: "match" are walked for nested run / ensure arms; matched targets appear as child items in the step tree (for example ▸ workflow my_flow or ▸ rule my_rule under the const row). This pipeline does not apply to jaiph run --raw.
npm run build runs npm run embed-assets (regenerates src/runtime/embedded-assets.ts from docs/jaiph-skill.md, and src/version.ts from package.json’s version field), then tsc, and copies src/runtime/ to dist/src/runtime/ (kernel, docker.ts, etc.). The published jaiph bin is node dist/src/cli.js.npm run build:standalone runs the same build, copies dist/src/runtime to dist/runtime beside the binary, then bun build --compile ./src/cli.ts --outfile dist/jaiph. Workflow launch self-spawns via process.execPath using the internal __workflow-runner argv marker (src/runtime/kernel/workflow-launch.ts + src/cli/index.ts): the node build invokes node dist/src/cli.js __workflow-runner …; the bun-compiled binary invokes itself, jaiph __workflow-runner …. The reserved marker is excluded from --help/usage and the file-shorthand path. docs/jaiph-skill.md is also embedded base64 inside the executable via src/runtime/embedded-assets.ts, so the standalone artifact is fully self-contained — no sibling runtime/ or docs/ files required. The displayed jaiph --version string is sourced from the generated src/version.ts (codegen’d from package.json by embed-assets), so the literal is statically baked into both the tsc and the bun build --compile outputs without a runtime read of package.json. Bash (or whatever shebang your script steps use) is still required on the host for script subprocesses. Ship dist/jaiph alone, or with dist/runtime alongside it for parity with the npm layout (table in Contributing)..github/workflows/release.yml cross-compiles the standalone binary for darwin/linux × arm64/x64 plus windows x64 (jaiph-windows-x64.exe; Bun has no windows arm64 target) on v* tag pushes and on pushes to the nightly branch, generates a SHA256SUMS covering the five binaries, signs it with minisign (SHA256SUMS.minisig), runs --version sanity gates on the linux-x64 and windows-x64 outputs, and uploads the seven assets (five binaries + SHA256SUMS + SHA256SUMS.minisig) to the matching GitHub Release (stable tag or rolling nightly prerelease). Asset filenames are fixed by the installer contract — see Contributing — Release asset naming contract. Two installers consume these assets: the POSIX docs/install (curl … | bash, darwin/linux; rejects Windows and points at the PowerShell one) and docs/install.ps1 (irm https://jaiph.org/install.ps1 | iex, Windows x64), which downloads jaiph-windows-x64.exe, verifies it against SHA256SUMS with Get-FileHash, and installs to %LOCALAPPDATA%\jaiph\bin.flowchart TD
U[User / CI] --> CLI[CLI: Node or Bun jaiph]
subgraph Transpile["Per-module: emitScriptsForModuleFromGraph()"]
VAL[validateModule]
EMIT[Emit atomic script files under scripts/]
VAL -->|compile errors| ERR[Deterministic compile errors]
VAL --> EMIT
end
CLI -->|jaiph run| LMG1[loadModuleGraph entry + closure]
LMG1 --> BS1[buildScriptsFromGraph]
BS1 --> Transpile
CLI -->|jaiph test| LMG2[loadModuleGraph(entry .test.jh)]
LMG2 --> BS2[buildScriptsFromGraph]
BS2 --> Transpile
LMG2 --> TR[Node Test Runner in-process]
Transpile -->|jaiph run local| RW[__workflow-runner child]
Transpile -->|jaiph run Docker| DC[Container: jaiph run --raw]
LMG1 -. JAIPH_MODULE_GRAPH_FILE (local non-Docker only) .-> RW
RW --> G[buildRuntimeGraph from graph]
G --> GRAPH[RuntimeGraph]
RW --> RT[NodeWorkflowRuntime]
RT --> GRAPH
DC --> G
DC --> RT
TR -->|test_run_workflow| G
TR --> RT
RT -->|script steps| SCRIPT[Managed script subprocesses]
RT -->|prompt steps| KERNEL[Kernel libs: prompt, events, inbox, stream, schema, mock]
RT -->|live events| EV["__JAIPH_EVENT__ stderr only"]
EV --> CLI
CLI --> PR[Progress rendering]
RT -->|channels files / queues| INBOX[Inbox under .jaiph/runs]
RT -->|durable artifacts| SUM[.jaiph/runs + run_summary.jsonl]
CLI --> HK[Hook dispatcher via event stream]
HK --> HPROC[Hook shell commands]
Emit artifacts: buildScripts() persists only extracted script bodies under scripts/. No workflow-level shell modules or jaiph_stdlib.sh are produced.
*.jh)Interactive jaiph run (no --raw): banner, progress tree, hooks, and PASS/FAIL footer.
sequenceDiagram
participant User
participant CLI as CLI jaiph run
participant Load as loadModuleGraph
participant Prep as buildScriptsFromGraph
participant TF as emitScriptsForModuleFromGraph per module
participant Runner as __workflow-runner child
participant Graph as buildRuntimeGraph(graph)
participant Runtime as NodeWorkflowRuntime
participant Kernel as JS kernel
participant Report as Artifacts (.jaiph/runs)
User->>CLI: jaiph run main.jh args...
CLI->>Load: loadModuleGraph(entry, workspace)
Load-->>CLI: ModuleGraph (modules map)
Note over CLI: reuse entry AST for metadataToConfig / banner
CLI->>Prep: buildScriptsFromGraph(graph, outDir)
Prep->>TF: loop: validateModule + emit (in-memory AST)
TF-->>Prep: scripts/ atomic only
Prep-->>CLI: scriptsDir + env JAIPH_SCRIPTS
alt local (non-Docker)
CLI->>CLI: writeModuleGraph(<outDir>/.jaiph-module-graph.json)
Note over CLI: set JAIPH_MODULE_GRAPH_FILE on child env
CLI->>Runner: spawn detached __workflow-runner child
else Docker
CLI->>CLI: prepareImage (pull --quiet + verify jaiph)
Note over CLI: runs before banner so pull doesn't interleave
CLI->>Runner: spawn container running jaiph run --raw
Note over CLI: CLI parses events on stderr only
end
alt JAIPH_MODULE_GRAPH_FILE set (local non-Docker)
Runner->>Runner: readModuleGraph(file)
Runner->>Graph: buildRuntimeGraph(graph)
Note over Graph: no .jh re-reads
else absent (Docker / --raw / test runner)
Runner->>Runner: loadModuleGraph(sourceAbs, workspace)
Runner->>Graph: buildRuntimeGraph(graph)
end
Graph-->>Runner: RuntimeGraph
Runner->>Runtime: runDefault(run args)
Runtime->>Kernel: prompt / managed scripts / emit / inbox
Runtime-->>CLI: __JAIPH_EVENT__ on stderr
Runtime->>Report: run_summary.jsonl + step artifacts
Runner-->>CLI: exit + meta file with run_dir paths
CLI-->>User: live progress
CLI-->>User: PASS/FAIL
Docker: the inner container command is jaiph run --raw … (see Sandboxing): no banner or progress UI inside the container; __JAIPH_EVENT__ lines still appear on stderr for the host CLI to parse.
jaiph test flowsequenceDiagram
participant User
participant CLI as CLI jaiph test
participant Load as loadModuleGraph
participant Prep as buildScriptsFromGraph
participant TestRunner as runTestFile / runTestBlock
participant Graph as buildRuntimeGraph(graph)
participant Runtime as NodeWorkflowRuntime
participant Report as Artifacts
User->>CLI: jaiph test flow.test.jh
CLI->>Load: loadModuleGraph(test file, workspace)
Load-->>CLI: ModuleGraph (entry + import closure)
CLI->>Prep: buildScriptsFromGraph(graph, tmp)
Prep-->>CLI: scriptsDir
CLI->>TestRunner: runTestFile(graph, workspace, scriptsDir, blocks)
TestRunner->>Graph: buildRuntimeGraph(graph) once per file
Graph-->>TestRunner: RuntimeGraph cached
loop each test block
TestRunner->>TestRunner: mocks / shell steps / expectations
opt test_run_workflow step
TestRunner->>Runtime: new runtime mockBodies from block (reuses cached graph)
Runtime->>Runtime: runNamedWorkflow(ref args)
Runtime-->>TestRunner: status output returnValue error
end
end
Runtime->>Report: artifacts when workflows ran
TestRunner-->>CLI: aggregate PASS/FAIL
CLI-->>User: exit code
.jh / *.test.jh share parser/AST. The pipeline is loadModuleGraph → buildScriptsFromGraph(graph, outDir) (per-module validateModule + buildScriptFiles via emitScriptsForModuleFromGraph); parsejaiph is I/O-pure and graph-based validation / emit operate entirely in-memory. buildRuntimeGraph consumes the same ModuleGraph (loaded in the runner from disk or — on the default local jaiph run path — deserialized from the parent CLI’s graph file via JAIPH_MODULE_GRAPH_FILE; see Local module graph).jaiph compile walks import closures through collectDiagnostics(graph) (the multi-error sibling of validateReferences), prints the full diagnostic set sorted by (file, line, col), and exits non-zero on any non-empty set — no scripts/ emission (no buildScriptFiles / buildScripts), no buildRuntimeGraph(), no runner spawn. Directory discovery omits *.test.jh unless you pass a test file explicitly.jaiph run, Docker jaiph run, and jaiph test — goes through NodeWorkflowRuntime. Docker containers run jaiph run --raw / __workflow-runner with the compiled JS tree and scripts mounted, using the same semantics as local execution.jaiph run --raw), and runtime preparation (buildScripts). jaiph run --raw still emits __JAIPH_EVENT__ on stderr from the runtime; the CLI does not attach the interactive progress/hooks pipeline. jaiph test passes suppressLiveEvents: true into NodeWorkflowRuntime so RuntimeEventEmitter skips writing those live stderr lines while run_summary.jsonl still records workflow traffic where the emitter appends it.NodeWorkflowRuntime, with script steps as managed subprocesses..sh files or jaiph_stdlib.sh are produced or required.__JAIPH_EVENT__, .jaiph/runs, run_summary.jsonl, hook payloads.