Skip to content

teamwright 0.12.0

Choose a tag to compare

@smykov smykov released this 24 Oct 21:18
· 12 commits to main since this release
c6619fa

Added

  • Stages: the lifecycle runs one stage per session when you want it to. Until now /teamwright:flow drove a task through analysis, red tests, implementation, gate, review and verification in one session: an autonomous job that was cut lost the whole task, and the context grew with every role. The lifecycle is now five stages — analyze (ready TODO, ANALYSIS, VERIFY_FAIL → IN_PROGRESS), red-tests, implement (green tests, the test-writer's failure-branch bite and tw-mutants, tw-gate --for-task + tw-gate <area>, technical-writer, commit, IN_PROGRESS → NEEDS_REVIEW; a failed check is fixed in the same session), review, verify — and push (DRAIN) stays a separate owner decision. New commands: /teamwright:analyze, /teamwright:red-tests, /teamwright:implement, /teamwright:review, /teamwright:verify run a named stage and first ask the router, refusing with the right command when the task is at another stage (/teamwright:implement T-12 on a task without red tests → /teamwright:red-tests T-12); /teamwright:router asks the router and runs exactly one stage, then stops at the barrier. /teamwright:flow stays, as a loop of router steps in one session; its lifecycle text moved into the stage commands, and its resume heuristic into the router.
  • The stage: task field (red-tests | implement, the sub-stage of IN_PROGRESS), written by the stage that finished its work and committed with it. The router checks it against the evidence (## Tests filled = the red tests are written; analysis complete): a field behind the evidence wins (red-tests with the tests filled — a send-back to the tests), a field ahead of it loses (implement without tests → red-tests), with a warning; a task without the field (started by an older flow) is routed by the evidence. IN_PROGRESS may now cross a session boundary when its stage is recorded and the tree is clean (lifecycle.md, sessions.md, AGENTS.md). Task template: a stage: "" line.
  • Stage gate (analysis gate, check 4): entering IN_PROGRESS sets the stage in the same edit (red-tests from TODO, ANALYSIS, VERIFY_FAIL; a send-back may set implement), the field only moves red-tests → implement (back to red-tests from implement, or when review or verification return the task), is never removed from an IN_PROGRESS task, stage: implement needs ## Tests filled, and IN_PROGRESS → NEEDS_REVIEW needs stage: implement, the tests and — for a task that changes code (_task.code_changed: a commit naming the task, or a tracked uncommitted change, touches a code file) — the ### Failure branches table: only the implement stage hands a task to review. Shell rewrites of stage: in a record are refused like status:, recognised by the values too. Tests in tests/test-analysis-gate.sh.
  • Router: stages, per-stage batches and thresholds, candidates, models, headless output (scripts/tw-next.py). Every task-bound decision carries stage; the output adds batch (the router's default) and batch_cap, candidates for the chosen stage — every waiting task in router order with priority, effective priority, how many tasks it unblocks, depends_on, seam paths and the areas they fall in, size hints, the candidates it overlaps (tw-parallel.py's seam rule) and those it depends on — so a session picks its batch without reading task files; stages (per stage: waiting, due, why), work_available (the one predicate for "what runs" and "nothing to do"), model, command, select and headless (run, prompt, model, a note) for one stage per CI job. --stage S [--task IDS] answers a stage command (request: ok, batch, candidates, hint; an explicit stage ignores min). New settings, validated by the installer: process.stages.<stage>.batch (1–10, default 1), .min (1–10, default 1) and .max_wait_hours (0–168, default 0) — a stage that is not due (fewer than min waiting, no P0/P1 task, the oldest younger than max_wait_hours) moves behind the due work, and runs when nothing else is due; process.models.<stage> (opus, sonnet, haiku, fable or a claude-… id; unset = the role files' model:), passed as the agent tool's model on the stage's role spawns; process.router.select: script | agent (default script: the script selects and counts, the session picks the batch from the candidates; agent: the session reads the task records itself — more tokens, for records the script cannot read). Defaults behave as before stages. Tests in tests/test-tw-next.sh (stage field vs evidence, legacy tasks, resume rules, thresholds, batches, candidates, requests, models, headless) and tests/test-installer.sh.
  • The journal records the stage and the chosen batch: outcomes.jsonl gets stage (from a Stage: analyze|red-tests|implement|review|verify|flow line), batch and batch_reason (from Batch: T-3 T-7 - <why>). Tests in tests/test-journal.sh.
  • Tokens per stage. Stop and SubagentStop lines in outcomes.jsonl carry tokens: the model usage of the agent's transcript so far, per model (input, output, cache_read, cache_write) — the session's own lines from transcript_path (sidechains left out), a subagent's from agent_transcript_path; a message the transcript repeats per content block is counted once, and the transcript is read incrementally (offset under .teamwright/logs/.tokens/). teamwright-metrics adds per stage (journal.stages, a Per stage table in Markdown): sessions grouped by the stage they declared, duration, tool calls of every agent, outcomes, tasks taken, tokens (per session, per task, per model) and, with the new --prices <json> ($ per million tokens per model regex), a cost; a session that sent no tokens is no data, never 0. /teamwright:improve reads it as a signal (model or batch per stage). docs/process/metrics.md, gates.md (journal). Tests in tests/test-journal.sh, tests/test-metrics.sh.
  • Review gate check 7: one reviewed task spawns at most two new tasks (lifecycle.md §6 — until now only a line in the review skill). A new docs/tasks/<ID>.md created while the session's latest spawn by its main agent is a code-reviewer for task R counts toward R's review (the reviewer's rca task and the orchestrator's follow-up task alike; a later spawn of another role closes the window); the third is refused with the exit — add to an existing record, one umbrella task, or ask the owner. Gate-first warns. Limit TEAMWRIGHT_REVIEW_NEW_TASKS (2); ledger .teamwright/sessions/<R>.new-tasks.jsonl. gates.md, the review skill. Tests in tests/test-review-gate.sh.
  • The recommended models per stage and stage thresholds are offered. tw-next.py --json reports recommended (_config.PROCESS_RECOMMENDED, now with stages: batch 1, min 1, max_wait_hours 0 per stage, beside the models analyze/implement/review/verify opus, red-tests sonnet). adopt and init ask about the models and thresholds in their settings block, /teamwright:settings shows the recommended column from it and offers the keys still at a different default, /teamwright:status names them. Tests in tests/test-tw-next.sh.
  • tests/test-usage-example.sh: the autonomous-run workflow in docs/usage.md is parsed (PyYAML, or a subset parser where it is missing), its route step is run against a real project (tw-next.py --json, the headless and reason fields it reads, run=false with nothing to do), every steps.route.outputs.* another step reads is printed, the dispatched file is the one the docs name, and the claude flags exist when the CLI is installed. It found a real error: run: echo "router: …" is not valid YAML — the step now passes the reason through env (also no template expansion inside the shell).
  • Release channels: stable and experimental. The branch next is published as a second marketplace, teamwright-next (/plugin marketplace add smykov/teamwright#next, /plugin install teamwright@teamwright-next); it carries no version, so every pushed commit is an update. scripts/check-versions.py (in make lint, and make release-check) applies the rule by the marketplace name, not the branch: teamwright — the versions agree and have a changelog section (unchanged), teamwright-<channel> — no version anywhere. The installer records a channel kit's version as the 12-hex commit Claude Code computes (from the git checkout, or installed_plugins.json for an installed copy), and enables its channel in .claude/settings.json only when no other channel of teamwright is enabled there or in settings.local.json — two enabled copies with one name do not mix. README (Channels), CONTRIBUTING (Branches and channels), docs/usage.md. Tests: tests/test-release-check.sh, tests/test-installer.sh.

Changed

  • resume means unfinished work in the tree: tracked changes, or a session that ended interrupted. A current task on a clean tree no longer forces resume: its stage is recorded and committed, and it is routed to that stage (continue). In IN_PROGRESS the router's role follows the stage (test-writer at red-tests, developer at implement). tests/test-tw-next.sh updated.
  • docs/process/sessions.md §8 (stages, the stage field, selection, batches, thresholds, models, the stage-command protocol, autonomous runs), §5 (model per stage); lifecycle.md, gates.md, plugin.md, AGENTS.md, the flow, parallel, settings and status skills, design-decisions.md, docs/usage.md (an example workflow, one stage per job) and the README describe it.
  • tw-next.py no longer prints a BrokenPipeError when its reader stops early (| grep -q).

Fixed

  • Router: only a linked worktree is a per-task tree. An autonomous run checks out the whole project on a task branch (task/T-12-run3); the router took the branch name for a parallel worktree of T-12, refused verify for it ("merge it from the main tree") and did not see the other task of the batch. The per-task view now needs a linked worktree (its git dir differs from the common dir).
  • Session check: a plugin loaded from a directory. claude --plugin-dir and the Agent SDK load the plugin without the registry, so the SessionStart check told an autonomous session the plugin was not installed. TEAMWRIGHT_PLUGIN_DIR names that directory; the check takes the plugin from there.
  • Headless: one owner decision no longer stops the chain of jobs (roast B1). Owner decisions (a BLOCKED question, an escalated review, NEEDS_OWNER) come before new work in the router's order, and headless.run was false for any decide — one open question stopped every autonomous job although work_available was true. headless now takes the first piece of work in router order and lists the pending decisions (decisions, also top level); run is false only when no work is left, the team is paused, the tree holds uncommitted changes of no task, or a pass ended. A start with process.autonomy.start: ask is skipped (nobody answers the start question in CI; run=false with that reason when nothing else is left), and headless.prompt carries no task ids: the stage session asks the router and picks its batch from the candidates, within the stage's cap. The interactive answer is unchanged. Tests in tests/test-tw-next.sh (headless), tests/test-usage-example.sh.
  • Gate evidence survives a job boundary (roast B2). The review gate needed .teamwright/sessions/<ID>.reviews.jsonl and the reviewer's spawn in tools.jsonl, the verify gate <ID>.verify.jsonl / <ID>.validation.jsonl — all local and gitignored, cleaned by every CI checkout: a job cut after the reviewer's APPROVE (or the validator's PASS) was committed but before the status move made the next job's gate refuse, and the router sent the same stage again, forever. When a gate lets a review, verify or validation record through it now also writes a stamp next to it, docs/tasks/<ID>.<reviews|verify|validation>/<n>.gate.json (writer's session, agent and type, round, verdict, kind/result/commit, whether the reviewer spawn was journaled, the record's sha256), committed with the record; a status move on a checkout without the task's ledger checks the stamp. A record changed after its stamp does not count; only the gates write stamps (state guard, the gates' shell checks); the trade-off — committed and reviewable, but no more tamper-proof than the local ledgers — is in gates.md, Gate stamps. docs/usage.md: the example workflow checks out with clean: false, caches .teamwright/logs (per-stage tokens, outcomes; the ledgers in .teamwright/sessions deliberately not), creates wip/teamwright when it is missing, and stops the chain when a stage made no new commit. Tests in tests/test-review-gate.sh, tests/test-verify-gate.sh (B2), tests/test-usage-example.sh (cache, branch, the no-commit guard run).
  • The stage: field can send a task back to red-tests (roast M1). The router trusted ## Tests being filled over the field, so stage: red-tests set by review or verify ("the tests themselves are wrong") was routed to implement, where the stage gate then refused NEEDS_REVIEW — a path to ESCALATE. The field now wins when it is behind the evidence; the evidence wins only when the field is ahead of it (implement without tests), with a warning. Tests in tests/test-tw-next.sh (stages).
  • The stage gate is no longer opt-in or evidence-free (roast M2). TODO → IN_PROGRESS went straight to implement, a record without the field was never checked, a send-back could clear it, → implement passed with ## Tests empty, perl -pi -e 's/red-tests/implement/' named no key and passed, and failure_branches was computed but never checked. Now: a stage is required on entering IN_PROGRESS (red-tests from the pre-analysis statuses), never cleared, implement needs tests_written, NEEDS_REVIEW needs stage: implement + tests + the failure-branch table when the task changes code, and shell rewrites are recognised by the stage and status values. A task started before stages sets stage: implement with its move to review. Gate-first warns, enforce blocks. The demo walks the stage field and shows the failure-branch denial. Tests in tests/test-analysis-gate.sh (4. the stage field), tests/test-demo.sh.
  • A task that deploys is verified after DRAIN, and the push decision exists on a wip branch (roast M3). The router offered verify for a CODE_COMPLETE task with deploys: true before its code was pushed, and in CI counted unpushed commits against the wip/ branch's own upstream — always 0 after the job's push, so no push decision ever came up. Verify for such a task is now offered only once its last code commit is on the delivery branch (origin/HEAD, else origin/main / origin/master; without a remote nothing waits); the push reason names the tasks waiting for it. Off the delivery branch, unpushed commits are counted against it (state.delivery_ref). docs/usage.md, sessions.md §7–§8, plugin.md. Tests in tests/test-tw-next.sh (M3).
  • The router's answer fits a stage session (roast M4). On a project with 116 tasks tw-next.py --json printed 183 KB, with request and the candidates last — cut off in a truncated preview — and every stage command also read sessions.md §8 (21 KB): the token saving of script selection was eaten by the reading. New --brief (implies --json): request first, the action, stage, batch, model, command, compact candidates (at most max(8, 2 × the batch cap), overlaps among the shown ones, candidates_total), per-stage counts, counts by status, decisions, warnings, headless, alternatives, settings, a compact state — no task list; about 5 KB for 120 tasks. The full JSON puts request first and no longer lists closed tasks (DONE, DEFERRED) in tasks; counts counts every status. The stage commands, /teamwright:router and /teamwright:flow ask with --brief and read the new docs/process/stage-protocol.md (under 4 KB: ask, choose the batch, per task, end block); sessions.md §8 keeps the long explanation and links to it. Tests in tests/test-tw-next.sh (M4: size, order, closed tasks, the skills).
  • Router: a plugin updated without apply no longer misroutes silently (roast M5). The router preferred the project's installed _task.py; one from before 0.12 has no tests_written / task_stage, so every IN_PROGRESS task went to red-tests without a word. Every helper the project's copy lacks is now taken from the plugin's copy (as _config already was), and warnings names the drift and tw-install.py apply. tw-progress.py and tw-blockers.py share the loader. plugin.md. Tests in tests/test-tw-next.sh (M5).
  • Explicit --task ids are capped and checked (roast M6, minor 4). A stage command with named tasks ran all of them, past the stage's batch, in the given order. The router now puts a named task after the one it depends on, keeps at most process.stages.<stage>.batch (the rest is left for the next session, with a warning), and names pairs whose seams overlap (request.warnings). With B1 (prompt without ids, a start that asks the owner skipped) and B2 (the usage example creates wip/teamwright) this closes M6. Tests in tests/test-tw-next.sh (M6).
  • Work in progress stays bounded with analyze batches (roast m1). With stages.analyze.batch above 1 an analyze session moved every task of its batch into IN_PROGRESS, although one .teamwright/current-task per tree serves one at a time, and sessions.md §3 said "the next task starts when the previous one reached a barrier". Analysis may still run ahead, but an analysed task waits in ANALYSIS while another task of the tree is IN_PROGRESS: the router does not offer it (state.wip_waiting; an explicit --task is refused with the reason). Send-backs are not held. sessions.md §3 and §8, lifecycle.md, the analyze skill, the stage protocol. Tests in tests/test-tw-next.sh (m1).
  • max_wait_hours fires for an uncommitted record (roast m2). A waiting task's age came only from its record's last commit, so a record with its move not yet committed never aged and max_wait_hours never fired; it now falls back to the file's modification time. Documented: thresholds hold a stage back, they do not rank due stages by queue length (sessions.md §8, plugin.md). Tests in tests/test-tw-next.sh (m2).
  • Review gate check 7 cannot be bypassed through the shell (roast m3). A task record created by a shell command without status: passed the analysis gate's shell check, and the review gate only counted records created by Edit/Write — the bypass was two commands. Now the analysis gate refuses any shell write that creates or replaces a task record (>, tee, cp / mv / install onto it), and the review gate counts again, by git, when the reviewed task's status leaves NEEDS_REVIEW: every record added in a commit since the reviewer's spawn, or new in the tree since then. gates.md. Tests in tests/test-review-gate.sh (m3), tests/test-analysis-gate.sh.
  • Only <ID>.md is a task record (roast m5). A side file such as docs/tasks/T-089.history.md was parsed as a task (id T-089.history) by the router, the progress report, the parallel and process scripts and tw-req, and its path matched the gates' task-record pattern. Every reader now takes docs/tasks/<ID>.md with <ID> matching TEAMWRIGHT_TASK_ID_RE (_task.is_task_file, _hookio.TASK_FILE_RE and the review/verify/validation patterns). Tests in tests/test-tw-next.sh (m5).
  • The journal takes the end block's Stage: and Batch: (roast m6). The Stop hook took the first Stage: / Batch: line of the final message, so a message that quoted the router's answer or an earlier step above its own end block was journaled under the wrong stage. It now takes the last. Documented why a flow run is journaled as flow (one session, cumulative tokens, no boundary between its stages: per-stage numbers come from stage sessions). sessions.md §2, journal.sh. Tests in tests/test-journal.sh.
  • Session check: a plugin directory still has a version (roast m7). With TEAMWRIGHT_PLUGIN_DIR set the check skipped the registry and with it the version comparison, so an autonomous run on an old plugin checkout against newer project files said nothing. The directory's version — plugin.json version, else its git commit (12 hex, as Claude Code versions a channel build) — is compared with @kit like the registry's; a channel build (a commit, no number) is reported when it differs. docs/usage.md, gates.md. Tests in tests/test-session-check.sh.
  • process.router.select: agent changes what the router prints (roast m8). The setting was only reported: the router listed candidates and a batch either way, so "agent mode" was the script's choice with extra reading. With agent the router now leaves candidates and batch empty (also in request, unless --task names the tasks) and command carries no ids; it still decides pause, resume, the stage and whether there is work. sessions.md §8, plugin.md, the stage protocol. Tests in tests/test-tw-next.sh (m8), together with the tests listed under each finding above.
  • An approval cannot be copied to another task (second roast N1). Copying T-1's reviews/1.md and 1.gate.json into T-2.reviews/ let T-2 move to CODE_COMPLETE with nobody reviewing it — on a fresh checkout, and also with a local ledger (it has no entry for T-2, so the stamp stood in): the stamp did not say which record it belongs to, and the gate never read the record's task:. Stamps now carry task, path and round and count only for that record; the review gate refuses a review record whose task: is not its directory's task when it is written and at the status move, and the verify gate checks an acceptance record's task: at DONE (verify records already were). gates.md (review check 1–2, Gate stamps). Tests in tests/test-review-gate.sh, tests/test-verify-gate.sh (N1).
  • A stamp is no way around a local ledger, and nobody but a gate writes one (second roast N2). A stamp counted whenever the ledger merely lacked that round, so a record and stamp written by python3 passed on the machine that has the ledger; piping a crafted payload into scripts/hooks/review-gate.sh produced a genuine-looking stamp with spawned: true; and cd docs/tasks && cp …, printf > 1.gate.json after a cd, git checkout|restore -- <record>, rsync and dd of= got past the shell check. Now: the stamps are consulted only when this checkout has no ledger of that kind for the task at all (a fresh clone) — where it exists it alone decides, as in 0.11 — and a stamp's spawned: true stands in for the journal only then; the state guard refuses running a hook (bash scripts/hooks/…, ./scripts/hooks/…, cd scripts/hooks && …, an interpreter importing _hookio); the review gate refuses any shell write into a review, verify or validation record directory or a stamp (_hookio.shell_writes_records: follows cd/pushd, redirects, cp/mv/install/rsync/ln including a directory copied into docs/tasks/, rm, dd of=, sed|perl -i, tar|unzip into them, find -delete|-exec, git checkout|restore|mv|rm, git stash pop|apply and git apply|am of changes touching them, sh -c, and python/node/perl/ruby one-liners that name them and write — best effort, documented); the analysis gate also refuses putting back an old version of a task record (git checkout <rev> -- <record>, git restore, git stash pop|apply, git apply|am), dd of= and interpreter one-liners that write one; and the git pre-commit hook refuses a staged new or changed stamp that matches no line of the task's local ledger, where that ledger exists. gates.md says plainly that stamps are not signed and what limits a forged one. docs/usage.md: the example workflow caches .teamwright/logs only — a cache restored from an older job would hold a task's ledger without the round a cut job wrote and refuse it. Tests in tests/test-review-gate.sh, tests/test-verify-gate.sh, tests/test-pre-commit.sh, tests/test-analysis-gate.sh (N2), tests/test-usage-example.sh.
  • Install drift stops autonomous runs; a process document missing the kit's sections is a conflict (second roast N3). The router warned "install drift: _task.py predates the plugin" but headless.run stayed true, so CI ran the new stage commands on 0.11 hooks — no stage gate, no stamps. tw-next.py now also compares the project's scripts/hooks/_hookio.py with the plugin's (public helpers it lacks, read with ast) and the @kit version in installed.json with the plugin's; any drift is listed in install_drift and the warning, and headless.run is false with the reason. And tw-install.py apply on a 0.11 project kept a locally edited docs/process/sessions.md (no stages section) as kept-local and reported "0 conflicts": a locally edited docs/process/*.md that lacks headings of the kit's version is now a conflict naming them (merge, then apply --accept). plugin.md, docs/usage.md. Tests in tests/test-tw-next.sh, tests/test-installer.sh (N3).
  • A squash merge of wip/teamwright is a delivery; the delivery remote need not be origin (second roast N4). The M3 check used ancestry, so after a squash merge (or a rebase) of the wip/ branch into main its tasks were never seen as delivered — no verify for a task that deploys, and the push decision stayed forever — and a remote not named origin meant no delivery branch at all. A task's code now counts as delivered when its last code commit is on the delivery branch, or its changes are: the same patch id (git cherry: rebase, cherry-pick), or every file the commit touches has there its content in the commit or at HEAD (squash); unpushed commits on a wip/ branch are counted the same way. The delivery remote is the current branch's upstream remote, else the only remote, else origin. docs/usage.md says to merge or reset wip/teamwright after a delivery; plugin.md, sessions.md. Tests in tests/test-tw-next.sh (N4: squash, cherry-pick, a remote named upstream).
  • A record write that never lands leaves no evidence (second roast, minor). The review and verify gates wrote the ledger line and the stamp in PreToolUse, before the write; a write another hook then refused, the person cancelled or that failed left an orphan stamp (and a ledger round) that blocked DONE ("written but its file is gone"), and agents cannot remove either. The gates now keep the line and stamp pending (.teamwright/sessions/.pending/) and write them in PostToolUse when the record on disk is what they checked; .claude/settings.json registers both gates for PostToolUse on Edit|Write (merged by tw-install.py apply). A stamp without its record is ignored and removed. gates.md (Gate stamps), the demo. Tests in tests/test-review-gate.sh, tests/test-verify-gate.sh.
  • A trivial change to code needs no failure-branch table (second roast, minor). A version bump or a typo fix in code (analysis: skipped:trivial) could not reach NEEDS_REVIEW without a ### Failure branches table — there is no failure branch to break. The stage gate now skips that requirement for analysis: skipped:trivial (never on an rca task, as for the analysis exit); ## Tests is still required. gates.md (analysis gate check 4), lifecycle.md, sessions.md, the implement skill. Tests in tests/test-analysis-gate.sh.
  • Records approved before 0.12 count on a fresh checkout (second roast, minor). A review, verify or acceptance record written by a 0.11 kit has no stamp, so on a fresh checkout (CI) a task approved or accepted before the upgrade could not move on. Such a record now counts when it was last changed in a commit before the one that moved .teamwright/installed.json from a pre-0.12 kit to a stamping one (0.12.0 or later, or a channel build) and the working copy is that version (_hookio.legacy_record); a record without a stamp committed after the upgrade, edited since, or in a project installed at 0.12 from the start is not. gates.md (Gate stamps, Known limitations). Tests in tests/test-review-gate.sh, tests/test-verify-gate.sh.
  • --brief carries state.wip_waiting (second roast, minor). The analyze skill reads which analysed task waits for which IN_PROGRESS one from state.wip_waiting, but --brief — what stage sessions read — left it out of its compact state. plugin.md. Tests in tests/test-tw-next.sh (m1).
  • An approval from before a VERIFY_FAIL does not carry over (found in an autonomous run). A task that failed verification went back through analysis and, with no code change, flow moved it NEEDS_REVIEW → CODE_COMPLETE on the review round written before the failure; the review gate let it through. The gate now compares the latest review record with the task's latest failed check (a FAIL verify or acceptance record, by the commit that added it, else the file time) and asks for a fresh round. Tests in tests/test-review-gate.sh.
  • Verify and acceptance records stay append-only in gate-first too (found in an autonomous run). A validator edited its verify record after writing it; in gate-first the gate only warned, and the edited record kept no stamp and no ledger line. Editing or overwriting an existing verify or acceptance round is now denied in every mode. Tests in tests/test-verify-gate.sh.
  • tw-gate: the temporary index keeps the index's mtime. The tree id of a dirty working tree was computed on a copy of the index with a fresh mtime, which switched off git's "racy git" re-check: a same-size edit in the second the index was written could look unchanged and reuse the previous PASS. The copy now keeps the original mtime (cp -p). Seen once as a flaky tests/test-tw-gate.sh run in CI.

Full changelog: CHANGELOG.md