Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ jobs:
- name: zig build test
run: zig build test --summary all

- name: zig build wasm
run: zig build wasm

- name: Local learning lifecycle and security regression
run: python3 tests/learn_e2e.py --graff zig-out/bin/graff

Expand Down Expand Up @@ -187,6 +190,9 @@ jobs:
- name: zig build test
run: zig build test --summary all

- name: zig build wasm
run: zig build wasm

- name: Local learning lifecycle and security regression (Windows)
run: python3 tests/learn_e2e.py --graff zig-out/bin/graff.exe

Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ The release workflow uses a tag's section here as its release notes (a
hand-written `docs/releases/<tag>.md` wins if present), so keeping this file
current is part of cutting a release.

## Unreleased

- `zig build wasm` emits `graff-kernel.wasm`: the ToolCatalog cube and the
lexical path jail, callable from JS (`sdk/wasm/`). Not the agent — no
HTTP, bash, or TTY. Same predicates Lean exports (ADR 0012).

## v0.0.267 (2026-08-19)

- Background jobs wait like grok-build: `bash_output(wait_ms>0)` and
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1265,6 +1265,7 @@ skips). Alternatively, run in place:
```sh
zig build run # or: ./zig-out/bin/graff
zig build test # the test suite (also run by CI, .github/workflows/ci.yml)
zig build wasm # graff-kernel.wasm — catalog + path cubes, not the agent
```

**Releases & verification.** Tagged releases ship a prebuilt **darwin-arm64**
Expand Down Expand Up @@ -1452,6 +1453,7 @@ about 20 seconds warm:
| `tests` | `zig build test`, and a suite count that may grow but never shrink |
| `invariants` | the named goal/loop/todo tests actually ran, not just compiled |
| `sdk` | the committed SDKs still match `graff --schema` |
| `wasm` | `zig build wasm` — `graff-kernel.wasm` still compiles |

A push that only touches docs skips the whole thing. When a check fails it names
the invariant, says which regression it guards, and prints the one-liner that
Expand Down
23 changes: 23 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -200,4 +200,27 @@ pub fn build(b: *std.Build) void {

const tui_test_step = b.step("tui-test", "Run fullscreen TUI unit tests");
tui_test_step.dependOn(&b.addRunArtifact(tui_tests).step);

// `zig build wasm` — the kernel cube as wasm32-freestanding. Not the
// agent (no HTTP, no bash, no TTY). A JS host loads graff-kernel.wasm
// and evaluates catalog/confined; see sdk/wasm/ and ADR 0012.
const wasm_target = b.resolveTargetQuery(.{
.cpu_arch = .wasm32,
.os_tag = .freestanding,
});
const wasm_optimize = if (optimize == .Debug) .ReleaseSmall else optimize;
const wasm_exe = b.addExecutable(.{
.name = "graff-kernel",
.root_module = b.createModule(.{
.root_source_file = b.path("src/wasm_main.zig"),
.target = wasm_target,
.optimize = wasm_optimize,
.strip = true,
}),
});
wasm_exe.entry = .disabled;
wasm_exe.rdynamic = true;
const install_wasm = b.addInstallArtifact(wasm_exe, .{});
const wasm_step = b.step("wasm", "Build graff-kernel.wasm (catalog + path kernels)");
wasm_step.dependOn(&install_wasm.step);
}
34 changes: 34 additions & 0 deletions docs/adr/0012-wasm-is-the-kernel-cube.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# 0012. WebAssembly ships the kernel cube, not the agent

Status: accepted 2026-08-20

## Context

fx compiles two `wasm32-freestanding` artifacts (`fx-core.wasm`, `fx-term.wasm`)
and a JS host that supplies fetch, session storage, and (optionally) exec. The
WASM build has no native processes, OS sandbox, WASI filesystem, MCP, or
subagents; JSPI is required for the async host calls.

graff's live loop (`std.http.Client`, `bash` jobs, MCP stdio, TTY restore)
does not compile to freestanding WASM as-is, and grafting a host layer onto
every I/O seam is a second product. The kernels already are total functions
over finite cubes (`{0,1}^6` catalogs, lexical paths) with no OS.

## Decision

`zig build wasm` produces `graff-kernel.wasm`: `catalog`, `advertised`, and
`confined` over the same fixtures Lean exports. The JS host is
`sdk/wasm/graff-kernel.js`. It does not use JSPI.

A later `graff-core.wasm` / `graff-term.wasm` needs an explicit host
capability table (fetch, workspace, no implicit bash), the same way fx does.
Do not compile `src/main.zig` to WASM and hope.

Do not target WASI so the module can load in a browser without a filesystem
polyfill. Do not emit `OSC 50` or claim the pager font is ours.

## Consequences

The wasm step is a compile-only tier-1 check (`zig build wasm`). Semantics
stay in the native suite against `spec/kernels/*.json`. Full-agent embed
stays `graff serve` + the remote SDK until a host layer exists.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ record only when you need the evidence or the edge cases.
| [0009](0009-gpt-5-6-explicit-prompt-cache-boundary.md) | GPT-5.6 OpenAI Platform marks the stable prefix explicitly; Codex and xAI stay on their supported keyed automatic-cache paths. |
| [0010](0010-background-jobs-wait-for-exit.md) | `bash_output`/`agent_output` `wait_ms>0` blocks until exit (10h cap); do not poll every 30s. |
| [0011](0011-prompt-cache-max-is-visible.md) | Prompt-cache max is `/cache` posture, not a new default; `/btw` rides the parent prefix. |
| [0012](0012-wasm-is-the-kernel-cube.md) | WASM is `graff-kernel.wasm` (catalog + path cubes), not the agent; a later core/term host must supply I/O like fx. |

## When to write one

Expand Down
18 changes: 17 additions & 1 deletion scripts/eval-tier1.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ cd "$repo_root"
# processes discover their repo from their cwd like they expect.
unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_COMMON_DIR GIT_OBJECT_DIRECTORY GIT_PREFIX

CHECKS=(fmt lines spec reach build tests tui tuiguard invariants sdk)
CHECKS=(fmt lines spec reach build tests tui tuiguard invariants sdk wasm)

usage() {
cat <<'EOF'
Expand All @@ -43,6 +43,7 @@ checks, in order:
and virtual-screen checks (test-tui-screenstate.py)
invariants the named goal/loop/todo tests actually ran, not just compiled
sdk the committed SDKs match `graff --schema`
wasm zig build wasm (graff-kernel.wasm still compiles)
EOF
}

Expand Down Expand Up @@ -440,6 +441,21 @@ if wanted sdk; then
fi
fi

# --- wasm --------------------------------------------------------------
# Compile-only: the kernels must keep targeting wasm32-freestanding. Semantics
# stay in the native suite (kernel_catalog / kernel_path / wasm_abi). Does not
# depend on zig-out/bin/graff.
if wanted wasm; then
announce wasm "zig build wasm — graff-kernel.wasm still compiles"
if zig build wasm; then
printf ' zig-out/bin/graff-kernel.wasm is current\n'
else
printf ' the wasm32-freestanding kernel target failed to compile.\n'
printf ' fix: zig build wasm\n'
record_fail wasm
fi
fi

# --- verdict -----------------------------------------------------------
elapsed=$((SECONDS - started))
if ((${#warned[@]} > 0)); then
Expand Down
4 changes: 4 additions & 0 deletions sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ TypeScript (`ts/`) and Python (`py/`) clients that drive graff over its
`--json` stdio protocol. **Both are auto-generated** — never hand-edit the
generated files.

`wasm/` is a separate, hand-written host for `graff-kernel.wasm` (the
catalog + path cubes, not the agent). `generate.py` does not touch it.
See [wasm/README.md](wasm/README.md) and ADR 0012.

## How it works

The graff binary is the single source of truth. `graff --schema` emits its
Expand Down
7 changes: 5 additions & 2 deletions sdk/ts/remote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,12 +171,15 @@ describe("RemoteHarness transport", () => {
test("constructor observes create rejection even when the caller never awaits it", async () => {
let unhandled = 0;
const listener = () => { unhandled += 1; };
process.on("unhandledRejection", listener);
// bun-types narrows Process.on/off to memoryPressure; Node's
// unhandledRejection is the event this test actually needs.
const proc = process as unknown as NodeJS.EventEmitter;
proc.on("unhandledRejection", listener);
globalThis.fetch = (async () => { throw new Error("create failed"); }) as unknown as typeof fetch;
const h = new RemoteHarness({ url: "http://bridge.test" });
live.push(h);
await Bun.sleep(30);
process.off("unhandledRejection", listener);
proc.off("unhandledRejection", listener);
expect(unhandled).toBe(0);
await expect(h.sessionId).rejects.toThrow("create failed");
});
Expand Down
48 changes: 48 additions & 0 deletions sdk/wasm/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# graff-kernel.wasm

The ToolCatalog cube and the lexical path jail, compiled to
`wasm32-freestanding`. This is **not** the agent. fx's `fx-core.wasm` is a
host-supplied ACP loop (JSPI fetch, no bash). graff's first wasm artifact is
the same finite functions Lean proves — 64 catalog cells, `confined(path)`.

See [ADR 0012](../../docs/adr/0012-wasm-is-the-kernel-cube.md).

## Build

```sh
zig build wasm
# zig-out/bin/graff-kernel.wasm
```

ReleaseSmall is the default even on a Debug configure: the kernels have
nothing to debug in DWARF, and a Debug wasm is mostly unused panic
machinery.

## Use

```js
import { loadGraffKernel } from "./graff-kernel.js";

const k = await loadGraffKernel("./graff-kernel.wasm");
k.cubeCells(); // 64
k.catalog(); // ["bash", "bash_output", ...]
k.catalog({ lean: true }); // 8 names
k.advertised("subagent", { isSub: true }); // false
k.confined("/etc/passwd"); // false
k.confined("src/main.zig"); // true
```

Flag bits: `noLocal`, `lean`, `imagegen`, `clockSleep`, `learnLoaded`, `isSub`.

No JSPI. A later `graff-core.wasm` that talks to a model will need a host
`fetch` (and must not pretend bash exists). Until then, embed the live
harness with `graff serve` and the remote SDK.

## Demo

After `zig build wasm`, from the repo root:

```sh
python3 -m http.server 8080
# open http://localhost:8080/sdk/wasm/demo.html
```
37 changes: 37 additions & 0 deletions sdk/wasm/demo.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<!doctype html>
<meta charset="utf-8" />
<title>graff-kernel.wasm</title>
<style>
body { font: 15px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; margin: 2rem; max-width: 44rem; }
h1 { font-size: 1.1rem; }
pre { background: #0b1220; color: #d7ffe4; padding: 1rem; overflow: auto; }
.err { color: #c2410c; }
</style>
<h1>graff-kernel.wasm</h1>
<p>The 64-cell catalog cube and the lexical path jail. Not the agent.</p>
<pre id="out">loading…</pre>
<script type="module">
import { loadGraffKernel } from "./graff-kernel.js";
const out = document.getElementById("out");
try {
const k = await loadGraffKernel("../../zig-out/bin/graff-kernel.wasm");
out.textContent = JSON.stringify(
{
abi: k.abiVersion(),
cube: k.cubeCells(),
root: k.catalog(),
lean: k.catalog({ lean: true }),
childSeesSubagent: k.advertised("subagent", { isSub: true }),
passwd: k.confined("/etc/passwd"),
mainZig: k.confined("src/main.zig"),
},
null,
2,
);
} catch (err) {
out.className = "err";
out.textContent =
String(err) +
"\n\nBuild first: zig build wasm\nThen serve the repo root so ../../zig-out/bin/graff-kernel.wasm resolves.";
}
</script>
76 changes: 76 additions & 0 deletions sdk/wasm/graff-kernel.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Host loader for graff-kernel.wasm (ADR 0012).
//
// The module is the finite kernels, not the agent. No JSPI. Flag bits
// match src/kernel_catalog.zig: no_local, lean, imagegen, clock_sleep,
// learn_loaded, is_sub.

export const FLAG = Object.freeze({
noLocal: 1 << 0,
lean: 1 << 1,
imagegen: 1 << 2,
clockSleep: 1 << 3,
learnLoaded: 1 << 4,
isSub: 1 << 5,
});

export function packFlags(flags = {}) {
let bits = 0;
if (flags.noLocal) bits |= FLAG.noLocal;
if (flags.lean) bits |= FLAG.lean;
if (flags.imagegen) bits |= FLAG.imagegen;
if (flags.clockSleep) bits |= FLAG.clockSleep;
if (flags.learnLoaded) bits |= FLAG.learnLoaded;
if (flags.isSub) bits |= FLAG.isSub;
return bits;
}

function decoder() {
return new TextDecoder();
}

function encoder() {
return new TextEncoder();
}

export async function loadGraffKernel(wasm) {
const source =
wasm instanceof WebAssembly.Module
? wasm
: wasm instanceof ArrayBuffer || ArrayBuffer.isView(wasm)
? wasm
: await (await fetch(wasm)).arrayBuffer();
const { instance } = await WebAssembly.instantiate(source, {});
const ex = instance.exports;
if (ex.graff_abi_version() !== 1) {
throw new Error(`unsupported graff-kernel ABI ${ex.graff_abi_version()}`);
}
return {
instance,
abiVersion: () => ex.graff_abi_version(),
cubeCells: () => ex.graff_cube_cells(),
catalog(flags = {}) {
const n = ex.graff_catalog(packFlags(flags));
if (n < 0) throw new Error("graff_catalog: scratch overflow");
const mem = new Uint8Array(ex.memory.buffer);
const ptr = ex.graff_scratch_ptr();
return JSON.parse(decoder().decode(mem.subarray(ptr, ptr + n)));
},
advertised(name, flags = {}) {
const n = writeScratch(ex, name);
return ex.graff_advertised(packFlags(flags), n) === 1;
},
confined(path) {
const n = writeScratch(ex, path);
return ex.graff_confined(n) === 1;
},
};
}

function writeScratch(ex, text) {
const bytes = encoder().encode(text);
const cap = ex.graff_scratch_len();
if (bytes.length > cap) throw new Error("scratch overflow");
const mem = new Uint8Array(ex.memory.buffer);
mem.set(bytes, ex.graff_scratch_ptr());
return bytes.length;
}
6 changes: 6 additions & 0 deletions sdk/wasm/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "graff-kernel",
"private": true,
"type": "module",
"description": "JS host for graff-kernel.wasm (catalog + path cubes, not the agent)"
}
Loading