diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7d9bc1f..06f0a22 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -19,12 +19,11 @@ developer --types--> commands: /surface-plan /surface-execute /surface-status plan folder: journal.jsonl + documents git: branch, merge base, other branches install.py --copies--> host .claude/skills/surface-*/ and .claude/agents/surface-*.md -host CI --runs----> surface-status check --require conformant ``` - **Commands** are three skills the developer types. `/surface-plan` explores, interviews, has the plan drafted and writes it with its gates, has the blueprint drawn and cross-checked, then commits, pushes and opens a draft pull request. It then stays in the conversation and takes amendments there, as it does after a block during planning, since a reply that approves nothing needs no new launch. `/surface-execute` approves the drafted revision by being launched, then dispatches slices, gate runs, reviews and fixes. It puts a plan change proposal and the ceiling to the developer in the conversation: a refusal or a resumption goes on in the session, while an accepted change or an amendment goes to `/surface-plan`, since this command cannot load it and never writes the plan. `/surface-status` reports, runs the conformity check, and abandons a plan on confirmation. - **Agents** are four roles of the chain, and one agent built into Claude Code. The `Plan` agent drafts the plan, in the form it chooses: a good plan is what it already does well, so the chain ships no definition for it and holds the plan only to the minimum it reads, the numbered acceptance criteria, the slices behind their markers, each enough for an agent that starts fresh, and the `gates` block ([ADR 0035](docs/adr/0035-plan-drafted-by-the-built-in-plan-agent.md)). It has no write tool: it returns the plan, and `/surface-plan` writes `plan.md` as returned, but for two repairs it makes without a word to the developer, who never reads the plan: a return that arrived escaped, whose markers the script would not read, and a path of the machine. That agent cannot ask the developer anything: a rule a user of the feature would see applied, which neither the specs, an answer of the developer nor the code settles, it settles and marks as an assumption of the plan, and the extractor shows it on the blueprint as an assumption that approving the page confirms, never as settled. Planning gains no step for it: a question sent back would cost the developer a turn. The extractor draws `blueprint.md` from `plan.md`, as long as the feature needs, since the developer reads all of it. A fact is said once in prose on the whole page: the criteria state the rules, and the body shows what no criterion states, as behavior and not as code, since a page that tells a rule three times is a page the developer skims. So an aspect the plan changes is shown by a criterion or in the body, and the checker reads both. Neither the plan nor the blueprint says of its own how a commit is written or signed: a slice of the plan carries what the host's conventions or the developer ask, and the blueprint only what the developer asked, since a commit is written by another agent than the one that drafts the plan, after the blueprint is frozen, and a page that promised its message or its author would turn a commit written another way into a contract break. A blueprint has a fixed frame and a free body: it opens with the idea, the acceptance criteria and the scope, and closes with the sensitive zones, while the extractor cuts the body by what the developer decides separately, and keeps the cut of the previous revision, so that a revision is read against the one before. Inside a section too long to take in at a glance, a title in bold opens each thing the developer would come back for on its own, since a rule with no title over it is found only by reading the section through. No heading carries a number: a section is cited by its title, which an amendment does not move, where a number would shift and send a report to another section. Five aspects are gone through whatever the cut, the data schema, the boundaries, the sequences, the state machines and the algorithms, and one closing line names those the plan leaves alone, since silence must not read as "unchanged". A diagram is drawn where what it shows has a shape that prose flattens, with the existing elements the change attaches to. The checker looks for what the plan does and the blueprint does not show, never for a cut, a short section or a diagram that is not there. The executor carries out a slice or a fix. The reviewer judges the branch against the approved blueprint, writes `conformity.md` when it finds nothing, with the changed files of the critical zones, judges a break an executor suspects, and corrects that list of files when the script refuses it. -- **The state script** derives the state, refuses illegal steps, runs the gates, and answers the skills and the host's CI. +- **The state script** derives the state, refuses illegal steps, runs the gates, and answers the skills. - **A plan folder**, `docs/plans/-/` by default, holds the specs, the exploration, the interview, the plan, the blueprint, the reports (`checks/`, `reviews/`, `plan-changes/`, `gates/`), `conformity.md` and `journal.jsonl`. It is the whole state of a plan. - **The installer** copies the chain into the host, updates it, and checks it for drift. @@ -61,7 +60,8 @@ host CI --runs----> surface-status check --require conformant - Every command sets `disable-model-invocation: true`: launching `/surface-execute` is an approval, which only the developer gives, and no sentence of a conversation approves a revision. - Each step goes to a fresh agent that reads files, never a conversation, one agent at a time. - Prompts run every command from the repository root, with paths from there, since the dispatcher and its agents share one shell. They ban neither `cd` nor `git -C`, since a ban gave a session a breach to report to a developer who could do nothing with it, and say nothing else of how an agent writes a command, nor of the tool it changes a file with: a rule that general is not the chain's, the agent judges, and what stops for an approval is the permission mode's call ([ADR 0032](docs/adr/0032-permissions-left-to-the-mode-no-tests-on-the-readme.md)). -- The chain never marks a pull request ready: that triggers the host's CI, so the developer does it, when they want, once the plan is conformant. `conformity.md` is the audit trail the `conformant` event cites, never a required reading. +- The chain acts on nothing of the host's CI: it reads its configuration for the commands that check the project and their order, reads no trigger, asks nothing before a push, and gives the host no job to run, since each would bind the chain to what every host does its own way, and a check prescribed at a moment would decide when a finished plan may leave the plans in progress ([ADR 0037](docs/adr/0037-the-host-ci-left-to-the-host.md)). `check --require conformant` is the developer's command before merging, which a CI can call. +- The chain never marks a pull request ready: what the host runs then is the developer's to start, so the developer does it, when they want, once the plan is conformant. `conformity.md` is the audit trail the `conformant` event cites, never a required reading. - No test asserts the prose of the README or of these documents ([ADR 0032](docs/adr/0032-permissions-left-to-the-mode-no-tests-on-the-readme.md)). - No file holds an em dash, and no commit message carries an attribution. @@ -89,7 +89,7 @@ The developer reads the blueprint and not the code, except where a mistake would ### Command line contract -Exit code 0 means accepted or passed, 1 refused or failed, 2 a wrong call or an unreadable journal. With `--json`, each answer is one object with `"v": 1`; a field is added without a new version, and renamed or removed only with `"v": 2`. The skills and the host's CI depend on both, and `tests/fixtures/cli/` pins the shapes. +Exit code 0 means accepted or passed, 1 refused or failed, 2 a wrong call or an unreadable journal. With `--json`, each answer is one object with `"v": 1`; a field is added without a new version, and renamed or removed only with `"v": 2`. The skills depend on both, as does a host that calls the check from its CI, and `tests/fixtures/cli/` pins the shapes. ### Install and drift diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 62a7bb6..6ac9c46 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,7 +38,7 @@ Ownership is by namespace: the installer owns `.claude/skills/surface-*/` and `. ## State script -`surface-status` is the only writer of each plan's `journal.jsonl`. State is derived from the journal, never stored, and the script refuses any transition its table does not allow, and any event whose guard fails. Its subcommands (`show`, `resolve`, `record`, `gate`, `abandon`, `commits`, `pr-body`, `check`) serve the developer, the skills and the host project's CI; `--json` is for the skills. +`surface-status` is the only writer of each plan's `journal.jsonl`. State is derived from the journal, never stored, and the script refuses any transition its table does not allow, and any event whose guard fails. Its subcommands (`show`, `resolve`, `record`, `gate`, `abandon`, `commits`, `pr-body`, `check`) serve the developer and the skills, and a host may call `check` from its CI; `--json` is for the skills. ## Text rules diff --git a/README.md b/README.md index 045b3a7..2cf976a 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Two commands, and three moments when the loop hands back to you: the blueprint w ## Going further -- [Guide](docs/guide.md): every step in detail, permissions, the conformity check in your CI, settings, updates. +- [Guide](docs/guide.md): every step in detail, permissions, the conformity check, which your CI can call, settings, updates. - [ARCHITECTURE.md](ARCHITECTURE.md): how the chain is built, and its invariants. - [`docs/adr/`](docs/adr/README.md): the decisions whose history matters. - [CONTRIBUTING.md](CONTRIBUTING.md): to work on the chain itself. diff --git a/docs/adr/0037-the-host-ci-left-to-the-host.md b/docs/adr/0037-the-host-ci-left-to-the-host.md new file mode 100644 index 0000000..cf52b94 --- /dev/null +++ b/docs/adr/0037-the-host-ci-left-to-the-host.md @@ -0,0 +1,22 @@ +# 0037. The host's CI left to the host + +Status: accepted +Date: 2026-10-08 + +## Context + +The chain had grown three holds on the CI of the host. Before the first push of a branch, `/surface-plan` read what triggers the CI, warned the developer and waited for their agreement when a push or a new pull request would start a run. The guide gave a GitHub Actions workflow that ran the conformity check once the pull request was marked ready, with the depth its checkout needs. And the hand back at conformity said that marking the pull request ready "triggers your CI". + +Each bound the chain to what every host does its own way: a CI system, its triggers, its checkout. The warning had to read the configuration of any CI. The workflow was one host's shape, had never run on a hosted repository, and named an action to keep current. The sentence at conformity was false in a host with no CI. And a check prescribed at the moment a pull request is marked ready decided when a finished plan may leave the plans in progress: its folder had to stay on the branch, readable, until the merge. + +## Decision + +The chain acts on nothing of the host's CI. + +`/surface-plan` still reads the CI configuration for what it says of the project, the commands that check it and their order, as it reads a manifest. It reads no trigger, warns of nothing and asks nothing before a push. No prompt says what a push or a ready mark starts. The chain still never marks a pull request ready: what the host runs then is the developer's to start. + +`surface-status check --require conformant` stays, as a command the developer runs before merging. The guide says that a CI can call it, with its exit codes and the full history it needs, and gives no workflow. The script goes on answering in the detached checkout a CI makes, and refusing one too shallow to find where the branch left the main branch, which `tests/ci` holds. + +## Consequences + +In a host whose CI runs on every push, the first push of a plan starts a run nobody announced: that is a configuration of its CI, not a thing the chain guesses. Whether the conformity check runs in a CI, and at which moment, is the developer's call, and so is the moment a finished plan leaves the plans in progress. A plan whose developer declined its first push, when the chain still asked, is pushed by the next launch that finishes its draft: the answer kept in its `interview.md` is no longer read. The end to end tests keep one project with a CI, to hold that planning pushes there without a question, and nothing of the chain is left to exercise on a hosted CI. diff --git a/docs/adr/README.md b/docs/adr/README.md index bc7e646..ab0e224 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -16,3 +16,4 @@ The current architecture is described in [ARCHITECTURE.md](../../ARCHITECTURE.md | 0034 | [Gates named by the plan, run by the script](0034-gates-named-by-the-plan-run-by-the-script.md) | | 0035 | [The plan is drafted by Claude Code's built-in Plan agent, held to the minimum the chain reads](0035-plan-drafted-by-the-built-in-plan-agent.md) | | 0036 | [Evaluations on real sessions, with a model as the developer and a judge that is checked](0036-evaluations-on-real-sessions.md) | +| 0037 | [The host's CI left to the host](0037-the-host-ci-left-to-the-host.md) | diff --git a/docs/guide.md b/docs/guide.md index 4d575b6..a959074 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -24,7 +24,6 @@ In a Claude Code session of your project, run `/surface-plan` with a short descr - It explores the code the feature touches, then asks you its questions one at a time. Each comes with its options and the one it would pick. Your answers are written down as you give them, and it never invents a business rule to fill a gap. A rule a user of the feature would see applied, an order, a number, a limit, is yours to decide: it asks unless your specs or your code already settle it, and settles alone only what is a matter of implementation. - It finds the commands that check your project, tests, lint, type checks, where your project states them, and writes them in the plan as its gates. It asks you only if it finds none. - It has a detailed plan drafted by the `Plan` agent built into Claude Code, then the blueprint drawn from it and cross-checked, so that the blueprint hides nothing the plan does. -- Before the first push, if your CI reacts to a push or to a new PR, it warns you and waits for your agreement. - It commits, pushes, opens a draft PR that links the blueprint and the plan, and tells you where to read the blueprint and which gates the loop will run. Everything lands in one folder per plan, `docs/plans/-/` by default. @@ -81,34 +80,15 @@ Conformant means the last review proved every acceptance criterion of the bluepr The PR description lists, one line each and for information, the decisions the agents took within the contract that you did not see go by: the plan amendments that keep the blueprint true, and the suspected breaks a reviewer dismissed. It lists nothing when there is nothing to list. -When you want, you mark the PR ready for review, which triggers your CI, and you merge. The chain never does it for you. The conformity check belongs in your CI when the PR is marked ready, and in your hands before merging: +When you want, you mark the PR ready for review, and you merge. The chain never does it for you. Before merging, the conformity check is in your hands: ```sh .claude/skills/surface-status/scripts/surface-status check --require conformant ``` -It fails as long as a plan of the branch is neither conformant nor abandoned before its approval, so keep it out of the gates your plans name. - -In a GitHub Actions workflow, the check needs the full history of the repository to find where your branch left the main branch. A checkout at depth 1, the default, makes it exit 2 and say so: - -```yaml -on: - pull_request: - types: [opened, synchronize, reopened, ready_for_review] - -jobs: - conformity: - # A draft pull request waits until it is marked ready. - if: ${{ !github.event.pull_request.draft }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - run: .claude/skills/surface-status/scripts/surface-status check --require conformant -``` +It fails as long as a plan of the branch is neither conformant nor abandoned before its approval, so keep it out of the gates your plans name. Exit code 0 means every plan of the branch is conformant, 1 that one is not, 2 that the check could not run. -Exit code 0 means every plan of the branch is conformant, 1 that one is not, 2 that the check could not run. +Your CI can call it too: whether it does, and when, is yours to decide. It needs the full history of the repository, main branch included, to find where your branch left it: in a checkout that is shallow, or that holds your branch alone, it exits with 2. ### At any time: `/surface-status` diff --git a/skills/surface-execute/SKILL.md b/skills/surface-execute/SKILL.md index 1284820..7667cee 100644 --- a/skills/surface-execute/SKILL.md +++ b/skills/surface-execute/SKILL.md @@ -127,7 +127,7 @@ The fixer runs the full gates once at its end. Green, it records `fix-done` and ## Conformity -The last review found nothing and nothing changed since: the reviewer wrote `conformity.md`. `surface-status record conformant --conformity conformity.md`, then commit the journal, with `conformity.md` when its content is not committed yet. Stop, as "When the loop stops" says, its push and its refresh first, then hand back in one line, which is all you say at this stop. It says, in this order: that the plan is conformant; when the branch changed files inside the critical zones the project declares, those files by name, as the code left for the developer to read themselves; and last, the developer's step: the developer marks the pull request ready when they want, which triggers their CI. The files are the `path` of each item of `critical_files`, in the entry of `plans` whose `name` is the name of this plan's folder, in the answer of `surface-status pr-body --json`, asked after the record. An item whose `link` is null is a file the branch removed: the line says so. When that list is empty, the line says nothing of the critical zones. +The last review found nothing and nothing changed since: the reviewer wrote `conformity.md`. `surface-status record conformant --conformity conformity.md`, then commit the journal, with `conformity.md` when its content is not committed yet. Stop, as "When the loop stops" says, its push and its refresh first, then hand back in one line, which is all you say at this stop. It says, in this order: that the plan is conformant; when the branch changed files inside the critical zones the project declares, those files by name, as the code left for the developer to read themselves; and last, the developer's step: the developer marks the pull request ready when they want. The files are the `path` of each item of `critical_files`, in the entry of `plans` whose `name` is the name of this plan's folder, in the answer of `surface-status pr-body --json`, asked after the record. An item whose `link` is null is a file the branch removed: the line says so. When that list is empty, the line says nothing of the critical zones. One line means a sentence or two, with no list and no heading, and nothing before it or after it: no report of what was done, no count of slices, of reviews or of passes, no path of a report, no remark on what was left untouched. It takes the place of what "When the loop stops" has you say in the terminal. The description of the pull request holds what was done, and the line ends on the developer's step. @@ -159,4 +159,4 @@ At every stop, once a plan was found: 1. Say in the terminal why the loop stopped and whose turn it is. On `conformant`, the line "Conformity" gives is all you say, and it comes last, after the push and the refresh: one of them that could not be done is not said apart, the step the line ends on tells it. 2. Push the branch to its upstream, which `/surface-plan` set at the first draft. Without an upstream, push nothing and say so. 3. Refresh the pull request's description: the output of `surface-status pr-body` (it takes no argument, since it describes every plan of the branch), given to `gh pr edit --body-file -` on its standard input. Without a pull request or without `gh`, say so in one line. Nothing else is written on the pull request. -4. Never mark the pull request ready, since that triggers the CI: on `conformant`, the developer does, when they want, or opens it first when the refresh reached none. +4. Never mark the pull request ready, since what the project runs on one that is ready is the developer's to start: on `conformant`, the developer does, when they want, or opens it first when the refresh reached none. diff --git a/skills/surface-plan/SKILL.md b/skills/surface-plan/SKILL.md index b7f1d46..0e14daa 100644 --- a/skills/surface-plan/SKILL.md +++ b/skills/surface-plan/SKILL.md @@ -46,7 +46,7 @@ If that answer is missing or is not JSON, the chain is not installed or `python3 | none yet (`state` is null) | Record `plan-opened` (step 3), then step 4. | | `interview` | If `exploration.md` is missing or unfinished, step 4. Else resume at the first question of `interview.md` without an answer, or ask the next open point. Never ask again a question that has an answer. | | `drafting` | Resume at the missing step: see "Drafting, the missing step". | -| `awaiting-approval` | First finish step 10 if it did not end (plan files uncommitted, branch never pushed, no pull request), unless `interview.md` records that the developer declined the push. Then take the amendment given (see "Taking an amendment"); without one, step 11. | +| `awaiting-approval` | First finish step 10 if it did not end (plan files uncommitted, branch never pushed, no pull request). Then take the amendment given (see "Taking an amendment"); without one, step 11. | | `plan-change-proposed` | See "A plan change proposal". | | `blocked` | See "After a block". | | `executing`, `reviewing`, `fixing` | This command does not see this plan: it is the agents' turn. See "Not your turn". | @@ -67,7 +67,7 @@ The conversation is the developer's, the plan folder is the repository's. ## First launch, with specs -1. Conventions. Read what the repository says about itself: its agent instructions, README, contributing guide, domain document if it keeps one, decision records, CI configuration and any script that replays the CI locally. Establish the business concepts, the architecture and its boundaries, the generated artifacts never edited by hand, the gates in the order they run, the conventions for branches, commits and pull requests, where decisions are recorded, and what triggers the CI. +1. Conventions. Read what the repository says about itself: its agent instructions, README, contributing guide, domain document if it keeps one, decision records, CI configuration and any script that replays the CI locally. Establish the business concepts, the architecture and its boundaries, the generated artifacts never edited by hand, the gates in the order they run, the conventions for branches, commits and pull requests, and where decisions are recorded. - The documents' language (see "Languages"), in this order: the first that answers wins. A language the agent instructions (`AGENTS.md`, `CLAUDE.md`) declare for what the repository holds. Otherwise the language of the repository's own documentation, its README and the plans it already holds. Otherwise the language the specs are written in. 2. Branch. The main branch is `origin/HEAD`, else `main`, else `master`, as the state script finds it. Launched from the main branch, create a branch named after the repository's practice, as below, and switch to it. From any other branch, stay: the plan opens there, and a branch may carry several plans. A new branch comes only from the main branch. - Find the practice in this order, and stop at the first that answers. A convention written in what step 1 read (agent instructions, contributing guide) wins. Otherwise the head branches of past pull requests, which outlive the branches deleted after merge: the developer's own first, `gh pr list --state all --author @me --limit 50 --json headRefName`, then the team's, `gh pr list --state all --limit 50 --json headRefName,author`, leaving out the branches of bots. Without `gh`, or when it finds no pull request, the remote branches, `git branch -r`, and the subjects of merge commits, `git log --merges --format=%s -n 50`, which name the branch they merged. @@ -149,7 +149,6 @@ The developer amends a plan only when it is their turn: awaiting approval, or bl ## Commit, push and pull request - Commit only the files of the plan folder, by path, following the repository's conventions. Each journal line goes in the commit of the files it describes. Never other changes of the working tree. -- Before the first push of the branch (it has no upstream yet), read the CI configuration. If its triggers react to a push of this branch or to the opening of a pull request, draft included, warn the developer, say what would run, and wait for their agreement. Write their answer into `interview.md` under "Git", so a relaunch does not ask again. Declined: push nothing, say the plan is committed locally, and go to step 11. - Push the branch, setting its upstream. - The pull request opens as a draft at the first `plan-drafted`. Its description is the text `surface-status pr-body` prints, and nothing else (it takes no argument, since it describes every plan of the branch): the output of the script is given to `gh` on its standard input, so that no text of yours stands between the two. No pull request yet: `gh pr create --draft --body-file -`, with a title after the repository's conventions. One exists: `gh pr edit --body-file -`. Refresh it the same way at the end of every launch that pushes. - Nothing else is written on the pull request, and you never mark it ready for review. Its comments are read only when the developer asks for it explicitly. diff --git a/skills/surface-plan/templates/exploration.md b/skills/surface-plan/templates/exploration.md index 35bcd80..fcae1a7 100644 --- a/skills/surface-plan/templates/exploration.md +++ b/skills/surface-plan/templates/exploration.md @@ -10,7 +10,6 @@ What the exploration of the repository established, so that a relaunched session - Gates: every check a change must pass, in the order they run, the command of each and where it was found, and the command that runs them all if there is one. Then the gates of the plan: the commands its `gates` block will hold, in their order, or that none was found and the interview asks. - Conventions: branches, commits, pull requests, reviews. - Decisions: where they are recorded, and in what form. -- CI: what triggers it (a push, a pull request opened, marked ready for review, a merge). - Language: the language of the plan documents, and where it was found: a language the agent instructions declare, else that of the repository's documentation, else that of the specs. Every document of the plan folder is written in it, and every agent reads it here. If the repository states none of this, say so, and the minimum the plan will hold to. diff --git a/skills/surface-plan/templates/interview.md b/skills/surface-plan/templates/interview.md index aefe42f..167f58a 100644 --- a/skills/surface-plan/templates/interview.md +++ b/skills/surface-plan/templates/interview.md @@ -37,5 +37,3 @@ Answer (): ## Git : - -: diff --git a/skills/surface-status/SKILL.md b/skills/surface-status/SKILL.md index be2cfa4..240424e 100644 --- a/skills/surface-status/SKILL.md +++ b/skills/surface-status/SKILL.md @@ -10,7 +10,7 @@ allowed-tools: Bash(.claude/skills/surface-status/scripts/surface-status *) Bash You tell the developer where the plans stand, from the state script alone, and you record one act of theirs: the abandonment of a plan, once they have confirmed it. Every other act belongs to `/surface-plan` or `/surface-execute`: name the one to run, and record nothing else. -The state script is `.claude/skills/surface-status/scripts/surface-status`, run from the root of the repository; below it is written `surface-status`, and `` is the plan folder. It is the only writer of the journal, and the same script the project's CI calls. Exit code 0 is accepted, 1 refused or check failed with the reason, 2 a usage error. The state lives in files: read it from the script at each step, never from memory of this conversation. +The state script is `.claude/skills/surface-status/scripts/surface-status`, run from the root of the repository; below it is written `surface-status`, and `` is the plan folder. It is the only writer of the journal. Exit code 0 is accepted, 1 refused or check failed with the reason, 2 a usage error. The state lives in files: read it from the script at each step, never from memory of this conversation. Every command runs from the root of the repository, with paths from there, since one shell serves the whole session. diff --git a/tests/e2e/README.md b/tests/e2e/README.md index cb9729f..3d648af 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -24,7 +24,6 @@ The states, from the need to the review: | `planning-ceiling` | specs that ask to rewrite the shelf file once the CSV is printed, a plan that does it, a blueprint that says the file is never written, and two cross-checks that count that omission, `max_autonomous_passes: 1`. Nothing committed | | `awaiting-approval` | the branch `feat/csv-export`, a plan drafted at revision 1 (two slices), pushed, and its draft pull request, open in the stand-in `gh` | | `unpushed` | that plan drafted and committed, the branch never pushed, no pull request | -| `unpushed-ci` | the same, under the CI of `specs-ci`, which `exploration.md` names | | `two-plans` | a second plan, `-count-books`, drafted on the same branch: both await their approval | | `slice-uncommitted` | revision 1 approved, the work of slice 1 written and `slice-done` recorded, neither committed | | `suspected-break` | a project whose `export` command exists before the feature and prints the shelf as JSON for a backup, which the plan and the blueprint did not see. Slice 1 done, then `break-suspected` recorded for slice 2 with that reason, and the test its executor had begun, neither committed | @@ -62,7 +61,7 @@ It answers the calls the prompts make, as `gh` 2.78.0 does when its output is no What it keeps of a project lives in its git directory, `.git/gh-stand-in/`: `pulls.json`, the pull requests, and `calls.jsonl`, one line per call with its arguments, its standard input when the call reads it, the description it gave, its exit code and what it printed. The git directory, and not a folder next to the bare remote, for three reasons. Every repository has one, with or without a remote, so a call refused for want of a remote is recorded too. It belongs to one project, where several run at the same time in one container. And it is out of the work tree: nothing shows in `git status`, nothing can be committed, and a session that explores the code does not meet it. A `gh pr ready` that is not an `--undo` has `marks_ready` true in its line, whatever became of it: the chain must never make one. A call reads its standard input before it holds that folder, so one `gh` may feed another. `gh_stand_in.calls(project)` and `gh_stand_in.pulls(project)` read both. -A prepared state with a drafted plan holds its draft pull request, as `/surface-plan` leaves it at the first `plan-drafted`: `toy.py` opens it in the stand-in, with the description `surface-status pr-body` prints then, and no call is recorded for it. Without it, `/surface-execute` would find no pull request at any stop and take the path of a branch that has none, the only one the runs took before the stand-in. A state past the approval keeps that first description, since the loop it prepares has not stopped yet, and a stop is what refreshes it. The states never pushed, `unpushed` and `unpushed-ci`, have none: the push and the opening are what their scenarios wait for. +A prepared state with a drafted plan holds its draft pull request, as `/surface-plan` leaves it at the first `plan-drafted`: `toy.py` opens it in the stand-in, with the description `surface-status pr-body` prints then, and no call is recorded for it. Without it, `/surface-execute` would find no pull request at any stop and take the path of a branch that has none, the only one the runs took before the stand-in. A state past the approval keeps that first description, since the loop it prepares has not stopped yet, and a stop is what refreshes it. The state never pushed, `unpushed`, has none: the push and the opening are what their scenarios wait for. What the stand-in does not imitate: @@ -112,8 +111,7 @@ A scenario kills a session at a moment it reads in the files of the project or i | `test_planning_killed_in_the_interview_resumes_at_the_next_question` | `specs` | `/surface-plan` with the need; the session that takes the first answer of the interview killed once `interview.md` holds it; then `/surface-plan` alone, and the recommendation at every question | the journal of the killed session kept as is, `exploration.md` untouched, no question that had its answer written a second time, one `plan-drafted` with the gate of the toy, the plan committed and pushed, and one draft pull request with the description `surface-status pr-body` prints | | `test_an_amendment_killed_once_recorded_is_drafted_at_the_relaunch` | `awaiting-approval` | `/surface-plan` with an amendment, killed once the journal holds `amendment-received`, then `/surface-plan` alone | the amendment in `interview.md` at the kill, one `amendment-received`, revision 2 of the plan and of the blueprint with the header in its new order, pushed, the description refreshed and no second opening | | `test_a_conversation_after_the_hand_over_amends_and_approves_nothing` | `awaiting-approval` | `/surface-plan`, then in its conversation a question, an amendment and "Fine, go." | the question records nothing. The amendment is in `interview.md` and in the journal, revision 2 is drafted and pushed. The sentence that agrees records nothing, and the session names `/surface-execute` | -| `test_a_ci_that_runs_on_a_push_makes_planning_wait_for_an_agreement` | `specs-ci` | `/surface-plan` with the need and the recommendation at every question, until the plan is drafted; then "Yes, push it and open the draft." | once the plan is drafted, the bare remote holds the main branch alone and no `gh pr create` was called. After the agreement: the plan pushed, one draft pull request, the answer in `interview.md`, the plan folder committed, and the description `surface-status pr-body` prints | -| `test_a_push_the_developer_declines_leaves_the_plan_committed_locally` | `unpushed-ci` | `/surface-plan`, then "No, do not push: this plan stays on my machine for now." | nothing recorded, the bare remote holds the main branch alone before the answer and after it, no `gh pr create`, the answer in `interview.md`, and the final message says the plan is local | +| `test_a_ci_that_runs_on_a_push_makes_planning_wait_for_nothing` | `specs-ci` | `/surface-plan` with the need and the recommendation at every question, until the plan is drafted | at the end of the session that drafts the plan, with no reply in between: the plan pushed, one draft pull request with the description `surface-status pr-body` prints, the plan folder committed. What the CI runs on a push is the host's, and the chain asks nothing about it | ### The developer's turn in the loop diff --git a/tests/e2e/test_toy.py b/tests/e2e/test_toy.py index d72f836..9a87237 100644 --- a/tests/e2e/test_toy.py +++ b/tests/e2e/test_toy.py @@ -240,7 +240,6 @@ def kill_when( (toy.State.PLANNING_CEILING, "drafting"), (toy.State.AWAITING, "awaiting-approval"), (toy.State.UNPUSHED, "awaiting-approval"), - (toy.State.UNPUSHED_CI, "awaiting-approval"), (toy.State.TWO_PLANS, "awaiting-approval"), (toy.State.SLICE_UNCOMMITTED, "executing"), (toy.State.SUSPECTED_BREAK, "executing"), @@ -648,7 +647,7 @@ def test_an_abandon_waits_for_a_yes_then_fails_the_conformity_check(tmp_path: Pa assert pushed(project) described(project) # The plan was approved: the branch may carry code never declared conformant, and the - # check of the host's CI fails on it from then on. + # conformity check fails on it from then on. assert toy.check(project) == (1, ["abandoned-after-approval"]) @@ -670,54 +669,31 @@ def test_two_plans_on_a_branch_are_put_to_the_developer(command: str, tmp_path: assert toy.SECOND_SLUG in final(log) -def test_a_ci_that_runs_on_a_push_makes_planning_wait_for_an_agreement(tmp_path: Path) -> None: +def test_a_ci_that_runs_on_a_push_makes_planning_wait_for_nothing(tmp_path: Path) -> None: project = toy.build(toy.State.SPECS_CI, tmp_path) - logs = tmp_path / "logs" - session = converse( + converse( project, f"/surface-plan {NEED}", - logs, + tmp_path / "logs", "need", until=recorded(project, "plan-drafted"), ) - # The CI of the toy runs on every push and on every pull request: the plan is drafted, and - # nothing of it has left the machine before the developer agrees. - assert on_the_remote(project) == {"main"} - assert not openings(project) - agreement = "Yes, push it and open the draft." - say(project, agreement, logs / "agreement.jsonl", resume=session) + # The CI of the toy runs on every push and on every pull request, and what it runs is the + # host's (ADR 0037): the session that drafts the plan pushes it and opens its draft, with + # no question between the two, which a reply would have had to answer in a session more. assert state(project) == "awaiting-approval" assert pushed(project) assert len(gh_stand_in.pulls(project)) == 1 - # The answer is kept, so that a relaunch does not ask again. - assert told(project, agreement.rstrip(".")) assert not dirty(project, "docs") described(project) -def test_a_push_the_developer_declines_leaves_the_plan_committed_locally(tmp_path: Path) -> None: - project = toy.build(toy.State.UNPUSHED_CI, tmp_path) - logs = tmp_path / "logs" - before = events(project) - session = say(project, "/surface-plan", logs / "relaunch.jsonl") - # The first push of the branch is still to come, and the CI runs on it: the session asks. - assert on_the_remote(project) == {"main"} - assert not openings(project) - refusal = logs / "refusal.jsonl" - say(project, "No, do not push: this plan stays on my machine for now.", refusal, resume=session) - assert events(project) == before - assert on_the_remote(project) == {"main"} - assert not openings(project) - assert told(project, "do not push") - assert "local" in final(refusal).lower() - - def test_a_draft_committed_and_never_pushed_is_pushed_at_the_relaunch(tmp_path: Path) -> None: project = toy.build(toy.State.UNPUSHED, tmp_path) before = events(project) assert run_to_end(project, "/surface-plan", tmp_path / "logs" / "relaunch.jsonl") == 0 - # The session that drafted the plan died between its commit and its push: the toy has no - # CI to warn about, so this one pushes and opens the draft without a question. + # The session that drafted the plan died between its commit and its push: this one pushes + # and opens the draft, and asks nothing first. assert events(project) == before assert pushed(project) assert len(gh_stand_in.pulls(project)) == 1 diff --git a/tests/e2e/toy/toy.py b/tests/e2e/toy/toy.py index 986a6a6..6a27b31 100644 --- a/tests/e2e/toy/toy.py +++ b/tests/e2e/toy/toy.py @@ -77,7 +77,6 @@ class State(StrEnum): PLANNING_CEILING = "planning-ceiling" # two cross-checks with an omission, a ceiling of one AWAITING = "awaiting-approval" # a plan drafted at revision 1, pushed, its draft opened UNPUSHED = "unpushed" # that plan drafted and committed, never pushed: no draft opened - UNPUSHED_CI = "unpushed-ci" # the same, under the CI that runs on every push TWO_PLANS = "two-plans" # a second plan drafted on the same branch SLICE_UNCOMMITTED = "slice-uncommitted" # approved, slice 1 recorded and not committed SUSPECTED_BREAK = "suspected-break" # slice 2 stopped on a break its executor suspects @@ -239,7 +238,7 @@ def test_prints_one_book_per_line(self) -> None: """ # A CI that reacts to a push of any branch and to a pull request, a draft included: what -# /surface-plan warns about before the first push of a branch. +# /surface-plan acts on in no way, and asks nothing about before a push (ADR 0037). CI_PATH = ".github/workflows/ci.yml" CI = """\ name: ci @@ -297,7 +296,6 @@ def test_the_shelf_takes_its_time(self) -> None: - Conventions: branches `feat/`; commits `: `, imperative. No pull request convention stated. - Decisions: not recorded anywhere. -- CI: none. ## What the feature touches @@ -485,12 +483,6 @@ def test_the_shelf_takes_its_time(self) -> None: "blueprint.md": BLUEPRINT, } -# Under the CI of `CI`, the exploration names it: a session reads there what triggers it. -EXPLORATION_CI = EXPLORATION.replace( - "- CI: none.", - f"- CI: `{CI_PATH}` runs the gate on every push, of any branch, and on every pull\n" - " request, a draft included.", -) # The plan of the planning ceiling: the specs ask for an effect on the developer's file, the plan # does it, and the blueprint, left as it is, says the file is never written. @@ -1084,7 +1076,7 @@ def critical_files(project: Path) -> list[str]: def check(project: Path) -> tuple[int, list[str]]: - """Run the conformity check of the host's CI: its exit code, and the codes of what fails it.""" + """Run the conformity check: its exit code, and the codes of what fails it.""" done = subprocess.run( [str(project / STATE_SCRIPT), "--json", "check", "--require", "conformant"], cwd=project, @@ -1103,7 +1095,8 @@ def check(project: Path) -> tuple[int, list[str]]: def _base(dest: Path, settings: Mapping[str, object], more: Mapping[str, str]) -> Path: """Make the project on its main branch, pushed, with the chain installed and committed. - `more` holds the files a state adds to the project before the feature: a CI, a slow test. + `more` holds the files a state adds to the project before the feature: a CI, a slow test, a + command the feature replaces. """ project = dest / "shelf" remote = dest / "origin.git" @@ -1203,14 +1196,12 @@ def _drafted(project: Path, state: State) -> str: An unpushed state stops before the push, and so has no pull request. """ git(project, "switch", "--quiet", "--create", BRANCH) - under_ci = state is State.UNPUSHED_CI - documents = {**DOCUMENTS, "exploration.md": EXPLORATION_CI} if under_ci else DOCUMENTS - plan = _open(project, SLUG, documents) + plan = _open(project, SLUG, DOCUMENTS) _draft(project, plan, CHECK, "plan: export the shelf as CSV, revision 1") if state is State.TWO_PLANS: second = _open(project, SECOND_SLUG, SECOND_DOCUMENTS) _draft(project, second, SECOND_CHECK, "plan: count the books of a shelf, revision 1") - if state not in {State.UNPUSHED, State.UNPUSHED_CI}: + if state is not State.UNPUSHED: git(project, "push", "--quiet", "--set-upstream", "origin", BRANCH) gh_stand_in.open_pull( project, head=BRANCH, base="main", title=PULL_TITLE, body=pr_body(project), draft=True @@ -1357,7 +1348,7 @@ def _conformant_left_unpushed(project: Path, plan: str) -> None: _ONE_PASS = frozenset({State.PLANNING_CEILING, State.CEILING, State.BLOCKED}) _WITH_THE_DEFECT = frozenset({State.FIXING, State.CEILING, State.BLOCKED, State.DEFECT}) _UNDRAFTED = frozenset({State.PLAN_WRITTEN, State.BLUEPRINT_DRAWN, State.PLANNING_CEILING}) -_DRAFTED = frozenset({State.AWAITING, State.UNPUSHED, State.UNPUSHED_CI, State.TWO_PLANS}) +_DRAFTED = frozenset({State.AWAITING, State.UNPUSHED, State.TWO_PLANS}) def _executed(project: Path, plan: str, state: State) -> None: @@ -1392,7 +1383,7 @@ def _executed(project: Path, plan: str, state: State) -> None: def _more(state: State) -> dict[str, str]: """Give the files a state adds to the project before the feature.""" - if state in {State.SPECS_CI, State.UNPUSHED_CI}: + if state is State.SPECS_CI: return {CI_PATH: CI} if state is State.SUSPECTED_BREAK: return { diff --git a/tests/prompts/chain_contract.py b/tests/prompts/chain_contract.py index b9558cb..1e4ee3c 100644 --- a/tests/prompts/chain_contract.py +++ b/tests/prompts/chain_contract.py @@ -20,10 +20,11 @@ # step, last. No report of what was done: the description of the pull request holds it, and a # report in the terminal buried the step. A criterion the reviewer cannot prove is a finding, so a # conformant plan leaves nothing to check: `conformity.md` is kept, never a required reading. The -# chain never marks the pull request ready, since that triggers the host's CI. +# chain never marks the pull request ready, since what the host runs then is the developer's to +# start. CONFORMANT_HAND_BACK = "hand back in one line, which is all you say at this stop" CONFORMANT_STEP = "the developer marks the pull request ready when they want" -# A branch may have no pull request: no `gh`, no remote, or a push the developer declined. The +# A branch may have no pull request: no `gh`, or no remote. The # line then names the step that fits, since nobody marks ready a pull request that does not exist. CONFORMANT_WITHOUT_PULL_REQUEST = ( "the developer opens the pull request, if none is open yet, with the description" diff --git a/tests/prompts/test_execute_and_status_skills.py b/tests/prompts/test_execute_and_status_skills.py index 6727980..1f8aa34 100644 --- a/tests/prompts/test_execute_and_status_skills.py +++ b/tests/prompts/test_execute_and_status_skills.py @@ -544,7 +544,7 @@ def test_conformant_hands_back_in_one_line_and_leaves_the_ready_mark_to_the_deve [ "It says, in this order: that the plan is conformant", "those files by name, as the code left for the developer to read themselves", - f"and last, the developer's step: {CONFORMANT_STEP}, which triggers their CI", + f"and last, the developer's step: {CONFORMANT_STEP}. The files are", ], ) assert conformant < files < step @@ -561,7 +561,10 @@ def test_conformant_hands_back_in_one_line_and_leaves_the_ready_mark_to_the_deve assert "one it cannot prove is a finding" in conformity assert "nothing asks the developer to read it" in conformity stopping = section(body, "When the loop stops") - assert "Never mark the pull request ready, since that triggers the CI" in stopping + # What the project runs on a pull request that is ready is the developer's to start, and + # the line says nothing of a CI, which a host may not have (ADR 0037). + assert "Never mark the pull request ready, since what the project runs on one" in stopping + assert not re.search(r"\bCI\b", conformity) def test_the_hand_back_at_conformity_is_its_line_and_nothing_else() -> None: diff --git a/tests/prompts/test_surface_plan.py b/tests/prompts/test_surface_plan.py index 90c1cb6..6113588 100644 --- a/tests/prompts/test_surface_plan.py +++ b/tests/prompts/test_surface_plan.py @@ -710,7 +710,7 @@ def test_the_blueprint_template_says_which_critical_zones_the_plan_touches() -> assert zones.index(said) < zones.index(CLOSING_LINE) -# Git, pull request and CI. +# Git and pull request. def _git_steps() -> list[str]: @@ -718,16 +718,18 @@ def _git_steps() -> list[str]: return [line for line in part.splitlines() if line.startswith("- ")] -def test_the_ci_triggers_are_read_before_the_first_push() -> None: +def test_the_push_waits_for_nothing_of_the_ci() -> None: + # What a host's CI does on a push is the host's: the command reads no trigger, and asks + # nothing before the first push (ADR 0037). The CI is still read in step 1, for the gates. steps = _git_steps() - check = next(i for i, step in enumerate(steps) if step.startswith("- Before the first push")) + commit = next(i for i, step in enumerate(steps) if step.startswith("- Commit only")) push = next(i for i, step in enumerate(steps) if step.startswith("- Push the branch")) - assert check < push - warning = steps[check] - assert "read the CI configuration" in warning - assert "react to a push of this branch or to the opening of a pull request" in warning - assert "wait for their agreement" in warning - assert 'under "Git", so a relaunch does not ask again' in warning + assert push == commit + 1 + assert not re.search(r"\bCI\b", section(_body(), "Commit, push and pull request")) + for template in ("exploration.md", "interview.md"): + assert not re.search(r"\bCI\b", _template(template)) + for text in (_body(), *(_template(path.name) for path in TEMPLATES.iterdir())): + assert "trigger" not in text.lower() def test_the_draft_pull_request_opens_at_the_first_plan_drafted() -> None: