From 7771548ae47cff26ce111655ee48f68b695ff442 Mon Sep 17 00:00:00 2001 From: coderloganli Date: Sat, 15 Aug 2026 20:42:09 -0400 Subject: [PATCH 1/4] Hold the one refusal with tests, and say which way it fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refusal is the only hard enforcement in the product and had no test of its own: nothing in the suite ran guard-write.mjs. Cases 49-52 drive it the way Claude Code does, as a child process fed a PreToolUse event on stdin. Case 50 holds the property the refusal rests on — that spelling a path differently does not change the answer. It passes today because both sides of the comparison come from one string: the guard hands context() the target's own directory and compares the target against the workspace that call derived. That is a property of the call site, not of classify(), so a future caller passing a workspace from elsewhere would lose it silently. Hence the test. Case 52 asserts that a ticket the guard cannot parse leaves the write unrefused, and §10 now says so: the hook fails open where the server fails closed. A hook failing closed would make a single corrupt file in tickets/ freeze every repository in the project, including the documents the harness tells you to go and fix. The cost is that the refusal is not a guarantee, which is the claim §2 already makes about circumvention, reached from the other side. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011mjhmFa9c2dwxmNeH5tboy --- docs/architecture.md | 21 ++++++++ test/refusal.test.mjs | 119 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 test/refusal.test.mjs diff --git a/docs/architecture.md b/docs/architecture.md index 4ee98cf..702166e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -416,6 +416,27 @@ initiative. Failing closed is the right direction: a task that cannot proceed is recoverable, a task that silently proceeds unsupervised is not. +**The refusal hook not working.** The other direction, and stated because the +sentence above would otherwise be read as covering it. The hook fails open: if it +crashes, times out, writes something that is not valid 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 rather than as a refusal, and +`readTicket` returning null for an unparseable file is indistinguishable to the hook +from a project that never adopted the harness. + +That is the intended direction here, for the same reason the server's is the +opposite. The server failing closed stops a task; the hook failing closed would stop +a project — a single corrupt file in `tickets/` would make every repository in it +unwritable, including the documents the harness tells you to go and fix. And the +failure is not silent for long: a stage that will not advance is noticed within one +task. + +What it costs is worth naming plainly. **A refusal that fails open is not a +guarantee.** It is the same claim §2 makes about circumvention, arrived at from the +other side: the harness keeps an honest process honest, and a broken hook is not +distinguishable from an absent one. Test cases 49-52 hold the refusal itself, and 52 +holds this degradation, so it stays a decision with a case behind it. + --- ## 11. Deliberately not in the design diff --git a/test/refusal.test.mjs b/test/refusal.test.mjs new file mode 100644 index 0000000..e8b68f2 --- /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 = [ + ['separators', target.split('/').join('\\')], + ['dot segments', `${workspace}/api/../api/src/search.ts`], + ['a trailing dot segment', `${workspace}/api/src/./search.ts`], + ]; + + // Windows paths are case-insensitive, so these name the same file. On a + // case-sensitive file system they name different files and must not be folded. + if (process.platform === 'win32') { + spellings.push( + ['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`); + assert.equal(ask(path.split('/').join('\\')), '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'); +}); From c589d52ca1792c0f29b3a36c5da816bc6c963e29 Mon Sep 17 00:00:00 2001 From: coderloganli Date: Sat, 15 Aug 2026 20:48:15 -0400 Subject: [PATCH 2/4] Cut the architecture document back towards its own limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 456 lines, for a document that tells every task to read two documents of about 200. The rule was stated three times in a file that broke it. What went is duplication, not content. The reasoning behind six decisions was narrated here and recorded again in docs/adr/0002-0008, which is the drift the ADRs exist to prevent; each is now a sentence and a pointer. The tool table repeated what every tool already tells the client about itself, and §11 repeated product.md's list of non-goals — what stays there is the three exclusions that are architectural rather than product decisions. 456 to 338. Reaching 200 from here means dropping the stage table, the layout diagram or the refused-paths block, which are the parts most often read. Case 32 held two phrases this moved. The promise that nothing is deleted is the product's, so it is now asserted against product.md, where it is made. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011mjhmFa9c2dwxmNeH5tboy --- docs/architecture.md | 460 ++++++++++++++++-------------------------- test/tickets.test.mjs | 12 +- 2 files changed, 181 insertions(+), 291 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 702166e..fc0d31f 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: +`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. -**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. +Three consequences that do work later: -**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. - -**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. --- @@ -167,102 +149,56 @@ Three kinds, with three lifetimes. | **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. +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. **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. - -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. +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,174 +219,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. - -**The refusal hook not working.** The other direction, and stated because the -sentence above would otherwise be read as covering it. The hook fails open: if it -crashes, times out, writes something that is not valid 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 rather than as a refusal, and -`readTicket` returning null for an unparseable file is indistinguishable to the hook -from a project that never adopted the harness. - -That is the intended direction here, for the same reason the server's is the -opposite. The server failing closed stops a task; the hook failing closed would stop -a project — a single corrupt file in `tickets/` would make every repository in it -unwritable, including the documents the harness tells you to go and fix. And the -failure is not silent for long: a stage that will not advance is noticed within one -task. - -What it costs is worth naming plainly. **A refusal that fails open is not a -guarantee.** It is the same claim §2 makes about circumvention, arrived at from the -other side: the harness keeps an honest process honest, and a broken hook is not -distinguishable from an absent one. Test cases 49-52 hold the refusal itself, and 52 -holds this degradation, so it stays a decision with a case behind it. +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/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`); From dfa91b52f8103663a1196393a171139c74a140dd Mon Sep 17 00:00:00 2001 From: coderloganli Date: Sat, 15 Aug 2026 21:18:24 -0400 Subject: [PATCH 3/4] State the limit each document is actually held to, and hold it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six places said both top-level documents were about 200 lines. One of them was 456, and the only test enforcing a bound checked the other document — which is how it got there. The two bounds now differ because the jobs do: product design at 200, since what a product is for and will not do is prose that grows only by repeating itself; architecture at 350, because how a system is built has reference material — a layout, a stage table, the exact paths a rule names — that a reader returns to and that sentences cannot replace. Case 53 holds both, and holds each document to stating the number it is held to, since a limit enforced in one place and stated in another drifts apart silently. Verified it fails: ten lines of padding, and it names the file, the count and the bound. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011mjhmFa9c2dwxmNeH5tboy --- README.md | 7 ++++--- docs/architecture.md | 15 +++++++++++---- docs/product.md | 5 +++-- skills/init/SKILL.md | 6 ++++-- skills/init/templates/architecture.md | 2 +- test/conventions.test.mjs | 21 +++++++++++++++++++++ 6 files changed, 44 insertions(+), 12 deletions(-) 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 fc0d31f..7f7f054 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -148,10 +148,17 @@ 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. +**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. + +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. **ADR.** One decision per file: context, decision, reasoning. Written when the decision is made, not reconstructed afterwards, and edited in place when a decision 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')); From 6c150936834c1abd5771c0f3898f94dded4eeb23 Mon Sep 17 00:00:00 2001 From: coderloganli Date: Sun, 16 Aug 2026 10:25:58 -0400 Subject: [PATCH 4/4] Spell a path the way the platform spells it Case 50 asserted that a backslash-separated path is refused, which is true on Windows and wrong everywhere else: on Linux a backslash is an ordinary character in a filename, so that path names a different file and allowing it is correct. The case-insensitivity cases were already guarded; this one was not. Found by the ubuntu job on its first run against these tests, which is what the matrix is for. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011mjhmFa9c2dwxmNeH5tboy --- test/refusal.test.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/refusal.test.mjs b/test/refusal.test.mjs index e8b68f2..b42e6ea 100644 --- a/test/refusal.test.mjs +++ b/test/refusal.test.mjs @@ -65,15 +65,16 @@ test('50 — the refusal does not depend on how the path is spelled', () => { const target = `${workspace}/api/src/search.ts`; const spellings = [ - ['separators', target.split('/').join('\\')], ['dot segments', `${workspace}/api/../api/src/search.ts`], ['a trailing dot segment', `${workspace}/api/src/./search.ts`], ]; - // Windows paths are case-insensitive, so these name the same file. On a - // case-sensitive file system they name different files and must not be folded. + // 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())], @@ -96,7 +97,6 @@ test('51 — the documentation paths stay writable, however they are spelled', ( for (const path of writable) { assert.equal(ask(path), 'allow', `${path} must stay writable in every stage`); - assert.equal(ask(path.split('/').join('\\')), 'allow', `${path} must stay writable in every stage`); } // After the design is approved nothing is refused at all.