diff --git a/README.md b/README.md index dc45df1..55da715 100644 --- a/README.md +++ b/README.md @@ -98,9 +98,10 @@ alone. | Decisions | `docs/adr/` in the repository they concern | The project | | The task | `task.md` at the workspace root | The task | -The two top-level documents are held to about 200 lines each. The limit is the -feature: they are read in full by every task, and a document too long to read in one -sitting stops being read. +Both top-level documents are bounded — product design at 200 lines, architecture at +350, the difference being that how a system is built has reference material that +prose cannot replace. The limit is the feature: they are read in full by every task, +and a document too long to read in one sitting stops being read. Decision records are searched, not browsed — `find_adr` returns the few that bear on the question. They are written during the design stage as decisions are made, and diff --git a/docs/architecture.md b/docs/architecture.md index 4ee98cf..7f7f054 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,19 +1,21 @@ # Claude Code Harness — Architecture Status: draft, pending review -Last updated: 2026-08-10 +Last updated: 2026-08-15 The harness is a Claude Code plugin that governs three things: the documents a project keeps, the workspace a task runs in, and the stages a task passes through. -This document says what the system is and how it gets Claude to follow the -procedure. It stays out of implementation detail. +This document says what the system is and what rules bind it. Why a particular +decision was taken lives in `docs/adr/`, which is what that directory is for; this +document points rather than repeats, so that it stays short enough to be read by +every task. --- ## 1. What the system is -A plugin directory the harness loads when the plugin is enabled. Almost everything +A plugin directory Claude Code loads when the plugin is enabled. Almost everything happens inside events the harness already fires; the one exception is a small MCP server, which exists because raising a dialog and receiving its answer is not something a hook can do. @@ -27,10 +29,8 @@ something a hook can do. | **Project config** | A few facts about one project | The repository is also its own marketplace: `.claude-plugin/marketplace.json` lists -one plugin whose source is the repository root, so `/plugin marketplace add` and -`/plugin install` reach it without a second repository to keep in step. Its `version` -is what tells an installed copy that a newer one exists, so it moves with -`plugin.json`'s. +one plugin whose source is the repository root. Its `version` is what tells an +installed copy that a newer one exists, so it moves with `plugin.json`'s. The line that matters: **a skill is text Claude may or may not follow; the server and the hooks are programs that run either way.** Every rule below is placed on one @@ -43,25 +43,20 @@ side of that line on purpose. Three means, in increasing order of strength. Most of the method sits on the first one, and that is not a defect to be engineered away — it is what a method is. -**Instruction.** The procedure is a skill loaded when the user starts a task. This -is the only means that can express anything needing judgement: how far to read, when -the interview is done, whether a review finding is real. It decays over a long -session, which §3 addresses. +**Instruction.** The procedure is a skill loaded when the user starts a task, and the +only means that can express anything needing judgement: how far to read, when the +interview is done, whether a review finding is real. It decays over a long session, +which §3 addresses. -**Stage control.** The current stage lives in the ticket, and the ticket is written -by the server, not by Claude. To move on, Claude calls `advance_stage`; the server -checks the stage's output exists before agreeing, and for the three stages that -need the user, raises a dialog and waits. Claude can ask to advance. It cannot -advance. +**Stage control.** The stage lives in the ticket, written by the server, not by +Claude. To move on, Claude calls `advance_stage`; the server checks the stage's output +exists, and for the three stages that need the user, raises a dialog and waits. Claude +can ask to advance. It cannot advance. **One refusal.** A `PreToolUse` hook refuses writes inside a repository until the -design has been approved. That is the whole of hard enforcement. - -There is deliberately no more. The user's instruction was to design for the happy -path and not against a model that is trying to get around the harness. A model -drifting under context pressure is stopped by the stage machine and the reminders; -a model determined to circumvent them is not something this design tries to beat, -and pretending otherwise would buy complexity with no safety. +design has been approved. That is the whole of hard enforcement, deliberately: a model +drifting under context pressure is stopped by the stage machine and the reminders, and +a model determined to circumvent them is not something this design tries to beat. ### What is refused, exactly @@ -74,15 +69,12 @@ docs/product.md docs/adr/** ``` -Everything else in a repository is refused, with a message naming the stage and -what would open it. - -Two properties make this cheap. It needs no per-project configuration, because -those three paths are conventions the plugin establishes at `init` time rather than -facts it has to discover. And it needs no notion of "source file" or "test file" — -the distinction that would have dragged in path patterns, project config, and rules -about protecting that config. The task document is outside every repository (§4), so -it is writable throughout without being an exception. +Everything else in a repository is refused, with a message naming the stage and what +would open it. This needs no per-project configuration, because those three paths are +conventions the plugin establishes at `init` time, and no notion of "source file" — +the distinction that would have dragged in path patterns and rules protecting them. +The task document is outside every repository (§4), so it stays writable without +being an exception. After the design is approved, nothing is refused for the rest of the task. @@ -90,30 +82,28 @@ After the design is approved, nothing is refused for the rest of the task. ## 3. Keeping instruction alive -A long task compacts, resumes, and fills with unrelated output. A procedure stated -at turn 3 is not reliably present at turn 300. Two techniques, neither of which -enforces anything: +A long task compacts, resumes, and fills with unrelated output. A procedure stated at +turn 3 is not reliably present at turn 300. Two techniques, neither of which enforces +anything: **Re-state the stage.** At session start and resume, and whenever a stage changes, -the current stage and what it calls for are injected as context. On a refusal, the -message says what would open the gate. A refusal is the one moment the model is -guaranteed to be attending to the rule, so its message is written as an -instruction — "the design is not approved yet; present it and ask" — never as a +the current stage and what it calls for are injected as context. A refusal is the one +moment the model is guaranteed to be attending to the rule, so its message is written +as an instruction — "the design is not approved yet; present it and ask" — never as a bare error. **Make the correct path the cheapest one.** The task document template already has -its sections, so filling them in beats inventing a format. `find_adr` beats -guessing filenames. `advance_stage` beats deciding on its own whether it is done. -A model under context pressure takes the locally cheapest action; the design's job -is to arrange for that to be the intended one. +its sections, so filling them in beats inventing a format. `find_adr` beats guessing +filenames. `advance_stage` beats deciding on its own whether it is done. A model +under context pressure takes the locally cheapest action; the design's job is to +arrange for that to be the intended one. --- ## 4. Layout -A project is a directory the plugin creates. Inside it: the main checkout of each -repository, one workspace per task, and the things that belong to the project but -to no repository. +A project is a directory. Inside it: the main checkout of each repository, one +workspace per task, and the things that belong to the project but to no repository. ``` / @@ -129,30 +119,22 @@ to no repository. tickets/ one file per task ``` -`main/` is not a special case: it is the workspace for the base branch, with the -same shape as every task workspace. Repositories are found by looking inside it, -which is why nothing has to be recorded about where they are. - -**`main/` is always a container, one directory per repository, even when there is -only one.** The obvious shortcut — letting `main/` be the checkout itself in a -single-repository project — costs more than it saves: the task workspace would -have to change shape to match, and then the task document has nowhere to live -except inside the repository, which is exactly what it must never do. One layout, -one rule, one place for `task.md`. - -Three consequences worth stating because they do work later: - -**The task document is outside every repository.** Nothing has to be excluded, no -ignore file is touched, and it can never appear in a diff. This is why `task.md` -sits at the workspace root rather than inside a worktree. +`main/` is not a special case: it is the workspace for the base branch, with the same +shape as every task workspace, which is why repositories can be found by listing it +and nothing has to be recorded about where they are. **It is always a container, one +directory per repository, even when there is only one** — letting it be the checkout +itself would leave the task document nowhere to live except inside a repository, +which is exactly what it must never do. -**A task is identified by its workspace path.** Concurrency and resumption both -fall out of that for free: two workspaces do not know about each other, and a -session started inside one is recognised as belonging to that task without being -told. +Three consequences that do work later: -**All repositories in a task share one branch name**, which is also the workspace -directory name. One task, one branch, however many repositories it touches. +- **The task document is outside every repository.** Nothing has to be excluded, no + ignore file is touched, and it can never appear in a diff. +- **A task is identified by its workspace path.** Concurrency and resumption fall out + for free: two workspaces do not know about each other, and a session started inside + one is recognised as belonging to that task without being told. +- **All repositories in a task share one branch name**, which is also the workspace + directory name. --- @@ -166,103 +148,64 @@ Three kinds, with three lifetimes. | **ADR** | `docs/adr/` in the repo the decision concerns | The project's life | Written during the design stage, as decisions are made | | **Task document** | `task.md` at the workspace root | The task | Claude, throughout | -**Top-level design.** Two documents, each under about 200 lines. The limit is the -point: a document nobody can read in one sitting stops being read, and these two -are meant to be read by every task. When a task changes what they say, the change -is part of that task, made as it happens rather than deferred. +**Top-level design.** Two documents, both bounded, because a document nobody can read +in one sitting stops being read and these two are meant to be read by every task. The +bounds differ because the jobs do: **product design, 200 lines**, since what a product +is for and will not do is prose that gets longer only by repeating itself; +**architecture, 350**, because how a system is built has reference material — a +layout, a stage table, the exact paths a rule names — that a reader comes back to and +that cannot be compressed into sentences. -**ADR.** One decision per file: context, decision, reasoning. Written when the -decision is made, in the design stage, not reconstructed afterwards. When a decision -changes, the record is edited in place — the project keeps the current answer, not -an archaeology of previous ones. +Both numbers are enforced by a test rather than asked for, since this document reached +456 lines while three places in the project said the limit was 200. When a task +changes what these documents say, the change is part of that task. -Retrieval is a tool, not a discipline. `find_adr(query)` scans every ADR's front -matter across the task's repositories and returns matching titles, one-line -summaries, and paths; Claude then reads the few that matter. The alternative — an -index file kept in step by hand — puts every ADR in two places and lets them drift. +**ADR.** One decision per file: context, decision, reasoning. Written when the +decision is made, not reconstructed afterwards, and edited in place when a decision +changes — the project keeps the current answer, not an archaeology of previous ones. +Retrieval is a tool rather than a discipline: `find_adr(query)` scans every ADR's +front matter and returns the few that bear on the question, so no hand-maintained +index exists to drift. -**Task document.** One file, holding the requirement, the design, the test cases, -and the record of which stages have passed. Never committed, because it lives -outside the repositories, and it dies with the workspace. +**Task document.** One file, holding the requirement, the design, the test cases, and +the record of which stages have passed. Never committed, and it dies with the +workspace. --- ## 6. Tickets -One file per task under `/tickets/`, created by the plugin at stage 1 and -never hand-edited. It holds the task description, the branch, the workspace path, -the repositories involved, the Claude session id, the current stage, and the -status. +One file per task under `/tickets/`, written by the plugin and never by +hand. It holds the description, the branch, the workspace path, the repositories, the +Claude session id, the stage, and the status. Its purpose is recovery. Days later the user remembers a task by what it was about, -not by its branch name, so `find_ticket(query)` matches on the description and -returns the workspace path and the session id to resume. A slash command exposes -the same search to the user directly. - -**The session id is passed in.** `start_task` takes it as an argument, and the task -skill reads `CLAUDE_CODE_SESSION_ID` in a shell call to supply it. With no argument -the server tries its own environment, which is empty today and costs nothing to try. -With neither, the ticket records null. - -Getting the id to the ticket took three failed attempts, and what they have in -common is worth more than any of them: - -- A hook wrote it into the ticket — but resolved *which* ticket from the event's - working directory, and a session creating a task is usually nowhere near the - workspace it is about to create. -- The server read it from its own environment — but a bundled MCP server does not - inherit `CLAUDE_CODE_SESSION_ID`, and a test that sets the variable itself cannot - discover that. -- A shared record was designed to bridge the two — and cut, because it could hold a - session from another project or one closed days ago, producing a wrong id where - there had been none. - -`SessionStart` still repairs a ticket when a session starts inside that ticket's own -workspace. That is the one place where resolving from the working directory is -right: the session demonstrably is there. See -`docs/adr/0002-session-id-from-the-environment.md`. - -**A ticket has a life of its own.** It is created by `start_task` at stage 1, or -written down before there is any work at all: `add_ticket` records a description and -a proposed branch with no workspace, no worktree and no stage. That is a backlog -entry — `active`, at stage 0, which `describe` renders as `backlog`. The status set -stays `active`, `done`, `abandoned`, because status says whether the work is live -and stage says how far it has got, and a fourth status would say the second thing -twice (`docs/adr/0004-backlog-is-a-stage-not-a-status.md`). - -From there `set_ticket_status` moves a ticket between `active` and `abandoned`, in -either direction, so a dropped task can be reopened — reopening a finished one also -clears `finished_at` and `authorised_at`, because a live task cannot also be one the -user has already authorised sending out, and returns the stage to 9, which is where -the dialog that authorises it lives. It cannot write `done`. `done` is what stage 10 -produces once the user has accepted the feature at stage 9, and a management tool -that could write it directly would be a way around that acceptance -(`docs/adr/0006-management-cannot-produce-done.md`). Every change it does make -raises the plugin's dialog first. - -**Cleanup is a move, not a delete.** `archive_ticket` moves the file into -`tickets/archive/`, out of every listing, and back out again on request. Nothing is -removed: not the ticket, not the workspace, not the worktree, not the branch. The -archive being a directory rather than a field is what makes the default listing -free — `allTickets` reads one directory, and archived tickets are simply not in it -(`docs/adr/0005-archiving-moves-the-file.md`). - -These three are addressed by branch name rather than by the working directory, since -managing a ticket means acting on one whose workspace you are not in. A branch name -therefore reaches a file path without a directory creation to stop it on the way, so -it is validated where it enters: lower-case letters, digits and hyphens, nothing -that can traverse. - -**The default listing shows unfinished work.** `find_ticket` with no arguments -returns active and backlog tickets, and nothing else; `done`, `abandoned`, `all` and -the archive are all reachable by asking. It answers as a table, one line per ticket, -and falls back to the full block when exactly one ticket matches — a search that -lands on one ticket is someone trying to get back into it. - -Workspaces are still not deleted when a task finishes, so directories on disk -accumulate whatever happens to their tickets. Reclaiming that space stays the user's -own, deliberately: a worktree can hold uncommitted work, and nothing in the plugin -can judge whether it matters. +not by its branch name, so `find_ticket(query)` matches on the description and returns +the workspace path and the session id to resume. Its default listing is unfinished +work — active and backlog, with `done`, `abandoned`, `all` and the archive one +argument away. + +**The session id is passed in** to `start_task`, supplied by the task skill from +`CLAUDE_CODE_SESSION_ID`; with nothing available the ticket records null, and +`SessionStart` repairs it if a session later starts inside that ticket's own +workspace. Three other mechanisms failed first (ADR 0002). + +**A ticket has a life of its own.** `add_ticket` records a description and a branch +and nothing else — a backlog entry, which is `active` at stage 0 rather than a fourth +status (ADR 0004). `set_ticket_status` moves a ticket between `active` and +`abandoned` in either direction, but cannot write `done`: that is what stage 10 +produces after the user's acceptance, and a management tool able to write it would be +a way around that acceptance (ADR 0006). `archive_ticket` moves the file into +`tickets/archive/` and back — a move, never a delete, because the ticket is the only +record of what a directory on disk was for (ADR 0005). + +Those three are addressed by branch name, since managing a ticket means acting on one +whose workspace you are not in. A branch name therefore reaches a file path with no +directory creation to stop it on the way, so it is validated where it enters: +lower-case letters, digits and hyphens, nothing that can traverse. + +Workspaces are never deleted, whatever happens to their tickets. A worktree can hold +uncommitted work, and nothing in the plugin can judge whether it matters. --- @@ -283,153 +226,120 @@ Ten stages. The stage lives in the ticket; the server owns it. | 9 | **User acceptance** | The user has run it, accepts, and thereby authorises the pull request — **dialog** | | 10 | Pull request | The PR is open | -Three stages end in a dialog the user answers — 5, 6 and 9. The rest end when Claude calls -`advance_stage` and the server agrees the stage's output exists — the task document -has the section, the ADR files are there, the test command was run and reported -failures. - -That check is a formality check, not a quality check. A program can see that a -Design section exists; it cannot see whether the design is any good. The design's -quality is what stages 4 and 5 are for, and the quality of the failing tests is what -the stage 6 dialog is for. **The server checks that the work happened; the user -checks that it was worth happening.** - -Worktrees are created at the end of stage 2, not stage 1, because which -repositories a task touches is an outcome of the interview. Stage 1 creates the -workspace directory, the ticket, and the task document; the worktrees appear once -there is an answer. - -Stage 4 and stage 8 are switchable in project config, because not every user has -codex. Switched off, they are skipped rather than passed. - -**Going backwards.** Declining at stage 5 returns the task to stage 3: the design -was wrong, so the design is what changes. Declining at stage 9 is ambiguous — -acceptance can fail because the implementation is wrong or because the design was — -so Claude asks which, and returns to stage 7 or stage 3 accordingly. The dialog -itself stays a two-button accept/decline, because putting the choice in the dialog -would cost a second click on every acceptance to serve the case that fails. The -ticket records each return, so a task that went around twice says so. +Three stages end in a dialog — 5, 6 and 9. The rest end when Claude calls +`advance_stage` and the server agrees the stage's output exists. That check is a +formality check, not a quality check: a program can see that a Design section exists, +not whether the design is any good. **The server checks that the work happened; the +user checks that it was worth happening.** + +Worktrees are created at the end of stage 2, not stage 1, because which repositories +a task touches is an outcome of the interview. Stages 4 and 8 are switchable in +project config, and switched off they are skipped rather than passed. + +**Going backwards.** Declining at stage 5 returns the task to stage 3: the design was +wrong, so the design is what changes. Declining at stage 9 is ambiguous — acceptance +can fail because the implementation is wrong or because the design was — so Claude +asks which, and returns to stage 7 or stage 3 accordingly. The ticket records each +return, so a task that went around twice says so. **What a dialog says.** The terminal cannot be scrolled while a dialog is open, so -whatever Claude presented just before it is out of reach until it is answered. Each -message therefore carries its own context — what is being approved, and what -accepting causes — rather than pointing at a conversation the user cannot see. The -plugin cannot make the dialog scrollable: `elicitation/create` carries a message and -a schema, and how it is drawn belongs to the client -(`docs/adr/0008-a-dialog-says-what-accepting-does.md`). +each message carries its own context — what is being approved, and what accepting +causes — rather than pointing at a conversation the user cannot see (ADR 0008). --- ## 8. The server -One MCP server, eleven tools. - -| Tool | What it does | -| :-- | :-- | -| `start_task` | Create the workspace, the ticket, and the task document | -| `advance_stage` | Check the current stage's output, raise a dialog when the stage needs the user, record the new stage | -| `return_to_stage` | Send the task back to an earlier stage, with the user's reason | -| `finish_task` | Mark the task done once the pull requests exist | -| `abandon_task` | Mark the task abandoned; delete nothing | -| `get_status` | Report the current task: stage, workspace, repositories, what is refused | -| `find_adr` | Search ADRs across the task's repositories | -| `find_ticket` | Search tickets by description, or list them; return workspace path and session id | -| `add_ticket` | Write down a backlog entry: a description and a proposed branch, and nothing else | -| `set_ticket_status` | Move a ticket between active and abandoned, by name, after the user accepts | -| `archive_ticket` | Move a ticket into the archive, or back out of it | +One MCP server, eleven tools, in four groups: the task's life (`start_task`, `finish_task`, +`abandon_task`), the stage (`advance_stage`, `return_to_stage`, `get_status`), +retrieval (`find_adr`, `find_ticket`), and the ticket as an object in its own right +(`add_ticket`, `set_ticket_status`, `archive_ticket`). Each tool describes itself to +the client, so what each one takes is not restated here. Stage 9's acceptance is also the authorisation to send the work out: accepting sets -`authorised_at` in the same write as the stage change, and stage 10 asks nothing. -Two dialogs on consecutive screens, about the same work, with nothing happening -between them, made the acceptance weaker rather than stronger — a confirmation that -always follows another confirmation stops being read -(`docs/adr/0007-one-acceptance-authorises-the-pull-request.md`). - -`finish_task` is still separate, and still refuses without `authorised_at`. That -keeps the ticket honest — a task is not done because permission was given, but -because the work left the machine. What went away is the second question, not the -second record. - -An advance that is waiting on a dialog holds the task: a second `advance_stage` +`authorised_at` in the same write as the stage change, and stage 10 asks nothing +(ADR 0007). `finish_task` stays separate and still refuses without `authorised_at` — +a task is not done because permission was given, but because the work left the +machine. An advance waiting on a dialog holds the task: a second `advance_stage` arriving meanwhile is turned away rather than queued, so one stage never raises two dialogs. -`advance_stage` takes a stage name. It does not take the wording of the dialog. The -server holds that text, so the question the user is asked is always the plugin's, -and the answer returns to the server without passing through Claude. This was -verified before the design was settled: MCP elicitation raises the dialog, blocks -until the user answers, and returns `accept` or `decline`. With an empty requested -schema the dialog has no fields, so approving is a single action. +`advance_stage` takes a stage name, not the wording of the dialog. The server holds +that text, so the question the user is asked is always the plugin's, and the answer +returns to the server without passing through Claude. MCP elicitation raises the +dialog, blocks until answered, and returns `accept` or `decline`; with an empty +requested schema the dialog has no fields, so approving is a single action. -The server holds no state in memory. One process is started per session, so state -in memory would be lost on restart and invisible to a second session working on -another task in the same project. Everything is in the ticket file. +The server holds no state in memory: one process per session, so anything held there +would be lost on restart and invisible to a second session working on another task in +the same project. Everything is in the ticket file. --- ## 9. Init `init` adopts a project, once. It creates `tickets/`, writes the document skeletons -that are missing — `docs/architecture.md`, `docs/product.md`, and `docs/adr/README.md` -in each repository — and asks for the three facts it cannot discover: how the test -suite is run, how the user tries a change, and whether codex is available. - -The decision-record directory gets a README rather than being left empty, because -git does not track empty directories and the directory would vanish on the first -commit. Making that file the README rather than a `.gitkeep` means it also explains -what the directory is for. `find_adr` skips it, since it holds no decision and -contains the words every search is made of. - -It does not move, clone, or reorganise anything. Repositories are wherever they -already are inside `main/`, and finding them is a directory listing. - -Skeletons are skeletons. The plugin writes headings and a sentence about what -belongs under each; the content is the user's. - -Project config therefore holds three values and no inventory. Which repositories a -task touches is a per-task answer and lives in that task's ticket. - -The three are not the same kind of thing, and saying so prevents the mistake that -produced them being treated alike. **`codex` is read by the server** and is the only -value any program reads: it decides whether stages 4 and 8 happen. **`test` and -`try` are read by Claude** — they exist so the question is asked once per project -rather than once per task, and their values are prose as much as commands. `try` is -named for what stage 9 needs, which is a way for the user to exercise the change -themselves; a project with nothing to launch answers it with a sentence. +that are missing — `docs/architecture.md`, `docs/product.md`, and +`docs/adr/README.md` in each repository (ADR 0003) — and asks for the three facts it +cannot discover. It moves, clones and reorganises nothing: repositories are wherever +they already are inside `main/`, and finding them is a directory listing. Skeletons +are headings and a sentence about what belongs under each; the content is the user's. + +The three config values are not the same kind of thing, and saying so prevents them +being treated alike. **`codex` is read by the server** and is the only value any +program reads: it decides whether stages 4 and 8 happen. **`test` and `try` are read +by Claude** — they exist so the question is asked once per project rather than once +per task, and their values are prose as much as commands. `try` is named for what +stage 9 needs, a way for the user to exercise the change themselves; a project with +nothing to launch answers it with a sentence. + +Which repositories a task touches is a per-task answer and lives in that task's +ticket, so the config holds no inventory. --- ## 10. Degradation **A project that never adopted the plugin.** The refusal hook looks for a ticket -covering the current directory. Finding none, it exits without a decision. Silence -is the default; enforcement is the exception. +covering the current directory. Finding none, it exits without a decision. Silence is +the default; enforcement is the exception. **Codex not installed.** Stages 4 and 8 are skipped, and the task document records that they were skipped rather than passed. -**A task abandoned mid-way.** The ticket is marked abandoned. The workspace is left -alone, because the user may come back to it; nothing is deleted on the plugin's -initiative. +**A task abandoned mid-way.** The ticket is marked abandoned, the workspace is left +alone, and nothing is deleted on the plugin's initiative. **The server not running.** Stages cannot advance and the refusal stays in force. -Failing closed is the right direction: a task that cannot proceed is recoverable, a -task that silently proceeds unsupervised is not. +Failing closed is right here: a task that cannot proceed is recoverable, a task that +silently proceeds unsupervised is not. + +**The refusal hook not working.** The other direction, stated because the sentence +above would otherwise be read as covering it. The hook fails open: if it crashes, +times out, emits invalid JSON, or cannot parse the ticket that would have told it the +stage, the write proceeds — Claude Code treats a crashed or timed-out `PreToolUse` +hook as no decision, and an unparseable ticket is indistinguishable to the hook from +a project that never adopted the harness. + +That is intended, for the same reason the server's direction is the opposite: the +server failing closed stops a task, while the hook failing closed would stop a +project — one corrupt file in `tickets/` would make every repository in it unwritable, +including the documents the harness tells you to go and fix. The cost is worth naming +plainly. **A refusal that fails open is not a guarantee**, which is the claim §2 makes +about circumvention reached from the other side. Cases 49-52 hold the refusal, and 52 +holds this degradation. --- ## 11. Deliberately not in the design -- **No defence against deliberate circumvention.** Stated as a decision so it is - not mistaken for an oversight. The harness keeps an honest process honest. -- **No path classification.** No rules about what counts as a source file or a test - file, and therefore no per-project path patterns to configure or protect. -- **No model calls of its own.** The plugin never invokes a model. It shapes what +What the product will not do is in `product.md`, and is not repeated here. Three +exclusions are architectural rather than product decisions, and belong with the +design they shape: + +- **No defence against deliberate circumvention** (§2), stated as a decision so it is + not mistaken for an oversight. +- **No path classification.** No rules about what counts as a source file, and + therefore no per-project path patterns to configure or protect (§2). +- **No model calls of its own.** The plugin never invokes a model; it shapes what Claude does. -- **No bundled tooling.** No reviewer, no test runner, no language support. Codex is - named in config and invoked by name, or switched off. -- **No deletion, of anything, ever.** Cleanup is archiving. Workspaces accumulate on - disk and removing them stays the user's own act. -- **No issue tracker.** A backlog entry is a description and a branch name. No - assignee, no priority, no labels, no cross-project view, and no remote or shared - store — the tickets of one project are the files in one directory. diff --git a/docs/product.md b/docs/product.md index 2effb71..a42992e 100644 --- a/docs/product.md +++ b/docs/product.md @@ -35,9 +35,10 @@ in. **The design document nobody reads.** An architecture document grows until reading it is a project of its own, so nobody does — including Claude, which then infers the design from whatever files it happened to open. Two top-level documents, -architecture and product design, each held to about 200 lines. The limit is the +architecture and product design, both bounded — 350 lines and 200. The limit is the feature: a document short enough to read in one sitting can be expected of every -task, and that expectation is what makes it worth maintaining. +task, and that expectation is what makes it worth maintaining. A limit nothing checks +is a wish, so both are held by a test. **The design document that is no longer true.** Updating it is always a separate task, and separate tasks do not get scheduled. A task that changes what those diff --git a/skills/init/SKILL.md b/skills/init/SKILL.md index 17d39c0..e029a04 100644 --- a/skills/init/SKILL.md +++ b/skills/init/SKILL.md @@ -84,8 +84,10 @@ And for the product design, exactly one copy: Never overwrite a file that exists. Report which ones you created and which were already there. -Both top-level documents are held to about 200 lines. Say so to the user: the limit -is the point, because they are read by every task. +The top-level documents are bounded: product design at about 200 lines, architecture +at about 350, because how a system is built carries reference material that prose +cannot replace. Say so to the user: the limit is the point, because they are read by +every task. ## Step 5 — Ask for what cannot be discovered diff --git a/skills/init/templates/architecture.md b/skills/init/templates/architecture.md index b1d22ee..e2b9319 100644 --- a/skills/init/templates/architecture.md +++ b/skills/init/templates/architecture.md @@ -1,6 +1,6 @@ # Architecture - diff --git a/test/conventions.test.mjs b/test/conventions.test.mjs index 0185724..7385c4d 100644 --- a/test/conventions.test.mjs +++ b/test/conventions.test.mjs @@ -49,6 +49,12 @@ test('10 — no stale run naming survives', () => { ); }); +// The bound each top-level document is held to. They differ because the jobs do, and +// they are here because only one of them used to be checked: architecture.md reached +// 456 lines while three places in the project said the limit was 200. A limit nothing +// checks is a wish. +const LIMITS = { 'docs/product.md': 200, 'docs/architecture.md': 350 }; + test('11 — one product document, under 200 lines', () => { assert.ok(!existsSync(join(REPO, 'docs/prd.md')), 'docs/prd.md should be gone'); assert.ok(existsSync(join(REPO, 'docs/product.md')), 'docs/product.md should exist'); @@ -66,6 +72,21 @@ test('11 — one product document, under 200 lines', () => { ); }); +test('53 — both top-level documents are within the bound stated for them', () => { + for (const [doc, limit] of Object.entries(LIMITS)) { + const lines = read(doc).split(/\r?\n/).length; + assert.ok(lines <= limit, `${doc} is ${lines} lines; the limit is ${limit}`); + + // A number stated in one place and enforced in another drifts apart silently, + // which is the failure this whole file exists to catch. + const stated = new RegExp(`\\b${limit}\\b`); + assert.match(read(doc), stated, `${doc} should say the bound it is held to`); + } + + assert.match(read('README.md'), /200 lines, architecture at\s+350/); + assert.match(read('skills/init/SKILL.md'), /about 200 lines, architecture\s+at about 350/); +}); + test('38 — one version, declared in three files, and an install line that reaches it', () => { const plugin = JSON.parse(read('.claude-plugin/plugin.json')); const marketplace = JSON.parse(read('.claude-plugin/marketplace.json')); diff --git a/test/refusal.test.mjs b/test/refusal.test.mjs new file mode 100644 index 0000000..b42e6ea --- /dev/null +++ b/test/refusal.test.mjs @@ -0,0 +1,119 @@ +// Test cases 49-52: the one refusal, driven the way Claude Code drives it — as a +// child process fed a PreToolUse event on stdin. +// +// The refusal is a path comparison, so its failure mode is not an error: a path it +// fails to recognise is classified `outside`, and `outside` is allowed. These cases +// spell one file every way a caller might and insist the answer never changes. +// +// What makes that safe today is that both sides of the comparison come from the same +// string: the guard hands `context()` the target's own directory, and compares the +// target against the workspace that call derived. Case, separators and dot segments +// therefore cannot disagree. Case 50 holds that property, which is a property of the +// call site rather than of `classify`, and so could be lost by a caller that passed a +// workspace from anywhere else. + +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { after, test } from 'node:test'; + +import { REPO, cleanup, makeProject } from './helpers.mjs'; + +const GUARD = join(REPO, 'scripts', 'guard-write.mjs'); + +after(cleanup); + +/** A project with one active task at stage 3 — before the design is approved. */ +function projectAtStage(stage) { + const root = makeProject(); + const branch = 'add-note-search'; + mkdirSync(join(root, branch, 'api', 'src'), { recursive: true }); + writeFileSync( + join(root, 'tickets', `${branch}.json`), + JSON.stringify({ + branch, + description: 'a task', + project: root, + workspace: `${root}/${branch}`, + repos: ['api'], + session_id: null, + stage, + status: 'active', + created_at: new Date().toISOString(), + history: [], + }), + ); + return { root, workspace: `${root}/${branch}` }; +} + +/** Ask the guard about one write. Returns 'deny' or 'allow'. */ +function ask(filePath) { + const event = JSON.stringify({ tool_name: 'Write', tool_input: { file_path: filePath } }); + const { stdout } = spawnSync(process.execPath, [GUARD], { input: event, encoding: 'utf8' }); + if (!stdout.trim()) return 'allow'; + return JSON.parse(stdout).hookSpecificOutput?.permissionDecision ?? 'allow'; +} + +test('49 — a write inside a repository is refused before the design is approved', () => { + const { workspace } = projectAtStage(3); + assert.equal(ask(`${workspace}/api/src/search.ts`), 'deny'); +}); + +test('50 — the refusal does not depend on how the path is spelled', () => { + const { workspace } = projectAtStage(3); + const target = `${workspace}/api/src/search.ts`; + + const spellings = [ + ['dot segments', `${workspace}/api/../api/src/search.ts`], + ['a trailing dot segment', `${workspace}/api/src/./search.ts`], + ]; + + // These spell the same file only on Windows. Elsewhere a backslash is an ordinary + // character in a name and case is significant, so each names a different file, and + // folding them would refuse writes the harness never meant to refuse. + if (process.platform === 'win32') { + spellings.push( + ['separators', target.split('/').join('\\')], + ['a lower-case drive letter', target[0].toLowerCase() + target.slice(1)], + ['an upper-cased segment', target.replace('/api/', '/API/')], + ['an upper-cased workspace', target.replace(workspace, workspace.toUpperCase())], + ); + } + + for (const [how, spelling] of spellings) { + assert.equal(ask(spelling), 'deny', `a path written with ${how} reached the file unrefused`); + } +}); + +test('51 — the documentation paths stay writable, however they are spelled', () => { + const { workspace } = projectAtStage(3); + const writable = [ + `${workspace}/task.md`, + `${workspace}/api/docs/architecture.md`, + `${workspace}/api/docs/product.md`, + `${workspace}/api/docs/adr/0001-a-decision.md`, + ]; + + for (const path of writable) { + assert.equal(ask(path), 'allow', `${path} must stay writable in every stage`); + } + + // After the design is approved nothing is refused at all. + const approved = projectAtStage(6); + assert.equal(ask(`${approved.workspace}/api/src/search.ts`), 'allow'); +}); + +test('52 — a ticket the guard cannot read leaves the write unrefused', () => { + // Asserted because it is the design, not because it is desirable: a guard that + // cannot tell what stage a task is at allows the write rather than freezing the + // project. The same is true of a crashed or timed-out hook, which Claude Code + // treats as no decision. See docs/architecture.md §10 — this test exists so that + // degradation is a stated property with a case behind it, rather than something a + // reader has to infer from a `catch {}`. + const { root, workspace } = projectAtStage(3); + assert.equal(ask(`${workspace}/api/src/search.ts`), 'deny'); + + writeFileSync(join(root, 'tickets', 'add-note-search.json'), '{ not json'); + assert.equal(ask(`${workspace}/api/src/search.ts`), 'allow'); +}); diff --git a/test/tickets.test.mjs b/test/tickets.test.mjs index 42cb82c..cabbc4e 100644 --- a/test/tickets.test.mjs +++ b/test/tickets.test.mjs @@ -497,9 +497,17 @@ test('32 — the architecture document describes the lifecycle it now has', () = assert.doesNotMatch(architecture, /No workspace or ticket lifecycle management in version one/); assert.match(architecture, /eleven tools/); for (const tool of ['add_ticket', 'set_ticket_status', 'archive_ticket']) { - assert.match(architecture, new RegExp(`\`${tool}\``), `the tool table lists ${tool}`); + assert.match(architecture, new RegExp(`\`${tool}\``), `§8 names ${tool}`); } - assert.match(architecture, /No deletion, of anything, ever/, 'it still promises nothing is deleted'); + + // The promise that nothing is deleted is the product's, and is asserted where it + // is made. The architecture states the consequence it has to live with. + assert.match( + readFileSync(join(REPO, 'docs/product.md'), 'utf8'), + /\*\*Delete anything\.\*\* Cleanup is archiving/, + 'it still promises nothing is deleted', + ); + assert.match(architecture, /Workspaces are never deleted/); const product = readFileSync(join(REPO, 'docs/product.md'), 'utf8').split(/\r?\n/).length; assert.ok(product <= 200, `docs/product.md is ${product} lines; the limit is about 200`);