Skip to content

shoalmark

Get a better-performing human Owner.

Agents do not wait for tools. They wait for their human. Asked what slows them most, three agents, run separately, gave the same answer, by a wide margin: the Owner's unanswered questions — one question, open three days, held three packages. And where the human does keep up, they keep up by not reading: in the repository this tool came from, 200 pull requests were merged in 22 days, 85 % of them less than a minute after opening, none with a review. Blocking and stamping have one root — a person asked for many small decisions in the middle of the work.

So the human is asked once, earlier, and in writing: what the work is for, what it must never do, what done has to prove. They see what waits for them, how long, and what it blocks — first, above everything else. The agents get what they need to run start to finish: state that outlives a session, one source of truth, a gate that refuses a done without a commit behind it. Less work and distraction for the human; more throughput and less friction for the agents.

What exists today: the tracker, the gate (it refuses a done without a commit behind it), the board with waiting for you on top, next: owner, a cold-start answer in one command (--next) — with git, on Windows, macOS and Linux, in any language. What is the direction, not yet built: the signed mandate, questions that carry a default and a deadline, the rest of evidence-checked done, the digest — explored, with what would kill each claim, in shoalmark's own tracker (FM-005, work-tracker/evidence/FM-005/design.md). Trust is earned there from the Owner's own answers before anything runs unattended.

In this beta:

  • Not a migration tool: it imports no ticket system and starts from your repository's own work. A path for a fleet that already runs a system of its own is filed, not built.
  • For one person in charge and their agent fleet. Several people in charge are not tested in this beta; support for them may come later.
  • Not proof that work is right. In Git or Subversion changes being checked, a move to Shipped must name in its ship log a commit already in that change's history that changes something outside the records (on Subversion, a revision). This evidence rule does not cover Closed. Validation reports a violation and exits non-zero. An installed Git commit hook can refuse the commit on that failure, but is optional and can be skipped. To enforce the check before merge, require a passing --check on the branch through your project's CI and merge rules; shoalmark does not set those up for you. Whether that commit did the job is what a review is for.
  • Made and tested on git with GitHub. Subversion passes the test suite but has not been used in real work: try it, without a guarantee. On Subversion it runs without git installed, from the release archive, and each run says what it cannot verify there: commit signatures and server-authenticated authors. Signed answers need a git repository, and the pull-request queue a git repository on GitHub and GitHub's command line, gh.

Git worktree identity and commit-trailer reports (--whoami, --session-trailer, --sessions), the forge queue (--queue) and signed answer-branch acts (--answer, --done, --due, --revoke) require Git. Under Subversion, --session new generates an id without checking a Git commit registry. A server login can attribute an SVN revision; it provides no cryptographic commit signature, and a local working-copy label proves no author. Checks that need server history refuse when that history cannot be read.

For archive acquisition, unpack the release ZIP and keep its complete inventory, including RELEASE and the probe files. Run the unpacked tool with --root <project directory> --vendor <destination>; --vendor names the destination and takes its source from the running tool's folder. It validates every exact source byte and required file before copying the 19 tool files. The manifest verifies the archive's internal bytes and completeness; obtain the release from the source you trust. The manifest and probe files remain acquisition inputs. A tag-none dry-run archive needs the existing --allow-untagged; neither that flag nor --partial permits an invalid or incomplete archive. Full SVN gate, board, history, protection and installed-entry qualification remains separate from archive acquisition; no complete operation without Git or new proof-platform result is promised here.

A convention, Stage 0: a requirements/ folder holds one line per requirement — id, shall, source, acceptance criterion — one text for developer and tester; a tracker cites the id. shoalmark has the convention only: checks and traceability are later.

A shoal mark is a mark set to show shallow water — a stake or a buoy. It tells a shoal where not to run aground.


A work tracker that lives in the repository it tracks: one Markdown file per work item, one Python file that reads them all, and checks you can run locally and in your project's CI. It needs no server, database or account of its own, and no dependency beyond Python 3.9. This page is written for the agent that has to use it. Find your situation, do what it says.

You are… Go to
starting a session in a repository that has tools/shoalmark/ §1 Start
about to file something §2 File
stopping, with work left §3 Stop
asked to run a triage pass §4 Triage
blocked by the gate §5 The gate refused me
adding it to a repository §6 Install
a seat starting a session, or reading who ran what §6 Sessions
making it know about releases, deploys, anything of the repository's own §7 The deriver
changing shoalmark itself §8 Working on shoalmark
giving the board a name, a logo, colours, another language §9 Branding

<cmd> below is python3 tools/shoalmark/shoalmark.py in a repository that vendors it — or whatever command that repository's own messages name. Every command finds the repository by itself: the nearest shoalmark.toml, else the nearest Git or SVN working-copy root. --root DIR selects the consumer project when the tool lives in a different folder; the tool's own source does not supply that project's identity or authority. Printed retry commands for answers and acts use PowerShell argument spelling on Windows and POSIX shell spelling on macOS and Linux.

The five rules

  1. The tracker is canonical. Spec, state and record of a piece of work live in its tracker — never in a side plan, a chat or a TODO comment. What is true now is rewritten in place; only the ship log is append-only.
  2. Look before you file. The default is a slice of a tracker that exists, not a new one.
  3. An id never changes and says nothing that can. MSR-012 — the kind of work is a tag, a story is a field.
  4. The seat judges, the command applies. Never hand-edit triaged: tier: rank: or a park.
  5. Close out by asking what this made obsolete — and delete it in the same change.

1. Start — what do I work on?

bash <cmd> --next

Prints the Owner's current path, then the ranked work in order — each with its next move, what it still needs, and the opening of its What is true now — and ends with START WITH: <id>: the first ranked tracker whose move is yours (review · run · script · build), not the Owner's (owner) and not a wait. Read that tracker. Work on a branch that carries the id: feat/msr-012-slug, fix/msr-013-slug.

Nothing ranked? It says how many trackers wait for a triage pass. No path written? Only the Owner writes it (<tracker dir>/TRIAGE.md) — ask; do not invent one.

2. File — look first

bash <cmd> --new "what is wrong, in one sentence" <cmd> --new "what is wrong, in one sentence" --tags bug # the kind of work, written into `tags:` as it is filed

It prints the closest existing trackers before it writes the file. Open the top hits. If one of them already owns this, add a slice to it and delete the new file. Otherwise fill the front matter:

```yaml

id: MSR-014 # written for you; equals the filename and the first heading status: Proposed # Proposed · In Progress · Parked · Reserved · Shipped · Closed considered: MSR-003, MSR-009 # what you held this against — or: none. REQUIRED: the gate refuses the file without it tags: bug # at most 3, from [tags] in shoalmark.toml — a closed vocabulary epic: MSR-003 # optional: the story this is a chapter of hook: "The problem as filed, in two or three sentences — this is the INDEX row."


```

Then the body: # MSR-014 — title · ## What is true now · ## Why · ## Done when · ## Ship log. <cmd> --schema prints every key, its shape and who may write it.

Tags. --new "…" --tags bug writes tags: into the new tracker. --tags takes a comma-separated list from [tags], at most three, case aside; a tag outside the vocabulary is refused before anything is written, and --tags without --new is refused. There is no filing freeze: R15, the Owner's ruling of 12 October, removed it, and every filing is written. A shoalmark.toml that still sets freeze_at or freeze_tag is named in one line by --new and --check, never refused; delete the line.

3. Stop — leave the fix

The next session starts cold. Before you stop:

  • Rewrite What is true now so its opening says what is left — one remainder, stated plainly.
  • Set next: to the next move: review (built, a branch waits to merge) · run (built, a real run is owed) · wait (a date or an event) · owner (the Owner's act alone) · script (mechanical) · build (anything else).
  • On a build, with the tracker open, set kind-of-problem: — obvious (a script could do it) · complicated (found by reading) · complex (found only by running) · chaos (harm now).
  • Waiting on something? blocked-by: MSR-009, Owner — the ruling awaited. Blocked is derived and clears itself.
  • Done? status: Shipped (merged — not deployed), with the commit that built it named in a ship-log row — the gate refuses the move without one (§5) — and remove rank:. Nothing was built? status: Closed. A story stays open while a chapter is.

4. Triage — you judge, the command applies

bash <cmd> --triage # writes <tracker dir>/evidence/triage/triage-<today>.md and prints the rules

Fill the Verdict and Reason cells, row by row, from the row. Run the same command again — every ten rows or so, and at the end: it applies what you filled, refreshes INDEX.md, keeps your rows, lists what is left.

Verdict Means The command writes
keep P1 #2 build worked on in the last 7 days, or the current path names it, or it is P0/P1 today triaged: tier: and — if given — rank: (1–10, one tracker each) and next:
epic MSR-003 P2 a keep that is a chapter of a story the same, plus epic:
park P3 fails the keep test; a real remainder, nobody on it. P2 or P3 only — harm is never parked status: Parked tier: triaged:
merge MSR-003 · close · fix its scope belongs elsewhere · retired · its status is simply wrong triaged: only — you finish these by hand, as the rules say

A raise is one line under a tracker's ## Raised — - <date> · <who> · <fact> · <source> · undermines: <what>. Dated after the tracker's triaged: and naming a signed rule it undermines — path N of the current path, or <ID>'s answer of a tracker they answered — it puts an open tracker under triage and on the next worksheet, marked RAISED, with the raise in its Now cell: a raise naming a signed rule re-judges the tracker the same day; any other raise waits for the next pass (the Owner's answer, FM-033). A day decides: a raise written after the same day's pass is re-judged by that seat's own re-run. One tracker, two rows: the re-judgement is a second filled row below the first, and the LAST filled row in the file is applied; the earlier is left as it is — the record of the first judgement — and every run names it, superseded on this sheet by the later row (FM-036). Never strike it to make the other apply.

Never write a note into a tracker during a pass; the reason lives in the worksheet. Never touch the current path. The pass is the seat's judgement, dated by its commit; the Owner lands it by merging; a row they disagree with is re-made by the seat on their word, or ruled by their signed answer — a merge rules nothing: an answer is written and signed, and a merge is not one.

5. The gate refused me

<cmd> regenerates INDEX.md; <cmd> --check only looks. Exit codes: 0 green · 3 drift — a tracker changed and nothing regenerated: run <cmd> · 4 a violation — fix the tracker; regenerating will not clear it · any other code comes from the repository's deriver (§7) and its message says why.

It says Do
a new tracker says what it was held against run --related <id>, open the hits, fill considered:
`xyz:` is not a front-matter key — did you mean … fix the typo; anything else belongs in the body
`key:` is <meaning> — <shape> — got … the value does not fit the key's shape; --schema shows it
dangling link -> FILE (did you mean …?) the id is right and the slug moved: use the file it names
duplicate tracker id · identity drift · H1 drift filename, id: and the first heading must carry one id; renumber the later filing — nothing is ever deduplicated for you
`rank:` … remove it when the tracker ships, parks or closes do that
`next: owner` without `ask-proposal:` an ask carries the move the seat would make: write it, with ask-kind: and ask-since:
`ask:` is ONE question one ?, at the end, 300 characters; the context goes in the body
`ask:` is the same question as <id> ask on that tracker, or say in considered: why this one differs
the answer is being removed and the exchange is nowhere in the body --clear-ask <id> <next move> — it keeps the record
--answer: the working tree has changes … Changed: … commit or stash what is yours; where it says a failed earlier --answer, run the one command it prints — it restores only the tool's leftovers — then the answer command it prints
`answerers = ["[email protected] signed"]` asks for a signed answer, and `owner = …` (or `[seats] …`) … is not signed add signed to that line, or remove answerers — with owner or [seats] it is not read for answers
`x@seat` is … the seat `y`, which does not hold `z` that change needs a right this seat has not got: [rights], §6
wait: not an answerer (<author>) (--queue) the answer/* pull request's answer, done: or due: commit — its head, or the newest that wrote one of those lines where only review files follow it — is by someone who may not answer — signed or not, it is not the Owner's answer: do not merge it, and find out who pushed it
wait: a seat's commit on your answer branch (<sha>, <author>) (--queue) the answer/* pull request carries, below their answer, done: or due: commit, a commit that is not their signed one and touches more than review files — --answer, --done and --due cut answer/<id> from the branch they stand on, so an act given on a seat's unmerged branch carries its commits, and a merge would take them in as theirs: do not merge it. The seat's commit lands on the default branch first, by its own pull request — the branch it came from (git branch -r --contains <sha>), else a branch the seat pushes at it — and then this one reads merge: your answer, your act as you signed it; nothing of yours is deleted (FM-030 C). --answer, --done and --due on that tracker say the same while it waits
wait: an unverified commit in your name on your answer branch (<sha>) (--queue) below your act is a commit in your name that does not verify as you — unsigned, or signed with a key the default branch's signers file does not hold for you. The tool's own refusal record (<ID>: --<act> refused — …, one line under ## Acts, unsigned by design) is not one: it is read as a review file's commit is. A seat can write your name: do not merge it; find out who made the commit, and let it land on the default branch by its own pull request first, as the row above says — and the tool's own refusal record is admitted only where the tracker's text proves it: one line under ## Acts, nothing else changed (RV-715)
wait: the base <base> is not fetched here — fetch it; the commits below <sha> are unread (--queue) this clone has not fetched the pull request's base — a single-branch clone fetches one branch — so it cannot see the commits below your act and says no merge: git fetch origin <branch>:refs/remotes/origin/<branch>, then --queue again
wait: answer not verified here — <why> (--queue) the Owner's answer/* pull request is signed, and this clone cannot check it — the same cause, the same cure as the next row
it is signed, but not with a key <file> on <default branch> holds for that identity the commit is signed with a key the gate does not trust for that email — it reads the default branch's copy of the signers file (§5). A new key lands there first: the Owner's signed commit to the file, merged — then the same commit verifies, nothing re-signed; --answer left it on answer/<id>, unpushed: push that branch
it is signed, but this clone cannot verify: … the commit is signed; this clone cannot check it. Set gpg.ssh.allowedSignersFile to the repository's signers file — the signing page says how. The exit is the same until it verifies
sign with SSH; GPG returns with a fingerprint binding a signed line verifies by SSH only, and this commit carries a GPG (or X.509) signature: sign it with your SSH key — the signing page says how
`owner` names `x` as signed, and a signed identity is an email address — … (exit 1, as the configuration is read; or [seats] …, answerers) a signed identity is an email: write the email the signers file names for the key — under answerers, as owner = "<email> signed"
carries no Session: trailer · a session id is eight hex characters · names the seat … set seat.session in your worktree: the harness's id, or <parent>/<seat>-<n> for a sub-agent of your own seat (§6 Sessions)
--answer: X is answered already an answer is never overwritten in place: --answer X revoke "<reason>" takes it back, --answer X accept\|reject "<option>" --supersede replaces it — the old one moves into the ship log
`answer/x` exists and is not merged the refusal says what is on it. Your commits: merge it first — or, where it names the act open there, git switch answer/x and run --done/--due on it. Nothing of yours: git branch -D answer/x, the one case it names. Nothing unmerged is deleted for you
refused: commit … changes the text under `## The intent` · ## The current path · renames or removes the heading · deletes or moves …/TRIAGE.md · points the tracker directory elsewhere · brings a text … that no parent had (FM-037) the two sections are the Owner's, and only their signed commit changes them. Every commit of the branch is judged, so a revert does not clear it — the revert changes the text too: carry the work onto a fresh branch without that commit (never force a pushed one), and put the change to them as an ask — ask: in its tracker, one sentence, ask-kind: ruling, ask-since:, next: owner. They commit it themselves, signed. Below the table
changes the signers file … (FM-037, AU-19) the keys their signature is verified against are theirs too: a seat proposes a key as an ask, and they commit it, signed
wait: TRIAGE.md changed unsigned (--queue) a commit on that branch changes their intent or current path and is not their signed commit — the refusal above, read on the pull request: do not merge it
names no tracker · not judged · Parked · not In Progress (judged_before_build) a commit that changes a path outside the tracker directory is built under a tracker a pass has kept In Progress — at the commit's parent. Name it: the id in the subject, or a branch <kind>/<NNN>-…; ask for the pass if it is not judged. Never write triaged: by hand, and never build first and judge after
moved to `Shipped` with no commit behind it (on Subversion … no revision behind it) — its ship log names no commit · abc1234 is not in the history · abc1234 changes nothing outside the records (FM-005) a move to Shipped names the commit that built it, in a row of the tracker's ship log: a git hash (seven characters or more) or, on Subversion, r<N> — one that is in the history of that change and changes a path outside the records ([ratio] records, else the tracker directory). The refusal prints the command that finds it: git log --oneline -- . ':(exclude,top)<tracker dir>/', on Subversion svn log -v -l 20. A commit being made cannot name itself: ship in a later commit. Nothing was built? The tracker is Closed, not Shipped — a move to Closed is not judged. It holds for every author, the Owner included, with or without [seats], and judges only the change that moves the status: a tracker shipped before, and left alone, is never read. On Subversion the change is the uncommitted one, else the newest revision. A squash or rebase merge rewrites the commits a ship log names, and --check on the default branch then refuses the move: there, ship in a later pull request, naming the commit the default branch holds
A story is open while a chapter is keep the story In Progress with next: wait, or move the chapters first
… differs from its PIN someone edited the vendored tool in place. Never do that: change it upstream, vendor again

Only the Owner changes their intent and their current path (FM-037, their word of 2026-09-25). --check walks the branch's own commits — HEAD less origin's default branch, merges included — and reads, for each, what a pass reads as theirs: the text under ## The intent and ## The current path of TRIAGE.md (the repository's [headings] names, or the English ones), from its heading line to the next ##, each commit under its own shoalmark.toml — its tracker_dir names the file. Byte for byte: whitespace counts, since the text is printed as written and a comparison with nothing normalised has nothing to slip past. A text that differs from the parent's is a change — a heading renamed or removed, TRIAGE.md deleted, moved away from its name or out from under the tracker directory, tracker_dir pointed at a directory whose TRIAGE.md reads otherwise or not at all; a merge's change is only a text no parent had (FM-019), what it carries having been judged on the commit that made it. A change is refused, exit 4, unless it is the Owner's signed commit: %G? G, the signer principal (%GS) the author's email, and the author the Owner — or a seat [rights] gives answer — in the default branch's configuration — never the branch's own, so a branch that names a seat the Owner, or drops signed from theirs, does not judge itself. Nor its keys (the Auditor seat's AU-19): a signature is verified against the default branch's copy of the signers file gpg.ssh.allowedSignersFile names, where that file sits in a checkout of this repository — never the branch's own — so a branch that appends its own key under their email vouches for nothing; the answer gate and --queue verify the same way. The signers file itself — that one, and <tracker dir>/allowed_signers — is kept like the two sections: a change to it is refused unless it is their signed commit. Its first version therefore lands on the default branch by their own hand: a branch cannot prove a key the default branch does not hold — until it is there, no SSH signature verifies against a signers file in a checkout (the cold re-review's R2), and a new key verifies once it is merged there: --answer asks the same test before it pushes (R1). ## Passes and the rest of the file stay open to seats; a scaffold where there was none is accepted — any whose words the readers know (--init's, the German one, 0.1.0's bare lines: AU-20). The reading of a move (the pass's R7): the guard judges what the tool reads, so a move of the whole tracker together with its key that leaves both sections byte-identical is no change — ae1f05e, the tracker's move out of docs/, is accepted on its text, not by its date; refusing every move and exempting that one by date was the alternative, not taken, because it keys a hole to a calendar and refuses a move that leaves every word of theirs as they wrote it. The commit-msg hook refuses a seat's such commit before it is made; it sees the author only — git signs after its hooks run — so the Owner's own passes it on their name and --check on the branch, the gate, judges the signature. --queue reads such a branch wait: TRIAGE.md changed unsigned. Where their owner is not signed, it proves the author only, and says so. Subversion is out of scope: its working copy carries no signature, so nothing there can tell their commit from a seat's. Its limit (FM-007): a commit signed with the Owner's key passes; at tier 0 any process on their account holds that key.

Never --no-verify. Never hand-edit INDEX.md — it is generated.

6. Install and upgrade

bash git clone --branch vX.Y.Z https://github.com/shoalmark/shoalmark /tmp/shoalmark # a release: a clean clone at its tag python3 /tmp/shoalmark/shoalmark.py --vendor <repo>/tools/shoalmark # a pinned, self-contained copy + PIN (sha256) cd <repo> python3 tools/shoalmark/shoalmark.py --init --key MSR # shoalmark.toml · TRIAGE.md · .gitignore · the contract in AGENTS.md · a CLAUDE.md router python3 tools/shoalmark/shoalmark.py --install-hook # plain git hooks, and the copy of the tool they all run; a hook that is not shoalmark's is never overwritten

--install-hook writes three hooks, the three that judge a commit — pre-commit, prepare-commit-msg and commit-msg — and every one runs a copy of the tool kept in the git directory (shoalmark-trusted/, shared by every worktree), never the tool a branch brings. No hook refreshes the board (R11, the Owner's ruling of 12 October): nothing shoalmark writes runs after a checkout, a merge, a rebase or a pull. The board is rebuilt by an explicit run — --html-only, or the plain run — by the commit's own hook where the commit stages a tracker, and by --answer, --done, --due and --revoke right after each pushes. Where an earlier version wrote post-checkout, post-merge or post-rewrite, the next --install-hook removes each one exactly as that version wrote it, with a line; a hook of those names that it did not write — or one changed since — is kept as it is, and named. A commit's hook writes only inside the repository, outside its git directory and through no symlink, and it fails closed: where the copy is not there, refuses, or does not finish within 35 seconds (SHOALMARK_BOARD_SECONDS gives it longer), the commit is refused with one line. No hook runs the repository's deriver: where there is one, the commit's hook leaves INDEX.md and the files it derives as staged and says so in one line — run the tool before committing; CI's --check, which runs the deriver, holds them. --install-hook refuses a hooks folder inside the working tree — a core.hooksPath there, or a symlink into it — where a branch could change the hooks themselves, and writes no hook and no copy. Only --install-hook writes the copy — from a pinned copy that passes its PIN, else from the working tree's tool, and it says which commit and branch — so run --install-hook on your default branch, and again after upgrading; where the tree's tool differs from the copy, a hook still judges with the copy and prints one line that says so. A person, an agent and CI run the repository's own tool, as ever. Developing the tool itself means installing the branch's copy deliberately: --install-hook on that branch, which warns that the copy carries that branch's tool. On git and on Subversion alike, after a checkout, a pull or svn update, run --html-only to rebuild the board.

Then the Owner writes the intent and the current path in <tracker dir>/TRIAGE.md. Nobody else edits those two sections. The intent is three lines in their own words about the repository as a whole, never one feature of it: what this repository, all of it, is for · what is true when it works · what no pass or seat may do to get there. --init writes an example in italics, a whole product, for them to overwrite:

markdown - **for** — *e.g. a village library's lending, all of it: members, loans, returns and the shelf in one record the librarian trusts* - **so that** — *e.g. a member finds a book and a librarian finds a member in one look, and nothing on loan is lost* - **never** — *e.g. lend what the catalogue does not hold, or drop a member's record before their last loan is back*

A pass reads everything they write under the intent and leaves out only the scaffold's own lead-in and examples, recognised by their exact text, never by italics, bold or length: an example with one word changed is theirs.

--vendor vouches for what it copies. It refuses — exit 4, nothing written — a source that is not the whole tool (it names the missing files), a source that is no release (a git checkout whose HEAD is exactly at v<VERSION>, with a clean tree — it names the HEAD and the changed paths), and a copy in the target that was edited in place. A consumer runs a release, never a working copy. --partial copies an incomplete source anyway and --allow-untagged a working copy; each says so in the PIN. The PIN's first line is the manifest — # shoalmark X.Y.Z · tag vX.Y.Z · commit <sha> · vendored <date> · complete — and the consumer's --check checks it (that exact form, the tag against the version, the version against the copy's VERSION) before it prints where the copy came from, with a warning for an untagged or partial one; any other line is named, and the copy called unverified.

Upgrade: --vendor again from the new release's clone (it names the tag, prints what changed since the version it replaces, and refuses a copy that was edited in place), then --init again to refresh the contract between its markers — your text outside them is kept.

What lives where, by convention — no setting names any of it:

Path What
shoalmark.toml optional. name · tracker_dir (default docs/work-tracker) · blob (forge URL prefix) · commit_url (the URL a commit's id is appended to, where blob gives no commit page — …/blob/ and GitLab's …/-/blob/ do; on Subversion a viewer's, the revision appended) · triage_days (7) · judged_before_build (false — a pass judges before the first build commit, §5) · owner (an identity, or a list of them, at the top before any table — the Owner, who is not a seat; §Seats) · [paths] reviews (evidence/reviews/ — where the Reviewer's files sit, a glob allowed; --queue reads it) · [ratio] records (the tracker directory, tracker_dir — the records a move to Shipped reads: a commit its ship log names must change a path outside them; a trailing slash is a directory, a plain path a file) and exclude ([] — kept as written; nothing counts with it since R13 removed --ratio, and --check says so where it is set); another [ratio] key is named by --check and not read · [kinds] id prefix → INDEX section · [considered_from] · [tags] · [seats] (a string, or a list of identities per seat — §Seat icons on the forge; owner there is the old spelling) · [rights] (§Seats) · [headings] the ten section names the tool reads and writes (state why done log asks raised acts in a tracker, intent path passes in TRIAGE.md) — for a repository that is not in English; the English names stay understood
git · Subversion · Windows --install-hook wires what the system has: git's commit hooks — they run a copy of the tool kept in the git directory; none refreshes the board — or on Subversion the TortoiseSVN hook properties and svn:ignore. svn commit on the command line runs no hook — run the tool first. On Windows the command is python. CI proves all three systems
what an ask must be ONE question — one ?, at the end, at most 300 characters — with ask-kind:, ask-since: and ask-proposal:, and never the same question as another open tracker's. At most five ask-options:, 120 characters each. The gate refuses the rest, and the board shows what got in anyway as N asks sent back — not for you
drafting an ask an ask: with next: review is a draft: any seat writes one (the question and its ask-options:), it needs no proposal and the Owner never sees it. The Planner rewrites it, orders the options, sets ask-proposal:, ask-since: and next: owner — that is what puts it in front of them
what needs the Owner ask: (one sentence they can answer) · ask-kind: ruling · action · determination · ceremony · ask-since: — with next: owner. The board leads with them; --owner is the digest a session ends its last message with; --standup is the agenda of their one sitting, at the time standup = "09:00" in shoalmark.toml names; --standup DIR … is one agenda across the repositories they name — each read as it stands, a folder it cannot read named with why and counted in nothing — that runs nothing of theirs and writes nothing, starts git and svn from PATH's absolute entries outside those repositories, and says what that leaves unread (Q7, the standup). Both list, after the asks, ACTS — yours, with their time: missed and overdue first, then what falls due, then no date yet, each with its due: and what it is; and both name what invites and notifications left on the machine (§What invites and notifications left). The board records what it was built from — the tracker folder, the configuration, the answer branches it reads — in view/built.json, beside what it read, and when, as its page names it (on Subversion and in a plain folder that alone); where any of it has changed since, both say so in one line after the digest, the board is older than what it shows, with the command that rebuilds it, --html-only. A commit alone changes none of it; under Subversion and in a plain folder nothing is said
what to merge --queue — the open pull requests, read from GitHub with gh (origin fetched once), ONE action each, in the order to take them: what they can act on first, then what waits, the oldest first inside each. A pull request from a fork — GitHub's isCrossRepository — reads wait: from a fork, read it yourself before every other rule: its commits supply no verdict and no carry-over, for it or for any other pull request. merge — a verdict names its head, or a head that only review addenda follow (commits touching nothing but the review folder — [paths] reviews in shoalmark.toml, evidence/reviews/ by default, a glob such as evidence/*/ allowed — and sessions.md; the verdict commit's own review*.md counts wherever it sits under <tracker dir>/evidence/, so a head that IS the verdict is covered), and git merge-tree merges it clean · closes with PR N — its head is inside N's · close: carried into PR N — every commit of its own is on N's branch, as the commit or as the same patch · wait: conflict in <paths> · wait: TRIAGE.md changed unsigned — a commit of its own changes the Owner's intent or current path and is not their signed commit (§5, FM-037; wait: TRIAGE.md change not verified here — <why> where it is signed and this clone cannot check it) · wait: no verdict on <sha> · wait: NOT READY (<verdict>). An answer/* pull request is the Owner's own answer, done: or due:, and needs no verdict, and is read by their answer, done: or due: commit — the newest of its own that changed an answer:, done: or due: line, where only review files follow it (a Reviewer's docs pass on the answer: the review folder, sessions.md, a review*.md anywhere under <tracker dir>/evidence/), else its head: merge: your answer when that commit's author may answer (the seat matched as the gate matches it, email or name) and the commit verifies, and every commit of its own below that one is theirs too, by the same test, a review file's only, or the tool's own refusal record — else wait: a seat's commit on your answer branch (<sha>, <author>), or for a commit in their name that does not verify wait: an unverified commit in your name on your answer branch (<sha>), the first such below theirs: a merge would carry it, and --answer, --done and --due cut the branch from the one they stand on; where the pull request's base is not fetched here, wait: the base <base> is not fetched here — …, never a merge it cannot see; wait: not an answerer (<author>) when the author may not answer, signed or not; for an author who may, wait: answer not verified here — <why> when it is signed and this clone cannot check it, else wait: unsigned answer. After the pull requests, each branch on origin that none carries — not the default branch, not answer/*, not a head a pull request ever had, not one already merged or inside an open pull request or another such branch — as branch <name> @ <sha> wait: no pull request — …, read the same way: no verdict on <sha>, verdict <sha> READY …: open it, NOT READY (<sha>), conflict in <paths>, TRIAGE.md changed unsigned (<sha>). A head the clone's fetch does not cover is fetched by its ref; one that still is not here reads not fetched here, on its line alone. A last line counts them: N waiting on you: a merge, b close, c wait, n pushed without a pull request. Then MERGED OR CLOSED IN THE LAST 24 HOURS: each pull request the forge merged or closed in the last day, with its time — so a seat's still open is checked against the forge in the same turn. Without gh, offline, or with no GitHub origin, it says so in one line, exit 3 — a view fails nothing. --owner and --standup end with it where the forge can be read, and leave it out where not
what the ask offers ask-options: — the choices as ONE line, a \| b \| c; ask-proposal: is the one the seat recommends — offered first and marked, and where options are named it must be one of them. A proposal alone is a list of one
an act owed to the Owner due: — when it falls due, an ISO time with its zone (2026-09-26T07:30:00+02:00); window: — the minutes after it in which it can still be done (60 where absent); done: — its result, <time> · <where it is>. The seat that schedules the act writes due:, with the action ask or when its time is set; --answer writes it with an accepted action answer whose promise names a date with its hour — 2026-09-26 09:00, 2026-09-26T09:00+02:00, or the weekday with its month and day, Sat 09-26 09:00 CEST, the weekday checked — in the zone it names, else the machine's own, and says so; a weekday alone, a month and day with neither year nor weekday, a zone name the machine does not carry, a word after the time that is no zone as written (cest), a 12-hour time (9:00 PM), a fraction of a second (09:00:00.000Z), or two times are not read — nothing rather than a wrong hour — and a due: already set is left (FM-030, the E0 counter's row 20); --clear-ask leaves it, because the answer to an action ask is a promise and the act is still owed. The gate refuses a time that is not one, or has no zone. An act is owed where an action ask was accepted or a due: is set, until done: is written: the Owner's board lists it after the questions, under your acts, with their time — no date yet, due, overdue after its time, missed once its window has passed with no result, by the page's clock — and INDEX.md lists it with its time as written, which no minute changes. An act that is a promise reads as what they promised — the text their accepted answer carries, the option they took or their change, else the proposal a bare accepted took — with the question it answered below it, smaller, as context: on the board and in its two dialogs, in --owner and --standup (FM-030, their word of 2026-09-27 13:38:30); a due: beside a question they have not answered keeps its own line. Each act on the board has two buttons, done and reschedule: one asks where the result is, the other the new time, and OK gives one command on the answer's second screen — --done <id> "<where the result is>" writes done: (the time, and where) — the person gives the path; the record gathers the facts: for a file in the repository (from its root, or from the tracker directory; a line anchor after it, file.md:12 or #L1-L9, kept as given), the commit that added it and its date, and for a review — a file that states a verdict, Verdict: READY or a bold **NOT READY** opening its line — the verdict of its last pass: the last line that states one, that pass's Reviewed: sha and Session: from its own lines, else the trailers of the newest commit that touched the file, with last pass in that commit where it is not the one that added it; a word that names no file there is recorded as given with not in the repository beside it, words as given, nothing guessed, nothing refused; a later --due on the done act keeps it in its record —, sets next: build where the answer left next: owner, and records the act under ## Acts; --due <id> <time> writes the new due: and records the old one there. Both are made as --answer makes their answer — on answer/<id>, signed where their owner is signed, pushed — and refused from a seat without the answer right. Refused on answer/<id>, after its cut — the commit refused, the file not written — --done, --due and --answer undo their change and leave one line under ## Acts there, **<date> <time>** · <the command> refused — <why>, committed unsigned — it changes no key and records no act, so no right is read in it — and pushed; the next run on that tracker names the branch. Refused before the cut — a tree with changes, no signing key — they print only (FM-030, the E0 counter's row 20)
invites and notifications Removed by R9 (the ruling signed 2026-10-12): --invite <id>, --notify and --standup FILE.ics each print one line saying so, and exit 2. Owed acts, due:, done: and their signed history stay, and --standup, --owner and the board list each act owed with its time. What the three left is named on every --standup and --owner that sees it, and never read, run or removed by the tool — §What invites and notifications left
the stand-up and the close-out Two rows in the board's box of what waits for the Owner: stand-up prompt before the sessions of the last day, close-out prompt at the box's end (FM-030, R9's two quick wins). Each button opens one dialog that copies a fixed prompt, in English on every board, for the session they use — Claude Code, Codex or another — and runs nothing: the stand-up asks the session to hold their sitting from --standup's agenda, one item at a time, printing the command that records each decision for them to run themselves; the close-out asks it to read the day back from --owner, --answered and one git log, and to change nothing. A prompt asks; it cannot make a session obey, and a message is never an answer. Four values fill the prompts, none written by an agent — the folder name, its path (from ~/ under the home folder), the tool's command, and for the close-out the default branch's name (origin/HEAD, read-only, as the board already reads it) — each checked first: ASCII letters and digits, the space and . _ / ~ \ : -, at most 200 characters; and where the tool's own command names it by a path that holds a space, which the copied command would split, the command fails too (a command set in SHOALMARK_CMD is taken as written). A value that fails fills no prompt: the dialog names it, escaped, and offers nothing to copy. The stand-up's dialog names what --standup still runs — the deriver, where there is one, and the forge's queue, gh and a fetch of origin. Copying takes the answer screen's clipboard path: Copied. only when the clipboard said so
their act or answer on its way The board reads git (FM-030, their signed answer 920970b7, 2026-09-27 14:56:15). --answer, --done and --due commit on answer/<id> and push, and the default branch knows nothing of it until their merge. The board, --owner and --standup read every origin/answer/<id> this clone holds that is not merged into origin's default branch — the tool's own push leaves that ref, a fetch brings another machine's; nothing is fetched to read it and nothing is written to remember it — and where its tip carries a done:, an answer: or a due: the default branch lacks, the ask leaves waiting for you, the act leaves your acts, and both are listed under on their way: n: their promise or their answer first, then answered, on its way, done, on its way — or rescheduled, on its way — due (--due is their act as --done and --answer are, and a --due after a done act opens a new one — never done revoked), revoked, on its way — and done revoked, on its way only where --revoke made the change, the branch, the commit that wrote it — read from the branch's commits, so a Reviewer's verdict on top is no act — its time, whether it verifies (--queue's own reading of an answer commit), and your merge is next — or, where --queue waits on the branch for more than that commit's own signature (a seat's commit below their act, a head past it, a base not fetched here), your merge waits: , the queue's own line. A merged branch, or one whose tip carries no such change, changes none of the board's rows and nothing of the rendered board. Right after the act the page shows it: put back on the branch they started on — or on answer/<id>, where they ran it — the command rebuilds the board itself (--html-only), and says so; no hook does. An answer or an act done on its way has one button, revoke (a reschedule has none — --due again, on its branch): --revoke <id> "<why>" takes back what they last did, as a new signed commit, never an overwrite — on an act done, done: leaves the front matter, the revocation is recorded under ## Acts and the act is owed again; else their answer, as --answer <id> revoke takes it. Where answer/<id> is not merged, it commits on top of it there — one branch per exchange; merged, it is cut fresh and the revocation is on its way in its turn. The seats that hold answer may run it
the Owner's answer on the board: accept or reject opens a dialog with the question, the choices and what it holds up — one radio per option, the recommended one first, and Other: with a box. OK opens a second screen: the one command, where to run it (the branch the board was built from), what it does, what success looks like, how to check the signature, the signing page for when it fails — and Done. --answer <id> accept\|reject ["text"] cuts answer/<id> from the ask's branch, writes the three lines and the next move — next: build after a ruling, a determination or a ceremony, the seat's move; next: owner kept for an action, whose act is still theirs — commits signed, pushes — naming each step on stderr as it starts — and goes back to the branch it started on. An answer/<id> left from an earlier answer is deleted and cut fresh when it is merged into origin's default branch, and refused when it is not — naming their commits on it, and git branch -D answer/<id> only where nothing of theirs is there: nothing unmerged is deleted for them. An answer is never overwritten in place: --answer <id> revoke "<reason>" takes it back (revoked - <reason>), and --answer <id> accept\|reject "<option>" --supersede replaces it; either moves the answer it replaces into the ship log — \| <date> \| Answer of <answered> superseded: *"<answer>"* (<sha>) — revoked: <reason> \|, or — replaced by: *"<answer>"* — and the board shows the answer with supersedes . The word they sign is the button's — under accepted the dialog and --answer write the proposal, another option and changed text alike — and every reading names its relation to the proposal beside it, computed from answer: against ask-proposal: and ask-options: with the answer's own normalisation on both sides (trimmed, whitespace runs one space, " as '): accepted the proposal · chose option N: (N its place in ask-options:) · accepted with a change · rejected: · revoked: · relation not computable where there is no proposal to compare it with. The board's tracker view, --answered and the record --clear-ask writes print it; the signed line is never rewritten, and a hand-written accepted — <text> reads as accepted - <text>. A failure after it has written anything undoes all of it — the paths restored, back on the starting branch, an empty answer/<id> deleted — and prints what refused it, the answer, and the command that gives it again; on answer/<id> the refusal is recorded there under ## Acts, as an act command's is. --answered is what they answered and no seat has acted on
acting on an answer --clear-ask <id> <next move> — moves the exchange into the body under ## Asks (date · question · answer · answered-by, a **relation** — line with the answer's relation to the proposal, which leaves the front matter with the ask, and a **signed** — <sha> · <G\|N\|U> line naming the commit that wrote the answer and what git says of its signature, %G?; newest last), clears the ask and answer lines, sets the move. Under [seats] that is the ask right's move — the planner's, not the owner's. The gate refuses a commit that drops an answer without that record, and --answered reports what was acted on since the last standup, by commit, with the relation its record carries. A record written before 0.18.1 has no **relation** — line: --answered and the board's tracker view — under each such record, not only the newest — read its relation from the commit that wrote its answer — the one commit that adds that answer: line under the record's question (its ask: at that revision), its ask-proposal: and ask-options: there — and name that commit; where no commit in the checkout holds both, or more than one does, relation not computable, never a guess
who may answer the Owner, named at the top of shoalmark.toml (§Seats) — owner = "[email protected] signed" — and any seat [rights] gives answer. Without owner and [seats], the old key: answerers = ["name"], git author names — a signed entry there must be an email, so write owner = "<email> signed" instead. The gate reads the answer's committer from git or Subversion; under signed the commit must be signed with SSH and verify under the default branch's signers file, and its principal must be the configured email exactly. Empty = nobody may answer. With owner or [seats], answerers is not read for answers — and a signed entry beside an unsigned seat that answers for it is refused, not dropped. Setup for a human: the signing page, docs/signing.md
<tracker dir>/<ID>-<slug>.md the trackers — one flat directory, the id in the filename
<tracker dir>/TRIAGE.md the Owner's intent and current path; one paragraph per pass
<tracker dir>/sessions.md gone since 0.18.0 — the registry of seat sessions is a report, --sessions (§Sessions); delete a file left from before, history keeps its rows
a seat's worktree three per-worktree git settings (git config --worktree), read by the tool and never by hand: user.email (the seat — who may) · seat.session (the run) · seat.harness (the id the harness gave the seat, for its model and effort — §Sessions; --schema lists them). --whoami prints From: <session> <seat> (<worktree>) · <model> · <effort> — the line a seat's report opens with; a message between sessions names its target by the same identity after To:
<tracker dir>/INDEX.md generated, committed — what an agent reads
<tracker dir>/index.html, view/ generated, git-ignored — the read-only board the Owner reads. Under its name one line says what it read, and when: the time it was built; on git the commit, the branch or a detached HEAD, and how many paths were changed and not committed — a plain run counts them; the board's own run, --html-only, says what is changed is not read —; on Subversion the working copy's revision; with none, the folder. The commit or the revision links to its page only where the configuration names the forge (commit_url, else blob) and, on git, only once origin holds it; else the line says why — never a link guessed from a remote's address — and that it reads its file again when you come back to it while nothing polls, with the command that rebuilds it, --html-only, and a button that copies it. It never calls itself current
<tracker dir>/evidence/ worksheets and pass records — append-only, never on a reader's path
<tracker dir>/derive optional, executable — the repository's own axis (§7)
<tracker dir>/brand/ optional — the repository's theme.css, logo.svg, wordmark.svg, labels.yaml, fonts (§9)

What invites and notifications left

R9 removed --invite <id>, --notify and --standup FILE.ics. What they wrote, or told you to add, is yours: the tool reads, runs and removes none of it. --standup and --owner name each piece they can see, after the digest, under LEFT BY INVITES AND NOTIFICATIONS, on every run until you remove it; the board names none of it.

  • Calendar files. --invite <id> wrote <tracker dir>/evidence/<id>/<id>-act.ics; --standup FILE.ics wrote where you named it. Both runs name such a file wherever it is in the repository — its tree walked through no symlinked folder — known by its first three lines; one written outside the repository is not seen. Delete the files, and the events you imported from them — the stand-up's weekly event says to run --standup, which stays.
  • The schedule. Before R9 this README gave --notify a cron line, which writes ~/.local/state/shoalmark/notify.log, and a launchd agent, ~/Library/LaunchAgents/app.shoalmark.notify.plist, which writes ~/Library/Logs/shoalmark-notify.log. Each run of either now prints only R9's line and exits 2. Take the cron line out with crontab -e, or launchctl unload the agent and delete its file; then delete the log. Both runs name the logs, with when each was last written, and the agent's file. The tool starts no program to read a crontab, so a schedule you named otherwise, or that logs elsewhere, is not seen.
  • What --notify remembered. notified.json in $XDG_STATE_HOME/shoalmark/, else ~/.local/state/shoalmark/ (on Windows %LOCALAPPDATA%\shoalmark\). Delete it, then the folder once it is empty.

Seats

Who is at the keyboard, and what that seat may change. Four rights, each a front-matter transition the gate sees in a diff: answer (the three answer lines) · ask (next: owner, and clearing an answered ask with its record) · close (a terminal status) · triage (considered:, kind-of-problem:, tier, rank); anything else is open to every seat. The tool knows the Owner, and three seats with their rights built in — planner ask · close · triage, reviewer triage, builder none; principal and implementer, their former names, still read and hold the same — any other name says so in [rights], in the same diff as anything it would allow. The Owner is not a seat. A top-level owner = "[email protected] signed", before any table, names them — one identity or a list of them, signed per identity as for a seat — and they hold all four rights. A signed identity is an email address, and it verifies by SSH only: the commit's signature is SSH, good under the default branch's signers file, and its principal equals that email exactly — never a principal that merely contains it, never the commit's author standing in for it. A signed identity that is not an email is refused when the configuration is read, exit 1, with the way to migrate: write the email the signers file names for the key. A GPG or X.509 signature on a signed line is refused: sign with SSH; GPG returns with a fingerprint binding. [seats] owner is still read, as its old spelling: both present and the same are read once, both present and different are refused at configuration, in one line naming both, and so is an owner key inside any other table (in [rights], a list of rights is the Owner's own), which is where a line meant for the top lands when it is written below a [table] header. Absent [seats] and owner, nothing of this is enforced. The seats are called Planner and Builder in prose and in [seats]: this repository's keys are planner and builder, each with its GitHub App's bot address beside the old one (§Seat icons on the forge). A merge is judged by what it changes itself — the files where it differs from every parent — under the merger, and an answer or a next: owner it sets in its own change is refused: set it in a commit of its own. Every commit it brings is judged under that commit's own author and signature: an ordinary commit against its own parent, a merge, nested at any depth, by what it changes itself. A merge that equals a parent in every tracker adds nothing, and never launders a commit that was made without the hook.

toml owner = "[email protected] signed" # the Owner, not a seat: at the top, before any table; `signed` as for a seat [seats] # a name you choose -> the identity version control reports planner = "planner@seat signed" # `signed`: an email; the commit is SSH-signed under a key trusted for exactly it builder = "builder@seat" [rights] chef = ["answer", "close"] # only for a name of your own — not owner, planner, reviewer or builder

The badge: git config extensions.worktreeConfig true once, then git config --worktree user.email planner@seat in each seat's worktree. A signed seat's key belongs where only that seat runs — a container, later; the Owner's key is never in a seat's environment. This catches an agent that does not know the rule, not one that lies: that is FM-007's class, and nothing moves work except the Owner's signed answer. On Subversion the identity is the server account and signed is refused — the server authenticated the commit, and the gate reads the author it recorded (svn blame --xml, as the answer gate does). One seat = one SVN account whose credentials exist only in that seat's environment (a container, or its own Windows user), never the Owner's cached ones (~/.subversion/auth, the Windows credential store); the svn command line runs no hook, so a protected edit is committed there, and --check after the commit is what judges it, with the board's sent back group as the backstop. A seat's charter — how it thinks, a bold Planner against a steady one — is <tracker dir>/seats/<name>.md, read by the agent at start, never by the gate.

Seat icons on the forge

A commit whose author email is a GitHub App's bot address, <id>+<slug>[bot]@users.noreply.github.com, shows that App's icon on the forge — so each seat can wear its own. Per seat, one GitHub App owned by the organisation: no permissions, webhook inactive, no private key, no client secret; only the icon uploaded. The bot's id is id in GET /users/<slug>%5Bbot%5D; if that answers 404, install the App on the organisation — only the repository where the screen offers a choice, and anyway where it offers none: with no permissions and no key, nothing can act through it. Then [seats] lists the new address beside the old one — a seat's value is a string as ever, or a list, and every identity in it is that seat — so history and the branches in flight keep resolving:

toml planner = ["planner@seat", "12345+shoalmark-planner[bot]@users.noreply.github.com"] # each item may end in `signed`

Each seat's worktree then takes the new address: git config --worktree user.email '12345+shoalmark-planner[bot]@users.noreply.github.com'. Nothing else changes: the seat's rights, its Session: trailer, and the Owner's signature, which stays theirs. signed is read per identity; an identity under two seats is refused at configuration, in one line naming both. The seats are planner and builder from 0.19.0 — principal and implementer still read, and hold the same. This repository made the change in one step: the keys renamed, each seat's bot address beside its old one, and gtm, a seat of its own until then, an address of go-to-market; a session begun under a seat's old name (<id>/implementer-<n>) still reads as the seat's. A seat is it, not a person — its texts say it on purpose; they is for the Owner and any person.

Sessions

The seat says who may; it cannot say which run: two sessions of one seat are one author in git. So a seat's worktree carries a second setting beside its badge, and every commit made there names its session.

bash git config --worktree user.email planner@seat # the seat — read by the gate for rights git config --worktree seat.session a9f3c2d1 # the session — the harness's session id, its first eight hex characters; # a sub-agent derives its id from its parent's: a9f3c2d1/reviewer-1; # a session with no parent and a harness with no id: `<cmd> --session new` git config --worktree seat.harness aefd1a3520c000231 # the id the HARNESS gave this seat, written by whoever spawns it from the spawn's # result — Claude Code's session id, or a sub-agent's agent id; Codex's thread id

The trailers: --install-hook writes a prepare-commit-msg hook that appends Session: <seat.session> and Worktree: <the checkout's directory> to every commit made in that worktree — never typed, and a trailer the message carries already is left alone. A repository with no seat.session (the Owner's checkout) gets nothing appended: their signature is their id. Read them back with git log --format='%h %ae %(trailers:key=Session,valueonly)'.

The model and the effort are appended beside them — Model: and Effort: — from the harness's own log, never from what the seat says of itself. The log is found by the id in seat.harness and by nothing else: the one file whose name carries it, ~/.claude/projects/<slug>/<id>.jsonl for a session, ~/.claude/projects/<slug>/<session>/subagents/agent-<id>.jsonl for a sub-agent, ~/.codex/sessions/…/rollout-*-<id>.jsonl for Codex (CODEX_THREAD_ID is read where seat.harness is unset). Not the log's cwd — a transcript names the directory its session was launched in on every turn, sub-agents included, so a seat working in its own worktree would match none, and one working in the launch directory would match them all. From the file the tool reads only top-level fields of the newest turn that has them — Claude Code's message.model and perTurnEffort, Codex's turn_context model and effort — never a message, never a tool's result. Two files for one id: --whoami refuses (exit 2, both paths named) and the hook writes no Model: and no Effort: (and does not stop the commit). No file, or no seat.harness: no trailers, and the board shows —. A trailer the message carries already is left alone, each key on its own.

text $ python3 shoalmark.py --whoami From: a9f3c2d1/reviewer-1 reviewer (reviewer-2) · claude-opus-4-8 · high

A repository with its own hook runner adds three entries, each running the copy of the tool in the git directory as --install-hook's own hooks do — with lefthook, the session rule on every commit, the trailers, and the judgement of the commit with its message:

yaml pre-commit: commands: session: # the session rule on EVERY commit, a tracker staged or not run: python3 -I "$(git rev-parse --git-common-dir)/shoalmark-trusted/shoalmark.py" --root "$(git rev-parse --show-toplevel)" --session-check prepare-commit-msg: commands: session: run: python3 -I "$(git rev-parse --git-common-dir)/shoalmark-trusted/shoalmark.py" --root "$(git rev-parse --show-toplevel)" --session-trailer {1} commit-msg: commands: judged: # where `judged_before_build` is on: the commit judged with its subject (FM-033) run: python3 -I "$(git rev-parse --git-common-dir)/shoalmark-trusted/shoalmark.py" --root "$(git rev-parse --show-toplevel)" --commit-msg {1}

Run --install-hook once, on your default branch, so the copy is there: it writes the copy, leaves the runner's own hooks alone and names each with the line to add, exit 4. Where the copy is missing, each entry fails — Python cannot open the file, exit 2 — and the runner refuses the commit until --install-hook writes it again. A hook runner reads its configuration from the tree, so a merged branch's configuration runs whatever it names: these entries keep the tool out of a branch's reach, not the runner's configuration. shoalmark's own --install-hook is the safe default — its hooks are plain files in the hooks folder git reads, and they run only the copy.

The commit hook is best-effort. The gate is --check on the branch, and it must be green on the pull request's head before merge. A bypass of the hook alone, which --check catches, is P3. Git's --no-verify skips any hook by design; what protects the default branch is --check on the branch. Without origin/HEAD — a CI checkout often has none — --check asks origin for its default branch where origin/main or origin/master is all it holds, and refuses in one line where origin cannot be read — a private repository's checkout that keeps no credentials (persist-credentials: false), on a push to the default branch or with fetch-depth: 0, or a clone offline: run git remote set-head origin <default> first there.

The registry is a report: <cmd> --sessions prints it from the trailers of the checkout's history — one row per session id: its seat (the author through [seats]), its first and last commit, how many commits carry it, and its worktree. Nothing is opened, closed or kept in a file, so two branches that land never conflict on it: a session is what its commits say, and it ends at its last one. An id seen in two worktrees is named under the table.

text | Session | Seat | First commit | Last commit | Commits | Worktree | |---|---|---|---|---|---| | a9f3c2d1 | planner | 2026-09-24 07:28 · 049a9ab | 2026-09-24 11:02 · 5e1f0a2 | 9 | planner-a9 | | a9f3c2d1/reviewer-1 | reviewer | 2026-09-24 09:40 · 7c2d8e1 | 2026-09-24 09:52 · 3a7d1e0 | 2 | reviewer-2 |

Until 0.18.0 the registry was <tracker dir>/sessions.md, written by --session open and --session close and read by the gate. Both commands are gone — each says so and exits 2 — and --check warns in one line where the file is left: delete it; history keeps its rows.

The gate holds one rule. A commit by a seat [seats] names — never the Owner's, never an author outside [seats] — must carry a Session: of the shape <8 hex> or <8 hex>/<seat>-<n>, and where it names a seat, that is the author's seat — or a name the seat had: principal for planner, implementer for builder, the name of an old <name>@seat address it lists — and a label that is another seat's own name, or that two seats claim, passes for none. Exit 4, and one of three lines:

text refused: this commit by planner@seat carries no Session: trailer — set `git config --worktree seat.session <id>` in its worktree: … refused: this commit by planner@seat carries `Session: q7` — a session id is eight hex characters, or `<id>/<seat>-<n>` for a sub-agent refused: this commit by planner@seat is the seat planner, and its Session: a9f3c2d1/reviewer-1 names the seat reviewer

The pre-commit hook --install-hook writes runs it on every commit — --session-check, the session rule alone, a tracker staged or not. It judges what the rights are judged on: the commit being made (by its worktree's seat.session, the trailer its hook will write), the commit at HEAD by its trailer and, in --check on a clean tree, every commit since origin's default branch — the newest alone where none is found, and none where the tree has uncommitted edits, as --check says — and every commit a merge brings, each by its own. A repository adopts the rule with its first Session:: a commit whose history carries none is not judged, so a repository that never set seat.session is not refused when it vendors.

Verdicts: a review commit names the tip it judged — the Reviewer types this trailer: Reviewed: <sha>. --check reports each verdict of the last triage_days days. The reviewed range is the branch's own commits — git rev-list <tip> ^<trunk> --no-merges (the default branch as --check reads it, else main, else master), less other verdicts; for a tip the trunk has since merged, the trunk as it stood before that merge — so what the branch merged in from the trunk is not its, and the report does not change when it lands. The verdict is independent when its session's root (a9 of a9/reviewer-1) is none of the range's sessions' roots, same session when it is one of them — a Reviewer run as a sub-agent of the author's session is not independent — untraced when either side names no session, and on trunk — not a branch verdict when the tip is on the trunk's own first-parent line. A count, not a refusal: the refusal is a later slice, after a week of counts. --queue reads a verdict's word from its commit's subject — READY, READY WITH FINDINGS, READY TO TAG or NOT READY: a verdict commit says one of them.

Seen: the board's strip is one line per parent session with a commit in the last day, its sub-sessions folded into it — sessions · 2 in the last day (9 with their sub-sessions), then a9f3c2d1 planner (planner-a9) · builder 1–6 · reviewer 1–5, 7 (a run of three or more is a–b). A line opens on each member's worktree, model and effort (— where its commits carried none). A parent that made no commit of its own still has its line, its seat and worktree —, derived from its sub-sessions' ids. It is read from the trailers alone, never from a transcript. Under it, reviews this week · independent n · same session m. --owner, the digest, carries the same grouping — a header with the two counts, then one line per parent — and ends with the queue where gh reads the forge (what to merge, above). Labels: sessions.recent (now with {1}, the count with sub-sessions) and reviews.* (§9).

7. The one seam: a deriver

The core knows nothing about releases, deploys or product areas. A repository that needs them puts one executable at <tracker dir>/derive. The core runs it first, on every run — so keep it fast — with this on stdin:

json { "root": "/abs/path", "mode": "write", "flags": [], "trackers": [ { "id": "MSR-012", "status": "Shipped", "file": "MSR-012-x.md", "fm": { "version": "1.2.0" } } ] }

and reads this from stdout:

json { "MSR-012": { "Ver": "1.2.0", "Release": ["1.2.0", "1.2.0 ✓"], "_needs": [] }, "_keys": { "version": { "shape": "\\d+\\.\\d+\\.\\d+", "says": "the release it shipped in", "who": "the ship commit" } }, "_problems": [ "MSR-014: version on work that has not shipped" ], "_index": [ "Ver" ], "_board": [ "Release" ], "_notes": [ "**Ver** = the release it shipped in." ], "_files": { "docs/RELEASES.md": "…the whole file…" } }

  • Each value key is a column in INDEX.md and on the board, and a view, a fact and a search word on the board. _index / _board narrow which goes where (default: all). A value may be [value, display]: the value groups, sorts and is printed in INDEX.md; the display form is for the board's cells.
  • _keys join the schema gate — add a key, never redefine one. _problems fail the commit like the core's own. _needs (per tracker) are shown beside the core's own marks. _notes go into INDEX.md's header.
  • _files are written, drift-checked (--check) and staged (--print-written) by the core. A deriver has no side effects, and nothing derived is ever stored — so nothing derived can be stale.
  • A non-zero exit refuses the run before anything is written — a crash included, and silence past 60 s. Say why on stderr.
  • mode is write · check · read. flags are what was typed as --derive-flag NAME on this run.
  • A deriver is told things on stdin and never reads the environment — the core runs it with PATH, HOME, the locale and nothing else. A git hook inherits whatever the shell that ran git commit had exported; a deriver that listened to that would let a stray variable decide what gets staged.
  • A wrapper script may set SHOALMARK_CMD so every message names the repository's own command.

8. Working on shoalmark

bash python3 test_shoalmark.py && python3 test_core.py # every check builds its own throwaway repository /usr/bin/python3 test_shoalmark.py # the oldest Python promised: 3.9, the one macOS ships

Where Chrome or Chromium is installed the board is rendered and read back. test_core.py pins the core's behaviour on a synthetic corpus; test_shoalmark.py pins what was built here. A change ships with its check, and the check is shown to fail without the change. Every consumer-visible change gets a CHANGELOG.md entry — --vendor prints it to the repository that upgrades. This repository tracks itself: python3 shoalmark.py --next. How a change here is reviewed, what a miss costs, and how the Owner is spoken to are this repository's house rules — the Owner's signed answers of 2026-09-24, in AGENTS.md.

9. Branding the board

Four optional files, the same names in every place — the board is built from the places in order, the later one wins: tools/shoalmark/brand/, the organisation's that set the repository up (--vendor copies and pins it) · <tracker dir>/brand/, the repository's, never loose among the trackers · ~/.config/shoalmark/, the person's, in every repository. Only the git-ignored board reads them: INDEX.md and the gate are the same whoever runs them.

File Carries
theme.css colours and fonts. Each place's file is its own stylesheet, so one variable changes one colour; @import and @font-face work, with paths written relative to the theme.css they are in — a font goes in brand/fonts/. A theme whose import is missing is left out whole
logo.svg / logo.png the header and the browser tab; at most 200 kB; a script inside an SVG cannot run
wordmark.svg the mark and the name drawn as one, inline in the header in place of the logo and the name — the logo stays the tab's, the name the page's title and the wordmark's accessible name. At most 200 kB of UTF-8; shapes, text, gradients, masks and a <use> of its own ids, each attribute from a fixed list with a fixed grammar — colours #hex, currentColor, none or a plain name, numbers as -?digits(.digits)? with one space or comma between, every id once, never style. Anything else refuses it whole, with a warning
labels.yaml every word of the board — flat key: value lines, tagline and footer among them. A German board is this file
  • --brand says which place gave the board its theme, logo, wordmark and labels; --brand DIR writes a commented starter. The name is name in shoalmark.toml. A theme hard to read is a warning; status colours keep their meaning.
  • Two themes ship as starters in the tool's brand/themes/, monochrome and shoalmark, each a theme.css with its fonts; --vendor copies and pins them, so a vendoring repository receives both. None is worn until --brand DIR --from <theme> copies it into a place: a board that chooses none looks as it did. A theme styles three hooks: the Owner's box's title #p>.pt (label owner.title), the last line #F (#f, #r), tr.zebra.
  • Light and dark: dark colours go under @media (prefers-color-scheme:dark); ◐ (auto → light → dark) switches that rule for any theme and remembers the choice in the browser; paper stays light. A wordmark in currentColor takes the page's ink, at its height, else 22 px. Every page ends in the tool's line: its mark, name and version.
  • Another language: labels.yaml for the board's words; [headings] in shoalmark.toml for the gate's sections.

Licence

Apache-2.0 OR MIT, at your option — LICENSE-APACHE, LICENSE-MIT, NOTICE. The one bundled file, vendor/marked-18.0.13.umd.js, is MIT. All three ship in a vendored copy.

What it is not, on purpose: no sprint, no estimate, no assignee, no comment thread, no editing in the board. A viewer, not a Jira.