diff options
| author | godosa <godosa@godosa.eu> | 2026-10-07 07:27:17 +0200 |
|---|---|---|
| committer | godosa <godosa@godosa.eu> | 2026-10-07 07:27:17 +0200 |
| commit | 81d4e80fd5aabe4e80f58e960affa795cf7d34ec (patch) | |
| tree | e98eeac2af6af63aa4287bba1f6d4a3af26b5727 /docs | |
| download | workflow-81d4e80fd5aabe4e80f58e960affa795cf7d34ec.tar.gz workflow-81d4e80fd5aabe4e80f58e960affa795cf7d34ec.zip | |
workflow: initial public history
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/cloud-lane.md | 195 | ||||
| -rw-r--r-- | docs/design.md | 329 | ||||
| -rw-r--r-- | docs/lanes.md | 115 | ||||
| -rw-r--r-- | docs/manual.md | 239 | ||||
| -rw-r--r-- | docs/multi-session.md | 46 | ||||
| -rw-r--r-- | docs/orchestrator.md | 152 | ||||
| -rw-r--r-- | docs/resource-ledger.md | 304 |
7 files changed, 1380 insertions, 0 deletions
diff --git a/docs/cloud-lane.md b/docs/cloud-lane.md new file mode 100644 index 0000000..d48c1ed --- /dev/null +++ b/docs/cloud-lane.md @@ -0,0 +1,195 @@ +# wf cloud — cloud worker lane on promo credits — design + +Status: owner decisions 2026-10-06 (design session). +Tool code goes to `/projects/public/workflow` (public tool repo); this spec was originally developed locally, +then a copy `docs/cloud-lane.md` in the tool repo becomes the live one (same as resource-ledger). + +## 1. Goal + +1. Use a promo cloud credit for worker tasks, so the weekly subscription limit goes to design/orchestration/local-only work. +2. No GitHub, no hosting: code goes up as a `claude --cloud` bundle upload from a local folder, results come + back through the session transcript. Push stays local-only. +3. A local ledger keeps cloud spend under a cap; the orchestrator routes fitting work to the cloud only while + the ledger is positive; otherwise everything runs locally as today. +4. Cloud work never touches TASKS.md / archive: bookkeeping stays local (`wf` is not in the VM). + +## 2. Owner decisions (2026-10-06) + +| # | Decision | +|---|----------| +| C1 | No GitHub (no mirror, no GitHub App). Bundle upload only. | +| C2 | Cloud ledger with budget set by owner (balance minus margin against overrun). | +| C3 | The orchestrator may put fitting work in the cloud on its own while the ledger balance is positive; at ≤ 0 it runs locally, no question asked. | +| C4 | Opus in the cloud is fine (most work is opus). | +| C5 | Purpose-built snapshot repos (code + the resource files a task needs) are the unit sent up; no hosted git. | + +<a id="facts"></a> +## 3. Facts (measured 2026-10-06, CLI 2.1.290) + +| # | Fact | +|---|------| +| F1 | `claude --cloud "<prompt>"` in a folder with no git remote uploads a bundle (history + tracked files; untracked and `.env`-like files left out; ≤ 100 MB, else branch-only, else squashed snapshot). It prints `Created cloud session: …`, `View: https://claude.ai/code/session_…`, `Resume with: claude --teleport session_…` and **exits** — scriptable. | +| F2 | `--cloud` needs a TTY (`-p` refused; piped stdout refused). A pty (tmux, python `pty`) works. | +| F3 | A never-seen folder shows the trust dialog first (blocks; "Yes" = Down, Enter). Pre-accepting it (`~/.claude.json` `projects[<path>].hasTrustDialogAccepted = true`, atomic rewrite) works — verified 2026-10-06, no dialog. | +| F4 | Cloud sessions run **Opus 5.5 at effort medium** by default (commit trailer, status line). | +| F5 | The VM can't reach local networks behind a firewall → it can't push to local git. Custom allowlist = claude.ai environment settings (owner action). | +| F6 | Large projects with native build tools may require environment setup; e.g., .NET SDK not in the base image → download from package repos (minutes, cached with environment setup script). | +| F7 | `claude --teleport <sid>` for a bundle session: validates, fetches logs, then **fails silently at "Checking out branch"** (branch only in the VM) — sometimes exits, sometimes still resumes the conversation. It never applies the branch. Teleporting a *running* session does not interrupt it (local copy shows "Interrupted"; cloud kept working). | +| F8 | After a teleport resumes, `/export <file>` writes the rendered conversation (80-col wrapped; tool output truncated `… +N lines`; **assistant text complete**) with **no model call**. The local `.jsonl` transcript only appears after a local message (= a full-context model call, avoid). | +| F9 | So the result channel is the session's **final assistant text**: a gzip+base64 patch between markers with a sha256, whitespace-stripped on read. A patch inside tool output is truncated and useless. | +| F10 | `claude -p "<msg>" --cloud <sid>` queues a follow-up into a running/idle session and exits (no TTY needed). | +| F12 | The export contains the prompt too: marker parsing must take only assistant lines (`● WF-RESULT …` = bullet prefix) after the prompt, never a bare substring match. | +| F13 | Environment configuration (owner, 2026-10-06): custom network allowlist for required services; environment variables for telemetry and timeouts; setup script for large dependencies. The exact setup depends on project needs and should be configured in claude.ai environment settings. | +| F11 | Cloud sessions share the subscription rate limits only after the promo credit is used up; until then they draw the credit (promo terms). No CLI shows the balance: owner reads it on claude.ai. | + +Baseline local cost (`wf usage --report`, API-price $, 2026-10-06): +opus worker ≈ $1.10–1.32 per done task (median $0.58–0.96, n=64), sonnet ≈ $0.14–0.30, orchestrator +≈ $0.25 per dispatched task. Transcript output counts are estimates. + +## 4. Design + +<a id="snapshot"></a> +### 4.1 Unit of work: snapshot repo + +`wf cloud send <id>` builds `<project>/out/cloud/<id>/` (git-ignored, deleted by `pull` after apply): +- `git archive <master HEAD>` of the project → plain files; plus every path in workflow.toml + `cloud_include` (git-ignored inputs, e.g. large data files), force-added; one commit "base <sha>". +- Packed size > 90 MB → refuse (`wf: snapshot <n> MB > 90 MB; trim cloud_include`). +- Never in the snapshot: `TASKS.md`/archive/`.wf`/`.worktrees`/`out/` except `cloud_include`. +- Pre-accept the trust dialog for that path (F3), then run `claude --cloud "<prompt>"` under a python pty with + a timeout (5 min); parse `session_…` from the output. No session id → exit 1 with the last 5 output lines. + +<a id="prompt"></a> +### 4.2 Prompt (template `templates/cloud-prompt.md`, filled by `send`) + +Fixed text (≈ 25 lines) + `wf show <id>` body + the project CLAUDE.md test recipe of the areas the task names +(`wf ctx` areas section). Rules in the template: +- `wf` is absent: ignore wf/TASKS/lane/gate rules in CLAUDE.md; never edit TASKS.md/archive. +- Never ask; blocked → final text `WF-AWAITING: <one-line question>` and stop. +- Test red → implement → green; commit on `main` (snapshot repo) with one-line messages. +- Network blocked for a needed source → say so in the report line, continue with what the repo has. +- **Final message text** (not a tool call), exactly: + ``` + WF-RESULT done|awaiting|handback + WF-REPORT <≤2-line archive entry> + WF-USAGE in=<n> cw=<n> cr=<n> out=<n> model=<id> (summed from its own transcript, script given in the template) + WF-PATCH-BEGIN sha256=<hex of the gzip> bytes=<n> + <base64 of `git format-patch --binary <base>..HEAD --stdout | gzip -9`> + WF-PATCH-END + ``` +- Built (cloud send task): the template writes each marker line in brackets (`[WF-RESULT R]`) so no prompt line + starts with a marker; `cloud.final_message(export)` = the F12 parser: lines of the last message starting + `● WF-RESULT` (assistant bullet; the prompt renders as `❯ `/` `, never `● `), bullet/indent stripped, + ends at the next non-indented line. `pull` parses fields from that list only. Snapshot record + `.wf/cloud/<id>.json`: id, sid, project, lane, master (sha for `git am`), base (snapshot commit = patch base), + folder, sent, model, bytes. Ledger row reserved before `claude --cloud` (sid `pending:…`), removed on failure. + +<a id="pull"></a> +### 4.3 Return: `wf cloud pull <id> | --all` + +- Teleport under a pty in the snapshot folder, `/export <tmp>`, `/exit` (F7, F8); no model call. +- Parse the export: last line starting `● WF-RESULT` (F12); none → `running` (exit 4, prints age since send). + None, but the last assistant message (no non-empty prompt after it) ends in a bare `WF-PATCH-END` line + (key words dropped) → handback `bad result: no WF-RESULT key`, patch kept when a `sha256=… bytes=…` header decodes. +- `done`: join base64 between markers (strip whitespace and the 2-space indent), check sha256 and bytes, + gunzip → patch. Apply in the task's lane worktree on branch `<lane>/<id>` from the recorded base: + `git am --3way`; files outside the project's tracked tree or in `cloud_include` → refuse. + Then `quick_gate`; green → `wf done <id> -m "<WF-REPORT>"` + commit + `wf merge` (the `wf finish` path, run by + `pull` itself, so a pull costs the orchestrator one Bash call). Red / am conflict → handback (below). +- `awaiting` → `wf add -s awaiting "<q>"` + `wf status <id> blocked <a-id>`. +- `handback` / red gate / bad patch → `wf note <id> "cloud <sid>: <why>"`, `wf status <id> clear`, task goes back + to the local queue with `Recovery: cloud attempt <sid> — <why>` in its note (a local worker may take the patch + from `out/cloud/<id>.patch`, kept on handback). +- Every pull that ends the session charges the ledger (4.4) and deletes the snapshot folder. +- Archive (owner ruling 2026-10-06, built as part of cloud infrastructure): each pull that ends a session then archives it in the + app (`POST https://api.anthropic.com/v1/code/sessions/<sid>/archive`, the CLI's internal archiveRemoteSession, + CLI 2.1.291; 200/409 ok). Failure → one `archive <sid> failed: …` line, pull exit unchanged; ledger row + `archived: true`; `wf cloud archive <sid>|--ended` retries. +- Built (cloud pull task): sections of the final message open at a key line, other lines continue it (export + wraps); the patch header + base64 are joined with all whitespace removed (`sha256=HEXbytes=N` then base64: + gzip base64 starts `H4sI`, never a digit). Refused paths: outside the tree / project folder, TASKS, archive, + `.wf`, `.worktrees`, `out`, `cloud_include`. One handback note `Recovery: cloud attempt <sid> — <why>[; patch …]`. + Lost = no result (or no export) and > 24 h since send. Branch `<lane>/<id>` already there / worktree_setup red → + exit 1, session kept (pull again). `--export FILE` parses a given export (tests, manual). Exit 0 ended, 4 running. + +<a id="ledger"></a> +### 4.4 Ledger: `~/.local/state/wf/cloud.json` (same state dir and lock style as `wf res`) + +``` +{"budget": 240.0, "spent": 0.0, "reserve_per_task": 4.0, "max_parallel": 3, + "entries": [{"id", "project", "sid", "model", "sent", "state": "running|done|awaiting|handback|lost", + "usd": null|float, "usd_source": "self|est|owner"}]} +``` +- balance = budget − spent − reserve_per_task × running. `send` refuses (exit 3, one line) when + balance < reserve_per_task or running ≥ max_parallel. +- Charge on end: `WF-USAGE` × `usage.PRICES` (reuse `wflib/usage.py`), plus a 15 % overhead factor for + what the session can't see (title generation, setup); missing WF-USAGE → charge reserve_per_task, + `usd_source=est`. +- `wf cloud ledger` prints budget/spent/balance/running; `--set-balance <usd from claude.ai>` sets + spent = budget − balance (owner reconcile, `usd_source=owner` row); `--budget N` changes the cap. +- A running entry older than 24 h without `WF-RESULT` → `lost` (charged reserve), task back to local. + +<a id="fit"></a> +### 4.5 What fits the cloud (routing) + +Project opt-in: workflow.toml `cloud = true` (+ `cloud_include = [...]`, optional `cloud_note = "..."` appended +to the prompt) only enables the lane; routing is per task (owner ruling 2026-10-06, amended: opus only). +Whoever adds a task (design session, orchestrator, worker follow-ups) classifies it: `Cloud: yes|no` +(`wf add --cloud` / `wf set --cloud`); yes = Done checkable from the repo alone (code, synthetic data, +`cloud_include` data), no GUI/display, live game/server/capture, LAN, Wine or local installs, `wf`/`wf res` steps, +owner; opus only, prefer larger (1h); unsure → no. Task fits when all hold: +- project opted in; task runner-ready (Done + Model), not `Sessions: owner|solo`, no `Cloud: no` line; +- effort ≤ `slice_above` (no slice jobs); Model opus (sonnet/haiku never, also with `Cloud: yes`: overhead ≈ their cost, §5); +- `Cloud: yes` → fits (regex skipped); no Cloud line → body must not name `wf ` commands as steps + (workflow-upgrade / bookkeeping tasks need `wf`), GUI, LAN hosts, `wf res`, or a live server — checked by a small + regex list in `wflib/cloud.py`. +`wf orch pick cloud` order (`cloud.pick_key`): `Cloud: yes` first, then prio, then effort desc (1h before <1h). +A worker that finds a task unfit for the cloud adds `Cloud: no` (`wf set <id> --cloud no`). + +<a id="orch"></a> +### 4.6 Orchestrator + +`/wf-orchestrate` and `wf batch` gain a virtual lane `cloud`: +- `wf orch pick cloud` = first fitting task across the project's lanes, ledger allows → `wf cloud send`, claim + `status progress cloud:<sid>`. Prints `stop lane cloud: <ledger|none fit|max parallel>` otherwise. +- The orchestrator calls `wf cloud pull --all` on each wake-up (no faster than every 10 min; a teleport + takes ~30 s and no tokens). Results feed `wf orch post` like a worker report. +- Local lanes skip tasks claimed `cloud:`; the cloud lane takes opus tasks only (the expensive ones locally), `Cloud: yes` first (§4.5). +- Ledger ≤ reserve → the cloud lane stops; the local lanes run as today (C3). + +## 5. Economics (measured, see §6) + +Value of local work = its API-price $ (what the weekly limit is spent on). Per opus task: + +| Item | $ | Source | +|---|---|---| +| Local opus worker avoided | 1.20 | Historical API-price per task (range 1.10–1.32, n=64) | +| Local overhead of a cloud task | 0.10 | send + pull = 2 orchestrator calls at ~150k cached ctx (~0.05 each); poll wakes amortised ~0.03; gate = CPU only | +| Failure (handback → local redo) | 0.24 | assumed p_fail 20 % × 1.20 | +| **Local $ saved** | **0.86** | | +| Cloud credit used | 1.40 | typical replay scenario with setup overhead | + +Net per opus task = 0.86 − v × 1.40: + +| Credit value v | Net per task | $240 credit → tasks / net | +|---|---|---| +| 0 % (use-or-lose, time-limited) | **+0.86** | ~150 tasks / **+$129 local** | +| 50 % | +0.16 | ~150 / +$24 | +| 100 % (as paid usage) | −0.54 | loss: only worth it when the local limit is hit | + +The analysis shows that using cloud credits for opus-only tasks is economical only when the credit value is low (time-limited or not yet consumed). +Sonnet tasks: local $0.14–0.30 vs overhead + failure ≈ $0.15 → ≈ 0 net even at v = 0 → **cloud lane takes opus +only** (sonnet only via `Cloud: yes`). Build cost: 6 slices worth of engineering. + +## 6. Validation + +Cloud worker send/pull was validated with: +- Manual pty driver tests against the real CLI on throwaway repos (no project content). +- Round-trip end-to-end tests from send → export → pull with real snapshot repos. +- Synthetic test fixtures with the same message shape as real cloud sessions (for unit tests in the tool repo). + +Key findings: +- Marker parsing must handle export word wrapping (spaces, indents). +- ANSI codes in the TUI output must be stripped before marker matching. +- Trust dialog pre-acceptance prevents interactive blocks. +- Export `/exit` may take multiple Enter presses to ensure the file is written. diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..5f691d7 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,329 @@ +# wf — design reference + +Shared task workflow for many coding agents (Claude Code sessions) working in parallel on the +projects under one folder (`/projects`). This file is the reference for the TASKS.md format, +`workflow.toml`, every `wf` command and the release rules. The memory/CPU ledger `wf res` has its +own document: [resource-ledger.md](resource-ledger.md). Overview: [README](../README.md). + +## 1. Goal + +One workflow definition and one tool, used by every agent working in a subfolder of +`/projects`, instead of task scripts and workflow text copied (and drifting) per project. +Project `CLAUDE.md` files keep project specifics only. + +Success: +- An agent started in any project answers "continue" with the same steps. +- A workflow fix is made once, in one repo, under revision control. +- Task references cannot rot silently (`wf check` fails on them). +- Less always-loaded instruction text per project. +- A worker that hits a workflow problem reports it in one command and keeps working; fixes + reach all workers without breaking one that is mid-task. +- Routine task upkeep (list, find, add, reprioritise, flag, finish) is one short command + each; an agent never reads or rewrites TASKS.md whole to do it. + +Non-goals: +- No runner: the task tool never builds, tests, commits or pushes. + +## 2. Layout + +``` +/projects/ + CLAUDE.md -> workflow/shared/CLAUDE.md symlink + workflow/ this repo + shared/CLAUDE.md shared loop (section 6), loaded by every project + CLAUDE.md rules for sessions changing this repo + wf.py CLI entry + wf_res.py `wf res` (files, /proc, systemd); logic in wflib/res.py + wflib/tasks.py pure text: parse, insert, remove, archive + wflib/refs.py pure text: ids, links, anchors, doc sections + wflib/config.py find project root, load workflow.toml + wflib/check.py everything `wf check` reports + wflib/ledgers.py in-flight plan ledgers for `wf next` + wflib/search.py pure text: ranked search over tasks, archive, docs + wflib/migrate.py one-time converter from a numbered task list + wflib/res.py pure logic of the resource ledger + tests/test_*.py unittest, stdlib + inbox.md reports from workers, append-only, not in git (section 7) + templates/ workflow.toml, TASKS.md, archive.md for `wf init` + CHANGES.md release notes, newest first + <group>/<project>/ any depth ≤ 3; a project = a folder with workflow.toml +``` + +Loading: Claude Code reads `CLAUDE.md` from the working directory and every parent, so +sessions in a project or in its `.worktrees/*` get the shared file without a pointer. If a +symlink is not loaded, a real one-line file `/projects/CLAUDE.md` containing +`@workflow/shared/CLAUDE.md` does the same. The shared file sits in `shared/` so that a session +inside `/projects/public/workflow` does not load it twice. + +Each project `CLAUDE.md` starts with: +`Workflow: /projects/CLAUDE.md (shared loop, wf). Below = project specifics; this file wins on conflict.` + +The folder scanned by `wf projects` is the parent of this repo, or its grandparent when the parent holds no project (`WF_ROOT` overrides); the inbox +is `inbox.md` here (`WF_INBOX` overrides). The tasks of the workflow itself can live in a +separate (private) wf project; nothing in this repo needs them. + +Requirements: Linux, Python ≥ 3.11 (stdlib only). `wf res` also needs systemd user units with +cgroup v2. + +## 3. TASKS.md format + +```markdown +# Tasks — <project> + +<free prose: commands, pointers> + +## Awaiting your decision + +- **a-smoke-screens**: needs you present, screen unlocked. ... + +## Pending + +- **t-cave-seams** [P3] (<1h): Terrain: cave entrance seams. Close the slit beside the lintel. + - Steps: ... + - Tests (red first): ... + - Done: ... + - After: [[t-terrain-cave-render]] + Ref: DESIGN.md#terrain + +## Needs human + +- **t-playtest-feel** [P1] (<1h): Play-test feel + tuning. ... + - [ ] ... + +## Deferred + +- **t-android-port** [P3] (10h): ... +``` + +Rules: +- Item = a line `- **<id>**` at column 0 plus every following line that is blank or indented. + A flush-left non-item line ends the item list of the section (prose after items is kept). +- Id grammar: `t-[a-z0-9-]+` for tasks (Pending, Needs human, Deferred), `a-[a-z0-9-]+` for + Awaiting items. A task keeps its id when it moves between sections. + (Changed from the chat draft, which used `h-` for Needs human: a prefix tied to a section + would force an id change on a move.) +- Ids are unique across TASKS.md and the archive, and never reused. +- Task header: `- **<id>** [P0-P3] (<effort>) [status]: Title. Goal.` (old `(<effort>, interactive)` + still reads as `Sessions: owner`; `check` warns.) + Effort is one of `<1h`, `1h`, `5h`, `10h`, `100h`. Awaiting items have no priority or effort. +- Status, optional, fixed words: `(in progress: <branch or note>)`, `(blocked: [[a-id]])`. +- `After: [[id]], [[id]]` lists dependencies. `Ref:` lists `path` or `path#anchor`, comma + separated, relative to the project root; text in parentheses after a ref is a free note. +- `Model: haiku|sonnet|opus` (none = opus): the model for the task (docs/lanes.md §0). + `Sessions: parallel|solo|owner [— why]` (none = parallel; `- Sessions:` works too): + solo = only while no other session is live in the project (lib/submodule bump, repo-wide refactor, + shared runner), and while it is in progress no other session picks anything; owner = needs the owner + present (real display, playtest, choices): `next` picks it only with `--owner`. +- Slices of a task over 1h: own items `t-<parent>-1`, `t-<parent>-2` (or any id given with + `--id`), each `After:` the previous one. The parent item stays as the summary and lists its + slices (`Slices:` line); it is done when its last slice is done, and `next` does not pick it + while a slice is open. +- Pending holds open work only, in pick order. +- Sections other than the four named ones are prose: the tool keeps them and checks only + the `[[id]]` links in them. + +Archive file: newest first, one line per finished task: +`- 2026-09-29 **t-cave-seams** Terrain: cave entrance seams — <entry, ≤2 lines>`. +Older lines without an id stay as they are. + +## 4. workflow.toml + +In the project root. Its presence marks the project root for `wf`. + +```toml +format = 1 # required: TASKS format version +tasks = "TASKS.md" # required +archive = "tasks/archive.md" # required +docs = ["DESIGN.md", "docs/"] # files/dirs whose links and anchors `check` validates +verify = ["dotnet build X.slnx", "dotnet test X.slnx"] # printed by `done`, never run +done = ["Feature changed -> docs/CATALOG.md"] # printed by `done` +ledgers = ".superpowers/sdd" # optional: in-flight plan ledgers +ctx_hint = 100000 # optional: tokens; 0 = off (see below) +worktree_setup = ["mkdir -p out && ln -sfn \"$WF_MAIN/out/data\" out/data"] # optional: `wf setup` (below) +quick_gate = ["dotnet test X.slnx --filter ContractTests"] # optional: `wf gate` (below) +cloud = true # optional: project opts in to the cloud lane (default false) +cloud_include = ["out/data/"] # optional: git-ignored paths force-added to the cloud snapshot +cloud_note = "No GUI here." # optional: appended to the cloud prompt + +[anchors] # optional: index/spec anchor scheme +index = "DESIGN.md" # every task Ref anchor must be a heading here +index_section = "Subsystems" +specs = "docs/superpowers/specs" # explicit <a id> here needs an index entry +``` + +Only `format`, `tasks` and `archive` are required. Unknown keys are an error (catches typos). + +`worktree_setup`: shell commands `wf setup` runs inside a lane worktree (cwd = the worktree's project +folder, env `WF_MAIN` = the main tree's project folder), in order, stopping at the first non-zero exit +(`wf: …`, exit 1). They provide git-ignored inputs a fresh worktree lacks (e.g. link `out/…` from the +main tree) and must be idempotent: wf-worker runs it after every worktree create/reuse. + +`cloud` fit (cloud-lane spec §4.5): with `cloud = true` a task fits when runner-ready, not `Sessions: owner|solo`, +no `Cloud: no` line, effort <= `slice_above`, Model opus, and its title/body does not match the unfit regex list +in `wflib/cloud.py` (`wf` / `wf res` steps, GUI, LAN hosts, live server). Routing is per task: whoever adds it sets +`Cloud: yes|no` (`wf add/set --cloud`); `Cloud: yes` skips the regex list only (still opus only), `Cloud: no` +always excludes, no line = the rules above. `wf orch pick cloud` order (`cloud.pick_key`): `Cloud: yes` first, +then prio, then larger effort. `wf check` accepts the `Cloud:` line (yes|no, one per task). + +`quick_gate`: fast regression commands (minutes, not the full slow gate) `wf gate` runs in the current +tree's project folder (worktree or main; env `WF_MAIN`), stopping at the first red (`wf: …`, exit 1); +none configured → exit 0. wf-worker runs it before `wf done`, so a break is caught by the task that made +it, not blamed on a later task by the async slow gate. The project picks the commands. + +## 5. wf CLI + +Run as `python3 /projects/public/workflow/wf.py <cmd>` from anywhere inside a project. The +project root is the nearest ancestor of the working directory that holds `workflow.toml`; +`--project DIR` overrides. Output is plain text, exit 0 on success. + +Principle: every routine operation is one command with short output, so agents spend no +context on reading or rewriting task files. Hand edits are for body prose only. + +Read (never write): + +| Command | Does | +|---|---| +| `next [--brief]` | Awaiting items, Needs-human titles, in-flight ledgers, then the next task with `ctx` of its refs. `--brief`: the task only. Skips blocked items, open `After:`, parents with open slices, tasks held by another live session, `Sessions: owner` (unless `--owner`), `Sessions: solo` while another session is live. A solo task in progress by another live session → picks nothing, exit 1. Exit 1 if nothing is pickable. | +| `list [filters]` | One line per item: `id P2 1h status Title` (title cut to fit 100 columns). Default: Pending. Filters: `-s pending\|human\|awaiting\|deferred\|all`, `-p 0-3` (that priority or higher), `--ready` (pickable now), `--runner` (ready + Done line, not owner-bound, no open slices), `--blocked`, `--progress`, `--ref <path[#anchor]>`, `-n N`. Last line: counts per section. | +| `show <id>...` | The raw item(s), nothing resolved. | +| `ctx <id \| path#anchor>` | The item, its refs resolved, its dependencies and the items that depend on or link to it, then the notes of every area (`## Areas`) the item names (area name, code-map anchor or Paths entry as a whole word). For an anchor: that doc section plus the tasks that reference it. | +| `search <words>... [filters]` | Ranked hits over tasks, archive and `docs`: one line each, `id` or `path:line heading`, plus the matching line. Filters: `--tasks`, `--archive`, `--docs`, `-n N` (default 15). | +| `log [-n N] [words]` | Newest archive lines (default 10), optionally only those matching the words. | +| `projects` | Every project under the folder that holds the workflow repo (`/projects`) with a `workflow.toml` (depth ≤ 3): counts per section, next task id + title, number of `check` errors. | +| `check` | Validates; prints every problem; exit 1 on any error. | + +Write (each changes only the items named, and prints the changed header lines): + +| Command | Does | +|---|---| +| `add "<Title. Goal.>" -p N -e EFFORT [opts]` | New item. Id made from the title unless `--id` given or the text starts with `<id>: `; printed. Opts: `-s SECTION`, `--after ID,…`, `--ref REF,…`, `--parent ID` (slice: id `t-<parent>-N` or `--id`, `After:` the previous slice, parent's slice list updated), `--model M`, `--sessions solo|owner`, `-b` (body lines from stdin; never read unasked, a harness may hold stdin open). `add -` reads a whole item block from stdin. | +| `done <id>... [-m "<entry>"]` | Removes the task(s), puts the dated line(s) on top of the archive, prints the `verify` and `done` lists once. `-m` is required for a single task and applies to it; several ids without `-m` use each task's goal sentence. | +| `prio <id> <0-3>` | Sets the priority and moves the item to its place by the insert rule. | +| `move <id> <section>` or `move <id> --before\|--after <id>` | Moves between sections, or reorders inside one (refused if it breaks priority or `After:` order, unless `--force`). | +| `status <id> progress "<note>" \| blocked <a-id> \| clear` | Sets or clears the status words. | +| `set <id> [--title T] [--effort E] [--after IDS] [--ref REFS] [--model M] [--sessions S] [--done TEXT]` | Changes header fields and the `After:` / `Ref:` / `Model:` / `Sessions:` / `Done:` lines (`""` removes). `--interactive` = old spelling of `--sessions owner`. `--after ""` removes the line. `--title` keeps the goal, unless it has its own (`Title. Goal.`) or ends in `?`/`!`: then it replaces the whole text. | +| `rename <id> <new>` | Changes an open item's id (same kind, unused, not archived) and every `[[id]]` link in TASKS.md. | +| `note <id> "<line>"` | Appends one `- <line>` to the item body. | +| `body <id>` | Replaces the item body with stdin (header, `After:`, `Ref:` kept). | +| `tick <id> <n \| text>` | Checks the n-th (or the matching) `- [ ]` box of a Needs-human item. | +| `report "<what happened>" [--kind bug\|idea\|friction] [--cmd "<command>"]` | Appends one entry to the workflow `inbox.md` (section 7). Touches nothing in the project. | +| `batch N [--lanes L,…] [--prep] [--model M] [--for 6h] [--mem 4G] [--dry-run]` · `batch --status` | Starts an unattended batch orchestrator (`claude -p`, auto mode, no prompts, prompt `templates/batch-prompt.md`) as a `wf res run` job with `CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS` = `--for`; it writes `out/wf-batch-<stamp>.md`. `--status` = this project's newest batch job + newest summary file. `--prep` = prep batch (`templates/prep-prompt.md`): sonnet workers write Done (+Model) for up to N tasks without Done, unclear → awaiting; none → nothing started. See `docs/orchestrator.md`. | +| `init` | In a folder without `workflow.toml`: writes it, TASKS.md and the archive from the templates. | +| `migrate [--write]` | One-time converter from the numbered format. Without `--write`: prints the diff, the id map and notes, changes nothing. `--write` also raises `format` in `workflow.toml`. | + +Every write command accepts `--dry-run` (prints a unified diff, writes nothing) and ends by +comparing the full check before and after: a change that adds a problem is refused and +nothing is written; problems that were there before do not block other edits. + +Details: + +- **Pick rule (`next`)**: first Pending item, in file order, that has no `(blocked: …)` and + whose `After:` ids are all in the archive. Items it skipped are listed in one line each + with the reason. +- **Ref resolution (`ctx`, `next`)**: `path#anchor` → the section under the heading whose + slug or explicit `<a id>` equals the anchor, up to the next heading of the same or higher + level, cut at 80 lines with a `… (N more lines: <path>:<line>)` note. `path` alone → the + file's first heading and its `**Goal:**` line if it has one. With `[anchors]` set, an + index anchor also prints the spec section that carries the same `<a id>`. +- **Context hint (`next`, `done`)**: with `CLAUDE_CODE_SESSION_ID` set, the last request's prompt size + (input + cache write + cache read) from the last 1 MB of `<CLAUDE_CONFIG_DIR|~/.claude>/projects/*/<id>.jsonl`; + over `ctx_hint` → last line `context ~Nk tokens (> Mk): ask the owner to /clear, then continue`. No id, + no transcript → nothing. Subagents share the id, so the line says it is about the main session. +- **Ledgers (`next`)**: for each `<ledgers>/*/progress.md`: plan path from its first line, + done tasks, where to resume, count of `Ruling:` lines. +- **Insert rule (`add`, `prio`, `move <section>`)**: position in the section = after the + last item of the same or higher priority, and after every item named in its `After:`. + Body is re-indented to two spaces. A block given to `add -` must carry an id and, for + tasks, a priority; a leading `N.` from the old format is refused with a message. +- **Id from title (`add`)**: `t-` (`a-` for Awaiting) + slug of the title (the text up to + the first `. `), at most 40 characters, cut at a word; if taken (TASKS.md or archive), `-2`, `-3`, … is appended. +- **Search ranking**: words are matched case-insensitively as whole words or word prefixes. + Score per hit = words matched × weight of where (id or title 5, doc heading 4, header + goal 3, `Ref:`/body/doc text 1); all words matched ranks before some. Ties: open tasks, + then docs, then archive. No index file; everything is read per call. +- **Exact ids only** in write commands; a unique id prefix is accepted by read commands. + An unknown id prints the three nearest ids. +- **`done`** refuses when the id is unknown or is a parent with open slices. On an `a-` id + it removes the item without an archive line (`-m` is optional there) and clears the + `(blocked: [[a-id]])` status of the tasks that waited on it, listing them. +- **`check`** errors: + - duplicate id; id reused from the archive; bad id grammar; id prefix not allowed in its section + - task without priority or with an effort outside the vocabulary + - `[[id]]` that is neither in TASKS.md nor in the archive + - `(blocked: [[x]])` where `x` is not an open `a-` item + - `After:` cycle; Pending item placed before an open item it is `After:` + - `Ref:` path missing; anchor missing in that file + - old-format leftovers: numbered items + - item after a flush-left prose line inside a task section; malformed item header + - with `[anchors]`: task anchor without index heading; two index headings with the same + slug; index link to a missing file or anchor; explicit spec `<a id>` without index entry + - config: missing required key, unknown key, configured path that does not exist + In `docs` only `[[t-…]]` / `[[a-…]]` links are validated (other `[[x]]` may be the + project's own wiki links). + Warnings (exit stays 0): `#N` number refs in TASKS.md; task in progress with no branch/note; Awaiting item that no task + references and that is older than 30 days by `git blame`. +- **Writes** are whole-file: read, change in memory, write to a temp file in the same + directory, rename. If the file changed on disk between read and write (mtime + size), the + command stops without writing. `done` writes the archive first, then TASKS.md; if the + second write fails it reports that the archive line must be removed. +- **Errors** go to stderr as one line each, naming file and id; no tracebacks for expected + failures. + +## 6. Shared CLAUDE.md + +Text: `shared/CLAUDE.md` (the file is the authority). Sections: Style (concise everywhere; +compaction summaries, specs, plans stay full and exact), Cold start, Loop, Done, TASKS.md, +Context economy, Memory / CPU, Workflow problems. + +## 7. Improving the workflow without disturbing workers + +Problem: `wf.py` and the shared CLAUDE.md are live for every worker the moment they change. +A worker that edits them mid-task, or a half-finished change, would hit all the others. + +**Report (any worker, any time)** +- `wf report` appends to `inbox.md` with a single `O_APPEND` write, so parallel workers + never clash and nobody waits. Entry: date, project, kind, the text, the command if given, + wf version (git short hash). + `- 2026-09-29 proj-a friction: prio change needs two commands (cmd: wf prio …) @84decd1` +- The worker then works around the problem and continues its own task. It does not edit + anything under `/projects/public/workflow` and does not wait for a fix. +- `wf next` in any project prints one line when the inbox has entries, for the user's eyes: + `workflow inbox: 3 reports (triage: workflow session)`. + +**Triage and fix (a workflow session, started by the owner)** +- The workflow's own tasks live in a wf project of their own (e.g. a private + `/projects/workflow`), whose CLAUDE.md holds the rules of this section. Cold start there: + each inbox entry becomes a task (`wf add`, duplicates merged) or is rejected with a line in the + archive; the entry is then deleted from `inbox.md`. `inbox.md` is git-ignored; tasks and + archive carry the record. Bookkeeping there changes nothing a worker runs. +- Changes to `wf.py`, `wflib/`, `shared/CLAUDE.md` or templates happen in a git worktree + `/projects/public/workflow/.worktrees/<topic>` on a branch, never in the main tree. Workers keep + running the main tree's `master` meanwhile. +- Release = fast-forward merge to `master` in the main tree after: tests green, and + `wf check` run with the new code against every project listed by `wf projects` gives the + same result as before the change (or differences that are the intended ones). +- The user starts workflow sessions and sees the release; no worker does it on its own. + +**Compatibility rules for a release** +- Commands and options are only added. Renaming or removing one keeps the old spelling + working for one release, printing a one-line notice with the new spelling. +- A change to the TASKS.md format raises `format` and ships with a `wf migrate` step. + `wf` run in a project with an older `format` refuses write commands with + `TASKS format 1, wf needs 2: run wf migrate --write (idle project, one commit)`; read + commands keep working on the old format. +- Shared CLAUDE.md text changes reach a worker at its next session start; a rule change + must therefore not make files written under the old rule invalid. +- Every release adds a line to `CHANGES.md` (newest first): date, what changed, what a + project has to do, if anything. + +## 8. Testing + +- `wflib` functions are pure (text in, text out); unit tests use literal before/after texts + written by hand, never output of the code under test. +- CLI tests run `wf.py` with `--project <tempdir>` on fixture projects: pick rule, skip + reasons, insert position with priority and `After:`, `done` refusals, every `check` error + once, write-conflict stop, `list` filters, `prio`/`move` placement and refusals, `status`, + `set`, `note`, `body`, `tick`, search ranking order on a fixed corpus, `report` from two + processes at once (both entries whole), `format` gate, migrate on a fixture with the known oddities (stray `N.`, + `N/M` titles, `After:` by title, prose after items). +- Each new test is seen failing first, and again with the tested line broken by hand. diff --git a/docs/lanes.md b/docs/lanes.md new file mode 100644 index 0000000..2fc6833 --- /dev/null +++ b/docs/lanes.md @@ -0,0 +1,115 @@ +# Lanes, Model line, slice jobs, area maps + +Reference (released 2026-10-05). Lanes split work by task **size** and **pick policy**: a fast lane clears +small, unblocking work and slices big tasks; a slow lane takes 1h slices; more lanes by config. The model is +a per-task attribute (`Model:` line): interactive sessions take tasks with Model ≤ their own, orchestrated +workers run on the task's Model. Area maps (code map + test recipe per area) are checked and refreshed by +auto-added tasks. + +## 0. The Model line +- Task body line ` Model: haiku|sonnet|opus` (2-space indent like `Ref:`; `- Model:` also parsed), in the + body tail before `After:` / `Ref:`. No line → `opus`. Order haiku < sonnet < opus. +- Set by the task's writer at `wf add --model M`; `wf set ID --model M`; `--model ""` removes it (→ opus). + - `haiku`: mechanical edit, exact instructions, no judgement. + - `sonnet`: exact Done criteria + an existing pattern to copy + a real-data pass/fail test. + - `opus`: design forks, debugging, reverse engineering / oracles, cross-source analysis, anything unclear. +- Wrong level found mid-task: `wf set ID --model <higher>` + `wf note ID "<why>"` + `wf status ID clear`. +- `wf check`: unknown value or two Model lines → error; extra words after the value → warning; missing → fine. + + +## 1. Lane config +`workflow.toml`, optional; absent → built-in default equal to: +```toml +slice_above = "1h" # effort above this → slice job (§3); one of EFFORTS + +[lanes.fast] +efforts = ["<1h"] # efforts this lane implements +order = "unblock" # pick policy (§2) +slices = true # this lane also takes slice jobs (exactly one lane may set it) + +[lanes.slow] +efforts = ["1h"] +order = "priority" +fallback = "fast" # nothing pickable here → pick from that lane +``` +- Lane name: `[a-z][a-z0-9-]*`; used in worktree `.worktrees/<lane>`, branch `<lane>/<id>`, registry file. +- `efforts`: subset of `EFFORTS`, ≤ `slice_above`; lanes' efforts are disjoint; every effort ≤ `slice_above` + belongs to some lane. Tasks without effort → the lane holding `<1h`. +- `order`: `unblock` | `priority`. `fallback`: another lane name, optional, no cycles. +- `slices = true` on exactly one lane; default: the lane holding `<1h`. +- Validation errors in `config.load` (unknown keys are errors). Defining `[lanes.*]` replaces the + whole default set (no merge). +- Extra lanes: add tables (e.g. `[lanes.long] efforts = ["5h"]` with `slice_above = "5h"`). Several workers in + one lane = existing `<lane>-2`, `-3` worktrees. + +## 2. Pick order +Pickable set (Pending, not blocked, no open slices, all `After:` archived, no live foreign claim), filtered +to the lane's efforts (plus slice jobs on the `slices` lane) and to Model ≤ session model (interactive only). +Effective priority = best (lowest P) of the task's own and that of every open task waiting on it, +transitively ("waits on" = `After: [[x]]`, or a parent waiting on its open slices). Keys: +- `unblock`: (1) waited-on count > 0 first, (2) more open tasks waiting on it (transitively) first, + (3) effective priority, (4) file order. +- `priority`: (1) effective priority, (2) waited on by another lane first, (3) waited-on count, + (4) file order (section order, then position). + +## 3. Slice jobs +- A Pending task with effort > `slice_above` and no open slices = slice job. It is not pickable as an + implementation task in any lane; it is pickable in the `slices` lane, ranked by that lane's order. +- Shown with `slice:` prefix in `wf next`, `wf list --runner`, `wf lanes` counts (`2 pickable (1 slice)`). +- `wf next` prints for it: `Slice job: wf add "<title>" -e <1h|1h> --parent <id> --model M` per slice, each + with Steps/Done/Ref; no code; `wf note <id> "sliced into …"`; `wf status <id> clear`. Not `wf done` (parent + stays open until its slices are done). +- Model of a slice job = the task's Model (default opus; slicing is judgement). +- A task with open slices is never a slice job (already sliced). All slices done → parent pickable + only if its effort ≤ `slice_above`; otherwise `wf done` of the last slice prints `parent <id>: all slices done + → wf done <id> or add slices` (it is not offered as a slice job again while it has done slices: rule = no + open slices AND no archived slices). + +## 4. Sessions, registry, model +- `wf next [--lane L] [--as M]`: `--as` = the session's model (haiku|sonnet|opus); none → haiku, with a + hint line. `--lane` = lane name; no `--lane` → all lanes merged, `priority` order, slice jobs included. +- Registry `.wf/sessions/<lane>.json` (`{"lane", "model", "socket", "pid", "session", "at"}`, from env + `CLAUDE_CODE_MESSAGING_SOCKET`, `CLAUDE_PID`, `CLAUDE_CODE_SESSION_ID`; skipped without them); no `--lane` + → `all.json` (`all` is reserved). `.wf/.gitignore` = `*`. Newest wins per lane unless another live session + holds it: then kept, and `wf next` first prints `another live <lane> session holds this lane: uds:…`. + Alive = pid and socket file exist. `wf lanes --unregister` drops this session's records. +- Claims: `wf status ID progress …` writes `.wf/claims/ID.json` (`{"id", "lane", "model", "socket", "pid", + "session", "at"}`); `status clear|blocked` and `wf done` remove it. `wf next` skips a task whose claim is + alive, not mine and still `in progress`. +- `wf lanes [--lane L]`: one line per configured lane: + `fast: 3 pickable (1 slice) · 1 waiting · session uds:… (alive)` / `… · no session`. +- Waiting block: `t-y waits on t-x (slow lane) → message uds:…: "t-x blocks my t-y, please take it"` / + `… → no slow session: tell the owner` — lane of a task = lane by effort (slice lane for slice jobs). +- `wf done`: tasks of other lanes that became pickable → `notify <lane> uds:…: now pickable t-y` / + `<lane> work now pickable: t-y (no session: tell the owner)`. +- Messages go with the agents' SendMessage tool, `to` = `uds:<socket>`. +- `wf list`: model and lane columns; filters `--model M`, `--lane L`; `--runner --lane L` = ids in pick order + for that lane (no session model filter; the orchestrator spawns per task model). + +## 5. Orchestrator, batch, worker +- `docs/orchestrator.md`, `shared/skills/wf-orchestrate`: lanes from `wf lanes`; per lane + `wf list --runner --lane <lane>` → first id; spawn `wf-worker` with `model: <task's Model>`; worktree + `.worktrees/<lane>`, branch `<lane>/<id>`. At most one worker per lane worktree; lanes in parallel. +- `wf batch N [--lanes fast,slow]` (default: all configured lanes). +- `shared/agents/wf-worker.md`: prompt line `Lane: <lane> Model: <model>`; slice job → slice only, no code. +- `wf usage --log … --lane L`: cost line per worker; `--report` rows by lane × model × effort. + +## 6. Area maps +- Notes file: config `areas = "<path>"`, default project `CLAUDE.md`. Section `## Areas`, one `### <area>` each + with lines (all optional): + - `- Code map: …` — anchors = backticked tokens (`` `parse_item` ``). + - `- Test recipe: …` + - `- Paths: <globs, space-separated>` — default: whole repo. + - `- Checked: <short sha>` — last refresh; set by `wf areas --mark`. +- `wf areas [AREA]`: per area: missing anchors (`git grep -F -q <token> -- <paths>` fails), commits touching + Paths since Checked (`git rev-list --count <sha>..HEAD -- <paths>`), stale yes/no. Exit 0. +- `wf areas --mark AREA`: write `Checked: <HEAD short sha>` into the section (add the line if missing). +- Stale = any missing anchor, or Checked set and commits ≥ `area_stale_commits` (config, default 20). + No Checked → only the anchor test. +- `wf check`: missing anchor → warning `area <a>: anchor <tok> not found`. Unknown Checked sha → warning. +- `wf done`: after bookkeeping, for each stale area with no open task whose id is `t-map-<slug>` → add + `t-map-<slug>` P2 `<1h` Model: sonnet, title `Refresh <area> area map`, Steps: `wf areas <area>`; fix missing + anchors and test recipe from changed files (`git log --stat <Checked>..HEAD -- <paths>`); `wf areas --mark + <area>`. Done: `wf areas <area>` shows not stale. Printed `added t-map-<slug> (area map stale)`. + Id taken by an archived task → `t-map-<slug>-2`, … (ids never reused). +- No `## Areas` section → feature silent. Template `templates/CLAUDE.md` area block has `Paths:`/`Checked:`. diff --git a/docs/manual.md b/docs/manual.md new file mode 100644 index 0000000..2b4594b --- /dev/null +++ b/docs/manual.md @@ -0,0 +1,239 @@ +# wf — owner's manual + +How to set up and run `wf` day to day. The README tells you what it is and why it exists. The +reference docs give the details: [design.md](design.md) (format, config, every command), +[orchestrator.md](orchestrator.md), [lanes.md](lanes.md), [multi-session.md](multi-session.md), +[resource-ledger.md](resource-ledger.md). Every command below also has `wf <command> -h`. + +This is still a personal setup. The manual describes how it runs on the author's machine. It is +not a supported product. + +## 1. Setup + +### Requirements +- Linux with systemd. `wf res` needs systemd user units and cgroup v2. Everything else only needs a shell. +- Python ≥ 3.11 (standard library only, nothing to `pip install`). +- git, and [Claude Code](https://claude.com/claude-code) (`claude` on `PATH`). +- Optional: the `superpowers` Claude Code plugin. The shared rules refer to its brainstorming, + debugging and plan-writing skills. Without it, agents just follow the plain rules. + +### One root folder +All projects live under one folder. The shipped rules, skills and agent file use the absolute +path `/projects` (for example `python3 /projects/public/workflow/wf.py`). Keep that path: create it once +and own it, or point it at a folder in your home directory. + +```sh +sudo mkdir /projects && sudo chown "$USER": /projects # or: sudo ln -s ~/projects /projects +git clone <this repo> /projects/public/workflow +ln -s workflow/shared/CLAUDE.md /projects/CLAUDE.md # every agent under /projects loads it +echo "alias wf='python3 /projects/public/workflow/wf.py'" >> ~/.bashrc +``` + +Claude Code reads `CLAUDE.md` from the working directory and from every parent folder, so every +session in `/projects/<anything>` gets the shared rules. If the symlink is not picked up, use a +one-line `/projects/CLAUDE.md` with the content `@workflow/shared/CLAUDE.md` instead. +`wf projects` scans the parent folder of the tool repo, or its grandparent when the parent holds no project (tool under e.g. `public/`; override: `WF_ROOT`). Workflow reports +go to `/projects/public/workflow/inbox.md` (override: `WF_INBOX`). + +### Agent and skills +The orchestrator spawns the worker subagent. The skills are the session modes in section 2. +Install them as symlinks, so that `git pull` updates them: + +```sh +mkdir -p ~/.claude/agents ~/.claude/skills +ln -s /projects/public/workflow/shared/agents/wf-worker.md ~/.claude/agents/wf-worker.md +for s in wf-orchestrate wf-pilot wf-design; do ln -s /projects/public/workflow/shared/skills/$s ~/.claude/skills/; done +``` + +Agents and skills appear only in sessions started after the install, so restart any running session. + +### A project +```sh +cd /projects/games/mygame # any depth ≤ 3 under /projects; a git repo is recommended +wf init # writes workflow.toml, TASKS.md, tasks/archive.md, CLAUDE.md (keeps existing files) +wf check # 0 errors +``` +If the project already has a numbered task list, `wf migrate` shows the conversion (dry run) and +`wf migrate --write` applies it. + +Edit the generated `CLAUDE.md`: layout, verify command, gotchas and one `### <area>` block per +code area (code map with grep anchors, test recipe, paths). Agents read these area notes before +they read code. + +`workflow.toml` (the template lists every key with a comment; unknown keys are errors): + +| Key | What it does | +|---|---| +| `tasks`, `archive` | file names (defaults `TASKS.md`, `tasks/archive.md`) | +| `docs` | files/folders whose `[[id]]` links `wf check` validates | +| `verify` | commands `wf done` reminds of (it never runs them) and `wf ctx` prints as Verify | +| `done` | extra checklist lines `wf done` prints | +| `quick_gate` | fast regression check; `wf gate` and `wf finish` run it and refuse when it is red | +| `worktree_setup` | idempotent commands `wf setup` runs in a fresh lane worktree (link git-ignored data, venv…; env `WF_MAIN` = main tree) | +| `slice_above`, `[lanes.*]` | task sizes per lane and when a task must be sliced (default lanes: fast `<1h`, slow `1h`); see lanes.md | +| `areas`, `code_root`, `area_stale_commits`, `area_ignore` | where the area notes live and when they count as stale | +| `ledgers`, `ctx_hint`, `[anchors]` | in-flight plan ledgers, `/clear` hint, required Ref anchors | +| `cloud`, `cloud_include`, `cloud_note` | cloud lane opt-in (below) | + +Add `out/` to the project's `.gitignore`: logs, batch summaries and scratch go there. + +### Resource ledger (`wf res`) +Optional, but needed once several agents build or test at the same time. + +```sh +wf res shell-init # prints an alias that starts claude inside agents.slice: add it to ~/.bashrc +wf res timer on # 1-minute timer: starts queued jobs, ends game mode, cleans scratch, adopts sessions +wf res status # budget, reservations, warnings +``` +Reserves and limits go in `~/.config/wf/resources.toml` (all keys optional: `user_reserve_gb`, +`user_reserve_cpus`, `game_reserve_gb`, `game_reserve_cpus`, `game_hours`, `small_headroom_gb`, +`scratch_hours`; see resource-ledger.md §3.3). Optional hook that hides the display from agent +commands while game mode is on, in `~/.claude/settings.json`: + +```json +"hooks": {"PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", + "command": "python3 /projects/public/workflow/wf_res.py hook"}]}]} +``` + +`wf batch` (and `/wf-pilot`) need one allow rule in `~/.claude/settings.json`, because auto mode +does not let an agent start another unattended agent: `Bash(python3 /projects/public/workflow/wf.py batch:*)`. + +### Cloud lane (optional) +Tasks can also run as `claude --cloud` sessions, which are billed to your cloud budget, not your +machine. You need a Claude Code version with `--cloud` / `--teleport` and a claude.ai login. +Per project: `cloud = true` in `workflow.toml`, plus `cloud_include` for git-ignored inputs the +task needs. Set the spending cap once with `wf cloud ledger --budget 100` and check it with +`wf cloud ledger`. Only opus tasks marked `Cloud: yes` (or passing the fit rules) are sent. See +orchestrator.md "Cloud lane". + +## 2. Daily use + +There are four kinds of session. Start each one in the project folder with `claude` (the +`agents.slice` alias if you set it up). + +### Worker session: "continue" +Type `continue` (or "next task"). The agent runs `wf next --as <its model>`. It shows open +questions, items that need you, plans in flight and the next task, then works on that task: +test red → implement → green → `wf done` → one commit. It does not ask before it starts. +Several sessions in one project: give each a lane ("continue in the slow lane"). `wf next` then +switches them to worktree mode (one branch per task, `wf finish` merges it). One session per lane. + +### Orchestrator: `/wf-orchestrate` +After `/clear`, type `/wf-orchestrate` (`go` = start the proposed run without asking, +`batch N` = prepare an unattended batch). The orchestrator holds no lane and never reads code. +It spawns one fresh `wf-worker` per task (`wf orch pick` / `wf orch post`), one per lane in +parallel, on the task's model, and stops a lane on the first awaiting, handback or red +post-check. Use it when the backlog is runner-ready (section 3) and you want to steer. + +### Overnight: `/wf-pilot` +`/wf-pilot [--batch 4] [--lanes a,b] [--for 8h]` in an idle session, which can be one you opened +from the phone. The pilot starts headless `wf batch` runs one after another until the deadline. +The session stays small, and you get push notifications on new questions, on stops and at the +end. Steering: `wf batch --stop` (graceful, running workers finish), `wf move <id> deferred` +(skip a task), `wf batch --status` (newest batch and its summary). A one-off short run without a +pilot is `wf batch N [--lanes …]`. `wf batch N --prep` makes tasks without a Done line +runner-ready instead. + +### Design: `/wf-design` +`/wf-design [topic | id]` is for brainstorming, specs and rulings with you. Flow: brainstorm → +spec (committed) → your approval → `wf add` slices ≤ 1h with Done, Model and Ref → the +orchestrator picks them up. It answers open questions with you and records each ruling where it +binds. + +### Your side +- **Awaiting** (`wf list -s awaiting`): questions agents could not decide. Answer them in a + design or orchestrator session. The session records the ruling and runs `wf done <a-id>`, + which unblocks the waiting tasks. +- **Needs human** (`wf list -s human`): things only you can do (play-test, hardware, accounts). + Check boxes with `wf tick <id> <n>`. Done → tell any session ("t-x is done") or run `wf done`. + Tasks marked `Sessions: owner` are picked only when you say you are present (`wf next --owner`). +- **Stop file**: `wf batch --stop` writes `out/wf-batch.stop`. Batches, pilots and + `wf lanes --wait` stop before the next spawn. +- **Game mode**: `wf res game on [--for 4h]` before you play. Agents keep running but get less + CPU and IO, and big jobs that do not fit wait. `wf res game off` when you are done. +- **Overview**: `wf projects` (all projects), `wf lanes`, `wf log -n 10`, `wf res status`. +- **Workflow inbox**: agents file problems with `wf report`. Triage `/projects/public/workflow/inbox.md` + now and then: fix the tool, or turn entries into tasks. + +## 3. Writing good tasks + +```sh +wf add "Cave seams. Close the slit beside the lintel." -p 1 -e '<1h' --model sonnet \ + --done "tests/test_cave.py::test_seam passes; render of fixture shows no gap" --ref DESIGN.md#terrain +``` + +- **Title. Goal.** One line: the title, then the outcome in one sentence. +- **Done line** (`--done`): a check a worker can run without you, such as a file, a test result + or an artifact. Never "owner confirms". Only tasks with a Done line are runner-ready: the + orchestrator, batch and pilot skip the others. (`wf batch --prep` drafts them.) +- **Model line** (`--model`): the cheapest model that fits. haiku = mechanical, exact steps. + sonnet = exact Done, an existing pattern to copy, and a real-data pass/fail. opus (the default + when the line is missing) = design, debugging, reverse engineering, anything unclear. A worker + that finds the task too hard raises it itself and hands it back. +- **Effort** (`-e`): `<1h`, `1h`, `5h`, `10h`, `100h`. Anything above `slice_above` (1h) becomes a + slice job: an agent splits it into `--parent` slices, each with its own Done/Model, and never + implements it whole. Slice big work yourself when you already know the steps. +- **Code anchors**: when you know them, add body lines like + `Code: src/terrain.py build_cave; test to copy: tests/test_cave.py::test_floor`. Write anchors + (function or string names), not line numbers. They save the worker most of its exploring. +- **Refs and dependencies**: `--ref doc.md#heading` makes `wf ctx` print that section; + `--after t-other` keeps it unpicked until `t-other` is done. +- **Areas**: keep each area's code map and test recipe in the project `CLAUDE.md`. `wf ctx` + prints the areas a task names. When code drifts, `wf done` adds a `t-map-…` refresh task + automatically. +- **Cloud** (cloud projects): `--cloud yes` only when the Done line can be checked from the repo + alone (no GUI, live server, LAN, local installs). When unsure, `no`. +- **Sessions**: `--sessions solo` for repo-wide changes (nothing else runs meanwhile), + `--sessions owner` when you must be there. + +## 4. Best practices and pitfalls + +- **One lane per session**, at most one worker per lane worktree. Parallel lanes are safe + (project lock, branch per task). Two sessions in one lane pick against each other. +- **Fresh context per task.** Cost is mostly cache reads, which grow with context. Short-lived + workers on the cheapest fitting model are cheaper than one long session. `/clear` the + orchestrator after a big design talk, but only when no worker is running (a `/clear` kills its + running command). +- **Big jobs through `wf res`**: anything over 2 GB, 4 cores or 10 minutes goes through + `wf res run --mem … --for … --title … -- cmd`. Exit 3 = busy: do other work and retry later, + or use `--queue`. `wf res hist` suggests sizes from past runs. +- **Never hand-edit TASKS.md** while agents run. Use `wf add/set/note/move/done`. If you must + edit by hand, run `wf check` afterwards. +- **Commit ≠ merge ≠ push.** Agents commit their own task. Workers ff-merge only their own task + branch. Pushing goes only to a remote named `home` (`git push home --all`), and nothing else is + pushed unless you ask. +- **Close the loop on problems.** Agents work around a workflow problem and `wf report` it. + Triaging the inbox is how the rules get better. Put project-only needs in the project's + `CLAUDE.md` or `workflow.toml`, not in the shared rules. +- **Watch cost**: `wf usage` (this session per agent), `wf usage --report` (per lane, model and + effort across every project's `out/wf-cost.log`). Expensive lanes usually mean vague Done lines + or a model that is too large. +- **Model choice**: run the orchestrator and design sessions on opus. Tasks get the cheapest + model that fits. A sonnet task without an exact Done line comes back as a handback. +- **Updating the tool**: `git -C /projects/public/workflow pull`. Read the top of `CHANGES.md`: each line + ends with "Projects: …", which says what a project must do (usually nothing). +- **Publishing a public copy**: keep the tool repo private (its history names your projects) and + share a fresh-history snapshot instead. Put your private words (project names, home paths), one + per line, in `publish-denylist.local` in the tool repo (git-ignored), then run + `python3 scripts/publish_snapshot.py [DEST]` (default `~/src/wf-public`). It copies master's + tree without `inbox.md`, `.worktrees/`, `__pycache__/`, `out/`, refuses if any denylist word is + in a path or file, and commits once per run (first: "initial public snapshot"; later: the new + `CHANGES.md` lines). It never pushes and never adds a remote: review DEST, then add the public + remote and `git push` there yourself. + +## 5. Troubleshooting + +| Symptom | Cause / fix | +|---|---| +| `busy: …` and exit 3 from `wf res run/note` | Not enough memory or CPUs beyond your reserve. Retry after the printed time, add `--queue`, or check `wf res status` for stale entries (`wf res release r-N`). If memory really is free (unused reservations), `--force` fits against real free memory. | +| `wf orch post` says post-check red | The worker said done, but the archive line is missing, the branch is still there, the worktree is dirty, or `wf check` has errors. The message names the problem. Fix it in the worktree (`wf merge`, commit or clean), then post again. | +| Task stuck `in progress` though no one works on it | A session or worker died. `wf list --stale` shows them, and `wf status --clear-stale` clears every claim without a live session (or `wf status <id> clear`). Re-run the worker with `wf orch pick <lane> --id <id> --recovery "<why>"` to keep its WIP. | +| `wf start` refuses: worktree has uncommitted changes | A dead worker left WIP. `--recovery` keeps it and shows it. Otherwise commit or reset it in that worktree. | +| `branch <lane>/<id> exists (local WIP?)` (cloud pull) | A local branch for that task already exists. Merge or delete it, then `wf cloud pull <id>` again. The cloud session is kept. | +| `wf cloud pull` exit 4 | The session is still running. Pull again later; after 24 h without a result it counts as lost. | +| `wf cloud pull` handback / bad patch | The task goes back to its local lane with a `Recovery: cloud attempt …` note. The patch is kept in `out/cloud/<id>.patch`. | +| `wf merge`: rebase conflict | The branch is untouched. `git rebase master`, resolve, run verify, `wf merge` again. | +| `wf finish` refused | The quick gate is red, files outside the given paths are uncommitted, or a path is outside the repo, missing or unchanged. Fix it and rerun. If it fails after `done:`, a rerun resumes. | +| A worker or batch does nothing | `wf list --runner` is empty: tasks lack a Done line (`wf batch --prep`), are blocked on an awaiting item, or wait on `After:`. `wf lanes` shows counts per lane. | +| `wf check` errors after a hand edit | Read the message (duplicate id, broken `[[link]]`, bad section) and fix the item. Ids are never reused. | +| `project format N is newer than this wf` | Update the tool: `git -C /projects/public/workflow pull`. | diff --git a/docs/multi-session.md b/docs/multi-session.md new file mode 100644 index 0000000..628aad9 --- /dev/null +++ b/docs/multi-session.md @@ -0,0 +1,46 @@ +# Multi-session: several sessions in one project + +Approved 2026-10-04. Problem: one work tree + `master` shared by sessions → a commit sweeps the +other's edits (`git add -A`), half-done code breaks the other's tests, two sessions of a lane pick +the same task. + +## 1. Claims +- `wf status ID progress …` writes `.wf/claims/ID.json` (who: socket, pid, lane, model); details in + `lanes.md` §4. `wf next` skips a task claimed by another live session. +- A second live session of a lane: `wf next` warns on its first line; the registry keeps the first one. + +- Task line `Sessions: solo` → picked only while no other session is live; while it is in progress + (claimed) every other session's `wf next` picks nothing (exit 1, names the holder); its `wf done` + prints `notify <lane> uds:…: solo <id> done, run wf next` for each other live session. + `Sessions: owner` → picked only by `wf next --owner` (owner present). + +## 2. Worktree mode (only while > 1 session is live, git projects) +- `wf next` prints `===== Multi-session =====`: from the main tree + `git worktree add .worktrees/<lane> -b <lane>/<task> master` (or `cd .worktrees/<lane> && git switch -c <lane>/<task> master` + when it exists), `&& wf setup` appended when workflow.toml has `worktree_setup`; inside a worktree a + reminder of where you are. +- `wf setup` (in a worktree) runs `worktree_setup` (cwd = worktree, env `WF_MAIN` = main tree): links / + copies git-ignored inputs (`out/…`). Idempotent; run after every worktree create/reuse. +- wf run inside a linked git worktree uses the main tree's project (same relative folder, when it + has `workflow.toml`): TASKS.md, archive, docs, `.wf/` are the main tree's. Bookkeeping never + conflicts; branches never touch TASKS.md. `wf check` there checks the main tree. +- One session alone: as before, on `master`. + +## 3. Done in worktree mode +`wf done` run inside a linked worktree prints, after verify/checklist: commit code (explicit paths), then +`wf merge`. `wf merge` (under the project lock, `.wf/lock`): worktree must be clean → `git rebase master` +(conflict → abort, branch untouched: rebase by hand, resolve, verify, `wf merge` again) → `merge --ff-only` +into the main tree → commit TASKS.md + archive there (`<id> done`, id from the branch `<lane>/<id>`) → +detach the worktree, delete the branch → push home if the remote exists (`--no-push` skips). +The ff-merge of one's own task branch is a standing owner permission (shared CLAUDE.md); never other branches. +`wf finish <id> -m ENTRY [--commit MSG PATH…]` does it in one call (workers): refuses first if files outside +PATHS are uncommitted or quick_gate is red (task stays open) → `wf done` → commit PATHS → `wf merge`. In a main tree +(no worktree) TASKS.md + archive join that commit and nothing is merged. Verify runs before it (`wf ctx` prints it). +Next task: new branch from master in the same worktree. + +## 3a. Project lock +Every wf write command and `wf merge` run under an exclusive flock on `.wf/lock` (main tree), so parallel +sessions/workers never lose a TASKS.md edit or race a merge-back. + +## 4. Commits +Shared rule: stage explicit paths, never `git add -A` / `git commit -a`. diff --git a/docs/orchestrator.md b/docs/orchestrator.md new file mode 100644 index 0000000..845c527 --- /dev/null +++ b/docs/orchestrator.md @@ -0,0 +1,152 @@ +# Orchestrator: one session talks to the owner, fresh workers do the tasks + +Why: cost is dominated by cache reads, which grow with context size. A fresh worker per task costs about +as much less as a cheaper model does; researching in one context and implementing in another is NOT cheaper +(rediscovery, handbacks). So: the orchestrator slices and rules, and one fresh worker per task researches +and implements. + +## Sessions +- **Orchestrator** (interactive, opus, `claude --autocompact 120k`): owner talk about runs, Awaiting rulings, + slicing, spawning workers, post-checks, unattended batches. Reads `wf` output and worker reports, never + code. Holds no lane: never `wf next --as` (use `wf lanes`, `wf list --runner --lane <lane>`). +- **Design session** (optional, big window): brainstorming, specs, hard rulings. Ends each topic with a + committed spec/task; the orchestrator picks it up from TASKS.md. +- **Workers**: the `wf-worker` subagent (`shared/agents/wf-worker.md`; install: + `ln -s /projects/public/workflow/shared/agents/wf-worker.md ~/.claude/agents/wf-worker.md`), one task each. + +## Starting a session +Skills (install once: `ln -s /projects/public/workflow/shared/skills/wf-orchestrate ~/.claude/skills/` and the same for +`wf-design` and `wf-pilot`): after `/clear`, type `/wf-orchestrate [go | batch N]`, `/wf-pilot [--batch 4] [--lanes a,b] [--for 8h]` or `/wf-design [topic | id]` (not +"continue": that is the normal worker cold start). Agents and skills installed while a session runs appear +only in sessions started afterwards: restart it. + +## Runner-ready tasks (slicing rule) +- `Model:` line set to the cheapest lane that fits (sonnet: exact Done + a pattern to copy; haiku: mechanical). +- Done checkable headless: a file, test result or artifact, plus `wf add` for follow-ups. Never "report to + owner" / "owner confirms". +- `After:` satisfied, no `Sessions: owner`. `Sessions: solo` → run it alone. + +## One worker +Two commands do steps 1-3 and 5-7 (one call each): `wf orch pick <lane> [--id ID] [--recovery WHY]` (stop file, +pick, claim, free worktree, recorded in `.wf/orch/<id>.json`, prints the spawn line and the prompt below) and +`wf orch post <id> <lane> --result "<report result line>" [--commit SHA] [--agent A] [--duration S] [--no-pick]` +(post-check, merge if needed, leftover commit, orch + cost log lines, then the next pick or `stop lane <lane>: <why>`). +The steps they automate: + +1. Pick: `wf list --runner --lane <lane>` → first id (lanes: `wf lanes`). Claim: `wf status <id> progress "worker"`. No commit of + its own: `wf merge` or any later bookkeeping commit takes TASKS.md (and the claim) along; harmless. +2. Worktree: `<main>/.worktrees/<lane>`; if a live session or another worker uses it, `<lane>-2`, `-3`, … + (live interactive sessions: `~/.claude/sessions/*.json` field `cwd`). Branch `<lane>/<id>`. +3. Spawn: Agent tool, `subagent_type: wf-worker`, `model: <task Model, none = opus>`, background, no worktree isolation + (it blocks `wf merge` on the main checkout). Prompt: + + ``` + Task: <id> Lane: <lane> Model: <model> + Main tree: <main> Worktree: <path> Branch: <lane>/<id> + Final message: the 4 report lines only. + ``` + (the last line is needed: the agent rule alone did not stop prose before the report.) + Slice job (row starts `slice:` / `wf show` effort > slice_above): same spawn; the worker only slices. + (no `wf ctx` output in the prompt: the worker fetches it; the prompt stays in your context forever.) +4. At most one worker per lane at a time; lanes in parallel. `wf` writes and `wf merge` take the project + lock (`.wf/lock`), so parallel workers are safe. +5. On completion read only the 4-line report (`followups` = ids the worker added). Post-check: + - `done`: `wf log -n 3` shows the id; `git -C <main> branch --list <lane>/<id>` empty; `wf check` 0 errors; + `git -C <path> status --porcelain` empty; `git -C <path> merge-base --is-ancestor HEAD <master>` exit 0 + (worktree HEAD in master: no impl commit left on a detached HEAD; else `cd <path> && wf merge`). + - Everything else: commit leftover bookkeeping + `git -C <main> commit -m "<id> <outcome> (orchestrator)" -- TASKS.md <archive>` if they changed. +6. Outcome: + | Report / state | Action | + |---|---| + | done + post-check ok | next task in the lane | + | model raised by the worker | continue; next pick spawns it on the new model | + | sliced | next task in the lane | + | done+gate-red <culprit> <fix-id> (async gate red, earlier task's break) | post-check as done; next pick in the lane = the P0 fix (no Done/Model → add them first); never stop the lane | + | awaiting / handback / post-check red / no report | stop the lane, tell the owner | + | wip (after a wrap-up) | stop the lane; reslice or rerun later | + | crashed / killed (no report) | one fresh worker, prompt plus `Recovery: <why>` (it inspects git status, git log master..HEAD, `git diff -- TASKS.md` — often just the claim — and `wf show <id>`), then stop | +7. Log one line per task to `out/wf-orch.log` (git-ignored): time, lane, model, id, outcome, commit, duration. + Cost: `wf usage --agent <agent id> --log <id> <outcome> --effort <task estimate: <1h|1h|5h|10h|100h> --lane <lane>` appends one key=value line to + `out/wf-cost.log` (tokens + API-price $ from the subagent transcript). `--duration <s>` adds dur=; `wf usage --report` (med_dur) = per lane/model and + effort: n, done, median/total $, $ per done task, median turns, over every project's log. + The completion notice's token count is NOT the cost (one worker: notice 67k, transcript 5.0M cache reads); + real usage is in the subagent transcript `~/.claude/projects/<project>/<session>/subagents/agent-*.jsonl` + (`wf usage` per session/agent; one request is several transcript entries: counted once; + subagent transcripts lack final output counts → out estimated from content, shown `~`, log `est=N`). + +## Cloud lane +Virtual lane `cloud` (projects with `cloud = true`; design: cloud-lane spec §4.6): tasks run as +`claude --cloud` sessions billed to the cloud budget, not as local workers. +- `wf orch pick cloud [--id ID]` = stop file → ledger (`wf cloud ledger`) → first fitting task across all lanes + (runner-ready, not in progress, `wf cloud` fit rules; opus only, `Cloud: yes` first, then prio, then larger effort) → `wf cloud send` + (claim `in progress: cloud:<sid>`, record `.wf/orch/<id>.json` lane cloud). Nothing to spawn: the session runs + remotely. Otherwise `stop lane cloud: ledger (…)` (balance < reserve, or send refused) | `none fit (…)` | + `max parallel (…)`. A stop of the cloud lane never stops the local lanes. +- Local lanes skip tasks claimed `cloud:` (every `in progress` task is skipped by `wf orch pick <lane>`). +- On each wake-up, at most every 10 min: `wf cloud pull --all` (teleport ~30 s, no tokens). Per ended task it prints + `<id>: <state> (<sid>, $… )` (state done | awaiting | handback | lost; done also `report: commit <sha>`) → + `wf orch post <id> cloud --result <state> [--commit <sha>]` = the worker-report path (done: archive line, branch + `<task lane>/<id>` gone, `wf check`; else leftover commit of the note/claim pull left), log line, next cloud + pick. `running` (exit 4) → nothing; pull again next wake. awaiting / handback / lost → `stop lane cloud`, tell the + owner; the task is back in its local lane (handback note `Recovery: cloud attempt …`). +- Fill: one `wf orch pick cloud` per free slot (up to `max_parallel`), each in its own call. +- Batch (`wf batch` on a `cloud = true` project) pulls itself: the job runs `wf_res.py batch-sidecar` around + `claude -p`; every 10 min while it lives (until `out/wf-batch.stop`) it runs `wf cloud pull --all` and per ended + task `wf orch post <id> cloud --result <state> [--commit <sha>] --no-pick` + a `cloud <id> <state> <sha> (sidecar pull)` + line in the summary, so pulls never starve while the batch waits on foreground workers. The batch orchestrator + only picks cloud, never pulls. Two pulls of one id at once: the second prints `<id>: pulled by another wf cloud + pull, skipped` (rc 0; flock on `.wf/cloud/<id>.json`). + Tail: the orchestrator exited with `.wf/cloud/*.json` records left (sent at batch end) → the sidecar keeps + pulling every 10 min (no picks, no sends) until no record is left, the stop file appears or 24 h passed; the job's + ledger reservation shrinks to 0.2 GB / 1 cpu for it (ledger only, the unit's MemoryMax stays); the summary ends + with `cloud tail ended: <why>; <n> pulls; left: <ids|none> (sidecar)`. A next batch's sidecar may overlap it (record + flock). + +## Unattended batch (nightly, long runs) +The orchestrator does not babysit long runs; a headless batch orchestrator (separate `claude -p` process, +fresh context per batch) does. The owner starts it (auto mode denies an agent launching another unattended agent): `!` + the command, or +once an allow rule `Bash(python3 /projects/public/workflow/wf.py batch:*)` in `~/.claude/settings.json`. + +``` +wf batch N [--lanes sonnet,haiku] [--model opus] [--for 6h] [--mem 4G] # --dry-run prints the command; default mem/for: wf res hist +wf batch --status # newest job + out/wf-batch-*.md +wf batch K --prep [--lanes fast] # prep batch (below) +``` += `wf res run --mem 4G --for 6h --title wf-batch -- claude -p --model opus --permission-mode auto +--permission-prompts none "<templates/batch-prompt.md>"` with `CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS` = `--for`. +The prompt: follow 'One worker' for up to N tasks, never implement; each round one wf-worker per lane in ONE +message, foreground, post-check, one line per task to `out/wf-batch-<stamp>.md`; stop a lane on its first stop +outcome; never ask (follow-ups → `wf add -p 2`, decisions → `wf add -s awaiting`); end with the ids added. + +Why foreground: `claude -p` ends when the orchestrator's turn ends and kills background workers after a +wait ceiling (default 10 min; the env above raises it as a backstop). Workers spawned in one message run in +parallel and the turn waits for all. Background subagents cannot spawn subagents, so the batch +orchestrator is never a subagent. The interactive orchestrator checks `wf batch --status`. + +Headless batch = short fire-and-forget runs (a few tasks, no steering). Long runs: pilot below. + +Prep batch (`--prep`): makes the backlog runner-ready, never implements. Targets = up to K Pending tasks without +Done, not `Sessions: owner`, no status, no open slices (pickable first, then priority); none → prints `prep: … +nothing started`, starts nothing. Prompt `templates/prep-prompt.md`: sonnet subagents (≤ 3 tasks each) read the +task text, `wf set <id> --done "…"` (+ `--model` if missing); unclear → `wf add -s awaiting` + `wf status <id> +blocked <a-id>`. Summary lines `<id> → Done: …` | `<id> → awaiting <a-id>`; then it commits the task file. +Pilot runs one when idle with `not runner-ready` tasks (once per idle spell). + +## Pilot (nested) +`/wf-pilot [--batch 4] [--lanes a,b] [--for 8h]`, typed into an idle project session (phone via Remote Control), +keeps that session tiny: subagents cannot spawn subagents, so it pilots headless batch orchestrators instead of +workers. Loop: `wf batch K --left <time to deadline>` (a wf res job, returns at once) → background Bash `wf res wait <rid>` → its exit +re-invokes the pilot → `wf batch --status` → one line to its context and `out/wf-orch.log` → next batch. A batch = +up to K tasks, rounds of one worker per lane in parallel (2 lanes, K=4 → two rounds). Nothing pickable → +prep batch if tasks are not runner-ready (once per idle spell), then background `wf lanes --wait 1800`, until `--for`. Deadline fit from history: task p90 = p90 of done/handback durations in `out/wf-orch.log` (local lanes, > 0; < 3 runs → 30 min); `--left` launches K' = min(K, floor(left / p90)) tasks with `--for` = left (K' = 0 → `fit: 0 …; nothing started`, the pilot stops); the batch prompt runs `wf batch --time-left <deadline>` before each round and spawns nothing more on `: stop` (left < p90). Steering: the owner talks to the pilot only; stop → +`wf batch --stop` (stop file, checked before every spawn: latency ≤ the running tasks), skip → `wf move <id> deferred`. Batch rc≠0 or no summary → +retry once, then stop. PushNotification on new awaiting items, on a stop and at run end; each alert also an `ALERT <text>` line in `out/wf-orch.log` and in the summary (push may be off). Phone push needs `agentPushNotifEnabled` (settings) / app notifications on. Needs the allow rule +`Bash(python3 /projects/public/workflow/wf.py batch:*)`. + +## Context +Orchestrator large (after design talk) → finish the topic, commit, ask the owner to `/clear`. State lives in +TASKS.md, the log and the batch summaries. +Safe to clear only with no background worker running. Tested (2026-10-05): `/clear` leaves a background +subagent alive and its hand-back + completion notice reach the new context, but SIGKILLs its running Bash +command (exit 137): a worker mid-test or mid-commit loses that step and reports a false failure or half state. diff --git a/docs/resource-ledger.md b/docs/resource-ledger.md new file mode 100644 index 0000000..17a0443 --- /dev/null +++ b/docs/resource-ledger.md @@ -0,0 +1,304 @@ +# wf res — shared resource ledger for agents — design + +Status: approved and implemented 2026-10-01. + +## 1. Goal + +1. No agent job is OOM-killed because another agent started a big job at the same time. +2. The user keeps a guaranteed share of RAM and CPU (gaming), switchable on demand. +3. An agent that cannot get resources now gets one clear line (who holds them, until when) and works on + something else instead of waiting or retrying blindly. +4. Agent-made waste (tmpfs scratch, failed units) is cleaned routinely. +5. Python ≥ 3.11 stdlib, Linux systemd user manager, no daemon. Works in any directory, wf project or not. + +Machine facts (2026-10-01): 16 cores, 30 GB RAM, 7 GB swap, `/tmp` is tmpfs (16 GB), user manager +delegates `cpu io memory pids`; `systemd-run --user --scope --slice=agents.slice` and transient units with +`MemoryMax`/`CPUWeight` verified working. + +## 2. User decisions (2026-10-01) + +| # | Decision | +|---|----------| +| D1 | Default user reserve 6 GB RAM, 4 CPUs. | +| D2 | Gaming reserve 12 GB RAM, 8 CPUs. | +| D3 | Gaming mode slows agents; never freezes or kills them. | +| D4 | Scratch under `/tmp/claude-<uid>/` untouched for 2 h (newest mtime in the entry) may be deleted automatically by any agent, whoever made it. Except a live session's own dir (`<project>/<session-id>`, live = `~/.claude/sessions/<pid>.json` with that `sessionId`, pid alive, `procStart` matching; added 2026-10-02 after an idle session lost its scratchpad). Same automatic clean sweeps top-level litter in `/tmp` and `/var/tmp` (own entries only): empty dirs idle 2 h (`scratch_hours`) and `clr-debug-pipe-<pid>-*` / `dotnet-diagnostic-<pid>-*` of dead pids; entries with content are never swept. Per-project `out/` patterns only list unless `--yes`. | +| D5 | v1 = everything in the proposal: run, status, wait, release, note, queue, game, clean, timer, shared rule. | +| D6 | `game on [--for D]`, default 4 h, turns itself off. | +| D7 | Install a 1-minute user timer via an explicit `wf res timer on`. | +| D8 | `game on` never fails and never squeezes running jobs; it switches budget and weights at once and prints the shortfall and the estimated time until the full reserve is free. | +| D9 | Small unreserved work: headroom (2 GB) in the budget **and** all agent processes inside `agents.slice`: running Claude sessions are moved there live by `wf res adopt` (run by every timer tick; no restart), new ones optionally start there via the `shell-init` alias. | + +## 3. Architecture + +### 3.1 Cgroup layout + +``` +user@<uid>.service +└── agents.slice ← MemoryHigh, CPUWeight, IOWeight (normal / gaming) + ├── run-…scope ← each Claude session (started via the alias), its shells, small jobs + └── agents-jobs.slice + └── wf-r-N.service ← each `wf res run` job: MemoryMax, MemoryHigh, MemorySwapMax=0, Nice=10 +``` + +- `agents.slice` caps the sum of everything agents do. The ledger decides who may start big work; the slice + is the kernel backstop for wrong estimates and unreserved small work. +- Slice properties are set with `systemctl --user set-property --runtime agents.slice …`. The slice unit file + `~/.config/systemd/user/agents.slice` is written on first use (then `daemon-reload`), so the slice exists + before any set-property. +- Normal: `CPUWeight=20`, `IOWeight=20`, `MemoryHigh = RAM − user_reserve_gb`. +- Gaming: `CPUWeight=5`, `IOWeight=5`, `MemoryHigh = max(RAM − game_reserve_gb, agents.slice MemoryCurrent)`; + re-lowered towards the target on every `wf res` call / tick (D8: no squeezing below current use). +- Weights only matter under contention; on an idle machine agents get full speed. + +### 3.2 Code layout + +- `wflib/res.py` — pure, text in / data out: parse `/proc/meminfo`, parse `systemctl show` output, durations + and sizes (`10G`, `40m`, `4h`), prune, capacity rule, queue start order, game slice values and shortfall, + stale-scratch selection (from a given list of (path, newest mtime)), all output lines. +- `wf_res.py` — I/O: lock, atomic ledger write, `/proc` and `/sys/fs/cgroup` reads, filesystem walk, the + runner (`subprocess.run` wrapper, injectable for tests), unit/timer file writing, printing. +- `wf.py` — only registers `wf res …` and dispatches to `wf_res.main(argv)`. `wf res` needs no + `workflow.toml`. + +### 3.3 Files + +- State dir `~/.local/state/wf/` (`$XDG_STATE_HOME/wf` if set): `resources.json`, `resources.lock`, + `logs/r-N.log`, `logs/r-N.rc`, `logs/r-N.peak`, `resources-history.jsonl` (finished rc=0 runs with a peak, + appended when pruned from the ledger after 24 h; newest 2000 kept; read by `wf res hist`). +- Config `~/.config/wf/resources.toml` (`$XDG_CONFIG_HOME`), all keys optional: + ```toml + user_reserve_gb = 6 + user_reserve_cpus = 4 + game_reserve_gb = 12 + game_reserve_cpus = 8 + game_hours = 4 + small_headroom_gb = 2 + scratch_hours = 2 + ``` +- Unit files under `~/.config/systemd/user/`: `agents.slice`, and with `timer on`: `wf-res.service`, + `wf-res.timer`. + +### 3.4 Ledger + +`resources.json`: `{"next": N, "game_until": iso|null, "last_clean": iso|null, "entries": [...]}`. + +Entry fields: `id` (`r-N`, never reused), `project` (git toplevel basename, else cwd basename), `owner` (pid of +the nearest ancestor process named `claude`, else the parent pid of `wf`), `title`, `mem_gb`, `cpus` +(default 1), `est_min`, `state` (`queued` | `running` | `note` | `done`), `unit` (`wf-r-N.service`, run only), +`cmd` (argv list), `cwd`, `log`, `queued` (iso), `started` (iso), `expires` (iso = started + 2 × est_min), +and for `done`: `ended`, `rc` (int or null), `peak_gb`, `why` (`exited` | `expired` | `owner gone` | `released` | a kill reason, e.g. +`killed: oom-kill by systemd-oomd, limit 4.0 GB; raise --mem`). +`env` (caller variables the user manager lacks; `WF_RES_ID` and secrets never), `by` (who to tell, run/note; +keys dropped when unknown): `name` (`--by`, else `WF_SESSION_NAME`, else `<lane> session` from the wf lane +record `.wf/sessions/*.json` with this `CLAUDE_PID`, main tree or toplevel), `task` (`WF_TASK`, else the +`<lane>/<id>` branch of cwd), `batch` (`WF_RES_ID`: every job unit gets `WF_RES_ID=r-N`, so a `wf batch` +worker's entries name the batch), `address` (`uds:$CLAUDE_CODE_MESSAGING_SOCKET`; a batch worker's = its +orchestrator's, reachable via SendMessage). Status lines of live entries end `[by <name> <task> batch r-N, +message uds:…]`; the throttle warning ends `; started by …`. + +Every command takes the lock (`fcntl.flock` exclusive on `resources.lock`), reads, then **prunes**, acts, +writes atomically (temp file in the same dir + `os.replace`), releases. + +Prune (pure, given unit states, live pids, now): +- `running` whose unit is inactive/failed/unknown → `done` (`rc` from `logs/r-N.rc`, `peak_gb` from + `logs/r-N.peak`). No `.rc` (the sh wrapper died with the job) → `rc` null and `why`/`peak_gb` from + `journalctl --user -u wf-r-N.service --since @started` (`Failed with result '…'`, `systemd-oomd killed`, + `… memory peak`), shown in `status` and `wait`; journal silent → `why` points at that journalctl command. +- `running` past `expires` → stays running (job not killed; `run` says so on start, status shows + `ETA overdue (still running, not killed)`), flagged `overdue` in status, and counted at + `max(reserved, used)`. +- `note` whose owner pid is gone or past `expires` → `done`. +- `done` older than 24 h → removed. +- `game_until` in the past → game off (slice back to normal values). + +### 3.5 Capacity rule + +``` +budget_mem = MemAvailable − reserve_gb − small_headroom_gb + − Σ over running: max(0, mem_gb − MemoryCurrent(unit)) + − Σ over notes: mem_gb +budget_cpus = nproc − reserve_cpus − Σ over running and notes: cpus +fits(req) = req.mem_gb ≤ budget_mem and req.cpus ≤ budget_cpus +``` + +- `reserve_*` is the user or the gaming value depending on game mode. +- A note counts its full reservation (its memory cannot be measured; overcounting briefly is safe). +- A running job's memory already in use is inside `MemAvailable`, so only its unused part is subtracted. + +## 4. Commands + +All print short lines; `--json` where stated. Exit: 0 ok · 3 busy (refused) · 1 `wf: …` one line (unknown +id, systemd failure) · 2 usage. + +### 4.1 `wf res run --mem 10G [--cpus N] --for 40m --title "…" [--queue] -- <cmd …>` + +- Fits → `systemd-run --user --slice=agents-jobs.slice --unit=wf-r-N --collect + -p MemoryMax=<mem> -p MemoryHigh=<0.9·mem> -p MemorySwapMax=0 -p Nice=10 + -p WorkingDirectory=<cwd> -p StandardOutput=append:<log> -p StandardError=append:<log> + /bin/sh -c '"$@"; rc=$?; cat /sys/fs/cgroup$(cut -d: -f3 /proc/self/cgroup)/memory.peak > <peak>; echo $rc > <rc>' sh <cmd …>` + (the unit is collected on exit, so the wrapper records exit code and peak bytes itself). Prints `r-N started; log <path>; ETA ~HH:MM`. Exit 0. +- Environment: a user unit starts from the user manager's environment, so the caller's variables that differ + from `systemctl --user show-environment` go along as `--setenv=NAME=VALUE` (stored in the entry, so a queued + job gets them too); skipped: `PWD OLDPWD SHLVL _` and names with TOKEN/SECRET/PASSWORD/PASSWD/CREDENTIAL. +- Does not fit, no `--queue` → exit 3, one line: + `busy: 7.5 GB held by proj-a "dotnet e2e" (r-4) until ~14:40; 2.1 GB free for agents; retry after ~14:40 or work on something else` + (names the holders whose release would make it fit, earliest ETA first; CPU shortage named the same way). +- Does not fit, `--queue` → `queued`, prints `r-N queued, position P; est. start ~HH:MM; cancel: wf res release r-N`. Exit 0. +- A request larger than the whole agent budget on an empty machine → exit 1 `wf: 20 GB can never fit (max …)`. +- `--force` (memory really free though the ledger says busy: unused reservations, stale notes): fits against + MemAvailable − user reserve (game reserve in game mode) − headroom only; ledger claims and the queue are ignored; + the entry is still recorded. Does not fit even so → exit 3 `busy even with --force: only X GB really free beyond + the reserve; …`. The normal busy line ends with `; X GB really free beyond the reserve: --force starts it past + the ledger (only if the holders will not use what they reserved)` when `--force` would fit. +- `systemd-run` failure → entry removed, exit 1 with its first stderr line. +- `--lock KEY`: one queued/running job per KEY and main tree (git common dir's parent: lane worktrees share it) + at a time, e.g. jobs sharing one checkout (a-game-project-builder `../.<project>-gate`). A title starting with the word + `gate` locks `gate` unasked (2026-10-06: two hand-started gates overlapped in one gate checkout). Held → exit 3 + `busy: lock 'KEY' held by r-N "title" (ETA ~HH:MM|queued): …; --queue waits for it (--force does not override + a lock)`; `--queue` → queued `(lock held by r-N)`. Ledger field `lock` = `KEY@<main tree>`. +- The command reaches the job verbatim (no systemd `%` specifier expansion: `--format='%h %s'` is safe). + +### 4.2 Queue + +Strict FIFO by `queued`: the head starts when it fits; entries behind a waiting head wait (a big job is never +starved); an entry whose lock a running job holds is skipped (it neither starts nor blocks the rest). Starting happens inside every `wf res` call (after prune) and every timer tick. A started queued job +runs with the cwd and argv it was queued with; its ETA counts from the actual start. + +### 4.3 `wf res status [r-N] [--json]` + +Without id: one line per entry (id, project, title, state, reserved / used / peak GB, cpus, started, ETA or +`overdue`), then the budget line (`agents may use X GB, Y cpus now; reserve 6 GB/4 cpus`), game mode with +time left and shortfall line (§4.6), unreserved agent memory (`agents.slice` MemoryCurrent − jobs; `(X GB of it file cache, reclaimable)` = +slice `memory.stat` `file` − `shmem`, capped at that figure), warnings: +- running job throttled at its own limit: used ≥ 0.85 × `--mem` **and** its cgroup `memory.pressure` + `some avg60` ≥ 20 % → `r-N throttled at its memory limit (…): likely too small; release --stop, re-run + with a bigger --mem` (page cache alone at the limit does not stall, so no false alarm; the timer tick + has no reader, so it does not warn); +- tmpfs `/tmp` above 25 % of RAM; +- `N claude sessions outside agents.slice (wf res adopt)`. +- `N jobs outside wf res (bare systemd-run): <unit> <used> GB, …`: running transient user services outside + `agents*` slices, except desktop ones (`app-*`, `dbus-*`). Warning only: a service cannot change slice live. + +With id: that entry in full, including `rc`, `peak_gb`, `why` for done entries. + +### 4.4 `wf res wait r-N [--timeout 2h]` + +Polls every 15 s (each poll is a normal locked call, so it also prunes and starts queued work) until the entry +is `done`; prints `r-N done rc=0 peak 9.4 GB in 37 min`. The throttle warning (§4.3) goes to stderr once. Exit 0 when done (whatever `rc`), 1 on timeout. If +reaped, `status r-N` answers from the ledger. + +### 4.5 `wf res release r-N [--stop]` + +- `queued` / `note` → removed. Running unit → refused with `wf: r-N is running; --stop to kill it` unless + `--stop`, then `systemctl --user stop`, entry → done (`why=released`). + +### 4.6 `wf res game on [--for 4h] | off` + +- `on`: `game_until = now + for`; set gaming slice values (§3.1); prints + ``` + game on until 22:40 (4h); CPU/IO now yours + short 3.1 GB of 12 GB: r-4 proj-a "dotnet e2e" 2.5 GB ~21:05, r-7 proj-b "r5 rebuild" 9 GB ~21:30 + full reserve free ~21:30 (est.); free now: wf res release r-7 --stop + ``` + The shortfall/estimate lines appear only when the reserve is not free now. Estimate = ETAs of the running + jobs that must end, in order (jobs are never stopped by game mode). New requests that do not fit are refused + or queued as usual. `on` while on → extends `game_until`. +- `off` or expiry: normal slice values, `game_until = null`. +- No windows over the game: `wf res hook` (Claude Code `PreToolUse` hook, matcher `Bash`, in + `~/.claude/settings.json`; the user adds it, wf never edits dotfiles) prefixes every agent Bash command with + `unset DISPLAY WAYLAND_DISPLAY;` while `game_until > now`, and adds context telling the agent why GUI apps + fail (run headless/offscreen or later). Reads the ledger only (no lock, no systemctl, ~60 ms); off, expired, + other tools or any error → no output, command unchanged. Live for running sessions: state is read per call. + ```json + "hooks": {"PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", + "command": "python3 /projects/public/workflow/wf_res.py hook"}]}]} + ``` + +### 4.7 `wf res note --mem 3G [--cpus N] --for 20m [--force] "title"` + +Reserves for foreground work (no unit). Freed when `owner` exits, at `expires`, or by `release`. Refused like +`run` (exit 3) when it does not fit; `--force` as in `run`. + +### 4.8 `wf res clean [--yes]` + +- Automatic part (no `--yes` needed; also run by any `wf res` call when `last_clean` > 10 min ago, and by every + tick): + - every direct and second-level entry under `/tmp/claude-<uid>/` whose newest mtime (recursive) is older than + `scratch_hours` → deleted; lines `freed 1.2 GB: /tmp/claude-1000/-projects-x/<session>` (only printed by + `clean` itself; silent from other commands); + - `systemctl --user reset-failed 'wf-r-*'`. +- Listed only, deleted with `--yes`: per-project patterns from the current project's `workflow.toml` + `cleanup = ["out/prof", "out/history-logs/*.log:30d"]` (glob relative to the project root, optional `:Nd` + minimum age by mtime). Never follows symlinks; never leaves the project root. + +### 4.9 `wf res timer on | off`, `wf res tick` + +- `timer on` writes `wf-res.service` (`ExecStart=python3 /projects/public/workflow/wf.py res tick`) and + `wf-res.timer` (`OnBootSec=1min`, `OnUnitActiveSec=1min`), `daemon-reload`, `enable --now wf-res.timer`. + `off` disables and removes them. +- `tick` = lock, prune, start queued, game expiry and slice re-lowering, automatic clean; then adopt., then + session caps: every running scope in `agents.slice` (a Claude session) gets `MemoryHigh = session_mem_gb` + (config, default 6, 0 = off → `infinity`) via `systemctl --user set-property --runtime`, only where it differs. + MemoryHigh only: a session over it is throttled, never killed (a MemoryMax OOM kill could pick `claude`, whose + oom_score_adj is 200). `status` warns `claude session N at its memory cap (…)` at ≥ 0.9 × cap and ≥ 20 % stall. + `wf res run` jobs live in `agents-jobs.slice`, not in a session, so the cap never limits them. Silent. + +### 4.10 `wf res adopt` + +Moves running Claude sessions into `agents.slice` without a restart (verified 2026-10-01: a live process moved +by `StartTransientUnit` with `PIDs`). Session roots = processes named `claude` whose parent is not `claude`, +outside the slice; each root and its descendants outside the slice go into a new scope +`wf-claude-<root pid>.scope` via +`busctl --user call org.freedesktop.systemd1 /org/freedesktop/systemd1 org.freedesktop.systemd1.Manager +StartTransientUnit 'ssa(sv)a(sa(sv))' wf-claude-<pid>.scope fail 2 PIDs au <n> <pids…> Slice s agents.slice 0`. +Children forked later inherit the scope. Prints one line per session; errors (process gone) are reported, and +the timer retries next minute. + +### 4.11 `wf res shell-init` + +Prints the alias line for `~/.bashrc` (the user adds it; wf never edits dotfiles): +`alias claude='systemd-run --user --scope --quiet --slice=agents.slice claude'`. + +### 4.12 `wf res hist [--project P]` + +Reservation sizes from history: history file + the ledger's finished runs (rc=0, peak known). Grouped by +project and title kind = title minus trailing `(…)` and trailing sha/hex words (7–40 hex chars, ≥ 1 digit): +`gate 2c91cac7 (fix x)` → `gate`. One line per kind: `n`, median request, peak p50/p95, median estimate, +duration p50/p90, suggestion. Suggestion (≥ 3 runs): mem = p95 peak ×1.15 (up to 0.1 GB, ≥ 0.2 GB), for = p90 +duration ×1.5 (≥ 5 min); percentiles nearest-rank. `run`/`note` asking > 2× the suggestion (mem or for) print +`hint: history says ~X GB / Y min (n runs)` on stderr; nothing is resized. `wf batch` without `--mem`/`--for` +uses the `wf-batch` suggestion of the project (not `--prep`; the bg-wait ceiling stays 6h unless `--for`). + +## 5. Shared rule (`shared/CLAUDE.md`, ~4 lines) + +- Job expected > 2 GB RAM, > 4 cores or > 10 min → `wf res run` (never bare `systemd-run`, never a long + foreground command); big foreground step → `wf res note`. +- Exit 3 = busy: note it on the task, do other work, retry after the printed time. No tight polling. + The line names `--force` when the memory is really free (claims unused): rerun with it if the holders will not grow. +- No big scratch in `/tmp` (RAM); use the project's git-ignored `out/`. +- Session end: release your own entries (`wf res status`). + +## 6. Errors + +- Not Linux / no systemd user manager / no cgroup v2 → `wf: wf res needs a systemd user session` exit 1. +- Corrupt ledger → renamed to `resources.json.bad-<ts>`, start empty (id counter salvaged from its text: ids never reused), one warning line. Unknown entry keys (written by a newer version) are ignored, never corrupt: a `wf res wait` started before a release must not wipe the ledger. Starting a job deletes stale `logs/<id>.rc/.peak`. +- Lock is held at most for one command's critical section (no subprocess wait longer than a `systemctl` + call inside the lock; `wait` polls with the lock released between polls). + +## 7. Tests + +- Pure (`tests/test_res.py`, hand-written literals): meminfo parse; size/duration parse; capacity with mixed + running/note entries, used figures, headroom and game reserve; prune (unit gone, pid gone, expired note, + overdue run, 24 h done removal, game expiry); FIFO start order incl. blocked head; busy line holder choice and + wording; game slice values and shortfall/estimate lines; stale scratch selection; cleanup pattern + age. +- I/O (`tests/test_res_io.py`): fake runner records argv for run/stop/set-property/timer; atomic write; corrupt + ledger recovery; two real processes reserving at once under the real lock never overbook (budget faked). +- Smoke (manual, release step): real `wf res run --mem 100M --for 1m -- true`, `wait`, `status`. + +## 8. Release + +Slices (≈1 h each): pure core · ledger+lock+run/status/release · wait/rc/peak · queue · note · game+slice · +clean · timer+tick+shell-init · shared rule + CHANGES + release. Release rules of the workflow repo apply; +after release the workflow session runs `wf res timer on` once and tells the user to add the +`shell-init` alias. |
