Skip to content
workerstackPublic

About

Agent workflow, code standards, gates, workspace and docs checks for workerstack repos

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

524 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@workerstack/repo

Agent workflow, code standards, gates, workspace and docs checks for workerstack repos

Contents

Scope

Plan by default. A straight shot is the exception. The plan caps the result, so its depth scales with the work. This file is the size-gate SSOT.

Gate

Straight shot: one file, one obvious edit, nothing to decide. Do it, starting with bun repo worktree add <name> like any other change: the main checkout takes no product writes unless workflow is main. Also skip planning for questions, lookups, commits, a follow-up inside an approved plan, and an explicit "just do it".

Plan: two or more files, two or more items in the message, two or more valid approaches, cross-cutting or hard-to-undo work, or the user asked to plan. A named goal is not a named edit. "Just" or "should be easy" on a goal is not "just do it": only words that skip the plan itself, such as "just do it" or "no plan", do.

/ask is this loop: it stops once for plan approval, then runs to done. It does not skip approval. /away decides instead of asking and logs each choice as a decision entry; under away the Stop hook runs the approval block's header choice once the review settles and logs it. A session value (/ask, /away, /set) beats a saved approval: ask|away, which applies only when the session has no value. With no session value and no in-progress approval:, agents.defaultApproval applies, else ask. A fast plan (/write-plan-fast, at most 40 lines, no review: fast-plan-format.md) or a fast roadmap (/write-roadmap-fast, at most 20 lines, no review: fast-roadmap-format.md) runs when the person asks for it or when the session effort is fast; otherwise the process here stays the default. /set saves approval, effort and strategy for the session (ask away, fast deep, free spawn-fast spawn-deep). The effort in the hook context picks the plan skills (/write-plan-{effort}). Run bun repo set <words> only when the person asks in plain words, never on your own judgment, and say what changed. /rn <task> skips the plan, the review, questions and its own test run, never the worktree or the commit gate.

Research

Read and search until every leaf is defensible and needs no re-planning. Depth scales with stakes and ambiguity: a clear task needs enough to write the leaves; an unclear, cross-cutting, or hard-to-undo one needs the alternatives checked against the code, docs, and data. Fat notes go under .repo/scratch/.

Questions

Ask only what research cannot settle and what changes the plan's shape: intent, scope, a trade-off the user owns. Batch them into one round, each with your recommendation. Research again on each answer, and ask again only if a new plan-shaping gap appears. When research settles everything, skip this step and write the plan in one shot.

Plan

/write-plan-deep. Format: node_modules/@workerstack/repo/references/plan-format.md. A plan over 100 lines above ## Previous mistakes, frontmatter included, is a roadmap: split it with /write-roadmap-deep. The plan is the proposal: the chosen approach and its rejected alternative sit in Context or Constraints. A minor open choice is written as the default plus "confirm"; a plan-shaping one goes back to Questions. Before writing, run bun repo lessons match on the files the plan touches and cite each match in Watch-outs. After saving, run /review-plan {n} in a fresh agent, settle each finding, add a review entry to ## Previous mistakes for each one you accept, and bump frontmatter reviews in that save. Write approval: beside advice: only when the session runs /away or the person asked for an approval. Report the goal, phases, top risks, open assumptions and path, then stop with the approval block (/write-plan-deep has the full form). The block comes only after the review settled, with reviews bumped since the review spawn; the Stop hook blocks it before that.

Approval

Feedback revises the same plan file (a Write of a saved plan updates it in place) and adds a feedback entry to ## Previous mistakes; research again if needed. Run /review-plan again on the revision. Stop again until the user approves. A plan with reviews: 0 cannot move to in-progress; run /review-plan {n} first. Approval is a click on the approval picker on Claude Code (the Stop hook names it; an answer that is no option's label is feedback), or the user replying i (or a short yes, or i with a word such as fast) to the approval block, an explicit "plan and do it" in the request, or /implement-roadmap-deep running a roadmap whose status is locked or in-progress. A plan-shaping question still stops and leaves that roadmap locked or in-progress. h hands the whole block to a fresh session and keeps each file's strategy; h free, h fast and h deep write free, spawn-fast or spawn-deep first. The hook writes one handoff and names the /pickup {serial} to run there; do no work in this session. An i or h on an item whose review has not settled is refused.

Implement

/implement-plan-deep after approval. Same turn when the request said "plan and do it", or when /implement-roadmap-deep is running a locked or in-progress roadmap. Skip [x]. Write-allowlist = paths listed in the plan, repo-relative to that worktree. Do not skip test leaves. Work in the plan's worktree {serial}-{name} (worktree.ts add). Before the first leaf, check the latest commits and read the plan's ## Previous mistakes. A failed Verify, a test that failed after a fix, or principle-attack-the-premise firing adds a verify or premise entry. An entry that will happen again on other tasks also becomes a lesson, written in that worktree: node_modules/@workerstack/repo/references/lessons.md. Journal: node_modules/@workerstack/repo/references/implementation-format.md under .repo/implementations/ (create on start; not for straight shots). Straight shot (/rn is exempt from the test and proof steps; the commit hook's gate runs the related tests): run the nearest existing test when the repo has one, repro a bug before the edit, and scaffold a test file only for a bug. Before you call a straight shot done, read principle-prove-it-works. Before you ask the user, read principle-never-block-on-the-human. Under away, log a decision entry instead of asking. Before a large dump or a fan-out, read principle-guard-the-context-window.

Types

One type per plan. Pick the strongest match. A feature that crosses an ownership, package, or API boundary is cross-cut. Straight shots skip this table and follow the straight-shot rule in ## Implement. Skip a named check only with a reason in Context. Approval of the plan is the select.

/write-plan-deep copies the row into Steps or Verify, including a Read leaf for each named principle. Each Read leaf sits in the phase it gates, not in a phase of its own. /implement-plan-deep reads that skill and follows it.

A slice is one child plan, not a phase. Two or more ordered slices are a roadmap (/write-roadmap-deep, then /implement-roadmap-deep), not one plan. /write-roadmap-deep runs /review-roadmap {n} in a fresh agent after each save, the same way plans are reviewed, and a roadmap with reviews: 0 cannot be locked. Format: node_modules/@workerstack/repo/references/roadmap-format.md.

type checks
bug repro; failing test before impl; read principle-fix-root-causes; read principle-test-behavior-not-implementation
feature failing test before impl; read principle-test-behavior-not-implementation
docs no test leaf; no browser unless the doc is a live page; Verify reads the page or runs the generator
perf Verify states the measurement and what limits it; read principle-explain-the-number
refactor existing suite or characterization tests; no feature leaves; read principle-laziness-protocol; read principle-subtract-before-you-add; read principle-minimize-reader-load; read principle-test-behavior-not-implementation
cross-cut Context holds architect lite (types, signatures, module map, rejected shape); a /blast-radius leaf; read principle-foundational-thinking; read principle-type-system-discipline
chore no test leaf unless a check already exists

Facts

/write-plan-deep adds a Read leaf for each fact that is true, in the phase that fact gates. No fact, no leaf. /implement-plan-deep reads that skill and follows it.

fact read
a guard, parser, or adapter at a CLI, config, or network edge principle-boundary-discipline
a new internal API while old callers still exist principle-migrate-callers-then-delete-legacy-apis
a command or loop that must survive crash and retry principle-make-operations-idempotent
two actors can write the same file, branch, or key principle-separate-before-serializing-shared-state
stateful logic, or the same shape copied across files principle-model-the-domain
a sweep of similar edits principle-build-the-lever
a rewrite with phase boundaries principle-outcome-oriented-execution
a person will see the output, the error text, or the default principle-experience-first
the requirement does not fit without a special-case branch principle-redesign-from-first-principles
research left two or more live options and the choice is hard to undo principle-exhaust-the-design-space

/implement-plan-deep reads a principle only when the type row names it, a true fact names it, the next sentence names it, or an already-read principle cites it. Do not open the rest of the catalog.

Verify of every plan reads principle-prove-it-works. After two fixes that share a premise fail the same gate, read principle-attack-the-premise before a third try. Before you ask the user, read principle-never-block-on-the-human. Before a large dump or a fan-out, read principle-guard-the-context-window.

/write-roadmap-deep reads principle-sequence-verifiable-units. /correct reads principle-encode-lessons-in-structure.

Change workflow

workflow in repo.config.ts says where changes are made and how they reach the default branch: "worktree" (the default, also when the key or the config is missing), "pull-request" or "main". bun repo sync writes the mode's steps into the workflow paragraph of AGENTS.md, so that paragraph is the one to follow. This file says what each command and gate does under each mode. hooks.md has the gate details, exports.md the bun repo push table.

worktree

Every change is made in a managed worktree: bun repo worktree add <name> makes .worktrees/<name> on branch wt/<name>. Edit, test and commit there.

  • bun repo worktree finish <name>, run from the root, rebases wt/<name> onto the default branch, fast-forwards the default branch to it, removes the worktree and deletes the branch, and origin's copy when there is one. It pushes nothing else.
  • bun repo push from the root then pushes the default branch and waits on CI. On a wt/* branch it exits 1: finish first.
  • bun repo release runs in the main checkout on the default branch: it commits the release, pushes it, waits on CI, then tags.
  • Gates: a write into the main checkout is denied, outside .worktrees/ and the ignored .repo/ dirs. A commit, merge, cherry-pick, revert, am or pull there is denied with the main checkout is shared: commit inside <root>/.worktrees/<name>, then run bun repo worktree finish <name>. Stash, reset, checkout, switch, restore, clean and rebase there are denied as a risk to other sessions' work.

pull-request

Changes are made in a managed worktree as under worktree, and the default branch takes them only through pull requests. It needs gh, logged in.

  • bun repo worktree finish <name>, run from the root, fetches origin and reads the branch's pull request. With none open, it rebases wt/<name> onto origin/<default>, pushes it and opens its pull request. Run again after more commits, it pushes them to the open pull request. Once the pull request merged, it fast-forwards the default branch to origin's, removes the worktree and deletes both copies of the branch. A closed pull request is refused: reopen it with gh pr reopen <number>, then run finish again.
  • bun repo push refuses the default branch with <default> takes changes only through pull requests (workflow is pull-request): run bun repo worktree finish <name> to push wt/<name> and open its pull request. On a wt/* branch it exits 1 and names finish, which pushes the branch itself.
  • bun repo release never pushes the default branch. It commits the release in a release-<version>-<short HEAD> worktree and opens its pull request, then stops: run bun repo release again once it merges. That rerun lands the merged release commit, waits on CI for it and tags it. Run before the merge, it says the pull request is not merged yet.
  • Gates: the worktree ones, plus a push or delete push of the default branch (also --all and --mirror) is denied from anywhere in the repo with the same text bun repo push prints. A word in the user's message does not lift this deny.

main

Changes are made in the main checkout, on the default branch. There is no worktree step.

  • Edit, test and commit in the main checkout, then run bun repo push, which pushes the default branch and waits on CI.
  • bun repo worktree add and finish still work as under worktree, for a change you want apart.
  • bun repo release works as under worktree.
  • Gates: writes and commits in the main checkout pass. Stash, reset, checkout, switch, restore, clean and rebase there are denied as a risk to uncommitted work, unless the user's message asks for them.

Every mode

A force push, and moving or deleting the default branch with git branch or git update-ref, are denied in every mode unless the user's message asks for them.

A config that fails to load

When repo.config.ts throws as it loads, or workflow holds another value, each gate takes its strictest rule: the main checkout takes no writes or commits, and the default branch is not pushed ((repo.config.ts failed to load) in the deny). bun repo worktree finish refuses with repo.config.ts failed to load: fix it, then run finish again.

Exports

What a repo imports from @workerstack/repo. bun repo docs --check type-checks each ts block below against the installed package.

Config

repo.config.ts at the root default-exports defineConfig from @workerstack/repo; bun repo init writes it. workflow says how changes reach the default branch: "worktree" (the default), "pull-request" or "main"; workflow.md says what each mode does.

Hover any key in defineConfig({ ... }) to see what it does, the values it takes, its default and an example, at every depth. A section whose pack is off is a type error that names the pack to add, like add "release" to packs to set this on nightly. The types also refuse what the schema would refuse at load: an empty ci.checks command, a check named after a built-in one, an empty lint.skip list of files or rules, a lint.plugins name that is a stock plugin, a lint.rules key whose plugin is not in lint.plugins, a knip.ignore entry that names two of file, dependency and binary, and a turbo task named typecheck or test.

import { defineConfig } from "@workerstack/repo";

export default defineConfig({
  packs: ["agents", "standards", "docs", "release"],
  lint: {
    skip: [{ files: ["src/legacy/**"], rules: ["eslint/max-lines"], reason: "split in issue 12" }],
    boundaries: {
      tags: { "@app/web": "app", "@app/ui": "ui", "@app/lib": "lib" },
      allow: { app: ["ui", "lib"], ui: ["lib"], lib: [] },
    },
    tailwind: { entryPoint: "apps/web/src/app.css" },
    plugins: { app: "lint/app.js" },
    rules: [{ files: ["src/**"], rules: { "app/no-x": "error" } }],
  },
});

Lint plugin

@workerstack/repo/lint is the oxlint JS plugin behind every repo/* rule. bun repo sync names it in the generated .oxlintrc.jsonc, so a repo never imports it by hand.

import plugin from "@workerstack/repo/lint";

const names: string[] = Object.keys(plugin.rules);
console.log(`${plugin.meta.name}: ${names.length} rules`);

Your own lint rules

Under the standards pack, a repo runs its own oxlint JS plugins beside the stock rules. lint.plugins maps a name to a repo-relative .js, .mjs or .ts file that default-exports a plugin ({ meta: { name }, rules: {} }). lint.rules turns its rules on: each entry is { files?, rules }, and each rule is "error" or ["error", options]. An entry with no files applies everywhere.

import { defineConfig } from "@workerstack/repo";

export default defineConfig({
  packs: ["standards"],
  lint: {
    plugins: { app: "lint/app.js" },
    rules: [
      { files: ["src/**"], rules: { "app/no-x": "error" } },
      { rules: { "app/max-y": ["error", { max: 3 }] } },
    ],
  },
});

bun repo sync adds { "name": "app", "specifier": "./lint/app.js" } to jsPlugins in .oxlintrc.jsonc, after the stock plugins. Rules with no files go in rules, the others in overrides. The file stays untracked, and bun repo sync --check reports drift when the key changes. The plugin's key is its name in the config, so its meta.name does not matter. A hit prints as app(no-x) and counts in bun repo lint, bun repo check, bun repo turn and .repo/lint-baseline.json like a stock hit. A lint.skip entry can name app/no-x. The plugin file may default-export, and knip counts it as used.

This only adds rules. Turn a stock rule off with lint.skip. Config load refuses, one line each:

repo.config.ts: lint.plugins.vitest: is a stock plugin name; pick another, like "app"
repo.config.ts: lint.plugins.App: must be a lowercase kebab-case name, like "app"
repo.config.ts: lint.plugins.app: must be a repo-relative path to a .js, .mjs or .ts file, like "lint/app.js"
repo.config.ts: lint.plugins.app: lint/app.js is no file
repo.config.ts: lint.plugins.app: lint/app.js does not default-export a plugin like { meta: { name }, rules: {} }
repo.config.ts: lint.rules[0].rules: "other/x" names no plugin in lint.plugins
repo.config.ts: lint.rules[0].rules: "app/nope" is not a rule of lint/app.js; it has no-x, max-y
repo.config.ts: lint.rules[0].rules.app/no-x: must be "error" or ["error", options]; lint.skip turns rules off

The types in defineConfig refuse a stock name and a rule whose plugin is not listed. Only the load can see the file and what it exports. Load imports each plugin file, so a plugin that throws fails every command that reads the config, and the line names the file.

There is no key for generated files. oxlint skips every path .gitignore ignores, so list generated output such as **/_gen/ or **/*.gen.ts there.

Stylelint

Under the standards pack, bun repo lint, bun repo check, bun repo turn and bun repo lint --baseline also lint every tracked .css file outside __fixtures__ dirs with Stylelint 17 and three packs: stylelint-config-standard, @dreamsicle.io/stylelint-config-tailwindcss (Tailwind v4 at-rules and --alpha()/--spacing(), plus @config and @plugin) and stylelint-config-recess-order. The config lives in this package, so nothing is written to your repo and the packs resolve from its install. Each warning is one stylelint(<rule>) hit the baseline counts like any other:

src/bad.css:4:10: error stylelint(color-hex-length): Expected "#ffffff" to be "#fff"
src/bad.css:9:3: error stylelint(order/properties-order): Expected "display" to come before "color"

bun repo lint --fix and bun repo turn run Stylelint's fix first (property order, hex length), then bun fmt on the sheets it rewrote, and bun repo precommit also restages them. bun repo lint src/a.css runs Stylelint alone. A lint.skip entry takes stylelint/<rule> (rules: ["stylelint/no-descending-specificity"]); a misspelled one fails bun repo check as has no hit. A checkout with no CSS runs no Stylelint.

Tailwind classes

Under the standards pack, a checkout with a Tailwind v4 entry sheet gets oxlint-tailwindcss 1.17.0's class rules at error: tailwindcss/no-unknown-classes, no-conflicting-classes, no-duplicate-classes and no-hardcoded-colors. bun repo sync takes the one sheet Stylelint lints whose line @import "tailwindcss" (or 'tailwindcss', with source(…) or a layer after it) loads the theme, and writes it into .oxlintrc.jsonc as settings.tailwindcss.entryPoint and into .oxfmtrc.jsonc as sortTailwindcss.stylesheet. The plugin loads as @workerstack/repo/tailwind, so it resolves from this package's install. Without such a sheet nothing changes.

src/app.tsx:5:20: error tailwindcss(no-unknown-classes): "bg-foo-bar" is not a valid Tailwind class.
src/app.tsx:5:20: error tailwindcss(no-duplicate-classes): Duplicate class: "flex". Removing the second occurrence.

When two or more sheets import tailwindcss, sync fails and asks for lint.tailwind.entryPoint, the repo-relative sheet to check classes against; one that is no file or does not import tailwindcss fails sync too. A lint.skip entry may name a tailwindcss/<rule> only while an entry turns the rules on.

Fixture folders

Test data lives in folders named __fixtures__, and every skip (lint, typecheck, Stylelint, jscpd, coverage, mutation, knip, sync pruning) leaves exactly that name out. bun repo check fails a folder named fixtures, fixture, __fixture__, test-fixtures, test_fixtures or testdata, tracked or untracked and not ignored, outside __fixtures__, vendor and the dependency dir:

repo check: fixtures: cli/sync/fixtures is a fixture folder; rename it to cli/sync/__fixtures__ (bun repo fixtures --fix)

bun repo fixtures lists each such folder with the references it would rewrite (cli/sync/fixtures -> cli/sync/__fixtures__ (41 references in 12 files)), and bun repo fixtures --fix renames each with git mv and rewrites the path references to it in tracked, writable text files: a /-ended path that resolves to the folder (./fixtures/, ../fixtures/, and a bare <parent>/fixtures/ or fixtures/ read from the root or from the file), and a quoted "fixtures" call argument whose quoted segments before it lead to the folder's parent (path.join(dir, "fixtures") beside it, path.join(root, "<parent>", "fixtures")). Prose, other quoted words and read-only generated files stay; bun repo sync rewrites those from their sources. A folder whose new name is taken is skipped and named.

Public package deps

bun repo check judges where each public package declares the packages it hands consumers. A package is judged when its package.json says "private": false and has a non-empty dependencies, at the root and in every workspace package.

The type surface is what the package's exports give a consumer's compiler. Every typed export goes through an in-memory declaration emit, and nothing is written to disk. A * pattern expands over what npm packs: the files entries, or without files every file but tests, __fixtures__, node_modules and .git. A null target, or a conditions key whose every target is null, excludes what it matches. An export that points at a tsconfig, as the TypeScript presets do, brings the .d.ts files its files lists. Without exports, the package reads types, then typings, then main. A declaration that is not built yet maps back to its source through tsconfig.build.json.

A package counts as used when the surface names it with a bare import or a /// <reference types>, or uses a global it declares. A package that no field of the manifest declares, a transitive one, counts for the dep one hop up that brings it in. A package with no bin is a library, and every dependencies entry of a library fails: bundle it or make it a peer. A package with bin keeps the deps only its own code uses, and fails each one its exported types use. A version moves as written, so a workspace catalog: still holds. optionalDependencies are left alone.

$ bun repo check
repo check: package.json: zod is in "dependencies" but the types of "." use it (cli/commit-config.ts); move it to "peerDependencies" and "devDependencies" keeping its version
repo check: packages/kit/package.json: nanoid is in "dependencies" of a library (no "bin"); bundle it, or move it to "peerDependencies" and "devDependencies"
repo check: packages/kit/package.json: the types of "./types" (types/index.d.ts) are missing; restore the file or drop the export
repo check: packages/kit/package.json: the types of "./client" (dist/client.d.ts) are missing and no tsconfig.build.json maps them to a source; build first or point "types" at the source
repo check: packages/kit/package.json: the types of "./server" (dist/server.d.ts) are missing and no source in tsconfig.build.json emits them; build first or point "types" at the source
repo check: packages/cli/package.json: could not read the types of its exports (Unexpected token in package.json)

Vitest presets

@workerstack/repo/vitest gives defineConfig for a root vitest.config.ts (istanbul coverage with the per-file floors of coverage-floors.json) and defineProject for a workspace package's.

import { defineConfig } from "@workerstack/repo/vitest";

export default defineConfig({ test: { include: ["src/**/*.test.ts"] } });

TypeScript presets

@workerstack/repo/tsconfig/* holds the shared tsconfig files: base, strict, bun, web and worker.

{ "extends": "@workerstack/repo/tsconfig/bun", "include": ["**/*.ts"] }

Commit messages

The generated commit-msg hook (bun repo commit-msg) holds a message to its subject plus at most three more things. Anything else is refused, and each refusal names the fix:

Refused Passes instead
a Key: value line (Co-Authored-By: A <a@b.c>, Signed-off-by:, Why: x) a plain sentence for a why
an attribution: 🤖, noreply@anthropic.com, or a line starting Generated with nothing; the work is the author's (a subject that says "generated with" passes)
a Breaking: line a BREAKING CHANGE: <what breaks> footer (the line and the non-blank lines after it)
any other body line nothing: subject only
a commit over commit.maxChangedLines with no body line one line saying why, even beside a footer
two or more why-lines over the limit one line

Allowed besides the subject: a BREAKING CHANGE: footer, git's revert line This reverts commit <sha>. (7 to 40 hex digits), and, only over the size limit, one why-line. Merge commits pass. The subject takes the types feat fix perf refactor test docs build ci chore revert, with ! before the colon for a breaking change; a plan's commit: line takes the same list.

On Linux, git commit --amend (an alias for it too) is judged as the whole commit it makes: the size limit, the test and CHANGELOG rules and red-proof read the change from HEAD^, not only the newly staged lines. Elsewhere an amend is judged on its staged lines, as a plain commit is.

$ git commit -m "feat: add x" -m "Co-Authored-By: A <a@b.c>"
commit-msg: the message was refused
  - remove "Co-Authored-By: A <a@b.c>"; commits carry no Key: value lines (write a why as a plain sentence)

Push

bun repo push [<branch>] pushes a branch and waits on its ci run with bun repo ci wait. Without a branch it picks one: the default branch is origin/HEAD; the recent branches are those in the last 30 reflog entries and the 8 newest heads that were committed to within 7 days, minus the default branch and wt/*.

On Result
a wt/* branch exit 1: run bun repo worktree finish <name> from the root, then bun repo push there
a wt/* branch, under pull-request exit 1: run bun repo worktree finish <name>, which pushes the branch and opens its pull request
the default branch it would push, under pull-request exit 1: <default> takes changes only through pull requests (workflow is pull-request): run bun repo worktree finish <name> to push wt/<name> and open its pull request; (repo.config.ts failed to load) when the config fails to load
another non-default branch pushes it
the default branch, no recent branch pushes the default branch
the default branch, one recent branch pushes that branch
the default branch, several exit 5, naming them: run bun repo push <branch>
a detached HEAD, or no origin default branch exit 4 (git remote set-head origin --auto for the latter)

A branch with no upstream is pushed with git push -u origin <branch>. The push runs through the pre-push hook and shows its output. Exit codes: 0 passed, 1 failed (a refused push, a worktree branch, or a failed ci run), 2 superseded, 3 nothing to wait on, 4 error (also a bad argument: usage: repo push [<branch>]), 5 ask. After the push, 0 to 4 are bun repo ci wait's.

Bench

The benchmark pack also links the /benchmark skill, which picks the dimensions and writes the spec.

bun repo bench <name> runs .repo/bench/<name>.bench.ts, whose default export is defineBench from @workerstack/repo/bench (with the types BenchSpec, BenchCandidate, BenchMetric, Better and OsLabel):

import { defineBench } from "@workerstack/repo/bench";

export default defineBench({
  candidates: { bun: { run: ["bun", "x.ts"] }, node: { run: ["node", "x.mjs"], setup: ["bun", "build", "x.ts"] } },
  metrics: { ops: { better: "higher", unit: "ops/s", weight: 1 }, rss: { weight: 1 }, wall: { weight: 2 } },
  os: ["ubuntu-latest", "macos-latest", "windows-latest"],
  runs: 10,
  warmup: 1,
});
Field Takes
candidates at least one name (a-z, 0-9, -) → run (the command, measured) and optional setup (run once before, unmeasured)
metrics at least one name → weight over 0, better lower (default) or higher, optional unit
runs measured runs per candidate, at least 3; default 10
warmup unmeasured runs per candidate first; default 1
os runner labels --remote measures on: ubuntu-latest, ubuntu-24.04-arm, macos-latest, macos-13, windows-latest; default none

Built in, lower is better, weight only: wall (ms), cpu (ms, user plus system) and rss (MB, peak). Any other metric is read from the candidate's last stdout line, bench: {"ops": 9120}; a run without it fails. Candidates run interleaved, round-robin. Each candidate's score is the weighted mean, over metrics, of best/value (value/best when higher is better) on medians, × 100, so the best on every metric scores 100; medians are raised to a floor first (wall 0.1 ms, cpu 1 ms, rss 1 MB, custom 1e-6), so a 0 never divides. Each cell is median ±CV% min–max; a CV over 10% gets ! and a line naming the cause. On Linux a child's peak RSS starts from the runner's own (the kernel carries it across exec), so the env records that peak as rssFloorBytes (null on macOS and Windows, which count per process), and an rss median within 5% of it gets ~ and a line saying the real peak is at most that. Columns go by weight, then name; rows by score, then name. The raw samples, machine and commit go to .repo/tmp/bench/<name>/<os label>.json.

$ bun repo bench startup
startup · ubuntu-latest (linux x64) · AMD Ryzen 9 7950X (32) · 64 GB · bun 1.3.2 · 82c0e43+dirty · 10 runs, 1 warmup
candidate  score  wall ms (×2)       ops ops/s (×1)      rss MB (×1)
bun          100  41.2 ±4% 39–45     9120 ±3% 8700–9500  48.1 ±1% 47–49
node          62  88.0 ±11%! 76–104  7010 ±4% 6600–7400  61.3 ±1% 60–63
! node wall: CV 11% over 10%; rerun with more runs or a quieter machine (load from other work counts)
raw: .repo/tmp/bench/startup/ubuntu-latest.json

--remote also measures on the spec's other os labels in CI: it runs here first when this machine's label is listed, dispatches bench.yml (the benchmark pack) at HEAD's sha, waits up to 90 minutes, downloads each bench-<name>-<os> artifact and prints one table per OS. It refuses, one line each: no .github/workflows/bench.yml, bench.yml not on origin/<default branch> (GitHub dispatches only from there), HEAD not on origin, a dirty tree, and an os with no other label. Exit codes: 0 done, 1 a candidate or the remote run failed, 4 a bad argument, spec or precondition.

Evals

bun repo eval <name> runs evals/<name>/eval.ts on Claude. It first prints a worst-case estimate and refuses a run over the cap before any call. The cap is $5.00 for a session run and $1.00 for an API run by default; --max-cost <usd> raises it. --dry-run stops after the estimate and reads no key. --via session (the default) runs each call as claude -p on the Claude Code session you are logged in to, with no key; Claude Code adds about 1500 hidden input tokens to every call, which the estimate counts, and a reply may pass the max tokens, so a run can overshoot the cap by the last case's extra output. Without the claude CLI logged in it prints eval: --via session needs the claude CLI, logged in; install Claude Code, or use --via api and exits 1. --via api calls the Anthropic API and needs ANTHROPIC_API_KEY; any other --via value exits 4 (eval: --via takes session or api). A real run stops before a case that could cross the cap, caches each answer under .repo/tmp/evals/cache, so a rerun of unchanged text costs $0 (session and API answers are cached apart), and writes a spend summary under .repo/tmp/evals/runs.

$ bun repo eval skill-routing --dry-run
eval skill-routing: 24 cases x 3 samples = 72 calls on claude-haiku-5-5 via session, at most $0.0952 (cap $5.00)

An eval lists the repo files and folders its prompt and grader read as files in defineEval: repo-relative, with /, and each must exist, or the eval is refused (eval broken: files.0: no file or folder agents/skills/nope). --changed <base> runs every case of the eval when one of those, or anything under evals/<name>/, changed since the merge base with <base>, committed or not, and runs none otherwise (exit 0, no call made). evals/rules-compliance asks claude-sonnet-5-5 its first step for real prompts, given the rules and the scope page, 2 samples a case so its API bound stays under the $1.00 cap; both evals pass an answer whose first word is the expected one.

$ bun repo eval skill-routing --changed main --dry-run
eval skill-routing: agents/skills/su/SKILL.md changed since main
eval skill-routing: 24 cases x 3 samples = 72 calls on claude-haiku-5-5 via session, at most $0.0952 (cap $5.00)
$ bun repo eval rules-compliance --changed main
eval rules-compliance: nothing it reads changed since main

With the evals pack on, bun repo github keeps a GitHub environment named evals that needs one reviewer and deploys only from the default branch, and sync writes .github/workflows/eval.yml. It runs only when started by hand, with the eval's name and a cap in US dollars (empty takes the default for its via) and via, session (default) or api. Its one job runs in the evals environment, so it waits for your approval and reads the secret from there: CLAUDE_CODE_OAUTH_TOKEN for session, which installs @anthropic-ai/claude-code@2.1.323 first, or ANTHROPIC_API_KEY for api. It restores and saves the answer cache, and uploads the summaries as the eval-<name> artifact.

$ claude setup-token                                   # prints a long-lived token; copy it
$ gh secret set CLAUDE_CODE_OAUTH_TOKEN --env evals    # once, for --via session; paste the token
$ gh secret set ANTHROPIC_API_KEY --env evals          # once, only for --via api; also set a spend limit in the Anthropic console
$ gh workflow run eval.yml -f eval=skill-routing -f max-cost=0.5 -f via=session

The run then waits under "Review deployments" until you approve it. A dispatch from any branch but the default one is refused by the environment.

Env

@workerstack/repo/env exports defineEnv and sealed (with the types EnvConfig, EnvInstance, ProcessSpec, Scope, Sealed and Value). It is for scripts: a Cloudflare Worker reads ctx.env, and defineEnv there throws the message "repo env: defineEnv is script-only; a Cloudflare Worker reads ctx.env".

import { defineEnv, sealed } from "@workerstack/repo/env";

export const repoEnv = defineEnv({
  global: { CLOUDFLARE_ACCOUNT_ID: "28fa2622…", NPM_TOKEN: sealed("YWdlLWVuY3J5cHRpb24ub3Jn…") },
  local: {},
  dev: {},
  prod: {},
  process: { CI: "optional", HOME: "required" },
});

repoEnv.current.NPM_TOKEN; // string, decrypted on the first sealed read
repoEnv.process.HOME; // string; CI is a key only when set (string | undefined)
Field Takes
global values every scope shares; optional
local dev prod one block per scope (required, may be {}); a string or sealed(...) per key
process process variables this env reads → "required" (a string; throws when read unset) or "optional" (a key only when set, typed ?: string | undefined, so a spread over defaults keeps them)
parent another defineEnv result; its values carry into each scope

A scope merges in this order: own global, then the parent's scope view, then own scope; the later wins. global is the parent's global then own. The instance holds:

Member Reads
current the scope REPO_ENV names (local, dev or prod; local when unset), checked at each read; a key that scope lacks throws
global local dev prod that view's merged values
process the declared process variables, read from process.env at each read
read(source) the process view of another source object (a test seam)
sealedKeys(scope) the set of keys whose winning value in scope is sealed

sealed("<base64>") takes the base64 of an age cipher made for the recipient in .repo/env.keys.json ({ "recipient": "age1…", "repo": "<name>" }); plaintext or an empty string throws at once. Nothing decrypts until a sealed value is read: the first such read decrypts every sealed value of the env and its parents in one bun child and keeps the plaintext in that instance only, so a plain read needs no key. The key is WORKERSTACK_ENV_KEY (an age identity) when set, else every ~/.workerstack/keys/*.key; the repo's own is ~/.workerstack/keys/<repo>.key, saved at 0600.

Commands

bun repo env <verb> seals, lists, reads and hands out the values of the defineEnv files, so no age CLI or base64 is needed. A value never enters argv: set reads a piped stdin whole (one trailing newline dropped) or, on a TTY, a hidden prompt.

$ bun repo env init                                # rerun with the same key: repo env: already set up (acme)
repo env: wrote .repo/env.keys.json (recipient age1qz…) and ~/.workerstack/keys/acme.key (0600); back the key up in a password manager
$ echo "$v" | bun repo env set STRIPE_SECRET_KEY --scope prod      # --file <env file> picks the file
repo env: sealed prod.STRIPE_SECRET_KEY in projects/shop/env.ts
$ echo acme.io | bun repo env set APEX_DOMAIN --scope global --plain
repo env: wrote global.APEX_DOMAIN (plain) in projects/shop/env.ts
$ bun repo env ls
KEY                SCOPE   KIND    FILE                   USED BY
APEX_DOMAIN        global  plain   projects/shop/env.ts   projects/shop/deploy.ts
STRIPE_SECRET_KEY  prod    sealed  projects/shop/env.ts   projects/shop/deploy.ts, projects/shop/seed.ts
$ bun repo env get STRIPE_SECRET_KEY --scope prod                  # --reveal prints it whole
sk_test_51…••••
$ bun repo env run --scope prod -- wrangler deploy                 # values reach that child's env only
$ bun repo env export --scope dev --format dotenv
APEX_DOMAIN='acme.io'
$ pbpaste | bun repo env key import                # rerun: repo env: already set up (acme)
repo env: saved ~/.workerstack/keys/acme.key (0600); it unlocks .repo/env.keys.json's recipient
$ bun repo env key ci --dry-run
repo env: would run: gh secret set WORKERSTACK_ENV_KEY (the key on stdin)
Verb Does
init writes .repo/env.keys.json and the key ~/.workerstack/keys/<repo>.key (0600); the repo is named by the main checkout's folder, so a worktree names the same repo; the same key again prints already set up, a key file for another recipient is refused
set KEY --scope s seals the value read from stdin into the env file's s block in place (or writes it plain with --plain; --scope global writes global), keeps every other byte, adds the sealed import when missing, and writes through a temp file and rename
get KEY --scope s prints the value masked (first 10 characters, then …••••; •••• under 12 characters); --reveal prints it whole
ls one row per key: KEY, SCOPE, KIND (plain or sealed), FILE and USED BY (the tracked files that import the env file and name the key)
run --scope s -- cmd runs cmd with the scope's values in its env only and passes its exit code through; WORKERSTACK_ENV_KEY is not handed to the child
export --scope s --format dotenv prints the scope's values sorted, one KEY=value line each, quoted so dotenv and Bun's --env-file read them back: single quotes when the value has no ', else double quotes with only \n and \r escaped, else backticks
key import reads an age identity from stdin, checks it unlocks the recipient in .repo/env.keys.json and saves it as ~/.workerstack/keys/<repo>.key (0600); the same key again prints already set up
key ci [--dry-run] sets the WORKERSTACK_ENV_KEY secret through gh, with the key on gh's stdin; of several keys it picks the one matching the recipient; --dry-run prints the line it would run

The env file is --file when given, else the env files that declare the key, else the one env file in the repo. export refuses a value that has a $ (Bun expands it in every quote, dotenv in none) or that no quote holds: <KEY> has no dotenv quoting that bun and dotenv read back the same; pass it with \bun repo env run`. A bad verb or flag prints usage: bun repo env init | set KEY --scope <global|local|dev|prod> [--plain] [--file f] | get KEY --scope s [--reveal] | ls | run --scope s -- cmd | export --scope s --format dotenv | key import | key ci [--dry-run]` on stderr and exits 4. A refusal is one line, exit 1:

repo env: no defineEnv file declares STRIPE_SECRET_KEY; pass --file <env file>
repo env: 2 env files: a/env.ts, b/env.ts; pass --file
repo env: no .repo/env.keys.json; run `bun repo env init`
repo env: the pasted key does not unlock age1qz…; paste this repo's key
repo env: init found ~/.workerstack/keys/acme.key for another recipient; move it aside first

The runtime errors, one line each (<key> is the read, <repo> the name in .repo/env.keys.json):

repo env: REPO_ENV is "staging"; set it to local, dev or prod
repo env: NPM_TOKEN has no prod value in src/env.ts; add it to its prod block
repo env: process.HOME is required and unset
repo env: NPM_TOKEN is sealed and no key is at ~/.workerstack/keys/<repo>.key; run `bun repo env key import`
repo env: NPM_TOKEN is sealed for another key than ~/.workerstack/keys/<repo>.key; run `bun repo env key import` with this repo's key
repo env: NPM_TOKEN could not be decrypted: <reason>

bun repo check reads every tracked .ts, .tsx and .mts file that calls defineEnv( and fails, one line each:

env: src/env.ts global.NPM_TOKEN looks like a secret (credential name) but is plain; run `bun repo env set NPM_TOKEN --scope global`
env: src/env.ts dev.NPM_TOKEN is missing (prod has it); add it to dev or remove it from prod
env: src/env.ts reads process.env.HOME but its process block does not declare it; add `HOME: "optional"`

The reason is sk_ prefix (or another provider prefix), high entropy or credential name; pk_live_ and pk_test_ values pass. A scope block with a spread or no key takes no part in the missing-key rule. The check also reads a config held in a same-file const, through as const, satisfies or parentheses (const config = { … } as const; defineEnv(config)).

Agents and references

@workerstack/repo/agents/* holds the stock skills, rules, subagents and hooks that bun repo sync links into each host tree. @workerstack/repo/references/* holds these files, which a repo's own references/INDEX.md links in node_modules.

A subagent source may carry variants:, a list of mappings. One source renders one subagent per entry, each entry deep-merging over the source's frontmatter without variants. Every entry needs a name, which names its file:

variants:
  - name: implementer-deep
    claudecode:
      model: opus
  - name: implementer-fast
    claudecode:
      model: sonnet
tier: implementer

Sync

Skills naming

Do not reuse a repo skill name for a consumer custom skill: a repo-owned skill of the same name in .repo/skills/ replaces the stock one. Pack names stay reserved when their directory is absent.

bun repo sync links the stock skills from the installed package into each host tree and writes the rest; it never copies stock references or scripts. doctor/listen/speak call the consumer's root scripts/lib/env.mjs loadEnv when that file exists.

Upgrading is running bun repo update '@workerstack/*': it bumps every @workerstack/* package to its npm latest, installs, and prints what that install's sync wrote and removed, plus each package's CHANGELOG sections between the installed and the new version. The first sync in a repo an older bun repo init copied moves your own rules, subagents, skills, settings keys, lessons, hooks and references into .repo/ and references/, untracks the generated paths, removes the copied .rulesync/, and prints one moved line per move. Commit the result.

Each section bun repo sync writes below, like its blocks in .gitignore and .gitattributes, sits between an opening and a closing comment that name it, and the opening one carries a short hash of the text sync wrote. Keep your own lines outside them: a line added or edited inside is moved just below the section by the next sync. A missing, doubled, misplaced or mangled comment is repaired rather than failing sync, each repair printing one line that names the file. A section a new version no longer writes is cut, and a shared file nothing writes any more keeps your lines with its sections cut, removed only when nothing else is left.

Untracked outputs

In a consumer, sync rebuilds these files from the installed package on every install. The skill links point into node_modules. A committed copy drifts from the package and conflicts on each upgrade. So sync keeps them in the repo:ignore block of .gitignore.

bun repo sync and bun repo sync --check refuse when git tracks a file the block covers. They print one line per block line, with the git rm --cached command to run. Migrate and adopt untrack a committed tree themselves.

References

Each references/**/*.md needs a frontmatter description: of at most 32 characters. It may also set order:, a whole number. Sync lists the files in the repo:refs block of that folder's INDEX.md: lowest order first, then the files without one, ties by path. The README's Contents and sections follow that list.

This holds in every repo with the docs pack and in each workspace package. A bad value fails sync and names the file.

Hooks

SessionStart carries only the folder map: Claude / Codex run the session-map hook, which injects the code-only map bun repo map prints as additionalContext (capped under Claude Code's 10,000-char per-hook additionalContext limit, about 2.4k tokens, below the 5k bun repo map budget; cache-stable, fail-open) and logs {rows, tokens} to .repo/tmp/sessions/<sid>/map.json. Inject nothing else at SessionStart. Antigravity has no generated sessionStart. Do not register a SessionStart hook on .grok/hooks/orchestrate-parent.json.

Grok PostToolUse / SessionStart are observe-only — do not attempt output scrub; compress via RTK / spawn / isolation facts only. Do not add PostToolUse scrub hooks. UserPromptSubmit may emit a one-line approval=ask|away effort=fast|deep strategy=free|spawn-fast|spawn-deep plus session receipt, then up to 8 other live lines from ~/.workerstack/board/ (hookSpecificOutput.additionalContext). The current session's own line is omitted. Skip host Stop banners. A prompt whose first word is /ask, /away or /set saves the words in .repo/tmp/sessions/<sid>/settings.json and the hook context carries the change or error line. Sid-less: nothing is saved. The three words are the effective values: the session value wins; for approval, else the newest in-progress plan or roadmap in this session's .repo/tmp/sessions/<sid>/approvals.txt with a valid approval:, else ask. Another session's plans never count.

In approval=away, parent Stop blocks a reply whose last line ends in ? and does not end with an approval block. Reason: away: do not ask; decide, log "- decision: {question} → {choice}, because {why}" in the plan, and continue. The block uses the stop-block counter, cap 3, so a true blocker still reaches the person. Under away, once the review settles, Stop also runs the approval block's header choice (a bare i) for every kind (plan, roadmap, fast-plan, fast-roadmap). It logs - decision: approval of {kind N} under /away → i (strategy S) because away runs the block's header choice once the review settled in each file's ## Previous mistakes, stamps away-run, and blocks once with away: ran the approval block's header choice and logged it in each file so re-read each file before you edit it; i approves …. The away-run stamp keeps it to one run per approval block. approval=ask does not Stop-block.

Claude / Codex / Antigravity / Cursor PreToolUse still run the scrub-spawn-prompt and gate-fat-tools hooks. Grok spawn/fat rewrite is the orchestrate-parent hook. RTK stays .grok/hooks/rtk.json, which runs the rtk-grok hook. Every repo hook command finds the root ($GROK_WORKSPACE_ROOT, else $CLAUDE_PROJECT_DIR, else its git top level) and runs <root>/node_modules/.bin/repo hook <name>. A checkout with no install prints repo: <root>/node_modules/.bin/repo is missing; run bun install and exits 1, which never blocks the tool call. bun repo sync --check fails any repo hook in another form. Claude Code's WorktreeCreate and WorktreeRemove come from the claudecode block of hooks.jsonc and run the worktree hook (60s), so its worktrees land in .worktrees/<name> on wt/<name>. Other harnesses have no such event and call bun repo worktree add directly.

Cursor

cursor-agent reads .cursor/hooks.json (JSONC, version: 1, camelCase events preToolUse beforeSubmitPrompt stop sessionStart, flat {command, timeout, matcher} entries). bun repo sync writes it (cli/sync/agents/render-hooks.ts) from hooks.jsonc with the matchers untranslated, so the gate and orchestrate-parent matchers name Cursor's tools (Shell, Read, Write, Grep) beside the others. hooks-union.ts appends a consumer's autorun scripts to it as flat entries (Write is Cursor's write matcher). Cursor also reads .claude/settings.json hooks and drops a command it already has in its own file, so nothing runs twice. Cursor refuses a hook file with a symlink on its path: keep .cursor/ a real dir. It reads AGENTS.md, so there is no .cursor/rules. bun repo sync generates .cursor in each consumer (gitignored there); it is not in the package files.

Payload: cursor_version, session_id, model, tool_name, tool_input, cwd; no turn_id, no subagent_type (every Cursor call counts as the parent). gate-fat-tools.ts harnessOf tests a string cursor_version first, because session_id and model also match the Codex check. Tools the gate answers:

Tool Input Gate
Shell command, cwd denylist, .gitignore, git worktree add and main-checkout git guards; dump, search, curl and ls rewrites
Read file_path denylist, 200KB deny, re-read deny
Grep pattern, file_path, glob denylist
Write file_path, content main-checkout deny, redirect of .repo work files and scratch to the root .repo, .gitignore guard; orchestrate-parent persists plans and the like

Read takes no offset or limit and Grep no head_limit, so the gate injects and caps neither (limits: false in TOOLS.cursor). A re-read of a file this session wrote therefore cannot say "Read with offset+limit": Cursor's reason names bun repo job --job exec --query 'sed -n 1,200p <file>'. List and Delete get no answer.

Reply: a deny is {"permission":"deny","user_message":reason,"agent_message":reason}. A rewrite is Claude's nested {"hookSpecificOutput":{"hookEventName":"PreToolUse","updatedInput":{...}}} with no permission and no top-level field (Cursor reads the nested form only then; permission would also auto-approve). Cursor applies a rewritten command (Shell) and file_path (Write), but writes its own content: a persisted tmp write lands at the .repo/scratch/ path with its TMP_NAME: line still in the body. Empty stdout with exit 0 allows. Invalid JSON on stdout blocks the call. stop may reply {"decision":"block","reason":...}. orchestrate-parent handles Cursor's beforeSubmitPrompt only to write git-asked.json (see Worktrees); there it saves no /ask, /away or /set, handles no y or h, prints no board line and adds no context.

Session agent tools are on. Fat Read/Grep/Glob/Bash still gate. Writes under .repo/plans/, .repo/plans-fast/ (a fast plan the person asked for: fast-plan-format.md; no review, a strategy set by the save as a plan's, the plan's block), .repo/roadmaps-fast/ (a fast roadmap the person asked for: fast-roadmap-format.md; at most 20 lines, a strategy set by the save, the plan's block), .repo/handoffs/, .repo/implementations/, .repo/roadmaps/, and .repo/scratch/ root files persist via cli/scripts/persist-plan.ts / persist-handoff.ts / persist-implementation.ts / persist-tmp.ts / persist-roadmap.ts. The hook hands the host the absolute path of the saved file. Invalid body denies. A Write of a saved plan updates that file in place (same serial); an Edit of a saved plan is checked against the text it would produce, and a valid one runs as sent. ## Previous mistakes entries are append-only on both. Roadmaps follow the same rules: a Write of a saved roadmap is validated and the host writes it, and an Edit is checked against the text it would produce.

Writes under .repo/lessons/ go through bun repo lessons: a lesson Write in a managed worktree is validated, saved, and reindexed (INDEX.md) there, and the same Write in the main checkout is denied with the Worktrees reason; an Edit of a lesson is denied with the Write and lessons.ts hit routes. Lessons match per holding checkout: the root, or the managed worktree that holds the file; an unmanaged linked worktree falls back to the caller's checkout. On any other Write or Edit, PreToolUse adds additionalContext for lessons whose globs match the file, each once per session (.repo/tmp/sessions/<sid>/lessons-seen.json). It reads the lesson files, not INDEX.md, and fails open.

SubagentStop (and child Stop with subagentType) intercepts a parseable need: whose jobs are all search|exec|slice|fetch (cap 4): runs job in-hook and continues the child with concatenated facts. Mixed/web/test, parse fail, FALLBACK, stopHookActive, or more than 3 intercepts per child session → empty stdout (allow stop).

Stop and SubagentStop timeout is 30s (job cap is 20s). PreToolUse / UserPromptSubmit stay at 5s.

The canonical hooks.jsonc event is beforeSubmitPrompt (→ Claude/Codex UserPromptSubmit), not userPromptSubmit.

[features] two_pass_compaction must live in ~/.grok/config.toml (from agents/global-settings/grok.toml via bun repo yolo). Project .grok/config.toml cannot set [features].

Local ci and bun test

localCiGuard in gate-shell.ts denies a shell command that runs the whole ci: bun repo ci (also run by path or without bun) or bun run ci (npm and pnpm too), alone or in a compound, when ci is followed by the end or a flag. The reason is GitHub runs ci, not you: push the branch, then run `bun repo ci wait <branch>` in the background. To check one change, run the nearest test file with `bun run test <file>`. ci wait, skips, times and merge pass. testRunGuard rewrites bun test (bun's own runner, which cannot run the vitest suites) to bun run test for every caller, in a compound too; an implementer's or a reviewing parent's plain run then goes through bun repo job as before.

Push gate

ci-push-gate.ts makes a turn that pushed wait for ci. On every PreToolUse, parent or subagent, notePush runs before emitFat and notes each git push in the Bash command: bare, origin main, -u origin x, HEAD:refs/heads/x, git -C <dir> push, cd <dir> && git push and chains. bun repo push [<branch>] notes a push of the branch they pick (an explicit branch, or the recent-branch table's); ask, a worktree branch and a detached HEAD note none. Dirs go through path.resolve, and the branch and sha are read in that dir. The entry lands in the session's pushed.json ([{branch, sha, at}]) and stays pending until a later hook sees the sha land on origin/<branch>; a rejected push and every older sha are ignored. The hook writes only pushed.json and its stamp. bun repo release pushes through git from node, not a Bash git push, and waits on ci itself, so the gate does not see it.

Per branch, only the newest landed sha counts. The parent's Stop blocks while that sha has no terminal record in .repo/tmp/ci/<sha>.json (written by bun repo ci wait): passed, nothing and error settle it, and so do three failed records of this session's pushes on the branch sharing a job. ci.yml cancels a run when the branch gets a newer push, so a sha with no record or a superseded one is also settled by a passed record of a later commit on origin/<branch> that contains it (git rev-list --ancestry-path <sha>..origin/<branch>); a later failed record does not settle it. Another session's records do not count. The record lives under the main checkout's .repo/tmp/ci/, so a push from a worktree and a wait from the root meet. The three lines:

  • Stop: you pushed <sha> to <branch> and its ci run has no result; run `bun repo ci wait <branch>` in the background and fix it if it fails
  • Stop: ci failed on <sha> on <branch> (<job>); fix it in a new worktree, push, and run `bun repo ci wait <branch>` in the background; if only the user can fix a job, run bun repo backlog add 'blocked: `<job>` <why>' and name that line in your reply
  • Stop: <job> failed 3 times on <branch> this session; report the failure and its logs to the user, then stop

A job only the user can fix is marked in the main checkout's .repo/backlog.md with an open line from column 0, - [ ] blocked: `<job>` <why> (or - [-]), which bun repo backlog add 'blocked: <why>' writes. Each line names one job, with its exact name from the run; an indented line, a * bullet and a backlog in a worktree do not count. A failed run whose jobs are all blocked lets the Stop go on; otherwise the failed line names only the jobs that are not blocked, and blocked jobs do not count toward three failures. A failed run with no job names is never released. Ticking the line [x] re-arms the gate.

The gate runs for the parent before the ask and away checks and before the stopHookActive pass. It has its own push-block stamp, capped at 6 blocks a turn and cleared on the next prompt beside stop-block; it is independent of the stop-block cap of 3, which ask and away share. A subagent's Stop is unchanged (interceptNeed); its pushes settle at the parent's Stop.

Review strategy

A plan or roadmap carries strategy: free|spawn-fast|spawn-deep (plan-format.md). free adds nothing here. For spawn-* the main session reviews and an implementer agent writes (review-loop.md). The code is cli/scripts/strategy.ts and cli/scripts/review-stamps.ts, called from gate-fat-tools.ts and cli/scripts/orchestrate-parent.ts.

Stamp: a parent's save of a plan or fast plan whose frontmatter holds a spawn-* strategy at ready or in-progress writes one file per plan, .repo/tmp/sessions/<sid>/reviews/{plan|fast-plan}-<basename>.json, which holds the plan's canonical path. A session holds as many stamps as it saved spawn plans, and two saves never rewrite one shared file. A box tick on such a plan stamps too, so a resumed run in a new session stamps on its first save. Each plan save first prunes every stamp whose plan is gone, has no frontmatter, or is no longer spawn-* at ready or in-progress; a Write that saves the plan done, blocked or free removes its stamp at once. An Edit is judged by the file before it lands, so an Edit that sets done drops the stamp at the next plan save. A child's save stamps nothing. The gate writes stamps for every harness. orchestrate-parent calls stampPlanSave first for the plan Writes it persists itself. activeReviews in cli/scripts/review-stamps.ts reads each stamped plan: it is active while status is in-progress and strategy is spawn-*. An old strategy.json is never read.

Deny: reviewGuard in processFatTools sits right after the worktree deny, before the write branch and the scout return. While a review is active in an enforced harness, a parent write whose target is outside the root .repo/ work dirs is denied. The target is judged by canonical path after the scratch rewrite, not as typed. A child, a missing sid and an unenforced harness are not denied. Reason with one active review: plan {n} runs strategy {s}: this session reviews. Spawn {implementer} for product writes; the plan, the journal and .repo/ work dirs stay writable. With several, plans by serial, then fast plans by serial, each named (a and b, a, b and c): plan 3 runs strategy spawn-fast and fast-plan 4 runs strategy spawn-deep: this session reviews. Spawn implementer-fast for plan 3 and implementer-deep for fast-plan 4; the plans, the journals and .repo/ work dirs stay writable.

Implementer guards apply under every strategy: the spawn prompt names the worktree path, so the implementer's first Write is under .worktrees/<name> (the main-checkout deny holds for it too); an implementer-* child's write under .repo/plans or .repo/implementations is denied (implementer writes product files and tests only; the reviewer writes the plan and the journal), and so is a shell git add or git commit (implementer never stages or commits; report the changed files and the reviewer stages them).

Test runs: a plain bun run test … (or vitest, npm test, pnpm test) from an implementer-* child, or from a parent while a review is active, is rewritten to <root>/node_modules/.bin/repo job --job test --query '<command>'. The full log goes to the stash and the caller sees the failing lines. A compound command passes through.

Approval: on Claude Code, outside Grok and away, the Stop hook blocks once per turn while a reviewed item waits, even when the reply already ends with the right typed block, and names the approval picker. The skills tell Claude Code to print no typed block there, so the person sees only the picker. The reason is call AskUserQuestion with exactly this input, then act on the answer; with no AskUserQuestion tool, end with this block:, the JSON input, then the typed block. The input has an Approval question (implement or hand off) and a Strategy question (the saved value first, then the advised one, then the rest; a mixed queue starts with as saved). A PreToolUse gate on AskUserQuestion denies an approval picker (a question headed Approval) that differs from the rendered one (the approval picker must be exactly this input: and the input), one under away (under away the hook runs the approval block's header choice; end the reply with the approval block) and one when nothing waits (nothing waits for approval; ask no approval picker); other pickers pass. A PostToolUse hook reads tool_input.answers, keyed by question text, and runs the same approval code as a typed i or h with a word, so the context (approve, strategy write, handoff, unsettled refusal) is the typed reply's. An answer that is no option's label is feedback: "{text}" is feedback on plan {n}: revise it, run /review-plan {n} again, then stop for approval again, and the next Stop asks again. Codex, Cursor, Antigravity, Grok and away keep the typed block. Otherwise the Stop hook blocks a reply that should ask for approval unless it ends with an approval block that names every pending item with the strategy its file holds. The block reason is end with this block: and the block closingBlock renders from the files. On the next prompt, i returns i approves plan {n}: plan {n}: run /implement-plan-deep {n} (<path>) and leaves the file alone. i with a strategy word (i free, i fast, i deep, or spelled out) writes that strategy into each approved item of any kind (validated through the persist module), and once the writes succeed it also sets the session strategy; the context adds set strategy spawn-fast (was free) and says to re-read the file before work. A retired letter code (if, isf, isd, hf, hsf, hsd, or one close to it), or a strategy word within two edits of several choices, approves nothing: "{reply}" is not a choice; reply i or h, alone or with free, fast or deep; end with this block again: and the block. Any other reply gets no approval context.

The approval block closingBlock renders, for one plan, fenced as code:

plan {n} · {s}
  plan {n} advice: {advice}
  i  implement    i free · i fast · i deep
  h  hand off     h free · h fast · h deep
     or reply to revise

A queue joins header items with , then . An item whose file has advice: adds an advice row under the header, one per item in header order; the Stop hook blocks a block whose advice row differs from the file's. The fixed rows never change. Replies are case-insensitive, and a trailing . or ! is ignored. i, y, yes, go and do it approve with each file's strategy, and h hands off with it. The six letter codes are retired with no alias. An action (i, y, h, or a word within two edits of implement or hand off) may take one or two strategy words, exact or within two edits of exactly one of free, spawn-fast, spawn-deep, fast or deep. Only words of four or more letters are inferred, A bare strategy word with no action, or strategy words that match nothing (i think so), is feedback.

Settled review: a parent spawn whose prompt starts /review-plan {n} or /review-roadmap {n} records {"plan {n}": <reviews at spawn>} in .repo/tmp/sessions/<sid>/reviews-open.json, keyed by plan {n} or roadmap {n}, never by path. An item is settled when its file's reviews is above the recorded number, or nothing was recorded and reviews is at least 1. Stop blocks an approval block that names an unsettled item, before the approval and mode checks: plan {n}'s review has not settled; wait for it, settle its findings with reviews bumped, then end with the approval block, or on reviews: 0, plan {n} has reviews: 0; run /review-plan {n} in a fresh agent first. A Stop block fires once, so an i or h on an unsettled item is refused too: i did not approve plan {n}: its review has not settled; wait for it, then end with the approval block again (h did not hand off … for h). Unsettled items never count as pending.

Handoff: h keeps each file's strategy; h with a strategy word writes that strategy through the same validated write as i with one, and leaves the session strategy alone. The hook writes one handoff for the whole block through persistHandoff: H1 run-plan-{n} (or run-{kind}-{n}-then-…), Goal names the items, State their paths and strategies, Context the source session, Next one leaf per item in order (/implement-plan-deep {n}, or set status locked, then /implement-roadmap-deep {n}), Watch-outs (none). The context: h handed off plan {n} (strategy spawn-fast written) to <abs path>; tell the person to run /pickup {serial} in a fresh session, and do no work here.

Enforcement, from the live probe of each harness's child payload:

Harness Child marker Enforced
claude agent_type and agent_id yes
codex agent_type and agent_id; shares the parent's session_id yes
grok subagentType, own sessionId; harnessOf reports claude yes, through claude
antigravity none; only a different conversationId, its only session-id key no
cursor none; only a different session_id no

AskUserQuestion is Claude Code only, proven by a live probe on 2.1.325: PostToolUse gets tool_input.answers keyed by question text, a typed Other answer arrives as the text, and PreToolUse sees only questions. claude -p has no such tool, so the typed block stays the fallback.

An unenforced harness follows the rule as an instruction (review-loop.md). Cursor's beforeSubmitPrompt writes only git-asked.json in orchestrate-parent, so i, h and their strategy words have no effect there and no slash settings: /away is prompt-only in Cursor, though a saved approval: away still runs Cursor's Stop. Antigravity has no Stop hook, so the settled-review, away and approval-run rules are prompt-only there. Headless cursor-agent -p (v2026.11.05) never fires beforeSubmitPrompt, so no prompt can grant a git subcommand there and the git denies still apply; the Cursor IDE was not tested.

git-asked.json (Worktrees) is keyed by sid. Claude and Codex children share the parent's sid and so its grant; a Grok, Cursor or Antigravity child has its own sid and gets none. Antigravity has no git-asked.json: the gate reads lastUserInput from the tool call, and an agy subagent's calls carry an empty one, so a child gets no grant.

Main-session compaction window: a user setting, modelSettings.<model>.autoCompactWindow in ~/.claude/settings.json. Each agent carries its own value in its claudecode block. Do not set CLAUDE_CODE_AUTO_COMPACT_WINDOW: it overrides every agent's value.

Worktrees

The main checkout is root (resolveRoots().root), outside <root>/.tmp and <root>/.worktrees. The gate (gate-fat-tools.ts sharedCheckoutReason, worktree.ts mainCheckoutWriteReason and sharedGitReason) answers for every caller, parent, scout and implementer alike, on every harness whose write tools it knows. It runs beside the git worktree add check, ahead of reviewGuard and every early return.

The rules follow the root's workflow (workflow.md), read from repo.config.ts only when a rule needs it; a config that fails to load, or holds another value, takes the strictest rule of each.

  • Write: under worktree and pull-request, a write whose canonical target (after the scratch redirect) is in the main checkout is denied: the main checkout is shared: run bun repo worktree add <name> and write under <root>/.worktrees/<name>; only <root>/.repo/{plans,plans-fast,roadmaps,roadmaps-fast,handoffs,implementations,scratch,tmp}/ is writable here. Under main it passes. A lesson Write is answered by orchestrate-parent before the gate, so it calls mainCheckoutWriteReason itself. lessons.ts persistLesson, hitLesson and removeLesson return { ok: false, error } with the same text, and the CLI hit, rm and index exit 1; match and check stay read-only, and writeIndex is unguarded because bun repo init and bun repo sync call it in a consumer's main checkout.
  • Git in the main checkout: under worktree and pull-request, commit, merge, cherry-pick, revert, am and pull are denied with the main checkout is shared: commit inside <root>/.worktrees/<name>, then run bun repo worktree finish <name>, and stash, reset, checkout, switch, restore, clean and rebase with git <sub> in the main checkout can destroy other sessions' work: do it inside your worktree, or run it here only when the user's message asks for git <sub>. Under main the commit group passes, and the destroy group is denied with git <sub> in the main checkout can destroy uncommitted work: run it only when the user's message asks for git <sub>.
  • Anywhere in the repo, in every mode: a force push (-f, --force, --force-with-lease, +ref); branch -d|-D|-f|-m|-M of the default branch; update-ref of the default branch. Under pull-request a push or delete push of the default branch (--delete, -d, :<branch>), --all and --mirror are denied too, before any grant, with <default> takes changes only through pull requests (workflow is pull-request): run bun repo worktree finish <name> to push wt/<name> and open its pull request; a config that fails to load gets the same deny with (repo.config.ts failed to load). The default branch is origin/HEAD, else init.defaultBranch, main or master when it exists locally, else the main checkout's current branch.
  • Own worktree: inside the managed worktree the effective cwd is in (cd and git -C are followed), every other git command runs, stash, reset --hard, checkout, rebase and commit --amend included.
  • Exception: .repo/tmp/sessions/<sid>/git-asked.json holds { "words": [...] }, rewritten on every user prompt (an empty list clears it) by orchestrate-parent on Claude, Codex and Grok UserPromptSubmit and on Cursor's beforeSubmitPrompt, and read by the gate by sid. A named word lets that subcommand run for the turn. Bare words grant only stash, commit (so /commit grants it), rebase and cherry-pick; a force push needs force push, force-push, push -f or push --force; every other subcommand needs the literal git <sub>, since "main checkout", "clean up" and "merge to main" are everyday phrases. A negation ("don't stash") matches too, so the AGENTS.md line still applies. Claude and Codex children share the parent's sid and so its grant; Grok and Cursor children do not. Antigravity (1.5.3) writes no file: every tool call carries the user's message as top-level lastUserInput, and the gate feeds it through the same word rules. Only the main agent's calls carry it; an agy subagent's calls carry lastUserInput: "", so a child gets no grant and a spawn prompt cannot grant one. Antigravity's gated tools are write_file {Path, Content}, edit_file {Path, Target, Replacement} and run_command {CommandLine}; read_file {Path, StartLine, EndLine} is not gated. Headless cursor-agent -p (v2026.11.05) never fires beforeSubmitPrompt either, so no prompt can grant a git subcommand there and the denies still apply; the Cursor IDE was not tested.
  • Fail-open: not a git repo, a repo with no commit yet, and an error inside the check let the call through. A missing sid or an unwritable git-asked.json means no grant, so the deny stands. finish, prune and the WorktreeRemove hook run git from node, never the shell, so the git deny cannot block them.
  • Not parsed: bash -c '...', $(...) and xargs git hide a git command from the gate. An rtk or env VAR=v prefix and cd <dir> or git -C <dir> are parsed; a heredoc body is not a command.
  • Shell-write gap: sed -i, >, tee and rm write the main checkout past the write deny, and so do the rm hints in bun repo doctor. The commit deny is the backstop: the change cannot be committed there without the user's message naming commit.
  • bun repo init and bun repo sync rewrite tracked files from node, so run them inside a worktree unless workflow is main. Run in the main checkout under the other modes they leave it dirty, and only the user can commit that.
  • Unmanaged linked worktrees outside .worktrees (T3 Code, Claude Desktop) are outside the root, so writes there pass, finish cannot land them, and /commit there only commits. They are not "own worktree" for the git rules.

bun repo worktree add <name> makes <root>/.worktrees/<name> on wt/<name> and prints the path. When the root has bun.lock it also runs bun install --frozen-lockfile in the new worktree (output on stderr, sub-second on a warm cache; a failure prints one line naming the command and the path still prints), so tools that look up <cwd>/node_modules/.bin, such as pipeline, work there.

bun repo worktree finish [<name>] lands the branch. Under pull-request it goes through the branch's pull request instead, as workflow.md says; with a config that fails to load it exits 1 with repo.config.ts failed to load: fix it, then run finish again. The rest of this paragraph is worktree and main. With no name it takes the worktree its cwd is in; from the root it exits 2 (run finish inside .worktrees/<name> or pass its name). It refuses a dirty worktree (any git status --porcelain line, untracked files included) and one mid-rebase, rebases wt/<name> onto the default branch inside the worktree, fast-forwards the default (merge --ff-only when the root has it checked out, else fetch . wt/<name>:<default>), then runs git worktree remove (never --force) and deletes the branch once the default holds it. If the default moved since the rebase, it rebases again, at most 3 tries (<default> kept moving while finishing wt/<name>; run finish again); a fast-forward that fails with the default unchanged reports git's reason with no retry (the root's own changes to the same file do this). A conflict aborts the rebase, keeps the worktree and names the files: resolve them in the worktree, rerun the checks, run finish again. A branch with no commits of its own is removed with wt/<name> has no commits of its own; removed <path>. Before it deletes a branch the default holds, finish (and prune) deletes origin's copy when refs/remotes/origin/wt/<name> exists, with git push --no-verify origin --delete wt/<name>, no prompt and a 30s limit; on failure it warns could not delete origin's wt/<name>: <git's reason>; run git push origin --delete wt/<name> and goes on. With origin's branch and tracking ref gone, the push gate no longer holds a Stop on the old run of wt/<name>. A rerun after a crash converges: a merged branch with no worktree is deleted, origin's copy first. When the root has the default branch checked out and can run bun repo (a repo script in its package.json, or a repo bin in node_modules/.bin), finish then runs bun repo sync there, output on stderr, so the root's generated configs match the new tree; on failure it warns could not sync <root>: run bun repo sync there and still exits 0. On success it prints merged wt/<name> into <default> at <sha> and removed <path>; your shell may still be in it: cd <root>. Run it from the root: cd <root> && bun repo worktree finish <name>. Run inside the worktree it removes, the work succeeds but Claude's Bash reports exit 1 (its own pwd fails in the deleted directory); check the merged … line and do not rerun.

bun repo doctor lists a managed worktree with commits the default lacks and a last commit 7 or more days old as stale worktree <path> (wt/<name>, last commit <n> days ago) with the finish <name> command. Younger ones are live work and are not listed.

bun repo doctor agent models checks that each model a generated agent names is still offered by its CLI: .codex/agents/*.toml, .grok/agents/*.md, .grok/roles/*.toml and [subagents.models] in agents/global-settings/grok.toml, .agents/agents/*.md (Antigravity) and .cursor/agents/*.md. Claude agents use the aliases haiku, sonnet, opus and are not checked. Cursor agents use model: inherit (the parent session's model, which any Cursor plan can run; a pinned Claude id is listed on a Free plan but refused), and inherit is not checked. Codex and agy are checked without running a command. Codex's list comes from ~/.codex/models_cache.json models[].slug (a file). Agy's is a fixed set, the only values agy accepts for an agent's model:: inherit, flash, pro and flash_lite. agy models lists session models, which an agent file cannot name, and agy silently drops an agent whose model: is anything else. An agy main agent's model: is ignored (it runs on the session model), so the orchestrator uses inherit. An agy agent with no tools: gets a read-only default, so the orchestrator lists its tools. Grok's and Cursor's lists come from grok models and cursor-agent --list-models; those two commands run in parallel under one 20 s cap. A dropped model prints <file>: model <id> is not offered by <cli>; offered: <ids>. Grok or Cursor is skipped with its reason when it is not installed or its list fails or takes over 20 s, and codex when its cache is missing or malformed (agent models ok (skipped: ...)). Agy is never skipped. Report only: the row never fails the run.

Plan format (SSOT)

Local session checklist. Not a design doc, PR stack, or DAG.

Path

.repo/plans/{serial}-{slug}-{YYYYMMDDTHHMMSSZ}.md

  • serial: unpadded decimal int only (1, 12, 10000). Never 0001, never quoted "0001" / "1". Serial ≥ 1.
  • filename: unpadded serial, slug, UTC datetime stamp (6-sync-global-settings-20260826T143000Z.md, not 0001-slug.md). Stamp is created with - and : dropped (keep T and Z).
  • lookup: integer equality on leading digits (1 matches 1-foo-20260826T143000Z.md and legacy 1-foo.md; also 0001-foo.md if an old padded file still exists).
  • valid filename (new): ^[0-9]+-[a-z0-9]+(-[a-z0-9]+)*-\d{8}T\d{6}Z\.md$
  • valid filename (legacy): ^[0-9]+-[a-z0-9]+(-[a-z0-9]+)*\.md$ (no stamp; still accepted for existing files)
  • Frontmatter line must match ^serial: [1-9][0-9]*$. Leading zeros are invalid (YAML octal / VS Code warning).
  • New writes always use the stamped filename. Collision: increment serial, rebuild filename, same stamp.

Slugify (PLAN_NAME)

Send PLAN_NAME: <name> as the body's first line. The save reads the name from it and drops the line wherever it sits above the first ## outside a code fence, so a body that opens with commit: or advice: lines still saves with the card right under the title. A PLAN_NAME: line inside a fence or below the first ## stays as text.

  1. lower
  2. non [a-z0-9] → -
  3. collapse -, strip edges
  4. trim to 50 chars; if cut on -, strip trailing -
  5. empty → unnamed
  6. strip trailing .lock if any

Allocate next serial (repo, bash)

mkdir -p .repo/plans
next=1
for f in .repo/plans/*; do
  [ -f "$f" ] || continue
  b=$(basename "$f")
  case "$b" in
    [0-9]*.md) n=${b%%-*}; n=$((10#$n)); [ "$n" -ge "$next" ] && next=$((n+1));;
  esac
done
# filename prefix: unpadded decimal "$next" (1, 12, 10000)

Collision: if target path exists, increment and retry (max 5).

Frontmatter (required)

---
serial: 1
name: add-auth
status: ready          # ready | in-progress | done | blocked
reviews: 0             # settled /review-plan rounds; never lowered or removed
created: 2026-08-26T14:30:00Z    # UTC ISO-8601 seconds + Z; no milliseconds
source: "<≤120 chars of user task>"
commit: feat: <subject>
strategy: free          # free | spawn-fast | spawn-deep
advice: spawn-deep because the hook and gate change together   # optional, ≤100 chars
approval: away             # optional: ask | away
---

name is the slug only (no datetime). source ≤120 chars.

strategy is set by the save, never by the agent: the session value (/set, bun repo set), then for a plan the newest in-progress roadmap this session saved, then agents.defaultStrategy, then free. An update that omits it keeps it. It says how the plan gets implemented:

  • free: the main session works as it would in a plain chat; it may spawn agents but nothing forces it to.
  • spawn-fast: the main session only reviews. Each phase goes to the implementer-fast agent. The reviewer reads the real diff, reruns the tests and sends findings back. A second attempt goes to implementer-deep.
  • spawn-deep: the same loop, with implementer-deep from the first attempt.

A strategy: line the agent sends is denied unless it equals the saved value: strategy: is set from the session, the roadmap or the config; send advice: {free|spawn-fast|spawn-deep} because {reason}. bun repo persist plan takes the config default and refuses the line the same way. An unknown saved value is invalid with strategy: free|spawn-fast|spawn-deep. The person can change the value with a word such as i fast when approving, or h fast when handing off. i with a word also sets the session strategy.

advice is optional: advice: {free|spawn-fast|spawn-deep} because {reason}, at most 100 characters, the strategy the agent would pick. A body line advice: {value} moves into the frontmatter after strategy: and before approval:, like commit:; a line inside a code fence is not moved. An update that omits it keeps it. A bad line is denied with advice: {free|spawn-fast|spawn-deep} because {reason}, at most 100 characters. /write-plan-deep always sends it on a new deep plan. It changes nothing by itself; the person picks at approval.

approval is optional: ask or away. While the plan is in-progress, its approval applies only when the session has no /ask or /away value, for the session that saved it only. The order is the session approval, then the in-progress approval, then agents.defaultApproval, then ask. /write-plan-deep writes it only when the session runs /away or the person asked for an approval. A body line approval: {value} moves into the frontmatter after advice:, like commit:; a line inside a code fence is not moved. Another value is denied with approval: ask|away.

reviews counts the /review-plan rounds whose findings the writer settled: a whole number with no sign or leading zero, 0 on a new plan. The writer bumps it in the same save that applies a round's findings, even a round with no findings. It never goes down, and once present it cannot be removed, so every Write that sends full frontmatter carries the current reviews. The count proves a round ran and was settled, not that the review was good.

created for new writes: new Date().toISOString() with milliseconds stripped (.\d{3}Z → Z), e.g. 2026-08-26T14:30:00Z. Existing files may still have created: YYYY-MM-DD.

Status machine

ready → in-progress → done | blocked

  • Fresh write: ready
  • Implement start: in-progress, only with reviews ≥ 1. The save denies the move with reviews: 0, and a missing reviews counts as 0. A plan already in-progress without the key stays editable
  • All boxes [x]: done
  • Cannot finish: blocked (leave remaining [ ])

Body headings (this order only)

# {slug}, the card, ## Goal, ## Usage, ## Context, ## Constraints, ## Watch-outs, ## Steps, ## Verify, ## Previous mistakes

No other ## headings. A plan saved before ## Usage or ## Previous mistakes existed stays valid without them; a fresh save needs both. Lines inside a code fence are never headings or checkboxes. No PR Plan, Key Decisions, or DAG. No required Why heading. Optional why footnote only (not a ##). Pick2 file plan stays how-first; missing Why heading does not block.

Card

The 5 lines a person reads first: right after # {slug} and one blank line, then a blank line before ## Goal.

WHAT   notes export --json prints a JSON array
WHY    scripts parse notes without the table
RISK   empty notebook must print []
TOUCH  src/cli.ts, src/export.ts (+ test)
DONE   bun run test; --json | JSON.parse ok
  • Labels WHAT, WHY, RISK, TOUCH, DONE, in that order, each padded to 7 columns so the text starts at column 8. The text is never empty.
  • Each line is at most 120 characters.
  • The card sums up Goal and Steps for a person reviewing at a glance. It does not replace ## Goal, which tools and /review-plan read.
  • Fresh writes only. An update to a plan saved without a card stays valid.
  • Each failing line has its own deny: start with the 5-line card after the title: WHAT, WHY, RISK, TOUCH, DONE, card line 2 must start with WHY, card line 4 (TOUCH) is 131 characters; the limit is 120.

Size

At most 100 lines above ## Previous mistakes, frontmatter included. More than that is two or more slices: write a roadmap with /write-roadmap-deep. A fresh save over the cap is denied with 131 lines above ## Previous mistakes, the cap is 100; split it into a roadmap (/write-roadmap-deep). An update over 100 lines is denied only when it is longer than the saved file, so a legacy plan can still flip boxes. ## Previous mistakes never counts: it grows during the run.

Previous mistakes

This plan's own log of what went wrong. Last heading, after Verify, no checkboxes. A new plan writes (none).

  • Entry: - {source}: {what was wrong} → {what changed}, optionally ending (lesson: {slug}).
  • source: feedback (the user sent the plan back), verify (a Verify or test failed after a fix), review (an accepted /review-plan finding), premise (an assumption turned out wrong), decision (a choice made under away instead of asking).
  • A decision entry is - decision: {question} → {choice}, because {why}.
  • Append-only. A save that edits or drops an existing entry is denied and names the entry.
  • Write a lesson only for a mistake our normal workflow will hit again on other tasks. A one-time issue stays in the plan's Previous mistakes. An entry that will happen again on other tasks also becomes a lesson: node_modules/@workerstack/repo/references/lessons.md.

Revisions

A Write of a saved plan updates that file: same path, serial, and created. The given status and [x] boxes are kept. A body without frontmatter keeps the saved frontmatter. An Edit of a saved plan is checked against the text it would produce and runs as sent when valid. Never save a revision as a new plan.

Checkbox grammar

  • Open: [ ] (space in box required)
  • Done: [x] (accept [X], write [x])
  • Line: ^{id} [ ] {text}$ with optional 0–2 space indent
  • {id} = N / N.M / N.M.K (digits + dots). Not 1. [ ]. Not GitHub - [ ]
  • Fresh plans: all [ ], never [x]
  • Marking done: only flip the box. No renumber, delete, or rewrite leaf text
  • Depth 2 default (N phase + N.M leaf). 3rd level only if a leaf would itself be a mini-plan
  • Every phase N has ≥1 leaf N.M. The session agent executes leaves. Roll up: all children [x] ⇒ parent [x]
  • Verify = last numbered phase, same grammar
  • A Read leaf (N.M [ ] read `principle-…` ) sits in the phase it gates, before the leaves it governs. A phase whose every leaf reads a principle gates nothing and is denied on a fresh write: phase 1 only reads principles; move each read into the phase it gates. An update is denied only when the saved file had no such phase. Verify reads principle-prove-it-works beside its command leaves
  • Done plan: every checkbox [x], status: done

Parallel phases

A phase line may end (parallel: <path>, <path>, …), listing every file its leaves write, repo-relative and comma-separated. An entry is a file (src/cli.ts), a folder ending / (src/parse/), or a glob (src/*.test.ts):

2 [ ] Parser (parallel: src/parse.ts, src/parse.test.ts)
2.1 [ ] add the parser in src/parse.ts
3 [ ] Printer (parallel: src/print.ts, src/print.test.ts)
3.1 [ ] add the printer in src/print.ts
  • Adjacent parallel phases form a group whose phases can run at the same time. A group needs two or more phases, and no two of its lists may share a path.
  • Entries are compared by path segment, never resolved on disk; \ reads as /, so src\cli.ts and src/cli.ts are the same path. A folder reaches every path under it. A glob segment matches a plain one when its pattern does (* and ? stay inside one segment, ** spans any depth). Two glob segments always count as overlapping. Two entries overlap when one path could match both: src/ overlaps src/cli.ts, cli/*.ts overlaps cli/init.ts, and cli/*.ts does not overlap cli/bench/.
  • Denies: parallel phases 2 and 3 both list src/cli.ts; parallel phases need disjoint files, parallel phases 2 and 3 overlap at src/ and src/cli.ts; parallel phases need disjoint files, phase 4 is parallel alone; parallel needs two or more adjacent phases, phase 2 is parallel with no files; list the files it writes after parallel:.
  • Under a review strategy a group runs at once as references/review-loop.md says; bun repo phase-files <plan> <phases> checks that each phase wrote only its list, owning a changed file by the same segment rule.
  • Every path a parallel phase's leaves name sits in its list. The save cannot see that; /review-plan checks it.

Prose vs steps

Section Amount
Goal 1–3 sentences. What “done” means.
Usage What a person or agent types and sees once the plan lands: each new or changed command, flag, output, error message, or file, in fenced blocks, with the before for each changed one. Internal steps nobody types or reads need none. A plan with no visible change writes one line: No visible change: and why.
Context / Constraints / Watch-outs bullets only. Watch-outs: non-obvious footguns; empty allowed ((none)). A new plan that names a file a lesson matches cites that lesson path in Watch-outs, or the save is denied. Context includes Task type:, Named checks: from node_modules/@workerstack/repo/references/scope.md ## Types, and a skip-with-reason when a named check is dropped. A roadmap slice plan holds Roadmap: <abs roadmap path> slice N with the slice's why.
Steps bulk of the file; each leaf = one coherent edit or one file-level action
Verify 1–5 runnable leaves (cmd or observable)

Tests live in Steps (not a new heading). New behavior or a bug: a failing test leaf before the impl leaf. Other behavior change: a test leaf immediately after the impl leaf. Docs, rename-only, config-only, generated: no test leaf. Verify is the end gate (project cmd or named subset), not a substitute for those leaves. Do not re-run the whole suite after every leaf unless it is cheap.

Verify batching

Send these Verify leaves in one message: bun repo lint, bun repo fmt --check, bun --bun tsc --noEmit -p <config>, bun repo sync --check and bun repo docs --check. None writes a file another reads. Run every other leaf alone after them: tests, bun repo coverage, bun repo ci, bun repo check, and the write forms bun repo lint --baseline, bun repo fmt, bun repo sync, bun repo docs, and tsc with --incremental or emit. When a batched check fails, fix it and rerun the whole batch.

Thinker cites repo-relative paths (cli/scripts/x.ts). The plan's worktree {serial}-{name} does not exist until the serial is saved, so /implement-plan-deep applies each path inside that worktree; an absolute path under the root is accepted too and names the same path there. Leaves must be implementable without re-planning. Product fork → pick the narrowest default, state it under Constraints, still emit steps.

Validation checklist

  • filename matches new stamped regex or legacy unstamped; new writes unpadded + stamp (1-foo-20260826T143000Z.md not 0001-foo.md); serial int equals leading digits; name equals slug after stripping optional trailing -\d{8}T\d{6}Z
  • frontmatter serial line matches ^serial: [1-9][0-9]*$ (unpadded decimal ≥ 1; reject 0001, "0001", 0, 01)
  • frontmatter keys: serial, name, status, reviews, created, source, strategy. strategy is optional and is free, spawn-fast or spawn-deep; the save sets it (session value, in-progress roadmap, agents.defaultStrategy, free), an agent line that differs is denied, and another value is invalid. Optional advice is {strategy} because {reason} within 100 characters; another form is invalid. Optional approval is ask or away; another value is invalid. reviews matches ^(0|[1-9][0-9]*)$, is 0 on a fresh write, is not lowered or removed on an update, and is ≥ 1 for a change into in-progress; a legacy plan without it stays valid. Optional commit: is a Conventional Commits subject (type(scope): subject, scope optional, imperative lowercase, no trailing period). A new plan includes it. A plan with no commit key stays valid. A present commit that is not that subject is invalid
  • status ∈ ready | in-progress | done | blocked; created YYYY-MM-DDTHH:MM:SSZ (new writes) or legacy YYYY-MM-DD; source ≤120
  • headings in order only; no extra ##; ## Usage right after ## Goal, required on a fresh write and optional on an update to a plan saved without it, holding a fenced block or a No visible change: line; lines inside code fences are never headings or checkboxes; ## Previous mistakes last and required on a fresh write, entries valid and append-only on an update; fresh write cites every matched lesson in Watch-outs; no required Why heading (optional footnote only)
  • ≥1 N.M [ ] leaf; every phase has ≥1 leaf; Verify is last numbered phase
  • PLAN_NAME: dropped wherever it sits above the first ## outside a fence
  • ≤100 lines above ## Previous mistakes, frontmatter included; an update over 100 only when no longer than the saved file
  • no phase whose every leaf reads a principle, on a fresh write or on an update whose saved file had none
  • (parallel: …) phases come in adjacent groups of two or more, each with a non-empty list, no two entries overlapping after \ → /
  • checkbox grammar (N [ ] / N.M [ ] / N.M.K [ ]); no - [ ]; no 1. [ ]
  • fresh write: the card right after # {slug} and one blank line, WHAT WHY RISK TOUCH DONE in order, padded to 7 columns, text non-empty, each line ≤120 characters; zero [x] / [X]; Goal 1–3 sentences; Context/Constraints/Watch-outs bullets; Context includes Task type:, Named checks:, and a skip-with-reason when a named check is dropped; frontmatter includes commit: and strategy: (set by the save); Verify 1–5 leaves

Worked example

File: .repo/plans/1-notes-export-json-20260826T143000Z.md

---
serial: 1
name: notes-export-json
status: ready
reviews: 0
created: 2026-08-26T14:30:00Z
source: "add --json to notes export"
commit: feat(notes): add --json to notes export
strategy: free
---

# notes-export-json

WHAT   notes export --json prints every note as one JSON array
WHY    scripts read notes without parsing the text table
RISK   an empty notebook must print [], and the table stays the default
TOUCH  src/cli.ts, src/export.ts (+ test)
DONE   bun run test passes; notes export --json | JSON.parse exits 0

## Goal
`notes export --json` prints every note as one JSON array on stdout, so scripts read notes without parsing the text table. The text table stays the default.

## Usage
Before:

```console
$ notes export --json
error: unknown flag --json
```

After:

```console
$ notes export --json
[{"id":1,"title":"groceries","updated":"2026-08-26T14:00:00Z"}]
$ notes export
1  groceries  2026-08-26 14:00
```

## Context
- Task type: feature
- Named checks: failing test before impl; read `principle-test-behavior-not-implementation`
- `/home/dev/Development/example-app/src/cli.ts` parses flags; `/home/dev/Development/example-app/src/export.ts` prints the table
- Rejected: a separate `notes json` command, which splits one export into two

## Constraints
- Keys are `id`, `title`, `updated`, in that order; `updated` is ISO 8601 UTC
- No new dependency

## Watch-outs
- An empty notebook prints `[]`, not nothing

## Steps
1 [ ] Failing test
1.1 [ ] read `principle-test-behavior-not-implementation`
1.2 [ ] in `/home/dev/Development/example-app/src/export.test.ts`, `notes export --json` on two notes prints a two-element array with those keys, and on none prints `[]`
1.3 [ ] run `bun run test export`; the new cases fail on the unknown flag
2 [ ] Implement
2.1 [ ] parse `--json` in `/home/dev/Development/example-app/src/cli.ts`
2.2 [ ] in `/home/dev/Development/example-app/src/export.ts`, print `JSON.stringify` of the notes when `--json` is set
2.3 [ ] run the test from 1.3; all pass

## Verify
3 [ ] Verify
3.1 [ ] read `principle-prove-it-works`
3.2 [ ] `bun run test` passes
3.3 [ ] `notes export --json | node -e "JSON.parse(require('fs').readFileSync(0, 'utf8'))"` exits 0

## Previous mistakes
(none)

Roadmap format (SSOT)

Ordered program of child plans. Not a plan, a handoff, or a design doc.

Path

.repo/roadmaps/{serial}-{slug}-{YYYYMMDDTHHMMSSZ}.md

  • serial: unpadded decimal int ≥ 1. Never 0001.
  • filename: ^[0-9]+-[a-z0-9]+(-[a-z0-9]+)*-\d{8}T\d{6}Z\.md$
  • Create mints the next serial. A later write of an existing file keeps that path and serial. The hook validates the update and the host writes it. An Edit of a saved roadmap is checked against the text it would produce, and a valid one runs as sent.
  • Child plans stay in .repo/plans/.

Frontmatter

---
serial: 1
name: auth-program
status: draft          # draft | locked | in-progress | done | blocked
reviews: 0             # settled /review-roadmap rounds; never lowered or removed
created: 2026-10-25T18:00:00Z
source: "<≤120 chars>"
strategy: free          # free | spawn-fast | spawn-deep
advice: spawn-fast because the slices are small   # optional, ≤100 chars
approval: away             # optional: ask | away
---

draft → locked → in-progress → done | blocked.

strategy is free, spawn-fast or spawn-deep. It means what it means in a plan (see plan-format.md). The save sets it, never the agent: the session value (/set, bun repo set), then agents.defaultStrategy, then free; a save that omits it keeps the saved value. A strategy: line the agent sends is denied unless it equals the saved value: strategy: is set from the session, the roadmap or the config; send advice: {free|spawn-fast|spawn-deep} because {reason}. On a roadmap it is the default its slices take: a slice plan gets the session value, then the roadmap's, then the config. The person can change the value with a word such as i fast when approving, or h fast when handing off. i with a word also sets the session strategy.

advice is optional, as in a plan (see plan-format.md): a body-only save sends a bare advice: {free|spawn-fast|spawn-deep} because {reason} line, at most 100 characters, above ROADMAP_NAME:, and a save that omits it keeps the saved one. A bad line is denied with advice: {free|spawn-fast|spawn-deep} because {reason}, at most 100 characters.

approval is optional: ask or away, as in a plan (see plan-format.md); the order is the session approval, then the in-progress approval, then agents.defaultApproval, then ask. A body-only save may send a bare approval: {value} line above ROADMAP_NAME:, and a save that omits it keeps the saved value. Another value is denied with approval: ask|away.

/implement-roadmap-deep refuses draft.

reviews counts the /review-roadmap rounds whose findings the writer settled. It starts at 0, never goes down, and once present cannot be removed. A body-only save sends it as a bare reviews: {n} line above ROADMAP_NAME:, and a save that omits it keeps the saved count. Moving into locked needs reviews ≥ 1; a missing key counts as 0. A roadmap already past draft without the key stays editable.

Body (this order only)

A roadmap is pointers: what each slice ships, in order. Decisions, Usage, context and the why of each slice go into that slice's plan, which /implement-roadmap-deep writes.

# auth-program

WHAT   auth ships as two slices: reserved types first, then the commit diff pass
WHY    later slices add skills, so their names must be reserved first
RISK   init and INDEX drift apart when a name changes
TOUCH  init reserved names, INDEX, commit skills
DONE   both slices done; repo init lists auth-types

## Order
1 [ ] auth types: Reserved names list the new skills.
      Location: /home/me/app/.repo/plans/14-auth-types-20261025T165531Z.md
2 [ ] commit diff pass: Commit skills strip narrating comments.

## Verify
3 [ ] Verify
3.1 [ ] both child plans have finished Verify

## Previous mistakes
(none)
  • Card: the 5 lines after # {slug} and one blank line, as in a plan (plan-format.md): WHAT, WHY, RISK, TOUCH, DONE, text at column 8, at most 120 characters each.
  • Headings: ## Order, ## Verify, ## Previous mistakes, nothing else. Lines inside a code fence are never headings. A new roadmap with any other ## is denied with a new roadmap has only ## Order, ## Verify and ## Previous mistakes; move ## {heading} into the slice plans.
  • Slice: one line, N [ ] {title, 2–6 words}: {one sentence}, optionally ending (parallel: <path>, …) (see Parallel slices). The sentence says what that slice's plan ships. A slice that breaks it is denied with slice N needs "N [ ] {title, 2–6 words}: {one sentence}".
  • Location: an optional indented line under the slice. Omit it in a draft. /implement-roadmap-deep sets it to the absolute plan path /write-plan-deep reported; a slice that has one does not get a second plan. A relative .repo/plans/{file} also resolves.
  • [x] only after that child plan's Verify is done.
  • Verify: 1–5 runnable program gates, same N.M [ ] grammar as plans.
  • Previous mistakes: (none), or one entry per line. Append-only.
  • Cap: at most 60 lines above ## Previous mistakes, frontmatter included, Location: lines not counted, so filling them in mid-run never trips it. A fresh save past 60 is denied with {n} lines above ## Previous mistakes, the cap is 60; move detail into the slice plans; an update only when it grows past both 60 and the saved file.

A roadmap has at least two slices. One slice is /write-plan-deep.

Roadmaps saved before pointers

A roadmap saved with ## Goal keeps that shape and its rules for every later save, even one that deletes ## Goal: the saved file decides. Its headings are # {slug}, ## Goal (1–3 sentences), ## Usage, ## Context, ## Decisions, ## Order, ## Watch-outs, ## Verify, ## Previous mistakes; Context, Decisions and Watch-outs are bullets, each decision naming the rejected alternative. Each slice is N [ ] Title: {2–6 words} with indented Goal: (one sentence), Why: (one or two sentences) and the optional Location:. It has no card and no cap. One saved before ## Usage or ## Previous mistakes existed stays valid without them.

Parallel slices

A slice line may end (parallel: <path>, …), listing every path its plan changes, repo-relative and comma-separated. Adjacent marked slices form a group that may run at once; mark slices that change disjoint paths and need nothing from each other.

1 [ ] auth types: Reserved names list the new skills. (parallel: cli/init.ts, references/INDEX.md)
2 [ ] commit diff pass: Commit skills strip narrating comments. (parallel: agents/skills/commit/, cli/lint/*.test.ts)
  • An entry is a file, a folder ending /, or a glob. Entries overlap by the segment rule in plan-format.md Parallel phases.
  • The marker is cut before the one-sentence check, so it never counts as a second sentence. A (parallel: …) mid-line is sentence text.
  • Old-shape roadmaps take no marker.
  • Denies: slice 3 is parallel alone; parallel needs two or more adjacent slices, slice 1 is parallel with no paths; list the paths it changes after parallel:, parallel slices 1 and 2 both list cli/init.ts; parallel slices need disjoint paths, parallel slices 1 and 2 overlap at agents/skills/ and agents/skills/commit/SKILL.md; parallel slices need disjoint paths.
  • The save cannot see whether a slice's plan stays inside its list: /review-roadmap checks the sentence against it, /review-plan checks a deep plan's Steps, and bun repo slice-files <roadmap path> <slice> checks the branch's changed files before it lands.
  • /implement-roadmap-deep writes every group plan, reviews them in one message, and runs a group of spawn plans together from the session; a free, mixed or pull-request group runs in order. Each group plan commits only and lands in slice order after slice-files, a rebase onto the default branch and bun repo ci.

Previous mistakes

Mistakes about the program: slice order, slice boundaries, a premise later slices lean on, and on an old-shape roadmap a Decision that turned out wrong. A mistake inside one slice stays in that child plan.

- {feedback|verify|review|premise|decision|slice}: {what was wrong} → {what changed}
- decision: {question} → {choice}, because {why}
  • feedback, review, premise and decision mean what they mean in a plan. verify is a failed program Verify gate.
  • slice is roadmap-only: what child plan N found that changed the Order or a later slice (or a Decision on an old-shape roadmap). The same save makes that change.
  • Optional (lesson: {slug}) at the end when the entry is also a lesson.
  • /implement-roadmap-deep copies each entry that touches a slice into that slice's plan Watch-outs, and promotes entries that outlive the program to lessons before done.

Validation checklist

  • filename stamped and unpadded; name equals the slug
  • status is one of the five values; source ≤120; strategy is one of the three values; advice, when present, is {strategy} because {reason} within 100 characters; approval, when present, is ask or away
  • a new roadmap: the card; only Order, Verify and Previous mistakes, in that order; ≥2 one-line slices, title 2–6 words, one sentence; (parallel: …) slices in adjacent groups of two or more, each with a non-empty list, no two entries overlapping; at most 60 lines above Previous mistakes without Location: lines
  • an update keeps the saved file's shape; an old-shape one keeps the old checks: headings in order, Goal 1–3 sentences, bulleted Context, Decisions and Watch-outs, ≥2 slices each with Title (2–6 words), one-sentence Goal and Why (1–2 sentences)
  • Location, when present, is the absolute plan path (a relative .repo/plans/{file} still resolves)
  • Verify has 1–5 leaves
  • ## Previous mistakes is last, required on a fresh create, and keeps every saved entry unchanged and in order
  • reviews is a whole number, never lower than the saved one; locked needs at least 1
  • a fresh create is draft, reviews: 0, and has no [x]

Review loop

Read this when the plan's strategy is spawn-fast or spawn-deep. You are the reviewer. An implementer agent writes the product code. Values: plan-format.md. Gate rules: hooks.md.

Start

The first action, at start or on resume, is a plan save (a status or box edit). It stamps the session with that plan. Until it runs, the gate does not know this session reviews. Each spawn plan or fast plan the session saves gets its own stamp, so a session can review several at once, and the parent-write deny names each running one. A Write that saves a plan done, blocked or free drops its stamp; after an Edit that sets done, the next plan save drops it.

Phases

  • Phases run in order; each goes to one implementer: implementer-fast first on spawn-fast, implementer-deep on spawn-deep (implementerOf(strategy, 1)). A parallel group spawns its implementers in one message. Each brief lists its phase's files as the only ones it writes; that list is guidance, and bun repo phase-files is the check. A parallel implementer runs only its phase's tests, never sync or the full suite. After the round, run phase-files, then stage each passed phase by name, one at a time, and run the full suite once. A failed phase reruns alone. A fast plan's steps are its phases and (parallel: …) steps its groups; pass phase-files the fast plan's path, never its serial.
  • The brief is that phase's leaves, plus the findings to fix. The agent file holds the rest of its rules. The spawn prompt names the plan's worktree path (<root>/.worktrees/{serial}-{name}): a Claude subagent starts in the parent's cwd, the root, where the gate denies a product write unless workflow is main. Run bun repo worktree add {serial}-{name} first if it is missing.
  • A Read leaf in a phase goes to that phase's implementer with the brief: it reads the principle, you do not. You read only principle-prove-it-works, before Verify.
  • Your own product writes are denied. The plan, the journal and .tmp/ stay writable.
  • Only you tick boxes and write the journal. Write one entry per round.

Review

  • Read the implementer's report: changed:, tests:, status:. A missing report or a status other than done is a failed round.
  • Read the real git diff in the worktree and rerun the phase's tests yourself. Do not trust the report.
  • Send findings back to the same implementer. Continue it where the harness allows. Otherwise respawn it with the findings and the current diff.
  • A phase passes when the diff matches its leaves and your test run is green.

Baseline

The index is the baseline. After a phase passes, stage only the implementer's changed: files that the phase's leaves name: git add -- <files>. Never git add -A on this shared tree. The next phase's git diff (unstaged) then shows only that phase. The implementer never stages or commits.

Escalate

After 3 failed rounds, a missing report or a status other than done, spawn implementerOf(strategy, 2) fresh with the findings. If that one fails the same way, set the plan blocked and add a - blocked: journal entry.

Commit

One commit at the end, in the worktree, as implement-plan-deep says; /commit then lands it the way AGENTS.md says (by default finish from the root). It holds only this plan's files. Under commit only the plan commits and stops; /implement-roadmap-deep lands it.

Dumps

You and the implementer send big output through bun repo job. The gate rewrites a plain test run to --job test. Use --job exec for any other large command. Neither of you spawns scout for the implementer's work.

Not enforced

The gate denies your product writes on claude (Grok runs through it) and codex.

Not enforced on: antigravity, cursor. There, make no product writes yourself; every product edit goes through the implementer.

Implementation format (SSOT)

Local session journal for /implement-plan-deep. Not a plan, handoff, or git commit.

Created when /implement-plan-deep starts. Straight shots skip this file. Do not merge into the plan (plan = flip the box only).

Path

.repo/implementations/{serial}-{slug}-{YYYYMMDDTHHMMSSZ}.md

  • serial: own counter (not plan serials). Unpadded decimal int ≥ 1. Never 0001.
  • filename: unpadded serial, slug, UTC datetime stamp. Same regex as new plans: ^[0-9]+-[a-z0-9]+(-[a-z0-9]+)*-\d{8}T\d{6}Z\.md$
  • lookup: integer equality on leading digits.
  • Frontmatter serial: line must match ^serial: [1-9][0-9]*$.
  • New writes always stamped. Collision: increment serial, same stamp.

Link

Frontmatter plan: is the plan serial (6) or none. Own serial stays independent.

Slugify / allocate

Same as plans (slugify, nextSerial on this dir). Body line 1: IMPL_NAME: <3-6 word name>. Optional IMPL_PLAN: <n|none> (default none).

Frontmatter (required)

---
serial: 1
plan: 6                 # unpadded int or none
name: add-auth
status: in-progress     # in-progress | done | blocked
created: 2026-08-26T14:30:00Z
source: "<≤120 chars of user task>"
---

Status machine

in-progress → done | blocked

  • Fresh write: in-progress
  • Plan Verify done and journal caught up: done
  • Cannot finish: blocked (## Blockers must not be (none))

Body headings (this order only)

# {slug}, ## Done, ## Issue, ## Files, ## Log, ## Blockers, ## Notes

No other ## headings.

Section Amount
Done bullets. What landed and why. Past tense. Fresh: (none)
Issue bug root cause bullets, or (none)
Files repo-relative product paths as - path bullets. Default /commit stage set. No abs paths. No .tmp/. Fresh: (none)
Log append-only. Fresh: (none). After each plan leaf: N.M [x] one line (notes, fail, skip). [x] allowed
Blockers open problems. (none) unless status: blocked
Notes short leftovers, or (none)

Write

Write under .repo/implementations/ (any filename in that dir). Grok orchestrate-parent.ts runs cli/scripts/persist-implementation.ts on create (IMPL_NAME:): serial/slug from the name, injects frontmatter, rewrites path + content. Updates to an existing file keep the path; full-body writes are validated in place. Invalid create/update denies — fix the body, do not retry elsewhere.

Host without that hook: name the file per Path, or pipe the body to bun repo persist implementation --workspace {repo} and use its absPath.

Never allocate a new serial to log a leaf. Append Log, update Files/Done, flip the plan box.

Validation checklist

  • filename stamped unpadded; serial int equals leading digits; name equals slug
  • frontmatter keys: serial, plan, name, status, created, source
  • plan is none or unpadded int ≥ 1; status ∈ in-progress | done | blocked
  • headings in order only; no extra ##
  • Done / Issue / Files / Log / Blockers / Notes: bullets and/or (none); none of those sections empty
  • Files: repo-relative; reject abs and .tmp/
  • status: blocked ⇒ Blockers is not only (none)

Worked example

File: .repo/implementations/1-notes-export-json-20260826T143000Z.md

---
serial: 1
plan: 1
name: notes-export-json
status: in-progress
created: 2026-08-26T14:30:00Z
source: "add --json to notes export"
---

# notes-export-json

## Done
- `notes export --json` prints every note as one JSON array; the text table stays the default

## Issue
(none)

## Files
- src/cli.ts
- src/export.ts
- src/export.test.ts

## Log
1.1 [x] read principle-test-behavior-not-implementation
2.1 [x] export tests for two notes and for none
2.2 [x] new cases fail on the unknown flag
3.1 [x] parse --json in cli.ts
3.2 [x] print JSON.stringify of the notes in export.ts
3.3 [x] export tests pass

## Blockers
(none)

## Notes
(none)

Fast plan format (SSOT)

A short checklist for work the person chose to do fast. Saved by cli/scripts/persist-fast-plan.ts. Default is a plan (plan-format.md).

Path

.repo/plans-fast/{serial}-{slug}-{YYYYMMDDTHHMMSSZ}.md, named like a plan.

Frontmatter

serial, name, status (ready | in-progress | done | blocked), created, source, optional commit:, strategy, and optional advice. No reviews.

Strategy

strategy is free, spawn-fast or spawn-deep, with the meanings in plan-format.md. The save sets it from the session value, then the newest in-progress fast roadmap this session saved, then agents.defaultStrategy, then free. A sent strategy: line that differs from that value is denied with the plan's text. A rewrite that omits it keeps it, and an unknown value is invalid with strategy: free|spawn-fast|spawn-deep.

advice is optional: advice: {free|spawn-fast|spawn-deep} because {reason}, at most 100 characters, sent beside commit:. It moves into the frontmatter after strategy:. A rewrite that omits it keeps it. A bad line is denied with advice: {free|spawn-fast|spawn-deep} because {reason}, at most 100 characters.

Body

The first line is FAST_PLAN_NAME: <name>, then commit: and advice:, then # {slug}, then ## Goal, ## Steps, ## Verify, in that order, then an optional ## Previous mistakes. Steps and Verify are flat N [ ] text boxes; a step may end (parallel: <path>, …) (Parallel steps). A new plan has only [ ]. No review, so in-progress needs none.

Parallel steps

A step may end (parallel: <path>, <path>, …), listing every file it writes, with the entry kinds and overlap rule of plan-format.md Parallel phases. Adjacent marked steps form a group whose steps can run at the same time. A group needs two or more steps, and no two of its lists may share a path. Only Steps take the marker:

## Steps
1 [ ] parse the flag in `src/parse.ts` (parallel: src/parse.ts, src/parse.test.ts)
2 [ ] print it in `src/print.ts` (parallel: src/print.ts, src/print.test.ts)
3 [ ] wire both into `src/cli.ts`

Under spawn-fast or spawn-deep the session spawns a group's implementers in one message, each writing only its own list, then checks the round with bun repo phase-files <fast plan path> <steps> (the path, never the serial: a serial names a plan in .repo/plans/) and runs the full suite once. Under free the steps run in order and the marker changes nothing.

Cap and denials

At most 40 lines, frontmatter included; the strategy: and advice: lines, ## Previous mistakes and its lines are outside the cap. It is append-only, and under /away it holds the - decision: lines. Replies:

  • a fast plan holds only Goal, Steps and Verify, then an optional Previous mistakes
  • a fast plan is at most 40 lines; write a plan (/write-plan-deep)
  • a new fast plan has only [ ] boxes
  • step 3 is parallel alone; parallel needs two or more adjacent steps
  • step 1 is parallel with no files; list the files it writes after parallel:
  • parallel steps 1 and 2 both list src/cli.ts; parallel steps need disjoint files
  • parallel steps 1 and 2 overlap at src/ and src/cli.ts; parallel steps need disjoint files
  • verify box 4 ends with parallel:; only steps may run at once

Approval

The plan's block (plan-format.md): header fast-plan {n} · {strategy}, an advice row when the file has advice:, then the i and h rows with their strategy words. On Claude Code the picker asks the Strategy question too. i runs /implement-plan-fast {n}; i fast writes spawn-fast first and sets the session strategy; h hands off with /implement-plan-fast {n} as Next.

/implement-plan-fast {n|path} commit only is the fast roadmap runner's subagent form: it commits in the worktree, never finishes, leaves status: in-progress, and ends with worktree: <abs path> and commit: <short sha> lines.

Verify boxes run as plan-format.md Verify batching says: report-only checks in one message, tests, coverage and ci alone.

A fast roadmap (fast-roadmap-format.md) runs its slices as fast plans.

Fast roadmap format (SSOT)

A short ordered program of fast plans, for work the person chose to do fast. Saved by cli/scripts/persist-fast-roadmap.ts. Default is a roadmap (roadmap-format.md).

Path

.repo/roadmaps-fast/{serial}-{slug}-{YYYYMMDDTHHMMSSZ}.md. Serials count in roadmaps-fast only.

Frontmatter

serial, name, status (draft | locked | in-progress | done), created, source, strategy, and optional advice. No reviews.

Strategy

strategy is free, spawn-fast or spawn-deep, with the meanings in plan-format.md. The save sets it from the session value, then agents.defaultStrategy, then free. A sent strategy: line that differs from that value is denied with the plan's text. A rewrite that omits it keeps it, and an unknown value is invalid with strategy: free|spawn-fast|spawn-deep.

advice is optional: advice: {free|spawn-fast|spawn-deep} because {reason}, at most 100 characters, sent beside the name line. It moves into the frontmatter after strategy:. A rewrite that omits it keeps it. A bad line is denied with advice: {free|spawn-fast|spawn-deep} because {reason}, at most 100 characters. A fast plan written for a slice takes the fast roadmap's strategy unless the session has one.

Body

The first line is FAST_ROADMAP_NAME: <name>, then advice:, then # {slug}, then ## Order and ## Verify, in that order, then an optional ## Previous mistakes. Order has two or more slices, N [ ] title: sentence, each optionally ending (parallel: <path>, …) with the rules in roadmap-format.md Parallel slices; Verify is flat N [ ] text boxes numbered after the slices. A new roadmap has only [ ].

Cap and Location

At most 20 lines, frontmatter included; Location lines, the strategy: and advice: lines, and ## Previous mistakes with its lines are not counted. Previous mistakes is append-only and holds the - decision: lines under /away. A slice may carry an indented Location: <abs path> line: an absolute path to a fast plan under .repo/plans-fast/.

Denials

  • a fast roadmap holds only Order and Verify, then an optional Previous mistakes
  • a fast roadmap is at most 20 lines; write a roadmap (/write-roadmap-deep)
  • a fast roadmap has two or more slices; write a fast plan (/write-plan-fast)
  • a new fast roadmap has only [ ] boxes
  • a fast roadmap slice's Location is a fast plan under .repo/plans-fast/
  • slice 3 is parallel alone; parallel needs two or more adjacent slices
  • slice 1 is parallel with no paths; list the paths it changes after parallel:
  • parallel slices 1 and 2 both list cli/init.ts; parallel slices need disjoint paths
  • parallel slices 1 and 2 overlap at agents/skills/ and agents/skills/commit/SKILL.md; parallel slices need disjoint paths

Approval

The plan's block (plan-format.md): header fast-roadmap {n} · {strategy}, an advice row when the file has advice:, then the i and h rows with their strategy words. i sets status: locked, then runs /implement-roadmap-fast {n}; a strategy word writes that value first; h hands off with that as Next.

Run

/implement-roadmap-fast {n} refuses a draft, sets in-progress, runs /write-plan-fast then /implement-plan-fast per open slice (no stop between), ticks each slice after its plan, runs Verify and sets done. A parallel group writes all its plans first, then runs free plans in commit-only claude subagents spawned in one message and spawn plans from the session, and runs mixed groups or any group under workflow: "pull-request" in order. It lands each plan in slice order after bun repo slice-files (a file outside the list moves that plan last), a rebase onto the default branch and bun repo ci. Its Verify boxes run as plan-format.md Verify batching says.

Lessons

Mistakes that outlive one task. One committed file per mistake under .repo/lessons/, with a generated index. Not host memory: everything is in the repo and reaches every machine through git.

Three layers

  • Plan: ## Previous mistakes in each plan holds that task's details: rejected approaches, failed Verify, accepted review findings. It stays with the plan in .tmp/. Format: node_modules/@workerstack/repo/references/plan-format.md.
  • Roadmap: ## Previous mistakes in a roadmap holds mistakes about the program: slice order, slice boundaries, and slice entries for what a child plan found that changed later slices. A roadmap saved in the old shape also logs its Decisions there; a pointer roadmap's decisions live in the slice plans. It lasts as long as the program, in .tmp/. Format: node_modules/@workerstack/repo/references/roadmap-format.md.
  • Repo: a lesson holds a mistake our normal workflow will hit again on other tasks. A one-time issue stays in the plan's Previous mistakes. A plan entry that generalizes becomes a lesson, and the entry ends with (lesson: {slug}).

File

.repo/lessons/{slug}.md: a title (the rule, at most 72 chars), ## Mistake (at most 280) and ## Fix (at most 360), with nothing between the title and ## Mistake. Every save, bun repo lessons check and bun repo sync --check refuse a lesson over a cap or with a line under the title.

---
name: plan-forks
paths: ["cli/scripts/persist-plan.ts", "agents/grok/hooks/**"]
hits: 1
last-hit: 2026-11-01
evidence: ["plan 16"]
---

# Plan rewrites fork serials

## Mistake
What went wrong, concretely.

## Fix
What to do instead.
  • name is the filename slug. paths are repo-relative globs (** spans dirs; * and ? stay in one segment) and each must match a file that exists.
  • hits counts occurrences. evidence keeps the 5 most recent plan serials or commits.
  • A new lesson may leave out hits and last-hit; the save fills 1 and today.

Index

.repo/lessons/INDEX.md is generated: one line per lesson (- \slug` (hits N): title), sorted by hits. It opens with the @generated by @workerstack/repoheader and is left read-only (0444); to change it, edit the lessons in.repo/lessons/. Every lesson save, hit, and rmrewrites it, andbun repo syncrewrites it too.bun repo sync --check` fails on index drift, an invalid lesson, or a glob that matches no file.

Write

  • Write, hit, rm and index run inside a worktree: the plan's {serial}-{name} (bun repo worktree add {serial}-{name} if it is missing), else lesson-{slug}, and /correct uses its correct-{slug}. The lesson then lands with that worktree's commit and finish (run from the root). Under workflow: "main" the main checkout takes them too. Under the other modes, in the main checkout the Write is denied, hit, rm and index exit 1 with the main checkout is shared: run bun repo worktree add <name> and write under <root>/.worktrees/<name>; only <root>/.repo/{plans,plans-fast,roadmaps,roadmaps-fast,handoffs,implementations,scratch,tmp}/ is writable here, and the library calls return { ok: false, error } with that text.
  • Write the whole file. The hook validates it, renders the standard form, and reindexes; the host saves it. An Edit of a lesson is denied.
  • bun repo lessons hit <slug> --evidence <text> counts a repeat. The same evidence counts once.
  • bun repo lessons rm <slug> deletes a lesson and reindexes.
  • bun repo lessons index rebuilds the index. check runs the checks and, like match, is read-only, so it runs anywhere.

When to write

Write a lesson only for a mistake our normal workflow will hit again on other tasks. A one-time issue stays in the plan's Previous mistakes.

After any of these, add a ## Previous mistakes entry to the plan. If the mistake will happen again on other tasks, run bun repo lessons match on the files involved first, then hit the matching lesson or write a new one.

  • Plan feedback from the user (/write-plan-deep).
  • A failed Verify, or a test that failed after a fix (/implement-plan-deep).
  • principle-attack-the-premise firing (/implement-plan-deep).
  • An accepted /review-plan finding (/write-plan-deep).
  • Roadmap feedback, an accepted /review-roadmap finding (/write-roadmap-deep), a child plan that changes the program, or a failed program Verify (/implement-roadmap-deep). These go to the roadmap's log. Before the roadmap is done, each entry that outlives the program becomes a lesson with --evidence "roadmap {n}".
  • An operator correction (/correct).

At hits: 2 the mistake is a class. /correct encodes it as a check (architecture, type, lint, or test), then runs bun repo lessons rm.

When to read

  • bun repo lessons match <file...> prints the lessons whose globs match, most hits first, 10 at most. It reads the lesson files, not INDEX.md; a lesson it cannot read is named on stderr (.repo/lessons/x.md: invalid, run bun repo lessons check) and skipped. Each file is matched against the lessons of the checkout that holds it: the root or a managed worktree; a file in an unmanaged linked worktree uses the checkout you run it from.
  • A new plan that names a matched file must cite that lesson's path in Watch-outs, or the save is denied with the lesson listed.
  • The first time a session edits a matched file, the hook adds that lesson's title and path to the context. Each lesson is shown once per session.
  • /review-plan reads the matched lessons and flags one that no leaf acts on.

Output

en-US default. Detail behind the reply rules in overview.md, which stays the source for voice and shape.

Cue names are examples too: example, e.g., for instance, such as, and so on, …, etc., a sketched sentence, or one labeled case. Do not reuse one reply for every turn. Pick the count and the form for this case.

A closed instruction stays literal. Cues of that kind include only, exactly, just these, verbatim, no others, a schema, an identifier, a command, or quoted text. When an open cue and a closed cue both apply, the closed cue wins.

Do not invent a question or let the close introduce new numbers. Completed work uses past verbs and paths. If more work remains, say what. Ask in English when a choice is real.

/su, /bro, /unslop, /blast-radius, /correct, /teach, plan files, commit subjects, and job stdout keep their own shape. /bro restates a reply that missed the voice in overview.md. /explain keeps the shape and may lengthen the middle. Repo children do not talk to the user.

Job stdout is a sentence plus up to 6 dash facts, cap 8 lines, and an optional stash= receipt.

Example: waiting on the user

One case of the class "the user must act before we can finish". Copy the shape, not the words.

Cold:

We're waiting on four things, and only the first is mine to do now.

  1. Local main isn't pushed. It's 10 commits ahead of origin, including my test fix. Nothing below can start until those commits reach origin.
  2. The first public-sync run after that push. It should copy the generated code into each public repo.
  3. Publish secrets. None of the public repos I checked has its own secrets or variables. They may be set at the org level, but my token can't read org settings.
  4. Your go to publish.

Should I push main myself, or keep waiting?

Partner:

We're one push away from the first releases, and most of what's left is yours.

You

  • Push main, or tell me to. It's 10 commits ahead, including another session's work.
  • NuGet (org/sdk-csharp): add a trusted publishing policy for release.yml, and the repo variable NUGET_USER.
  • Maven (org/sdk-java): add the user token as MAVEN_CENTRAL_USERNAME and MAVEN_CENTRAL_PASSWORD, and the GPG key as MAVEN_GPG_PRIVATE_KEY plus MAVEN_GPG_PASSPHRASE. Skip any already set at the org level; my token can't see org settings.

Me: writing the test that blocks a release when the spec differs from prod, now. After the push I run the public sync, cut the releases, and verify each registry on Linux, macOS and Windows.

Say "publish" when the secrets are in.

The partner reply opens on the goal, groups the user's steps by where they do them, says what the agent does next, and ends on one action.

Map

Cheap outline. Not a file dump.

Protocol

  • node_modules/@workerstack/repo/references/cascade.md — session agent tools; job first; spawn scout when the dump needs judgment
  • node_modules/@workerstack/repo/references/rtk.md — shell compression
  • node_modules/@workerstack/repo/references/browser.md — Lightpanda-first session CLI (nav|snap|click|fill|scroll|wait|hover|select|shot|pdf|stop); not MCP
  • node_modules/@workerstack/repo/references/hooks.md — hook policy
  • node_modules/@workerstack/repo/references/map.md — this file
  • node_modules/@workerstack/repo/references/output.md — default voice
  • node_modules/@workerstack/repo/references/scope.md — size gate; plan by default: research → /write-plan-deep → approval → /implement-plan-deep; two or more slices → /write-roadmap-deep
  • node_modules/@workerstack/repo/references/INDEX.md — the catalog, read first: the generated repo:refs (one line per reference doc), repo:stack (the stack's installed packages) and repo:available (the published ones not installed) sections. INDEX always. Not if-maps-else

Agent SSOT

  • Docs SoT: agents/skills + references/ (shipped in the package; a consumer reads node_modules/@workerstack/repo/references/). bun repo sync maps anything placed there. .claude/skills/<name> and .agents/skills/<name> are generated links to agents/skills/<name>, not SoT; no mirror scan
  • The trees come from cli/sync/agents/ (sources frontmatter rules subagents hooks) via bun repo sync, reading the stock sources in agents/ (exported as @workerstack/repo/agents/*)
  • agents/subagents/{orchestrator,repo}.md
  • agents/rules/overview.md → AGENTS.md
  • A consumer's own content lives in .repo/ (below) and references/; both are committed, and bun repo sync merges them over the package's
  • Default output: node_modules/@workerstack/repo/references/output.md (overview.md → AGENTS.md; also agent bodies, not auto-injected)

Skills layout

  • Source: agents/skills/<name>/SKILL.md, the one copy (STOCK_SKILLS_REL in cli/scripts/skill-conflicts.ts) in this repo. A consumer's own skills go to .repo/skills/<name>/, and one named like a stock skill replaces it
  • A consumer's bun repo sync links each stock skill (and each .repo/skills/<name>) into .agents/skills/<name> for codex and antigravity and into .claude/skills/<name> for claude, as a directory symlink (a junction with an absolute target on Windows): to the generated .agents/skills/<name> when there is one, else straight to the skill. Optional packs (google-workspace listen speak clasp) are linked only when agents.skills names them, and a config pack can gate a stock skill the same way (the benchmark pack links /benchmark). In this repo sync links both .claude/skills/<name> and .agents/skills/<name> straight to agents/skills/<name> with a relative link. The package ships no generated trees, so each consumer's sync makes its links
  • Read directly: Codex and Antigravity read .agents/skills; Grok reads .grok/skills, .agents/skills and .claude/skills (deduplicated by name); Cursor reads .cursor/skills, .claude/skills, .codex/skills, .grok/skills and .agents/skills (follows symlinks, deduplicates by realpath)
  • There is no skills generator step: bun repo sync only links skills; nothing wipes .agents/skills

Grok hand files

  • cli/scripts/orchestrate-parent.ts + agents/grok/hooks/orchestrate-parent.json — spawn/fat/stop/persist / SubagentStop intercept / session receipt
  • bun repo sync prunes what it wrote last run and no longer plans (the .repo/tmp/sync.json manifest); a path outside the manifest is never removed
  • agents/grok/hooks/rtk.json — RTK
  • agents/global-settings/ — manifest.json (hosts claude codex grok antigravity cursor opencode: format, payload, dest under $HOME) and one payload per host (claude.json codex.toml grok.toml antigravity.json cursor.json opencode.json); sync-global-settings.ts (bun repo yolo) merges each into its dest (not auto-loaded; project .grok/config.toml cannot set [features])
  • agents/mcp-policy.jsonc — MCP deny-default SSOT (default: deny, allow: [])
  • agents/grok/{roles,hooks}/ — grok's static roles and hook configs; bun repo sync copies them into .grok/roles and .grok/hooks
  • agents/skills/<name>/ — the one skills source; bun repo sync links each into the host skill dirs

Scripts

  • cli/scripts/check-globals.ts — every manifest payload key (minus skipKeyPattern) must be present in its $HOME dest; project .grok/config.toml sets no [features] or [agent]. Not in bun repo sync --check: bun cli/scripts/check-globals.ts in this repo and the bun repo doctor globals row everywhere
  • cli/scripts/gate-fat-tools.ts
  • cli/scripts/scrub-spawn-prompt.ts
  • cli/scripts/job.ts — flags --job --query --path --glob (repeatable) --cwd; regex in --query OK; never cd &&; unknown flags / exec shell-meta → FALLBACK
  • cli/scripts/parse-need.ts
  • cli/scripts/persist-handoff.ts — .repo/handoffs/{serial}-{slug}-{stamp}.md
  • cli/scripts/persist-tmp.ts — .repo/scratch/{serial}-{slug}-{stamp}.{ext} root files
  • cli/scripts/persist-plan.ts
  • cli/scripts/persist-implementation.ts — .repo/implementations/{serial}-{slug}-{stamp}.md
  • cli/scripts/sync-global-settings.ts — applies by default, --dry-run previews, --check reports drift, --host <id>; report line {action} {id} {dest}, or skip {id} {dest}: <reason> for a dest it leaves as it is
  • cli/scripts/purge-global-mcps.ts
  • cli/scripts/emit-mcp-policy.ts
  • cli/scripts/emit-agent-shell-tools.ts
  • cli/scripts/browser.ts — session browser rail
  • cli/scripts/google-workspace.ts — Sheets Docs Slides Calendar Gmail Drive
  • cli/scripts/speak.ts — TTS
  • cli/scripts/listen.ts — STT
  • cli/scripts/setup.ts — handheld speak / listen / google-workspace / browser (~/.workerstack/)
  • cli/scripts/doctor.ts — unified prereqs; globals row (ok or first drift line); optional google-workspace (oauth tokens adc clasprc), speak, clasp, ffmpeg, whisper-cli
  • cli/env/ — the @workerstack/repo/env module (index exports; define merge and views; keys identities and the keys file; age encrypt and decrypt); cli/env.ts holds defineEnv and sealed, which decrypt in a bun child
  • cli/repo-env.ts — this repo's own env (repoEnv): its process variables; add a name there to read one
  • cli/secret-reason.ts — the secret-shape detection (sk_ prefix, high entropy, credential name) the env check uses
  • cli/scripts/env-command.ts (+ .constants.ts) — bun repo env dispatch, usage and exit codes; env-args.ts parses the flags; env-io.ts is the deps (cwd, stdin, stdout, gh) and the one-line refusal
  • cli/scripts/env-files.ts — finds the defineEnv files, picks one (--file, the one declaring the key, the only one) and lists USED BY
  • cli/scripts/env-edit.ts — env set: the AST edit that seals or writes a value into the scope block in place
  • cli/scripts/env-keys.ts — env init, key import, key ci: the key pair, the keys file and the gh secret set
  • cli/scripts/env-values.ts — env ls, get, run, export: masked reads, the child env and dotenv quoting
  • cli/__fixtures__/env-repo.ts — builds a scratch git repo with a keys file and an env file for the env-*.test.ts files
  • cli/check-env-values.ts (+ .constants.ts) — the three bun repo check env rules over every file that calls defineEnv(: plain secret, key one scope lacks, undeclared process read
  • cli/check-deps.ts (+ .constants.ts) — the bun repo check deps rule per public package: a used dep outside peerDependencies, any dep of a library (no bin), missing export types, an unreadable surface
  • cli/type-surface.ts (+ .constants.ts) — the in-memory declaration emit of a package's typed exports over a virtual host, and the packages its declarations use, keyed to the export that reaches each file
  • cli/type-surface-entries.ts — the files the types of exports start from: exact keys, * patterns, null exclusions, tsconfig files, the types/typings/main fallback, and the keys whose types are missing
  • cli/type-surface-scan.ts — the bare imports, /// <reference types> and globals of the emitted declarations, each charged to the package that declares it, and the own files each imports
  • cli/type-surface-blame.ts — reads a JSON or JSONC file's fields, and charges a used package declared in no field to the deps one hop up
  • cli/build-outputs.ts — maps each tsconfig.build.json output to its source; none for a missing or unparsable config
  • cli/packed-under.ts — the files under a dir and whether npm packs each, by files (globs, dirs, ! negations) or the default skips
  • cli/package-locator.ts — the package that owns a file: the nearest named package.json, memoized
  • cli/__fixtures__/deps-kit.ts, cli/__fixtures__/surface-kit.ts — scratch package trees for the check-deps and type-surface* tests

Tmp

  • .repo/plans/ — persist-plan; format SSOT = node_modules/@workerstack/repo/references/plan-format.md
  • .repo/implementations/ — persist-implementation; format SSOT = node_modules/@workerstack/repo/references/implementation-format.md
  • .repo/tmp/sessions/<sid>/settings.json — the session's approval, effort and strategy
  • .repo/tmp/orchestrator-logs/ — need-intercept, stop-block stamps
  • .repo/scratch/ — scratch + /tmp dumps {serial}-{slug}-{stamp}.{ext} at root (not nested tmp/)
  • .repo/tmp/browser/ — browser session/profile/shot/pdf (not MCP)
  • .repo/handoffs/ — persist-handoff / /handoff
  • .repo/tmp/stash/ — job / snap overflow
  • .repo/tmp/sessions/{sid}/ — wrote/read/receipt
  • .repo/tmp/speak/ — TTS mp3
  • .repo/tmp/listen/ — STT wav + latest.txt

.repo/ layout

Repo-owned content, committed. In this repo .repo/ holds lessons/ and the ignored tmp/.

  • lessons/ — one file per lesson plus the generated INDEX.md
  • rules/ — path-scoped rules only (each has paths), written to the host rule trees; a rule without paths is moved by one sync below the repo:rules region of its scope's AGENTS.md (except one with overrides); a stock rule's extra sections go to <name>-local.md so the stock text stays live
  • subagents/ — extra agents; one named like a stock agent replaces it
  • skills/ — extra skills, linked into each host tree (a real skill dir found under .agents/skills or .claude/skills moves here)
  • settings/claude.json — keys deep-merged into the generated .claude/settings.json
  • hooks.jsonc — replaces the stock hooks whole
  • mcp-policy.jsonc — deep-merged over the stock MCP policy
  • permissions.json — path deny rules added to the generated hooks
  • lint-baseline.json — per file, per rule, the lint problems still allowed; read by bun repo turn and bun repo check
  • test-times.json — whole milliseconds per test file, written by bun repo ci times; read by the vitest shard sequencer
  • global-settings/ — a payload of the stock's name here (claude.json, codex.toml, grok.toml, antigravity.json, cursor.json, opencode.json) replaces that stock payload whole in bun repo yolo and the globals check; the stock manifest stays
  • bench/ — <name>.bench.ts specs, a defineBench default export each; read by bun repo bench <name>, not by sync; root only
  • env.keys.json — { "recipient": "age1…", "repo": "<name>" }: the age public key sealed(...) values are sealed for, and the name of the key file ~/.workerstack/keys/<repo>.key
  • tmp/ — ignored: sync.json (the manifest) and stage/ (the build dir, removed after each sync)

Generated (do not hand-edit)

bun repo sync writes these from node_modules/@workerstack/repo plus .repo/ for the hosts in agents.hosts (default: all five; HOST_TREES in cli/sync/hosts.ts is the one table). In a consumer they are gitignored and recreated by every bun install; in this repo they are committed. bun repo sync --check reports drift. Three more agents keys in repo.config.ts: defaultStrategy (free, spawn-fast or spawn-deep; default free) is the strategy: a new plan, roadmap, fast plan or fast roadmap gets when the session holds none (and, for a plan, no in-progress roadmap this session saved has one; for a fast plan, no in-progress fast roadmap), read by the saves and bun repo persist; defaultApproval (ask or away; default ask) applies when the session has no session approval and no in-progress plan or roadmap with an approval:, read by the hook; defaultEffort (fast or deep; default deep) is the session effort when none was set, shown in the hook context, which picks the plan skills the last line of the root AGENTS.md names. Example: agents: { defaultStrategy: "spawn-deep", defaultApproval: "away", defaultEffort: "fast" }. Each file sync writes whole opens with a @generated by @workerstack/repo header line where its format takes a comment and is left read-only (0444, 0555 for a hook) by every sync; to change one, edit its source in .repo/ (the layout above) or in the package, then sync. A tracked file with that header that no output names any more is removed, even with no manifest; one under a __fixtures__ folder is kept, and changing the header line keeps a copy. Files where sync owns only regions (.gitignore, .gitattributes, references/INDEX.md) stay writable. A region opens with # repo:<name> <hash> and closes with # /repo:<name> (<!-- repo:<name> <hash> --> and <!-- /repo:<name> --> in markdown); the hash is the first 8 hex of the sha256 of the body sync wrote. Sync never fails on a shared file: it prints one <path>: <repair> line on stderr per repair (converted the old markers to repo:ignore for the old # >>> @workerstack/repo pair, added the hash to, closed, opened, reordered the markers of, rewrote the markers of, dropped 1 extra, dropped 1 stray marker of, moved 1 edited line out of (kept just below the region), cut for a region no version writes any more). A shared path no output names any more has its regions cut and keeps the rest (--check: <path> has regions no output writes), removed only when nothing but whitespace is left. An invalid or non-object .vscode/settings.json is left as is with a warning. bun repo sync and --check refuse when git tracks a file the repo:ignore block of .gitignore covers, and print the git rm --cached command to run.

  • AGENTS.md (the root and each package's) — shared: sync writes only the hashed repo:rules region at the top; everything else is your text, tracked and committed. There is no package CLAUDE.md. The always-on text over 12 KiB is a warning
  • Not written by sync: .mcp.json is your tracked file (bun repo init writes the first one; sync never writes or deletes it). Every other host's MCP file is built from it, filtered by the MCP policy allow list; a .repo/mcp.json or a policy servers key is moved into .mcp.json by one sync, and .mcp.json's own server wins on a name collision
  • claude → .claude/: agents/*, settings.json (MCP keys, hooks, the .repo/settings/claude.json merge), skills/<name> links
  • codex → .codex/ (agents/*, config.toml mcp_servers, absent when allow empty) and .agents/skills/<name> links
  • antigravity → .agents/: agents/*, skills/<name> links, mcp_config.json, hooks
  • grok → .grok/: agents/*, config.toml (project MCP [[permissions]] + [plugins] deny_default; no [features]), roles/scout.toml and hooks/*.json copied from agents/grok/; the hook scripts stay in the package's cli/scripts/
  • cursor → .cursor/: hooks.json (hooks-union adds consumer extras), agents/scout.md; a real dir, not shipped in the package
  • release pack: .github/workflows/ci.yml (tracked, whole-file; the e2e job only when the root has a test:e2e script, the consumer job only for a named public root); also, for a public root with a config owner and a GitHub repository, SECURITY.md, CONTRIBUTING.md, .github/CODEOWNERS, .github/PULL_REQUEST_TEMPLATE.md and .github/ISSUE_TEMPLATE/{bug_report,config}.yml (tracked, whole-file); a hand-written one is refused
  • benchmark pack: .github/workflows/bench.yml (tracked, whole-file), which bun repo bench --remote dispatches with the spec, the OSes and the sha; a hand-written one is refused; also the /benchmark skill links in .agents/skills and .claude/skills
  • evals pack: .github/workflows/eval.yml (tracked, whole-file), dispatched by hand with an eval name and a cost cap, and the evals environment bun repo github manages; a hand-written eval.yml is refused
  • standards pack, editor: keys the file lacks are added to .vscode/settings.json, and ids it lacks to recommendations in .vscode/extensions.json, never changing what you set: Bun formats on save through the Simple LSP Client extension (bun fmt --no-script --stdin-filepath), the oxc extension only lints (oxc.enable.oxfmt: false, fix-all on save), the workspace TypeScript (js/ts.tsdk.path, with the TypeScript 7 extension for 7 or later; a deprecated typescript.tsdk moves to js/ts.tsdk.path), .css as tailwindcss with a Tailwind entry sheet, the Vitest extension when vitest is installed
  • always: bunfig.toml, the repo:ignore region of .gitignore, the repo:generated region of .gitattributes, references/INDEX.md regions repo:refs, repo:packages, repo:stack and repo:available, .repo/lessons/INDEX.md when lessons exist, .repo/.gitkeep (so .repo/ is always committed), .vscode/tasks.json (committed in a consumer too)

Generator divergences

Where cli/sync/agents/ differs from the rulesync 43.7.0 it replaced:

  • YAML 1.2 parsing: yes stays a string
  • No 80-column folding of long values
  • The agentsmd and codexcli rule targets both fold into AGENTS.md only
  • A leading ./ in a claude hook command becomes "$CLAUDE_PROJECT_DIR"/
  • A bad subagent, or a rule without frontmatter, fails sync

Protocol

The session agent has tools. Fat Read/Grep/Bash dumps are rewritten to <root>/node_modules/.bin/repo job (squeez + stash). Isolation facts stay ≤8 lines.

Run bun repo job --job search|exec|slice|fetch|test first. Spawn scout when the dump needs judgment (browse snap, messy test log) and the return is a short isolation fact; spawn researcher for heavy web or browser research that returns facts with sources. Use a host agent or a fan-out when that split is cheaper for the task, or when the user asks for it.

need: JSON (search|exec|slice|fetch) may be intercepted in-hook (cap 4). Peek/kill if a child is running: Grok get_command_or_subagent_output timeout_ms=60000. Other hosts: block on spawn return.

Session settings: /ask (default) stops once for plan approval, then runs to done; it asks only on a blocker or a real choice. /away approves the plan itself once the review settles, decides instead of asking, and logs each choice as a - decision: entry in the plan. A prompt that starts with /ask, /away or /set <words> saves the session's approval, effort and strategy in .repo/tmp/sessions/<sid>/settings.json (/set: ask away, fast deep, free spawn-fast spawn-deep, any subset in any order; bun repo set <words> does the same when the person asks in plain words). A session value beats a plan or roadmap approval:, which applies only with no session value. On Claude Code outside away, Stop names an approval picker to click; other harnesses and away keep the typed block. Under away the Stop hook runs the approval block's header choice once the review settles and logs the decision.

Scratch: .repo/scratch/. Stash: .repo/tmp/stash/. Sessions: .repo/tmp/sessions/{sid}/. Browser: .repo/tmp/browser/.

RTK

Shell stdout compression. PreToolUse: Bash → rtk <cmd>. Not Read/Grep/Glob/prompts/images.

Grok PostToolUse is observe-only — do not attempt output scrub; compress via RTK / spawn / isolation facts only.

Commands

ls · tree · read · smart · git · gh · glab · clasp · aws · psql · pnpm · err · test · json · deps · env · find · diff · log · dotnet · docker · kubectl · oc · summary · grep · rg · init · wget · wc · gain · cc-economics · config · jest · vitest · prisma · tsc · next · lint · prettier · format · playwright · lightpanda · cargo · npm · npx · curl · discover · session · telemetry · learn · run · proxy · pipe · trust · untrust · verify · ruff · pytest · mypy · rake · rubocop · rspec · pip · go · gt · golangci-lint · gradlew · mvn · hook-audit · rewrite · hook · help

git: diff · log · status · show · add · commit · push · pull · branch · fetch · stash (list show pop apply drop push) · worktree cargo: build · test · clippy · check · install · nextest go: test · build · vet docker: ps · images · logs · compose (ps logs build) gh: pr · issue · run · repo clasp: login · logout · create · clone · pull · push · status · open · run · deploy · logs · setting

Flags: -v/-vv/-vvv · --ultra-compact · --skip-env · -h · -V

Unknown cmds passthrough. rtk run unfiltered.

playwright: test-runner family only — not the browser rail. lightpanda: browser-rail CDP via bun repo browser (not MCP, not env).

Setup

Handheld machine setup for optional integrations. Any OS. Secrets stay in ~/.workerstack/ (Windows %USERPROFILE%\.workerstack). Not git. Env still wins over the file.

bun repo setup
bun repo setup speak
bun repo setup listen
bun repo setup google-workspace
bun repo setup browser
bun repo setup jev

TTY: menu with ok/missing. The menu lists enabled packs and always lists browser and jev. A pack is enabled when agents.skills in repo.config.ts names it. bun repo setup <pack> when that pack is off prints how to enable it (add it to agents.skills, then bun repo sync) and exits 1. Already ok asks redo (--redo skips the ask). Missing bins: print the OS command (never run it), then re-check PATH. Ends with one status line per listed target. Non-TTY needs flags (keys / --creds). Doctor never installs bins; setup never installs bins either.

target what it writes flags
speak ~/.workerstack/speak.json --elevenlabs-key --openai-key --provider --skip-verify
listen same file (openai / listen) plus ~/.workerstack/whisper/ --openai-key --device --skip-download
google-workspace Desktop OAuth JSON + tokens under ~/.workerstack/ --creds PATH --account NAME --project ID
browser nothing (print Lightpanda / Chromium hints; both required except win32 Chromium-only)
jev ~/.workerstack/jev.json (apiKey, mode 0600). TYPESAFE_API_KEY wins when set --api-key

Full-permission defaults

bun repo init and bun repo yolo merge full-permission keys into six machine-wide files, keeping every other key (skip with --skip-globals). Plain claude, codex, grok, agy, cursor-agent and opencode then start without approval prompts; there are no repo:<agent> launch scripts.

host dest under $HOME keys
claude .claude/settings.json permissions.defaultMode = "bypassPermissions", skipDangerousModePermissionPrompt = true
codex .codex/config.toml approval_policy = "never", sandbox_mode = "danger-full-access"
grok .grok/config.toml [ui] permission_mode = "always-approve" plus the orchestrator, subagent, memory and [features] tables
antigravity .gemini/antigravity-cli/settings.json toolPermission = "always-proceed"
cursor .cursor/cli-config.json approvalMode = "unrestricted"

bun repo yolo applies by default; --dry-run previews, --check prints one line of per-host status, exits 1 on any drift, and --host <id> limits it to one host; an unknown id prints unknown host: <id> and a deleted payload prints missing payload: <path>. bun repo doctor shows one globals row: ok, or the first drift line, which names the host, dest and key and ends with the bun repo yolo hint. The project hooks (gate-fat-tools, scrub-spawn-prompt) still run in bypass mode.

opencode (V2) has no yolo flag: its payload is one allow-all rule in permissions of ~/.config/opencode/opencode.json. The merge puts that rule ahead of the rules already there, so a user's own deny or ask still wins, and a second run adds nothing. A dest whose permissions is not a list, or one with an opencode.jsonc beside it (the file opencode reads), is left as is: apply prints skip opencode .config/opencode/opencode.json: <reason>, --check prints opencode skipped: <reason> and exits 1.

bun repo shim installs a bare repo launcher once per machine, so a command runs without the bun prefix. It writes a launcher into ~/.local/bin (Windows: %LOCALAPPDATA%\workerstack\bin, with repo.mjs and repo.cmd beside it for cmd.exe and PowerShell). From any directory the launcher runs the nearest parent's node_modules/@workerstack/repo, so each project keeps its own version; outside a project it prints repo: no @workerstack/repo found above <dir>; run \bun install` in the projectand exits 1.--dry-runpreviews,--checkexits 1 on a missing or stale file,--dir installs elsewhere. When the directory is not on PATH it prints the line to add (bash and zsh, fish, or PowerShell); it never edits PATH or a shell rc file. Rerun it after upgrading the package:bun repo doctorshows ashim row (ok, not installedorstale). bun must be on PATH, as for bun repo`.

Do not invent keys. If the session is not a TTY, tell the user to run bun repo setup in their terminal. This spec is enough. Do not read https://github.com/workerstack/repo#readme unless this file is missing.

Browser

Zero-config in-tree session rail. Not MCP. Not env. Not raw Playwright. Lightpanda first; swap to Chromium when blocked.

bun repo browser nav|snap|click|fill|scroll|wait|hover|select|shot|pdf|stop|doctor|ensure

Default engine: Lightpanda (lightpanda on PATH). Chromium when a rule below requires it. Never report "can't browse" while Chromium is available.

Verbs

verb purpose
nav <url> launch or reuse session; go to URL
snap markdown + numbered AX refs (default read)
click <ref> click a ref from last snap
fill <ref> <text> fill a ref from last snap
`scroll [ref down
wait [ms] pause (default 1000, cap 15000)
hover <ref> hover a snap ref
select <ref> <value> set a select/option value
shot screenshot (Chromium paint)
pdf PDF (Chromium paint)
stop reap engine pid; clear session; idempotent
doctor unified prereq doctor (bun repo doctor); alias ensure
ensure alias of doctor

Session + profile + artifacts: .repo/tmp/browser/ only.

Install

Run bun repo doctor. Handheld engine hints (no keys): bun repo setup browser. Doctor/setup is enough. Do not read a README unless those are missing; then https://github.com/workerstack/repo#prerequisites (not the consumer README).

Engine

Lightpanda first (lightpanda serve internally, CDP, LP.getMarkdown + AX refs).

Chromium immediately when any of:

  • verb is shot | pdf | trace
  • process.platform === "win32"
  • Chromium-first host: figma, Google docs / sheets / slides, mail.google, earth, amazon
  • lightpanda missing from PATH and a Chromium binary is present
  • Lightpanda probe fails once → one Chromium replay of last URL; no loop
  • snap on Lightpanda is empty, a bot/JS wall, or client-rendered junk → one Chromium replay, then snap again

The session agent does not invent a "can't" for Amazon or other client-rendered sites. The rail swaps. One swap cap. App e2e stays Playwright (playwright test), not this CLI.

No user env. Never REPO_BROWSER*. Never LIGHTPANDA_CDP_URL.

Non-goals

  • MCP browser servers
  • user env knobs
  • raw Playwright as the browse tool (Playwright is the app e2e runner)
  • repo job:browse
  • teaching thinker / orchestrator to browse

RTK

playwright on the RTK command list is the test-runner family only. Browser rail is lightpanda + bun repo browser.

Jev

Closed external decision. The source text is already saved. The question is a yes/no or a named choice. One call. Not open research. Not code. Not job.

Availability

bun repo jev --available

Prints yes or no and exits 0. No request. No setup line.

no: the session answers the question itself. Do not run the decision command. Do not mention Jev, a missing key, or setup.

yes: continue below.

A key is TYPESAFE_API_KEY or apiKey in ~/.workerstack/jev.json (mode 0600). Env wins. Setup, when a person asks for it: bun repo setup jev. Spec: node_modules/@workerstack/repo/references/setup.md.

Decision

Write .repo/scratch/jev/<slug>-state.txt (plain text or a JSON object) and .repo/scratch/jev/<slug>-questions.json (the System One questions map). Put every label for that study in the one map. At most 8 questions. State at most 64000 characters.

bun repo jev --state .repo/scratch/jev/<slug>-state.txt --questions .repo/scratch/jev/<slug>-questions.json

Yes/no:

{ "keep": { "type": "noul", "instructions": "Does this passage promise zero retention on the default plan?" } }

Named choice:

{
  "endpoint": {
    "type": "choice",
    "instructions": "Which endpoint should the app call?",
    "criteria": {
      "typesafe": "Vendor host, path, and key",
      "other": "A different host, path, or key"
    }
  }
}

Model is jev-1.13.0. Stdout is at most 8 lines: model, input tokens, then one label line. Exit 0. Use that label and confidence. A low confidence is the result. Do not rephrase and call again.

One request per process. UTC-day ledger ~/.workerstack/jev-usage.json stops at 40 requests or 1000000 input tokens. A 429 or 529 retries once only when Retry-After is at most 2 seconds.

Exit 2 means the decision call did not return a label. Answer from the sources. Do not invent a label. Do not print the key or the state.

Cloudflare

Use cf for every Cloudflare task. Do not run wrangler yourself and do not add it to a project that has none.

Find the command

cf cli search "<describe the task>"
<command from the result> --help
cf schema <command from the result, without the leading cf>

cf cli search returns five matches. Pick one instead of repeating the search. Write the query as the action and resource type only: no names, domains, emails, account or resource IDs, or tokens. cf schema shows the API request detail for a command.

Project commands

  • cf dev runs the dev server (--local uses local resource simulations).
  • cf deploy deploys (--dry-run builds and checks without uploading).
  • cf build builds the project.
  • cf workers types generates types from cloudflare.config.ts.
  • cf init workers [directory] creates or sets up a Workers project.
  • cf auth manages login and profiles; --profile <name> picks one on any command.

cf dev and cf deploy run the dev server the project declares in package.json: @cloudflare/vite-plugin (recommended), or wrangler for a legacy Worker project that keeps wrangler.jsonc. With none declared they print the install choices and stop. Ask before adding a dependency.

Docs

Product docs: https://developers.cloudflare.com/ . Machine-readable index: https://developers.cloudflare.com/llms.txt .

Status

cf is in beta, so commands and config can still change. Wrangler is not deprecated yet; Cloudflare plans to maintain it for 18 months after the cf beta ends.

google-workspace

Create and edit Google Sheets, Docs, Slides, Calendar events, and Gmail from the session.

bun repo google-workspace whoami
bun repo google-workspace scopes
bun repo google-workspace login
bun repo google-workspace sheet create --title "Q3" --csv "item,qty\nA,2"
bun repo google-workspace sheet set --id ID --cell A1 --value x
bun repo google-workspace sheet get --id ID --range A1:B10
bun repo google-workspace sheet append --id ID --csv "c,3"
bun repo google-workspace sheet clear --id ID --range A1:Z
bun repo google-workspace doc create --title "Notes" --body "hello"
bun repo google-workspace doc append --id ID --body "more"
bun repo google-workspace drive list --query "mimeType='application/vnd.google-apps.spreadsheet'"
bun repo google-workspace slide create --title "Deck"
bun repo google-workspace meeting create --title "Standup" --start 2026-09-15T09:00:00Z --end 2026-09-15T09:15:00Z --attendees a@x.com,b@y.com
bun repo google-workspace mail send --to you@example.com --subject hi --body hello
bun repo google-workspace mail list

Each person has their own Google Cloud app. Creds stay in ~/.workerstack/ on that machine. Do not share OAuth JSON or tokens. Do not commit them. Inviting others to a meeting is attendee emails, not shared creds.

Handheld (any OS): bun repo setup google-workspace or bun repo setup → google-workspace. Opens each Cloud Console page, waits, copies the Desktop JSON into ~/.workerstack/workspace/default/google-oauth.json, then browser Allow. Non-TTY: --creds PATH to a Desktop download. --account NAME for a second Google user.

Doctor reports google-workspace as optional: ok when Desktop OAuth JSON, tokens, gcloud ADC, or ~/.clasprc.json exists, else missing (optional). It prints source names (oauth tokens adc clasprc), not secrets. clasprc is Drive-only.

Two Google accounts on one machine: --account NAME (or WORKSPACE_ACCOUNT). Each account, default included, uses ~/.workerstack/workspace/NAME/.

bun repo google-workspace --account work login
bun repo google-workspace --account work whoami
bun repo google-workspace accounts

Each account needs its own Desktop JSON in that folder (google-oauth.json) and its own login.

Clasp’s public OAuth client is Drive-only. Calendar and Gmail are restricted; Google blocks extra scopes on that app. Use your Desktop OAuth client.

Agent: setup e2e

If whoami works but meeting / mail / sheet set fail with missing scope or “app is blocked”, run this user through bun repo setup google-workspace (TTY). Do not reuse another person’s JSON. Do not invent a client JSON.

Local dir is created by setup (~/.workerstack/, mode 700 on Unix). Manual equivalent: mkdir -p "$HOME/.workerstack" && chmod 700 "$HOME/.workerstack" then:

  1. Open https://console.cloud.google.com/projectcreate — Create. Org can be their Workspace org. Note Project ID from the dashboard URL (?project=).
  2. Open https://console.cloud.google.com/auth/overview/create?project=PROJECT_ID (or Google Auth Platform → Branding).
    • App name: anything (autorun is fine). That string is only the Allow screen.
    • User support email: their address.
    • Audience: Internal if the Google account is a paid Workspace org (e.g. @rearm.co). Testing + add their own address as a test user if it is a personal @gmail.com (Internal is not offered). APIs work on both; only this consent type changes.
    • Next / Create through the wizard.
  3. Open https://console.cloud.google.com/flows/enableapi?apiid=calendar-json.googleapis.com,gmail.googleapis.com,sheets.googleapis.com,docs.googleapis.com,drive.googleapis.com&project=PROJECT_ID — Enable.
  4. Open https://console.cloud.google.com/auth/clients/create?project=PROJECT_ID.
    • Application type: Desktop app.
    • Create → Download JSON.
  5. Copy the download to $HOME/.workerstack/workspace/default/google-oauth.json (Windows: %USERPROFILE%\.workerstack\workspace\default\google-oauth.json). chmod 600 on Unix. Confirm it has "installed" and client_id. For --account NAME, use $HOME/.workerstack/workspace/NAME/google-oauth.json.
  6. From the repo: bun repo google-workspace login (or --account NAME) — browser Allow. Tokens write beside the JSON (workspace/NAME/tokens.json).
  7. Smoke:
bun repo google-workspace whoami
bun repo google-workspace scopes
bun repo google-workspace sheet create --title smoke-sheet --csv "k,v\nok,1"
bun repo google-workspace doc create --title smoke-doc --body ok
bun repo google-workspace slide create --title smoke-slide
bun repo google-workspace meeting create --title smoke-meeting --start 2026-09-16T09:00:00Z --end 2026-09-16T09:15:00Z
bun repo google-workspace mail send --to THEIR_EMAIL --subject smoke --body ok

scopes must include calendar, gmail.modify or gmail.send, spreadsheets, documents, drive. If login times out, they did not finish Allow; re-run login.

If google-oauth.json is missing, login prints setup: bun repo setup google-workspace. Prefer the handheld setup (it opens the Project ID URLs). Manual steps above still work.

gcloud on PATH is an alternate login (gcloud auth application-default login with the extra scopes). Still per person, still local.

Clasp

Low-level Apps Script CLI. Optional. Doctor reports it.

For create/edit sheet, doc, slide, meeting, or mail, use google-workspace: node_modules/@workerstack/repo/references/google-workspace.md and bun repo google-workspace. Do not start from clasp.

bun add -g @google/clasp
clasp login
clasp create --type standalone --title <name>
clasp pull
clasp push
clasp run <function>
clasp status

Script files live next to appsscript.json. Git tracks them. clasp run needs the Apps Script API and an API-executable deployment. That path is for custom .gs, not day-to-day Workspace.

Listen

Speech-to-text from the session. Input only. Not TTS. Not a live voice agent. Not MCP.

bun repo listen whoami
bun repo listen devices
bun repo listen rec [--seconds 8] [--device D] [--clip]
bun repo listen start [--device D]
bun repo listen stop [--clip]
bun repo listen toggle [--clip]
bun repo listen status
bun repo listen file --path clip.wav [--clip]
bun repo listen latest

rec records then transcribes (default 8s, cap 120s). toggle is the hotkey verb: first run starts, second run stops and prints the transcript. Audio and latest.txt live under .repo/tmp/listen/. --cwd /path/to/repo when the hotkey does not already run in the repo.

Auth and capture

Handheld (any OS): bun repo setup listen or bun repo setup → listen. Prints OS install commands for ffmpeg / whisper-cli (does not run them), optional ggml download into ~/.workerstack/whisper/, OpenAI fallback key if local STT is missing.

If ffmpeg, whisper-cli, a ggml model, or OPENAI_API_KEY is missing, the CLI prints setup: bun repo setup listen and exits 1. Agents should show that line, not a stack or JSON. Do not invent a key.

STT prefers local whisper.cpp. Install the binary; the first listen downloads ggml-base.en.bin (~142MB) into ~/.workerstack/whisper/. No API cost after that. If the binary or download is missing, it falls back to OpenAI whisper-1 (OPENAI_API_KEY or ~/.workerstack/speak.json). Force with LISTEN_STT=local or LISTEN_STT=openai. Never commit keys.

env ~/.workerstack/speak.json
Local model WHISPER_MODEL listen.whisperModel
Force engine LISTEN_STT listen.stt (auto / local / openai)
OpenAI key OPENAI_API_KEY openai.apiKey
OpenAI model LISTEN_MODEL listen.model (default whisper-1)
Device LISTEN_DEVICE listen.device
Seconds LISTEN_SECONDS listen.seconds

Local Whisper (preferred)

One install:

Then bun repo listen whoami or rec. The first run fetches ggml-base.en.bin to ~/.workerstack/whisper/. Do not pip-install OpenAI Whisper. Do not curl the model yourself unless the auto-download fails.

Larger models: put another ggml-*.bin in that folder or set WHISPER_MODEL. Doctor reports whisper-cli as optional. whoami prints stt=local or stt=openai.

ffmpeg (mic only)

  • mac: brew install ffmpeg
  • linux: sudo apt install ffmpeg
  • win: winget install Gyan.FFmpeg

file and latest work without a mic. Doctor reports ffmpeg as optional.

rec detaches ffmpeg and does not attach it to the IDE TTY. AVFoundation can stall forever on a VS Code / Electron PTY even when Visual Studio Code already has Microphone access.

Default devices: mac :0 (AVFoundation audio 0), linux Pulse default (ALSA if the id is hw:…), Windows requires --device or LISTEN_DEVICE from listen devices.

Hotkeys

Bind one key to toggle. Include --clip so the transcript is on the clipboard as well as latest.txt.

bun --cwd /ABS/repo repo listen toggle --clip

Replace /ABS/repo with the repo root.

  • mac: Shortcuts (Run Shell Script) or skhd: hyper - r : bun --cwd /ABS/repo repo listen toggle --clip
  • win: PowerToys Keyboard Manager or AutoHotkey: ^!r:: Run, bun --cwd C:\ABS\repo repo listen toggle --clip, C:\ABS\repo, Hide
  • linux: desktop custom shortcut or sxhkd to the same command.

No daemon. Each keypress is one CLI. First press records; second press transcribes.

--clip uses pbcopy (mac), clip (win), or wl-copy / xclip / xsel (linux).

Speak

Text-to-speech from the session. Output only. Not STT. Not a mic. Not MCP. Speech-to-text is /listen: node_modules/@workerstack/repo/references/listen.md and bun repo listen.

bun repo speak whoami
bun repo speak voices [--provider elevenlabs|openai]
bun repo speak say --text "hello" [--provider elevenlabs|openai] [--voice ID] [--out path] [--play]

say writes an mp3 under .repo/tmp/speak/ (or --out) and prints the path. --play is local afplay (mac) or ffplay.

Auth

Handheld (any OS): bun repo setup speak or bun repo setup → speak. Opens the key page, hides the paste, writes ~/.workerstack/speak.json, verifies. Non-TTY: --elevenlabs-key / --openai-key.

If a key is missing or rejected, the CLI prints setup: bun repo setup speak and exits 1. Agents should show that line, not a stack or JSON. Do not invent a key.

One key is enough. Never commit it.

env file
ElevenLabs ELEVENLABS_API_KEY elevenlabs.apiKey
OpenAI OPENAI_API_KEY openai.apiKey
Force provider SPEAK_PROVIDER provider

~/.workerstack/speak.json (chmod 600):

{
  "provider": "elevenlabs",
  "elevenlabs": {
    "apiKey": "sk_…",
    "voiceId": "JBFqnCBsd6RMkjVDRZzb",
    "modelId": "eleven_multilingual_v2"
  },
  "openai": {
    "apiKey": "sk-…",
    "voice": "coral",
    "model": "gpt-4o-mini-tts"
  }
}

Env wins over the file. Default provider is SPEAK_PROVIDER else the first configured key (elevenlabs, then openai).

Defaults

  • ElevenLabs voice: George JBFqnCBsd6RMkjVDRZzb. Model: eleven_multilingual_v2.
  • OpenAI voice: coral. Model: gpt-4o-mini-tts.
  • Format: mp3.

Create an ElevenLabs key at https://elevenlabs.io/app/settings/api-keys. OpenAI at https://platform.openai.com/api-keys.

Doctor reports speak as optional: ok when a key or ~/.workerstack/speak.json exists, else missing (optional). It prints the provider name, not the secret.

About

Agent workflow, code standards, gates, workspace and docs checks for workerstack repos

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages