Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,15 @@ jobs:
agentic-preflight verify "$ATTESTED_SHA"

test:
# Single combo for fast PR feedback. Run this workflow manually
# (workflow_dispatch) to expand to the full matrix -- useful as a pre-tag
# check, since release.yml only gates publishing after the tag is already
# pushed.
# Linux and Windows on every pull request. macOS is close enough to Linux
# that a Linux-only run catches most of what it would; Windows is not, and
# its failures are the ones a contributor on macOS or Linux is least likely
# to notice before merging. Run this workflow manually (workflow_dispatch)
# to expand to the full matrix -- useful as a pre-tag check, since
# release.yml only gates publishing after the tag is already pushed.
uses: ./.github/workflows/test.yml
with:
os: ${{ github.event_name == 'workflow_dispatch' && '["ubuntu-latest", "macos-latest"]' || '["ubuntu-latest"]' }}
os: ${{ github.event_name == 'workflow_dispatch' && '["ubuntu-latest", "macos-latest", "windows-latest"]' || '["ubuntu-latest", "windows-latest"]' }}
python-versions: ${{ github.event_name == 'workflow_dispatch' && '["3.11", "3.12", "3.13"]' || '["3.13"]' }}
coverage: true

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
# and pull request.
uses: ./.github/workflows/test.yml
with:
os: '["ubuntu-latest", "macos-latest"]'
os: '["ubuntu-latest", "macos-latest", "windows-latest"]'
python-versions: '["3.11", "3.12", "3.13"]'

build:
Expand Down
18 changes: 9 additions & 9 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,15 +59,15 @@ jobs:
- name: Test (pytest)
run: uv run pytest ${{ inputs.coverage && '--cov=agentic_preflight --cov-report=term-missing --cov-report=xml' || '' }}

# Deliberately not pinned to a specific os/python-version. The callers
# choose the matrix, so naming a combination here would mean two files
# having to agree with nothing enforcing it. Relies instead on the
# invariant that the only caller passing coverage: true, ci.yml, passes a
# single combination on push events. If that ever stops being true, these
# steps would run more than once and upload-artifact would reject the
# duplicate name.
# Pinned to one runner OS rather than to a caller-supplied combination.
# The badge is a single artifact, so exactly one matrix leg may produce
# it; this previously relied on ci.yml passing a single combination on
# push events, which stopped being true when Windows joined the matrix.
# A self-contained condition cannot drift out of step with a caller.
# Python version is still left free: only one is passed on push.
- name: Generate coverage badge
if: inputs.coverage && github.event_name == 'push'
if: inputs.coverage && github.event_name == 'push' && runner.os == 'Linux'
shell: bash
run: |
coverage_percent="$(uv run coverage report --format=total)"
if [ "$coverage_percent" -ge 90 ]; then
Expand All @@ -86,7 +86,7 @@ jobs:
--output coverage.svg

- name: Upload coverage badge
if: inputs.coverage && github.event_name == 'push'
if: inputs.coverage && github.event_name == 'push' && runner.os == 'Linux'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coverage-badge
Expand Down
55 changes: 55 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,61 @@ All notable changes to Agentic Preflight are documented here. This project follo

## Unreleased

### Added

- Native Windows support. Windows 10 or newer joins macOS and Linux as a supported
platform, is covered by the pull-request CI matrix, and installs with the new
`install.ps1` / `uninstall.ps1` scripts. WSL is no longer required.

- `install.ps1` and `uninstall.ps1`, PowerShell counterparts to the bash installers
with identical behaviour and ordering, including the deliberate pause before
uninstalling so repository state can be cleaned up while the skill still exists.

### Changed

- Stage, review, and setup commands are now executed directly as a program and its
arguments when they contain no shell grammar. A shell is used only for commands
that need one: pipes, `&&`, redirection, globs, expansions, variable assignments,
or a program that does not resolve. This removes the hard dependency on a POSIX
shell for the common case, and takes the shell out of the injection surface of the
one code path that runs repository-controlled strings.

A consequence worth noting: a directly executed command does not source your shell
profile, where `bash -lc` did. A command whose program is only on `PATH` via
`~/.profile` still resolves through the shell fallback and is unaffected, but a
stage that depended on a profile also exporting environment now runs without it.
This matches how the same command behaves in CI.

On Windows, the shell fallback is the one Git for Windows installs, located through
the Git installation rather than `PATH`, because `bash.exe` on `PATH` is normally
the WSL launcher and would run stages against a different filesystem.

- All file and subprocess text is now read and written as UTF-8 explicitly rather
than in the platform's default encoding, and generated files use Unix line endings.
Under the Windows default of `cp1252`, a non-ASCII path or review finding could
previously corrupt git output or raise `UnicodeEncodeError`.

### Fixed

- Restored attestation reuse on Git 2.30 through 2.37. Those releases predate
`merge-tree --write-tree` and report the unknown flag on stderr *while exiting
zero*, so the fallback written for them was never reached: `merge_tree` returned
"no clean merge" for every comparison, and `start` silently reopened review instead
of reusing a still-valid green attestation after a base synchronization. The
interface is now chosen from the reported Git version rather than by recognising an
error message, which also stops the detection breaking under a non-English locale
where Git's messages are translated.

- Copied `[worktree] copy_files` entries are now restricted to their owner on Windows
using an ACL, as `os.chmod` there does not affect permissions. A copy that cannot be
restricted is deleted and refused rather than left readable, so the guarantee that
makes copying a local `.env` acceptable is never silently unmet.

- Replacing a run document no longer fails when another process briefly holds it open,
which POSIX `rename` permits but Windows does not. The replace is retried with
backoff and still raises if the file stays held, so a lost write cannot pass as a
recorded state transition.

## [0.4.0] - 2026-08-13

### Changed
Expand Down
28 changes: 22 additions & 6 deletions COMPATIBILITY.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,35 @@
# Compatibility policy

Agentic Preflight supports Python 3.11, 3.12, and 3.13 on macOS 15 or newer and on
Linux. Windows is not supported because the implementation requires Bash, `fcntl`, and
other POSIX behavior. Git 2.30 or newer is required.
Agentic Preflight supports Python 3.11, 3.12, and 3.13 on macOS 15 or newer, on Linux,
and on Windows 10 or newer. Git 2.30 or newer is required.

Windows support is native: it does not go through WSL, and it does not require a POSIX
shell for ordinary use. Two Windows-specific notes are worth knowing before adopting it:

- **A stage command containing shell grammar needs Git Bash.** Commands are executed
directly as a program and its arguments wherever possible, so `pytest`,
`ruff check .`, and `npm run test` need no shell at all. A command using pipes,
`&&`, redirection, or globs falls back to a shell, and on Windows that shell is the
one Git for Windows installs. It is found through the Git installation rather than
through `PATH`, because `bash.exe` on `PATH` is normally the WSL launcher, which
would run the command against a different filesystem.
- **Symlink-related behaviour requires Developer Mode.** Creating symlinks is a
privileged operation on Windows by default. This affects repositories that contain
symlinks; nothing else in the tool creates one.

## Validation tiers

The supported combinations receive different validation frequencies so pull-request
feedback stays fast:

- Pull requests and pushes to `main` run on `ubuntu-latest` with Python 3.13.
- Pull requests and pushes to `main` run on `ubuntu-latest` and `windows-latest` with
Python 3.13. Windows is in the pull-request job rather than a scheduled one because
it is the platform whose failures are least likely to be noticed by a contributor
working on macOS or Linux.
- A scheduled regression run covers the oldest supported boundary, macOS 15 with
Python 3.11, every Monday and Thursday.
- Manual CI runs and release tags cover Python 3.11, 3.12, and 3.13 on both
`ubuntu-latest` and `macos-latest`.
- Manual CI runs and release tags cover Python 3.11, 3.12, and 3.13 on
`ubuntu-latest`, `macos-latest`, and `windows-latest`.

A platform is supported even when it is not in the pull-request job. A failure that is
specific to a supported combination is a release blocker and should be fixed with the
Expand Down
15 changes: 8 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,9 @@ agentic-preflight init
```

When working from this source checkout, `./install.sh` installs or updates the CLI and
all five supported agent integrations in one step. Pass integration names to choose
only the coding agents you use.
Run `./uninstall.sh` to remove the managed skills and CLI. It pauses first so you can
all five supported agent integrations in one step (`.\install.ps1` on Windows). Pass
integration names to choose only the coding agents you use.
Run `./uninstall.sh` (or `.\uninstall.ps1`) to remove the managed skills and CLI. It pauses first so you can
enter `agentic-preflight:uninstall` in every initialized repository; that trigger
removes the repository configuration and managed hook logic while preserving unrelated
hooks, run history, and attestations.
Expand Down Expand Up @@ -239,11 +239,12 @@ and the behavior behind the less-obvious keys live in the

## Requirements

- A supported macOS/Linux and Python combination from the
- A supported macOS/Linux/Windows and Python combination from the
[compatibility policy](https://github.com/elanthus/agentic-preflight/blob/v0.4.0/COMPATIBILITY.md)
(Windows is not supported)
- git 2.30+
- Bash
- A POSIX shell, only for stage commands that use shell grammar such as pipes or `&&`.
Plain commands run without one. On Windows this is the shell Git for Windows
installs; nothing extra to set up.
- `gh` (optional; used for pull requests, hosted checks, and merge verification during
cleanup; it owns auth, we never handle credentials)

Expand Down Expand Up @@ -285,7 +286,7 @@ make different tradeoffs about who owns the workflow and what the durable record
| Local architecture | Runs a daemon, proxy repository, SQLite store, TUI, and disposable worktrees | Runs as a daemonless JSON-over-stdout CLI with file-based state and an agent skill |
| Validation checkout | Always isolates the pipeline in a disposable worktree | Offers in-place, reusable isolated, and fresh strict worktree modes |
| Hosted lifecycle | Creates PRs across several forges, monitors CI, and can auto-fix failures | Keeps hosted lifecycle outside the stateful core and delegates GitHub operations to the active agent and `gh` |
| Runtime and platforms | Ships as a Go application for macOS, Linux, and Windows | Ships as a Python package for supported macOS and Linux combinations |
| Runtime and platforms | Ships as a Go application for macOS, Linux, and Windows | Ships as a Python package for supported macOS, Linux, and Windows combinations |

## Credits

Expand Down
25 changes: 25 additions & 0 deletions agentic_preflight/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

import sys

import click

from .cli_integrations import register as register_integrations
Expand All @@ -12,10 +14,33 @@
__all__ = ["command", "main"]


def _use_utf8_streams() -> None:
"""Read and write the agent protocol as UTF-8 regardless of the locale.

The envelope itself is ASCII-safe, but the prose written to stderr and the
findings read from stdin are not: both carry file paths and review text
from the repository. Left to the platform default these become ``cp1252``
on Windows, where a single non-ASCII path turns a working command into a
``UnicodeEncodeError``.

Streams that cannot be reconfigured — a captured buffer under test, a pipe
already wrapped by a caller — are left alone rather than replaced.
"""
for stream in (sys.stdin, sys.stdout, sys.stderr):
reconfigure = getattr(stream, "reconfigure", None)
if reconfigure is None:
continue
try:
reconfigure(encoding="utf-8")
except (OSError, ValueError):
continue


@click.group(context_settings={"help_option_names": ["-h", "--help"]})
@click.version_option(package_name="agentic-preflight")
def main() -> None:
"""Agent-driven quality gate. Every command prints one JSON object."""
_use_utf8_streams()


register_runs(main)
Expand Down
2 changes: 1 addition & 1 deletion agentic_preflight/cli_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ def approval_check(
from . import gitx

try:
reviews = json.loads(reviews_file.read_text())
reviews = json.loads(reviews_file.read_text(encoding="utf-8"))
result = approvalmod.evaluate(
gitx.repo_root(Path.cwd()),
base_sha=base_sha,
Expand Down
2 changes: 1 addition & 1 deletion agentic_preflight/cli_runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def context(section: str) -> None:
@command
def submit_findings(file_path: str) -> None:
"""Record the agent's findings for the active stage."""
raw = sys.stdin.read() if file_path == "-" else Path(file_path).read_text()
raw = sys.stdin.read() if file_path == "-" else Path(file_path).read_text(encoding="utf-8")
try:
payload = json.loads(raw)
except json.JSONDecodeError as exc:
Expand Down
91 changes: 91 additions & 0 deletions agentic_preflight/filelock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
"""An exclusive advisory lock on a file, portable across platforms.

The lock guards the read-modify-write window around a run document. Two
parallel ``Bash`` calls in a single agent turn are a real hazard, not a
theoretical one, so the semantics that matter are pinned here rather than left
to whichever primitive a platform happens to offer:

* **Exclusive.** One holder at a time, across processes.
* **Blocking.** Waiting is correct; failing a concurrent caller is not. The
competing writer is another invocation of this tool doing legitimate work,
and the second defence against a *logically* stale write is ``expect_seq``,
not lock contention.
* **Released on any exit.** Including an exception, which is the path that
leaves the on-disk state untouched.

``fcntl.flock`` provides all three directly. Windows has no ``fcntl``; the
equivalent is a mandatory byte-range lock through ``msvcrt``, which differs in
two ways that have to be handled rather than papered over. It locks a *range*
rather than a whole file, so a single conventional byte at offset zero stands in
for the file. And its blocking mode is not truly blocking: it retries for about
ten seconds and then raises, so it is driven here in a loop to restore the
indefinite wait that ``flock`` gives for free.
"""

from __future__ import annotations

import os
import sys
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path

# ``sys.platform`` rather than ``os.name``: it is the form a type checker
# narrows on, so each platform's branch is checked against its own standard
# library instead of being reported as a missing attribute on the other's.
if sys.platform == "win32": # pragma: no cover - platform-selected at import
import msvcrt
else: # pragma: no cover - platform-selected at import
import fcntl

# The byte the Windows range lock is taken on. Every participant locks the same
# one, so the choice only has to be consistent, and offset zero always exists
# once the file has been created.
_LOCK_BYTE = 1


def _acquire(handle) -> None:
if sys.platform == "win32":
handle.seek(0)
while True:
try:
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, _LOCK_BYTE)
return
except OSError:
# LK_LOCK gives up after roughly ten seconds. A contended lock
# is normal here, so keep waiting rather than failing a caller
# that is simply second in line.
continue
else:
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def _release(handle) -> None:
if sys.platform == "win32":
handle.seek(0)
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, _LOCK_BYTE)
else:
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)


@contextmanager
def exclusive(path: Path | str) -> Iterator[None]:
"""Hold an exclusive lock on ``path`` for the duration of the block.

The file is opened for append rather than write: truncating it would be a
second, unsynchronised mutation of the very file being used to synchronise,
and the Windows range lock needs a byte to exist to lock.
"""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "ab+") as handle:
if sys.platform == "win32" and os.fstat(handle.fileno()).st_size < _LOCK_BYTE:
# Locking past end-of-file is permitted, but writing the byte keeps
# the lock range backed by real content on every platform.
handle.write(b"\0")
handle.flush()
_acquire(handle)
try:
yield
finally:
_release(handle)
Loading
Loading