Skip to content

v0.27.0

Latest

Choose a tag to compare

@github-actions github-actions released this 07 Oct 22:10
58638ed

The handoff-robustness release, plus everything merged since 0.26.0. A real Pi run on a local 27B model finished two and a half hours of validated work and then stopped on a YAML shape mistake in its handoff. Behind that one refusal, the handoff parser was silently dropping closures and question text. Completion now refuses what it would lose, Pi gives the phase one turn to repair a refused handoff, and the smoke workflow no longer hangs for six hours when it fails. Also shipping: the MCP server on SDK v2 serving the 2026-07-28 revision, and the first, EXPERIMENTAL, engineering-record surface (codecarto_change).

Added

  • EXPERIMENTAL: codecarto_change, an MCP tool that records an engineering change and never executes anything. tools/list now returns 23 tools, not 22. The new tool is added at the end of the list so the analysis tools keep their order, and its description begins EXPERIMENTAL. It takes one action discriminator, which is one of create, update, show, list, plan, start_attempt, capture_candidate, record_proof, record_review, gate, request_acceptance, ingest_observations. These actions let a host session run the whole supervised loop through tool calls alone: create → plan (with slices) → start an attempt → capture a candidate → record proofs → record a review → gate → ask for acceptance. Records are written under .codecarto/engineering/changes/<chg_…>/ (change.json, brief.md, plan.md, slices/, and per attempt snapshots/, proofs/, reviews/, requests/, approvals/). The tool's limits are deliberate:

    • No execution. Nothing runs: no exec or spawn, no target-code write, no network call. execute and run are refused with a reason. approve and accept are refused with a reason too, and state cannot be set directly.
    • Derived fields. Fields the record derives (authority, attested_by, outcome, stability, digests, candidate_*, input_digest, …) are refused by name on the lifecycle actions. A proof recorded through a tool call is attested caller, reads as claimed, and discharges nothing under the verified policy, whatever its payload says.
    • Snapshots. start_attempt and capture_candidate capture the baseline and candidate snapshots themselves from the server's cwd and attest them adapter. A caller-supplied snapshot, manifest, coverage, repository, … is refused rather than ignored. Git HEAD is read from .git without running git, so repository.dirty is always true on a git tree. A candidate can be re-captured until a proof names it.
    • Attempt inputs. start_attempt derives its input digests from the stored brief.md/plan.md. If either file changes after the attempt starts, gate refuses with input-stale.
    • Gate and acceptance. gate re-reads the working tree, refuses a candidate the tree no longer matches (proof-stale), and reports needs-human-acceptance rather than may-accept. request_acceptance asks a person through the client's own MCP elicitation form, and only for a host/client pair registered in VERIFIED_ACCEPTANCE_INTEGRATIONS at the exact client version. That registry ships empty, so today every client gets needs-human-acceptance and is never asked. The request is still stored.
    • Host observations. ingest_observations reads the Claude Code hook's inbox (below). No host-configuration channel reaches the MCP server yet, so every proof it ingests is caller and discharges nothing, and the result says so.
    • Error codes. Refusals are JSON-RPC -32602. Retries are safe with request_id, and update and plan compare-and-swap on the revision the caller last read.

    The shape may change before it leaves experimental. See docs/mcp-quickstart.md § Experimental, docs/engineering/attempt-lifecycle.md and docs/engineering/acceptance-adapter.md (#434, #445, #446, #447; tracking #398, #409).

  • EXPERIMENTAL: the engineering record core, core/engineering/, re-exported from core/index.ts. This is the record system behind codecarto_change, and it adds new exports to the package's library API:

    • Contract (E01). A versioned v1 record and approval contract: types, ID grammar, canonical-JSON SHA-256 digests, and pure validators for records, bundles, operation requests, acceptance requests and approval receipts. Unknown fields are refused by own-property checks, and authority is derived from collector plus attestation, never read from a record. Acceptance is classified verified or cooperative against a registry and a storage boundary. The validators also enforce the acceptance-request TTL against the client's request timeout, the presentation's disclosure, and the registry shape. An optional class_sweep field on review objections records the class a fix belongs to and every instance of it. docs/engineering/record-contract.md is the normative text (#414, #415, #417, #425).
    • Store (E02). A file-backed store with two storage classes: change and slice records are compare-and-swapped on revision under a per-change lock, and attempt records are create-only once their outcome leaves running. Paths are checked for containment twice. Corrupt records are reported beside healthy ones, never skipped (#426, #447).
    • Snapshots (E03). Snapshot identity puts coverage inside the digest. Unreadable files become uncovered_relevant_input, which blocks acceptance. Secret files are excluded by name and their digests are never recorded (#420).
    • Planning (E04). A change brief and plan are built without a library. Planning refuses floating versions, empty or unknown scenario lists and dependency cycles, and it shows uncovered scenarios and stale findings instead of hiding them (#431).
    • Proofs (E05). Proof ingestion refuses a contradictory result and refuses to overwrite a proof. Failed, blocked, claimed and unstable-snapshot proofs are kept but discharge nothing (#432).
    • Gates (E06). The acceptance gate judges the work before it consults the host's capability, names a remedy for every blocker, and states on every outcome that it cannot establish semantic correctness (#433).
    • Traverse (E08). planTraverseStep, a bounded, resumable next-step planner that works from the records alone (#436, #447).
    • Lifting (E09, E10). liftSlices, which reads a spec's or plan's Slices table into the record vocabulary (#438, #439).

    This core does not run checks, so it cannot establish that a recorded result is true. It records who asserted what.

  • EXPERIMENTAL: the traverse skill and three new templates, delivered to workspaces by init and scaffold refresh. .codecarto/skills/traverse/SKILL.md describes the supervised change loop as procedure for an agent. It is prose, and it says so: enforcement lives in core/engineering/. templates/traverse-record.md, templates/change-brief.md and templates/change-plan.md are new. codecarto_list_skills now lists traverse. On MCP, codecarto_skill {name: "traverse"} is served without a completed pipeline. The exemption matches that one exact name, the way broadside is matched, and every other skill stays pipeline-gated. For a skill that is not post-pipeline, the prompt no longer says "The pipeline is complete" and drops "Do not modify source files outside .codecarto/". On Pi, /codecarto-skill traverse is still pipeline-gated and gets the post-pipeline prompt (#436).

  • EXPERIMENTAL: a Claude Code hook that records host-observed Bash results, shipped in the repository but not in the npm package. hosts/claude-code/observe.mjs is a PostToolUse/PostToolUseFailure hook, and hosts/claude-code/settings.template.json wires it up. For each Bash call the hook writes one observation file into .codecarto/engineering/inbox/ (identity, command, exit code and output digests, never output text). For each Edit/Write/MultiEdit/NotebookEdit call it writes an activity entry. Every file is created O_CREAT|O_EXCL|O_NOFOLLOW with mode 0600. The hook executes nothing from its payload, refuses a symlinked namespace, and always exits 0. ingestHostObservations reads only regular inbox/*.json files and matches each command byte-for-byte to one obligation. A command that matches none or several obligations is reported, not ingested. Processed files are rotated to inbox/processed/. ProofObligation gains an optional command. hosts/ is not in package.json files; docs/engineering/host-observations.md says to install from a checkout at a reviewed commit, and the hook needs Node ≥ 22. The adversarial checks for this hook placement (D4/E5/E6) have not been performed, so tool_result_path must be declared unprotected. Every proof from the hook is then caller (#446).

  • The analysis templates carry stable scenario and slice handles, an agent-addressability assessment, and a verification route per slice. Scaffold refresh brings them, with their pipeline completion criteria, into existing workspaces.

    • Scenario and slice tables (E09). reimplementation-spec.md, reimplementation-spec-opinionated.md and project-plan.md give each scenario a Scenario ID and a Tier. They add a Slices table with Slice ID, deliverable, modules, Proves scenarios, Depends on and tier. In the two spec templates the table also has a Verification route column (test, run, manual-procedure, or none), and the opinionated variant adds a Proof command column.
    • Agent Addressability (E10). architecture-map.md and reverse-engineering-bundle.md gain an Agent Addressability table: headless, inspectable state, and programmatic seam, each tagged with an evidence level. A missing seam is recorded as a portability hazard, not evidence of correctness.
    • Validation rows and completion criteria. The matching validation rows and completion criteria are added to every pipeline that carries the phase: architecture in seven pipelines, porting and reimplementation-spec in four each, and goal-synthesis-finalize in pipeline-synthesis.yaml. The architecture, porting, reimplementation-spec and goal-synthesis-finalize SKILLs now ask for the new tables.
    • What enforces them. The tables are filled and checked by the phase session's own validation table. No completion code reads them. liftSlices (above) is a library function that no command calls yet. Existing outputs still validate, because phase validation only refuses rows marked FAIL (#438, #439).

Changed

  • The MCP server runs on SDK v2 and serves the 2026-07-28 revision alongside the 2025 era. @modelcontextprotocol/sdk ^1.29.0 is replaced by @modelcontextprotocol/server + /core 2.0.0 (the latest line; the v1 package is no longer in the tree, and 80 transitive packages the v1 line dragged in — express, hono, ajv, cors, … — go with it). The codemod rewrote the imports and McpError/ErrorCode → ProtocolError/ProtocolErrorCode (the four JSON-RPC codes the server emits, -32600/-32601/-32602/-32603, are unchanged) and setRequestHandler(Schema, …) → setRequestHandler("tools/list" | "tools/call", …); the two-handler dispatch and the 22 tools it served are untouched (codecarto_change, under Added, is the 23rd). The stdio entry is now the SDK's serveStdio, which owns the era decision per connection: a client that opens with initialize is pinned to a 2025-era instance and served exactly as before (every revision the v1 line accepted — 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05, 2024-10-07 — is still accepted and echoed; @modelcontextprotocol/server-legacy is SSE + OAuth and is not needed); a client that probes with server/discover is pinned to a 2026-07-28 instance, on which the SDK itself answers the probe (supportedVersions: ["2026-07-28"], capabilities, instructions), stamps resultType: "complete" and serverInfo onto every result, and emits the caching hints — tools/list is declared ttlMs: 86400000, cacheScope: "public" because the inventory is a module constant. A 2025-era response never carries any of the 2026 vocabulary. server.json's $schema stays at 2025-12-11: it is the only registry schema published (…/schemas/2026-07-28/server.schema.json is 404 and the 2026-07-28 spec's registry docs reference 2025-12-11). tests/mcp-protocol-2026-07-28.test.mjs drives the shipped binary over stdio in both eras, and scripts/smoke-mcp.mjs now runs 12 checks: the original nine through initialize, then three through the v2 client's versionNegotiation: { mode: "auto" } probe. One wire-visible detail changed: ProtocolError.message no longer carries the v1 MCP error <code>: prefix (the code is in error.code; four tests that matched the prefix now match the message body and keep asserting the code). Before this release the SDK-era swap went through CONTRIBUTING's surface verification on the packed tarball: codecarto_init then codecarto_next from Claude Code, Codex and Hermes, and a full phase on Pi with a real model. The returns are recorded in the release PR (#185).

  • Ten test expectations no longer assume POSIX paths. The windows-latest job's first run reported ten failures; two were product defects (#393, #394) and the other eight were expectations written for /-separated paths: an outputDir's last segment taken with split("/"), which does not split a backslash path; four comparisons against a raw library.path or source_repo line, where a path containing backslashes is correctly emitted as a quoted YAML scalar with those backslashes escaped; a path interpolated into a RegExp source, where backslashes read as escapes and \b becomes a word boundary; a refusal message matched against a /-rooted pattern; and a joined path compared against a /-joined literal. Each now compares a parsed value, a basename, a path.join on both sides, or a literal prefix — and one doesNotMatch in the same family, which a quoted backslash path would have satisfied vacuously rather than failing, became a parsed-value comparison too. Fixing those eight exposed two more of the same kind — a third raw library.path comparison and a second /-rooted source_repo pattern — because a test stops at its first failing assertion, so the run could only report the first one in each: the issue's list of eight was what was visible, not the whole set. A sweep for every instance of these shapes across tests/ (raw-line comparisons, a path interpolated into a pattern, split("/") on a path) finds no others; what remains is writes that feed the parser, URLs, which are always /-separated, and patterns that already escape their input. No product code changed. continue-on-error came off the test-windows job once a run reported it green (#427) (#395).

  • Engineering runtime state is private to the workspace: ignored by git for new workspaces, never copied by init, and kept out of the npm package. .codecarto/engineering/ holds change briefs, attempts, proofs, review objections and approval receipts. The shipped .codecarto/.gitignore and templates/gitignore now ignore engineering/. codecarto_init / /codecarto-init copy the directory without its contents. package.json files excludes .codecarto/engineering/**. Upgrading: scaffold refresh never rewrites an existing workspace's .codecarto/.gitignore (it only creates one when absent). A workspace created before this release will not ignore engineering/ unless the line is added by hand (#426, #441).

  • CI: the Windows suite now gates the run instead of only reporting. continue-on-error is removed from the test-windows job. A Windows failure now fails the CI run (#427, closes #395).

  • Published type declarations omit @internal members. tsconfig.json sets stripInternal: true. The test-only seams (the lock's fsOps, buildServer({ acceptanceRegistry }), the working-tree capture seam) are absent from dist/**/*.d.ts, and a consumer test compiles the declarations with skipLibCheck off (#444, #445, #447).

Fixed

  • The release workflow builds before it runs the tests. release.yml ran npm test before npm run build, while CI builds first. The MCP protocol and acceptance-adapter tests added since 0.26.0 drive the shipped binary in dist/, so the first v0.27.0 tag push failed on 51 tests before any publish step ran. Nothing was published. The workflow now builds first, and tests/release-workflow.test.mjs pins build-before-test in both workflows.

  • Scaffold refresh never copies engineering records. Init and the npm pack both excluded .codecarto/engineering/. Refresh walked every directory of the template it refreshes from, except its own exclusion list, and that list did not name the namespace. In a checkout install, that template is the developer's working .codecarto/, so their change briefs, proofs and approval receipts were copied into every workspace refreshed from it. Reproduced against a copy of the template with a planted history, where the refresh list carried the approval and proof files. engineering/ is now protected the way scratch/ and broadside/ are, and the Pi refresh preview names it. npm installs were never affected, because the tarball excludes the namespace.

  • The traverse skill no longer promises a next-step action codecarto_change does not have. It told the agent to "ask codecarto_change for the next step", but no action returns the planner's answer, and planTraverseStep is only reachable from the core library. The skill now says so, and over MCP it directs the agent to show and gate. docs/mcp-quickstart.md now lists all twelve codecarto_change actions instead of four, and says what request_acceptance does today. README's tool table gains the codecarto_change row. acceptance-adapter.md no longer calls attempt records create-only while they are running.

  • The smoke harness exits when a step fails, and neither smoke workflow can hang for six hours. A failed step threw out of main() before its MCP client was closed. The open stdio client kept the server child, and with it the harness process, alive, so the scheduled Smoke workflow sat until GitHub's six-hour limit on both Node legs every day from 2026-09-22. The harness now closes every client it opened (bounded at five seconds) and exits explicitly. Reproduced against the packed tarball with one tool removed from EXPECTED_TOOLS: the old harness was still running when killed at 60 s, and the new one exits 1 in 3 s. The scheduled run now checks out the tag of the version it tests, defaulting to the latest published version rather than package.json's: the harness asserts the exact tool inventory, and main's harness lists tools that are not published yet (it expected codecarto_change from 0.26.0). Both workflows gained step and job timeouts (#449).

  • Production dependencies audit clean again. The release workflow gates on npm audit --omit=dev --audit-level=high, and main had stopped passing it. Two high advisories came through the Pi peer dependency's tree: undici 8.10.0 (eleven advisories, fixed in 8.10.2) and brace-expansion 5.0.9 (three DoS advisories, fixed in 5.0.12). A targeted npm update moves the lockfile to undici 8.11.2 and brace-expansion 5.0.12, six lines each way. The undici override floor rises to ^8.10.2 so the lockfile cannot drift back.

  • A malformed handoff entry fails completion instead of being dropped from it. parseHandoff filtered what it could not read, so a handoff could complete while part of it never reached workflow/status.yaml, the opposite of GUIDE.md's "malformed collection shapes fail completion". A carry_forward_closures entry written as { id: arch-CF1, … }, which is the shape open_question_closures takes, was dropped, and the routed item stayed open for a phase that had already closed it. An open_questions, carry_forward or post_pipeline entry with its text under another key (question:, why_source_cannot_settle:) was stored as a bare id and kind. Both came from a real run on a local 27B model. carry_forward_closures now takes a bare id or a mapping with one. A closure with no id is refused, and a closure's other text (closure:, reason:) is kept as an owner note instead of being dropped. A structured entry whose text sits under a key completion does not read, instead of description, is refused, and the refusal names those keys. An entry made only of recognized fields is still accepted, since it loses nothing. An owner_notes or decisions entry with nested structure is refused too. An unquoted note containing ": ", which YAML reads as a one-key mapping and which was also silently dropped, is now read back as its key: value text. Amendments get the same treatment: their closures take { id, evidence }, and the evidence is recorded in the closeout's notes. An amendment's notes now reach its closeout even when closeout_content is supplied, as the template's own shape does; before, they were dropped while Pi's preview promised them. status.yaml keeps its lenient reader; only model-written input is strict (#453).

  • Pi: a handoff completion would refuse gets one repair turn before the phase ends. Completion judged the handoff only after the phase's sub-agent had been disposed, so a finished phase whose handoff had a YAML shape mistake stopped the run, and the model that wrote it never saw why. The observed case was 151 minutes and 629k tokens of PASS-validated findings stranded by owner_notes written as one folded string. A new checkPhaseHandoff(cwd, phaseId) in core/ runs the same load and the same state-dependent gates as completion (parse and shape, phase_id, a required handoff that is missing, carry-forward targets, D1, D3). It writes nothing and returns completion's own refusal word for word. A workspace it cannot read is not treated as a refusal, since editing the handoff could not fix it. When the primary output exists and the check refuses, the still-live sub-agent gets one more prompt carrying the refusal verbatim and an instruction to fix only the handoff. It is one turn, not a loop: a handoff still refused after it stops the run as before. Both /codecarto-next paths get it through runSinglePhase. MCP is unaffected, because codecarto_complete already returns the refusal to the host's live agent (#454).

  • A containment root that does not exist yet is canonicalized the way its target is. isWithinPathResolved resolved the target's existing prefix through symlinks but the root through realpath alone, which throws on a .codecarto/ that is not there yet and fell back to the root as spelled. Wherever an ancestor needed expanding the two operands then disagreed — the 8.3 short name the Windows runner puts in %TEMP% (C:\Users\RUNNER~1\…), macOS' /var → /private/var — and a write to .codecarto/findings/…, the one place a phase session may write, was refused while that directory did not exist yet. Both operands now resolve through resolveExistingPrefix, and the primitive takes the base a relative operand resolves against. The Pi orchestrator hook and the phase hook call that single primitive instead of each hand-rolling the resolve-canonicalize-compare sequence one of them had drifted from, and the MCP spec_path containment check inherits the fix. What containment refuses is unchanged. Confirmed by the test-windows job's first run (#394).

  • Windows: a rename that loses a race retries instead of failing the write. atomicWriteFile writes a uniquely named sibling temp file and renames it over the destination; on Windows that rename fails with EPERM while another writer holds or is replacing the same file, so two concurrent writers — two MCP hosts, Pi and MCP, the usage log appending as a phase completes — lost a write with an error rather than serializing. Every canonical write in the framework goes through that function (status.yaml, the usage log, Broad-Side state.json, the library index). The rename now retries on EPERM, EBUSY and EACCES within a bounded, jittered budget and still propagates any other error on the first attempt; the temp-file cleanup is unchanged. Confirmed by the test-windows job's first run (#393).

  • Windows: only one waiter can report breaking a dead lock ticket. On the Windows runner, two waiters both recorded brokeStale for one dead ticket. Reaping a stale ticket now first creates an exclusive per-ticket claim marker (<lock>.claim.<ticket>), then renames the ticket to a unique tombstone (<lock>.reaped.<hex>). Only the marker's creator can report the break. EPERM/EBUSY/EACCES on that rename are retried while the ticket still exists and read as a lost race once it is gone. Any other error still surfaces after the waiter's own ticket and heartbeat are cleaned up. Old tombstones and markers are swept. Only ENOENT from a ticket stat now counts as the owner being gone (#444, closes #435).

  • Test fixtures no longer pick up the developer's git environment or write into the live .codecarto/. Git configuration injected through the environment (GIT_CONFIG_COUNT/_KEY_n/_VALUE_n, and GIT_CONFIG_PARAMETERS) used to reach fixture git commands. So did GIT_TEMPLATE_DIR (whose hooks ran inside fixture commits), the repository-location overrides (GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILE, …) and the programs git launches (editor, pager, askpass, ssh). One side-effect helper, tests/helpers/git-environment-isolation.mjs, now neutralizes them and is imported first in every git-using test file (#421, #442; closes #412, #423). The tarball-exclusion test packs a disposable copy and asserts that the live .codecarto/engineering/changes/ is unchanged (#441, closes #437). A committed node_modules symlink that broke npm test on a clone without an install was removed, and .gitignore now ignores node_modules as either a directory or a symlink (#429). Test-only; no product code changed.

Documentation

  • The engineering-evolution plan, contract and pilot record are in docs/engineering/. It holds the vision, implementation plan and roadmap (#410), the preregistered pilots and evaluation rubric (#413), the Claude Code 2.1.263 feasibility spike (#416, with fixtures under spikes/, which is not packaged), E01's status and the D3 decision (#419, #425), the B01 pilot report (#424), the E09/E10 dogfood notes and the E11 end-to-end report (#438, #439, #440), and the host-observation, acceptance-adapter and attempt-lifecycle pages (#445, #446, #447). The README links them under Contributing as planned, not shipped, capability. The self-audit close-out (#397) and the FreeAgent evidence names (#430) are also updated.