SLOPSHOPPER

itos

Works in a repository itos manages: titles each itos ID in Claude's replies, and answers an agent's git commit or git push with the itos command to use instead.

newrowsprocess
v4.1.0AGPL-3.0updated 2026-10-09donvargax/itos/integrations/claude-code
A shopper browsing a rack in a slop shop
README

itos

Every task is proven by commands, every commit names its work. itos is a task tool for repositories where people and coding agents work side by side: a ledger of tasks whose "done" is a list of commands that must pass, commit rules that tie each commit to the task or the scenarios it serves, and a CI plan that runs what a push's commits name, in cost order. Its policy is one YAML file, itos.yaml, so a project changes its rules without changing code.

The name comes from palitos, the little sticks of a tally, the strokes work is counted with; -itos is the Spanish diminutive, and said aloud it is also hitos, milestones. It is not withakay/ito, one letter away and also a task tool for agents, where an agent says a task is done; here a task is done when its commands pass.

Two halves

A project works by its rules, and a rule is one of two kinds:

  • What a command can decide is itos's: the commit's shape and footers, the paths each commit type may touch, which checks prove a task, what a CI run selects and in what order, who may take which work. A git hook or CI enforces it on every commit, whoever or whatever made it.
  • What no command can check is the agent instructions': which session you are, how to split work into commits, when to stop and ask, how to brief and check an agent, what to do when a slice fails. They live in AGENTS.md (the implementing session's) and docs/ORCHESTRATING.md (the coordinator's).

A rule moves from the instructions into itos as soon as a command can decide it, and the instructions then point to the check instead of restating it.

What it does

  • Starting a session: start every fresh agent session in a repository with ! itos go (! runs it in Claude Code's prompt, so its output lands in the session). It prints the coordinator's guide, the repository's own notes, this clone's own notes and where the work stands, so the session you talk to coordinates: it specifies the work and hands each item to an agent. An agent it starts is told by its brief to run itos guide work, the implementer's guide, instead; no file asks an agent to judge which it is.
  • A ledger of tasks with executable checks (tasks/): itos task <id> runs a task's done_when commands and says done, pending or failing, and itos task add mints the next ledger ID, writes the task into its group's file and its item into the work registry, committing the two alone.
  • Commit rules: Conventional Commits; each feat or fix names the scenarios it turns green (Scenarios: @ID-…), every other type the task it belongs to (Task: T-…), and each type may touch only certain paths. itos hook commit-msg applies them before a commit, validates itos's own files as the commit stages them, and runs the static checks of the tasks it names, rejecting it when a finished task's check fails; `itos hook

pre-push verifies the commits a push adds before they leave, refusing the push with how to fix them when one fails (and printing nothing when they pass), and itos verify re-checks a pushed range in CI, from commits.since on. itos commit --task <id> (or --scenarios <ids>, a flag for each footer of free text such as --upgrading <text>, and --breaking <text>) is git commit with the footers written for you, as git trailers, whether the message comes from -m, -F` or the editor; a commit missing a footer its type requires is refused before git runs, naming the flag, and the hook judges the rest as typed footers.

  • Pushing: itos push ends a piece of work after its commits. It pulls the branch's upstream with a rebase, whatever pull.rebase and rebase.autostash say, then pushes HEAD to it in a separate step, the pre-push hook running as for any push. It refuses to start while tracked files have uncommitted changes, leaves a rebase that stops for you to finish (git rebase --continue, then itos push again, or git rebase --abort) with nothing pushed, and never forces: a push the remote refuses is reported, not retried.
  • The git shim: linked as git before the real git on your PATH, itos makes a hand-typed, scripted or editor's git commit and git push in a repository it manages run as itos commit and itos push, and passes everything else to the real git (below).
  • Named tests behind an adapter: Gherkin is built in; any runner that can list its tests as JSON can be another kind. CI merges every selection of a kind into one run.
  • A CI plan: itos ci run runs a push's steps and the checks of the tasks its commits name, in cost order, stopping at the first failure, with a shortcut for prose-only pushes; itos ci plan prints it without running anything.
  • Work routing: itos work says what the person a session works for can start next, from tasks/work-items.yaml and CONTRIBUTORS.md; `itos work

list lists every item with its title, done ones too, and itos work show <id> one item with its scenarios and the commits that belong to it, by their footers, --patch adding their diffs. itos work take <id> sets an item in progress for the person, itos work promote <idea> makes an idea a slice or a task with a minted ID, itos work done <id> marks an item done once it has landed (its scenarios live, its commits pushed, its CI run green), and itos work add <idea-id> makes an idea (slices and tasks are minted) and itos work edit <id> changes one, each editing the registry in place and committing it alone; closing a task with checks, work done` takes them out of its ledger entry in the same commit.

  • Decisions waiting on the person: itos decision add <text> [--item <id>] asks the person the work is for a question only they can answer, q-1, q-2 and so on, itos decision answer <id> <text> keeps the answer beside it, itos decision lists the open ones and itos decision show <id> prints one; anything to take up with someone else, a question included, is itos followup. They are public: asks.yaml beside the work registry, each change committed alone, and itos work show <id> lists the questions naming the item.
  • Follow-ups with people: itos followup add <id> --with <who> --title … --note … opens a thread, itos followup note <id> <text> appends a dated note, itos followup close <id> closes it, itos followup lists the open ones, itos followup show <id> prints one whole and itos followup doc <id> <path> writes it out as Markdown. The threads are yours alone, in the git folder (follow-ups.yaml under git rev-parse --git-common-dir/itos), never committed and shared by every worktree; no config is needed.

tools/bin/itos --help lists the commands, and itos <command> --help each one. A command itos does not have runs itos-<command> from the PATH, so itos takes extensions as git does: docs/extensions.md says how to write one.

Status

Scripts that consume --json can branch on a problem's stable rule ID, not its message or fix. See the JSON problem rule ID catalogue for built-in rules and configured or delegated IDs.

v2, Go only. itos is the Go binary (cmd/itos, internal/), one file with no runtime. The TypeScript v0 it began as has left the repository, and from v2.0.0 no release carries its tarball. This repository governs itself with itos, the Go binary: its own itos.yaml, ledger, hooks and CI.

itos's named tests are the Gherkin feature files in features/, whose steps (Go, run by godog) treat itos as a black box: they run whatever binary ITOS_BIN names in scratch repositories and read its exit codes and output. The conformance corpus (tools/itos/conformance/) is the regression record it must pass. PLAN.md says what itos is, and docs/decisions/ holds the decisions behind it.

Install

The newest release: https://github.com/donvargax/itos/releases/latest. Its notes give everything below filled in for it: the install script with each platform's hash, the pin's two lines and the schema line. CI cuts a release whenever a feat or a fix lands on main with its checks green, its version computed from the commits since the last one. Each release holds:

  • itos-<version>-<os>-<arch>.tar.gz, the Go binary, for linux and darwin on amd64 and arm64, and itos-<version>-windows-amd64.zip; each holds the binary (itos, or itos.exe), LICENSE and README.md at its top level;
  • itos.schema.json, the config's JSON Schema;
  • checksums.txt, the SHA-256 of every file above. sha256sum --ignore-missing -c checksums.txt checks the ones downloaded beside it, and gh attestation verify <archive> -R donvargax/itos checks an archive was built by this repository's release workflow.

Pin a release, never a branch, and pin each asset by its line in checksums.txt, so a replaced release fails every later install.

The binary: download your platform's archive, check it against its line, and unpack the binary into an ignored .tools/bin/. A project commits this as a script that pins the version and each platform's hash: the newest release's notes (Upgrading) give the whole script with both filled in, and v2.0.0's release notes, Upgrading, step 1, the shim that runs it from your hooks and CI.

Go developers can instead go install github.com/donvargax/itos/v7/cmd/itos@v<version> for a v7 release or release candidate (the module path ends in the major version from v2, as Go requires: /v6 for the v6 releases, /v5 for the v5 releases, /v4 for the v4 releases, /v3 for v3.4.0 to v3.7.1, while v3.0.0 to v3.3.0 were cut from a path still ending in /v2 and Go refuses them).

A version bump is the script's version and hashes, from the newer release's notes.

A global install (from v2.1.0): install itos once per machine, by either way above into a folder on your PATH, and pin the release each repository runs in its itos.yaml:

pin:
  version: <x.y.z> # the release, without its v
  checksums: <the SHA-256 of that release's checksums.txt> # sha256sum checksums.txt

(the release's notes give both lines filled in), or let itos pin write them: itos pin moves the pin to the newest release, itos pin <x.y.z> to that one, changing those two values and nothing else in the file, and prints the release's notes to read before you commit the change.

The installed binary is then a launcher, never rewritten: in a repository that pins another version it fetches that release into its cache, checks checksums.txt against pin.checksums and the archive against its line, and runs it with the same arguments and exit code. A release that cannot be fetched or does not match exits 3 and nothing runs in its place. One hash covers every platform, so the pin replaces the install script, and a version bump is the two lines. A config without pin runs the binary that was called, so a repository that installs itos its own way keeps it.

Where there is no itos.yaml at all (outside a project, or in a repository that does not use itos) the launcher runs the newest release: it asks for it at most once an hour (<base>/latest/download/checksums.txt), fetches it into the cache, checked against that list, and runs the newest of itself and the releases the cache holds. In a repository that pins an older release than the newest it knows of (the server's answer, the releases the cache holds or the itos that runs) it runs the pin and says so on stderr, once a day per repository:

itos 2.3.0 is out (this repository pins 2.2.0): https://github.com/donvargax/itos/releases/tag/v2.3.0

It asks nothing where the answer goes unused (a config without pin), never in CI (the CI variable set) and not with ITOS_NO_UPDATE=1, and never says in CI or with ITOS_NO_UPDATE_NOTICE=1. A release server it cannot reach is not an error: it waits a few seconds at most, once an hour, and carries on with what the cache has.

ITOS_VERSION=<x.y.z> runs another release for one call (trusting its checksums.txt as fetched, unless it is the one pinned); ITOS_RELEASES names where releases come from (<base>/download/v<version>/<asset>, https://github.com/donvargax/itos/releases by default) and ITOS_CACHE the cache (itos/ in your user cache folder by default).

From v4.0.0 the hooks itos hook install writes call itos, this launcher, unless the config sets hooks.bin (internal and unsupported, for a repository that must run its own build, as this one sets tools/bin/itos), so every machine that commits, and CI, needs it on the PATH.

The hooks (from v6.0.0): itos hook install declares itos's commit-msg and pre-push hooks in the clone's own git config, hook.itos-commit-msg and hook.itos-pre-push in .git/config, which git never commits and every worktree of the clone shares. Git runs them beside the project's own hooks, whatever core.hooksPath says, so a hook manager's files and settings are the project's business and itos writes none of them. Run it once in each clone. itos commit, itos push, the git shim's git commit and git push, and the commands that commit the work registry refuse, exit 3, where git would run none of itos's checks: no hook of itos's declared (run itos hook install), or a git older than 2.54.0, the first that runs the hooks its config declares (upgrade git). A project that called itos hook commit-msg or itos hook pre-push from its hook manager's files takes those lines out, or the checks run twice, and drops hooks.manager from its config, which v6.0.0 refuses.

In CI (from the first release after v3.7.1): a CI runner gets the global itos in one step, and the repository's pin chooses what runs, as on your machine; a repository with no pin runs the version installed. On GitHub Actions:

- uses: donvargax/itos@v<x.y.z> # that release's launcher, on the PATH
- uses: donvargax/itos@<sha> # v<x.y.z>, or pinned by that release's commit

Pin the action by a release's tag or by that release's commit: it installs the launcher of the release its ref names, so the version is said once. From the first release after v7.0.0-rc.2, a pre-release's tag (v7.0.0-rc.1) and a release's commit sha name their release too; a commit carrying both a pre-release and a release tag installs the release, and a branch, or a commit no release tag names, installs the newest (a commit with a warning). with: { version: <x.y.z> } is only for choosing another launcher than the ref's. Other CI runs the script the action runs, fetched from a release tag, into a folder on its PATH:

curl -fsSL -o install-launcher https://raw.githubusercontent.com/donvargax/itos/v<x.y.z>/tools/bin/install-launcher
sh install-launcher "$HOME/.local/bin" <x.y.z>

It checks the archive against the release's checksums.txt and fails on any mismatch or missing asset; with no version it installs the newest, and ITOS_RELEASES moves where it fetches from.

Ready a repository (from v2.7.0): itos init in a repository, or in a folder that is not one yet (it runs git init first), writes a starter itos.yaml at its top, small and commented: the Conventional Commits types under itos's own header lint, a Task: footer that every type but feat, fix and docs needs, docs held to Markdown, docs/ and tasks/ so that it cannot skip the footer for code, commits.since at HEAD so no commit written before it is judged, and the hooks calling the global itos; a ledger, tasks/phase-1.yaml, holding T-1, the task the commit that adds all this names, and an empty work registry, tasks/work-items.yaml; and when features/ holds feature files, a Scenarios: footer that feat and fix need and a smoke set, features/smoke.yaml, naming one live scenario of each file. A file already there is kept. It pins the newest release, as itos pin does (where it cannot reach the release server it pins nothing and says so), then declares the hooks in the git config, as itos hook install does. Commit what it wrote with itos commit --task T-1 -m 'chore: adopt itos', and grow the config from there: more path scopes, a CI plan, the people. Run again where a config is, it writes nothing and lists what is missing (what itos config check finds, a hook of itos's the git config does not declare, with what puts it right), exiting 1 when anything is, so it doubles as a check. itos init --stealth does the same for one person in a repository whose team does not use itos (below). It also offers the Claude Code plugin (below), through the claude on your PATH, and never unasked: --plugin installs it for the project (in the committed .claude/settings.json), --plugin user for every repository of yours, --plugin local for you alone in this one, the default under --stealth, which keeps .claude/settings.local.json out of git status; on a terminal it asks, anywhere else it says how. Run again, it reports a plugin not installed without counting it as missing. It offers the rules for agents the same way: --agent-rules generates what the config decides (the commit types, the footer each type needs, the paths each type may touch and what the hooks and CI run) into AGENTS.md, between <!-- itos:begin --> and <!-- itos:end --> at its end, the rest of the file kept, and adds an @AGENTS.md line to CLAUDE.md so Claude Code reads it; --no-agent-rules declines. The block is one line a paragraph, so a Markdown formatter leaves it alone. Run again, init reports a block the config no longer matches, and itos init --agent-rules rewrites only what is between the markers. Under --stealth nothing tracked changes, so --agent-rules writes the block to .git/itos/AGENTS.md, which every worktree shares, with a CLAUDE.local.md importing it (and @AGENTS.md, when the project has one) for Claude Code and an AGENTS.override.md for Codex holding a marked copy of the project's AGENTS.md, then the block; both are listed in .git/info/exclude, and a file of yours already there only gains the block. Run again, init also reports a copy that no longer matches AGENTS.md. How to work with itos is not in the block: start a session with ! itos go, and brief an implementer to run itos guide work first.

Where itos reads its config. itos reads itos.yaml in the folder it runs in, or the file --config or ITOS_CONFIG names; --root <dir> runs it as if started in <dir>. With none of those and no itos.yaml in the folder, a run inside a git repository reads the itos.yaml at the repository's top (or the config in the git folder, below) and runs as if started there, so every path the config names means what it means at the top, and the launcher runs the version pinned there. A path you type (itos commit check-paths's, a message file, itos commit's pathspecs) is still read from the folder you are in, as git reads one.

The git shim (from v2.2.0): to have every git commit and git push in a repository itos manages go through itos, whoever types them (you, a script, an editor, an agent), link itos as git in a folder that comes before the real git on your PATH:

itos git-shim install                 # a link named git beside the itos binary
itos git-shim install --dir ~/.local/bin/itos-shim   # or in a folder of your choice

It says whether that folder comes before the real git on the PATH; if not, put it first (export PATH="$HOME/.local/bin/itos-shim:$PATH" in your shell's profile), and run hash -r in a shell that has already looked git up. In a repository with an itos.yaml (at its top, or in the folder you are in) or a stealth config, from any folder of it, git commit … is then itos commit … and git push … is itos push …, with the same arguments: a commit missing a footer is refused before git runs, git commit --task T-001 -m … writes the footer, and git push --force is refused. git's -C <dir> and -c <key>=<value> before the command are honoured. Every other command, and every command in any other repository, runs the real git (the first git on the PATH that is not itos) with its arguments, input, terminal and exit code untouched, at the cost of starting itos and a few file checks. itos's own git, and any git a hook or check started by itos runs, is always the real one. In a repository that pins a version, git commit is that version's itos commit (v2.2.0 or later); under a pin older than v2.2.0, which has no shim to hand it to, git commit and git push run the real git, after a line on stderr saying so. The hooks and CI's itos verify stay the gates: the shim is per machine and opt-in. To turn it off, remove the link:

itos git-shim uninstall               # or: itos git-shim uninstall --dir <the folder>

Neither command touches a git that is not a link to itos.

The Claude Code plugin: this repository is also a Claude Code plugin marketplace, and its one plugin, itos, is published from it, with a version of its own, raised only when the plugin changes, not with each itos release. itos init --plugin installs it, or from Claude Code:

/plugin marketplace add donvargax/itos
/plugin install itos@itos

It runs the itos on your PATH (v2.3.0 or later) in every repository you open, and does something only where itos manages the repository. It never runs a program the repository ships, neither its hooks.bin nor a tools/bin/itos at its top, since the guard runs before every Bash command and the titles on every reply, and a repository you cloned would otherwise run its own program just by being opened. A global install, above, is the launcher, which lets each repository's pin pick the version from itos's releases; to have a build of your own answer, put it first on your PATH.

  • Titles. An itos ID in Claude's replies is drawn with its title beside it, T-066 as ` T-066: The itos plugin for Claude Code , and "slice 43" as the registry's slice-43. Only the drawing changes: the transcript and what Claude reads back stay as written. The titles come from itos work list --all --json (plain itos work list --json on an older itos, which refuses --all) and itos task list --json, asked at the start of the session and of each turn; with no itos that answers, from tasks/work-items.yaml`.
  • A guard. A PreToolUse hook on Bash runs itos guard claude-code, which denies an agent's git commit or git push, its reason naming itos commit --task <id> (or --scenarios <ids>) or itos push, so the agent commits with the footers and pushes without forcing. Everything else gets no answer, so your own permission rules still decide. It reads the command as bash does, so git -C . commit and make && git push are caught; sh -c '…', eval and scripts are not looked into, and the hooks stay the gates. Under a pin older than v6.0.0 the launcher answers for the guard with nothing; with no itos on the PATH the plugin answers nothing.

How to work with itos is not the plugin's: it is itos's own guides, versioned with the binary, itos go for the session you talk to and itos guide work for an agent it starts (above), so no flow needs the plugin. Try it in a repository itos manages: start a session with ! itos go (the IDs in Claude's answers carry their titles), or ask Claude to commit with git commit, which the guard turns into itos commit. To wire only the guard, without the plugin, put the hook in .claude/settings.json:

{
	"hooks": {
		"PreToolUse": [
			{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "itos guard claude-code" }] }
		]
	}
}

The schema, for editors. An editor with a YAML language server checks an `itos.

Source 2 files
hooks/register.ts 95 lines
1import type { EngineInterface, Register } from "claude-code";
2import { Annotator, listed, merge, parseRegistry, type Titles } from "./titles";
3
4// Where itos keeps the work registry by default, relative to the project:
5// read only when the itos on the PATH does not answer.
6const REGISTRY = "tasks/work-items.yaml";
7
8// The titles drawn beside the IDs, asked for at the session's start (a reload
9// starts it again) and at each turn's, never while drawing.
10let titles: Titles | undefined;
11
12// What a command prints and its exit code in the session's root, or undefined
13// when it cannot start.
14async function run($: EngineInterface, root: string, argv: string[]) {
15	try {
16		return await $.process.run(argv, { cwd: root, timeoutMs: 15_000 });
17	} catch {
18		return undefined;
19	}
20}
21
22// The only itos the titles run is the one on the PATH (T-097), as hooks/guard.sh
23// runs for the guard: never the repository's hooks.bin, nor a tools/bin/itos at
24// its top, since the titles are asked for in every repository the plugin is
25// enabled for and a program a cloned repository ships would run just by opening
26// Claude Code there. A global install is the launcher, which fetches the version
27// the repository's pin names from itos's releases, checked against their
28// checksums; a developer of itos puts a build of their own first on the PATH.
29const ITOS = "itos";
30
31// The JSON an itos command prints in the session's root, or undefined when no
32// itos answers: none on the PATH, a non-zero exit, or output that is not JSON.
33async function itos($: EngineInterface, root: string, args: string[]): Promise<unknown> {
34	const answer = await run($, root, [ITOS, ...args, "--json"]);
35	try {
36		return answer?.exitCode === 0 ? JSON.parse(answer.stdout) : undefined;
37	} catch {
38		return undefined;
39	}
40}
41
42async function registryFile($: EngineInterface, root: string): Promise<Titles | undefined> {
43	try {
44		return parseRegistry(await $.fs.read(`${root}/${REGISTRY}`));
45	} catch {
46		return undefined;
47	}
48}
49
50// Every registry item, done ones too, from itos work list --all (v3.x, slice
51// 77), which v4.0.0 keeps while plain work list comes to print the open items
52// alone; plain work list, every item before v4.0.0, when an older itos
53// refuses --all.
54async function everyItem($: EngineInterface, root: string): Promise<unknown> {
55	return (await itos($, root, ["work", "list", "--all"])) ?? itos($, root, ["work", "list"]);
56}
57
58// Every registry item from itos work list, wherever work.registry puts the
59// registry, and the ledger's tasks from itos task list, which may have no
60// item, both from the itos on the PATH; the registry file read as the mod did
61// when work list gives no items.
62async function refresh($: EngineInterface): Promise<void> {
63	const root = await $.session.root();
64	const [work, tasks] = await Promise.all([everyItem($, root), itos($, root, ["task", "list"])]);
65	const items = listed(work, "items") ?? (await registryFile($, root));
66	titles = merge(items, listed(tasks, "tasks"));
67}
68
69// A reply's text block with each bare ID titled, fenced code and longer code
70// spans left as written.
71function annotate(text: string, known: Titles): string {
72	const a = new Annotator(known);
73	return a.push(text) + a.flush();
74}
75
76export const register: Register = (on) => {
77	on("session.start", async ($, e, next) => {
78		await refresh($);
79		return next(e);
80	});
81
82	on("turn.start", async ($, e, next) => {
83		await refresh($);
84		return next(e);
85	});
86
87	// The drawing alone: the stored reply, and what the model reads back, stay
88	// as written, so the stream and the transcript are never touched.
89	on("ui.render", { component: "AssistantMessage" }, async (_$, e, next) => {
90		if (!titles?.size) return next(e);
91		const text = annotate(e.props.text, titles);
92		return text === e.props.text ? next(e) : next({ ...e, props: { ...e.props, text } });
93	});
94};
95
hooks/titles.ts 137 lines
1// The titles of a project's itos work items and tasks, and the rule that
2// writes one beside its ID in prose: `T-061` or a bare T-061 becomes
3// `T-061: v2.0.0, Go only`. An ID already followed by its title, an ID inside
4// a code span that holds more than the ID, and anything in a fenced block are
5// left as written, so commands and code are never changed.
6
7export type Titles = ReadonlyMap<string, string>;
8
9// The registry's items, from tasks/work-items.yaml as itos writes it: each
10// item a `  - id:` line followed by its `    title:` line. What the titles
11// read when no itos answers.
12export function parseRegistry(text: string): Map<string, string> {
13	const titles = new Map<string, string>();
14	const item = /^ {2}- id: *(\S+) *\n {4}title: *(.+?) *$/gm;
15	for (const [, id, raw] of text.matchAll(item)) {
16		const title = raw!.replace(/^(["'])(.*)\1$/, "$2");
17		if (!titles.has(id!)) titles.set(id!, title);
18	}
19	return titles;
20}
21
22// The `id` and `title` of each entry of `list` in an itos command's JSON
23// (`items` for itos work list, `tasks` for itos task list), or undefined when
24// the answer has no such list: an itos older than v2.3.0 reads `work list` as
25// `work` and prints the proposal, which has no `items`.
26export function listed(json: unknown, list: "items" | "tasks"): Map<string, string> | undefined {
27	const entries: unknown = Object(json)[list];
28	if (!Array.isArray(entries)) return undefined;
29	const pairs = entries.map((entry) => [Object(entry).id, Object(entry).title]).filter(isPair);
30	return merge(pairs);
31}
32
33const isPair = (pair: unknown[]): pair is [string, string] =>
34	typeof pair[0] === "string" && typeof pair[1] === "string";
35
36// Every source's titles in one map, the first source to name an ID winning:
37// the registry's items, then the ledger's tasks that have no item.
38export function merge(...sources: (Iterable<[string, string]> | undefined)[]): Map<string, string> {
39	const titles = new Map<string, string>();
40	for (const source of sources)
41		for (const [id, title] of source ?? []) if (!titles.has(id)) titles.set(id, title);
42	return titles;
43}
44
45const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
46
47// One pattern over every known ID, longest first so T-0610 is never read as
48// T-061, bounded so an ID inside a longer word or path is not one.
49function idPattern(titles: Titles): RegExp | undefined {
50	const ids = [...titles.keys()].sort((a, b) => b.length - a.length).map(escape);
51	return ids.length ? new RegExp(`(?<![\\w/.-])(${ids.join("|")})(?![\\w/-])`, "g") : undefined;
52}
53
54const titled = (id: string, title: string) => `\`${id}: ${title}\``;
55
56// Whether the text after an ID already gives its title: `ID: …` or `ID (…`,
57// as a reply that named it properly writes it.
58const alreadyTitled = (after: string, title: string) =>
59	/^\s*[:(]/.test(after) || after.slice(0, title.length + 8).includes(title);
60
61// A slice as prose names it, "slice 24" or "Slice 24", for the registry's
62// slice-24.
63const SLICE = /(?<![\w/.-])[Ss]lice (\d+)(?![\w/-])/g;
64
65// Prose outside code spans: each bare ID gains its title, and so does a
66// slice written with a space.
67function annotateProse(prose: string, titles: Titles, pattern: RegExp): string {
68	const ids = prose.replace(pattern, (id: string, _g: string, at: number, whole: string) => {
69		const title = titles.get(id)!;
70		return alreadyTitled(whole.slice(at + id.length), title) ? id : titled(id, title);
71	});
72	return ids.replace(SLICE, (words: string, n: string, at: number, whole: string) => {
73		const id = `slice-${n}`;
74		const title = titles.get(id);
75		return !title || alreadyTitled(whole.slice(at + words.length), title)
76			? words
77			: titled(id, title);
78	});
79}
80
81// One line of prose: code spans kept, but a span that is exactly an ID gains
82// its title inside the backticks.
83export function annotateLine(line: string, titles: Titles): string {
84	const pattern = idPattern(titles);
85	if (!pattern) return line;
86	let out = "";
87	let last = 0;
88	for (const span of line.matchAll(/`[^`\n]*`/g)) {
89		const start = span.index!;
90		out += annotateProse(line.slice(last, start), titles, pattern);
91		const inner = span[0].slice(1, -1);
92		const title = titles.get(inner);
93		out +=
94			title && !alreadyTitled(line.slice(start + span[0].length), title)
95				? titled(inner, title)
96				: span[0];
97		last = start + span[0].length;
98	}
99	return out + annotateProse(line.slice(last), titles, pattern);
100}
101
102// Text arriving in pieces: whole lines are annotated, a fence toggles the
103// code state, and the unfinished tail waits for the next piece or the end.
104export class Annotator {
105	private pending = "";
106	private fenced = false;
107	constructor(private readonly titles: Titles) {}
108
109	push(piece: string): string {
110		this.pending += piece;
111		const cut = this.pending.lastIndexOf("\n");
112		if (cut < 0) return "";
113		const ready = this.pending.slice(0, cut + 1);
114		this.pending = this.pending.slice(cut + 1);
115		return this.lines(ready);
116	}
117
118	flush(): string {
119		const rest = this.pending;
120		this.pending = "";
121		return this.lines(rest);
122	}
123
124	private lines(text: string): string {
125		return text
126			.split("\n")
127			.map((line) => {
128				if (/^\s*(```|~~~)/.test(line)) {
129					this.fenced = !this.fenced;
130					return line;
131				}
132				return this.fenced ? line : annotateLine(line, this.titles);
133			})
134			.join("\n");
135	}
136}
137