diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..391e3b1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,28 @@ +name: Feature request +description: Suggest an improvement or new capability for writ +labels: ["enhancement"] +body: + - type: textarea + id: use-case + attributes: + label: What are you trying to do? + description: The problem in your propose -> approve -> implement -> attest -> merge flow, not the solution. A workflow that needs a workaround today is a great answer. + validations: + required: true + - type: textarea + id: proposal + attributes: + label: What should writ do? + description: The behavior you want, ideally as commands and output. If you have a sketch of a writ TOML change, include it. + placeholder: | + $ writ + + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: What do you do today instead? + description: Existing writ features, raw git, scripts, or other tools you use to get by without this. + validations: + required: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34c32ec..06f2a1c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,35 @@ jobs: steps: - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + cache: true + + - name: Gofmt + run: | + unformatted="$(gofmt -l .)" + if [ -n "$unformatted" ]; then + echo "These files are not gofmt-clean:" + echo "$unformatted" + exit 1 + fi + + - name: Build + run: go build ./... + + - name: Vet + run: go vet ./... + + - name: Test + run: go test ./... -race -count=1 + + test-macos: + name: Test (macOS) + runs-on: macos-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 with: go-version-file: go.mod diff --git a/AGENTS.md b/AGENTS.md index 177b3ea..13fda86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,9 +8,10 @@ This file is the project's committed home for project-intrinsic agent knowledge: - The writ lifecycle is `propose` (agent drafts a complete writ, unapproved) → `approve` (human tightens it in `$EDITOR`, `--yes` to skip) → implement → `attest`/`unattest ` (claim or clear a criterion as met, `--human` for a human claim) → `status`/`merge`, with `discard` throwing away an unwanted open writ (rejected proposal, abandoned work, corrupt state file) so `propose` is unblocked. There is no `writ open`: a hand-authored writ from a blank file was the failure mode this replaced. `writ.Writ.Approved` (nil = proposed) and `Criterion.Attestation` gate `gate.Decide`'s auto-merge path; `render.Status` keeps agent claims visually distinct from human confirmation and from machine-verified evidence — never blur the three. Intake (`propose` and `approve`) validates via `writ.Writ.ValidateProposal`, which additionally refuses criteria arriving with `met`/`attestation` set — claims exist only after approval, so pre-assessed drafts cannot self-bless through `approve --yes`; plain `Validate` stays the validator for mid-flight state. Author-written TOML (stdin/`--file` drafts, `$EDITOR` saves) parses through exported `writ.Parse`, which refuses keys naming no Writ field so typos are reported instead of silently dropped; `writ.Load` deliberately stays lenient for Save-written `.writ/current.toml` reads so version skew cannot strand an open writ. - `.writ/current.toml` is runtime output, never committed. Package-level constant `writPath` in `internal/writ/writ.go` is the source of truth for its location. Two defenses keep it that way: `propose` seeds `.writ/` into the repo-local `.git/info/exclude` (best-effort, skipped when an ignore rule already covers it; resolved via `--git-common-dir` because git reads info/exclude only from the common dir — seeding a linked worktree's own gitdir silently does nothing) so blanket `git add -A` cannot track the state file, and `decide()` (status/merge) refuses a git-indexed state file (`cmd/writ.writStateTracked`, staged counts too) with `git rm --cached` guidance — a tracked copy leaves stale state on base and blocks checkout with raw git errors. - `cmd/writ` commands signal a specific process exit code (0 auto-mergeable, 1 needs human, 2 no writ open) by returning the unexported `exitCodeErr{code}` from `RunE`; `main()` unwraps it via `errors.As` before falling back to the default exit-1 path. Command tests that need a real repo `os.Chdir` into a `t.TempDir()` git repo, since `repoRoot()` shells out to `git rev-parse --show-toplevel` against the process cwd rather than taking a directory argument. +- The README's FAQ section states verifiable facts about the codebase ("no HTTP client anywhere in it or its two dependencies", "reads no API keys — only `EDITOR`/`VISUAL`/`NO_COLOR`/`WRIT_VERIFY_TIMEOUT`", "only child processes are `git` and POSIX `sh`"), and its `$ writ status` fence embeds byte-real output for the same writ the propose example creates. These rot silently when reality changes: adding a dependency, an env read, a subprocess, or changing `render.Status` output means re-verifying/updating them (`rg "net/http|Getenv|LookupEnv|exec.Command" --type go` covers the FAQ trio). Capture doc examples by running the real binary in a throwaway repo, never by hand-typing output. - This repo is on the captain's **personal** GitHub account (`Laaaaksh`). The default `gh` login has changed between the personal and work profiles over time (as of 2026-08-22 the personal account is the default and `~/.config/gh-personal` no longer exists), so never assume either way: verify with `gh api user --jq .login` (must print `Laaaaksh`) before any GitHub CLI operation, and if it prints a work account, export `GH_CONFIG_DIR="$HOME/.config/gh-personal"` first — recreating that dir via `gh auth login` if needed. The default branch is `master`, not `main`. - A repo commit-identity guard hook requires the git author email to match the personal class (`laksh.sadhwani07@gmail.com`) for this remote — set via local (not global) `git config user.email` if a commit is rejected. -- Release packaging: `.github/workflows/ci.yml` (build/vet/test on PR and push to `master`), `.github/workflows/release.yml` (goreleaser on `v*` tag push), and `.goreleaser.yml` (darwin/linux amd64/arm64, publishing a Homebrew formula to `Laaaaksh/homebrew-writ` via `brews:`) mirror the sibling `Laaaaksh/vessel` repo's setup. Releasing requires a `HOMEBREW_TAP_TOKEN` repo secret — without it goreleaser falls back to `GITHUB_TOKEN`, which cannot write to the separate tap repo and the release fails (this is what broke vessel's first release). The tap repo's README.md is hand-maintained (goreleaser writes only `Formula/`), so when writ's README install instructions change, mirror them there too — it kept serving a stale broken one-liner for releases after the main README was fixed (corrected 2026-08-22). `.github/dependabot.yml` runs weekly `gomod` + `github-actions` version-update PRs on top of the GitHub-side Dependabot alerts/security-fixes toggles (those live in repo settings, not files); expect its PRs against `master`. `.github/workflows/codeql.yml` runs CodeQL code scanning (languages go,actions) on PRs, pushes to `master`, and a weekly cron — its status check is named `CodeQL`; activity is proven by `/code-scanning/analyses` entries, not default-setup state. +- Release packaging: `.github/workflows/ci.yml` (ubuntu build/vet/gofmt/test plus a separate `Test (macOS)` job for the darwin release target; the macOS job must stay its own job, not a matrix on `test`, because branch protection requires the literal check name `Test` and matrix suffixes like `Test (ubuntu-latest)` would never satisfy it — the gofmt gate lives inside `Test` so unformatted code cannot land even though only that one check is required), `.github/workflows/release.yml` (goreleaser on `v*` tag push), and `.goreleaser.yml` (darwin/linux amd64/arm64, publishing a Homebrew formula to `Laaaaksh/homebrew-writ` via `brews:`) mirror the sibling `Laaaaksh/vessel` repo's setup. Releasing requires a `HOMEBREW_TAP_TOKEN` repo secret — without it goreleaser falls back to `GITHUB_TOKEN`, which cannot write to the separate tap repo and the release fails (this is what broke vessel's first release). The tap repo's README.md is hand-maintained (goreleaser writes only `Formula/`), so when writ's README install instructions change, mirror them there too — it kept serving a stale broken one-liner for releases after the main README was fixed (corrected 2026-08-22). `.github/dependabot.yml` runs weekly `gomod` + `github-actions` version-update PRs on top of the GitHub-side Dependabot alerts/security-fixes toggles (those live in repo settings, not files); expect its PRs against `master`. `.github/workflows/codeql.yml` runs CodeQL code scanning (languages go,actions) on PRs, pushes to `master`, and a weekly cron — its status check is named `CodeQL`; activity is proven by `/code-scanning/analyses` entries, not default-setup state. `.github/ISSUE_TEMPLATE/` holds GitHub issue-form YAML (`bug_report.yml`, `feature_request.yml`) plus `config.yml`; keep new templates in that schema rather than markdown bodies. `CHANGELOG.md` is the human-readable release record in Keep a Changelog format — GitHub release bodies are just goreleaser commit dumps — so land user-facing changes with an `[Unreleased]` entry as part of the change itself, and when cutting a tag move `[Unreleased]` into a dated section with compare links (backfilled 0.1.0–0.2.4 from per-tag `git log --no-merges ..` ranges, since goreleaser's release notes misattribute commits across the tightly-spaced v0.2.x tags). - `go.mod`'s `go` directive is the single source of the Go toolchain for CI and releases: both workflows pass `go-version-file: go.mod` to actions/setup-go, so that line decides which compiler (and which stdlib security patches) ship inside released binaries — v0.2.x shipped go1.22.12, EOL since Feb 2025, before the directive was bumped to a supported line. Audit it against the two-current-versions support policy whenever cutting a release. - `master` is branch-protected: required `Test` status check (strict), enforced for admins, conversation resolution required, no direct pushes — land every change through a PR and wait for its `Test` check (`gh pr merge` honors the rules); `CONTRIBUTING.md` documents the contributor-facing flow. Repo settings keep `delete_branch_on_merge` on: after any PR merges (including this run's own gnhf branch PRs), GitHub deletes that head branch from origin — re-push the local branch to recreate it; don't misread the vanished origin ref as data loss. Wiki/Projects tabs are deliberately disabled; don't re-enable them when auditing settings. - `cmd/writ/main.go`'s `version`, `commit`, and `date` must stay package-level `var`s (with `default*` const placeholders), never `const` — goreleaser's `-X main.version=...` ldflags silently no-op against a `const` string, so every released binary would misreport itself. `versionString` is the single place that renders them; it omits the `(commit ..., built ...)` suffix when both are still at their defaults. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c9b4c77 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,116 @@ +# Changelog + +All notable changes to writ are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- FAQ answering what AI-coding-agent users actually ask: writ needs no AI subscription or API key, works with any agent that can pipe TOML and run shell commands, allows one open writ per repo (parallel agents need separate git worktrees), and each worktree should use its own provider key or subscription because sharing one invites rate-limit retry storms. +- Real rendered `$ writ status` example output and a pasteable agent-discipline snippet for your repo's `AGENTS.md` / `CLAUDE.md` driving the propose → implement → attest loop. +- Feature-request issue template alongside the bug template. +- CI runs a gofmt gate inside the required `Test` check and a dedicated macOS job covering the darwin/arm64 release target. +- This changelog. + +## [0.2.4] - 2026-08-22 + +### Security + +- Released binaries now build with a supported Go toolchain (`go.mod`'s `go` directive bumped off EOL Go 1.22); both workflows resolve their compiler from that directive. +- CodeQL code scanning added as an advanced-setup workflow covering Go and GitHub Actions. + +### Fixed + +- Generated Homebrew formula now passes `brew style` and `brew audit --strict`. + +### Changed + +- Documented the fork-and-PR contribution flow under branch protection in `CONTRIBUTING.md`. +- Pinned `goreleaser-action` to `~> v2` instead of the deprecated floating latest. + +## [0.2.3] - 2026-08-22 + +### Fixed + +- `writ merge` from a linked git worktree printed an Auto-mergeable verdict and then died on git's raw "'main' is already used by worktree" fatal; it now refuses up front with detach-or-remove instructions. + +### Security + +- Added `SECURITY.md` with a private vulnerability-disclosure channel; enabled Dependabot vulnerability alerts, automated security fixes, and private vulnerability reporting. +- Weekly dependency-update PRs via `.github/dependabot.yml` (`gomod` + `github-actions`). + +### Changed + +- Bumped `goreleaser-action` from v6 to v7. + +## [0.2.2] - 2026-08-22 + +### Fixed + +- Clear failure message when git itself is missing from PATH (previously misreported as "not inside a git repository"). +- README corrected: writ requires git and POSIX sh and ships for macOS/Linux only. + +## [0.2.1] - 2026-08-22 + +### Security + +- CI workflow hardened to least-privilege `permissions: contents: read`. + +### Fixed + +- Install docs cover the explicit trust step current Homebrew requires before using an untapped third-party formula. + +## [0.2.0] - 2026-08-22 + +Public-release hardening of the core shipped in 0.1.x. + +### Added + +- `writ discard` to throw away an unwanted open writ (rejected proposal, abandoned work, corrupt state file) so `propose` is unblocked without hand-deleting state. +- `--version` / `-v` root flag. +- Strict TOML intake: author-written drafts (stdin, `--file`, `$EDITOR`) reject keys naming no writ field, so typos are reported instead of silently dropped. +- Shell-completion install instructions for bash/zsh/fish using an idiom that works on macOS's stock bash 3.2, plus documentation of color behavior when output is scripted. +- The exit-code contract (0 auto-mergeable, 1 needs human, 2 no writ open) documented across all six deciding commands. +- `WRIT_VERIFY_TIMEOUT` knob documented (10-minute default) and guarded against a zero-value footgun. + +### Fixed + +- `.writ/current.toml` state file can no longer be accidentally committed: seeded into the repo-local git exclude at propose time (working inside linked worktrees too), with a refusal and untrack guidance if a tracked copy appears at decide time. +- Corrupt state files now point every reading command at `writ discard` instead of surfacing raw errors. +- Commands outside a git repository fail loudly instead of silently treating the current directory as the repo. +- status/merge handle brand-new repos with zero commits (unborn HEAD). +- `approve` works when `EDITOR` contains arguments (e.g. `code -w`). +- Bare interactive `writ propose` refuses up front with actionable remedies instead of hanging silently. +- Dirty-tree merge guard no longer weakened by git's `status.showUntrackedFiles=no`. +- Vacuous verdicts closed: deleted criteria no longer count as "all met"; criteria with empty ids or text are rejected; whole-repo-scoped tampered writs are refused; unresolvable declared bases are refused; driving a writ from its own base branch is refused. +- Agents cannot self-bless acceptance claims: intake rejects criteria arriving pre-assessed (`met`/`attestation` set). +- README documents creating a feature branch off base during implement, matching writ's own refusals. + +## [0.1.1] - 2026-08-21 + +### Fixed + +- Generated Homebrew formula writes into the tap's `Formula/` directory so it cannot be shadowed by a root-level formula later. + +## [0.1.0] - 2026-08-21 + +### Added + +- Initial public release of `writ`, a git-native contract between you and your coding agent. +- Lifecycle commands: `propose`, `approve`, `attest`/`unattest `, `status`, `merge`, `version`. +- Drift detection diffing committed work against the writ's declared scope. +- Evidence-based verification running each criterion's verify command and gating the merge decision on results. +- Human attestation kept visually distinct from machine claims and from agent attestations. +- Cross-platform releases (darwin/linux × amd64/arm64) with Homebrew tap publishing. + +[Unreleased]: https://github.com/Laaaaksh/writ/compare/v0.2.4...HEAD +[0.2.4]: https://github.com/Laaaaksh/writ/compare/v0.2.3...v0.2.4 +[0.2.3]: https://github.com/Laaaaksh/writ/compare/v0.2.2...v0.2.3 +[0.2.2]: https://github.com/Laaaaksh/writ/compare/v0.2.1...v0.2.2 +[0.2.1]: https://github.com/Laaaaksh/writ/compare/v0.2.0...v0.2.1 +[0.2.0]: https://github.com/Laaaaksh/writ/compare/v0.1.1...v0.2.0 +[0.1.1]: https://github.com/Laaaaksh/writ/compare/v0.1.0...v0.1.1 +[0.1.0]: https://github.com/Laaaaksh/writ/releases/tag/v0.1.0 diff --git a/README.md b/README.md index a5c1154..b7c7f46 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ refuses to accept one - at intake and again before any status or merge decision. [![Platform](https://img.shields.io/badge/platform-macOS%20%E2%80%A2%20Linux-lightgrey)](#install) [![Homebrew](https://img.shields.io/badge/brew-laaaaksh%2Fwrit-orange?logo=homebrew)](#install) -**[Install](#install) • [The loop](#the-loop-propose---approve---implement---attest---merge) • [Completions](#shell-completions) • [Exit codes](#exit-codes) • [Contributing](CONTRIBUTING.md) • [License](LICENSE)** +**[Install](#install) • [The loop](#the-loop-propose---approve---implement---attest---merge) • [Agent rules](#drive-it-from-your-agents-rules) • [Completions](#shell-completions) • [Exit codes](#exit-codes) • [FAQ](#faq) • [Contributing](CONTRIBUTING.md) • [License](LICENSE)** **[Code of conduct](CODE_OF_CONDUCT.md) • [Contributing](CONTRIBUTING.md) • [License](LICENSE) • [Security](SECURITY.md)** @@ -139,8 +139,20 @@ like `45s` or `30m`) to change that; invalid or non-positive values fall back to When a proposal is rejected or abandoned, `writ discard` removes the open writ so `propose` can start fresh; it touches only `.writ/current.toml`, never branches or commits. +A full loop, as `status` sees it at the end of a real run - the agent's claim on record, +verification green, nothing outside the declared scope: + ``` $ writ status + writ add a retry to the webhook sender + + CONTRACT 1/1 criteria + retries-on-5xx claimed by agent "injected a 500 then watched the sender retry twice before succeeding" + EVIDENCE go test ok + IN SCOPE 1 files + DRIFT none + + Auto-mergeable: zero drift, verification passed, all criteria met. ``` ``` @@ -149,6 +161,37 @@ $ writ version Prints the writ CLI version; `writ --version` (or `-v`) prints the same line. +## Drive it from your agent's rules + +writ only works if the agent actually follows the loop, and agents follow what their +instruction file says. Paste this into your repo's `AGENTS.md` or `CLAUDE.md`: + +```markdown +## Writ discipline + +For any non-trivial change, drive the writ loop instead of free-form editing: + +1. Propose before code. Read the relevant code first, then draft a complete + writ - intent, checkable criteria, the narrowest honest file scope, and a + verification command - and pipe it to `writ propose`. Never invent path + globs from memory: a lazy scope like `app/**` makes drift meaningless. +2. Wait for approval. Never implement against an unapproved writ. The human + runs `writ approve`; proceed only once it succeeds. `--yes` is the + human's call, never yours. +3. Stay inside the declared scope. Work on a branch off `base`. If the work + turns out to need files outside the writ's scope, stop: `writ discard`, + draft a new writ that covers reality, and propose again. Quiet scope + creep is exactly what writ exists to catch. +4. Attest only what you checked. Run each criterion's check yourself, then + record it: `writ attest --note ""`. + Never pass `--human`: that records a human confirmation, and only humans + may make one. +5. Let exit codes decide. Run `writ status` from the feature branch. Exit 0 + means it will auto-merge - say so and stop. Exit 1 means a human is + needed: print writ's reasons verbatim and wait. Never edit + `.writ/current.toml` by hand; use `attest`, `unattest`, or `discard`. +``` + ## Exit codes For scripting and agents, `writ` signals its decision on the process exit code: @@ -161,6 +204,36 @@ Output stays script-friendly: ANSI color appears only when stdout is a terminal, and setting `NO_COLOR` (to any non-empty value) turns it off even then, so piped or captured output is always plain text. +## FAQ + +**Does writ need an AI subscription or API key?** + +No. writ never talks to a model provider: there is no HTTP client anywhere in it or in its +two dependencies, it reads no API keys from your environment (the only variables it touches +are `EDITOR`, `VISUAL`, `NO_COLOR`, and `WRIT_VERIFY_TIMEOUT`), and its only child processes +are `git` and a POSIX `sh`. All the intelligence lives in whatever coding agent you already +run; writ is just the contract both of you sign and the referee that checks the work. + +**Which agents can drive it?** + +Any agent that can write TOML text and run shell commands: Claude Code, Codex CLI, +opencode, Cursor, aider, a cron script you wrote yourself. There is no plugin to install - +the whole interface is stdin, stdout, and [exit codes](#exit-codes). + +**Can several agents work in one repo?** + +Not on one writ. State lives in a single `.writ/current.toml` per repository, and a second +`propose` refuses while one is open. Give each parallel agent its own checkout with +`git worktree add ../repo-agent-b`; each worktree holds its own open writ. + +**Should my parallel agents share my API key or subscription?** + +No. N agents behind one key multiply your request rate N-fold, so when the provider answers +HTTP 429 (rate limited), all of them hit the wall at once - and each agent's retry logic +then fires together, producing a retry storm that can burn through quota or get the key +throttled entirely. Put each worktree on its own key or subscription so agents fail +independently instead of failing together. + ## Star this repo If `writ` makes reviewing agent PRs tractable, [leave a star](https://github.com/Laaaaksh/writ/stargazers) - it helps other people find it.