diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 3befc7a..295c2ad 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -16,6 +16,30 @@ permissions: contents: read jobs: + # The pre-commit and pre-push hooks already scan for secrets, but only on the + # machines where `bun run prepare` succeeded, and `--no-verify` is one flag + # away. This is the copy that cannot be skipped. It is its own job so it does + # not queue behind `bun install`, and it needs the full history: a secret is + # in the branch whether it arrived in the last commit or the first. + secrets: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # The binary rather than gitleaks-action: the action wants a licence key + # for organisation-owned repositories, and pinning the version keeps CI + # and the hooks finding the same things. + - name: Install gitleaks + env: + GITLEAKS_VERSION: 8.30.1 + run: | + curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + | sudo tar -xz -C /usr/local/bin gitleaks + + - run: gitleaks git --no-banner --redact . + quality: runs-on: ubuntu-latest steps: @@ -27,13 +51,29 @@ jobs: - run: bun run lint + - run: bun run knip + + # Known advisories against the installed tree. Every package this can + # flag today is a BUILD-time dependency: `codesema` ships with no runtime + # dependencies at all, so nothing here reaches a user's machine by being + # installed. It still matters, because these packages execute during + # `bun run build` and that build produces the `dist` we publish. + # + # Two transitive packages are pinned past their advisories through the + # `overrides` block in the root package.json (postcss, and the nanoid it + # pulls). Their parents — vite, vue, @vue/compiler-sfc — still declare + # ranges that resolve to the vulnerable versions, so removing the override + # silently reintroduces both. Drop an entry only once its parent has moved + # on, and confirm with `bun run audit` rather than by reading the range. + - run: bun run audit + - run: bun run format:check - run: bun run typecheck - run: bun run build - - run: bun run test + - run: bun run test:coverage - run: bunx publint packages/cli diff --git a/.gitignore b/.gitignore index b7cc7b3..7e34147 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ dist/ web-dist/ .codesema/ *.log + +# only produced when the coverage run is asked for a file reporter (lcov); +# the default text reporter of scripts/coverage-gate.mjs writes nothing to disk +coverage/ .DS_Store .env .env.* diff --git a/.prettierignore b/.prettierignore index f72b2c3..dc2d4b4 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,8 @@ web-dist coverage bun.lock CHANGELOG.md + +# Committed copies of the brain repo's own generated JSON Schemas +# (packages/contract/scripts/sync-brain-schemas.mjs). Left in the brain's own +# export format so a sync is a plain copy, never a copy plus a reformat. +packages/contract/fixtures/cerveau-schemas diff --git a/CHANGELOG.md b/CHANGELOG.md index d1a777d..4f462be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,109 @@ All notable changes to `codesema` (the npm package in `packages/cli`) are documented here. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [SemVer](https://semver.org). +## [0.15.0] - 2026-08-26 + +### Added + +- **The review loop now judges on evidence, not vibes: acceptance criteria carry their proof method.** Every criterion drafted by the brain-mode agent ends with a `[proof: ]` tag, one of `command` (a shell command whose exit code settles it), `diff` (a file the diff must touch), `read` (content the worktree must contain) or `judgment` (the reviewer decides). The gate settles the first three mechanically, without an LLM in the loop, and only `judgment` criteria reach the reviewer; `lintTicketBody` refuses a bras-drafted ticket whose criteria are missing their method, while existing tickets stay valid unchanged. This closes the failure mode where an unprovable criterion stayed "unclear" for fifteen turns straight. + +- **Check results are evidence the reviewer and the fixer both receive.** The typed outcome of the task's checks (command, status, tail of a failing run) is folded into the review prompt as its own chapter, with the instruction that a green run has already been proven and a red one is a fact, not a hypothesis; the auto-fix prompt gets the same chapter in imperative form. The checks already ran before the review since `0.13`; what was missing was the wire. + +- **After a merge, the checks replay on the default branch.** A throwaway worktree fetches the merge target, runs the same checks plan, and journals a `post_merge_checks` event (forwarded to the brain when the task belongs to one), so a merge that breaks the default branch is a recorded fact minutes after it lands. Best-effort by design: a replay that cannot run never blocks or fails the task that just merged. + +- **The reviewer remembers the previous turn and majors must prove themselves.** In simple mode a second review of the same head receives the previous verdict and is told to say what changed rather than re-judge from scratch, and a moved head gets the incremental prompt that manual reviews have had since `0.13`. A `major` finding that asserts concrete behaviour must now carry a `repro`: a self-verifying command whose non-zero exit proves the defect; the runner executes it in the task's container and demotes to `minor` any major that does not reproduce (capped at ten executions per turn). This answers a measured pathology: three different verdicts on the same commit, and a phantom major asserted against unchanged code. + +- **The reviewer can go look, read-only.** On harnesses that support it the review agent gets `Read`/`Grep`/`Glob` bounded to the task's worktree through a temporary settings file (deny-by-default mode), enough to follow an import beyond the diff or check parity with neighbouring code; harnesses that cannot bound tools keep running with none, and the judge lane never gets any. The hardened zero-tool command used by setup probes is untouched. + +- **A crash between the verdict and the merge no longer strands the task.** The ship and merge steps persist a `cycle_step` marker on the task record; on boot the server resumes exactly that step instead of leaving a green-lit task orphaned. Re-running the merge on a branch the forge already merged is now detected (journal first, then the forge's own state after a failed call) instead of tipping the task into `waiting_for_you` by mistake. + +- **The brain daemon is a real daemon.** `codesema brain serve --detach` forks the workspace into the background with a repo-local pidfile under `.codesema/`, `codesema brain stop` sends it a bounded SIGTERM, `codesema brain status` reads back pid, port and uptime, and a documented systemd user unit ships in the package for the always-on case. The heartbeat now also reports the task's local status to the brain on every beat, and when a human answers from the brain's dashboard (ship, reply with an instruction, abandon), the daemon picks the order up on its next beat and applies it through the same guarded manager calls a local click would use. + +- **The contract grew a cross-repo conformance net.** `@codesema/contract` 0.8.0 ships the proof-method and repro types above plus `ArmOrder`/`ArmHeartbeatResponse`, and now carries committed copies of the brain's real endpoint schemas, validated against the sanitizers with ajv on every test run; `scripts/sync-brain-schemas.mjs --check` fails on drift. Building this net immediately caught and closed a real mismatch of the same family as the `run_id` incident that motivated it. + +- **A task now has a turn budget.** `maxTaskTurns` (default 30, a `codesema config` setting) caps the review/fix loop; a task that exhausts it parks as blocked with the count in its journal instead of looping. + +- **Brain mode: a local brain can now hand the workspace tickets to work on, hands-off.** `codesema brain connect --url --token ` points a workspace at a brain (the same account `codesema sync`/`codesema link` already use, sharing its stored credentials); `codesema brain status` shows what it knows about this repo; `codesema brain ticket --issue ` (or `--title`/`--prompt`) runs the configured agent once, off the interactive workspace, to draft a ticket body in the grammar the brain requires, lints it, retries once with the lint's own reasons folded back into the prompt, then publishes it. `codesema workspace --brain` (or its alias `codesema brain serve`) additionally runs a background daemon on the same task manager the web UI drives: it drafts and submits any ticket request the brain has queued for this repo, and — once the workspace has no task already running — claims the next published ticket and starts a task from it exactly as if it had been typed by hand, keeping the claim alive with a heartbeat every 45 seconds and replaying through an outbox anything a network hiccup left unsent. `GET /api/brain/tickets` exposes the brain's own view of in-flight tickets for a future dashboard. `brainAutoMerge` (on by default) is now a toggle in `codesema config`, not only a config-file key. + +- **Code review is a place in the workspace now, not a separate application.** A third rail category lists every open merge request and every local branch worth reviewing, across all registered projects at once — one line each, carrying its project's name, the verdict of its last review and how long ago that was. Picking one stages it: a merge request shows the forge's own detail (body, labels, reviewers, milestone, check rollup) and a branch shows the deterministic preview git alone can compute, each under the same band of launch controls and archived reviews. Starting a review from the browser was never the missing piece — `POST /api/mrs/review` has run one in a throwaway worktree since `0.13.0`, streaming into the same SSE session — but only the standalone review page ever offered the button, and the workspace does not mount that page. What was missing was the screen, and the history behind it. + +- **A branch's past reviews are readable without loading them.** `GET /api/reviews?project=&branch=` answers with `ReviewArchiveSummary` — ref, branches, date, verdict, mode, finding count — and `GET /api/reviews/record` returns one whole record on demand. The split is not tidiness: a `ReviewRecord` carries its entire diff, so serving the twenty a branch keeps would be megabytes to render four scalars per row. The archives were already on disk (`.codesema/reviews/`, twenty per branch since the task runner started writing them); `archiveNames` already computed a branch's list; nothing exposed either. `GET /api/reviews/latest?project=` answers the same shape once per branch, which is what paints the badges. A `ref` that escapes the archive directory is refused, and one naming another branch's archive is refused too — a valid ref is not a licence to read any review. + +- **A review in progress is a place you can leave and come back to.** `FocusView` gains `reviewTarget` (a resting view, like a repository) and `reviewRun` (an overlay that flattens, like a read review). The run variant names only the target — project, source, mode — never the stream: `status`, `partial`, `partial_b` and `judge` mutate on every SSE frame, and folding them into the navigation value would make every frame a new identity for a value the rest of the app diffs on. A finished run takes the stage from the reader who was watching **that** run and from nobody else; anyone looking elsewhere learns it from the row's own badge, which is what `promoteReviewRun` decides. Reloading the page during a run resurrects nothing, by the same doctrine that already refuses to reopen a draft: the first status poll paints the badge, the stage stays where it was. +- **A forge board in the workspace focus zone, laid out in three columns.** Where the focus zone used to show a one-line invitation when nothing was open, the selected project now gets a rail, a list and a detail panel, sized 288, 320 and whatever remains. The rail carries navigation and controls together, and it is permanent: the project menu sits in its head at all times, and the board's filter sections appear underneath only once a project is picked. Picking a project therefore adds sections below the menu rather than moving or resizing the menu itself, and the desk stops standing a menu beside a panel that does half the same job. The work queue steps aside while the board is up, since four columns would leave the detail nothing to live in. The two inner boundaries carry a resize handle that works with the pointer and with the keyboard (one notch per arrow, four with Shift held, since crossing the rail's range one notch at a time takes fifteen keypresses), announces its bounds, and stores only the open width, so a rail dragged shut and reopened comes back where it was. The rail's width animates when it jumps — the collapse button, a keyboard step — and never while it is dragged, since a width that animates cannot keep up with a pointer rewriting it every frame. Dragging past the minimum collapses the controls panel to a 48px band showing the repository name set vertically, and the whole band reopens it. Widths, collapsed state and the active section persist as a single typed JSON blob in `localStorage`, read back tolerantly (absent, empty, partial or corrupted all degrade to defaults, an out-of-range width is clamped rather than rejected) and always behind a `try`/`catch`, since the store itself throws in a private window. + +- **The controls panel is a navigation, not a container.** Its two sections, issues and merge requests, open one at a time and decide what the list column shows. Sort and filters are vertical rows whose selection is a tinted background rather than a border, separated from the cumulative toggles by a hairline. Label pills carry their count on the left and **the colour the forge gave them**, filled at 16 percent at rest and solid once selected, with a text colour computed from the background's relative luminance so it stays legible on whatever the forge sends. A colour the forge did not give, or gave unreadable, falls back to a neutral token: never an invented hex, never a hole. The label search unfolds from a magnifier and empties its query when it closes. + +- **Sorting is by update time or by title**, the only two fields that exist and are never null on both sides. Merge requests then get two filter dimensions that are deliberately not the same mechanism, separated by a hairline. **Above it the state** — open, merged, closed, all — which is exclusive and **changes what is fetched from the forge**, so picking "merged" asks for a different list rather than sieving the open one; the per-state cache means asking for closed never evicts nor serves open. **Below it a cumulative "drafts only" toggle**, which sieves the list already fetched and combines with whichever state is selected. A merge request whose draft flag the forge never reported is not shown when that toggle is on: `null` means unknown, and a filter asking for drafts cannot honestly include one it cannot vouch for. The two used to share one field, which offered `draft`/`ready` as though they were states and left the real state filter unreachable even though the route, the cache and the loader all supported it. When a filter empties the list, the screen says so with a message distinct from "the forge has nothing", because one is a fact about the view and the other a fact about the repository. The count badge then shows both numbers, and stays absent entirely while the real state is unknown rather than showing a zero nobody measured. + +- **The list column carries the density the screen is built for.** Cards sit at 10px of padding with a 12px radius and 8px between them, their titles clamped to two lines in CSS so the full text stays in the DOM, and their age written relatively (`3 days ago`) by a formatter tested at every bucket boundary and on a future timestamp. A search box filters by title and number, a footer states the count, says when the list was last refreshed and offers to refresh it, and the first load shows five skeleton cards whose shimmer is offset per card and per element so the sweep crosses the list diagonally instead of pulsing as a block. + +- **A detail panel with its own metadata rail, under a header in three bands.** The first band sticks to the top and holds the close control; the title scrolls normally beneath it; the toolbar sticks just under the first, and goes static once there is room. When the title scrolls out of view a compact echo of it fades into the first band, so what you are reading stays named however far down the thread you are. The switch is an intersection observer on the title itself rather than a pixel threshold, which would be wrong the moment a title wraps to two lines. The body sits left, a 236px rail that never shrinks sits right. Every rail section follows one pattern: a 12px icon, an 11px uppercase heading, and a hairline underneath that the last section does not get. The changes section is a definition list where an unknown metric prints a dash and the mergeability line is absent altogether when nothing is known, since a dash claims a measurement failed while an absent line claims nothing at all. Passing checks fold behind a single count, failures and running checks stay spelled out: a green check calls for no action, so it does not deserve a line. A merged state takes its own hue rather than the green already spoken for by "this passes", so a merged request with failing checks cannot show green twice for opposite reasons. + +- **Every icon now comes from one set** (`@lucide/vue`), sized to the text it accompanies rather than on a scale of its own, which is what makes a dense screen read as one system. The whole change cost 0.8 kB gzipped, the stylesheet having shed the stroke rules the hand-drawn glyphs needed. + +- **Both lists can be read beyond the open state.** `/api/mrs` and `/api/issues` accept a state, validated against a closed set per route so an unknown value is refused outright rather than quietly folded back to the default. Absent, the behaviour is what it always was. The cache is keyed by project **and** state, so asking for the closed list never evicts nor serves the open one. GitHub and GitLab agree here, unlike on issues: both treat closed and merged as exclusive, so the mapping is direct with no reconciliation table. Expect `truncated` far more often than before, since an active repository accumulates more closed history than it ever has open work. + +- **`GET /api/issues?project=` serves the forge issues to the web UI.** The CLI already knew how to read them; nothing exposed them. The route mirrors `/api/mrs` exactly: same project resolution, same 404 on an unknown project, same test seam, and the result is passed through untouched. The `useIssues` composable loads them lazily per project, caches per project, retries a project left in transport error, and keeps the whole result rather than a bare array, including its `truncated` flag and the five unavailability reasons with their optional detail. A forge that is unreachable and a project with no open issue are never confused. + +- **The merge request contract carries thirteen enriched fields, every one of them nullable.** State, draft flag, labels, additions, deletions, changed files, a check rollup, reviewers, assignees, milestone, mergeability, commit count and body. `null` means the forge did not say it, never zero: a renderer omits the element instead of printing a `0` that reads as a measurement, while a zero that was actually measured is shown. `gh` fills them from a widened `--json` selection. `glab` has no field selection at all, so it fills what its list payload already carries and adds one bounded `api` call per merge request for the rest, leaving `null` behind wherever the REST API exposes nothing. GitLab therefore shows less than GitHub, which is the point: it never shows something false. The commit count is the one field neither forge reports: `gh` would return the full commit list with its authors, roughly five thousand GraphQL nodes per merge request, which multiplied by the page size exceeds GitHub's own budget of five hundred thousand and gets the whole query refused. It is therefore not requested at all, and the rail leaves that line out rather than trading it for a shorter list. + +- **A shared merge request card and metadata rail** (`packages/web/src/components/mr/`), consumed by the workspace's forge board and adopted by the standalone review UI, so the rule that turns data into pixels lives in one place instead of two that drift. Both render in the light and the dark theme through the existing `.ws-root` remap, using only remapped tokens. Passing checks fold into a single count because a green check calls for no action, while failures and pending runs are spelled out. When a check list is truncated the card refuses its own numbers and falls back to an aggregate signal, in text as well as in every attribute. "Check status unavailable" and "no automated checks" stay two different sentences, and an unknown metric prints as a dash rather than a zero. + +- **The description of an issue or a merge request is rendered as markdown**, through an explicit allow list applied to the syntax tree rather than to a string of HTML: a fixed set of tags, `href` on links and `src` and `alt` on images and nothing else, protocols restricted to http and https, and the tags that carry raw text (script, style, iframe, object and their kin) removed **with their content** rather than merely unwrapped. A reference like `#123` becomes a link by rewriting the markdown source, never by patching rendered HTML, and only after the fenced blocks, inline code and existing links have been masked, so a hex colour and a comment anchor survive untouched. That link earns its dotted underline only when both its address and its visible text match something the rewriter itself produced, so a handwritten link cannot borrow the look of an internal reference. + +- **Tailwind v4 as a CSS-first theme foundation.** Our existing tokens are exposed through an `@theme inline` block, which keeps the generated utilities pointing at the live custom properties so the dark remap still applies to them. Preflight is deliberately not imported: the existing components rely on browser default styles inside their scoped blocks, and its reset would break them on sight. + +- **A conversation no longer needs a repository to exist.** Opening the workspace anywhere now offers a **scratch project**: a destination that is not a git repository, where a conversation lives before it has been given any code. Creating one materializes a plain working directory and nothing else. No `git worktree add`, no `codesema/task-*` branch, no `.codesema/` written into a repository the user never pointed at. Until now the first message of a conversation always cost a branch and a checkout, whether or not it was ever going to produce a commit, and a workspace launched outside a repository could not start a conversation at all: `addProject` refuses anything but a git root, so with an empty registry the compose button did nothing, silently. The scratch project is **synthesized on every read and never persisted** in `projects.json` (it is a property of the workspace, not something the user registered), which is what lets `listProjects` keep meaning "the registry alone" for everything that writes to that file. It carries `kind: 'scratch'`; every registry entry now carries `kind: 'repo'`. Naming a `branch` or a `base` on such a conversation is a **400**, not a silently ignored field: there is no repository for either to name something in. + +- **Repositories can be handed to a conversation after it started**, one or several, through `POST /api/tasks/:id/attach` with `{ repo_project_id }`. The worktree is materialized **inside the conversation's own working directory**, not under the repository's `.codesema/worktrees/`, and that placement is the whole point rather than a detail: **the directory the agent runs in never changes**, whatever is attached to it later. A provider that keys its transcripts by working directory (Claude Code does) would otherwise lose the conversation the moment a repository arrived, and `--resume` would silently start a fresh one. The branch still belongs to the attached repository, is created there, and is taken under **that repository's worktree lock**, so a concurrent codesema process racing the same index is refused exactly as before. Attaching what is already attached is the same request answered twice, never a conflict; two repositories sharing a basename get distinct directories (`web`, then `web-2`); a conversation that already lives inside a repository refuses one (**409**), since that would nest a second repository under the first. `TaskRecord` gains an optional `attachments` array and `@codesema/contract` a `TaskAttachment` type, both absent when nothing was ever attached. + +- **Attached repositories keep their git inside the cage.** A linked worktree's `.git` is a one-line file pointing at a HOST path, which is why a caged turn already mounts the repository's shared git directory read-only at `/gitcommon` and lays a generated pointer over `/work/.git`. An attached repository is a linked worktree too, one level down, so it gets the same treatment under a name of its own (`/gitcommon-`), and `safe.directory` now carries an entry per path rather than a fixed pair: it is not recursive, so a repository under the work dir needs its own. Without this, git inside the container answered `not a git repository` for every attached repository while working fine for the conversation itself. + +- **The agent is told what it can read, on every turn.** A conversation given a repository mid-flight now opens each prompt with the directories it actually has, and their branches. Restating it every turn is deliberate: a resumed provider session replays what was **said**, not the filesystem the agent now finds itself in, so an agent told once would go on believing it has nothing to read for the rest of the conversation. + +- **The workspace's focus zone is now one explicit model, not five refs and a deduction.** `WorkspaceView.vue` used to carry `filter`, `reviewRecord`, `deck`, `showSettings` and `activeSection` as independent refs, reconciled only by a `boardVisible` computed that *inferred* what the zone showed instead of deciding it. A `FocusView` union now says so directly — `empty` / `conversation` / `draft` / `repository` / `review` — and a review never stacks: opening one over another always flattens onto the view **underneath** the first rather than onto the first review itself, so closing two in a row lands back where you actually started. `useWorkspaceNav.ts` is pure (no Vue, no DOM, no fetch), 34 tests; `useRailPrefs.ts` persists the rail's own chrome — list width, active category, collapsed state, last project and repo tab — the same tolerant way `ForgePrefs.ts` already does (an absent, partial or corrupted blob degrades to defaults, an out-of-range width clamps rather than rejects), but never the `FocusView` itself: a reload must not resurrect an in-progress draft or reopen a conversation as though it were still live. + +- **A repository now has its own view, three tabs: Branches, Issues and Merge requests.** Branches — the one you land on — opens with four tiles (branch count, worktrees, active conversations, conversations needing you) above a table built by `useRepository.ts`: one row per local branch, one more per worktree checked out on a detached HEAD, a branch a live conversation still holds sorting first, then an open MR or the current checkout, then everything else, detached worktrees always last. A row carries whether a worktree is checked out, its branch name and last commit, an open merge request, the conversations attached to it — its own branch first, falling back to its base only when neither an MR nor another row has already claimed it — and expands into opening one of those conversations or starting a new one directly on that branch; `codesema/task-*` branches never get a row of their own, since each already shows nested under its base. The tiles needed a fact `listLocalBranches` cannot give: it only walks `refs/heads`, where a worktree on a detached HEAD has no entry at all, so `GET /api/worktrees?project=` (`listWorktrees`) was added to answer for it. Pure, 44 tests. Two things the table deliberately does not show: how far a branch trails its base (a `git rev-list --count` per row) and how much disk a worktree spends (a `du` per row) — left out rather than shown as a guess or an empty column. + +### Changed + +- **The review session is a composable, not a hundred lines inside `App.vue`.** Loading a record, following the live stream, launching a run and polling its status all lived inline in the shell that renders the standalone review UI, which is why the workspace could not reach any of it. `useReviewSession` is a factory rather than a singleton — the doctrine `useForgePrefs` already states — so the two UIs share the code and never an instance; they are never mounted together anyway. `App.vue` lost a hundred and thirty lines to the move and a further ninety-four to the trim below. + +- **The workspace navigation is three zones now, not a sidebar tree, a status queue and a pinnable deck.** A 215px category rail — collapsible to a strip of icons — switches between **Conversation** and **Repository**; next to it, a resizable list column shows whatever that category holds; past both, one content zone renders exactly one thing at a time — a conversation, a draft, a repository, or a review — never several side by side. Conversations are no longer sorted into four visible bands: `groupConversationsByProject` now groups them by project first (display-name order), and what used to be the section — **Needs you**, **In progress**, **Ready to ship**, **Done** — is only the sort key inside each group (`SECTION_RANK`, `compareByActivity` breaking ties), so a project's own conversations sit together instead of being scattered across four piles. The header's search field is gone from the header: each list searches its own corpus now, conversations or repositories, and a box sitting above both would be a second search reaching for a third, overlapping thing — `⌘K` still focuses it, wherever it currently lives. + +### Removed + +- **The standalone review page no longer browses the forge.** `MrSidebar.vue`, `BranchSidebar.vue` and `MrDetailPanel.vue` are gone, and the twenty-three message keys they alone owned with them. What that page does is read the one review its process is serving — what `codesema review` opens, and what CI keeps — and the merge request and branch browsing it used to carry is what the workspace now does better, across every project instead of one. `components/FocusView.vue` is renamed `ReviewFocusMode.vue`: it is the review's problems-first mode and had nothing to do with the `FocusView` navigation union it shared a name with. +- **The three-column pinnable deck, the sidebar's own MR/branch tree, and the code that only they needed.** `useFocusDeck.ts` and `useColumns.ts`, 941 lines together with their tests, go with the deck itself: up to three conversations shown side by side, pinned with 📌 so that opening a new one replaced only the unpinned columns, never the ones kept. `ProjectsNav.vue`'s per-project tree of open merge requests and active branches goes the same way, superseded by the repository view's own table; `ConversationsColumn.vue` stops being a deck column, though the grouping logic it used (`ConversationsLogic.ts`) survives, now read by the rail's conversations list instead. The `workspace.pin` / `workspace.unpin` strings go with the button they used to label. `deriveComposeTarget` — see Fixed, below, for what it got wrong — is deleted outright rather than patched again, and `resolveBranchClick`'s `draft-fork` branch is deleted alongside it: a case the resolver had never actually returned since amendment 4 settled that a plain branch click always means work-on, never fork. + +### Security + +- **A body could freeze the tab, or crash it, and anyone could write one.** Parsing cost grew quadratically with nesting: 24,000 characters of split emphasis blocked the main thread for eight seconds, and 16,000 characters of nested blockquotes overflowed the call stack with no handler anywhere above it. On a public repository anyone can open an issue with such a body, and merely opening the detail panel was enough to be hit. Length was never the danger, a single five-million-character line renders in half a second; shape was. The render is now bounded on both, separately: a length cap generous enough to leave real descriptions whole, and a linear-time scan that refuses to invoke the parser at all on the shapes that blow up, falling back to escaped plain text. The two conditions tell the reader different things, since "there was more of it" and "we would not render this" are not the same fact. A `try`/`catch` sits behind both. + +- **Hot module replacement on the web UI, through Vite's backend integration mode.** `packages/cli/web-dist` is a build, so every pixel of the forge board used to cost a full `bun run build`. Setting `CODESEMA_DEV_VITE` to a loopback origin (`bun run dev:cli`, alongside `bun run dev:web`) makes the CLI serve a page whose modules come from the Vite dev server instead of the embedded bundle. The **CLI still serves the page**, which is the whole point of choosing this over a `/api` proxy in Vite: the origin stays the CLI's, so the SSE streams, the loopback and Host guards, and the per-server CSRF tokens injected into the page are the ones a real install runs, and workspace mode (detected by the presence of `__CODESEMA_TASKS_TOKEN__`) survives HMR instead of silently falling back to the standalone review UI. The variable is read once at boot and nothing sets it implicitly, so a published install cannot fall into this mode; the value must parse as an `http(s)` loopback origin, since it ends up as a `', + ) + expect(html).toContain( + '', + ) + }) + + test('mirrors the shell of packages/web/index.html', () => { + // The two shells are kept in step by hand: if the real one grows a tag, this + // fails instead of the dev page silently rendering something else. + const source = readFileSync( + fileURLToPath(new URL('../../web/index.html', import.meta.url)), + 'utf8', + ) + const shell = (html: string) => + html + .replace(/]*><\/script>/g, '') + .replace(/\s+/g, ' ') + .trim() + expect(shell(devIndexHtml('http://localhost:5173'))).toBe(shell(source)) + }) + + test('keeps the anchor startServer injects the boot script into', () => { + expect(devIndexHtml('http://localhost:5173')).toContain('') + }) +}) + +describe('startServer in dev mode', () => { + let port: number + let stop: () => Promise + let repoDir: string + const previousDevVite = process.env.CODESEMA_DEV_VITE + + beforeAll(async () => { + repoDir = mkdtempSync(join(tmpdir(), 'codesema-serve-dev-')) + process.env.CODESEMA_DEV_VITE = 'http://localhost:5173' + const started = await startServer(createSession(), { cwd: repoDir, port: 4931 }) + port = started.port + stop = started.stop + }) + + afterAll(async () => { + await stop() + if (previousDevVite === undefined) { + delete process.env.CODESEMA_DEV_VITE + } else { + process.env.CODESEMA_DEV_VITE = previousDevVite + } + rmSync(repoDir, { recursive: true, force: true }) + }) + + test('serves the dev shell instead of the embedded bundle', async () => { + const res = await rawRequest(port, '/') + expect(res.status).toBe(200) + expect(res.contentType).toBe('text/html; charset=utf-8') + expect(res.body).toContain('http://localhost:5173/@vite/client') + expect(res.body).not.toContain('/assets/') + }) + + test('still injects the boot script, so workspace mode survives HMR', async () => { + const res = await rawRequest(port, '/') + // Same injection as the bundled path: this is the whole point of letting the + // CLI serve the page rather than proxying /api to Vite. + expect(res.body).toContain('window.__CODESEMA_CONFIG_TOKEN__=') + expect(res.body).toContain('window.__CODESEMA_LOCALE__=') + }) + + test('keeps /api on the CLI origin', async () => { + // 202: no record yet. What matters is that the route answers from the same + // origin as the page, so no proxy and no CORS are in the dev loop at all. + const res = await rawRequest(port, '/api/review') + expect(res.status).toBe(202) + }) }) diff --git a/packages/cli/src/serve.ts b/packages/cli/src/serve.ts index dca46dc..fc10ac7 100644 --- a/packages/cli/src/serve.ts +++ b/packages/cli/src/serve.ts @@ -4,23 +4,44 @@ import { readFile } from 'node:fs/promises' import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' import { extname, join, resolve, sep } from 'node:path' import { fileURLToPath } from 'node:url' -import { listLocalBranches } from './branches.js' +import { brainErrorMessage, brainRemoteUrl, listTickets, type BrainError } from './brain-client.js' +import { startBrainDaemon, type BrainDaemonHandle } from './brain-daemon.js' +import { listLocalBranches, listWorktrees } from './branches.js' import { loadGlobalConfig, saveGlobalConfig, type CodesemaConfig } from './config.js' -import { isTaskId, TASK_AGENT_MAX, type ReviewRecord } from './contract.js' +import { + isTaskId, + sanitizeRecord, + TASK_AGENT_MAX, + type ArmTicket, + type ReviewRecord, +} from './contract.js' import type { JudgeDecision } from './dual.js' import type { FixRunner } from './fix.js' -import { listOpenMrs, type ForgeMrsResult } from './forge-mrs.js' +import { + listIssues as probeIssues, + type ForgeIssuesResult, + type ForgeIssueStateFilter, +} from './forge-issues.js' +import { listOpenMrs, type ForgeMrsResult, type ForgeMrStateFilter } from './forge-mrs.js' import { t } from './i18n.js' -import type { MrReviewMode, MrReviewRunner, ReviewSource } from './mr-review-runner.js' +import type { + MrReviewMode, + MrReviewRunner, + MrReviewScope, + MrReviewStatus, + ReviewSource, +} from './mr-review-runner.js' import type { PartialReview } from './partial.js' import { buildFileDiff, buildPreview, parsePreviewPath, parsePreviewSource } from './preview.js' import { addProject, discoverProjects, getProject, - listProjects, + isProjectId, + listWorkspaceProjects, removeProject, } from './projects.js' +import { listLatestReviews, listReviewHistory, readJson, resolveArchivePath } from './record.js' import { readRulesContent, readSyncAutoPush, @@ -28,6 +49,7 @@ import { setSyncAutoPush, writeRulesContent, } from './repo-config.js' +import { loadSyncCredentials } from './sync.js' import { applyTaskCriteria } from './task-criteria.js' import type { TaskActionResult } from './task-runner.js' import type { CreateTaskManagerInput, TaskEnvelope, TaskManager } from './task-server.js' @@ -402,7 +424,9 @@ async function handleMrReviewStart( req: IncomingMessage, res: ServerResponse, mrReview: MrReviewEndpoint | undefined, + ctx: { searchParams: URLSearchParams; cwd: string }, ): Promise { + const { searchParams, cwd } = ctx if (!mrReview) { return sendJson(res, 501, { error: 'MR review runner unavailable' }) } @@ -420,7 +444,13 @@ async function handleMrReviewStart( if (!source || (b?.mode !== 'simple' && b?.mode !== 'dual')) { return sendText(res, 400, 'bad request') } - const started = await mrReview.runner.start(source, b?.mode as MrReviewMode) + const scoped = resolveProjectCwd(searchParams, cwd) + if ('error' in scoped) { + return sendText(res, 404, 'not found') + } + const projectId = searchParams.get('project')?.trim() || null + const scope: MrReviewScope | undefined = projectId ? { projectId, cwd: scoped.cwd } : undefined + const started = await mrReview.runner.start(source, b?.mode as MrReviewMode, scope) if (!started.ok) { return sendJson(res, started.code, { error: started.error }) } @@ -719,6 +749,32 @@ export function resolveProjectCwd( return { cwd: project.path } } +/** + * The single MR review runner is shared by every project, so a status whose + * `project_id` belongs to a DIFFERENT project than the one asked for must not + * leak through as if it were this project's own — the caller sees 'idle' + * instead. `projectId: null` (no `?project=`) keeps today's behavior: the + * raw, unfiltered status. + */ +export function mrReviewStatusForProject( + status: MrReviewStatus, + projectId: string | null, +): MrReviewStatus { + if (projectId === null || status.phase === 'idle' || status.project_id === projectId) { + return status + } + return { available: true, phase: 'idle' } +} + +/** Null on a missing or corrupt archive: the caller turns that into a 404, never a crash. */ +function readReviewRecord(path: string): ReviewRecord | null { + try { + return sanitizeRecord(readJson(path)) + } catch { + return null + } +} + /** A parsed task-creation request, ready for either the real create or its dry-run. */ type TaskCreateRequest = { tasks: TasksEndpoint @@ -891,7 +947,8 @@ async function handleTaskPreview( return sendJson(res, 200, previewed.plan) } -type TaskActionKind = 'reply' | 'ship' | 'interrupt' | 'abandon' | 'checks' | 'resume' | 'criteria' +type TaskActionKind = + 'reply' | 'ship' | 'interrupt' | 'abandon' | 'checks' | 'resume' | 'criteria' | 'attach' /** * The mutations that carry NO request body: everything they need is already @@ -932,7 +989,7 @@ function taskActionBody(result: TaskActionResult): Record { : { error: result.error, ...(result.reason_code ? { reason_code: result.reason_code } : {}) } } -/** POST /api/tasks/:id/(reply|ship|interrupt|abandon|checks|resume|criteria)?project=, all under the tasks CSRF token. */ +/** POST /api/tasks/:id/(reply|ship|interrupt|abandon|checks|resume|criteria|attach)?project=, all under the tasks CSRF token. */ async function handleTaskAction( req: IncomingMessage, res: ServerResponse, @@ -989,6 +1046,20 @@ async function handleTaskAction( ? sendJson(res, 202, { ok: true }) : sendJson(res, result.code, { error: result.error }) } + if (action.kind === 'attach') { + let body: unknown + try { + body = await readJsonBody(req, MAX_TASK_BODY_BYTES) + } catch { + return sendText(res, 400, 'bad request') + } + const repoProjectId = (body as { repo_project_id?: unknown } | null)?.repo_project_id + if (!isProjectId(repoProjectId)) { + return sendText(res, 400, 'bad request') + } + const attached = await tasks.manager.attach(projectId, action.id, repoProjectId) + return sendJson(res, attached.ok ? 200 : attached.code, taskActionBody(attached)) + } if (action.kind !== 'reply') { const result = await BODYLESS_TASK_ACTIONS[action.kind](tasks.manager, projectId, action.id) return sendJson(res, result.ok ? 200 : result.code, taskActionBody(result)) @@ -1142,12 +1213,105 @@ function serveTaskEvents( async function handleMrsList( res: ServerResponse, cwd: string, - listMrs: (cwd: string) => Promise, + listMrs: (cwd: string, state?: ForgeMrStateFilter) => Promise, + state: ForgeMrStateFilter | undefined, +): Promise { + const result = await listMrs(cwd, state) + sendJson(res, 200, result) +} + +async function handleIssuesList( + res: ServerResponse, + cwd: string, + listIssues: (cwd: string, state?: ForgeIssueStateFilter) => Promise, + state: ForgeIssueStateFilter | undefined, ): Promise { - const result = await listMrs(cwd) + const result = await listIssues(cwd, state) sendJson(res, 200, result) } +/** + * The brain's own view of this project's in-flight tickets, for a future + * dashboard: every status a caller could act on or care about right now. + * `done` is left out on purpose — the wire contract has no way to bound it by + * date, and an unbounded "every ticket ever finished" is not what "in + * flight" means. 503 whenever the brain integration is not usable for this + * project right now (no credentials, or no git origin remote to scope tickets + * by) — not 501 (this codebase's convention for "no task manager at all"), + * since the feature exists here, it is just not connected. + */ +const BRAIN_DASHBOARD_STATUSES = [ + 'published', + 'in_progress', + 'mr_opened', + 'ready_to_merge', +] as const + +async function handleBrainTicketsList(res: ServerResponse, cwd: string): Promise { + const creds = loadSyncCredentials() + const remoteUrl = creds ? brainRemoteUrl(cwd) : null + if (!creds || !remoteUrl) { + return sendJson(res, 503, { available: false }) + } + const results = await Promise.all( + BRAIN_DASHBOARD_STATUSES.map((status) => listTickets(creds, remoteUrl, status)), + ) + const tickets: ArmTicket[] = [] + let firstError: BrainError | null = null + for (const result of results) { + if (result.ok) { + tickets.push(...result.data) + } else { + firstError ??= result.error + } + } + if (tickets.length === 0 && firstError) { + return sendJson(res, 503, { available: false, error: brainErrorMessage(firstError) }) + } + return sendJson(res, 200, { available: true, tickets }) +} + +/** Adapts forge-issues's `listIssues({cwd, state?})` to the plain `(cwd, state?) => + * Promise` shape every route handler and test seam here uses; absent state falls + * through to the underlying probe's own default (open). */ +const listIssuesDefault = ( + cwd: string, + state?: ForgeIssueStateFilter, +): Promise => probeIssues({ cwd, state }) + +/** Same adaptation as `listIssuesDefault`, for forge-mrs's `listOpenMrs(cwd, {state?})`. */ +const listMrsDefault = (cwd: string, state?: ForgeMrStateFilter): Promise => + listOpenMrs(cwd, { state }) + +const MR_STATE_FILTERS: ReadonlySet = new Set(['open', 'merged', 'closed', 'all']) +const ISSUE_STATE_FILTERS: ReadonlySet = new Set(['open', 'closed', 'all']) + +type StateParamResult = { ok: true; state: T | undefined } | { ok: false } + +/** + * Parses the optional `?state=` query param shared by /api/mrs and /api/issues. + * Absent → `undefined` (the underlying probe's own default, open); present but + * not one of `values` → refused outright, never silently folded back to the + * default: a caller asking for a state this server does not recognise must + * be told so, not served a different list than the one it asked for. + */ +function parseStateParam( + params: URLSearchParams, + values: ReadonlySet, +): StateParamResult { + const raw = params.get('state') + if (raw === null) { + return { ok: true, state: undefined } + } + return values.has(raw) ? { ok: true, state: raw as T } : { ok: false } +} + +const parseMrStateParam = (params: URLSearchParams): StateParamResult => + parseStateParam(params, MR_STATE_FILTERS) + +const parseIssueStateParam = (params: URLSearchParams): StateParamResult => + parseStateParam(params, ISSUE_STATE_FILTERS) + /** GET /api/preview?source=mr&number=N | ?source=branch&name=X: deterministic (no agent) MR/branch preview. */ async function handlePreview( res: ServerResponse, @@ -1204,7 +1368,7 @@ async function serveStaticFile(res: ServerResponse, pathname: string): Promise Promise + listMrs: (cwd: string, state?: ForgeMrStateFilter) => Promise + listIssues: (cwd: string, state?: ForgeIssueStateFilter) => Promise fix?: FixEndpoint | undefined mrReview?: MrReviewEndpoint | undefined tasks?: TasksEndpoint | undefined }) { - const { session, indexHtml, cwd, configToken, listMrs, fix, mrReview, tasks } = handlerOpts + const { session, indexHtml, cwd, configToken, listMrs, listIssues, fix, mrReview, tasks } = + handlerOpts // One cap for BOTH streams (review session + tasks): each browser tab holds // at most one of each, the cap only guards against runaway clients. let sseClients = 0 @@ -1253,7 +1419,7 @@ function createRequestHandler(handlerOpts: { return void handleFixStart(req, res, fix) } if (pathname === '/api/mrs/review') { - return void handleMrReviewStart(req, res, mrReview) + return void handleMrReviewStart(req, res, mrReview, { searchParams, cwd }) } if (pathname === '/api/tasks') { return void handleTaskCreate(req, res, tasks) @@ -1325,7 +1491,13 @@ function createRequestHandler(handlerOpts: { } if ( pathname === '/api/mrs' || + pathname === '/api/issues' || pathname === '/api/branches' || + pathname === '/api/worktrees' || + pathname === '/api/brain/tickets' || + pathname === '/api/reviews/latest' || + pathname === '/api/reviews' || + pathname === '/api/reviews/record' || pathname === '/api/preview' || pathname === '/api/preview/diff' ) { @@ -1334,11 +1506,54 @@ function createRequestHandler(handlerOpts: { return sendText(res, 404, 'not found') } if (pathname === '/api/mrs') { - return void handleMrsList(res, scoped.cwd, listMrs) + const state = parseMrStateParam(searchParams) + if (!state.ok) { + return sendText(res, 400, 'bad request') + } + return void handleMrsList(res, scoped.cwd, listMrs, state.state) + } + if (pathname === '/api/issues') { + const state = parseIssueStateParam(searchParams) + if (!state.ok) { + return sendText(res, 400, 'bad request') + } + return void handleIssuesList(res, scoped.cwd, listIssues, state.state) } if (pathname === '/api/branches') { return sendJson(res, 200, listLocalBranches(scoped.cwd)) } + if (pathname === '/api/worktrees') { + return sendJson(res, 200, listWorktrees(scoped.cwd)) + } + if (pathname === '/api/brain/tickets') { + return void handleBrainTicketsList(res, scoped.cwd) + } + if (pathname === '/api/reviews/latest') { + return sendJson(res, 200, { latest: listLatestReviews(scoped.cwd) }) + } + if (pathname === '/api/reviews/record') { + const branch = searchParams.get('branch')?.trim() + const ref = searchParams.get('ref')?.trim() + if (!branch || !ref) { + return sendText(res, 400, 'bad request') + } + const archivePath = resolveArchivePath(scoped.cwd, ref) + const record = archivePath ? readReviewRecord(archivePath) : null + // A `ref` that resolves inside the project's reviews dir but belongs + // to a DIFFERENT branch is refused too: a valid ref alone must never + // serve another branch's review. + if (!record || record.meta.branch !== branch) { + return sendText(res, 404, 'not found') + } + return sendJson(res, 200, record) + } + if (pathname === '/api/reviews') { + const branch = searchParams.get('branch')?.trim() + if (!branch) { + return sendText(res, 400, 'bad request') + } + return sendJson(res, 200, { branch, entries: listReviewHistory(scoped.cwd, branch) }) + } if (pathname === '/api/preview') { return void handlePreview(res, scoped.cwd, searchParams) } @@ -1354,7 +1569,12 @@ function createRequestHandler(handlerOpts: { if (!mrReview) { return sendJson(res, 200, { available: false }) } - return sendJson(res, 200, mrReview.runner.status()) + const scoped = resolveProjectCwd(searchParams, cwd) + if ('error' in scoped) { + return sendText(res, 404, 'not found') + } + const projectId = searchParams.get('project')?.trim() || null + return sendJson(res, 200, mrReviewStatusForProject(mrReview.runner.status(), projectId)) } if (pathname === '/api/events') { if (sseClients >= MAX_SSE_CLIENTS) { @@ -1374,7 +1594,9 @@ function createRequestHandler(handlerOpts: { // for older UIs. selectProject is client-local, so the UI reads // `project.isolation` for the active card instead of refetching. return sendJson(res, 200, { - projects: listProjects().map((project) => ({ + // Workspace projects, not the registry: the scratch project is a + // destination the UI must be able to name, and it is in no file. + projects: listWorkspaceProjects().map((project) => ({ ...project, isolation: tasks.manager.workspaceInfo(project.id), })), @@ -1517,6 +1739,54 @@ async function listen( throw new Error(t('serve.noFreePort', { start: startPort, end: startPort + 19 })) } +/** + * Dev-only: the Vite dev server origin to load the UI from, or undefined for the + * embedded `web-dist` build. Set by `CODESEMA_DEV_VITE` so nothing can switch a + * published install into dev mode implicitly. Loopback only: the value ends up as + * a ` + + + +` +} + export async function startServer( session: LiveSession, opts: { @@ -1529,10 +1799,14 @@ export async function startServer( /** Project auto-registered from the boot repo (GET /api/projects `current`). */ currentProjectId?: string | null | undefined /** Test seam for GET /api/mrs (same shape as mr-review-runner's); defaults to the real forge CLI probe. */ - listMrs?: ((cwd: string) => Promise) | undefined + listMrs?: ((cwd: string, state?: ForgeMrStateFilter) => Promise) | undefined + /** Test seam for GET /api/issues; defaults to the real forge CLI probe. */ + listIssues?: + ((cwd: string, state?: ForgeIssueStateFilter) => Promise) | undefined }, ): Promise<{ url: string; port: number; stop: () => Promise }> { - if (!existsSync(join(WEB_DIST, 'index.html'))) { + const devViteOrigin = resolveDevViteOrigin(process.env.CODESEMA_DEV_VITE) + if (!devViteOrigin && !existsSync(join(WEB_DIST, 'index.html'))) { throw new Error(t('serve.noWebUi', { path: WEB_DIST })) } const configToken = randomBytes(16).toString('hex') @@ -1556,10 +1830,10 @@ export async function startServer( ...(mrReview ? [`window.__CODESEMA_MRREVIEW_TOKEN__=${JSON.stringify(mrReview.token)}`] : []), ...(tasks ? [`window.__CODESEMA_TASKS_TOKEN__=${JSON.stringify(tasks.token)}`] : []), ].join(';') - const indexHtml = readFileSync(join(WEB_DIST, 'index.html'), 'utf8').replace( - '', - ``, - ) + const indexSource = devViteOrigin + ? devIndexHtml(devViteOrigin) + : readFileSync(join(WEB_DIST, 'index.html'), 'utf8') + const indexHtml = indexSource.replace('', ``) const { server, port } = await listen( createRequestHandler({ @@ -1567,17 +1841,29 @@ export async function startServer( indexHtml, cwd: opts.cwd, configToken, - listMrs: opts.listMrs ?? listOpenMrs, + listMrs: opts.listMrs ?? listMrsDefault, + listIssues: opts.listIssues ?? listIssuesDefault, fix, mrReview, tasks, }), opts.port ?? 4400, ) - const stop = () => - new Promise((resolveClose) => { + // `codesema workspace --brain` / `codesema brain serve` (index.ts, + // brain-commands.ts) set this before calling workspace(), which has no + // room in its own options type for a brain flag: read here, at the one + // place that actually needs it, the same way CODESEMA_SYNC_URL / + // CODESEMA_DEV_VITE already cross an intermediate layer in this codebase. + const brainDaemon: BrainDaemonHandle | null = + opts.taskManager && process.env.CODESEMA_BRAIN_MODE === '1' + ? startBrainDaemon({ manager: opts.taskManager, cwd: opts.cwd }) + : null + const stop = async () => { + await brainDaemon?.stop() + await new Promise((resolveClose) => { server.closeAllConnections() server.close(() => resolveClose()) }) + } return { url: `http://localhost:${port}`, port, stop } } diff --git a/packages/cli/src/task-brain-ticket.test.ts b/packages/cli/src/task-brain-ticket.test.ts new file mode 100644 index 0000000..ea894d7 --- /dev/null +++ b/packages/cli/src/task-brain-ticket.test.ts @@ -0,0 +1,271 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import type { ArmTicket } from './contract.js' +import { addProject, type Project } from './projects.js' +import { createBrainTicketTask, resolveBrainTicketOrigin } from './task-brain-ticket.js' +import type { TaskActionResult, TaskRunner, TaskRunnerOptions } from './task-runner.js' +import { createTaskManager } from './task-server.js' +import { listTasks, readTaskEvents } from './tasks-store.js' + +// --- rigs, on the exact patron of task-server.test.ts ---------------------- + +let configDir: string +const previousConfigDir = process.env.CODESEMA_CONFIG_DIR +const cleanups: string[] = [] + +beforeEach(() => { + configDir = mkdtempSync(join(tmpdir(), 'codesema-brain-ticket-cfg-')) + cleanups.push(configDir) + process.env.CODESEMA_CONFIG_DIR = configDir +}) + +afterEach(() => { + for (const dir of cleanups.splice(0)) { + rmSync(dir, { recursive: true, force: true }) + } + if (previousConfigDir === undefined) { + delete process.env.CODESEMA_CONFIG_DIR + } else { + process.env.CODESEMA_CONFIG_DIR = previousConfigDir + } +}) + +function makeDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'codesema-brain-ticket-')) + cleanups.push(dir) + return dir +} + +/** Real git repo: projects must be git roots. */ +function makeRepo(): string { + const repo = makeDir() + const run = (args: string[]) => execFileSync('git', args, { cwd: repo, stdio: 'ignore' }) + run(['init', '-b', 'main']) + run(['config', 'user.email', 't@t']) + run(['config', 'user.name', 't']) + writeFileSync(join(repo, 'base.txt'), 'a\n') + run(['add', '-A']) + run(['commit', '-m', 'init: base']) + return repo +} + +function register(repo: string): Project { + const added = addProject(repo) + if (!added.ok) { + throw new Error(added.error) + } + return added.project +} + +/** Captures the manager→runner seam without ever launching an agent. */ +function fakeRunner(): { createRunnerFn: (options: TaskRunnerOptions) => TaskRunner } { + return { + createRunnerFn: () => ({ + start: (): TaskActionResult => ({ ok: true }), + reply: (): TaskActionResult => ({ ok: false, code: 409, error: 'not waiting for a reply' }), + resume: (): TaskActionResult => ({ ok: true }), + interrupt: (): TaskActionResult => ({ ok: true }), + abandon: () => Promise.resolve({ ok: true as const }), + isAbandoning: () => false, + attach: () => Promise.resolve({ ok: true as const }), + shutdown: () => Promise.resolve(), + runningCount: () => 0, + }), + } +} + +const managerOpts = { command: 'claude -p', timeoutMs: 1000 } + +// --- a valid ticket body: five headings, three EARS criteria --------------- + +const VALID_BODY = `**Context** +The onboarding flow drops new users who close the tab mid-way. + +**Goal** +Persist onboarding progress so it resumes where it left off. + +**Scope** +The onboarding wizard's client-side state only. + +**Acceptance criteria** +- WHEN a user closes the tab mid-onboarding THE SYSTEM SHALL persist their progress +- WHEN a user reopens the onboarding wizard THE SYSTEM SHALL resume from the saved step +- WHEN onboarding completes THE SYSTEM SHALL clear the saved progress + +**Out of scope** +Server-side onboarding analytics.` + +function fakeTicket(overrides: Partial = {}): ArmTicket { + return { + id: 'tkt-1', + repo_remote_url: '', + title: 'Persist onboarding progress', + body: VALID_BODY, + status: 'in_progress', + depends_on: null, + executed_by: null, + lease_expires_at: null, + issue: null, + branch: null, + mr_iid: null, + mr_url: null, + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + ...overrides, + } +} + +describe('resolveBrainTicketOrigin', () => { + test('a valid ticket resolves title, prompt, criteria and brainTicket', () => { + const origin = resolveBrainTicketOrigin('/repo', fakeTicket()) + expect(origin.ok).toBe(true) + if (!origin.ok) { + return + } + expect(origin.title).toBe('Persist onboarding progress') + expect(origin.prompt).toBe(VALID_BODY) + expect(origin.criteria.length).toBe(3) + expect(origin.criteria.every((c) => c.id.startsWith('ac-'))).toBe(true) + expect(origin.brainTicket).toEqual({ id: 'tkt-1', title: 'Persist onboarding progress' }) + expect(origin.issue).toBeNull() + expect(origin.issueSnapshot).toBeNull() + expect(origin.coverageGap).toBe(false) + }) + + test('an empty title refuses', () => { + const origin = resolveBrainTicketOrigin('/repo', fakeTicket({ title: ' ' })) + expect(origin.ok).toBe(false) + if (origin.ok) { + return + } + expect(origin.refusal.code).toBe(400) + expect(origin.refusal.error).toBe('empty title') + }) + + test('a body that fails T2.3 lint refuses, naming the problem', () => { + const origin = resolveBrainTicketOrigin('/repo', fakeTicket({ body: 'not a ticket at all' })) + expect(origin.ok).toBe(false) + if (origin.ok) { + return + } + expect(origin.refusal.code).toBe(400) + expect(origin.refusal.error).toContain('missing section') + }) + + test('a body with fewer than three criteria refuses', () => { + const shortBody = VALID_BODY.replace( + /\*\*Acceptance criteria\*\*[\s\S]*?\n\n\*\*Out of scope\*\*/, + '**Acceptance criteria**\n- WHEN a user closes the tab THE SYSTEM SHALL persist progress\n\n**Out of scope**', + ) + const origin = resolveBrainTicketOrigin('/repo', fakeTicket({ body: shortBody })) + expect(origin.ok).toBe(false) + if (origin.ok) { + return + } + expect(origin.refusal.error).toContain('at least 3') + }) + + test('brainTicket.url prefers mr_url over the source issue url', () => { + const origin = resolveBrainTicketOrigin( + '/repo', + fakeTicket({ + mr_url: 'https://forge.example/mr/1', + issue: { iid: '42', url: 'https://forge.example/issues/42' }, + }), + ) + expect(origin.ok).toBe(true) + if (!origin.ok) { + return + } + expect(origin.brainTicket.url).toBe('https://forge.example/mr/1') + }) + + test('brainTicket.url falls back to the source issue url when there is no mr_url', () => { + const origin = resolveBrainTicketOrigin( + '/repo', + fakeTicket({ mr_url: null, issue: { iid: '42', url: 'https://forge.example/issues/42' } }), + ) + expect(origin.ok).toBe(true) + if (!origin.ok) { + return + } + expect(origin.brainTicket.url).toBe('https://forge.example/issues/42') + }) + + test('no mr_url and no source issue: brainTicket carries no url at all', () => { + const origin = resolveBrainTicketOrigin('/repo', fakeTicket()) + expect(origin.ok).toBe(true) + if (!origin.ok) { + return + } + expect('url' in origin.brainTicket).toBe(false) + }) +}) + +describe('createBrainTicketTask', () => { + test('a valid ticket creates a queued task with the right title, criteria and brain_ticket', async () => { + const repo = makeRepo() + const project = register(repo) + const manager = createTaskManager({ ...managerOpts, ...fakeRunner() }) + + const created = await createBrainTicketTask(manager, project.path, fakeTicket()) + + expect(created.ok).toBe(true) + if (!created.ok) { + return + } + expect(created.record.status).toBe('queued') + expect(created.record.title).toBe('Persist onboarding progress') + expect(created.record.auto_ship).toBe(true) + expect(created.record.criteria?.length).toBe(3) + expect(created.record.brain_ticket).toEqual({ + id: 'tkt-1', + title: 'Persist onboarding progress', + }) + + // On disk, not just in the in-memory return value. + const onDisk = listTasks(project.path).find((t) => t.id === created.record.id) + expect(onDisk?.criteria?.length).toBe(3) + expect(onDisk?.brain_ticket?.id).toBe('tkt-1') + + // The criteria landed with a journal line, same as a human validation would. + const events = readTaskEvents(project.path, created.record.id) + expect(events.some((e) => e.type === 'criteria' && e.data.name === 'validated')).toBe(true) + }) + + test('an invalid ticket body refuses without creating a task', async () => { + const repo = makeRepo() + const project = register(repo) + const manager = createTaskManager({ ...managerOpts, ...fakeRunner() }) + + const created = await createBrainTicketTask( + manager, + project.path, + fakeTicket({ body: 'not a ticket at all' }), + ) + + expect(created.ok).toBe(false) + if (created.ok) { + return + } + expect(created.code).toBe(400) + expect(listTasks(project.path)).toEqual([]) + }) + + test('a repo that was never registered refuses with 404', async () => { + const repo = makeRepo() + // Deliberately not registered. + const manager = createTaskManager({ ...managerOpts, ...fakeRunner() }) + + const created = await createBrainTicketTask(manager, repo, fakeTicket()) + + expect(created.ok).toBe(false) + if (created.ok) { + return + } + expect(created.code).toBe(404) + }) +}) diff --git a/packages/cli/src/task-brain-ticket.ts b/packages/cli/src/task-brain-ticket.ts new file mode 100644 index 0000000..f8a2ec9 --- /dev/null +++ b/packages/cli/src/task-brain-ticket.ts @@ -0,0 +1,141 @@ +// Turns a brain ticket (a ticket the local brain owns and this arm claimed) +// into a queued task: the symmetric twin of task-issue.ts's +// resolveIssueOrigin/admitIssue, but for a ticket the brain already resolved +// and validated rather than a forge issue read live over the network. +// +// No forge round trip here: an ArmTicket arrives already sanitized +// (sanitizeArmTicket, contract/brain.ts) by whoever claimed it from the +// brain, so admission is a pure, synchronous lint: lintTicketBody (T2.3), +// the SAME gate task-issue.ts's admitIssue runs on a forge issue's body. +// +// Criteria are frozen on the record AT CREATION, atomically with the title +// and prompt (task-server.ts folds `resolveBrainTicketOrigin`'s `criteria` +// straight into `createTask`'s input), never posed afterwards through +// applyTaskCriteria's own POST /api/tasks/:id/criteria mechanics. A +// brain-ticket task's very first turn already reads `taskCriteria(record)` +// (task-runner.ts) to build its prompt; criteria landing even one write +// later would race that read, and the task would draft-and-wait for a human +// validation nobody is coming to give: the brain validated them already. + +import { + formatTicketProblems, + lintTicketBody, + TASK_TITLE_MAX, + TASK_TURN_TEXT_MAX, + type AcceptanceCriterion, + type ArmTicket, +} from './contract.js' +import type { TaskCreateResult, TaskManager } from './task-server.js' + +/** + * What `resolveBrainTicketOrigin` hands back: the same shape task-server.ts's + * own (unexported) `TaskOrigin` accepts on its `ok: true` branch: title, + * prompt, no forge issue (a brain ticket is not reconciled against a live + * forge issue the way T2.4's own origin is; `brainTicket.url` is a plain + * pointer, not a reconciliation anchor), and the two brain-only fields + * (`brainTicket`, `criteria`) `task-server.ts`'s `create()` folds onto the + * record. The refusal is wrapped in `refusal`, matching + * `resolveIssueOrigin`/`resolveTitlePromptOrigin`'s own shape exactly, so + * `create()`'s `if (!origin.ok) return origin.refusal` reads it unchanged. + */ +export type BrainTicketOrigin = + | { + ok: true + title: string + prompt: string + issue: null + issueSnapshot: null + coverageGap: false + brainTicket: { id: string; title: string; url?: string } + criteria: AcceptanceCriterion[] + } + | { ok: false; refusal: { ok: false; code: 400; error: string } } + +/** + * Validates a brain ticket and derives the task it would become. `cwd` is + * taken for symmetry with `resolveIssueOrigin(cwd, ref, execFn)`, whose + * caller (`task-server.ts`) reaches this the same way; nothing here touches + * disk or the network, so nothing here reads it. + * + * Refusals, in order: an empty or over-long title (same bound and same + * wording as `resolveTitlePromptOrigin`'s own guard), then T2.3's lint on + * the body: a ticket the brain itself would not have been able to publish + * without passing this same gate, but re-checked here rather than trusted, + * since a ticket that failed to lint must never become a task with no + * criteria to judge it against. + * + * `cwd` (unused: nothing here touches disk or the network) is kept for + * call-shape symmetry with `resolveIssueOrigin(cwd, ref, execFn)`: both are + * called from the same three-way ternary in `task-server.ts`'s `create()`. + */ +export function resolveBrainTicketOrigin(_cwd: string, ticket: ArmTicket): BrainTicketOrigin { + const title = ticket.title.trim() + if (!title) { + return { ok: false, refusal: { ok: false, code: 400, error: 'empty title' } } + } + if (title.length > TASK_TITLE_MAX) { + return { + ok: false, + refusal: { ok: false, code: 400, error: `title too long (max ${TASK_TITLE_MAX})` }, + } + } + const lint = lintTicketBody(ticket.body) + if (!lint.ok) { + return { + ok: false, + refusal: { ok: false, code: 400, error: formatTicketProblems(lint.problems) }, + } + } + // Same choice as admitIssue's own prompt (task-issue.ts): the RAW body, + // post-lint, pre-reconstruction, never silently truncated, since dropping + // the tail would silently drop instructions. + const prompt = ticket.body.trim() + if (prompt.length > TASK_TURN_TEXT_MAX) { + return { + ok: false, + refusal: { + ok: false, + code: 400, + error: `ticket body too long to use as the initial prompt (max ${TASK_TURN_TEXT_MAX} chars)`, + }, + } + } + const url = ticket.mr_url ?? ticket.issue?.url + return { + ok: true, + title, + prompt, + issue: null, + issueSnapshot: null, + coverageGap: false, + brainTicket: { id: ticket.id, title, ...(url ? { url } : {}) }, + criteria: lint.body.acceptance_criteria, + } +} + +/** + * Creates a task from a brain ticket. Resolves `cwd` to its registered + * project the same way `task-server.ts`'s own `context()` does + * (`listAll()`, matched on `project.path`) and calls `manager.create()` with + * the ticket as the task's origin: `task-server.ts` resolves it through + * `resolveBrainTicketOrigin` above, so a ticket that fails T2.3's lint never + * reaches `createTask`, and the caller learns why from the very same + * `TaskCreateResult` shape any other origin refuses with. + * + * `autoShip: true`: a brain-ticket task runs unattended end to end (code, + * ship, review, merge), which is exactly what `record.auto_ship` already + * gates (`task-server.ts`'s `auto_ship && status === 'review_ok'`); this + * simply opts every brain-ticket task into it, the same way a human ticking + * "auto-ship" in the UI would for a task they created by hand. + */ +export async function createBrainTicketTask( + manager: TaskManager, + cwd: string, + ticket: ArmTicket, +): Promise { + const project = manager.listAll().find((entry) => entry.project.path === cwd)?.project + if (!project) { + return { ok: false, code: 404, error: 'unknown project' } + } + return manager.create(project.id, { brainTicket: ticket, autoShip: true }) +} diff --git a/packages/cli/src/task-brain.test.ts b/packages/cli/src/task-brain.test.ts new file mode 100644 index 0000000..d438f13 --- /dev/null +++ b/packages/cli/src/task-brain.test.ts @@ -0,0 +1,394 @@ +import { execFileSync } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { loadGlobalConfig, saveGlobalConfig } from './config.js' +import type { ArmOrder, TaskEvent, TaskRecord, TaskTurn } from './contract.js' +import { + flushBrainOutbox, + heartbeatBrainTicket, + queueBrainEvent, + reportBrainTransition, + resetPendingBrainEventBatches, +} from './task-brain.js' + +type Call = { url: string; init: RequestInit } + +/** Same stub as sync.test.ts: records every call, answers one fixed response. */ +function fetchStub(status: number, body: unknown, calls: Call[]): typeof fetch { + return ((url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), init: init ?? {} }) + return Promise.resolve( + new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }), + ) + }) as typeof fetch +} + +/** Never resolves the network call at all: offline. */ +function fetchOffline(message = 'network unreachable'): typeof fetch { + return (() => Promise.reject(new Error(message))) as unknown as typeof fetch +} + +function requestBody(call: Call): Record { + return JSON.parse(String(call.init.body)) as Record +} + +function outboxPath(cwd: string): string { + return join(cwd, '.codesema', 'brain-outbox.jsonl') +} + +function outboxLines(cwd: string): unknown[] { + if (!existsSync(outboxPath(cwd))) { + return [] + } + return readFileSync(outboxPath(cwd), 'utf8') + .split('\n') + .filter((line) => line.trim()) + .map((line) => JSON.parse(line)) +} + +async function settle(): Promise { + // Lets a fire-and-forget effect's own microtask/macrotask chain (fetchStub's + // resolved Response, its own .then chain inside postToBrain) run to + // completion before an assertion reads its side effect. + await new Promise((resolve) => setTimeout(resolve, 20)) +} + +function fakeRecord(overrides: Partial = {}): TaskRecord { + return { + version: 1, + id: 'abc123def456', + title: 't', + status: 'shipped', + base: 'main', + branch: 'codesema/task-t', + worktree: '', + agent_session_id: null, + turns: [], + review_ref: null, + work_ms: 0, + wait_ms: 0, + auto_ship: true, + work_on: false, + isolation: 'policy', + brain_ticket: { id: 'tkt-1', title: 't' }, + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + ...overrides, + } +} + +function fakeTurn(): TaskTurn { + return { + prompt: 'do work', + response: null, + question: null, + started_at: '2026-01-01T00:00:00.000Z', + ended_at: null, + } +} + +function fakeEvent(seq: number): TaskEvent { + return { seq, at: '2026-01-01T00:00:00.000Z', type: 'commit', data: { message: `commit ${seq}` } } +} + +/** exactOptionalPropertyTypes forbids `{ brain_ticket: undefined }`: the key must be ABSENT, not present-as-undefined. */ +function withoutBrainTicket(record: TaskRecord): TaskRecord { + const { brain_ticket: _dropped, ...rest } = record + return rest +} + +describe('task-brain', () => { + const previousConfigDir = process.env.CODESEMA_CONFIG_DIR + let configDir: string + let cwd: string + + beforeEach(() => { + configDir = mkdtempSync(join(tmpdir(), 'codesema-brain-cfg-')) + process.env.CODESEMA_CONFIG_DIR = configDir + cwd = mkdtempSync(join(tmpdir(), 'codesema-brain-repo-')) + saveGlobalConfig({ + ...loadGlobalConfig(), + syncUrl: 'https://brain.example', + syncWorkspaceId: 'ws1', + syncSecret: 'sec1', + }) + }) + + afterEach(() => { + resetPendingBrainEventBatches() + rmSync(configDir, { recursive: true, force: true }) + rmSync(cwd, { recursive: true, force: true }) + if (previousConfigDir === undefined) { + delete process.env.CODESEMA_CONFIG_DIR + } else { + process.env.CODESEMA_CONFIG_DIR = previousConfigDir + } + }) + + describe('reportBrainTransition', () => { + test('a successful report carries the right URL, Bearer header and body', async () => { + const calls: Call[] = [] + const record = fakeRecord() + await reportBrainTransition( + cwd, + record, + { type: 'mr_opened', mr_url: 'https://forge.example/mr/1', branch: 'codesema/task-t' }, + fetchStub(200, {}, calls), + ) + expect(calls.length).toBe(1) + expect(calls[0]?.url).toBe('https://brain.example/api/cli/tickets/tkt-1/transitions') + expect(calls[0]?.init.method).toBe('POST') + const headers = calls[0]?.init.headers as Record + expect(headers.authorization).toBe('Bearer csk_ws1.sec1') + const body = requestBody(calls[0] as Call) + expect(body.type).toBe('mr_opened') + expect(body.mr_url).toBe('https://forge.example/mr/1') + expect(body.branch).toBe('codesema/task-t') + expect(body.idempotency_key).toBe('abc123def456:mr_opened:0') + expect(typeof body.at).toBe('string') + // Nothing queued: a successful send never touches the outbox. + expect(outboxLines(cwd)).toEqual([]) + }) + + test('two review_result reports for the same task at different turn counts get distinct idempotency keys', async () => { + // A fix-loop round settles a SECOND, genuinely different verdict on the + // same task; the brain must not read it as a retry of the first and + // drop it as an already-applied duplicate. + const calls: Call[] = [] + const fetchImpl = fetchStub(200, {}, calls) + await reportBrainTransition( + cwd, + fakeRecord({ turns: [] }), + { type: 'review_result', verdict: 'request_changes' }, + fetchImpl, + ) + await reportBrainTransition( + cwd, + fakeRecord({ turns: [fakeTurn()] }), + { type: 'review_result', verdict: 'approve' }, + fetchImpl, + ) + const keys = calls.map((call) => requestBody(call).idempotency_key) + expect(keys).toEqual(['abc123def456:review_result:0', 'abc123def456:review_result:1']) + }) + + test('a task with no brain_ticket is a no-op', async () => { + const calls: Call[] = [] + const record = withoutBrainTicket(fakeRecord()) + await reportBrainTransition(cwd, record, { type: 'mr_opened' }, fetchStub(200, {}, calls)) + expect(calls.length).toBe(0) + }) + + test('a network failure queues the report in the outbox', async () => { + const record = fakeRecord() + await reportBrainTransition(cwd, record, { type: 'merged' }, fetchOffline()) + const lines = outboxLines(cwd) + expect(lines.length).toBe(1) + const entry = lines[0] as { kind: string; ticket_id: string; transition: { type: string } } + expect(entry.kind).toBe('transition') + expect(entry.ticket_id).toBe('tkt-1') + expect(entry.transition.type).toBe('merged') + }) + + test('a 5xx queues the report in the outbox', async () => { + const calls: Call[] = [] + const record = fakeRecord() + await reportBrainTransition( + cwd, + record, + { type: 'merged' }, + fetchStub(503, { error: 'down' }, calls), + ) + expect(outboxLines(cwd).length).toBe(1) + }) + + test('a 4xx is logged and abandoned, never queued', async () => { + const calls: Call[] = [] + const record = fakeRecord() + await reportBrainTransition( + cwd, + record, + { type: 'failed', error_message: 'boom' }, + fetchStub(409, { error: 'already applied' }, calls), + ) + expect(calls.length).toBe(1) + expect(outboxLines(cwd)).toEqual([]) + }) + }) + + describe('flushBrainOutbox', () => { + test('replays a queued transition and empties the outbox on success', async () => { + const record = fakeRecord() + await reportBrainTransition(cwd, record, { type: 'merged' }, fetchOffline()) + expect(outboxLines(cwd).length).toBe(1) + + const calls: Call[] = [] + await flushBrainOutbox(cwd, fetchStub(200, {}, calls)) + expect(calls.length).toBe(1) + expect(calls[0]?.url).toBe('https://brain.example/api/cli/tickets/tkt-1/transitions') + expect(outboxLines(cwd)).toEqual([]) + }) + + test('a 409 on replay drops the entry rather than keeping it queued', async () => { + const record = fakeRecord() + await reportBrainTransition(cwd, record, { type: 'merged' }, fetchOffline()) + expect(outboxLines(cwd).length).toBe(1) + + const calls: Call[] = [] + await flushBrainOutbox(cwd, fetchStub(409, { error: 'already applied' }, calls)) + expect(calls.length).toBe(1) + expect(outboxLines(cwd)).toEqual([]) + }) + + test('still offline: the entry is kept, not lost', async () => { + const record = fakeRecord() + await reportBrainTransition(cwd, record, { type: 'merged' }, fetchOffline()) + expect(outboxLines(cwd).length).toBe(1) + + await flushBrainOutbox(cwd, fetchOffline('still offline')) + expect(outboxLines(cwd).length).toBe(1) + }) + + test('no outbox file: a no-op', async () => { + const calls: Call[] = [] + await flushBrainOutbox(cwd, fetchStub(200, {}, calls)) + expect(calls.length).toBe(0) + }) + }) + + describe('heartbeatBrainTicket', () => { + test('posts to the ticket heartbeat route with the Bearer header', async () => { + const calls: Call[] = [] + await heartbeatBrainTicket(cwd, fakeRecord(), undefined, fetchStub(200, {}, calls)) + expect(calls.length).toBe(1) + expect(calls[0]?.url).toBe('https://brain.example/api/cli/tickets/tkt-1/heartbeat') + const headers = calls[0]?.init.headers as Record + expect(headers.authorization).toBe('Bearer csk_ws1.sec1') + }) + + test('a task with no brain_ticket is a no-op', async () => { + const calls: Call[] = [] + await heartbeatBrainTicket( + cwd, + withoutBrainTicket(fakeRecord()), + undefined, + fetchStub(200, {}, calls), + ) + expect(calls.length).toBe(0) + }) + + test('sends local_status in the body when given', async () => { + const calls: Call[] = [] + await heartbeatBrainTicket(cwd, fakeRecord(), 'waiting_for_you', fetchStub(200, {}, calls)) + expect(requestBody(calls[0] as Call)).toEqual({ local_status: 'waiting_for_you' }) + }) + + test('omits local_status from the body when not given', async () => { + const calls: Call[] = [] + await heartbeatBrainTicket(cwd, fakeRecord(), undefined, fetchStub(200, {}, calls)) + expect(requestBody(calls[0] as Call)).toEqual({}) + }) + + test('returns the sanitized order the brain hands back', async () => { + const order: ArmOrder = { + action: 'ship', + instruction: null, + issued_at: '2026-01-01T00:00:00.000Z', + } + const fetchImpl = fetchStub(200, { lease_expires_at: '2026-01-01T00:05:00.000Z', order }, []) + const result = await heartbeatBrainTicket(cwd, fakeRecord(), undefined, fetchImpl) + expect(result).toEqual(order) + }) + + test('returns null when the response carries no order', async () => { + const fetchImpl = fetchStub( + 200, + { lease_expires_at: '2026-01-01T00:05:00.000Z', order: null }, + [], + ) + const result = await heartbeatBrainTicket(cwd, fakeRecord(), undefined, fetchImpl) + expect(result).toBeNull() + }) + + test('returns null, without throwing, when the success body is empty or not JSON', async () => { + const fetchImpl = (() => + Promise.resolve(new Response('', { status: 200 }))) as unknown as typeof fetch + const result = await heartbeatBrainTicket(cwd, fakeRecord(), undefined, fetchImpl) + expect(result).toBeNull() + }) + + test('returns null, without throwing, on a network failure', async () => { + const result = await heartbeatBrainTicket(cwd, fakeRecord(), undefined, fetchOffline()) + expect(result).toBeNull() + }) + }) + + describe('queueBrainEvent', () => { + test('flushes once the batch reaches its cap, as one POST /api/cli/events', async () => { + const calls: Call[] = [] + const record = fakeRecord() + const fetchImpl = fetchStub(200, {}, calls) + for (let i = 1; i <= 20; i++) { + queueBrainEvent({ + cwd, + taskId: record.id, + ticketId: record.brain_ticket?.id ?? '', + event: fakeEvent(i), + fetchImpl, + }) + } + await settle() + expect(calls.length).toBe(1) + expect(calls[0]?.url).toBe('https://brain.example/api/cli/events') + const body = requestBody(calls[0] as Call) + expect(body.run_id).toBe(record.id) + expect(body.ticket_id).toBe('tkt-1') + expect(Array.isArray(body.events)).toBe(true) + expect((body.events as unknown[]).length).toBe(20) + }) + + test('the origin remote is cached per cwd: a second flush reuses it even once the repo origin is gone', async () => { + execFileSync('git', ['init'], { cwd, stdio: 'ignore' }) + execFileSync('git', ['remote', 'add', 'origin', 'git@github.com:o/r.git'], { + cwd, + stdio: 'ignore', + }) + const calls: Call[] = [] + const fetchImpl = fetchStub(200, {}, calls) + for (let i = 1; i <= 20; i++) { + queueBrainEvent({ + cwd, + taskId: 'task-a', + ticketId: 'tkt-1', + event: fakeEvent(i), + fetchImpl, + }) + } + await settle() + expect(calls.length).toBe(1) + expect(requestBody(calls[0] as Call).remote_url).toBe('git@github.com:o/r.git') + + // The repo's origin is gone: an uncached read would now answer null. + // A second batch, same cwd, different task: it must still carry the + // cached URL rather than a fresh (and now null) read. + execFileSync('git', ['remote', 'remove', 'origin'], { cwd, stdio: 'ignore' }) + for (let i = 1; i <= 20; i++) { + queueBrainEvent({ + cwd, + taskId: 'task-b', + ticketId: 'tkt-1', + event: fakeEvent(i), + fetchImpl, + }) + } + await settle() + expect(calls.length).toBe(2) + expect(requestBody(calls[1] as Call).remote_url).toBe('git@github.com:o/r.git') + }) + }) +}) diff --git a/packages/cli/src/task-brain.ts b/packages/cli/src/task-brain.ts new file mode 100644 index 0000000..c4abb67 --- /dev/null +++ b/packages/cli/src/task-brain.ts @@ -0,0 +1,522 @@ +// Fire-and-forget reporting from the arm (this CLI) back to the brain: the +// local SaaS that owns a ticket while this workspace executes it. Same +// doctrine as task-labels.ts, its closest sibling: never blocks a task +// transition on a network round trip, and a failure that could not be +// recovered by the outbox is always logged, never swallowed. +// +// The brain is reached at the SAME base URL and with the SAME bearer +// credentials as codesema.com cloud sync (sync.ts): `loadSyncCredentials()` +// and `authHeader()`. No credentials configured, or a task with no +// `brain_ticket`: every export here degrades to a no-op, never a throw, the +// same degrade-to-nothing contract as `pushReview`/`autoPushReview`. +// +// Outbox (`.codesema/brain-outbox.jsonl`): same append-only recipe as +// tasks-store.ts's events.jsonl, one JSON line per entry. A report that hit +// a network failure or a 5xx is appended here and replayed by +// `flushBrainOutbox`; a 4xx (the brain itself rejected the body, a stale +// idempotency key included, on a 409) is logged once and dropped, never +// retried: resending the exact same rejected body would only repeat the +// rejection. + +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { ensureWorkDir } from './config.js' +import { + ARM_LABEL_MAX, + cutCodePoints, + sanitizeArmOrder, + type ArmEvent, + type ArmOrder, + type ArmTransition, + type TaskEvent, + type TaskRecord, + type TaskStatus, +} from './contract.js' +import { tryGitAsync } from './git.js' +import { authHeader, loadSyncCredentials, type SyncCredentials } from './sync.js' + +const BRAIN_REQUEST_TIMEOUT_MS = 10_000 +const BRAIN_EVENT_BATCH_MAX = 20 +const BRAIN_EVENT_BATCH_DELAY_MS = 5_000 + +/** + * A separator that cannot appear in a `cwd` (an absolute path) or a 12-hex + * task id: NUL, built at RUNTIME with `fromCharCode` rather than written as + * a literal escape in a template string, because source-shape.test.ts + * requires every source file to stay byte-for-byte plain text, and a literal + * escape here risks being saved as the raw byte instead (same runtime + * character, but a file `rg` then treats as binary and silently stops + * scanning). + */ +const KEY_SEP = String.fromCharCode(0) + +function brainOutboxPath(cwd: string): string { + return join(cwd, '.codesema', 'brain-outbox.jsonl') +} + +/** + * One entry of the outbox. `key` is a local label only (never sent to the + * brain): it names the report in a log line and lets a caller recognise its + * own write, never a server-side idempotency mechanism. Only + * `ArmTransition.idempotency_key`, inside `transition`, is that. + */ +type BrainOutboxEntry = + | { kind: 'transition'; key: string; ticket_id: string; transition: ArmTransition } + | { + kind: 'events' + key: string + run_id: string + remote_url: string | null + ticket_id: string + events: ArmEvent[] + } + +function appendToOutbox(cwd: string, entry: BrainOutboxEntry): void { + ensureWorkDir(cwd) + try { + const line = `${JSON.stringify(entry)}\n` + writeFileSync(brainOutboxPath(cwd), line, { flag: 'a' }) + } catch (err) { + // The outbox itself could not be written (disk full, permissions): the + // report is lost, and that is said rather than silently swallowed. + logBrainFailure(`outbox write (${entry.kind}, ${entry.key})`, errorMessage(err)) + } +} + +function logBrainFailure(action: string, detail: string): void { + console.warn(`[brain] ${action}: ${detail}`) +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err) +} + +/** + * Cached per `cwd` for the life of the process: a working tree's origin + * remote does not change mid-run, and `flushEventBatch` calls this on every + * event-batch flush, potentially several per task per session. The cache + * stores the PROMISE itself, not its resolved value: two flushes for the + * same `cwd` racing before the first read finishes must share the one git + * call in flight rather than each start their own. + */ +const originRemoteUrlCache = new Map>() + +/** + * Same read as server-context.ts: raw, unnormalized; the brain normalizes it + * server-side. `tryGitAsync`, never the synchronous `tryGit`: this runs on + * every event-batch flush, and a synchronous git call would block the WHOLE + * process for its duration (git.ts's own doc comment on `tryGitAsync` + * describes exactly this pool-blocking scenario). + */ +function originRemoteUrl(cwd: string): Promise { + const cached = originRemoteUrlCache.get(cwd) + if (cached) { + return cached + } + const promise = tryGitAsync(['remote', 'get-url', 'origin'], cwd) + originRemoteUrlCache.set(cwd, promise) + return promise +} + +type BrainPostOutcome = + | { kind: 'ok'; body: unknown } + | { kind: 'client_error'; status: number; detail: string } + | { kind: 'retryable'; detail: string } + +async function postToBrain( + path: string, + body: unknown, + creds: SyncCredentials, + fetchImpl: typeof fetch, +): Promise { + let res: Response + try { + res = await fetchImpl(`${creds.url}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...authHeader(creds) }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(BRAIN_REQUEST_TIMEOUT_MS), + }) + } catch (err) { + return { kind: 'retryable', detail: errorMessage(err) } + } + if (res.ok) { + // Tolerant on purpose: most callers post to routes that answer with no + // body at all, and JSON.parse on an empty string throws rather than + // returning something falsy, so a route that DOES answer with a body + // (the heartbeat's `order`, D19) is read the same tolerant way instead + // of every other caller needing its own empty-body special case. + const responseBody = await res.json().catch(() => undefined) + return { kind: 'ok', body: responseBody } + } + const parsed = (await res.json().catch(() => ({}))) as { error?: unknown } + const detail = typeof parsed.error === 'string' ? parsed.error : `HTTP ${res.status}` + // 5xx: the brain itself is unwell, worth a retry once it recovers. Anything + // else in the 4xx family: the request itself was refused (bad body, unknown + // ticket, a 409 replay of an idempotency key already applied) and a retry + // would only repeat the same refusal. + return res.status >= 500 + ? { kind: 'retryable', detail } + : { kind: 'client_error', status: res.status, detail } +} + +/** + * Reports one fact about a brain ticket's execution back to the brain: + * `mr_opened` on ship, `review_result` on a settled review verdict, `merged` + * on a landed merge, `failed` on a failure or an explicit interruption. A + * no-op for a task that carries no `brain_ticket`, and for a machine with no + * sync credentials configured. + * + * `idempotency_key` and `at` are computed here, never by the caller: the key + * is `::`, stable for a given task, transition type + * and turn count, which is what makes a retried report (this call, or its + * outbox replay) land on the SAME fact rather than mint a second one, while + * still telling apart two DIFFERENT facts of the same type on the same task + * (`review_result` after each of several fix-loop rounds carries a genuinely + * different verdict; a constant key would have the brain read every round + * past the first as a duplicate of the first and drop it). + * + * Never throws. Offline, or a 5xx: appended to `.codesema/brain-outbox.jsonl` + * for `flushBrainOutbox` to replay later. A 4xx: logged once and abandoned, + * never retried. + */ +export async function reportBrainTransition( + cwd: string, + record: TaskRecord, + transition: Omit, + fetchImpl: typeof fetch = fetch, +): Promise { + const ticketId = record.brain_ticket?.id + if (!ticketId) { + return + } + const full: ArmTransition = { + ...transition, + idempotency_key: `${record.id}:${transition.type}:${record.turns.length}`, + at: new Date().toISOString(), + } + const label = `transition '${transition.type}' for task ${record.id}` + const creds = loadSyncCredentials() + if (!creds) { + logBrainFailure(label, 'no sync credentials configured') + return + } + try { + const outcome = await postToBrain( + `/api/cli/tickets/${encodeURIComponent(ticketId)}/transitions`, + full, + creds, + fetchImpl, + ) + if (outcome.kind === 'ok') { + return + } + if (outcome.kind === 'client_error') { + logBrainFailure( + label, + `rejected by the brain (${outcome.status}): ${outcome.detail}; abandoned`, + ) + return + } + logBrainFailure(label, `${outcome.detail}; queued for retry`) + appendToOutbox(cwd, { + kind: 'transition', + key: full.idempotency_key, + ticket_id: ticketId, + transition: full, + }) + } catch (err) { + // The seam contract says postToBrain never rejects, but a fire-and-forget + // effect must not depend on that holding forever (same discipline as + // task-labels.ts's syncCycleLabel): caught here rather than left to + // become an unhandled rejection. + logBrainFailure(label, `${errorMessage(err)}; queued for retry`) + appendToOutbox(cwd, { + kind: 'transition', + key: full.idempotency_key, + ticket_id: ticketId, + transition: full, + }) + } +} + +/** + * Reads the `order` field off a heartbeat response body without assuming its + * shape: `body` is `unknown` (postToBrain only ever confirms "this parsed as + * JSON"), so this is the one narrowing step between the wire and + * `sanitizeArmOrder`, which validates everything else about it. + */ +function orderFieldOf(body: unknown): unknown { + return body && typeof body === 'object' && 'order' in body + ? (body as { order: unknown }).order + : undefined +} + +/** + * Sends a heartbeat for a task's brain ticket lease, and returns the order a + * human decided from the dashboard while this ticket sat waiting (D19): + * ship, reply with an instruction, or abandon. `null` on every ordinary tick + * nothing is waiting on, and on any failure. + * + * No outbox: a missed heartbeat is superseded by the next one (the daemon + * owns the 45s timer, not this module), and a stale order is superseded the + * same way (the brain purges an order the moment it hands it back, so the + * next heartbeat only ever carries a fresh one, or none). Retrying either is + * never useful. Never throws. + * + * `localStatus`, when given, rides along as `local_status` so the brain can + * show this ticket as waiting (or not) on its own dashboard; omitted, the + * body is `{}`, same as before D19. + * + * `cwd` (unused) is kept for call-shape symmetry with this module's other + * exports (`reportBrainTransition`, `queueBrainEvent`), all of which the + * daemon calls the same way; a heartbeat needs only the ticket id. + */ +export async function heartbeatBrainTicket( + _cwd: string, + record: TaskRecord, + localStatus?: TaskStatus, + fetchImpl: typeof fetch = fetch, +): Promise { + const ticketId = record.brain_ticket?.id + if (!ticketId) { + return null + } + const creds = loadSyncCredentials() + if (!creds) { + return null + } + const label = `heartbeat for task ${record.id}` + try { + const outcome = await postToBrain( + `/api/cli/tickets/${encodeURIComponent(ticketId)}/heartbeat`, + localStatus ? { local_status: localStatus } : {}, + creds, + fetchImpl, + ) + if (outcome.kind !== 'ok') { + logBrainFailure(label, outcome.detail) + return null + } + return sanitizeArmOrder(orderFieldOf(outcome.body)) + } catch (err) { + logBrainFailure(label, errorMessage(err)) + return null + } +} + +// --- events: batched per task ---------------------------------------------- + +type PendingEventBatch = { + cwd: string + runId: string + ticketId: string + events: ArmEvent[] + timer: ReturnType +} + +const pendingEventBatches = new Map() + +/** The label a journal line carries to the brain: its own message, its own name, or its bare type. */ +function armEventLabel(event: TaskEvent): string { + const data = event.data as Record | undefined + if (typeof data?.message === 'string' && data.message) { + return data.message + } + if (typeof data?.name === 'string' && data.name) { + return data.name + } + return event.type +} + +function armEventFrom(taskId: string, event: TaskEvent): ArmEvent { + return { + run_id: taskId, + at: event.at, + event_type: event.type, + // Bounded HERE, not only by the brain's schema: one oversized label (a + // forge CLI dumping its usage text into a message) must degrade to a cut + // label, never poison its whole batch with a 422. + label: cutCodePoints(armEventLabel(event), ARM_LABEL_MAX) || event.type, + ...(event.data && Object.keys(event.data).length > 0 ? { payload: event.data } : {}), + } +} + +async function flushEventBatch(key: string, fetchImpl: typeof fetch): Promise { + const batch = pendingEventBatches.get(key) + if (!batch) { + return + } + pendingEventBatches.delete(key) + clearTimeout(batch.timer) + const label = `${batch.events.length} event(s) for task ${batch.runId}` + const creds = loadSyncCredentials() + if (!creds) { + logBrainFailure(label, 'no sync credentials configured') + return + } + const remoteUrl = await originRemoteUrl(batch.cwd) + const body = { + remote_url: remoteUrl, + run_id: batch.runId, + ticket_id: batch.ticketId, + events: batch.events, + } + const enqueueForRetry = (): void => { + appendToOutbox(batch.cwd, { + kind: 'events', + key: `${batch.runId}:event:${batch.events.length}`, + run_id: batch.runId, + remote_url: remoteUrl, + ticket_id: batch.ticketId, + events: batch.events, + }) + } + try { + const outcome = await postToBrain('/api/cli/events', body, creds, fetchImpl) + if (outcome.kind === 'ok') { + return + } + if (outcome.kind === 'client_error') { + logBrainFailure( + label, + `rejected by the brain (${outcome.status}): ${outcome.detail}; abandoned`, + ) + return + } + logBrainFailure(label, `${outcome.detail}; queued for retry`) + enqueueForRetry() + } catch (err) { + logBrainFailure(label, `${errorMessage(err)}; queued for retry`) + enqueueForRetry() + } +} + +/** + * Queues one task journal line for the brain, batched with its task's other + * pending lines into ONE `POST /api/cli/events`, sent once 20 events have + * queued, or 5s after the first one did, whichever comes first. Meant to be + * called only for a task that carries a `brain_ticket` (`tasks-store.ts`'s + * `appendTaskEvent` is the one caller, gated on that); `ticketId` is taken + * from it directly rather than re-derived, so this module never has to load + * a task record to do its job. Never throws. + */ +export function queueBrainEvent(opts: { + cwd: string + taskId: string + ticketId: string + event: TaskEvent + fetchImpl?: typeof fetch +}): void { + const { cwd, taskId, ticketId, event, fetchImpl = fetch } = opts + const armEvent = armEventFrom(taskId, event) + const key = `${cwd}${KEY_SEP}${taskId}` + const existing = pendingEventBatches.get(key) + if (existing) { + existing.events.push(armEvent) + if (existing.events.length >= BRAIN_EVENT_BATCH_MAX) { + void flushEventBatch(key, fetchImpl) + } + return + } + const timer = setTimeout(() => { + void flushEventBatch(key, fetchImpl) + }, BRAIN_EVENT_BATCH_DELAY_MS) + // A pending batch must never keep the process alive on its own: shutdown + // must not wait out a 5s timer nobody else is blocking on. + timer.unref?.() + pendingEventBatches.set(key, { cwd, runId: taskId, ticketId, events: [armEvent], timer }) +} + +/** + * Test hygiene: drops every pending batch and its timer, and the cached + * origin-remote reads alongside it. Never used in production code. + */ +export function resetPendingBrainEventBatches(): void { + for (const batch of pendingEventBatches.values()) { + clearTimeout(batch.timer) + } + pendingEventBatches.clear() + originRemoteUrlCache.clear() +} + +// --- outbox replay ----------------------------------------------------------- + +function outboxRequest(entry: BrainOutboxEntry): { path: string; body: unknown } { + if (entry.kind === 'transition') { + return { + path: `/api/cli/tickets/${encodeURIComponent(entry.ticket_id)}/transitions`, + body: entry.transition, + } + } + return { + path: '/api/cli/events', + body: { + remote_url: entry.remote_url, + run_id: entry.run_id, + ticket_id: entry.ticket_id, + events: entry.events, + }, + } +} + +/** + * Replays every entry `.codesema/brain-outbox.jsonl` holds, in file order, + * and rewrites the file with only what still could not be sent. A line this + * process cannot parse (a hand edit, a crash-truncated tail) is dropped + * rather than kept forever unreadable, the same tolerance + * `tasks-store.ts`'s own journal reader gives a corrupt event line. A 4xx on + * replay (a 409 included: the brain already applied this idempotency key) + * drops the entry for good, same rule as a fresh send. Never throws. + */ +export async function flushBrainOutbox( + cwd: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const path = brainOutboxPath(cwd) + if (!existsSync(path)) { + return + } + let raw: string + try { + raw = readFileSync(path, 'utf8') + } catch { + return + } + const creds = loadSyncCredentials() + const remaining: BrainOutboxEntry[] = [] + for (const line of raw.split('\n')) { + if (!line.trim()) { + continue + } + let entry: BrainOutboxEntry + try { + entry = JSON.parse(line) as BrainOutboxEntry + } catch { + continue + } + if (!creds) { + remaining.push(entry) + continue + } + const { path: requestPath, body } = outboxRequest(entry) + const label = `outbox replay (${entry.kind}, ${entry.key})` + try { + const outcome = await postToBrain(requestPath, body, creds, fetchImpl) + if (outcome.kind === 'retryable') { + logBrainFailure(label, `${outcome.detail}; kept for retry`) + remaining.push(entry) + } else if (outcome.kind === 'client_error') { + logBrainFailure( + label, + `rejected by the brain (${outcome.status}): ${outcome.detail}; abandoned`, + ) + } + // 'ok': dropped in silence, a successful replay is not news. + } catch (err) { + logBrainFailure(label, `${errorMessage(err)}; kept for retry`) + remaining.push(entry) + } + } + writeFileSync(path, remaining.map((entry) => `${JSON.stringify(entry)}\n`).join('')) +} diff --git a/packages/cli/src/task-checks.test.ts b/packages/cli/src/task-checks.test.ts index 902b952..77b48c5 100644 --- a/packages/cli/src/task-checks.test.ts +++ b/packages/cli/src/task-checks.test.ts @@ -6,8 +6,11 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { containerGitStateDir } from './container-git.js' import { TASK_CHECK_TAIL_MAX, type TaskChecks } from './contract.js' import { + AD_HOC_CHECK_DEFAULT_TIMEOUT_SECONDS, bootstrapWorktreeInstall, + buildChecksChapter, BUN_INSTALL_COMMAND, + CHECKS_CHAPTER_TAIL_MAX, DEFAULT_CHECK_TIMEOUT_SECONDS, DEFAULT_CHECKS_IMAGE, detectChecks, @@ -18,6 +21,7 @@ import { pkgCacheVolume, planFromConfig, resolveChecksPlan, + runAdHocCheck, runChecks, worktreeHasDeps, type ExecFn, @@ -893,3 +897,187 @@ describe('resolveChecksPlan', () => { expect(calls.some((c) => c.args.at(-1) === 'bun run lint')).toBe(false) }) }) + +// --- runAdHocCheck (D17) ---------------------------------------------------- + +describe('runAdHocCheck', () => { + test('success: passed, run caged the same way a checks step is (rw mount, no network)', async () => { + const { calls, exec } = dockerRig(() => ok({ stdout: 'ok' })) + const result = await runAdHocCheck({ worktree, command: 'echo hi', execFn: exec }) + expect(result).toEqual({ + command: 'echo hi', + status: 'passed', + exit_code: 0, + duration_ms: expect.any(Number), + tail: 'ok', + }) + const run = calls.find((c) => c.args.at(-1) === 'echo hi') + const args = run?.args ?? [] + expect(args[args.indexOf('--network') + 1]).toBe('none') + expect(args[args.indexOf('-v') + 1]).toBe(`${worktree}:/work:rw`) + // No install step: nothing runs before the command itself. + expect(calls.filter((c) => c.args[0] === 'run')).toHaveLength(1) + }) + + test('failure: a nonzero exit is failed, tail carries stderr', async () => { + const { exec } = dockerRig(() => ok({ code: 1, stderr: 'boom' })) + const result = await runAdHocCheck({ worktree, command: 'false', execFn: exec }) + expect(result.status).toBe('failed') + expect(result.exit_code).toBe(1) + expect(result.tail).toContain('boom') + }) + + test('timeout: marked timeout, exit_code null, the default budget is AD_HOC_CHECK_DEFAULT_TIMEOUT_SECONDS', async () => { + const { calls, exec } = dockerRig(() => ok({ code: null, timedOut: true })) + const result = await runAdHocCheck({ worktree, command: 'sleep 999', execFn: exec }) + expect(result.status).toBe('timeout') + expect(result.exit_code).toBeNull() + const run = calls.find((c) => c.args.at(-1) === 'sleep 999') + expect(run?.timeoutMs).toBe(AD_HOC_CHECK_DEFAULT_TIMEOUT_SECONDS * 1000) + }) + + test('an explicit timeoutSeconds overrides the default', async () => { + const { calls, exec } = dockerRig(() => ok()) + await runAdHocCheck({ worktree, command: 'echo hi', timeoutSeconds: 5, execFn: exec }) + const run = calls.find((c) => c.args.at(-1) === 'echo hi') + expect(run?.timeoutMs).toBe(5000) + }) + + test('no container runtime: a synthetic failed result, never throws', async () => { + const { calls, exec } = fakeExec(() => ok({ code: null, failure: 'spawn docker ENOENT' })) + const result = await runAdHocCheck({ worktree, command: 'echo hi', execFn: exec }) + expect(result).toEqual({ + command: 'echo hi', + status: 'failed', + exit_code: null, + duration_ms: 0, + tail: expect.stringContaining('docker or podman'), + }) + // No run was ever attempted past the two --version probes. + expect(calls.every((c) => c.args[0] === '--version')).toBe(true) + }) + + test('defaults to DEFAULT_CHECKS_IMAGE when no image is given', async () => { + const { calls, exec } = dockerRig(() => ok()) + await runAdHocCheck({ worktree, command: 'echo hi', execFn: exec }) + const run = calls.find((c) => c.args.at(-1) === 'echo hi') + expect(run?.args).toContain(DEFAULT_CHECKS_IMAGE) + }) + + test('an explicit image is used instead of the default', async () => { + const { calls, exec } = dockerRig(() => ok()) + await runAdHocCheck({ worktree, command: 'echo hi', image: 'python:3.12', execFn: exec }) + const run = calls.find((c) => c.args.at(-1) === 'echo hi') + expect(run?.args).toContain('python:3.12') + expect(run?.args).not.toContain(DEFAULT_CHECKS_IMAGE) + }) +}) + +// --- buildChecksChapter (D16) ----------------------------------------------- + +describe('buildChecksChapter', () => { + const baseChecks: TaskChecks = { + head_sha: 'abc', + started_at: '2026-08-26T10:00:00.000Z', + finished_at: '2026-08-26T10:01:00.000Z', + status: 'passed', + error: null, + checks: [], + } + + test('a passed-only run: one line per check, no tail, the "fact" closing', () => { + const checks: TaskChecks = { + ...baseChecks, + source: 'scripts', + checks: [{ command: 'bun test', status: 'passed', exit_code: 0, duration_ms: 5, tail: 'ok' }], + } + const chapter = buildChecksChapter(checks) + expect(chapter).toContain('MANDATORY chapter (passed, source: scripts)') + expect(chapter.split('\n')).toContain('- bun test: passed') + // The tail of a GREEN check is never spent: it needs no evidence. + expect(chapter).not.toContain('ok') + expect(chapter).toContain('is a fact, not a hypothesis') + }) + + test('a failing check carries its tail, truncated to the LAST CHECKS_CHAPTER_TAIL_MAX chars', () => { + // "DROPPED" sits at the FRONT of a tail otherwise exactly + // CHECKS_CHAPTER_TAIL_MAX long: slicing the last N characters must cut it + // whole and leave the 'x' run untouched. The end of a tail is where the + // verdict lives, never the start. + const longTail = `DROPPED${'x'.repeat(CHECKS_CHAPTER_TAIL_MAX)}` + const checks: TaskChecks = { + ...baseChecks, + status: 'failed', + checks: [ + { command: 'bun test', status: 'failed', exit_code: 1, duration_ms: 5, tail: longTail }, + ], + } + const chapter = buildChecksChapter(checks) + expect(chapter).toContain('- bun test: failed') + expect(chapter).toContain('x'.repeat(CHECKS_CHAPTER_TAIL_MAX)) + expect(chapter).not.toContain('DROPPED') + }) + + test('a skipped check gets no tail either, even sitting beside a failed one that does', () => { + const checks: TaskChecks = { + ...baseChecks, + status: 'failed', + checks: [ + { + command: 'npm ci', + status: 'failed', + exit_code: 1, + duration_ms: 5, + tail: 'lockfile mismatch', + }, + { command: 'npm run test', status: 'skipped', exit_code: null, duration_ms: 0, tail: '' }, + ], + } + const chapter = buildChecksChapter(checks) + expect(chapter.split('\n')).toContain('- npm run test: skipped') + expect(chapter).toContain('lockfile mismatch') + }) + + test('unconfigured: says so plainly, no check lines', () => { + const chapter = buildChecksChapter({ ...baseChecks, status: 'unconfigured' }) + expect(chapter).toContain('No checks are detected or configured') + }) + + test('error: names the engine failure, never a check result', () => { + const chapter = buildChecksChapter({ + ...baseChecks, + status: 'error', + error: 'no container runtime found', + }) + expect(chapter).toContain('The checks engine itself failed to run: no container runtime found') + }) + + test('purpose review vs fix: the closing instruction differs, the body does not', () => { + const checks: TaskChecks = { + ...baseChecks, + status: 'failed', + checks: [ + { command: 'bun test', status: 'failed', exit_code: 1, duration_ms: 5, tail: 'FAIL' }, + ], + } + const review = buildChecksChapter(checks, { purpose: 'review' }) + const fix = buildChecksChapter(checks, { purpose: 'fix' }) + expect(review).toContain('is a fact, not a hypothesis') + expect(review).not.toContain('What must still pass') + expect(fix).toContain('What must still pass') + expect(fix).not.toContain('is a fact, not a hypothesis') + expect(review).toContain('- bun test: failed') + expect(fix).toContain('- bun test: failed') + }) + + test('purpose defaults to review when omitted', () => { + const checks: TaskChecks = { + ...baseChecks, + status: 'failed', + checks: [ + { command: 'bun test', status: 'failed', exit_code: 1, duration_ms: 5, tail: 'FAIL' }, + ], + } + expect(buildChecksChapter(checks)).toBe(buildChecksChapter(checks, { purpose: 'review' })) + }) +}) diff --git a/packages/cli/src/task-checks.ts b/packages/cli/src/task-checks.ts index 59dc2ee..18f4627 100644 --- a/packages/cli/src/task-checks.ts +++ b/packages/cli/src/task-checks.ts @@ -990,6 +990,103 @@ async function ensureInstallExtraArgs(opts: { ] } +/** Wall-clock budget for a criterion's ad hoc `[proof:command ...]` check (D17), kept short on purpose: this runs inline in the review path, never as a background job. */ +export const AD_HOC_CHECK_DEFAULT_TIMEOUT_SECONDS = 60 + +export type RunAdHocCheckOptions = { + /** The task's worktree: the ONLY host path the container ever sees, same as `RunChecksOptions`. */ + worktree: string + /** The criterion's `[proof:command ...]` argument, run verbatim. */ + command: string + /** Defaults to `DEFAULT_CHECKS_IMAGE`: a criterion's command names no stack, so nothing better can be inferred. */ + image?: string + timeoutSeconds?: number + execFn?: ExecFn +} + +/** + * Runs ONE command in an ephemeral checks container, for a `[proof:command + * ...]` criterion (D17) whose command is not among the task's own + * `TaskChecks.checks[]`. `task-criteria-gate.ts`'s `resolveMechanicalCriteria` + * is the only caller. No install step (the worktree carries whatever + * dependencies its last checks run, or the agent's own turn, left behind) + * and never network, unlike a checks run's own install step: a criterion's + * command is not trusted with either. Never throws: an absent container + * runtime is reported as a synthetic 'failed' result, the same discipline + * `runChecks` itself follows for every engine-level problem. + */ +export async function runAdHocCheck(opts: RunAdHocCheckOptions): Promise { + const exec = opts.execFn ?? defaultExec + const runtime = await containerRuntime(opts.execFn) + if (!runtime) { + return { + command: opts.command, + status: 'failed', + exit_code: null, + duration_ms: 0, + tail: 'no container runtime found: install docker or podman to run this check', + } + } + const git = prepareContainerGit({ worktree: opts.worktree, workDir: CHECKS_WORK_DIR }) + const { result } = await runStep({ + exec, + runtime, + step: { command: opts.command, network: false }, + plan: { + image: opts.image?.trim() || DEFAULT_CHECKS_IMAGE, + install: null, + commands: [], + network: false, + timeoutSeconds: opts.timeoutSeconds ?? AD_HOC_CHECK_DEFAULT_TIMEOUT_SECONDS, + source: 'config', + }, + worktree: opts.worktree, + gitMounts: git?.mountArgs ?? [], + }) + return result +} + +/** How much of a non-passed/skipped check's tail a prompt chapter spends, far smaller than `TASK_CHECK_TAIL_MAX`: this travels in a model's context, not a log. */ +export const CHECKS_CHAPTER_TAIL_MAX = 600 + +/** + * The mandatory chapter a task's checks contribute to a review or fix prompt + * (D16). The commands already ran, in an isolated container, before this + * prompt was ever built: the whole point of this chapter is that neither the + * reviewer nor a fixing agent has to re-derive a check's outcome from the + * diff. A status here is a fact of THIS run, not an inference to make again. + */ +export function buildChecksChapter( + checks: TaskChecks, + opts: { purpose?: 'review' | 'fix' } = {}, +): string { + const header = `Repository checks, MANDATORY chapter (${checks.status}${ + checks.source ? `, source: ${checks.source}` : '' + }):` + if (checks.status === 'unconfigured') { + return [header, 'No checks are detected or configured for this repository.'].join('\n') + } + if (checks.status === 'error') { + return [ + header, + `The checks engine itself failed to run: ${checks.error ?? 'unknown error'}.`, + ].join('\n') + } + const lines = checks.checks.map((check) => { + const line = `- ${check.command}: ${check.status}` + // Only a check that is neither green nor merely skipped earns its tail. + // A pass needs no evidence and a skip has none to show. + return check.status === 'passed' || check.status === 'skipped' + ? line + : `${line}\n ${check.tail.slice(-CHECKS_CHAPTER_TAIL_MAX)}` + }) + const closing = + (opts.purpose ?? 'review') === 'fix' + ? 'What must still pass: make every failed or timed-out command above exit 0. These already ran once against your last commit and will run again against your next one.' + : 'These commands already ran in an isolated container: a passed check is not to be re-derived from the diff, and a failed or timed-out one is a fact, not a hypothesis to weigh against the code.' + return [header, ...lines, '', closing].join('\n') +} + export type BootstrapInstallStatus = 'passed' | 'skipped' | 'failed' | 'unconfigured' export type BootstrapInstallResult = { diff --git a/packages/cli/src/task-criteria-gate.test.ts b/packages/cli/src/task-criteria-gate.test.ts index 5b52976..815cb1e 100644 --- a/packages/cli/src/task-criteria-gate.test.ts +++ b/packages/cli/src/task-criteria-gate.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, test } from 'bun:test' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { acceptanceCriterionId, CRITERION_VERDICT_EVIDENCE_MAX, @@ -6,13 +9,18 @@ import { TICKET_CRITERIA_MAX, type AcceptanceCriterion, type CriterionVerdict, + type TaskChecks, } from './contract.js' +import type { ExecFn } from './task-checks.js' import { buildCriteriaChapter, + combineCriteriaOutcomes, CRITERIA_REASON_IDS_MAX, criteriaUnmetDetail, mergeCriterionVerdicts, + partitionCriteriaByProof, resolveCriteria, + resolveMechanicalCriteria, unmetCriteriaFixChapter, } from './task-criteria-gate.js' @@ -700,3 +708,275 @@ describe('CRITERIA_REASON_IDS_MAX', () => { expect(detail).toContain('9 unclear') }) }) + +// --- D17: mechanical criteria ----------------------------------------------- + +const MC_COMMAND = criterion('WHEN checks run THE SYSTEM SHALL pass them [proof:command bun test]') +const MC_DIFF = criterion( + 'WHEN the gate changes THE SYSTEM SHALL touch it [proof:diff src/gate.ts]', +) +const MC_DIFF_GHOST = criterion( + 'WHEN a ghost file changes THE SYSTEM SHALL touch it [proof:diff src/ghost.ts]', +) +const MC_READ = criterion('WHEN a marker exists THE SYSTEM SHALL find it [proof:read marker.txt]') +const MC_READ_SUBSTRING = criterion( + 'WHEN a marker has content THE SYSTEM SHALL find it [proof:read marker.txt :: hello]', +) +const JUDGMENT_TAGGED = criterion( + 'WHEN judged explicitly THE SYSTEM SHALL still ask a human [proof:judgment]', +) + +function checksOf(over: Partial = {}): TaskChecks { + return { + head_sha: 'abc', + started_at: '2026-08-26T10:00:00.000Z', + finished_at: '2026-08-26T10:01:00.000Z', + status: 'passed', + error: null, + checks: [], + ...over, + } +} + +describe('partitionCriteriaByProof', () => { + test('splits a mixed list, each side keeping the input order', () => { + const { mechanical, judged } = partitionCriteriaByProof([ + C1, + MC_COMMAND, + JUDGMENT_TAGGED, + MC_DIFF, + ]) + expect(mechanical.map((c) => c.id)).toEqual([MC_COMMAND.id, MC_DIFF.id]) + expect(mechanical.map((c) => c.proof.method)).toEqual(['command', 'diff']) + expect(judged).toEqual([C1, JUDGMENT_TAGGED]) + }) + + test('a criterion written before D17 (no proof tag at all) lands in judged', () => { + const { mechanical, judged } = partitionCriteriaByProof(TASK_CRITERIA) + expect(mechanical).toEqual([]) + expect(judged).toEqual(TASK_CRITERIA) + }) + + test('[proof:judgment] is judged, never mechanical, even though it is a well-formed tag', () => { + const { mechanical, judged } = partitionCriteriaByProof([JUDGMENT_TAGGED]) + expect(mechanical).toEqual([]) + expect(judged).toEqual([JUDGMENT_TAGGED]) + }) +}) + +describe('resolveMechanicalCriteria', () => { + let worktree: string + + beforeEach(() => { + worktree = mkdtempSync(join(tmpdir(), 'codesema-criteria-gate-')) + }) + + afterEach(() => { + rmSync(worktree, { recursive: true, force: true }) + }) + + test('command: a match in the turn checks decides it, passed is met, no ad hoc run', async () => { + const { mechanical } = partitionCriteriaByProof([MC_COMMAND]) + let adHocRan = false + const execFn: ExecFn = async () => { + adHocRan = true + return { code: 0, stdout: '', stderr: '', timedOut: false, failure: null } + } + const verdicts = await resolveMechanicalCriteria(mechanical, { + worktree: '/nonexistent', + diff: '', + checks: checksOf({ + checks: [{ command: 'bun test', status: 'passed', exit_code: 0, duration_ms: 1, tail: '' }], + }), + execFn, + }) + expect(verdicts).toEqual([ + { criterion_id: MC_COMMAND.id, status: 'met', evidence: expect.stringContaining('bun test') }, + ]) + expect(adHocRan).toBe(false) + }) + + test('command: a match that failed or timed out is unmet', async () => { + const { mechanical } = partitionCriteriaByProof([MC_COMMAND]) + for (const status of ['failed', 'timeout'] as const) { + const verdicts = await resolveMechanicalCriteria(mechanical, { + worktree: '/nonexistent', + diff: '', + checks: checksOf({ + checks: [{ command: 'bun test', status, exit_code: 1, duration_ms: 1, tail: '' }], + }), + }) + expect(verdicts[0]?.status).toBe('unmet') + } + }) + + test('command: absent from the turn checks runs an ad hoc check instead', async () => { + const { mechanical } = partitionCriteriaByProof([MC_COMMAND]) + const ranCommands: string[] = [] + const execFn: ExecFn = async (_file, args) => { + if (args[0] === '--version') { + return { + code: 0, + stdout: 'Docker version 27', + stderr: '', + timedOut: false, + failure: null, + } + } + ranCommands.push(args.at(-1) ?? '') + return { code: 0, stdout: '', stderr: '', timedOut: false, failure: null } + } + const verdicts = await resolveMechanicalCriteria(mechanical, { + worktree: '/nonexistent', + diff: '', + checks: null, + execFn, + }) + expect(verdicts[0]?.status).toBe('met') + expect(ranCommands.some((c) => c === 'bun test')).toBe(true) + }) + + test('command: a match still SKIPPED carries no fact and also falls back to an ad hoc run', async () => { + const { mechanical } = partitionCriteriaByProof([MC_COMMAND]) + let adHocRan = false + const execFn: ExecFn = async (_file, args) => { + if (args[0] === '--version') { + return { code: 0, stdout: 'Docker version 27', stderr: '', timedOut: false, failure: null } + } + adHocRan = true + return { code: 1, stdout: '', stderr: '', timedOut: false, failure: null } + } + const verdicts = await resolveMechanicalCriteria(mechanical, { + worktree: '/nonexistent', + diff: '', + checks: checksOf({ + checks: [ + { command: 'bun test', status: 'skipped', exit_code: null, duration_ms: 0, tail: '' }, + ], + }), + execFn, + }) + expect(adHocRan).toBe(true) + expect(verdicts[0]?.status).toBe('unmet') + }) + + test('diff: met when the argument names a file the diff touches, unmet otherwise', async () => { + const { mechanical: touched } = partitionCriteriaByProof([MC_DIFF]) + const metVerdicts = await resolveMechanicalCriteria(touched, { + worktree: '/nonexistent', + diff: DIFF, + checks: null, + }) + expect(metVerdicts[0]?.status).toBe('met') + + const { mechanical: ghost } = partitionCriteriaByProof([MC_DIFF_GHOST]) + const unmetVerdicts = await resolveMechanicalCriteria(ghost, { + worktree: '/nonexistent', + diff: DIFF, + checks: null, + }) + expect(unmetVerdicts[0]?.status).toBe('unmet') + }) + + test('read: met on bare presence, unmet when the file is missing', async () => { + writeFileSync(join(worktree, 'marker.txt'), 'hello world') + const { mechanical } = partitionCriteriaByProof([MC_READ]) + const present = await resolveMechanicalCriteria(mechanical, { + worktree, + diff: '', + checks: null, + }) + expect(present[0]?.status).toBe('met') + + const { mechanical: missing } = partitionCriteriaByProof([ + criterion('WHEN a marker exists THE SYSTEM SHALL find it [proof:read gone.txt]'), + ]) + const absent = await resolveMechanicalCriteria(missing, { worktree, diff: '', checks: null }) + expect(absent[0]?.status).toBe('unmet') + }) + + test('read: the "path :: substring" form checks the substring too', async () => { + writeFileSync(join(worktree, 'marker.txt'), 'hello world') + const { mechanical } = partitionCriteriaByProof([MC_READ_SUBSTRING]) + const found = await resolveMechanicalCriteria(mechanical, { worktree, diff: '', checks: null }) + expect(found[0]?.status).toBe('met') + + const { mechanical: notFound } = partitionCriteriaByProof([ + criterion( + 'WHEN a marker has content THE SYSTEM SHALL find it [proof:read marker.txt :: goodbye]', + ), + ]) + const missingSubstring = await resolveMechanicalCriteria(notFound, { + worktree, + diff: '', + checks: null, + }) + expect(missingSubstring[0]?.status).toBe('unmet') + }) + + test('read: a malformed argument (no path before "::") still resolves rather than throwing', async () => { + const { mechanical } = partitionCriteriaByProof([ + criterion('WHEN X THE SYSTEM SHALL Y [proof:read :: hello]'), + ]) + const verdicts = await resolveMechanicalCriteria(mechanical, { + worktree, + diff: '', + checks: null, + }) + expect(verdicts[0]?.status).toBe('unmet') + }) +}) + +describe('combineCriteriaOutcomes', () => { + test('satisfied iff every criterion, mechanical and judged alike, is met', () => { + const mechanicalVerdicts = [met(MC_COMMAND, 'ran'), met(MC_DIFF, 'touched')] + const judgedOutcome = resolveCriteria([C1], [met(C1, ANCHORED)], DIFF) + const outcome = combineCriteriaOutcomes( + [MC_COMMAND, MC_DIFF, C1], + mechanicalVerdicts, + judgedOutcome, + ) + expect(outcome.satisfied).toBe(true) + expect(outcome.counts).toEqual({ met: 3, unmet: 0, unclear: 0 }) + expect(outcome.verdicts.map((v) => v.criterion_id)).toEqual([MC_COMMAND.id, MC_DIFF.id, C1.id]) + }) + + test('one mechanical unmet blocks satisfied even though every judged criterion is met', () => { + const mechanicalVerdicts: CriterionVerdict[] = [ + { criterion_id: MC_COMMAND.id, status: 'unmet', evidence: 'failed' }, + ] + const judgedOutcome = resolveCriteria([C1], [met(C1, ANCHORED)], DIFF) + const outcome = combineCriteriaOutcomes([MC_COMMAND, C1], mechanicalVerdicts, judgedOutcome) + expect(outcome.satisfied).toBe(false) + expect(outcome.counts).toEqual({ met: 1, unmet: 1, unclear: 0 }) + }) + + test('the grounding-shaped fields are carried over from judgedOutcome untouched', () => { + const judgedOutcome = resolveCriteria( + [C1], + [met(C1, 'src/ghost.ts:3, never in this diff')], + DIFF, + ) + // Sanity on the fixture: this is the exact shape that produces a drop+demotion. + expect(judgedOutcome.dropped_evidence).toBe(1) + expect(judgedOutcome.demoted).toBe(1) + const outcome = combineCriteriaOutcomes( + [MC_COMMAND, C1], + [met(MC_COMMAND, 'ran')], + judgedOutcome, + ) + expect(outcome.dropped_evidence).toBe(1) + expect(outcome.demoted).toBe(1) + expect(outcome.unknown_ids).toBe(judgedOutcome.unknown_ids) + expect(outcome.unjudged).toBe(judgedOutcome.unjudged) + expect(outcome.overflowed).toBe(judgedOutcome.overflowed) + expect(outcome.report).toBe(judgedOutcome.report) + }) + + test('a criterion neither input names falls back to unclear, defensively', () => { + const judgedOutcome = resolveCriteria([], [], DIFF) + const outcome = combineCriteriaOutcomes([MC_COMMAND], [], judgedOutcome) + expect(outcome.verdicts).toEqual([{ criterion_id: MC_COMMAND.id, status: 'unclear' }]) + expect(outcome.satisfied).toBe(false) + }) +}) diff --git a/packages/cli/src/task-criteria-gate.ts b/packages/cli/src/task-criteria-gate.ts index cc1c284..69dc81f 100644 --- a/packages/cli/src/task-criteria-gate.ts +++ b/packages/cli/src/task-criteria-gate.ts @@ -22,16 +22,24 @@ // the same story — that module decides which criteria exist, this one decides // whether the branch meets them. +import { readFileSync } from 'node:fs' +import { join } from 'node:path' import { groundCriterionVerdicts, + parseCriterionProof, sanitizeCriterionVerdict, sanitizeCriterionVerdicts, TICKET_CRITERIA_MAX, type AcceptanceCriterion, type CriteriaGroundingReport, + type CriterionProof, type CriterionStatus, type CriterionVerdict, + type TaskCheckResult, + type TaskChecks, } from './contract.js' +import { diffFilePaths } from './impact.js' +import { runAdHocCheck, type ExecFn } from './task-checks.js' /** * How many blocking criterion ids the readable reason names before it stops. @@ -74,6 +82,26 @@ export function buildCriteriaChapter(criteria: readonly AcceptanceCriterion[]): export type CriteriaCounts = { met: number; unmet: number; unclear: number } +/** + * The tally + the one boolean it decides, shared by `resolveCriteria` (the + * judged half of the gate) and `combineCriteriaOutcomes` (D17, the merge of + * that half with the mechanical one) so the two never compute "satisfied" + * two different ways. + */ +function tallyCriteria(verdicts: readonly CriterionVerdict[]): { + counts: CriteriaCounts + satisfied: boolean +} { + const counts: CriteriaCounts = { met: 0, unmet: 0, unclear: 0 } + for (const verdict of verdicts) { + counts[verdict.status] += 1 + } + // INVARIANT n° 4, as code: the only thing consulted is the tally of the + // statuses computed above. Nothing the model asserted about the WHOLE + // ("all satisfied", "90% done") has any path to this boolean. + return { counts, satisfied: verdicts.length > 0 && counts.met === verdicts.length } +} + export type CriteriaOutcome = { /** Exactly one entry per criterion of the task, in the ticket's own order. */ verdicts: CriterionVerdict[] @@ -195,17 +223,11 @@ export function resolveCriteria( (criterion): CriterionVerdict => byId.get(criterion.id) ?? { criterion_id: criterion.id, status: 'unclear' }, ) - const counts: CriteriaCounts = { met: 0, unmet: 0, unclear: 0 } - for (const verdict of verdicts) { - counts[verdict.status] += 1 - } + const { counts, satisfied } = tallyCriteria(verdicts) const unjudged = criteria.filter((criterion) => !byId.has(criterion.id)).length return { verdicts, - // INVARIANT n° 4, as code: the only thing consulted is the tally of the - // statuses computed above. Nothing the model asserted about the WHOLE — - // "all satisfied", "90% done" — has any path to this boolean. - satisfied: verdicts.length > 0 && counts.met === verdicts.length, + satisfied, counts, // Readable entries naming an id this task does not carry, counted once per // distinct id. @@ -225,7 +247,260 @@ export function resolveCriteria( } } +// --- Mechanical criteria (D17) ---------------------------------------------- +// +// A criterion whose text ends in `[proof:command|diff|read ...]` is decided +// HERE, deterministically, from the checks that already ran, the diff, or the +// worktree: never asked of the reviewer, and never run through +// `groundCriterionVerdicts`, since that function demotes a verdict for lacking an +// anchor IN THE DIFF, which is the wrong test for a verdict that was never +// meant to have one (a command's exit code needs no diff line to point at). +// `[proof:judgment]`, and a criterion with no proof tag at all, still go +// through the model exactly as before D17. + +/** One criterion known (D17) to carry a machine-checkable proof. */ +export type MechanicalCriterion = AcceptanceCriterion & { proof: CriterionProof } + +export type CriteriaPartition = { + /** Proof method is `command`, `diff` or `read`: decided by this file, never by the model. */ + mechanical: MechanicalCriterion[] + /** No proof tag, or an explicit `[proof:judgment]`: still the reviewer's call. */ + judged: AcceptanceCriterion[] +} + +/** + * Splits a task's criteria on whether their own text carries a machine- + * checkable proof (D17). Each list keeps the input's relative order; a + * criterion written before D17 existed carries no tag at all and lands in + * `judged`, exactly where it already was. + */ +export function partitionCriteriaByProof( + criteria: readonly AcceptanceCriterion[], +): CriteriaPartition { + const mechanical: MechanicalCriterion[] = [] + const judged: AcceptanceCriterion[] = [] + for (const criterion of criteria) { + const proof = parseCriterionProof(criterion.text) + if (proof && proof.method !== 'judgment') { + mechanical.push({ ...criterion, proof }) + } else { + judged.push(criterion) + } + } + return { mechanical, judged } +} + +export type ResolveMechanicalContext = { + /** The task's worktree: read for `read`, and the sandbox an ad hoc `command` runs in. */ + worktree: string + /** The SAME diff the judged half of the gate grounds against (`outcome.record.diff`). */ + diff: string + /** The turn's own checks run, or null when none exists: `command` falls back to an ad hoc run either way. */ + checks: TaskChecks | null + execFn?: ExecFn +} + +/** `passed` is the only mechanical `met`: `failed`, `timeout` and a synthetic engine failure are all `unmet`, with no middle ground, and a mechanical criterion is never `unclear`. */ +function statusFromCheck(check: TaskCheckResult): CriterionStatus { + return check.status === 'passed' ? 'met' : 'unmet' +} + +async function resolveCommandCriterion( + criterion: MechanicalCriterion, + command: string, + ctx: ResolveMechanicalContext, +): Promise { + // Strict equality against the turn's OWN checks first: the command already + // ran once, for real, and re-running it would only spend a second container + // to learn the same fact. A match still `skipped` carries no such fact (an + // earlier install failure canceled it): that is treated as no match, same + // as a command this task's checks never ran at all. + const already = ctx.checks?.checks.find((check) => check.command === command) + if (already && already.status !== 'skipped') { + return { + criterion_id: criterion.id, + status: statusFromCheck(already), + evidence: `task check \`${command}\`: ${already.status}`, + } + } + const ranNow = await runAdHocCheck({ + worktree: ctx.worktree, + command, + ...(ctx.execFn ? { execFn: ctx.execFn } : {}), + }) + return { + criterion_id: criterion.id, + status: statusFromCheck(ranNow), + evidence: `ad hoc \`${command}\`: ${ranNow.status}`, + } +} + +function resolveDiffCriterion( + criterion: MechanicalCriterion, + path: string, + ctx: ResolveMechanicalContext, +): CriterionVerdict { + const touched = diffFilePaths(ctx.diff).includes(path) + return { + criterion_id: criterion.id, + status: touched ? 'met' : 'unmet', + evidence: touched ? `${path} is changed in the diff` : `${path} does not appear in the diff`, + } +} + +/** + * Separates a `[proof:read :: ]` argument's two halves: + * this file's own convention (D17 leaves the argument's inner grammar to the + * caller). `::` was picked as unlikely to collide with a real path, and is + * split on its FIRST occurrence so a substring that itself contains `::` + * still comes through whole. + */ +const READ_ARG_SEPARATOR = '::' + +function resolveReadCriterion( + criterion: MechanicalCriterion, + argument: string, + ctx: ResolveMechanicalContext, +): CriterionVerdict { + const sep = argument.indexOf(READ_ARG_SEPARATOR) + const path = (sep < 0 ? argument : argument.slice(0, sep)).trim() + const substring = sep < 0 ? null : argument.slice(sep + READ_ARG_SEPARATOR.length).trim() || null + let content: string + try { + content = readFileSync(join(ctx.worktree, path), 'utf8') + } catch { + return { + criterion_id: criterion.id, + status: 'unmet', + evidence: `${path} not found in the worktree`, + } + } + if (substring && !content.includes(substring)) { + return { + criterion_id: criterion.id, + status: 'unmet', + evidence: `${path} does not contain "${substring}"`, + } + } + return { + criterion_id: criterion.id, + status: 'met', + evidence: substring ? `${path} contains "${substring}"` : `${path} is present in the worktree`, + } +} + +async function resolveOneMechanicalCriterion( + criterion: MechanicalCriterion, + ctx: ResolveMechanicalContext, +): Promise { + const { method, argument } = criterion.proof + // `parseCriterionProof` already refuses `command`/`diff`/`read` with no + // argument, so this is unreachable through `partitionCriteriaByProof`: + // defensive only, never trusting a `MechanicalCriterion` built by hand. + if (!argument) { + return { + criterion_id: criterion.id, + status: 'unmet', + evidence: `proof:${method} has no argument`, + } + } + if (method === 'command') { + return resolveCommandCriterion(criterion, argument, ctx) + } + if (method === 'diff') { + return resolveDiffCriterion(criterion, argument, ctx) + } + if (method === 'read') { + return resolveReadCriterion(criterion, argument, ctx) + } + // Unreachable through `partitionCriteriaByProof` (a `judgment` proof lands + // in `judged`, never in the `mechanical` list this function reads). + return { + criterion_id: criterion.id, + status: 'unmet', + evidence: `proof:${method} is not mechanical`, + } +} + +/** + * The mechanical half of the gate (D17): one verdict per `MechanicalCriterion`, + * decided from the checks/diff/worktree rather than asked of a reviewer. + * Sequential, like `runChecks`'s own steps: an ad hoc `command` check is a + * container too, and nothing here caps how many run at once. + */ +export async function resolveMechanicalCriteria( + criteria: readonly MechanicalCriterion[], + ctx: ResolveMechanicalContext, +): Promise { + const verdicts: CriterionVerdict[] = [] + for (const criterion of criteria) { + verdicts.push(await resolveOneMechanicalCriterion(criterion, ctx)) + } + return verdicts +} + +/** + * Re-merges the mechanical verdicts (D17, never grounded) with `judgedOutcome` + * (the reviewer's own half, already grounded by `resolveCriteria`) into the + * single ordered `CriteriaOutcome` the rest of the gate reads: counts and + * `satisfied` recomputed over BOTH halves; the grounding-shaped fields + * (`unknown_ids`, `unjudged`, `dropped_evidence`, `demoted`, `overflowed`, + * `report`) carried over from `judgedOutcome` unchanged, since none of them + * has a mechanical equivalent. A mechanical criterion is always resolved, + * never `unclear`, and was never in the prompt for the model to overreach on. + */ +export function combineCriteriaOutcomes( + criteria: readonly AcceptanceCriterion[], + mechanicalVerdicts: readonly CriterionVerdict[], + judgedOutcome: CriteriaOutcome, +): CriteriaOutcome { + const byId = new Map() + for (const verdict of [...mechanicalVerdicts, ...judgedOutcome.verdicts]) { + byId.set(verdict.criterion_id, verdict) + } + const verdicts = criteria.map( + (criterion): CriterionVerdict => + byId.get(criterion.id) ?? { criterion_id: criterion.id, status: 'unclear' }, + ) + const { counts, satisfied } = tallyCriteria(verdicts) + return { + verdicts, + satisfied, + counts, + unknown_ids: judgedOutcome.unknown_ids, + unjudged: judgedOutcome.unjudged, + dropped_evidence: judgedOutcome.dropped_evidence, + demoted: judgedOutcome.demoted, + overflowed: judgedOutcome.overflowed, + report: judgedOutcome.report, + } +} + /** `1 criterion` / `3 criteria` — a count and the word that agrees with it. */ +/** + * D18: whether an unsatisfied gate may be LIFTED by a review the reviewer + * itself settled as OK. Only the pure unclear case qualifies: every judged + * criterion is met or unclear, nothing is unmet, nothing went unjudged and the + * diff was indexable. An unclear criterion is an evidence gap (the proof lives + * outside the diff, or needs an execution the reviewer cannot run), not a + * failure; a false "satisfied" stays impossible because `satisfied` itself is + * untouched and the waiver is journaled out loud by the caller. + */ +export function criteriaGateWaivable(outcome: CriteriaOutcome): boolean { + return ( + outcome.verdicts.length > 0 && + outcome.counts.unmet === 0 && + outcome.unjudged === 0 && + !outcome.report.diff_unreadable && + // A demoted 'met' or a dropped proof is a claim that failed anchoring, + // not a sincere doubt: those never qualify, or a fabricated evidence + // would ride an approve through the gate. + outcome.demoted === 0 && + outcome.dropped_evidence === 0 && + outcome.counts.unclear > 0 + ) +} + const plural = (n: number, one: string, many: string): string => `${n} ${n === 1 ? one : many}` /** diff --git a/packages/cli/src/task-criteria.test.ts b/packages/cli/src/task-criteria.test.ts index 3085b34..570953b 100644 --- a/packages/cli/src/task-criteria.test.ts +++ b/packages/cli/src/task-criteria.test.ts @@ -264,6 +264,7 @@ function unusedTaskManager(): TaskManager { isolation_configured: 'auto', agent: 'claude -p', }), + attach: () => Promise.resolve(refused), checksApply: () => refused, startPending: async () => [], sweepOrphanedVolumes: async () => {}, diff --git a/packages/cli/src/task-isolation.test.ts b/packages/cli/src/task-isolation.test.ts index 00bf8c0..18f297b 100644 --- a/packages/cli/src/task-isolation.test.ts +++ b/packages/cli/src/task-isolation.test.ts @@ -13,9 +13,12 @@ import { type WatchdogBudgets, } from './agent.js' import { + attachedGitCommonDir, CAGE_GIT_COMMON_DIR, containerGitStateDir, gitPointerContent, + gitSafeDirectoryEnvArgs, + prepareAttachedContainerGit, prepareContainerGit, resolveWorktreeGitLink, } from './container-git.js' @@ -2427,3 +2430,48 @@ describe('spawnContainer semantic watchdog', () => { expect((err as Error).message).toMatch(/interrupted|interrompu/) }) }) + +describe('git of the repositories attached to a conversation', () => { + test('each gets its own mounted git directory and its own pointer', () => { + const first = makeLinkedWorktree() + const second = makeLinkedWorktree() + const stateDir = makeDir('codesema-attached-git-') + + const prepared = prepareAttachedContainerGit( + [ + { name: 'api', worktree: first.worktree }, + { name: 'web', worktree: second.worktree }, + ], + '/work', + stateDir, + ) + + expect(prepared.mountArgs).toContain(`${join(first.repo, '.git')}:${'/gitcommon-api'}:ro`) + expect(prepared.mountArgs).toContain(`${join(second.repo, '.git')}:${'/gitcommon-web'}:ro`) + expect(prepared.mountArgs).toContain(`${join(stateDir, 'dotgit-api')}:/work/api/.git:ro`) + expect(prepared.mountArgs).toContain(`${join(stateDir, 'dotgit-web')}:/work/web/.git:ro`) + // safe.directory is not recursive: the checkout AND its git directory. + expect(prepared.safeDirectories).toEqual([ + '/work/api', + '/gitcommon-api', + '/work/web', + '/gitcommon-web', + ]) + expect(attachedGitCommonDir('api')).toBe('/gitcommon-api') + }) + + test('a directory that is not a linked worktree is skipped, never mounted blind', () => { + const plain = makeDir('codesema-plain-') + const prepared = prepareAttachedContainerGit([{ name: 'x', worktree: plain }], '/work') + expect(prepared).toEqual({ mountArgs: [], safeDirectories: [] }) + }) + + test('every attached path earns a safe.directory entry of its own', () => { + const args = gitSafeDirectoryEnvArgs('/work', ['/work/api', '/gitcommon-api']) + expect(args).toContain('GIT_CONFIG_COUNT=4') + expect(args).toContain('GIT_CONFIG_VALUE_0=/work') + expect(args).toContain(`GIT_CONFIG_VALUE_1=${CAGE_GIT_COMMON_DIR}`) + expect(args).toContain('GIT_CONFIG_VALUE_2=/work/api') + expect(args).toContain('GIT_CONFIG_VALUE_3=/gitcommon-api') + }) +}) diff --git a/packages/cli/src/task-isolation.ts b/packages/cli/src/task-isolation.ts index 707d480..1f758c6 100644 --- a/packages/cli/src/task-isolation.ts +++ b/packages/cli/src/task-isolation.ts @@ -46,7 +46,12 @@ import { type WatchdogBudgets, } from './agent.js' import type { IsolationMode } from './config.js' -import { gitSafeDirectoryEnvArgs, prepareContainerGit } from './container-git.js' +import { + gitSafeDirectoryEnvArgs, + prepareAttachedContainerGit, + prepareContainerGit, + type ContainerGitAttachment, +} from './container-git.js' import { isTaskId, type TaskIsolation } from './contract.js' import { t } from './i18n.js' import type { ChecksConfig } from './repo-config.js' @@ -92,7 +97,13 @@ export const CAGE_HOME_DIR = '/home/agent' /** Default install of the agent CLI into the image; injectable (tests, smoke runs). */ export const DEFAULT_CLAUDE_INSTALL_COMMAND = 'npm install -g @anthropic-ai/claude-code' -/** Bun bases have no npm: BUN_INSTALL puts the binary on the shared PATH. */ +/** + * Bun bases have no npm: BUN_INSTALL puts the binary on the shared PATH. + * + * @deadcode Nothing selects a bun base today, so nothing reads this. Kept as the + * counterpart of DEFAULT_CLAUDE_INSTALL_COMMAND until that path is either wired + * up or dropped; delete both this and the tag when the question is settled. + */ export const BUN_CLAUDE_INSTALL_COMMAND = 'BUN_INSTALL=/usr/local bun install -g @anthropic-ai/claude-code' @@ -1612,6 +1623,12 @@ export type ContainerRunSpec = { * already carries its `.git` inside the mount. */ gitMounts?: readonly string[] + /** + * Container paths that need a safe.directory entry beyond the work dir and + * `/gitcommon`: one attached repository contributes its own checkout and its + * own mounted git directory, and safe.directory is not recursive. + */ + gitSafeDirectories?: readonly string[] memory?: string cpus?: string /** @@ -1643,7 +1660,7 @@ export function containerRunArgs(spec: ContainerRunSpec): string[] { '-v', `${spec.homeVolume}:${CAGE_HOME_DIR}`, ...(spec.gitMounts ?? []), - ...gitSafeDirectoryEnvArgs(CAGE_WORK_DIR), + ...gitSafeDirectoryEnvArgs(CAGE_WORK_DIR, spec.gitSafeDirectories ?? []), '-e', `HTTP_PROXY=${proxy}`, '-e', @@ -1910,7 +1927,12 @@ export function hostAgentVersion(command: string, execFn?: IsolationExecFn): Pro return started } -/** Host claude version; wrapper around hostAgentVersion. */ +/** + * Host claude version; wrapper around hostAgentVersion. + * + * @deadcode Every caller went through hostAgentVersion directly. Delete this + * wrapper and its tag unless a caller comes back. + */ export function hostClaudeVersion(execFn?: IsolationExecFn): Promise { return hostAgentVersion('claude', execFn) } @@ -1919,6 +1941,12 @@ export type RunContainerTurnOptions = { taskId: string /** The task worktree: the only host path the cage ever sees. */ worktree: string + /** + * Repositories attached to this conversation, as directories inside + * `worktree`. Each needs its shared git directory mounted too, or git is + * dead inside it exactly as it would be on the worktree itself. + */ + attachments?: readonly ContainerGitAttachment[] /** Raw agent command line to run inside the cage (already flagged). */ command: string prompt: string @@ -1996,6 +2024,7 @@ export async function runContainerTurn(opts: RunContainerTurnOptions): Promise env[key]), - ...(git ? { gitMounts: git.mountArgs } : {}), + gitMounts: [...(git?.mountArgs ?? []), ...attachedGit.mountArgs], + gitSafeDirectories: attachedGit.safeDirectories, }) const spawnFn = opts.spawnFn ?? spawnContainer return spawnFn({ diff --git a/packages/cli/src/task-labels.ts b/packages/cli/src/task-labels.ts index 8e3b7c8..bfd99eb 100644 --- a/packages/cli/src/task-labels.ts +++ b/packages/cli/src/task-labels.ts @@ -333,7 +333,12 @@ async function poseCycleLabel( // set that never belonged to it. Everything after the read is therefore // pinned to the forge that ANSWERED the read. const pin = read.answeredBy ?? null - const next = recomposeCycleLabels(read.issue.labels, label) + // Cycle labels are recomposed by NAME only: colour is a display concern + // this module never touches, and `setLabels` writes back names. + const next = recomposeCycleLabels( + read.issue.labels.map((forgeLabel) => forgeLabel.name), + label, + ) if (next === null) { return { kind: 'unchanged', label } } diff --git a/packages/cli/src/task-merge.test.ts b/packages/cli/src/task-merge.test.ts index b4538de..707a60c 100644 --- a/packages/cli/src/task-merge.test.ts +++ b/packages/cli/src/task-merge.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, test } from 'bun:test' -import { DEFAULT_MERGE_SETTINGS, type MergeSettings } from './config.js' +import { DEFAULT_MERGE_SETTINGS, saveRepoConfig, type MergeSettings } from './config.js' import { acceptanceCriterionId, type AcceptanceCriterion, @@ -18,6 +18,7 @@ import { PROBE_TIMEOUT_MS } from './git.js' import { branchAncestry, criteriaDraftProposed, + effectiveMergePolicyIsAuto, isMergeConflictError, MERGE_GIT_TIMEOUT_MS, mergeReadiness, @@ -695,6 +696,7 @@ describe("the merge gate's git reads are bounded (MAJEUR 2)", () => { `const started = Date.now()`, `const outcome = await mergeTask({`, ` cwd: ${JSON.stringify(cwd)},`, + ` brainAutoMerge: true,`, ` task: ${JSON.stringify(greenTask())},`, ` settings: ${JSON.stringify(settings({ policy: 'auto' }))},`, // Injected: this test is about the ONE git read left on this path. @@ -747,6 +749,7 @@ describe('mergeTask under mergePolicy: human (the default)', () => { const repo = makeRepoWithOrigin('git@github.com:o/r.git') const forge = recordingForge() const outcome = await mergeTask({ + brainAutoMerge: true, cwd: repo, task: greenTask(), settings: settings(), @@ -769,6 +772,7 @@ describe('mergeTask under mergePolicy: human (the default)', () => { const repo = makeRepoWithOrigin('git@github.com:o/r.git') const forge = recordingForge() const outcome = await mergeTask({ + brainAutoMerge: true, cwd: repo, task: greenTask(), settings: settings(), @@ -788,6 +792,7 @@ describe('mergeTask under mergePolicy: auto', () => { const repo = makeRepoWithOrigin('git@github.com:o/r.git') const forge = recordingForge({ kind: 'ok', stdout: 'Merged pull request #7' }) const outcome = await mergeTask({ + brainAutoMerge: true, cwd: repo, task: greenTask(), settings: auto(), @@ -805,6 +810,7 @@ describe('mergeTask under mergePolicy: auto', () => { const repo = makeRepoWithOrigin('git@gitlab.com:o/r.git') const forge = recordingForge({ kind: 'ok', stdout: '' }) await mergeTask({ + brainAutoMerge: true, cwd: repo, task: greenTask(), settings: auto(), @@ -823,6 +829,7 @@ describe('mergeTask under mergePolicy: auto', () => { const repo = makeRepoWithOrigin('git@github.com:o/r.git') const forge = recordingForge() await mergeTask({ + brainAutoMerge: true, cwd: repo, task: greenTask(), settings: auto(), @@ -837,6 +844,7 @@ describe('mergeTask under mergePolicy: auto', () => { test('an explicit strategy reaches the argv, per CLI', async () => { const gh = recordingForge() await mergeTask({ + brainAutoMerge: true, cwd: makeRepoWithOrigin('git@github.com:o/r.git'), task: greenTask(), settings: auto({ strategy: 'squash' }), @@ -847,6 +855,7 @@ describe('mergeTask under mergePolicy: auto', () => { const glab = recordingForge() await mergeTask({ + brainAutoMerge: true, cwd: makeRepoWithOrigin('git@gitlab.com:o/r.git'), task: greenTask(), settings: auto({ strategy: 'rebase' }), @@ -859,6 +868,7 @@ describe('mergeTask under mergePolicy: auto', () => { test("glab has no merge-commit flag: 'merge' sends none rather than inventing one", async () => { const glab = recordingForge() await mergeTask({ + brainAutoMerge: true, cwd: makeRepoWithOrigin('git@gitlab.com:o/r.git'), task: greenTask(), settings: auto({ strategy: 'merge' }), @@ -877,6 +887,7 @@ describe('mergeTask under mergePolicy: auto', () => { test('the branch is NOT deleted by default, and is on request', async () => { const kept = recordingForge() await mergeTask({ + brainAutoMerge: true, cwd: makeRepoWithOrigin('git@github.com:o/r.git'), task: greenTask(), settings: auto(), @@ -887,6 +898,7 @@ describe('mergeTask under mergePolicy: auto', () => { const deleted = recordingForge() await mergeTask({ + brainAutoMerge: true, cwd: makeRepoWithOrigin('git@github.com:o/r.git'), task: greenTask(), settings: auto({ deleteBranch: true }), @@ -905,6 +917,7 @@ describe('mergeTask under mergePolicy: auto', () => { ]) { const forge = recordingForge() const outcome = await mergeTask({ + brainAutoMerge: true, cwd: makeRepoWithOrigin('git@github.com:o/r.git'), task: greenTask(), settings: auto({ allowMergeWithoutChecks: false }), @@ -923,6 +936,7 @@ describe('mergeTask under mergePolicy: auto', () => { // one that depends on which condition happened to be evaluated last. const forge = recordingForge() const outcome = await mergeTask({ + brainAutoMerge: true, cwd: makeRepoWithOrigin('git@github.com:o/r.git'), task: greenTask(), settings: auto(), @@ -968,6 +982,7 @@ describe('mergeTask under mergePolicy: auto', () => { test('a task with no criteria emits no merge command either (DP2)', async () => { const forge = recordingForge() const outcome = await mergeTask({ + brainAutoMerge: true, cwd: makeRepoWithOrigin('git@github.com:o/r.git'), task: makeTask({ review_ref: '/nowhere/review.json' }), settings: auto(), @@ -982,6 +997,7 @@ describe('mergeTask under mergePolicy: auto', () => { test('the consent valve unblocks an unconfigured repo, and the merge happens', async () => { const forge = recordingForge() const outcome = await mergeTask({ + brainAutoMerge: true, cwd: makeRepoWithOrigin('git@github.com:o/r.git'), task: greenTask(), settings: auto({ allowMergeWithoutChecks: true }), @@ -997,6 +1013,7 @@ describe('mergeTask under mergePolicy: auto', () => { test('the valve never covers a broken runtime, and no command is emitted', async () => { const forge = recordingForge() const outcome = await mergeTask({ + brainAutoMerge: true, cwd: makeRepoWithOrigin('git@github.com:o/r.git'), task: greenTask(), settings: auto({ allowMergeWithoutChecks: true }), @@ -1011,6 +1028,141 @@ describe('mergeTask under mergePolicy: auto', () => { }) }) +describe('arm/brain integration: brainAutoMerge overrides mergePolicy for a ticketed task', () => { + // `brainAutoMerge` is GLOBAL-ONLY (config.ts, REPO_IGNORED_GLOBAL_ONLY_KEYS): + // `mergeTask` never reads config at all any more, global or repo. The + // caller (`runMergeStep`, task-server.ts) resolves the boolean once from + // the global file and hands it in as `opts.brainAutoMerge`, so every test + // below sets it directly, with no config directory to isolate. + const ticketedGreenTask = (over: Partial = {}): TaskRecord => + greenTask({ brain_ticket: { id: 'tkt-1', title: 'x' }, ...over }) + + test('a ticketed task merges under mergePolicy human when brainAutoMerge is true', async () => { + const repo = makeRepoWithOrigin('git@github.com:o/r.git') + const forge = recordingForge({ kind: 'ok', stdout: '' }) + const outcome = await mergeTask({ + cwd: repo, + brainAutoMerge: true, + task: ticketedGreenTask(), + settings: settings({ policy: 'human' }), + inputs: greenInputs(), + execForge: forge.exec, + }) + expect(outcome.kind).toBe('merged') + expect(forge.calls.length).toBe(1) + }) + + test('brainAutoMerge: false holds a ticketed task, like any human-policy task', async () => { + const repo = makeRepoWithOrigin('git@github.com:o/r.git') + const forge = recordingForge() + const outcome = await mergeTask({ + cwd: repo, + brainAutoMerge: false, + task: ticketedGreenTask(), + settings: settings({ policy: 'human' }), + inputs: greenInputs(), + execForge: forge.exec, + }) + expect(outcome.kind).toBe('held') + expect(forge.calls).toEqual([]) + }) + + test('mergeTask never reads config itself: a repo file setting brainAutoMerge has no effect', async () => { + const repo = makeRepoWithOrigin('git@github.com:o/r.git') + // Global-only per REPO_IGNORED_GLOBAL_ONLY_KEYS: silently stripped from a + // repo file already. Written here anyway, on purpose, so this test would + // still catch it if `mergeTask` ever read config back on its own. + saveRepoConfig(repo, { brainAutoMerge: true }) + const forge = recordingForge() + const outcome = await mergeTask({ + cwd: repo, + brainAutoMerge: false, + task: ticketedGreenTask(), + settings: settings({ policy: 'human' }), + inputs: greenInputs(), + execForge: forge.exec, + }) + expect(outcome.kind).toBe('held') + expect(forge.calls).toEqual([]) + }) + + test('a task with no brain_ticket keeps mergePolicy human untouched even when brainAutoMerge is true', async () => { + const repo = makeRepoWithOrigin('git@github.com:o/r.git') + const forge = recordingForge() + const outcome = await mergeTask({ + cwd: repo, + brainAutoMerge: true, + task: greenTask(), + settings: settings({ policy: 'human' }), + inputs: greenInputs(), + execForge: forge.exec, + }) + expect(outcome.kind).toBe('held') + expect(forge.calls).toEqual([]) + }) + + test('a repo-wide mergePolicy: auto merges regardless of brainAutoMerge', async () => { + const repo = makeRepoWithOrigin('git@github.com:o/r.git') + const forge = recordingForge({ kind: 'ok', stdout: '' }) + const outcome = await mergeTask({ + cwd: repo, + brainAutoMerge: false, + task: ticketedGreenTask(), + settings: settings({ policy: 'auto' }), + inputs: greenInputs(), + execForge: forge.exec, + }) + expect(outcome.kind).toBe('merged') + }) + + test('a ticketed task still holds under human policy when a condition is unmet', async () => { + const repo = makeRepoWithOrigin('git@github.com:o/r.git') + const forge = recordingForge() + const outcome = await mergeTask({ + cwd: repo, + brainAutoMerge: true, + task: ticketedGreenTask(), + settings: settings({ policy: 'human' }), + inputs: greenInputs({ checks: makeChecks({ status: 'failed' }) }), + execForge: forge.exec, + }) + // Auto-merging the four conditions is not a bypass of the four conditions: + // a red run still refuses, exactly as it would under an ordinary auto policy. + expect(outcome.kind).toBe('refused') + expect(forge.calls).toEqual([]) + }) +}) + +// D20: extracted from mergeTask's own inline calc so task-server.ts's ship() +// can ask the SAME question before mergeTask ever runs. The four cases below +// are the exact ones the describe block above already exercises through +// mergeTask's observable behavior — this is the same truth table, asserted +// directly against the exported function. +describe('effectiveMergePolicyIsAuto: the exact question mergeTask answers, exported', () => { + test('an explicit auto policy is auto, brain_ticket or not', () => { + expect(effectiveMergePolicyIsAuto(greenTask(), settings({ policy: 'auto' }), false)).toBe(true) + }) + + test('a human policy with no brain_ticket is never auto', () => { + expect(effectiveMergePolicyIsAuto(greenTask(), settings({ policy: 'human' }), true)).toBe(false) + }) + + test('a brain ticket with brainAutoMerge overrides a human policy to auto', () => { + const ticketed = greenTask({ brain_ticket: { id: 'tkt-1', title: 'x' } }) + expect(effectiveMergePolicyIsAuto(ticketed, settings({ policy: 'human' }), true)).toBe(true) + }) + + test('a brain ticket WITHOUT brainAutoMerge does not override a human policy', () => { + const ticketed = greenTask({ brain_ticket: { id: 'tkt-1', title: 'x' } }) + expect(effectiveMergePolicyIsAuto(ticketed, settings({ policy: 'human' }), false)).toBe(false) + }) + + test('a repo-wide auto policy is untouched by brainAutoMerge either way', () => { + const ticketed = greenTask({ brain_ticket: { id: 'tkt-1', title: 'x' } }) + expect(effectiveMergePolicyIsAuto(ticketed, settings({ policy: 'auto' }), false)).toBe(true) + }) +}) + describe('what the merge never does', () => { test('a conflict is merge_conflict, and the branch and worktree are untouched', async () => { const repo = makeRepoWithOrigin('git@github.com:o/r.git') @@ -1030,6 +1182,7 @@ describe('what the merge never does', () => { message: 'Pull request is not mergeable: the merge commit cannot be cleanly created', }) const outcome = await mergeTask({ + brainAutoMerge: true, cwd: repo, task: greenTask(), settings: settings({ policy: 'auto' }), @@ -1052,6 +1205,7 @@ describe('what the merge never does', () => { const repo = makeRepoWithOrigin('git@example.test:o/r.git') const forge = recordingForge({ kind: 'missing' }) const outcome = await mergeTask({ + brainAutoMerge: true, cwd: repo, task: greenTask(), settings: settings({ policy: 'auto' }), @@ -1071,6 +1225,7 @@ describe('what the merge never does', () => { message: 'GraphQL: Base branch was modified. Review and try the merge again.', }) const outcome = await mergeTask({ + brainAutoMerge: true, cwd: repo, task: greenTask(), settings: settings({ policy: 'auto' }), @@ -1093,6 +1248,97 @@ describe('what the merge never does', () => { }) }) +// D20: a crash between an EARLIER call's forge merge landing and the caller +// (task-server.ts's runMergeStep) recording it resumes on the SAME branch — +// mergeTask must not ask the forge to merge an already-merged branch a +// second time without at least checking first. +describe('D20 idempotence: a branch the forge already merged is never merged twice', () => { + test('a forge error is re-read as merged when the branch is already merged there', async () => { + const repo = makeRepoWithOrigin('git@github.com:o/r.git') + const calls: string[][] = [] + const execForge: ShipForgeExecFn = (_cli, args) => { + calls.push(args) + if (args.includes('merge')) { + return Promise.resolve({ kind: 'error', message: 'GraphQL: pull request is not open' }) + } + return Promise.resolve({ kind: 'ok', stdout: JSON.stringify([{ number: 42 }]) }) + } + const task = greenTask() + const outcome = await mergeTask({ + brainAutoMerge: true, + cwd: repo, + task, + settings: settings({ policy: 'auto' }), + inputs: greenInputs(), + execForge, + }) + expect(outcome.kind).toBe('merged') + expect(outcome.kind === 'merged' && outcome.cli).toBe('gh') + // No URL: this call never merged anything, an EARLIER one did. + expect(outcome.kind === 'merged' && outcome.url).toBeNull() + // Exactly two calls: the merge attempt, then the read-only check — never a + // second merge attempt, and never a third call once the first two agree. + expect(calls).toHaveLength(2) + expect(calls[1]).toEqual([ + 'pr', + 'list', + `--head=${task.branch}`, + '--state', + 'merged', + '--limit', + '1', + '--json', + 'number', + ]) + const mergedEvent = outcome.events.find((event) => event.data.name === 'merged') + expect(mergedEvent?.data.already_merged).toBe(true) + }) + + test('a real conflict never asks whether the branch already merged', async () => { + const repo = makeRepoWithOrigin('git@github.com:o/r.git') + const forge = recordingForge({ + kind: 'error', + message: 'Pull request is not mergeable: the merge commit cannot be cleanly created', + }) + const outcome = await mergeTask({ + brainAutoMerge: true, + cwd: repo, + task: greenTask(), + settings: settings({ policy: 'auto' }), + inputs: greenInputs(), + execForge: forge.exec, + }) + expect(outcome.kind).toBe('failed') + expect(outcome.kind === 'failed' && outcome.reason.code).toBe('merge_conflict') + // The one call: a conflict is a fact about the branch, not a reason to + // wonder whether it already landed. + expect(forge.calls).toHaveLength(1) + }) + + test('an unreadable already-merged check falls through to the ordinary failure', async () => { + const repo = makeRepoWithOrigin('git@github.com:o/r.git') + let call = 0 + const execForge: ShipForgeExecFn = () => { + call += 1 + return Promise.resolve( + call === 1 + ? { kind: 'error', message: 'GraphQL: pull request is not open' } + : { kind: 'error', message: 'rate limited' }, + ) + } + const outcome = await mergeTask({ + brainAutoMerge: true, + cwd: repo, + task: greenTask(), + settings: settings({ policy: 'auto' }), + inputs: greenInputs(), + execForge, + }) + expect(outcome.kind).toBe('failed') + expect(outcome.kind === 'failed' && outcome.reason.code).toBe('forge_unreachable') + }) +}) + // --- the default exec is really wired (M38, M67) --------------------------- // // Every test above injects `execForge`, which is what makes them fast and @@ -1127,6 +1373,7 @@ describe('the merge really runs a forge CLI when nothing is injected', () => { `const { mergeTask } = await import(${JSON.stringify(modulePath)})`, `const outcome = await mergeTask({`, ` cwd: ${JSON.stringify(repo)},`, + ` brainAutoMerge: true,`, ` task: ${JSON.stringify(greenTask())},`, ` settings: ${JSON.stringify(settings({ policy: 'auto' }))},`, ` inputs: ${JSON.stringify(greenInputs())},`, @@ -1190,6 +1437,7 @@ describe('criteriaDraftProposed: the only trace a turn-1 draft ever leaves', () describe('an unusable merge setting is named, never absorbed', () => { test('the degraded keys ride the task journal as their own line', async () => { const outcome = await mergeTask({ + brainAutoMerge: true, cwd: makeRepoWithOrigin('git@github.com:o/r.git'), task: greenTask(), settings: settings(), diff --git a/packages/cli/src/task-merge.ts b/packages/cli/src/task-merge.ts index 6a44375..e8fbe4e 100644 --- a/packages/cli/src/task-merge.ts +++ b/packages/cli/src/task-merge.ts @@ -42,6 +42,7 @@ import { type TaskRecord, } from './contract.js' import { detectForgeHint, isAncestor, refExists } from './git.js' +import { reportBrainTransition } from './task-brain.js' import { CRITERIA_REASON_IDS_MAX } from './task-criteria-gate.js' import { blockingFindingsDetail, @@ -365,9 +366,17 @@ function criteriaCondition( const archived = new Map( (review?.review.criteria ?? []).map((verdict) => [verdict.criterion_id, verdict.status]), ) + // D18: an archived 'unclear' does not block on its own (the review that + // reached this condition already settled OK, and an unclear is an evidence + // gap, not a failure). An 'unmet', or a criterion the archive never judged + // at all, still refuses the merge. const blocking = criteria - .map((criterion) => ({ id: criterion.id, status: archived.get(criterion.id) ?? 'unclear' })) - .filter((entry) => entry.status !== 'met') + .map((criterion) => ({ id: criterion.id, status: archived.get(criterion.id) ?? 'unjudged' })) + .filter((entry) => entry.status === 'unmet' || entry.status === 'unjudged') + .map((entry) => ({ + id: entry.id, + status: entry.status === 'unjudged' ? ('unclear' as const) : entry.status, + })) if (blocking.length === 0) { return { id: 'criteria', satisfied: true, detail: null } } @@ -604,6 +613,60 @@ export function isMergeConflictError(message: string): boolean { return /conflict|not mergeable|cannot be merged/i.test(message) } +/** + * D20 idempotence guard, read-only: has the FORGE already recorded this + * exact branch as merged? Checked ONLY after a forge merge call has already + * failed (see its call site) — never before a fresh attempt, so an open, + * unmerged branch pays nothing extra for the ordinary case. + * + * Never a local git ancestry check: a squash or rebase merge lands a NEW + * commit on the target, one `record.branch`'s own tip is never an ancestor + * of, so only the forge's own open/closed/merged bookkeeping can answer this + * honestly (`branchAncestry` above answers a different question — whether + * the TARGET is already in the branch, not the reverse). + * + * The unambiguous LIST form, same as prep.ts's `forgeProbes`: a NAMED branch + * is never passed as a positional (`gh pr view 1234` / `glab mr view 1234` + * read a purely numeric argument as a PR/MR NUMBER). `--head=`/ + * `--source-branch=` and the merged-state filters are the same flags already + * verified against gh 2.46.0 / glab 1.53.0 elsewhere in this file + * (`mergeCandidates`) and in prep.ts. + * + * Unreadable, or any shape this cannot parse, answers `false`: not proof + * either way, so the ordinary forge failure this guards falls through and + * surfaces exactly as it always has. + */ +async function branchAlreadyMerged( + cli: 'gh' | 'glab', + cwd: string, + branch: string, + execForge: ShipForgeExecFn, +): Promise { + const args = + cli === 'gh' + ? ['pr', 'list', `--head=${branch}`, '--state', 'merged', '--limit', '1', '--json', 'number'] + : [ + 'mr', + 'list', + `--source-branch=${branch}`, + '--merged', + '--per-page', + '1', + '--output', + 'json', + ] + const outcome = await execForge(cli, args, cwd) + if (outcome.kind !== 'ok') { + return false + } + try { + const data: unknown = JSON.parse(outcome.stdout) + return Array.isArray(data) && data.length > 0 + } catch { + return false + } +} + // --- outcome --------------------------------------------------------------- export type MergeOutcome = { @@ -630,6 +693,17 @@ export type MergeTaskOptions = { cwd: string task: TaskRecord settings: MergeSettings + /** + * Arm/brain integration: `brainAutoMerge` (config.ts), resolved by the + * CALLER from the global config alone and handed in as a plain value. + * GLOBAL-ONLY, same doctrine as every field of `settings` above; this + * module never reads config itself, so the boundary between "the workspace + * resolved a setting" and "a repo could sneak one past this gate" cannot + * blur here. `true` (a brain-ticket task's own consent OVERRIDES + * `mergePolicy` to `'auto'` for that task only) is the caller's honest + * default when nothing configures it either way. + */ + brainAutoMerge: boolean /** Test seam: the four facts. Omitted, they are collected from disk by `readMergeInputs`. */ inputs?: MergeInputs /** Test seam: the default runs a real gh / glab. */ @@ -666,6 +740,31 @@ function conditionEvents(readiness: MergeReadiness): AppendTaskEventInput[] { const forgeOutcomeMessage = (outcome: Extract): string => outcome.message.slice(0, MERGE_ERROR_MAX) +/** + * Whether the policy this call actually merges under — `opts.settings.policy` + * after the SAME brain override `mergeTask` applies below — is `'auto'`. + * + * Arm/brain integration: a brain-ticket task's own consent (`brainAutoMerge`, + * GLOBAL-ONLY, default true, resolved by the caller and handed in as a plain + * value) OVERRIDES `mergePolicy` to `'auto'` for THIS task only: the + * workspace-wide setting, and every task that carries no `brain_ticket`, are + * untouched. Never the other direction: a repo that explicitly wants + * `mergePolicy: 'auto'` for every task keeps that regardless of + * `brainAutoMerge`. + * + * Exported (D20) so a caller can ask the SAME question `mergeTask` is about + * to answer BEFORE calling it — `task-server.ts`'s `ship()` reads it to + * decide whether the merge about to run is worth a `cycle_step: 'merge'` + * marker — without a second, drifting copy of this exact calculation. + */ +export function effectiveMergePolicyIsAuto( + task: TaskRecord, + settings: MergeSettings, + brainAutoMerge: boolean, +): boolean { + return settings.policy === 'auto' || Boolean(task.brain_ticket && brainAutoMerge) +} + /** * Evaluate, then — only under `mergePolicy: 'auto'`, and only on four * satisfied conditions — merge. @@ -678,8 +777,15 @@ const forgeOutcomeMessage = (outcome: Extract * — all come back as an outcome the caller states. */ export async function mergeTask(opts: MergeTaskOptions): Promise { + const settings: MergeSettings = effectiveMergePolicyIsAuto( + opts.task, + opts.settings, + opts.brainAutoMerge, + ) + ? { ...opts.settings, policy: 'auto' } + : opts.settings const inputs = opts.inputs ?? readMergeInputs(opts.cwd, opts.task) - const readiness = mergeReadiness(opts.task, inputs, opts.settings) + const readiness = mergeReadiness(opts.task, inputs, settings) const events: AppendTaskEventInput[] = [] if (opts.degradedKeys && opts.degradedKeys.length > 0) { // A config value that was present and unusable never merely disappears @@ -701,7 +807,7 @@ export async function mergeTask(opts: MergeTaskOptions): Promise { // it has to say, and it REFUSES nothing: nobody asked it to merge, so // turning a shipped task into "needs you" would be a refusal invented on // the user's behalf. The caller leaves the record exactly as it found it. - if (opts.settings.policy !== 'auto') { + if (settings.policy !== 'auto') { events.push({ type: MERGE_EVENT, data: { @@ -722,7 +828,7 @@ export async function mergeTask(opts: MergeTaskOptions): Promise { type: MERGE_EVENT, data: { name: 'refused', - policy: opts.settings.policy, + policy: settings.policy, terminal: isTerminalReason(reason.code), ...(reason.detail ? { message: reason.detail } : {}), }, @@ -733,7 +839,7 @@ export async function mergeTask(opts: MergeTaskOptions): Promise { const execForge = opts.execForge ?? ((cli, args, cwd) => execCli(cli, args, cwd)) let note: string | null = null - for (const candidate of mergeCandidates(opts.cwd, opts.task, opts.settings)) { + for (const candidate of mergeCandidates(opts.cwd, opts.task, settings)) { const outcome = await execForge(candidate.cli, candidate.args, opts.cwd) if (outcome.kind === 'missing') { continue @@ -754,8 +860,39 @@ export async function mergeTask(opts: MergeTaskOptions): Promise { data: { name: 'failed', cli: candidate.cli, message: reason.detail ?? message }, reason_code: 'merge_conflict', }) + if (opts.task.brain_ticket) { + void reportBrainTransition(opts.cwd, opts.task, { + type: 'failed', + error_message: reason.detail ?? message, + }) + } return { kind: 'failed', reason, readiness, events } } + // D20 idempotence: a crash between an EARLIER attempt's forge merge + // landing and this process recording it resumes here on the SAME + // branch, and the forge's own refusal (already merged, the PR/MR no + // longer open) reads exactly like any other error — never a conflict, + // so it never took the branch above. Asked here, not before the call: + // see branchAlreadyMerged's own header for why the cost is paid only + // once a fresh attempt has already failed. + if (await branchAlreadyMerged(candidate.cli, opts.cwd, opts.task.branch, execForge)) { + events.push({ + type: MERGE_EVENT, + data: { + name: 'merged', + cli: candidate.cli, + branch: opts.task.branch, + already_merged: true, + }, + }) + if (opts.task.brain_ticket) { + void reportBrainTransition(opts.cwd, opts.task, { + type: 'merged', + branch: opts.task.branch, + }) + } + return { kind: 'merged', cli: candidate.cli, url: null, readiness, events } + } // Keep trying (a dual-remote setup may have the other CLI working) but // remember the failure: it is the honest note if nothing else succeeds. note = `${candidate.cli} failed: ${message}` @@ -768,11 +905,18 @@ export async function mergeTask(opts: MergeTaskOptions): Promise { name: 'merged', cli: candidate.cli, branch: opts.task.branch, - strategy: opts.settings.strategy ?? 'forge default', - deleted_branch: opts.settings.deleteBranch, + strategy: settings.strategy ?? 'forge default', + deleted_branch: settings.deleteBranch, ...(url ? { url } : {}), }, }) + if (opts.task.brain_ticket) { + // `merge_sha` is omitted: neither `gh pr merge` nor `glab mr merge` + // hands one back on this path (only the MR/PR url, when the forge + // gives one). The brain reads a `merged` transition with no sha as + // "landed, sha unknown" rather than a claim about a commit nobody read. + void reportBrainTransition(opts.cwd, opts.task, { type: 'merged', branch: opts.task.branch }) + } return { kind: 'merged', cli: candidate.cli, url, readiness, events } } @@ -793,5 +937,11 @@ export async function mergeTask(opts: MergeTaskOptions): Promise { data: { name: 'failed', message: reason.detail ?? 'the merge could not be performed' }, reason_code: 'forge_unreachable', }) + if (opts.task.brain_ticket) { + void reportBrainTransition(opts.cwd, opts.task, { + type: 'failed', + error_message: reason.detail ?? 'the merge could not be performed', + }) + } return { kind: 'failed', reason, readiness, events } } diff --git a/packages/cli/src/task-plan.ts b/packages/cli/src/task-plan.ts index 4d84f1d..7068af6 100644 --- a/packages/cli/src/task-plan.ts +++ b/packages/cli/src/task-plan.ts @@ -57,6 +57,12 @@ import { resolveKnownAgentCommand } from './wizard.js' export type TaskPlanDeps = { /** The PROJECT's repo root — never the repo the workspace was launched from. */ cwd: string + /** + * The scratch project: `cwd` is a plain directory. There is no base to fork + * from and no branch to name, and announcing either would promise the user a + * deliverable this conversation will never produce. + */ + scratch?: boolean /** * Fresh per-project runtime snapshot (T1.4): the agent command a new task * runs when it does not name its own, and the project's configured @@ -375,6 +381,25 @@ function resolveTargets( if (branch && base) { return { ok: false, code: 400, error: "'branch' and 'base' are mutually exclusive" } } + if (deps.scratch) { + if (branch || base) { + return { + ok: false, + code: 400, + error: "a conversation with no repository cannot name a 'branch' or a 'base'", + } + } + return { + ok: true, + branch: '', + recordBase: '', + planBase: '', + planTarget: '', + planBranch: '', + branchCertain: true, + baseNote: null, + } + } if (base) { const refusal = refuseExplicitBase(deps.cwd, base) if (refusal) { diff --git a/packages/cli/src/task-post-merge-checks.test.ts b/packages/cli/src/task-post-merge-checks.test.ts new file mode 100644 index 0000000..9b5e8ea --- /dev/null +++ b/packages/cli/src/task-post-merge-checks.test.ts @@ -0,0 +1,191 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, describe, expect, test } from 'bun:test' +import type { TaskRecord } from './contract.js' +import { tryGit } from './git.js' +import type { ExecFn, ExecResult } from './task-checks.js' +import { replayChecksOnDefaultBranch, type GitExecFn } from './task-post-merge-checks.js' + +function git(args: string[], cwd: string): void { + execFileSync('git', args, { cwd, encoding: 'utf8', stdio: 'pipe' }) +} + +const tempDirs: string[] = [] + +afterAll(() => { + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +/** + * origin (bare) + a clone with `main` pushed, then a further LOCAL-ONLY + * commit on `cwd`'s own `main` that is never pushed: `mainSha` is the tip + * BEFORE that local drift, so a test can prove the replay reflects what was + * FETCHED from origin, not the caller's own local state. + */ +function setupRepo(): { origin: string; cwd: string; mainSha: string } { + const origin = mkdtempSync(join(tmpdir(), 'codesema-postmerge-origin-')) + tempDirs.push(origin) + git(['init', '--bare', '-b', 'main', origin], origin) + + const cwd = mkdtempSync(join(tmpdir(), 'codesema-postmerge-cwd-')) + tempDirs.push(cwd) + git(['init', '-b', 'main', cwd], cwd) + git(['config', 'user.email', 'a@b.c'], cwd) + git(['config', 'user.name', 'Test'], cwd) + git(['remote', 'add', 'origin', origin], cwd) + writeFileSync(join(cwd, 'bun.lock'), '') + writeFileSync(join(cwd, 'a.txt'), 'base\n') + git(['add', '.'], cwd) + git(['commit', '-m', 'init'], cwd) + git(['push', 'origin', 'main'], cwd) + const mainSha = execFileSync('git', ['rev-parse', 'main'], { cwd, encoding: 'utf8' }).trim() + + writeFileSync(join(cwd, 'a.txt'), 'local-only\n') + git(['commit', '-am', 'local only, never pushed'], cwd) + + return { origin, cwd, mainSha } +} + +function fakeTask(overrides: Partial = {}): TaskRecord { + const now = new Date().toISOString() + return { + version: 1, + id: 'abcdef123456', + title: 'a task', + status: 'shipped', + base: 'main', + branch: 'codesema/task-a', + worktree: '/nowhere/worktree', + agent_session_id: null, + turns: [], + review_ref: null, + work_ms: 0, + wait_ms: 0, + auto_ship: true, + work_on: false, + isolation: 'policy', + created_at: now, + updated_at: now, + ...overrides, + } +} + +function worktreeCount(cwd: string): number { + return (tryGit(['worktree', 'list', '--porcelain'], cwd) ?? '') + .split('\n\n') + .filter((s) => s.trim()).length +} + +const ok = (over: Partial = {}): ExecResult => ({ + code: 0, + stdout: '', + stderr: '', + timedOut: false, + failure: null, + ...over, +}) + +type Call = { file: string; args: string[]; timeoutMs: number } + +function fakeExec(respond: (call: Call) => ExecResult): { calls: Call[]; exec: ExecFn } { + const calls: Call[] = [] + const exec: ExecFn = (file, args, opts) => { + const call = { file, args, timeoutMs: opts.timeoutMs } + calls.push(call) + return Promise.resolve(respond(call)) + } + return { calls, exec } +} + +/** Rule: docker exists; every other step (install, checks) answers ok. */ +function dockerRig() { + return fakeExec((call) => { + if (call.args[0] === '--version') { + return call.file === 'docker' ? ok({ stdout: 'Docker version 27' }) : ok({ code: 1 }) + } + return ok() + }) +} + +const failingFetch: GitExecFn = (args) => { + if (args[0] === 'fetch') { + throw new Error('could not resolve host') + } + throw new Error(`unexpected git call in this test: ${args.join(' ')}`) +} + +const throwingExec: ExecFn = () => { + throw new Error('engine vanished mid-probe') +} + +describe('replayChecksOnDefaultBranch', () => { + test('a fetch that cannot reach the remote resolves to null and touches no worktree', async () => { + const { cwd } = setupRepo() + + const result = await replayChecksOnDefaultBranch({ + cwd, + task: fakeTask(), + target: 'main', + gitExecFn: failingFetch, + }) + + expect(result).toBeNull() + expect(worktreeCount(cwd)).toBe(1) + }) + + test('fetches the target, runs checks in a disposable worktree at the FETCHED sha, and cleans up', async () => { + const { cwd, mainSha } = setupRepo() + const { calls, exec } = dockerRig() + + const result = await replayChecksOnDefaultBranch({ + cwd, + task: fakeTask({ id: 'aaaaaaaaaaaa' }), + target: 'main', + execFn: exec, + }) + + expect(result?.status).toBe('passed') + // The caller's own LOCAL main has an extra, never-pushed commit (see + // setupRepo): a head_sha equal to mainSha proves this replay ran on what + // was FETCHED from origin, not on local state. + expect(result?.head_sha).toBe(mainSha) + + const testRun = calls.find((c) => c.args.at(-1) === 'bun test') + expect(testRun).toBeDefined() + const mountArg = testRun?.args[(testRun.args.indexOf('-v') ?? -1) + 1] ?? '' + expect(mountArg.startsWith(`${join(tmpdir(), 'codesema-postmerge-aaaaaaaaaaaa-')}`)).toBe(true) + + expect(worktreeCount(cwd)).toBe(1) + }) + + test('the disposable worktree is torn down even when runChecks itself throws', async () => { + const { cwd } = setupRepo() + + const result = await replayChecksOnDefaultBranch({ + cwd, + task: fakeTask(), + target: 'main', + execFn: throwingExec, + }) + + expect(result).toBeNull() + expect(worktreeCount(cwd)).toBe(1) + }) + + test('an unresolvable target branch resolves to null rather than throwing', async () => { + const { cwd } = setupRepo() + + const result = await replayChecksOnDefaultBranch({ + cwd, + task: fakeTask(), + target: 'no-such-branch', + }) + + expect(result).toBeNull() + expect(worktreeCount(cwd)).toBe(1) + }) +}) diff --git a/packages/cli/src/task-post-merge-checks.ts b/packages/cli/src/task-post-merge-checks.ts new file mode 100644 index 0000000..372a153 --- /dev/null +++ b/packages/cli/src/task-post-merge-checks.ts @@ -0,0 +1,97 @@ +// D22 (minimal): best-effort replay of a task's checks on the default branch, +// right after its merge landed. `checks` only ever proved the task branch +// green in ISOLATION; this is the one confirmation that what merged still +// passes once combined with everything else already on the target. Every +// failure mode here — a fetch that cannot reach the remote, a worktree that +// could not be materialized, an engine that vanished mid-run — resolves to +// `null`, NEVER a throw: per TaskEventType's own `post_merge_checks` doc +// (contract/tasks.ts), this replay is news about the default branch, not a +// verdict on the task, and nothing here may strand a caller waiting on it. + +import { randomBytes } from 'node:crypto' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { TaskChecks, TaskRecord } from './contract.js' +import { addDetachedWorktree, removeMrWorktree, underRepoLock } from './ephemeral-worktree.js' +import { git } from './git.js' +import type { ChecksConfig } from './repo-config.js' +import { runChecks, type ExecFn } from './task-checks.js' + +/** + * Same shape as `git()` (git.ts): argv in, stdout out, throws on failure. + * Injectable so a caller can simulate a fetch that cannot reach the remote — + * the one operation here that fails for reasons outside this repository — + * without standing up a broken remote for real. + */ +export type GitExecFn = (args: string[], cwd: string) => string + +export type ReplayChecksOptions = { + /** MAIN repo root: the fetch and the disposable worktree both happen here — the task's own worktree may already be gone by the time its merge lands. */ + cwd: string + /** Carried for its id (the disposable worktree's name) only; a red replay converting to a ticket is D22's step G, out of this scope. */ + task: TaskRecord + /** The branch the task merged into (`record.base`). */ + target: string + config?: ChecksConfig | null + projectId?: string + /** Test seam for `runChecks`'s own container exec; the default drives a real docker/podman. */ + execFn?: ExecFn + /** Test seam for the git calls below; the default runs a real `git`. */ + gitExecFn?: GitExecFn +} + +/** + * Fetches the target, checks it out detached in a throwaway worktree, runs + * the same checks engine `runChecks` already uses on a task's own branch, and + * always tears the worktree down. `null` on ANY failure along the way — a + * repository this replay could not fetch, lock or check out tells nothing + * about whether the merged code is green, so it is reported as "not + * evaluated", exactly like `resolveChecksPlan` returning no plan does. + */ +export async function replayChecksOnDefaultBranch( + opts: ReplayChecksOptions, +): Promise { + const gitFn = opts.gitExecFn ?? git + const worktreeDir = join( + tmpdir(), + `codesema-postmerge-${opts.task.id}-${randomBytes(4).toString('hex')}`, + ) + + try { + gitFn( + ['fetch', 'origin', `+refs/heads/${opts.target}:refs/remotes/origin/${opts.target}`], + opts.cwd, + ) + } catch { + return null + } + + try { + await underRepoLock(opts.cwd, () => + addDetachedWorktree(opts.cwd, worktreeDir, `refs/remotes/origin/${opts.target}`), + ) + } catch { + return null + } + + try { + const resolvedSha = gitFn(['rev-parse', 'HEAD'], worktreeDir) + return await runChecks({ + worktree: worktreeDir, + ...(opts.config !== undefined ? { config: opts.config } : {}), + ...(opts.projectId !== undefined ? { projectId: opts.projectId } : {}), + headSha: resolvedSha, + ...(opts.execFn ? { execFn: opts.execFn } : {}), + }) + } catch { + return null + } finally { + try { + await underRepoLock(opts.cwd, () => removeMrWorktree(opts.cwd, worktreeDir)) + } catch { + // Best-effort cleanup: a lock that could not be acquired, or a removal + // that failed, must not turn a completed (or aborted) replay into a + // thrown error — `git worktree prune` sweeps orphaned entries later. + } + } +} diff --git a/packages/cli/src/task-review.test.ts b/packages/cli/src/task-review.test.ts index 02ea2d6..08e8d2a 100644 --- a/packages/cli/src/task-review.test.ts +++ b/packages/cli/src/task-review.test.ts @@ -10,6 +10,7 @@ import { type Finding, type ReviewRecord, type TaskChecks, + type TaskReason, type TaskRecord, type TaskStatus, type Verdict, @@ -28,6 +29,7 @@ import { actionableFindingIds, applyChecksGate, blockingFindingsDetail, + brainSettleTransition, buildAutoFixTurnPrompt, buildFixTurnPrompt, checksBlockReady, @@ -46,6 +48,7 @@ import { loadTask, readTaskEvents, saveTask, + writeTaskChecks, type AppendTaskEventInput, } from './tasks-store.js' @@ -659,10 +662,13 @@ describe('createTaskReviewer', () => { const record = await makeTaskWithWorktree(repo, 'spread task') commitChange(record.worktree, 'work.txt') const rig = fakeIo(record) + // D24: kind 'design' is exempt from the repro rule (a judgment call, not + // a reproducible-behavior claim) — keeps this test's severities exactly + // as the model reported them, isolated from verifyFindingRepros. const findings: Finding[] = [ { file: 'work.txt', severity: 'critical', message: 'boom' }, - { file: 'work.txt', severity: 'major', message: 'meh' }, - { file: 'work.txt', severity: 'major', message: 'also meh' }, + { file: 'work.txt', severity: 'major', kind: 'design', message: 'meh' }, + { file: 'work.txt', severity: 'major', kind: 'design', message: 'also meh' }, { file: 'work.txt', severity: 'info', message: 'nit' }, ] const flow = fakeSimpleFlow({ @@ -1395,6 +1401,156 @@ describe('createTaskReviewer: the criteria chapter (T3.2)', () => { }) }) +// --- D16: the checks chapter ----------------------------------------------- + +function checksAt(over: Partial = {}): TaskChecks { + return { + head_sha: 'whatever', + started_at: '2026-08-26T10:00:00.000Z', + finished_at: '2026-08-26T10:01:00.000Z', + status: 'passed', + error: null, + checks: [], + ...over, + } +} + +describe('createTaskReviewer: the checks chapter (D16)', () => { + test('a red checks run reaches the review prompt as its own MANDATORY chapter', async () => { + const repo = makeRepo() + const record = await makeTaskWithWorktree(repo, 'checked task') + commitChange(record.worktree, 'feature.txt') + writeTaskChecks( + repo, + record.id, + checksAt({ + status: 'failed', + source: 'scripts', + checks: [ + { + command: 'bun test', + status: 'failed', + exit_code: 1, + duration_ms: 5, + tail: 'FAIL feature.test.ts', + }, + ], + }), + ) + const rig = fakeIo(record) + const flow = fakeSimpleFlow({ ok: true, record: fakeReview('approve'), reportLines: [] }) + + await reviewer(repo, { runSimpleFlowFn: flow.fn })(record, rig.io) + + const prompt = flow.calls[0]?.prompt ?? '' + expect(prompt).toContain('Repository checks, MANDATORY chapter') + expect(prompt).toContain('- bun test: failed') + expect(prompt).toContain('FAIL feature.test.ts') + }) + + test('a task with no checks.json at all gets no checks chapter', async () => { + const repo = makeRepo() + const record = await makeTaskWithWorktree(repo, 'unchecked task') + commitChange(record.worktree, 'feature.txt') + const rig = fakeIo(record) + const flow = fakeSimpleFlow({ ok: true, record: fakeReview('approve'), reportLines: [] }) + + await reviewer(repo, { runSimpleFlowFn: flow.fn })(record, rig.io) + + expect(flow.calls[0]?.prompt ?? '').not.toContain('Repository checks') + }) + + test('a still-RUNNING checks snapshot is not a result yet: no chapter either', async () => { + const repo = makeRepo() + const record = await makeTaskWithWorktree(repo, 'running-checks task') + commitChange(record.worktree, 'feature.txt') + writeTaskChecks(repo, record.id, checksAt({ status: 'running', finished_at: null })) + const rig = fakeIo(record) + const flow = fakeSimpleFlow({ ok: true, record: fakeReview('approve'), reportLines: [] }) + + await reviewer(repo, { runSimpleFlowFn: flow.fn })(record, rig.io) + + expect(flow.calls[0]?.prompt ?? '').not.toContain('Repository checks') + }) +}) + +// --- D17: mechanical criteria decided without the reviewer ------------------ + +describe('createTaskReviewer: mechanical criteria (D17)', () => { + test('a [proof:command] criterion is decided from the turn checks, never sent to the model', async () => { + const repo = makeRepo() + const record = await makeTaskWithWorktree(repo, 'mechanical task') + const mechanicalCriterion = criterionOf( + 'WHEN the suite runs THE SYSTEM SHALL pass it [proof:command bun test]', + ) + const judgedCriterion = criterionOf('WHEN reviewed THE SYSTEM SHALL be judged by a human') + record.criteria = [mechanicalCriterion, judgedCriterion] + saveTask(repo, record) + commitChange(record.worktree, 'feature.txt') + writeTaskChecks( + repo, + record.id, + checksAt({ + checks: [{ command: 'bun test', status: 'passed', exit_code: 0, duration_ms: 5, tail: '' }], + }), + ) + const rig = fakeIo(record) + const flow = fakeSimpleFlow({ + ok: true, + record: fakeReviewWithCriteria('approve', [ + { criterion_id: judgedCriterion.id, status: 'met', evidence: ANCHOR }, + ]), + reportLines: [], + }) + + await reviewer(repo, { runSimpleFlowFn: flow.fn })(record, rig.io) + + // The mechanical criterion never reached the model's own prompt... + const prompt = flow.calls[0]?.prompt ?? '' + expect(prompt).not.toContain(mechanicalCriterion.id) + expect(prompt).toContain(judgedCriterion.id) + // ...yet the final gate still carries a verdict for it, decided from the + // checks that already ran, and the task is not blocked on it. + expect(record.status).toBe('review_ok') + const gateEvents = rig.events.filter((e) => e.type === 'criteria') + expect(gateEvents).toHaveLength(1) + expect(gateEvents[0]?.data).toMatchObject({ name: 'gate_passed', met: 2, unmet: 0, unclear: 0 }) + }) + + test('a [proof:command] criterion whose command FAILED in the turn checks blocks the gate', async () => { + const repo = makeRepo() + const record = await makeTaskWithWorktree(repo, 'mechanical failing task') + const mechanicalCriterion = criterionOf( + 'WHEN the suite runs THE SYSTEM SHALL pass it [proof:command bun test]', + ) + record.criteria = [mechanicalCriterion] + saveTask(repo, record) + commitChange(record.worktree, 'feature.txt') + writeTaskChecks( + repo, + record.id, + checksAt({ + status: 'failed', + checks: [{ command: 'bun test', status: 'failed', exit_code: 1, duration_ms: 5, tail: '' }], + }), + ) + const rig = fakeIo(record) + const flow = fakeSimpleFlow({ + ok: true, + record: fakeReviewWithCriteria('approve', undefined), + reportLines: [], + }) + + await reviewer(repo, { runSimpleFlowFn: flow.fn })(record, rig.io) + + // review_ko for TWO independent reasons here (the checks gate AND the + // criteria gate); either is a correct block, what matters is it is one. + expect(record.status).toBe('review_ko') + const gateEvent = rig.events.find((e) => e.type === 'criteria') + expect(gateEvent?.data).toMatchObject({ unmet: 1 }) + }) +}) + describe('createTaskReviewer: the hard gate (T3.2)', () => { const allMet: CriterionVerdict[] = [ { criterion_id: GC1.id, status: 'met', evidence: ANCHOR }, @@ -1447,21 +1603,24 @@ describe('createTaskReviewer: the hard gate (T3.2)', () => { expect(rig.events.some((event) => event.type === 'error')).toBe(false) }) - test('a single unclear blocks exactly as hard, and says so', async () => { + test('a sincere unclear is LIFTED by the settled OK review, out loud (D18)', async () => { const { record, rig } = await runGate([ { criterion_id: GC1.id, status: 'met', evidence: ANCHOR }, { criterion_id: GC2.id, status: 'met', evidence: ANCHOR }, { criterion_id: GC3.id, status: 'unclear' }, ]) - expect(record.status).toBe('review_ko') - expect(record.reason?.code).toBe('criteria_unmet') - expect(record.reason?.detail).toContain('1 unclear') + expect(record.status).toBe('review_ok') + expect(record.reason).toBeUndefined() expect(rig.events.find((event) => event.type === 'criteria')?.data).toEqual({ - name: 'gate_blocked', + name: 'gate_waived', met: 2, unmet: 0, unclear: 1, }) + const waived = rig.events.find( + (event) => event.type === 'message' && event.data.name === 'criteria_unclear_waived', + ) + expect(waived?.data.text).toContain('1') }) test('a criterion the model skipped blocks: silence is never a pass', async () => { @@ -1491,21 +1650,22 @@ describe('createTaskReviewer: the hard gate (T3.2)', () => { expect(record.reason?.detail).toContain('no verdict back from the reviewer') }) - test('…and a reviewer that judged all three and doubted says something ELSE', async () => { - // The discriminator for the line above: identical tally, different fact. + test('…and a reviewer that judged all three and doubted is waived, unlike silence (D18)', async () => { + // The discriminator for the line above: identical tally, different fact, + // different outcome. Silence (unjudged) blocks; a sincere doubt on every + // criterion rides the settled OK verdict, and the gate line says which. const { record, rig } = await runGate([ { criterion_id: GC1.id, status: 'unclear' }, { criterion_id: GC2.id, status: 'unclear' }, { criterion_id: GC3.id, status: 'unclear' }, ]) - expect(record.status).toBe('review_ko') + expect(record.status).toBe('review_ok') expect(rig.events.find((event) => event.type === 'criteria')?.data).toEqual({ - name: 'gate_blocked', + name: 'gate_waived', met: 0, unmet: 0, unclear: 3, }) - expect(record.reason?.detail).not.toContain('no verdict back from the reviewer') }) test('an evidence the diff cannot carry is journaled as such, not as a doubt', async () => { @@ -1783,8 +1943,11 @@ describe('createTaskReviewer: an approve never releases a blocking finding (T3.3 test('approve + an unresolved MAJOR finding: the task stays blocked', async () => { // `groundReview` escalates approve+critical, never approve+major: this is // the gap the CLI-side guard-rail closes. + // D24: kind 'design' is exempt from the repro rule, so this finding is + // never touched by verifyFindingRepros — this test is about T3.3's + // guard-rail, not about D24's demotion (covered on its own elsewhere). const { record, rig } = await runVerdict('approve', [ - { file: 'feature.txt', severity: 'major', message: 'leaks a descriptor' }, + { file: 'feature.txt', severity: 'major', kind: 'design', message: 'leaks a descriptor' }, ]) expect(record.status).toBe('review_ko') expect(record.reason?.code).toBe('review_blocked') @@ -1826,6 +1989,123 @@ describe('createTaskReviewer: an approve never releases a blocking finding (T3.3 }) }) +describe('createTaskReviewer: D24 repro verification and inter-turn memory', () => { + test('a major finding with no repro is demoted before the guard-rail reads it: the verdict is released', async () => { + const repo = makeRepo() + const record = await makeTaskWithWorktree(repo, 'unproven major') + commitChange(record.worktree, 'feature.txt') + const rig = fakeIo(record) + // Behavior-asserting (kind undefined, severity major), no `repro`: before + // D24 this alone forced review_ko through hasBlockingFindings (T3.3), + // exactly the fixture the T3.3 describe block above uses with `kind: + // 'design'` to stay OUT of this rule. + const unproven: Finding = { file: 'feature.txt', severity: 'major', message: 'looks risky' } + const flow = fakeSimpleFlow({ + ok: true, + record: fakeReview('approve', [unproven]), + reportLines: [], + }) + + await reviewer(repo, { runSimpleFlowFn: flow.fn })(record, rig.io) + + expect(record.status).toBe('review_ok') + expect(record.reason).toBeUndefined() + const done = rig.events.find((e) => e.type === 'review_done') + expect(done?.data).toMatchObject({ repro_demoted: 1, severity_minor: 1 }) + expect(done?.data.severity_major).toBeUndefined() + // T3.3's own guard-rail message never fires: nothing blocking survived. + expect(rig.events.some((e) => e.data.name === 'review_verdict_overridden')).toBe(false) + }) + + test('a second turn on the SAME head (nothing committed since the last review) gets the repeat prompt', async () => { + const repo = makeRepo() + const record = await makeTaskWithWorktree(repo, 'repeat turn') + commitChange(record.worktree, 'feature.txt') + const rig1 = fakeIo(record) + // The real flow builds its record's meta from the prep input (record.ts's + // buildRecord): mirrored here, since it is what findPreviousReview later + // matches on (same pattern as the baseline-anchoring incremental test). + const flow1 = fakeSimpleFlow((options) => ({ + ok: true, + record: { + ...fakeReview('request_changes', [ + { file: 'feature.txt', severity: 'major', kind: 'design', message: 'first pass' }, + ]), + meta: { + ...fakeReview('approve').meta, + branch: options.input.branch, + target: options.input.target, + head_sha: options.input.head_sha, + }, + }, + reportLines: [], + })) + + await reviewer(repo, { runSimpleFlowFn: flow1.fn })(record, rig1.io) + expect(record.status).toBe('review_ko') + + // Turn 2: NO new commit — the worktree HEAD is exactly what turn 1 just + // archived. `record.status` is left as turn 1 settled it: the hook is + // called directly here (bypassing the runner), and never reads the + // incoming status itself — only the runner uses it as a precondition. + const rig2 = fakeIo(record) + const flow2 = fakeSimpleFlow({ ok: true, record: fakeReview('approve'), reportLines: [] }) + + await reviewer(repo, { runSimpleFlowFn: flow2.fn })(record, rig2.io) + + expect(flow2.calls).toHaveLength(1) + expect(flow2.calls[0]?.incremental).toBe(true) + expect(flow2.calls[0]?.prompt).toContain('EXACT SAME commit') + expect(flow2.calls[0]?.prompt).toContain('Previous review verdict: request_changes') + expect(flow2.calls[0]?.prompt).toContain('') + expect(record.status).toBe('review_ok') + }) + + test('dual mode never receives a repeat/incremental prompt, even with a previous review at the same head', async () => { + const repo = makeRepo() + const record = await makeTaskWithWorktree(repo, 'dual unaffected') + commitChange(record.worktree, 'feature.txt') + const rig1 = fakeIo(record) + const simpleFlow1 = fakeSimpleFlow((options) => ({ + ok: true, + record: { + ...fakeReview('approve'), + meta: { + ...fakeReview('approve').meta, + branch: options.input.branch, + target: options.input.target, + head_sha: options.input.head_sha, + }, + }, + reportLines: [], + })) + // Turn 1 in SIMPLE mode plants a previous archive at the current head. + await reviewer(repo, { runSimpleFlowFn: simpleFlow1.fn })(record, rig1.io) + expect(record.status).toBe('review_ok') + + // Turn 2 in DUAL mode, same head, no new commit: task-review.ts's D24 + // wiring only ever computes a prebuiltPrompt for SIMPLE mode (the + // `mode === 'simple'` guard), so runSimpleFlow must never even be called. + const rig2 = fakeIo(record) + const simpleFlow2 = fakeSimpleFlow({ + ok: false, + failure: 'run', + message: 'must not be called in dual mode', + }) + const dual = fakeDualFlow({ ok: true, record: fakeReview('approve'), reportLines: [] }) + + await reviewer(repo, { + mode: 'dual', + runSimpleFlowFn: simpleFlow2.fn, + runDualFlowFn: dual.fn, + })(record, rig2.io) + + expect(simpleFlow2.calls).toHaveLength(0) + expect(dual.calls).toHaveLength(1) + expect(record.status).toBe('review_ok') + }) +}) + describe('buildAutoFixTurnPrompt (T3.3)', () => { test('asks for every actionable finding, and never for the notes', () => { const repo = makeRepo() @@ -1836,7 +2116,10 @@ describe('buildAutoFixTurnPrompt (T3.3)', () => { ]), repo, ) - const prompt = buildAutoFixTurnPrompt({ review_ref: saved, turns: [] } as unknown as TaskRecord) + const prompt = buildAutoFixTurnPrompt( + { review_ref: saved, turns: [] } as unknown as TaskRecord, + null, + ) expect(prompt).toContain('off by one') expect(prompt).not.toContain('a nit nobody must chase') // It IS the manual path's prompt: same builder, same rules. @@ -1861,11 +2144,14 @@ describe('buildAutoFixTurnPrompt (T3.3)', () => { }, repo, ) - const prompt = buildAutoFixTurnPrompt({ - review_ref: saved, - criteria: [GC1, GC2, GC3], - turns: [], - } as unknown as TaskRecord) + const prompt = buildAutoFixTurnPrompt( + { + review_ref: saved, + criteria: [GC1, GC2, GC3], + turns: [], + } as unknown as TaskRecord, + null, + ) expect(prompt).toContain(GC2.id) expect(prompt).toContain('WHEN checks fail') expect(prompt).toContain(GC3.id) @@ -1873,7 +2159,7 @@ describe('buildAutoFixTurnPrompt (T3.3)', () => { expect(prompt).not.toContain(GC1.id) }) - test('null when there is nothing concrete to ask for', () => { + test('null when there is nothing concrete to ask for: no findings, no criteria, no blocking checks', () => { const repo = makeRepo() // An archive with no actionable finding and no criteria: a round spent on // this would name no work at all. @@ -1881,20 +2167,54 @@ describe('buildAutoFixTurnPrompt (T3.3)', () => { fakeReview('comment', [{ file: 'a.ts', severity: 'info', message: 'nit' }]), repo, ) - expect(buildAutoFixTurnPrompt({ review_ref: empty, turns: [] } as unknown as TaskRecord)).toBe( - null, - ) - expect(buildAutoFixTurnPrompt({ review_ref: null, turns: [] } as unknown as TaskRecord)).toBe( - null, - ) expect( - buildAutoFixTurnPrompt({ - review_ref: join(repo, 'gone.json'), - turns: [], - } as unknown as TaskRecord), + buildAutoFixTurnPrompt({ review_ref: empty, turns: [] } as unknown as TaskRecord, null), + ).toBe(null) + expect( + buildAutoFixTurnPrompt({ review_ref: null, turns: [] } as unknown as TaskRecord, null), + ).toBe(null) + expect( + buildAutoFixTurnPrompt( + { review_ref: join(repo, 'gone.json'), turns: [] } as unknown as TaskRecord, + null, + ), + ).toBe(null) + // A GREEN checks run blocks nothing either: still null. + expect( + buildAutoFixTurnPrompt( + { review_ref: empty, turns: [] } as unknown as TaskRecord, + checksOf({ status: 'passed' }), + ), ).toBe(null) }) + test('a RED checks run earns its own chapter, even with nothing else to ask for', () => { + const repo = makeRepo() + const empty = archiveRecord( + fakeReview('comment', [{ file: 'a.ts', severity: 'info', message: 'nit' }]), + repo, + ) + const failing = checksOf({ + status: 'failed', + checks: [ + { + command: 'bun test', + status: 'failed', + exit_code: 1, + duration_ms: 5, + tail: 'FAIL a.test.ts', + }, + ], + }) + const prompt = buildAutoFixTurnPrompt( + { review_ref: empty, turns: [] } as unknown as TaskRecord, + failing, + ) + expect(prompt).toContain('What must still pass') + expect(prompt).toContain('bun test') + expect(prompt).toContain('FAIL a.test.ts') + }) + test('non-regression: the MANUAL path still honours the human’s own selection', () => { const repo = makeRepo() const saved = archiveRecord( @@ -1909,8 +2229,67 @@ describe('buildAutoFixTurnPrompt (T3.3)', () => { expect(manual).toContain('second one') expect(manual).not.toContain('first one') // ...while the automatic one takes both, because both block. - const auto = buildAutoFixTurnPrompt(task) + const auto = buildAutoFixTurnPrompt(task, null) expect(auto).toContain('first one') expect(auto).toContain('second one') }) }) + +describe('brainSettleTransition', () => { + test('review_ok with no reviewOutcome (the empty-diff short-circuit): an approve, no findings_total', () => { + const transition = brainSettleTransition({ status: 'review_ok' }) + expect(transition).toEqual({ type: 'review_result', verdict: 'approve' }) + }) + + test('review_ok with a reviewOutcome: an approve, carrying findings_total', () => { + const transition = brainSettleTransition({ + status: 'review_ok', + reviewOutcome: fakeReview('approve', [{ file: 'a.ts', severity: 'minor', message: 'nit' }]), + }) + expect(transition).toEqual({ type: 'review_result', verdict: 'approve', findings_total: 1 }) + }) + + test('review_ko with a reviewOutcome (a verdict was produced, possibly overridden): request_changes', () => { + const transition = brainSettleTransition({ + status: 'review_ko', + reviewOutcome: fakeReview('request_changes', [ + { file: 'a.ts', severity: 'major', message: 'bug' }, + ]), + }) + expect(transition).toEqual({ + type: 'review_result', + verdict: 'request_changes', + findings_total: 1, + }) + }) + + test('review_ko with NO reviewOutcome (a flow failure, or an exception): failed, not review_result', () => { + // No reviewer ever produced a verdict here: reporting review_result would + // be indistinguishable from a reviewer that looked at the work and + // rejected it. + const transition = brainSettleTransition({ status: 'review_ko' }) + expect(transition).toEqual({ type: 'failed' }) + }) + + test('review_ko with no reviewOutcome and a reason: failed, carrying the reason as error_message', () => { + const reason: TaskReason = { code: 'review_blocked', detail: 'review failed: agent crashed' } + const transition = brainSettleTransition({ status: 'review_ko', reason }) + expect(transition).toEqual({ + type: 'failed', + error_message: 'review failed: agent crashed', + }) + }) + + test('a reason with no detail adds no error_message', () => { + const reason: TaskReason = { code: 'review_blocked' } + const transition = brainSettleTransition({ status: 'review_ko', reason }) + expect(transition).toEqual({ type: 'failed' }) + }) + + test('costTicks rides along on a review_result, omitted entirely when absent', () => { + const withCost = brainSettleTransition({ status: 'review_ok', costTicks: 42 }) + expect(withCost).toEqual({ type: 'review_result', verdict: 'approve', cost_ticks: 42 }) + const withoutCost = brainSettleTransition({ status: 'review_ok' }) + expect('cost_ticks' in withoutCost).toBe(false) + }) +}) diff --git a/packages/cli/src/task-review.ts b/packages/cli/src/task-review.ts index 335cddd..642c60b 100644 --- a/packages/cli/src/task-review.ts +++ b/packages/cli/src/task-review.ts @@ -9,33 +9,43 @@ // back by GET /api/tasks/:id/review (readTaskReview), so a conversation can // open the review of ANY of its turns, not just the last one. -import { join, resolve, sep } from 'node:path' import { ensureWorkDir, type ReviewMode } from './config.js' import { sanitizeRecord, + type ArmTransition, type Finding, type ReviewRecord, type TaskChecks, type TaskReason, type TaskRecord, } from './contract.js' +import { verifyFindingRepros } from './finding-repro.js' import { buildAgentFixPrompt, isFixable } from './fix.js' import { isAncestor, refExists, tryGit } from './git.js' import { createLoadCap, DEFAULT_MAX_CONCURRENT_AGENTS, type LoadCap } from './load-cap.js' import { prep } from './prep.js' -import { archiveRecord, readJson } from './record.js' +import { archiveRecord, findPreviousReview, readJson, resolveArchivePath } from './record.js' import { buildFullReviewPrompt, + buildIncrementalPrompt, + buildRepeatReviewPrompt, runDualFlow, runSimpleFlow, type DualOutcome, type SimpleOutcome, } from './review.js' import { createSession } from './serve.js' +import { autoPushReview } from './sync.js' +import { reportBrainTransition } from './task-brain.js' +import { buildChecksChapter } from './task-checks.js' import { buildCriteriaChapter, + combineCriteriaOutcomes, + criteriaGateWaivable, criteriaUnmetDetail, + partitionCriteriaByProof, resolveCriteria, + resolveMechanicalCriteria, unmetCriteriaFixChapter, type CriteriaOutcome, } from './task-criteria-gate.js' @@ -45,7 +55,7 @@ import { type TaskTurnIo, type TaskTurnReviewFn, } from './task-runner.js' -import { loadTask, taskReason } from './tasks-store.js' +import { loadTask, readTaskChecks, taskReason } from './tasks-store.js' import { progressLabel } from './ui.js' /** @@ -70,10 +80,14 @@ export function taskReviewVerdict(record: ReviewRecord): 'review_ok' | 'review_k if (verdict === 'request_changes') { return 'review_ko' } - // `isFixable` (fix.ts) rather than a second copy of the same predicate: the - // bar that makes a 'comment' block has to be the bar the fix prompt then - // carries, or a task blocks on a finding nobody is ever asked to fix. - return findings.some(isFixable) ? 'review_ko' : 'review_ok' + // D25: the bar that BLOCKS a 'comment' is critical/major only, while the + // bar that PROPOSES a fix stays `isFixable` (fix.ts) untouched. A lone + // minor used to flip 'comment' to review_ko, which made the D18 waiver + // (gated on review_ok) structurally unreachable and looped a task for + // turns; a minor now ships as an MR finding instead of blocking. + return findings.some((finding) => isFixable(finding) && isBlockingSeverity(finding.severity)) + ? 'review_ko' + : 'review_ok' } /** @@ -84,6 +98,9 @@ export function taskReviewVerdict(record: ReviewRecord): 'review_ok' | 'review_k */ const BLOCKING_SEVERITIES = ['critical', 'major'] as const +const isBlockingSeverity = (severity: Finding['severity']): boolean => + (BLOCKING_SEVERITIES as readonly string[]).includes(severity) + /** * Whether a review still carries a finding no `approve` may override: a * `critical` or `major` one that asks for a code change (T3.3). "Unresolved" @@ -147,46 +164,45 @@ export function buildFixTurnPrompt(task: TaskRecord, findingIds: number[]): stri } /** - * The prompt of an AUTOMATIC fix turn (T3.3). It IS the manual path's prompt — + * The prompt of an AUTOMATIC fix turn (T3.3). It IS the manual path's prompt: * `buildAgentFixPrompt` on the same archive, through the same helper the click - * uses — with the two differences that automating it requires: + * uses, with the differences that automating it requires: * * - the findings are not a human's selection but every ACTIONABLE one, which * is exactly the set that made the review block. Asking for less would * guarantee the next review blocks on the remainder and burns a round; * - a criteria chapter is appended when the acceptance-criteria gate is what - * blocks, because a review that approved the code raises no finding at all. + * blocks, because a review that approved the code raises no finding at all; + * - a checks chapter (D16) is appended when `checks` still blocks "ready to + * merge" (`checksBlockReady`), the same reason a red run turns an OK into + * a `review_ko` in `applyChecksGate`, so a fix round asked for by a red + * check actually NAMES it, instead of leaving the agent to guess why the + * turn it just finished was not enough. * - * Null when there is nothing concrete to ask for — no archive, an unreadable - * one, or an archive carrying neither an actionable finding nor an unsatisfied - * criterion. That null is a REFUSAL to spend a round, never an empty prompt. + * Null when there is nothing concrete to ask for: no archive, an unreadable + * one, or an archive carrying no actionable finding, no unsatisfied criterion + * and no blocking check. That null is a REFUSAL to spend a round, never an + * empty prompt. */ -export function buildAutoFixTurnPrompt(task: TaskRecord): string | null { +export function buildAutoFixTurnPrompt(task: TaskRecord, checks: TaskChecks | null): string | null { const review = readReviewRef(task) if (!review) { return null } const ids = actionableFindingIds(review) - const chapter = unmetCriteriaFixChapter(taskCriteria(task), review.review.criteria) - if (ids.length === 0 && !chapter) { + const criteriaChapter = unmetCriteriaFixChapter(taskCriteria(task), review.review.criteria) + const checksChapter = + checks && checksBlockReady(checks) ? buildChecksChapter(checks, { purpose: 'fix' }) : null + if (ids.length === 0 && !criteriaChapter && !checksChapter) { return null } const base = buildAgentFixPrompt(review, ids) + const chapter = [criteriaChapter, checksChapter] + .filter((c): c is string => Boolean(c)) + .join('\n\n') return chapter ? `${base}\n\n${chapter}` : base } -/** - * An archive path is servable only when it lands INSIDE the project's - * .codesema/reviews: a `ref` comes from the client (the review_done event it - * read), so it is resolved against that directory and rejected the moment it - * escapes it — a relative "../../" or an absolute path elsewhere never reads. - */ -function archiveInProject(cwd: string, ref: string): string | null { - const dir = resolve(join(cwd, '.codesema', 'reviews')) - const path = resolve(dir, ref) - return path.startsWith(`${dir}${sep}`) ? path : null -} - /** * The archived review of ONE task, for GET /api/tasks/:id/review. `ref` (the * archive path a review_done event carries) opens the review of THAT turn; @@ -204,7 +220,7 @@ export function readTaskReview( if (!task) { return null } - const path = ref ? archiveInProject(cwd, ref) : task.review_ref + const path = ref ? resolveArchivePath(cwd, ref) : task.review_ref if (!path) { return null } @@ -302,6 +318,46 @@ export function applyChecksGate(record: TaskRecord, checks: TaskChecks | null | record.reason = taskReason('checks_failed', checksFailedDetail(checks)) } +/** + * The arm/brain fact a settled turn reports, decided from what the turn + * actually produced rather than from `status` alone. `status: 'review_ko'` + * covers two different situations, and the brain must not read them as the + * same fact: + * + * - a `reviewOutcome` is present: a reviewer ran and returned a verdict, + * later possibly overridden to KO by a deterministic guard-rail (a + * blocking finding, an unmet criterion). A real verdict was produced, so + * this is `review_result`. + * - no `reviewOutcome`: the review FLOW itself failed (a bad agent + * response, an exception) or never ran to a verdict at all. Reporting + * `review_result / request_changes` here would be indistinguishable from + * a reviewer that actually looked at the work and rejected it. This is + * `failed`, the same fact `settleInterrupted` already reports for a + * shutdown mid-review. + * + * Pure and exported so this distinction is tested directly, with no fetch or + * outbox to mock. + */ +export function brainSettleTransition(opts: { + status: 'review_ok' | 'review_ko' + reviewOutcome?: ReviewRecord + reason?: TaskReason + costTicks?: number +}): Omit { + if (opts.status === 'review_ko' && !opts.reviewOutcome) { + return { + type: 'failed', + ...(opts.reason?.detail ? { error_message: opts.reason.detail } : {}), + } + } + return { + type: 'review_result', + verdict: opts.status === 'review_ok' ? 'approve' : 'request_changes', + ...(opts.reviewOutcome ? { findings_total: opts.reviewOutcome.review.findings.length } : {}), + ...(opts.costTicks !== undefined ? { cost_ticks: opts.costTicks } : {}), + } +} + /** * Final transition of the automatic review, and its ONLY owner. A KO states * WHY in the record — the code plus the producer's own message in `detail` — @@ -323,16 +379,41 @@ const settle = ( record: TaskRecord, io: TaskTurnIo, status: 'review_ok' | 'review_ko', - /** Why a KO blocks; defaults to a bare `review_blocked`. Ignored on an OK. */ - blocked?: TaskReason, + opts: { + /** MAIN repo root: only used for the arm/brain report below, never for I/O on `record` itself. */ + cwd: string + /** Why a KO blocks; defaults to a bare `review_blocked`. Ignored on an OK. */ + blocked?: TaskReason + /** The review that just settled, when one ran (absent on a flow failure). Read for `findings_total` only. */ + reviewOutcome?: ReviewRecord + }, ): void => { record.status = status if (status === 'review_ko') { - record.reason = blocked ?? taskReason('review_blocked') + record.reason = opts.blocked ?? taskReason('review_blocked') } else { delete record.reason } io.persist() + // Arm/brain integration: reported AFTER the persist, never instead of it, + // same discipline as every other fire-and-forget effect a settled turn + // triggers (task-labels.ts's cycle label). Never awaited: a brain round + // trip must not hold up the turn this settle ends. + if (record.brain_ticket) { + void reportBrainTransition( + opts.cwd, + record, + brainSettleTransition({ + status, + ...(opts.reviewOutcome ? { reviewOutcome: opts.reviewOutcome } : {}), + ...(record.reason ? { reason: record.reason } : {}), + ...(record.cost_ticks !== undefined ? { costTicks: record.cost_ticks } : {}), + }), + ) + if (opts.reviewOutcome) { + void autoPushReview(opts.reviewOutcome, opts.cwd) + } + } } /** @@ -377,11 +458,11 @@ export function baselineFallbackReason(record: TaskRecord): string | null { * `data.name` through the web's own translated key, so a sentence built here * would either be ignored or served to a French UI in English. */ -const emitCriteriaGate = (io: TaskTurnIo, gate: CriteriaOutcome): void => { +const emitCriteriaGate = (io: TaskTurnIo, gate: CriteriaOutcome, waived: boolean): void => { io.emit({ type: 'criteria', data: { - name: gate.satisfied ? 'gate_passed' : 'gate_blocked', + name: gate.satisfied ? 'gate_passed' : waived ? 'gate_waived' : 'gate_blocked', met: gate.counts.met, unmet: gate.counts.unmet, unclear: gate.counts.unclear, @@ -404,7 +485,7 @@ const emitCriteriaGate = (io: TaskTurnIo, gate: CriteriaOutcome): void => { ...(gate.demoted > 0 ? { demoted: gate.demoted } : {}), ...(gate.overflowed ? { overflowed: true } : {}), }, - ...(gate.satisfied ? {} : { reason_code: 'criteria_unmet' as const }), + ...(gate.satisfied || waived ? {} : { reason_code: 'criteria_unmet' as const }), }) } @@ -416,7 +497,7 @@ const emitCriteriaGate = (io: TaskTurnIo, gate: CriteriaOutcome): void => { * 'interrupted', with the human-interruption code, its work committed and its * worktree kept. A reply (or a later turn) picks it back up. */ -const settleInterrupted = (record: TaskRecord, io: TaskTurnIo): void => { +const settleInterrupted = (record: TaskRecord, io: TaskTurnIo, cwd: string): void => { io.emit({ type: 'interrupted', data: { reason: 'shutdown' }, @@ -425,6 +506,9 @@ const settleInterrupted = (record: TaskRecord, io: TaskTurnIo): void => { record.status = 'interrupted' record.reason = taskReason('interrupted_by_user', REVIEW_CUT_DETAIL) io.persist() + if (record.brain_ticket) { + void reportBrainTransition(cwd, record, { type: 'failed', error_message: REVIEW_CUT_DETAIL }) + } } export type CreateTaskReviewerOptions = { @@ -473,12 +557,26 @@ export type CreateTaskReviewerOptions = { loadCap?: Pick } +type FlowRunnerPrompt = { + /** T3.2's judged-criteria chapter and D16's checks chapter, merged, or null when the task has neither. */ + chapter: string | null + /** + * D24: a pre-built simple-mode prompt (repeat or incremental, `chapter` + * already folded in by whichever of `buildRepeatReviewPrompt` / + * `buildIncrementalPrompt` built it), or null to build the ordinary full + * prompt inline. Ignored in dual mode, which always starts from scratch + * (review.ts's own comment on `dual` explains why: a judge has no + * equivalent for reconciling two lanes against a remembered verdict). + */ + prebuiltPrompt: string | null +} + type FlowRunner = ( opts: CreateTaskReviewerOptions, input: Awaited>, io: TaskTurnIo, - /** T3.2's acceptance-criteria chapter, or null when the task carries none. */ - criteriaChapter: string | null, + /** Bundled rather than two more params: max-params caps a function at 4. */ + prompt: FlowRunnerPrompt, ) => Promise /** @@ -488,7 +586,7 @@ type FlowRunner = ( * task_text SSE channel — the persisted journal only gets the bounded * review_started/review_done/error events. */ -const runReviewFlow: FlowRunner = async (opts, input, io, criteriaChapter) => { +const runReviewFlow: FlowRunner = async (opts, input, io, { chapter, prebuiltPrompt }) => { const mode: TaskReviewMode = opts.mode ?? 'simple' const runSimple = opts.runSimpleFlowFn ?? runSimpleFlow const runDual = opts.runDualFlowFn ?? runDualFlow @@ -518,7 +616,7 @@ const runReviewFlow: FlowRunner = async (opts, input, io, criteriaChapter) => { timeoutMs: opts.timeoutMs, session, spinner: { update: (status) => io.text(status) }, - ...(criteriaChapter ? { criteriaChapter } : {}), + ...(chapter ? { criteriaChapter: chapter } : {}), signal: io.signal, }) : await runSimple({ @@ -527,8 +625,12 @@ const runReviewFlow: FlowRunner = async (opts, input, io, criteriaChapter) => { dir, timeoutMs: opts.timeoutMs, session, - prompt: buildFullReviewPrompt(input, criteriaChapter ?? undefined), - incremental: false, + prompt: prebuiltPrompt ?? buildFullReviewPrompt(input, chapter ?? undefined), + // D24: a repeat or incremental prompt legitimately revisits only + // what changed (or, on a repeat, nothing at all) — same reasoning + // runSimpleFlow's own comment gives for skipping the coverage-gap + // check on a true incremental review. + incremental: prebuiltPrompt !== null, signal: io.signal, }) } finally { @@ -560,7 +662,7 @@ export function createTaskReviewer(opts: CreateTaskReviewerOptions): TaskTurnRev if (io.signal.aborted) { // The shutdown beat us to the start line: never spawn an agent this // process is about to abandon. - settleInterrupted(record, io) + settleInterrupted(record, io, opts.cwd) return } try { @@ -589,7 +691,7 @@ export function createTaskReviewer(opts: CreateTaskReviewerOptions): TaskTurnRev const changed = tryGit(['diff', '--name-only', range], record.worktree) if (changed !== null && !changed.trim()) { io.emit({ type: 'message', data: { text: 'no changes' } }) - settle(record, io, 'review_ok') + settle(record, io, 'review_ok', { cwd: opts.cwd }) return } @@ -597,7 +699,18 @@ export function createTaskReviewer(opts: CreateTaskReviewerOptions): TaskTurnRev // a task whose turn changed nothing gets no chapter and no model call, // exactly as before this ticket. const criteria = taskCriteria(record) - const criteriaChapter = criteria.length > 0 ? buildCriteriaChapter(criteria) : null + // D17: only the criteria the reviewer must actually JUDGE earn a prompt + // chapter. A mechanical one (a `[proof:command|diff|read ...]` tag) is + // decided by this file below, never asked of the model. + const { mechanical, judged } = partitionCriteriaByProof(criteria) + const criteriaChapter = judged.length > 0 ? buildCriteriaChapter(judged) : null + // D16: the SAME checks snapshot feeds this review chapter and the + // mechanical `command` criteria resolved below, read once from disk, + // never re-read mid-turn. + const checks = terminalChecksResult(readTaskChecks(opts.cwd, record.id)) + const checksChapter = checks ? buildChecksChapter(checks, { purpose: 'review' }) : null + const chapter = + [criteriaChapter, checksChapter].filter((c): c is string => Boolean(c)).join('\n\n') || null io.emit({ type: 'review_started', data: { turn: record.turns.length, mode } }) const input = await prepFn({ @@ -615,9 +728,32 @@ export function createTaskReviewer(opts: CreateTaskReviewerOptions): TaskTurnRev // spawn a review agent (two, in dual mode) only to kill it on the next // tick: "no review is ever launched for nothing" is the promise, and // this is the gap where it was not kept. - settleInterrupted(record, io) + settleInterrupted(record, io, opts.cwd) return } + // D24: inter-turn memory, SIMPLE mode only (dual always starts from + // scratch, see `runReviewFlow`'s own comment). When the last archived + // review of this branch/target sits at the SAME head as this turn's + // own HEAD, nothing was committed since it: hand that review back and + // ask the model to confirm or say what changed, instead of re-judging + // from a blank slate — the root cause D24 fixes (three different + // verdicts on one unchanged head_sha, see the plan's diagnosis). When + // the head moved and the previous archive is a verified ancestor of + // it, `buildIncrementalPrompt` covers that exactly as the single-review + // CLI flow already does. Any other shape (no previous archive, a + // rebased/unrelated head) falls through to `null`, which `runReviewFlow` + // reads as "build the ordinary full prompt", unchanged from before + // this ticket. + let prebuiltPrompt: string | null = null + if (mode === 'simple') { + const previousReview = findPreviousReview(opts.cwd, record.branch, record.base) + if (previousReview && previousReview.meta.head_sha === input.head_sha) { + prebuiltPrompt = buildRepeatReviewPrompt(input, previousReview, chapter ?? undefined) + } else if (previousReview) { + prebuiltPrompt = + buildIncrementalPrompt(input, opts.cwd, chapter ?? undefined)?.prompt ?? null + } + } // T1.3 (D4): the review agent is a heavy consumer of the machine load // cap, gated tightly around the actual agent call — never around prep // (local git work) nor around the archive/settle that follows (no @@ -642,7 +778,7 @@ export function createTaskReviewer(opts: CreateTaskReviewerOptions): TaskTurnRev // fired WHILE it was queued, in which case `acquire` already handed // this back immediately instead of leaving it parked (see the // `loadCap` option doc above). Either way nothing was ever spawned. - settleInterrupted(record, io) + settleInterrupted(record, io, opts.cwd) return } outcome = await runReviewFlow( @@ -652,7 +788,7 @@ export function createTaskReviewer(opts: CreateTaskReviewerOptions): TaskTurnRev }, input, io, - criteriaChapter, + { chapter, prebuiltPrompt }, ) } finally { release() @@ -661,7 +797,7 @@ export function createTaskReviewer(opts: CreateTaskReviewerOptions): TaskTurnRev if (io.signal.aborted) { // The agent was killed by the shutdown: whatever came back is a // half-run, not a verdict. - settleInterrupted(record, io) + settleInterrupted(record, io, opts.cwd) return } if (!outcome.ok) { @@ -669,20 +805,46 @@ export function createTaskReviewer(opts: CreateTaskReviewerOptions): TaskTurnRev // the record repeats that same message in reason.detail. const message = `review failed: ${outcome.message}` io.emit({ type: 'error', data: { message }, reason_code: 'review_blocked' }) - settle(record, io, 'review_ko', taskReason('review_blocked', message)) + settle(record, io, 'review_ko', { + cwd: opts.cwd, + blocked: taskReason('review_blocked', message), + }) return } + // D24: rebuts every 'major' finding that claims a concrete repro by + // actually running it, BEFORE anything downstream reads severity — the + // criteria gate below, `taskReviewVerdict` and `hasBlockingFindings` + // (T3.3's guard-rail) all read the CORRECTED severities from here on, + // never the model's raw, unverified claim. + const reproOutcome = await verifyFindingRepros(outcome.record.review.findings, { + worktree: record.worktree, + }) + outcome.record.review.findings = reproOutcome.findings + // T3.2, and BEFORE the archive on purpose: the normalized per-criterion - // statuses are what T3.6 reads back — possibly at a later boot — so they + // statuses are what T3.6 reads back, possibly at a later boot, so they // have to be part of the record that lands on disk, not a structure that - // dies with this process. `resolveCriteria` is the whole gate: it joins - // on the ticket's stable ids, discards what the model invented, grounds - // each evidence in the diff and forces one status per criterion. - const gate = - criteria.length > 0 - ? resolveCriteria(criteria, outcome.record.review.criteria, outcome.record.diff) - : null + // dies with this process. `resolveCriteria` grounds the JUDGED criteria + // in the diff exactly as before D17; the MECHANICAL ones never go + // through it (nor `groundCriterionVerdicts`): a `command`/`diff`/`read` + // verdict has nothing to anchor in the diff and would be wrongly demoted + // for lacking one. `combineCriteriaOutcomes` re-merges both halves into + // the single ordered outcome the rest of this function (and T3.6) reads. + let gate: CriteriaOutcome | null = null + if (criteria.length > 0) { + const mechanicalVerdicts = await resolveMechanicalCriteria(mechanical, { + worktree: record.worktree, + diff: outcome.record.diff, + checks, + }) + const judgedOutcome = resolveCriteria( + judged, + outcome.record.review.criteria, + outcome.record.diff, + ) + gate = combineCriteriaOutcomes(criteria, mechanicalVerdicts, judgedOutcome) + } if (gate) { outcome.record.review.criteria = gate.verdicts } @@ -704,12 +866,26 @@ export function createTaskReviewer(opts: CreateTaskReviewerOptions): TaskTurnRev ref: record.review_ref, ...(summary ? { summary } : {}), ...findingSeverityCounts(outcome.record.review.findings), + ...(reproOutcome.report.demoted > 0 + ? { repro_demoted: reproOutcome.report.demoted } + : {}), }, }) + const verdict = taskReviewVerdict(outcome.record) + // D18: an unclear-only gate is LIFTED by a review the reviewer settled + // as OK. The waiver never applies over a blocking finding (the branch + // below still turns those into a KO) and never touches `satisfied` + // itself; it is journaled on the gate line and in a message naming the + // criteria it lifted. + const criteriaWaived = + gate !== null && + !gate.satisfied && + verdict === 'review_ok' && + !hasBlockingFindings(outcome.record) && + criteriaGateWaivable(gate) if (gate) { - emitCriteriaGate(io, gate) + emitCriteriaGate(io, gate, criteriaWaived) } - const verdict = taskReviewVerdict(outcome.record) // T3.3, and BEFORE the criteria gate: the deterministic guard-rail. // `groundReview` already escalates an `approve` that carries a // `critical` — but only when it could index the diff — and it has never @@ -729,29 +905,50 @@ export function createTaskReviewer(opts: CreateTaskReviewerOptions): TaskTurnRev data: { text: detail, name: 'review_verdict_overridden' }, reason_code: 'review_blocked', }) - settle(record, io, 'review_ko', taskReason('review_blocked', detail)) + settle(record, io, 'review_ko', { + cwd: opts.cwd, + blocked: taskReason('review_blocked', detail), + reviewOutcome: outcome.record, + }) return } - // The HARD gate (D11): one criterion that is not `met` blocks "ready to - // merge", with no weighting and no exception. It only ever turns an OK - // into a KO — a review that already blocks keeps its own, more - // actionable reason rather than being relabelled. - if (gate && !gate.satisfied && verdict === 'review_ok') { - settle(record, io, 'review_ko', taskReason('criteria_unmet', criteriaUnmetDetail(gate))) + // The HARD gate (D11, softened by D18): an `unmet` or unjudged + // criterion, or an unreadable diff, blocks "ready to merge" with no + // weighting and no exception. It only ever turns an OK into a KO — a + // review that already blocks keeps its own, more actionable reason + // rather than being relabelled. + if (criteriaWaived && gate) { + io.emit({ + type: 'message', + data: { + text: `the settled review lifts ${gate.counts.unclear} 'unclear' criterion/criteria (evidence outside the diff or requiring execution); nothing is unmet`, + name: 'criteria_unclear_waived', + }, + }) + } + if (gate && !gate.satisfied && !criteriaWaived && verdict === 'review_ok') { + settle(record, io, 'review_ko', { + cwd: opts.cwd, + blocked: taskReason('criteria_unmet', criteriaUnmetDetail(gate)), + reviewOutcome: outcome.record, + }) return } - settle(record, io, verdict) + settle(record, io, verdict, { cwd: opts.cwd, reviewOutcome: outcome.record }) } catch (err) { if (io.signal.aborted) { // The rejection IS the abort (a killed agent, an interrupted prep): // reporting it as a blocked review would blame the reviewer for the // shutdown. - settleInterrupted(record, io) + settleInterrupted(record, io, opts.cwd) return } const message = `review failed: ${errorMessage(err)}` io.emit({ type: 'error', data: { message }, reason_code: 'review_blocked' }) - settle(record, io, 'review_ko', taskReason('review_blocked', message)) + settle(record, io, 'review_ko', { + cwd: opts.cwd, + blocked: taskReason('review_blocked', message), + }) } } } diff --git a/packages/cli/src/task-runner.test.ts b/packages/cli/src/task-runner.test.ts index 36f190c..d04f687 100644 --- a/packages/cli/src/task-runner.test.ts +++ b/packages/cli/src/task-runner.test.ts @@ -1,7 +1,7 @@ import { execFileSync, type ChildProcess } from 'node:child_process' import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { basename, join } from 'node:path' import { afterEach, describe, expect, test } from 'bun:test' import { AGENT_KILL_GRACE_MS, @@ -4914,3 +4914,216 @@ describe('turn cost', () => { expect(costEvents(repo, task.id)).toEqual([]) }) }) + +// --- scratch conversations: a project that is not a repository ------------- + +/** A plain directory: the shape of the scratch project's own path. */ +function makePlainDir(): string { + const dir = mkdtempSync(join(tmpdir(), 'codesema-scratch-')) + cleanups.push(dir) + return dir +} + +describe('scratch conversations', () => { + test('get a plain working directory: no worktree, no branch, no git at all', async () => { + const dir = makePlainDir() + const task = makeTask(dir, 'just talking', 'explain something') + const runner = createTaskRunner({ + cwd: dir, + scratch: true, + command: 'claude -p', + timeoutMs: 1000, + runAgentFn: fakeClaude(() => 'here you go').run, + }) + + expect(runner.start(task)).toEqual({ ok: true }) + await until(() => status(dir, task.id) === 'waiting_for_you') + + const record = loadTask(dir, task.id) + expect(record?.worktree).toBe(join(dir, '.codesema', 'worktrees', task.id)) + expect(existsSync(record?.worktree ?? '')).toBe(true) + // The pointer file a real worktree carries: its absence is what proves + // nothing here was handed to git. + expect(existsSync(join(record?.worktree ?? '', '.git'))).toBe(false) + expect(record?.branch).toBe('') + expect(record?.base).toBe('') + }) + + test('the turn runs in that directory, so the agent sees no code', async () => { + const dir = makePlainDir() + const task = makeTask(dir, 'just talking', 'explain something') + const seenCwd: string[] = [] + const runner = createTaskRunner({ + cwd: dir, + scratch: true, + command: 'claude -p', + timeoutMs: 1000, + runAgentFn: (options) => { + seenCwd.push(options.cwd) + return Promise.resolve('answered') + }, + }) + + expect(runner.start(task)).toEqual({ ok: true }) + await until(() => status(dir, task.id) === 'waiting_for_you') + + expect(seenCwd).toEqual([join(dir, '.codesema', 'worktrees', task.id)]) + }) + + test('abandon removes the directory and reports no branch fate', async () => { + const dir = makePlainDir() + const task = makeTask(dir, 'just talking', 'explain something') + const runner = createTaskRunner({ + cwd: dir, + scratch: true, + command: 'claude -p', + timeoutMs: 1000, + runAgentFn: fakeClaude(() => 'here you go').run, + }) + expect(runner.start(task)).toEqual({ ok: true }) + await until(() => status(dir, task.id) === 'waiting_for_you') + const workdir = loadTask(dir, task.id)?.worktree ?? '' + expect(existsSync(workdir)).toBe(true) + + const outcome = await runner.abandon(task.id) + + expect(outcome).toEqual({ ok: true }) + expect(existsSync(workdir)).toBe(false) + expect(status(dir, task.id)).toBe('failed') + }) +}) + +describe('attaching a repository to a conversation', () => { + const startedScratch = async (dir: string, title = 'talk then code') => { + const task = makeTask(dir, title, 'explain something') + const runner = createTaskRunner({ + cwd: dir, + scratch: true, + command: 'claude -p', + timeoutMs: 1000, + runAgentFn: fakeClaude(() => 'here you go').run, + }) + expect(runner.start(task)).toEqual({ ok: true }) + await until(() => status(dir, task.id) === 'waiting_for_you') + return { task, runner } + } + + test('the worktree lands inside the conversation, never in the repository', async () => { + const dir = makePlainDir() + const repo = makeRepo() + const { task, runner } = await startedScratch(dir) + + expect(await runner.attach(task.id, { project_id: projectIdFor(repo), path: repo })).toEqual({ + ok: true, + }) + + const record = loadTask(dir, task.id) + const workspace = join(dir, '.codesema', 'worktrees', task.id) + // The directory the agent runs in is the SAME before and after: that is + // what keeps a provider transcript keyed by cwd findable. + expect(record?.worktree).toBe(workspace) + const attached = record?.attachments ?? [] + expect(attached).toHaveLength(1) + expect(attached[0]?.worktree).toBe(join(workspace, basename(repo))) + expect(attached[0]?.repo).toBe(repo) + expect(attached[0]?.branch.startsWith('codesema/task-')).toBe(true) + // A real checkout of the repository, and the branch exists in it. + expect(existsSync(join(attached[0]?.worktree ?? '', 'base.txt'))).toBe(true) + expect(refExists(`refs/heads/${attached[0]?.branch}`, repo)).toBe(true) + // And the repository's own worktrees directory was never used. + expect(existsSync(join(repo, '.codesema', 'worktrees', task.id))).toBe(false) + }) + + test('attaching the same repository twice is the same request, not a conflict', async () => { + const dir = makePlainDir() + const repo = makeRepo() + const { task, runner } = await startedScratch(dir) + const target = { project_id: projectIdFor(repo), path: repo } + + expect(await runner.attach(task.id, target)).toEqual({ ok: true }) + expect(await runner.attach(task.id, target)).toEqual({ ok: true }) + + expect(loadTask(dir, task.id)?.attachments).toHaveLength(1) + }) + + test('two repositories sharing a basename get distinct directories', async () => { + const dir = makePlainDir() + const first = makeRepo() + const second = makeRepo() + const { task, runner } = await startedScratch(dir) + + await runner.attach(task.id, { project_id: projectIdFor(first), path: first }) + await runner.attach(task.id, { project_id: projectIdFor(second), path: second }) + + const attached = loadTask(dir, task.id)?.attachments ?? [] + expect(attached).toHaveLength(2) + expect(attached[0]?.worktree).not.toBe(attached[1]?.worktree) + expect(existsSync(attached[0]?.worktree ?? '')).toBe(true) + expect(existsSync(attached[1]?.worktree ?? '')).toBe(true) + }) + + test('a conversation that already lives in a repository refuses one', async () => { + const repo = makeRepo() + const task = makeTask(repo, 'ordinary', 'do work') + const runner = createTaskRunner({ + cwd: repo, + command: 'claude -p', + timeoutMs: 1000, + runAgentFn: fakeClaude(() => 'done').run, + }) + expect(runner.start(task)).toEqual({ ok: true }) + await until(() => status(repo, task.id) === 'waiting_for_you') + + const other = makeRepo() + expect( + await runner.attach(task.id, { project_id: projectIdFor(other), path: other }), + ).toMatchObject({ ok: false, code: 409 }) + }) + + test('an unknown task is a 404, not a directory quietly created', async () => { + const dir = makePlainDir() + const repo = makeRepo() + const runner = createTaskRunner({ + cwd: dir, + scratch: true, + command: 'claude -p', + timeoutMs: 1000, + runAgentFn: fakeClaude(() => 'x').run, + }) + expect( + await runner.attach('ffffffffffff', { project_id: projectIdFor(repo), path: repo }), + ).toMatchObject({ ok: false, code: 404 }) + }) +}) + +describe('what an agent is told about attached repositories', () => { + test('every turn names them, because a resumed session replays words, not directories', async () => { + const dir = makePlainDir() + const repo = makeRepo() + const prompts: string[] = [] + const task = makeTask(dir, 'talk then code', 'explain something') + const runner = createTaskRunner({ + cwd: dir, + scratch: true, + command: 'claude -p', + timeoutMs: 1000, + runAgentFn: (options) => { + prompts.push(options.prompt) + return Promise.resolve('ok') + }, + }) + expect(runner.start(task)).toEqual({ ok: true }) + await until(() => status(dir, task.id) === 'waiting_for_you') + expect(prompts[0]).not.toContain('Repositories available') + + await runner.attach(task.id, { project_id: projectIdFor(repo), path: repo }) + expect(runner.reply(task.id, 'now look at the code')).toEqual({ ok: true }) + await until(() => (loadTask(dir, task.id)?.turns.length ?? 0) >= 2) + await until(() => status(dir, task.id) === 'waiting_for_you') + + const second = prompts[1] ?? '' + expect(second).toContain('Repositories available in your working directory:') + expect(second).toContain(`./${basename(repo)}`) + expect(second).toContain('now look at the code') + }) +}) diff --git a/packages/cli/src/task-runner.ts b/packages/cli/src/task-runner.ts index 97c0015..fd774ae 100644 --- a/packages/cli/src/task-runner.ts +++ b/packages/cli/src/task-runner.ts @@ -12,6 +12,7 @@ import { randomUUID } from 'node:crypto' import { existsSync } from 'node:fs' +import { basename, join } from 'node:path' import { AGENT_WATCHDOG_DEFAULTS, agentEnv, @@ -28,11 +29,13 @@ import { type AgentRunOptions, type WatchdogBudgets, } from './agent.js' +import { loadGlobalConfig, resolveMaxTaskTurns } from './config.js' import { EARS_RESPONSE, EARS_TRIGGER, isTerminalReason, reasonCodeOf, + TASK_ATTACHMENTS_MAX, TICKET_CRITERIA_MIN, type AcceptanceCriterion, type ReasonCode, @@ -59,6 +62,7 @@ import { } from './load-cap.js' import { projectIdFor } from './projects.js' import type { ChecksConfig } from './repo-config.js' +import { reportBrainTransition } from './task-brain.js' import { bootstrapWorktreeInstall, type BootstrapInstallResult, @@ -78,7 +82,9 @@ import { createTaskQueue, type EnqueueResult, type TaskQueueIo } from './task-qu import { branchHasOwnCommits, BranchInUseError, + createScratchWorkdir, createTaskWorktree, + removeScratchWorkdir, removeTaskWorktree, renameTaskBranch, resolveBranchRef, @@ -108,6 +114,9 @@ import { * instead. This constant, `TaskSlotPool` and `TaskRunnerOptions.maxParallel` * /`.slots` stay exactly as inert as T1.2 left them, kept only so the * deprecated key still parses into a shape nothing reads for admission. + * + * @deadcode Inert by design since T1.2, per the note above — the tag records + * that knip is right, not that the constant is about to go. */ export const DEFAULT_MAX_PARALLEL_TASKS = 3 @@ -729,6 +738,14 @@ export async function runTaskTurn(opts: RunTaskTurnOptions): Promise ({ + name: a.name, + worktree: a.worktree, + })), + } + : {}), command, prompt: opts.prompt, timeoutMs: absoluteCapMs, @@ -1067,6 +1084,12 @@ export type TaskRunnerOptions = { * holds instead of re-hashing the path. */ projectId?: string + /** + * This runner drives the scratch project: `cwd` is a plain directory, not a + * repository. Its turns get a working directory instead of a worktree, and + * no branch is ever named, so nothing on this path reaches for git. + */ + scratch?: boolean /** Raw configured agent command. */ command: string /** Last-resort absolute ceiling of a turn; the watchdog is what detects a dead one. */ @@ -1211,6 +1234,14 @@ export type TaskRunner = { * writes the record last. */ isAbandoning: (taskId: string) => boolean + /** + * Gives a repository to a conversation that started without one: its + * worktree is materialized INSIDE the conversation's own workspace, so the + * directory the agent runs in is the same before and after. Idempotent on a + * repository already attached, refused (409) while a turn is in flight. + * NEVER rejects, for the same reason abandon() never does. + */ + attach: (taskId: string, repo: { project_id: string; path: string }) => Promise /** * Graceful process exit: aborts every running agent, persists 'interrupted' * with an event {reason:'shutdown'} for the turns that were IN FLIGHT, keeps @@ -1228,6 +1259,28 @@ export type TaskRunner = { const errorMessage = (err: unknown): string => (err instanceof Error ? err.message : String(err)) +/** How many `-n` suffixes an attached repo directory tries before a timestamp. */ +const ATTACHMENT_SUFFIX_MAX = 99 + +/** + * Directory name an attached repository takes inside a conversation's + * workspace. Two repositories can share a basename (`api/web` and `admin/web`) + * and would then fight for the same directory: the second one gets `web-2`. + */ +function freeAttachmentName(workspace: string, wanted: string): string { + const safe = wanted || 'repo' + if (!existsSync(join(workspace, safe))) { + return safe + } + for (let n = 2; n <= ATTACHMENT_SUFFIX_MAX; n++) { + const candidate = `${safe}-${n}` + if (!existsSync(join(workspace, candidate))) { + return candidate + } + } + return `${safe}-${Date.now().toString(36)}` +} + /** * Folds ONE attempt's measurement into the turn, then RECOMPUTES the record's * total from the turns it actually carries. The total is derived, never @@ -1305,6 +1358,24 @@ function transcript(record: TaskRecord): string { return parts.join('\n') } +/** + * Repositories handed to this conversation, named the way the agent sees them: + * directories inside the one it runs in. + * + * Restated on EVERY turn rather than announced once when the repository + * arrives. A resumed provider session replays what was SAID, not the + * filesystem the agent now finds itself in, so an agent told once would go on + * believing it has nothing to read for the rest of the conversation. + */ +function attachedRepositoriesNote(record: TaskRecord): string { + const attached = record.attachments ?? [] + if (attached.length === 0) { + return '' + } + const lines = attached.map((a) => `- ./${a.name} (on branch ${a.branch}, from ${a.base})`) + return ['Repositories available in your working directory:', ...lines].join('\n') +} + /** * First turn: standing instructions + the task prompt. Later turns: claude * resumes its session so the reply alone is enough; other providers get a @@ -1312,18 +1383,21 @@ function transcript(record: TaskRecord): string { */ function composeTurnPrompt(record: TaskRecord, command: string): string { const message = record.turns.at(-1)?.prompt ?? '' + const repositories = attachedRepositoriesNote(record) + const withRepositories = (text: string): string => + repositories ? `${repositories}\n\n${text}` : text if (record.turns.length <= 1) { // A work-on conversation is not asked to name anything: it works on the // user's own pre-existing branch, which is never renamed. const standing = buildTaskPrompt(record, { askBranchName: !record.work_on }) const draft = taskCriteria(record).length === 0 ? `\n\n${criteriaDraftInstruction()}` : '' - return `${standing}${draft}\n\n${message}` + return withRepositories(`${standing}${draft}\n\n${message}`) } if (supportsSessionResume(command) && record.agent_session_id) { - return message + return withRepositories(message) } - return [buildTaskPrompt(record), '', transcript(record), '', `New instruction: ${message}`].join( - '\n', + return withRepositories( + [buildTaskPrompt(record), '', transcript(record), '', `New instruction: ${message}`].join('\n'), ) } @@ -1800,6 +1874,9 @@ export function createTaskRunner(opts: TaskRunnerOptions): TaskRunner { // later, can genuinely succeed. Same argument as the abort path above. const retryable = code !== null && !isTerminalReason(code) record.status = retryable ? 'interrupted' : 'failed' + if (!retryable && record.brain_ticket) { + void reportBrainTransition(opts.cwd, record, { type: 'failed', error_message: message }) + } emit(record.id, { // The event names the same outcome as the status. A task parked on // 'interrupted' — with a resume affordance — announced by an 'error' @@ -1971,6 +2048,14 @@ export function createTaskRunner(opts: TaskRunnerOptions): TaskRunner { } // No worktree ever named on this record = nothing can be on its branch yet. const first = record.worktree === '' + if (opts.scratch) { + // No repository, so nothing below applies: no base to fork from, no + // branch to adopt back, no lineage to anchor. `base` and `branch` stay + // empty, which is what tells every reader downstream that this + // conversation has no deliverable on any branch. + record.worktree = createScratchWorkdir(opts.cwd, record.id) + return + } if (record.work_on) { // Work-on task (POST /api/tasks branch=…): the conversation identifies // with its pre-existing branch — check the branch itself out, first @@ -2810,6 +2895,24 @@ export function createTaskRunner(opts: TaskRunnerOptions): TaskRunner { // is neither work nor wait. accrueWait(record) } + // D25's safety net: a task that already burned its turn budget refuses + // every further reply (human, daemon, fix loop alike) instead of + // looping; the record keeps its state and the journal says why. + const turnCap = resolveMaxTaskTurns(loadGlobalConfig()) + if (record.turns.length >= turnCap) { + emit(record.id, { + type: 'message', + data: { + text: `turn budget exhausted: ${record.turns.length} turn(s) spent, cap ${turnCap}`, + name: 'turn_budget_exhausted', + }, + }) + return { + ok: false, + code: 409, + error: `turn budget exhausted (${record.turns.length}/${turnCap}): raise maxTaskTurns in codesema config, or decide this task by hand`, + } + } record.turns.push({ prompt: text, response: null, @@ -2949,13 +3052,29 @@ export function createTaskRunner(opts: TaskRunnerOptions): TaskRunner { // removeTaskWorktree runs — the worktree lock does not protect a // decision already made, and nothing about the branch's shape can // change while no lock is held, this call included. - const fate = decideBranchFate(opts.cwd, taskId, record, emit) + // A scratch conversation never had a branch, so there is no fate to + // decide: every answer below is the empty one, and `createdOrAdopted` + // false keeps the whole reporting path quiet about a branch that never + // existed. + const fate: BranchFateDecision = opts.scratch + ? { + deleteBranch: false, + hasOwnCommits: false, + ownCommitsCount: null, + branchStillExists: false, + createdOrAdopted: false, + } + : decideBranchFate(opts.cwd, taskId, record, emit) let removal: WorktreeRemoval try { - removal = await removeTaskWorktree(opts.cwd, taskId, record.branch, { - deleteBranch: fate.deleteBranch, - ...(opts.worktreeLockFn ? { lockFn: opts.worktreeLockFn } : {}), - }) + // A scratch conversation has no branch and no worktree to weigh: its + // directory is the whole of what it leaves behind. + removal = opts.scratch + ? { serialized: true, worktree_removed: removeScratchWorkdir(opts.cwd, taskId) } + : await removeTaskWorktree(opts.cwd, taskId, record.branch, { + deleteBranch: fate.deleteBranch, + ...(opts.worktreeLockFn ? { lockFn: opts.worktreeLockFn } : {}), + }) } catch (err) { // TOTAL, like ship one layer up: this result is dispatched by an // HTTP handler that does not catch, and an unhandled rejection kills @@ -3080,6 +3199,12 @@ export function createTaskRunner(opts: TaskRunnerOptions): TaskRunner { // Everything else abandoned mid-cycle is discarded work: failed. if (current.status !== 'shipped') { current.status = 'failed' + if (current.brain_ticket) { + void reportBrainTransition(opts.cwd, current, { + type: 'failed', + error_message: 'worktree removed, task abandoned', + }) + } } // T1.9 review round 3, Mineur 3: the terminal status is written to // disk BEFORE the (possibly slow, up to the release seam's own @@ -3108,6 +3233,63 @@ export function createTaskRunner(opts: TaskRunnerOptions): TaskRunner { return abandoning.has(taskId) }, + async attach(taskId, repo) { + // Only a conversation whose working directory is its OWN can take a + // repository in: one already living inside a repo's .codesema/ would be + // nesting a second repository under the first. + if (!opts.scratch) { + return { ok: false, code: 409, error: 'this conversation already has a repository' } + } + if (active.has(taskId) || abandoning.has(taskId)) { + return { ok: false, code: 409, error: 'task is busy' } + } + const record = loadTask(opts.cwd, taskId) + if (!record) { + return { ok: false, code: 404, error: 'task not found' } + } + const attachments = record.attachments ?? [] + // Idempotent: attaching what is already attached is the same request + // answered twice, not a conflict. + if (attachments.some((a) => a.project_id === repo.project_id)) { + return { ok: true } + } + if (attachments.length >= TASK_ATTACHMENTS_MAX) { + return { ok: false, code: 409, error: 'too many repositories attached' } + } + // The turn may never have run, in which case the conversation has no + // directory yet: the repository is what creates it here. + const workspace = record.worktree || createScratchWorkdir(opts.cwd, record.id) + const name = freeAttachmentName(workspace, basename(repo.path)) + try { + const wt = await createTaskWorktree(repo.path, record.id, record.title, { + worktreePath: join(workspace, name), + ...(opts.worktreeLockFn ? { lockFn: opts.worktreeLockFn } : {}), + }) + record.worktree = workspace + record.attachments = [ + ...attachments, + { + project_id: repo.project_id, + repo: repo.path, + name, + worktree: wt.worktree, + branch: wt.branch, + base: wt.base, + }, + ] + persist(record) + emit(record.id, { + type: 'resource', + data: { name: 'repository_attached', repo: name, branch: wt.branch }, + }) + return { ok: true } + } catch (err) { + // TOTAL, like abandon one layer up: the HTTP handler that dispatches + // this does not catch, and an unhandled rejection kills the server. + return { ok: false, code: 500, error: errorMessage(err) } + } + }, + async shutdown() { draining = true // Stops this runner from being pumped by every OTHER project's slot diff --git a/packages/cli/src/task-server.test.ts b/packages/cli/src/task-server.test.ts index 449408a..9931d20 100644 --- a/packages/cli/src/task-server.test.ts +++ b/packages/cli/src/task-server.test.ts @@ -37,7 +37,7 @@ import { import type { ForgeCli, ForgeCliOutcome, ForgeIssuesExecFn } from './forge-issues.js' import { t as translate } from './i18n.js' import { createLoadCap } from './load-cap.js' -import { addProject, listProjects, projectsPath, type Project } from './projects.js' +import { addProject, listProjects, projectsPath, scratchProject, type Project } from './projects.js' import { archiveRecord } from './record.js' import { readChecksConfig } from './repo-config.js' import { createSession, startServer } from './serve.js' @@ -223,6 +223,7 @@ type FakeRunnerRig = { resumes: string[] /** Task ids the runner reports as having an abandon in flight. */ abandoning: Set + attaches: { id: string; repo: { project_id: string; path: string } }[] } /** @@ -248,6 +249,7 @@ function fakeRunner(opts: { replyResult?: TaskActionResult } = {}): FakeRunnerRi abandons: [], resumes: [], abandoning: new Set(), + attaches: [] as { id: string; repo: { project_id: string; path: string } }[], runnerOptions: () => { const last = rig.allRunnerOptions.at(-1) if (!last) { @@ -279,6 +281,10 @@ function fakeRunner(opts: { replyResult?: TaskActionResult } = {}): FakeRunnerRi return Promise.resolve({ ok: true as const }) }, isAbandoning: (id) => rig.abandoning.has(id), + attach: (id, repo) => { + rig.attaches.push({ id, repo }) + return Promise.resolve({ ok: true as const }) + }, shutdown: () => Promise.resolve(), runningCount: () => 0, } @@ -1681,9 +1687,17 @@ describe('createTaskManager', () => { const b2 = seedTask(projectB.path, 'in B two') const all = manager.listAll() - expect(all.map((entry) => entry.project.id)).toEqual([projectA.id, projectB.id]) - expect(all[0]?.records.map((r) => r.id)).toEqual([a.id]) - expect(new Set(all[1]?.records.map((r) => r.id))).toEqual(new Set([b1.id, b2.id])) + // The scratch project leads every workspace listing, task-less here: the + // SSE replay has to carry it too, or a conversation held there would be + // invisible until the client refetched. + expect(all.map((entry) => entry.project.id)).toEqual([ + scratchProject().id, + projectA.id, + projectB.id, + ]) + expect(all[0]?.records).toEqual([]) + expect(all[1]?.records.map((r) => r.id)).toEqual([a.id]) + expect(new Set(all[2]?.records.map((r) => r.id))).toEqual(new Set([b1.id, b2.id])) }) }) @@ -2261,6 +2275,325 @@ describe('manager.ship', () => { const event = readTaskEvents(cwd, record.id).find((e) => e.type === 'resource') expect(event?.data.name).toBe('container_runtime_absent') }) + + // D20: `cycle_step` is a crash-recovery marker, not a normal ship() concern + // — these prove it is posed and cleared exactly where the plan says, in the + // SAME writes ship() already makes. + test('D20: a ship that will auto-merge afterward advances cycle_step to merge in the same write', async () => { + const project = register(makeRepo()) + const cwd = project.path + const stub = shipStub({ pushed: true, mrUrl: 'https://github.com/o/r/pull/9', note: null }) + const manager = createTaskManager({ + ...managerOpts, + shipTaskFn: stub.fn, + ...fakeRunner(), + mergeSettings: { policy: 'auto', deleteBranch: false, allowMergeWithoutChecks: false }, + }) + const record = seedShippable(cwd) + + expect(await manager.ship(project.id, record.id)).toEqual({ ok: true }) + expect(loadTask(cwd, record.id)?.cycle_step).toBe('merge') + }) + + test('D20: an ordinary ship under mergePolicy human never sets cycle_step', async () => { + const project = register(makeRepo()) + const cwd = project.path + const stub = shipStub({ pushed: true, mrUrl: 'https://github.com/o/r/pull/9', note: null }) + const manager = createTaskManager({ ...managerOpts, shipTaskFn: stub.fn, ...fakeRunner() }) + const record = seedShippable(cwd) + + expect(await manager.ship(project.id, record.id)).toEqual({ ok: true }) + expect(loadTask(cwd, record.id)?.cycle_step).toBeUndefined() + }) + + test('D20: a stale cycle_step does not survive a shipRefusal', async () => { + const project = register(makeRepo()) + const cwd = project.path + const stub = shipStub({ pushed: true, mrUrl: null, note: null }) + const manager = createTaskManager({ ...managerOpts, shipTaskFn: stub.fn, ...fakeRunner() }) + const shipped = seedShippable(cwd) + shipped.status = 'shipped' + shipped.cycle_step = 'merge' + saveTask(cwd, shipped) + + expect(await manager.ship(project.id, shipped.id)).toEqual({ + ok: false, + code: 409, + error: 'task is already shipped', + }) + expect(stub.calls).toHaveLength(0) + // A stale marker a refusal saw must not outlive the refusal, or a + // resumed boot would keep calling ship() on this exact refusal forever. + expect(loadTask(cwd, shipped.id)?.cycle_step).toBeUndefined() + }) + + test('D20: a stale cycle_step does not survive a push failure', async () => { + const project = register(makeRepo()) + const cwd = project.path + const stub = shipStub({ pushed: false, error: 'git push failed: permission denied' }) + const manager = createTaskManager({ ...managerOpts, shipTaskFn: stub.fn, ...fakeRunner() }) + const record = seedShippable(cwd) + record.cycle_step = 'ship' + saveTask(cwd, record) + + expect(await manager.ship(project.id, record.id)).toEqual({ + ok: false, + code: 502, + error: 'git push failed: permission denied', + }) + expect(loadTask(cwd, record.id)?.status).toBe('review_ok') + expect(loadTask(cwd, record.id)?.cycle_step).toBeUndefined() + }) +}) + +// --- D20: cycle_step ship/merge -------------------------------------------- + +describe('D20: cycle_step boot recovery, idempotence and reentrancy', () => { + test('a crash between review_ok and ship resumes the ship on the next boot', async () => { + const project = register(makeRepo()) + const repo = project.path + const seeded = seedShippable(repo) + seeded.auto_ship = true + seeded.cycle_step = 'ship' + saveTask(repo, seeded) + + const stub = shipStub({ pushed: true, mrUrl: 'https://github.com/o/r/pull/1', note: null }) + // A fresh manager over the SAME .codesema/: nothing here remembers the + // process that set cycle_step, only the disk does. + const manager = createTaskManager({ ...managerOpts, shipTaskFn: stub.fn, ...fakeRunner() }) + + await manager.startPending() + + expect(stub.calls).toMatchObject([{ task: { id: seeded.id } }]) + const record = loadTask(repo, seeded.id) + expect(record?.status).toBe('shipped') + // Default mergePolicy is human: nothing to chain into, so the resumed + // ship clears the marker outright rather than advancing it. + expect(record?.cycle_step).toBeUndefined() + }) + + test('a crash between shipped and merge resumes the merge on the next boot', async () => { + const project = register(makeRepo()) + const repo = project.path + const seeded = seedShippable(repo) + seeded.status = 'shipped' + seeded.cycle_step = 'merge' + saveTask(repo, seeded) + + const mergeCalls: string[] = [] + const manager = createTaskManager({ + ...managerOpts, + ...fakeRunner(), + mergeSettings: { policy: 'auto', deleteBranch: false, allowMergeWithoutChecks: false }, + mergeTaskFn: (options) => { + mergeCalls.push(options.task.id) + return Promise.resolve({ + kind: 'merged' as const, + cli: 'gh' as const, + url: 'https://github.com/o/r/pull/2', + readiness: { ready: true, conditions: [], blockers: [] }, + events: [{ type: 'merge' as const, data: { name: 'merged', cli: 'gh' } }], + }) + }, + }) + + await manager.startPending() + + expect(mergeCalls).toEqual([seeded.id]) + const record = loadTask(repo, seeded.id) + expect(record?.status).toBe('shipped') + expect(record?.cycle_step).toBeUndefined() + expect( + readTaskEvents(repo, seeded.id).some( + (event) => event.type === 'merge' && event.data.name === 'merged', + ), + ).toBe(true) + }) + + test('a cycle_step resumed on an already-merged ticket never calls mergeTaskFn again', async () => { + const project = register(makeRepo()) + const repo = project.path + const seeded = seedShippable(repo) + seeded.status = 'shipped' + seeded.cycle_step = 'merge' + saveTask(repo, seeded) + // The earlier, crashed call's own line: the merge landed, but the process + // died before it cleared cycle_step. + appendTaskEvent(repo, seeded.id, { type: 'merge', data: { name: 'merged', cli: 'gh' } }) + + let calls = 0 + const manager = createTaskManager({ + ...managerOpts, + ...fakeRunner(), + mergeSettings: { policy: 'auto', deleteBranch: false, allowMergeWithoutChecks: false }, + mergeTaskFn: () => { + calls += 1 + return Promise.resolve({ + kind: 'merged' as const, + cli: 'gh' as const, + url: null, + readiness: { ready: true, conditions: [], blockers: [] }, + events: [], + }) + }, + }) + + await manager.startPending() + + expect(calls).toBe(0) + expect(loadTask(repo, seeded.id)?.cycle_step).toBeUndefined() + }) + + test("runMergeStep's merging guard refuses a second concurrent resume for the same task", async () => { + const project = register(makeRepo()) + const repo = project.path + const seeded = seedShippable(repo) + seeded.status = 'shipped' + seeded.cycle_step = 'merge' + saveTask(repo, seeded) + + let calls = 0 + const manager = createTaskManager({ + ...managerOpts, + ...fakeRunner(), + mergeSettings: { policy: 'auto', deleteBranch: false, allowMergeWithoutChecks: false }, + mergeTaskFn: async () => { + calls += 1 + // Widens the in-flight window: if the guard were absent, a second + // concurrent resume would land its own mergeTaskFn call well inside it. + await new Promise((resolve) => setTimeout(resolve, 20)) + return { + kind: 'merged' as const, + cli: 'gh' as const, + url: null, + readiness: { ready: true, conditions: [], blockers: [] }, + events: [{ type: 'merge' as const, data: { name: 'merged', cli: 'gh' } }], + } + }, + }) + + await Promise.all([manager.startPending(), manager.startPending()]) + + expect(calls).toBe(1) + expect(loadTask(repo, seeded.id)?.cycle_step).toBeUndefined() + }) + + test('reply is refused with 409 while a resumed merge is in flight, and purges nothing while refused', async () => { + const project = register(makeRepo()) + const repo = project.path + const seeded = seedShippable(repo) + seeded.status = 'shipped' + seeded.cycle_step = 'merge' + saveTask(repo, seeded) + + let entered = false + let releaseMerge: () => void = () => {} + const inFlight = new Promise((resolve) => { + releaseMerge = resolve + }) + const rig = fakeRunner() + const manager = createTaskManager({ + ...managerOpts, + ...rig, + mergeSettings: { policy: 'auto', deleteBranch: false, allowMergeWithoutChecks: false }, + mergeTaskFn: async () => { + entered = true + await inFlight + return { + kind: 'merged' as const, + cli: 'gh' as const, + url: null, + readiness: { ready: true, conditions: [], blockers: [] }, + events: [{ type: 'merge' as const, data: { name: 'merged', cli: 'gh' } }], + } + }, + }) + + const pending = manager.startPending() + await until(() => entered) + + expect(manager.reply(project.id, seeded.id, 'try again')).toEqual({ + ok: false, + code: 409, + error: 'merge in progress', + }) + expect(rig.replies).toEqual([]) + + releaseMerge() + await pending + expect(loadTask(repo, seeded.id)?.cycle_step).toBeUndefined() + }) +}) + +describe('D20: reply/resume/abandon purge a stale cycle_step', () => { + test('reply purges cycle_step before delegating to the runner', () => { + const project = register(makeRepo()) + const repo = project.path + const seeded = seedTask(repo, 'stale marker') + seeded.status = 'waiting_for_you' + seeded.cycle_step = 'ship' + saveTask(repo, seeded) + const rig = fakeRunner({ replyResult: { ok: true } }) + const manager = createTaskManager({ ...managerOpts, ...rig }) + + const result = manager.reply(project.id, seeded.id, 'try again') + + expect(result.ok).toBe(true) + expect(rig.replies).toEqual([{ id: seeded.id, message: 'try again' }]) + expect(loadTask(repo, seeded.id)?.cycle_step).toBeUndefined() + }) + + test('resume purges cycle_step before delegating to the runner', () => { + const project = register(makeRepo()) + const repo = project.path + const seeded = seedTask(repo, 'stale marker') + seeded.status = 'interrupted' + seeded.cycle_step = 'merge' + saveTask(repo, seeded) + const rig = fakeRunner() + const manager = createTaskManager({ ...managerOpts, ...rig }) + + const result = manager.resume(project.id, seeded.id) + + expect(result.ok).toBe(true) + expect(rig.resumes).toEqual([seeded.id]) + expect(loadTask(repo, seeded.id)?.cycle_step).toBeUndefined() + }) + + test('abandon purges cycle_step before delegating to the runner', async () => { + const project = register(makeRepo()) + const repo = project.path + const seeded = seedTask(repo, 'stale marker') + seeded.status = 'waiting_for_you' + seeded.cycle_step = 'ship' + saveTask(repo, seeded) + const rig = fakeRunner() + const manager = createTaskManager({ ...managerOpts, ...rig }) + + const result = await manager.abandon(project.id, seeded.id) + + expect(result.ok).toBe(true) + expect(rig.abandons).toEqual([seeded.id]) + expect(loadTask(repo, seeded.id)?.cycle_step).toBeUndefined() + }) + + test('a task carrying no cycle_step at all is left exactly as it was (no needless write)', () => { + const project = register(makeRepo()) + const repo = project.path + const seeded = seedTask(repo, 'nothing stale') + seeded.status = 'waiting_for_you' + saveTask(repo, seeded) + const before = loadTask(repo, seeded.id)?.updated_at + const rig = fakeRunner() + const manager = createTaskManager({ ...managerOpts, ...rig }) + + manager.reply(project.id, seeded.id, 'go') + + // reply() itself still runs (the runner stub records it); only the + // defensive purge is a no-op when there is nothing to purge. + expect(rig.replies).toEqual([{ id: seeded.id, message: 'go' }]) + expect(loadTask(repo, seeded.id)?.updated_at).toBe(before) + }) }) // --- manager.checks ------------------------------------------------------- @@ -2293,6 +2626,128 @@ function seedCommittedTask(projectPath: string): { record: TaskRecord; worktree: return { record, worktree } } +// --- D22 (minimal): post-merge checks replay ------------------------------- + +/** A landed-merge outcome `mergeTaskFn` can resolve with, shared by the D22 tests below. */ +function mergedOutcome() { + return Promise.resolve({ + kind: 'merged' as const, + cli: 'gh' as const, + url: null, + readiness: { ready: true, conditions: [], blockers: [] }, + events: [{ type: 'merge' as const, data: { name: 'merged', cli: 'gh' } }], + }) +} + +describe('D22 (minimal): post-merge checks replay', () => { + test('a landed merge journals post_merge_checks without waiting for it to complete the turn', async () => { + const project = register(makeRepo()) + const repo = project.path + const seeded = seedShippable(repo) + seeded.status = 'shipped' + seeded.cycle_step = 'merge' + saveTask(repo, seeded) + + let releaseReplay: () => void = () => {} + const replayGate = new Promise((resolve) => { + releaseReplay = resolve + }) + const manager = createTaskManager({ + ...managerOpts, + ...fakeRunner(), + mergeSettings: { policy: 'auto', deleteBranch: false, allowMergeWithoutChecks: false }, + mergeTaskFn: mergedOutcome, + replayPostMergeChecksFn: async () => { + await replayGate + return finishedChecks({ + status: 'failed', + checks: [ + { + command: 'bun test', + status: 'failed', + exit_code: 1, + duration_ms: 500, + tail: 'boom\n', + }, + ], + }) + }, + }) + + await manager.startPending() + + // The merge step already completed the task — still gated behind + // `replayGate` — with no post_merge_checks line yet: completion never + // awaited the replay. + expect(loadTask(repo, seeded.id)?.status).toBe('shipped') + expect(loadTask(repo, seeded.id)?.cycle_step).toBeUndefined() + expect(readTaskEvents(repo, seeded.id).some((e) => e.type === 'post_merge_checks')).toBe(false) + + releaseReplay() + await until(() => readTaskEvents(repo, seeded.id).some((e) => e.type === 'post_merge_checks')) + + const event = readTaskEvents(repo, seeded.id).find((e) => e.type === 'post_merge_checks') + // `record.base` is 'origin/main' (seedShippable): the event names the + // bare target the replay actually fetched. + expect(event?.data).toMatchObject({ status: 'failed', passed: 0, failed: 1, target: 'main' }) + expect(event?.reason_code).toBe('checks_failed') + }) + + test('a replay that could not even run (null) is logged, never journaled, never crashes', async () => { + const project = register(makeRepo()) + const repo = project.path + const seeded = seedShippable(repo) + seeded.status = 'shipped' + seeded.cycle_step = 'merge' + saveTask(repo, seeded) + + const notices: string[] = [] + const manager = createTaskManager({ + ...managerOpts, + ...fakeRunner(), + onNotice: (message) => notices.push(message), + mergeSettings: { policy: 'auto', deleteBranch: false, allowMergeWithoutChecks: false }, + mergeTaskFn: mergedOutcome, + replayPostMergeChecksFn: async () => null, + }) + + await manager.startPending() + expect(loadTask(repo, seeded.id)?.status).toBe('shipped') + + await until(() => notices.some((m) => m.includes('post-merge checks replay'))) + expect(readTaskEvents(repo, seeded.id).some((e) => e.type === 'post_merge_checks')).toBe(false) + }) + + test('the replay hook itself throwing is caught and never crashes the merge step', async () => { + const project = register(makeRepo()) + const repo = project.path + const seeded = seedShippable(repo) + seeded.status = 'shipped' + seeded.cycle_step = 'merge' + saveTask(repo, seeded) + + const notices: string[] = [] + const manager = createTaskManager({ + ...managerOpts, + ...fakeRunner(), + onNotice: (message) => notices.push(message), + mergeSettings: { policy: 'auto', deleteBranch: false, allowMergeWithoutChecks: false }, + mergeTaskFn: mergedOutcome, + replayPostMergeChecksFn: () => { + throw new Error('boom: broken seam') + }, + }) + + await manager.startPending() + expect(loadTask(repo, seeded.id)?.status).toBe('shipped') + + await until((): boolean => + notices.some((m) => m.includes('post-merge checks replay hook failed unexpectedly')), + ) + expect(readTaskEvents(repo, seeded.id).some((e) => e.type === 'post_merge_checks')).toBe(false) + }) +}) + describe('manager.checks', () => { test('guards: no commit is a 409, unknown task/project are 404, gone worktree is a 409', () => { const project = register(makeRepo()) @@ -3979,6 +4434,7 @@ describe('task routes with a stub manager', () => { abandons: [] as string[], checksStarts: [] as string[], checksSetups: [] as string[], + attaches: [] as { projectId: string; taskId: string; repoProjectId: string }[], checksApplies: [] as string[], resumes: [] as { project: string; id: string }[], } @@ -4064,6 +4520,13 @@ describe('task routes with a stub manager', () => { startPending: () => Promise.resolve([]), sweepOrphanedVolumes: async () => {}, applyRetention: async () => {}, + attach: (projectId, taskId, repoProjectId) => { + if (!known(projectId)) { + return Promise.resolve({ ok: false as const, code: 404, error: 'unknown project' }) + } + calls.attaches.push({ projectId, taskId, repoProjectId }) + return Promise.resolve({ ok: true as const }) + }, checksApply: (projectId) => { if (!known(projectId)) { return { ok: false, code: 404, error: 'unknown project' } @@ -4930,6 +5393,7 @@ describe('project routes', () => { // `no-remote` is the motif either way (it wins over the machine probe, // like the forge client's own ladder decides it). const forgeFacts = { forge_available: false, forge_reason: 'no-remote' } + const scratch = scratchProject() expect(JSON.parse(initial.body)).toEqual({ current: current.id, workspace: { @@ -4941,10 +5405,28 @@ describe('project routes', () => { ...forgeFacts, }, projects: [ + // The scratch project leads the list and carries NO forge verdict: + // it has no repository, so `forgeRemote` answers 'unknown' rather + // than the 'no-remote' a remote-less repo earns. + { + id: scratch.id, + path: scratch.path, + name: scratch.name, + kind: 'scratch', + added_at: scratch.added_at, + isolation: { + isolation_available: false, + isolation_default: 'policy', + isolation_reason: 'container isolation was not probed', + isolation_configured: 'policy', + agent: 'claude -p', + }, + }, { id: current.id, path: current.path, name: current.name, + kind: 'repo', added_at: current.added_at, isolation: { isolation_available: false, @@ -7383,6 +7865,51 @@ describe('manager.sweepOrphanedVolumes', () => { expect(notices).toEqual(outcome.notices) }) + // Without the scratch project in the walk, registering a SINGLE repository + // would make every conversation held outside one read as orphaned, and the + // sweep would take its HOME volume out from under a live task. + test('conversations of the scratch project claim their ids too', async () => { + const project = register(makeRepo()) + const inRepo = seedTask(project.path, 'a task') + const scratch = scratchProject() + mkdirSync(scratch.path, { recursive: true }) + const outsideRepo = seedTask(scratch.path, 'just talking') + const seenClaimed: ReadonlySet[] = [] + const manager = createTaskManager({ + ...managerOpts, + sweepOrphanedVolumesFn: (opts) => { + seenClaimed.push(opts.claimedIds) + return Promise.resolve({ removed: [], notices: [] }) + }, + ...fakeRunner(), + }) + + await manager.sweepOrphanedVolumes() + + expect(seenClaimed).toEqual([new Set([outsideRepo.id, inRepo.id])]) + }) + + test('an empty registry says exactly that, never that something could not be read', async () => { + const notices: string[] = [] + const swept: unknown[] = [] + const manager = createTaskManager({ + ...managerOpts, + onNotice: (message) => notices.push(message), + sweepOrphanedVolumesFn: (opts) => { + swept.push(opts) + return Promise.resolve({ removed: [], notices: [] }) + }, + ...fakeRunner(), + }) + + await manager.sweepOrphanedVolumes() + + expect(swept).toHaveLength(0) + expect(notices).toEqual([ + 'orphaned HOME volume sweep skipped: no repository registered to claim them', + ]) + }) + // T1.9 review round 1, Critique 1: listTasks/loadTask drops an unparsable // task.json IN SILENCE (no onStoreUnreadable call — the directory listed // fine, only the file's content didn't parse). Building claimedIds from it @@ -7706,11 +8233,17 @@ describe('manager.applyRetention', () => { await manager.applyRetention() + // Retention covers the scratch project too: conversations held there pile + // up exactly like a repo's, and nothing else would ever purge them. + const scratch = scratchProject() expect(seen).toEqual([ + { cwd: scratch.path, keep: 5 }, { cwd: projectA.path, keep: 5 }, { cwd: projectB.path, keep: 5 }, ]) expect(notices).toEqual([ + `${scratch.name}: ${outcome.notices[0]}`, + `${scratch.name}: retention purged 1 task(s)`, `${projectA.name}: ${outcome.notices[0]}`, `${projectA.name}: retention purged 1 task(s)`, `${projectB.name}: ${outcome.notices[0]}`, @@ -7751,7 +8284,7 @@ describe('manager.applyRetention', () => { await manager.applyRetention() - expect(seen).toEqual([20]) // DEFAULT_TASK_RETENTION + expect(seen).toEqual([20, 20]) // DEFAULT_TASK_RETENTION, scratch then repo }) test('one project failing does not stop the others (fenced per project, like boot recovery)', async () => { @@ -7774,7 +8307,7 @@ describe('manager.applyRetention', () => { await manager.applyRetention() - expect(ran).toEqual([projectA.path, projectB.path]) + expect(ran).toEqual([scratchProject().path, projectA.path, projectB.path]) expect(notices.some((line) => line.includes('disk full'))).toBe(true) }) }) @@ -10409,3 +10942,66 @@ describe('cycle labels and the recap, wired onto a real run', () => { expect(at('labels codesema:merged')).toBeLessThan(at('close')) }) }) + +// ── Conversations with no repository (the scratch project) ───────────────── + +describe('scratch conversations over HTTP', () => { + test('a workspace with no repo registered can still open a conversation', async () => { + const manager = createTaskManager({ ...managerOpts, ...fakeRunner() }) + const started = await startServer(createSession(), { + cwd: makeDir(), + port: 5187, + taskManager: manager, + currentProjectId: null, + }) + try { + const token = await tasksToken(started.port) + const scratch = scratchProject() + + const listed = await rawRequest(started.port, '/api/projects') + expect(JSON.parse(listed.body).projects).toHaveLength(1) + expect(JSON.parse(listed.body).projects[0]).toMatchObject({ + id: scratch.id, + kind: 'scratch', + }) + + const created = await rawRequest(started.port, '/api/tasks', { + method: 'POST', + headers: { 'x-codesema-tasks-token': token }, + body: JSON.stringify({ project_id: scratch.id, title: 'just talking', prompt: 'hello' }), + }) + expect(created.status).toBe(201) + const id = JSON.parse(created.body).id as string + + const fetched = await rawRequest(started.port, `/api/tasks/${id}?project=${scratch.id}`) + expect(fetched.status).toBe(200) + expect(JSON.parse(fetched.body)).toMatchObject({ record: { id, branch: '', base: '' } }) + } finally { + await started.stop() + } + }) + + test('naming a branch or a base is refused: there is no repository to name one in', async () => { + const manager = createTaskManager({ ...managerOpts, ...fakeRunner() }) + const started = await startServer(createSession(), { + cwd: makeDir(), + port: 5188, + taskManager: manager, + currentProjectId: null, + }) + try { + const token = await tasksToken(started.port) + const scratch = scratchProject() + for (const extra of [{ branch: 'feat/x' }, { base: 'main' }]) { + const refused = await rawRequest(started.port, '/api/tasks', { + method: 'POST', + headers: { 'x-codesema-tasks-token': token }, + body: JSON.stringify({ project_id: scratch.id, title: 't', prompt: 'p', ...extra }), + }) + expect(refused.status).toBe(400) + } + } finally { + await started.stop() + } + }) +}) diff --git a/packages/cli/src/task-server.ts b/packages/cli/src/task-server.ts index 1b17404..394bb59 100644 --- a/packages/cli/src/task-server.ts +++ b/packages/cli/src/task-server.ts @@ -19,6 +19,8 @@ import { } from './checks-setup.js' import { DEFAULT_MERGE_SETTINGS, + loadGlobalConfig, + resolveBrainAutoMerge, resolveMaxAutoFixRounds, resolveProjectAgentCommand, resolveProjectConfig, @@ -32,6 +34,8 @@ import { isActiveTaskStatus, TASK_TITLE_MAX, TASK_TURN_TEXT_MAX, + type AcceptanceCriterion, + type ArmTicket, type ReasonCode, type ReviewRecord, type TaskChecks, @@ -57,8 +61,15 @@ import type { ForgeIssuesExecFn } from './forge-issues.js' import { refExists, tryGit, tryGitAsync } from './git.js' import { t } from './i18n.js' import { createLoadCap, type LoadCap, type LoadCapSnapshot } from './load-cap.js' -import { listProjects, listProjectsDetailed, type Project } from './projects.js' +import { + listProjectsDetailed, + listWorkspaceProjects, + scratchProject, + type Project, +} from './projects.js' import { readChecksConfig } from './repo-config.js' +import { resolveBrainTicketOrigin } from './task-brain-ticket.js' +import { reportBrainTransition } from './task-brain.js' import { runChecks } from './task-checks.js' import { applyFixLoopDecision, @@ -99,8 +110,9 @@ import { syncCycleLabel, type CycleLabel, } from './task-labels.js' -import { mergeTask, type MergeOutcome } from './task-merge.js' +import { effectiveMergePolicyIsAuto, mergeTask, type MergeOutcome } from './task-merge.js' import { resolveTaskPlan, type TaskPlanDeps, type TaskPreviewResult } from './task-plan.js' +import { replayChecksOnDefaultBranch } from './task-post-merge-checks.js' import { createTaskQueue, type TaskQueue } from './task-queue.js' import { publishTaskRecap } from './task-recap-publish.js' import { @@ -255,6 +267,17 @@ export type CreateTaskManagerInput = { * simply wins. */ issue?: CreateTaskManagerIssueInput + /** + * Arm/brain integration: creates the task FROM this ticket the local + * brain owns, instead of a bare title+prompt or a forge issue. Mutually + * exclusive in effect with `issue` and with `title`/`prompt`: when given, + * they are ignored and this wins, same convention `issue` already has over + * `title`/`prompt`. The ticket's own title and (linted) body take their + * place, and the task's record carries `brain_ticket` and its already + * brain-validated `criteria` (see `resolveBrainTicketOrigin`, + * task-brain-ticket.ts). + */ + brainTicket?: ArmTicket /** * Per-task agent CLI (id or full known command). Validated with * `resolveKnownAgentCommand`; unknown/custom is a 400. Absent: the @@ -380,6 +403,14 @@ export type TaskManager = { /** Resolved agent command a NEW unspecified task of this project would run. */ agent: string } + /** + * Gives a registered repository to a conversation that started without one. + * `repoProjectId` names a project in the registry; the scratch project is + * not one, and neither is an id nothing claims (404). The worktree lands + * inside the conversation's own workspace, so the directory its agent runs + * in is unchanged. + */ + attach: (projectId: string, taskId: string, repoProjectId: string) => Promise /** * Writes the ready proposal to the project's .codesema/config.json — the * ONLY path from a proposal to disk. 409 when nothing is proposed. @@ -623,6 +654,13 @@ export type CreateTaskManagerOptions = { degradedMergeKeys?: readonly string[] /** Test seam: the default evaluates the four conditions for real and drives gh/glab. */ mergeTaskFn?: typeof mergeTask + /** + * D22 (minimal) test seam: the post-merge checks replay `runMergeStep` fires + * fire-and-forget after a landed merge (`schedulePostMergeReplay`). The + * default runs a real fetch against the repo's own `origin` and a real + * checks engine — no test of this module drives either. + */ + replayPostMergeChecksFn?: typeof replayChecksOnDefaultBranch } /** One project whose persisted queue was resumed, for the boot announcement. */ @@ -709,6 +747,18 @@ function reconcileTasks(cwd: string, projectId: string): ReconcileOutcome { const records = listTasks(cwd) /** Facts worth a line on the terminal that are not, in themselves, failures. */ const notices: string[] = [] + // D20: a `cycle_step` is how a task tells boot it was mid-ship or + // mid-merge when the previous process died. `startPending` is what + // actually resumes it (resumeCycleStep) — this is only the "never + // silent" half (invariant n° 2): a human reading the boot log sees the + // resume coming instead of a task quietly finishing a step nobody knew + // was still open. + const pendingCycleSteps = records.filter((record) => record.cycle_step) + if (pendingCycleSteps.length > 0) { + notices.push( + `${pendingCycleSteps.length} task(s) carry a pending cycle step from an earlier session and will resume it: ${pendingCycleSteps.map((record) => `${record.id} (${record.cycle_step})`).join(', ')}`, + ) + } /** * `reason` travels WITH the status, always. A boot rewrite is a degradation * like any other (invariant 2) and the D2 vocabulary is the machine-readable @@ -916,12 +966,34 @@ function shipRefusal(record: TaskRecord): TaskActionResult | null { return null } +/** + * D20 defensive purge. A `cycle_step` marker names a ship or merge step this + * process is running THROUGH `ship()`/`runMergeStep()` — never through + * `reply`/`resume`/`abandon` — so any of the three finding one already set + * can only mean a crash left it behind (the step's own write always clears + * it, success or failure alike, before the runner claims the task again). An + * explicit human reply/resume/abandon overrides whatever that stale step + * still claims to be doing, so it purges the marker itself, before its own + * effect, rather than leaving it for a `startPending` the human's own action + * has already overtaken. + */ +function purgeStaleCycleStep(cwd: string, id: string): void { + const record = loadTask(cwd, id) + if (record?.cycle_step) { + delete record.cycle_step + record.updated_at = new Date().toISOString() + saveTask(cwd, record) + } +} + /** Everything the manager holds per project, built lazily at first access. */ type ProjectContext = { project: Project runner: TaskRunner /** Tasks with a ship in flight (see ship below). */ shipping: Set + /** Tasks with a merge in flight (see runMergeStep below). Mirrors shipping exactly. */ + merging: Set /** Tasks with a checks run in flight (one run at a time per task). */ checking: Set /** @@ -948,6 +1020,10 @@ type TaskOrigin = issueSnapshot: TaskIssueSnapshot | null /** T2.4/DP13: true when the issue's raw body carries content the edit-detector cannot see. Always false off the title+prompt path. */ coverageGap: boolean + /** Arm/brain integration: the ticket this task was created from, when it was one. Absent off every other origin. */ + brainTicket?: { id: string; title: string; url?: string } | null + /** The brain's already-validated criteria, frozen onto the record at creation. Absent off every other origin. */ + criteria?: AcceptanceCriterion[] | null } | { ok: false; refusal: Extract } @@ -1153,7 +1229,9 @@ async function runWithConcurrency( } export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { - const registered = opts.listProjectsFn ?? listProjects + // Workspace projects, not the registry: the scratch project is a destination + // a conversation can be created against, and it is in no file. + const registered = opts.listProjectsFn ?? listWorkspaceProjects const notice = opts.onNotice ?? ((message: string) => console.warn(message)) /** * Machine-wide load cap (T1.3, D4): ONE instance for the whole manager, @@ -1298,8 +1376,19 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { // that will not resolve forbids the WHOLE sweep, same as an unparsable // registry or a tasks/ that will not list. let pathUnresolved = false - for (const project of registry.projects) { + // The scratch project holds conversations like any other and their HOME + // volumes are claimed like any other: leaving it out of this walk would + // read every one of them as orphaned the moment a single repository is + // registered alongside. It is deliberately NOT counted in projectCount, + // which guards "we know of no project at all, so sweep nothing" and must + // keep meaning exactly that. + for (const project of [scratchProject(), ...registry.projects]) { if (!existsSync(project.path)) { + // The scratch directory is created on first use: not existing yet is + // a project holding nothing, not a project we failed to read. + if (project.kind === 'scratch') { + continue + } pathUnresolved = true continue } @@ -1433,6 +1522,7 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { const runtime = projectRuntime(project.path) return { cwd: project.path, + ...(project.kind === 'scratch' ? { scratch: true } : {}), runtime: { command: runtime.command, isolationMode: runtime.isolationMode }, probe, tasks: () => listTasks(project.path), @@ -1694,6 +1784,16 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { } const refusal = shipRefusal(record) if (refusal) { + // D20: this attempt never ran, but a `cycle_step` an earlier, crashed + // attempt left behind must not survive a refusal that will not retry + // itself — otherwise a resumed boot would keep calling ship() on this + // exact refusal forever. The record is left exactly as shipRefusal + // read it, this one field aside. + if (record.cycle_step) { + delete record.cycle_step + record.updated_at = new Date().toISOString() + saveTask(cwd, record) + } return refusal } // This `record` crosses the push (network, slow) before saveTask below — @@ -1724,6 +1824,14 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { ...(outcome.reasonCode ? { reason_code: outcome.reasonCode } : {}), }) emit({ project_id: projectId, task_id: id, event: { name: 'task_event', data: event } }) + // D20: the push failed, so nothing shipped — but a `cycle_step` this + // attempt carried in must not survive a refusal that will not retry + // itself, same reasoning as shipRefusal's own clear above. + if (record.cycle_step) { + delete record.cycle_step + record.updated_at = new Date().toISOString() + saveTask(cwd, record) + } // 502: the failure is on the remote/CLI side, not in the request. return { ok: false, @@ -1765,6 +1873,22 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { delete record.reason } record.status = 'shipped' + // D20, same write as the status above: advanced to 'merge' when the + // chained runMergeStep below will actually attempt one — the SAME + // settings/brainAutoMerge it resolves a few lines later — or cleared + // when it will not (mergePolicy 'human', no brain override), so the + // record never claims to be mid-step when nothing is about to run. + if ( + effectiveMergePolicyIsAuto( + record, + opts.mergeSettings ?? DEFAULT_MERGE_SETTINGS, + resolveBrainAutoMerge(loadGlobalConfig()), + ) + ) { + record.cycle_step = 'merge' + } else { + delete record.cycle_step + } record.updated_at = new Date().toISOString() saveTask(cwd, record) emit({ project_id: projectId, task_id: record.id, event: { name: 'task', data: record } }) @@ -1775,6 +1899,15 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { // `codesema:reviewing` with the `review_ok` it comes from, so the // nominal auto-ship spends nothing at all here. trackCycleLabel(mirrorCycleLabel(projectId, cwd, record)) + // Arm/brain integration: the same "after the persisted transition, + // never instead of it" discipline as the cycle label right above. + // Never awaited: a brain round trip must not hold up the ship's own + // answer, exactly like the label. + void reportBrainTransition(cwd, record, { + type: 'mr_opened', + ...(outcome.mrUrl ? { mr_url: outcome.mrUrl } : {}), + branch: record.branch, + }) // T1.9: nothing was ever created for a 'policy' task, so nothing is // attempted for one either — same gate as the runner's abandon path. if (record.isolation === 'container') { @@ -1852,6 +1985,65 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { } } + /** + * D22 (minimal): replays this task's checks on the default branch after its + * merge landed and journals the outcome. Called fire-and-forget from + * `runMergeStep` — see the call site — because the turn that shipped this + * task is already over, and nothing about ITS status may wait on what a + * check of the DEFAULT branch, possibly minutes later, turns out to say. + * + * `replayChecksOnDefaultBranch` returning `null` is INFRASTRUCTURE noise (an + * unreachable remote, a lock that timed out, a vanished container engine), + * not news about the default branch itself: it is logged, never journaled, + * so a flaky fetch never leaves a permanent line on a task that otherwise + * finished cleanly. + */ + const schedulePostMergeReplay = async ( + ctx: ProjectContext, + record: TaskRecord, + ): Promise => { + const cwd = ctx.project.path + const projectId = ctx.project.id + // Same normalization as task-merge.ts's own `branchAncestry`: a fork + // records `origin/` while a work-on conversation records the bare + // MR target branch, and the fetch below needs the bare remote branch name + // either way. + const target = record.base.replace(/^origin\//, '') + const replay = opts.replayPostMergeChecksFn ?? replayChecksOnDefaultBranch + const checks = await replay({ + cwd, + task: record, + target, + config: readChecksConfig(cwd), + projectId, + }) + if (!checks) { + notice(`${record.id}: the post-merge checks replay on '${target}' could not run`) + return + } + const passed = checks.checks.filter((c) => c.status === 'passed').length + const failed = checks.checks.filter( + (c) => c.status === 'failed' || c.status === 'timeout', + ).length + // Same "blocking" reading `checks`'s own journal line uses (above, + // read-checks job): a genuinely red run, never an 'error'/'unconfigured' + // one — those mean "not evaluated", not "failed" (see reasons.ts's own + // `checks_unavailable` doc for why the two must never share a code). + const blocking = checks.status === 'failed' || checks.checks.some((c) => c.status === 'timeout') + const event = appendTaskEvent(cwd, record.id, { + type: 'post_merge_checks', + data: { + status: checks.status, + passed, + failed, + target, + ...(checks.error ? { error: checks.error } : {}), + }, + ...(blocking ? { reason_code: 'checks_failed' as const } : {}), + }) + emit({ project_id: projectId, task_id: record.id, event: { name: 'task_event', data: event } }) + } + /** * T3.6 (D12): the merge step. Chained after a SUCCESSFUL ship, inside * `onTurnDone`, so it only ever runs when there is a merge request to merge. @@ -1888,54 +2080,150 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { const runMergeStep = async (ctx: ProjectContext, id: string): Promise => { const cwd = ctx.project.path const projectId = ctx.project.id + // D20 anti-reentrancy, exact mirror of ship()'s own `shipping` guard: the + // normal auto-chain (onTurnDone) and a boot resume (resumeCycleStep) are + // two independent callers that can land on the same id. + if (ctx.merging.has(id)) { + return null + } const record = loadTask(cwd, id) if (!record || record.status !== 'shipped') { return null } - const settings = opts.mergeSettings ?? DEFAULT_MERGE_SETTINGS - const run = opts.mergeTaskFn ?? mergeTask - let outcome: MergeOutcome - try { - outcome = await run({ - cwd, - task: record, - settings, - ...(opts.degradedMergeKeys && opts.degradedMergeKeys.length > 0 - ? { degradedKeys: opts.degradedMergeKeys } - : {}), - }) - } catch (err) { - const event = appendTaskEvent(cwd, id, { - type: 'error', - data: { - message: `the merge step failed: ${err instanceof Error ? err.message : String(err)}`, - }, - }) - emit({ project_id: projectId, task_id: id, event: { name: 'task_event', data: event } }) + // D20 idempotence: a crash between an EARLIER call's landed merge and the + // write that would have cleared `cycle_step` resumes this exact call at + // the next boot, on a record still sitting on 'shipped' (a landed merge + // never moves the status — see WHAT MOVES A STATUS below). That earlier + // call's own 'merged' journal line (appended a few lines down, on every + // success) is already there; reading it back is cheaper than a fresh + // mergeTaskFn call and never itself races the merge module's own + // forge-side guard (task-merge.ts's `branchAlreadyMerged`). + if ( + readTaskEvents(cwd, id).some( + (event) => event.type === 'merge' && event.data.name === 'merged', + ) + ) { + if (record.cycle_step) { + delete record.cycle_step + record.updated_at = new Date().toISOString() + saveTask(cwd, record) + emit({ project_id: projectId, task_id: record.id, event: { name: 'task', data: record } }) + } return null } - for (const input of outcome.events) { - const event = appendTaskEvent(cwd, id, input) - emit({ project_id: projectId, task_id: id, event: { name: 'task_event', data: event } }) - } - if (outcome.kind === 'refused' || outcome.kind === 'failed') { - record.status = 'waiting_for_you' - record.reason = outcome.reason - record.updated_at = new Date().toISOString() - saveTask(cwd, record) - emit({ project_id: projectId, task_id: record.id, event: { name: 'task', data: record } }) - // T3.7, same shape as the ship's: after the persisted transition, never - // instead of it. `waiting_for_you` is `codesema:blocked` — the ticket - // now says a person is needed, which is exactly what just became true. - trackCycleLabel(mirrorCycleLabel(projectId, cwd, record)) + ctx.merging.add(id) + try { + const settings = opts.mergeSettings ?? DEFAULT_MERGE_SETTINGS + const run = opts.mergeTaskFn ?? mergeTask + let outcome: MergeOutcome + try { + outcome = await run({ + cwd, + task: record, + settings, + // Arm/brain integration: `brainAutoMerge` is GLOBAL-ONLY (see its own + // field comment, config.ts), resolved HERE, once, from the global + // file alone, and handed to `mergeTask` as a plain value rather than + // read there: a repo file can never contribute to it, and a merge + // module that read config itself would blur that boundary. + brainAutoMerge: resolveBrainAutoMerge(loadGlobalConfig()), + ...(opts.degradedMergeKeys && opts.degradedMergeKeys.length > 0 + ? { degradedKeys: opts.degradedMergeKeys } + : {}), + }) + } catch (err) { + const event = appendTaskEvent(cwd, id, { + type: 'error', + data: { + message: `the merge step failed: ${err instanceof Error ? err.message : String(err)}`, + }, + }) + emit({ project_id: projectId, task_id: id, event: { name: 'task_event', data: event } }) + return null + } + for (const input of outcome.events) { + const event = appendTaskEvent(cwd, id, input) + emit({ project_id: projectId, task_id: id, event: { name: 'task_event', data: event } }) + } + if (outcome.kind === 'refused' || outcome.kind === 'failed') { + record.status = 'waiting_for_you' + record.reason = outcome.reason + // D20, same write as the status above: the step just ended, one way + // or the other, so nothing is left claiming it is still running. + delete record.cycle_step + record.updated_at = new Date().toISOString() + saveTask(cwd, record) + emit({ project_id: projectId, task_id: record.id, event: { name: 'task', data: record } }) + // T3.7, same shape as the ship's: after the persisted transition, never + // instead of it. `waiting_for_you` is `codesema:blocked` — the ticket + // now says a person is needed, which is exactly what just became true. + trackCycleLabel(mirrorCycleLabel(projectId, cwd, record)) + } else if (record.cycle_step) { + // D20: 'held' or 'merged' — neither moves the status (see WHAT MOVES + // A STATUS below), but a resumed marker must not outlive either + // outcome any more than it outlives a status change. No write at all + // when `ship()` never set one to begin with — the ordinary 'held' + // case under mergePolicy 'human'. + delete record.cycle_step + record.updated_at = new Date().toISOString() + saveTask(cwd, record) + emit({ project_id: projectId, task_id: record.id, event: { name: 'task', data: record } }) + } + if (outcome.kind === 'merged') { + // T3.5 × T3.6 × T3.7, and the only place `outcome.kind === 'merged'` is + // ever read: what a LANDED merge owes the ticket. AWAITED — see + // `publishMergedOutcome`. + await publishMergedOutcome(ctx, record) + // D22 (minimal): deliberately NOT awaited — the turn this merge + // belongs to is already done, see `schedulePostMergeReplay`'s own + // doc. `.catch()` rather than a bare `void`, same discipline as + // `trackCycleLabel` above: nothing in this hook's own contract is + // "never rejects" the way `reportBrainTransition`'s is, so an + // unexpected throw is turned into a notice instead of an unhandled + // rejection. + void schedulePostMergeReplay(ctx, record).catch((err: unknown) => { + notice( + `${record.id}: the post-merge checks replay hook failed unexpectedly (${errorMessage(err)})`, + ) + }) + } + return outcome + } finally { + ctx.merging.delete(id) } - if (outcome.kind === 'merged') { - // T3.5 × T3.6 × T3.7, and the only place `outcome.kind === 'merged'` is - // ever read: what a LANDED merge owes the ticket. AWAITED — see - // `publishMergedOutcome`. - await publishMergedOutcome(ctx, record) + } + + /** + * D20 boot recovery: resumes exactly the step a `cycle_step` marker names, + * for a task a crash caught mid-ship or mid-merge. `ship()` and + * `runMergeStep()` are what clear the marker (success or failure alike), + * so this does nothing else — no notice on the ordinary case, no retry + * budget, no decision of its own. Called from `startPending()`, once, for + * every task a project's disk still carries one on. + * + * `'ship'` re-reads the record after `ship()` returns rather than trusting + * what it had in hand: a successful ship advances the marker to `'merge'` + * in the SAME write as the `shipped` status (see `ship()`'s own D20 + * comment), and only that fresh copy can say so. + */ + const resumeCycleStep = async (ctx: ProjectContext, record: TaskRecord): Promise => { + try { + if (record.cycle_step === 'ship') { + await ship(ctx, record.id) + const reloaded = loadTask(ctx.project.path, record.id) + if (reloaded?.cycle_step === 'merge') { + await runMergeStep(ctx, record.id) + } + return + } + if (record.cycle_step === 'merge') { + await runMergeStep(ctx, record.id) + } + } catch (err) { + notice( + `${ctx.project.name}: resuming task ${record.id}'s '${record.cycle_step}' step failed unexpectedly (${errorMessage(err)})`, + ) } - return outcome } /** @@ -2288,7 +2576,7 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { state.decided = true // Read at THIS instant on purpose: the archive the fix turn works // from is the one the reviewer just wrote. - state.fixPrompt = reviewArchived ? buildAutoFixTurnPrompt(record) : null + state.fixPrompt = reviewArchived ? buildAutoFixTurnPrompt(record, gateChecks) : null // A function of the DISK, never of a counter this process holds: a // workspace restarted mid-loop resumes at the right round. And a // journal it could not READ is handed on as null — not as the count @@ -2304,6 +2592,13 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { fixable: state.fixPrompt !== null, }) applyFixLoopDecision(record, state.loop) + // D20: posed in the SAME write as the verdict that decides it, right + // before whichever persist follows — never a write of its own. The + // exact condition `ship()` chains on below, so the marker is set + // the instant it becomes true and never lags a turn behind it. + if (record.auto_ship && record.status === 'review_ok') { + record.cycle_step = 'ship' + } } // The persist the reviewer (or a test stub) calls is THE unique write // of the final status: the gates mutate the in-memory record first, so @@ -2467,6 +2762,7 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { timeoutMs, ...(watchdog ? { watchdog } : {}), projectId, + ...(project.kind === 'scratch' ? { scratch: true } : {}), onTurnDone, // A degradation of queue.json met OUTSIDE the boot pass: journaled on // the tasks the rebuilt queue holds, and said out loud. The rebuild @@ -2588,6 +2884,7 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { project, runner, shipping: new Set(), + merging: new Set(), checking: new Set(), command, } @@ -3096,6 +3393,25 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { ) } } + // D20: every registered project, checked for a task left mid-ship or + // mid-merge by a process that died before it cleared `cycle_step`. + // `listTasks` is a plain disk read — `context()` (and the runner it + // builds) is only ever reached for a project that actually has one, + // same laziness `context()`'s own docstring promises for every other + // caller. + for (const project of registered()) { + const stuck = listTasks(project.path).filter((record) => record.cycle_step) + if (stuck.length === 0) { + continue + } + const ctx = context(project.id) + if (!ctx) { + continue + } + for (const record of stuck) { + await resumeCycleStep(ctx, record) + } + } return resumed }, @@ -3114,7 +3430,15 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { // "poll until the registry looks readable and then delete things". async sweepOrphanedVolumes() { const first = projectClaimedIds() - if (!first.complete || first.projectCount === 0) { + // Two different facts, and conflating them told every workspace with an + // empty registry that something was unreadable. Nothing is: there is + // simply nothing registered to compare volumes against yet, which is now + // an ordinary state rather than a workspace that cannot be used at all. + if (first.projectCount === 0) { + notice('orphaned HOME volume sweep skipped: no repository registered to claim them') + return + } + if (!first.complete) { notice( "orphaned HOME volume sweep skipped: the project registry or a project's task store could not be read completely", ) @@ -3210,10 +3534,13 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { } // Reads the issue exactly as `create` does — `admitIssue` never writes — // and then throws the snapshot away: D-d, previewing is not launching, so - // nothing dates the ticket of a task that does not exist. - const origin = input.issue - ? await resolveIssueOrigin(project.path, input.issue, opts.issueExecFn) - : resolveTitlePromptOrigin(input) + // nothing dates the ticket of a task that does not exist. Same + // brainTicket > issue > title/prompt order `create()` resolves with. + const origin = input.brainTicket + ? resolveBrainTicketOrigin(project.path, input.brainTicket) + : input.issue + ? await resolveIssueOrigin(project.path, input.issue, opts.issueExecFn) + : resolveTitlePromptOrigin(input) if (!origin.ok) { return origin.refusal } @@ -3249,15 +3576,28 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { if (!ctx) { return unknownProject } - // Both given together or neither: `issue` is what freezes the ticket, - // and a task's record must never carry one without the other (T2.4). - const origin = input.issue - ? await resolveIssueOrigin(ctx.project.path, input.issue, opts.issueExecFn) - : resolveTitlePromptOrigin(input) + // `brainTicket` wins over `issue`, which wins over a bare + // title+prompt: the same "one origin, and it decides everything + // else" convention `issue` already has. `resolveBrainTicketOrigin` is + // synchronous (no forge round trip: the ticket arrives already + // resolved and validated by the brain), unlike `resolveIssueOrigin`. + const origin = input.brainTicket + ? resolveBrainTicketOrigin(ctx.project.path, input.brainTicket) + : input.issue + ? await resolveIssueOrigin(ctx.project.path, input.issue, opts.issueExecFn) + : resolveTitlePromptOrigin(input) if (!origin.ok) { return origin.refusal } - const { title, prompt, issue: issueRef, issueSnapshot, coverageGap } = origin + const { + title, + prompt, + issue: issueRef, + issueSnapshot, + coverageGap, + brainTicket, + criteria, + } = origin // Every guard below — base/branch exclusivity and shape, the work-on // uniqueness and checked-out-elsewhere 409s, the agent, the isolation // refusal — and every DECISION the record carries now live in @@ -3309,6 +3649,13 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { // resolveIssueOrigin/resolveTitlePromptOrigin) — one guard, not two, // so a future drift here cannot silently split the pair. ...(issueRef && issueSnapshot ? { issue: issueRef, issueSnapshot } : {}), + // Arm/brain integration: both land in the SAME write as everything + // else above. Criteria in particular must never trail the record by + // a second write: the task's very first turn already reads + // `taskCriteria(record)` to build its prompt, and criteria arriving + // even one write later would race that read. + ...(brainTicket ? { brainTicket } : {}), + ...(criteria && criteria.length > 0 ? { criteria } : {}), }) // The WHY is journaled on the task itself: an 'auto' workspace that fell // back to policy must be able to say so, months later, from the record. @@ -3352,6 +3699,25 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { }) } } + // Arm/brain integration: the same 'criteria'/'validated' line + // POST /api/tasks/:id/criteria journals for a human-validated list + // (task-criteria.ts); the brain played that role instead, so the + // record's journal says so the same way. + if (brainTicket && criteria && criteria.length > 0) { + const criteriaEvent = appendTaskEvent(ctx.project.path, record.id, { + type: 'criteria', + data: { + name: 'validated', + message: 'acceptance criteria validated', + count: criteria.length, + }, + }) + emit({ + project_id: projectId, + task_id: record.id, + event: { name: 'task_event', data: criteriaEvent }, + }) + } // start() rereads the task.json written just above; on a fresh 'queued' // record it cannot legitimately refuse, but a refusal must not be // swallowed: the caller would wait forever on a task that never runs. @@ -3380,6 +3746,12 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { task_id: failure.id, event: { name: 'task_event', data: event }, }) + // Never awaited: the caller must not wait on a brain round trip for a + // task that just failed to even start. + void reportBrainTransition(ctx.project.path, failure, { + type: 'failed', + error_message: started.error, + }) return started } // The caller learns right away whether it got the repo (no position) or @@ -3396,27 +3768,39 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { // While a ship pushes, a reply would start a new turn (and a new commit) // under it, and an abandon would delete the very branch being pushed: // both wait until the ship settles. interrupt already 409s at the runner - // (a shippable task is neither active nor queued). + // (a shippable task is neither active nor queued). D20: a merge in + // flight is the same class of risk (abandon deleting the very branch a + // forge merge call is mid-flight on), so it waits on `merging` too. reply(projectId, id, message) { const ctx = context(projectId) if (!ctx) { return unknownProject } - return ctx.shipping.has(id) - ? { ok: false, code: 409, error: 'ship in progress' } - : ctx.runner.reply(id, message) + if (ctx.shipping.has(id)) { + return { ok: false, code: 409, error: 'ship in progress' } + } + if (ctx.merging.has(id)) { + return { ok: false, code: 409, error: 'merge in progress' } + } + purgeStaleCycleStep(ctx.project.path, id) + return ctx.runner.reply(id, message) }, // Same reason as reply: a resume starts a turn (and a commit) under a push - // in flight, so it waits for the ship to settle. + // or a merge in flight, so it waits for either to settle. resume(projectId, id) { const ctx = context(projectId) if (!ctx) { return unknownProject } - return ctx.shipping.has(id) - ? { ok: false, code: 409, error: 'ship in progress' } - : ctx.runner.resume(id) + if (ctx.shipping.has(id)) { + return { ok: false, code: 409, error: 'ship in progress' } + } + if (ctx.merging.has(id)) { + return { ok: false, code: 409, error: 'merge in progress' } + } + purgeStaleCycleStep(ctx.project.path, id) + return ctx.runner.resume(id) }, interrupt(projectId, id) { @@ -3436,9 +3820,14 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { } // Removing a worktree waits for the repo lock, so this one is async // where its siblings are not: the refusals stay immediate values. - return ctx.shipping.has(id) - ? Promise.resolve({ ok: false, code: 409, error: 'ship in progress' }) - : ctx.runner.abandon(id) + if (ctx.shipping.has(id)) { + return Promise.resolve({ ok: false, code: 409, error: 'ship in progress' }) + } + if (ctx.merging.has(id)) { + return Promise.resolve({ ok: false, code: 409, error: 'merge in progress' }) + } + purgeStaleCycleStep(ctx.project.path, id) + return ctx.runner.abandon(id) }, checks(projectId, id) { @@ -3495,6 +3884,20 @@ export function createTaskManager(opts: CreateTaskManagerOptions): TaskManager { } }, + async attach(projectId, taskId, repoProjectId) { + const ctx = context(projectId) + if (!ctx) { + return unknownProject + } + const repo = findProject(repoProjectId) + // The scratch project is a destination, never a source: attaching the + // conversation's own workspace to itself would nest it in itself. + if (!repo || repo.kind !== 'repo') { + return { ok: false, code: 404, error: 'unknown repository' } + } + return ctx.runner.attach(taskId, { project_id: repo.id, path: repo.path }) + }, + checksApply(projectId) { const project = findProject(projectId) return project ? checksSetup.apply(project) : unknownProject diff --git a/packages/cli/src/task-worktree.ts b/packages/cli/src/task-worktree.ts index dd71aed..9da93e1 100644 --- a/packages/cli/src/task-worktree.ts +++ b/packages/cli/src/task-worktree.ts @@ -12,7 +12,7 @@ // never renamed, without saying so out loud when the attempt is refused or // when a caller chooses to keep one that carries work. -import { existsSync, mkdirSync } from 'node:fs' +import { existsSync, mkdirSync, rmSync } from 'node:fs' import { join } from 'node:path' import { listWorktrees } from './branches.js' import { ensureWorkDir, taskWorktreesDir } from './config.js' @@ -346,7 +346,12 @@ export function branchCheckoutPath(cwd: string, branch: string): string | null { * is cleaned before the in-use check — pruning it may be exactly what frees * the branch — and nothing else is created before both checks pass. */ -function createWorkOnWorktree(cwd: string, taskId: string, branch: string): MaterializedWorktree { +function createWorkOnWorktree( + cwd: string, + taskId: string, + branch: string, + worktreePath?: string, +): MaterializedWorktree { // The refs/heads/ qualification neutralizes option-lookalike names ('-evil'). let created = false if (!refExists(`refs/heads/${branch}`, cwd)) { @@ -366,7 +371,7 @@ function createWorkOnWorktree(cwd: string, taskId: string, branch: string): Mate throw new Error(t('task.unknownBranch', { branch })) } } - const worktree = taskWorktreePath(cwd, taskId) + const worktree = worktreePath ?? taskWorktreePath(cwd, taskId) // Unconditional: a crashed run may have left the directory (remove drops // it) OR only a dangling git registration of a deleted directory (prune // drops that) — either would keep the branch looking checked out. @@ -401,17 +406,17 @@ function createForkWorktree( cwd: string, taskId: string, title: string, - base?: string, + where: { base?: string | undefined; worktreePath?: string | undefined } = {}, ): MaterializedWorktree { // Base and start point are resolved BEFORE any directory or ref is created: // a typo (or a repo with no trunk at all) must leave the repo untouched. // Same call the dry-run plan makes, so the two can never disagree. - const { base: resolvedBase, startPoint } = resolveForkBase(cwd, base) + const { base: resolvedBase, startPoint } = resolveForkBase(cwd, where.base) // .codesema/ must exist self-gitignored before git materializes anything in it. ensureWorkDir(cwd) mkdirSync(taskWorktreesDir(cwd), { recursive: true }) const branch = freeBranchName(cwd, taskId, title) - const worktree = taskWorktreePath(cwd, taskId) + const worktree = where.worktreePath ?? taskWorktreePath(cwd, taskId) // Unconditional, for the same reason as the work-on path: a crashed run may // have left the DIRECTORY (remove drops it) or only a dangling git // registration of a directory that is already gone (prune drops that). @@ -454,12 +459,54 @@ const defaultLockFn: WorktreeLockFn = (cwd, signal) => acquireWorktreeLock(cwd, export type CreateTaskWorktreeOptions = { base?: string branch?: string + /** + * Where the worktree goes, when it must NOT go under this repository's + * `.codesema/worktrees/`: a repository attached to a conversation is + * materialized inside THAT CONVERSATION's working directory, so the + * directory its agent runs in never changes as repositories come and go. + * The branch still belongs to this repository, and so does the lock. + */ + worktreePath?: string /** Gives up the wait for the repo lock as soon as the caller is interrupted. */ signal?: AbortSignal | undefined /** Test seam: the repo lock the whole creation runs under. */ lockFn?: WorktreeLockFn | undefined } +/** + * Working directory for a conversation that has been given no repository: a + * plain directory, in the same place a worktree would have gone, so retention + * and the orphan sweep keep finding tasks where they always have. + * + * Nothing here is git, and that is the point: no branch to name, no base to + * fork from, and no lock to take, since a directory nothing else can reach is + * shared with nobody. The doctrine at the top of this file does not apply + * either, because there is no branch for work to survive on: what such a + * conversation produces lives in its transcript, or in a repository attached + * to it later. + */ +export function createScratchWorkdir(cwd: string, taskId: string): string { + ensureWorkDir(cwd) + const dir = join(taskWorktreesDir(cwd), taskId) + mkdirSync(dir, { recursive: true }) + return dir +} + +/** + * Counterpart of createScratchWorkdir, and best-effort like removeTaskWorktree: + * a directory that could not be removed must never turn its caller's outcome + * into an error. Nothing git-tracked can be lost here, so there is no branch + * fate to weigh first. + */ +export function removeScratchWorkdir(cwd: string, taskId: string): boolean { + try { + rmSync(join(taskWorktreesDir(cwd), taskId), { recursive: true, force: true }) + return true + } catch { + return false + } +} + /** * Materializes the task's worktree and hands it out with its BASELINE: the sha * of the point it starts from, resolved before the worktree exists. Nothing is @@ -493,8 +540,11 @@ export async function createTaskWorktree( const uncommitted = countUncommitted(cwd) const made = opts.branch !== undefined - ? createWorkOnWorktree(cwd, taskId, shortBranchName(opts.branch)) - : createForkWorktree(cwd, taskId, title, opts.base) + ? createWorkOnWorktree(cwd, taskId, shortBranchName(opts.branch), opts.worktreePath) + : createForkWorktree(cwd, taskId, title, { + base: opts.base, + worktreePath: opts.worktreePath, + }) return { ...made, uncommitted_files: uncommitted, diff --git a/packages/cli/src/tasks-store.test.ts b/packages/cli/src/tasks-store.test.ts index f48766f..ec52c72 100644 --- a/packages/cli/src/tasks-store.test.ts +++ b/packages/cli/src/tasks-store.test.ts @@ -27,10 +27,12 @@ import { listTasks, loadTask, onStoreUnreadable, + peekBrainTicketIdCache, readTaskChecks, readTaskEvents, readTaskJournal, removeTaskDir, + resetBrainTicketIdCache, resetJournalCursors, resetStoreReports, saveTask, @@ -62,6 +64,7 @@ afterEach(() => { setJournalReader(null) resetJournalCursors() resetStoreReports() + resetBrainTicketIdCache() rmSync(cwd, { recursive: true, force: true }) }) @@ -909,4 +912,34 @@ describe('removeTaskDir', () => { expect(removeTaskDir(cwd, 'AAAAAAAAAAAA')).toBe(false) expect(removeTaskDir(cwd, '')).toBe(false) }) + + // Arm/brain integration: `brainTicketIdCache` is WRITE-ONCE for a LIVE + // task, but this is the one place a task's own id stops meaning that task + // at all: an id left in the cache after removal would answer a later, + // unrelated task carrying the same 12-hex id with a ticket from a task + // that no longer exists. + test('evicts the removed task from brainTicketIdCache', () => { + const task = createTask(cwd, { ...input, brainTicket: { id: 'tkt-1', title: 't' } }) + expect(peekBrainTicketIdCache(cwd, task.id)).toBe('tkt-1') + + expect(removeTaskDir(cwd, task.id)).toBe(true) + + expect(peekBrainTicketIdCache(cwd, task.id)).toBeUndefined() + }) + + test.skipIf(RUNNING_AS_ROOT)( + 'a removal that could not complete leaves the cache entry untouched', + () => { + const task = createTask(cwd, { ...input, brainTicket: { id: 'tkt-1', title: 't' } }) + // Same recipe as the other permission-failure tests in this file: no + // write access on the PARENT directory makes rmSync fail without root. + chmodSync(tasksDir(cwd), 0o000) + try { + expect(removeTaskDir(cwd, task.id)).toBe(false) + expect(peekBrainTicketIdCache(cwd, task.id)).toBe('tkt-1') + } finally { + chmodSync(tasksDir(cwd), 0o700) + } + }, + ) }) diff --git a/packages/cli/src/tasks-store.ts b/packages/cli/src/tasks-store.ts index f51477b..8502f0d 100644 --- a/packages/cli/src/tasks-store.ts +++ b/packages/cli/src/tasks-store.ts @@ -22,6 +22,7 @@ import { sanitizeTaskEvent, sanitizeTaskRecord, TASK_REASON_DETAIL_MAX, + type AcceptanceCriterion, type ReasonCode, type TaskChecks, type TaskEvent, @@ -33,11 +34,56 @@ import { type TaskReason, type TaskRecord, } from './contract.js' +import { queueBrainEvent } from './task-brain.js' export function tasksDir(cwd: string): string { return join(cwd, '.codesema', 'tasks') } +/** + * Per-process cache of `record.brain_ticket?.id`, keyed by `${cwd}\0${id}`. + * `brain_ticket` is WRITE-ONCE (see `CreateTaskInput.brainTicket`'s own doc + * comment), so a cache entry never goes stale for the lifetime of its task. + * `appendTaskEvent` is on the hot path of a chatty turn (tens of thousands + * of `tool_use`/`tool_result` lines), and reloading task.json on every + * single append just to answer "does this task have a brain_ticket" would + * cost exactly what the journal cursor cache below exists to avoid. + * `createTask` warms it directly for every task (brain-ticket or not, so + * `null` is cached rather than leaving a gap); a task written by an earlier + * process gets one lazy `loadTask` the first time one of its events is + * appended in THIS process. + */ +const brainTicketIdCache = new Map() + +/** + * A separator that cannot appear in a `cwd` (an absolute path) or a 12-hex + * task id: NUL, built at RUNTIME with `fromCharCode` rather than written as + * a literal escape in a template string, because source-shape.test.ts + * requires every source file to stay byte-for-byte plain text, and a literal + * escape here risks being saved as the raw byte instead (same runtime + * character, but a file `rg` then treats as binary and silently stops + * scanning). + */ +const KEY_SEP = String.fromCharCode(0) + +function brainTicketCacheKey(cwd: string, id: string): string { + return `${cwd}${KEY_SEP}${id}` +} + +/** Test hygiene: drops the cache, i.e. simulates a fresh process. */ +export function resetBrainTicketIdCache(): void { + brainTicketIdCache.clear() +} + +/** + * Test hygiene: the cache's raw entry for one task, with the same + * undefined/null distinction `appendTaskEvent` reads it with: `undefined` + * (never touched) versus `null` (touched, cached as "no ticket"). + */ +export function peekBrainTicketIdCache(cwd: string, id: string): string | null | undefined { + return brainTicketIdCache.get(brainTicketCacheKey(cwd, id)) +} + export function taskDir(cwd: string, id: string): string { return join(tasksDir(cwd), id) } @@ -65,6 +111,12 @@ export function removeTaskDir(cwd: string, id: string): boolean { } try { rmSync(taskDir(cwd, id), { recursive: true, force: true }) + // Arm/brain integration: the ONE eviction `brainTicketIdCache` needs. + // WRITE-ONCE means a live task's entry never goes stale, but a removed + // task's directory is gone for good (this function's own doc comment): + // an entry for it staying in the cache forever would be the one leak in + // an otherwise process-lifetime-bounded cache. + brainTicketIdCache.delete(brainTicketCacheKey(cwd, id)) return true } catch { return false @@ -95,6 +147,20 @@ export type CreateTaskInput = { */ issue?: TaskIssueRef issueSnapshot?: TaskIssueSnapshot + /** + * Arm/brain integration: the brain ticket this task was created from, when + * it was one. WRITE-ONCE, same discipline as `issue`: fixed here, at + * creation, never re-decided by a later turn. + */ + brainTicket?: { id: string; title: string; url?: string } + /** + * The brain's already-validated acceptance criteria, frozen onto the + * record in this SAME write. Never posed as a second write through + * `applyTaskCriteria` (task-criteria.ts): the task's very first turn reads + * `taskCriteria(record)` (task-runner.ts) to build its prompt, and + * criteria landing even one write later would race that read. + */ + criteria?: AcceptanceCriterion[] } /** @@ -139,10 +205,13 @@ export function createTask(cwd: string, input: CreateTaskInput): TaskRecord { ...(input.issue && input.issueSnapshot ? { issue: input.issue, issue_snapshot: input.issueSnapshot } : {}), + ...(input.brainTicket ? { brain_ticket: input.brainTicket } : {}), + ...(input.criteria && input.criteria.length > 0 ? { criteria: input.criteria } : {}), created_at: now, updated_at: now, } saveTask(cwd, record) + brainTicketIdCache.set(brainTicketCacheKey(cwd, id), input.brainTicket?.id ?? null) return record } @@ -552,6 +621,20 @@ export function appendTaskEvent(cwd: string, id: string, input: AppendTaskEventI size: cursor.size + Buffer.byteLength(line, 'utf8'), needsNewline: false, }) + // Arm/brain integration: fire-and-forget, cache-gated (see + // `brainTicketIdCache`'s own doc comment) so a chatty turn's tens of + // thousands of tool_use/tool_result lines never cost an extra task.json + // read each: only the FIRST event of a task this process has not yet + // touched pays for one. + const cacheKey = brainTicketCacheKey(cwd, id) + let ticketId = brainTicketIdCache.get(cacheKey) + if (ticketId === undefined) { + ticketId = loadTask(cwd, id)?.brain_ticket?.id ?? null + brainTicketIdCache.set(cacheKey, ticketId) + } + if (ticketId) { + queueBrainEvent({ cwd, taskId: id, ticketId, event }) + } return event } diff --git a/packages/cli/src/wizard.test.ts b/packages/cli/src/wizard.test.ts index 710fb41..aa9def2 100644 --- a/packages/cli/src/wizard.test.ts +++ b/packages/cli/src/wizard.test.ts @@ -19,16 +19,27 @@ import { afterEach(() => setLanguage(null)) describe('describeConfigEntries', () => { - test('lists agent, language, auto-sync then back, with current values as hints', () => { + test('lists agent, language, auto-sync, brain auto-merge, turn budget then back, with current values as hints', () => { const entries = describeConfigEntries({ agent: 'claude -p --model opus', language: 'fr', syncAutoPush: true, + brainAutoMerge: false, + maxTaskTurns: 60, }) - expect(entries.map((entry) => entry.id)).toEqual(['agent', 'language', 'autoSync', 'back']) + expect(entries.map((entry) => entry.id)).toEqual([ + 'agent', + 'language', + 'autoSync', + 'brainAutoMerge', + 'maxTaskTurns', + 'back', + ]) expect(entries[0]?.hint).toBe('claude -p --model opus') expect(entries[1]?.hint).toBe('Français') expect(entries[2]?.hint).toBe(t('config.autoSyncOn')) + expect(entries[3]?.hint).toBe(t('config.brainAutoMergeOff')) + expect(entries[4]?.hint).toBe('60') }) test('falls back to explicit placeholders when nothing is configured', () => { @@ -36,6 +47,8 @@ describe('describeConfigEntries', () => { expect(entries[0]?.hint).toBe(t('config.agentEntryUnset')) expect(entries[1]?.hint).toBe(t('config.languageAuto')) expect(entries[2]?.hint).toBe(t('config.autoSyncUnset')) + // Unlike the other three, absent resolves to ON (resolveBrainAutoMerge's own doctrine), never an "unset" placeholder. + expect(entries[3]?.hint).toBe(t('config.brainAutoMergeOn')) }) test('a declined auto-sync opt-in shows as off', () => { diff --git a/packages/cli/src/wizard.ts b/packages/cli/src/wizard.ts index b40d3e1..e607c19 100644 --- a/packages/cli/src/wizard.ts +++ b/packages/cli/src/wizard.ts @@ -494,7 +494,8 @@ export async function runOnboarding(cwd: string): Promise { return result.command } -export type ConfigEntryId = 'agent' | 'language' | 'autoSync' | 'back' +export type ConfigEntryId = + 'agent' | 'language' | 'autoSync' | 'brainAutoMerge' | 'maxTaskTurns' | 'back' export type ConfigEntry = { id: ConfigEntryId @@ -519,6 +520,11 @@ function autoSyncLabel(syncAutoPush: boolean | undefined): string { return syncAutoPush ? t('config.autoSyncOn') : t('config.autoSyncOff') } +/** Unlike autoSyncLabel, no "unset" state: resolveBrainAutoMerge (config.ts) treats absent as on. */ +function brainAutoMergeLabel(brainAutoMerge: boolean | undefined): string { + return (brainAutoMerge ?? true) ? t('config.brainAutoMergeOn') : t('config.brainAutoMergeOff') +} + /** Entries of the `codesema config` submenu, current values shown as hints. */ export function describeConfigEntries(current: CodesemaConfig): ConfigEntry[] { return [ @@ -529,6 +535,16 @@ export function describeConfigEntries(current: CodesemaConfig): ConfigEntry[] { }, { id: 'language', label: t('config.languageEntry'), hint: languageLabel(current.language) }, { id: 'autoSync', label: t('config.autoSyncEntry'), hint: autoSyncLabel(current.syncAutoPush) }, + { + id: 'brainAutoMerge', + label: t('config.brainAutoMergeEntry'), + hint: brainAutoMergeLabel(current.brainAutoMerge), + }, + { + id: 'maxTaskTurns', + label: t('config.maxTurnsEntry'), + hint: String(current.maxTaskTurns ?? 30), + }, { id: 'back', label: t('config.back'), hint: '' }, ] } @@ -595,6 +611,59 @@ export async function configCommand(repoRoot: string | null): Promise { continue } + if (picked === 'maxTaskTurns') { + const currentCap = current.maxTaskTurns ?? 30 + const choice = await select({ + title: t('config.maxTurnsQuestion'), + options: [10, 30, 60, 120].map((cap) => ({ + label: String(cap), + hint: cap === 30 ? t('config.maxTurnsDefaultHint') : '', + value: cap, + })), + initialIndex: Math.max(0, [10, 30, 60, 120].indexOf(currentCap)), + summary: false, + }) + if (choice === null) { + continue + } + // GLOBAL-ONLY (config.ts's own doc on maxTaskTurns): the turn budget is + // the machine owner's money, never a cloned repository's consent. + const path = saveGlobalConfig({ ...loadGlobalConfig(), maxTaskTurns: choice }) + console.log('') + console.log(` ${t('config.maxTurnsSaved', { cap: String(choice), path })}`) + console.log('') + continue + } + + if (picked === 'brainAutoMerge') { + const choice = await select<'on' | 'off'>({ + title: t('config.brainAutoMergeQuestion'), + options: [ + { label: t('config.brainAutoMergeOff'), hint: '', value: 'off' }, + { + label: t('config.brainAutoMergeOn'), + hint: t('config.brainAutoMergeOnHint'), + value: 'on', + }, + ], + initialIndex: (current.brainAutoMerge ?? true) ? 1 : 0, + summary: false, + }) + if (choice === null) { + continue + } + // GLOBAL-ONLY, same doctrine as autoSync/mergePolicy (config.ts's own + // doc on brainAutoMerge): a consent to merge without asking is the + // machine owner's to give, not a cloned repository's. + const path = saveGlobalConfig({ ...loadGlobalConfig(), brainAutoMerge: choice === 'on' }) + console.log('') + console.log( + ` ${t('config.brainAutoMergeSaved', { state: brainAutoMergeLabel(choice === 'on'), path })}`, + ) + console.log('') + continue + } + await configureAgent(repoRoot, current) } } diff --git a/packages/cli/src/workspace-lock.test.ts b/packages/cli/src/workspace-lock.test.ts index a6534be..a47e207 100644 --- a/packages/cli/src/workspace-lock.test.ts +++ b/packages/cli/src/workspace-lock.test.ts @@ -3,7 +3,12 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node: import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, test } from 'bun:test' -import { acquireWorkspaceLock, readWorkspaceLock, workspaceLockPath } from './workspace-lock.js' +import { + acquireWorkspaceLock, + isPidAlive, + readWorkspaceLock, + workspaceLockPath, +} from './workspace-lock.js' // The lock is GLOBAL (one workspace process per machine): it lives in // globalConfigDir(), redirected to a fresh tmpdir per test via @@ -98,3 +103,20 @@ describe('acquireWorkspaceLock', () => { expect(readWorkspaceLock()).toBeNull() }) }) + +// Exported for brain-pidfile.ts (D21): brain.pid follows the same "a dead +// pid is never a permanent blocker" doctrine as this lock, and reuses this +// exact check rather than a second copy of it. +describe('isPidAlive', () => { + test('true for our own, very much alive, pid', () => { + expect(isPidAlive(process.pid)).toBe(true) + }) + + test('false for a pid that already ran to completion', () => { + expect(isPidAlive(deadPid())).toBe(false) + }) + + test('true for pid 1 (EPERM: alive, just not ours)', () => { + expect(isPidAlive(1)).toBe(true) + }) +}) diff --git a/packages/cli/src/workspace-lock.ts b/packages/cli/src/workspace-lock.ts index 771ce47..a6f2747 100644 --- a/packages/cli/src/workspace-lock.ts +++ b/packages/cli/src/workspace-lock.ts @@ -36,8 +36,10 @@ export function readWorkspaceLock(): WorkspaceLock | null { /** * Signal 0 probes existence without sending anything. EPERM means the pid is * alive but owned by someone else — still alive, so still a real holder. + * Exported for brain-pidfile.ts's readers (D21): the repo-local brain.pid + * follows the same "a dead pid blocks nothing" doctrine as this lock. */ -function isPidAlive(pid: number): boolean { +export function isPidAlive(pid: number): boolean { try { process.kill(pid, 0) return true diff --git a/packages/cli/src/workspace.ts b/packages/cli/src/workspace.ts index 143bc4a..d5c1b90 100644 --- a/packages/cli/src/workspace.ts +++ b/packages/cli/src/workspace.ts @@ -5,7 +5,15 @@ // is auto-registered and becomes the current project; launched outside any // repo, the workspace opens on the existing registry (possibly empty — add // projects from the UI). The process stays in the foreground: tasks live as -// long as it runs (no detached daemon, decision n°4 of the plan). The first +// long as it runs. D21 introduces one targeted exception to that: +// `codesema brain serve --detach` (brain-commands.ts) backgrounds the brain +// daemon behind a detached child process; every other entry point (bare +// `codesema workspace`, `codesema review`, `codesema brain serve` without the +// flag) stays foreground-only. Whenever CODESEMA_BRAIN_MODE is set, a +// repo-local `/.codesema/brain.pid` (brain-pidfile.ts) records +// {pid, port, started_at} once the port is known, so `brain stop`/`brain +// status`, run later from a different process, can find this daemon; it +// is erased on shutdown, right beside the lock below. The first // Ctrl-C shuts down gracefully (agents SIGTERMed, the turns IN FLIGHT // persisted 'interrupted', worktrees kept — the next boot offers them back, // and one click on Resume in the UI restarts the turn that died; a turn that @@ -18,6 +26,7 @@ // racing this one's registry and task stores. import { knownAgent, type WatchdogBudgets } from './agent.js' +import { removeBrainPidfile, writeBrainPidfile } from './brain-pidfile.js' import { globalConfigPath, hasInvalidPositiveIntKey, @@ -348,8 +357,9 @@ function installShutdownHandlers(deps: { lock: WorkspaceLockHandle probe: IsolationProbe draining: AbortController + cwd: string }): void { - const { manager, stop, lock, probe, draining } = deps + const { manager, stop, lock, probe, draining, cwd } = deps let shuttingDown = false const shutdown = (): void => { if (shuttingDown) { @@ -372,6 +382,11 @@ function installShutdownHandlers(deps: { // Exit inside finally: even a failing drain must not leave a headless // process holding the lock. lock.release() + // Mirrors the write at lock.setPort() below: only ever written and + // removed together, gated on the same env var. + if (process.env.CODESEMA_BRAIN_MODE === '1') { + removeBrainPidfile(cwd) + } process.exit(0) } })() @@ -591,6 +606,9 @@ export async function workspace( throw err } lock.setPort(started.port) + if (process.env.CODESEMA_BRAIN_MODE === '1') { + writeBrainPidfile(repoRoot ?? opts.cwd, process.pid, started.port) + } console.log('') console.log(`codesema — ${t('workspace.intro')}`) @@ -608,7 +626,14 @@ export async function workspace( if (opts.open) { openBrowser(started.url) } - installShutdownHandlers({ manager: taskManager, stop: started.stop, lock, probe, draining }) + installShutdownHandlers({ + manager: taskManager, + stop: started.stop, + lock, + probe, + draining, + cwd: repoRoot ?? opts.cwd, + }) // T1.9 housekeeping: orphaned HOME volumes and the retention purge of old // terminated tasks. Neither gates the workspace being usable (both report // through `notice` — the console today, see task-server.ts) and neither is diff --git a/packages/contract/README.md b/packages/contract/README.md index d77b660..7c6fe2c 100644 --- a/packages/contract/README.md +++ b/packages/contract/README.md @@ -11,7 +11,8 @@ This package is intentionally tiny and dependency-free. It contains no I/O, no n - **Grounding**: `groundReview` checks a sanitized review against the diff it claims to describe — findings on files absent from the diff are dropped, line anchors outside every hunk are removed, duplicates (same file, line and kind) merge keeping the highest severity, and an `approve` verdict left with a critical finding is escalated to `request_changes`. It returns the corrected review plus a `GroundingReport` of what was changed. - **Secret scanner**: `detectDiffSecrets` returns the `SecretMatch`es in a diff (dotenv files, private keys, and AWS/GitHub/Slack/Google/Stripe/OpenAI/Anthropic credentials), so a diff carrying a committed secret is never uploaded. - **Ticket contract**: `TicketBody` (five sections with verbatim English headings) and `AcceptanceCriterion` (`{ id, text }`, the `id` derived from the text so reordering the list renames nothing), with the deterministic lint that gates a ticket about to be launched — `lintTicketBody`, `lintCriteria` — and the tolerant read-back side `sanitizeTicketBody`, `readAcceptanceCriteria`, `extractAcceptanceCriteria`. -- **JSON Schemas**: `reviewRecordSchema` and `ticketBodySchema`, the record and the ticket-body shapes as draft 2020-12 schemas, for validation outside TypeScript. +- **Brain wire types**: the types and sanitizers for the tickets, transitions and events exchanged between the brain (the local SaaS that owns a repository's tickets) and the arm (this CLI, claiming and executing them): `ArmTicketRequest` and `ArmTicket` (a ticket at proposal time and once published, `ArmTicket.status` a closed lifecycle enum), `ArmTransition` (one fact the arm reports back, e.g. `mr_opened`, `merged`, gated on a mandatory `idempotency_key`), `ArmEvent` (one line of the arm's execution journal) and `ArmClaimResult` (the brain's claim/lease response), with their sanitizers `sanitizeArmTicketRequest`, `sanitizeArmTicket`, `sanitizeArmTransition`, `sanitizeArmEvent`, `sanitizeArmClaimResult`. `TaskRecord.brain_ticket` (tasks.ts) carries the write-once pointer back from a task to the brain ticket it was claimed from. +- **JSON Schemas**: `reviewRecordSchema`, `ticketBodySchema`, `recapRecordSchema`, `armTicketSchema` and `armTransitionSchema`, the record, ticket-body, recap, arm-ticket and arm-transition shapes as draft 2020-12 schemas, for validation outside TypeScript. ## Usage @@ -24,6 +25,23 @@ if (!record) throw new Error('unusable review record') The codesema CLI uses these functions to validate agent output before archiving a review; codesema.com uses the very same functions to validate reviews synced from the CLI. One source of truth on both sides of the wire. +## Cross-repo conformance with the brain + +The brain (a separate repo: the local SaaS whose `/api/cli` routes the `Arm*` sanitizers above exist to talk to) publishes its own TypeBox body schemas for those routes. `fixtures/cerveau-schemas/*.schema.json` is a committed, hand-synced copy of them, and `brain.test.ts`'s "cross-repo" tests validate this package's sanitizer output against those copies with [ajv](https://ajv.js.org) (a devDependency, test-only: the published package stays runtime dependency-free), on top of the tests that validate output against this package's own published schemas. + +This exists because of a real incident: a 422 on `run_id` crossed both repos' test suites unnoticed, because the brain required a uuid shape while the arm sends a 12-hex task id, and each repo only ever checked its own copy of the shape. + +**Syncing the fixtures.** Run from a machine with both repos checked out as local siblings: + +``` +bun run --cwd packages/contract sync-brain-schemas -- --check # report drift, exit 1 if stale, writes nothing +bun run --cwd packages/contract sync-brain-schemas # copy the brain's current schemas over the fixtures +``` + +The brain repo path defaults to this repo's sibling directory named `codesema`; override it with a positional argument or the `CODESEMA_BRAIN_REPO` env var. The brain must have already run its own export (`bun backend/scripts/export-cli-schemas.ts` from the brain repo) so its `backend/contracts/cli/*.schema.json` files exist. + +The sync is manual and deliberately NOT wired into CI: the fixtures are allowed to lag behind the brain's actual schemas between syncs, on purpose, so this package's own test suite never depends on the brain repo being present or reachable. Run it after a change to the brain's `/api/cli` body schemas, or whenever the cross-repo tests in `brain.test.ts` look suspicious. + ## Versioning `ReviewRecord.version` identifies the record schema (currently `1`). The package follows semver: a breaking change to the record shape bumps the major version. diff --git a/packages/contract/fixtures/cerveau-schemas/claim.schema.json b/packages/contract/fixtures/cerveau-schemas/claim.schema.json new file mode 100644 index 0000000..ed02f11 --- /dev/null +++ b/packages/contract/fixtures/cerveau-schemas/claim.schema.json @@ -0,0 +1,10 @@ +{ + "type": "object", + "properties": { + "lease_seconds": { + "minimum": 1, + "maximum": 900, + "type": "number" + } + } +} diff --git a/packages/contract/fixtures/cerveau-schemas/events.schema.json b/packages/contract/fixtures/cerveau-schemas/events.schema.json new file mode 100644 index 0000000..add8216 --- /dev/null +++ b/packages/contract/fixtures/cerveau-schemas/events.schema.json @@ -0,0 +1,69 @@ +{ + "type": "object", + "required": ["remote_url", "run_id", "events"], + "properties": { + "remote_url": { + "minLength": 1, + "maxLength": 2000, + "type": "string" + }, + "run_id": { + "minLength": 1, + "maxLength": 64, + "type": "string" + }, + "ticket_id": { + "format": "uuid", + "type": "string" + }, + "events": { + "minItems": 1, + "maxItems": 50, + "type": "array", + "items": { + "type": "object", + "required": ["run_id", "at", "event_type", "label"], + "properties": { + "run_id": { + "minLength": 1, + "type": "string" + }, + "at": { + "type": "string" + }, + "event_type": { + "minLength": 1, + "maxLength": 100, + "type": "string" + }, + "label": { + "minLength": 1, + "maxLength": 500, + "type": "string" + }, + "payload": { + "type": "object", + "patternProperties": { + "^(.*)$": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + } + } + } + } + } +} diff --git a/packages/contract/fixtures/cerveau-schemas/heartbeat.schema.json b/packages/contract/fixtures/cerveau-schemas/heartbeat.schema.json new file mode 100644 index 0000000..b07dbdf --- /dev/null +++ b/packages/contract/fixtures/cerveau-schemas/heartbeat.schema.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "lease_seconds": { + "minimum": 1, + "maximum": 900, + "type": "number" + }, + "local_status": { + "maxLength": 40, + "type": "string" + } + } +} diff --git a/packages/contract/fixtures/cerveau-schemas/transitions.schema.json b/packages/contract/fixtures/cerveau-schemas/transitions.schema.json new file mode 100644 index 0000000..cb1422e --- /dev/null +++ b/packages/contract/fixtures/cerveau-schemas/transitions.schema.json @@ -0,0 +1,83 @@ +{ + "type": "object", + "required": ["type", "idempotency_key", "at"], + "properties": { + "type": { + "anyOf": [ + { + "const": "mr_opened", + "type": "string" + }, + { + "const": "review_result", + "type": "string" + }, + { + "const": "merged", + "type": "string" + }, + { + "const": "failed", + "type": "string" + } + ] + }, + "idempotency_key": { + "minLength": 1, + "maxLength": 200, + "type": "string" + }, + "at": { + "type": "string" + }, + "mr_iid": { + "minLength": 1, + "maxLength": 100, + "type": "string" + }, + "mr_url": { + "minLength": 1, + "maxLength": 2000, + "type": "string" + }, + "branch": { + "minLength": 1, + "maxLength": 300, + "type": "string" + }, + "verdict": { + "anyOf": [ + { + "const": "approve", + "type": "string" + }, + { + "const": "request_changes", + "type": "string" + }, + { + "const": "comment", + "type": "string" + } + ] + }, + "findings_total": { + "minimum": 0, + "type": "number" + }, + "merge_sha": { + "minLength": 1, + "maxLength": 100, + "type": "string" + }, + "error_message": { + "minLength": 1, + "maxLength": 5000, + "type": "string" + }, + "cost_ticks": { + "minimum": 0, + "type": "number" + } + } +} diff --git a/packages/contract/package.json b/packages/contract/package.json index 8144ae3..2114088 100644 --- a/packages/contract/package.json +++ b/packages/contract/package.json @@ -1,6 +1,6 @@ { "name": "@codesema/contract", - "version": "0.6.0", + "version": "0.8.0", "description": "Shared review contract (types + sanitizers) between the codesema CLI and codesema.com.", "license": "MIT", "author": "Hasan TASKIN", @@ -29,13 +29,15 @@ "scripts": { "build": "tsdown", "typecheck": "tsc --noEmit", - "prepublishOnly": "npm run build" + "prepublishOnly": "npm run build", + "sync-brain-schemas": "node scripts/sync-brain-schemas.mjs" }, "engines": { "node": ">=20" }, "devDependencies": { "@types/node": "^26.1.1", + "ajv": "^8.20.0", "tsdown": "^0.22.5", "typescript": "^6.0.3" } diff --git a/packages/contract/scripts/sync-brain-schemas.mjs b/packages/contract/scripts/sync-brain-schemas.mjs new file mode 100644 index 0000000..b769a4e --- /dev/null +++ b/packages/contract/scripts/sync-brain-schemas.mjs @@ -0,0 +1,113 @@ +/** + * Manual sync of the brain's exported `/api/cli` JSON Schemas into this + * package's committed fixtures (D-contrat, asymmetric arbitration). + * + * The brain (backend/scripts/export-cli-schemas.ts, a SEPARATE repo) emits + * its TypeBox body schemas as JSON Schema files. This script copies those + * files into fixtures/cerveau-schemas/ so brain.test.ts can validate this + * package's sanitizer output against the brain's ACTUAL wire contract, not a + * hand-copied guess of it. That guess is exactly the class of bug that + * motivated this: a 422 on `run_id` (uuid on the brain's side, a 12-hex + * arm-generated id on this side) that neither repo's own tests could see, + * because each repo only checked its own copy of the shape. + * + * Deliberately NOT wired into CI and NOT a network fetch: both repos are + * assumed to sit as local sibling checkouts on the machine running this + * script, and the sync is a manual step run after the brain regenerates its + * schemas. The fixtures are therefore allowed to lag behind the brain by + * design: that lag is the cost of keeping this repo's tests independent of + * the brain repo's availability, not an oversight. + * + * Usage (from packages/contract/): + * node scripts/sync-brain-schemas.mjs # copy, report drift + * node scripts/sync-brain-schemas.mjs --check # report only, exit 1 on drift + * node scripts/sync-brain-schemas.mjs /path/to/codesema # explicit brain repo path + * CODESEMA_BRAIN_REPO=/path/to/codesema node scripts/sync-brain-schemas.mjs + * + * Resolution order for the brain repo path: CLI argument, then + * CODESEMA_BRAIN_REPO env var, then a default resolved relative to THIS + * FILE (not the invocation cwd, which would make the default fragile + * depending on where the script is run from): ../../../../codesema, i.e. + * codesema-tools's own sibling directory named `codesema`. + */ + +import { deepStrictEqual } from 'node:assert/strict' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const SCHEMA_NAMES = ['claim', 'heartbeat', 'transitions', 'events'] + +const here = path.dirname(fileURLToPath(import.meta.url)) +const FIXTURES_DIR = path.join(here, '..', 'fixtures', 'cerveau-schemas') +const DEFAULT_BRAIN_REPO = path.join(here, '..', '..', '..', '..', 'codesema') + +function parseArgs(argv) { + const check = argv.includes('--check') + const positional = argv.find((arg) => arg !== '--check') + const brainRepo = positional ?? process.env.CODESEMA_BRAIN_REPO ?? DEFAULT_BRAIN_REPO + return { check, brainRepo: path.resolve(brainRepo) } +} + +function readBrainSchema(brainRepo, name) { + const filePath = path.join(brainRepo, 'backend', 'contracts', 'cli', `${name}.schema.json`) + if (!existsSync(filePath)) { + throw new Error( + `brain schema not found: ${filePath}\n` + + `run its export script first: (cd ${brainRepo} && bun backend/scripts/export-cli-schemas.ts)`, + ) + } + return JSON.parse(readFileSync(filePath, 'utf8')) +} + +function readFixture(name) { + const filePath = path.join(FIXTURES_DIR, `${name}.schema.json`) + return existsSync(filePath) ? JSON.parse(readFileSync(filePath, 'utf8')) : null +} + +function reportDrift(name, current, incoming) { + console.log(`${name}: DRIFT`) + try { + deepStrictEqual(current, incoming) + } catch (error) { + console.log(error.message) + } +} + +function syncOne(name, brainRepo, check) { + const incoming = readBrainSchema(brainRepo, name) + const current = readFixture(name) + try { + deepStrictEqual(current, incoming) + console.log(`${name}: up to date`) + return false + } catch { + reportDrift(name, current, incoming) + if (!check) { + const filePath = path.join(FIXTURES_DIR, `${name}.schema.json`) + writeFileSync(filePath, `${JSON.stringify(incoming, null, 2)}\n`) + console.log(`${name}: written to ${filePath}`) + } + return true + } +} + +function main() { + const { check, brainRepo } = parseArgs(process.argv.slice(2)) + console.log(`brain repo: ${brainRepo}${check ? ' (--check: report only)' : ''}`) + mkdirSync(FIXTURES_DIR, { recursive: true }) + + const drifted = SCHEMA_NAMES.map((name) => syncOne(name, brainRepo, check)).some(Boolean) + + if (check && drifted) { + console.error('\nfixtures are stale: run without --check to update them') + process.exit(1) + } +} + +try { + main() +} catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exit(1) +} diff --git a/packages/contract/src/brain.test.ts b/packages/contract/src/brain.test.ts new file mode 100644 index 0000000..9f8ad2b --- /dev/null +++ b/packages/contract/src/brain.test.ts @@ -0,0 +1,1316 @@ +import { randomBytes, randomUUID } from 'node:crypto' +import Ajv from 'ajv' +import { describe, expect, test } from 'bun:test' +import claimBodySchema from '../fixtures/cerveau-schemas/claim.schema.json' +import eventsBodySchema from '../fixtures/cerveau-schemas/events.schema.json' +import heartbeatBodySchema from '../fixtures/cerveau-schemas/heartbeat.schema.json' +import transitionsBodySchema from '../fixtures/cerveau-schemas/transitions.schema.json' +import { + ARM_BODY_MAX, + ARM_BRANCH_MAX, + ARM_ERROR_MESSAGE_MAX, + ARM_EVENT_TYPE_MAX, + ARM_ID_MAX, + ARM_IDEMPOTENCY_KEY_MAX, + ARM_ISSUE_IID_MAX, + ARM_ISSUE_URL_MAX, + ARM_LABEL_MAX, + ARM_MR_IID_MAX, + ARM_MR_URL_MAX, + ARM_PROMPT_MAX, + ARM_REPO_URL_MAX, + ARM_RUN_ID_MAX, + ARM_STATUS_MAX, + ARM_TIMESTAMP_MAX, + ARM_TITLE_MAX, + armOrderSchema, + armTicketSchema, + armTransitionSchema, + sanitizeArmClaimResult, + sanitizeArmEvent, + sanitizeArmHeartbeatResponse, + sanitizeArmOrder, + sanitizeArmTicket, + sanitizeArmTicketRequest, + sanitizeArmTransition, + type ArmClaimResult, + type ArmEvent, + type ArmHeartbeatResponse, + type ArmIssueRef, + type ArmOrder, + type ArmTicket, + type ArmTicketRequest, + type ArmTransition, +} from './brain.js' +import { TASK_STATUS_VALUES } from './tasks.js' + +// --- Fixtures ---------------------------------------------------------------- + +const validIssueRef: ArmIssueRef = { + iid: '42', + url: 'https://github.com/getCodesema/codesema-cli/issues/42', +} + +const validTicketRequest: ArmTicketRequest = { + id: 'req-1', + repo_remote_url: 'https://github.com/getCodesema/codesema-cli.git', + prompt: 'Add rate limiting to the public API', + status: 'proposed', + source_issue: validIssueRef, + created_at: '2026-08-14T10:00:00.000Z', +} + +const validTicket: ArmTicket = { + id: 'tick-1', + repo_remote_url: 'https://github.com/getCodesema/codesema-cli.git', + title: 'Add rate limiting', + body: 'Add a token bucket limiter to the public API.', + status: 'in_progress', + depends_on: null, + executed_by: 'arm-worker-1', + lease_expires_at: '2026-08-14T11:00:00.000Z', + issue: validIssueRef, + branch: 'codesema/task-add-rate-limiting', + mr_iid: null, + mr_url: null, + created_at: '2026-08-14T10:00:00.000Z', + updated_at: '2026-08-14T10:05:00.000Z', +} + +const minimalTransition: ArmTransition = { + type: 'mr_opened', + idempotency_key: 'tick-1:mr_opened:1', + at: '2026-08-14T10:10:00.000Z', +} + +const fullTransition: ArmTransition = { + type: 'review_result', + idempotency_key: 'tick-1:review_result:1', + at: '2026-08-14T10:20:00.000Z', + mr_iid: '7', + mr_url: 'https://github.com/getCodesema/codesema-cli/pull/7', + branch: 'codesema/task-add-rate-limiting', + verdict: 'approve', + findings_total: 3, + merge_sha: 'a1b2c3d4e5f6a7b8c9d0', + error_message: 'none', + cost_ticks: 42, +} + +const validEvent: ArmEvent = { + run_id: 'run-1', + at: '2026-08-14T10:00:00.000Z', + event_type: 'turn_started', + label: 'Turn 1 started', +} + +const validClaimResult: ArmClaimResult = { + ticket: validTicket, + lease_expires_at: '2026-08-14T11:00:00.000Z', +} + +const shipOrder: ArmOrder = { + action: 'ship', + instruction: null, + issued_at: '2026-08-14T10:00:00.000Z', +} + +const replyOrder: ArmOrder = { + action: 'reply', + instruction: 'Add a test for the empty-branch case before shipping.', + issued_at: '2026-08-14T10:00:00.000Z', +} + +const validHeartbeatResponse: ArmHeartbeatResponse = { + lease_expires_at: '2026-08-14T11:00:00.000Z', + order: replyOrder, +} + +test('published bounds are locked to their literal values', () => { + expect(ARM_ID_MAX).toBe(64) + expect(ARM_REPO_URL_MAX).toBe(500) + expect(ARM_TITLE_MAX).toBe(200) + expect(ARM_BODY_MAX).toBe(20_000) + expect(ARM_PROMPT_MAX).toBe(20_000) + expect(ARM_STATUS_MAX).toBe(100) + expect(ARM_BRANCH_MAX).toBe(200) + expect(ARM_MR_IID_MAX).toBe(64) + expect(ARM_MR_URL_MAX).toBe(2_000) + expect(ARM_ISSUE_IID_MAX).toBe(64) + expect(ARM_ISSUE_URL_MAX).toBe(500) + expect(ARM_TIMESTAMP_MAX).toBe(40) + expect(ARM_IDEMPOTENCY_KEY_MAX).toBe(200) + expect(ARM_ERROR_MESSAGE_MAX).toBe(2_000) + expect(ARM_RUN_ID_MAX).toBe(64) + expect(ARM_EVENT_TYPE_MAX).toBe(100) + expect(ARM_LABEL_MAX).toBe(500) +}) + +// `sanitizeArmIssueRef` is a private helper (same doctrine as tasks.ts's own +// `sanitizeIssueRef`, also unexported): it is covered here only through its +// two callers, `sanitizeArmTicketRequest.source_issue` and +// `sanitizeArmTicket.issue` below, never in isolation. + +// --- sanitizeArmTicketRequest -------------------------------------------------- + +describe('sanitizeArmTicketRequest', () => { + test('a valid request round-trips unchanged', () => { + expect(sanitizeArmTicketRequest(structuredClone(validTicketRequest))).toEqual( + validTicketRequest, + ) + }) + + test('source_issue: null round-trips unchanged', () => { + const withoutIssue = { ...validTicketRequest, source_issue: null } + expect(sanitizeArmTicketRequest(structuredClone(withoutIssue))).toEqual(withoutIssue) + }) + + test('non-object input: null', () => { + expect(sanitizeArmTicketRequest(null)).toBeNull() + expect(sanitizeArmTicketRequest(undefined)).toBeNull() + expect(sanitizeArmTicketRequest('junk')).toBeNull() + expect(sanitizeArmTicketRequest(42)).toBeNull() + expect(sanitizeArmTicketRequest([])).toBeNull() + }) + + test('a missing or blank id: no usable identity, null', () => { + for (const id of [undefined, '', ' ', 42, null]) { + expect(sanitizeArmTicketRequest({ ...validTicketRequest, id })).toBeNull() + } + }) + + test('status is a free-form string: an unrecognized value is kept, never rejected', () => { + const r = sanitizeArmTicketRequest({ ...validTicketRequest, status: 'some-future-status' }) + expect(r?.status).toBe('some-future-status') + }) + + test('an unusable source_issue drops only that field, never the whole request', () => { + const r = sanitizeArmTicketRequest({ ...validTicketRequest, source_issue: { iid: '1' } }) + expect(r).not.toBeNull() + expect(r?.source_issue).toBeNull() + }) + + test('repo_remote_url, prompt and status are truncated, never rejected for length', () => { + const r = sanitizeArmTicketRequest({ + ...validTicketRequest, + repo_remote_url: 'r'.repeat(ARM_REPO_URL_MAX + 50), + prompt: 'p'.repeat(ARM_PROMPT_MAX + 50), + status: 's'.repeat(ARM_STATUS_MAX + 50), + }) + expect(r?.repo_remote_url.length).toBe(ARM_REPO_URL_MAX) + expect(r?.prompt.length).toBe(ARM_PROMPT_MAX) + expect(r?.status.length).toBe(ARM_STATUS_MAX) + }) + + test('missing created_at falls back to a generated stamp', () => { + const at = sanitizeArmTicketRequest({ + ...validTicketRequest, + created_at: undefined, + })?.created_at + expect(typeof at).toBe('string') + expect(at?.length).toBeGreaterThan(0) + }) +}) + +// --- sanitizeArmTicket ---------------------------------------------------------- + +describe('sanitizeArmTicket', () => { + test('a valid ticket round-trips unchanged', () => { + expect(sanitizeArmTicket(structuredClone(validTicket))).toEqual(validTicket) + }) + + test('every nullable field set to null round-trips unchanged', () => { + const allNull: ArmTicket = { + ...validTicket, + depends_on: null, + executed_by: null, + lease_expires_at: null, + issue: null, + branch: null, + mr_iid: null, + mr_url: null, + } + expect(sanitizeArmTicket(structuredClone(allNull))).toEqual(allNull) + }) + + test('non-object input: null', () => { + expect(sanitizeArmTicket(null)).toBeNull() + expect(sanitizeArmTicket(undefined)).toBeNull() + expect(sanitizeArmTicket('junk')).toBeNull() + expect(sanitizeArmTicket(42)).toBeNull() + expect(sanitizeArmTicket([])).toBeNull() + }) + + test('a missing or blank id: no usable identity, null', () => { + for (const id of [undefined, '', ' ', 42, null]) { + expect(sanitizeArmTicket({ ...validTicket, id })).toBeNull() + } + }) + + test('an unrecognized status drops the WHOLE record: never fabricated', () => { + for (const status of ['not-a-status', '', undefined, null, 42]) { + expect(sanitizeArmTicket({ ...validTicket, status })).toBeNull() + } + }) + + test('all valid statuses are kept', () => { + const statuses = [ + 'proposed', + 'rejected', + 'published', + 'in_progress', + 'mr_opened', + 'ready_to_merge', + 'done', + 'failed', + 'already_implemented', + ] as const + for (const status of statuses) { + expect(sanitizeArmTicket({ ...validTicket, status })?.status).toBe(status) + } + }) + + test('title, body and repo_remote_url are truncated, never rejected for length', () => { + const r = sanitizeArmTicket({ + ...validTicket, + title: 't'.repeat(ARM_TITLE_MAX + 50), + body: 'b'.repeat(ARM_BODY_MAX + 50), + repo_remote_url: 'r'.repeat(ARM_REPO_URL_MAX + 50), + }) + expect(r?.title.length).toBe(ARM_TITLE_MAX) + expect(r?.body.length).toBe(ARM_BODY_MAX) + expect(r?.repo_remote_url.length).toBe(ARM_REPO_URL_MAX) + }) + + test('depends_on and executed_by: a non-string or blank value becomes null, not the empty string', () => { + for (const junk of [42, {}, [], '', ' ']) { + const r = sanitizeArmTicket({ ...validTicket, depends_on: junk, executed_by: junk }) + expect(r?.depends_on).toBeNull() + expect(r?.executed_by).toBeNull() + } + }) + + test('an unusable issue ref becomes null, never keeps a half-populated one', () => { + for (const issue of [{ iid: '1' }, { url: validIssueRef.url }, { iid: '', url: '' }]) { + expect(sanitizeArmTicket({ ...validTicket, issue })?.issue).toBeNull() + } + }) + + test('a non-http(s) issue url becomes a null issue, not a half-populated one', () => { + for (const url of ['not a url', 'ftp://example.com/1', 'javascript:alert(1)']) { + const r = sanitizeArmTicket({ ...validTicket, issue: { ...validIssueRef, url } }) + expect(r?.issue).toBeNull() + } + }) + + test('a valid issue ref has its iid and url truncated to their bounds, never rejected for length', () => { + const r = sanitizeArmTicket({ + ...validTicket, + issue: { + iid: 'i'.repeat(ARM_ISSUE_IID_MAX + 50), + url: `https://example.com/${'x'.repeat(ARM_ISSUE_URL_MAX)}`, + }, + }) + expect(r?.issue?.iid.length).toBe(ARM_ISSUE_IID_MAX) + expect(r?.issue?.url.length).toBe(ARM_ISSUE_URL_MAX) + }) + + test('mr_url must be an http(s) URL or it becomes null', () => { + for (const url of ['not a url', 'ftp://example.com/1', 'javascript:alert(1)']) { + expect(sanitizeArmTicket({ ...validTicket, mr_url: url })?.mr_url).toBeNull() + } + const r = sanitizeArmTicket({ + ...validTicket, + mr_url: 'https://github.com/getCodesema/codesema-cli/pull/9', + }) + expect(r?.mr_url).toBe('https://github.com/getCodesema/codesema-cli/pull/9') + }) + + test('missing created_at falls back to a generated stamp, updated_at falls back to created_at', () => { + const r = sanitizeArmTicket({ ...validTicket, created_at: undefined, updated_at: undefined }) + expect(typeof r?.created_at).toBe('string') + expect(r?.created_at.length).toBeGreaterThan(0) + expect(r?.updated_at).toBe(r?.created_at) + }) + + test('created_at and updated_at are bounded, never left unbounded from hostile input', () => { + const long = 'x'.repeat(ARM_TIMESTAMP_MAX + 500) + const r = sanitizeArmTicket({ ...validTicket, created_at: long, updated_at: long }) + expect(r?.created_at.length).toBe(ARM_TIMESTAMP_MAX) + expect(r?.updated_at.length).toBe(ARM_TIMESTAMP_MAX) + }) + + // A value with no LEADING or TRAILING whitespace of its own can still gain + // one once truncated, when the cut lands right after an INTERNAL run of + // whitespace: `'a'.repeat(ARM_ID_MAX - 1) + ' ' + 'b'.repeat(50)` trims to + // itself unchanged, but slicing at ARM_ID_MAX keeps exactly the leading + // run plus that one space. Every field below is NON_BLANK in + // armTicketSchema, so a truncated trailing space is not merely untidy, it + // is a value the sanitizer's own published schema would refuse. + test('truncation never leaves a trailing space on a NON_BLANK field, even when the cut lands on an internal run of whitespace', () => { + const idWithInternalSpace = `${'a'.repeat(ARM_ID_MAX - 1)} ${'b'.repeat(50)}` + const timestampWithInternalSpace = `${'2'.repeat(ARM_TIMESTAMP_MAX - 1)} ${'x'.repeat(50)}` + const r = sanitizeArmTicket({ + ...validTicket, + id: idWithInternalSpace, + depends_on: idWithInternalSpace, + created_at: timestampWithInternalSpace, + updated_at: timestampWithInternalSpace, + }) + expect(r?.id).toBe(r?.id.trim()) + expect(r?.depends_on).toBe(r?.depends_on?.trim()) + expect(r?.created_at).toBe(r?.created_at.trim()) + expect(r?.updated_at).toBe(r?.updated_at.trim()) + expect(ticketSchemaErrors(r)).toEqual([]) + }) +}) + +// --- sanitizeArmTransition ------------------------------------------------------- + +describe('sanitizeArmTransition', () => { + test('a minimal transition (required fields only) round-trips unchanged', () => { + expect(sanitizeArmTransition(structuredClone(minimalTransition))).toEqual(minimalTransition) + }) + + test('a full transition round-trips unchanged', () => { + expect(sanitizeArmTransition(structuredClone(fullTransition))).toEqual(fullTransition) + }) + + test('non-object input: null', () => { + expect(sanitizeArmTransition(null)).toBeNull() + expect(sanitizeArmTransition(undefined)).toBeNull() + expect(sanitizeArmTransition('junk')).toBeNull() + expect(sanitizeArmTransition(42)).toBeNull() + expect(sanitizeArmTransition([])).toBeNull() + }) + + test('an unrecognized type drops the WHOLE transition: never fabricated', () => { + for (const type of ['not-a-type', '', undefined, null, 42]) { + expect(sanitizeArmTransition({ ...minimalTransition, type })).toBeNull() + } + }) + + test('all valid types are kept', () => { + const types = ['mr_opened', 'review_result', 'merged', 'failed'] as const + for (const type of types) { + expect(sanitizeArmTransition({ ...minimalTransition, type })?.type).toBe(type) + } + }) + + test('a missing or blank idempotency_key drops the WHOLE transition', () => { + for (const idempotency_key of [undefined, '', ' ', 42, null]) { + expect(sanitizeArmTransition({ ...minimalTransition, idempotency_key })).toBeNull() + } + }) + + test('idempotency_key is truncated, never rejected for length', () => { + const r = sanitizeArmTransition({ + ...minimalTransition, + idempotency_key: 'k'.repeat(ARM_IDEMPOTENCY_KEY_MAX + 50), + }) + expect(r?.idempotency_key.length).toBe(ARM_IDEMPOTENCY_KEY_MAX) + }) + + test('missing at falls back to a generated stamp', () => { + const at = sanitizeArmTransition({ ...minimalTransition, at: undefined })?.at + expect(typeof at).toBe('string') + expect(at?.length).toBeGreaterThan(0) + }) + + test('an unrecognized verdict is omitted, not fabricated into a valid one', () => { + const r = sanitizeArmTransition({ ...minimalTransition, verdict: 'not-a-verdict' }) + expect(r && 'verdict' in r).toBe(false) + }) + + test('all valid verdicts are kept', () => { + const verdicts = ['approve', 'request_changes', 'comment'] as const + for (const verdict of verdicts) { + expect(sanitizeArmTransition({ ...minimalTransition, verdict })?.verdict).toBe(verdict) + } + }) + + test('findings_total: a negative, float or non-numeric value is omitted, never coerced to 0', () => { + for (const findings_total of [-1, 1.5, 'three', Number.NaN, -0]) { + const r = sanitizeArmTransition({ ...minimalTransition, findings_total }) + expect(r && 'findings_total' in r).toBe(false) + } + expect(sanitizeArmTransition({ ...minimalTransition, findings_total: 0 })?.findings_total).toBe( + 0, + ) + }) + + test('cost_ticks: same predicate as findings_total, -0 refused explicitly', () => { + for (const cost_ticks of [-1, 1.5, 'lots', Number.NaN, -0]) { + const r = sanitizeArmTransition({ ...minimalTransition, cost_ticks }) + expect(r && 'cost_ticks' in r).toBe(false) + } + expect(sanitizeArmTransition({ ...minimalTransition, cost_ticks: 0 })?.cost_ticks).toBe(0) + }) + + test('merge_sha: whitelisted as hex, half a sha is omitted rather than kept truncated', () => { + for (const merge_sha of ['not-hex', 'a1b2c3', 'g1b2c3d', '']) { + const r = sanitizeArmTransition({ ...minimalTransition, merge_sha }) + expect(r && 'merge_sha' in r).toBe(false) + } + const r = sanitizeArmTransition({ ...minimalTransition, merge_sha: 'A1B2C3D' }) + expect(r?.merge_sha).toBe('a1b2c3d') + }) + + test('mr_url must be an http(s) URL or the field is omitted', () => { + for (const mr_url of ['not a url', 'ftp://example.com/1']) { + const r = sanitizeArmTransition({ ...minimalTransition, mr_url }) + expect(r && 'mr_url' in r).toBe(false) + } + }) + + test('mr_iid, branch and error_message are truncated, never rejected for length', () => { + const r = sanitizeArmTransition({ + ...minimalTransition, + mr_iid: 'i'.repeat(ARM_MR_IID_MAX + 50), + branch: 'b'.repeat(ARM_BRANCH_MAX + 50), + error_message: 'e'.repeat(ARM_ERROR_MESSAGE_MAX + 50), + }) + expect(r?.mr_iid?.length).toBe(ARM_MR_IID_MAX) + expect(r?.branch?.length).toBe(ARM_BRANCH_MAX) + expect(r?.error_message?.length).toBe(ARM_ERROR_MESSAGE_MAX) + }) + + // Same regression as sanitizeArmTicket's own: a cut landing right after an + // INTERNAL run of whitespace must not leave a trailing space behind on a + // NON_BLANK field. + test('truncation never leaves a trailing space on a NON_BLANK field, even when the cut lands on an internal run of whitespace', () => { + const keyWithInternalSpace = `${'k'.repeat(ARM_IDEMPOTENCY_KEY_MAX - 1)} ${'x'.repeat(50)}` + const r = sanitizeArmTransition({ + ...minimalTransition, + idempotency_key: keyWithInternalSpace, + error_message: keyWithInternalSpace, + }) + expect(r?.idempotency_key).toBe(r?.idempotency_key.trim()) + expect(r?.error_message).toBe(r?.error_message?.trim()) + expect(transitionSchemaErrors(r)).toEqual([]) + }) +}) + +// --- sanitizeArmEvent ------------------------------------------------------------- + +describe('sanitizeArmEvent', () => { + test('a valid event without a payload round-trips unchanged', () => { + expect(sanitizeArmEvent(structuredClone(validEvent))).toEqual(validEvent) + }) + + test('a valid event with a payload round-trips unchanged', () => { + const withPayload = { ...validEvent, payload: { turn: 1, ok: true, name: 'x' } } + expect(sanitizeArmEvent(structuredClone(withPayload))).toEqual(withPayload) + }) + + test('non-object input: null', () => { + expect(sanitizeArmEvent(null)).toBeNull() + expect(sanitizeArmEvent(undefined)).toBeNull() + expect(sanitizeArmEvent('junk')).toBeNull() + expect(sanitizeArmEvent(42)).toBeNull() + expect(sanitizeArmEvent([])).toBeNull() + }) + + test('a missing or blank run_id: no usable identity, null', () => { + for (const run_id of [undefined, '', ' ', 42, null]) { + expect(sanitizeArmEvent({ ...validEvent, run_id })).toBeNull() + } + }) + + test('an empty or non-object payload is omitted rather than kept as {}', () => { + for (const payload of [{}, null, 'nope', 42, []]) { + const r = sanitizeArmEvent({ ...validEvent, payload }) + expect(r && 'payload' in r).toBe(false) + } + }) + + test('payload: nested values dropped, strings truncated, keys capped', () => { + const wide: Record = {} + for (let i = 0; i < 30; i++) { + wide[`k${i}`] = i + } + const r = sanitizeArmEvent({ + ...validEvent, + payload: { + // `nested`/`list` are dropped WITHOUT counting against the cap (they + // never reach `out`), so they must come before `wide` in insertion + // order for this test to actually exercise that rule rather than + // merely observing `wide` alone exhaust the cap first. + nested: { a: 1 }, + list: [1, 2, 3], + long: 'x'.repeat(3_000), + ...wide, + }, + }) + expect(r?.payload && 'nested' in r.payload).toBe(false) + expect(r?.payload && 'list' in r.payload).toBe(false) + const long = r?.payload?.long + expect(typeof long).toBe('string') + expect((long as string).length).toBeLessThanOrEqual(2_000) + expect(Object.keys(r?.payload ?? {}).length).toBeLessThanOrEqual(16) + }) + + test('event_type and label are truncated, never rejected for length', () => { + const r = sanitizeArmEvent({ + ...validEvent, + event_type: 't'.repeat(ARM_EVENT_TYPE_MAX + 50), + label: 'l'.repeat(ARM_LABEL_MAX + 50), + }) + expect(r?.event_type.length).toBe(ARM_EVENT_TYPE_MAX) + expect(r?.label.length).toBe(ARM_LABEL_MAX) + }) + + test('run_id is truncated, never rejected for length', () => { + const r = sanitizeArmEvent({ ...validEvent, run_id: 'r'.repeat(ARM_RUN_ID_MAX + 50) }) + expect(r?.run_id.length).toBe(ARM_RUN_ID_MAX) + }) + + test('missing at falls back to a generated stamp', () => { + const at = sanitizeArmEvent({ ...validEvent, at: undefined })?.at + expect(typeof at).toBe('string') + expect(at?.length).toBeGreaterThan(0) + }) +}) + +// --- sanitizeArmClaimResult ------------------------------------------------------- + +describe('sanitizeArmClaimResult', () => { + test('a valid claim result round-trips unchanged', () => { + expect(sanitizeArmClaimResult(structuredClone(validClaimResult))).toEqual(validClaimResult) + }) + + test('non-object input: null', () => { + expect(sanitizeArmClaimResult(null)).toBeNull() + expect(sanitizeArmClaimResult(undefined)).toBeNull() + expect(sanitizeArmClaimResult('junk')).toBeNull() + expect(sanitizeArmClaimResult(42)).toBeNull() + expect(sanitizeArmClaimResult([])).toBeNull() + }) + + test('an unusable ticket drops the WHOLE claim result', () => { + expect(sanitizeArmClaimResult({ ...validClaimResult, ticket: { id: 'x' } })).toBeNull() + expect(sanitizeArmClaimResult({ ...validClaimResult, ticket: null })).toBeNull() + }) + + test('missing lease_expires_at falls back to the lease carried by the ticket', () => { + const r = sanitizeArmClaimResult({ ...validClaimResult, lease_expires_at: undefined }) + expect(r?.lease_expires_at).toBe(validClaimResult.ticket.lease_expires_at ?? undefined) + }) + + test('a claim with no lease anywhere is refused rather than expiring at once', () => { + const ticket = { ...validClaimResult.ticket, lease_expires_at: null } + expect(sanitizeArmClaimResult({ ticket, lease_expires_at: undefined })).toBeNull() + expect(sanitizeArmClaimResult({ ticket, lease_expires_at: 42 })).toBeNull() + }) +}) + +// --- sanitizeArmOrder / sanitizeArmHeartbeatResponse (D19) -------------------- + +describe('sanitizeArmOrder', () => { + test('a valid ship order round-trips unchanged', () => { + expect(sanitizeArmOrder(structuredClone(shipOrder))).toEqual(shipOrder) + }) + + test('a valid reply order keeps its instruction', () => { + expect(sanitizeArmOrder(structuredClone(replyOrder))).toEqual(replyOrder) + }) + + test('non-object input: null', () => { + expect(sanitizeArmOrder(null)).toBeNull() + expect(sanitizeArmOrder(undefined)).toBeNull() + expect(sanitizeArmOrder('junk')).toBeNull() + expect(sanitizeArmOrder(42)).toBeNull() + expect(sanitizeArmOrder([])).toBeNull() + }) + + test('an unrecognized action is refused, never fabricated into a known one', () => { + expect(sanitizeArmOrder({ ...shipOrder, action: 'delete' })).toBeNull() + expect(sanitizeArmOrder({ instruction: null, issued_at: shipOrder.issued_at })).toBeNull() + }) + + test('instruction: absent or blank degrades to null, never to an empty string', () => { + for (const instruction of [undefined, null, '', ' ']) { + expect(sanitizeArmOrder({ ...shipOrder, instruction })?.instruction).toBeNull() + } + }) + + test('instruction is truncated to ARM_PROMPT_MAX, never rejected for length', () => { + const long = 'x'.repeat(ARM_PROMPT_MAX + 5_000) + const order = sanitizeArmOrder({ ...replyOrder, instruction: long }) + expect(order?.instruction?.length).toBe(ARM_PROMPT_MAX) + }) + + test('a missing or unusable issued_at falls back to now, same as ArmTransition.at', () => { + const order = sanitizeArmOrder({ action: 'ship', instruction: null }) + expect(typeof order?.issued_at).toBe('string') + expect(order?.issued_at.length).toBeGreaterThan(0) + }) +}) + +describe('sanitizeArmHeartbeatResponse', () => { + test('a valid response carrying an order round-trips unchanged', () => { + expect(sanitizeArmHeartbeatResponse(structuredClone(validHeartbeatResponse))).toEqual( + validHeartbeatResponse, + ) + }) + + test('a valid response with no order keeps order null', () => { + const response: ArmHeartbeatResponse = { + lease_expires_at: '2026-08-14T11:00:00.000Z', + order: null, + } + expect(sanitizeArmHeartbeatResponse(structuredClone(response))).toEqual(response) + }) + + test('order absent (never sent) reads the same as order null', () => { + const response = sanitizeArmHeartbeatResponse({ lease_expires_at: '2026-08-14T11:00:00.000Z' }) + expect(response?.order).toBeNull() + }) + + test('non-object input: null', () => { + expect(sanitizeArmHeartbeatResponse(null)).toBeNull() + expect(sanitizeArmHeartbeatResponse(undefined)).toBeNull() + expect(sanitizeArmHeartbeatResponse('junk')).toBeNull() + expect(sanitizeArmHeartbeatResponse(42)).toBeNull() + }) + + test('a missing or unusable lease_expires_at refuses the WHOLE response, never falls back to now', () => { + expect(sanitizeArmHeartbeatResponse({ order: null })).toBeNull() + expect(sanitizeArmHeartbeatResponse({ lease_expires_at: '', order: null })).toBeNull() + expect(sanitizeArmHeartbeatResponse({ lease_expires_at: 42, order: null })).toBeNull() + }) + + test('a malformed order degrades to null rather than sinking the whole response', () => { + const response = sanitizeArmHeartbeatResponse({ + lease_expires_at: '2026-08-14T11:00:00.000Z', + order: { action: 'delete' }, + }) + expect(response).toEqual({ lease_expires_at: '2026-08-14T11:00:00.000Z', order: null }) + }) +}) + +// --- The published schemas ---------------------------------------------------- + +describe('armTicketSchema / armTransitionSchema', () => { + test('both declare a draft 2020-12 schema with their own id', () => { + expect(armTicketSchema.$schema).toBe('https://json-schema.org/draft/2020-12/schema') + expect(armTicketSchema.$id).toBe('https://codesema.com/schemas/arm-ticket.json') + expect(armTransitionSchema.$schema).toBe('https://json-schema.org/draft/2020-12/schema') + expect(armTransitionSchema.$id).toBe('https://codesema.com/schemas/arm-transition.json') + expect(armOrderSchema.$schema).toBe('https://json-schema.org/draft/2020-12/schema') + expect(armOrderSchema.$id).toBe('https://codesema.com/schemas/arm-order.json') + }) + + test('every $ref in armTicketSchema resolves to a defined $def', () => { + const refs: string[] = [] + const walk = (node: unknown): void => { + if (!node || typeof node !== 'object') { + return + } + for (const [key, value] of Object.entries(node)) { + if (key === '$ref' && typeof value === 'string') { + refs.push(value) + } else { + walk(value) + } + } + } + walk(armTicketSchema) + const defs = new Set(Object.keys(armTicketSchema.$defs)) + expect(refs.length).toBeGreaterThan(0) + for (const ref of refs) { + expect(defs.has(ref.replace('#/$defs/', ''))).toBe(true) + } + }) + + test('every required key exists in properties, on all three schemas', () => { + for (const schema of [armTicketSchema, armTransitionSchema, armOrderSchema]) { + const props = new Set(Object.keys(schema.properties)) + for (const key of schema.required) { + expect(props.has(key)).toBe(true) + } + } + for (const def of Object.values(armTicketSchema.$defs)) { + const d = def as { required?: readonly string[]; properties?: Record } + const defProps = new Set(Object.keys(d.properties ?? {})) + for (const key of d.required ?? []) { + expect(defProps.has(key)).toBe(true) + } + } + }) +}) + +// --- Cross tests: sanitizer output validates against the published schema, and +// the schema is not looser than what the sanitizer actually accepts. Deliberately +// local and tiny, like recap.test.ts's and index.test.ts's own validators: this +// proves the SCHEMA against the SANITIZER, not a library's leniency, and is the +// one automatic lock against a field added to one but not the other. + +type Schema = Record + +function deref(schema: Schema, root: Schema): Schema { + const ref = schema.$ref + if (typeof ref !== 'string') { + return schema + } + const defs = (root.$defs ?? {}) as Record + const key = ref.replace('#/$defs/', '') + const target = Object.hasOwn(defs, key) ? (defs[key] ?? {}) : {} + const { $ref: _drop, ...siblings } = schema + return { ...target, ...siblings } +} + +function typeMatches(node: unknown, type: string): boolean { + switch (type) { + case 'null': + return node === null + case 'string': + return typeof node === 'string' + case 'boolean': + return typeof node === 'boolean' + case 'integer': + return typeof node === 'number' && Number.isInteger(node) + case 'array': + return Array.isArray(node) + case 'object': + return !!node && typeof node === 'object' && !Array.isArray(node) + default: + return false + } +} + +function validateString(node: string, s: Schema, path: string): string[] { + const errors: string[] = [] + const length = [...node].length + if (typeof s.maxLength === 'number' && length > s.maxLength) { + errors.push(`${path}: maxLength`) + } + if (typeof s.minLength === 'number' && length < s.minLength) { + errors.push(`${path}: minLength`) + } + if (typeof s.pattern === 'string' && !new RegExp(s.pattern, 'u').test(node)) { + errors.push(`${path}: pattern`) + } + return errors +} + +function validateNumber(node: number, s: Schema, path: string): string[] { + const errors: string[] = [] + if (typeof s.minimum === 'number' && node < s.minimum) { + errors.push(`${path}: minimum`) + } + if (typeof s.maximum === 'number' && node > s.maximum) { + errors.push(`${path}: maximum`) + } + return errors +} + +function validateObject(node: object, s: Schema, root: Schema, path: string): string[] { + const errors: string[] = [] + const record = node as Record + const properties = (s.properties ?? {}) as Record + for (const key of (s.required ?? []) as string[]) { + if (!Object.hasOwn(record, key)) { + errors.push(`${path}.${key}: required`) + } + } + for (const [key, value] of Object.entries(record)) { + const child = Object.hasOwn(properties, key) ? properties[key] : undefined + if (!child) { + if (s.additionalProperties === false) { + errors.push(`${path}.${key}: additionalProperties`) + } + continue + } + errors.push(...validate(value, child, root, `${path}.${key}`)) + } + return errors +} + +function validate(node: unknown, schema: Schema, root: Schema, path = '$'): string[] { + const s = deref(schema, root) + const types = + typeof s.type === 'string' ? [s.type] : Array.isArray(s.type) ? (s.type as string[]) : [] + const hasAssertion = 'const' in s || 'enum' in s || types.length > 0 || Array.isArray(s.anyOf) + if (!hasAssertion) { + // A schema node that asserts NOTHING accepts every value that reaches it. + // Fail loudly here instead of quietly proving nothing. + throw new Error(`arm schema validator: '${path}' asserts nothing`) + } + const errors: string[] = [] + if ('const' in s && node !== s.const) { + errors.push(`${path}: const`) + } + if (Array.isArray(s.enum) && !s.enum.includes(node)) { + errors.push(`${path}: enum`) + } + if (Array.isArray(s.anyOf)) { + const branches = s.anyOf as Schema[] + if (!branches.some((branch) => validate(node, branch, root, path).length === 0)) { + errors.push(`${path}: anyOf`) + } + } + if (types.length === 0) { + return errors + } + if (!types.some((type) => typeMatches(node, type))) { + errors.push(`${path}: type`) + return errors + } + if (typeof node === 'string') { + errors.push(...validateString(node, s, path)) + } else if (typeof node === 'number') { + errors.push(...validateNumber(node, s, path)) + } else if (Array.isArray(node)) { + const items = s.items as Schema | undefined + if (items) { + node.forEach((item, i) => errors.push(...validate(item, items, root, `${path}[${i}]`))) + } + } else if (node && typeof node === 'object') { + errors.push(...validateObject(node, s, root, path)) + } + return errors +} + +const ticketSchemaErrors = (value: unknown): string[] => + validate(value, armTicketSchema as unknown as Schema, armTicketSchema as unknown as Schema) + +const transitionSchemaErrors = (value: unknown): string[] => + validate( + value, + armTransitionSchema as unknown as Schema, + armTransitionSchema as unknown as Schema, + ) + +const orderSchemaErrors = (value: unknown): string[] => + validate(value, armOrderSchema as unknown as Schema, armOrderSchema as unknown as Schema) + +describe('cross test: sanitizeArmTicket output validates against armTicketSchema', () => { + test('the full nominal ticket validates', () => { + expect(ticketSchemaErrors(sanitizeArmTicket(structuredClone(validTicket)))).toEqual([]) + }) + + test('a ticket with every nullable field null validates', () => { + const allNull = { + ...validTicket, + depends_on: null, + executed_by: null, + lease_expires_at: null, + issue: null, + branch: null, + mr_iid: null, + mr_url: null, + } + expect(ticketSchemaErrors(sanitizeArmTicket(allNull))).toEqual([]) + }) + + test('hostile input, once sanitized, still validates', () => { + const hostile = sanitizeArmTicket({ + id: 'tick-1', + repo_remote_url: { nested: true }, + title: 42, + body: [], + status: 'in_progress', + depends_on: 42, + executed_by: {}, + lease_expires_at: [], + issue: 'not-a-ref', + branch: 42, + mr_iid: {}, + mr_url: 'javascript:alert(1)', + created_at: 'x'.repeat(500), + updated_at: 'y'.repeat(500), + }) + expect(ticketSchemaErrors(hostile)).toEqual([]) + }) + + test('every valid status produces a validating ticket', () => { + const statuses = [ + 'proposed', + 'rejected', + 'published', + 'in_progress', + 'mr_opened', + 'ready_to_merge', + 'done', + 'failed', + 'already_implemented', + ] as const + for (const status of statuses) { + expect(ticketSchemaErrors(sanitizeArmTicket({ ...validTicket, status }))).toEqual([]) + } + }) +}) + +describe('reverse cross test: armTicketSchema is not looser than sanitizeArmTicket accepts', () => { + const BASE = { + id: 'tick-1', + repo_remote_url: '', + title: '', + body: '', + status: 'proposed', + depends_on: null, + executed_by: null, + lease_expires_at: null, + issue: null, + branch: null, + mr_iid: null, + mr_url: null, + created_at: '2026-08-14T10:00:00.000Z', + updated_at: '2026-08-14T10:00:00.000Z', + } + + test('an empty id is schema-invalid: sanitizeArmTicket refuses the WHOLE record for it', () => { + expect(ticketSchemaErrors({ ...BASE, id: '' })).not.toEqual([]) + }) + + test('an unknown status is schema-invalid: sanitizeArmTicket never emits one', () => { + expect(ticketSchemaErrors({ ...BASE, status: 'not-a-status' })).not.toEqual([]) + }) + + test('an empty-string depends_on is schema-invalid: sanitizeArmTicket only ever emits null or a non-blank string', () => { + expect(ticketSchemaErrors({ ...BASE, depends_on: '' })).not.toEqual([]) + }) + + test('a missing key is schema-invalid: every key of ArmTicket is always present', () => { + const { branch: _drop, ...missingBranch } = BASE + expect(ticketSchemaErrors(missingBranch)).not.toEqual([]) + }) + + test('an extra unknown key is schema-invalid: additionalProperties is false', () => { + expect(ticketSchemaErrors({ ...BASE, extra: 'nope' })).not.toEqual([]) + }) +}) + +describe('cross test: sanitizeArmTransition output validates against armTransitionSchema', () => { + test('the minimal transition validates', () => { + expect( + transitionSchemaErrors(sanitizeArmTransition(structuredClone(minimalTransition))), + ).toEqual([]) + }) + + test('the full transition validates', () => { + expect(transitionSchemaErrors(sanitizeArmTransition(structuredClone(fullTransition)))).toEqual( + [], + ) + }) + + test('hostile input, once sanitized, still validates', () => { + const hostile = sanitizeArmTransition({ + type: 'merged', + idempotency_key: 'k'.repeat(500), + at: 'x'.repeat(500), + mr_iid: 42, + mr_url: 'javascript:alert(1)', + branch: {}, + verdict: 'not-a-verdict', + findings_total: -5, + merge_sha: 'NOT-HEX', + error_message: [], + cost_ticks: 'lots', + }) + expect(transitionSchemaErrors(hostile)).toEqual([]) + }) + + test('every valid type produces a validating transition', () => { + const types = ['mr_opened', 'review_result', 'merged', 'failed'] as const + for (const type of types) { + expect(transitionSchemaErrors(sanitizeArmTransition({ ...minimalTransition, type }))).toEqual( + [], + ) + } + }) +}) + +describe('reverse cross test: armTransitionSchema is not looser than sanitizeArmTransition accepts', () => { + const BASE = { + type: 'mr_opened', + idempotency_key: 'k1', + at: '2026-08-14T10:00:00.000Z', + } + + test('an empty idempotency_key is schema-invalid: sanitizeArmTransition refuses the WHOLE record for it', () => { + expect(transitionSchemaErrors({ ...BASE, idempotency_key: '' })).not.toEqual([]) + }) + + test('an unknown type is schema-invalid: sanitizeArmTransition never emits one', () => { + expect(transitionSchemaErrors({ ...BASE, type: 'not-a-type' })).not.toEqual([]) + }) + + test('an empty optional string is schema-invalid: sanitizeArmTransition omits rather than blanks it', () => { + expect(transitionSchemaErrors({ ...BASE, branch: '' })).not.toEqual([]) + }) + + test('a non-hex merge_sha is schema-invalid: sanitizeArmTransition whitelists, never truncates it', () => { + expect(transitionSchemaErrors({ ...BASE, merge_sha: 'not-hex' })).not.toEqual([]) + }) + + test('an extra unknown key is schema-invalid: additionalProperties is false', () => { + expect(transitionSchemaErrors({ ...BASE, extra: 'nope' })).not.toEqual([]) + }) +}) + +describe('cross test: sanitizeArmOrder output validates against armOrderSchema', () => { + test('a ship order (instruction null) validates', () => { + expect(orderSchemaErrors(sanitizeArmOrder(structuredClone(shipOrder)))).toEqual([]) + }) + + test('a reply order (instruction set) validates', () => { + expect(orderSchemaErrors(sanitizeArmOrder(structuredClone(replyOrder)))).toEqual([]) + }) + + test('every valid action produces a validating order', () => { + for (const action of ['ship', 'reply', 'abandon'] as const) { + expect(orderSchemaErrors(sanitizeArmOrder({ ...shipOrder, action }))).toEqual([]) + } + }) + + test('hostile input, once sanitized, still validates', () => { + const hostile = sanitizeArmOrder({ + action: 'reply', + instruction: 42, + issued_at: 'x'.repeat(500), + }) + expect(orderSchemaErrors(hostile)).toEqual([]) + }) +}) + +describe('reverse cross test: armOrderSchema is not looser than sanitizeArmOrder accepts', () => { + const BASE = { + action: 'ship', + instruction: null, + issued_at: '2026-08-14T10:00:00.000Z', + } + + test('an unknown action is schema-invalid: sanitizeArmOrder never emits one', () => { + expect(orderSchemaErrors({ ...BASE, action: 'not-an-action' })).not.toEqual([]) + }) + + test('an empty-string instruction is schema-invalid: sanitizeArmOrder only ever emits null or a non-blank string', () => { + expect(orderSchemaErrors({ ...BASE, instruction: '' })).not.toEqual([]) + }) + + test('a missing key is schema-invalid: every key of ArmOrder is always present', () => { + const { instruction: _drop, ...missingInstruction } = BASE + expect(orderSchemaErrors(missingInstruction)).not.toEqual([]) + }) + + test('an extra unknown key is schema-invalid: additionalProperties is false', () => { + expect(orderSchemaErrors({ ...BASE, extra: 'nope' })).not.toEqual([]) + }) +}) + +// --- Cross-repo: brain schemas (D-contrat, asymmetric arbitration) -------- +// +// The brain (a separate repo) exports its own TypeBox body schemas for the +// four `/api/cli` routes this package's sanitizers exist to talk to, as +// plain JSON Schema files synced here BY HAND (never over the network, never +// wired into CI: scripts/sync-brain-schemas.mjs, see this package's README) +// into fixtures/cerveau-schemas/. Everything above this point proves a +// sanitizer's output against THIS package's own published schema; the tests +// below prove the same output against the BRAIN's independently-maintained +// schema, the only thing that can catch the two repos' copies of a shape +// drifting apart. +// +// Concrete motivation: a 422 that crossed both repos' own test suites, +// because the brain once required `run_id` to look like a uuid while the arm +// generates one as a 12-hex string (`randomBytes(6).toString('hex')`, +// packages/cli/src/tasks-store.ts, reused below as `armRunId`). Each repo's +// tests only ever checked its own copy of the shape, so neither caught the +// mismatch. The tests below require the brain's copied schema to accept +// exactly that shape, so a regression on either side fails here instead of +// on a production heartbeat. + +const ajv = new Ajv({ allErrors: true }) +ajv.addFormat('uuid', /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i) + +const validateClaimBody = ajv.compile(claimBodySchema) +const validateHeartbeatBody = ajv.compile(heartbeatBodySchema) +const validateTransitionBody = ajv.compile(transitionsBodySchema) +const validateEventsBody = ajv.compile(eventsBodySchema) + +/** packages/cli/src/tasks-store.ts: a task id, reused on the wire as `run_id`. */ +const armRunId = randomBytes(6).toString('hex') + +function sanitizedValidEvent(overrides: Partial = {}): ArmEvent { + const event = sanitizeArmEvent({ ...validEvent, ...overrides }) + if (!event) { + throw new Error('test fixture: expected this override to still sanitize to a valid event') + } + return event +} + +/** + * The envelope POSTed to `/api/cli/tickets/:id/events`: `remote_url`/`run_id`/ + * `ticket_id` alongside the batch, on top of each item's own `run_id` + * (cli-tickets.ts's `cliEventsBodySchema`, brain repo). Not a type this + * package publishes (only the per-item `ArmEvent` is), so built here + * directly. Override fields are typed `unknown`, not `ArmEvent`-shaped: + * several tests below deliberately pass a shape sanitizeArmEvent would never + * produce, to prove the BRAIN schema also refuses it. + */ +function eventEnvelope( + overrides: { + remote_url?: unknown + run_id?: unknown + ticket_id?: unknown + events?: unknown + } = {}, +): Record { + return { + remote_url: 'https://github.com/getCodesema/codesema-cli.git', + run_id: armRunId, + events: [sanitizedValidEvent()], + ...overrides, + } +} + +describe('cross-repo: claim and heartbeat request bodies (no dedicated sanitizer in this package)', () => { + // Mirrors the brain's own MAX_LEASE_SECONDS and 1-second floor + // (backend/src/modules/tickets/adapters/ticket-claim.ts), duplicated here + // rather than imported: same asymmetric arbitration as the rest of this + // block, this package has no dependency on the brain repo. + const BRAIN_LEASE_SECONDS_MIN = 1 + const BRAIN_LEASE_SECONDS_MAX = 900 + + test('an empty claim body (lease_seconds omitted) validates', () => { + expect(validateClaimBody({})).toBe(true) + }) + + test('a lease_seconds within the brain-documented bail range validates', () => { + for (const lease_seconds of [BRAIN_LEASE_SECONDS_MIN, 180, BRAIN_LEASE_SECONDS_MAX]) { + expect(validateClaimBody({ lease_seconds })).toBe(true) + } + }) + + test('a lease_seconds outside the brain-documented bail range is refused', () => { + for (const lease_seconds of [BRAIN_LEASE_SECONDS_MIN - 1, BRAIN_LEASE_SECONDS_MAX + 1, 0, -1]) { + expect(validateClaimBody({ lease_seconds })).toBe(false) + } + }) + + test('every TASK_STATUS_VALUES entry is an acceptable heartbeat local_status', () => { + for (const local_status of TASK_STATUS_VALUES) { + expect(validateHeartbeatBody({ lease_seconds: 180, local_status })).toBe(true) + } + }) + + test('heartbeat with local_status omitted (a CLI predating D19) still validates', () => { + expect(validateHeartbeatBody({ lease_seconds: 180 })).toBe(true) + }) + + test('a local_status at the brain bound (40) validates, one over it is refused', () => { + expect(validateHeartbeatBody({ local_status: 'x'.repeat(40) })).toBe(true) + expect(validateHeartbeatBody({ local_status: 'x'.repeat(41) })).toBe(false) + }) +}) + +describe('cross-repo: sanitizeArmTransition output validates against the brain schema', () => { + test('the minimal transition validates', () => { + expect(validateTransitionBody(sanitizeArmTransition(structuredClone(minimalTransition)))).toBe( + true, + ) + }) + + test('the full transition validates', () => { + expect(validateTransitionBody(sanitizeArmTransition(structuredClone(fullTransition)))).toBe( + true, + ) + }) + + test('every valid transition type produces a brain-schema-valid transition', () => { + const types = ['mr_opened', 'review_result', 'merged', 'failed'] as const + for (const type of types) { + expect(validateTransitionBody(sanitizeArmTransition({ ...minimalTransition, type }))).toBe( + true, + ) + } + }) +}) + +describe('reverse cross-repo: the brain schema is not looser than sanitizeArmTransition on the fields it constrains', () => { + test('a blank idempotency_key: refused by sanitizeArmTransition (null) and by the brain schema', () => { + expect(sanitizeArmTransition({ ...minimalTransition, idempotency_key: '' })).toBeNull() + expect(validateTransitionBody({ ...minimalTransition, idempotency_key: '' })).toBe(false) + }) + + test('an unrecognized type: refused by sanitizeArmTransition (null) and by the brain schema', () => { + expect(sanitizeArmTransition({ ...minimalTransition, type: 'not-a-type' })).toBeNull() + expect(validateTransitionBody({ ...minimalTransition, type: 'not-a-type' })).toBe(false) + }) + + test('a missing idempotency_key: refused by sanitizeArmTransition (null) and by the brain schema', () => { + const { idempotency_key: _drop, ...withoutKey } = minimalTransition + expect(sanitizeArmTransition(withoutKey)).toBeNull() + expect(validateTransitionBody(withoutKey)).toBe(false) + }) +}) + +describe('cross-repo: closes the run_id class (12-hex arm task id vs the brain schema)', () => { + test('a 12-hex run_id, the shape the arm actually generates, validates at the envelope level', () => { + expect(validateEventsBody(eventEnvelope())).toBe(true) + }) + + test('the same 12-hex run_id, inside a sanitized event item, also validates', () => { + const item = sanitizedValidEvent({ run_id: armRunId }) + expect(validateEventsBody(eventEnvelope({ events: [item] }))).toBe(true) + }) + + // Mirrors sanitizeArmEvent's own "a missing or blank run_id: no usable + // identity, null" table (above), at the ENVELOPE level, where the original + // incident actually lived: an empty run_id has length 0, so the brain's + // own `minLength: 1` catches it exactly like sanitizeArmEvent does. + test('an empty run_id: refused by sanitizeArmEvent (null) and by the brain schema (minLength 1)', () => { + expect(sanitizeArmEvent({ ...validEvent, run_id: '' })).toBeNull() + expect(validateEventsBody(eventEnvelope({ run_id: '' }))).toBe(false) + }) + + // A DIFFERENT case from the empty string above, and deliberately NOT + // asserted as refused: `minLength` counts raw characters, it does not trim + // first, so a whitespace-only run_id (length 3) satisfies the brain's + // `minLength: 1` even though sanitizeArmEvent refuses it as blank. Brain + // schema looser than this package's sanitizer is fine per the D-contrat + // arbitration (only the reverse, brain stricter than what the arm actually + // produces, is the bug class this suite exists to catch), and + // sanitizeArmEvent never lets a whitespace-only run_id reach the wire in + // the first place, so this asymmetry has no real payload to bite on. + test('a whitespace-only run_id: refused by sanitizeArmEvent (null), but the brain schema does not trim, so it accepts the raw shape', () => { + expect(sanitizeArmEvent({ ...validEvent, run_id: ' ' })).toBeNull() + expect(validateEventsBody(eventEnvelope({ run_id: ' ' }))).toBe(true) + }) + + test('a run_id over the brain envelope bound (64) is refused', () => { + expect(validateEventsBody(eventEnvelope({ run_id: 'a'.repeat(65) }))).toBe(false) + }) + + // Documents a real asymmetry rather than asserting a failure for it: the + // brain's ITEM-level run_id has no maxLength, unlike its own envelope-level + // run_id (64) or this package's own ARM_RUN_ID_MAX (64) truncation. A + // brain schema looser than this package's sanitizer is fine per the + // D-contrat arbitration; only the reverse (brain stricter than what the arm + // actually produces) is the bug class this suite exists to catch. + test('the brain schema is looser than this package at the item level: an over-length item run_id still validates there', () => { + const item = sanitizedValidEvent({ run_id: armRunId }) + const overLength = { ...item, run_id: 'x'.repeat(200) } + expect(validateEventsBody(eventEnvelope({ events: [overLength] }))).toBe(true) + }) +}) + +describe('cross-repo: ticket_id, when present, must be a real uuid (brain-side format check)', () => { + test('a real uuid ticket_id validates', () => { + expect(validateEventsBody(eventEnvelope({ ticket_id: randomUUID() }))).toBe(true) + }) + + // ArmTicket.id (sanitizeArmTicket, above) only requires a non-blank string + // up to ARM_ID_MAX: it does NOT enforce a uuid shape. `ticket_id` here is + // exactly that id, echoed back by packages/cli's task-brain.ts + // (`ticketId = record.brain_ticket?.id`) when it reports events for a + // claimed ticket. Verified structurally today, since every ticket id the + // brain currently hands out IS a uuid, but nothing in this package's own + // sanitizer enforces that, so this is the same class of risk as the + // run_id incident, one hop over: noted here rather than silently assumed + // away. + test('a non-uuid ticket_id is refused by the brain schema', () => { + expect(validateEventsBody(eventEnvelope({ ticket_id: 'not-a-uuid' }))).toBe(false) + }) +}) diff --git a/packages/contract/src/brain.ts b/packages/contract/src/brain.ts new file mode 100644 index 0000000..774240d --- /dev/null +++ b/packages/contract/src/brain.ts @@ -0,0 +1,681 @@ +// Brain wire contract: types and sanitizers for the tickets, transitions and +// events exchanged between the brain (the local SaaS that owns tickets) and +// the arm (this CLI, claiming and executing them). Same doctrine as the rest +// of the contract: whitelist and truncate, never throw. +// +// These shapes mirror the BRAIN's own wire format, not this package's usual +// style: several fields below are REQUIRED keys carrying an explicit `null` +// rather than an optional key that is simply omitted (TaskRecord's own +// convention, tasks.ts). That mirrors nullable columns in the brain's own +// store, which always sends the key. The sanitizers below preserve that +// shape rather than converting it to "absent means unknown". + +import { + TASK_EVENT_DATA_KEY_MAX, + TASK_EVENT_DATA_KEYS_MAX, + TASK_EVENT_DATA_STRING_MAX, + type TaskEventData, +} from './tasks.js' +import { NON_BLANK } from './ticket.js' + +/** + * A forge issue the brain resolved a ticket from, or attached to one. + * + * `iid` is a STRING here, unlike `TaskIssueRef.iid` (tasks.ts, a decimal + * integer): the brain names issues however its own forge client returns + * them, and this contract must not assume every source it may grow to + * support hands back a number. Gated as a pair, same doctrine as tasks.ts's + * `sanitizeIssueRef`: a reference missing either half cannot be resolved by + * anything downstream, so a half-populated one is worse than none. + */ +export type ArmIssueRef = { + iid: string + url: string +} + +/** + * A ticket proposal as the brain first raises it, before it is published. + * + * `status` is a plain STRING, not `ArmTicketStatus`: the proposal lifecycle + * is the brain's own vocabulary, and this contract has no business rejecting + * a value it does not yet recognize there. Only `ArmTicket.status`, the + * lifecycle the arm actually acts on, is a closed enum below. + */ +export type ArmTicketRequest = { + id: string + repo_remote_url: string + prompt: string + status: string + source_issue: ArmIssueRef | null + created_at: string +} + +/** + * A ticket's place in the lifecycle the arm acts on: claiming, executing, + * opening a merge request, reporting back. Unlike `ArmTicketRequest.status`, + * an unrecognized value here is never fabricated into a plausible-looking + * one (contrast `TaskStatus`'s own `'failed'` fallback, tasks.ts): a ticket + * this build cannot place in its own lifecycle is not safe to act on. + */ +export type ArmTicketStatus = + | 'proposed' + | 'rejected' + | 'published' + | 'in_progress' + | 'mr_opened' + | 'ready_to_merge' + | 'done' + | 'failed' + | 'already_implemented' + +/** + * A ticket the brain owns and the arm may claim and execute. + * + * `depends_on`, `executed_by`, `lease_expires_at`, `issue`, `branch`, + * `mr_iid` and `mr_url` are all REQUIRED keys of type `string | null` (see + * this module's own doc comment for why). `sanitizeArmTicket` always emits + * every one of them, never omits one for being unset. + */ +export type ArmTicket = { + id: string + repo_remote_url: string + title: string + body: string + status: ArmTicketStatus + depends_on: string | null + executed_by: string | null + lease_expires_at: string | null + issue: ArmIssueRef | null + branch: string | null + mr_iid: string | null + mr_url: string | null + created_at: string + updated_at: string +} + +/** What kind of fact an `ArmTransition` reports back to the brain about one ticket. */ +export type ArmTransitionType = 'mr_opened' | 'review_result' | 'merged' | 'failed' + +/** + * One fact the arm reports back to the brain about a ticket it executed. + * + * `idempotency_key` is MANDATORY, unlike every other field below: the + * brain's report endpoint uses it to tell a retried report from a second, + * real transition apart. A transition this sanitizer cannot name one for is + * not a degraded transition, it is unsafe to apply, so `sanitizeArmTransition` + * refuses the whole record rather than keeping the rest of it. + */ +export type ArmTransition = { + type: ArmTransitionType + idempotency_key: string + at: string + mr_iid?: string + mr_url?: string + branch?: string + /** + * Same literal union as `Verdict` (index.ts), restated rather than + * imported: index.ts itself re-exports this module (`export * from + * './brain.js'`), so importing `Verdict` from index.ts here would cycle + * straight back through it. TypeScript compares union types structurally, + * so this stays interchangeable with `Verdict` for every caller. + */ + verdict?: 'approve' | 'request_changes' | 'comment' + findings_total?: number + merge_sha?: string + error_message?: string + cost_ticks?: number +} + +/** One line of the arm's own execution journal for a ticket run, reported to the brain. */ +export type ArmEvent = { + run_id: string + at: string + event_type: string + label: string + payload?: TaskEventData +} + +/** What claiming a ticket (the brain's lease endpoint) hands back to the arm. */ +export type ArmClaimResult = { + ticket: ArmTicket + lease_expires_at: string +} + +/** What a human decided, from the dashboard, about a ticket the arm reported waiting on (D19). */ +export type ArmOrderAction = 'ship' | 'reply' | 'abandon' + +/** + * The decision itself, as the brain's heartbeat response hands it back to the + * arm: what to do, and the instruction to carry out when that is `'reply'`. + * `instruction` and `issued_at` are REQUIRED keys, same convention as + * `ArmTicket` above (this module's own doc comment) rather than tasks.ts's + * usual "absent means unset": this mirrors an order that, once issued, always + * carries all three facts together. + */ +export type ArmOrder = { + action: ArmOrderAction + /** The human's own words, for `'reply'`. `null` for `'ship'`/`'abandon'`. */ + instruction: string | null + issued_at: string +} + +/** + * What the brain's heartbeat route hands back to the arm (D19): the lease + * extension every heartbeat already grants, plus the order a human decided + * from the dashboard while this ticket was waiting on one. `null` on every + * ordinary tick nothing is waiting on. + */ +export type ArmHeartbeatResponse = { + lease_expires_at: string + order: ArmOrder | null +} + +export const ARM_ID_MAX = 64 +export const ARM_REPO_URL_MAX = 500 +export const ARM_TITLE_MAX = 200 +export const ARM_BODY_MAX = 20_000 +export const ARM_PROMPT_MAX = 20_000 +export const ARM_STATUS_MAX = 100 +export const ARM_BRANCH_MAX = 200 +export const ARM_MR_IID_MAX = 64 +export const ARM_MR_URL_MAX = 2_000 +export const ARM_ISSUE_IID_MAX = 64 +export const ARM_ISSUE_URL_MAX = 500 +/** Bound for an ISO-8601 instant read back from the wire: same figure as tasks.ts's TASK_TIMESTAMP_MAX. */ +export const ARM_TIMESTAMP_MAX = 40 +export const ARM_IDEMPOTENCY_KEY_MAX = 200 +export const ARM_ERROR_MESSAGE_MAX = 2_000 +export const ARM_RUN_ID_MAX = 64 +export const ARM_EVENT_TYPE_MAX = 100 +export const ARM_LABEL_MAX = 500 + +const ARM_TICKET_STATUSES: ReadonlySet = new Set([ + 'proposed', + 'rejected', + 'published', + 'in_progress', + 'mr_opened', + 'ready_to_merge', + 'done', + 'failed', + 'already_implemented', +]) + +const ARM_TRANSITION_TYPES: ReadonlySet = new Set([ + 'mr_opened', + 'review_result', + 'merged', + 'failed', +]) + +type ArmVerdict = 'approve' | 'request_changes' | 'comment' +const ARM_VERDICTS: ReadonlySet = new Set(['approve', 'request_changes', 'comment']) + +const ARM_ORDER_ACTIONS: ReadonlySet = new Set(['ship', 'reply', 'abandon']) + +/** A git object name: hex only, from an abbreviated 7 up to a sha256 repo's 64. Whitelisted, not merely bounded. */ +const ARM_SHA_PATTERN = '^[0-9a-f]{7,64}$' +const ARM_SHA_RE = new RegExp(ARM_SHA_PATTERN) + +/** + * Trim, cut, trim AGAIN (same recipe as recap.ts's own `str`): slicing a + * trimmed string at an arbitrary `max` can still land right after an + * INTERNAL run of whitespace, leaving a truncated value that ends (or, + * symmetrically, could start) with a blank the first trim never saw. Every + * NON_BLANK-patterned field this module publishes a schema for goes through + * this helper, so the second trim is load-bearing for the forward cross + * test, not cosmetic. + */ +const str = (v: unknown, max: number): string => + typeof v === 'string' ? v.trim().slice(0, max).trim() : '' + +const nullableStr = (v: unknown, max: number): string | null => { + const s = str(v, max) + return s ? s : null +} + +/** + * `at`/`created_at`/`updated_at` doctrine: an ISO instant, bounded, falling + * back to `fallback` when unusable. Bounded, unlike tasks.ts's own + * `isoOrNow` (which never truncates): this module publishes JSON Schemas for + * some of the shapes that use it, and the forward cross test in + * brain.test.ts requires every string this sanitizer can produce to already + * satisfy the bound the matching schema declares. Built on `str`, not a + * separate trim+slice, so it inherits the same trim-cut-trim guarantee: a + * value that degrades to whitespace-only after truncation falls back to + * `fallback` rather than returning a blank string the NON_BLANK-patterned + * schemas below would reject. + */ +const isoOr = (v: unknown, fallback: string, max: number = ARM_TIMESTAMP_MAX): string => { + const s = str(v, max) + return s ? s : fallback +} + +const isoOrNow = (v: unknown, max: number = ARM_TIMESTAMP_MAX): string => + isoOr(v, new Date().toISOString(), max) + +/** + * Non-negative safe integer, `-0` refused explicitly: same predicate as + * tasks.ts's own `optionalCostTicks`, duplicated here rather than imported + * (that helper is private to tasks.ts, and this module otherwise has no + * reason to widen that file's public surface). Used for both `cost_ticks` + * and `findings_total`: absence means UNKNOWN, never `0`. + */ +const optionalNonNegativeInt = (v: unknown): number | null => + Number.isSafeInteger(v) && (v as number) >= 0 && !Object.is(v, -0) ? (v as number) : null + +/** An issue URL is whatever `new URL()` accepts as http(s), same rule as tasks.ts's own `isHttpUrl`. */ +function isHttpUrl(value: string): boolean { + try { + const parsed = new URL(value) + return parsed.protocol === 'http:' || parsed.protocol === 'https:' + } catch { + return false + } +} + +function sanitizeArmSha(raw: unknown): string | undefined { + if (typeof raw !== 'string') { + return undefined + } + const sha = raw.trim().toLowerCase() + return ARM_SHA_RE.test(sha) ? sha : undefined +} + +/** + * Whitelist and gate together, same doctrine as tasks.ts's `sanitizeIssueRef`: + * `iid` and `url` are only meaningful as a pair, so either one being unusable + * drops the whole reference rather than keeping half of it. + */ +function sanitizeArmIssueRef(raw: unknown): ArmIssueRef | null { + if (!raw || typeof raw !== 'object') { + return null + } + const r = raw as Record + const iid = str(r.iid, ARM_ISSUE_IID_MAX) + const url = str(r.url, ARM_ISSUE_URL_MAX) + if (!iid || !url || !isHttpUrl(url)) { + return null + } + return { iid, url } +} + +/** + * Revalidates a ticket proposal read off the brain's wire. `id` is the one + * identity-bearing field, same role `id` plays for `sanitizeTaskRecord` + * (tasks.ts): without it the object cannot be told apart from any other, so + * the whole proposal is unusable. Every other field degrades independently. + */ +export function sanitizeArmTicketRequest(raw: unknown): ArmTicketRequest | null { + if (!raw || typeof raw !== 'object') { + return null + } + const r = raw as Record + const id = str(r.id, ARM_ID_MAX) + if (!id) { + return null + } + return { + id, + repo_remote_url: str(r.repo_remote_url, ARM_REPO_URL_MAX), + prompt: str(r.prompt, ARM_PROMPT_MAX), + status: str(r.status, ARM_STATUS_MAX), + source_issue: sanitizeArmIssueRef(r.source_issue), + created_at: isoOrNow(r.created_at), + } +} + +/** + * Revalidates an `ArmTicket` read off the brain's wire. Two fields gate the + * whole record: `id` (identity) and `status`. An unrecognized status is + * never fabricated into a plausible one (contrast `TaskStatus`'s own + * `'failed'` fallback, tasks.ts): a ticket this build cannot place in its + * own lifecycle is not safe to claim or execute, so the whole record is + * refused instead of half-trusted. + */ +export function sanitizeArmTicket(raw: unknown): ArmTicket | null { + if (!raw || typeof raw !== 'object') { + return null + } + const r = raw as Record + const id = str(r.id, ARM_ID_MAX) + const status = ARM_TICKET_STATUSES.has(r.status as ArmTicketStatus) + ? (r.status as ArmTicketStatus) + : null + if (!id || !status) { + return null + } + // Whitelisted, not merely bounded: an `mr_url` nobody's browser could open + // is worse than none, same reasoning as `isHttpUrl` everywhere else here. + const mrUrlCandidate = nullableStr(r.mr_url, ARM_MR_URL_MAX) + const created_at = isoOrNow(r.created_at) + return { + id, + repo_remote_url: str(r.repo_remote_url, ARM_REPO_URL_MAX), + title: str(r.title, ARM_TITLE_MAX), + body: str(r.body, ARM_BODY_MAX), + status, + depends_on: nullableStr(r.depends_on, ARM_ID_MAX), + executed_by: nullableStr(r.executed_by, ARM_ID_MAX), + lease_expires_at: nullableStr(r.lease_expires_at, ARM_TIMESTAMP_MAX), + issue: sanitizeArmIssueRef(r.issue), + branch: nullableStr(r.branch, ARM_BRANCH_MAX), + mr_iid: nullableStr(r.mr_iid, ARM_MR_IID_MAX), + mr_url: mrUrlCandidate && isHttpUrl(mrUrlCandidate) ? mrUrlCandidate : null, + created_at, + updated_at: isoOr(r.updated_at, created_at), + } +} + +/** + * Revalidates an `ArmTransition` before it is sent to, or read back from, + * the brain's report endpoint. Two fields gate the whole record: `type` + * (same never-fabricate rule as `ArmTicket.status`) and `idempotency_key`, + * mandatory per this type's own doc comment. Every other field is optional + * and degrades to absence, never to an invented placeholder. + */ +export function sanitizeArmTransition(raw: unknown): ArmTransition | null { + if (!raw || typeof raw !== 'object') { + return null + } + const r = raw as Record + const type = ARM_TRANSITION_TYPES.has(r.type as ArmTransitionType) + ? (r.type as ArmTransitionType) + : null + const idempotency_key = str(r.idempotency_key, ARM_IDEMPOTENCY_KEY_MAX) + if (!type || !idempotency_key) { + return null + } + const mrIid = str(r.mr_iid, ARM_MR_IID_MAX) + const mrUrl = str(r.mr_url, ARM_MR_URL_MAX) + const branch = str(r.branch, ARM_BRANCH_MAX) + const verdict = ARM_VERDICTS.has(r.verdict as ArmVerdict) ? (r.verdict as ArmVerdict) : null + const findingsTotal = optionalNonNegativeInt(r.findings_total) + const mergeSha = sanitizeArmSha(r.merge_sha) + const errorMessage = str(r.error_message, ARM_ERROR_MESSAGE_MAX) + const costTicks = optionalNonNegativeInt(r.cost_ticks) + return { + type, + idempotency_key, + at: isoOrNow(r.at), + ...(mrIid ? { mr_iid: mrIid } : {}), + ...(mrUrl && isHttpUrl(mrUrl) ? { mr_url: mrUrl } : {}), + ...(branch ? { branch } : {}), + ...(verdict ? { verdict } : {}), + ...(findingsTotal !== null ? { findings_total: findingsTotal } : {}), + ...(mergeSha ? { merge_sha: mergeSha } : {}), + ...(errorMessage ? { error_message: errorMessage } : {}), + ...(costTicks !== null ? { cost_ticks: costTicks } : {}), + } +} + +/** + * Same flat-and-bounded doctrine as tasks.ts's own `sanitizeTaskEventData` + * (private to that module, so reimplemented here rather than imported), but + * reusing that module's own published bound constants so the two stay + * numerically identical without this module owning the numbers twice. + */ +function sanitizeArmEventPayload(raw: unknown): TaskEventData { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return {} + } + const out: TaskEventData = {} + let kept = 0 + for (const [key, value] of Object.entries(raw)) { + if (kept >= TASK_EVENT_DATA_KEYS_MAX) { + break + } + const k = key.slice(0, TASK_EVENT_DATA_KEY_MAX) + if (!k) { + continue + } + if (typeof value === 'string') { + out[k] = value.slice(0, TASK_EVENT_DATA_STRING_MAX) + } else if (typeof value === 'number' && Number.isFinite(value)) { + out[k] = value + } else if (typeof value === 'boolean' || value === null) { + out[k] = value + } else { + continue + } + kept++ + } + return out +} + +/** + * Revalidates an `ArmEvent` before it is reported to the brain. Gated on + * `run_id`: an event this reader cannot place under a run is unusable, same + * role `TaskEvent.seq` plays in `sanitizeTaskEvent` (tasks.ts). + */ +export function sanitizeArmEvent(raw: unknown): ArmEvent | null { + if (!raw || typeof raw !== 'object') { + return null + } + const r = raw as Record + const run_id = str(r.run_id, ARM_RUN_ID_MAX) + if (!run_id) { + return null + } + const payload = sanitizeArmEventPayload(r.payload) + return { + run_id, + at: isoOrNow(r.at), + event_type: str(r.event_type, ARM_EVENT_TYPE_MAX), + label: str(r.label, ARM_LABEL_MAX), + ...(Object.keys(payload).length > 0 ? { payload } : {}), + } +} + +/** + * Revalidates the brain's claim/lease response. Gated on `ticket`: a claim + * whose ticket cannot itself be trusted (see `sanitizeArmTicket`) grants + * nothing usable, so the whole result is refused rather than handing back a + * lease over an unreadable ticket. The lease falls back to the ticket's own + * `lease_expires_at`, never to "now": a lease that expires at the instant it + * was granted would make the arm drop a ticket it just validly claimed. + */ +export function sanitizeArmClaimResult(raw: unknown): ArmClaimResult | null { + if (!raw || typeof raw !== 'object') { + return null + } + const r = raw as Record + const ticket = sanitizeArmTicket(r.ticket) + if (!ticket) { + return null + } + const leaseExpiresAt = str(r.lease_expires_at, ARM_TIMESTAMP_MAX) || ticket.lease_expires_at + if (!leaseExpiresAt) { + return null + } + return { + ticket, + lease_expires_at: leaseExpiresAt, + } +} + +/** + * Revalidates an `ArmOrder` read off the brain's heartbeat response. Gated on + * `action`: same never-fabricate rule as `ArmTicket.status` and + * `ArmTransition.type`, an order outside this closed set is not safe to + * dispatch, so the whole order is refused rather than guessed at. + * `instruction` degrades to `null`, never to an empty string, so a `'reply'` + * that arrives with no usable instruction stays tellable from one that + * legitimately carries none. + */ +export function sanitizeArmOrder(raw: unknown): ArmOrder | null { + if (!raw || typeof raw !== 'object') { + return null + } + const r = raw as Record + const action = ARM_ORDER_ACTIONS.has(r.action as ArmOrderAction) + ? (r.action as ArmOrderAction) + : null + if (!action) { + return null + } + return { + action, + instruction: nullableStr(r.instruction, ARM_PROMPT_MAX), + issued_at: isoOrNow(r.issued_at), + } +} + +/** + * Revalidates the brain's heartbeat response. Gated on `lease_expires_at`, + * same reasoning as `sanitizeArmClaimResult`: a heartbeat that cannot say + * when the lease it just renewed expires is not a usable response, and unlike + * `created_at`/`updated_at` elsewhere in this module, a lease deadline must + * never fall back to "now": that would either claim an already-expired lease + * or fabricate an extension the brain never granted. A malformed `order` + * degrades to `null` rather than sinking the whole response, the same + * never-fabricate rule `sanitizeArmOrder` itself applies. + */ +export function sanitizeArmHeartbeatResponse(raw: unknown): ArmHeartbeatResponse | null { + if (!raw || typeof raw !== 'object') { + return null + } + const r = raw as Record + const leaseExpiresAt = str(r.lease_expires_at, ARM_TIMESTAMP_MAX) + if (!leaseExpiresAt) { + return null + } + return { + lease_expires_at: leaseExpiresAt, + order: sanitizeArmOrder(r.order), + } +} + +/** + * JSON Schema (draft 2020-12) for an `ArmTicket`, on the same pattern as + * `reviewRecordSchema` (index.ts), `ticketBodySchema` and `recapRecordSchema` + * (this package): every `sanitizeArmTicket` output validates here (forward), + * and the schema refuses every shape the sanitizer refuses (backward, tested + * in brain.test.ts) so the two cannot silently drift apart. + */ +export const armTicketSchema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'https://codesema.com/schemas/arm-ticket.json', + title: 'Codesema arm ticket', + type: 'object', + additionalProperties: false, + required: [ + 'id', + 'repo_remote_url', + 'title', + 'body', + 'status', + 'depends_on', + 'executed_by', + 'lease_expires_at', + 'issue', + 'branch', + 'mr_iid', + 'mr_url', + 'created_at', + 'updated_at', + ], + properties: { + id: { type: 'string', maxLength: ARM_ID_MAX, pattern: NON_BLANK }, + repo_remote_url: { type: 'string', maxLength: ARM_REPO_URL_MAX }, + title: { type: 'string', maxLength: ARM_TITLE_MAX }, + body: { type: 'string', maxLength: ARM_BODY_MAX }, + status: { + enum: [ + 'proposed', + 'rejected', + 'published', + 'in_progress', + 'mr_opened', + 'ready_to_merge', + 'done', + 'failed', + 'already_implemented', + ], + }, + depends_on: { + anyOf: [{ type: 'null' }, { type: 'string', maxLength: ARM_ID_MAX, pattern: NON_BLANK }], + }, + executed_by: { + anyOf: [{ type: 'null' }, { type: 'string', maxLength: ARM_ID_MAX, pattern: NON_BLANK }], + }, + lease_expires_at: { + anyOf: [ + { type: 'null' }, + { type: 'string', maxLength: ARM_TIMESTAMP_MAX, pattern: NON_BLANK }, + ], + }, + issue: { anyOf: [{ type: 'null' }, { $ref: '#/$defs/issueRef' }] }, + branch: { + anyOf: [{ type: 'null' }, { type: 'string', maxLength: ARM_BRANCH_MAX, pattern: NON_BLANK }], + }, + mr_iid: { + anyOf: [{ type: 'null' }, { type: 'string', maxLength: ARM_MR_IID_MAX, pattern: NON_BLANK }], + }, + mr_url: { + anyOf: [{ type: 'null' }, { type: 'string', maxLength: ARM_MR_URL_MAX, pattern: NON_BLANK }], + }, + created_at: { type: 'string', maxLength: ARM_TIMESTAMP_MAX, pattern: NON_BLANK }, + updated_at: { type: 'string', maxLength: ARM_TIMESTAMP_MAX, pattern: NON_BLANK }, + }, + $defs: { + issueRef: { + type: 'object', + additionalProperties: false, + required: ['iid', 'url'], + properties: { + iid: { type: 'string', maxLength: ARM_ISSUE_IID_MAX, pattern: NON_BLANK }, + url: { type: 'string', maxLength: ARM_ISSUE_URL_MAX, pattern: NON_BLANK }, + }, + }, + }, +} as const + +/** + * JSON Schema (draft 2020-12) for an `ArmTransition`, same pattern and same + * forward/backward guarantee as `armTicketSchema` above. Every field beyond + * `type`/`idempotency_key`/`at` is optional here exactly as it is on the + * type: `sanitizeArmTransition` omits rather than blanks an unusable one, so + * none of them is in `required`. + */ +export const armTransitionSchema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'https://codesema.com/schemas/arm-transition.json', + title: 'Codesema arm transition', + type: 'object', + additionalProperties: false, + required: ['type', 'idempotency_key', 'at'], + properties: { + type: { enum: ['mr_opened', 'review_result', 'merged', 'failed'] }, + idempotency_key: { type: 'string', maxLength: ARM_IDEMPOTENCY_KEY_MAX, pattern: NON_BLANK }, + at: { type: 'string', maxLength: ARM_TIMESTAMP_MAX, pattern: NON_BLANK }, + mr_iid: { type: 'string', maxLength: ARM_MR_IID_MAX, pattern: NON_BLANK }, + mr_url: { type: 'string', maxLength: ARM_MR_URL_MAX, pattern: NON_BLANK }, + branch: { type: 'string', maxLength: ARM_BRANCH_MAX, pattern: NON_BLANK }, + verdict: { enum: ['approve', 'request_changes', 'comment'] }, + findings_total: { type: 'integer', minimum: 0, maximum: 9_007_199_254_740_991 }, + merge_sha: { type: 'string', pattern: ARM_SHA_PATTERN }, + error_message: { type: 'string', maxLength: ARM_ERROR_MESSAGE_MAX, pattern: NON_BLANK }, + cost_ticks: { type: 'integer', minimum: 0, maximum: 9_007_199_254_740_991 }, + }, +} as const + +/** + * JSON Schema (draft 2020-12) for an `ArmOrder`, same pattern and same + * forward/backward guarantee as `armTicketSchema` above. + */ +export const armOrderSchema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'https://codesema.com/schemas/arm-order.json', + title: 'Codesema arm order', + type: 'object', + additionalProperties: false, + required: ['action', 'instruction', 'issued_at'], + properties: { + action: { enum: ['ship', 'reply', 'abandon'] }, + instruction: { + anyOf: [{ type: 'null' }, { type: 'string', maxLength: ARM_PROMPT_MAX, pattern: NON_BLANK }], + }, + issued_at: { type: 'string', maxLength: ARM_TIMESTAMP_MAX, pattern: NON_BLANK }, + }, +} as const diff --git a/packages/contract/src/index.test.ts b/packages/contract/src/index.test.ts index 837cf84..00cfd4c 100644 --- a/packages/contract/src/index.test.ts +++ b/packages/contract/src/index.test.ts @@ -173,6 +173,59 @@ describe('sanitizeFindings', () => { const [f] = sanitizeFindings([{ file: 'f'.repeat(9000), message: 'm', severity: 'minor' }]) expect(f?.file.length).toBe(500) }) + + test('repro: absent by default', () => { + const [f] = sanitizeFindings([{ file: 'a.ts', message: 'm', severity: 'major' }]) + expect(f?.repro).toBeUndefined() + }) + + test('repro: a valid pair is kept, trimmed', () => { + const [f] = sanitizeFindings([ + { + file: 'a.ts', + message: 'm', + severity: 'major', + repro: { command: ' npm test ', expected: ' exit 0 ' }, + }, + ]) + expect(f?.repro).toEqual({ command: 'npm test', expected: 'exit 0' }) + }) + + test('repro: command and expected are truncated to their own bounds', () => { + const [f] = sanitizeFindings([ + { + file: 'a.ts', + message: 'm', + severity: 'major', + repro: { command: 'c'.repeat(9000), expected: 'e'.repeat(9000) }, + }, + ]) + expect(f?.repro?.command.length).toBe(500) + expect(f?.repro?.expected.length).toBe(300) + }) + + test('repro: a blank command drops the WHOLE pair, never a repro with nothing to run', () => { + for (const command of ['', ' ', '\n\t']) { + const [f] = sanitizeFindings([ + { file: 'a.ts', message: 'm', severity: 'major', repro: { command, expected: 'exit 0' } }, + ]) + expect(f?.repro).toBeUndefined() + } + }) + + test('repro: expected may be blank, the pair still survives on a usable command', () => { + const [f] = sanitizeFindings([ + { file: 'a.ts', message: 'm', severity: 'major', repro: { command: 'npm test' } }, + ]) + expect(f?.repro).toEqual({ command: 'npm test', expected: '' }) + }) + + test('repro: a non-object, or one with a non-string command, is dropped entirely', () => { + for (const repro of ['nope', 42, null, [], { command: 42 }]) { + const [f] = sanitizeFindings([{ file: 'a.ts', message: 'm', severity: 'major', repro }]) + expect(f?.repro).toBeUndefined() + } + }) }) describe('sanitizeNarrative', () => { @@ -1226,6 +1279,11 @@ const withCriteria = (criteria: unknown): unknown => ({ review: { ...RECORD_BASE.review, criteria }, }) +const withFindings = (findings: unknown): unknown => ({ + ...RECORD_BASE, + review: { ...RECORD_BASE.review, findings }, +}) + describe('cross test: sanitizeRecord output validates against reviewRecordSchema', () => { test('a full record — narrative, findings, files_reviewed, criteria, dual — validates', () => { const record = sanitizeRecord({ @@ -1247,6 +1305,7 @@ describe('cross test: sanitizeRecord output validates against reviewRecordSchema message: 'm', suggestion: 'do this', consensus: true, + repro: { command: 'npm test', expected: 'exit 0' }, }, ], narrative: { @@ -1274,6 +1333,7 @@ describe('cross test: sanitizeRecord output validates against reviewRecordSchema }, }) expect(schemaErrors(record)).toEqual([]) + expect(record?.review.findings[0]?.repro).toEqual({ command: 'npm test', expected: 'exit 0' }) }) test('the minimal record — everything the sanitizer defaults — validates', () => { @@ -1408,4 +1468,34 @@ describe('reverse cross test: reviewRecordSchema is not looser than sanitizeRevi ).not.toEqual([]) expect(schemaErrors({ ...RECORD_BASE, version: 2 })).not.toEqual([]) }) + + test('a repro with a blank command is schema-invalid: the sanitizer OMITS the whole repro object', () => { + const finding = { + file: 'a.ts', + message: 'm', + severity: 'major', + repro: { command: ' ', expected: 'x' }, + } + expect(schemaErrors(withFindings([finding]))).not.toEqual([]) + expect(sanitizeFindings([finding])[0]?.repro).toBeUndefined() + }) + + test('a repro.command past the published bound is schema-invalid: the sanitizer truncates to it', () => { + const tooLong = { command: 'x'.repeat(501), expected: '' } + const exact = { command: 'x'.repeat(500), expected: '' } + expect( + schemaErrors( + withFindings([{ file: 'a.ts', message: 'm', severity: 'major', repro: tooLong }]), + ), + ).not.toEqual([]) + expect( + schemaErrors(withFindings([{ file: 'a.ts', message: 'm', severity: 'major', repro: exact }])), + ).toEqual([]) + }) + + test('a finding with an undeclared key is schema-invalid: additionalProperties is false', () => { + const finding = { file: 'a.ts', message: 'm', severity: 'major', confidence: 0.9 } + expect(schemaErrors(withFindings([finding]))).not.toEqual([]) + expect(sanitizeFindings([finding])[0]).not.toHaveProperty('confidence') + }) }) diff --git a/packages/contract/src/index.ts b/packages/contract/src/index.ts index 31c29c4..d927ffa 100644 --- a/packages/contract/src/index.ts +++ b/packages/contract/src/index.ts @@ -14,6 +14,7 @@ import { // All agent input passes through here: whitelist and truncate, never throw. +export * from './brain.js' export * from './reasons.js' export * from './recap.js' export * from './tasks.js' @@ -64,6 +65,17 @@ export type Verdict = 'approve' | 'request_changes' | 'comment' export type FindingSeverity = 'critical' | 'major' | 'minor' | 'info' export type FindingKind = 'security' | 'perf' | 'convention' | 'design' | 'praise' | 'why' +/** + * How a `major` finding claims to reproduce (D24): the command to run, and + * what a human should expect to see. `expected` is documentation for a + * person, never parsed by anything downstream: the only machine-readable + * signal a repro run produces is its exit code. + */ +export type FindingRepro = { + command: string + expected: string +} + export type Finding = { file: string line?: number @@ -75,6 +87,8 @@ export type Finding = { suggestion?: string /** Dual review: true when both independent reviewers raised this finding. */ consensus?: boolean + /** D24: how to reproduce the finding, when the reviewer stated one. */ + repro?: FindingRepro } export type ReviewedFileStatus = 'clean' | 'findings' @@ -152,6 +166,10 @@ const CHECK_MAX = 300 const TITLE_MAX = 200 const MESSAGE_MAX = 2000 const SUGGESTION_MAX = 4000 +/** Bound of `FindingRepro.command`: a shell command, not a script. */ +const FINDING_REPRO_COMMAND_MAX = 500 +/** Bound of `FindingRepro.expected`: a one-line human expectation, not a transcript. */ +const FINDING_REPRO_EXPECTED_MAX = 300 function sanitizeReviewFirst(raw: unknown, stepsCount: number): ReviewFirstItem[] { if (!Array.isArray(raw)) { @@ -295,6 +313,28 @@ const KINDS: ReadonlySet = new Set([ 'why', ]) +/** + * Whitelist and truncate, never throw, same doctrine as `sanitizeFindings` + * itself: a `command` that is empty after trimming drops the WHOLE repro + * (nothing to run is worse than no repro claimed at all), while `expected` + * degrades to an empty string rather than dropping the pair, since it is + * documentation for a human, not a condition anything checks. + */ +function sanitizeFindingRepro(raw: unknown): FindingRepro | undefined { + if (!raw || typeof raw !== 'object') { + return undefined + } + const r = raw as Record + const command = + typeof r.command === 'string' ? r.command.trim().slice(0, FINDING_REPRO_COMMAND_MAX) : '' + if (!command) { + return undefined + } + const expected = + typeof r.expected === 'string' ? r.expected.trim().slice(0, FINDING_REPRO_EXPECTED_MAX) : '' + return { command, expected } +} + export function sanitizeFindings(raw: unknown): Finding[] { if (!Array.isArray(raw)) { return [] @@ -330,6 +370,7 @@ export function sanitizeFindings(raw: unknown): Finding[] { typeof f.suggestion === 'string' ? f.suggestion.slice(0, SUGGESTION_MAX) || undefined : undefined + const repro = sanitizeFindingRepro(f.repro) out.push({ file, message, @@ -340,6 +381,7 @@ export function sanitizeFindings(raw: unknown): Finding[] { ...(title !== undefined ? { title } : {}), ...(suggestion !== undefined ? { suggestion } : {}), ...(f.consensus === true ? { consensus: true } : {}), + ...(repro !== undefined ? { repro } : {}), }) } return out @@ -1137,6 +1179,20 @@ export const reviewRecordSchema = { message: { type: 'string' }, suggestion: { type: 'string' }, consensus: { type: 'boolean' }, + repro: { $ref: '#/$defs/findingRepro' }, + }, + }, + // `command` carries `NON_BLANK`: `sanitizeFindingRepro` drops the WHOLE + // repro rather than keep a blank command (see its own doc comment), so a + // finding this schema admits must never claim a repro with nothing to + // run. `expected` has no such guarantee: the sanitizer keeps an empty one. + findingRepro: { + type: 'object', + additionalProperties: false, + required: ['command', 'expected'], + properties: { + command: { type: 'string', maxLength: FINDING_REPRO_COMMAND_MAX, pattern: NON_BLANK }, + expected: { type: 'string', maxLength: FINDING_REPRO_EXPECTED_MAX }, }, }, narrative: { diff --git a/packages/contract/src/tasks.test.ts b/packages/contract/src/tasks.test.ts index 827a462..afa7600 100644 --- a/packages/contract/src/tasks.test.ts +++ b/packages/contract/src/tasks.test.ts @@ -3,10 +3,12 @@ import { acceptanceCriterionId, isActiveTaskStatus, isTaskId, + isTaskStatus, sanitizeTaskChecks, sanitizeTaskEvent, sanitizeTaskRecord, TASK_AGENT_MAX, + TASK_BRAIN_TICKET_ID_MAX, TASK_CHECK_COMMAND_MAX, TASK_CHECK_TAIL_MAX, TASK_CHECKS_ERROR_MAX, @@ -15,6 +17,7 @@ import { TASK_EVENT_DATA_STRING_MAX, TASK_ISSUE_PROJECT_MAX, TASK_ISSUE_URL_MAX, + TASK_STATUS_VALUES, TASK_TIMESTAMP_MAX, TASK_TITLE_MAX, TASK_TURN_TEXT_MAX, @@ -42,6 +45,12 @@ const validIssue: TaskIssueRef = { url: 'https://github.com/getCodesema/codesema-cli/issues/42', } +const validBrainTicket = { + id: 'tick-1', + title: 'Add rate limiting', + url: 'https://brain.local/tickets/tick-1', +} + const CRITERION_TEXT = 'WHEN x THE SYSTEM SHALL y' const validSnapshot: TaskIssueSnapshot = { body_hash: FAKE_BODY_HASH, @@ -319,6 +328,23 @@ describe('sanitizeTaskRecord', () => { ).toBe(false) }) + test('cycle_step: optional, whitelisted, unknown dropped', () => { + expect( + sanitizeTaskRecord(validRecord) && 'cycle_step' in sanitizeTaskRecord(validRecord)!, + ).toBe(false) + for (const step of ['ship', 'merge'] as const) { + expect(sanitizeTaskRecord({ ...validRecord, cycle_step: step })?.cycle_step).toBe(step) + } + expect( + sanitizeTaskRecord({ ...validRecord, cycle_step: 'review' }) && + 'cycle_step' in sanitizeTaskRecord({ ...validRecord, cycle_step: 'review' })!, + ).toBe(false) + expect( + sanitizeTaskRecord({ ...validRecord, cycle_step: 42 }) && + 'cycle_step' in sanitizeTaskRecord({ ...validRecord, cycle_step: 42 })!, + ).toBe(false) + }) + test('cost_ticks: a 0.12 record has none, on the record and on its turns', () => { // FROZEN fixture of a record as codesema 0.12 wrote it: no `cost_ticks` // key anywhere, because the cost unit did not exist yet. @@ -895,6 +921,76 @@ describe('sanitizeTaskRecord — issue binding (T2.4)', () => { }) }) +describe('sanitizeTaskRecord — brain ticket binding', () => { + test('a record with brain_ticket round-trips unchanged', () => { + const withTicket = { ...validRecord, brain_ticket: validBrainTicket } + expect(sanitizeTaskRecord(structuredClone(withTicket))).toEqual(withTicket) + }) + + test('brain_ticket without a url round-trips unchanged (url is optional)', () => { + const { url: _drop, ...withoutUrl } = validBrainTicket + const withTicket = { ...validRecord, brain_ticket: withoutUrl } + expect(sanitizeTaskRecord(structuredClone(withTicket))).toEqual(withTicket) + }) + + test('a record without brain_ticket carries no key, same as any record predating this field', () => { + const r = sanitizeTaskRecord(structuredClone(validRecord)) + expect(r).toEqual(validRecord) + expect(r && 'brain_ticket' in r).toBe(false) + }) + + test('brain_ticket: a non-object drops the whole field rather than inventing one', () => { + for (const junk of [null, 'tick-1', 42, [], true]) { + const r = sanitizeTaskRecord({ ...validRecord, brain_ticket: junk }) + expect(r && 'brain_ticket' in r).toBe(false) + } + }) + + test('brain_ticket: a missing or blank id drops the whole field: no usable identity', () => { + for (const id of [undefined, '', ' ', 42, null]) { + const r = sanitizeTaskRecord({ ...validRecord, brain_ticket: { ...validBrainTicket, id } }) + expect(r && 'brain_ticket' in r).toBe(false) + } + }) + + test('brain_ticket: id and title are truncated to their bounds, never rejected for length', () => { + const r = sanitizeTaskRecord({ + ...validRecord, + brain_ticket: { + ...validBrainTicket, + id: 'i'.repeat(TASK_BRAIN_TICKET_ID_MAX + 50), + title: 't'.repeat(TASK_TITLE_MAX + 50), + }, + }) + expect(r?.brain_ticket?.id.length).toBe(TASK_BRAIN_TICKET_ID_MAX) + expect(r?.brain_ticket?.title.length).toBe(TASK_TITLE_MAX) + }) + + test('brain_ticket: title degrades to an empty string rather than dropping the field', () => { + const r = sanitizeTaskRecord({ + ...validRecord, + brain_ticket: { id: validBrainTicket.id, title: 42 }, + }) + expect(r?.brain_ticket).toEqual({ id: validBrainTicket.id, title: '' }) + }) + + test('brain_ticket: url must be an http(s) URL, or the key is simply omitted', () => { + for (const url of ['not a url', 'ftp://example.com/1', 'javascript:alert(1)']) { + const r = sanitizeTaskRecord({ ...validRecord, brain_ticket: { ...validBrainTicket, url } }) + expect(r && r.brain_ticket && 'url' in r.brain_ticket).toBe(false) + } + }) + + test('brain_ticket: url is truncated to its bound, never rejected for length', () => { + const longUrl = `https://brain.local/${'x'.repeat(TASK_ISSUE_URL_MAX)}` + const r = sanitizeTaskRecord({ + ...validRecord, + brain_ticket: { ...validBrainTicket, url: longUrl }, + }) + expect(r?.brain_ticket?.url?.length).toBe(TASK_ISSUE_URL_MAX) + }) +}) + describe('sanitizeTaskRecord — top-level criteria (T2.5)', () => { const validCriterion = { id: acceptanceCriterionId(CRITERION_TEXT), @@ -954,6 +1050,32 @@ describe('isActiveTaskStatus', () => { }) }) +describe('TASK_STATUS_VALUES / isTaskStatus', () => { + test('TASK_STATUS_VALUES names exactly the nine TaskStatus values', () => { + const allStatuses: TaskStatus[] = [ + 'queued', + 'running', + 'waiting_for_you', + 'reviewing', + 'review_ok', + 'review_ko', + 'shipped', + 'failed', + 'interrupted', + ] + expect([...TASK_STATUS_VALUES].toSorted()).toEqual(allStatuses.toSorted()) + }) + + test('isTaskStatus accepts every value TASK_STATUS_VALUES names, and nothing else', () => { + for (const status of TASK_STATUS_VALUES) { + expect(isTaskStatus(status)).toBe(true) + } + for (const junk of ['done', 'blocked', '', 42, null, undefined, {}]) { + expect(isTaskStatus(junk)).toBe(false) + } + }) +}) + describe('sanitizeTaskEvent', () => { const validEvent: TaskEvent = { seq: 3, @@ -1020,6 +1142,7 @@ describe('sanitizeTaskEvent', () => { 'queue', 'issue', 'criteria', + 'post_merge_checks', ] as const for (const type of types) { expect(sanitizeTaskEvent({ ...validEvent, type })?.type).toBe(type) diff --git a/packages/contract/src/tasks.ts b/packages/contract/src/tasks.ts index c605b73..ed04692 100644 --- a/packages/contract/src/tasks.ts +++ b/packages/contract/src/tasks.ts @@ -56,6 +56,32 @@ export type CostBasis = 'harness' | 'lower_bound' const COST_BASES: ReadonlySet = new Set(['harness', 'lower_bound']) +/** + * A repository handed to a conversation that did not start with one. + * + * The worktree lives INSIDE the conversation's own working directory, not + * under the repository's `.codesema/worktrees/`, and that placement is the + * whole point: the directory the agent runs in never changes, whatever is + * attached to it or detached from it later. A provider that indexes its + * transcripts by working directory would otherwise lose the conversation the + * moment a repository arrived. + * + * `branch` and `base` mean what they mean on TaskRecord, except they belong to + * THIS repository: several attachments each carry their own. + */ +export type TaskAttachment = { + /** Registry id of the attached project. */ + project_id: string + /** Absolute root of the attached repository. */ + repo: string + /** Directory name inside the conversation's workspace: the repo's basename. */ + name: string + /** Absolute path of the worktree, inside the conversation's workspace. */ + worktree: string + branch: string + base: string +} + export type TaskTurn = { prompt: string response: string | null @@ -208,6 +234,17 @@ export type TaskEventType = * five that answers "may this branch land". */ | 'merge' + /** + * D22 (minimal): the result of replaying this task's checks on the default + * branch AFTER its merge landed, a best-effort confirmation that what + * merged still passes once combined with everything else that landed + * beside it, since `checks` alone only ever proved the branch green in + * isolation. NEUTRAL like `checks`, never `error`: a failed replay is news + * about the default branch, not about this task, and nothing here re-opens + * or blocks the task it is journaled against. Fired at most once, + * fire-and-forget, after the merge step itself is already settled. + */ + | 'post_merge_checks' /** * How a task's agent turns are contained. @@ -344,6 +381,13 @@ export function isActiveTaskStatus(status: TaskStatus): boolean { return status !== 'shipped' && status !== 'failed' } +/** + * Which half of the post-review pipeline a task is currently inside, when it + * is inside one (D20): `'ship'` while the ship step runs, `'merge'` while the + * merge step runs. + */ +export type CycleStep = 'ship' | 'merge' + export type TaskRecord = { version: 1 /** 12 lowercase hex chars, doubles as the on-disk directory name. */ @@ -399,6 +443,17 @@ export type TaskRecord = { * nothing rather than inventing a comparison it cannot make. */ head_sha?: string + /** + * Repositories handed to this conversation after it started, in the order + * they were attached. + * + * OPTIONAL, and absence means the conversation was never given one: either + * it works on the single repository named by `base`/`branch` above (the + * ordinary case), or it has no repository at all. The two are told apart by + * `worktree` being inside a repository's `.codesema/` or not, never by this + * field. + */ + attachments?: TaskAttachment[] /** Provider session id (claude --resume), null before the first turn ran. */ agent_session_id: string | null turns: TaskTurn[] @@ -535,6 +590,35 @@ export type TaskRecord = { * always means "no criteria". */ criteria?: AcceptanceCriterion[] + /** + * The brain ticket this task was created from, when it was (arm/brain + * integration): a stable pointer back to the ticket that owns this task, so + * a reader can open it without knowing the brain's own routing. WRITE-ONCE, + * same discipline as `issue`: fixed at creation, never re-decided by a + * later turn. + * + * OPTIONAL, and absence is the honest default: a record predating this + * field, and a task never claimed from a brain ticket (title+prompt, or a + * forge issue per T2.4/T2.5), name no ticket, exactly what "no + * brain_ticket" always meant before this field existed. + */ + brain_ticket?: { + id: string + title: string + url?: string + } + /** + * Which half of ship/merge this task is currently inside, when it is + * (D20). Written at the start of that step and cleared at its end, success + * or failure alike, and on every reply, resume or abandon, so a stale value + * can never outlive the step it named. Read back at boot so a crash between + * the step finishing and the record's next write resumes the step instead + * of losing it. + * + * OPTIONAL, and absence is the honest default: a record written before D20, + * and a task that is not currently shipping or merging, are the same fact. + */ + cycle_step?: CycleStep created_at: string updated_at: string } @@ -551,6 +635,13 @@ export const TASK_TIMESTAMP_MAX = 40 /** Applies to a turn's prompt, response and question alike. */ export const TASK_TURN_TEXT_MAX = 20_000 export const TASK_TURNS_MAX = 500 +/** + * Bound of `TaskRecord.attachments`. A conversation reaching for a dozen + * repositories at once has stopped being a conversation, and the cap keeps a + * hand-edited (or corrupted) task.json from asking the runner to materialize + * an unbounded number of worktrees at its next turn. + */ +export const TASK_ATTACHMENTS_MAX = 8 export const TASK_EVENT_DATA_KEYS_MAX = 16 export const TASK_EVENT_DATA_KEY_MAX = 64 export const TASK_EVENT_DATA_STRING_MAX = 2_000 @@ -558,6 +649,8 @@ export const TASK_EVENT_DATA_STRING_MAX = 2_000 export const TASK_ISSUE_PROJECT_MAX = 200 /** Bound of `TaskIssueRef.url`: a forge issue URL, never long in practice. */ export const TASK_ISSUE_URL_MAX = 500 +/** Bound of `TaskRecord.brain_ticket.id`: an id from an external system, not this store's own 12-hex TASK_ID_RE. */ +export const TASK_BRAIN_TICKET_ID_MAX = 64 const TASK_STATUSES: ReadonlySet = new Set([ 'queued', @@ -571,6 +664,19 @@ const TASK_STATUSES: ReadonlySet = new Set([ 'interrupted', ]) +/** + * The closed set of `TaskStatus` values, as an array: the published surface a + * consumer outside this module reads to validate a status without + * hand-copying `TASK_STATUSES`. Derived from that same Set, so the two can + * never drift apart. + */ +export const TASK_STATUS_VALUES: readonly TaskStatus[] = [...TASK_STATUSES] + +/** Type guard over the same closed set, for a value arriving as `unknown`. */ +export function isTaskStatus(value: unknown): value is TaskStatus { + return TASK_STATUSES.has(value as TaskStatus) +} + /** Terminal checks statuses a TaskRecord may carry. `running` is never a result. */ const TASK_RECORD_CHECKS_STATUSES: ReadonlySet> = new Set([ 'passed', @@ -601,9 +707,11 @@ const TASK_EVENT_TYPES: ReadonlySet = new Set([ 'prep', 'criteria', 'merge', + 'post_merge_checks', ]) const TASK_ISOLATIONS: ReadonlySet = new Set(['container', 'policy']) +const CYCLE_STEPS: ReadonlySet = new Set(['ship', 'merge']) const ISSUE_FORGES: ReadonlySet = new Set(['github', 'gitlab']) /** @@ -746,6 +854,8 @@ function sanitizeIssueSnapshot(raw: unknown): TaskIssueSnapshot | null { /** The id names a directory under .codesema/tasks/: nothing else is usable. */ const TASK_ID_RE = /^[0-9a-f]{12}$/ +/** Mirrors the workspace registry's own id shape (packages/cli, projects.ts). */ +const PROJECT_ID_RE = /^[0-9a-f]{8}$/ /** Guards every id joined into a filesystem path (store, HTTP routes). */ export function isTaskId(value: unknown): value is string { @@ -753,7 +863,7 @@ export function isTaskId(value: unknown): value is string { } const str = (v: unknown, max: number): string => - typeof v === 'string' ? v.trim().slice(0, max) : '' + typeof v === 'string' ? v.trim().slice(0, max).trim() : '' const nullableStr = (v: unknown, max: number): string | null => { const s = str(v, max) @@ -827,6 +937,51 @@ const costPair = ( return ticks === null || basis === null ? null : { cost_ticks: ticks, cost_basis: basis } } +function sanitizeTaskAttachment(raw: unknown): TaskAttachment | null { + if (!raw || typeof raw !== 'object') { + return null + } + const a = raw as Record + const project_id = typeof a.project_id === 'string' ? a.project_id.trim().toLowerCase() : '' + const repo = typeof a.repo === 'string' ? a.repo.slice(0, TASK_PATH_MAX) : '' + const worktree = typeof a.worktree === 'string' ? a.worktree.slice(0, TASK_PATH_MAX) : '' + const name = typeof a.name === 'string' ? a.name.slice(0, TASK_TITLE_MAX) : '' + // An attachment that cannot name its project, its repository and where the + // worktree went is unusable: nothing downstream could rebuild or remove it. + if (!PROJECT_ID_RE.test(project_id) || !repo.trim() || !worktree.trim() || !name.trim()) { + return null + } + return { + project_id, + repo, + name, + worktree, + branch: typeof a.branch === 'string' ? a.branch.slice(0, TASK_BASE_MAX) : '', + base: typeof a.base === 'string' ? a.base.slice(0, TASK_BASE_MAX) : '', + } +} + +function sanitizeTaskAttachments(raw: unknown): TaskAttachment[] { + if (!Array.isArray(raw)) { + return [] + } + const out: TaskAttachment[] = [] + const seen = new Set() + for (const item of raw) { + if (out.length >= TASK_ATTACHMENTS_MAX) { + break + } + const attachment = sanitizeTaskAttachment(item) + // One worktree per repository per conversation: a duplicate could only + // fight the first one for the same directory. + if (attachment && !seen.has(attachment.project_id)) { + seen.add(attachment.project_id) + out.push(attachment) + } + } + return out +} + function sanitizeTaskTurn(raw: unknown): TaskTurn | null { if (!raw || typeof raw !== 'object') { return null @@ -857,6 +1012,31 @@ function sanitizeTaskTurn(raw: unknown): TaskTurn | null { } } +/** + * Whitelist and truncate, never throw: a non-object, or one whose `id` is + * missing or blank, drops the WHOLE field, same doctrine as `sanitizeIssueRef` + * above, since a brain ticket pointer nobody can identify is worse than none. + * `title` degrades to an empty string rather than nulling the field, and + * `url` is kept only when it is an http(s) URL, same rule `isHttpUrl` applies + * everywhere else in this module. + */ +function sanitizeBrainTicket(raw: unknown): { id: string; title: string; url?: string } | null { + if (!raw || typeof raw !== 'object') { + return null + } + const r = raw as Record + const id = str(r.id, TASK_BRAIN_TICKET_ID_MAX) + if (!id) { + return null + } + const url = str(r.url, TASK_ISSUE_URL_MAX) + return { + id, + title: str(r.title, TASK_TITLE_MAX), + ...(url && isHttpUrl(url) ? { url } : {}), + } +} + /** * Revalidates a TaskRecord read back from disk. Returns null when the input * has no usable identity (missing or malformed id); every other field is @@ -896,9 +1076,11 @@ export function sanitizeTaskRecord(raw: unknown): TaskRecord | null { totalPair === null || costTurns === null ? null : { ...totalPair, cost_turns: costTurns } const baselineSha = sanitizeBaselineSha(r.baseline_sha) const headSha = sanitizeBaselineSha(r.head_sha) + const attachments = sanitizeTaskAttachments(r.attachments) const issue = sanitizeIssueRef(r.issue) const issueSnapshot = sanitizeIssueSnapshot(r.issue_snapshot) const criteria = sanitizeAcceptanceCriteria(r.criteria) + const brainTicket = sanitizeBrainTicket(r.brain_ticket) return { version: 1, id, @@ -973,11 +1155,22 @@ export function sanitizeTaskRecord(raw: unknown): TaskRecord | null { // than trusted. ...(issue ? { issue } : {}), ...(issueSnapshot ? { issue_snapshot: issueSnapshot } : {}), + ...(brainTicket ? { brain_ticket: brainTicket } : {}), + // Optional and whitelisted, same doctrine as `checks_status`: absence is + // "not currently shipping or merging", which is also what an unknown or + // stale token degrades to rather than being trusted as a step in progress. + ...(typeof r.cycle_step === 'string' && CYCLE_STEPS.has(r.cycle_step as CycleStep) + ? { cycle_step: r.cycle_step as CycleStep } + : {}), // Optional, whitelist-and-truncate, never throw: a missing or unusable // list is "no criteria", which is the honest default for every record // written before T2.5 and every task that still has none. An empty list // after sanitizing is dropped so absence cannot drift from `[]`. ...(criteria.length > 0 ? { criteria } : {}), + // Same rule as `criteria`: an empty list is dropped so absence cannot + // drift from `[]`, and absence honestly means "no repository was ever + // handed to this conversation". + ...(attachments.length > 0 ? { attachments } : {}), created_at, updated_at: typeof r.updated_at === 'string' && r.updated_at ? r.updated_at : created_at, } diff --git a/packages/contract/src/ticket.test.ts b/packages/contract/src/ticket.test.ts index ab02646..e19c67c 100644 --- a/packages/contract/src/ticket.test.ts +++ b/packages/contract/src/ticket.test.ts @@ -13,6 +13,8 @@ import { lintCriteria, lintTicketBody, normalizeCriterionText, + parseCriterionProof, + PROOF_METHODS, readAcceptanceCriteria, sanitizeAcceptanceCriteria, sanitizeAcceptanceCriterion, @@ -31,8 +33,10 @@ import { type AcceptanceCriterion, type CriterionVerdict, type TicketBody, + type TicketLintOptions, type TicketLintResult, type TicketProblem, + type TicketProblemCode, type TicketSectionHeading, } from './ticket.js' @@ -69,8 +73,8 @@ function markdown(parts: Parts = {}): string { .join('\n\n') } -function lintOk(raw: string): TicketBody { - const result = lintTicketBody(raw) +function lintOk(raw: string, opts?: TicketLintOptions): TicketBody { + const result = lintTicketBody(raw, opts) expect(result.ok).toBe(true) if (!result.ok) { throw new Error('unreachable') @@ -78,8 +82,8 @@ function lintOk(raw: string): TicketBody { return result.body } -function lintKo(raw: unknown): TicketProblem[] { - const result: TicketLintResult = lintTicketBody(raw) +function lintKo(raw: unknown, opts?: TicketLintOptions): TicketProblem[] { + const result: TicketLintResult = lintTicketBody(raw, opts) expect(result.ok).toBe(false) if (result.ok) { throw new Error('unreachable') @@ -87,8 +91,8 @@ function lintKo(raw: unknown): TicketProblem[] { return result.problems } -function criteriaOk(raw: unknown): AcceptanceCriterion[] { - const result = lintCriteria(raw) +function criteriaOk(raw: unknown, opts?: TicketLintOptions): AcceptanceCriterion[] { + const result = lintCriteria(raw, opts) expect(result.ok).toBe(true) if (!result.ok) { throw new Error('unreachable') @@ -96,8 +100,8 @@ function criteriaOk(raw: unknown): AcceptanceCriterion[] { return result.criteria } -function criteriaKo(raw: unknown): TicketProblem[] { - const result = lintCriteria(raw) +function criteriaKo(raw: unknown, opts?: TicketLintOptions): TicketProblem[] { + const result = lintCriteria(raw, opts) expect(result.ok).toBe(false) if (result.ok) { throw new Error('unreachable') @@ -328,6 +332,7 @@ describe('exported bounds', () => { 'criteria_duplicated', 'criterion_not_ears', 'criterion_too_long', + 'criterion_missing_proof', ]) }) }) @@ -481,6 +486,83 @@ describe('stable ids (the formatRules precedent, not reproduced)', () => { }) }) +// --- parseCriterionProof (D17) ----------------------------------------------- + +describe('parseCriterionProof', () => { + test('a valid command tag at the end is read: method and argument', () => { + expect(parseCriterionProof('WHEN x THE SYSTEM SHALL y [proof:command npm test]')).toEqual({ + method: 'command', + argument: 'npm test', + }) + }) + + test('every proof method parses', () => { + for (const method of PROOF_METHODS) { + const arg = method === 'judgment' ? '' : ' something' + expect(parseCriterionProof(`WHEN x THE SYSTEM SHALL y [proof:${method}${arg}]`)?.method).toBe( + method, + ) + } + }) + + test('no tag at all: absent', () => { + expect(parseCriterionProof('WHEN x THE SYSTEM SHALL y')).toBeNull() + }) + + test('an unknown method is invalid, never fabricated into a known one', () => { + expect(parseCriterionProof('WHEN x THE SYSTEM SHALL y [proof:eyeball it]')).toBeNull() + }) + + test('a tag that is not at the end is invalid: trailing prose is never absorbed', () => { + expect(parseCriterionProof('[proof:command npm test] WHEN x THE SYSTEM SHALL y')).toBeNull() + expect( + parseCriterionProof('WHEN x THE SYSTEM SHALL y [proof:command npm test] and more'), + ).toBeNull() + }) + + test('command/diff/read with no argument (or a blank one) is invalid', () => { + for (const method of ['command', 'diff', 'read'] as const) { + expect(parseCriterionProof(`WHEN x THE SYSTEM SHALL y [proof:${method}]`)).toBeNull() + expect(parseCriterionProof(`WHEN x THE SYSTEM SHALL y [proof:${method} ]`)).toBeNull() + } + }) + + test('judgment with no argument is valid: argument is null, not an empty string', () => { + expect(parseCriterionProof('WHEN x THE SYSTEM SHALL y [proof:judgment]')).toEqual({ + method: 'judgment', + argument: null, + }) + }) + + test('judgment with an argument keeps it', () => { + expect(parseCriterionProof('WHEN x THE SYSTEM SHALL y [proof:judgment obviously]')).toEqual({ + method: 'judgment', + argument: 'obviously', + }) + }) + + test('tolerates extra internal whitespace around the method and the argument', () => { + expect( + parseCriterionProof('WHEN x THE SYSTEM SHALL y [proof: command npm test ]'), + ).toEqual({ method: 'command', argument: 'npm test' }) + }) + + test('an argument containing a literal "]" is kept whole, closed at the LAST bracket', () => { + expect( + parseCriterionProof('WHEN x THE SYSTEM SHALL y [proof:command grep "[unit]" out.log]'), + ).toEqual({ method: 'command', argument: 'grep "[unit]" out.log' }) + }) + + test('stays compatible with EARS_RE: a tagged criterion still lints as an ordinary EARS sentence', () => { + // D17: the tag lives inside the response clause's own trailing `.+`, so it + // must never be stripped, and must never make an otherwise-conforming + // criterion fail the EARS check. + const tagged = `${CRITERIA[0] ?? ''} [proof:command npm test]` + const body = lintOk(markdown({ criteria: [tagged, CRITERIA[1] ?? '', CRITERIA[2] ?? ''] })) + expect(body.acceptance_criteria.map((c) => c.text)).toContain(tagged) + }) +}) + // --- Lint: the happy path --------------------------------------------------- describe('lintTicketBody — a conforming body', () => { @@ -2257,6 +2339,73 @@ describe('lintCriteria', () => { }) }) +// --- requireProofMethod (D17) ------------------------------------------------- + +describe('lintTicketBody / lintCriteria: requireProofMethod (D17)', () => { + const PROVEN_CRITERIA = [ + `${CRITERIA[0] ?? ''} [proof:command npm test]`, + `${CRITERIA[1] ?? ''} [proof:diff src/ticket.ts]`, + `${CRITERIA[2] ?? ''} [proof:judgment]`, + ] + + test('unset, {} and false are the very same lint: the pre-D17 default, byte for byte', () => { + for (const raw of [markdown(), markdown({ criteria: ['not ears', CRITERIA[1] ?? ''] })]) { + expect(lintTicketBody(raw)).toEqual(lintTicketBody(raw, {})) + expect(lintTicketBody(raw)).toEqual(lintTicketBody(raw, { requireProofMethod: false })) + } + expect(lintCriteria(CRITERIA)).toEqual(lintCriteria(CRITERIA, {})) + expect(lintCriteria(CRITERIA)).toEqual(lintCriteria(CRITERIA, { requireProofMethod: false })) + }) + + test('false (the default): a body with no proof tags anywhere still lints clean', () => { + expect(lintTicketBody(markdown()).ok).toBe(true) + expect(lintCriteria(CRITERIA).ok).toBe(true) + }) + + test('true: every criterion missing a valid tag is refused by name, one problem each', () => { + const problems = lintKo(markdown(), { requireProofMethod: true }) + const missing = problems.filter((p) => p.code === 'criterion_missing_proof') + expect(missing.map((p) => p.criterion)).toEqual(CRITERIA) + }) + + test('true: lintCriteria refuses the same way, on the bare list', () => { + const problems = criteriaKo(CRITERIA, { requireProofMethod: true }) + expect(problems.map((p) => p.code)).toEqual(CRITERIA.map(() => 'criterion_missing_proof')) + }) + + test('true: a body where every criterion carries a valid tag is accepted', () => { + const body = lintOk(markdown({ criteria: PROVEN_CRITERIA }), { requireProofMethod: true }) + expect(body.acceptance_criteria.map((c) => c.text)).toEqual(PROVEN_CRITERIA) + }) + + test('true: lintCriteria accepts the same proven list', () => { + expect(criteriaOk(PROVEN_CRITERIA, { requireProofMethod: true }).map((c) => c.text)).toEqual( + PROVEN_CRITERIA, + ) + }) + + test('true: a tag with an unknown method still counts as missing', () => { + const problems = criteriaKo( + [ + `${CRITERIA[0] ?? ''} [proof:eyeball it]`, + PROVEN_CRITERIA[1] ?? '', + PROVEN_CRITERIA[2] ?? '', + ], + { requireProofMethod: true }, + ) + expect(problems.map((p) => p.code)).toEqual(['criterion_missing_proof']) + }) + + test('true: EARS and proof are independent refusals, a criterion can carry both', () => { + const problems = criteriaKo(['not ears at all', CRITERIA[1] ?? '', CRITERIA[2] ?? ''], { + requireProofMethod: true, + }) + const forOffender = problems.filter((p) => p.criterion === 'not ears at all') + const expectedCodes: TicketProblemCode[] = ['criterion_missing_proof', 'criterion_not_ears'] + expect(forOffender.map((p) => p.code).toSorted()).toEqual(expectedCodes.toSorted()) + }) +}) + // --- sanitizeTicketBody ----------------------------------------------------- describe('sanitizeTicketBody', () => { diff --git a/packages/contract/src/ticket.ts b/packages/contract/src/ticket.ts index 3145972..6e82dde 100644 --- a/packages/contract/src/ticket.ts +++ b/packages/contract/src/ticket.ts @@ -646,6 +646,79 @@ export function extractAcceptanceCriteria(body: unknown): AcceptanceCriterion[] return readAcceptanceCriteria(body).criteria } +// --- Proof methods (D17) ------------------------------------------------------ + +/** + * How a criterion's verdict may be established, named strictly enough that a + * caller can act on it mechanically. `judgment` is the escape hatch: the only + * method with nothing mechanical behind it, for a criterion nothing else can + * verify. Never a JSON field on `AcceptanceCriterion` (D17): a new field would + * need every existing producer and consumer of a `TicketBody` updated in + * lockstep before a single criterion could carry one, where a textual tag + * inside `text` is legible to all of them the day it starts appearing. + * Extensible, never renamed: same doctrine as `TICKET_PROBLEM_CODES`. + */ +export const PROOF_METHODS = ['command', 'diff', 'read', 'judgment'] as const + +export type ProofMethod = (typeof PROOF_METHODS)[number] + +/** + * One criterion's declared proof: which method judges it, and the argument + * that method acts on (the command to run, the path a diff must touch, the + * path or substring a read must find). `argument` is `null` only for + * `judgment`, the one method with nothing for an argument to name. + */ +export type CriterionProof = { + method: ProofMethod + argument: string | null +} + +/** + * Matches a `[proof: ]` tag anchored at the very END of a + * criterion's text (trailing whitespace tolerated, nothing else: a tag + * followed by more prose is not at the end and must not match). Tolerant of + * extra internal whitespace around `` and before ``, since + * this tag is typed by hand as often as it is generated. `` is + * matched GREEDILY up to the last `]` the anchor allows, so an argument that + * itself contains a literal `]` (a grep pattern, a JSON snippet in a command) + * is not cut at the first one. + */ +const CRITERION_PROOF_RE = /\[proof:\s*(\S+)(?:\s+(.+))?\]\s*$/ + +const PROOF_METHOD_SET: ReadonlySet = new Set(PROOF_METHODS) + +/** + * Reads the `[proof:...]` tag off a criterion's text. Returns `null` when + * there is none, when it sits anywhere but the end, or when its method is not + * one of `PROOF_METHODS`: the same never-fabricate rule this contract + * applies to every closed enum, a method this build does not recognize is + * refused, not guessed at. `argument` is REQUIRED for `command`/`diff`/`read` + * (a mechanical check needs something to act on): a tag missing one, or + * carrying only whitespace, is refused for those three exactly like a missing + * tag would be. `judgment` is the one method `argument` is optional for. + * + * Deliberately compatible with `EARS_RE`: the tag lives inside the response + * clause's own trailing `.+`, so a criterion carrying one still is, and + * always was, an ordinary EARS sentence to every reader that does not know + * about proofs yet (D17: D12, a structured field instead, is not done). + */ +export function parseCriterionProof(text: string): CriterionProof | null { + const match = CRITERION_PROOF_RE.exec(text) + if (!match) { + return null + } + const methodToken = match[1] ?? '' + if (!PROOF_METHOD_SET.has(methodToken)) { + return null + } + const method = methodToken as ProofMethod + const argument = match[2]?.trim() || null + if (method !== 'judgment' && !argument) { + return null + } + return { method, argument } +} + // --- Lint ------------------------------------------------------------------- /** @@ -665,6 +738,7 @@ export const TICKET_PROBLEM_CODES = [ 'criteria_duplicated', 'criterion_not_ears', 'criterion_too_long', + 'criterion_missing_proof', ] as const export type TicketProblemCode = (typeof TICKET_PROBLEM_CODES)[number] @@ -686,6 +760,21 @@ export type TicketLintResult = export type TicketCriteriaLintResult = { ok: true; criteria: AcceptanceCriterion[] } | { ok: false; problems: TicketProblem[] } +/** + * Shared options of `lintTicketBody` and `lintCriteria` (D17). + * + * `requireProofMethod` defaults to `false`, and that default is the whole + * lint's behavior before D17 existed, byte-for-byte: nothing changes for a + * caller that does not pass this. Only `brain-draft.ts` passes `true`, to gate + * drafts on carrying a `[proof:...]` tag per criterion; the ADMISSION lint + * (task-from-issue) and boot-time reconciliation are deliberately left at the + * default, so an existing ticket written before D17 keeps linting exactly as + * it always has. + */ +export type TicketLintOptions = { + requireProofMethod?: boolean +} + type SectionScan = { blocks: Map duplicated: Set @@ -1196,7 +1285,20 @@ function problemsForSection(scan: SectionScan, heading: TicketSectionHeading): T return out } -function problemsForCriterion(text: string, seen: Set): TicketProblem[] { +/** + * Resolved lint behavior for the criteria rules below. Never partial: callers + * always pass a fully-defaulted object, so nothing here re-decides what + * `requireProofMethod`'s own absence means. + */ +type CriteriaLintOptions = { + requireProofMethod: boolean +} + +function problemsForCriterion( + text: string, + seen: Set, + opts: CriteriaLintOptions, +): TicketProblem[] { const out: TicketProblem[] = [] // Counted in CODE POINTS, the unit the bound is published in: an emoji is one // character of a criterion, not two, and the refusal must say the same. @@ -1219,6 +1321,19 @@ function problemsForCriterion(text: string, seen: Set): TicketProblem[] ), ) } + // Off by default (byte-identical to the lint's pre-D17 behavior): only + // `brain-draft.ts` opts in today. When it does, a criterion with no valid + // `[proof: ]` tag is refused by name, same as a criterion + // that fails EARS. + if (opts.requireProofMethod && !parseCriterionProof(text)) { + out.push( + problem( + 'criterion_missing_proof', + `acceptance criterion has no valid "[proof: ]" tag: ${quoted(text)}`, + { criterion: text }, + ), + ) + } const id = acceptanceCriterionId(text) if (seen.has(id)) { out.push( @@ -1232,11 +1347,14 @@ function problemsForCriterion(text: string, seen: Set): TicketProblem[] } /** - * The rules that apply to the criteria THEMSELVES — count, bound, EARS, - * duplicates — shared verbatim by `lintTicketBody` and `lintCriteria` so a body - * and a bare list can never be judged by two different standards. + * The rules that apply to the criteria THEMSELVES (count, bound, EARS, + * duplicates, proof), shared verbatim by `lintTicketBody` and `lintCriteria` + * so a body and a bare list can never be judged by two different standards. */ -function problemsForCriteriaTexts(texts: readonly string[]): TicketProblem[] { +function problemsForCriteriaTexts( + texts: readonly string[], + opts: CriteriaLintOptions, +): TicketProblem[] { const problems: TicketProblem[] = [] if (texts.length < TICKET_CRITERIA_MIN) { problems.push( @@ -1257,12 +1375,15 @@ function problemsForCriteriaTexts(texts: readonly string[]): TicketProblem[] { } const seen = new Set() for (const text of texts) { - problems.push(...problemsForCriterion(text, seen)) + problems.push(...problemsForCriterion(text, seen, opts)) } return problems } -function problemsForCriteria(scan: SectionScan): { problems: TicketProblem[]; items: string[] } { +function problemsForCriteria( + scan: SectionScan, + opts: CriteriaLintOptions, +): { problems: TicketProblem[]; items: string[] } { const block = scan.blocks.get(ACCEPTANCE_CRITERIA_HEADING) // A missing section is already reported by `problemsForSection`; saying it // twice would only pad the refusal. @@ -1282,7 +1403,7 @@ function problemsForCriteria(scan: SectionScan): { problems: TicketProblem[]; it // No filter here: an item that carries no text was already named above, so // nothing silently disappears between the section and the count. const texts = items.map((item) => collapse(item)) - problems.push(...problemsForCriteriaTexts(texts)) + problems.push(...problemsForCriteriaTexts(texts, opts)) return { problems, items: texts } } @@ -1300,7 +1421,7 @@ function problemsForCriteria(scan: SectionScan): { problems: TicketProblem[]; it * Never throws: `raw` that is not a non-empty string is a refusal like any * other, with its own code. */ -export function lintTicketBody(raw: unknown): TicketLintResult { +export function lintTicketBody(raw: unknown, opts: TicketLintOptions = {}): TicketLintResult { if (typeof raw !== 'string' || !raw.trim()) { return { ok: false, @@ -1312,7 +1433,9 @@ export function lintTicketBody(raw: unknown): TicketLintResult { for (const { heading } of TICKET_SECTIONS) { problems.push(...problemsForSection(scan, heading)) } - const criteria = problemsForCriteria(scan) + const criteria = problemsForCriteria(scan, { + requireProofMethod: opts.requireProofMethod ?? false, + }) problems.push(...criteria.problems) if (problems.length > 0) { return { ok: false, problems } @@ -1361,7 +1484,7 @@ function typeName(value: unknown): string { * * Never throws: anything that is not a list is a refusal with its own code. */ -export function lintCriteria(raw: unknown): TicketCriteriaLintResult { +export function lintCriteria(raw: unknown, opts: TicketLintOptions = {}): TicketCriteriaLintResult { if (!Array.isArray(raw)) { return { ok: false, @@ -1390,7 +1513,9 @@ export function lintCriteria(raw: unknown): TicketCriteriaLintResult { ), ) }) - problems.push(...problemsForCriteriaTexts(texts)) + problems.push( + ...problemsForCriteriaTexts(texts, { requireProofMethod: opts.requireProofMethod ?? false }), + ) if (problems.length > 0) { return { ok: false, problems } } diff --git a/packages/web/components.json b/packages/web/components.json new file mode 100644 index 0000000..87c00ab --- /dev/null +++ b/packages/web/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://shadcn-vue.com/schema.json", + "style": "new-york", + "typescript": true, + "tailwind": { + "config": "", + "css": "src/style.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "composables": "@/composables", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib" + }, + "iconLibrary": "lucide", + "pointer": false, + "rtl": false +} diff --git a/packages/web/package.json b/packages/web/package.json index e6132bf..901c086 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -9,10 +9,22 @@ "typecheck": "vue-tsc --noEmit" }, "dependencies": { + "@lucide/vue": "^1.34.0", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "rehype-stringify": "^10.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "unified": "^11.0.5", + "unist-util-visit": "^5.1.0", "vue": "^3.5.39" }, "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@types/hast": "^3.0.5", + "@types/node": "^26.1.1", "@vitejs/plugin-vue": "^6.0.7", + "tailwindcss": "^4.3.3", "typescript": "^6.0.3", "vite": "^8.1.4", "vue-tsc": "^3.3.7" diff --git a/packages/web/src/App.vue b/packages/web/src/App.vue index ea2db6b..637b93e 100644 --- a/packages/web/src/App.vue +++ b/packages/web/src/App.vue @@ -1,230 +1,39 @@