Skip to content
Merged
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
35 changes: 35 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"ask": [
"Bash(git push *)",
"Bash(gh pr merge *)",
"Bash(* --no-verify)",
"Bash(* --no-verify *)"
]
},
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "node \"$CLAUDE_PROJECT_DIR/scripts/guard.mjs\" turn-start",
"timeout": 10
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node \"$CLAUDE_PROJECT_DIR/scripts/guard.mjs\" turn-end",
"timeout": 30
}
]
}
]
}
}
36 changes: 36 additions & 0 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Runs `pnpm verify`, the same checks as on your Mac, on every push to main and every pull request.
# macOS because the app is built and tested there, and Tauri on Linux needs extra WebKit packages.
name: verify

on:
push:
branches: [main]
pull_request:

permissions:
contents: read

# A newer push to the same branch cancels the older run. A cancelled run is not a failure.
concurrency:
group: verify-${{ github.ref }}
cancel-in-progress: true

jobs:
verify:
runs-on: macos-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10.28.1
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
cache: pnpm
- run: rustup component add clippy rustfmt
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
workspaces: src-tauri
- run: pnpm install --frozen-lockfile
- run: pnpm verify
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ node_modules
dist
dist-ssr
*.local
.claude/settings.local.json

# Editor directories and files
.vscode/*
Expand Down
24 changes: 20 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,29 @@ What comes next, and the decisions behind the editor: the "Next" section of
pnpm install
pnpm dev # the editor in a plain browser: no files, fake AI results
pnpm tauri dev # the desktop app, with live reload
pnpm test # editor, format, Lab, and palette tests (vitest, no DOM)
pnpm exec tsc --noEmit -p . # type check
cd src-tauri && cargo test --lib # Rust tests
pnpm verify # every check CI runs, in about 10 seconds
pnpm test # only the vitest tests
pnpm tauri build --bundles app # src-tauri/target/release/bundle/macos/Undertext.app
```

`cargo` must be on `PATH`. With Homebrew's rustup it lives in `/opt/homebrew/opt/rustup/bin`.
`pnpm tauri` needs `cargo` on `PATH`. With Homebrew's rustup it lives in
`/opt/homebrew/opt/rustup/bin`. `pnpm verify` finds it there on its own.

## Done

- Done means proven: a test that failed before the change and passes after, or the change seen
in the app. Reading the code or a passing compile is not proof.
- Run `pnpm verify` after each change, and before you call anything done. It records the tree
when every check passes on it, or when only docs changed since its last pass.
- It can't see the app window (WebKit), real AI calls, file dialogs, or the keychain. When a
change depends on them, check it in `pnpm tauri dev`, or say it is not verified.
- Never weaken a check to pass it: no skipped or deleted tests, no loosened assertions. If a
check is wrong, stop and say why.
- At the end of a turn that changed code `pnpm verify` hasn't passed, a hook sends you back
once. Run it, or say what is not verified and stop. `git push` and `gh pr merge` ask the
owner first.
- Report what ran, what it showed, and what you did not verify. How this works:
`docs/agents.md`.

## How the editor works

Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,11 +166,14 @@ limits. If `claude` isn't in `~/.local/bin`, `/opt/homebrew/bin`, or `/usr/local
## Development

```bash
pnpm test # editor and format tests
pnpm verify # every check CI runs: types, tests, web build, Rust format, lint, and tests
pnpm test # only the vitest tests
pnpm icons # rebuild the app icons from brand/ (needs ImageMagick)
cd src-tauri && cargo test
```

`pnpm verify` is also what coding agents run to prove their work. See
[docs/agents.md](docs/agents.md).

The editor is [CodeMirror](https://codemirror.net) inside a [Tauri](https://tauri.app) app. The page
text is plain Markdown; alternatives, ghosts, and versions are ranges kept beside it (`src/editor/layers.ts`).
The icon borrows the mark of [Klipstack](brand/README.md), with a handwritten u in place of its clip.
Expand Down
140 changes: 140 additions & 0 deletions docs/agents.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# Working with coding agents

Undertext is built with coding agents, mostly Claude Code. This page says what the repository
checks on its own, what it only asks agents to do, and what is still up to you.

The idea: an agent proves its work before it calls it done, without you asking. A passing check
is proof; "I read the code" is not.

## The layers so far

1. **`pnpm verify`** runs every check, in about 10 seconds, and records the tree when they all
pass.
2. **The end-of-turn gate** sends Claude back once when a turn changed code that `pnpm verify`
hasn't passed.
3. **Ask first:** Claude Code asks you before it pushes or merges.
4. **CI** runs `pnpm verify` on GitHub for every push to `main` and every pull request.
5. **`CLAUDE.md`** tells every agent session what done means and to run `pnpm verify`.

## Rules, and what holds them

| Rule | What holds it | How sure |
| --- | --- | --- |
| Types, tests, web build, and Rust format, lint, and tests pass | `pnpm verify`, then CI | Checked. CI shows a red run, but nothing blocks a push to `main`. |
| Run `pnpm verify` before calling work done | The end-of-turn gate, `CLAUDE.md` | Checked once per turn. Claude may stop the second time, and must say what is not verified. |
| Push or merge only when you say so | Claude Code `ask` rules | Checked for the usual commands. A speed bump, not a lock. |
| Never weaken a check to pass it | `CLAUDE.md` | Asked |
| Say what was not verified | `CLAUDE.md` | Asked |

"Asked" means an agent can forget it. Later steps turn some of these into checks.

## The end-of-turn gate

Two Claude Code hooks in `.claude/settings.json` run `scripts/guard.mjs`:

- When a turn starts, it records a hash of every file, as `pnpm verify` does.
- When Claude stops, it compares the tree with that record. If the turn changed code (anything
but docs) and `pnpm verify` hasn't passed on the tree as it is now, it sends Claude back with
the files it changed and what to do.
- It does this **once per turn**. The second stop goes through, so Claude can stop on purpose.
It then has to say what is not verified. Another Stop hook, such as a loop plugin, doesn't turn
it off.
- Turns that changed nothing, changed only docs, or undid their edits go through.
- A turn that changed only CI files can't be verified on your Mac. The gate asks Claude to say so.

**It fails open.** If the guard breaks, it lets the turn through. Usually it shows a message that
starts with "Undertext guard". If it crashes outright or takes too long, Claude Code shows its own
hook error instead. Either way, the turn was not checked.

**Limits:**

- It sees every change in the turn, also yours: an edit you make in another editor while Claude
works counts.
- It checks the checkout it lives in, or a linked worktree of it that the session is in. Edits in
another repository are not checked. A turn that moves to another checkout is not checked, and
the message says so.
- Two sessions in the same checkout see each other's edits. Each may send its Claude back once for
the other's work.
- Only Claude Code runs it. Codex, Amp, and Cursor are not set up here.

Its records live in `.git/undertext/turns/`, one file per session, shared by every worktree. Files
older than a week are deleted. Its tests, in `scripts/guard.test.mjs`, run copies of the guard in throwaway
repositories.

## Ask first

`.claude/settings.json` has `ask` rules for `git push`, `gh pr merge`, and any command with
`--no-verify`. Claude Code then asks you, even in auto mode, and even inside a longer command such
as `cd x && git push`.

- **It is a speed bump.** It matches the commands Claude usually writes. A different form of the
same thing, such as a script that pushes, gets through.
- `--no-verify` skips git hooks. This repository has none today, so that rule guards nothing yet.
It misses the short form, `git commit -n`.
- The real lock would be branch protection on GitHub. You push to `main` yourself, so there is
none.

## `pnpm verify`

It runs, in order, and prints one line per check:

| Check | Command |
| --- | --- |
| types | `tsc --noEmit -p .` |
| config types | `tsc` on `vite.config.ts` |
| tests | `vitest run`, with no `.only` allowed (fails if no test ran) |
| web build | `vite build`, into a scratch folder, so `dist/` stays as it was |
| rust format | `cargo fmt --check` |
| rust lint | `cargo clippy --all-targets --locked -- -D warnings` |
| rust tests | `cargo test --lib --locked` (fails if no test ran) |

- It never picks a subset of the checks. They are fast, so there is nothing to gain. It runs all
of them, or none in the three cases below.
- On a failure it prints the end of that check's log and the full log's path. Logs live in
`.git/undertext/logs/`, or under `.git/worktrees/<name>/` in a linked worktree.
- **It records a pass** only when every check passed and no file changed during the run. The
record is a hash of every file's contents, untracked files included, in
`.git/undertext/verified.json`. Each worktree keeps its own. A failed run deletes the record.
- **Nothing changed** since the last pass: it says so and stops. `pnpm verify --force` runs the
checks anyway, for example after `rustup update`.
- **Only docs changed** since the last pass (`docs/`, `LICENSE`, and `*.md` outside `src/` and
`src-tauri/`): nothing to check. It records the new tree.
- **Only CI files changed** (`.github/`): local checks can't see them. It records nothing, even
with `--force`. Push and read the CI run.
- It finds `cargo` on `PATH`, in `~/.cargo/bin`, or in Homebrew's rustup folder. Without cargo it
fails. It never skips a check it can't run.

**What it can't see:** the app window, which is WebKit, while `pnpm dev` runs in Chromium; real AI
calls; file dialogs; and the keychain. For those, run `pnpm tauri dev` and try the change.

**Known limits:**

- It compares the tree before and after the run. An edit made and undone while it runs goes
unseen.
- It checks the files on disk, not what is staged. When you stage only part of a change, the
commit holds a tree that was never checked. CI checks the commit after you push.
- A stray `.only` fails it, as in CI. A `.skip` does not.

The script is `scripts/verify.mjs`, the checks are in `scripts/verify-steps.mjs`, and its tests are
in `scripts/verify.test.mjs`. The tests run copies of the scripts in throwaway repositories.

## CI

`.github/workflows/verify.yml` runs `pnpm verify` on a GitHub macOS machine. It is free for public
repositories.

- **Why macOS:** the app is built and tested there. On Linux, Tauri needs extra WebKit packages.
- **It is a signal, not a gate.** `main` has no branch protection, so a red run blocks nothing.
You push to `main` yourself, and that stays as it is.
- **Cancelled runs are not failures.** A newer push to the same branch cancels the older run. A
run that never got a machine or timed out needs a re-run, not a code change.
- **Rust is not pinned.** CI uses the latest stable Rust. A new Clippy lint can fail CI before it
fails on your Mac. Run `rustup update`, then `pnpm verify --force`.
- Each action is pinned to one commit, with its version in a comment, so a changed tag can't
change what runs.

## Where the ideas come from

Kent Beck, Simon Willison, Mitchell Hashimoto, Anthropic's and Amp's guidance on coding agents,
OpenAI's harness engineering, Lauren Tan's pstack, Matt Pocock's skills, and Jesse Vincent's
superpowers.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"preview": "vite preview",
"tauri": "tauri",
"test": "vitest run",
"verify": "node scripts/verify.mjs",
"icons": "node scripts/icons.mjs"
},
"dependencies": {
Expand Down
82 changes: 82 additions & 0 deletions scripts/guard.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// The end-of-turn gate for Claude Code, run by the hooks in .claude/settings.json with the hook's
// JSON on stdin. `turn-start` (UserPromptSubmit) records the tree. `turn-end` (Stop) sends Claude
// back once when the turn changed code that `pnpm verify` hasn't passed. On any error it lets the
// turn through and says why.
import { mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const own = realpathSync(resolve(dirname(fileURLToPath(import.meta.url)), ".."));
const WEEK = 7 * 24 * 60 * 60 * 1000;
const list = (paths) => (paths.length > 5 ? `${paths.slice(0, 5).join(", ")} and ${paths.length - 5} more` : paths.join(", "));

/** The checkout this script lives in, or a linked worktree of it that Claude is in. Never another repository. */
function checkout(git, cwd) {
if (!cwd) return own;
try {
const top = realpathSync(git(cwd, "rev-parse", "--show-toplevel").trim());
const common = (dir) => realpathSync(resolve(dir, git(dir, "rev-parse", "--git-common-dir").trim()));
return common(top) === common(own) ? top : own;
} catch {
return own;
}
}

function turnStart({ git, snapshot }, input, file) {
rmSync(file, { force: true }); // if the snapshot fails, turn-end finds no record and says so
mkdirSync(dirname(file), { recursive: true });
for (const name of readdirSync(dirname(file))) {
const path = join(dirname(file), name);
try {
if (Date.now() - statSync(path).mtimeMs > WEEK) rmSync(path, { force: true });
} catch {
// another session removed it first
}
}
const root = checkout(git, input.cwd);
writeFileSync(file, JSON.stringify({ root, ...snapshot(root) }));
return null;
}

function turnEnd({ changedPaths, git, isCiOnly, isDoc, readPass, snapshot }, input, file) {
let start;
try {
start = JSON.parse(readFileSync(file, "utf8"));
} catch {
return { systemMessage: "Undertext guard: no record of this turn's start, so this turn was not checked." };
}
// Once per turn: the second stop goes through, so Claude can stop on purpose and say what's
// unfinished. Not `stop_hook_active`: any Stop hook sets that, and it would turn the gate off.
if (start.blocked) return null;
const root = checkout(git, input.cwd);
if (start.root && root !== start.root) {
return { systemMessage: `Undertext guard: this turn moved from ${start.root} to ${root}, so it was not checked.` };
}
const now = snapshot(root);
const touched = changedPaths(start.files, now.files).filter((path) => !isDoc(path));
if (touched.length === 0) return null; // nothing changed, only docs, or the edits were undone
const pass = readPass(root);
const unverified = pass ? changedPaths(pass.files, now.files).filter((path) => !isDoc(path)) : touched;
if (unverified.length === 0) return null;

writeFileSync(file, JSON.stringify({ ...start, blocked: true }));
const reason = touched.every(isCiOnly)
? `This turn changed CI files (${list(touched)}). Local checks can't see them. Say they are not verified until CI runs, then stop.`
: `This turn changed code that \`pnpm verify\` hasn't passed: ${list(touched)}. Run \`pnpm verify\` and fix what fails. If you are stopping on purpose, say what is not verified, then stop again.`;
return { decision: "block", reason, systemMessage: "Undertext guard: sent Claude back to verify this turn's changes." };
}

try {
// Imported here, so that a broken tree.mjs still ends in the message below.
const tree = await import("./tree.mjs");
const input = JSON.parse(readFileSync(0, "utf8") || "{}");
const event = process.argv[2];
if (event !== "turn-start" && event !== "turn-end") throw new Error(`unknown event "${event}"`);
// One folder for every checkout of this repository, so a session's record is found wherever it ends.
const turns = join(realpathSync(resolve(own, tree.git(own, "rev-parse", "--git-common-dir").trim())), "undertext/turns");
const file = join(turns, `${String(input.session_id ?? "unknown").replace(/[^\w-]/g, "_")}.json`);
const out = event === "turn-start" ? turnStart(tree, input, file) : turnEnd(tree, input, file);
if (out) process.stdout.write(JSON.stringify(out));
} catch (error) {
process.stdout.write(JSON.stringify({ systemMessage: `Undertext guard failed, so it let this turn through: ${error.message}` }));
}
Loading
Loading