From 3a1793e2ab3fd849b3296c00ec4ad77493aea67a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 17:34:53 +0000 Subject: [PATCH 1/6] fix(release): close plan-doctor false positives and lint parity gap; prep v0.9.0 Release review of v0.8.1 plus the unreleased work on main. Three defects found, two fixed here; plan state reconciled and v0.9.0 prepped. plan-doctor rules (CIB-005, closes #132 / ISS-010) The bundled skill judged plans against a stricter private convention than plans/aps-rules.md documents, drawing ~385 warnings on a consumer plan with no real defects. Status now normalises through the documented alias table (Proposed -> Draft, Done -> Complete) before W03/W04/W05/W07 evaluate, W04 counts Done as terminal, and the filename-prefix half of W02 becomes Info reported once per directory. A collapsing rule was added: a finding that hits 100% of files is house style, not signal. The skill now agrees with its sibling aps-planning skill, which already accepted the aliases. release-plan lint parity (CIB-006, closes ISS-011) R001-R004 existed in the Rust linter only, so bash and PowerShell never discovered plans/releases/v*.md at all - 42 files checked against Rust's 50, and a malformed release plan passed both fallbacks silently. Ports added with matching codes, severities and messages; discovery fixed in get_file_type and find_aps_files; output byte-identical across all three. The parity harness also dropped R codes from its findings regex and carried no release fixtures, so CI could not have caught this either. installer manifests Sourcing a new rule module from bin/aps without listing it in the installer manifests broke a vendored bash CLI on startup. Fixed across all fourteen sites; a new test derives the required list from bin/aps itself. plan reconciliation PROMPTS-001 and EXAMPLES-001 marked Complete against their merge evidence, prompts and examples modules closed, cli-redesign moved to In Progress, D-045 recorded as decided, roadmap overview rewritten to match what shipped. aps audit goes from two findings to none. The completed archive was filing v0.3.0-v0.7.0 tables under an Unreleased heading and never rolled v0.4.0-v0.8.1 at all; the false heading is fixed and the ~60-item backfill left to REL-005 rather than reconstructed by hand (ISS-012). new backlog items CIB-007 (ISS-013): neither fallback CLI can answer --version, though both stamp a version into project config - the exact question the pin-mismatch warning raises. CIB-008 (ISS-014, ISS-015): three residual cross-CLI lint divergences and release rules weaker than they read, both reported rather than fixed in one CLI only. v0.9.0 prep Version stamped to 0.9.0 across fifteen files; the checklist's stray-version grep is empty. Release narrative, changelog section and README highlights written. Tag, crates.io publish and Scoop hash remain outstanding. Verification: cargo test (201), clippy -D warnings, cargo fmt --check, test/run.sh, test/cli-parity.sh, markdownlint, aps lint (50 files, no issues), aps audit (no findings). PowerShell parity verified by the porter under pwsh 7.4.6; the local parity run skips that leg and CI covers it. APS: CIB-005 CIB-006 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01N2expUMF61CKDC5zxZUeev --- .aps/config.yml | 2 +- CHANGELOG.md | 49 ++++- README.md | 50 +++--- ROADMAP.md | 19 +- bin/aps | 1 + bin/aps.ps1 | 1 + cli/Cargo.lock | 2 +- cli/Cargo.toml | 2 +- cli/src/doctor.rs | 1 + cli/src/migrate.rs | 1 + lib/Lint.psm1 | 33 +++- lib/Scaffold.psm1 | 5 +- lib/export.sh | 2 +- lib/lint.sh | 36 +++- lib/rules/Release.psm1 | 86 +++++++++ lib/rules/common.sh | 4 +- lib/rules/release.sh | 69 +++++++ lib/scaffold.sh | 11 +- package.json | 2 +- packaging/scoop/aps.json | 4 +- plans/completed.aps.md | 34 +++- plans/index.aps.md | 10 +- plans/issues.md | 169 ++++++++++++++++++ plans/modules/cli-redesign.aps.md | 4 +- .../continuous-improvement-backlog.aps.md | 165 ++++++++++++++++- plans/modules/examples.aps.md | 18 +- plans/modules/prompts.aps.md | 16 +- plans/modules/release-planning.aps.md | 2 +- plans/releases/v0.9.0.md | 136 ++++++++++++++ scaffold/init.sh | 1 + scaffold/install | 8 +- scaffold/install.ps1 | 10 +- scaffold/plan-doctor/SKILL.md | 72 ++++++-- scaffold/update | 6 +- scaffold/update.ps1 | 6 +- scaffold/upgrade | 2 +- test/cli-parity.sh | 17 +- .../plans/releases/.release.md | 3 + .../release-invalid/plans/releases/README.md | 3 + .../release-invalid/plans/releases/notes.md | 4 + .../release-invalid/plans/releases/v0.1.0.md | 16 ++ .../release-invalid/plans/releases/v0.2.0.md | 17 ++ .../release-invalid/plans/releases/v0.4.0.md | 17 ++ .../release-invalid/plans/releases/v0.5.0.md | 4 + .../release/plans/releases/.release.md | 3 + .../fixtures/release/plans/releases/README.md | 4 + .../fixtures/release/plans/releases/v0.3.0.md | 25 +++ test/run.sh | 39 ++++ 48 files changed, 1088 insertions(+), 103 deletions(-) create mode 100644 lib/rules/Release.psm1 create mode 100644 lib/rules/release.sh create mode 100644 plans/releases/v0.9.0.md create mode 100644 test/fixtures/release-invalid/plans/releases/.release.md create mode 100644 test/fixtures/release-invalid/plans/releases/README.md create mode 100644 test/fixtures/release-invalid/plans/releases/notes.md create mode 100644 test/fixtures/release-invalid/plans/releases/v0.1.0.md create mode 100644 test/fixtures/release-invalid/plans/releases/v0.2.0.md create mode 100644 test/fixtures/release-invalid/plans/releases/v0.4.0.md create mode 100644 test/fixtures/release-invalid/plans/releases/v0.5.0.md create mode 100644 test/fixtures/release/plans/releases/.release.md create mode 100644 test/fixtures/release/plans/releases/README.md create mode 100644 test/fixtures/release/plans/releases/v0.3.0.md diff --git a/.aps/config.yml b/.aps/config.yml index f87f675..4051ff5 100644 --- a/.aps/config.yml +++ b/.aps/config.yml @@ -2,7 +2,7 @@ # # The toolchain pin and runtime path defaults the global `aps` binary # discovers by walking up from cwd. Explicit flags (--plans, ...) override. -cli_version: 0.8.1 +cli_version: 0.9.0 profile: solo plans_dir: plans/ docs_dir: docs/ diff --git a/CHANGELOG.md b/CHANGELOG.md index e58bede..f15c135 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,12 +6,59 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +## [0.9.0] - 2026-09-12 + +**Release narrative:** [plans/releases/v0.9.0.md](./plans/releases/v0.9.0.md). + ### Added - **`aps update --global`** upgrades the machine-wide CLI at `~/.aps` (or `$APS_HOME`) from GitHub releases. Project `aps update` is unchanged. Bash and PowerShell `--global` no longer overwrite a native binary with the - script runtime. + script runtime. Fails closed rather than damaging a working install: + `--global` is rejected alongside a project directory, symlink payloads in + release archives are refused, and an unreadable native binary aborts the + update (CLI-003). +- **Release-plan lint in the bash and PowerShell CLIs (CIB-006)** — both + fallback linters now discover `plans/releases/v*.md` (excluding `README.md` + and the dotfile template) and apply R001–R004 with the same codes, + severities, and messages as the Rust binary. Release fixtures in the shared + parity suite pin the three-way identity. Previously these rules existed in + Rust only, so bash checked 42 of this repo's 50 plan files and a malformed + release plan passed the fallback CLIs silently — closing the last known + D-039 lockstep gap. + +### Fixed + +- **`plan-doctor` no longer floods healthy plans with false warnings + (CIB-005)** — status is normalised through the documented alias table + (`Proposed` → `Draft`, `Done` → `Complete`) before any rule evaluates, W04 + counts `Done` as terminal, the "no `NN-` filename prefix" half of W02 is + demoted to an Info code reported once per directory, and a consistently + applied undocumented status (`Archived` on archived index rows) reports under + its own Info code instead of W03. A consumer plan previously drew roughly 385 + warnings with no real defects, which made the bundled skill unusable. + Resolves [#132](https://github.com/eddacraft/anvil-plan-spec/issues/132). +- **Installer manifests carry every bash rule module** — a rule module sourced + by `bin/aps` but absent from the installer file lists broke a vendored or + scaffolded bash CLI on startup with "No such file or directory". All fourteen + manifest sites now list `lib/rules/release.sh` (and `Release.psm1`), and a + new test derives the required list from `bin/aps` itself so the next rule + module cannot repeat it. +- **Dependency patches** — markdownlint-cli 0.49.1 pulls patched js-yaml, + linkify-it, and brace-expansion; the MCP SDK transitives are overridden to + patched hono, `@hono/node-server`, fast-uri, ip-address, and body-parser. + Together these resolve 17 Dependabot alerts. + +### Changed + +- **Plan accuracy sweep** — PROMPTS-001 and EXAMPLES-001 are marked Complete + (both had landed but the status lagged), the `prompts` and `examples` modules + close, `cli-redesign` moves Draft → In Progress, D-045 is recorded as decided + rather than proposed, and the roadmap overview is rewritten to match what + actually shipped through v0.8. The completed-work archive no longer files + v0.3.0–v0.7.0 task tables under an `Unreleased` heading; the missing + v0.4.0–v0.8.1 roll is tracked as ISS-012 against REL-005. ## [0.8.1] - 2026-07-31 diff --git a/README.md b/README.md index 029c985..33be498 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) -[![Version](https://img.shields.io/badge/version-0.8.1-green.svg)](https://github.com/EddaCraft/anvil-plan-spec/releases/tag/v0.8.1) +[![Version](https://img.shields.io/badge/version-0.9.0-green.svg)](https://github.com/EddaCraft/anvil-plan-spec/releases/tag/v0.9.0) [![Made for AI agents](https://img.shields.io/badge/made%20for-AI%20agents-purple.svg)](docs/ai-agent-guide.md) @@ -271,19 +271,29 @@ need one. APS needs no hosted service, proprietary format, or mandatory integration. -## What's in v0.8.1 - -Patch release (**2026-07-31**) on top of v0.8.0's harness expansion: - -- **Skill embeds carry YAML frontmatter:** the bundled `SKILL.md` files now - ship the `---`-delimited `name`/`description` block that Agent Skills - harnesses require to discover them — without it they silently skip the skill. -- **`aps update` respects the version pin:** update now gates on a - `cli_version` pin mismatch instead of refreshing against the wrong toolchain. - -The v0.8.0 line (still current): - -- **Seven new harnesses:** `init`/`setup`/wizard now support **Antigravity, Amp, +## What's in v0.9.0 + +Feature release (**2026-09-12**) — self-update and trustworthy diagnostics: + +- **`aps update --global`:** the CLI upgrades itself. The machine-wide install + at `$APS_HOME/bin` (default `~/.aps/bin`) refreshes from GitHub releases, + while project `aps update` is unchanged. Fails closed rather than replacing a + working binary: symlink payloads in release archives are refused, and the + bash and PowerShell fallbacks never overwrite a native install with the + script runtime. +- **`plan-doctor` judges plans by the documented vocabulary:** status is + normalised through the accepted aliases (`Proposed` → `Draft`, + `Done` → `Complete`) before any rule evaluates, dependencies on `Done` items + count as terminal, and the filename-prefix check is advisory. A healthy plan + now reports clean instead of flagging every module. +- **Release-plan lint in all three CLIs:** the bash and PowerShell linters + discover `plans/releases/v*.md` and apply R001–R004 with the same codes and + messages as the Rust binary, pinned by release fixtures in the shared parity + suite. A malformed release plan can no longer pass the fallback CLIs. + +The v0.8 line (still current): + +- **Twelve harnesses:** `init`/`setup`/wizard support **Antigravity, Amp, Gemini CLI, Windsurf, Roo Code, OpenClaw, and Cursor** alongside Claude Code, Copilot, Codex, OpenCode, and Grok — twelve tools sharing one planning contract. Each was added under the D-045 native-discovery gate (reads @@ -291,14 +301,14 @@ The v0.8.0 line (still current): full Rust/bash/PowerShell parity. - **Refreshed APS planning stack:** the bundled `aps-planning` skill and the `aps-planner`/`aps-librarian`/`aps-conductor` agents are re-vended from the - canonical eddacraft source, and a new **`plan-doctor`** skill (structural plan - diagnostics) now installs alongside `aps-planning`. -- **Coherent command surface:** the CLI-001 redesign map (`update` vs `migrate` - vs `upgrade`, profile-aware surfacing) is recorded as the north star for - ongoing command-surface work. + canonical eddacraft source, and a **`plan-doctor`** skill (structural plan + diagnostics) installs alongside `aps-planning`. +- **Skill embeds carry YAML frontmatter:** bundled `SKILL.md` files ship the + `---`-delimited `name`/`description` block that Agent Skills harnesses + require to discover them. Full notes: [CHANGELOG.md](CHANGELOG.md) · -[release narrative](plans/releases/v0.8.1.md). +[release narrative](plans/releases/v0.9.0.md). ## Templates diff --git a/ROADMAP.md b/ROADMAP.md index e0c386d..3980363 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,15 +5,16 @@ to plan APS. ## Quick Overview -| Horizon | Focus | Status | -| --------------------- | ----------------------------------------------------------------- | ------- | -| **v0.2 Usability** | Scaffold, templates, docs, validation | Done | -| **v0.3 Distribution** | Install overhaul, multi-harness agents | Done | -| **Near Term** | Orchestration CLI (`next`/`start`/`complete`/`graph`), TUI wizard | Current | -| **Future** | Conductor agent, MCP server, GitHub Action, formal spec | Planned | - -See [plans/index.aps.md](plans/index.aps.md) for the full breakdown with modules, -status, and work items. +| Horizon | Focus | Status | +| ------------------------- | ---------------------------------------------------------------------- | ------- | +| **v0.2 Usability** | Scaffold, templates, docs, validation | Done | +| **v0.3 Orchestration** | Orchestration CLI (`next`/`start`/`complete`/`graph`), multi-agent reach | Done | +| **v0.4 Distribution** | Crosscutting conductor modules, binary-first install, TUI wizard | Done | +| **v0.5–v0.6 Monorepo** | Federated nested plans, the tagged package tier, full-lifecycle tooling | Done | +| **v0.7 Team Foundations** | Managed skill freshness, JSON export, GitHub Action, MCP server | Done | +| **v0.8 Harnesses** | Twelve supported harnesses from one shared planning asset | Done | +| **Near Term** | Release tooling (`aps release`), plan-doctor accuracy, lint parity | Current | +| **Future** | Team coordination plane, progress journals, profile-aware CLI | Planned | ## Non-Goals diff --git a/bin/aps b/bin/aps index a2ea053..6d1462e 100755 --- a/bin/aps +++ b/bin/aps @@ -39,6 +39,7 @@ source "$LIB_DIR/rules/index.sh" source "$LIB_DIR/rules/workitem.sh" source "$LIB_DIR/rules/issues.sh" source "$LIB_DIR/rules/design.sh" +source "$LIB_DIR/rules/release.sh" source "$LIB_DIR/lint.sh" source "$LIB_DIR/scaffold.sh" source "$LIB_DIR/orchestrate.sh" diff --git a/bin/aps.ps1 b/bin/aps.ps1 index b1e1239..c8ca0d5 100644 --- a/bin/aps.ps1 +++ b/bin/aps.ps1 @@ -40,6 +40,7 @@ Import-Module (Join-Path $RulesDir "Module.psm1") -Force -Global Import-Module (Join-Path $RulesDir "Index.psm1") -Force -Global Import-Module (Join-Path $RulesDir "Issues.psm1") -Force -Global Import-Module (Join-Path $RulesDir "Design.psm1") -Force -Global +Import-Module (Join-Path $RulesDir "Release.psm1") -Force -Global Import-Module (Join-Path $LibDir "Lint.psm1") -Force -Global Import-Module (Join-Path $LibDir "Scaffold.psm1") -Force -Global diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 6af40ff..1e5285e 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -106,7 +106,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "aps-cli" -version = "0.8.1" +version = "0.9.0" dependencies = [ "clap", "crossterm", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 4b219ac..38ba3e7 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aps-cli" -version = "0.8.1" +version = "0.9.0" edition = "2024" license = "Apache-2.0" description = "Anvil Plan Spec — native Rust CLI and TUI wizard" diff --git a/cli/src/doctor.rs b/cli/src/doctor.rs index 5a1a107..0948a66 100644 --- a/cli/src/doctor.rs +++ b/cli/src/doctor.rs @@ -30,6 +30,7 @@ pub const REQUIRED_LIB_FILES: &[&str] = &[ "rules/workitem.sh", "rules/issues.sh", "rules/design.sh", + "rules/release.sh", ]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/cli/src/migrate.rs b/cli/src/migrate.rs index e02ab40..166a18e 100644 --- a/cli/src/migrate.rs +++ b/cli/src/migrate.rs @@ -31,6 +31,7 @@ const APS_LIB_FILES: &[&str] = &[ "rules/workitem.sh", "rules/issues.sh", "rules/design.sh", + "rules/release.sh", ]; /// True when every file under `dir` is a recognised APS bash-lib file — so the diff --git a/lib/Lint.psm1 b/lib/Lint.psm1 index fef5cc1..c4e1483 100644 --- a/lib/Lint.psm1 +++ b/lib/Lint.psm1 @@ -6,6 +6,15 @@ # Dependencies (Output, Common, rules/*) must be imported by the entry point # before this module is loaded. See bin/aps.ps1. +# True when a path sits anywhere under a `releases/` directory. Mirrors +# in_releases_dir() in cli/src/parser.rs / lib/lint.sh, including the +# bare-relative case ("releases/v0.3.0.md") and Windows backslashes. +function Test-ApsInReleasesDir { + param([string]$FilePath) + $p = $FilePath -replace '\\', '/' + return ($p -match '/releases/' -or $p -match '^releases/') +} + function Get-ApsFileType { param([string]$FilePath) $name = Split-Path $FilePath -Leaf @@ -29,6 +38,17 @@ function Get-ApsFileType { # Actions files if ($FilePath -match '[/\\]execution[/\\]' -and $name -match '\.actions\.md$') { return "actions" } + # Release narratives live in releases/ as `v.md` (REL-003). + # README.md is the directory guide, and `.`-prefixed templates were already + # classified above. Anything else here is a (possibly misnamed) release + # file — R001 flags the naming. Checked before the modules/ rule so it + # matches the Rust classifier's precedence (D-039). + # -cmatch/-cne: Rust and bash both compare the extension and README.md + # case-sensitively, so `README.MD` is NOT a release narrative (it is not + # `*.md`) while `readme.md` IS one. PowerShell's default -match/-ne would + # fold both and diverge. + if ((Test-ApsInReleasesDir -FilePath $FilePath) -and $name -cmatch '\.md$' -and $name -cne 'README.md') { return "release" } + # Module files if ($dir -match '[/\\]modules($|[/\\])') { return "module" } @@ -43,7 +63,17 @@ function Find-ApsFiles { Get-ChildItem -Path $Directory -Recurse -File -ErrorAction SilentlyContinue | Where-Object { -not $_.Name.StartsWith('.') -and - ($_.Name -match '\.aps\.md$' -or $_.Name -match '\.actions\.md$' -or $_.Name -match '\.design\.md$' -or $_.Name -eq 'issues.md') + ( + $_.Name -match '\.aps\.md$' -or $_.Name -match '\.actions\.md$' -or + $_.Name -match '\.design\.md$' -or $_.Name -eq 'issues.md' -or + # Release narratives: any *.md under a releases/ dir except the + # README guide. Mirrors find_aps_files() in cli/src/parser.rs and + # the releases/ clause in lib/lint.sh's find (REL-003 / D-039). + ( + (Test-ApsInReleasesDir -FilePath $_.FullName) -and + $_.Name -cmatch '\.md$' -and $_.Name -cne 'README.md' + ) + ) } | Sort-Object FullName | ForEach-Object { $_.FullName } @@ -293,6 +323,7 @@ function Invoke-ApsFileLint { "simple" { return (Invoke-ApsModuleLint -File $File -TreeIds $TreeIds -ChildIds $ChildIds) } "issues" { return (Invoke-ApsIssuesLint -File $File) } "design" { return (Invoke-ApsDesignLint -File $File) } + "release" { return (Invoke-ApsReleaseLint -File $File) } "actions" { return $true } "archive" { return $true } "template" { return $true } diff --git a/lib/Scaffold.psm1 b/lib/Scaffold.psm1 index b072d65..64ba708 100644 --- a/lib/Scaffold.psm1 +++ b/lib/Scaffold.psm1 @@ -11,7 +11,7 @@ $script:ApsBaseUrl = "https://raw.githubusercontent.com/EddaCraft/anvil-plan-spe # Semver of the PowerShell CLI release, stamped into managed skill markers. # Mirrors APS_CLI_VERSION in lib/scaffold.sh; the native binary stamps its # crate version. One semver across channels (D-036). -$script:ApsCliVersion = if ($env:APS_CLI_VERSION) { $env:APS_CLI_VERSION } else { "0.8.1" } +$script:ApsCliVersion = if ($env:APS_CLI_VERSION) { $env:APS_CLI_VERSION } else { "0.9.0" } # Files to download for plans/ $script:PlanFiles = @( @@ -53,6 +53,7 @@ $script:CliFilesPowerShell = @( "lib/rules/WorkItem.psm1" "lib/rules/Issues.psm1" "lib/rules/Design.psm1" + "lib/rules/Release.psm1" ) # --- v2 file lists (.aps/ layout, parity with lib/scaffold.sh) --- @@ -584,7 +585,7 @@ function Write-ApsConfigV2 { "tooling_root: .aps/" "" "aps:" - " version: `"0.8.1`"" + " version: `"0.9.0`"" " config_schema: 1" " installed: `"$today`"" " updated: `"$today`"" diff --git a/lib/export.sh b/lib/export.sh index f095c73..a9afaac 100644 --- a/lib/export.sh +++ b/lib/export.sh @@ -103,7 +103,7 @@ EOF return 1 } - local json='{"schema":"aps-export/v1","generated_by":"aps '"${APS_CLI_VERSION:-0.8.1}"'","plans_dir":"'"$(export_json_escape "$plan_root")"'","modules":[' + local json='{"schema":"aps-export/v1","generated_by":"aps '"${APS_CLI_VERSION:-0.9.0}"'","plans_dir":"'"$(export_json_escape "$plan_root")"'","modules":[' local i cur_key="" first_module=true first_item=true for i in "${!ORCH_ITEM_IDS[@]}"; do diff --git a/lib/lint.sh b/lib/lint.sh index 3a452f4..d0aadde 100644 --- a/lib/lint.sh +++ b/lib/lint.sh @@ -6,6 +6,15 @@ # Associative array to store file types for JSON output declare -A FILE_TYPES +# True when a path sits anywhere under a `releases/` directory. Mirrors +# in_releases_dir() in cli/src/parser.rs, including the bare-relative case +# ("releases/v0.3.0.md") for a lint target inside plans/. +# Usage: in_releases_dir "path/to/file.md" +in_releases_dir() { + local path="${1//\\//}" + [[ "$path" == *"/releases/"* || "$path" == releases/* ]] +} + # Determine file type based on path # Usage: get_file_type "path/to/file.aps.md" get_file_type() { @@ -51,6 +60,16 @@ get_file_type() { return fi + # Release narratives live in releases/ as `v.md` (REL-003). + # README.md is the directory guide, and `.`-prefixed templates were already + # classified above. Anything else here is a (possibly misnamed) release + # file — R001 flags the naming. Checked before the modules/ rule so it + # matches the Rust classifier's precedence (D-039). + if in_releases_dir "$file" && [[ "$basename" == *.md && "$basename" != "README.md" ]]; then + echo "release" + return + fi + # Module files (in modules/ directory) if [[ "$dirname" == *"/modules" || "$dirname" == *"/modules/"* ]]; then echo "module" @@ -71,8 +90,18 @@ get_file_type() { find_aps_files() { local dir="$1" - # Find .aps.md, .actions.md, .design.md, and issues.md files, excluding dotfiles - find "$dir" -type f \( -name "*.aps.md" -o -name "*.actions.md" -o -name "*.design.md" -o -name "issues.md" \) ! -name ".*" 2>/dev/null | sort + # Find .aps.md, .actions.md, .design.md, issues.md and release narratives + # (any *.md under a releases/ dir except README.md), excluding dotfiles. + # The releases/ clause mirrors find_aps_files() in cli/src/parser.rs — both + # the "*/releases/*" and bare "releases/*" shapes, so a target of plans/ and + # a target of plans/releases/ discover the same set (REL-003 / D-039). + # LC_ALL=C so the sort is byte-order, matching the Rust Vec sort. + find "$dir" -type f \ + \( \ + -name "*.aps.md" -o -name "*.actions.md" -o -name "*.design.md" -o -name "issues.md" \ + -o \( -name "*.md" ! -name "README.md" \ + \( -path "*/releases/*" -o -path "releases/*" \) \) \ + \) ! -name ".*" 2>/dev/null | LC_ALL=C sort } # Cross-file ID index: work item and decision IDs from the whole plan tree. @@ -352,6 +381,9 @@ lint_file() { design) lint_design "$file" ;; + release) + lint_release "$file" + ;; actions) # Actions files have minimal validation for now # Could add checkpoint format validation later diff --git a/lib/rules/Release.psm1 b/lib/rules/Release.psm1 new file mode 100644 index 0000000..c4bd3a7 --- /dev/null +++ b/lib/rules/Release.psm1 @@ -0,0 +1,86 @@ +# +# APS CLI Release Plan Validation Rules (REL-003) +# +# Release narratives live under a `releases/` directory as `v.md`. +# Unlike design documents these rules are ERRORS, not warnings: a malformed +# release plan must fail CI rather than whisper. Hand-port of `lint_release` +# in cli/src/lint.rs and lib/rules/release.sh — same codes, order, severities +# and messages (D-039). +# + +# Dependencies (Output, Common) must be imported by the entry point. + +# True when a basename is `v...md` — `v0.3.0.md`, `v1.2.0-beta.md`. +# Mirrors is_release_filename() in cli/src/lint.rs: a literal lowercase `v` +# followed by an ASCII digit, and a `.md` extension. `-cmatch` keeps the check +# case-sensitive like the Rust and bash versions (PowerShell's `-match` is +# case-insensitive by default, which would wrongly accept `V0.3.0.md`). +function Test-ApsReleaseFilename { + param([string]$Basename) + return ($Basename -cmatch '^v[0-9].*\.md$') +} + +# R001: Release file must be named v.md +function Test-R001ReleaseNaming { + param([string]$File) + $basename = Split-Path $File -Leaf + if (-not (Test-ApsReleaseFilename -Basename $basename)) { + Add-ApsResult -Path $File -Type "error" -Code "R001" ` + -Message "Release file must be named v.md (e.g. v0.3.0.md)" + } +} + +# R002: Missing release header table with Target and Status fields +# The header table drives the release — Target (which version) and Status +# (where it is in the lifecycle). Both rows must appear in the first 20 lines. +function Test-R002ReleaseHeaderTable { + param([string]$File) + $lines = @(Get-Content -LiteralPath $File -ErrorAction SilentlyContinue) + $limit = [Math]::Min(20, $lines.Count) + $hasTarget = $false + $hasStatus = $false + for ($i = 0; $i -lt $limit; $i++) { + # -cmatch: the Rust and bash checks are case-sensitive, so `| target |` + # must not satisfy this rule. PowerShell's bare -match would. + if ($lines[$i] -cmatch '^\| *Target *\|') { $hasTarget = $true } + if ($lines[$i] -cmatch '^\| *Status *\|') { $hasStatus = $true } + } + if (-not ($hasTarget -and $hasStatus)) { + Add-ApsResult -Path $File -Type "error" -Code "R002" ` + -Message "Missing release header table with Target and Status fields" + } +} + +# R003: Missing ## Release Theme section +function Test-R003ReleaseTheme { + param([string]$File) + if (-not (Test-ApsSection -FilePath $File -SectionHeader "## Release Theme")) { + Add-ApsResult -Path $File -Type "error" -Code "R003" -Message "Missing ## Release Theme section" + } +} + +# R004: Missing ## What Ships section +function Test-R004WhatShips { + param([string]$File) + if (-not (Test-ApsSection -FilePath $File -SectionHeader "## What Ships")) { + Add-ApsResult -Path $File -Type "error" -Code "R004" -Message "Missing ## What Ships section" + } +} + +# Run all release rules. Emission order is load-bearing for cross-CLI parity: +# R001, R002, R003, R004 — matching lint_release in cli/src/lint.rs. +function Invoke-ApsReleaseLint { + param([string]$File) + + Test-R001ReleaseNaming -File $File + Test-R002ReleaseHeaderTable -File $File + Test-R003ReleaseTheme -File $File + Test-R004WhatShips -File $File + + return $true +} + +Export-ModuleMember -Function @( + 'Invoke-ApsReleaseLint' + 'Test-ApsReleaseFilename' +) diff --git a/lib/rules/common.sh b/lib/rules/common.sh index c9ea051..57b2c9c 100644 --- a/lib/rules/common.sh +++ b/lib/rules/common.sh @@ -61,8 +61,8 @@ aps_check_cli_version() { cfgdir="$(aps_find_config)" || return 0 pin="$(aps_config_get "$cfgdir/.aps/config.yml" cli_version)" [[ -z "$pin" ]] && return 0 - if [[ "$pin" != "${APS_CLI_VERSION:-0.8.1}" ]]; then - warn "project pins cli_version $pin but this CLI is ${APS_CLI_VERSION:-0.8.1}" + if [[ "$pin" != "${APS_CLI_VERSION:-0.9.0}" ]]; then + warn "project pins cli_version $pin but this CLI is ${APS_CLI_VERSION:-0.9.0}" if [[ "$strict" == true ]]; then error "cli_version mismatch under --strict" exit 1 diff --git a/lib/rules/release.sh b/lib/rules/release.sh new file mode 100644 index 0000000..cc825d0 --- /dev/null +++ b/lib/rules/release.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# +# Validation rules for release plan files (REL-003). +# +# Release narratives live under a `releases/` directory as `v.md`. +# Unlike design documents these rules are ERRORS, not warnings: a malformed +# release plan must fail CI rather than whisper. Hand-port of `lint_release` +# in cli/src/lint.rs — same codes, order, severities and messages (D-039). +# + +# True when a basename is `v...md` — `v0.3.0.md`, `v1.2.0-beta.md`. +# Mirrors is_release_filename() in cli/src/lint.rs: a literal `v` followed by +# an ASCII digit, and a `.md` extension. Case-sensitive, like the Rust check. +is_release_filename() { + local basename="$1" + [[ "$basename" == v[0-9]*.md ]] +} + +# R001: Release file must be named v.md +check_r001_naming() { + local file="$1" + local basename + basename=$(basename "$file") + if ! is_release_filename "$basename"; then + add_result "$file" "error" "R001" \ + "Release file must be named v.md (e.g. v0.3.0.md)" + fi +} + +# R002: Missing release header table with Target and Status fields +# The header table drives the release — Target (which version) and Status +# (where it is in the lifecycle). Both rows must appear in the first 20 lines. +check_r002_header_table() { + local file="$1" + if ! ( head -20 "$file" | grep -qE '^\| *Target *\|' && \ + head -20 "$file" | grep -qE '^\| *Status *\|' ); then + add_result "$file" "error" "R002" \ + "Missing release header table with Target and Status fields" + fi +} + +# R003: Missing ## Release Theme section +check_r003_release_theme() { + local file="$1" + if ! has_section "$file" "## Release Theme"; then + add_result "$file" "error" "R003" "Missing ## Release Theme section" + fi +} + +# R004: Missing ## What Ships section +check_r004_what_ships() { + local file="$1" + if ! has_section "$file" "## What Ships"; then + add_result "$file" "error" "R004" "Missing ## What Ships section" + fi +} + +# Run all release rules. Emission order is load-bearing for cross-CLI parity: +# R001, R002, R003, R004 — matching lint_release in cli/src/lint.rs. +lint_release() { + local file="$1" + + check_r001_naming "$file" + check_r002_header_table "$file" + check_r003_release_theme "$file" + check_r004_what_ships "$file" + + return 0 +} diff --git a/lib/scaffold.sh b/lib/scaffold.sh index 8f3bfa9..a3b637b 100644 --- a/lib/scaffold.sh +++ b/lib/scaffold.sh @@ -9,7 +9,7 @@ APS_BASE_URL="https://raw.githubusercontent.com/EddaCraft/anvil-plan-spec/$APS_V # Semver of the bash CLI release, stamped into .aps/config.yml as the project # contract's cli_version (INSTALL-014). The native binary stamps its own crate # version; this is the bash-fallback equivalent. -APS_CLI_VERSION="${APS_CLI_VERSION:-0.8.1}" +APS_CLI_VERSION="${APS_CLI_VERSION:-0.9.0}" # --- v2 file lists (.aps/ layout) --- @@ -60,6 +60,7 @@ V2_CLI_FILES=( "lib/rules/workitem.sh" "lib/rules/issues.sh" "lib/rules/design.sh" + "lib/rules/release.sh" ) # Agent files (Claude Code) @@ -110,6 +111,7 @@ CLI_FILES=( "lib/rules/workitem.sh" "lib/rules/issues.sh" "lib/rules/design.sh" + "lib/rules/release.sh" ) # Canonical tool identifiers @@ -561,7 +563,7 @@ write_config() { echo "tooling_root: .aps/" echo "" echo "aps:" - echo " version: \"0.8.1\"" + echo " version: \"0.9.0\"" echo " config_schema: 1" echo " installed: \"$today\"" echo " updated: \"$today\"" @@ -1797,7 +1799,8 @@ cmd_migrate() { if [[ -d "$target/lib" ]] && [[ -f "$target/lib/output.sh" ]]; then local aps_lib_files=(output.sh Output.psm1 lint.sh Lint.psm1 orchestrate.sh scaffold.sh Scaffold.psm1) local aps_rule_files=(common.sh Common.psm1 module.sh Module.psm1 index.sh Index.psm1 - workitem.sh WorkItem.psm1 issues.sh Issues.psm1 design.sh Design.psm1) + workitem.sh WorkItem.psm1 issues.sh Issues.psm1 design.sh Design.psm1 + release.sh Release.psm1) for f in "${aps_lib_files[@]}"; do rm -f "$target/lib/$f"; done for f in "${aps_rule_files[@]}"; do rm -f "$target/lib/rules/$f"; done rmdir "$target/lib/rules" 2>/dev/null @@ -2059,7 +2062,7 @@ EOF APS_LIB_FILES=( output.sh lint.sh orchestrate.sh audit.sh scaffold.sh rules/common.sh rules/module.sh rules/index.sh - rules/workitem.sh rules/issues.sh rules/design.sh + rules/workitem.sh rules/issues.sh rules/design.sh rules/release.sh ) # True when every file under "$1" is a known APS lib file. diff --git a/package.json b/package.json index a90b990..34f564a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "anvil-plan-spec", - "version": "0.8.1", + "version": "0.9.0", "private": true, "description": "A lightweight specification format for planning and work item authorisation in AI-assisted development.", "license": "Apache-2.0", diff --git a/packaging/scoop/aps.json b/packaging/scoop/aps.json index e553e9c..34c103b 100644 --- a/packaging/scoop/aps.json +++ b/packaging/scoop/aps.json @@ -1,5 +1,5 @@ { - "version": "0.8.1", + "version": "0.9.0", "description": "Anvil Plan Spec — native CLI and TUI wizard for AI-assisted planning", "homepage": "https://github.com/EddaCraft/anvil-plan-spec", "license": "Apache-2.0", @@ -11,7 +11,7 @@ ], "architecture": { "64bit": { - "url": "https://github.com/EddaCraft/anvil-plan-spec/releases/download/v0.8.1/aps-x86_64-pc-windows-gnu.zip", + "url": "https://github.com/EddaCraft/anvil-plan-spec/releases/download/v0.9.0/aps-x86_64-pc-windows-gnu.zip", "hash": "c10e5e7db2a78f2088db4cd816c31b5ca3dfd97c26c5c295b00e75da09562679", "bin": "aps.exe" } diff --git a/plans/completed.aps.md b/plans/completed.aps.md index 6ec0142..a596832 100644 --- a/plans/completed.aps.md +++ b/plans/completed.aps.md @@ -7,7 +7,7 @@ | ------- | ---------- | | Scope | ALL | | Status | Active | -| Updated | 2026-07-17 | +| Updated | 2026-09-12 | ## Purpose @@ -25,10 +25,26 @@ section below. ## Unreleased -_Will roll into the next cut. See [`plans/index.aps.md`](./index.aps.md) for -the active roadmap._ +_Will roll into the next cut — queued for +[v0.9.0](./releases/v0.9.0.md). Items stay here until the release closes out; +`Merged` items (CLI-003) advance to Complete at closeout and live in +[`plans/index.aps.md`](./index.aps.md) until then._ -### Team Rollout (v0.7.0) +| Task | Module | Description | Status | +| ------------ | ---------------------- | ------------------------------------------------ | -------------------- | +| CIB-005 | continuous-improvement | plan-doctor rules realigned with aps-rules.md | Complete: 2026-09-12 | +| CIB-006 | continuous-improvement | Release-plan lint rules in bash and PowerShell | Complete: 2026-09-12 | +| PROMPTS-001 | prompts | Normalize OpenCode + Claude Code prompt variants | Complete: 2026-08-03 | +| EXAMPLES-001 | examples | Expand worked examples (four workflow shapes) | Complete: 2026-09-12 | + +--- + +## v0.7.0 — Team Foundations + +_See [`plans/releases/v0.7.0.md`](./releases/v0.7.0.md) for theme, success +criteria, and risks._ + +### Team Rollout | Task | Module | Description | Status | | ---------------- | ---------------------- | ------------------------------------------------ | -------------------- | @@ -44,6 +60,16 @@ the active roadmap._ | CIB-003 | continuous-improvement | Monorepo shape seeds monorepo root index | Complete: 2026-07-17 | | CIB-004 | continuous-improvement | Native Windows PowerShell user journeys | Complete: 2026-07-17 | +--- + +## Pre-v0.7.0 — compacted by theme + +_These task tables were archived by theme before the per-release roll became +routine, so they are grouped by concern rather than by release. Per-release +attribution lives in the narratives under [`plans/releases/`](./releases/). +Rows are preserved as originally written (see Conventions: don't rewrite +history)._ + ### Compound-Engineering Library | Task | Module | Description | Status | diff --git a/plans/index.aps.md b/plans/index.aps.md index 96af80f..fb0dc67 100644 --- a/plans/index.aps.md +++ b/plans/index.aps.md @@ -5,7 +5,7 @@ | Status | Active | | Owner | @aneki | | Created | 2025-12-31 | -| Updated | 2026-08-07 | +| Updated | 2026-09-12 | ## Problem @@ -82,12 +82,12 @@ release narratives live in [`plans/releases/`](./releases/). | --------------------------------------------- | --------------------------------------------------------- | -------- | | [spec](./modules/spec.aps.md) | Canonical vocabulary + schema (status reconciliation) | Complete | | [tasks](./modules/tasks.aps.md) | Claude Code Tasks integration | Complete | -| [examples](./modules/examples.aps.md) | Additional worked examples | In Progress | -| [prompts](./modules/prompts.aps.md) | Tool-specific prompt variants | In Progress | +| [examples](./modules/examples.aps.md) | Additional worked examples | Complete | +| [prompts](./modules/prompts.aps.md) | Tool-specific prompt variants | Complete | | [integrations](./modules/integrations.aps.md) | JSON export, GitHub Action, lint/rollup CI surface | Complete | | [team-coordination](./modules/team-coordination.aps.md) | Multi-human, multi-agent claims, handoffs, and visibility | Draft | | [progress-journal](./modules/progress-journal.aps.md) | Standing modules, create-only progress journals, computed counts | Draft | -| [cli-redesign](./modules/cli-redesign.aps.md) | Solo/team CLI command rethink, binary upgrade, harness expansion | Draft | +| [cli-redesign](./modules/cli-redesign.aps.md) | Solo/team CLI command rethink, binary upgrade, harness expansion | In Progress | | [monorepo](./modules/monorepo.aps.md) | Nested index.aps.md plans, federated lint + orchestration | Complete | | [package-views](./modules/package-views.aps.md) | CLI tooling for the tagged monorepo tier (`Packages:` lint, next filter, generated views) | Complete | | [ci-parity](./modules/ci-parity.aps.md) | Behavioural pwsh + cross-CLI parity checks in CI | Complete | @@ -156,4 +156,4 @@ These are explicitly out of scope: - **D-042:** Managed skill markers are a three-CLI parity surface — _decided 2026-07-20: `.aps-managed.json` is written on skill install and reconciled on update by Rust, bash, and PowerShell alike, byte-identical across producers; PowerShell is first-class (adopting audience), bash is the deliberate fallback. Extends D-039 lockstep to the install/update surface. See [install.aps.md](./modules/install.aps.md) INSTALL-019/020_ - **D-043:** Curl entrypoints always deliver the current layout — _decided 2026-07-20: `scaffold/update` / `update.ps1` bring v1 and v2 projects to the latest layout (skill trees, `.aps/scripts/`, `.aps/config.yml`, markers) from the packaged `scaffold/aps-planning/` payload; they never maintain v1 in place. See [install.aps.md](./modules/install.aps.md) INSTALL-021_ - **D-044:** Single on-disk version surface — _decided 2026-07-20: `cli_version` in `.aps/config.yml` is the only version stamp APS writes into a project; `plans/.aps-version` is retired and the skill staleness check compares config against the running CLI. See [install.aps.md](./modules/install.aps.md) INSTALL-022_ -- **D-045 (proposed):** CLI redesign + harness expansion — _proposed 2026-07-22: rethink the `aps` command surface so it reads coherently for solo and team users (profile-aware vocabulary over `.aps/config.yml` `profile:`), ship it as a binary upgrade, and widen the harness set beyond the D-040 core (Claude Code, Copilot, Codex, OpenCode, Grok). Additions gated by the native-discovery test (reads the `AGENTS.md` family + `.agents/skills/` natively → zero-asset add like Grok). Candidates for the CLI-005 spike: Antigravity, Hermes, OpenClaw, and others. See [cli-redesign.aps.md](./modules/cli-redesign.aps.md)._ +- **D-045:** CLI redesign + harness expansion — _decided 2026-07-23 (proposed 2026-07-22): rethink the `aps` command surface so it reads coherently for solo and team users (profile-aware vocabulary over `.aps/config.yml` `profile:`), ship it as a binary upgrade, and widen the harness set beyond the D-040 core (Claude Code, Copilot, Codex, OpenCode, Grok). Additions gated by the native-discovery test (reads the `AGENTS.md` family + `.agents/skills/` natively → zero-asset add like Grok). Candidates for the CLI-005 spike: Antigravity, Hermes, OpenClaw, and others. Accepted with the CLI-001 redesign map on 2026-07-23 (D-CLI-a…d accepted, OQ-1 routed to CLI-003/CLI-006); CLI-005 evaluated the candidates and CLI-006/CLI-007 added seven harnesses in v0.8.0, taking the supported set to twelve. CLI-002 (profile-aware vocabulary) is the remaining item and is gated on TEAM. See [cli-redesign.aps.md](./modules/cli-redesign.aps.md)._ diff --git a/plans/issues.md b/plans/issues.md index 5ec0b38..81a2879 100644 --- a/plans/issues.md +++ b/plans/issues.md @@ -234,6 +234,175 @@ PowerShell 5.1 journey passes. --- +### ISS-010: plan-doctor rules disagree with the documented status vocabulary + +| Field | Value | +| ---------- | ------------------- | +| Status | Closed (2026-09-12) | +| Severity | High | +| Discovered | 2026-07-26 | +| Module | CIB | +| Work Item | CIB-005 | + +**Context:** The bundled `plan-doctor` skill hard-codes a status set and a +filename convention that `plans/aps-rules.md` does not require. W03 omits the +documented `Proposed` → `Draft` and `Done` → `Complete` aliases, W04 inherits +the same gap by treating only `Complete`/`Merged`/`Released`/`Shipped` as +terminal, and W02 flags every module whose filename lacks a numeric prefix — +a convention no consumer plan follows. + +**Impact:** Roughly 385 warnings fire on a consumer plan with no real defects +(162 `Done` and 132 `Proposed` statuses, plus all 91 module filenames), which +makes the skill unusable and trains readers to ignore its output. The skill +bytes are managed and embedded in the binary, so a consumer cannot patch it +locally without desyncing `.aps-managed.json`. + +**Tracking:** [CIB-005](./modules/continuous-improvement-backlog.aps.md), +[issue #132](https://github.com/eddacraft/anvil-plan-spec/issues/132) + +--- + +### ISS-011: Release-plan lint rules exist only in the Rust CLI + +| Field | Value | +| ---------- | ------------------- | +| Status | Closed (2026-09-12) | +| Severity | Medium | +| Discovered | 2026-09-12 | +| Module | CIB | +| Work Item | CIB-006 | + +**Context:** R001–R004 were implemented in `cli/src/lint.rs` only. The bash and +PowerShell linters never discover `plans/releases/v*.md`, so on this repo's own +plans bash reports 42 files checked where the Rust binary reports 49. The +cross-CLI parity suite carries no release fixtures, so CI cannot see the gap. + +**Impact:** A malformed release plan passes both fallback CLIs silently, +breaking the D-039 lockstep guarantee that one command surface behaves +identically across the three implementations. + +**Tracking:** [CIB-006](./modules/continuous-improvement-backlog.aps.md) + +--- + +### ISS-012: Completed-work archive was never rolled for v0.4.0–v0.8.1 + +| Field | Value | +| ---------- | ---------- | +| Status | Open | +| Severity | Low | +| Discovered | 2026-09-12 | +| Module | REL | +| Work Item | REL-005 | + +**Context:** `plans/completed.aps.md` jumps from theme-compacted pre-v0.7.0 +tables to v0.7.0 and then to the v0.9.0 roll. The task tables for v0.4.0, +v0.5.0, v0.6.0, v0.8.0, and v0.8.1 were never archived — roughly sixty items +across `MONO`, `PKG`, `CIP`, `COND`, `INSTALL`, `REL`, `SPEC`, `TASKS`, and +`CLI` are Complete in their modules but absent from the archive. Until the +2026-09-12 release review, everything up to and including v0.7.0 also sat +under a `## Unreleased` heading, which is how the omission stayed invisible. + +**Impact:** The archive cannot be used to answer "what shipped when" for five +releases, and the roll-up convention in `AGENTS.md` ("roll the task table into +`plans/completed.aps.md`") is not being met. Low severity because the module +files remain authoritative and the release narratives record the content. + +**Implementation:** Backfill mechanically rather than by hand — this is the +closeout sweep REL-005's `aps release close` is specified to perform from the +prose release records, and the v0.5.0 record is already its acceptance target. +Reconstructing attribution by hand risks fabricating it, so the false +`Unreleased` label was corrected on review and the backfill left to the tool. + +**Tracking:** [REL-005](./modules/release-planning.aps.md) + +--- + +### ISS-013: Fallback CLIs cannot report their own version + +| Field | Value | +| ---------- | ---------- | +| Status | Open | +| Severity | Low | +| Discovered | 2026-09-12 | +| Module | CIB | +| Work Item | CIB-007 | + +**Context:** The Rust binary answers `aps --version` with `aps 0.9.0`. The bash +CLI rejects both `--version` and `version` as unknown commands and lists no +version entry in `aps --help`; the PowerShell CLI has no version surface +either. Both fallbacks nonetheless know their version — each defaults +`APS_CLI_VERSION` and writes it as `cli_version` into `.aps/config.yml`. + +**Impact:** A user on a fallback CLI who hits the `cli_version` pin-mismatch +warning has no way to ask the CLI which version it is, which is the one +question the warning raises. It also breaks the D-039 expectation that one +command surface behaves identically across the three implementations. + +**Tracking:** [CIB-007](./modules/continuous-improvement-backlog.aps.md) + +--- + +### ISS-014: Three residual lint divergences across the CLI implementations + +| Field | Value | +| ---------- | ---------- | +| Status | Open | +| Severity | Low | +| Discovered | 2026-09-12 | +| Module | CIB | +| Work Item | CIB-008 | + +**Context:** Surfaced while porting R001–R004 for CIB-006, on targets outside +that item's scope. (1) `cli/scaffold` is a symlink to `../scaffold`; Rust's +`is_dir()` follows it, so `aps lint .` lints two extra files and raises a W019 +that bash `find` and PowerShell `Get-ChildItem -Recurse` never see. (2) +PowerShell's `Find-ApsFiles` uses `Sort-Object FullName`, which is +culture-aware, while Rust and bash sort byte-order — with mixed-case sibling +filenames in one directory this reorders findings. (3) bash and PowerShell run +the cross-tree W020/W021 collision checks before the per-file loop, so a +federation parent's group is emitted first, whereas Rust emits in path order. + +**Impact:** `aps lint .` and mixed-case plan trees can produce different output +from different CLIs, which is the condition D-039 exists to prevent. Low +severity: none of the three affects `aps lint plans` on a conventional tree, +which is the documented invocation, and the parity suite is green. + +**Implementation:** Fix in all three CLIs together or record as accepted +differences with a note in the parity suite. The PowerShell ordinal-sort fix +changes ordering for every file type, so it needs its own parity pass. + +**Tracking:** [CIB-008](./modules/continuous-improvement-backlog.aps.md) + +--- + +### ISS-015: Release-plan rules are weaker than they read + +| Field | Value | +| ---------- | ---------- | +| Status | Open | +| Severity | Low | +| Discovered | 2026-09-12 | +| Module | CIB | +| Work Item | CIB-008 | + +**Context:** R001 only requires a literal `v`, one ASCII digit, and a `.md` +extension, so `v0garbage.md` and `v9.aps.md` pass as well-formed release +filenames — it does not validate a version. R002 is satisfied by any two lines +in the first twenty that start `| Target |` and `| Status |`; they need not sit +in the same table, or in a table at all. Separately, Rust classifies +`plans/releases/foo.aps.md` as a release file, so a module placed under +`releases/` silently loses all module linting. + +**Impact:** A malformed release record can pass the structural gate, and a +misfiled module is validated by the wrong rule set with no warning. Not a +regression — this is the behaviour CIB-006 deliberately mirrored into bash and +PowerShell rather than diverging from. + +**Tracking:** [CIB-008](./modules/continuous-improvement-backlog.aps.md) + +--- + ## Questions ### Q-001: Which shared claim transport should team mode use? diff --git a/plans/modules/cli-redesign.aps.md b/plans/modules/cli-redesign.aps.md index 90b395a..6f814b1 100644 --- a/plans/modules/cli-redesign.aps.md +++ b/plans/modules/cli-redesign.aps.md @@ -4,9 +4,9 @@ | ID | Owner | Priority | Status | | --- | ------ | -------- | ------ | -| CLI | @aneki | high | Draft | +| CLI | @aneki | high | In Progress | -**Last reviewed:** 2026-09-03 +**Last reviewed:** 2026-09-12 ## Purpose diff --git a/plans/modules/continuous-improvement-backlog.aps.md b/plans/modules/continuous-improvement-backlog.aps.md index 3427ac0..a3e04e4 100644 --- a/plans/modules/continuous-improvement-backlog.aps.md +++ b/plans/modules/continuous-improvement-backlog.aps.md @@ -4,7 +4,7 @@ | --- | --------- | ------ | -------- | ----------- | | CIB | Conductor | @aneki | medium | In Progress | -**Last reviewed:** 2026-07-17 +**Last reviewed:** 2026-09-12 ## Purpose @@ -183,12 +183,171 @@ is promoted back to the relevant module. journey. The final CI run passed the full native journey under both PowerShell 7 and Windows PowerShell 5.1. +### CIB-005: Realign plan-doctor rules with the documented status vocabulary + +- **Status:** Complete: 2026-09-12 +- **Intent:** Make the bundled `plan-doctor` skill usable on a healthy consumer + plan by judging it against `plans/aps-rules.md` rather than a stricter + private convention. +- **Expected Outcome:** W03 accepts the documented aliases (`Proposed` → `Draft`, + `Done` → `Complete`) after normalising through the alias table, W04 counts + `Done` as terminal, the undocumented `Archived` case reports under its own + quieter code, and the "no numeric filename prefix" half of W02 is advisory + rather than a warning. A structurally healthy plan produces no warnings. +- **Validation:** `npx markdownlint-cli "scaffold/plan-doctor/SKILL.md"` and + `cargo test --manifest-path cli/Cargo.toml` pass; the rule table and every + worked example in the skill agree with `plans/aps-rules.md` § Status + Vocabulary; a consumer plan using `Done`/`Proposed` statuses reports clean. +- **Identified From:** [Issue #132](https://github.com/eddacraft/anvil-plan-spec/issues/132), + filed 2026-07-26 from the `anvil-001` vendoring review — roughly 385 warnings + fired on a plan with no real defects (162 `Done` + 132 `Proposed` statuses + false-flagged by W03, every one of 91 modules flagged by W02). +- **Files:** `scaffold/plan-doctor/SKILL.md` +- **Confidence:** high +- **Dependencies:** none +- **Notes:** The skill bytes are managed and embedded in the binary + (`include_str!` in `cli/src/scaffold.rs`), so hand-editing them in a consumer + repo desyncs `.aps-managed.json` and is overwritten on the next vend — the + fix has to ship from here and reaches users only on a release. +- **Results:** `scaffold/plan-doctor/SKILL.md` now opens `## What to check` + with a "Normalise status before judging it" section quoting + `plans/aps-rules.md` § Status Vocabulary, and instructs that the alias table + be applied before W03, W04, W05, and W07 evaluate. W03 narrows to values + still unrecognised after normalisation (`WIP`, `Almost done`); W04 counts + `Done` as terminal alongside `Merged`/`Released`/`Shipped`; W02 keeps only + the prefix-versus-dependency-order conflict. Two Info codes were added: I04 + for a missing `NN-` filename prefix (reported once per directory with a + count) and I05 for a consistently-applied undocumented status such as + `Archived`. A collapsing rule was added to the report section — a finding + that hits 100% of files is house style, not signal — which addresses the + root cause rather than just the three symptoms. The skill now agrees with + its sibling `aps-planning` skill, which already recognised the aliases. + Frontmatter untouched; `cli/src/scaffold.rs` needed no change (its + assertions check file count and frontmatter shape, not bytes). + markdownlint and `cargo test` (201 tests) pass. + +### CIB-006: Port release-plan lint rules to bash and PowerShell + +- **Status:** Complete: 2026-09-12 +- **Intent:** Retire the last known three-way lockstep gap so a malformed + release plan cannot pass the fallback CLIs. +- **Expected Outcome:** The bash and PowerShell linters discover + `plans/releases/v*.md` (excluding `README.md` and the dotfile template) and + implement R001–R004 with the same codes, severities, and messages as the Rust + linter. All three CLIs report identical findings and identical file counts on + this repo's `plans/`, and release fixtures in the shared parity suite keep + them that way. +- **Validation:** `./bin/aps lint plans` and the Rust binary agree on file count + and findings; `./test/cli-parity.sh` covers a valid release plan plus a + malformed one exercising each of R001–R004; `./test/run.sh` and + `./test/ps-parity.ps1` pass. +- **Identified From:** Release review 2026-09-12 — bash reported 42 files + checked against the Rust binary's 49, the difference being exactly the seven + `plans/releases/v*.md` records. REL-003 implemented the rules Rust-only, which + D-039 later superseded. +- **Files:** `lib/rules/release.sh`, `lib/rules/Release.psm1`, `lib/lint.sh`, + `bin/aps`, `bin/aps.ps1`, `test/cli-parity.sh`, `test/fixtures/**` +- **Confidence:** high +- **Dependencies:** none +- **Notes:** D-039 makes bash and PowerShell maintained peers, not frozen + fallbacks — REL-003's "aps lint is now the Rust CLI" rationale predates it. +- **Results:** Added `lib/rules/release.sh` and `lib/rules/Release.psm1` as + hand-ports of `lint_release`, wired into `lib/lint.sh`, `lib/Lint.psm1`, + `bin/aps`, and `bin/aps.ps1`. Discovery was the larger half of the defect: + `get_file_type` needed a `release` branch at the Rust classifier's + precedence (between `actions` and `module`) and `find_aps_files` needed a + `releases/` clause, pinned to `LC_ALL=C sort` to match Rust's byte-order + sort. File counts went 42 → 50 in bash and PowerShell, matching Rust, and + `diff` of the full output (plus `--json`) is byte-identical across all + three on `plans`, `plans/releases`, a single record, a relative target, and + the no-arg default. Two further causes of CI blindness were fixed: the + parity harness carried no release fixtures, and its `findings()` regex + matched only `(E|W)[0-9]{3}`, so R-codes were invisible even when present. + Fixtures `release/plans` (clean, plus the `README.md` and dotfile-template + exclusions) and `release-invalid/plans` (one record per failure mode, ten + findings) now pin the behaviour. The PowerShell port needed `-cmatch`/`-cne` + for the Target/Status rows, the `.md` extension, and `README.md` — the same + case-insensitivity class of bug recorded at `lib/rules/Common.psm1:115`; + without it `| target |` would have satisfied R002. Verified against Rust on + a scratch fixture of `V0.3.0.md`, `v.md`, `readme.md`, `README.MD`, + `v1.2.0-beta.md`, `v9.aps.md`, and `sub/v2.0.0.md`. +- **Follow-ups:** Sourcing a new rule module from `bin/aps` without adding it + to the installer manifests broke a vendored bash CLI on startup; fixed + across all fourteen manifest sites (`lib/scaffold.sh` ×4, `lib/Scaffold.psm1`, + `scaffold/{init.sh,install,install.ps1,update,update.ps1,upgrade}`, + `cli/src/migrate.rs`, `cli/src/doctor.rs`) and pinned by a new `test/run.sh` + guard that derives the rule list from `bin/aps` itself, so the next rule + module cannot repeat it. Rule weaknesses and residual cross-CLI divergences + were reported rather than unilaterally fixed in one CLI — see CIB-008. + +### CIB-007: Give the fallback CLIs a version surface + +- **Status:** Draft +- **Intent:** Let the bash and PowerShell CLIs report which version they are, + so the toolchain a project is actually running is always observable. +- **Expected Outcome:** `aps --version` (and the bare `version` command if the + Rust binary accepts one) reports the CLI's own version from all three + implementations, with identical formatting. The value is the same + `APS_CLI_VERSION` the fallbacks already stamp into `.aps/config.yml`. +- **Validation:** `aps --version` emits the same string from the Rust binary, + `bin/aps`, and `bin/aps.ps1`; a parity fixture or test leg pins the three-way + identity; `./test/run.sh` and `./test/ps-parity.ps1` pass. +- **Identified From:** Release review 2026-09-12 — the Rust binary answers + `aps --version` with `aps 0.9.0`, while `bin/aps --version` and + `bin/aps --help` have no version surface at all (`--version` errors with + "Unknown command"). The fallbacks already know their version: both default + `APS_CLI_VERSION` and write it as `cli_version`. +- **Files:** `bin/aps`, `bin/aps.ps1`, `lib/scaffold.sh`, `lib/Scaffold.psm1`, + `test/cli-parity.sh` +- **Confidence:** high +- **Dependencies:** none +- **Notes:** Small but load-bearing for D-044's "single on-disk version + surface" and for CLI-003's honest-version-surface intent: a user debugging a + `cli_version` pin mismatch on a fallback CLI currently has no way to ask the + CLI what it is. + +### CIB-008: Harden the release-plan rules and close residual lint divergences + +- **Status:** Draft +- **Intent:** Tighten release-plan validation beyond the structural minimum and + retire the three-CLI lint divergences surfaced while porting R001–R004. +- **Expected Outcome:** R001 validates a plausible version rather than + `v` + one digit; a `*.aps.md` file under `releases/` is not silently denied + module linting; R002 requires the Target and Status rows to belong to the + same header table. Separately, the three known cross-CLI divergences are + either fixed in all three implementations or documented as accepted + differences with a parity-suite note: symlinked directory traversal on + `aps lint .`, PowerShell's culture-aware file sort versus byte-order sorting + in Rust and bash, and the W020/W021 grouping order. +- **Validation:** Fixtures covering `v0garbage.md`, `v9.aps.md`, and a record + whose Target and Status rows sit in different tables; `./test/cli-parity.sh` + green with mixed-case sibling filenames and a symlinked directory in scope; + `./test/run.sh` and `./test/ps-parity.ps1` pass. +- **Identified From:** CIB-006 (2026-09-12). The porter mirrored the Rust + reference exactly, as D-039 requires, and reported the weaknesses rather than + silently "improving" one implementation — which would itself have created a + divergence. +- **Files:** `cli/src/lint.rs`, `cli/src/parser.rs`, `lib/rules/release.sh`, + `lib/rules/Release.psm1`, `lib/lint.sh`, `lib/Lint.psm1`, + `test/cli-parity.sh`, `test/fixtures/**` +- **Confidence:** medium +- **Dependencies:** CIB-006 +- **Notes:** Any change here lands in all three CLIs in the same work item — + fixing the Rust rule alone would re-open the gap CIB-006 just closed. + The PowerShell sort fix (`[StringComparer]::Ordinal`) changes ordering for + every file type, not just release plans, so it needs its own parity pass. + ## Status Roll-up - **Concern:** Standing APS maintenance intake -- **Progress:** 3/4 work items Complete +- **Progress:** 5/8 work items Complete - **Readout:** CIB-002, CIB-003, and CIB-004 are complete with native Windows - CI evidence. CIB-001 remains Draft and isolated in its own worktree. + CI evidence. CIB-001 remains Draft and isolated in its own worktree. CIB-005 + (plan-doctor false positives, issue #132) and CIB-006 (release-lint three-CLI + parity) were intaken from the 2026-09-12 release review and are Ready. + CIB-005 and CIB-006 completed on 2026-09-12. CIB-007 (version surface on the + fallback CLIs) and CIB-008 (release-rule hardening plus residual lint + divergences) were intaken from the same review and are Draft. ## Decisions diff --git a/plans/modules/examples.aps.md b/plans/modules/examples.aps.md index 5365462..152a0da 100644 --- a/plans/modules/examples.aps.md +++ b/plans/modules/examples.aps.md @@ -2,9 +2,9 @@ | ID | Owner | Priority | Status | | -------- | ------ | -------- | ------ | -| EXAMPLES | @aneki | medium | In Progress | +| EXAMPLES | @aneki | medium | Complete | -**Last reviewed:** 2026-07-16 +**Last reviewed:** 2026-09-12 ## Purpose @@ -30,20 +30,30 @@ execution plans, decisions, and design documents. - `examples/user-auth/` - `examples/opencode-companion/` - `examples/team-payments/` +- `examples/monorepo-nested/` - `test/fixtures/valid/` - `docs/team-rollout.md` ## Work Items -### EXAMPLES-001: Expand worked examples — Draft +### EXAMPLES-001: Expand worked examples — Complete 2026-09-12 -- **Status:** Draft +- **Status:** Complete: 2026-09-12 - **Intent:** Show APS in more than one project shape and tool workflow - **Expected Outcome:** Examples cover single-feature, multi-module, agent-led, and execution-plan workflows with valid markdown. - **Validation:** `./bin/aps lint examples test/fixtures/valid` - **Files:** examples/, test/fixtures/valid/ - **Confidence:** medium +- **Results:** All four target shapes are covered: single-feature + (`examples/user-auth/`), multi-module with a conductor and in-flight statuses + (`examples/team-payments/`), agent-led (`examples/opencode-companion/`), and + execution plans (`user-auth/execution/`, `opencode-companion/execution/`). + `examples/monorepo-nested/` additionally covers the federated nested-index + tier from MONO. Validation passes with no errors. Completed on review + 2026-09-12 rather than by a dedicated change — the coverage arrived + incrementally with the modules that needed each shape, which is why + `aps audit` had been reporting the item as understated. ### EXAMPLES-002: Team rollout guide + multi-owner example — Complete 2026-07-16 diff --git a/plans/modules/prompts.aps.md b/plans/modules/prompts.aps.md index ec722bb..f8d1653 100644 --- a/plans/modules/prompts.aps.md +++ b/plans/modules/prompts.aps.md @@ -2,9 +2,9 @@ | ID | Owner | Priority | Status | | ------- | ------ | -------- | ------ | -| PROMPTS | @aneki | medium | In Progress | +| PROMPTS | @aneki | medium | Complete | -**Last reviewed:** 2026-07-16 +**Last reviewed:** 2026-09-12 ## Purpose @@ -68,9 +68,9 @@ harness now has a prompt entry point (D-006 closed by PROMPTS-003). ## Work Items -### PROMPTS-001: Normalize existing prompt variants — In Progress +### PROMPTS-001: Normalize existing prompt variants — Complete 2026-08-03 -- **Status:** In Progress +- **Status:** Complete: 2026-08-03 - **Intent:** Keep the OpenCode and Claude Code variants consistent with the generic APS rules so tool prompts state only their tool-specific deltas and otherwise defer to shared APS concepts. @@ -86,8 +86,9 @@ harness now has a prompt entry point (D-006 closed by PROMPTS-003). - **Results:** Normalized eight OpenCode and Claude Code variants to defer to their generic prompts while retaining harness-specific deltas. APS lint, the full CLI suite, repository-wide Markdown lint, diff checks, and fresh - independent verification pass. Review: - [#143](https://github.com/eddacraft/anvil-plan-spec/pull/143) (base: `main`). + independent verification pass. Merged via + [#143](https://github.com/eddacraft/anvil-plan-spec/pull/143) (`5db8a3b`, + ancestor of `main`). ### PROMPTS-002: Document variant-vs-stub policy — Complete 2026-07-16 @@ -133,4 +134,5 @@ harness now has a prompt entry point (D-006 closed by PROMPTS-003). ## Notes Promoted Draft → Ready on 2026-06-27. PROMPTS-002 and PROMPTS-003 completed on -2026-07-16; PROMPTS-001 is the remaining item and is now In Progress. +2026-07-16; PROMPTS-001 merged via #143 on 2026-08-03, completing the module. +The module status lagged the merge until the 2026-09-12 release review. diff --git a/plans/modules/release-planning.aps.md b/plans/modules/release-planning.aps.md index 4b4226d..29dc3e3 100644 --- a/plans/modules/release-planning.aps.md +++ b/plans/modules/release-planning.aps.md @@ -4,7 +4,7 @@ | --- | --------- | ------ | -------- | ----------- | | REL | Conductor | @aneki | medium | In Progress | -**Last reviewed:** 2026-07-13 +**Last reviewed:** 2026-09-12 ## Purpose diff --git a/plans/releases/v0.9.0.md b/plans/releases/v0.9.0.md new file mode 100644 index 0000000..bb9d6a0 --- /dev/null +++ b/plans/releases/v0.9.0.md @@ -0,0 +1,136 @@ + + +# Release Plan: v0.9.0 + +| Field | Value | +| ---------------- | ------------------------------ | +| Target | v0.9.0 | +| Cut from | `main` | +| Previous release | v0.8.1 (2026-08-01) | +| Status | Cutting | +| Date | 2026-09-12 (planning) | + +## Release Theme + +**Self-update and trustworthy diagnostics** — v0.8 widened APS to twelve +harnesses; v0.9 makes the tooling that serves them honest. The CLI can now +upgrade itself, the bundled `plan-doctor` skill judges plans against the +documented status vocabulary instead of a stricter private convention, and the +last known three-way lockstep gap closes so a malformed release plan cannot +slip past the fallback CLIs. The through-line is diagnostics you can act on: a +warning that fires on every file in a healthy plan is noise, and a rule that +exists in only one of three implementations is not a rule. + +## What Ships + +### Distribution (CLI) + +Self-update lands on `aps update --global`, leaving project `update` untouched. + +| Area | Detail | +| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Self-update | **`aps update --global`** upgrades the machine-wide CLI at `$APS_HOME/bin` (default `~/.aps/bin`) from GitHub releases (CLI-003, `f945038`) | +| Fail-closed | `--global` is rejected alongside a project dir, symlink payloads in release archives are refused, and an unreadable native binary fails closed rather than silently (`7f50c9f`) | +| No downgrades | Bash and PowerShell `--global` never overwrite a native binary with the script runtime | + +**APS modules:** cli-redesign (6/7 work items done — CLI-002 remains, gated on +TEAM). + +### Diagnostics (plan-doctor) + +The bundled skill was unusable on a healthy consumer plan: roughly 385 warnings +fired on a plan with no real defects. + +| Area | Detail | +| ----------------- | -------------------------------------------------------------------------------------------------------------------------- | +| Status aliases | Status is normalised through the documented alias table (`Proposed` → `Draft`, `Done` → `Complete`) before W03/W04/W05/W07 evaluate | +| Terminal deps | W04 counts `Done` as terminal alongside `Complete`, `Merged`, `Released`, `Shipped` | +| Filename prefixes | The "no `NN-` prefix" half of W02 is demoted to a new Info code, reported once per directory with a count | +| Local conventions | A consistently-applied undocumented status (`Archived` on archived index rows) reports under its own Info code, not W03 | + +**APS modules:** continuous-improvement (CIB-005). Closes +[#132](https://github.com/eddacraft/anvil-plan-spec/issues/132) / ISS-010. + +### Validation parity (three-CLI lockstep) + +Release-plan lint rules existed in the Rust linter only, so the fallback CLIs +never discovered `plans/releases/v*.md` at all. + +| Area | Detail | +| --------- | --------------------------------------------------------------------------------------------------------- | +| Discovery | The bash and PowerShell linters now find `plans/releases/v*.md`, excluding `README.md` and the template | +| Rules | R001–R004 ported with identical codes, severities, and messages; all three CLIs agree on findings and counts | +| Regression | Release fixtures in the shared parity suite pin the three-way byte identity; the harness's findings regex now matches `R` codes, which it previously dropped | +| Manifests | Every bash rule module sourced by `bin/aps` now ships in all fourteen installer/updater manifests, with a test deriving the list from `bin/aps` itself | + +**APS modules:** continuous-improvement (CIB-006). Satisfies D-039 and +supersedes REL-003's Rust-only rationale. Closes ISS-011. + +### Maintenance + +| Area | Detail | +| ------------- | ------------------------------------------------------------------------------------------------------------------- | +| Dependencies | markdownlint-cli 0.49.1 and patched MCP SDK transitives resolve 17 Dependabot alerts (`0ec815d`, `f2ae14c`) | +| Plan hygiene | Status drift corrected (PROMPTS-001, EXAMPLES-001, cli-redesign, D-045), the roadmap overview refreshed, and the completed archive's false `Unreleased` heading fixed | + +**APS modules:** prompts (Complete), examples (Complete). + +## Success Criteria + +- [ ] `aps update --global` upgrades a global install on all five release targets +- [ ] `plan-doctor` reports zero warnings on a structurally healthy plan that + uses `Done` / `Proposed` statuses +- [ ] `aps lint plans` reports identical findings under Rust, bash, and PowerShell +- [ ] All channels publish 0.9.0 (GitHub release, crates.io, Scoop ×2) + +## Risks + +| Risk | Impact | Mitigation | +| ----------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------ | +| Self-update replaces a working binary with a broken one | High | Fail-closed on unreadable binaries and symlink payloads; script runtimes never overwrite a native install | +| The plan-doctor fix only reaches users on a release | Medium | The skill bytes are embedded in the binary, so this cut is the delivery mechanism — no consumer patching | +| Ported lint rules drift from Rust again | Medium | Release fixtures in the shared parity suite fail CI on divergence, rather than relying on review | + +## Out of Scope + +- **`aps release` (REL-005)** — deferred; the top-ranked Ready item, and the + tool that would have mechanised this cut's own closeout. +- **Archive backfill for v0.4.0–v0.8.1 (ISS-012)** — deferred to REL-005 rather + than reconstructed by hand, which would risk fabricating attribution. +- **`aps upgrade` as a verb** — stays reserved until bash `upgrade` is only a + `migrate` alias (D-CLI-a). +- **CLI-002 profile-aware vocabulary** — gated on the TEAM coordination plane. + +## Rollout + +- **Distribution channels:** GitHub release (five targets + `SHA256SUMS`), + crates.io (`aps-cli`), Scoop (`packaging/scoop/aps.json` + + `eddacraft/scoop-bucket`). +- **Migration steps:** none. Projects pinning `cli_version: 0.8.1` keep working; + `aps update` gates on the pin as of v0.8.1. +- **Communication:** CHANGELOG entry linking here. + +## Channels + +Per the `release.yml` checklist — prep sweep done, publish pending: + +- **Version stamps** — bumped to 0.9.0 across `cli/Cargo.toml`, `package.json`, + `.aps/config.yml`, the bash/PowerShell CLI defaults and emitted config, the + curl install/update fallbacks, the Scoop manifest version + asset URL, and the + README badge. ⏳ +- **GitHub release** `v0.9.0` — pending tag push. ⏳ +- **crates.io** — pending `cargo publish`. ⏳ +- **Scoop** — manifest hash pending the published `SHA256SUMS`; both + `packaging/scoop/aps.json` and the bucket manifest need it. ⏳ + +## Related + +- **CHANGELOG entry:** [CHANGELOG.md](../../CHANGELOG.md) +- **Completed roll-up:** [plans/completed.aps.md](../completed.aps.md) — + queued under `## Unreleased`; rolls into a `## v0.9.0` section at closeout, + when CLI-003 advances Merged → Complete +- **Module specs:** [cli-redesign](../modules/cli-redesign.aps.md), + [continuous-improvement](../modules/continuous-improvement-backlog.aps.md), + [prompts](../modules/prompts.aps.md), [examples](../modules/examples.aps.md) +- **Decisions:** D-036 (install-channel semver alignment), D-039 (three-way + lockstep), D-044 (single on-disk version surface), D-045 (CLI redesign) diff --git a/scaffold/init.sh b/scaffold/init.sh index 355b36d..c08395d 100755 --- a/scaffold/init.sh +++ b/scaffold/init.sh @@ -124,6 +124,7 @@ install_cli() { "lib/rules/index.sh" "lib/rules/workitem.sh" "lib/rules/design.sh" + "lib/rules/release.sh" ) for f in "${cli_files[@]}"; do diff --git a/scaffold/install b/scaffold/install index fdac09c..bc3ccd5 100755 --- a/scaffold/install +++ b/scaffold/install @@ -296,7 +296,7 @@ install_global() { echo " Re-run without --binary to use the bash CLI fallback." >&2 exit 1 else - for f in bin/aps lib/output.sh lib/lint.sh lib/orchestrate.sh lib/audit.sh lib/export.sh lib/scaffold.sh lib/rules/common.sh lib/rules/module.sh lib/rules/index.sh lib/rules/workitem.sh lib/rules/issues.sh lib/rules/design.sh; do + for f in bin/aps lib/output.sh lib/lint.sh lib/orchestrate.sh lib/audit.sh lib/export.sh lib/scaffold.sh lib/rules/common.sh lib/rules/module.sh lib/rules/index.sh lib/rules/workitem.sh lib/rules/issues.sh lib/rules/design.sh lib/rules/release.sh; do download_root "$f" "$aps_home/$f" done chmod +x "$aps_home/bin/aps" @@ -461,7 +461,7 @@ write_min_config() { # when it is an explicit semver. Otherwise fall back to the release version. case "$VERSION" in v[0-9]*|[0-9]*) cli_version="${VERSION#v}" ;; - *) cli_version="0.8.1" ;; + *) cli_version="0.9.0" ;; esac mkdir -p "$TARGET/.aps" touch "$TARGET/.aps/.gitignore" @@ -477,7 +477,7 @@ docs_dir: docs/ tooling_root: .aps/ aps: - version: "0.8.1" + version: "0.9.0" config_schema: 1 installed: "$today" updated: "$today" @@ -548,7 +548,7 @@ if [[ "$USE_LOCAL_CLI" == true ]]; then if [[ "$USE_BINARY" == true ]] && install_binary "$TARGET/.aps/bin"; then : # native binary installed else - for f in bin/aps lib/output.sh lib/lint.sh lib/orchestrate.sh lib/audit.sh lib/export.sh lib/scaffold.sh lib/rules/common.sh lib/rules/module.sh lib/rules/index.sh lib/rules/workitem.sh lib/rules/issues.sh lib/rules/design.sh; do + for f in bin/aps lib/output.sh lib/lint.sh lib/orchestrate.sh lib/audit.sh lib/export.sh lib/scaffold.sh lib/rules/common.sh lib/rules/module.sh lib/rules/index.sh lib/rules/workitem.sh lib/rules/issues.sh lib/rules/design.sh lib/rules/release.sh; do download_root "$f" "$TARGET/.aps/$f" done chmod +x "$TARGET/.aps/bin/aps" diff --git a/scaffold/install.ps1 b/scaffold/install.ps1 index 6bba65d..220ca51 100644 --- a/scaffold/install.ps1 +++ b/scaffold/install.ps1 @@ -5,7 +5,7 @@ # # Usage: # & ([scriptblock]::Create((irm "https://raw.githubusercontent.com/EddaCraft/anvil-plan-spec/main/scaffold/install.ps1"))) --cli -# $env:APS_VERSION = "v0.8.1"; & ([scriptblock]::Create((irm "..."))) --cli +# $env:APS_VERSION = "v0.9.0"; & ([scriptblock]::Create((irm "..."))) --cli # # For updating existing projects, use the update script instead. # @@ -274,7 +274,8 @@ function Install-ApsGlobal { "lib/rules/Index.psm1", "lib/rules/WorkItem.psm1", "lib/rules/Issues.psm1", - "lib/rules/Design.psm1" + "lib/rules/Design.psm1", + "lib/rules/Release.psm1" ) foreach ($f in $cliAll) { @@ -524,7 +525,7 @@ if (-not (Test-Path -LiteralPath $gitignore) -or -not ((Get-Content -LiteralPath $today = (Get-Date -Format "yyyy-MM-dd") # $Version is a git ref ("main") by default; only pin it as cli_version when # it is an explicit semver, else fall back to the release version. -if ($Version -match '^v?[0-9]') { $cliVersion = $Version -replace '^v', '' } else { $cliVersion = "0.8.1" } +if ($Version -match '^v?[0-9]') { $cliVersion = $Version -replace '^v', '' } else { $cliVersion = "0.9.0" } $configBody = @" # .aps/config.yml — written by installer, read by updater @@ -536,7 +537,7 @@ docs_dir: docs/ tooling_root: .aps/ aps: - version: "0.8.1" + version: "0.9.0" config_schema: 1 installed: "$today" updated: "$today" @@ -568,6 +569,7 @@ if ($UseLocalCli) { "lib/rules/WorkItem.psm1" "lib/rules/Issues.psm1" "lib/rules/Design.psm1" + "lib/rules/Release.psm1" ) foreach ($f in $cliFilesPowerShell) { Invoke-DownloadRoot -Path $f -Destination (Join-Path $apsDir $f) diff --git a/scaffold/plan-doctor/SKILL.md b/scaffold/plan-doctor/SKILL.md index 8f6532f..00d7e4e 100644 --- a/scaffold/plan-doctor/SKILL.md +++ b/scaffold/plan-doctor/SKILL.md @@ -24,6 +24,26 @@ Walk the planning directory (`plans/` per `plans/aps-rules.md`) and verify each of the following. Severity codes keep reports scannable and repeat runs comparable. +### Normalise status before judging it + +`plans/aps-rules.md` ("Status Vocabulary") is the authority on status +values. Canonical values are `Draft`, `Ready`, `In Progress`, `Complete`, +`Blocked`, and two aliases are accepted — tools normalise these internally +and plan files are not rewritten: + +- `Proposed` → `Draft` (not yet actionable) +- `Done` → `Complete` (terminal / compacted items) + +Terminal compaction may also use `Merged`, `Released`, or `Shipped`; lint +treats them like `Complete`/`Done` for required-field exemptions, and so +does plan-doctor. These are accepted lifecycle states, not aliases (this +catalogue records them in ADR-0013; `Merged` is the interim post-land +state), so they are never rewritten either. + +Apply that table **before** evaluating W03, W04, W05, or W07. A plan that +writes `Done` or `Proposed` throughout is conformant, not defective — never +report those values as status drift. + ### Errors — the plan is unreliable until fixed | Code | Check | @@ -38,9 +58,9 @@ runs comparable. | Code | Check | | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | W01 | Module file on disk that the index Modules table does not list (orphaned module) | -| W02 | Module filename breaks the `NN-name.aps.md` convention, or numbering conflicts with dependency order | -| W03 | Status outside the native APS transition set (`Draft`, `Ready`, `In Progress`, `Complete`, `Blocked`) and this catalogue's accepted ADR-0013 lifecycle states `Merged` (interim post-land), `Released`, `Shipped` | -| W04 | Item marked `Ready` whose dependencies are not terminal (`Complete`, or lifecycle states `Merged`, `Released`, `Shipped`) | +| W02 | Module file numbering contradicts dependency order — a numbered module sorts ahead of a module it depends on, or against the index Modules table order (only meaningful where prefixes exist; their absence is I04) | +| W03 | Status value still unrecognised after alias normalisation: neither canonical, nor a documented alias, nor an accepted lifecycle state — a one-off or freeform value (`WIP`, `Almost done`) an agent cannot place in the flow | +| W04 | Item marked `Ready` whose dependencies are not terminal (`Complete` — including its alias `Done` — or the lifecycle states `Merged`, `Released`, `Shipped`) | | W05 | Terminal item with no `Validation:` field or validation evidence | | W06 | Action plan in `plans/execution/` whose work item ID no longer exists (orphaned actions) | | W07 | Index module status contradicts its module file (e.g. index says `Done`, items still open) | @@ -49,11 +69,19 @@ runs comparable. ### Info — worth a look, not necessarily wrong -| Code | Check | -| ---- | --------------------------------------------------------------------------------- | -| I01 | Item `In Progress` with no recent journal entry or matching branch (may be stale) | -| I02 | Design doc in `plans/designs/` referenced by no module or work item | -| I03 | Open question in the index resolved nowhere (no ADR, no item) | +| Code | Check | +| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| I01 | Item `In Progress` with no recent journal entry or matching branch (may be stale) | +| I02 | Design doc in `plans/designs/` referenced by no module or work item | +| I03 | Open question in the index resolved nowhere (no ADR, no item) | +| I04 | Module filename carries no `NN-` numeric prefix (aps-rules "Naming Conventions") — a style note, not a defect; report once per directory with a count | +| I05 | Status value outside the documented vocabulary but used consistently as a local convention (e.g. `Archived` on archived index module rows) — report once per value | + +W03 and I05 split the same observation by consistency. A value used once, +or used inconsistently across sibling items, is unmappable drift (W03). A +value applied uniformly to a whole class of rows — the way `Archived` marks +archived module rows in `index.aps.md` — is a local convention this spec +does not document, so it is Info, reported once for the value. These catalogues are a floor, not a ceiling — report anything else that would mislead an agent reading the plan cold. @@ -71,13 +99,26 @@ Errors: N | Warnings: N | Info: N Fix: restore the file or remove the row (content fix — propose, don't auto-apply) ## Warnings -- [W03] AUTH-002 status "Done" — canonical is "Complete" - Fix: mechanical rename, auto-repairable +- [W03] AUTH-002 status "Almost done" — not canonical, not a documented alias + Fix: mechanical rename to the nearest canonical value, auto-repairable + +## Info +- [I05] status "Archived" on 4 index module rows — consistent local convention + outside the aps-rules vocabulary; no action needed +- [I04] 91 of 91 module files have no NN- prefix — house style differs from + aps-rules "Naming Conventions"; renumbering is a content change ... ``` `HEALTHY` = no errors or warnings. `DEGRADED` = warnings only. `BROKEN` = -any error. +any error. Info never affects that status and never blocks a run; omit the +Info section entirely when there is nothing to note. + +Report each Info cause once, with a count (`4 index rows`, `91 of 91 module +files`), not one line per occurrence. Apply the same collapsing to a warning +that fires uniformly across a directory: a finding that hits 100% of files +is house style, not signal — state the pattern once, say how many files it +covers, and consider whether it belongs in Info instead. ## Repairs @@ -87,9 +128,10 @@ inside an autonomous loop whose authority covers plan bookkeeping). **Mechanical — safe to apply on request:** -- normalise unambiguous status aliases to the native APS vocabulary (W03), e.g. - `Done` → `Complete` — but `Merged`, `Released`, and `Shipped` are accepted - lifecycle states, not aliases, and are never normalised; +- normalise an unrecognised status to its nearest canonical value (W03), e.g. + `Completed` → `Complete` — but the documented aliases `Proposed` and `Done`, + and the lifecycle states `Merged`, `Released`, and `Shipped`, are accepted + values that tools normalise internally, so never rewrite them in plan files; - add an index row for an orphaned module, mirroring the module's own status (W01); - create missing canonical directories (`plans/execution/`, @@ -99,7 +141,7 @@ inside an autonomous loop whose authority covers plan bookkeeping). **Content — always propose, never auto-apply:** - anything touching intent, scope, outcomes, or dependencies; -- renaming or renumbering module files; +- renaming or renumbering module files (W02, I04); - deleting orphaned files (move to a proposal; the user decides). After applying repairs, re-run the affected checks and report the final diff --git a/scaffold/update b/scaffold/update index a3af8e6..57d3e63 100755 --- a/scaffold/update +++ b/scaffold/update @@ -79,12 +79,14 @@ CLI_BASH_FILES=( lib/scaffold.sh lib/rules/common.sh lib/rules/module.sh lib/rules/index.sh lib/rules/workitem.sh lib/rules/issues.sh lib/rules/design.sh + lib/rules/release.sh ) CLI_PS_FILES=( bin/aps.ps1 lib/Output.psm1 lib/Lint.psm1 lib/Scaffold.psm1 lib/rules/Common.psm1 lib/rules/Module.psm1 lib/rules/Index.psm1 lib/rules/WorkItem.psm1 lib/rules/Issues.psm1 lib/rules/Design.psm1 + lib/rules/Release.psm1 ) # Hook script basenames (both shells) as shipped into .aps/scripts/. @@ -303,7 +305,7 @@ write_min_config() { # when it is an explicit semver. Otherwise fall back to the release version. case "$VERSION" in v[0-9]*|[0-9]*) cli_version="${VERSION#v}" ;; - *) cli_version="0.8.1" ;; + *) cli_version="0.9.0" ;; esac mkdir -p "$TARGET/.aps" touch "$TARGET/.aps/.gitignore" @@ -319,7 +321,7 @@ write_min_config() { echo "tooling_root: .aps/" echo "" echo "aps:" - echo " version: \"0.8.1\"" + echo " version: \"0.9.0\"" echo " config_schema: 1" echo " installed: \"$today\"" echo " updated: \"$today\"" diff --git a/scaffold/update.ps1 b/scaffold/update.ps1 index b57e26e..0868c27 100644 --- a/scaffold/update.ps1 +++ b/scaffold/update.ps1 @@ -67,12 +67,14 @@ $CliFilesBash = @( "lib/scaffold.sh" "lib/rules/common.sh", "lib/rules/module.sh", "lib/rules/index.sh" "lib/rules/workitem.sh", "lib/rules/issues.sh", "lib/rules/design.sh" + "lib/rules/release.sh" ) $CliFilesPowerShell = @( "bin/aps.ps1" "lib/Output.psm1", "lib/Lint.psm1", "lib/Scaffold.psm1" "lib/rules/Common.psm1", "lib/rules/Module.psm1", "lib/rules/Index.psm1" "lib/rules/WorkItem.psm1", "lib/rules/Issues.psm1", "lib/rules/Design.psm1" + "lib/rules/Release.psm1" ) # Hook script basenames (both shells) as shipped into .aps/scripts/. @@ -301,7 +303,7 @@ function Write-MinConfig { $today = Get-Date -Format "yyyy-MM-dd" # $Version is a git ref ("main") by default; only use it as the contract # pin when it is an explicit semver. Otherwise fall back to the release. - $cliVersion = if ($Version -cmatch '^v?[0-9]') { $Version -creplace '^v', '' } else { "0.8.1" } + $cliVersion = if ($Version -cmatch '^v?[0-9]') { $Version -creplace '^v', '' } else { "0.9.0" } $apsDir = Join-Path $Target ".aps" New-Item -ItemType Directory -Path $apsDir -Force | Out-Null $gitignore = Join-Path $apsDir ".gitignore" @@ -323,7 +325,7 @@ function Write-MinConfig { "tooling_root: .aps/" "" "aps:" - " version: `"0.8.1`"" + " version: `"0.9.0`"" " config_schema: 1" " installed: `"$today`"" " updated: `"$today`"" diff --git a/scaffold/upgrade b/scaffold/upgrade index 36746fc..77ae261 100755 --- a/scaffold/upgrade +++ b/scaffold/upgrade @@ -54,7 +54,7 @@ if [[ ! -d "$TARGET/plans" ]]; then fi # Known APS bash-lib filenames (relative to a lib/ dir). -APS_LIB_FILES="output.sh lint.sh orchestrate.sh audit.sh scaffold.sh rules/common.sh rules/module.sh rules/index.sh rules/workitem.sh rules/issues.sh rules/design.sh" +APS_LIB_FILES="output.sh lint.sh orchestrate.sh audit.sh scaffold.sh rules/common.sh rules/module.sh rules/index.sh rules/workitem.sh rules/issues.sh rules/design.sh rules/release.sh" dir_only_aps_lib() { local d="$1" rel f diff --git a/test/cli-parity.sh b/test/cli-parity.sh index 05d8425..f98f2f0 100755 --- a/test/cli-parity.sh +++ b/test/cli-parity.sh @@ -67,6 +67,16 @@ fail=0 # Fixture directories whose lint output must be identical across every CLI. # (Command-specific fixtures — audit/, orchestrate/, config/ — are excluded; # this harness covers `aps lint` only.) +# +# release/plans one well-formed `releases/v.md`, plus the +# README.md guide and a dotfile template that every CLI +# must exclude from discovery. Lints clean everywhere. +# release-invalid/plans one release narrative per R00x failure mode plus a +# combined case, so R001 (naming), R002 (Target+Status +# header table), R003 (## Release Theme) and R004 +# (## What Ships) each fire in isolation and together. +# This is the coverage whose absence let R001-R004 ship +# Rust-only (REL-003 / D-039). FIXTURES=( "valid" "invalid" @@ -78,12 +88,17 @@ FIXTURES=( "pkgtags/plans" "pkgtags-clean/plans" "pkgtags-nomarker/plans" + "release/plans" + "release-invalid/plans" ) # Order-preserving finding lines: `CODE: message (line N)`. Strips the file-path # group headers and summary lines, leaving the sequence of findings each CLI # emits — which must match byte-for-byte, order included. -findings() { grep -oE '(E|W)[0-9]{3}:.*' || true; } +# Code prefixes: E (error), W (warning), R (release-plan rule, REL-003). A new +# prefix must be added here or its findings are invisible to this harness — +# which is how R001-R004 stayed bash/PowerShell-less without CI noticing. +findings() { grep -oE '(E|W|R)[0-9]{3}:.*' || true; } # Run one CLI lint invocation, validate its exit status, and store its findings # in the named variable. `aps lint` exits 0 (clean/warnings) or 1 (errors); any diff --git a/test/fixtures/release-invalid/plans/releases/.release.md b/test/fixtures/release-invalid/plans/releases/.release.md new file mode 100644 index 0000000..648a9b0 --- /dev/null +++ b/test/fixtures/release-invalid/plans/releases/.release.md @@ -0,0 +1,3 @@ +# Release Plan: vX.Y.Z + + diff --git a/test/fixtures/release-invalid/plans/releases/README.md b/test/fixtures/release-invalid/plans/releases/README.md new file mode 100644 index 0000000..100eae9 --- /dev/null +++ b/test/fixtures/release-invalid/plans/releases/README.md @@ -0,0 +1,3 @@ +# Releases + +Directory guide, not a release narrative. Never linted as one. diff --git a/test/fixtures/release-invalid/plans/releases/notes.md b/test/fixtures/release-invalid/plans/releases/notes.md new file mode 100644 index 0000000..eecce9d --- /dev/null +++ b/test/fixtures/release-invalid/plans/releases/notes.md @@ -0,0 +1,4 @@ +# Scratch release notes + +Free-form jottings that were dropped into releases/ by mistake. Wrong name, +no header table, neither narrative section — R001, R002, R003 and R004. diff --git a/test/fixtures/release-invalid/plans/releases/v0.1.0.md b/test/fixtures/release-invalid/plans/releases/v0.1.0.md new file mode 100644 index 0000000..c933349 --- /dev/null +++ b/test/fixtures/release-invalid/plans/releases/v0.1.0.md @@ -0,0 +1,16 @@ +# Release Plan: v0.1.0 + +| Field | Value | +| ------ | ---------- | +| Target | v0.1.0 | +| Status | Planning | +| Date | 2026-04-01 | + +## Release Theme + +**Theme only** — this narrative stops before enumerating scope, so R004 fires +and nothing else does. + +## Verification + +- Exercises R004 in isolation. diff --git a/test/fixtures/release-invalid/plans/releases/v0.2.0.md b/test/fixtures/release-invalid/plans/releases/v0.2.0.md new file mode 100644 index 0000000..09d9cb3 --- /dev/null +++ b/test/fixtures/release-invalid/plans/releases/v0.2.0.md @@ -0,0 +1,17 @@ +# Release Plan: v0.2.0 + +| Field | Value | +| ------ | ---------- | +| Status | Cutting | +| Date | 2026-05-01 | + +## Release Theme + +**Untargeted** — the header table never says which version this release is +for, so R002 fires while R001, R003 and R004 stay silent. + +## What Ships + +| Area | Detail | +| ---- | ------ | +| Lint | R002 in isolation | diff --git a/test/fixtures/release-invalid/plans/releases/v0.4.0.md b/test/fixtures/release-invalid/plans/releases/v0.4.0.md new file mode 100644 index 0000000..257e34f --- /dev/null +++ b/test/fixtures/release-invalid/plans/releases/v0.4.0.md @@ -0,0 +1,17 @@ +# Release Plan: v0.4.0 + +| Field | Value | +| ------ | ---------- | +| Target | v0.4.0 | +| Status | Shipped | +| Date | 2026-07-01 | + +## What Ships + +| Area | Detail | +| ---- | ------ | +| Lint | R003 in isolation | + +## Verification + +- Exercises R003 in isolation. diff --git a/test/fixtures/release-invalid/plans/releases/v0.5.0.md b/test/fixtures/release-invalid/plans/releases/v0.5.0.md new file mode 100644 index 0000000..af6ac3b --- /dev/null +++ b/test/fixtures/release-invalid/plans/releases/v0.5.0.md @@ -0,0 +1,4 @@ +# Release Plan: v0.5.0 + +An empty shell. Named correctly, so R001 stays silent while R002, R003 and +R004 all fire — the multi-finding emission order is part of the contract. diff --git a/test/fixtures/release/plans/releases/.release.md b/test/fixtures/release/plans/releases/.release.md new file mode 100644 index 0000000..648a9b0 --- /dev/null +++ b/test/fixtures/release/plans/releases/.release.md @@ -0,0 +1,3 @@ +# Release Plan: vX.Y.Z + + diff --git a/test/fixtures/release/plans/releases/README.md b/test/fixtures/release/plans/releases/README.md new file mode 100644 index 0000000..de8327a --- /dev/null +++ b/test/fixtures/release/plans/releases/README.md @@ -0,0 +1,4 @@ +# Releases + +Release narratives live here as `v.md`. This guide is not a release +plan and must never be linted as one. diff --git a/test/fixtures/release/plans/releases/v0.3.0.md b/test/fixtures/release/plans/releases/v0.3.0.md new file mode 100644 index 0000000..6f3a05a --- /dev/null +++ b/test/fixtures/release/plans/releases/v0.3.0.md @@ -0,0 +1,25 @@ +# Release Plan: v0.3.0 + +| Field | Value | +| ---------------- | ------------------- | +| Target | v0.3.0 | +| Cut from | `main` | +| Previous release | v0.2.0 (2026-05-01) | +| Status | Shipped | +| Date | 2026-06-01 | + +## Release Theme + +**Release-plan linting** — the fixture release narrative every CLI must accept +unchanged. It carries the full shape R001–R004 look for: a `v.md` +name, a header table with Target and Status rows, and both narrative sections. + +## What Ships + +| Area | Detail | +| ---- | ------ | +| Lint | R001–R004 in all three CLIs | + +## Verification + +- `aps lint plans` reports this file valid in bash, Rust and PowerShell. diff --git a/test/run.sh b/test/run.sh index 4153907..b683bf8 100755 --- a/test/run.sh +++ b/test/run.sh @@ -848,6 +848,45 @@ echo -n "Test: cross-CLI parity harness present and wired into CI... " grep -q 'cli-parity.sh' "$PROJECT_ROOT/.github/workflows/ci.yml" || fail "ci.yml does not run test/cli-parity.sh" pass +# Test 53b: REL-003 / D-039 — release-plan rules (R001-R004) exist in all three +# linters, ship in the installer manifests, and the parity harness carries +# release fixtures. Byte-for-byte behaviour is asserted by test/cli-parity.sh; +# these are the cheap string guards that keep a port from silently +# disappearing again (the rules were Rust-only from REL-003 until v0.9.0). +echo -n "Test: release-plan rules present in all three CLIs... " +[[ -f "$PROJECT_ROOT/lib/rules/release.sh" ]] || fail "lib/rules/release.sh missing" +[[ -f "$PROJECT_ROOT/lib/rules/Release.psm1" ]] || fail "lib/rules/Release.psm1 missing" +for code in R001 R002 R003 R004; do + grep -q "\"$code\"" "$PROJECT_ROOT/cli/src/lint.rs" || fail "lint.rs missing $code" + grep -q "\"$code\"" "$PROJECT_ROOT/lib/rules/release.sh" || fail "release.sh missing $code" + grep -q "\"$code\"" "$PROJECT_ROOT/lib/rules/Release.psm1" || fail "Release.psm1 missing $code" +done +grep -q 'lint_release' "$PROJECT_ROOT/lib/lint.sh" || fail "lib/lint.sh does not dispatch release files" +grep -q 'Invoke-ApsReleaseLint' "$PROJECT_ROOT/lib/Lint.psm1" || fail "Lint.psm1 does not dispatch release files" +grep -q 'release-invalid/plans' "$PROJECT_ROOT/test/cli-parity.sh" || fail "cli-parity.sh missing release fixtures" +output=$($APS lint "$SCRIPT_DIR/fixtures/release-invalid/plans" 2>&1) || true +for code in R001 R002 R003 R004; do + echo "$output" | grep -q "$code" || fail "$code not raised on the malformed release fixture" +done +output=$($APS lint "$SCRIPT_DIR/fixtures/release/plans" 2>&1) || true +echo "$output" | grep -q "no issues" || fail "clean release fixture did not lint cleanly (got: $output)" +pass + +# Test 53c: a new lib/rules/*.sh sourced by bin/aps must also ship in every +# installer manifest, or a vendored/scaffolded bash CLI dies on startup with +# "No such file or directory". Caught exactly that for release.sh in v0.9.0. +echo -n "Test: every rule module sourced by bin/aps ships in the manifests... " +while IFS= read -r rule; do + grep -q "lib/rules/$rule" "$PROJECT_ROOT/lib/scaffold.sh" || fail "lib/scaffold.sh manifest missing $rule" + grep -q "lib/rules/$rule" "$PROJECT_ROOT/scaffold/init.sh" || fail "scaffold/init.sh manifest missing $rule" + grep -q "lib/rules/$rule" "$PROJECT_ROOT/scaffold/install" || fail "scaffold/install manifest missing $rule" + grep -q "lib/rules/$rule" "$PROJECT_ROOT/scaffold/update" || fail "scaffold/update manifest missing $rule" + grep -q "rules/$rule" "$PROJECT_ROOT/scaffold/upgrade" || fail "scaffold/upgrade manifest missing $rule" + grep -q "rules/$rule" "$PROJECT_ROOT/cli/src/migrate.rs" || fail "migrate.rs APS_LIB_FILES missing $rule" + grep -q "rules/$rule" "$PROJECT_ROOT/cli/src/doctor.rs" || fail "doctor.rs APS_LIB_FILES missing $rule" +done < <(grep -oE 'rules/[a-z]+\.sh' "$PROJECT_ROOT/bin/aps" | sed 's|rules/||' | sort -u) +pass + # Test 54: PKG-002 — W022 Packages: tag validation (bash, canonical behaviour). # A tag that resolves to no workspace directory warns; resolvable tags and # single-package projects (no packages/ or apps/ dirs) stay silent. PowerShell From bc8d63756af40e3c03c0f355aee0832ed64e1e06 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 17:39:46 +0000 Subject: [PATCH 2/6] docs(release): link module plans from the v0.9.0 What Ships sections The release template writes module references as markdown links (`**APS modules:** [name](../modules/name.aps.md)`), and v0.5.0 follows that; v0.9.0 was using plain module names. Linking them makes the record machine-readable for release tooling, which resolves work-item scope by following those links out of `## What Ships`. With the links in place the record resolves 4 modules and 20 work items in scope, correctly identifying CLI-003 as the one item awaiting release closeout. APS: CIB-006 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01N2expUMF61CKDC5zxZUeev --- plans/releases/v0.9.0.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/plans/releases/v0.9.0.md b/plans/releases/v0.9.0.md index bb9d6a0..ad3dcf1 100644 --- a/plans/releases/v0.9.0.md +++ b/plans/releases/v0.9.0.md @@ -33,8 +33,8 @@ Self-update lands on `aps update --global`, leaving project `update` untouched. | Fail-closed | `--global` is rejected alongside a project dir, symlink payloads in release archives are refused, and an unreadable native binary fails closed rather than silently (`7f50c9f`) | | No downgrades | Bash and PowerShell `--global` never overwrite a native binary with the script runtime | -**APS modules:** cli-redesign (6/7 work items done — CLI-002 remains, gated on -TEAM). +**APS modules:** [cli-redesign](../modules/cli-redesign.aps.md) (6/7 work +items done — CLI-002 remains, gated on TEAM). ### Diagnostics (plan-doctor) @@ -48,7 +48,8 @@ fired on a plan with no real defects. | Filename prefixes | The "no `NN-` prefix" half of W02 is demoted to a new Info code, reported once per directory with a count | | Local conventions | A consistently-applied undocumented status (`Archived` on archived index rows) reports under its own Info code, not W03 | -**APS modules:** continuous-improvement (CIB-005). Closes +**APS modules:** [continuous-improvement](../modules/continuous-improvement-backlog.aps.md) +(CIB-005). Closes [#132](https://github.com/eddacraft/anvil-plan-spec/issues/132) / ISS-010. ### Validation parity (three-CLI lockstep) @@ -63,8 +64,9 @@ never discovered `plans/releases/v*.md` at all. | Regression | Release fixtures in the shared parity suite pin the three-way byte identity; the harness's findings regex now matches `R` codes, which it previously dropped | | Manifests | Every bash rule module sourced by `bin/aps` now ships in all fourteen installer/updater manifests, with a test deriving the list from `bin/aps` itself | -**APS modules:** continuous-improvement (CIB-006). Satisfies D-039 and -supersedes REL-003's Rust-only rationale. Closes ISS-011. +**APS modules:** [continuous-improvement](../modules/continuous-improvement-backlog.aps.md) +(CIB-006). Satisfies D-039 and supersedes REL-003's Rust-only rationale. +Closes ISS-011. ### Maintenance @@ -73,7 +75,8 @@ supersedes REL-003's Rust-only rationale. Closes ISS-011. | Dependencies | markdownlint-cli 0.49.1 and patched MCP SDK transitives resolve 17 Dependabot alerts (`0ec815d`, `f2ae14c`) | | Plan hygiene | Status drift corrected (PROMPTS-001, EXAMPLES-001, cli-redesign, D-045), the roadmap overview refreshed, and the completed archive's false `Unreleased` heading fixed | -**APS modules:** prompts (Complete), examples (Complete). +**APS modules:** [prompts](../modules/prompts.aps.md) (Complete), +[examples](../modules/examples.aps.md) (Complete). ## Success Criteria From 40a0517acb81124d65c800f2d4db6cbf2b0054f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 17:45:06 +0000 Subject: [PATCH 3/6] feat(cli): add aps release subcommand (REL-005, Rust phase) Implements the four subcommands REL-005 specifies, in the Rust CLI only. new scaffold plans/releases/v.md from the template status modules, lifecycle dates, and work-item completion for a release notes draft markdown from Complete items since the previous release close advance Merged/Released items to Complete with release evidence The closeout reads the prose release record plus the plan tree directly. No JSON sidecar: APS is markdown-first, and a sibling project's equivalent script was unusable precisely because it expected structured input the records do not carry. Two safety properties worth keeping. Dry-run is the default and was verified to write nothing, by diffing a scratch copy of plans/ afterwards. And --apply refuses a record whose Status is not Shipped/Released/Archived unless --force, so a release cannot be closed out mid-prep; a record with no ship evidence warns that it would stamp today rather than doing so silently, with --date and --tag to override. Two defects were found by running the commands against this repo's real release records rather than fixtures, and fixed: - status reported an unshipped release as shipped today, defaulting the ship date to the planning date. v0.9.0 now renders Cut and Shipped as absent, which is the honest answer. - the (cut) label used by the v0.8.0 and v0.8.1 records was not understood, so the cut date was reported as the ship date and the real one dropped. Module resolution follows markdown links to module plans out of ## What Ships, which the release template already specified; the v0.9.0 record was updated to use them in bc8d637. REL-005 stays In Progress, not Complete. D-039 requires the bash and PowerShell CLIs to carry the same surface with shared-fixture coverage before a new command surface is done, and only the Rust phase has landed. Phasing is allowed by the work item; shipping one-CLI-only is not. The CHANGELOG entry sits under Unreleased rather than in v0.9.0 for the same reason, so the release narrative's out-of-scope list stays accurate. Verification: cargo fmt --check, clippy -D warnings, cargo test (230), test/run.sh, test/cli-parity.sh, markdownlint, aps lint (50 files, no issues). APS: REL-005 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01N2expUMF61CKDC5zxZUeev --- CHANGELOG.md | 13 + cli/src/main.rs | 101 + cli/src/release.rs | 2894 +++++++++++++++++++++++++ plans/modules/release-planning.aps.md | 33 +- 4 files changed, 3040 insertions(+), 1 deletion(-) create mode 100644 cli/src/release.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index f15c135..874e014 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Added + +- **`aps release` (REL-005, Rust phase)** — `new` scaffolds a release record + from the template, `status` summarises a release's modules and work-item + completion, `notes` drafts markdown from Complete items since the previous + release, and `close` reads the prose release record plus the plan tree and + advances `Merged`/`Released` items to `Complete` with a release-evidence + line, reporting every item advanced or skipped. Dry-run is the default; + `--apply` mutates, and refuses a record that has not shipped unless + `--force`. The prose record drives the closeout — no JSON sidecar. + **Not yet in the bash or PowerShell CLIs**, so per D-039 the work item stays + In Progress until all three agree with shared-fixture coverage. + ## [0.9.0] - 2026-09-12 **Release narrative:** [plans/releases/v0.9.0.md](./plans/releases/v0.9.0.md). diff --git a/cli/src/main.rs b/cli/src/main.rs index 79dd36c..d9ac8f0 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -17,6 +17,7 @@ mod migrate; mod next; mod orchestrate; mod parser; +mod release; mod rollup; mod scaffold; mod self_update; @@ -224,10 +225,73 @@ enum Command { #[arg(long)] json: bool, }, + /// Create, track, and close out release records under plans/releases/ + Release { + #[command(subcommand)] + command: ReleaseCommand, + }, /// Diagnose migration state (global binary, cli_version, leftover CLI) Doctor, } +// `propagate_version` hands every subcommand an auto `--version` flag, which +// would collide with these positional `version` arguments. The release version +// is positional here, so the inherited flag is dropped. +#[derive(Subcommand)] +enum ReleaseCommand { + /// Create plans/releases/v.md from the release template + #[command(disable_version_flag = true)] + New { + /// Release version (0.6.0 or v0.6.0) + version: String, + /// Plan root directory (default: plans) + #[arg(long, value_name = "DIR")] + plans: Option, + }, + /// Summarise work-item completion for a release (default: the newest) + #[command(disable_version_flag = true)] + Status { + /// Release version (default: the most recent record) + version: Option, + /// Plan root directory (default: plans) + #[arg(long, value_name = "DIR")] + plans: Option, + }, + /// Draft markdown release notes from Complete items since the last release + #[command(disable_version_flag = true)] + Notes { + /// Release version (0.6.0 or v0.6.0) + version: String, + /// Plan root directory (default: plans) + #[arg(long, value_name = "DIR")] + plans: Option, + }, + /// Advance Merged/Released work items to Complete with release evidence + #[command(disable_version_flag = true)] + Close { + /// Release version (0.6.0 or v0.6.0) + version: String, + /// Write the changes (default: dry run) + #[arg(long)] + apply: bool, + /// Preview only — never modify files (the default) + #[arg(long)] + dry_run: bool, + /// Advance even when the record's Status is not Shipped/Released + #[arg(long)] + force: bool, + /// Tag commit for the evidence line (default: from the record's prose) + #[arg(long, value_name = "SHA")] + tag: Option, + /// Release date for the evidence line (default: the record's ship date) + #[arg(long, value_name = "YYYY-MM-DD")] + date: Option, + /// Plan root directory (default: plans) + #[arg(long, value_name = "DIR")] + plans: Option, + }, +} + fn main() { let cli = Cli::parse(); match cli.command { @@ -410,6 +474,43 @@ fn main() { stale_days, )); } + Some(Command::Release { command }) => { + let code = match command { + ReleaseCommand::New { version, plans } => { + let resolved = resolve_plans(plans, cli.strict, "aps release new"); + release::cmd_release_new(&resolved, &version) + } + ReleaseCommand::Status { version, plans } => { + let resolved = resolve_plans(plans, cli.strict, "aps release status"); + release::cmd_release_status(&resolved, version.as_deref()) + } + ReleaseCommand::Notes { version, plans } => { + let resolved = resolve_plans(plans, cli.strict, "aps release notes"); + release::cmd_release_notes(&resolved, &version) + } + ReleaseCommand::Close { + version, + apply, + dry_run, + force, + tag, + date, + plans, + } => { + let resolved = resolve_plans(plans, cli.strict, "aps release close"); + // --dry-run is the default and always wins over --apply. + release::cmd_release_close( + &resolved, + &version, + apply && !dry_run, + force, + tag.as_deref(), + date.as_deref(), + ) + } + }; + std::process::exit(code); + } Some(Command::Doctor) => { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); std::process::exit(doctor::run(&cwd)); diff --git a/cli/src/release.rs b/cli/src/release.rs new file mode 100644 index 0000000..b7225cc --- /dev/null +++ b/cli/src/release.rs @@ -0,0 +1,2894 @@ +//! Native `aps release` — new / status / notes / close (REL-005). +//! +//! Release records are **prose Markdown** (`plans/releases/v.md`), +//! not a structured sidecar: APS is markdown-first, and the index constraint +//! "no runtime dependencies" rules out a JSON companion. Every command here +//! therefore reads the narrative itself — its header table, its +//! `## What Ships` module links and work-item mentions, and its ship-evidence +//! prose — using the shared document model in `parser`. +//! +//! `close` is the load-bearing one: after a cut ships, work items sitting in +//! the terminal-ish lifecycle states (`Merged` / `Released` / `Shipped`) +//! advance to `Complete` with a release-evidence line, and the module and +//! index rows that reflect them advance too. It is a dry run unless `--apply` +//! is passed, and both paths report from the same plan, so the preview and the +//! write can never disagree. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::date; +use crate::next::plan_roots; +use crate::orchestrate::{RewriteMode, rewrite_work_item}; +use crate::parser::{self, PlanFile}; + +/// Fallback for `aps release new` when the project has no +/// `plans/releases/.release.template.md` of its own. +const EMBEDDED_TEMPLATE: &str = include_str!("../templates/release.template.md"); + +/// Lifecycle states that `close` advances. `parser::normalize_status` reports +/// these as `Unknown` by design — they are terminal *compaction* markers +/// (plans/aps-rules.md § Status Vocabulary), not canonical states — so the +/// release lifecycle recognises them here and nowhere else. +const RELEASE_STATES: [&str; 3] = ["merged", "released", "shipped"]; + +/// Record `Status` values that mean the cut is out the door, so advancing +/// work items against it is safe. +const SHIPPED_STATES: [&str; 3] = ["shipped", "released", "archived"]; + +// --- Versions ---------------------------------------------------------------- + +/// `v0.5.0` and `0.5.0` name the same release. +pub fn normalize_version(raw: &str) -> String { + let trimmed = raw.trim(); + trimmed + .strip_prefix('v') + .or_else(|| trimmed.strip_prefix('V')) + .unwrap_or(trimmed) + .to_string() +} + +/// Ordering key for a version string: numeric components first, then a +/// pre-release marker (`1.0.0-beta` sorts *before* `1.0.0`), then the +/// pre-release text. +fn version_key(version: &str) -> (Vec, u8, String) { + let (core, pre) = match version.find(['-', '+']) { + Some(index) => (&version[..index], &version[index + 1..]), + None => (version, ""), + }; + let numbers = core + .split('.') + .map(|part| part.parse::().unwrap_or(0)) + .collect(); + (numbers, u8::from(pre.is_empty()), pre.to_ascii_lowercase()) +} + +fn releases_dir(plans: &Path) -> PathBuf { + plans.join("releases") +} + +fn record_path(plans: &Path, version: &str) -> PathBuf { + releases_dir(plans).join(format!("v{version}.md")) +} + +/// Every release record under `/releases/`, ascending by version. +/// Mirrors the lint notion of a release filename: `v….md`. +fn list_records(plans: &Path) -> Vec<(String, PathBuf)> { + let mut out: Vec<(String, PathBuf)> = Vec::new(); + let Ok(entries) = fs::read_dir(releases_dir(plans)) else { + return out; + }; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + let Some(stem) = name.strip_suffix(".md") else { + continue; + }; + let Some(rest) = stem.strip_prefix('v') else { + continue; + }; + if !rest.starts_with(|c: char| c.is_ascii_digit()) { + continue; + } + out.push((rest.to_string(), entry.path())); + } + out.sort_by(|left, right| version_key(&left.0).cmp(&version_key(&right.0))); + out +} + +// --- Markdown helpers -------------------------------------------------------- + +/// A table row that carries no data — only `|`, `:`, `-` and spaces. +fn is_separator_row(line: &str) -> bool { + line.starts_with('|') && line.chars().all(|c| matches!(c, '|' | ':' | '-' | ' ')) +} + +/// Two-column header-table lookup: `| Target | v0.5.0 |` → `v0.5.0`. Scans +/// the whole document so a record with a long guidance comment still resolves. +fn header_field(plan: &PlanFile, name: &str) -> String { + for line in &plan.lines { + if !line.starts_with('|') || is_separator_row(line) { + continue; + } + let mut cells = line.split('|').skip(1); + let Some(label) = cells.next() else { + continue; + }; + if label.trim() != name { + continue; + } + if let Some(value) = cells.next() { + return value.trim().to_string(); + } + } + String::new() +} + +/// `split('|')` index of the cell whose text is `name`, for a table header row. +fn header_column(line: &str, name: &str) -> Option { + if !line.starts_with('|') || is_separator_row(line) { + return None; + } + line.split('|').position(|cell| cell.trim() == name) +} + +/// Replace cell `col` (a `split('|')` index) of a markdown table row, +/// preserving the cell's original width when the new value is shorter so the +/// surrounding table stays aligned and the row keeps its column count. +fn replace_cell(line: &str, col: usize, value: &str) -> String { + let parts: Vec<&str> = line.split('|').collect(); + if col == 0 || col >= parts.len() { + return line.to_string(); + } + let width = parts[col].chars().count(); + let mut cell = format!(" {value} "); + let len = cell.chars().count(); + if len < width { + cell.push_str(&" ".repeat(width - len)); + } + let mut out = String::new(); + for (index, part) in parts.iter().enumerate() { + if index > 0 { + out.push('|'); + } + if index == col { + out.push_str(&cell); + } else { + out.push_str(part); + } + } + out +} + +/// Blank-line-separated blocks with their lines joined by a space. Markdown +/// prose is hard-wrapped, so evidence like ``tag `v0.5.0` on `408f8cf``` can +/// straddle a line break; blocks make the scan wrap-insensitive. +fn prose_blocks(lines: &[String]) -> Vec { + let mut blocks = Vec::new(); + let mut current: Vec<&str> = Vec::new(); + for line in lines { + if line.trim().is_empty() { + if !current.is_empty() { + blocks.push(current.join(" ")); + current.clear(); + } + continue; + } + current.push(line.trim()); + } + if !current.is_empty() { + blocks.push(current.join(" ")); + } + blocks +} + +/// Backtick-quoted spans of `text`, in order. +fn backticked(text: &str) -> Vec<&str> { + let mut out = Vec::new(); + let bytes = text.as_bytes(); + let mut index = 0; + while index < bytes.len() { + if bytes[index] != b'`' { + index += 1; + continue; + } + let start = index + 1; + match text[start..].find('`') { + Some(offset) => { + out.push(&text[start..start + offset]); + index = start + offset + 1; + } + None => break, + } + } + out +} + +/// A git object name: 7–40 hex digits. +fn looks_like_sha(token: &str) -> bool { + (7..=40).contains(&token.len()) && token.chars().all(|c| c.is_ascii_hexdigit()) +} + +/// Every `YYYY-MM-DD` in `text`, in order. +fn dates_in(text: &str) -> Vec { + let chars: Vec = text.chars().collect(); + let mut out = Vec::new(); + let mut index = 0; + while index + 10 <= chars.len() { + let window: String = chars[index..index + 10].iter().collect(); + if date::parse_civil_date(&window).is_some() { + out.push(window); + index += 10; + } else { + index += 1; + } + } + out +} + +/// Each date in a `Date` row paired with the lower-cased `(…)` label that +/// follows it, or an empty label when the date is unlabelled. The label runs +/// to the next date, so `2026-07-28 (cut); 2026-07-29 (shipped)` yields +/// `[("2026-07-28", "cut"), ("2026-07-29", "shipped")]`. +/// +/// The labels actually in use across this repo's records are `planning`, +/// `cut`, `shipped`, the combined `planning + shipped`, and none at all +/// (v0.3.0). Anything else is left unrecognised on purpose: reporting no date +/// is always better than reporting a wrong one. +fn labelled_dates(row: &str) -> Vec<(String, String)> { + let dates = dates_in(row); + let mut spans: Vec<(usize, String)> = Vec::new(); + let mut cursor = 0; + for date in dates { + if let Some(offset) = row[cursor..].find(&date) { + let at = cursor + offset; + cursor = at + date.len(); + spans.push((at + date.len(), date)); + } + } + let mut out = Vec::new(); + for (index, (after, date)) in spans.iter().enumerate() { + let end = spans + .get(index + 1) + .map(|(_, next)| row.find(next.as_str()).unwrap_or(row.len())) + .unwrap_or(row.len()); + let window = &row[(*after).min(end)..end.max(*after)]; + let label = match (window.find('('), window.find(')')) { + (Some(open), Some(close)) if close > open => { + window[open + 1..close].trim().to_lowercase() + } + _ => String::new(), + }; + out.push((date.clone(), label)); + } + out +} + +/// The first date whose label mentions `keyword`. +fn date_labelled(dates: &[(String, String)], keyword: &str) -> Option { + dates + .iter() + .find(|(_, label)| label.contains(keyword)) + .map(|(date, _)| date.clone()) +} + +// --- The prose release record ------------------------------------------------ + +/// A release narrative, read as prose. Every field is best-effort: a record is +/// hand-written Markdown, so a missing row degrades to a warning and a +/// documented fallback rather than a parse failure. +#[derive(Debug, Default)] +pub struct ReleaseRecord { + pub path: String, + /// Version from the `Target` row, falling back to the filename. + pub version: String, + /// `Status` row verbatim (`Shipped`, `Planning`, …). + pub status: String, + /// `Previous release` row, version only (`v0.4.0 (2026-06-22)` → `0.4.0`). + pub previous: String, + pub previous_date: String, + /// `Date` row entry labelled `(planning)`, empty when absent. + pub planning_date: String, + /// `Date` row entry labelled `(cut)`, empty when absent. + pub cut_date: String, + /// Shipped date: the `Date` row entry labelled `(shipped)` (or + /// `(planning + shipped)`), else a `Ship evidence (…)` date, else — only + /// for a terminal record whose `Date` row carries no labels at all — the + /// last date in that row. Empty otherwise: an unshipped release has no + /// ship date, and inventing one asserts something false. + pub ship_date: String, + /// Tag commit from the ship-evidence prose. + pub tag_commit: String, + /// Module plan files linked from `## What Ships` (resolved, existing). + pub module_links: Vec, + /// Bare module names from the `**APS module(s):**` lines in + /// `## What Ships`. Records in the wild write those references both as + /// links and as plain text, so both are collected. + pub module_refs: Vec, + /// Work-item IDs named in `## What Ships`, ranges expanded. + pub named_items: Vec, + /// Non-fatal observations about the record itself. + pub warnings: Vec, +} + +/// Read a release record. Fails only when the file cannot be read or carries +/// no recognisable release header table (lint R002). +pub fn parse_record(path: &Path) -> Result { + let path_str = path.to_string_lossy().into_owned(); + let plan = PlanFile::load(&path_str) + .map_err(|err| format!("cannot read release record {path_str}: {err}"))?; + + let mut record = ReleaseRecord { + path: path_str.clone(), + ..ReleaseRecord::default() + }; + + let target = header_field(&plan, "Target"); + record.status = header_field(&plan, "Status"); + if target.is_empty() && record.status.is_empty() { + return Err(format!( + "{path_str}: no release header table with Target and Status rows (lint R002) — cannot read this record as a release narrative" + )); + } + if target.is_empty() { + record.warnings.push( + "header table has no Target row (lint R002); using the filename for the version" + .to_string(), + ); + } + if record.status.is_empty() { + record + .warnings + .push("header table has no Status row (lint R002)".to_string()); + } + + let file_version = path + .file_stem() + .map(|stem| normalize_version(&stem.to_string_lossy())) + .unwrap_or_default(); + record.version = if target.is_empty() { + file_version.clone() + } else { + normalize_version(&target) + }; + if !target.is_empty() && !file_version.is_empty() && record.version != file_version { + record.warnings.push(format!( + "header table Target is v{} but the filename says v{file_version}", + record.version + )); + } + + let previous = header_field(&plan, "Previous release"); + record.previous = normalize_version(previous.split_whitespace().next().unwrap_or("")); + record.previous_date = dates_in(&previous).first().cloned().unwrap_or_default(); + + let date_row = header_field(&plan, "Date"); + let dates = labelled_dates(&date_row); + let unlabelled = !dates.is_empty() && dates.iter().all(|(_, label)| label.is_empty()); + let terminal = SHIPPED_STATES + .iter() + .any(|state| record.status.to_lowercase().starts_with(state)); + + record.planning_date = date_labelled(&dates, "planning") + .or_else(|| unlabelled.then(|| dates[0].0.clone())) + .unwrap_or_default(); + record.cut_date = date_labelled(&dates, "cut").unwrap_or_default(); + // A `shipped` label is authoritative. Failing that, an entirely unlabelled + // Date row on a terminal record (v0.3.0's bare `2026-05-20`) is a ship + // date. A labelled row that never says shipped — v0.4.0's + // `(planning), (cut)`, v0.9.0's `(planning)` — has none, and says so. + record.ship_date = date_labelled(&dates, "ship") + .or_else(|| date_labelled(&dates, "released")) + .or_else(|| (unlabelled && terminal).then(|| dates[dates.len() - 1].0.clone())) + .unwrap_or_default(); + + let blocks = prose_blocks(&plan.lines); + if let Some(evidence) = blocks + .iter() + .find(|block| block.to_lowercase().starts_with("ship evidence")) + && let Some(date) = dates_in(evidence).first() + { + record.ship_date = date.clone(); + } + if record.ship_date.is_empty() && terminal && !dates.is_empty() { + record.warnings.push(format!( + "Status is '{}' but the Date row labels no date as shipped ({date_row:?}) — `close` needs --date", + record.status + )); + } + record.tag_commit = find_tag_commit(&blocks, &record.version); + + // `## What Ships` is the template's designated inventory of what the cut + // delivers, so it — and not the whole document — scopes the closeout. + // Risks, Out of Scope and Related deliberately name work that is *not* + // shipping. A record with no such section falls back to the whole file. + let mut ships: Vec<&str> = plan.section_content("## What Ships"); + if ships.is_empty() { + record.warnings.push( + "no ## What Ships section (lint R004); scoping from the whole record instead" + .to_string(), + ); + ships = plan.lines.iter().map(String::as_str).collect(); + } + let base = path.parent().unwrap_or(Path::new(".")); + record.module_links = module_links(&ships, base); + record.module_refs = module_references(&ships); + record.named_items = named_item_ids(&ships.join("\n")); + + // Documented fallback: when `## What Ships` names no modules at all, the + // `**Module specs:**` bullet under `## Related` carries the same links in + // most records. Only reached when the primary scope is empty, and the + // caller warns that it was used. + if record.module_links.is_empty() && record.module_refs.is_empty() { + let related = plan.section_content("## Related"); + let specs: Vec<&str> = related + .iter() + .copied() + .filter(|line| line.contains("**Module specs:**")) + .chain(continuation_lines(&related, "**Module specs:**")) + .collect(); + record.module_links = module_links(&specs, base); + if !record.module_links.is_empty() { + record.warnings.push( + "## What Ships names no modules; scoped from the **Module specs:** list under ## Related instead" + .to_string(), + ); + } + } + + Ok(record) +} + +/// The indented continuation lines of a wrapped `- **Field:** …` bullet. +fn continuation_lines<'a>(lines: &[&'a str], marker: &str) -> Vec<&'a str> { + let Some(start) = lines.iter().position(|line| line.contains(marker)) else { + return Vec::new(); + }; + lines[start + 1..] + .iter() + .take_while(|line| { + let trimmed = line.trim_start(); + !trimmed.is_empty() && trimmed.len() != line.len() && !trimmed.starts_with("- **") + }) + .copied() + .collect() +} + +/// Bare module names from the `**APS module:**` / `**APS modules:**` lines the +/// template puts under each `## What Ships` area. Records write these both as +/// markdown links (picked up by `module_links`) and as plain text, e.g. +/// `**APS modules:** cli-redesign (6/7 work items done — CLI-002 remains)`. +/// +/// Parenthesised asides and link targets are dropped, then every remaining +/// lowercase word is offered as a candidate. Resolution against the plan tree +/// is what actually filters them, so prose like `Closes` or `Satisfies` simply +/// never matches and needs no grammar of its own. +fn module_references(ships: &[&str]) -> Vec { + let owned: Vec = ships.iter().map(|line| (*line).to_string()).collect(); + let mut seen: BTreeSet = BTreeSet::new(); + let mut out = Vec::new(); + for block in prose_blocks(&owned) { + let lower = block.to_lowercase(); + let Some(at) = lower.find("**aps module") else { + continue; + }; + let Some(colon) = block[at..].find(":**") else { + continue; + }; + let text = &block[at + colon + 3..]; + + // Drop `(…)` asides and rewrite `[label](target)` down to `label`. + let mut flat = String::new(); + let mut depth = 0usize; + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '(' => depth += 1, + ')' => depth = depth.saturating_sub(1), + ']' => { + // Skip the `](target)` that follows a link label. + if chars.peek() == Some(&'(') { + for inner in chars.by_ref() { + if inner == ')' { + break; + } + } + } + } + '[' => {} + _ if depth == 0 => flat.push(c), + _ => {} + } + } + + for token in flat.split(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_')) { + let token = token.trim_matches('-'); + if token.len() < 3 + || token.chars().any(|c| c.is_ascii_uppercase()) + || !token.starts_with(|c: char| c.is_ascii_lowercase()) + { + continue; + } + if seen.insert(token.to_string()) { + out.push(token.to_string()); + } + } + } + out +} + +/// Tag commit from ship-evidence prose. Prefers the conventional +/// ``tag `v` on ```` phrasing, then any block that mentions a +/// tag and carries a hex object name. +fn find_tag_commit(blocks: &[String], version: &str) -> String { + let anchor = format!("tag `v{version}`"); + for block in blocks { + if let Some(at) = block.find(&anchor) + && let Some(sha) = backticked(&block[at + anchor.len()..]) + .into_iter() + .find(|token| looks_like_sha(token)) + { + return sha.to_string(); + } + } + for block in blocks { + let lower = block.to_lowercase(); + let Some(at) = lower.find("tag") else { + continue; + }; + if let Some(sha) = backticked(&block[at..]) + .into_iter() + .find(|token| looks_like_sha(token)) + { + return sha.to_string(); + } + } + String::new() +} + +/// Markdown link targets ending in `.aps.md`, resolved against `base` and +/// lexically normalised. Order-preserving and deduped; does not touch disk. +fn plan_link_targets(lines: &[&str], base: &Path) -> Vec { + let mut seen: BTreeSet = BTreeSet::new(); + let mut out = Vec::new(); + for line in lines { + let mut rest = *line; + while let Some(open) = rest.find("](") { + let after = &rest[open + 2..]; + let Some(close) = after.find(')') else { + break; + }; + let target = after[..close].split('#').next().unwrap_or(""); + rest = &after[close + 1..]; + if !target.ends_with(".aps.md") { + continue; + } + let resolved = parser::normalize_path(&format!("{}/{target}", base.to_string_lossy())); + if seen.insert(resolved.clone()) { + out.push(resolved); + } + } + } + out +} + +/// `plan_link_targets` filtered to links that name a file that really exists — +/// what a release record's module references have to clear. +fn module_links(lines: &[&str], base: &Path) -> Vec { + plan_link_targets(lines, base) + .into_iter() + .filter(|link| Path::new(link).is_file()) + .collect() +} + +/// Work-item IDs named in prose, with the collapsed forms release narratives +/// actually use expanded: `MONO-007/008` → both items, `MONO-001…006` → the +/// whole run. The prefix must be two or more uppercase letters, which keeps +/// decision refs (`D-039`) out, and the number two to four digits, which keeps +/// prose like `UTF-8` out. Prefixes unknown to the plan tree are filtered by +/// the caller, so an incidental `ISO-8601` never becomes a finding. +fn named_item_ids(text: &str) -> Vec { + const RANGE_MARKS: [&str; 5] = ["…", "...", "--", "–", "—"]; + let chars: Vec = text.chars().collect(); + let mut seen: BTreeSet = BTreeSet::new(); + let mut out = Vec::new(); + let mut index = 0; + + fn push(id: String, out: &mut Vec, seen: &mut BTreeSet) { + if seen.insert(id.clone()) { + out.push(id); + } + } + + while index < chars.len() { + if !chars[index].is_ascii_uppercase() { + index += 1; + continue; + } + // A letter run that continues past uppercase (`Mixed`) is prose. + let letters_start = index; + while index < chars.len() && chars[index].is_ascii_uppercase() { + index += 1; + } + let letters: String = chars[letters_start..index].iter().collect(); + if letters.len() < 2 || index >= chars.len() || chars[index] != '-' { + continue; + } + let digits_start = index + 1; + let mut cursor = digits_start; + while cursor < chars.len() && chars[cursor].is_ascii_digit() { + cursor += 1; + } + let width = cursor - digits_start; + if !(2..=4).contains(&width) { + index = cursor.max(index + 1); + continue; + } + let first: String = chars[digits_start..cursor].iter().collect(); + push(format!("{letters}-{first}"), &mut out, &mut seen); + index = cursor; + + // `/008` repetitions share the prefix and the digit width. + while index < chars.len() && chars[index] == '/' { + let mut peek = index + 1; + while peek < chars.len() && chars[peek].is_ascii_digit() { + peek += 1; + } + if peek == index + 1 { + break; + } + let number: String = chars[index + 1..peek].iter().collect(); + index = peek; + let Ok(number) = number.parse::() else { + break; + }; + push(format!("{letters}-{number:0width$}"), &mut out, &mut seen); + } + + // `…006` / `...006` / `--006` closes a run from the first ID. + let tail: String = chars[index..].iter().collect(); + let Some(mark) = RANGE_MARKS.iter().find(|mark| tail.starts_with(**mark)) else { + continue; + }; + let after = index + mark.chars().count(); + let mut peek = after; + while peek < chars.len() && chars[peek].is_ascii_digit() { + peek += 1; + } + if peek == after { + continue; + } + let last: String = chars[after..peek].iter().collect(); + index = peek; + let (Ok(from), Ok(to)) = (first.parse::(), last.parse::()) else { + continue; + }; + // A sane run, not a typo that would enumerate thousands of IDs. + if to <= from || to - from > 200 { + continue; + } + for number in from + 1..=to { + push(format!("{letters}-{number:0width$}"), &mut out, &mut seen); + } + } + out +} + +// --- The plan tree ----------------------------------------------------------- + +/// Lifecycle bucket for a raw status string. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Life { + /// `Merged` / `Released` / `Shipped` — awaiting release closeout. + Releasable, + Complete, + Open, +} + +/// True when `text` begins with `word` as a whole word. +fn starts_with_word(text: &str, word: &str) -> bool { + text.starts_with(word) + && !text[word.len()..] + .chars() + .next() + .is_some_and(|c| c.is_alphanumeric() || c == '_') +} + +/// Bucket a raw status. The canonical vocabulary and its documented aliases +/// (`Proposed` → `Draft`, `Done` → `Complete`) come from +/// `parser::normalize_status`; the release lifecycle states it reports as +/// `Unknown` are recognised here. +fn lifecycle(raw: &str) -> Life { + let letters: String = raw + .chars() + .skip_while(|c| !c.is_ascii_alphabetic()) + .collect(); + let lower = letters.to_lowercase(); + if RELEASE_STATES + .iter() + .any(|state| starts_with_word(&lower, state)) + { + return Life::Releasable; + } + if parser::normalize_status(&letters, "") == "Complete" { + return Life::Complete; + } + Life::Open +} + +/// A terminal suffix on a work-item header (`### X-001: Title — Merged`), +/// used only when the item carries no `- **Status:**` field. Mirrors lint's +/// `header_has_terminal_suffix`. +fn header_terminal_suffix(header: &str) -> String { + let mut search = header; + let mut best = String::new(); + loop { + let (at, dash) = match (search.find('—'), search.find("--")) { + (Some(em), Some(dd)) if em <= dd => (em, '—'.len_utf8()), + (Some(em), None) => (em, '—'.len_utf8()), + (_, Some(dd)) => (dd, 2), + (None, None) => return best, + }; + let rest = search[at + dash..].trim_start_matches(' '); + if lifecycle(rest) != Life::Open { + best = rest.to_string(); + } + search = &search[at + dash..]; + } +} + +#[derive(Debug, Clone)] +struct ModuleInfo { + id: String, + file: String, + index_file: Option, + raw_status: String, +} + +#[derive(Debug, Clone)] +struct TreeItem { + id: String, + title: String, + file: String, + raw_status: String, + module: String, +} + +#[derive(Debug, Default)] +struct Tree { + modules: Vec, + items: Vec, +} + +impl Tree { + fn module_by_file(&self, file: &str) -> Option<&ModuleInfo> { + self.modules.iter().find(|module| module.file == file) + } + + fn items_in(&self, file: &str) -> impl Iterator { + self.items.iter().filter(move |item| item.file == file) + } + + fn prefixes(&self) -> BTreeSet { + self.items + .iter() + .filter_map(|item| { + item.id + .split_once('-') + .map(|(prefix, _)| prefix.to_string()) + }) + .collect() + } + + /// Resolve a bare module name written in a record: the module file's stem + /// (`monorepo` → `monorepo.aps.md`), its metadata ID (`MONO`), or — for + /// the abbreviations records actually use — a *unique* stem whose name + /// continues at a `-` boundary, so `continuous-improvement` finds + /// `continuous-improvement-backlog.aps.md`. Ambiguity resolves to nothing. + fn module_by_name(&self, name: &str) -> Option<&ModuleInfo> { + let name = name.trim().to_lowercase(); + if name.is_empty() { + return None; + } + if let Some(exact) = self + .modules + .iter() + .find(|module| module_label(&module.file).to_lowercase() == name) + { + return Some(exact); + } + if let Some(by_id) = self + .modules + .iter() + .find(|module| module.id.to_lowercase() == name) + { + return Some(by_id); + } + if name.len() < 4 { + return None; + } + let boundary = format!("{name}-"); + let mut matches = self.modules.iter().filter(|module| { + module_label(&module.file) + .to_lowercase() + .starts_with(&boundary) + }); + let first = matches.next()?; + matches.next().is_none().then_some(first) + } +} + +/// What a release record says shipped, resolved against the plan tree. Shared +/// by `status`, `notes` and `close` so the three commands can never disagree +/// about a release's scope. +#[derive(Debug, Default)] +struct Scope { + /// Module plan files the record says shipped, in record order. + module_files: Vec, + /// Every work item in those modules, plus any item the record names + /// directly. Deduped, plan-tree order. + items: Vec, + /// IDs the record names whose prefix belongs to a module in this tree but + /// whose item does not exist. + unresolved: Vec, + warnings: Vec, +} + +fn build_scope(record: &ReleaseRecord, tree: &Tree) -> Scope { + let mut scope = Scope::default(); + let mut files: BTreeSet = BTreeSet::new(); + + for link in &record.module_links { + if tree.module_by_file(link).is_some() { + if files.insert(link.clone()) { + scope.module_files.push(link.clone()); + } + } else { + scope.warnings.push(format!( + "{link} is linked as a module but is not a module plan in this tree" + )); + } + } + for name in &record.module_refs { + if let Some(module) = tree.module_by_name(name) + && files.insert(module.file.clone()) + { + scope.module_files.push(module.file.clone()); + } + } + + let prefixes = tree.prefixes(); + let mut seen: BTreeSet = BTreeSet::new(); + for file in &scope.module_files { + for item in tree.items_in(file) { + if seen.insert(item.id.clone()) { + scope.items.push(item.clone()); + } + } + } + for id in &record.named_items { + // A prefix unknown to the tree is prose, not a work item, so an + // incidental `ISO-8601` never becomes a finding. + if !id + .split_once('-') + .is_some_and(|(prefix, _)| prefixes.contains(prefix)) + { + continue; + } + match tree.items.iter().find(|item| &item.id == id) { + Some(item) => { + if seen.insert(item.id.clone()) { + scope.items.push(item.clone()); + } + } + None => scope.unresolved.push(id.clone()), + } + } + scope +} + +/// The hint every command gives when a record maps to nothing. +const NO_SCOPE_HINT: &str = "add a markdown link to ../modules/.aps.md (or an `**APS modules:** ` line) under ## What Ships"; + +/// Load every module file and work item in the plan tree, keeping **raw** +/// status text. `aps next`'s `PlanGraph` normalises status, which folds the +/// release lifecycle states into `Unknown` — exactly the information the +/// closeout needs — so this walks the same roots (`next::plan_roots`, so a +/// federated tree is covered) with the same `parser` primitives instead. +fn load_tree(plans: &Path) -> Tree { + let mut tree = Tree::default(); + for root in plan_roots(plans) { + let index = root.join("index.aps.md"); + let index_file = index + .is_file() + .then(|| parser::normalize_path(&index.to_string_lossy())); + let module_dir = root.join("modules"); + if !module_dir.is_dir() { + continue; + } + for file in parser::find_aps_files(&module_dir) { + if !file.ends_with(".aps.md") { + continue; + } + let Ok(plan) = PlanFile::load(&file) else { + continue; + }; + let file = parser::normalize_path(&file); + let module_id = plan.module_id().unwrap_or_else(|| { + Path::new(&file) + .file_stem() + .map(|stem| { + stem.to_string_lossy() + .trim_end_matches(".aps") + .to_uppercase() + }) + .unwrap_or_default() + }); + tree.modules.push(ModuleInfo { + id: module_id.clone(), + file: file.clone(), + index_file: index_file.clone(), + raw_status: plan.status().unwrap_or_default(), + }); + for header in plan.work_items() { + let Some(id) = parser::parse_work_item_id(&header.header) else { + continue; + }; + let content = plan.item_content(header.line); + let mut raw_status = parser::field_value(&content, "Status"); + if raw_status.is_empty() { + raw_status = header_terminal_suffix(&header.header); + } + tree.items.push(TreeItem { + id: id.to_string(), + title: parser::work_item_title(&header.header), + file: file.clone(), + raw_status, + module: module_id.clone(), + }); + } + } + } + tree +} + +// --- Table cell locators ----------------------------------------------------- + +/// The metadata-table `Status` cell of a module plan: `(line index, +/// split index, current value)`. Matches `PlanFile::status()` — the first +/// table whose header row names both `ID` and `Status`. +fn module_status_cell(plan: &PlanFile) -> Option<(usize, usize, String)> { + let mut column = None; + for (index, line) in plan.lines.iter().enumerate() { + match column { + None => { + if header_column(line, "ID").is_some() + && let Some(col) = header_column(line, "Status") + { + column = Some(col); + } + } + Some(col) => { + if !line.starts_with('|') || is_separator_row(line) { + continue; + } + let value = line.split('|').nth(col).unwrap_or("").trim().to_string(); + return Some((index, col, value)); + } + } + } + None +} + +/// The `Status` cell of the index row that links to `module_file`. Index +/// tables are `| Module | Purpose | Status |`, but the column is located by +/// name from the nearest preceding header row so a different shape still works. +fn index_status_cell(plan: &PlanFile, module_file: &str) -> Option<(usize, usize, String)> { + let base = Path::new(&plan.path) + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + let mut column = None; + for (index, line) in plan.lines.iter().enumerate() { + if let Some(col) = header_column(line, "Status") { + column = Some(col); + continue; + } + if !line.starts_with('|') || is_separator_row(line) { + continue; + } + let links = plan_link_targets(&[line.as_str()], &base); + if !links.iter().any(|link| link == module_file) { + continue; + } + let col = column?; + let value = line.split('|').nth(col).unwrap_or("").trim().to_string(); + return Some((index, col, value)); + } + None +} + +// --- Work-item mutation ------------------------------------------------------ + +/// Insert or replace a `- **Field:** value` line immediately after a work +/// item's `- **Status:**` line (and any indented continuation of it). Status +/// itself is rewritten through `orchestrate::rewrite_work_item`, so the two +/// edits share the plan tree's one canonical notion of an item block. +fn set_item_field(path: &str, id: &str, field: &str, value: &str) -> Result<(), String> { + let text = fs::read_to_string(path).map_err(|err| format!("cannot read {path}: {err}"))?; + let lines: Vec = text.split('\n').map(str::to_string).collect(); + let header = format!("### {id}:"); + let field_prefix = format!("- **{field}:**"); + let new_line = format!("- **{field}:** {value}"); + + let mut out: Vec = Vec::with_capacity(lines.len() + 1); + let mut in_target = false; + let mut done = false; + let mut pending = false; + + for line in lines { + if pending { + // Skip over indented continuation of the Status line, then insert. + let trimmed = line.trim_start(); + if !trimmed.is_empty() && trimmed.len() != line.len() { + out.push(line); + continue; + } + out.push(new_line.clone()); + pending = false; + done = true; + } + if line.starts_with("### ") { + in_target = line.starts_with(&header); + out.push(line); + continue; + } + if in_target && line.starts_with("## ") { + in_target = false; + out.push(line); + continue; + } + if in_target && !done { + if line.starts_with(&field_prefix) { + out.push(new_line.clone()); + done = true; + continue; + } + if line.starts_with("- **Status:**") { + out.push(line); + pending = true; + continue; + } + } + out.push(line); + } + if pending { + out.push(new_line.clone()); + done = true; + } + if !done { + return Err(format!( + "{id}: no **Status:** line to anchor **{field}:** in {path}" + )); + } + fs::write(path, out.join("\n")).map_err(|err| format!("cannot write {path}: {err}")) +} + +/// Replace a located table cell in a file, re-reading it so the edit is +/// applied to whatever the earlier work-item rewrites left behind. +fn set_table_cell(path: &str, line_index: usize, col: usize, value: &str) -> Result<(), String> { + let text = fs::read_to_string(path).map_err(|err| format!("cannot read {path}: {err}"))?; + let mut lines: Vec = text.split('\n').map(str::to_string).collect(); + let line = lines + .get_mut(line_index) + .ok_or_else(|| format!("{path}: line {} vanished before the write", line_index + 1))?; + *line = replace_cell(line, col, value); + fs::write(path, lines.join("\n")).map_err(|err| format!("cannot write {path}: {err}")) +} + +// --- `aps release close` plan ------------------------------------------------ + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Advance { + pub id: String, + pub module: String, + pub file: String, + pub from: String, + pub to: String, + pub evidence: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RowChange { + /// `module` or `index`. + pub kind: &'static str, + pub module: String, + pub file: String, + pub line: usize, + pub col: usize, + pub from: String, + pub to: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Skip { + pub id: String, + pub reason: String, +} + +#[derive(Debug, Default)] +pub struct ClosePlan { + pub version: String, + pub record: String, + pub record_status: String, + pub date: String, + pub tag: String, + pub advances: Vec, + pub rows: Vec, + pub skips: Vec, + pub unresolved: Vec, + pub warnings: Vec, +} + +/// The evidence line stamped beside an advanced item. Deterministic, so the +/// bash and PowerShell ports can reproduce it byte for byte. +fn evidence_line(version: &str, date: &str, tag: &str, from: &str) -> String { + let mut out = format!("v{version} ({date}"); + if !tag.is_empty() { + out.push_str(&format!(", tag `{tag}`")); + } + out.push(')'); + if !from.is_empty() { + out.push_str(&format!(" — was {from}")); + } + out +} + +/// Work out everything `close` would do. Pure: reads the record and the plan +/// tree, writes nothing. The dry run and `--apply` both report from this, so a +/// preview can never disagree with the write. +pub fn plan_close( + plans: &Path, + version: &str, + tag_override: Option<&str>, + date_override: Option<&str>, +) -> Result { + let version = normalize_version(version); + let path = record_path(plans, &version); + if !path.is_file() { + return Err(format!( + "no release record at {} — run `aps release new {version}` first", + path.display() + )); + } + let record = parse_record(&path)?; + let tree = load_tree(plans); + + let mut plan = ClosePlan { + version: record.version.clone(), + record: record.path.clone(), + record_status: record.status.clone(), + tag: tag_override.unwrap_or(&record.tag_commit).to_string(), + warnings: record.warnings.clone(), + ..ClosePlan::default() + }; + + plan.date = match date_override { + Some(date) => { + if date::parse_civil_date(date).is_none() { + return Err(format!("--date {date} is not a YYYY-MM-DD civil date")); + } + date.to_string() + } + None if !record.ship_date.is_empty() => record.ship_date.clone(), + None => { + let today = date::today_utc_ymd(); + plan.warnings.push(format!( + "record carries no shipped date; stamping today ({today}) — override with --date" + )); + today + } + }; + if plan.tag.is_empty() { + plan.warnings.push( + "record carries no tag commit in its ship evidence; evidence will omit it — override with --tag" + .to_string(), + ); + } + + let scope = build_scope(&record, &tree); + plan.warnings.extend(scope.warnings.iter().cloned()); + plan.unresolved = scope.unresolved.clone(); + + if scope.items.is_empty() { + plan.warnings.push(format!( + "the record maps to no work items in this plan tree — nothing to close out; {NO_SCOPE_HINT}" + )); + } + + for item in &scope.items { + match lifecycle(&item.raw_status) { + Life::Releasable => plan.advances.push(Advance { + id: item.id.clone(), + module: item.module.clone(), + file: item.file.clone(), + from: item.raw_status.clone(), + to: format!("Complete: {}", plan.date), + evidence: evidence_line(&plan.version, &plan.date, &plan.tag, &item.raw_status), + }), + Life::Complete => plan.skips.push(Skip { + id: item.id.clone(), + reason: "already Complete".to_string(), + }), + Life::Open => plan.skips.push(Skip { + id: item.id.clone(), + reason: format!( + "status '{}' is not a release state", + if item.raw_status.is_empty() { + "(none)" + } else { + &item.raw_status + } + ), + }), + } + } + + // A module row only advances when this closeout actually advanced items in + // it *and* every item in it is now terminal — the same judgement the + // manual sweeps made, and it never touches a module the release did not + // move. + let mut touched: BTreeMap = BTreeMap::new(); + for advance in &plan.advances { + touched.insert(advance.file.clone(), ()); + } + for file in touched.keys() { + let advanced: BTreeSet<&str> = plan + .advances + .iter() + .filter(|advance| &advance.file == file) + .map(|advance| advance.id.as_str()) + .collect(); + let all_terminal = tree.items_in(file).all(|item| { + advanced.contains(item.id.as_str()) || lifecycle(&item.raw_status) == Life::Complete + }); + if !all_terminal { + continue; + } + let Some(module) = tree.module_by_file(file) else { + continue; + }; + if lifecycle(&module.raw_status) == Life::Complete { + continue; + } + let Ok(module_plan) = PlanFile::load(file) else { + continue; + }; + if let Some((line, col, from)) = module_status_cell(&module_plan) { + plan.rows.push(RowChange { + kind: "module", + module: module.id.clone(), + file: file.clone(), + line, + col, + from, + to: "Complete".to_string(), + }); + } + let Some(index_file) = &module.index_file else { + continue; + }; + let Ok(index_plan) = PlanFile::load(index_file) else { + continue; + }; + match index_status_cell(&index_plan, file) { + Some((line, col, from)) if lifecycle(&from) != Life::Complete => { + plan.rows.push(RowChange { + kind: "index", + module: module.id.clone(), + file: index_file.clone(), + line, + col, + from, + to: "Complete".to_string(), + }); + } + Some(_) => {} + None => plan.warnings.push(format!( + "{index_file} has no row linking {file}; its index status was left alone" + )), + } + } + + plan.advances.sort_by(|a, b| a.id.cmp(&b.id)); + plan.skips.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(plan) +} + +/// Apply a plan: item statuses and evidence first, then the table rows (whose +/// cells are relocated against the rewritten file). +fn apply_close(plan: &ClosePlan) -> Result<(), String> { + for advance in &plan.advances { + rewrite_work_item(&advance.file, &advance.id, RewriteMode::Status, &advance.to)?; + set_item_field(&advance.file, &advance.id, "Released", &advance.evidence)?; + } + for row in &plan.rows { + // Work-item rewrites can shift lines, so re-locate the cell by content. + let plan_file = PlanFile::load(&row.file) + .map_err(|err| format!("cannot re-read {}: {err}", row.file))?; + let located = if row.kind == "module" { + module_status_cell(&plan_file) + } else { + let module_file = plan + .rows + .iter() + .find(|other| other.kind == "module" && other.module == row.module) + .map(|other| other.file.clone()) + .unwrap_or_default(); + index_status_cell(&plan_file, &module_file) + }; + let (line, col, _) = located.ok_or_else(|| { + format!( + "{}: the {} Status cell for {} moved before the write", + row.file, row.kind, row.module + ) + })?; + set_table_cell(&row.file, line, col, &row.to)?; + } + Ok(()) +} + +// --- Commands ---------------------------------------------------------------- + +/// `aps release new ` — copy the release template into +/// `plans/releases/v.md` with the version, dates and previous release +/// filled in. Never overwrites an existing record. +pub fn cmd_release_new(plan_root: &str, version: &str) -> i32 { + let plans = Path::new(plan_root); + if !plans.is_dir() { + eprintln!("error: Path not found: {plan_root}"); + return 1; + } + let version = normalize_version(version); + if version.is_empty() || !version.starts_with(|c: char| c.is_ascii_digit()) { + eprintln!("error: '{version}' is not a version — expected 0.6.0 or v0.6.0"); + return 1; + } + let target = record_path(plans, &version); + if target.exists() { + eprintln!( + "error: {} already exists — refusing to overwrite a release record", + target.display() + ); + return 1; + } + + // A project's own template wins; the scaffolded copy lives beside the + // records as a dotfile, which `aps lint` skips. + let local = releases_dir(plans).join(".release.template.md"); + let (template, source) = match fs::read_to_string(&local) { + Ok(text) => (text, local.display().to_string()), + Err(_) => ( + EMBEDDED_TEMPLATE.to_string(), + "templates/release.template.md (embedded)".to_string(), + ), + }; + + let previous = list_records(plans) + .into_iter() + .map(|(found, _)| found) + .rfind(|found| version_key(found) < version_key(&version)) + .unwrap_or_default(); + let today = date::today_utc_ymd(); + let body = render_template(&template, &version, &previous, &today); + + if let Err(err) = fs::create_dir_all(releases_dir(plans)) { + eprintln!( + "error: cannot create {}: {err}", + releases_dir(plans).display() + ); + return 1; + } + if let Err(err) = fs::write(&target, body) { + eprintln!("error: cannot write {}: {err}", target.display()); + return 1; + } + + println!("Created {}", target.display()); + println!(" from: {source}"); + println!(" version: v{version}"); + println!( + " previous: {}", + if previous.is_empty() { + "(none found)".to_string() + } else { + format!("v{previous}") + } + ); + println!(" planning: {today}"); + println!("Next: fill in ## Release Theme and ## What Ships, then `aps lint`."); + 0 +} + +/// Fill the template's placeholders. The multi-line guidance comment is +/// dropped — every record in the wild keeps only the one-line APS pointer. +fn render_template(template: &str, version: &str, previous: &str, today: &str) -> String { + let mut body = String::new(); + let mut skipping = false; + for line in template.lines() { + if line.trim() == "" { + skipping = false; + } + continue; + } + body.push_str(line); + body.push('\n'); + } + // Leading blank lines left by the dropped comment. + let body = body.trim_start_matches('\n').to_string(); + + let previous_label = if previous.is_empty() { + "[previous-version]".to_string() + } else { + previous.to_string() + }; + // The header table's values go through the width-preserving cell writer + // rather than plain substitution, so filling in a version longer than the + // placeholder does not leave the table ragged. Applied to the original + // line, before any substitution has changed the cell's width. + let cells = [ + ("Target", format!("v{version}")), + ("Previous release", format!("v{previous_label}")), + ("Date", format!("{today} (planning), YYYY-MM-DD (shipped)")), + ]; + let mut out = String::with_capacity(body.len()); + for line in body.lines() { + let label = line + .starts_with('|') + .then(|| line.split('|').nth(1).map(str::trim).unwrap_or("")) + .filter(|_| !is_separator_row(line)) + .unwrap_or(""); + let rendered = match cells.iter().find(|(name, _)| *name == label) { + Some((_, value)) => replace_cell(line, 2, value), + None => line + .replace("v[previous-version]", &format!("v{previous_label}")) + .replace("v[version]", &format!("v{version}")) + .replace("[version]", version), + }; + out.push_str(&rendered); + out.push('\n'); + } + out +} + +/// `aps release status [version]` — work-item completion for a release. +pub fn cmd_release_status(plan_root: &str, version: Option<&str>) -> i32 { + let plans = Path::new(plan_root); + if !plans.is_dir() { + eprintln!("error: Path not found: {plan_root}"); + return 1; + } + let path = match version { + Some(version) => { + let version = normalize_version(version); + let path = record_path(plans, &version); + if !path.is_file() { + eprintln!("error: no release record at {}", path.display()); + return 1; + } + path + } + None => match list_records(plans).pop() { + Some((_, path)) => path, + None => { + eprintln!( + "error: no release records under {} — run `aps release new `", + releases_dir(plans).display() + ); + return 1; + } + }, + }; + + let record = match parse_record(&path) { + Ok(record) => record, + Err(err) => { + eprintln!("error: {err}"); + return 1; + } + }; + let tree = load_tree(plans); + + println!( + "Release v{} — {}", + record.version, + if record.status.is_empty() { + "(no Status row)" + } else { + &record.status + } + ); + println!("Record: {}", record.path); + // Each milestone is reported only when the record labels it. An unshipped + // release has no ship date, and a blank is the honest rendering. + println!( + "Planning: {} Cut: {} Shipped: {}", + blank_as_dash(&record.planning_date), + blank_as_dash(&record.cut_date), + blank_as_dash(&record.ship_date) + ); + println!( + "Previous: {} Tag: {}", + if record.previous.is_empty() { + "-".to_string() + } else { + format!("v{}", record.previous) + }, + blank_as_dash(&record.tag_commit) + ); + + let scope = build_scope(&record, &tree); + let mut complete = 0usize; + let mut releasable = 0usize; + let mut open = 0usize; + let mut rows: Vec<(String, String, String)> = Vec::new(); + for file in &scope.module_files { + let Some(module) = tree.module_by_file(file) else { + continue; + }; + let mut module_complete = 0usize; + let mut module_total = 0usize; + let mut module_releasable = 0usize; + for item in tree.items_in(file) { + module_total += 1; + match lifecycle(&item.raw_status) { + Life::Complete => { + module_complete += 1; + complete += 1; + } + Life::Releasable => { + module_releasable += 1; + releasable += 1; + } + Life::Open => open += 1, + } + } + let note = if module_releasable > 0 { + format!( + "module: {}, {module_releasable} awaiting closeout", + blank_as_dash(&module.raw_status) + ) + } else { + format!("module: {}", blank_as_dash(&module.raw_status)) + }; + rows.push(( + module.id.clone(), + format!("{module_complete}/{module_total} complete"), + note, + )); + } + // Items named directly but outside any scoped module still count. + for item in &scope.items { + if scope.module_files.contains(&item.file) { + continue; + } + match lifecycle(&item.raw_status) { + Life::Complete => complete += 1, + Life::Releasable => releasable += 1, + Life::Open => open += 1, + } + } + + println!(); + if rows.is_empty() { + println!("Modules: none — this record names no module plans."); + println!(" To map it to the plan tree, {NO_SCOPE_HINT}."); + } else { + println!("Modules ({}):", rows.len()); + let width = rows.iter().map(|row| row.0.len()).max().unwrap_or(0); + let counts = rows.iter().map(|row| row.1.len()).max().unwrap_or(0); + for (id, count, note) in &rows { + println!(" {id: 0 { + println!( + "Run `aps release close {}` to preview the closeout.", + record.version + ); + } + if !scope.unresolved.is_empty() { + println!( + "Unresolved: {} named but absent from the plan tree", + scope.unresolved.join(", ") + ); + } + for warning in record.warnings.iter().chain(scope.warnings.iter()) { + eprintln!("warning: {warning}"); + } + 0 +} + +fn blank_as_dash(value: &str) -> String { + if value.is_empty() { + "-".to_string() + } else { + value.to_string() + } +} + +/// `aps release notes ` — a markdown draft of the Complete items +/// since the previous release, grouped by module. +pub fn cmd_release_notes(plan_root: &str, version: &str) -> i32 { + let plans = Path::new(plan_root); + if !plans.is_dir() { + eprintln!("error: Path not found: {plan_root}"); + return 1; + } + let version = normalize_version(version); + let path = record_path(plans, &version); + if !path.is_file() { + eprintln!( + "error: no release record at {} — run `aps release new {version}` first", + path.display() + ); + return 1; + } + let record = match parse_record(&path) { + Ok(record) => record, + Err(err) => { + eprintln!("error: {err}"); + return 1; + } + }; + let tree = load_tree(plans); + + // Lower bound: the previous release's date from this record, else that + // record's own shipped date. Without one, every dated Complete item counts. + let mut since = record.previous_date.clone(); + if since.is_empty() && !record.previous.is_empty() { + let previous_path = record_path(plans, &record.previous); + if previous_path.is_file() + && let Ok(previous) = parse_record(&previous_path) + { + since = previous.ship_date; + } + } + let since_days = date::parse_civil_date(&since); + let until_days = date::parse_civil_date(&record.ship_date); + + // Scope to the modules the record says shipped. A record fresh from the + // template names none, so notes — which only reads — falls back to the + // whole tree rather than emitting nothing. `close`, which writes, does not. + let resolved = build_scope(&record, &tree); + let scoped_to_record = !resolved.module_files.is_empty(); + let scope: Vec = if scoped_to_record { + resolved.module_files.clone() + } else { + tree.modules + .iter() + .map(|module| module.file.clone()) + .collect() + }; + + let mut groups: BTreeMap> = BTreeMap::new(); + let mut undated: BTreeMap> = BTreeMap::new(); + for file in &scope { + let Some(module) = tree.module_by_file(file) else { + continue; + }; + let heading = format!("{} ({})", module_label(file), module.id); + for item in tree.items_in(file) { + if lifecycle(&item.raw_status) != Life::Complete { + continue; + } + let date = dates_in(&item.raw_status).first().cloned(); + let Some(date) = date else { + undated + .entry(heading.clone()) + .or_default() + .push(format!("- **{}:** {}", item.id, item.title)); + continue; + }; + let days = date::parse_civil_date(&date); + if let (Some(days), Some(lower)) = (days, since_days) + && days <= lower + { + continue; + } + if let (Some(days), Some(upper)) = (days, until_days) + && days > upper + { + continue; + } + groups + .entry(heading.clone()) + .or_default() + .push(format!("- **{}:** {} ({date})", item.id, item.title)); + } + } + + println!( + "## v{}{}", + record.version, + if record.ship_date.is_empty() { + String::new() + } else { + format!(" — {}", record.ship_date) + } + ); + println!(); + let window = match (record.previous.is_empty(), since.is_empty()) { + (false, false) => format!("since v{} ({since})", record.previous), + (false, true) => format!("since v{}", record.previous), + _ => "to date".to_string(), + }; + println!("Complete work items {window}."); + + if groups.is_empty() && undated.is_empty() { + println!(); + println!("_No Complete work items in scope._"); + } + for (heading, mut items) in groups { + items.sort(); + println!(); + println!("### {heading}"); + println!(); + for item in items { + println!("{item}"); + } + } + if !undated.is_empty() { + println!(); + println!("### Undated (verify manually)"); + println!(); + println!( + "" + ); + for (heading, mut items) in undated { + items.sort(); + println!(); + println!("**{heading}**"); + println!(); + for item in items { + println!("{item}"); + } + } + } + + if !scoped_to_record { + eprintln!( + "warning: this record names no module plans, so notes cover the whole plan tree; {NO_SCOPE_HINT}" + ); + } + for warning in record.warnings.iter().chain(resolved.warnings.iter()) { + eprintln!("warning: {warning}"); + } + 0 +} + +/// `plans/modules/monorepo.aps.md` → `monorepo`. +fn module_label(file: &str) -> String { + Path::new(file) + .file_name() + .map(|name| { + name.to_string_lossy() + .trim_end_matches(".aps.md") + .to_string() + }) + .unwrap_or_else(|| file.to_string()) +} + +/// `aps release close ` — advance released work. Dry run by default. +pub fn cmd_release_close( + plan_root: &str, + version: &str, + apply: bool, + force: bool, + tag: Option<&str>, + date_override: Option<&str>, +) -> i32 { + let plans = Path::new(plan_root); + if !plans.is_dir() { + eprintln!("error: Path not found: {plan_root}"); + return 1; + } + let plan = match plan_close(plans, version, tag, date_override) { + Ok(plan) => plan, + Err(err) => { + eprintln!("error: {err}"); + return 1; + } + }; + + let shipped = SHIPPED_STATES + .iter() + .any(|state| plan.record_status.to_lowercase().starts_with(state)); + + println!( + "Release v{} closeout — {} ({})", + plan.version, + if plan.record_status.is_empty() { + "(no Status row)" + } else { + &plan.record_status + }, + plan.record + ); + println!( + "Release date: {} Tag commit: {}", + plan.date, + blank_as_dash(&plan.tag) + ); + println!( + "Mode: {}", + if apply { + "apply — files will be written" + } else { + "dry run — no files written" + } + ); + + println!(); + if plan.advances.is_empty() { + println!("Advancing 0 work items — nothing is in a release state."); + } else { + println!("Advancing {} work item(s):", plan.advances.len()); + let width = plan + .advances + .iter() + .map(|advance| advance.id.len()) + .max() + .unwrap_or(0); + for advance in &plan.advances { + println!( + " {: {}", + advance.id, advance.module, advance.from, advance.to + ); + println!(" + - **Released:** {}", advance.evidence); + println!(" in {}", advance.file); + } + } + + if !plan.rows.is_empty() { + println!(); + println!("Advancing {} status row(s):", plan.rows.len()); + for row in &plan.rows { + println!( + " {:<6} {} {} -> {} ({})", + row.kind, row.module, row.from, row.to, row.file + ); + } + } + + if !plan.skips.is_empty() { + println!(); + let complete = plan + .skips + .iter() + .filter(|skip| skip.reason == "already Complete") + .count(); + println!( + "Skipped {} work item(s) — {complete} already Complete:", + plan.skips.len() + ); + for skip in &plan.skips { + if skip.reason != "already Complete" { + println!(" {}: {}", skip.id, skip.reason); + } + } + } + + if !plan.unresolved.is_empty() { + println!(); + println!( + "Unresolved {} work item(s) named by the record but absent from the plan tree:", + plan.unresolved.len() + ); + for id in &plan.unresolved { + println!(" {id}"); + } + } + + for warning in &plan.warnings { + eprintln!("warning: {warning}"); + } + + println!(); + if !apply { + println!( + "Dry run — no files changed. Re-run with --apply to advance {} item(s) and {} row(s).", + plan.advances.len(), + plan.rows.len() + ); + if !shipped && !plan.advances.is_empty() { + eprintln!( + "warning: record Status is '{}', not Shipped/Released/Archived — --apply will refuse without --force", + blank_as_dash(&plan.record_status) + ); + } + return 0; + } + + if !shipped && !force { + eprintln!( + "error: record Status is '{}' — refusing to advance work items for a release that has not shipped (pass --force to override)", + blank_as_dash(&plan.record_status) + ); + return 1; + } + if plan.advances.is_empty() && plan.rows.is_empty() { + println!("Nothing to apply — no files changed."); + return 0; + } + if let Err(err) = apply_close(&plan) { + eprintln!("error: {err}"); + return 1; + } + println!( + "Closeout complete — advanced {} work item(s) and {} status row(s) for v{}.", + plan.advances.len(), + plan.rows.len(), + plan.version + ); + 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + // --- Fixture: the plan tree as it stood before the 2026-07-13 v0.5.0 + // sweep (recovered with `git show d9cb739^:`). MONO-007 and MONO-008 + // are the two Merged items the manual sweep advanced; MONO-001…006 were + // already Complete, the monorepo module row and its index row said + // In Progress. + + const PRE_SWEEP_MONOREPO: &str = r#"# Monorepo Module + +| ID | Owner | Priority | Status | +| ---- | ------ | -------- | ------ | +| MONO | @aneki | medium | In Progress | + +**Last reviewed:** 2026-07-01 + +## Purpose + +Nested index.aps.md plans, federated lint + orchestration. + +## Work Items + +### MONO-001: Decision table for tags vs nested plans + +- **Intent:** Document the two tiers +- **Expected Outcome:** A decision table +- **Validation:** `true` +- **Status:** Complete + +### MONO-002: Federated lint traversal + +- **Intent:** Lint a federation +- **Expected Outcome:** Transitive traversal +- **Validation:** `true` +- **Status:** Complete + +### MONO-003: Federated orchestration + +- **Intent:** Orchestrate a federation +- **Expected Outcome:** next/start/complete traverse +- **Validation:** `true` +- **Status:** Complete + +### MONO-004: Root roll-up view + +- **Intent:** Roll up child completion +- **Expected Outcome:** `aps rollup` +- **Validation:** `true` +- **Status:** Complete + +### MONO-005: Scaffold nested layouts + +- **Intent:** Scaffold a nested tree +- **Expected Outcome:** `--scope nested` +- **Validation:** `true` +- **Status:** Complete + +### MONO-006: Nested-plans guide and worked example + +- **Intent:** Document the tier +- **Expected Outcome:** Guide plus storefront example +- **Validation:** `true` +- **Status:** Complete + +### MONO-007: Rust CLI parity for nested-plan lint + +- **Intent:** Keep the three CLIs in lockstep +- **Expected Outcome:** Rust matches bash for federated lint +- **Validation:** `true` +- **Confidence:** high +- **Dependencies:** MONO-002 (complete) +- **Status:** Merged +- **Notes:** Completes MONO-002's parity contract (index D-039). + +### MONO-008: Child-scoped module statuses + +- **Intent:** Scope module statuses by child +- **Expected Outcome:** Statuses keyed by child in bash and Rust +- **Validation:** `true` +- **Confidence:** medium +- **Dependencies:** MONO-003 (complete) +- **Status:** Merged: 2026-07-10 +- **Notes:** `ORCH_MODULE_STATUSES` / `PlanGraph.module_statuses`. + +## Decisions + +- D-040: Nested plans are opt-in. +"#; + + const PRE_SWEEP_INDEX: &str = r#"# APS Roadmap + +| Field | Value | +| ------- | ---------- | +| Status | Active | +| Owner | @aneki | +| Created | 2025-12-31 | +| Updated | 2026-06-17 | + +## Problem + +APS needs continued development. + +## Modules + +### Near Term + +| Module | Purpose | Status | +| --------------------------------------------- | --------------------------------------------------------- | -------- | +| [prompts](./modules/prompts.aps.md) | Tool-specific prompt variants | Ready | +| [monorepo](./modules/monorepo.aps.md) | Nested index.aps.md plans, federated lint + orchestration | In Progress | +| [ci-parity](./modules/ci-parity.aps.md) | Behavioural pwsh + cross-CLI parity checks in CI | Complete | +"#; + + const PRE_SWEEP_CI_PARITY: &str = r#"# CI Parity Module + +| ID | Owner | Priority | Status | +| --- | ------ | -------- | -------- | +| CIP | @aneki | medium | Complete | + +## Purpose + +Behavioural pwsh + cross-CLI parity checks in CI. + +## Work Items + +### CIP-001: Extend the behavioural PowerShell harness + +- **Intent:** Behavioural coverage +- **Expected Outcome:** Fixtures execute under pwsh +- **Validation:** `true` +- **Status:** Complete + +### CIP-002: Cross-CLI byte-diff parity harness + +- **Intent:** Compare ordered findings +- **Expected Outcome:** CI diffs all three CLIs +- **Validation:** `true` +- **Status:** Complete +"#; + + const V050_RECORD: &str = r#" + +# Release Plan: v0.5.0 + +| Field | Value | +| ---------------- | -------------------------------------- | +| Target | v0.5.0 | +| Cut from | `main` | +| Previous release | v0.4.0 (2026-06-22) | +| Status | Shipped | +| Date | 2026-07-11 (planning), 2026-07-13 (shipped) | + +## Release Theme + +**Federated Plans & Full-Lifecycle Tooling** — v0.5 lets the workflow scale +beyond one plan tree. + +## What Ships + +### Federated nested plans (MONO) + +| Area | Detail | +| ----------- | ------ | +| Plan layout | Co-located standalone child plans | + +**APS module:** [monorepo](../modules/monorepo.aps.md) (MONO-001…006 +Complete; MONO-007/008 merged for this cut) + +### Cross-implementation parity (CIP) + +| Area | Detail | +| ----------- | ------ | +| Corpus diff | CI compares ordered lint findings | + +**APS modules:** [ci-parity](../modules/ci-parity.aps.md) (CIP-001/002 Complete) + +## Success Criteria + +- [x] The full CLI suite passes for the release candidate. + +Ship evidence (2026-07-13): tag `v0.5.0` on `408f8cf` (re-cut after the +`x86_64-pc-windows-gnu` build failure on the first tag). Release workflow run +29204184841 green across all five targets. + +## Out of Scope + +- **`aps release` subcommand** (REL-005) remains deferred. +- **Prompt variants** (PROMPTS-001…003) remain Ready. + +## Related + +- **Previous release:** [plans/releases/v0.4.0.md](./v0.4.0.md) +"#; + + /// A temp plan tree reproducing the pre-sweep state. `test/` is off limits + /// for fixtures, so the corpus lives here and the tree is built per test. + fn pre_sweep_tree(tag: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!( + "aps-release-{tag}-{}-{}", + std::process::id(), + tag.len() + )); + let _ = fs::remove_dir_all(&root); + let plans = root.join("plans"); + fs::create_dir_all(plans.join("modules")).unwrap(); + fs::create_dir_all(plans.join("releases")).unwrap(); + fs::write(plans.join("index.aps.md"), PRE_SWEEP_INDEX).unwrap(); + fs::write(plans.join("modules/monorepo.aps.md"), PRE_SWEEP_MONOREPO).unwrap(); + fs::write(plans.join("modules/ci-parity.aps.md"), PRE_SWEEP_CI_PARITY).unwrap(); + fs::write(plans.join("releases/v0.5.0.md"), V050_RECORD).unwrap(); + plans + } + + /// The whole clap surface must build. `propagate_version = true` hands + /// every subcommand an auto `--version`, which collides with a positional + /// argument called `version` — clap only catches that at parse time, so + /// without this the panic ships to users instead of to CI. + #[test] + fn clap_surface_builds() { + use clap::CommandFactory; + crate::Cli::command().debug_assert(); + } + + // --- Version handling --- + + #[test] + fn versions_normalize_and_order() { + assert_eq!(normalize_version("v0.5.0"), "0.5.0"); + assert_eq!(normalize_version(" 0.5.0 "), "0.5.0"); + assert!(version_key("0.9.0") > version_key("0.8.1")); + assert!(version_key("0.10.0") > version_key("0.9.0")); + assert!(version_key("1.0.0-beta") < version_key("1.0.0")); + } + + // --- Prose parsing --- + + #[test] + fn record_parses_prose_header_dates_and_tag() { + let plans = pre_sweep_tree("parse"); + let record = parse_record(&plans.join("releases/v0.5.0.md")).unwrap(); + assert_eq!(record.version, "0.5.0"); + assert_eq!(record.status, "Shipped"); + assert_eq!(record.previous, "0.4.0"); + assert_eq!(record.previous_date, "2026-06-22"); + assert_eq!(record.planning_date, "2026-07-11"); + assert_eq!(record.ship_date, "2026-07-13"); + assert_eq!(record.tag_commit, "408f8cf"); + assert!(record.warnings.is_empty(), "{:?}", record.warnings); + fs::remove_dir_all(plans.parent().unwrap()).ok(); + } + + #[test] + fn record_scopes_to_what_ships_only() { + let plans = pre_sweep_tree("scope"); + let record = parse_record(&plans.join("releases/v0.5.0.md")).unwrap(); + assert_eq!(record.module_links.len(), 2, "{:?}", record.module_links); + assert!( + record + .module_links + .iter() + .any(|l| l.ends_with("monorepo.aps.md")) + ); + assert!( + record + .module_links + .iter() + .any(|l| l.ends_with("ci-parity.aps.md")) + ); + // REL-005 and PROMPTS-001…003 live in ## Out of Scope, so they are not + // part of the cut and must not appear. + assert!(!record.named_items.iter().any(|id| id == "REL-005")); + assert!( + !record + .named_items + .iter() + .any(|id| id.starts_with("PROMPTS")) + ); + fs::remove_dir_all(plans.parent().unwrap()).ok(); + } + + #[test] + fn collapsed_and_ranged_ids_expand() { + let ids = named_item_ids("[monorepo](x) (MONO-001…006 Complete; MONO-007/008 merged)"); + for number in 1..=8 { + assert!( + ids.contains(&format!("MONO-{number:03}")), + "MONO-{number:03} missing from {ids:?}" + ); + } + assert_eq!(named_item_ids("CIP-001/002"), ["CIP-001", "CIP-002"]); + assert_eq!(named_item_ids("REL-001, REL-003"), ["REL-001", "REL-003"]); + // Decision refs and prose numerals are not work items. + assert!(named_item_ids("see D-039 and UTF-8 and W003").is_empty()); + // A range typo never enumerates a silly number of IDs. + assert_eq!(named_item_ids("MONO-001…9999"), ["MONO-001"]); + } + + /// The `Date`-row label vocabulary actually in use across + /// `plans/releases/*.md`, enumerated from those files rather than guessed. + #[test] + fn date_row_labels_in_the_wild() { + // v0.5.0, v0.7.0 — planning/shipped, comma- or semicolon-separated. + let dates = labelled_dates("2026-07-11 (planning), 2026-07-13 (shipped)"); + assert_eq!(date_labelled(&dates, "planning").unwrap(), "2026-07-11"); + assert_eq!(date_labelled(&dates, "ship").unwrap(), "2026-07-13"); + + // v0.8.0, v0.8.1 — cut/shipped. The cut date is NOT the ship date. + let dates = labelled_dates("2026-07-28 (cut); 2026-07-29 (shipped)"); + assert_eq!(date_labelled(&dates, "cut").unwrap(), "2026-07-28"); + assert_eq!(date_labelled(&dates, "ship").unwrap(), "2026-07-29"); + assert_eq!(date_labelled(&dates, "planning"), None); + + // v0.6.0 — one date carrying a combined label. + let dates = labelled_dates("2026-07-16 (planning + shipped)"); + assert_eq!(date_labelled(&dates, "planning").unwrap(), "2026-07-16"); + assert_eq!(date_labelled(&dates, "ship").unwrap(), "2026-07-16"); + + // v0.4.0 — planning/cut, and nothing that says shipped. + let dates = labelled_dates("2026-06-21 (planning), 2026-06-22 (cut)"); + assert_eq!(date_labelled(&dates, "ship"), None); + + // v0.9.0 — planning only. + let dates = labelled_dates("2026-09-12 (planning)"); + assert_eq!(date_labelled(&dates, "ship"), None); + + // v0.3.0 — a bare date with no label at all. + let dates = labelled_dates("2026-05-20"); + assert_eq!(dates, [("2026-05-20".to_string(), String::new())]); + } + + #[test] + fn unshipped_record_reports_no_ship_date() { + let root = std::env::temp_dir().join(format!("aps-release-dates-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + let releases = root.join("plans/releases"); + fs::create_dir_all(&releases).unwrap(); + + let write = |version: &str, status: &str, date_row: &str| { + fs::write( + releases.join(format!("v{version}.md")), + format!( + "# Release Plan: v{version}\n\n| Field | Value |\n| --- | --- |\n| Target | v{version} |\n| Status | {status} |\n| Date | {date_row} |\n\n## Release Theme\n\nt\n\n## What Ships\n\nw\n" + ), + ) + .unwrap(); + parse_record(&releases.join(format!("v{version}.md"))).unwrap() + }; + + // Cutting, planning date only: nothing shipped, so no ship date. + let cutting = write("0.9.0", "Cutting", "2026-09-12 (planning)"); + assert_eq!(cutting.planning_date, "2026-09-12"); + assert_eq!( + cutting.ship_date, "", + "an unshipped release has no ship date" + ); + assert_eq!(cutting.cut_date, ""); + + // `(cut)` is not `(shipped)`. + let cut = write("0.8.0", "Shipped", "2026-07-28 (cut); 2026-07-29 (shipped)"); + assert_eq!(cut.cut_date, "2026-07-28"); + assert_eq!(cut.ship_date, "2026-07-29"); + assert_eq!(cut.planning_date, ""); + + // Shipped, but the row never labels a shipped date: report none, warn. + let unlabelled_ship = write( + "0.4.0", + "Shipped", + "2026-06-21 (planning), 2026-06-22 (cut)", + ); + assert_eq!(unlabelled_ship.ship_date, ""); + assert!( + unlabelled_ship + .warnings + .iter() + .any(|w| w.contains("labels no date as shipped")) + ); + + // A wholly unlabelled row on a terminal record is the ship date. + let bare = write("0.3.0", "Shipped", "2026-05-20"); + assert_eq!(bare.ship_date, "2026-05-20"); + assert_eq!(bare.planning_date, "2026-05-20"); + + // The same bare row on a non-terminal record is not. + let bare_planning = write("0.2.0", "Planning", "2026-05-20"); + assert_eq!(bare_planning.ship_date, ""); + fs::remove_dir_all(&root).ok(); + } + + #[test] + fn plain_text_module_references_resolve() { + // v0.9.0's original phrasing: names, not links, with asides. + let refs = module_references(&[ + "**APS modules:** cli-redesign (6/7 work items done — CLI-002 remains, gated on", + "TEAM).", + ]); + assert!(refs.contains(&"cli-redesign".to_string()), "{refs:?}"); + + // Link labels survive, targets and prose do not trip it up. + let refs = module_references(&[ + "**APS modules:** [prompts](../modules/prompts.aps.md) (Complete),", + "[examples](../modules/examples.aps.md) (Complete). Closes ISS-011.", + ]); + assert!(refs.contains(&"prompts".to_string()), "{refs:?}"); + assert!(refs.contains(&"examples".to_string()), "{refs:?}"); + + // Lines that are not APS-module references contribute nothing. + assert!(module_references(&["Some prose about prompts and examples."]).is_empty()); + } + + #[test] + fn module_names_resolve_by_stem_id_and_unique_prefix() { + let plans = pre_sweep_tree("names"); + let tree = load_tree(&plans); + assert_eq!( + tree.module_by_name("monorepo").map(|m| m.id.as_str()), + Some("MONO") + ); + assert_eq!( + tree.module_by_name("MONO").map(|m| m.id.as_str()), + Some("MONO") + ); + assert_eq!( + tree.module_by_name("ci-parity").map(|m| m.id.as_str()), + Some("CIP") + ); + assert!(tree.module_by_name("nope").is_none()); + // Too short to prefix-match, and not an exact stem or ID. + assert!(tree.module_by_name("mon").is_none()); + fs::remove_dir_all(plans.parent().unwrap()).ok(); + } + + #[test] + fn related_module_specs_are_the_documented_fallback() { + let plans = pre_sweep_tree("fallback"); + let record = plans.join("releases/v0.5.0.md"); + // Strip the module references out of What Ships, leaving them only in + // a ## Related **Module specs:** bullet. + let text = fs::read_to_string(&record) + .unwrap() + .replace( + "**APS module:** [monorepo](../modules/monorepo.aps.md) (MONO-001…006", + "(MONO-001…006", + ) + .replace( + "**APS modules:** [ci-parity](../modules/ci-parity.aps.md) (CIP-001/002 Complete)", + "(CIP-001/002 Complete)", + ) + .replace( + "- **Previous release:** [plans/releases/v0.4.0.md](./v0.4.0.md)", + "- **Module specs:** [monorepo](../modules/monorepo.aps.md),\n [ci-parity](../modules/ci-parity.aps.md)", + ); + fs::write(&record, text).unwrap(); + + let parsed = parse_record(&record).unwrap(); + assert_eq!(parsed.module_links.len(), 2, "{:?}", parsed.module_links); + assert!( + parsed + .warnings + .iter() + .any(|w| w.contains("**Module specs:**")) + ); + // The closeout still finds the same two Merged items. + let plan = plan_close(&plans, "0.5.0", None, None).unwrap(); + let ids: Vec<&str> = plan.advances.iter().map(|a| a.id.as_str()).collect(); + assert_eq!(ids, ["MONO-007", "MONO-008"]); + fs::remove_dir_all(plans.parent().unwrap()).ok(); + } + + #[test] + fn tag_commit_survives_a_wrapped_line() { + let blocks = prose_blocks(&[ + "Ship evidence (2026-07-13): tag `v0.9.0` on".to_string(), + "`deadbee` after the re-cut.".to_string(), + ]); + assert_eq!(find_tag_commit(&blocks, "0.9.0"), "deadbee"); + } + + #[test] + fn malformed_record_without_a_header_table_is_rejected() { + let root = std::env::temp_dir().join(format!("aps-release-bad-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + let releases = root.join("plans/releases"); + fs::create_dir_all(&releases).unwrap(); + fs::write( + releases.join("v9.9.9.md"), + "# Release Plan: v9.9.9\n\nprose only\n", + ) + .unwrap(); + let err = parse_record(&releases.join("v9.9.9.md")).unwrap_err(); + assert!(err.contains("R002"), "{err}"); + assert_eq!( + cmd_release_close( + &root.join("plans").to_string_lossy(), + "9.9.9", + false, + false, + None, + None + ), + 1 + ); + fs::remove_dir_all(&root).ok(); + } + + #[test] + fn record_without_what_ships_falls_back_to_the_whole_file() { + let root = std::env::temp_dir().join(format!("aps-release-nows-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + let plans = root.join("plans"); + fs::create_dir_all(plans.join("modules")).unwrap(); + fs::create_dir_all(plans.join("releases")).unwrap(); + fs::write(plans.join("modules/monorepo.aps.md"), PRE_SWEEP_MONOREPO).unwrap(); + fs::write( + plans.join("releases/v0.5.0.md"), + "# Release Plan: v0.5.0\n\n| Field | Value |\n| --- | --- |\n| Target | v0.5.0 |\n| Status | Shipped |\n| Date | 2026-07-13 (shipped) |\n\nShipped [monorepo](../modules/monorepo.aps.md).\n", + ) + .unwrap(); + let record = parse_record(&plans.join("releases/v0.5.0.md")).unwrap(); + assert!(record.warnings.iter().any(|w| w.contains("R004"))); + assert_eq!(record.module_links.len(), 1); + fs::remove_dir_all(&root).ok(); + } + + // --- Lifecycle --- + + #[test] + fn lifecycle_recognises_release_states_and_canonical_aliases() { + assert_eq!(lifecycle("Merged"), Life::Releasable); + assert_eq!(lifecycle("Merged: 2026-07-10"), Life::Releasable); + assert_eq!(lifecycle("Released in v0.5.0"), Life::Releasable); + assert_eq!(lifecycle("Shipped"), Life::Releasable); + assert_eq!(lifecycle("Complete"), Life::Complete); + assert_eq!(lifecycle("Complete: 2026-06-08"), Life::Complete); + assert_eq!(lifecycle("Done"), Life::Complete); + assert_eq!(lifecycle("Ready"), Life::Open); + assert_eq!(lifecycle("In Progress"), Life::Open); + assert_eq!(lifecycle("Proposed"), Life::Open); + assert_eq!(lifecycle(""), Life::Open); + // Not a release state: a word that merely starts the same way. + assert_eq!(lifecycle("Mergeable"), Life::Open); + } + + // --- The v0.5.0 acceptance test --- + + #[test] + fn close_050_reproduces_the_manual_sweep() { + let plans = pre_sweep_tree("accept"); + let plan = plan_close(&plans, "0.5.0", None, None).unwrap(); + + assert_eq!(plan.date, "2026-07-13"); + assert_eq!(plan.tag, "408f8cf"); + let ids: Vec<&str> = plan.advances.iter().map(|a| a.id.as_str()).collect(); + assert_eq!( + ids, + ["MONO-007", "MONO-008"], + "only the Merged items advance" + ); + assert_eq!(plan.advances[0].to, "Complete: 2026-07-13"); + assert_eq!( + plan.advances[0].evidence, + "v0.5.0 (2026-07-13, tag `408f8cf`) — was Merged" + ); + assert_eq!( + plan.advances[1].evidence, + "v0.5.0 (2026-07-13, tag `408f8cf`) — was Merged: 2026-07-10" + ); + // MONO-001…006 plus CIP-001/002 are already Complete. + assert_eq!(plan.skips.len(), 8, "{:?}", plan.skips); + assert!(plan.skips.iter().all(|s| s.reason == "already Complete")); + assert!(plan.unresolved.is_empty(), "{:?}", plan.unresolved); + + // Module + index rows advance; ci-parity is untouched (nothing moved). + let rows: Vec<(&str, &str, &str)> = plan + .rows + .iter() + .map(|r| (r.kind, r.module.as_str(), r.from.as_str())) + .collect(); + assert_eq!( + rows, + [ + ("module", "MONO", "In Progress"), + ("index", "MONO", "In Progress") + ] + ); + + fs::remove_dir_all(plans.parent().unwrap()).ok(); + } + + #[test] + fn dry_run_writes_nothing_and_lists_the_same_advances() { + let plans = pre_sweep_tree("dry"); + let before: Vec = ["modules/monorepo.aps.md", "index.aps.md"] + .iter() + .map(|name| fs::read_to_string(plans.join(name)).unwrap()) + .collect(); + + let preview = plan_close(&plans, "0.5.0", None, None).unwrap(); + assert_eq!( + cmd_release_close(&plans.to_string_lossy(), "0.5.0", false, false, None, None), + 0 + ); + for (name, text) in ["modules/monorepo.aps.md", "index.aps.md"] + .iter() + .zip(&before) + { + assert_eq!( + &fs::read_to_string(plans.join(name)).unwrap(), + text, + "{name} must be untouched by a dry run" + ); + } + + // The same plan drives both paths, so --apply advances exactly the + // items the dry run listed. + assert_eq!( + cmd_release_close(&plans.to_string_lossy(), "0.5.0", true, false, None, None), + 0 + ); + let after = fs::read_to_string(plans.join("modules/monorepo.aps.md")).unwrap(); + for advance in &preview.advances { + assert!( + after.contains(&format!("- **Released:** {}", advance.evidence)), + "{} evidence missing", + advance.id + ); + } + fs::remove_dir_all(plans.parent().unwrap()).ok(); + } + + #[test] + fn apply_050_stamps_items_and_advances_rows() { + let plans = pre_sweep_tree("apply"); + assert_eq!( + cmd_release_close(&plans.to_string_lossy(), "0.5.0", true, false, None, None), + 0 + ); + + let monorepo = fs::read_to_string(plans.join("modules/monorepo.aps.md")).unwrap(); + assert!(!monorepo.contains("- **Status:** Merged")); + assert_eq!( + monorepo + .matches("- **Status:** Complete: 2026-07-13") + .count(), + 2 + ); + assert!(monorepo.contains( + "- **Released:** v0.5.0 (2026-07-13, tag `408f8cf`) — was Merged: 2026-07-10" + )); + // The already-Complete items keep their exact prior text. + assert_eq!(monorepo.matches("- **Status:** Complete\n").count(), 6); + // The evidence line sits directly after the Status line it explains. + let mono_007 = monorepo.split("### MONO-007:").nth(1).unwrap(); + assert!(mono_007.contains( + "- **Status:** Complete: 2026-07-13\n- **Released:** v0.5.0 (2026-07-13, tag `408f8cf`) — was Merged\n" + )); + + // Module metadata row and the index row both read Complete. + let module_plan = + PlanFile::load(&plans.join("modules/monorepo.aps.md").to_string_lossy()).unwrap(); + assert_eq!(module_plan.status().as_deref(), Some("Complete")); + let index = fs::read_to_string(plans.join("index.aps.md")).unwrap(); + let row = index + .lines() + .find(|line| line.contains("modules/monorepo.aps.md")) + .unwrap(); + assert!(row.trim_end().ends_with("| Complete |"), "{row}"); + // Sibling rows are untouched. + assert!( + index.contains( + "| Tool-specific prompt variants | Ready |" + ) + ); + // ci-parity had nothing to advance, so its module file is byte-identical. + assert_eq!( + fs::read_to_string(plans.join("modules/ci-parity.aps.md")).unwrap(), + PRE_SWEEP_CI_PARITY + ); + fs::remove_dir_all(plans.parent().unwrap()).ok(); + } + + #[test] + fn apply_is_idempotent() { + let plans = pre_sweep_tree("idem"); + let plans_str = plans.to_string_lossy().into_owned(); + assert_eq!( + cmd_release_close(&plans_str, "0.5.0", true, false, None, None), + 0 + ); + let once = fs::read_to_string(plans.join("modules/monorepo.aps.md")).unwrap(); + let index_once = fs::read_to_string(plans.join("index.aps.md")).unwrap(); + + // Second run: nothing is in a release state any more. + let second = plan_close(&plans, "0.5.0", None, None).unwrap(); + assert!(second.advances.is_empty()); + assert!(second.rows.is_empty()); + assert_eq!( + cmd_release_close(&plans_str, "0.5.0", true, false, None, None), + 0 + ); + assert_eq!( + fs::read_to_string(plans.join("modules/monorepo.aps.md")).unwrap(), + once + ); + assert_eq!( + fs::read_to_string(plans.join("index.aps.md")).unwrap(), + index_once + ); + fs::remove_dir_all(plans.parent().unwrap()).ok(); + } + + #[test] + fn unresolved_items_are_reported_not_fatal() { + let plans = pre_sweep_tree("unresolved"); + let record = plans.join("releases/v0.5.0.md"); + let text = fs::read_to_string(&record) + .unwrap() + .replace("MONO-007/008 merged", "MONO-007/008/099 merged"); + fs::write(&record, text).unwrap(); + + let plan = plan_close(&plans, "0.5.0", None, None).unwrap(); + assert_eq!(plan.unresolved, ["MONO-099"]); + assert_eq!(plan.advances.len(), 2, "the real items still advance"); + assert_eq!( + cmd_release_close(&plans.to_string_lossy(), "0.5.0", false, false, None, None), + 0, + "an unresolved mention is a finding, not a failure" + ); + fs::remove_dir_all(plans.parent().unwrap()).ok(); + } + + #[test] + fn unshipped_record_refuses_to_apply_without_force() { + let plans = pre_sweep_tree("unshipped"); + let record = plans.join("releases/v0.5.0.md"); + let text = fs::read_to_string(&record).unwrap().replace( + "| Status | Shipped", + "| Status | Cutting", + ); + fs::write(&record, text).unwrap(); + let plans_str = plans.to_string_lossy().into_owned(); + let before = fs::read_to_string(plans.join("modules/monorepo.aps.md")).unwrap(); + + // Dry run still previews. + assert_eq!( + cmd_release_close(&plans_str, "0.5.0", false, false, None, None), + 0 + ); + // --apply refuses and changes nothing. + assert_eq!( + cmd_release_close(&plans_str, "0.5.0", true, false, None, None), + 1 + ); + assert_eq!( + fs::read_to_string(plans.join("modules/monorepo.aps.md")).unwrap(), + before + ); + // --force overrides. + assert_eq!( + cmd_release_close(&plans_str, "0.5.0", true, true, None, None), + 0 + ); + assert!( + fs::read_to_string(plans.join("modules/monorepo.aps.md")) + .unwrap() + .contains("- **Released:** v0.5.0") + ); + fs::remove_dir_all(plans.parent().unwrap()).ok(); + } + + #[test] + fn tag_and_date_overrides_win_over_the_record() { + let plans = pre_sweep_tree("override"); + let plan = plan_close(&plans, "0.5.0", Some("abcdef1"), Some("2026-08-01")).unwrap(); + assert_eq!(plan.date, "2026-08-01"); + assert_eq!( + plan.advances[0].evidence, + "v0.5.0 (2026-08-01, tag `abcdef1`) — was Merged" + ); + assert!(plan_close(&plans, "0.5.0", None, Some("not-a-date")).is_err()); + fs::remove_dir_all(plans.parent().unwrap()).ok(); + } + + #[test] + fn module_row_waits_for_every_item_to_be_terminal() { + let plans = pre_sweep_tree("partial"); + // Leave one item Ready: the module is not finished, so its row stays. + let module = plans.join("modules/monorepo.aps.md"); + let text = fs::read_to_string(&module).unwrap().replacen( + "- **Status:** Complete", + "- **Status:** Ready", + 1, + ); + fs::write(&module, text).unwrap(); + + let plan = plan_close(&plans, "0.5.0", None, None).unwrap(); + assert_eq!(plan.advances.len(), 2); + assert!(plan.rows.is_empty(), "{:?}", plan.rows); + assert!( + plan.skips + .iter() + .any(|skip| skip.reason.contains("not a release state")) + ); + fs::remove_dir_all(plans.parent().unwrap()).ok(); + } + + #[test] + fn missing_record_is_an_error() { + let plans = pre_sweep_tree("missing"); + assert!(plan_close(&plans, "9.9.9", None, None).is_err()); + assert_eq!( + cmd_release_close(&plans.to_string_lossy(), "9.9.9", false, false, None, None), + 1 + ); + fs::remove_dir_all(plans.parent().unwrap()).ok(); + } + + // --- new / status / notes --- + + #[test] + fn new_renders_the_template_and_refuses_to_overwrite() { + let plans = pre_sweep_tree("new"); + let plans_str = plans.to_string_lossy().into_owned(); + assert_eq!(cmd_release_new(&plans_str, "v0.6.0"), 0); + let body = fs::read_to_string(plans.join("releases/v0.6.0.md")).unwrap(); + assert!(body.starts_with("