diff --git a/CHANGELOG.md b/CHANGELOG.md index 983b276..03a05f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,53 @@ All notable changes to `fleet` are documented here. The format is loosely based on [Keep a Changelog](https://keepachangelog.com/), and the project adheres to [Semantic Versioning](https://semver.org/). +## [0.2.0] - 2026-08-20 + +Second release: **Health v2**. Adds a git-aware, four-class health scheme +(`stranded` / `active` / `paused` / `dead`) that runs alongside the v1 +recency-only scheme, and ships it end-to-end through the report, CLI, and +snapshot layers. The v2 classifier is a pure function of days-since-activity, +last outcome, and a git-side work-in-flight signal; it resolves +**most-severe-wins** (`stranded` > `active` > `paused` > `dead`), and only the +exact outcome `max_steps_reached` counts as "work in flight". The v1 and v2 +dead boundaries intentionally diverge at exactly 30 days (v1 `> 30`, v2 +`>= 30`) and are not unified. + +### Added + +- **Git-side signal** (`fleet.gittest`): `read_gitstate(repo_path) -> + GitState(unmerged_build_branches, unpushed_commits)` and `EMPTY_STATE`. + Reports local `build*` branches with commits not on `main` and unpushed + commits on `main`; never raises (missing repo / no `origin` / no `main` / + any git failure → `EMPTY_STATE`). +- **v2 classifier** (`fleet.health.classify_health_v2`): a pure, four-class, + most-severe-wins classifier (`stranded` / `active` / `paused` / `dead`). + `stranded` = an unmerged `build*` branch OR unpushed commits on `main`, + regardless of recency; `active` = touched ≤ 7 days AND work in flight; + `paused` = recently touched but done, or idle in the 8-29 day band; `dead` + = 30+ days untouched (or no activity signal) with nothing in flight. +- **`ProjectHealth.health_v2`**: a defaulted field (`None`) carrying the v2 + class when known. +- **Report** (`fleet.report`): `render_portfolio(..., git_states=...)` gains an + opt-in trailing `Git` column (a compact work-in-flight summary: + `unmerged:` / `unpushed:` / `-` when clean). +- **CLI** (`fleet.cli`): `status` always shows the `Git` column; `--filter` + now accepts `stranded` / `paused` (selected with the v2 classifier over each + project's git state) in addition to the v1 `active` / `stalled` / `dead` / + `all`. +- **Snapshot v2** (`fleet.snapshot`): `save_snapshot(..., git_states=...)` + computes and stores a per-row `health_v2`; `snapshot_diff` surfaces a + `health_v2 ->` fragment (`-` for `None`); old v1 snapshots (no + `health_v2` key) still load with `health_v2` defaulting to `None`. +- **Integration**: an end-to-end v2 pipeline test (in-tree fixture) plus a + live-root self-consistency smoke test. + +### Notes + +- The v1 `Health` column and `classify_health` are unchanged; v2 is additive. +- The v1/v2 30-day dead-boundary divergence (`> 30` vs `>= 30`) is intentional + and pinned by tests in both schemes. + ## [0.1.0] - 2026-08-19 First release. `fleet` is a multi-project health scanner for the four diff --git a/README.md b/README.md index 53fdf76..6f8caec 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,16 @@ The portfolio table is produced by a four-stage pipeline: 2. **assess** (`fleet.health.assess` / `project_health`) extracts per-project metrics via the `fourseer` parsers: last cycle, last outcome, days since activity, and open issues. -3. **classify** (`fleet.health.classify_health`) maps those metrics to a health - label: `active`, `stalled`, or `dead`. +3. **classify** (`fleet.health.classify_health`) maps those metrics to a v1 + health label: `active`, `stalled`, or `dead`. A second, git-aware scheme + (`fleet.health.classify_health_v2`) classifies into four classes + (`stranded` / `active` / `paused` / `dead`) — see below. 4. **render** (`fleet.report.render_portfolio`) emits the markdown table, sorted by last-activity descending (no-activity last). -## Health classification +## Health classification (v1) + +`classify_health` is the recency-only scheme used by the `Health` column: | Health | Condition | |------------|-----------| @@ -31,18 +35,46 @@ The portfolio table is produced by a four-stage pipeline: | **stalled** | has trajectories AND 8-30 days, OR has trajectories but no activity signal | | **dead** | no trajectories, OR > 30 days since activity | +## Health classification (v2) + +`classify_health_v2(days, last_outcome, git_state)` is a pure, git-aware scheme +with **four classes**, resolved **most-severe-wins** +(`stranded` > `active` > `paused` > `dead`). "Work in flight" means an unmerged +`build*` branch, unpushed commits on `main`, or a last outcome of exactly +`max_steps_reached` (no other outcome counts as in-flight). + +| Health | Condition | +|--------------|-----------| +| **stranded** | an unmerged `build*` branch OR unpushed commits on `main`, regardless of recency (git work in flight) | +| **active** | touched ≤ 7 days AND work in flight (last outcome `max_steps_reached`; an unmerged branch already → `stranded`) | +| **paused** | recently touched but done (nothing in flight), or idle in the 8-29 day band with nothing in flight | +| **dead** | 30+ days untouched AND nothing in flight, or no activity signal at all with nothing in flight | + +> **v1/v2 30-day boundary divergence.** The two schemes intentionally disagree +> at exactly 30 days: v1 `dead` is `> 30` days (so 30 days is `stalled`), while +> v2 `dead` is `>= 30` days (so 30 days is `dead`). They are **not** unified — +> each is pinned by its own tests. Do not "normalize" one to match the other. + ## CLI - fleet status [--root ~/AI] [--filter active|stalled|dead|all] + fleet status [--root ~/AI] [--filter active|stalled|dead|stranded|paused|all] fleet snapshot [--root ~/AI] [--snapshot SNAPSHOT] fleet diff [--root ~/AI] [--snapshot SNAPSHOT] - `status` prints the current portfolio as a markdown table, optionally - filtered by health (`active`/`stalled`/`dead`/`all`). + filtered by health (`active`/`stalled`/`dead`/`stranded`/`paused`/`all`). + The v1 classes (`active`/`stalled`/`dead`) match the `Health` column; the two + v2-only classes (`stranded`/`paused`) are selected with the v2 classifier + over each project's git state. The table always shows a trailing `Git` + column — a compact work-in-flight summary: `unmerged:` / + `unpushed:` / `-` when clean. - `snapshot` saves the current portfolio as a snapshot JSON (the baseline that - `diff` compares against). + `diff` compares against). Each row now stores a `health_v2` field (the v2 + class computed from the project's git state). - `diff` compares the current portfolio against a saved snapshot JSON and - prints a markdown diff table. + prints a markdown diff table. A v2 transition surfaces as a + `health_v2 ->` fragment (`-` for `None`). Old v1 snapshots (no + `health_v2` key) still load, with `health_v2` defaulting to `None`. Output is a markdown table sorted by last-activity descending. diff --git a/fleet/__init__.py b/fleet/__init__.py index b3638da..7108b6e 100644 --- a/fleet/__init__.py +++ b/fleet/__init__.py @@ -20,7 +20,7 @@ import os import sys -__version__ = "0.1.0" +__version__ = "0.2.0" # Bootstrap: make the fourseer seed package importable. fleet's only # third-party dependency is fourseer, imported from the seed path. The seed diff --git a/pyproject.toml b/pyproject.toml index b02071c..6680a73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "fleet" -version = "0.1.0" +version = "0.2.0" description = "Multi-project health scanner for the four pipeline: portfolio status table." readme = "README.md" requires-python = ">=3.10" diff --git a/tests/test_cli.py b/tests/test_cli.py index 426b4f2..53a59a0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -583,9 +583,9 @@ def test_cli_version_flag_prints_exact_version(capsys) -> None: assert out.strip() == f"fleet {__version__}" -def test_cli_version_is_0_1_0() -> None: - """The shipped version is pinned to 0.1.0 (first release).""" - assert __version__ == "0.1.0" +def test_cli_version_is_0_2_0() -> None: + """The shipped version is pinned to 0.2.0 (second release / Health v2).""" + assert __version__ == "0.2.0" # --------------------------------------------------------------------------- diff --git a/tickets/TICKET-073-bump-version-to-0-2-0.md b/tickets/TICKET-073-bump-version-to-0-2-0.md new file mode 100644 index 0000000..1a1d5e3 --- /dev/null +++ b/tickets/TICKET-073-bump-version-to-0-2-0.md @@ -0,0 +1,30 @@ +# TICKET-073: Bump the shipped version to 0.2.0 (Health v2 release) + +## Title +Move `fleet` from `0.1.0` to `0.2.0` consistently across the version string, +the packaging metadata, and the hard-coded version test, so the Health v2 arc +(Cycles 14-18) ships as the second release. + +## Evidence +- `fleet/__init__.py:23` — `__version__ = "0.1.0"`. +- `pyproject.toml:7` — `version = "0.1.0"` (the `[project]` version, not the + `ruff>=0.1.0` dev pin on line 18, which is unrelated and must not change). +- `tests/test_cli.py:586-588` — `test_cli_version_is_0_1_0` hard-asserts + `__version__ == "0.1.0"`. The two sibling version tests + (`test_cli_version_flag_exits_zero_and_prints_version`, + `test_cli_version_flag_prints_exact_version`) are dynamic (they compare + against `__version__`) and need no change. + +## Change +- `fleet/__init__.py`: `__version__ = "0.2.0"`. +- `pyproject.toml`: `version = "0.2.0"`. +- `tests/test_cli.py`: rename `test_cli_version_is_0_1_0` to + `test_cli_version_is_0_2_0`, assert `__version__ == "0.2.0"`, and update the + docstring to "second release / Health v2". + +## Acceptance +- `fleet.__version__ == "0.2.0"` and `pyproject.toml` `version == "0.2.0"`. +- `python3 -m fleet --version` prints exactly `fleet 0.2.0` and exits 0. +- `test_cli_version_is_0_2_0` passes; the two dynamic version tests stay green. +- Gate: `pytest tests/ -x -q`, `ruff check fleet/`, + `mypy fleet/ --ignore-missing-imports`. diff --git a/tickets/TICKET-074-readme-health-v2-threshold-table.md b/tickets/TICKET-074-readme-health-v2-threshold-table.md new file mode 100644 index 0000000..ea34483 --- /dev/null +++ b/tickets/TICKET-074-readme-health-v2-threshold-table.md @@ -0,0 +1,42 @@ +# TICKET-074: Document the Health v2 threshold table in the README + +## Title +The README documents only the v1 health table (`active`/`stalled`/`dead`). +Add a **Health classification (v2)** section documenting the four-class, +git-aware scheme, the most-severe-wins rule, the `max_steps_reached`-only +in-flight rule, and the v1/v2 30-day boundary divergence. + +## Evidence +- `README.md` (pre-change) — a single "Health classification" section with a + three-row v1 table; no mention of `stranded`/`paused`, the `Git` column, + `--filter stranded|paused`, the `health_v2` snapshot field, or the 30-day + divergence. +- `fleet/health.py` — `classify_health_v2(days, last_outcome, git_state)` is + the four-class, most-severe-wins classifier (`stranded` > `active` > + `paused` > `dead`); only the exact outcome `max_steps_reached` counts as + "work in flight". +- `fleet/health.py` — `DEAD_MIN_DAYS = 30` (v2 dead is `>= 30`) vs + `STALLED_MAX_DAYS = 30` (v1 dead is `> 30`); the two intentionally disagree + at exactly 30 days (pinned by tests in Cycles 15/17; see TICKET-059). + +## Change +- Rename the existing section to **Health classification (v1)** and keep the + v1 table. +- Add **Health classification (v2)** with a four-row table: + - `stranded` — unmerged `build*` branch OR unpushed commits on `main`, + regardless of recency. + - `active` — touched ≤ 7 days AND work in flight (last outcome + `max_steps_reached`; an unmerged branch already → `stranded`). + - `paused` — recently touched but done, or idle in the 8-29 day band with + nothing in flight. + - `dead` — 30+ days untouched (or no activity signal) with nothing in + flight. +- State the most-severe-wins rule and that only `max_steps_reached` counts as + in-flight. +- Add a callout documenting the v1/v2 30-day boundary divergence (v1 `> 30`, + v2 `>= 30`; not unified; do not normalize). + +## Acceptance +- README documents all four v2 classes, the most-severe-wins rule, the + `max_steps_reached`-only in-flight rule, and the v1/v2 30-day divergence. +- The v1 table is preserved. diff --git a/tickets/TICKET-075-readme-cli-git-column-filter-snapshot.md b/tickets/TICKET-075-readme-cli-git-column-filter-snapshot.md new file mode 100644 index 0000000..b0be2f7 --- /dev/null +++ b/tickets/TICKET-075-readme-cli-git-column-filter-snapshot.md @@ -0,0 +1,36 @@ +# TICKET-075: Document the v2 CLI surface (Git column, --filter, snapshot health_v2) + +## Title +The README's CLI section still shows the v1 `--filter active|stalled|dead|all` +and says nothing about the `Git` column, the `stranded`/`paused` filter +values, or the `health_v2` snapshot field. Update it to match the shipped v2 +CLI behavior. + +## Evidence +- `fleet/cli.py` — `_VALID_FILTERS = ("active", "stalled", "dead", "stranded", + "paused", "all")`; `_cmd_status` selects `stranded`/`paused` with + `classify_health_v2` over each project's git state, and the v1 classes match + the `Health` column. +- `fleet/cli.py` — `_cmd_status` always passes `git_states` to + `render_portfolio`, so the `status` table always shows a trailing `Git` + column. +- `fleet/report.py` — `_fmt_git` renders `unmerged:+` / `unpushed:` + / `-` when clean. +- `fleet/snapshot.py` — `save_snapshot(..., git_states=...)` stores a per-row + `health_v2`; `_field_changes` surfaces a `health_v2 ->` fragment + (`-` for `None`); `_health_from_dict` reads `health_v2=d.get("health_v2")` + so old v1 snapshots load with `health_v2 is None`. + +## Change +- Update the `status` usage line to + `[--filter active|stalled|dead|stranded|paused|all]`. +- Note that the `status` table always shows a trailing `Git` column + (`unmerged:` / `unpushed:` / `-` when clean) and that + `stranded`/`paused` are selected with the v2 classifier over the git state. +- Note that `snapshot` now stores a per-row `health_v2` field and `diff` + surfaces a `health_v2 ->` fragment (`-` for `None`), and that old v1 + snapshots still load with `health_v2` defaulting to `None`. + +## Acceptance +- README CLI section matches the shipped v2 CLI: filter choices, the always-on + `Git` column, and the snapshot `health_v2` field. diff --git a/tickets/TICKET-076-changelog-0-2-0-entry.md b/tickets/TICKET-076-changelog-0-2-0-entry.md new file mode 100644 index 0000000..89f5f35 --- /dev/null +++ b/tickets/TICKET-076-changelog-0-2-0-entry.md @@ -0,0 +1,33 @@ +# TICKET-076: Add a [0.2.0] CHANGELOG entry covering the Health v2 arc + +## Title +`CHANGELOG.md` has a single `[0.1.0]` entry. Add a `## [0.2.0]` section above +it that records the Health v2 arc (Cycles 14-18) as the second release, +leaving `[0.1.0]` intact. + +## Evidence +- `CHANGELOG.md` (pre-change) — only `## [0.1.0] - 2026-08-19`. +- The Health v2 arc landed across Cycles 14-18: `fleet.gittest` + (`read_gitstate`/`GitState`/`EMPTY_STATE`), `classify_health_v2`, + `ProjectHealth.health_v2`, the report `Git` column, the CLI + `--filter stranded|paused`, snapshot v2 (`health_v2` round-trip + diff + fragment + old-snapshot loading), and the end-to-end v2 integration test. + +## Change +- Add `## [0.2.0] - ` above `[0.1.0]` with a summary paragraph (four + classes, most-severe-wins, `max_steps_reached`-only in-flight, v1/v2 30-day + divergence) and an **Added** list covering: + - `fleet.gittest` — `read_gitstate` / `GitState` / `EMPTY_STATE`. + - `fleet.health.classify_health_v2` — four-class, most-severe-wins. + - `ProjectHealth.health_v2` — defaulted field. + - Report — opt-in `Git` column. + - CLI — `Git` column always shown; `--filter` accepts `stranded`/`paused`. + - Snapshot v2 — `save_snapshot(..., git_states=...)` stores `health_v2`; + `snapshot_diff` surfaces a `health_v2 ->` fragment; old v1 snapshots + still load. + - Integration — end-to-end v2 pipeline test + live-root smoke test. +- Keep the existing `[0.1.0]` entry byte-for-byte intact. + +## Acceptance +- `CHANGELOG.md` has a `[0.2.0]` entry covering the Health v2 arc; `[0.1.0]` + is unchanged. diff --git a/tickets/TICKET-077-release-verification-and-tag-decision.md b/tickets/TICKET-077-release-verification-and-tag-decision.md new file mode 100644 index 0000000..04ad070 --- /dev/null +++ b/tickets/TICKET-077-release-verification-and-tag-decision.md @@ -0,0 +1,29 @@ +# TICKET-077: Release verification — gate green, --version prints 0.2.0, tag decision + +## Title +Cut the 0.2.0 release: confirm the full gate is green, confirm `--version` +prints `fleet 0.2.0`, and record the decision on whether to create a `v0.2.0` +git tag. + +## Evidence +- `fleet/cli.py` — the top-level `--version` flag already exists + (`action="version"`, `version=f"fleet {__version__}"`); after the bump it + prints `fleet 0.2.0`. No new flag is needed. +- `git tag -l` — returns empty: the repo has **no prior tags**. Per the + Cycle 19 briefing, a tag is optional when there are no prior tags and may be + skipped to keep the cycle minimal. + +## Change / Decision +- Verify the gate: `pytest tests/ -x -q`, `ruff check fleet/`, + `mypy fleet/ --ignore-missing-imports` — all green. +- Verify `python3 -m fleet --version` prints exactly `fleet 0.2.0` and exits 0. +- **Tag decision: skip the `v0.2.0` tag.** The repo has no prior tags, so a + single tag would not establish a tagging convention and the briefing marks + it optional. Revisit tagging when a convention is adopted. + +## Acceptance +- All pre-existing tests (report/cli/integration/integration_v2/snapshot/ + health/gittest/smoke/examples) stay green. +- `fleet --version` prints `fleet 0.2.0`. +- No `fleet/` source logic changed (only the `__version__` string in + `fleet/__init__.py`); the release is docs + version only.