diff --git a/.specify/feature.json b/.specify/feature.json index 2c7ba31b..822500c5 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/052-conditional-landing" + "feature_directory": "specs/053-assumption-review-console" } diff --git a/CLAUDE.md b/CLAUDE.md index 7133b963..43017704 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -543,13 +543,14 @@ Beads-only workflow model. All development is driven by beads (`bd` CLI). | `maverick refuel ` | Decompose plan into beads | | `maverick refuel --speckit [--dry-run\|--enrich]` | Deterministic Spec Kit ingestion | | `maverick fly --epic ` | Implement beads (actor-mailbox) | -| `maverick land [--eject\|--finalize]` | Curate history and merge | -| `maverick reconcile [--dry-run]` | Reapply changed human answers into jj history | +| `maverick land [--eject\|--finalize] [--status] [--json]` | Curate history and merge, or query the frontier | +| `maverick reconcile [--dry-run] [--json]` | Reapply changed human answers into jj history | | `maverick workspace status\|clean` | Manage hidden workspace | -| `maverick init` | Initialize a Maverick project | +| `maverick init` | Initialize a Maverick project (installs the `maverick-review` skill) | | `maverick brief [--watch\|--human]` | Bead status + assumption counts | -| `maverick review [--answer\|--waive]` | Resolve a human-assigned bead | -| `maverick review --spec --waive ` | Bulk-waive a spec's open entries | +| `maverick review [--answer\|--waive] [--json]` | Resolve a human-assigned bead | +| `maverick review --spec --waive [--json]` | Bulk-waive a spec's open entries | +| `maverick review --list [--status\|--spec\|--severity]... [--json]` | List ledger entries with full provenance | | `maverick runway seed\|consolidate` | Manage knowledge store | ### spec (headless Spec Kit chain) @@ -654,6 +655,28 @@ owns matching the severity filter (default: low only) in one invocation — the strict any-severity gate makes clearing accepted-risk low-severity noise a named operation rather than one-by-one. +**JSON verbs (053-assumption-review-console)**: `land --status --json` +runs the gate evaluation and builds/persists the report without +curating — a read-only frontier query (`verb: land.status`, always +exits 0 on a completed evaluation, blocked is an answer not a failure). +`land --json` (with `--yes` to skip the interactive curation-approval +prompt, which is unreachable in JSON mode) emits the same envelope +shape as every other JSON verb; a frontier refusal is `ok: false`, +`kind: frontier-blocked`, with the full report under +`error.details.report`. Both documents carry a top-level `degraded` +flag: when the ledger can't be read at all the gate degrades *open* +and materializes zero entries, so `frontier_clear` is trivially true — +consumers must read `frontier_clear && !degraded` as the landable +condition. See +`specs/053-assumption-review-console/contracts/cli-land-json.md` and +`error-envelope.md` for the full contract; `src/maverick/cli/json_output.py` +is the shared envelope/error-kind implementation every JSON verb uses. + +The gate evaluation, report building, terminal rendering, and +persistence shared by `land` and `land --status` live in +`src/maverick/cli/commands/land_gate.py` — both commands import it, +neither imports the other. + ### reconcile `maverick reconcile [--dry-run]` retroactively reapplies human answers to @@ -696,6 +719,20 @@ via `maverick review` re-arms it for the next reconcile run (FR-017). See `src/maverick/workflows/reconcile/` and `specs/051-reconcile-changed-answers/` for the full contract. +**JSON verbs (053-assumption-review-console)**: `reconcile --json` +(verb `reconcile.run`) and `reconcile --dry-run --json` (verb +`reconcile.dry-run`) emit `ReconcileReport.to_dict()` under `result` +with workflow progress routed to stderr; precondition failures +(dirty working copy, concurrent fly run, held lockfile, missing +`.jj`) map to the `dirty-working-copy`/`concurrent-run`/`locked`/`vcs` +error kinds instead of a bare stderr message. That mapping keys off +`WorkflowError.reason` — a stable code (`maverick.exceptions.workflow`'s +`REASON_*` constants) the raiser sets, so rewording a precondition +message can't silently reclassify it as `internal`; prose-marker +matching survives only as a fallback for raisers that set no `reason`. +Exit-code semantics are unchanged from the non-JSON contract. See +`specs/053-assumption-review-console/contracts/cli-reconcile-json.md`. + **Mid-flight integration (052-conditional-landing)**: a running `maverick fly` triggers this same workflow in-process at every bead boundary (`### fly` above) when detection is non-empty, passing @@ -740,6 +777,38 @@ clears any prior reconcile status on re-answer so a corrected human answer re-enters reconcile detection without special-casing elsewhere. See `### reconcile` above and `specs/051-reconcile-changed-answers/`. +### Assumption review console (053-assumption-review-console) + +Every review-lifecycle verb is invocable headlessly with `--json`: +`review --list [--status\|--spec\|--severity]...` (verb `review.list`, +full provenance rows filtered/sorted server-side — owning spec asc, +severity high→low, stable ledger order), `review --answer/--waive` +(verbs `review.answer`/`review.waive`, with an already-resolved +pre-check that applies in both JSON and human mode), and +`review --spec --waive ` (verb `review.bulk-waive`). All +JSON verbs across `review`/`reconcile`/`land` share one envelope shape +and error-kind registry (`src/maverick/cli/json_output.py`, +`specs/053-assumption-review-console/contracts/error-envelope.md`); the +canonical entry-row projection (`src/maverick/assumptions/serialize.py` +`entry_to_dict`) is shared verbatim by `review --list` and the land +report so both surfaces can never drift. `maverick review` without +`--json` is unchanged (FR-018) — it remains the bare-terminal fallback +for humans without Claude Code. + +`maverick init` installs a packaged Claude Code skill, +`maverick-review` (`src/maverick/skills/review_console/SKILL.md`, +installed to `/.claude/skills/maverick-review/SKILL.md`, +always overwritten — Maverick-owned, versions with the wheel; removed +by `maverick uninstall`). Invoked as `/maverick-review` or by prose, it +sweeps the open queue one entry at a time via `AskUserQuestion` +(adopted answer + alternatives + free-form + waive/skip), applies each +decision immediately through the JSON verbs above, then — once, after +the sweep — runs `reconcile --json`, reports the frontier via +`land --status --json`, and offers to land only on explicit +confirmation. The skill never touches jj/git/bd or files directly; see +`specs/053-assumption-review-console/contracts/skill-review-console.md` +for its full behavioral contract. + ## Dependencies - [uv](https://docs.astral.sh/uv/) for dependencies (`uv sync`). diff --git a/pyproject.toml b/pyproject.toml index 791307db..76cf9976 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,6 +89,7 @@ packages = ["src/maverick"] include = [ "src/maverick/**/*.py", "src/maverick/agents/system_prompts/*.md", + "src/maverick/skills/**/*.md", ] [tool.ruff] diff --git a/specs/052-conditional-landing/contracts/land-report-schema.md b/specs/052-conditional-landing/contracts/land-report-schema.md index cfa4bd3e..2e26484a 100644 --- a/specs/052-conditional-landing/contracts/land-report-schema.md +++ b/specs/052-conditional-landing/contracts/land-report-schema.md @@ -23,7 +23,10 @@ and carries no independent fields. "entries": [ { "bead_id": "ma-0042", + "owner_spec": "052-conditional-landing", + "status": "open | answered | waived", "bucket": "resolved | waived | open", + "blocks_landing": false, "question": "…", "adopted_answer": "…", "final_answer": "… | null", @@ -35,13 +38,13 @@ and carries no independent fields. "affected_change_ids": ["zzkw…", "rlvk…"], "waiver": { "by": "…", "at": "2026-07-24T14:00:00Z", "reason": "…" }, "reconcile": { - "status": "reconciled | needs-interactive-review | skipped | pending | null", + "status": "reconciled | needs-interactive-review | pending | null", "reconciled_answer": "… | null", "change_id": "… | null", "reason": "… | null" }, "pending_reconcile": false, - "annotations": ["legacy", "reconcile: skipped"] + "annotations": ["legacy", "reconcile: needs-interactive-review"] } ] } @@ -61,6 +64,22 @@ Field notes: (report may be partial; `verification` omitted → key absent). - `verification: "blocked"` reports never accompany a landing — they are the audit trail of a refused attempt. +- `reconcile.status` never actually persists as `"skipped"` — the ledger + only ever writes `"reconciled"` or `"needs-interactive-review"` as + terminal values, plus the non-terminal `"pending"` re-arm sentinel (a + reconcile run that skips an entry, e.g. an immutable target, still + terminal-marks it `"needs-interactive-review"`, per + `specs/051-reconcile-changed-answers/`); this corrects an earlier, + inaccurate version of this schema note that listed `"skipped"` as a + possible persisted value. +- `owner_spec`, `status` (the ledger's `open|answered|waived` lifecycle + state, distinct from `bucket`'s land-report-specific grouping), and + `blocks_landing` are additive fields (053-assumption-review-console) — + present on every row from that feature onward, needed by + `review --list --json`'s flat listing (which has no section key to + carry `owner_spec` the way this report's `specs[].owner_spec` does) and + reused here for schema parity between the two surfaces. `schema_version` + is unchanged (still `1`) since additive fields don't require a bump. ## `land-report.md` (structure, not schema) diff --git a/specs/053-assumption-review-console/checklists/requirements.md b/specs/053-assumption-review-console/checklists/requirements.md new file mode 100644 index 00000000..c28e88d3 --- /dev/null +++ b/specs/053-assumption-review-console/checklists/requirements.md @@ -0,0 +1,43 @@ +# Specification Quality Checklist: Assumption Review Console + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-07-25 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- "Claude Code skill" and "machine-readable output mode of existing commands" + are named deliberately: they are the product surface the user requested, not + leaked implementation detail. Mechanism-level choices (question-presentation + tooling, output serialization format, flag names) are left to planning. +- The one-batched-reconcile rule, the immediate per-decision verb application, + and the no-bypass land gate are the load-bearing behavioral constraints; + each is covered by an FR, an acceptance scenario, and a success criterion. +- No [NEEDS CLARIFICATION] markers were required — the feature description was + unusually complete; remaining defaults (skip semantics, bulk-waive severity + default, landing mode) are documented in Assumptions. diff --git a/specs/053-assumption-review-console/contracts/cli-land-json.md b/specs/053-assumption-review-console/contracts/cli-land-json.md new file mode 100644 index 00000000..3fa91922 --- /dev/null +++ b/specs/053-assumption-review-console/contracts/cli-land-json.md @@ -0,0 +1,120 @@ +# Contract: `maverick land` JSON modes + +**Feature**: 053-assumption-review-console +**Envelope**: all documents follow `error-envelope.md`. +Human-mode behavior (no `--json`) and the 052 contracts +(`specs/052-conditional-landing/contracts/cli-land.md`, +`land-report-schema.md`) are unchanged. The `report` object embedded +below is exactly `LandReport.to_dict()` (schema_version 1, additive-only +per 052); this feature adds `owner_spec`, `status`, `bucket`, and +`blocks_landing` keys to its entry rows (additive, legal under 052). + +## `land --status [--json]` — verb `land.status` + +Read-only frontier/landability query. Evaluates the assumption gate, +builds **and persists** the land report (`.maverick/runs// +land-report.{json,md}` — persistence failure degrades to a stderr +warning and `"degraded_persistence": true`, never a failure), and stops. +No curation, no consolidation, no manifest display, no history mutation. + +- `--status` is mutually exclusive with `--dry-run`, `--eject`, + `--finalize`, `--no-curate`, `--heuristic-only`, `--yes`. `--base` and + `--branch` are accepted but ignored in `--status` mode (`--base` has a + default and is therefore always present; the gate evaluation does not + use it). + +### Result + +```json +{ + "degraded": false, + "frontier_clear": false, + "verification": "verified | conditionally-verified | blocked | null", + "blocking": { + "open": ["mav-101", "mav-102"], + "pending_reconcile": ["mav-201"] + }, + "report": { LandReport }, + "report_paths": {"json": ".maverick/runs//land-report.json", + "md": ".maverick/runs//land-report.md"} +} +``` + +- **`degraded: true` means the ledger could not be read at all** (bd + unavailable, or the query failed). The gate degrades *open* on that + path, so it materializes zero entries and `frontier_clear` is + trivially `true` — a degraded document is therefore + indistinguishable from a genuinely verified one on `frontier_clear` + alone. **Consumers MUST treat `frontier_clear && !degraded` as the + landable condition.** Distinct from `degraded_persistence`, which + only says the report artifact couldn't be *written*. +- `verification` is `null` exactly when `degraded` is `true` — + mirrors 052's omitted-verification semantics; `degraded` inside + `report` carries the same signal nested. +- `blocking` lists are empty when `frontier_clear` is true. + +### Exit codes + +- `0` — always, on any completed evaluation (blocked is an **answer**, + not a failure, for a status query). +- `1` — only for error envelopes (`internal`, `vcs`). + +## `land [--json] [--yes] [--dry-run] [--no-curate|--heuristic-only] [--eject|--finalize] [--base ] [--branch ]` — verb `land.run` + +### Behavior + +- **Gate refusal**: frontier non-empty → `ok: false`, `kind: + frontier-blocked`, `error.details.report` = full report document, + exit 1. Same gate as human mode; `--json` introduces no bypass + (FR-007). Exception: with `--dry-run`, the full preview still runs and + the document reports the block; exit is deferred to the end (052 + behavior preserved). +- **Consent**: the agent-curation approval prompt never fires in JSON + mode. Reaching it without `--yes` → `confirmation-required` envelope, + exit 1, before any plan execution. `--yes`, `--no-curate`, + `--heuristic-only`, and `--dry-run` paths need no prompt. Consent is + the caller's job (the skill asks the human first). +- Progress and curation narration go to stderr. +- "Nothing to land" is success: `ok: true`, `"landed": false`, + `"reason": "nothing-to-land"`. It carries the **same key set** as + every other `land.run` success document (so a consumer reading + `verification` / `report` / `curation` never `KeyError`s), with + `"report": null` — the gate is not evaluated on this path, since + there is no landing to gate. + +### Result + +```json +{ + "landed": true, + "mode": "approve | eject | finalize | dry-run", + "verification": "verified | conditionally-verified | null", + "degraded": false, + "curation": { + "strategy": "none | heuristic | agent", + "executed_count": 3, + "total_count": 3 + }, + "report": { LandReport }, + "report_paths": { "json": "...", "md": "..." }, + "hint": "mode-specific next-step text | null" +} +``` + +- `degraded` carries the same meaning as in `land.status` above: the + ledger could not be read, so `verification` is `null` and the gate + passed vacuously rather than on a clean frontier. +- `curation.executed_count` / `total_count` are curation-operation + counts on the `agent` strategy and squash counts on `heuristic`. + The heuristic strategy additionally carries `absorb_ran` (bool) and + `squashed_count` (int): `jj absorb` rewrites history without + squashing anything, so `squashed_count: 0` alone cannot distinguish + "absorb folded N fixups" from "nothing to do". + +### Exit codes + +- `0` — landed (or dry-run preview with clear frontier; or + nothing-to-land). +- `1` — `frontier-blocked`, `confirmation-required`, `curation-failed`, + or any other error envelope; dry-run with blocked frontier (deferred, + 052 semantics). diff --git a/specs/053-assumption-review-console/contracts/cli-reconcile-json.md b/specs/053-assumption-review-console/contracts/cli-reconcile-json.md new file mode 100644 index 00000000..87c4db24 --- /dev/null +++ b/specs/053-assumption-review-console/contracts/cli-reconcile-json.md @@ -0,0 +1,77 @@ +# Contract: `maverick reconcile` JSON modes + +**Feature**: 053-assumption-review-console +**Envelope**: all documents follow `error-envelope.md`. +Human-mode behavior (no `--json`) and the 051 CLI contract +(`specs/051-reconcile-changed-answers/contracts/cli-reconcile.md`) are +unchanged; this contract only adds the JSON projection. + +Both verbs are synchronous: the invocation blocks until the workflow +completes and the document reflects the final state. No job/polling +protocol exists. + +## `reconcile --json` — verb `reconcile.run` + +### Behavior + +- Preconditions checked exactly as today; failures map to envelopes: + bd missing → `bd-unavailable`; `.jj` missing or jj failure → `vcs`; + dirty working copy → `dirty-working-copy`; concurrent flying run → + `concurrent-run`; held lockfile → `locked`. +- Workflow progress events render to **stderr** in JSON mode. +- Zero detected answers is success: `ok: true`, empty `outcomes`, + exit 0 (mirrors today's "run_state is None" path). + +### Result + +`ReconcileReport.to_dict()` (unchanged shape from +`workflows/reconcile/models.py`), plus persisted per-answer state detail: + +```json +{ + "run_id": "ab12cd34", + "dry_run": false, + "started_at": "...", "finished_at": "...", + "exit_success": false, + "outcomes": [ + { + "entry_id": "mav-201", + "status": "reconciled | skipped | needs_interactive_review", + "reason": "... | null", + "stage_reached": "...", + "target_change_id": "... | null", + "escalation_bead_id": "... | null", + "gate_passed": true, + "no_change_required": false + } + ] +} +``` + +### Exit codes (unchanged semantics) + +- `0` — nothing to reconcile, or every outcome `reconciled`. +- `1` — any outcome `skipped` / `needs_interactive_review` (`ok: true` — + the verb ran; the outcomes carry the news), or any error envelope. + +Consumers (the skill) MUST read `outcomes[].status`, not just the exit +code, and MUST surface `needs_interactive_review` entries with their +`reason` / `escalation_bead_id` to the human without retrying (FR-017). + +## `reconcile --dry-run --json` — verb `reconcile.dry-run` + +### Behavior + +Read-only detection preview per the 051 contract: detection, ordering, +target resolution, mutability guard only; zero jj/bd/filesystem writes; +skips lock and concurrency guards. This is the "reconcile status" verb. + +### Result + +Same report shape as `reconcile.run` with `dry_run: true`; predicted +`outcomes[].status` is only ever `reconciled` or `skipped`. + +### Exit codes + +- `0` — always, on any completed prediction (per 051 contract). +- `1` — only for error envelopes (`bd-unavailable`, `vcs`, `validation`). diff --git a/specs/053-assumption-review-console/contracts/cli-review-json.md b/specs/053-assumption-review-console/contracts/cli-review-json.md new file mode 100644 index 00000000..0922d603 --- /dev/null +++ b/specs/053-assumption-review-console/contracts/cli-review-json.md @@ -0,0 +1,142 @@ +# Contract: `maverick review` JSON modes + +**Feature**: 053-assumption-review-console +**Envelope**: all documents follow `error-envelope.md`. +Human-mode behavior (no `--json`) is byte-for-byte unchanged (FR-018). + +## `review --list [--json]` — verb `review.list` + +List assumption-ledger entries with full provenance. + +### Invocation + +``` +maverick review --list [--status open|answered|waived]... [--spec ]... + [--severity low|medium|high]... [--json] +``` + +- `--status`, `--spec`, `--severity` are repeatable; within one option + values OR, across options AND. +- Default when no `--status` given: `open` only — the sweep population. +- `--list` is mutually exclusive with `BEAD_ID` and with all decision + flags (`--answer`, `--waive`, `--approve`, `--reject`, `--defer`). +- Without `--json`, renders a human table (new, minimal; reuses the same + data). + +### Result + +```json +{ + "entries": [ {row} ], + "counts": { + "total": 7, + "by_status": {"open": 5, "answered": 1, "waived": 1}, + "by_severity": {"low": 3, "medium": 3, "high": 1}, + "pending_reconcile": 1 + } +} +``` + +- `entries` ordering is the canonical sweep order: `owner_spec` + (ascending), then severity high→low, then stable ledger order. Clients + MUST NOT re-sort for presentation. +- Row shape: the canonical entry row (see `data-model.md` + `entry_to_dict`): `bead_id, question, adopted_answer, alternatives[], + severity, severity_defaulted, status, bucket, blocks_landing, + owner_spec, source_bead, is_legacy, final_answer, waiver|null, + reconcile{...}, pending_reconcile, affected_change_ids[], + annotations[]`. Identical to the land-report row shape. +- `counts` reflects the **filtered** selection. + +### Exit codes + +- `0` — listed (including zero entries: `ok: true`, empty `entries`). +- `1` — `bd-unavailable` or query failure. + +## `review --answer [--json]` — verb `review.answer` + +### Behavior + +- JSON mode requires `--answer` (or `--waive`); no flag → + `validation` error. Prompting never occurs in JSON mode. +- Empty/whitespace answer → `validation` (rejected before any write). +- Pre-check: entry's current status must be `open` (or `answered` for a + re-answer, which is legal per 051 FR-017 and re-arms reconcile). + A `waived` or otherwise closed target → `already-resolved` with the + current row in `error.details.entry`. +- Unknown bead id → `not-found`. +- The "not flagged for human review" interactive confirm becomes a + `validation` error in JSON mode. +- Legacy escalation beads accept `--approve` / `--reject ` / + `--defer` instead; result then reports the legacy action taken and any + correction bead created. + +### Result + +```json +{"entry": {row}, "action": "answered"} +``` + +`entry` is the post-write row (status `answered`, `reconcile.status` +`"pending"`). + +**Degraded projection**: the row comes from a re-read issued *after* the +ledger write committed. If that read fails, the write still happened, so +the verb reports success with `"entry": null` and `"degraded": true` +rather than a failure envelope — reporting `ok: false` here would tell +the caller a recorded decision was not recorded. `degraded` is absent +when the projection succeeded. + +### Exit codes: `0` recorded; `1` any error envelope. + +## `review --waive [--json]` — verb `review.waive` + +Same guards as `review.answer` (open target required; `already-resolved` +otherwise). `waived_by` resolves from git user name (fallback +`"unknown"`), unchanged. The same degraded-projection rule applies. + +### Result + +```json +{"entry": {row}, "action": "waived"} +``` + +### Exit codes: `0` recorded; `1` any error envelope. + +## `review --spec --waive [--severity ...]... [--json]` — verb `review.bulk-waive` + +### Behavior + +- Existing semantics unchanged: severity filter defaults to `low` when + omitted; selects open entries owned by `` matching the filter; + waives each; per-entry failures collected. +- Zero matching entries is success (idempotent), `ok: true` with empty + `waived`. + +### Result + +```json +{ + "owner_spec": "049-assumption-ledger", + "severities": ["low"], + "waived": [ {row} ], + "failed": {"mav-123": "reason string"}, + "unprojected": ["mav-456"] +} +``` + +- `unprojected` (present only when non-empty) lists entries that **were + waived successfully** but whose post-write row could not be re-read. + They are neither `waived` rows nor `failed` — treating them as failures + would misreport a completed waive. Same degraded-projection rule as + `review.answer` above, applied per entry so one unreadable row never + discards the others. + +### Exit codes + +- `0` — all selected entries waived (or none matched). +- `1` — one or more per-entry failures (`ok: true`, `failed` non-empty — + the verb ran; outcomes say what failed), or an error envelope + (`not-found` for unknown spec, `bd-unavailable`, `validation`). + `unprojected` alone does **not** affect the exit code — those waives + succeeded. diff --git a/specs/053-assumption-review-console/contracts/error-envelope.md b/specs/053-assumption-review-console/contracts/error-envelope.md new file mode 100644 index 00000000..fe9a1ff8 --- /dev/null +++ b/specs/053-assumption-review-console/contracts/error-envelope.md @@ -0,0 +1,112 @@ +# Contract: JSON Envelope & Error Kinds + +**Feature**: 053-assumption-review-console +**Stability**: Public contract. Additive evolution only — existing keys, +verb ids, and error kinds are never renamed or removed; new ones may be +added. `schema_version` bumps only for breaking changes (none planned). + +## Scope + +Applies to every document emitted by a Maverick command invoked with +`--json`. One JSON document per invocation, written to **stdout only**; +all diagnostics, warnings, and progress go to **stderr**. No document is +ever partially written: emission happens once, at terminal state. + +`maverick brief --format json` predates this contract and keeps its +existing payload shapes (not enveloped); it is unchanged by this feature. + +## Envelope + +Success: + +```json +{ + "schema_version": 1, + "verb": "", + "ok": true, + "result": { } +} +``` + +Failure: + +```json +{ + "schema_version": 1, + "verb": "", + "ok": false, + "error": { + "kind": "", + "message": "", + "details": { } + } +} +``` + +Rules: + +- Exactly one of `result` / `error` is present (the other key is omitted, + never `null`). +- `ok: true` means the verb executed and produced its result. Outcome + semantics inside `result` (e.g. reconcile escalations) drive the exit + code per the verb's own contract — `ok` and exit code are related but + not identical. +- `ok: false` means the verb refused or failed. Exit code is non-zero. +- `error.kind` is a stable identifier — safe to branch on. +- `error.message` is human-readable prose — never branch on it. +- `error.details` is verb-specific structured context (documented per + verb); absent keys mean "no additional context". + +## Verb registry + +| Verb id | Command | +|---|---| +| `review.list` | `maverick review --list --json` | +| `review.answer` | `maverick review --answer --json` | +| `review.waive` | `maverick review --waive --json` | +| `review.bulk-waive` | `maverick review --spec --waive --json` | +| `reconcile.run` | `maverick reconcile --json` | +| `reconcile.dry-run` | `maverick reconcile --dry-run --json` | +| `land.status` | `maverick land --status --json` | +| `land.run` | `maverick land --json` (incl. `--dry-run`, `--eject`, `--finalize` variants) | + +## Error kinds registry + +| Kind | Meaning | Typical `details` | +|---|---|---| +| `validation` | Invalid flag combination or input (e.g. `--json` review with no decision flag; empty answer text; interactive-only path reached) | `{"hint": "..."}` | +| `not-found` | Referenced entry/bead/spec does not exist | `{"bead_id": "..."}` or `{"owner_spec": "..."}` | +| `already-resolved` | Entry is no longer open (concurrent resolution) | `{"entry": {row}}` — current entry row | +| `bd-unavailable` | bd CLI missing, not initialized, or ledger query failed. **Every** verb reports this for a failed `verify_available()` / bd-readiness precondition — never `validation` (the shared handler cannot classify a check that never raises, so each verb translates it itself, and they must agree). | — | +| `dirty-working-copy` | Working copy not clean where cleanliness is required | — | +| `concurrent-run` | Another workflow run (e.g. a flying run) blocks this verb | `{"run_id": "..."}` when known | +| `locked` | Run lockfile held | `{"lock_path": "..."}` when known | +| `frontier-blocked` | Land refused by the assumption-frontier gate | `{"report": {LandReport}}` — full report document | +| `confirmation-required` | Action needs explicit consent not supplied (e.g. `land --json` agent-curation without `--yes`) | `{"hint": "pass --yes"}` | +| `curation-failed` | Curation gather/plan/execution failed during land | `{"stage": "gather\|plan\|execute", "error": "..."}` | +| `vcs` | jj/git operation failed outside the kinds above | `{"operation": "..."}` when known | +| `internal` | Unexpected error (the bare-`Exception` boundary) | — | + +## Exit codes + +Unchanged `ExitCode` enum: `0` success, `1` failure, `2` partial (unused +by these verbs), `130` interrupted. Per-verb exit semantics are defined in +each verb's contract; every `ok: false` document accompanies a non-zero +exit. `KeyboardInterrupt` exits 130 and emits **no** JSON document (the +sole exception to one-document-per-invocation). + +## Stream discipline + +- stdout: the JSON document, nothing else. Implementations MUST route + Rich rendering, workflow progress (`render_workflow_events`), and + warnings to stderr in `--json` mode. +- Group-level failures that occur before subcommand option parsing + (missing `git`/`gh`, bad `maverick.yaml`) predate flag handling and are + NOT enveloped; automation should treat non-JSON stdout+non-zero exit as + an environment error. This is a documented limitation, not a bug. + +## Mapping (implementation note, non-normative) + +`maverick.cli.json_output.json_error_handler()` is the single boundary +that converts the `MaverickError` hierarchy and known precondition +failures into envelopes. Commands MUST NOT hand-roll error JSON. diff --git a/specs/053-assumption-review-console/contracts/skill-review-console.md b/specs/053-assumption-review-console/contracts/skill-review-console.md new file mode 100644 index 00000000..4190f485 --- /dev/null +++ b/specs/053-assumption-review-console/contracts/skill-review-console.md @@ -0,0 +1,118 @@ +# Contract: `maverick-review` Claude Code skill + +**Feature**: 053-assumption-review-console +**Artifact**: `src/maverick/skills/review_console/SKILL.md` (packaged), +installed to `/.claude/skills/maverick-review/SKILL.md` by +`maverick init`; removed by `maverick uninstall`. + +This contract specifies the skill's *behavior* — what the SKILL.md +instructions must make Claude Code do. The skill is judgment and +presentation only: its sole effect channel is invoking the JSON verbs +defined in the sibling contracts. It MUST NOT run jj/git/bd commands, +edit files, or mutate ledger state by any other means (FR-011). + +## Identity + +- Frontmatter: `name: maverick-review`; `description` carries the + trigger text ("human review console for Maverick assumption sweeps — + use when the user wants to review pending assumptions, answer or waive + ledger entries, reconcile answers, or land"); `user-invocable: true`. +- Invocable as `/maverick-review`; also model-invocable when the user + asks in prose to review assumptions. + +## Preflight + +1. Run `maverick review --list --json`. +2. On `bd-unavailable` or non-JSON output: report the environment + problem and stop (suggest `maverick init` / installing `bd`). Never + guess at queue state. +3. If `entries` is empty: say nothing is pending, then run + `maverick land --status --json` and report the frontier state + (FR-013). Offer landing if clear (see Landing below). Done. + +## Sweep + +4. Present entries **one at a time, in document order** (the listing is + pre-sorted: spec group → severity high→low → ledger order; FR-009). + When entering a new spec group, say which spec the following entries + belong to. +5. For each entry, ask one question (AskUserQuestion) showing: the + question text, owning spec, severity, and affected change ids. + Options (FR-010): + - The adopted answer, marked "(Recommended)" — first option. + - Each recorded alternative, up to the option surface's capacity. + - A "Waive / more…" option when alternatives overflow or to reach + waive/skip. + - Free-form input arrives via the built-in "Other" affordance. + Every recorded alternative MUST be reachable (follow-up question for + overflow); none may be silently dropped. +6. Decision → verb, applied **immediately** (FR-011): + - Confirm adopted answer → `maverick review --answer "" --json` + - Alternative or free-form → `maverick review --answer "" --json` + (empty/whitespace free-form: re-prompt, never invoke) + - Waive → collect a reason, then `maverick review --waive "" --json` + - Skip → no invocation; entry stays open; continue. +7. On `already-resolved`: tell the human it was resolved elsewhere + (show the current state from `error.details.entry`) and continue the + sweep. On any other `ok: false`: report kind + message and continue + with remaining entries; never retry an invocation unprompted. + On `ok: true` with `"degraded": true` (and `entry: null`): the + decision **was** recorded — only its post-write row was unreadable. + Report it as recorded and continue; never re-apply. + **Interruption tolerance (FR-012)**: the skill holds no sweep state — + every decision was already applied when made. If a session is + interrupted, a later invocation simply starts at Preflight again; the + fresh listing contains only the still-open entries, so decided + entries never reappear. +8. **Bulk-waive shortcut** (clarification Q5): when ≥2 open low-severity + entries share the current spec group, the skill MAY offer once per + spec: "waive all remaining low-severity entries in ?" with a + reason prompt → `maverick review --spec --waive "" --json` + (default low severity). Per-entry presentation remains the default; + declining continues entry-by-entry. Ids in the result's `unprojected` + list were waived successfully (row unreadable only) — count them as + waived, never as failures. + +## Batched reconcile (FR-014) + +9. After the last entry: if **zero** answers were recorded during the + sweep (only waives/skips), skip reconcile entirely. +10. Otherwise run `maverick reconcile --json` **exactly once**. Never per + answer; never re-run on failure. +11. Report outcomes from `result.outcomes`: reconciled entries briefly; + every `needs_interactive_review` or `skipped` outcome explicitly with + its `reason` and `escalation_bead_id` (FR-017). On an `ok: false` + envelope (`dirty-working-copy`, `concurrent-run`, `locked`, …): + explain, suggest the remedy (e.g. retry after the other run + finishes), do not retry (spec edge case). + +## Frontier report & landing (FR-015, FR-016) + +12. Run `maverick land --status --json`. If `result.degraded` is true the + ledger could not be read — `frontier_clear` is then trivially true + and means nothing; say so, offer no landing, skip to step 15. + Otherwise report the frontier in plain language: **verified**, + **conditionally verified**, or **still blocked** — when blocked, list + blocking entries with next steps (open → review it; + pending_reconcile → run reconcile / resolve escalations). +13. If `frontier_clear` is true and `degraded` is not: ask the human + whether to land now + (single confirm question). Only on explicit confirmation run + `maverick land --yes --json`. Report `verification` classification + and `hint` from the result. On `ok: false` (`frontier-blocked` race, + `curation-failed`, …): report kind + message; do not retry. +14. If the human declines, end with the frontier summary; state that + `maverick land` remains available. + +## Reporting + +15. End every session with a short summary: entries answered / waived / + skipped, bulk-waives, reconcile outcome counts, frontier state, and + landing result if any. + +## Prohibitions + +- No jj / git / bd / file mutations; no verbs outside this contract. +- No blind retries of any failed invocation. +- No landing without explicit human confirmation in this session. +- No parsing of human-mode (non-`--json`) output. diff --git a/specs/053-assumption-review-console/data-model.md b/specs/053-assumption-review-console/data-model.md new file mode 100644 index 00000000..25df922b --- /dev/null +++ b/specs/053-assumption-review-console/data-model.md @@ -0,0 +1,122 @@ +# Data Model: Assumption Review Console + +**Feature**: 053-assumption-review-console | **Date**: 2026-07-25 + +This feature is contract-heavy and model-light: it projects existing typed +models over a new JSON surface and adds two small new types. Existing +models are reused unmodified unless noted. + +## Reused existing models (read side) + +| Model | Location | Role here | +|---|---|---| +| `AssumptionRecord` | `assumptions/models.py:139` | Core entry state (bead_id, question, adopted_answer, alternatives, severity, status, owner_spec, source_bead, change_ids, is_legacy) | +| `AssumptionReportEntry` | `assumptions/models.py:196` | Rich read view (record + final_answer, waiver fields, reconcile fields, `pending_reconcile`, derived `bucket`, `affected_change_ids`, `blocks_landing`) — the single source for listing rows and land-report rows | +| `LandFrontier` | `assumptions/models.py:257` | Gate evaluation (`open_entries`, `pending_reconcile_entries`, `is_empty`) | +| `LandVerification` | `assumptions/models.py:180` | `verified \| conditionally-verified \| blocked` | +| `LandReport` / `SpecReportSection` | `assumptions/land_report.py` | Versioned report document (`to_dict()` = schema_version 1, contract 052) | +| `ReconcileReport` / `AnswerOutcome` | `workflows/reconcile/models.py` | Reconcile run result (`to_dict()`; outcome status `reconciled \| skipped \| needs_interactive_review`) | +| `ReconcileRunState` / `AnswerState` | `workflows/reconcile/state.py` | Persisted per-run state (pydantic, `model_dump(mode="json")`) | +| `BulkWaiveResult` | `assumptions/models.py:297` | `waived: tuple[AssumptionRecord,...]`, `failed: dict[str, str]` | +| `Severity` | `assumptions/models.py` | `low \| medium \| high` | + +## New types + +### `ErrorKind` (StrEnum) — `maverick/cli/json_output.py` + +Stable machine-branchable failure taxonomy. Additive evolution only; +values are part of the public contract (see +`contracts/error-envelope.md`). + +``` +validation | not-found | already-resolved | bd-unavailable +| dirty-working-copy | concurrent-run | locked | frontier-blocked +| confirmation-required | curation-failed | vcs | internal +``` + +### `JsonEnvelope` (frozen dataclass) — `maverick/cli/json_output.py` + +The one shape every `--json` document takes. + +| Field | Type | Notes | +|---|---|---| +| `schema_version` | `int` | Constant `1` for this feature; additive bumps only | +| `verb` | `str` | Stable dotted id: `review.list`, `review.answer`, `review.waive`, `review.bulk-waive`, `reconcile.run`, `reconcile.dry-run`, `land.status`, `land.run` | +| `ok` | `bool` | `true` = verb executed and produced `result`; `false` = refused/failed with `error` | +| `result` | `dict \| None` | Present iff `ok` | +| `error` | `JsonError \| None` | Present iff not `ok` | + +`JsonError` (frozen dataclass): `kind: ErrorKind`, `message: str`, +`details: dict` (default empty; verb-specific structured context, e.g. +the full land report under `frontier-blocked`). + +Constructors: `JsonEnvelope.success(verb, result)` / +`JsonEnvelope.failure(verb, kind, message, details=...)`; `to_dict()` +omits the absent branch entirely (never `"result": null`). + +**Validation rules**: `ok XOR error`; `verb` must be in the registry; +serialization is the only output path (`emit_json` writes exactly one +document to stdout). + +### `entry_to_dict(entry: AssumptionReportEntry) -> dict[str, object]` — `maverick/assumptions/serialize.py` + +Not a new model — the canonical row projection, extracted from +`land_report._entry_to_dict` and extended additively. Both `review --list` +and the land report emit this shape (land report nests it under spec +sections; the listing is flat). + +| Key | Source | +|---|---| +| `bead_id`, `question`, `adopted_answer`, `alternatives[]`, `severity`, `severity_defaulted`, `is_legacy`, `source_bead` | `entry.record` | +| `owner_spec` | `entry.record.owner_spec` (**new in row**, needed by flat listing) | +| `status` | `entry.record.status` (`open \| answered \| waived`) | +| `bucket` | derived (`resolved \| waived \| open`) (**new in row**) | +| `blocks_landing` | derived (`bucket == open or pending_reconcile`) (**new in row**) | +| `final_answer`, `waiver{by,at,reason} \| null` | waive/answer fields | +| `reconcile{status,reconciled_answer,change_id,reason}` | reconcile lifecycle fields | +| `pending_reconcile` | 051 predicate | +| `affected_change_ids[]` | ledger stamps + reconcile change id, deduped | +| `annotations[]` | derived display hints (unchanged) | + +### Skill asset — `src/maverick/skills/review_console/SKILL.md` + +Package data, not code. Identity: frontmatter `name: maverick-review`. +Installed to `/.claude/skills/maverick-review/SKILL.md` by +`maverick init` (always overwritten — Maverick-owned, versions with the +wheel); removed by `maverick uninstall`. Behavior contract in +`contracts/skill-review-console.md`. + +## Conceptual entities (no code artifact) + +### Decision (skill-level) + +One human resolution of one presented entry. States: +`confirm-adopted | choose-alternative | free-form-answer | waive(reason) +| skip | bulk-waive(spec, severities, reason)`. Every non-skip decision +maps to exactly one verb invocation, applied immediately (FR-011). + +### Sweep (skill-level) + +Ordered pass over the listing document (`owner_spec` group → severity +high→low → ledger order). Lifecycle: `list → [decide]* → (reconcile once +| skip) → frontier report → (land offer → land | end)`. Interruption-safe +because no state is held: restarting re-lists and only open entries +reappear (FR-012). + +## State transitions touched + +Entry status (existing, unchanged semantics — this feature only adds +surfaces that trigger them): + +``` +open --answer(text)--> answered [reconcile_status=pending] +open --waive(reason)--> waived +answered --reconcile--> reconcile_status ∈ {reconciled, needs-interactive-review} +answered --re-answer--> answered [reconcile status cleared, re-armed] (051 FR-017) +``` + +New boundary guard (R6): the verbs pre-check current status before any +ledger write. `answer` accepts `open` or `answered` targets (re-answer +supersedes and re-arms reconcile, 051 FR-017); `waive` accepts `open` +only. Any other target state → `already-resolved` error envelope with the +current row in `error.details.entry`, no ledger write. diff --git a/specs/053-assumption-review-console/plan.md b/specs/053-assumption-review-console/plan.md new file mode 100644 index 00000000..aafe0f4e --- /dev/null +++ b/specs/053-assumption-review-console/plan.md @@ -0,0 +1,146 @@ +# Implementation Plan: Assumption Review Console + +**Branch**: `053-assumption-review-console` | **Date**: 2026-07-25 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/053-assumption-review-console/spec.md` + +## Summary + +Expose the assumption-review lifecycle as headless, machine-readable CLI +verbs — list the queue with full provenance, answer, waive, bulk-waive, +reconcile status, land status, run reconcile, land — by adding `--json` +output modes (plus a `review --list` mode and a `land --status` mode) to +the existing commands, all sharing one response envelope with stable error +kinds and strict stdout purity. On top of that surface, ship a packaged +Claude Code skill (`maverick-review`, installed into user projects' +`.claude/skills/` by `maverick init`) that sweeps the queue with the human +one entry at a time via AskUserQuestion, applies each decision immediately +through a verb invocation, triggers a single batched reconcile at sweep +end, reports the frontier state, and offers to land — never touching jj +history itself. + +## Technical Context + +**Language/Version**: Python 3.11+ (`from __future__ import annotations`) +**Primary Dependencies**: Click + Rich (CLI), Pydantic (config/models), +structlog, GitPython (reads), `JjClient` (VCS writes), `BeadClient` (bd +ledger), existing `maverick.assumptions` / `maverick.workflows.reconcile` +APIs; no new third-party dependencies +**Storage**: bd (beads) ledger state via `BeadClient`; run artifacts under +`.maverick/runs//` (land-report.json/md, reconcile.json); packaged +skill asset installed to `/.claude/skills/maverick-review/` +**Testing**: pytest + pytest-asyncio + xdist (`make test`), Click +`CliRunner` for command-level tests, unit + scenario split per repo +convention +**Target Platform**: Linux/macOS developer machines and headless CI (no +TTY required for any verb) +**Project Type**: single project (existing `src/maverick/` package) +**Performance Goals**: `review --list` completes in one bd sweep +(`report_entries`, single subprocess pass); JSON emission adds no +measurable overhead over existing rendering; reconcile/land runtimes +unchanged (dominated by model calls / jj operations) +**Constraints**: stdout carries exactly one JSON document in `--json` mode +(diagnostics → stderr); no interactive prompt reachable in JSON mode; +envelope + error kinds evolve additively only; existing human-mode output +and exit codes byte-for-byte unchanged when `--json` is absent (FR-018) +**Scale/Scope**: ledgers of tens to low hundreds of entries; 4 commands +touched, 1 new serializer module, 1 new JSON-output module, 1 packaged +skill, ~8 documented verb contracts + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +| Principle | Assessment | +|---|---| +| I. Async-First | PASS — all verbs ride existing `@async_command` paths; no new subprocess or blocking I/O; workflow execution unchanged. | +| II. Separation of Concerns | PASS — the skill provides judgment/UX only and calls verbs; all deterministic side effects (ledger writes, reconcile, curation, jj) stay in workflows/actions. No agent gains side effects. | +| III. Dependency Injection | PASS — commands keep receiving config via `ctx.obj`; `entry_to_dict`/envelope helpers are pure functions; no global state added. | +| IV. Fail Gracefully | PASS — `json_error_handler()` maps the `MaverickError` hierarchy to structured envelopes; partial bulk-waive outcomes enumerated, not discarded; land-report persistence failure stays a degradation, never a block. | +| V. Test-First | PASS — every new surface (envelope, serializer, `--list` filters, JSON modes, init install step, skill asset presence) gets tests before implementation; existing-mode regression tests pin human output. | +| VI. Typed Contracts | PASS — `JsonEnvelope` frozen dataclass + `ErrorKind` StrEnum; row projection sourced from typed `AssumptionReportEntry`; reuses versioned `LandReport.to_dict()` / `ReconcileReport.to_dict()`. No new ad-hoc dict blobs on public paths (existing untyped curation-action dicts are consumed, not extended). | +| VII. Simplicity & DRY | PASS — one envelope, one entry serializer shared by listing and land report, one JSON error handler; no parallel commands; reuses dead-but-present `cli/output.py` formatting seam. | +| VIII. Relentless Progress | PASS — skill continues sweep past per-entry failures (already-resolved etc.); reconcile/land failure surfaces actionable structured errors; no silent giving up, and deliberately **no blind retries** per spec FR-017 (human-facing surface, retry is the human's call). | +| IX. Hardening | PASS — already-resolved pre-check validates at the boundary; JSON mode validates flag combinations up front; timeouts/retries unchanged in underlying clients. | +| X. Guardrails | PASS — Guardrail 0/7: commands keep resolving `cwd` at the CLI boundary and passing it down; no `Path.cwd()` added below the CLI layer. Guardrail 3: skill performs no deterministic side effects. Guardrail 5: no new subprocess wrappers. | +| XI. Modularize Early | PASS WITH ACTION — `review.py` (~490 LOC) splits into a `review/` package in this feature (R10); `land.py` gains a helper module rather than inline growth. | +| XII. Ownership | PASS — feature includes fixing the uneven error-handler adoption for the touched commands' JSON paths via the shared handler. | + +**Post-Phase-1 re-check**: design artifacts introduce no new violations — +envelope and serializer are single-responsibility modules; skill asset is +data, not code; no new external-system wrappers. Gate holds. + +## Project Structure + +### Documentation (this feature) + +```text +specs/053-assumption-review-console/ +├── plan.md # This file +├── research.md # Phase 0 output (R1–R10) +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ # Phase 1 output +│ ├── error-envelope.md +│ ├── cli-review-json.md +│ ├── cli-reconcile-json.md +│ ├── cli-land-json.md +│ └── skill-review-console.md +└── tasks.md # Phase 2 output (/speckit-tasks — NOT created by plan) +``` + +### Source Code (repository root) + +```text +src/maverick/ +├── assumptions/ +│ ├── serialize.py # NEW: entry_to_dict() canonical row projection (R4) +│ └── land_report.py # MODIFIED: delegate _entry_to_dict → serialize; additive row fields +├── cli/ +│ ├── json_output.py # NEW: JsonEnvelope, ErrorKind, emit_json(), json_error_handler() (R1, R3) +│ ├── output.py # MODIFIED: emit_json transport helper +│ └── commands/ +│ ├── review/ # NEW package (split of review.py, R10) +│ │ ├── __init__.py # command def + dispatch (re-exports `review`) +│ │ ├── listing.py # --list mode: filters, sort, counts (R5) +│ │ ├── entry_actions.py # answer/waive/bulk-waive incl. JSON + already-resolved guard (R6) +│ │ └── legacy.py # legacy escalation-bead flow +│ ├── reconcile.py # MODIFIED: --json for run + dry-run (R7) +│ ├── land.py # MODIFIED: --json, --status dispatch (R8) +│ ├── land_status.py # NEW: status/report path helper (R8, R10) +│ └── brief.py # UNCHANGED (existing --format json satisfies FR-003) +├── skills/ # NEW package-data directory +│ └── review_console/ +│ └── SKILL.md # NEW: maverick-review skill source of truth (R9) +├── init/__init__.py # MODIFIED: install/refresh skill into .claude/skills/ (R9) +└── cli/commands/uninstall.py # MODIFIED: remove installed skill + +pyproject.toml # MODIFIED: include src/maverick/skills/**/*.md in wheel + +tests/ +├── unit/ +│ ├── assumptions/test_serialize.py # NEW +│ ├── cli/test_json_output.py # NEW: envelope, error-kind mapping, stdout purity +│ ├── cli/commands/test_review_listing.py # NEW: filters, ordering, counts, JSON doc +│ ├── cli/commands/test_review_json.py # NEW: answer/waive/bulk-waive JSON + guards +│ ├── cli/commands/test_reconcile_json.py # NEW +│ ├── cli/commands/test_land_json.py # NEW: --status, gate refusal envelope, --yes +│ └── init/test_skill_install.py # NEW: init installs/refreshes, uninstall removes +└── integration/ + └── cli/test_json_verbs_scenario.py # NEW: seeded ledger → list → answer/waive → + # reconcile dry-run → land --status end-to-end +``` + +**Structure Decision**: Single-project layout, all inside the existing +`src/maverick/` package. New code is two focused modules +(`assumptions/serialize.py`, `cli/json_output.py`), one command-package +split (`cli/commands/review/`), one helper (`land_status.py`), and one +package-data asset (`skills/review_console/SKILL.md`). The skill is data +shipped in the wheel and installed by `maverick init`; no runtime imports +it. + +## Complexity Tracking + +No constitution violations require justification. The one structural +change beyond strict feature need — splitting `review.py` into a package — +is mandated by Principle XI's refactor trigger, uses the +backwards-compatible re-export pattern, and is scoped inside this feature. diff --git a/specs/053-assumption-review-console/quickstart.md b/specs/053-assumption-review-console/quickstart.md new file mode 100644 index 00000000..0dae8907 --- /dev/null +++ b/specs/053-assumption-review-console/quickstart.md @@ -0,0 +1,134 @@ +# Quickstart: Assumption Review Console + +**Feature**: 053-assumption-review-console + +Runnable validation scenarios proving the feature end-to-end. Row/document +shapes referenced here are normative in [contracts/](./contracts/) and +[data-model.md](./data-model.md). + +## Prerequisites + +- Maverick installed (`uv sync`), `bd` CLI available, repo initialized + (`maverick init` — jj colocated, beads ready). +- A ledger with entries to exercise. For a synthetic fixture, seed via + the test helpers (see `tests/integration/cli/test_json_verbs_scenario.py`) + or use any repo where `maverick fly` recorded assumptions. +- `jq` for spot-checking output (optional). + +## Scenario 1 — Headless verbs (User Story 1) + +```bash +# 1. List the open queue (sweep population), machine-readable +maverick review --list --json | jq '.verb, .ok, .result.counts' +# EXPECT: "review.list", true, counts object; exit 0 +# EXPECT: stdout is exactly one JSON document (pipe through jq -e '.' to assert) + +# 2. Filters +maverick review --list --status waived --severity low --json | jq '.result.entries | length' + +# 3. Answer an entry (pick an id from step 1) +maverick review --answer "Use ISO-8601 everywhere" --json \ + | jq '.result.action, .result.entry.status, .result.entry.reconcile.status' +# EXPECT: "answered", "answered", "pending"; exit 0 + +# 4. Answer it again from a "concurrent" session after waiving elsewhere +maverick review --answer "x" --json | jq '.ok, .error.kind' +# EXPECT: false, "already-resolved"; exit 1 + +# 5. Waive + bulk-waive +maverick review --waive "accepted risk" --json | jq '.result.action' +maverick review --spec --waive "low-sev noise" --json \ + | jq '.result | {waived: (.waived|length), failed}' +# EXPECT: per-entry enumeration; exit 0 when failed == {} + +# 6. No TTY anywhere: re-run any verb with stdin/stdout detached +maverick review --list --json < /dev/null | jq -e '.ok' +``` + +## Scenario 2 — Reconcile verbs + +```bash +# Detection preview (status verb): read-only, always exit 0 +maverick reconcile --dry-run --json | jq '.result.dry_run, [.result.outcomes[].status]' +# EXPECT: true, statuses only "reconciled"/"skipped" + +# Real run: exactly once, synchronous; progress on stderr only +maverick reconcile --json 2>progress.log | jq '.result.exit_success, [.result.outcomes[].status]' +# EXPECT: stdout parseable in isolation; exit 0 iff nothing to do or all reconciled +# EXPECT: needs_interactive_review outcomes carry reason + escalation_bead_id + +# Precondition failure shape (run with a dirty working copy) +maverick reconcile --json | jq '.ok, .error.kind' +# EXPECT: false, "dirty-working-copy"; exit 1 +``` + +## Scenario 3 — Land verbs + +```bash +# Status query: never fails on "blocked", never curates +maverick land --status --json | jq '.result.frontier_clear, .result.verification, .result.blocking' +# EXPECT: exit 0 whether clear or blocked; report persisted under .maverick/runs// + +# Apply while blocked: refusal envelope, same gate as human mode +maverick land --json | jq '.ok, .error.kind, (.error.details.report.totals)' +# EXPECT: false, "frontier-blocked"; exit 1 + +# Consent guard (run BEFORE landing — needs a clear frontier and work to land): +# agent-curation path without --yes +maverick land --json | jq '.error.kind' +# EXPECT: "confirmation-required" (when gate passes and agent curation is reached) + +# Apply with clear frontier, consent supplied by caller +maverick land --yes --json | jq '.result.landed, .result.verification, .result.mode' +# EXPECT: true, "verified" or "conditionally-verified"; exit 0 +``` + +## Scenario 4 — Skill install & guided sweep (User Stories 2–3) + +```bash +# Install/refresh the skill into the project +maverick init +test -f .claude/skills/maverick-review/SKILL.md && echo installed +# EXPECT: installed; re-running init refreshes it idempotently + +# Uninstall removes it +maverick uninstall --dry-run # shows the skill among removals +``` + +Then, in Claude Code inside the project: + +1. Run `/maverick-review` (or ask "review my pending assumptions"). +2. EXPECT: entries presented one at a time, grouped by spec, severity + high→low; each question offers the adopted answer (Recommended), + alternatives, free-form via Other, and a route to waive/skip. +3. Decide a few entries; interrupt the session; re-invoke. + EXPECT: decided entries do not reappear (immediate application). +4. Complete the sweep. EXPECT: exactly one `maverick reconcile --json` + invocation (verify in the session transcript), a plain-language + frontier report, and a landing offer only if clear. +5. Confirm landing. EXPECT: `maverick land --yes --json` runs; the + classification banner is relayed; no jj/git commands appear anywhere + in the skill's transcript. + +## Scenario 5 — Regression: human modes unchanged + +```bash +# No --json: outputs and exit codes identical to pre-feature behavior +maverick review # interactive menu still works (FR-018) +maverick reconcile --dry-run # Rich table preview +maverick land --dry-run # full preview, deferred exit on block +maverick brief --format json # pre-existing payload untouched +``` + +## Automated validation + +```bash +make test # unit + integration, includes new test modules +make ci # pre-push gate: lint + typecheck + tests + format +``` + +Key automated coverage (see plan.md test layout): envelope/error-kind +mapping, stdout purity (no stray bytes around the document), listing +filters + canonical ordering, already-resolved guard, reconcile/land +envelope projections, gate-refusal with embedded report, init +install/refresh + uninstall removal, and human-mode snapshot regressions. diff --git a/specs/053-assumption-review-console/research.md b/specs/053-assumption-review-console/research.md new file mode 100644 index 00000000..53f5e639 --- /dev/null +++ b/specs/053-assumption-review-console/research.md @@ -0,0 +1,276 @@ +# Research: Assumption Review Console + +**Feature**: 053-assumption-review-console | **Date**: 2026-07-25 + +All decisions below resolve the unknowns in plan.md's Technical Context. No +NEEDS CLARIFICATION markers remain. + +## R1: JSON output mode mechanics (`--json` flag + stdout purity) + +**Decision**: Add a `--json` boolean flag to `maverick review`, `maverick +reconcile`, and `maverick land`. `maverick brief` keeps its existing +`--format json` (it already satisfies FR-003 for the briefing surface); no +change to its selector, but its payload gains nothing new in this feature. +When `--json` is set: + +- Exactly one JSON document is written to stdout, at process end, via a new + `emit_json(document)` helper in `maverick/cli/output.py` (serialises with + `json.dumps`, writes through a dedicated non-markup, non-wrapping Rich + Console on stdout — same transport idea as `brief`'s + `console.print_json`, but guaranteed single-document and unstyled). +- All Rich rendering that normally goes to stdout (tables, panels, progress + from `render_workflow_events`) is either suppressed or redirected to + stderr. Commands branch early on `json_mode` and skip their human + renderers; workflow progress events in JSON mode are rendered to + `err_console` (satisfies the clarified stream-discipline contract). +- Interactive affordances are disabled: any code path that would call + `click.prompt` / `click.confirm` / `console.input` in JSON mode instead + returns a structured `confirmation-required` or `validation` error. + +**Rationale**: The clarified spec requires stdout to carry only the +structured document. A per-command flag (rather than a global `--output` +option) matches how the four commands are lazily registered in `main.py` +(no group change needed) and matches the user's explicit request ("add a +--json output mode"). `cli/output.py` already owns formatting helpers and +exports an unused `format_json` — extending it is the DRY move. + +**Alternatives considered**: (a) Global `--json` on the `maverick` group — +rejected: group-level failures print before subcommand parsing, and only 4 +commands need it; (b) reusing `--format text|json` everywhere — rejected: +`brief` is the only precedent and duplicating a two-value enum on three +more commands adds surface without benefit; the spec names `--json`. + +## R2: Where each verb lives (mapping spec verbs → CLI surface) + +**Decision**: + +| Spec verb | CLI surface | New or existing | +|---|---|---| +| List queue (full provenance, filters) | `maverick review --list [--status ...] [--spec ...] [--severity ...] --json` | New `--list` mode on existing `review` | +| Answer entry | `maverick review --answer --json` | Existing + `--json` | +| Waive entry | `maverick review --waive --json` | Existing + `--json` | +| Bulk-waive by severity | `maverick review --spec --waive [--severity ...] --json` | Existing + `--json` | +| Reconcile status (detection preview) | `maverick reconcile --dry-run --json` | Existing + `--json` | +| Run reconcile | `maverick reconcile --json` | Existing + `--json` | +| Land status (frontier + report, no curation) | `maverick land --status --json` | New `--status` mode on existing `land` | +| Land | `maverick land --yes --json` | Existing + `--json` | + +**Rationale**: FR-003 forbids parallel commands where an existing command +covers the action. Listing has no existing surface; `review` owns the +entry lifecycle, so `--list` is a mode of `review`, not a new command. +Land status must not run curation (it's a read-only query the skill polls +after reconcile), so it's a `--status` mode of `land` that stops after the +gate evaluation + report build/persist — reusing `_check_assumption_gate` +and `_render_and_persist_land_report` exactly as the full command does. + +**Alternatives considered**: `maverick brief --assumptions` for listing — +rejected: brief is a bead-status surface; entry provenance and resolution +belong to review's domain and the skill treats list/answer/waive as one +lifecycle. A standalone `maverick assumptions` group — rejected as a +parallel command family (violates FR-003). + +## R3: Response envelope and stable error kinds + +**Decision**: Every JSON document shares one envelope (documented in +`contracts/error-envelope.md`): + +```json +{"schema_version": 1, "verb": "review.list", "ok": true, "result": { ... }} +{"schema_version": 1, "verb": "land.run", "ok": false, + "error": {"kind": "frontier-blocked", "message": "...", "details": { ... }}} +``` + +- `verb` is a stable dotted identifier (`review.list`, `review.answer`, + `review.waive`, `review.bulk-waive`, `reconcile.run`, + `reconcile.dry-run`, `land.status`, `land.run`). +- `ok: true` means the verb executed and produced its result; outcome + semantics (e.g., reconcile escalations) live in `result` and drive the + exit code per verb contract. `ok: false` means the verb refused or + failed; `error.kind` is a stable registry value. +- Error kinds registry (initial): `validation`, `not-found`, + `already-resolved`, `bd-unavailable`, `dirty-working-copy`, + `concurrent-run`, `locked`, `frontier-blocked`, `confirmation-required`, + `curation-failed`, `vcs`, `internal`. Additive evolution only. +- Implemented as a frozen dataclass `JsonEnvelope` + `ErrorKind` StrEnum in + a new `maverick/cli/json_output.py`, plus a `json_error_handler()` + context manager — the JSON-mode sibling of `cli_error_handler()` that + maps the `MaverickError` hierarchy (and `BeadClient` availability + failures, `JjClient` errors, lock/concurrency `WorkflowError`s) onto + error kinds, emits the error envelope on stdout, and exits non-zero. + +**Rationale**: The spec requires stable machine-distinguishable kinds and +success-or-structured-error on every invocation (FR-005). A single +envelope means the skill parses one shape. Mapping in one context manager +keeps land/review (which today hand-roll errors) from growing per-command +error plumbing — the constitutionally mandated single canonical wrapper. + +**Alternatives considered**: Per-verb bespoke top-levels (brief's current +style) — rejected: the skill would need N parsers and error output would +stay unstructured. HTTP-style numeric codes — rejected: string kinds are +self-documenting and match `LandVerification`-style StrEnum precedent. + +## R4: Entry serialization — one canonical row shape + +**Decision**: Extract `land_report._entry_to_dict` into a public +`entry_to_dict(entry: AssumptionReportEntry) -> dict` in +`maverick/assumptions/serialize.py` (re-exported from `land_report` for +backward compatibility), extended with `owner_spec`, `bucket`, and +`blocks_landing` fields needed by the flat listing. `review --list` and +the land report both use it, so the skill sees the same row shape in both +documents (the land report nests rows under spec sections; the listing is +flat with `owner_spec` inline). + +**Rationale**: FR-001's provenance fields are exactly what +`AssumptionReportEntry` + `_entry_to_dict` already carry (question, +adopted answer, alternatives, severity, owning spec, affected change ids, +status/bucket, waiver, reconcile state). Duplicating the projection would +guarantee drift between the listing and the land report (Principle VII). +The land-report schema contract is additive-only, so adding fields to the +row is legal; `owner_spec` inside the row is redundant with the section +key in land-report context but harmless and consistent. + +**Alternatives considered**: A new Pydantic model per row — rejected: +`AssumptionReportEntry` is already the typed contract; the dict projection +is a serializer, not a second model. + +## R5: Listing filters and defaults + +**Decision**: `review --list` supports `--status open|answered|waived` +(repeatable; default `open` — the sweep population), `--spec ` +(repeatable), `--severity low|medium|high` (repeatable). Backed by +`ledger.report_entries()` (one bd sweep) with client-side filtering; the +result document carries `entries` (sorted: owner_spec, severity high→low, +then stable ledger order — the clarified sweep order, so the skill can +present in document order without re-sorting) and `counts` per +status/severity. + +**Rationale**: Matches clarification Q1 (full ledger reachable, open is +the default selection) and Q3 (ordering is decided once, server-side, so +the skill and any future client agree). `report_entries` is the single +canonical reader; filters are cheap in-process. + +## R6: JSON-mode behavior of existing interactive paths in `review` + +**Decision**: In `--json` mode, `review ` requires exactly one of +`--answer`/`--waive` (ledger entries) or `--approve`/`--reject`/`--defer` +(legacy escalation beads); absence → `validation` error envelope. The +"not flagged for review — Review anyway?" confirm becomes a `validation` +error. Answering an entry whose status is no longer `open` returns +`already-resolved` with the entry's current state in `error.details` +(spec edge case: concurrent resolution) — implemented as a pre-check in +the command reading the entry's current status before calling +`ledger.answer`/`ledger.waive`. `waived_by` resolution (git user name) +is unchanged. + +**Rationale**: FR-004 (no prompts) plus the concurrent-sweep edge case. +`ledger.answer`/`waive` don't themselves guard against re-resolution, so +the CLI boundary adds the check — boundary validation per Principle IX. + +## R7: Reconcile verb in JSON mode + +**Decision**: `reconcile --json` runs the existing flow (preconditions → +`execute_python_workflow` with progress rendered to stderr → `load_run_state`) +and emits `result` = `ReconcileReport.to_dict()` (already the workflow's +`final_output`), augmented with the per-answer terminal detail from +`ReconcileRunState` when present. Exit codes preserve the existing +contract: 0 when nothing to reconcile or all outcomes `reconciled`; 1 +otherwise (with `ok: true` — the verb ran; the outcomes say what +happened). Precondition failures (dirty working copy, concurrent fly run, +held lock, bd unavailable) map to `dirty-working-copy` / +`concurrent-run` / `locked` / `bd-unavailable` error envelopes. +`reconcile --dry-run --json` emits the dry-run prediction report +(statuses `reconciled`/`skipped` only) and always exits 0, per the +existing 051 contract. Both are synchronous (clarification Q4) — no job +protocol. + +**Rationale**: `ReconcileReport.to_dict()` already exists and is +deterministic; wrapping it beats inventing a second report. Keeping exit +semantics identical between human and JSON modes means the skill and a +human reading the table can never disagree about what happened. + +## R8: Land verbs in JSON mode + +**Decision**: + +- `land --status --json`: evaluate gate (`_check_assumption_gate`) → + build + persist land report → emit `result` containing the + `LandReport.to_dict()` document plus `frontier_clear: bool` and + `blocking` summary (open vs pending-reconcile entry ids). **Always + exits 0 unless a real error occurs** — blocked is an answer, not a + failure, for a status query. Skips curation, consolidation, and the + human-review manifest display entirely. +- `land --json` (apply): the frontier gate refusing to land emits + `ok: false`, `kind: frontier-blocked`, with the full report dict in + `error.details.report`, exit 1 — same gate, no bypass (FR-007). + When the gate passes, curation proceeds; the agent-curation approval + prompt is a `confirmation-required` error unless `--yes` was given + (consent is gathered by the caller — the skill asks the human first, + per the spec's Assumptions). Success emits the report dict plus + curation summary (`mode`, `curation` outcome, `verification` + classification, mode-specific `hint`), exit 0. `--dry-run --json` + mirrors the human dry-run (runs the full preview, defers non-zero exit + to the end when blocked). + +**Rationale**: `LandReport.to_dict()` is already a versioned public +contract (052) with the terminal renderer deliberately derived from it — +the JSON verb is the third consumer of the same dict, so nothing can +drift. Distinguishing status (query, exit 0) from apply (action, exit 1 +on refusal) matches the spec's acceptance scenarios for both. + +## R9: Skill authoring, packaging, and installation + +**Decision**: The skill is a new packaged asset: + +- Source of truth: `src/maverick/skills/review_console/SKILL.md` + (Markdown with YAML frontmatter: `name: maverick-review`, + `description` with trigger text, `user-invocable: true`), included in + the wheel via a `pyproject.toml` include entry (same mechanism as + `agents/system_prompts/*.md`). +- Installed by `maverick init` into + `/.claude/skills/maverick-review/SKILL.md` as a new idempotent + init step (always overwrite — the file is Maverick-owned and versioned + with the package; a header comment says so). Non-fatal on failure + (advisory, like the Spec Kit offer). `maverick uninstall` removes it. +- The skill body instructs Claude Code to: run `maverick review --list + --json`; sweep entries in document order, one `AskUserQuestion` per + entry (adopted answer marked Recommended, alternatives as options, + free-form via the built-in Other, waive/skip reachable — when + alternatives exceed the option surface, a follow-up question presents + the remainder; nothing is silently dropped); apply each decision + immediately via `review --answer/--waive --json`; offer spec-level + bulk-waive when multiple open low-severity entries share a spec; after + the sweep run `maverick reconcile --json` once (skip when no answers + were given); report the frontier via `maverick land --status --json`; + offer landing and on explicit confirmation run `maverick land --yes + --json`; surface every `ok: false` envelope and every + `needs_interactive_review` outcome verbatim, never retrying. + +**Rationale**: Exploration confirmed Maverick ships no skills today and +`uninstall.py` documents that the old `~/.claude/skills/maverick-*` +mechanism is gone — project-local `.claude/skills/` is the current Claude +Code convention (this repo's own skills use it) and travels with the +repo. `maverick init` is the established idempotent provisioning surface. +Always-overwrite keeps the skill in lockstep with the CLI contract it +drives (the skill and the verbs version together in the wheel). + +**Alternatives considered**: user-level `~/.claude/skills` install — +rejected (explicitly retired by uninstall.py's history; per-project +versioning is safer when different projects run different maverick +versions). Publishing as a Claude Code plugin — rejected: out of scope, +no marketplace mechanism in this repo. + +## R10: Module layout to respect Principle XI (review.py growth) + +**Decision**: `cli/commands/review.py` is ~490 LOC; adding `--list`, +JSON envelopes, and the already-resolved guard would push it past the +soft limit. Split it into a package as part of this feature: +`cli/commands/review/{__init__.py (command + dispatch), listing.py, +entry_actions.py, legacy.py}` re-exporting `review` from `__init__` so +`main.py`'s lazy registration string keeps working. `land.py` (~680 LOC) +gains only the `--status` branch and JSON emission (+~80 LOC): extract +the status/report path into `cli/commands/land_status.py` helper module +consumed by the command rather than splitting the whole command in this +slice. + +**Rationale**: Constitution XI refactor trigger; backwards-compatible +package split is the constitution's preferred pattern. diff --git a/specs/053-assumption-review-console/spec.md b/specs/053-assumption-review-console/spec.md new file mode 100644 index 00000000..a33cbc0b --- /dev/null +++ b/specs/053-assumption-review-console/spec.md @@ -0,0 +1,358 @@ +# Feature Specification: Assumption Review Console + +**Feature Branch**: `053-assumption-review-console` +**Created**: 2026-07-25 +**Status**: Draft +**Input**: User description: "Provide a Claude Code skill that serves as the human review console for assumption sweeps. The skill reads the pending-assumption queue and presents each entry one at a time using AskUserQuestion — options are the adopted answer and the recorded alternatives, plus free-form input — then translates each decision into a headless CLI verb invocation. It never performs history surgery itself: all jj mechanics stay deterministic in Maverick, and the LLM only chooses which verb to call. The CLI side exposes thin plumbing verbs with machine-readable JSON output suitable for skill consumption: list the pending queue with full provenance (question, adopted answer, alternatives, severity, owning spec, affected change IDs, current status), answer an entry, waive an entry, bulk-waive a spec's entries by severity, report reconcile and land status, run reconcile, and land. Each verb is transactional and reports success or a structured error; none of them require a TTY or interactive confirmation. Where a verb already exists (maverick review, maverick reconcile, maverick land, maverick brief), add a --json output mode rather than a parallel command. The skill's flow is: read the queue, sweep it with the human, then — once the sweep is complete — trigger a single batched reconcile rather than one per answer, surface the resulting frontier state (verified, conditionally-verified, or still blocked), and offer to land when the frontier is clear. It reports reconcile failures and entries marked needing interactive review back to the human rather than retrying blindly. The existing structured maverick review command remains the bare-terminal fallback for humans without Claude Code; no further interactive review UX is built into the CLI." + +## Clarifications + +### Session 2026-07-25 + +- Q: Does the listing verb return only open entries or the full ledger? → A: All ledger entries with their current status, filterable by status / owning spec / severity; default selection is open entries (the sweep population). +- Q: What is the output-stream contract in machine-readable mode? → A: The structured document is the only content on standard output; all diagnostics, warnings, and progress go to standard error. +- Q: In what order does the sweep present entries? → A: Grouped by owning spec; within a spec, severity high→low; stable ledger order within severity. +- Q: Are the reconcile and land verbs synchronous or job-based? → A: Synchronous — the invocation blocks until completion and returns the final outcome; no background-job or polling protocol. +- Q: Does the skill use bulk-waive during a sweep? → A: Yes, as an optional shortcut — when multiple open low-severity entries share an owning spec, the skill may offer a spec-level bulk-waive mapped to the bulk-waive verb; per-entry presentation remains the default. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Headless review verbs with machine-readable output (Priority: P1) + +An automation client (the review-console skill, a script, or any agent) drives the +entire assumption-review lifecycle through headless command invocations: list the +pending queue with full provenance, answer an entry, waive an entry, bulk-waive a +spec's entries by severity, check reconcile and land status, run reconcile, and +land. Every invocation returns machine-readable structured output — on success and +on failure — and never prompts, requires a TTY, or asks for interactive +confirmation. + +**Why this priority**: Every other part of this feature is a consumer of these +verbs. Without a headless, machine-readable command surface there is no way for +the skill (or any other client) to act on the queue. Delivered alone, it is +already a viable product: scripts and CI can inspect and resolve assumptions +without a human at a terminal. + +**Independent Test**: From a repository with pending assumption entries, invoke +each verb in machine-readable mode from a non-interactive shell (no TTY). Verify +each returns parseable structured output containing the documented fields, that +answer/waive/reconcile/land actually change ledger and repository state, and that +failure cases (unknown entry id, blocked land, concurrent conflict) return a +structured error and a non-zero exit code instead of a prompt or a stack trace. + +**Acceptance Scenarios**: + +1. **Given** a repository with pending assumption entries, **When** the client + requests the queue in machine-readable mode, **Then** it receives one record + per open entry containing the entry id, question, adopted answer, recorded + alternatives, severity, owning spec, affected change identifiers, and current + status. +2. **Given** a pending entry, **When** the client invokes the answer verb with an + answer text, **Then** the entry is recorded as answered, the response reports + success with the entry's updated state, and no interactive confirmation is + requested. +3. **Given** a pending entry, **When** the client invokes the waive verb with a + reason, **Then** the entry is recorded as waived with that reason and the + response reports the updated state. +4. **Given** a spec with several open entries of mixed severity, **When** the + client invokes bulk-waive for that spec with a severity filter and a reason, + **Then** exactly the matching entries are waived in one invocation and the + response lists which entries were affected. +5. **Given** answered-but-unreconciled entries, **When** the client invokes the + reconcile verb in machine-readable mode, **Then** reconcile runs to completion + and the response reports a per-entry outcome (reconciled, skipped, escalated to + interactive review, or failed) plus an overall result. +6. **Given** any verb invocation that fails (nonexistent entry id, land blocked by + the frontier gate, reconcile blocked by a concurrent run), **When** the failure + occurs, **Then** the client receives a structured error with a stable, + machine-distinguishable error kind and a human-readable message, the process + exits non-zero, and repository/ledger state is left consistent (no partial + mutation without a reported outcome). +7. **Given** an existing command already covers a verb's action (review, + reconcile, land, brief), **When** machine-readable output is requested, **Then** + it is available as an output mode of that existing command — not as a new + parallel command. + +--- + +### User Story 2 - Guided sweep in the review console (Priority: P2) + +A developer working in Claude Code asks to review pending assumptions. The +review-console skill reads the pending queue and walks them through it one entry +at a time. For each entry it shows the question with its provenance (owning spec, +severity, affected changes) and offers a choice: confirm the adopted answer, +select one of the recorded alternatives, supply a free-form answer, or waive the +entry. Each decision is applied immediately by invoking the corresponding +headless verb. The skill itself never edits history, files, or ledger state +directly — it only chooses which verb to call. + +**Why this priority**: This is the headline experience — the human review console +that turns an assumption backlog into a fast guided sweep. It depends on the P1 +verbs but delivers the feature's core value: humans resolve assumptions without +memorizing entry ids or CLI syntax. + +**Independent Test**: With the P1 verbs available and a queue of pending entries, +invoke the skill. Verify every entry is presented exactly once with its adopted +answer and alternatives as selectable options plus a free-form path, that each +decision results in exactly one verb invocation, and that afterwards the ledger +reflects each decision. Verify by inspection that the skill performed no direct +repository or history mutation. + +**Acceptance Scenarios**: + +1. **Given** a queue of pending entries, **When** the developer starts the sweep, + **Then** the skill presents entries one at a time, each showing the question, + owning spec, severity, and affected changes, with the adopted answer and each + recorded alternative as selectable options plus a free-form input path. +2. **Given** an entry is presented, **When** the developer selects the adopted + answer, an alternative, or provides a free-form answer, **Then** the skill + records it via the answer verb and moves to the next entry. +3. **Given** an entry is presented, **When** the developer chooses to waive it + with a reason, **Then** the skill records the waive via the waive verb. +4. **Given** an entry is presented, **When** the developer chooses to skip it, + **Then** the entry is left open and untouched, and the sweep continues. +5. **Given** a sweep is interrupted partway through, **When** the developer later + restarts it, **Then** already-decided entries do not reappear (their decisions + were applied immediately) and the sweep resumes over the remaining open + entries. +6. **Given** the queue is empty, **When** the developer starts the sweep, **Then** + the skill reports there is nothing pending and shows the current frontier and + land status instead of presenting questions. + +--- + +### User Story 3 - Batched reconcile, frontier report, and landing offer (Priority: P3) + +When the sweep is complete, the skill triggers exactly one batched reconcile run +covering all answers given during the sweep — never one reconcile per answer. It +then reports the resulting frontier state — verified, conditionally verified, or +still blocked — in plain language. If the frontier is clear, it offers to land and +proceeds only on the developer's explicit confirmation. Reconcile failures and +entries escalated to interactive review are reported back to the developer with +the entry and reason; the skill never retries them blindly. + +**Why this priority**: Closes the loop from "answers recorded" to "history +corrected and work landed", which is the end state the sweep exists to reach. It +builds on P1 and P2 but is separable: without it, a developer can still finish a +sweep and run reconcile and land by hand. + +**Independent Test**: Complete a sweep with multiple answers, then verify exactly +one reconcile run occurred, that the skill's frontier report matches the status +verb's output, that landing is offered only when the frontier is clear and only +proceeds after explicit confirmation, and that a forced reconcile failure is +reported once with its reason rather than retried. + +**Acceptance Scenarios**: + +1. **Given** a completed sweep in which several entries were answered, **When** + the sweep ends, **Then** the skill triggers a single reconcile run covering all + of them, and no reconcile is triggered per individual answer. +2. **Given** reconcile completes, **When** the skill reports the outcome, **Then** + the developer sees the frontier state (verified, conditionally verified, or + still blocked) and, when blocked, the list of entries still standing in the way + with the suggested next step for each. +3. **Given** the frontier is clear, **When** the skill offers to land, **Then** + landing proceeds only after the developer explicitly confirms, and the landing + outcome (including its verified / conditionally-verified classification) is + reported back. +4. **Given** reconcile fails for an entry or marks it as needing interactive + review, **When** the skill reports results, **Then** that entry is surfaced to + the developer with its reason and is not silently retried. +5. **Given** the sweep ended with only waives and no changed answers, **When** + there is nothing to reconcile, **Then** the skill skips the reconcile run, + reports the frontier state directly, and still offers to land if clear. + +--- + +### Edge Cases + +- Queue changes underneath the sweep (another session resolves an entry + mid-sweep): if the entry was waived or closed, the verb invocation returns a + structured "already resolved" error carrying the entry's current state; the + skill reports it and continues with the remaining entries rather than + aborting. If the entry was concurrently *answered*, a new answer proceeds as + a re-answer — it supersedes the earlier answer and re-arms the entry for + reconciliation (consistent with the ledger's existing re-answer lifecycle). +- An entry has no recorded alternatives: the choice presented is the adopted + answer, free-form input, waive, or skip. +- An entry has more recorded alternatives than the presentation surface can offer + at once: the skill still makes every alternative reachable (e.g., across a + follow-up choice or via free-form), never silently dropping options. +- Reconcile is blocked because another workflow run is active: the verb reports a + structured error; the skill tells the developer to retry after the run finishes + rather than looping. +- Land is invoked headlessly while the frontier is non-empty: the command exits + non-zero with a structured report of the blocking entries — the same gate the + interactive command enforces, with no bypass introduced by the machine-readable + mode. +- Free-form answer is empty or whitespace-only: rejected before any verb is + invoked; the developer is re-prompted. +- A verb's structured output must remain parseable even when the underlying + operation partially succeeded (e.g., bulk-waive where one entry was already + resolved): the response enumerates per-entry outcomes. +- The developer declines the landing offer: nothing is landed; the sweep ends + with the frontier report and the state remains ready for a later manual land. + +## Requirements *(mandatory)* + +### Functional Requirements + +#### Headless verb surface + +- **FR-001**: The CLI MUST provide a headless way to list assumption-ledger + entries in machine-readable form, where each entry includes: entry identifier, + question, adopted answer, recorded alternatives, severity, owning spec, + affected change identifiers, and current status. The listing MUST be + filterable by status, owning spec, and severity; its default selection is the + open entries that constitute the sweep population. +- **FR-002**: The CLI MUST provide headless, machine-readable verbs to: answer an + entry, waive an entry with a reason, bulk-waive a spec's open entries filtered + by severity, report reconcile status, report land/frontier status, run + reconcile, and land. All verbs — including the long-running reconcile and land + — execute synchronously: the invocation blocks until completion and returns + the final outcome; no background-job or polling protocol is introduced. +- **FR-003**: Where an existing command already performs a verb's action + (reviewing, reconciling, landing, briefing/status), the machine-readable + behavior MUST be added as an output mode of that existing command; no parallel + duplicate commands may be introduced. +- **FR-004**: No verb in machine-readable mode may require a TTY, prompt for + input, or ask for interactive confirmation; all inputs MUST be expressible as + invocation arguments. +- **FR-005**: Every verb MUST report either success with the resulting state or a + structured error carrying a stable, machine-distinguishable error kind and a + human-readable message, and MUST exit non-zero on failure. Raw stack traces or + unstructured log noise MUST NOT be the failure contract. In machine-readable + mode the structured document MUST be the only content on standard output; + diagnostics, warnings, and progress MUST go to standard error. +- **FR-006**: Each verb MUST be transactional: on failure, ledger and repository + state are left consistent, and any partial effects (e.g., in a bulk operation) + are enumerated in the response rather than left silent. +- **FR-007**: Machine-readable land MUST enforce the same assumption-frontier + gate as the existing land command, with no bypass introduced by the output + mode; when blocked it MUST report the blocking entries in structured form. +- **FR-008**: The machine-readable output schema for each verb MUST be documented + and treated as a public interface for automation clients. + +#### Review-console skill + +- **FR-009**: A Claude Code skill MUST act as the human review console: it reads + the pending queue via the listing verb and presents each entry one at a time, + showing the question and its provenance (owning spec, severity, affected + changes). Entries are presented grouped by owning spec; within a spec, + severity high to low; within a severity, stable ledger order. +- **FR-010**: For each entry, the presented choices MUST include the adopted + answer, each recorded alternative, and a free-form input path; the developer + MUST also be able to waive the entry with a reason or skip it (leaving it + open). When multiple open low-severity entries share an owning spec, the skill + MAY additionally offer a spec-level bulk-waive shortcut that maps to the + bulk-waive verb; per-entry presentation remains the default. +- **FR-011**: The skill MUST translate every decision into exactly one + corresponding headless verb invocation, applied immediately when the decision + is made. The skill MUST NOT mutate repository history, files, or ledger state + through any other means — all history mechanics remain deterministic inside + Maverick. +- **FR-012**: The skill MUST tolerate mid-sweep interruption: because decisions + are applied immediately, restarting the sweep operates only on the entries + still open. +- **FR-013**: When the queue is empty at sweep start, the skill MUST say so and + present the current frontier and land status instead of questions. + +#### Post-sweep flow + +- **FR-014**: On sweep completion, the skill MUST trigger at most one reconcile + run covering all answers from the sweep; it MUST NOT trigger reconcile per + individual answer. If no answers require reconciliation, the reconcile run is + skipped. +- **FR-015**: After reconcile (or its skip), the skill MUST surface the frontier + state to the developer as one of: verified, conditionally verified, or still + blocked — including, when blocked, the blocking entries and the suggested next + step for each. +- **FR-016**: When the frontier is clear, the skill MUST offer to land and + proceed only on explicit developer confirmation, then report the landing + outcome and its classification. +- **FR-017**: Reconcile failures and entries marked as needing interactive review + MUST be reported back to the developer with the entry and reason; the skill + MUST NOT retry them automatically. + +#### Fallback boundary + +- **FR-018**: The existing interactive review command MUST remain available and + behaviorally unchanged as the bare-terminal fallback for humans without Claude + Code; this feature MUST NOT add any further interactive review UX to the CLI. + +### Key Entities + +- **Pending assumption entry**: A unit of the review queue. Attributes surfaced + to clients: identifier, question, adopted answer, recorded alternatives, + severity, owning spec, affected change identifiers, current status. +- **Decision**: The human's resolution of one entry during a sweep — confirm + adopted answer, choose an alternative, free-form answer, waive (with reason), + or skip. Every non-skip decision maps to exactly one verb invocation. +- **Verb result**: The structured outcome of a headless invocation — success with + resulting state, or a structured error (stable kind + message); bulk operations + enumerate per-entry outcomes. +- **Frontier report**: The post-reconcile summary of landability — verified, + conditionally verified, or still blocked, with blocking entries and suggested + next steps. +- **Sweep**: One guided pass over the open queue in the review console, ending in + at most one batched reconcile and an optional landing offer. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A developer can take a queue of 10 pending assumptions from unseen + to fully resolved (answered or waived) in a single guided sweep without leaving + their Claude Code session and without typing any entry identifier or command + syntax by hand. +- **SC-002**: 100% of the review-lifecycle verbs (list, answer, waive, + bulk-waive, reconcile status, land status, reconcile, land) complete from a + non-interactive environment with no TTY, and every invocation — success or + failure — yields parseable structured output. +- **SC-003**: A sweep with any number of answered entries results in exactly one + reconcile run (zero when nothing needs reconciliation), verifiable from run + records. +- **SC-004**: After a sweep that resolves every entry and a successful reconcile, + the developer reaches a completed landing with zero manual history-manipulation + commands issued by them or by the skill. +- **SC-005**: 100% of reconcile failures and interactive-review escalations that + occur during a sweep's reconcile are surfaced to the developer with the + affected entry and reason, and zero automatic retries occur. +- **SC-006**: A human without Claude Code can still complete the same + answer/waive lifecycle using the existing interactive command, unchanged from + its pre-feature behavior. + +## Assumptions + +- The pending queue for a sweep is the set of open (unanswered, unwaived) + assumption-ledger entries across specs in the current repository — the same + population the existing land gate counts as blocking, including open legacy + escalation entries. +- Skipping an entry during a sweep is allowed and leaves it open; a skipped entry + simply remains in the queue for a future sweep and continues to block landing. +- Decisions are applied immediately (per entry) rather than batched at sweep end; + only reconcile is batched. This is what makes interruption-safe sweeps and + concurrent-session tolerance possible. +- Bulk-waive defaults to the lowest severity when no severity filter is given, + matching the existing bulk-waive behavior; the machine-readable mode changes + output shape only, not semantics. +- Landing through the skill uses the standard landing flow in its default mode; + preview/finalize variants remain available through the CLI directly and are + out of scope for the skill's offer. +- The skill's landing offer constitutes the explicit human approval for that + landing; the headless land verb itself never asks for confirmation (FR-004), + so consent is gathered by the caller. +- Machine-readable error kinds are stable identifiers (safe to branch on), while + human-readable messages may change freely. + +## Out of Scope + +- Any new interactive (TTY) review experience in the CLI beyond what already + exists. +- Reviewing or editing assumption entries' provenance (question text, + alternatives, severity) — the console resolves entries; it does not author or + reclassify them. +- Automatic retry policies for failed reconciles or landings. +- Driving the sweep from any surface other than the Claude Code skill (e.g., web + UI, editor plugins); the headless verbs are the extension point for such future + clients. diff --git a/specs/053-assumption-review-console/tasks.md b/specs/053-assumption-review-console/tasks.md new file mode 100644 index 00000000..b24e5925 --- /dev/null +++ b/specs/053-assumption-review-console/tasks.md @@ -0,0 +1,183 @@ +# Tasks: Assumption Review Console + +**Input**: Design documents from `/specs/053-assumption-review-console/` +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/, quickstart.md + +**Tests**: Included — the project constitution (Principle V) mandates TDD. Test tasks are written first and must fail before their implementation tasks. + +**Organization**: Tasks are grouped by user story. US1 (headless JSON verbs) is the MVP; US2 (guided sweep skill) and US3 (post-sweep flow) build on it. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks) +- **[Story]**: US1 = headless verbs, US2 = guided sweep skill, US3 = batched reconcile/frontier/landing flow + +## Path Conventions + +Single project: `src/maverick/`, `tests/` at repository root (per plan.md). + +--- + +## Phase 1: Setup + +**Purpose**: No new project scaffolding is needed (existing package). The one structural preparation is the constitution-mandated split of the module every US1 review task touches. + +- [X] T001 Split `src/maverick/cli/commands/review.py` into a behavior-preserving package `src/maverick/cli/commands/review/` with `__init__.py` (command definition + dispatch, re-exporting `review` so `main.py`'s lazy registration string keeps working), `listing.py` (empty stub for now), `entry_actions.py` (answer/waive/bulk-waive flows moved verbatim), `legacy.py` (legacy escalation-bead flow moved verbatim). All existing review tests must pass unchanged; no option or output changes in this task. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: The two shared modules every JSON verb depends on — the canonical entry serializer and the envelope/error machinery. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete. + +- [X] T002 [P] Write failing unit tests for the canonical entry row projection in `tests/unit/assumptions/test_serialize.py`: field-for-field projection from a built `AssumptionReportEntry` (question, adopted_answer, alternatives, severity, severity_defaulted, owner_spec, status, bucket, blocks_landing, source_bead, is_legacy, final_answer, waiver, reconcile block, pending_reconcile, affected_change_ids, annotations), null-omission rules, and equality with the land-report row for the same entry. +- [X] T003 [P] Write failing unit tests for the JSON envelope machinery in `tests/unit/cli/test_json_output.py`: `JsonEnvelope.success`/`.failure` shape (result XOR error, absent branch omitted), `ErrorKind` registry values frozen per `contracts/error-envelope.md`, `emit_json` writes exactly one parseable document to stdout with nothing else (no markup, no wrapping), and `json_error_handler()` mapping table — `MaverickError` subclasses, bd-unavailable, dirty-working-copy, concurrent-run, locked, bare `Exception` → `internal` — each producing the right kind, stderr diagnostics, and non-zero exit. +- [X] T004 [P] Implement `src/maverick/assumptions/serialize.py` with public `entry_to_dict(entry: AssumptionReportEntry) -> dict[str, object]`, extracted from `land_report._entry_to_dict` and additively extended with `owner_spec`, `status`, `bucket`, `blocks_landing`; update `src/maverick/assumptions/land_report.py` to delegate to it (keep a `_entry_to_dict` alias for compatibility) and re-export from `src/maverick/assumptions/__init__.py`. +- [X] T005 [P] Implement `src/maverick/cli/json_output.py`: `ErrorKind` StrEnum (12 registry values), frozen dataclasses `JsonError` and `JsonEnvelope` (with `success()`/`failure()` constructors, `to_dict()`), `emit_json(envelope)` using a dedicated non-markup non-wrapping stdout Console (transport helper added to `src/maverick/cli/output.py`), and `json_error_handler(verb)` context manager mapping the exception hierarchy to error envelopes + `SystemExit(ExitCode.FAILURE)`, siblings of `cli_error_handler()` in style. + +**Checkpoint**: `make test-fast` green with T002/T003 passing — user story phases may begin. + +--- + +## Phase 3: User Story 1 - Headless review verbs with machine-readable output (Priority: P1) 🎯 MVP + +**Goal**: Every review-lifecycle verb (list, answer, waive, bulk-waive, reconcile status, run reconcile, land status, land) invocable headlessly with enveloped JSON on stdout, structured errors, correct exit codes, and untouched human-mode behavior. + +**Independent Test**: From a non-interactive shell against a repo with seeded ledger entries, invoke each verb with `--json` and verify parseable envelopes, real state changes, structured failures (unknown id, blocked land, dirty working copy), and byte-identical human-mode output without `--json` (quickstart Scenarios 1–3, 5). + +### Tests for User Story 1 (write first, must fail) ⚠️ + +- [X] T006 [P] [US1] Write failing tests for `review --list` in `tests/unit/cli/commands/test_review_listing.py`: default open-only selection, repeatable `--status`/`--spec`/`--severity` filters (OR within, AND across), canonical ordering (owner_spec asc → severity high→low → ledger order), counts over the filtered selection, empty-queue success, `bd-unavailable` envelope, mutual exclusion with `BEAD_ID` and decision flags, human-table mode renders without `--json`. +- [X] T007 [P] [US1] Write failing tests for review JSON actions in `tests/unit/cli/commands/test_review_json.py`: `review.answer` and `review.waive` success envelopes (post-write row, `action` field, reconcile status `pending` after answer), `validation` when no decision flag / empty answer text in JSON mode, `already-resolved` guard with current row in `error.details.entry` (waived target), re-answer of an `answered` entry allowed (051 FR-017), `not-found`, legacy bead `--approve/--reject/--defer` JSON results, `review.bulk-waive` envelope (waived rows, failed map, zero-match success, exit 1 when failed non-empty). +- [X] T008 [P] [US1] Write failing tests for reconcile JSON modes in `tests/unit/cli/commands/test_reconcile_json.py`: `reconcile.run` success envelope wrapping `ReconcileReport.to_dict()`, exit 0 on empty/all-reconciled and exit 1 with `ok: true` on escalated outcomes, precondition envelopes (`bd-unavailable`, `vcs`, `dirty-working-copy`, `concurrent-run`, `locked`), `reconcile.dry-run` always exit 0 with predicted statuses only, workflow progress routed to stderr (stdout is exactly one document). +- [X] T009 [P] [US1] Write failing tests for land JSON modes in `tests/unit/cli/commands/test_land_json.py`: `land.status` result (frontier_clear, verification incl. degraded-null, blocking id lists, embedded report + paths, exit 0 even when blocked, no curation invoked), `--status` flag mutual exclusions, `land.run` gate refusal (`frontier-blocked` with full report in details, exit 1), `confirmation-required` on agent-curation path without `--yes`, success document (landed, mode, verification, curation summary, hint), nothing-to-land success, dry-run deferred exit. + +### Implementation for User Story 1 + +- [X] T010 [US1] Implement `--list` mode in `src/maverick/cli/commands/review/listing.py` + option wiring in `review/__init__.py`: `report_entries()` sweep, in-process filtering, canonical sort, counts, `entry_to_dict` rows, `emit_json` envelope for `--json`, minimal human table via existing output helpers otherwise (satisfies T006). +- [X] T011 [US1] Implement JSON mode for single-entry actions in `src/maverick/cli/commands/review/entry_actions.py` and `review/legacy.py`: `--json` flag on the command, decision-flag requirement, empty-text rejection, already-resolved pre-check (read current status before `ledger.answer`/`ledger.waive`), post-write row in result, legacy-flow JSON results; all prompts unreachable in JSON mode (satisfies T007's single-entry cases). +- [X] T012 [US1] Implement `review.bulk-waive` JSON in `src/maverick/cli/commands/review/entry_actions.py`: envelope over `BulkWaiveResult` (waived rows via `entry_to_dict`, failed map, severities echoed), exit-code rule (satisfies remainder of T007). +- [X] T013 [US1] Implement `--json` on `src/maverick/cli/commands/reconcile.py` for run + dry-run: wrap precondition checks in `json_error_handler("reconcile.run"/"reconcile.dry-run")`, route `render_workflow_events` progress to `err_console` in JSON mode, emit `ReconcileReport.to_dict()`-based result, preserve existing exit semantics (satisfies T008). +- [X] T014 [US1] Implement `land --status` via new helper `src/maverick/cli/commands/land_status.py` (gate evaluation → `build_report`/`persist_report` → status result document) and dispatch + mutual-exclusion wiring in `src/maverick/cli/commands/land.py` (satisfies T009 status cases). +- [X] T015 [US1] Implement `--json` on the `land` apply path in `src/maverick/cli/commands/land.py`: `frontier-blocked` refusal envelope with embedded report, `confirmation-required` guard replacing `console.input` when `--yes` absent, success/nothing-to-land/dry-run documents, all narration to stderr in JSON mode (satisfies remainder of T009). +- [X] T016 [US1] Write end-to-end scenario test `tests/integration/cli/test_json_verbs_scenario.py`: seeded ledger → `review --list --json` → answer one + waive one + bulk-waive a spec → `reconcile --dry-run --json` → `land --status --json`, asserting envelope chain, state transitions, and stdout purity at every step (quickstart Scenarios 1–3 automated). + +**Checkpoint**: US1 fully functional — any automation client can drive the whole lifecycle headlessly. MVP deliverable. + +--- + +## Phase 4: User Story 2 - Guided sweep in the review console (Priority: P2) + +**Goal**: The packaged `maverick-review` Claude Code skill exists, ships in the wheel, installs into user projects via `maverick init` (removed by `maverick uninstall`), and instructs a compliant sweep: one entry at a time in document order, adopted answer + alternatives + free-form + waive/skip, every decision applied immediately via exactly one verb. + +**Independent Test**: `maverick init` in a project installs `.claude/skills/maverick-review/SKILL.md`; invoking `/maverick-review` in Claude Code walks a seeded queue per `contracts/skill-review-console.md` steps 1–8, with ledger state reflecting each decision and no direct jj/bd/file mutation by the skill (quickstart Scenario 4, steps 1–3). + +### Tests for User Story 2 (write first, must fail) ⚠️ + +- [X] T017 [P] [US2] Write failing tests in `tests/unit/init/test_skill_install.py`: `maverick init` installs the packaged asset to `/.claude/skills/maverick-review/SKILL.md`, re-running init overwrites a locally modified copy (Maverick-owned refresh), install failure is non-fatal/advisory, `maverick uninstall` removes the file and empty parent dirs, and the packaged source `src/maverick/skills/review_console/SKILL.md` has valid frontmatter (`name: maverick-review`, non-empty `description`, `user-invocable: true`). + +### Implementation for User Story 2 + +- [X] T018 [P] [US2] Author `src/maverick/skills/review_console/SKILL.md` — identity, preflight, and sweep sections per `contracts/skill-review-console.md` (steps 1–8): `review --list --json` preflight with bd-unavailable stop, empty-queue branch, document-order presentation with spec-group announcements, per-entry AskUserQuestion layout (adopted answer first + "(Recommended)", alternatives, overflow follow-up so nothing is dropped, waive/skip reachable, Other = free-form), immediate verb invocation per decision, empty free-form re-prompt, already-resolved continue-not-abort, bulk-waive shortcut offer, prohibitions section. +- [X] T019 [US2] Add wheel packaging for the skill asset in `pyproject.toml` (include `src/maverick/skills/**/*.md`, same mechanism as `agents/system_prompts/*.md`) and verify the file lands in the built wheel. +- [X] T020 [US2] Implement the init install step in `src/maverick/init/__init__.py`: read the packaged asset (`importlib.resources`), write idempotently to `/.claude/skills/maverick-review/SKILL.md` with always-overwrite + Maverick-owned header, non-fatal on failure, reported in init's summary output (satisfies T017 install cases). +- [X] T021 [US2] Implement skill removal in `src/maverick/cli/commands/uninstall.py` (including `--dry-run` listing), satisfying T017 removal cases. + +**Checkpoint**: US1 + US2 — a human in Claude Code can complete a full guided sweep against a real queue. + +--- + +## Phase 5: User Story 3 - Batched reconcile, frontier report, and landing offer (Priority: P3) + +**Goal**: The skill closes the loop after the sweep: exactly one batched reconcile (skipped when no answers), plain-language frontier state, landing offered only when clear and executed only on explicit confirmation, all failures and interactive-review escalations surfaced verbatim with zero retries. + +**Independent Test**: Complete a sweep with several answers; verify from the session transcript exactly one `maverick reconcile --json` invocation, a frontier report matching `land --status --json` output, landing gated on explicit confirmation via `land --yes --json`, and a forced failure (e.g. dirty working copy) reported once without retry (quickstart Scenario 4, steps 4–5). + +### Implementation for User Story 3 + +- [X] T022 [US3] Extend `src/maverick/skills/review_console/SKILL.md` with the post-sweep sections per `contracts/skill-review-console.md` (steps 9–15): reconcile-once rule with zero-answers skip, outcome reporting (`needs_interactive_review`/`skipped` with reason + escalation bead id), no-retry rule with remedy suggestions for `dirty-working-copy`/`concurrent-run`/`locked`, `land --status --json` frontier report wording (verified / conditionally verified / still blocked with per-entry next steps), explicit-confirmation landing via `land --yes --json`, decline path, and the end-of-session summary. +- [X] T023 [US3] Manual validation pass of the full skill flow per quickstart Scenario 4 in a seeded sample project (sweep → single reconcile → frontier report → landing offer → land), recording the transcript evidence for SC-003/SC-004/SC-005 in the PR description; fix any SKILL.md instruction ambiguities surfaced. + +**Checkpoint**: All three user stories independently functional — full console loop from queue to landed history. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +- [X] T024 [P] Update `CLAUDE.md` (CLI Workflows table + a short "JSON verbs" note under the review/reconcile/land sections) and the 053 spec's `quickstart.md` if flags drifted during implementation. +- [X] T025 [P] Additively document the new entry-row keys (`owner_spec`, `status`, `bucket`, `blocks_landing`) in `specs/052-conditional-landing/contracts/land-report-schema.md` (additive evolution note, schema_version unchanged), and correct that contract's `reconcile.status` value list — it names `"skipped"`, which the ledger never persists (actual values: `reconciled | needs-interactive-review | pending`). +- [X] T026 Human-mode regression sweep: run the full existing CLI test suite and manually diff `review`/`reconcile --dry-run`/`land --dry-run`/`brief --format json` outputs against `main` to confirm FR-018 (no `--json` ⇒ byte-identical behavior); fix any drift found. +- [X] T027 Run `make format-fix && make ci` and execute `quickstart.md` Scenarios 1–3 + 5 verbatim in a scratch repo; fix anything red. + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: none — start immediately. +- **Foundational (Phase 2)**: independent of Phase 1 (different files) but both block Phase 3. T004/T005 require their tests T002/T003 first. +- **US1 (Phase 3)**: requires Phases 1 + 2. Internally: T006–T009 (tests) before their implementation tasks; T010→T012 share `review/` package files with T011 (T010 ∥ T011 touch different modules; T012 follows T011 — same file); T013, T014+T015 independent of the review tasks. +- **US2 (Phase 4)**: requires US1 (the skill invokes the verbs). T017 before T020/T021; T018/T019 parallel to T017. +- **US3 (Phase 5)**: requires US1 (reconcile/land verbs) and US2 (the SKILL.md artifact it extends). +- **Polish (Phase 6)**: after desired stories complete. + +### User Story Dependencies + +- **US1 (P1)**: only Foundational. Independently testable (headless verbs). +- **US2 (P2)**: consumes US1's verbs; independently testable given US1. +- **US3 (P3)**: extends US2's artifact, exercises US1's reconcile/land verbs. + +### Parallel Opportunities + +- Phase 2: T002 ∥ T003, then T004 ∥ T005 (and T001 ∥ all of Phase 2). +- US1 tests: T006 ∥ T007 ∥ T008 ∥ T009. +- US1 impl: {T010, T011} ∥ T013 ∥ {T014→T015}; T012 after T011; T016 last. +- US2: T017 ∥ T018; T019 ∥ T020 after T018. +- Polish: T024 ∥ T025. + +--- + +## Parallel Example: User Story 1 + +```bash +# After Phase 2, launch all US1 test authoring together: +Task: "T006 failing tests for review --list in tests/unit/cli/commands/test_review_listing.py" +Task: "T007 failing tests for review JSON actions in tests/unit/cli/commands/test_review_json.py" +Task: "T008 failing tests for reconcile JSON in tests/unit/cli/commands/test_reconcile_json.py" +Task: "T009 failing tests for land JSON in tests/unit/cli/commands/test_land_json.py" + +# Then implementation streams in parallel: +Task: "T010 review --list in src/maverick/cli/commands/review/listing.py" +Task: "T013 reconcile --json in src/maverick/cli/commands/reconcile.py" +Task: "T014 land --status helper in src/maverick/cli/commands/land_status.py" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Phase 1 (T001) + Phase 2 (T002–T005). +2. Phase 3 complete (T006–T016). +3. **STOP and VALIDATE**: quickstart Scenarios 1–3 + 5; `make ci`. +4. Ship — scripts/CI/any agent can already drive the review lifecycle headlessly. + +### Incremental Delivery + +1. MVP (above). +2. Add US2 (T017–T021) → `maverick init` ships the console → validate Scenario 4 steps 1–3. +3. Add US3 (T022–T023) → full sweep-to-landing loop → validate Scenario 4 steps 4–5. +4. Polish (T024–T027) → `make ci` green, docs current. + +--- + +## Notes + +- Constitution Principle V: every test task precedes and must fail before its implementation task. +- Human-mode output is a compatibility surface (FR-018) — treat any diff without `--json` as a bug. +- Envelope keys, verb ids, and error kinds are frozen contracts once merged (additive only). +- Commit after each task or logical group (`bead(...)`-style subjects if driven via beads). diff --git a/src/maverick/assumptions/__init__.py b/src/maverick/assumptions/__init__.py index 28eddf42..5f73fd9b 100644 --- a/src/maverick/assumptions/__init__.py +++ b/src/maverick/assumptions/__init__.py @@ -39,6 +39,7 @@ coerce_severity, nnn_prefix, ) +from maverick.assumptions.serialize import entry_to_dict __all__ = [ "ASSUMPTION_LABEL", @@ -70,6 +71,7 @@ "Severity", "StampResult", "coerce_severity", + "entry_to_dict", "nnn_prefix", "report_entries", ] diff --git a/src/maverick/assumptions/land_report.py b/src/maverick/assumptions/land_report.py index 52919014..34024343 100644 --- a/src/maverick/assumptions/land_report.py +++ b/src/maverick/assumptions/land_report.py @@ -17,11 +17,11 @@ from typing import Any from maverick.assumptions.models import ( - RECONCILE_STATUS_NEEDS_REVIEW, AssumptionReportEntry, LandFrontier, LandVerification, ) +from maverick.assumptions.serialize import _annotations, entry_to_dict from maverick.utils.atomic import atomic_write_json, atomic_write_text __all__ = [ @@ -78,54 +78,17 @@ def _bucket_counts(entries: Sequence[AssumptionReportEntry]) -> dict[str, int]: return counts -def _annotations(entry: AssumptionReportEntry) -> tuple[str, ...]: - """Denormalized, human-facing tags — every one derivable from other fields. - - ``reconcile_status`` only ever persists as the single - ``RECONCILE_STATUS_NEEDS_REVIEW`` value for both the "skipped" (no - mutation attempted) and "needs_interactive_review" (rolled back) - flavours described in data-model.md §2 — the ledger has no - discriminating field for the two, so both surface identically here. - """ - tags: list[str] = [] - if entry.record.is_legacy: - tags.append("legacy") - if entry.reconcile_status == RECONCILE_STATUS_NEEDS_REVIEW: - tags.append(f"reconcile: {entry.reconcile_status}") - if entry.pending_reconcile: - tags.append("pending reconcile") - return tuple(tags) - - def _entry_to_dict(entry: AssumptionReportEntry) -> dict[str, Any]: - record = entry.record - waiver = ( - {"by": entry.waived_by, "at": entry.waived_at, "reason": entry.waive_reason} - if entry.bucket == "waived" - else None - ) - return { - "bead_id": record.bead_id, - "bucket": entry.bucket, - "question": record.question, - "adopted_answer": record.adopted_answer, - "final_answer": entry.final_answer, - "alternatives": list(record.alternatives), - "severity": record.severity.value, - "severity_defaulted": record.severity_defaulted, - "is_legacy": record.is_legacy, - "source_bead": record.source_bead, - "affected_change_ids": list(entry.affected_change_ids), - "waiver": waiver, - "reconcile": { - "status": entry.reconcile_status, - "reconciled_answer": entry.reconciled_answer, - "change_id": entry.reconcile_change_id, - "reason": entry.reconcile_reason, - }, - "pending_reconcile": entry.pending_reconcile, - "annotations": list(_annotations(entry)), - } + """Backward-compatible alias for :func:`serialize.entry_to_dict`. + + The land report row and the canonical projection used by + ``review --list`` (053-assumption-review-console, research R4) are the + same shape — this module no longer maintains its own copy. Kept under + this name (rather than importing ``entry_to_dict`` directly at call + sites) since ``SpecReportSection.to_dict()``/``_render_entry_line``/ + tests already reference ``_entry_to_dict``. + """ + return entry_to_dict(entry) @dataclass(frozen=True, slots=True) diff --git a/src/maverick/assumptions/ledger.py b/src/maverick/assumptions/ledger.py index 70008a48..012e6c89 100644 --- a/src/maverick/assumptions/ledger.py +++ b/src/maverick/assumptions/ledger.py @@ -76,6 +76,7 @@ "record_assumption", "record_standalone_assumption", "report_entries", + "report_entry_from_details", "stamp_change_id", "waive", ] @@ -1053,6 +1054,53 @@ def is_answered_unreconciled(details: object) -> bool: return normalized_human_answer != normalize_answer(state.get(KEY_RECONCILED_ANSWER, "")) +def report_entry_from_details(details: object) -> AssumptionReportEntry | None: + """Build one :class:`AssumptionReportEntry` from an already-fetched bead. + + Extracted out of :func:`report_entries`'s per-candidate loop body + (053-assumption-review-console) so both the repo-wide sweep and + ``maverick review``'s single-entry reads (already-resolved pre-check, + post-write projection) build the identical row shape from one bead's + details — no second copy of the label/state-key wiring. + + Returns ``None`` for a bead :func:`report_entries` would also skip: one + carrying neither ledger label, or a closed legacy escalation bead (see + that function's docstring for why closed legacy beads are dropped + rather than misreported as open). + """ + labels = getattr(details, "labels", None) or [] + state: dict[str, str] = dict(getattr(details, "state", None) or {}) + + if ASSUMPTION_LABEL in labels: + return AssumptionReportEntry( + record=_record_from_details(details), + final_answer=state.get(KEY_ANSWER), + waived_by=state.get(KEY_WAIVED_BY), + waived_at=state.get(KEY_WAIVED_AT), + waive_reason=state.get(KEY_WAIVE_REASON), + reconcile_status=state.get(KEY_RECONCILE_STATUS), + reconciled_answer=state.get(KEY_RECONCILED_ANSWER), + reconcile_change_id=state.get(KEY_RECONCILE_CHANGE_ID), + reconcile_reason=state.get(KEY_RECONCILE_REASON), + pending_reconcile=is_answered_unreconciled(details), + ) + is_open_legacy = getattr(details, "status", None) not in _CLOSED_STATUSES + if ASSUMPTION_REVIEW_LABEL in labels and is_open_legacy: + return AssumptionReportEntry( + record=_legacy_record_from_details(details), + final_answer=None, + waived_by=None, + waived_at=None, + waive_reason=None, + reconcile_status=None, + reconciled_answer=None, + reconcile_change_id=None, + reconcile_reason=None, + pending_reconcile=False, + ) + return None + + async def report_entries(client: BeadClient) -> tuple[AssumptionReportEntry, ...]: """Repo-wide, all-status materialization of every ledger entry. @@ -1094,39 +1142,9 @@ async def report_entries(client: BeadClient) -> tuple[AssumptionReportEntry, ... except BeadError as exc: raise AssumptionLedgerError(f"Failed to load bead {candidate.id}: {exc}") from exc - labels = details.labels or [] - state = details.state or {} - - if ASSUMPTION_LABEL in labels: - entries.append( - AssumptionReportEntry( - record=_record_from_details(details), - final_answer=state.get(KEY_ANSWER), - waived_by=state.get(KEY_WAIVED_BY), - waived_at=state.get(KEY_WAIVED_AT), - waive_reason=state.get(KEY_WAIVE_REASON), - reconcile_status=state.get(KEY_RECONCILE_STATUS), - reconciled_answer=state.get(KEY_RECONCILED_ANSWER), - reconcile_change_id=state.get(KEY_RECONCILE_CHANGE_ID), - reconcile_reason=state.get(KEY_RECONCILE_REASON), - pending_reconcile=is_answered_unreconciled(details), - ) - ) - elif ASSUMPTION_REVIEW_LABEL in labels and details.status not in _CLOSED_STATUSES: - entries.append( - AssumptionReportEntry( - record=_legacy_record_from_details(details), - final_answer=None, - waived_by=None, - waived_at=None, - waive_reason=None, - reconcile_status=None, - reconciled_answer=None, - reconcile_change_id=None, - reconcile_reason=None, - pending_reconcile=False, - ) - ) + entry = report_entry_from_details(details) + if entry is not None: + entries.append(entry) return tuple(entries) diff --git a/src/maverick/assumptions/serialize.py b/src/maverick/assumptions/serialize.py new file mode 100644 index 00000000..576b0a4f --- /dev/null +++ b/src/maverick/assumptions/serialize.py @@ -0,0 +1,76 @@ +"""Canonical row projection for one :class:`AssumptionReportEntry`. + +Extracted from ``land_report._entry_to_dict`` (research R4, +specs/053-assumption-review-console/data-model.md "entry_to_dict") so both +``maverick land``'s provenance report and the new `review --list` listing +share one projection instead of maintaining two copies. ``land_report``'s +``_entry_to_dict`` is now a thin backward-compatible alias for +:func:`entry_to_dict`. +""" + +from __future__ import annotations + +from maverick.assumptions.models import ( + RECONCILE_STATUS_NEEDS_REVIEW, + AssumptionReportEntry, +) + +__all__ = ["entry_to_dict"] + + +def _annotations(entry: AssumptionReportEntry) -> tuple[str, ...]: + """Denormalized, human-facing tags — every one derivable from other fields. + + ``reconcile_status`` only ever persists as the single + ``RECONCILE_STATUS_NEEDS_REVIEW`` value for both the "skipped" (no + mutation attempted) and "needs_interactive_review" (rolled back) + flavours described in data-model.md §2 — the ledger has no + discriminating field for the two, so both surface identically here. + """ + tags: list[str] = [] + if entry.record.is_legacy: + tags.append("legacy") + if entry.reconcile_status == RECONCILE_STATUS_NEEDS_REVIEW: + tags.append(f"reconcile: {entry.reconcile_status}") + if entry.pending_reconcile: + tags.append("pending reconcile") + return tuple(tags) + + +def entry_to_dict(entry: AssumptionReportEntry) -> dict[str, object]: + """Project *entry* into the JSON-serializable row shared by the land + report and ``review --list`` (data-model.md "entry_to_dict"). + + ``waiver`` is ``None`` unless *entry* is in the waived bucket. + """ + record = entry.record + waiver = ( + {"by": entry.waived_by, "at": entry.waived_at, "reason": entry.waive_reason} + if entry.bucket == "waived" + else None + ) + return { + "bead_id": record.bead_id, + "owner_spec": record.owner_spec, + "status": record.status, + "bucket": entry.bucket, + "blocks_landing": entry.blocks_landing, + "question": record.question, + "adopted_answer": record.adopted_answer, + "final_answer": entry.final_answer, + "alternatives": list(record.alternatives), + "severity": record.severity.value, + "severity_defaulted": record.severity_defaulted, + "is_legacy": record.is_legacy, + "source_bead": record.source_bead, + "affected_change_ids": list(entry.affected_change_ids), + "waiver": waiver, + "reconcile": { + "status": entry.reconcile_status, + "reconciled_answer": entry.reconciled_answer, + "change_id": entry.reconcile_change_id, + "reason": entry.reconcile_reason, + }, + "pending_reconcile": entry.pending_reconcile, + "annotations": list(_annotations(entry)), + } diff --git a/src/maverick/cli/commands/init.py b/src/maverick/cli/commands/init.py index 4c041dd6..d5b02e2f 100644 --- a/src/maverick/cli/commands/init.py +++ b/src/maverick/cli/commands/init.py @@ -178,6 +178,27 @@ def _format_speckit_output(result: InitResult) -> list[str]: return [] +def _format_skill_install_output(result: InitResult) -> list[str]: + """Format the maverick-review skill install notice (053). + + Always-on, always-overwrite step — silent when not applicable + (``None``), otherwise a single success/warning line. + """ + if result.skill_installed is True: + return [ + "[green]✓[/] Installed the maverick-review skill " + "(.claude/skills/maverick-review/SKILL.md).", + "", + ] + if result.skill_installed is False: + return [ + "[yellow]Warning:[/yellow] Failed to install the maverick-review skill " + "— `/maverick-review` will be unavailable until it's installed manually.", + "", + ] + return [] + + def _format_config_output( result: InitResult, verbose: bool = False, @@ -329,6 +350,7 @@ async def init( lines.extend(_format_provider_output(result.provider_discovery)) lines.extend(_format_git_output(result, verbose)) lines.extend(_format_speckit_output(result)) + lines.extend(_format_skill_install_output(result)) lines.extend(_format_config_output(result, verbose)) for line in lines: diff --git a/src/maverick/cli/commands/land.py b/src/maverick/cli/commands/land.py index 5b02207b..d05a6c8a 100644 --- a/src/maverick/cli/commands/land.py +++ b/src/maverick/cli/commands/land.py @@ -30,22 +30,25 @@ from typing import TYPE_CHECKING, Any import click -from rich.markup import escape from rich.panel import Panel from rich.table import Table +from maverick.cli.commands.land_gate import ( + build_report, + check_assumption_gate, + display_verification, + persist_report_json, + render_and_persist_land_report, +) +from maverick.cli.commands.land_status import run_status from maverick.cli.console import console, err_console from maverick.cli.context import ExitCode, async_command +from maverick.cli.json_output import ErrorKind, JsonEnvelope, emit_json from maverick.cli.output import format_error, format_success, format_warning from maverick.logging import get_logger if TYPE_CHECKING: from maverick.assumptions.land_report import LandReport - from maverick.assumptions.models import ( - AssumptionReportEntry, - LandFrontier, - LandVerification, - ) logger = get_logger(__name__) @@ -105,6 +108,19 @@ default=None, help="Branch label suggested in the next-step hint.", ) +@click.option( + "--status", + is_flag=True, + default=False, + help="Read-only frontier/landability check — no curation, no mutation.", +) +@click.option( + "--json", + "json_output", + is_flag=True, + default=False, + help="Emit machine-readable JSON on stdout instead of Rich console output.", +) @click.pass_context @async_command async def land( @@ -118,6 +134,8 @@ async def land( finalize: bool, no_consolidate: bool, branch: str | None, + status: bool, + json_output: bool, ) -> None: """Curate commit history written by ``maverick fly``. @@ -131,7 +149,21 @@ async def land( maverick land --eject maverick land --finalize maverick land --yes + maverick land --status --json """ + if status: + _validate_status_flags( + dry_run=dry_run, + yes=yes, + eject=eject, + finalize=finalize, + no_curate=no_curate, + heuristic_only=heuristic_only, + json_mode=json_output, + ) + await run_status(base=base, cwd=Path.cwd().resolve(), json_mode=json_output) + return + from maverick.library.actions.jj import ( curate_history, gather_curation_context, @@ -140,26 +172,69 @@ async def land( cwd = Path.cwd().resolve() project_name = cwd.name run_id = uuid.uuid4().hex[:8] + mode = "eject" if eject else "finalize" if finalize else "approve" + # Narration console: stdout is reserved for the single JSON document in + # `--json` mode, so every progress line routes to stderr there. Bound + # once — a later `console.print` added without this routing would + # silently corrupt the one-document guarantee. + out = err_console if json_output else console # ── 1. Check there are commits to land ────────────────────────── curation_ctx = await gather_curation_context(base, cwd=cwd) if not curation_ctx["success"]: - err_console.print( - format_error( - f"Failed to gather commit context: {curation_ctx['error']}", + if json_output: + emit_json( + JsonEnvelope.failure( + "land.run", + ErrorKind.VCS, + f"Failed to gather commit context: {curation_ctx['error']}", + ) + ) + else: + err_console.print( + format_error( + f"Failed to gather commit context: {curation_ctx['error']}", + ) ) - ) raise SystemExit(ExitCode.FAILURE) commits = curation_ctx["commits"] if not commits: - console.print("Nothing to land — no commits found above base revision.") + if json_output: + # Same key set as every other `land.run` success document — + # a consumer reading `result["verification"]` or + # `result["report"]` must not KeyError just because there was + # nothing above base. `report` is null here (and only here): + # the gate is never evaluated on this path, since there is no + # landing to gate. + emit_json( + JsonEnvelope.success( + "land.run", + { + "landed": False, + "reason": "nothing-to-land", + "mode": mode, + "verification": None, + "degraded": False, + "curation": _curation_summary("none"), + "report": None, + "report_paths": {}, + "hint": None, + }, + ) + ) + else: + console.print("Nothing to land — no commits found above base revision.") return - console.print(f"Found {len(commits)} commit(s) above [bold]{base}[/bold].") + if json_output: + out.print(f"Found {len(commits)} commit(s) above {base}.") + else: + out.print(f"Found {len(commits)} commit(s) above [bold]{base}[/bold].") # ── 1b. Display human review manifest if present ───────────── - _display_human_review_manifest(cwd) + if not json_output: + _display_human_review_manifest(cwd) # ── 1c. Assumption ledger gate + provenance report. Blocks unless # every entry (any severity, incl. legacy) has been answered or @@ -169,55 +244,148 @@ async def land( # report (contracts/cli-land.md); `--dry-run` still evaluates and # renders, but only exits non-zero at the end (after the rest of the # preview runs) rather than short-circuiting immediately. - gate_blocks, gate_entries, verification = await _check_assumption_gate(cwd) - report_md_path = _render_and_persist_land_report( - gate_entries, verification, run_id=run_id, dry_run=dry_run, cwd=cwd - ) + gate_blocks, gate_entries, verification = await check_assumption_gate(cwd, quiet=json_output) + degraded = verification is None + report: LandReport | None = None + report_paths: dict[str, str | None] = {} + if json_output: + report = build_report(gate_entries, verification, run_id=run_id, dry_run=dry_run) + report_paths, _degraded_persistence = persist_report_json(report, cwd=cwd) + else: + report_md_path = render_and_persist_land_report( + gate_entries, verification, run_id=run_id, dry_run=dry_run, cwd=cwd + ) if gate_blocks and not dry_run: + if json_output: + assert report is not None # built above whenever json_output is True + emit_json(_frontier_blocked_envelope(report)) raise SystemExit(ExitCode.FAILURE) # ── 2. Curation ──────────────────────────────────────────────── + curation: dict[str, object] = _curation_summary("none") + if no_curate: - console.print("Skipping curation (--no-curate).") + out.print("Skipping curation (--no-curate).") elif heuristic_only and dry_run: # `curate_history` rewrites history (jj absorb + squash). A # dry run is a preview on every other curation path, so it must # be one here too — the agent path already stops at the plan. - console.print("Dry run — heuristic curation not applied.") + curation = _curation_summary("heuristic") + out.print("Dry run — heuristic curation not applied.") elif heuristic_only: - console.print("Running heuristic curation...") result = await curate_history(base, cwd=cwd) if result["success"]: - absorb = "yes" if result["absorb_ran"] else "no" squashed = result["squashed_count"] - console.print(f"Heuristic curation: absorb={absorb}, squashed={squashed} commits.") + absorb_ran = bool(result["absorb_ran"]) + # `absorb_ran` is a distinct signal from `squashed_count`: + # absorb alone rewrites history with zero squashes, which + # would otherwise be indistinguishable from "nothing done". + curation = _curation_summary( + "heuristic", + executed_count=squashed, + total_count=squashed, + absorb_ran=absorb_ran, + squashed_count=squashed, + ) + out.print( + f"Heuristic curation: absorb={'yes' if absorb_ran else 'no'}, " + f"squashed={squashed} commits." + ) else: - err_console.print( - format_error( - f"Heuristic curation failed: {result['error']}", + curation = _curation_summary("heuristic") + if json_output: + emit_json( + JsonEnvelope.failure( + "land.run", + ErrorKind.CURATION_FAILED, + f"Heuristic curation failed: {result['error']}", + ) + ) + else: + err_console.print( + format_error( + f"Heuristic curation failed: {result['error']}", + ) ) - ) raise SystemExit(ExitCode.FAILURE) else: - await _agent_curate( + agent_executed, agent_total = await _agent_curate( curation_ctx=curation_ctx, base=base, dry_run=dry_run, auto_approve=yes, cwd=cwd, + json_mode=json_output, + ) + curation = _curation_summary( + "agent", executed_count=agent_executed, total_count=agent_total ) if dry_run: - console.print("Dry run — skipping next-step hint.") + out.print("Dry run — skipping next-step hint.") + if json_output: + assert report is not None + if gate_blocks: + emit_json(_frontier_blocked_envelope(report)) + raise SystemExit(ExitCode.FAILURE) + emit_json( + JsonEnvelope.success( + "land.run", + { + "landed": False, + "mode": "dry-run", + "verification": (verification.value if verification is not None else None), + "degraded": degraded, + "curation": curation, + "report": report.to_dict(), + "report_paths": report_paths, + "hint": None, + }, + ) + ) + return if gate_blocks: raise SystemExit(ExitCode.FAILURE) return # ── 3. Runway consolidation (best-effort) ───────────────────── - await _maybe_consolidate(cwd, no_consolidate) + await _maybe_consolidate(cwd, no_consolidate, json_mode=json_output) # ── 4. Mode-specific next-step hint ─────────────────────────── - _display_verification(verification, gate_entries) + if json_output: + assert report is not None + if eject: + preview = branch or f"maverick/preview/{project_name}" + hint = f"Eject hint: push to a preview branch with `git push origin HEAD:{preview}`." + elif finalize: + target = branch or f"maverick/{project_name}" + md_path = report_paths.get("md") + body_file = f" --body-file {md_path}" if md_path else "" + hint = ( + f"Finalize hint: push to {target} and open a PR with " + f"`gh pr create --base {base}{body_file}`." + ) + else: + hint = "Next: push the curated branch to your remote and open a PR." + + emit_json( + JsonEnvelope.success( + "land.run", + { + "landed": True, + "mode": mode, + "verification": (verification.value if verification is not None else None), + "degraded": degraded, + "curation": curation, + "report": report.to_dict(), + "report_paths": report_paths, + "hint": hint, + }, + ) + ) + return + + display_verification(verification, gate_entries) console.print(format_success(f"Curated {len(commits)} commit(s) on the current branch.")) if eject: preview = branch or f"maverick/preview/{project_name}" @@ -242,6 +410,79 @@ async def land( console.print("Next: push the curated branch to your remote and open a PR.") +def _validate_status_flags( + *, + dry_run: bool, + yes: bool, + eject: bool, + finalize: bool, + no_curate: bool, + heuristic_only: bool, + json_mode: bool, +) -> None: + """Enforce ``--status``'s mutual exclusion with every apply-path flag. + + ``--base``/``--branch`` are deliberately excluded — the contract accepts + (and ignores) them in status mode since they always carry a default. + """ + conflicting = { + "--dry-run": dry_run, + "--yes": yes, + "--eject": eject, + "--finalize": finalize, + "--no-curate": no_curate, + "--heuristic-only": heuristic_only, + } + conflicts = [name for name, is_set in conflicting.items() if is_set] + if not conflicts: + return + + message = f"--status cannot be combined with {', '.join(conflicts)}." + if json_mode: + emit_json(JsonEnvelope.failure("land.status", ErrorKind.VALIDATION, message)) + else: + err_console.print(format_error(message)) + raise SystemExit(ExitCode.FAILURE) + + +def _curation_summary( + strategy: str, + *, + executed_count: int = 0, + total_count: int = 0, + **extra: object, +) -> dict[str, object]: + """Build the ``curation`` object carried by every ``land.run`` document. + + ``executed_count``/``total_count`` are the operation counts (agent + path) or squash counts (heuristic path). Strategy-specific signals go + in ``extra`` — the heuristic path adds ``absorb_ran``/``squashed_count`` + so an absorb-only rewrite isn't reported as a no-op. + """ + summary: dict[str, object] = { + "strategy": strategy, + "executed_count": executed_count, + "total_count": total_count, + } + summary.update(extra) + return summary + + +def _frontier_blocked_envelope(report: LandReport) -> JsonEnvelope: + """The shared ``frontier-blocked`` failure envelope for ``land.run``. + + Used both for the immediate refusal (non-dry-run) and the deferred + refusal at the end of a ``--dry-run`` preview (052 semantics: the + preview still runs, only the exit is delayed). + """ + return JsonEnvelope.failure( + "land.run", + ErrorKind.FRONTIER_BLOCKED, + "Assumption frontier is not clear — resolve or waive every open entry before landing.", + details={"report": report.to_dict()}, + ) + + # ===================================================================== # Runway consolidation # ===================================================================== @@ -250,6 +491,8 @@ async def land( async def _maybe_consolidate( cwd: Path, no_consolidate: bool, + *, + json_mode: bool = False, ) -> None: """Best-effort runway consolidation. @@ -257,10 +500,15 @@ async def _maybe_consolidate( and survives across runs without any sync step. Consolidation is the only operation worth running here — it prunes stale episodic records and updates the semantic summary. + + ``json_mode`` routes progress/warning narration to stderr instead of + stdout — a JSON invocation's stdout must stay exactly one document. """ if no_consolidate: return + out = err_console if json_mode else console + try: from maverick.config import load_config @@ -270,7 +518,7 @@ async def _maybe_consolidate( from maverick.library.actions.consolidation import consolidate_runway - console.print("Consolidating runway knowledge store...") + out.print("Consolidating runway knowledge store...") result = await consolidate_runway( cwd=cwd, max_age_days=config.runway.consolidation.max_episodic_age_days, @@ -283,12 +531,12 @@ async def _maybe_consolidate( msg = f" Pruned {result.records_pruned} old records." if result.summary_updated: msg += " Updated consolidated-insights.md." - console.print(msg) + out.print(msg) else: - console.print(format_warning(f"Runway consolidation failed: {result.error}")) + out.print(format_warning(f"Runway consolidation failed: {result.error}")) except Exception as exc: # Best-effort — never block landing - console.print(format_warning(f"Runway consolidation failed: {exc}")) + out.print(format_warning(f"Runway consolidation failed: {exc}")) logger.debug("runway_consolidation_error", error=str(exc)) @@ -303,11 +551,23 @@ async def _agent_curate( dry_run: bool, auto_approve: bool, cwd: Path, -) -> None: - """Run agent-driven curation with interactive approval.""" + *, + json_mode: bool = False, +) -> tuple[int, int]: + """Run agent-driven curation with interactive approval. + + Returns ``(executed_count, total_count)`` — ``(0, 0)`` when the curator + found nothing to do, ``(0, len(plan))`` for an unexecuted dry-run + preview. In JSON mode (``json_mode=True``) progress narration goes to + stderr, the plan table is never rendered to stdout, and reaching the + interactive approval prompt instead emits a ``confirmation-required`` + envelope and exits — *before* ``execute_curation_plan`` runs (consent is + the caller's job, never an interactive prompt in headless mode). + """ from maverick.library.actions.jj import execute_curation_plan - console.print("Analyzing commits with curator agent...") + out = err_console if json_mode else console + out.print("Analyzing commits with curator agent...") try: from maverick.agents.personas import CuratorAgent @@ -341,55 +601,91 @@ async def _agent_curate( except SystemExit: raise except Exception as e: - err_console.print( - format_error( - f"Curator agent failed: {e}", - suggestion="Try --heuristic-only as a fallback.", + if json_mode: + emit_json( + JsonEnvelope.failure( + "land.run", + ErrorKind.CURATION_FAILED, + f"Curator agent failed: {e}", + ) + ) + else: + err_console.print( + format_error( + f"Curator agent failed: {e}", + suggestion="Try --heuristic-only as a fallback.", + ) ) - ) raise SystemExit(ExitCode.FAILURE) from e if not plan: - console.print("Curator: no curation needed — history looks clean.") - return + out.print("Curator: no curation needed — history looks clean.") + return (0, 0) - # Display plan - _display_plan(plan) + # Display plan (Rich table — stdout only; JSON mode relies on the + # final curation summary object instead). + if not json_mode: + _display_plan(plan) if dry_run: - console.print("Dry run — plan not applied.") + out.print("Dry run — plan not applied.") # Do NOT raise SystemExit here — the caller (`land()`) decides the # final exit code from the assumption gate (`gate_blocks`), which # this branch must not pre-empt (T012 fix; analysis I1). - return + return (0, len(plan)) # Approval gate if not auto_approve: + if json_mode: + emit_json( + JsonEnvelope.failure( + "land.run", + ErrorKind.CONFIRMATION_REQUIRED, + "Agent curation plan is ready but requires confirmation.", + details={"hint": "pass --yes"}, + ) + ) + raise SystemExit(ExitCode.FAILURE) answer = console.input("\nApply this plan? [y/N] ") if not answer.strip().lower().startswith("y"): console.print("Curation cancelled.") raise SystemExit(ExitCode.SUCCESS) # Execute - console.print("Applying curation plan...") + out.print("Applying curation plan...") result = await execute_curation_plan(plan, cwd=cwd) if result["success"]: - console.print( + out.print( f"Curation complete: " f"{result['executed_count']}/{result['total_count']} " f"operations applied." ) + return (result["executed_count"], result["total_count"]) else: - err_console.print( - format_error( - f"Curation failed: {result['error']}", - details=[ - f"Executed {result['executed_count']}/{result['total_count']} steps.", - f"Snapshot ID: {result['snapshot_id']} (for manual recovery).", - ], - suggestion=("Repository was rolled back to pre-curation state."), + if json_mode: + emit_json( + JsonEnvelope.failure( + "land.run", + ErrorKind.CURATION_FAILED, + f"Curation failed: {result['error']}", + details={ + "executed_count": result["executed_count"], + "total_count": result["total_count"], + "snapshot_id": result["snapshot_id"], + }, + ) + ) + else: + err_console.print( + format_error( + f"Curation failed: {result['error']}", + details=[ + f"Executed {result['executed_count']}/{result['total_count']} steps.", + f"Snapshot ID: {result['snapshot_id']} (for manual recovery).", + ], + suggestion=("Repository was rolled back to pre-curation state."), + ) ) - ) raise SystemExit(ExitCode.FAILURE) @@ -416,206 +712,6 @@ def _display_plan(plan: list[dict[str, Any]]) -> None: console.print(panel) -# ===================================================================== -# Assumption ledger gate -# ===================================================================== - - -async def _check_assumption_gate( - cwd: Path, -) -> tuple[bool, tuple[AssumptionReportEntry, ...], LandVerification | None]: - """Evaluate the strict, repo-wide assumption frontier gate. - - Returns ``(blocks, entries, verification)``. The gate blocks on any - open entry (any severity, incl. legacy) or any answered entry pending - reconciliation (051's predicate) — strict, no bypass flag - (Clarifications 2026-07-24). ``entries`` is the full repo-wide - materialization (empty when degraded); ``verification`` is ``None`` - when bd is unavailable or the ledger query failed — the gate degrades - open (never blocks) but must not report a false "verified" - classification. Prints the blocking table (grouped by owning spec, - per-row action hints) as a side effect when the gate blocks. - """ - from maverick.assumptions.land_report import classify, frontier - from maverick.assumptions.ledger import report_entries - from maverick.beads.client import BeadClient - - client = BeadClient(cwd=cwd) - if not await client.verify_available(): - return False, (), None - - try: - entries = await report_entries(client) - except Exception as exc: # noqa: BLE001 — non-fatal; gate passes on query failure - console.print(format_warning(f"Assumption gate check failed: {exc}")) - return False, (), None - - land_frontier = frontier(entries) - verification = classify(entries) - - if not land_frontier.is_empty: - _display_assumption_gate_table(land_frontier) - return True, entries, verification - - return False, entries, verification - - -def _display_assumption_gate_table(land_frontier: LandFrontier) -> None: - """Render blocking entries (open + pending-reconcile) grouped by spec. - - Open entries and pending-reconciliation entries get distinct - resolution hints (research R4 — one detection predicate, two - actions) printed below the table rather than a per-row column, to - keep the table readable at typical terminal widths. - """ - entries = tuple(land_frontier.open_entries) + tuple(land_frontier.pending_reconcile_entries) - - table = Table(show_header=True, header_style="bold red") - table.add_column("ID", width=20) - table.add_column("Severity", width=10) - table.add_column("Spec", width=25) - table.add_column("Question") - - for entry in sorted(entries, key=lambda e: (e.record.owner_spec, e.record.bead_id)): - question = entry.record.question - question = question[:80] + "..." if len(question) > 80 else question - # Agent-authored free text: `escape` it, or Rich silently eats any - # `[...]` run as a style tag. - table.add_row( - entry.record.bead_id, - entry.record.severity.value, - escape(entry.record.owner_spec), - escape(question), - ) - - console.print() - panel = Panel( - table, - title=f"Blocking Assumptions ({len(entries)})", - border_style="red", - ) - console.print(panel) - console.print() - if land_frontier.open_entries: - console.print("Resolve open entries with: [bold]maverick review [/bold]") - if land_frontier.pending_reconcile_entries: - console.print("Resolve pending reconciliation with: [bold]maverick reconcile[/bold]") - console.print() - - -def _display_verification( - verification: LandVerification | None, - entries: tuple[AssumptionReportEntry, ...], -) -> None: - """Print the land classification line (contracts/cli-land.md "Output" §2). - - No-op when *verification* is ``None`` (bd unavailable / query failed — - the degraded gate must never report a false classification). - """ - from maverick.assumptions.models import LandVerification - - if verification is LandVerification.CONDITIONALLY_VERIFIED: - waived_count = sum(1 for e in entries if e.bucket == "waived") - console.print( - f"[yellow]✓ Conditionally verified on unresolved assumptions " - f"({waived_count} waived)[/yellow]" - ) - elif verification is LandVerification.VERIFIED: - console.print("[green]✓ Verified[/green]") - - -# ===================================================================== -# Assumption land report (US2 — provenance + persistence) -# ===================================================================== - - -def _render_and_persist_land_report( - entries: tuple[AssumptionReportEntry, ...], - verification: LandVerification | None, - *, - run_id: str, - dry_run: bool, - cwd: Path, -) -> Path | None: - """Build, render (terminal), and persist the land provenance report. - - Runs for every evaluation (blocked, dry-run, successful) — the report - is the audit trail of what land saw, even for a refused attempt - (contracts/cli-land.md). Persistence failure degrades to a warning - and never affects the gate's exit code. - - Returns: - The persisted markdown artifact's path, or ``None`` when - persistence failed (so callers don't advertise a missing file). - """ - from maverick.assumptions.land_report import build_report, persist_report - - degraded = verification is None - report = build_report(entries, verification, run_id=run_id, dry_run=dry_run, degraded=degraded) - - if degraded: - console.print(format_warning("Assumption ledger unavailable — report may be incomplete.")) - - _render_land_report_terminal(report) - - try: - json_path, md_path = persist_report(report, cwd=cwd) - except OSError as exc: - console.print(format_warning(f"Failed to persist land report: {exc}")) - return None - console.print(f"Report: {json_path}") - console.print() - return md_path - - -def _render_land_report_terminal(report: LandReport) -> None: - """Render the grouped provenance report to the terminal. - - Walks ``report.to_dict()`` (not raw entries) so the terminal view can - never drift from the persisted JSON — one source of truth. - """ - data = report.to_dict() - if not data["specs"]: - console.print("No assumptions adopted.") - return - - bucket_style = {"resolved": "green", "waived": "yellow", "open": "red"} - bucket_heading = {"resolved": "Resolved", "waived": "Waived", "open": "Open"} - - for spec in data["specs"]: - console.print(f"[bold]{escape(spec['owner_spec'] or '(unattributed)')}[/bold]") - by_bucket: dict[str, list[dict[str, Any]]] = {"resolved": [], "waived": [], "open": []} - for entry in spec["entries"]: - by_bucket[entry["bucket"]].append(entry) - - for bucket_key in ("resolved", "waived", "open"): - bucket_entries = by_bucket[bucket_key] - if not bucket_entries: - continue - style = bucket_style[bucket_key] - console.print( - f" [{style}]{bucket_heading[bucket_key]}[/{style}] ({len(bucket_entries)})" - ) - for entry in bucket_entries: - # Every free-text field below is agent- or human-authored, - # so it goes through `escape`: Rich parses `[...]` as a - # style tag and silently drops it otherwise. - console.print(f" {entry['bead_id']} {escape(entry['question'])}") - if entry["waiver"]: - waiver = entry["waiver"] - console.print( - f" waived by {escape(str(waiver['by']))} at " - f"{escape(str(waiver['at']))}: {escape(str(waiver['reason']))}" - ) - if entry["affected_change_ids"]: - console.print(f" changes: {', '.join(entry['affected_change_ids'])}") - if entry["annotations"]: - # Literally bracketed — `escape` is what keeps the whole - # line from rendering as blank whitespace. - console.print(f" {escape('[' + ', '.join(entry['annotations']) + ']')}") - console.print() - - def _display_human_review_manifest(cwd: Path) -> None: """Display human review manifest if one exists from the fly phase.""" import json as _json diff --git a/src/maverick/cli/commands/land_gate.py b/src/maverick/cli/commands/land_gate.py new file mode 100644 index 00000000..e174d2dd --- /dev/null +++ b/src/maverick/cli/commands/land_gate.py @@ -0,0 +1,289 @@ +"""Assumption-frontier gate + provenance report — shared by ``land`` verbs. + +``maverick land`` (the apply path, ``land.py``) and ``maverick land +--status`` (the read-only query, ``land_status.py``) evaluate the exact +same gate and render/persist the exact same report; only what they do +*afterwards* differs. That shared half lives here so neither command +imports the other's private surface — an earlier revision had +``land_status`` reaching into five underscore-prefixed ``land`` helpers +while ``land`` lazily imported ``land_status`` back to dodge the resulting +cycle. + +Gate semantics (052-conditional-landing, strict / no bypass flag): any +open entry of any severity — including low, including legacy escalation +beads — or any answered entry pending reconciliation blocks landing. +The gate degrades *open* (never blocks) when bd is unavailable or the +ledger query fails, but reports ``verification=None`` so callers never +advertise a false "verified" classification. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from rich.markup import escape +from rich.panel import Panel +from rich.table import Table + +from maverick.cli.console import console, err_console +from maverick.cli.output import format_warning + +if TYPE_CHECKING: + from pathlib import Path + + from maverick.assumptions.land_report import LandReport + from maverick.assumptions.models import ( + AssumptionReportEntry, + LandFrontier, + LandVerification, + ) + +__all__ = [ + "build_report", + "check_assumption_gate", + "display_verification", + "persist_report_json", + "render_and_persist_land_report", + "render_land_report_terminal", +] + + +async def check_assumption_gate( + cwd: Path, + *, + quiet: bool = False, +) -> tuple[bool, tuple[AssumptionReportEntry, ...], LandVerification | None]: + """Evaluate the strict, repo-wide assumption frontier gate. + + Returns ``(blocks, entries, verification)``. The gate blocks on any + open entry (any severity, incl. legacy) or any answered entry pending + reconciliation (051's predicate) — strict, no bypass flag + (Clarifications 2026-07-24). ``entries`` is the full repo-wide + materialization (empty when degraded); ``verification`` is ``None`` + when bd is unavailable or the ledger query failed — the gate degrades + open (never blocks) but must not report a false "verified" + classification. Prints the blocking table (grouped by owning spec, + per-row action hints) as a side effect when the gate blocks, unless + ``quiet`` is set (JSON modes, and any caller that renders the full + provenance report itself — the blocking rows appear there too, so + printing both duplicates every row). + """ + from maverick.assumptions.land_report import classify, frontier + from maverick.assumptions.ledger import report_entries + from maverick.beads.client import BeadClient + + client = BeadClient(cwd=cwd) + if not await client.verify_available(): + return False, (), None + + try: + entries = await report_entries(client) + except Exception as exc: # noqa: BLE001 — non-fatal; gate passes on query failure + out = err_console if quiet else console + out.print(format_warning(f"Assumption gate check failed: {exc}")) + return False, (), None + + land_frontier = frontier(entries) + verification = classify(entries) + + if not land_frontier.is_empty: + if not quiet: + _display_assumption_gate_table(land_frontier) + return True, entries, verification + + return False, entries, verification + + +def _display_assumption_gate_table(land_frontier: LandFrontier) -> None: + """Render blocking entries (open + pending-reconcile) grouped by spec. + + Open entries and pending-reconciliation entries get distinct + resolution hints (research R4 — one detection predicate, two + actions) printed below the table rather than a per-row column, to + keep the table readable at typical terminal widths. + """ + entries = tuple(land_frontier.open_entries) + tuple(land_frontier.pending_reconcile_entries) + + table = Table(show_header=True, header_style="bold red") + table.add_column("ID", width=20) + table.add_column("Severity", width=10) + table.add_column("Spec", width=25) + table.add_column("Question") + + for entry in sorted(entries, key=lambda e: (e.record.owner_spec, e.record.bead_id)): + question = entry.record.question + question = question[:80] + "..." if len(question) > 80 else question + # Agent-authored free text: `escape` it, or Rich silently eats any + # `[...]` run as a style tag. + table.add_row( + entry.record.bead_id, + entry.record.severity.value, + escape(entry.record.owner_spec), + escape(question), + ) + + console.print() + panel = Panel( + table, + title=f"Blocking Assumptions ({len(entries)})", + border_style="red", + ) + console.print(panel) + console.print() + if land_frontier.open_entries: + console.print("Resolve open entries with: [bold]maverick review [/bold]") + if land_frontier.pending_reconcile_entries: + console.print("Resolve pending reconciliation with: [bold]maverick reconcile[/bold]") + console.print() + + +def display_verification( + verification: LandVerification | None, + entries: tuple[AssumptionReportEntry, ...], +) -> None: + """Print the land classification line (contracts/cli-land.md "Output" §2). + + No-op when *verification* is ``None`` (bd unavailable / query failed — + the degraded gate must never report a false classification). + """ + from maverick.assumptions.models import LandVerification + + if verification is LandVerification.CONDITIONALLY_VERIFIED: + waived_count = sum(1 for e in entries if e.bucket == "waived") + console.print( + f"[yellow]✓ Conditionally verified on unresolved assumptions " + f"({waived_count} waived)[/yellow]" + ) + elif verification is LandVerification.VERIFIED: + console.print("[green]✓ Verified[/green]") + + +# ===================================================================== +# Assumption land report (US2 — provenance + persistence) +# ===================================================================== + + +def render_and_persist_land_report( + entries: tuple[AssumptionReportEntry, ...], + verification: LandVerification | None, + *, + run_id: str, + dry_run: bool, + cwd: Path, +) -> Path | None: + """Build, render (terminal), and persist the land provenance report. + + Runs for every evaluation (blocked, dry-run, successful) — the report + is the audit trail of what land saw, even for a refused attempt + (contracts/cli-land.md). Persistence failure degrades to a warning + and never affects the gate's exit code. + + Returns: + The persisted markdown artifact's path, or ``None`` when + persistence failed (so callers don't advertise a missing file). + """ + from maverick.assumptions.land_report import persist_report + + report = build_report(entries, verification, run_id=run_id, dry_run=dry_run) + + if report.degraded: + console.print(format_warning("Assumption ledger unavailable — report may be incomplete.")) + + render_land_report_terminal(report) + + try: + json_path, md_path = persist_report(report, cwd=cwd) + except OSError as exc: + console.print(format_warning(f"Failed to persist land report: {exc}")) + return None + console.print(f"Report: {json_path}") + console.print() + return md_path + + +def build_report( + entries: tuple[AssumptionReportEntry, ...], + verification: LandVerification | None, + *, + run_id: str, + dry_run: bool, +) -> LandReport: + """Build the land provenance report with no terminal rendering (JSON paths). + + Counterpart to :func:`render_and_persist_land_report` for ``--json`` + callers (``land_status.run_status`` and ``land``'s own JSON apply + path) — same ``build_report`` call, no Rich output. + """ + from maverick.assumptions.land_report import build_report as _build + + degraded = verification is None + return _build(entries, verification, run_id=run_id, dry_run=dry_run, degraded=degraded) + + +def persist_report_json( + report: LandReport, + *, + cwd: Path, +) -> tuple[dict[str, str | None], bool]: + """Persist *report*, routing any failure warning to stderr (JSON paths). + + Returns ``(report_paths, degraded_persistence)`` — ``report_paths`` + values are ``None`` when persistence failed (never a gate failure — + the caller still lands/reports, just without the artifact paths). + """ + from maverick.assumptions.land_report import persist_report + + try: + json_path, md_path = persist_report(report, cwd=cwd) + except OSError as exc: + err_console.print(format_warning(f"Failed to persist land report: {exc}")) + return {"json": None, "md": None}, True + return {"json": str(json_path), "md": str(md_path)}, False + + +def render_land_report_terminal(report: LandReport) -> None: + """Render the grouped provenance report to the terminal. + + Walks ``report.to_dict()`` (not raw entries) so the terminal view can + never drift from the persisted JSON — one source of truth. + """ + data = report.to_dict() + if not data["specs"]: + console.print("No assumptions adopted.") + return + + bucket_style = {"resolved": "green", "waived": "yellow", "open": "red"} + bucket_heading = {"resolved": "Resolved", "waived": "Waived", "open": "Open"} + + for spec in data["specs"]: + console.print(f"[bold]{escape(spec['owner_spec'] or '(unattributed)')}[/bold]") + by_bucket: dict[str, list[dict[str, Any]]] = {"resolved": [], "waived": [], "open": []} + for entry in spec["entries"]: + by_bucket[entry["bucket"]].append(entry) + + for bucket_key in ("resolved", "waived", "open"): + bucket_entries = by_bucket[bucket_key] + if not bucket_entries: + continue + style = bucket_style[bucket_key] + console.print( + f" [{style}]{bucket_heading[bucket_key]}[/{style}] ({len(bucket_entries)})" + ) + for entry in bucket_entries: + # Every free-text field below is agent- or human-authored, + # so it goes through `escape`: Rich parses `[...]` as a + # style tag and silently drops it otherwise. + console.print(f" {entry['bead_id']} {escape(entry['question'])}") + if entry["waiver"]: + waiver = entry["waiver"] + console.print( + f" waived by {escape(str(waiver['by']))} at " + f"{escape(str(waiver['at']))}: {escape(str(waiver['reason']))}" + ) + if entry["affected_change_ids"]: + console.print(f" changes: {', '.join(entry['affected_change_ids'])}") + if entry["annotations"]: + # Literally bracketed — `escape` is what keeps the whole + # line from rendering as blank whitespace. + console.print(f" {escape('[' + ', '.join(entry['annotations']) + ']')}") + console.print() diff --git a/src/maverick/cli/commands/land_status.py b/src/maverick/cli/commands/land_status.py new file mode 100644 index 00000000..1b30a731 --- /dev/null +++ b/src/maverick/cli/commands/land_status.py @@ -0,0 +1,105 @@ +"""``maverick land --status`` — read-only frontier/landability query. + +The read-only counterpart to the ``land`` apply path (053-assumption- +review-console, contracts/cli-land-json.md "land --status"): evaluates the +assumption frontier gate, builds and persists the same provenance report +``land`` would, and stops — no curation, no consolidation, no manifest +display, no history mutation. A blocked frontier is an *answer* for a +status query, not a failure, so this always exits 0 unless a real error +(``internal``/``vcs``) occurs. + +Shares its gate-evaluation/report-building/persisting logic with +``maverick.cli.commands.land`` via the common +``maverick.cli.commands.land_gate`` module rather than duplicating it (or +reaching into the other command's private surface) — this module only +assembles the status-specific result document and renders/emits it. +""" + +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +from maverick.cli.commands.land_gate import ( + build_report, + check_assumption_gate, + display_verification, + persist_report_json, + render_land_report_terminal, +) +from maverick.cli.console import console +from maverick.cli.json_output import JsonEnvelope, emit_json, json_error_handler +from maverick.cli.output import format_warning + +if TYPE_CHECKING: + from pathlib import Path + +__all__ = ["run_status"] + +_VERB = "land.status" + + +async def run_status(*, base: str, cwd: Path, json_mode: bool) -> None: + """Evaluate the assumption frontier gate read-only and report status. + + ``base`` is accepted for CLI symmetry with the apply path but ignored: + the gate evaluates the whole repo-wide ledger, not a base-revision + scoped commit range (contracts/cli-land-json.md). + """ + del base + + from maverick.assumptions.land_report import frontier + + run_id = uuid.uuid4().hex[:8] + + # `quiet=True` on both paths: this function renders the full grouped + # provenance report itself (or emits it inside the envelope), which + # already lists every blocking row. Letting the gate print its own + # blocking panel too would duplicate all of them. + if json_mode: + with json_error_handler(_VERB): + _blocks, entries, verification = await check_assumption_gate(cwd, quiet=True) + report = build_report(entries, verification, run_id=run_id, dry_run=False) + report_paths, degraded_persistence = persist_report_json(report, cwd=cwd) + land_frontier = frontier(entries) + + result: dict[str, object] = { + # `degraded` means the ledger could not be read at all (bd + # unavailable / query failure), so `frontier_clear: true` + # below reflects an *empty* evaluation, not a verified one. + # Consumers must treat `frontier_clear && !degraded` as the + # landable condition — this is the top-level signal for + # that, distinct from `degraded_persistence` (which is only + # about writing the artifact). + "degraded": report.degraded, + "frontier_clear": land_frontier.is_empty, + "verification": (verification.value if verification is not None else None), + "blocking": { + "open": [e.record.bead_id for e in land_frontier.open_entries], + "pending_reconcile": [ + e.record.bead_id for e in land_frontier.pending_reconcile_entries + ], + }, + "report": report.to_dict(), + "report_paths": report_paths, + } + if degraded_persistence: + result["degraded_persistence"] = True + emit_json(JsonEnvelope.success(_VERB, result)) + return + + # Human mode: same evaluation, rendered to the terminal. + from maverick.assumptions.land_report import persist_report + + _blocks, entries, verification = await check_assumption_gate(cwd, quiet=True) + report = build_report(entries, verification, run_id=run_id, dry_run=False) + if report.degraded: + console.print(format_warning("Assumption ledger unavailable — report may be incomplete.")) + render_land_report_terminal(report) + try: + json_path, _md_path = persist_report(report, cwd=cwd) + console.print(f"Report: {json_path}") + except OSError as exc: + console.print(format_warning(f"Failed to persist land report: {exc}")) + console.print() + display_verification(verification, entries) diff --git a/src/maverick/cli/commands/reconcile.py b/src/maverick/cli/commands/reconcile.py index 220e9489..afa7bbdc 100644 --- a/src/maverick/cli/commands/reconcile.py +++ b/src/maverick/cli/commands/reconcile.py @@ -20,6 +20,16 @@ render_workflow_events` for identical progress output) and reads the predicted report straight off ``workflow.result.final_output`` — entirely in-memory, matching the contract's zero-mutation guarantee. + +``--json`` (053-assumption-review-console, T013; see +specs/053-assumption-review-console/contracts/cli-reconcile-json.md) adds a +third path, ``_run_reconcile_json``, used for *both* the real run and +``--dry-run``: it never goes through ``execute_python_workflow`` (which +would swallow a ``WorkflowError`` into a stderr message before +``json_error_handler`` ever saw it — see that function's docstring), always +driving ``ReconcileWorkflow.execute()`` directly and reading +``workflow.result.final_output`` the same way the dry-run path above +already does. """ from __future__ import annotations @@ -31,9 +41,18 @@ import click from rich.table import Table -from maverick.cli.common import cli_error_handler, verify_bd_ready +from maverick.cli.common import ( + BD_MISSING, + BD_NOT_INITIALIZED, + bd_ready_reason, + cli_error_handler, + resolve_verbosity, + verify_bd_ready, +) from maverick.cli.console import console, err_console from maverick.cli.context import ExitCode, async_command +from maverick.cli.json_output import JsonEnvelope, emit_json, json_error_handler +from maverick.exceptions import REASON_DIRTY_WORKING_COPY, BeadError, JjError, WorkflowError from maverick.jj.client import JjClient from maverick.workflows.reconcile.state import AnswerState, ReconcileRunState @@ -209,6 +228,150 @@ async def _run_dry_run_preview(ctx: click.Context, *, run_id: str, cwd: Path) -> _render_dry_run_summary_and_exit(report) +#: Machine reason (``maverick.cli.common.bd_ready_reason``) -> the message +#: the ``bd-unavailable`` envelope carries. +_BD_REASON_MESSAGES = { + BD_MISSING: "The bd CLI is required but not found on PATH.", + BD_NOT_INITIALIZED: ( + "this project hasn't been initialized for Maverick yet — run `maverick init`." + ), +} + + +def _require_bd_ready_json(cwd: Path) -> None: + """JSON-mode sibling of :func:`maverick.cli.common.verify_bd_ready`. + + ``verify_bd_ready`` prints a Rich-formatted message to ``console`` + (stdout) and calls ``SystemExit`` directly rather than raising — see + ``maverick.cli.json_output``'s module docstring, "Scope note on + bd-unavailable". That's incompatible with ``--json`` (FR: stdout + carries exactly one JSON document), so this consumes the same shared + predicate (:func:`~maverick.cli.common.bd_ready_reason` — the single + place the conditions live, so the two modes can't drift) and raises + :class:`~maverick.exceptions.BeadError` instead, letting the caller's + ``json_error_handler`` build the ``bd-unavailable`` envelope via its + existing ``BeadError`` mapping. Human mode keeps calling + ``verify_bd_ready`` unchanged (FR-018 byte-for-byte preservation). + """ + reason = bd_ready_reason(cwd) + if reason is None: + return + raise BeadError(_BD_REASON_MESSAGES.get(reason, f"bd is not ready ({reason}).")) + + +async def _run_reconcile_json(ctx: click.Context, *, dry_run: bool, cwd: Path) -> None: + """Drive reconcile end-to-end in ``--json`` mode: one envelope on stdout. + + Bypasses :func:`~maverick.cli.workflow_executor.execute_python_workflow` + for the real run too (not just dry-run, which already bypassed it — + see this module's docstring): that helper wraps its dispatch in + :func:`~maverick.cli.common.cli_error_handler`, which catches + ``WorkflowError`` (raised by :meth:`ReconcileWorkflow._run` for the + concurrent-fly-run and reconcile-lockfile preconditions) and converts + it directly to a stderr message + ``SystemExit`` *before* control ever + returns here — there would be nothing left for ``json_error_handler`` + to map to ``concurrent-run``/``locked``. Driving the workflow's public + ``execute()`` template method directly (same mechanism the dry-run + path already uses) keeps the ``WorkflowError`` intact. + + ``ReconcileWorkflow._run`` returns ``ReconcileReport.to_dict()`` + unconditionally — real run and dry run alike (workflow.py) — so + ``workflow.result.final_output`` is already the exact JSON result + shape for both verbs; no separate real-run report needs to be + assembled from ``load_run_state``. + + All four preconditions (bd-ready, jj-colocated, clean working copy, + then the workflow's own concurrent-fly/lockfile guards) are expressed + as raised exceptions inside one ``json_error_handler`` scope, reusing + its existing ``BeadError`` -> ``bd-unavailable``, ``JjError`` -> + ``vcs``, and ``WorkflowError`` substring -> ``dirty-working-copy``/ + ``concurrent-run``/``locked`` mappings — no envelope is built by hand + here. The ``.jj``-missing check has no natural ``JjError`` subclass + (``JjNotFoundError`` specifically means "the jj binary isn't on + PATH", a different condition), so it raises the ``JjError`` base + class directly with this project's own message. + """ + verb = "reconcile.dry-run" if dry_run else "reconcile.run" + + with json_error_handler(verb): + _require_bd_ready_json(cwd) + + if not (cwd / ".jj").is_dir(): + raise JjError("this project is not a jj-colocated repository.") + + jj_client = JjClient(cwd=cwd) + working_copy_stat = await jj_client.diff_stat(revision="@") + if working_copy_stat.files_changed != 0: + # Same precondition (and same typed reason) the workflow itself + # raises — `json_error_handler` dispatches on `reason_code`, so the + # prose is free to diverge without breaking the mapping. + raise WorkflowError( + "working copy is not clean — commit or discard changes before running reconcile", + reason_code=REASON_DIRTY_WORKING_COPY, + ) + + run_id = uuid.uuid4().hex[:8] + + from maverick.checkpoint.store import FileCheckpointStore + from maverick.cli.workflow_executor import render_workflow_events + from maverick.config import load_config + from maverick.workflows.reconcile.workflow import WORKFLOW_NAME, ReconcileWorkflow + + config = (ctx.obj or {}).get("config") if ctx.obj else None + if config is None: + config = load_config() + + # Parity with `execute_python_workflow`'s dispatch, which this path + # deliberately bypasses (see the docstring above): a real run still + # gets a checkpoint store, and progress still honours `-v`. + # `--dry-run` gets neither — it must not touch the filesystem. + checkpoint_store = ( + None if dry_run else FileCheckpointStore(cwd / ".maverick" / "checkpoints") + ) + workflow = ReconcileWorkflow( + config=config, + checkpoint_store=checkpoint_store, + workflow_name=WORKFLOW_NAME, + ) + events = workflow.execute({"run_id": run_id, "cwd": str(cwd), "dry_run": dry_run}) + + verbosity = resolve_verbosity(ctx) + steps_meta = getattr(ReconcileWorkflow, "STEPS", None) or {} + total_steps = len(steps_meta) if isinstance(steps_meta, dict) else 0 + + # Progress narration goes to stderr in JSON mode (contract: + # stdout carries exactly one JSON document) — `err_console` + # instead of the human-mode `console`. + await render_workflow_events( + events, + err_console, + workflow_name=WORKFLOW_NAME, + verbosity=verbosity, + total_steps=total_steps, + ) + + assert workflow.result is not None + if not workflow.result.success: + # `execute()` re-raises the underlying exception after yielding + # WorkflowCompleted(success=False) — that exception propagates + # through `render_workflow_events` into the `json_error_handler` + # scope above before we ever reach this line. Unreachable + # defensive fallback, same rationale as `_run_dry_run_preview`'s + # equivalent branch. + raise SystemExit(ExitCode.FAILURE) + + report = workflow.result.final_output + assert isinstance(report, dict) + + # Contract exit codes: dry-run always 0 (barring an error envelope + # above); real run 0 when nothing to reconcile or every outcome + # `reconciled` (`report["exit_success"]`), else 1 — `ok: true` either + # way, the outcomes carry the news. + exit_code = ExitCode.SUCCESS if (dry_run or report["exit_success"]) else ExitCode.FAILURE + emit_json(JsonEnvelope.success(verb, report)) + raise SystemExit(exit_code) + + @click.command("reconcile") @click.option( "--dry-run", @@ -218,9 +381,16 @@ async def _run_dry_run_preview(ctx: click.Context, *, run_id: str, cwd: Path) -> "Preview detection, ordering, and target resolution with zero jj/bd/filesystem mutations." ), ) +@click.option( + "--json", + "json_output", + is_flag=True, + default=False, + help="Emit a single machine-readable JSON envelope to stdout instead of Rich console output.", +) @click.pass_context @async_command -async def reconcile(ctx: click.Context, dry_run: bool) -> None: +async def reconcile(ctx: click.Context, dry_run: bool, json_output: bool) -> None: """Reconcile changed assumption-ledger answers against the current stack. Detects human-answered assumption-ledger entries whose adopted answer @@ -228,10 +398,16 @@ async def reconcile(ctx: click.Context, dry_run: bool) -> None: correction to its resolved target change, gates the result (format, lint, typecheck, test), and marks each answer terminal — reconciled or needs-interactive-review. See - specs/051-reconcile-changed-answers/contracts/cli-reconcile.md. + specs/051-reconcile-changed-answers/contracts/cli-reconcile.md and, for + ``--json``, specs/053-assumption-review-console/contracts/ + cli-reconcile-json.md. """ cwd = Path.cwd().resolve() + if json_output: + await _run_reconcile_json(ctx, dry_run=dry_run, cwd=cwd) + return + with cli_error_handler(): # Preconditions (contract "Preconditions", checked in order). verify_bd_ready(cwd) diff --git a/src/maverick/cli/commands/review.py b/src/maverick/cli/commands/review.py deleted file mode 100644 index 5f1aa948..00000000 --- a/src/maverick/cli/commands/review.py +++ /dev/null @@ -1,465 +0,0 @@ -"""``maverick review`` command — lightweight human review of assumption beads. - -Two flavors of "human-assigned bead" share this command: - -* **Ledger entries** (labeled ``assumption``, from the assumption ledger - feature) — full-context display (question / adopted answer / - alternatives / severity / owning spec / change stamps / discovered-from - source) with an answer/waive resolution flow. -* **Legacy escalation beads** (``needs-human-review`` / ``assumption-review`` - without ``assumption``) — the original approve/reject/defer flow. - Rejected beads spawn a correction bead back into the agent pipeline. - -The human provides judgment and direction, not code. -""" - -from __future__ import annotations - -from pathlib import Path -from typing import TYPE_CHECKING - -import click -from rich.markup import escape - -from maverick.cli.console import console -from maverick.cli.context import ExitCode, async_command -from maverick.logging import get_logger - -if TYPE_CHECKING: - from maverick.beads.client import BeadClient - from maverick.beads.models import BeadDetails - -logger = get_logger(__name__) - - -@click.command("review") -@click.argument("bead_id", required=False, default=None) -@click.option( - "--approve", - is_flag=True, - default=False, - help="Approve without interactive prompt (legacy escalation beads).", -) -@click.option( - "--reject", - "reject_guidance", - default=None, - help="Reject with guidance text (non-interactive, legacy escalation beads).", -) -@click.option( - "--defer", - is_flag=True, - default=False, - help="Defer without interactive prompt (legacy escalation beads).", -) -@click.option( - "--answer", - "answer_text", - default=None, - help="Answer a ledger entry (non-interactive).", -) -@click.option( - "--waive", - "waive_reason", - default=None, - help="Waive a ledger entry with a reason (non-interactive).", -) -@click.option( - "--spec", - "owner_spec", - default=None, - help="Bulk-waive: owning spec selector (matches `owner_spec` attribution).", -) -@click.option( - "--severity", - "severities", - multiple=True, - type=click.Choice(["low", "medium", "high"]), - help="Bulk-waive: severity filter, repeatable (default: low only).", -) -@click.pass_context -@async_command -async def review( - ctx: click.Context, - bead_id: str | None, - approve: bool, - reject_guidance: str | None, - defer: bool, - answer_text: str | None, - waive_reason: str | None, - owner_spec: str | None, - severities: tuple[str, ...], -) -> None: - """Review a human-assigned bead, or bulk-waive a spec's open entries. - - For assumption-ledger entries: displays full context and captures - answer or waive. For legacy escalation beads: displays the escalation - context and captures approve, reject (with guidance for the - correction agent), or defer. ``--spec`` switches to bulk waive: every - open entry owned by that spec, filtered by ``--severity`` (default - low only), is waived with a shared reason. - - Examples: - - maverick review dea-ykp.7 - - maverick review dea-ykp.7 --answer "Per bead, matches existing scoping." - - maverick review dea-ykp.7 --waive "No longer applicable" - - maverick review dea-ykp.7 --approve - - maverick review dea-ykp.7 --reject "Use Dockerfile generation instead" - - maverick review dea-ykp.7 --defer - - maverick review --spec 052-conditional-landing --waive "accepted for MVP" - """ - from maverick.beads.client import BeadClient - - if answer_text is not None and waive_reason is not None: - console.print("[red]Error:[/] --answer and --waive are mutually exclusive") - raise SystemExit(ExitCode.FAILURE) - - if bead_id is not None and owner_spec is not None: - console.print("[red]Error:[/] BEAD_ID and --spec are mutually exclusive") - raise SystemExit(ExitCode.FAILURE) - - if bead_id is None and owner_spec is None: - console.print("[red]Error:[/] Provide either BEAD_ID or --spec") - raise SystemExit(ExitCode.FAILURE) - - if owner_spec is None and severities: - console.print("[red]Error:[/] --severity only applies to bulk waive (--spec)") - raise SystemExit(ExitCode.FAILURE) - - if owner_spec is not None: - # Validate up front, matching the single-entry path — otherwise an - # empty reason sails past this check and surfaces as N identical - # per-entry "failed to waive" rows from inside `waive()`. - if waive_reason is None or not waive_reason.strip(): - console.print("[red]Error:[/] --spec requires --waive ") - raise SystemExit(ExitCode.FAILURE) - if answer_text is not None or approve or reject_guidance is not None or defer: - console.print( - "[red]Error:[/] --spec only supports --waive " - "(bulk answering is unsupported — answers are per-question)" - ) - raise SystemExit(ExitCode.FAILURE) - - client = BeadClient(cwd=Path.cwd()) - if not await client.verify_available(): - console.print("[red]Error:[/] bd is not available") - raise SystemExit(ExitCode.FAILURE) - - await _bulk_waive_flow( - client, - owner_spec=owner_spec, - severities=severities or ("low",), - reason=waive_reason, - ) - return - - assert bead_id is not None # mutual-exclusion checks above guarantee this - from maverick.assumptions.models import ASSUMPTION_LABEL - from maverick.beads.models import ( - BeadCategory, - BeadDefinition, - BeadType, - ) - - client = BeadClient(cwd=Path.cwd()) - - if not await client.verify_available(): - console.print("[red]Error:[/] bd is not available") - raise SystemExit(ExitCode.FAILURE) - - # Fetch bead details - try: - details = await client.show(bead_id) - except Exception as exc: - console.print(f"[red]Error:[/] Could not fetch bead {bead_id}: {exc}") - raise SystemExit(ExitCode.FAILURE) from exc - - state = details.state or {} - labels = details.labels or [] - - if ASSUMPTION_LABEL in labels: - await _review_ledger_entry( - client, bead_id, details, answer_text=answer_text, waive_reason=waive_reason - ) - return - - # Verify this is a human-assigned review bead - is_human = "needs-human-review" in labels or "assumption-review" in labels - if not is_human: - console.print( - f"[yellow]Warning:[/] Bead {bead_id} is not flagged for " - f"human review (labels: {labels})" - ) - if not click.confirm("Review anyway?", default=False): - return - - # Display the review context - source_bead = state.get("source_bead", "unknown") - escalation_type = state.get("escalation_type", "unknown") - flight_plan = state.get("flight_plan", "unknown") - - console.print() - console.print("[bold]━" * 60) - console.print(f"[bold] Assumption Review: {details.title}") - console.print("[bold]━" * 60) - console.print() - console.print(f"[dim]Source bead:[/] {source_bead}") - console.print(f"[dim]Flight plan:[/] {flight_plan}") - console.print(f"[dim]Escalation:[/] {escalation_type}") - console.print() - - if details.description: - console.print(details.description) - console.print() - - console.print("[bold]━" * 60) - console.print() - - # Determine decision — from flags or interactive prompt - if approve: - decision = "approve" - guidance = "" - elif reject_guidance is not None: - decision = "reject" - guidance = reject_guidance - elif defer: - decision = "defer" - guidance = "" - else: - # Interactive mode - console.print("[bold]Decision:[/]") - console.print() - console.print(" [green]1.[/] Approve — the current implementation is acceptable") - console.print(" [red]2.[/] Reject — needs correction (you'll provide guidance)") - console.print(" [yellow]3.[/] Defer — not enough information, skip for now") - console.print() - - choice = click.prompt("Choice", type=click.Choice(["1", "2", "3"])) - - if choice == "1": - decision = "approve" - guidance = "" - elif choice == "2": - decision = "reject" - console.print() - console.print( - "[bold]Guidance for the correction agent[/] (brief note — what should change?):" - ) - guidance = click.prompt(">") - else: - decision = "defer" - guidance = "" - - # Execute the decision - if decision == "approve": - await client.close(bead_id, reason="approved") - console.print(f"\n[green]✓[/] Bead {bead_id} closed as approved.") - - elif decision == "reject": - # Create a correction bead assigned to an agent - correction_title = f"Correction: {details.title[:150]}" - correction_desc = ( - f"## Human Guidance\n\n{guidance}\n\n" - f"## Original Escalation\n\n{details.description or 'N/A'}\n\n" - f"## Source Bead\n\n{source_bead}" - ) - - correction_def = BeadDefinition( - title=correction_title, - bead_type=BeadType.TASK, - priority=1, - category=BeadCategory.VALIDATION, - description=correction_desc, - labels=["correction", f"corrects:{bead_id}"], - ) - - # Resolve parent epic from source bead - parent_id = None - try: - source_details = await client.show(source_bead) - parent_id = source_details.parent_id - except Exception: - pass - - try: - created = await client.create_bead(correction_def, parent_id=parent_id) - console.print(f"\n[yellow]→[/] Correction bead created: {created.bd_id}") - except Exception as exc: - console.print(f"\n[red]Error:[/] Failed to create correction bead: {exc}") - console.print("Close the review bead manually when ready.") - raise SystemExit(ExitCode.FAILURE) from exc - - # Close the review bead as rejected - await client.close(bead_id, reason=f"rejected: {guidance[:200]}") - console.print(f"[red]✗[/] Bead {bead_id} closed as rejected.") - console.print( - f"\nThe correction bead ({created.bd_id}) will be picked up " - f"by the next `maverick fly` run." - ) - - elif decision == "defer": - console.print(f"\n[yellow]⏸[/] Bead {bead_id} deferred — no action taken.") - console.print("Run `maverick review` again when ready.") - - -async def _bulk_waive_flow( - client: BeadClient, - *, - owner_spec: str, - severities: tuple[str, ...], - reason: str, -) -> None: - """Waive every open entry owned by *owner_spec* matching *severities*. - - Contract (contracts/cli-review-bulk-waive.md): zero matches prints a - message and exits zero (idempotent); a partial failure waives what it - can, lists per-entry failures, and exits non-zero. - """ - from maverick.assumptions.errors import AssumptionLedgerError - from maverick.assumptions.ledger import bulk_waive - from maverick.assumptions.models import Severity - - severity_set = frozenset(Severity(s) for s in severities) - waived_by = _resolve_git_user_name(Path.cwd()) - - try: - result = await bulk_waive( - client, - owner_spec=owner_spec, - severities=severity_set, - reason=reason, - waived_by=waived_by, - ) - except AssumptionLedgerError as exc: - # `bulk_waive`'s selection sweep raises (only per-entry failures - # are collected). Without this the CLI dumps a raw traceback, - # unlike every other ledger path in this command. - console.print(f"[red]Error:[/] {exc}") - raise SystemExit(ExitCode.FAILURE) from exc - - if not result.waived and not result.failed: - severities_label = "/".join(sorted(s.value for s in severity_set)) - console.print(f"No open {severities_label}-severity assumptions for {owner_spec}.") - return - - if result.waived: - console.print( - f"[green]✓[/] Waived {len(result.waived)} assumption" - f"{'s' if len(result.waived) != 1 else ''} for {owner_spec}:" - ) - for record in result.waived: - # Agent-authored text — `escape` keeps Rich from eating `[...]`. - console.print(f" {record.bead_id} {escape(record.question)}") - console.print(f"Reason: {reason} (waived by {waived_by})") - - if result.failed: - console.print() - console.print(f"[red]✗[/] Failed to waive {len(result.failed)} entries:") - for bead_id, error in result.failed.items(): - console.print(f" {bead_id}: {error}") - raise SystemExit(ExitCode.FAILURE) - - -def _resolve_git_user_name(cwd: Path) -> str: - """Resolve the git user name via GitPython config (Guardrail 8).""" - try: - from git import Repo - - repo = Repo(cwd, search_parent_directories=True) - with repo.config_reader() as cfg: - name = cfg.get_value("user", "name", default="unknown") - return str(name) - except Exception: # noqa: BLE001 — best-effort attribution - return "unknown" - - -async def _review_ledger_entry( - client: BeadClient, - bead_id: str, - details: BeadDetails, - *, - answer_text: str | None, - waive_reason: str | None, -) -> None: - """Full-context display + answer/waive flow for an assumption ledger entry.""" - from maverick.assumptions.errors import AssumptionLedgerError - from maverick.assumptions.ledger import answer as ledger_answer - from maverick.assumptions.ledger import parse_description - from maverick.assumptions.ledger import waive as ledger_waive - from maverick.assumptions.models import ( - KEY_CHANGE_IDS, - KEY_OWNER_SPEC, - KEY_SEVERITY, - KEY_SEVERITY_DEFAULTED, - KEY_SOURCE_BEAD, - ) - - state = details.state or {} - question, adopted_answer, alternatives = parse_description(details.description or "") - severity = state.get(KEY_SEVERITY, "medium") - defaulted = state.get(KEY_SEVERITY_DEFAULTED) == "true" - owner_spec = state.get(KEY_OWNER_SPEC, "") - source_bead = state.get(KEY_SOURCE_BEAD, "") - stamps = state.get(KEY_CHANGE_IDS) or "unstamped" - - console.print() - console.print("[bold]━" * 60) - console.print(f"[bold] Assumption: {question or details.title}") - console.print("[bold]━" * 60) - console.print() - console.print(f"[dim]Question:[/] {question}") - console.print(f"[dim]Adopted answer:[/] {adopted_answer}") - if alternatives: - console.print("[dim]Alternatives:[/]") - for alt in alternatives: - console.print(f" - {alt}") - severity_display = f"{severity}{' (defaulted)' if defaulted else ''}" - console.print(f"[dim]Severity:[/] {severity_display}") - console.print(f"[dim]Owning spec:[/] {owner_spec}") - console.print(f"[dim]Change stamps:[/] {stamps}") - console.print(f"[dim]Discovered from:[/] {source_bead}") - console.print() - console.print("[bold]━" * 60) - console.print() - - if answer_text is None and waive_reason is None: - console.print("[bold]Decision:[/]") - console.print() - console.print(" [green]1.[/] Answer — record the resolution and close") - console.print(" [yellow]2.[/] Waive — record a reason and close") - console.print() - choice = click.prompt("Choice", type=click.Choice(["1", "2"])) - console.print() - if choice == "1": - answer_text = click.prompt("Answer") - else: - waive_reason = click.prompt("Waive reason") - - if answer_text is not None: - if not answer_text.strip(): - console.print("[red]Error:[/] Answer text must not be empty") - raise SystemExit(ExitCode.FAILURE) - try: - await ledger_answer(client, bead_id=bead_id, answer_text=answer_text) - except AssumptionLedgerError as exc: - console.print(f"[red]Error:[/] {exc}") - raise SystemExit(ExitCode.FAILURE) from exc - console.print(f"\n[green]✓[/] Bead {bead_id} answered and closed.") - else: - if not waive_reason or not waive_reason.strip(): - console.print("[red]Error:[/] Waive reason must not be empty") - raise SystemExit(ExitCode.FAILURE) - waived_by = _resolve_git_user_name(Path.cwd()) - try: - await ledger_waive(client, bead_id=bead_id, reason=waive_reason, waived_by=waived_by) - except AssumptionLedgerError as exc: - console.print(f"[red]Error:[/] {exc}") - raise SystemExit(ExitCode.FAILURE) from exc - console.print(f"\n[yellow]⚠[/] Bead {bead_id} waived by {waived_by} and closed.") diff --git a/src/maverick/cli/commands/review/__init__.py b/src/maverick/cli/commands/review/__init__.py new file mode 100644 index 00000000..a0acd850 --- /dev/null +++ b/src/maverick/cli/commands/review/__init__.py @@ -0,0 +1,386 @@ +"""``maverick review`` command — lightweight human review of assumption beads. + +Two flavors of "human-assigned bead" share this command: + +* **Ledger entries** (labeled ``assumption``, from the assumption ledger + feature) — full-context display (question / adopted answer / + alternatives / severity / owning spec / change stamps / discovered-from + source) with an answer/waive resolution flow. +* **Legacy escalation beads** (``needs-human-review`` / ``assumption-review`` + without ``assumption``) — the original approve/reject/defer flow. + Rejected beads spawn a correction bead back into the agent pipeline. + +The human provides judgment and direction, not code. + +This package splits the command's implementation across: + +* ``entry_actions.py`` — ledger-entry answer/waive flow and bulk-waive flow. +* ``legacy.py`` — the legacy escalation-bead approve/reject/defer flow. +* ``listing.py`` — ``--list`` mode (053-assumption-review-console). + +``--json`` (053-assumption-review-console) makes every path emit exactly +one :class:`~maverick.cli.json_output.JsonEnvelope` document on stdout +instead of Rich narration — see +``specs/053-assumption-review-console/contracts/cli-review-json.md``. +Human-mode behavior (no ``--json``) is unchanged (FR-018). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, NoReturn + +import click + +from maverick.cli.console import console +from maverick.cli.context import ExitCode, async_command +from maverick.logging import get_logger + +if TYPE_CHECKING: + from maverick.cli.json_output import ErrorKind + +logger = get_logger(__name__) + + +def _fail( + *, + json_mode: bool, + verb: str, + message: str, + kind: ErrorKind | None = None, +) -> NoReturn: + """Shared top-level failure exit: JSON envelope or console print. + + *kind* defaults to ``validation`` — right for the flag-combination + guards that dominate this command — but must be passed explicitly for + anything else (notably the ``verify_available()`` precondition, which + is ``bd-unavailable``; ``json_error_handler`` can't classify a check + that never raises, so that translation is the caller's job — see + ``maverick.cli.json_output``'s module docstring). + + Always raises ``SystemExit(ExitCode.FAILURE)``. + """ + if json_mode: + from maverick.cli.json_output import ErrorKind as _ErrorKind + from maverick.cli.json_output import JsonEnvelope, emit_json + + emit_json(JsonEnvelope.failure(verb, kind or _ErrorKind.VALIDATION, message)) + else: + console.print(f"[red]Error:[/] {message}") + raise SystemExit(ExitCode.FAILURE) + + +@click.command("review") +@click.argument("bead_id", required=False, default=None) +@click.option( + "--approve", + is_flag=True, + default=False, + help="Approve without interactive prompt (legacy escalation beads).", +) +@click.option( + "--reject", + "reject_guidance", + default=None, + help="Reject with guidance text (non-interactive, legacy escalation beads).", +) +@click.option( + "--defer", + is_flag=True, + default=False, + help="Defer without interactive prompt (legacy escalation beads).", +) +@click.option( + "--answer", + "answer_text", + default=None, + help="Answer a ledger entry (non-interactive).", +) +@click.option( + "--waive", + "waive_reason", + default=None, + help="Waive a ledger entry with a reason (non-interactive).", +) +@click.option( + "--list", + "list_mode", + is_flag=True, + default=False, + help="List assumption-ledger entries instead of reviewing one.", +) +@click.option( + "--status", + "statuses", + multiple=True, + type=click.Choice(["open", "answered", "waived"]), + help="--list filter: entry status, repeatable (default: open only).", +) +@click.option( + "--spec", + "owner_specs", + multiple=True, + help=( + "Bulk-waive: owning spec selector (matches `owner_spec` attribution). " + "Also usable with --list to filter by owner spec; repeatable." + ), +) +@click.option( + "--severity", + "severities", + multiple=True, + type=click.Choice(["low", "medium", "high"]), + help=( + "Bulk-waive: severity filter, repeatable (default: low only). " + "Also usable with --list to filter by severity." + ), +) +@click.option( + "--json", + "json_mode", + is_flag=True, + default=False, + help="Emit a machine-readable JSON envelope instead of Rich output.", +) +@click.pass_context +@async_command +async def review( + ctx: click.Context, + bead_id: str | None, + approve: bool, + reject_guidance: str | None, + defer: bool, + answer_text: str | None, + waive_reason: str | None, + list_mode: bool, + statuses: tuple[str, ...], + owner_specs: tuple[str, ...], + severities: tuple[str, ...], + json_mode: bool, +) -> None: + """Review a human-assigned bead, list ledger entries, or bulk-waive a spec. + + For assumption-ledger entries: displays full context and captures + answer or waive. For legacy escalation beads: displays the escalation + context and captures approve, reject (with guidance for the + correction agent), or defer. ``--list`` lists ledger entries instead + of reviewing one. ``--spec`` (without ``--list``) switches to bulk + waive: every open entry owned by that spec, filtered by ``--severity`` + (default low only), is waived with a shared reason. ``--json`` emits + a machine-readable envelope instead of Rich output on every path. + + Examples: + + maverick review dea-ykp.7 + + maverick review dea-ykp.7 --answer "Per bead, matches existing scoping." + + maverick review dea-ykp.7 --waive "No longer applicable" + + maverick review dea-ykp.7 --approve + + maverick review dea-ykp.7 --reject "Use Dockerfile generation instead" + + maverick review dea-ykp.7 --defer + + maverick review --spec 052-conditional-landing --waive "accepted for MVP" + + maverick review --list --status open --json + """ + from maverick.beads.client import BeadClient + from maverick.cli.commands.review.entry_actions import ( + _bulk_waive_flow, + _review_ledger_entry, + ) + from maverick.cli.commands.review.legacy import handle_legacy_review + from maverick.cli.commands.review.listing import run_list + + if list_mode: + if bead_id is not None: + _fail( + json_mode=json_mode, + verb="review.list", + message="--list and BEAD_ID are mutually exclusive", + ) + decision_flags_given = ( + answer_text is not None + or waive_reason is not None + or approve + or reject_guidance is not None + or defer + ) + if decision_flags_given: + _fail( + json_mode=json_mode, + verb="review.list", + message=( + "--list is mutually exclusive with --answer/--waive/--approve/--reject/--defer" + ), + ) + + await run_list( + statuses=frozenset(statuses), + owner_specs=frozenset(owner_specs), + severities=frozenset(severities), + json_mode=json_mode, + ) + return + + if answer_text is not None and waive_reason is not None: + _fail( + json_mode=json_mode, + verb="review.answer", + message="--answer and --waive are mutually exclusive", + ) + + if bead_id is not None and owner_specs: + _fail( + json_mode=json_mode, + verb="review.bulk-waive", + message="BEAD_ID and --spec are mutually exclusive", + ) + + if bead_id is None and not owner_specs: + _fail( + json_mode=json_mode, + verb="review.answer", + message="Provide either BEAD_ID or --spec", + ) + + if not owner_specs and severities: + _fail( + json_mode=json_mode, + verb="review.bulk-waive", + message="--severity only applies to bulk waive (--spec)", + ) + + if owner_specs: + if len(owner_specs) > 1: + _fail( + json_mode=json_mode, + verb="review.bulk-waive", + message="--spec accepts only one value for bulk waive", + ) + owner_spec = owner_specs[0] + # Validate up front, matching the single-entry path — otherwise an + # empty reason sails past this check and surfaces as N identical + # per-entry "failed to waive" rows from inside `waive()`. + if waive_reason is None or not waive_reason.strip(): + _fail( + json_mode=json_mode, + verb="review.bulk-waive", + message="--spec requires --waive ", + ) + if answer_text is not None or approve or reject_guidance is not None or defer: + _fail( + json_mode=json_mode, + verb="review.bulk-waive", + message="--spec only supports --waive " + "(bulk answering is unsupported — answers are per-question)", + ) + + client = BeadClient(cwd=Path.cwd()) + if not await client.verify_available(): + from maverick.cli.json_output import ErrorKind + + _fail( + json_mode=json_mode, + verb="review.bulk-waive", + message="bd is not available", + kind=ErrorKind.BD_UNAVAILABLE, + ) + + await _bulk_waive_flow( + client, + owner_spec=owner_spec, + severities=severities or ("low",), + reason=waive_reason, + json_mode=json_mode, + ) + return + + assert bead_id is not None # mutual-exclusion checks above guarantee this + from maverick.assumptions.models import ASSUMPTION_LABEL + from maverick.cli.json_output import ErrorKind + from maverick.exceptions.beads import BeadQueryError + + client = BeadClient(cwd=Path.cwd()) + + default_verb = "review.waive" if waive_reason is not None else "review.answer" + + if not await client.verify_available(): + _fail( + json_mode=json_mode, + verb=default_verb, + message="bd is not available", + kind=ErrorKind.BD_UNAVAILABLE, + ) + + # Fetch bead details + try: + details = await client.show(bead_id) + except BeadQueryError as exc: + # A `show()` failure on an already-verified-available bd means an + # unknown/bad bead id, not "bd unavailable" — handle it directly + # rather than letting it fall through to `json_error_handler`'s + # generic BeadError->bd-unavailable mapping. + message = f"Could not fetch bead {bead_id}: {exc}" + if json_mode: + from maverick.cli.json_output import JsonEnvelope, emit_json + + emit_json( + JsonEnvelope.failure( + default_verb, ErrorKind.NOT_FOUND, message, details={"bead_id": bead_id} + ) + ) + else: + console.print(f"[red]Error:[/] {message}") + raise SystemExit(ExitCode.FAILURE) from exc + except Exception as exc: + message = f"Could not fetch bead {bead_id}: {exc}" + if json_mode: + from maverick.cli.json_output import JsonEnvelope, emit_json + + emit_json(JsonEnvelope.failure(default_verb, ErrorKind.INTERNAL, message)) + else: + console.print(f"[red]Error:[/] {message}") + raise SystemExit(ExitCode.FAILURE) from exc + + labels = details.labels or [] + + if ASSUMPTION_LABEL in labels: + await _review_ledger_entry( + client, + bead_id, + details, + answer_text=answer_text, + waive_reason=waive_reason, + json_mode=json_mode, + ) + return + + await handle_legacy_review( + client, + bead_id, + details, + approve=approve, + reject_guidance=reject_guidance, + defer=defer, + json_mode=json_mode, + ) + + +def _resolve_git_user_name(cwd: Path) -> str: + """Resolve the git user name via GitPython config (Guardrail 8).""" + try: + from git import Repo + + repo = Repo(cwd, search_parent_directories=True) + with repo.config_reader() as cfg: + name = cfg.get_value("user", "name", default="unknown") + return str(name) + except Exception: # noqa: BLE001 — best-effort attribution + return "unknown" + + +__all__ = ["review"] diff --git a/src/maverick/cli/commands/review/entry_actions.py b/src/maverick/cli/commands/review/entry_actions.py new file mode 100644 index 00000000..40da317a --- /dev/null +++ b/src/maverick/cli/commands/review/entry_actions.py @@ -0,0 +1,337 @@ +"""Ledger-entry answer/waive flow and bulk-waive flow for ``maverick review``. + +Split out of ``maverick.cli.commands.review`` (T001, +053-assumption-review-console) — behavior-preserving move, no logic changes. +``--json`` support (T007/T011/T012) added on top: every branch either +prints Rich narration to stdout (human mode, unchanged) or emits exactly +one :class:`~maverick.cli.json_output.JsonEnvelope` document (JSON mode) — +never both. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, NoReturn + +import click +from rich.markup import escape + +from maverick.assumptions.models import STATUS_WAIVED +from maverick.cli.console import console +from maverick.cli.context import ExitCode + +if TYPE_CHECKING: + from maverick.beads.client import BeadClient + from maverick.beads.models import BeadDetails + + +async def _project_after_write(client: BeadClient, bead_id: str) -> dict[str, object] | None: + """Re-read *bead_id* and project it into the canonical entry row. + + **Must be called outside any ``json_error_handler`` scope.** This read + happens *after* the ledger write has already succeeded; letting a + transient bd failure here surface as ``ok: false`` would report a + recorded decision as an unrecorded one — the skill would then tell the + human their answer wasn't saved while the ledger says otherwise. + Failing soft (``None``, surfaced as ``degraded: true`` on the success + envelope) keeps the envelope honest about what actually happened. + """ + from maverick.assumptions.ledger import report_entry_from_details + from maverick.assumptions.serialize import entry_to_dict + + try: + details = await client.show(bead_id) + except Exception: # noqa: BLE001 — the write already succeeded; never fail on the read + return None + entry = report_entry_from_details(details) + return entry_to_dict(entry) if entry is not None else None + + +async def _bulk_waive_flow( + client: BeadClient, + *, + owner_spec: str, + severities: tuple[str, ...], + reason: str, + json_mode: bool = False, +) -> None: + """Waive every open entry owned by *owner_spec* matching *severities*. + + Contract (contracts/cli-review-json.md "review.bulk-waive"): zero + matches is success (idempotent, ``ok: true`` with an empty ``waived`` + list); a partial failure waives what it can and reports per-entry + failures — ``ok: true`` still (the verb ran), but the CLI exits + non-zero since outcomes say what failed. + + Human mode (``json_mode=False``) is unchanged from the pre-053 + behavior: zero matches prints a message and exits zero; a partial + failure waives what it can, lists per-entry failures, and exits + non-zero. + """ + from maverick.assumptions.errors import AssumptionLedgerError + from maverick.assumptions.ledger import bulk_waive + from maverick.assumptions.models import Severity + from maverick.cli.commands.review import _resolve_git_user_name + from maverick.cli.json_output import JsonEnvelope, emit_json, json_error_handler + + severity_set = frozenset(Severity(s) for s in severities) + waived_by = _resolve_git_user_name(Path.cwd()) + + if json_mode: + with json_error_handler("review.bulk-waive"): + result = await bulk_waive( + client, + owner_spec=owner_spec, + severities=severity_set, + reason=reason, + waived_by=waived_by, + ) + + # Post-write projection, deliberately OUTSIDE the handler above: + # every waive in `result` already succeeded, so a read failure on + # one row must not discard the record of *all* of them. Each row is + # re-read independently and fails soft into `unprojected`. + # (A single repo-wide `report_entries()` sweep would look cheaper + # but isn't — it queries every task bead and shows each one, which + # is strictly more work than showing just the entries waived here.) + waived_rows: list[dict[str, object]] = [] + unprojected: list[str] = [] + for record in result.waived: + row = await _project_after_write(client, record.bead_id) + if row is None: + unprojected.append(record.bead_id) + else: + waived_rows.append(row) + + payload: dict[str, object] = { + "owner_spec": owner_spec, + "severities": sorted(s.value for s in severity_set), + "waived": waived_rows, + "failed": dict(result.failed), + } + if unprojected: + # These entries WERE waived — only their post-write rows + # couldn't be re-read. Reported separately so a consumer never + # mistakes a projection gap for a failed waive. + payload["unprojected"] = unprojected + emit_json(JsonEnvelope.success("review.bulk-waive", payload)) + if result.failed: + raise SystemExit(ExitCode.FAILURE) + return + + try: + result = await bulk_waive( + client, + owner_spec=owner_spec, + severities=severity_set, + reason=reason, + waived_by=waived_by, + ) + except AssumptionLedgerError as exc: + # `bulk_waive`'s selection sweep raises (only per-entry failures + # are collected). Without this the CLI dumps a raw traceback, + # unlike every other ledger path in this command. + console.print(f"[red]Error:[/] {exc}") + raise SystemExit(ExitCode.FAILURE) from exc + + if not result.waived and not result.failed: + severities_label = "/".join(sorted(s.value for s in severity_set)) + console.print(f"No open {severities_label}-severity assumptions for {owner_spec}.") + return + + if result.waived: + console.print( + f"[green]✓[/] Waived {len(result.waived)} assumption" + f"{'s' if len(result.waived) != 1 else ''} for {owner_spec}:" + ) + for record in result.waived: + # Agent-authored text — `escape` keeps Rich from eating `[...]`. + console.print(f" {record.bead_id} {escape(record.question)}") + console.print(f"Reason: {reason} (waived by {waived_by})") + + if result.failed: + console.print() + console.print(f"[red]✗[/] Failed to waive {len(result.failed)} entries:") + for bead_id, error in result.failed.items(): + console.print(f" {bead_id}: {error}") + raise SystemExit(ExitCode.FAILURE) + + +def _emit_or_print_validation(*, json_mode: bool, verb: str, message: str) -> NoReturn: + """Shared validation-failure exit for the ledger-entry flow. + + JSON mode emits a ``validation`` envelope; human mode keeps the + existing ``console.print`` + ``SystemExit`` style. Always raises. + """ + if json_mode: + from maverick.cli.json_output import ErrorKind, JsonEnvelope, emit_json + + emit_json(JsonEnvelope.failure(verb, ErrorKind.VALIDATION, message)) + else: + console.print(f"[red]Error:[/] {message}") + raise SystemExit(ExitCode.FAILURE) + + +async def _review_ledger_entry( + client: BeadClient, + bead_id: str, + details: BeadDetails, + *, + answer_text: str | None, + waive_reason: str | None, + json_mode: bool = False, +) -> None: + """Full-context display + answer/waive flow for an assumption ledger entry. + + JSON mode (research R6, contracts/cli-review-json.md): no prompts, no + Rich narration on stdout — exactly one envelope document. A pre-check + (research R6) reads the entry's *current* status from *details* + (already fetched by the caller moments earlier) before any write: a + ``waived`` target is ``already-resolved`` in both modes — re-answering + an ``answered`` entry stays legal (051 FR-017). + """ + from maverick.assumptions.errors import AssumptionLedgerError + from maverick.assumptions.ledger import answer as ledger_answer + from maverick.assumptions.ledger import ( + parse_description, + report_entry_from_details, + ) + from maverick.assumptions.ledger import waive as ledger_waive + from maverick.assumptions.models import ( + KEY_CHANGE_IDS, + KEY_OWNER_SPEC, + KEY_SEVERITY, + KEY_SEVERITY_DEFAULTED, + KEY_SOURCE_BEAD, + ) + from maverick.assumptions.serialize import entry_to_dict + from maverick.cli.commands.review import _resolve_git_user_name + from maverick.cli.json_output import ErrorKind, JsonEnvelope, emit_json, json_error_handler + + is_waive_only = waive_reason is not None and answer_text is None + verb = "review.waive" if is_waive_only else "review.answer" + + if json_mode and answer_text is None and waive_reason is None: + _emit_or_print_validation( + json_mode=True, + verb=verb, + message="Provide --answer or --waive in --json mode (no interactive prompt available)", + ) + + # Already-resolved pre-check (research R6) — applies in both modes. + # `details` carries the ASSUMPTION_LABEL (the caller only dispatches + # here for ledger entries), so this never returns None. + current_entry = report_entry_from_details(details) + assert current_entry is not None # ledger entries always project + if current_entry.record.status == STATUS_WAIVED: + message = f"Bead {bead_id} is already waived — no further action possible" + if json_mode: + emit_json( + JsonEnvelope.failure( + verb, + ErrorKind.ALREADY_RESOLVED, + message, + details={"entry": entry_to_dict(current_entry)}, + ) + ) + else: + console.print(f"[red]Error:[/] {message}") + raise SystemExit(ExitCode.FAILURE) + + if not json_mode: + state = details.state or {} + question, adopted_answer, alternatives = parse_description(details.description or "") + severity = state.get(KEY_SEVERITY, "medium") + defaulted = state.get(KEY_SEVERITY_DEFAULTED) == "true" + owner_spec = state.get(KEY_OWNER_SPEC, "") + source_bead = state.get(KEY_SOURCE_BEAD, "") + stamps = state.get(KEY_CHANGE_IDS) or "unstamped" + + console.print() + console.print("[bold]━" * 60) + console.print(f"[bold] Assumption: {question or details.title}") + console.print("[bold]━" * 60) + console.print() + console.print(f"[dim]Question:[/] {question}") + console.print(f"[dim]Adopted answer:[/] {adopted_answer}") + if alternatives: + console.print("[dim]Alternatives:[/]") + for alt in alternatives: + console.print(f" - {alt}") + severity_display = f"{severity}{' (defaulted)' if defaulted else ''}" + console.print(f"[dim]Severity:[/] {severity_display}") + console.print(f"[dim]Owning spec:[/] {owner_spec}") + console.print(f"[dim]Change stamps:[/] {stamps}") + console.print(f"[dim]Discovered from:[/] {source_bead}") + console.print() + console.print("[bold]━" * 60) + console.print() + + if answer_text is None and waive_reason is None: + console.print("[bold]Decision:[/]") + console.print() + console.print(" [green]1.[/] Answer — record the resolution and close") + console.print(" [yellow]2.[/] Waive — record a reason and close") + console.print() + choice = click.prompt("Choice", type=click.Choice(["1", "2"])) + console.print() + if choice == "1": + answer_text = click.prompt("Answer") + else: + waive_reason = click.prompt("Waive reason") + + if answer_text is not None: + if not answer_text.strip(): + _emit_or_print_validation( + json_mode=json_mode, + verb="review.answer", + message="Answer text must not be empty", + ) + + if json_mode: + with json_error_handler("review.answer"): + await ledger_answer(client, bead_id=bead_id, answer_text=answer_text) + # Projection re-read is outside the handler on purpose — see + # `_project_after_write`. The write is already committed here. + row = await _project_after_write(client, bead_id) + payload: dict[str, object] = {"entry": row, "action": "answered"} + if row is None: + payload["degraded"] = True + emit_json(JsonEnvelope.success("review.answer", payload)) + return + + try: + await ledger_answer(client, bead_id=bead_id, answer_text=answer_text) + except AssumptionLedgerError as exc: + console.print(f"[red]Error:[/] {exc}") + raise SystemExit(ExitCode.FAILURE) from exc + console.print(f"\n[green]✓[/] Bead {bead_id} answered and closed.") + else: + if not waive_reason or not waive_reason.strip(): + _emit_or_print_validation( + json_mode=json_mode, + verb="review.waive", + message="Waive reason must not be empty", + ) + + waived_by = _resolve_git_user_name(Path.cwd()) + + if json_mode: + with json_error_handler("review.waive"): + await ledger_waive( + client, bead_id=bead_id, reason=waive_reason, waived_by=waived_by + ) + # Outside the handler — same rationale as the answer path above. + row = await _project_after_write(client, bead_id) + payload = {"entry": row, "action": "waived"} + if row is None: + payload["degraded"] = True + emit_json(JsonEnvelope.success("review.waive", payload)) + return + + try: + await ledger_waive(client, bead_id=bead_id, reason=waive_reason, waived_by=waived_by) + except AssumptionLedgerError as exc: + console.print(f"[red]Error:[/] {exc}") + raise SystemExit(ExitCode.FAILURE) from exc + console.print(f"\n[yellow]⚠[/] Bead {bead_id} waived by {waived_by} and closed.") diff --git a/src/maverick/cli/commands/review/legacy.py b/src/maverick/cli/commands/review/legacy.py new file mode 100644 index 00000000..d7dfe373 --- /dev/null +++ b/src/maverick/cli/commands/review/legacy.py @@ -0,0 +1,221 @@ +"""Legacy escalation-bead review flow (approve/reject/defer). + +Split out of ``maverick.cli.commands.review`` (T001, +053-assumption-review-console) — behavior-preserving move, no logic changes. +Handles beads labeled ``needs-human-review`` / ``assumption-review`` that +predate the assumption ledger (i.e. lack the ``assumption`` label). + +``--json`` support (T007/T011/T012) added on top: contracts/cli-review-json.md +documents legacy approve/reject/defer under the ``review.answer`` verb +("Legacy escalation beads accept --approve / --reject / --defer +instead; result then reports the legacy action taken"). JSON mode never +prompts — no flag and no ``is_human`` label both become ``validation`` +errors instead of an interactive confirm/prompt. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, NoReturn + +import click + +from maverick.cli.console import console +from maverick.cli.context import ExitCode + +if TYPE_CHECKING: + from maverick.beads.client import BeadClient + from maverick.beads.models import BeadDetails + +#: contracts/cli-review-json.md documents legacy approve/reject/defer under +#: the `review.answer` verb section. +_LEGACY_VERB = "review.answer" + + +def _fail_validation(*, json_mode: bool, message: str) -> NoReturn: + if json_mode: + from maverick.cli.json_output import ErrorKind, JsonEnvelope, emit_json + + emit_json(JsonEnvelope.failure(_LEGACY_VERB, ErrorKind.VALIDATION, message)) + else: + console.print(f"[red]Error:[/] {message}") + raise SystemExit(ExitCode.FAILURE) + + +async def handle_legacy_review( + client: BeadClient, + bead_id: str, + details: BeadDetails, + *, + approve: bool, + reject_guidance: str | None, + defer: bool, + json_mode: bool = False, +) -> None: + """Display legacy escalation context and execute an approve/reject/defer decision. + + Rejected beads spawn a correction bead assigned to the next `maverick fly` run. + """ + from maverick.beads.models import BeadCategory, BeadDefinition, BeadType + + state = details.state or {} + labels = details.labels or [] + + # Verify this is a human-assigned review bead + is_human = "needs-human-review" in labels or "assumption-review" in labels + if not is_human: + if json_mode: + _fail_validation( + json_mode=True, + message=f"Bead {bead_id} is not flagged for human review (labels: {labels})", + ) + console.print( + f"[yellow]Warning:[/] Bead {bead_id} is not flagged for " + f"human review (labels: {labels})" + ) + if not click.confirm("Review anyway?", default=False): + return + + # Display the review context + source_bead = state.get("source_bead", "unknown") + escalation_type = state.get("escalation_type", "unknown") + flight_plan = state.get("flight_plan", "unknown") + + if not json_mode: + console.print() + console.print("[bold]━" * 60) + console.print(f"[bold] Assumption Review: {details.title}") + console.print("[bold]━" * 60) + console.print() + console.print(f"[dim]Source bead:[/] {source_bead}") + console.print(f"[dim]Flight plan:[/] {flight_plan}") + console.print(f"[dim]Escalation:[/] {escalation_type}") + console.print() + + if details.description: + console.print(details.description) + console.print() + + console.print("[bold]━" * 60) + console.print() + + # Determine decision — from flags or interactive prompt + if approve: + decision = "approve" + guidance = "" + elif reject_guidance is not None: + decision = "reject" + guidance = reject_guidance + elif defer: + decision = "defer" + guidance = "" + elif json_mode: + _fail_validation( + json_mode=True, + message="Provide --approve, --reject, or --defer in --json mode " + "(no interactive prompt available)", + ) + else: + # Interactive mode + console.print("[bold]Decision:[/]") + console.print() + console.print(" [green]1.[/] Approve — the current implementation is acceptable") + console.print(" [red]2.[/] Reject — needs correction (you'll provide guidance)") + console.print(" [yellow]3.[/] Defer — not enough information, skip for now") + console.print() + + choice = click.prompt("Choice", type=click.Choice(["1", "2", "3"])) + + if choice == "1": + decision = "approve" + guidance = "" + elif choice == "2": + decision = "reject" + console.print() + console.print( + "[bold]Guidance for the correction agent[/] (brief note — what should change?):" + ) + guidance = click.prompt(">") + else: + decision = "defer" + guidance = "" + + # Execute the decision + if decision == "approve": + await client.close(bead_id, reason="approved") + if json_mode: + from maverick.cli.json_output import JsonEnvelope, emit_json + + emit_json(JsonEnvelope.success(_LEGACY_VERB, {"action": "approved"})) + return + console.print(f"\n[green]✓[/] Bead {bead_id} closed as approved.") + + elif decision == "reject": + # Create a correction bead assigned to an agent + correction_title = f"Correction: {details.title[:150]}" + correction_desc = ( + f"## Human Guidance\n\n{guidance}\n\n" + f"## Original Escalation\n\n{details.description or 'N/A'}\n\n" + f"## Source Bead\n\n{source_bead}" + ) + + correction_def = BeadDefinition( + title=correction_title, + bead_type=BeadType.TASK, + priority=1, + category=BeadCategory.VALIDATION, + description=correction_desc, + labels=["correction", f"corrects:{bead_id}"], + ) + + # Resolve parent epic from source bead + parent_id = None + try: + source_details = await client.show(source_bead) + parent_id = source_details.parent_id + except Exception: + pass + + try: + created = await client.create_bead(correction_def, parent_id=parent_id) + except Exception as exc: + message = f"Failed to create correction bead: {exc}" + if json_mode: + from maverick.cli.json_output import ErrorKind, JsonEnvelope, emit_json + + emit_json(JsonEnvelope.failure(_LEGACY_VERB, ErrorKind.INTERNAL, message)) + raise SystemExit(ExitCode.FAILURE) from exc + console.print(f"\n[red]Error:[/] {message}") + console.print("Close the review bead manually when ready.") + raise SystemExit(ExitCode.FAILURE) from exc + + if not json_mode: + console.print(f"\n[yellow]→[/] Correction bead created: {created.bd_id}") + + # Close the review bead as rejected + await client.close(bead_id, reason=f"rejected: {guidance[:200]}") + + if json_mode: + from maverick.cli.json_output import JsonEnvelope, emit_json + + emit_json( + JsonEnvelope.success( + _LEGACY_VERB, + {"action": "rejected", "correction_bead_id": created.bd_id}, + ) + ) + return + + console.print(f"[red]✗[/] Bead {bead_id} closed as rejected.") + console.print( + f"\nThe correction bead ({created.bd_id}) will be picked up " + f"by the next `maverick fly` run." + ) + + elif decision == "defer": + if json_mode: + from maverick.cli.json_output import JsonEnvelope, emit_json + + emit_json(JsonEnvelope.success(_LEGACY_VERB, {"action": "deferred"})) + return + console.print(f"\n[yellow]⏸[/] Bead {bead_id} deferred — no action taken.") + console.print("Run `maverick review` again when ready.") diff --git a/src/maverick/cli/commands/review/listing.py b/src/maverick/cli/commands/review/listing.py new file mode 100644 index 00000000..2be6df9c --- /dev/null +++ b/src/maverick/cli/commands/review/listing.py @@ -0,0 +1,173 @@ +"""``maverick review --list [--json]`` — list assumption-ledger entries. + +Split out of ``maverick.cli.commands.review`` (T006/T010, +053-assumption-review-console). One bd sweep via +``assumptions.ledger.report_entries``, filtered in-process by +status/spec/severity, sorted into the canonical presentation order, and +rendered either as a JSON envelope (``verb`` ``review.list``) or a minimal +Rich table. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +from rich.markup import escape +from rich.table import Table + +from maverick.assumptions.models import STATUS_OPEN +from maverick.cli.console import console +from maverick.cli.context import ExitCode + +if TYPE_CHECKING: + from maverick.assumptions.models import AssumptionReportEntry + +__all__ = ["run_list"] + +#: Presentation-order rank — high severity sorts first (data-model.md +#: "canonical ordering": owner_spec asc, then severity high->low, then +#: stable ledger order). +_SEVERITY_RANK: dict[str, int] = {"high": 2, "medium": 1, "low": 0} + + +def _filter_and_sort( + entries: tuple[AssumptionReportEntry, ...], + *, + statuses: frozenset[str], + owner_specs: frozenset[str], + severities: frozenset[str], +) -> list[AssumptionReportEntry]: + """Apply the AND-across/OR-within status/spec/severity filters, then sort. + + Sort key: ``(owner_spec, -severity_rank, original_index)`` — Python's + ``sorted`` is stable, so capturing ``original_index`` before sorting + preserves ``report_entries()``'s ledger order as the final tiebreak + without needing any special-casing. + """ + indexed = list(enumerate(entries)) + selected = [ + (idx, entry) + for idx, entry in indexed + if entry.record.status in statuses + and (not owner_specs or entry.record.owner_spec in owner_specs) + and (not severities or entry.record.severity.value in severities) + ] + selected.sort( + key=lambda pair: ( + pair[1].record.owner_spec, + -_SEVERITY_RANK[pair[1].record.severity.value], + pair[0], + ) + ) + return [entry for _, entry in selected] + + +def _build_counts(entries: list[AssumptionReportEntry]) -> dict[str, object]: + """The ``counts`` object — reflects the filtered selection (contract).""" + by_status = {"open": 0, "answered": 0, "waived": 0} + by_severity = {"low": 0, "medium": 0, "high": 0} + pending_reconcile = 0 + for entry in entries: + status = entry.record.status + if status in by_status: + by_status[status] += 1 + by_severity[entry.record.severity.value] += 1 + if entry.pending_reconcile: + pending_reconcile += 1 + return { + "total": len(entries), + "by_status": by_status, + "by_severity": by_severity, + "pending_reconcile": pending_reconcile, + } + + +def _render_table(entries: list[AssumptionReportEntry], counts: dict[str, object]) -> None: + """Minimal human table (contract: "new, minimal; reuses the same data").""" + if not entries: + console.print("No assumption-ledger entries match.") + return + + table = Table(show_header=True, header_style="bold") + table.add_column("ID") + table.add_column("Status") + table.add_column("Severity") + table.add_column("Spec") + table.add_column("Question") + + for entry in entries: + question = entry.record.question or entry.record.bead_id + question = question[:80] + "..." if len(question) > 80 else question + # Agent/human-authored free text: `escape` it, or Rich silently + # eats any `[...]` run as a style tag. + table.add_row( + entry.record.bead_id, + entry.record.status, + entry.record.severity.value, + escape(entry.record.owner_spec), + escape(question), + ) + + console.print(table) + console.print(f"Total: {counts['total']}") + + +async def run_list( + *, + statuses: frozenset[str], + owner_specs: frozenset[str], + severities: frozenset[str], + json_mode: bool, +) -> None: + """List assumption-ledger entries, filtered and canonically ordered. + + Verifies bd availability first (contract: ``bd-unavailable`` / + exit 1), then runs one ``report_entries()`` sweep and filters/sorts/ + projects in-process — no second bd call. + """ + from maverick.assumptions.ledger import report_entries + from maverick.assumptions.serialize import entry_to_dict + from maverick.beads.client import BeadClient + from maverick.cli.json_output import ErrorKind, JsonEnvelope, emit_json, json_error_handler + + client = BeadClient(cwd=Path.cwd()) + if not await client.verify_available(): + message = "bd is not available" + if json_mode: + emit_json(JsonEnvelope.failure("review.list", ErrorKind.BD_UNAVAILABLE, message)) + else: + console.print(f"[red]Error:[/] {message}") + raise SystemExit(ExitCode.FAILURE) + + effective_statuses = statuses or frozenset({STATUS_OPEN}) + + if json_mode: + with json_error_handler("review.list"): + entries = await report_entries(client) + else: + from maverick.assumptions.errors import AssumptionLedgerError + + try: + entries = await report_entries(client) + except AssumptionLedgerError as exc: + console.print(f"[red]Error:[/] {exc}") + raise SystemExit(ExitCode.FAILURE) from exc + + selected = _filter_and_sort( + entries, + statuses=effective_statuses, + owner_specs=owner_specs, + severities=severities, + ) + counts = _build_counts(selected) + + if json_mode: + result: dict[str, object] = { + "entries": [entry_to_dict(entry) for entry in selected], + "counts": counts, + } + emit_json(JsonEnvelope.success("review.list", result)) + return + + _render_table(selected, counts) diff --git a/src/maverick/cli/commands/uninstall.py b/src/maverick/cli/commands/uninstall.py index 075cba4f..26c31ca3 100644 --- a/src/maverick/cli/commands/uninstall.py +++ b/src/maverick/cli/commands/uninstall.py @@ -1,8 +1,9 @@ """Maverick uninstall command. -Removes the project's ``maverick.yaml`` configuration file. The earlier -``~/.claude/skills/maverick-*`` cleanup is gone — those skills were -never installed by ``maverick init`` so there's nothing to clean up. +Removes the project's ``maverick.yaml`` configuration file and the +packaged ``maverick-review`` Claude Code skill installed by +``maverick init`` (053-assumption-review-console) at +``.claude/skills/maverick-review/SKILL.md``. """ from __future__ import annotations @@ -13,6 +14,7 @@ from maverick.cli.console import console, err_console from maverick.logging import get_logger +from maverick.skills import REVIEW_SKILL_RELATIVE_PATH logger = get_logger(__name__) @@ -40,28 +42,42 @@ def uninstall( force: bool, verbose: bool, ) -> None: - """Remove ``maverick.yaml`` from the current directory. + """Remove ``maverick.yaml`` and the maverick-review skill. Examples: - maverick uninstall # Remove maverick.yaml + maverick uninstall # Remove maverick.yaml + skill maverick uninstall --dry-run # Preview what would be removed maverick uninstall --force # Skip confirmation """ config_path = Path.cwd() / "maverick.yaml" + skill_path = Path.cwd() / REVIEW_SKILL_RELATIVE_PATH config_exists = config_path.exists() + skill_exists = skill_path.exists() - if not config_exists: + if not config_exists and not skill_exists: console.print("Nothing to remove.") if verbose: - logger.info("cleanup_nothing_to_do", config_path=str(config_path)) + logger.info( + "cleanup_nothing_to_do", + config_path=str(config_path), + skill_path=str(skill_path), + ) return if dry_run or not force: console.print("[bold]The following will be removed:[/]") console.print() - console.print("Configuration file:") - console.print(f" - [dim]{config_path}[/]") - console.print() + if config_exists: + console.print("Configuration file:") + # `soft_wrap` keeps the path on one line: Rich otherwise wraps at + # the terminal width, splitting a long path mid-segment so it + # can't be copied out of the terminal. + console.print(f" - [dim]{config_path}[/]", soft_wrap=True) + console.print() + if skill_exists: + console.print("Review-console skill:") + console.print(f" - [dim]{skill_path}[/]", soft_wrap=True) + console.print() if dry_run: console.print("[dim]\\[DRY RUN] No files were removed.[/]") @@ -71,21 +87,82 @@ def uninstall( console.print("Cleanup canceled.") return + removed_config = False + if config_exists: + try: + config_path.unlink() + removed_config = True + if verbose: + logger.info("config_removed", path=str(config_path)) + except Exception as e: + logger.warning( + "config_removal_failed", + path=str(config_path), + error=str(e), + ) + err_console.print( + f"[yellow]Warning:[/yellow] Failed to remove {config_path}: {e}", + ) + + removed_skill = False + if skill_exists: + removed_skill = _remove_review_skill(skill_path, verbose) + + if not removed_config and not removed_skill: + return + + console.print() + console.print("[bold]Cleanup complete:[/]") + if removed_config: + console.print(" [green]check[/] Removed configuration file") + if removed_skill: + console.print(" [green]check[/] Removed maverick-review skill") + + +def _remove_review_skill(skill_path: Path, verbose: bool) -> bool: + """Remove the packaged skill file and prune now-empty parent dirs. + + Removes ``/.claude/skills/maverick-review/`` entirely (it's + Maverick-owned — nothing else lives there), then removes + ``.claude/skills/`` too, but only if it's empty afterward (a user may + have other, unrelated skills installed there). ``.claude/`` itself is + never removed. + + Returns: + ``True`` on success, ``False`` on a (logged) failure. + """ + import shutil + + skill_dir = skill_path.parent # .claude/skills/maverick-review/ + skills_dir = skill_dir.parent # .claude/skills/ + try: - config_path.unlink() - if verbose: - logger.info("config_removed", path=str(config_path)) + shutil.rmtree(skill_dir) except Exception as e: logger.warning( - "config_removal_failed", - path=str(config_path), + "skill_removal_failed", + path=str(skill_path), error=str(e), ) err_console.print( - f"[yellow]Warning:[/yellow] Failed to remove {config_path}: {e}", + f"[yellow]Warning:[/yellow] Failed to remove {skill_path}: {e}", ) - return + return False - console.print() - console.print("[bold]Cleanup complete:[/]") - console.print(" [green]check[/] Removed configuration file") + if verbose: + logger.info("skill_removed", path=str(skill_path)) + + try: + if skills_dir.is_dir() and not any(skills_dir.iterdir()): + skills_dir.rmdir() + if verbose: + logger.info("skills_dir_removed", path=str(skills_dir)) + except OSError as e: + # Best-effort — leaving an empty skills/ dir behind is harmless. + logger.debug( + "skills_dir_removal_skipped", + path=str(skills_dir), + error=str(e), + ) + + return True diff --git a/src/maverick/cli/common.py b/src/maverick/cli/common.py index f00d4210..56b0859a 100644 --- a/src/maverick/cli/common.py +++ b/src/maverick/cli/common.py @@ -3,6 +3,7 @@ import contextlib from collections.abc import Generator from pathlib import Path +from typing import Any from maverick.cli.console import err_console from maverick.cli.context import ExitCode @@ -55,6 +56,64 @@ def cli_error_handler() -> Generator[None, None, None]: raise SystemExit(ExitCode.FAILURE) from e +def resolve_verbosity(ctx: Any) -> int: + """Read the CLI verbosity level (``-v`` count) off a Click context. + + The root group stores it as ``ctx.obj["verbose"]`` + (``maverick.main.cli``). Callers reaching for ``ctx.obj["verbosity"]`` + silently got ``0`` no matter how many ``-v`` flags were passed, which + is why verbose progress rendering never engaged; both spellings are + accepted here so neither reader can regress. + + Args: + ctx: Click context (or anything with an ``obj`` mapping). + + Returns: + The verbosity level, ``0`` when unset. + """ + obj = getattr(ctx, "obj", None) + if not obj: + return 0 + value = obj.get("verbose", obj.get("verbosity", 0)) + return int(value or 0) + + +#: ``bd_ready_reason`` result codes. ``BD_MISSING`` — the CLI isn't on +#: PATH; ``BD_NOT_INITIALIZED`` — it is, but this project has no +#: ``.beads/``. +BD_MISSING = "bd-missing" +BD_NOT_INITIALIZED = "bd-not-initialized" + + +def bd_ready_reason(cwd: Path | None = None) -> str | None: + """Why ``bd`` isn't usable in *cwd*, or ``None`` when it is. + + The single predicate behind every bd preflight — :func:`verify_bd_ready` + (human mode, prints + exits) and ``maverick.cli.commands.reconcile. + _require_bd_ready_json`` (JSON mode, raises ``BeadError``) both consume + it, so the two can't drift as conditions are added. + + Args: + cwd: Project root to check. Defaults to ``Path.cwd()``. + + Returns: + :data:`BD_MISSING`, :data:`BD_NOT_INITIALIZED`, or ``None`` when + both checks pass. + """ + import shutil + + from maverick.beads.client import BeadClient + + if shutil.which("bd") is None: + return BD_MISSING + + target = cwd if cwd is not None else Path.cwd() + if not BeadClient(cwd=target).is_initialized(): + return BD_NOT_INITIALIZED + + return None + + def verify_bd_ready(cwd: Path | None = None) -> None: """Preflight: ``bd`` is on PATH AND ``.beads/`` is initialized in ``cwd``. @@ -65,16 +124,21 @@ def verify_bd_ready(cwd: Path | None = None) -> None: Exits with :class:`ExitCode.FAILURE` and a friendly remediation message when either check fails. Returns ``None`` when both pass. + The conditions themselves live in :func:`bd_ready_reason`; this + function owns only the human-facing rendering. Args: cwd: Project root to check. Defaults to ``Path.cwd()``. """ - import shutil - - from maverick.beads.client import BeadClient from maverick.cli.console import console - if shutil.which("bd") is None: + reason = bd_ready_reason(cwd) + if reason is None: + return + + target = cwd if cwd is not None else Path.cwd() + + if reason == BD_MISSING: console.print( "[red]Error:[/red] The [bold]bd[/bold] CLI is required but not found " "on PATH.\n" @@ -83,19 +147,16 @@ def verify_bd_ready(cwd: Path | None = None) -> None: ) raise SystemExit(ExitCode.FAILURE) - target = cwd if cwd is not None else Path.cwd() - client = BeadClient(cwd=target) - if not client.is_initialized(): - console.print( - f"[red]Error:[/red] this project hasn't been initialized for " - f"Maverick yet.\n" - f"Run [cyan]maverick init[/cyan] in [cyan]{target}[/cyan] — " - f"it's safe to re-run on an existing project (it won't " - f"overwrite [bold]maverick.yaml[/bold]) and handles both " - f"fresh setups and joining a project where a teammate has " - f"already done the initial work.\n" - f"[dim]Tip: any cached briefing / outline / details from a " - f"previous run will be picked up automatically, so re-running " - f"the workflow after init is a fast cache-hit pass.[/]" - ) - raise SystemExit(ExitCode.FAILURE) + console.print( + f"[red]Error:[/red] this project hasn't been initialized for " + f"Maverick yet.\n" + f"Run [cyan]maverick init[/cyan] in [cyan]{target}[/cyan] — " + f"it's safe to re-run on an existing project (it won't " + f"overwrite [bold]maverick.yaml[/bold]) and handles both " + f"fresh setups and joining a project where a teammate has " + f"already done the initial work.\n" + f"[dim]Tip: any cached briefing / outline / details from a " + f"previous run will be picked up automatically, so re-running " + f"the workflow after init is a fast cache-hit pass.[/]" + ) + raise SystemExit(ExitCode.FAILURE) diff --git a/src/maverick/cli/json_output.py b/src/maverick/cli/json_output.py new file mode 100644 index 00000000..f0eab12b --- /dev/null +++ b/src/maverick/cli/json_output.py @@ -0,0 +1,258 @@ +"""JSON envelope machinery for ``--json`` CLI verbs (feature 053). + +Every Maverick command invoked with ``--json`` emits exactly one JSON +document to stdout: an envelope with ``ok: true`` and a ``result`` payload +on success, or ``ok: false`` and a structured ``error`` on failure. See +``specs/053-assumption-review-console/contracts/error-envelope.md`` for the +full normative contract (this module is its implementation). + +``json_error_handler()`` is the JSON-mode sibling of +:func:`maverick.cli.common.cli_error_handler` — same shape (a context +manager that catches exceptions and raises ``SystemExit``), but instead of +formatting to stderr it builds a failure :class:`JsonEnvelope`, writes it to +stdout via :func:`emit_json`, and raises ``SystemExit(ExitCode.FAILURE)``. + +**Scope note on ``bd-unavailable``**: there is no single "bd unavailable" +exception type today — commands currently check via +``BeadClient.verify_available()`` returning ``False`` and print+exit +manually rather than raising. ``json_error_handler`` maps *bead exceptions* +(``BeadError`` and subclasses) to ``bd-unavailable`` when they're raised, +but the precondition check (``if not await client.verify_available():``) is +each command's own responsibility to translate into an envelope + exit in +JSON mode, same as today's non-JSON pattern — this handler cannot detect a +check that never raises. +""" + +from __future__ import annotations + +import contextlib +from collections.abc import Generator +from dataclasses import dataclass, field +from enum import StrEnum +from typing import Any + +from maverick.cli.context import ExitCode +from maverick.cli.output import write_json_document +from maverick.exceptions import ( + REASON_CONCURRENT_RUN, + REASON_DIRTY_WORKING_COPY, + REASON_LOCKED, + BeadError, + JjError, + MaverickError, + WorkflowError, +) +from maverick.logging import get_logger + +__all__ = [ + "ErrorKind", + "JsonError", + "JsonEnvelope", + "emit_json", + "json_error_handler", +] + + +class ErrorKind(StrEnum): + """Stable, machine-branchable failure taxonomy. + + Additive evolution only — values are part of the public contract (see + ``contracts/error-envelope.md``). Never rename or remove a value. + """ + + VALIDATION = "validation" + NOT_FOUND = "not-found" + ALREADY_RESOLVED = "already-resolved" + BD_UNAVAILABLE = "bd-unavailable" + DIRTY_WORKING_COPY = "dirty-working-copy" + CONCURRENT_RUN = "concurrent-run" + LOCKED = "locked" + FRONTIER_BLOCKED = "frontier-blocked" + CONFIRMATION_REQUIRED = "confirmation-required" + CURATION_FAILED = "curation-failed" + VCS = "vcs" + INTERNAL = "internal" + + +@dataclass(frozen=True, slots=True) +class JsonError: + """The ``error`` object nested inside a failure :class:`JsonEnvelope`. + + Attributes: + kind: Stable identifier — safe to branch on. + message: Human-readable prose — never branch on this. + details: Verb-specific structured context; empty when there's no + additional context to attach. + """ + + kind: ErrorKind + message: str + details: dict[str, object] = field(default_factory=dict) + + +@dataclass(frozen=True, slots=True) +class JsonEnvelope: + """The one shape every ``--json`` document takes. + + Exactly one of ``result``/``error`` is populated; :meth:`to_dict` omits + whichever is absent rather than emitting it as ``null``. + """ + + schema_version: int + verb: str + ok: bool + result: dict[str, object] | None = None + error: JsonError | None = None + + @classmethod + def success(cls, verb: str, result: dict[str, object]) -> JsonEnvelope: + """Build a success envelope (``ok: true``) wrapping ``result``.""" + return cls(schema_version=1, verb=verb, ok=True, result=result) + + @classmethod + def failure( + cls, + verb: str, + kind: ErrorKind, + message: str, + details: dict[str, object] | None = None, + ) -> JsonEnvelope: + """Build a failure envelope (``ok: false``) wrapping a structured error.""" + return cls( + schema_version=1, + verb=verb, + ok=False, + error=JsonError(kind=kind, message=message, details=details or {}), + ) + + def to_dict(self) -> dict[str, object]: + """Serialize to the envelope's public JSON shape. + + Omits whichever of ``result``/``error`` is ``None`` entirely + (mirroring ``LandReport.to_dict()``'s "omit absent key" style in + ``maverick.assumptions.land_report``) rather than emitting + ``"result": null`` / ``"error": null``. + """ + data: dict[str, Any] = { + "schema_version": self.schema_version, + "verb": self.verb, + "ok": self.ok, + } + if self.result is not None: + data["result"] = self.result + if self.error is not None: + data["error"] = { + "kind": self.error.kind.value, + "message": self.error.message, + "details": self.error.details, + } + return data + + +def emit_json(envelope: JsonEnvelope) -> None: + """Write ``envelope`` as the sole JSON document on stdout. + + Serializes via :func:`maverick.cli.output.write_json_document` — a + dedicated, non-markup, non-wrapping Rich ``Console`` — so the document + is never corrupted by ANSI codes or line wrapping regardless of whether + stdout is a TTY. + """ + write_json_document(envelope.to_dict()) + + +# Primary dispatch: `WorkflowError.reason_code`, a stable code the raiser sets +# (see maverick.exceptions.workflow's REASON_* constants). Reword a message +# and this keeps working. +_REASON_TO_KIND: dict[str, ErrorKind] = { + REASON_DIRTY_WORKING_COPY: ErrorKind.DIRTY_WORKING_COPY, + REASON_CONCURRENT_RUN: ErrorKind.CONCURRENT_RUN, + REASON_LOCKED: ErrorKind.LOCKED, +} + +# Fallback dispatch for `WorkflowError`s raised without a `reason_code` — +# third-party or not-yet-migrated call sites. Substring-matching prose is +# fragile by construction; it exists only so an un-migrated raiser degrades +# to the right kind instead of `internal`. New raisers set `reason_code=`. +_MESSAGE_MARKERS: tuple[tuple[str, ErrorKind], ...] = ( + ("working copy is not clean", ErrorKind.DIRTY_WORKING_COPY), + ("fly run is in progress", ErrorKind.CONCURRENT_RUN), + ("already in progress", ErrorKind.LOCKED), +) + + +def _workflow_error_kind(exc: WorkflowError) -> ErrorKind: + """Classify a ``WorkflowError``: typed ``reason_code`` first, prose second.""" + code = getattr(exc, "reason_code", None) + if code is not None and code in _REASON_TO_KIND: + return _REASON_TO_KIND[code] + for marker, kind in _MESSAGE_MARKERS: + if marker in exc.message: + return kind + return ErrorKind.INTERNAL + + +@contextlib.contextmanager +def json_error_handler(verb: str) -> Generator[None, None, None]: + """JSON-mode sibling of :func:`maverick.cli.common.cli_error_handler`. + + Catches exceptions raised by a ``--json`` command body, maps them to a + failure :class:`JsonEnvelope` for ``verb``, emits it to stdout via + :func:`emit_json`, and raises ``SystemExit(ExitCode.FAILURE)`` — except + ``KeyboardInterrupt``, which per the contract emits **no** JSON document + at all (the sole exception to one-document-per-invocation) and exits + ``ExitCode.INTERRUPTED``. + + Mapping order (specific before generic): + + 1. ``KeyboardInterrupt`` -> no document, ``SystemExit(INTERRUPTED)``. + 2. ``WorkflowError`` -> dispatched on its typed ``reason_code`` + (``dirty-working-copy`` / ``concurrent-run`` / ``locked``), falling + back to prose markers for raisers that set no ``reason_code``; anything + unrecognized falls through to ``internal``. See + :func:`_workflow_error_kind`. + 3. ``JjError`` (and subclasses) -> ``vcs``, with + ``details={"operation": exc.command}`` when ``exc.command`` is set. + 4. ``BeadError`` (and subclasses, e.g. ``BeadQueryError``) -> + ``bd-unavailable``. See the module docstring: the + ``verify_available()`` precondition check itself is NOT covered + here — callers must translate that check's ``False`` result into an + envelope directly. + 5. ``AssumptionLedgerError`` -> ``validation`` (reasonable default for + ledger write failures like empty answer/reason text; downstream + verb implementations may special-case specific instances, e.g. + ``already-resolved``, before ever reaching this generic handler). + 6. ``MaverickError`` (catch-all base class) -> ``internal``. + 7. Bare ``Exception`` -> ``internal``, logged via + ``get_logger(__name__).exception(...)`` first. + """ + # Imported lazily to avoid a hard dependency cycle at module import time + # between maverick.cli (low-level) and maverick.assumptions (which + # itself may import CLI-adjacent helpers in some configurations). + from maverick.assumptions.errors import AssumptionLedgerError + + logger = get_logger(__name__) + + try: + yield + except KeyboardInterrupt: + raise SystemExit(ExitCode.INTERRUPTED) from None + except WorkflowError as e: + emit_json(JsonEnvelope.failure(verb, _workflow_error_kind(e), e.message)) + raise SystemExit(ExitCode.FAILURE) from e + except JjError as e: + details: dict[str, object] = {"operation": e.command} if e.command else {} + emit_json(JsonEnvelope.failure(verb, ErrorKind.VCS, e.message, details)) + raise SystemExit(ExitCode.FAILURE) from e + except BeadError as e: + emit_json(JsonEnvelope.failure(verb, ErrorKind.BD_UNAVAILABLE, e.message)) + raise SystemExit(ExitCode.FAILURE) from e + except AssumptionLedgerError as e: + emit_json(JsonEnvelope.failure(verb, ErrorKind.VALIDATION, e.message)) + raise SystemExit(ExitCode.FAILURE) from e + except MaverickError as e: + emit_json(JsonEnvelope.failure(verb, ErrorKind.INTERNAL, e.message)) + raise SystemExit(ExitCode.FAILURE) from e + except Exception as e: + logger.exception("Unexpected error in JSON command") + emit_json(JsonEnvelope.failure(verb, ErrorKind.INTERNAL, str(e))) + raise SystemExit(ExitCode.FAILURE) from e diff --git a/src/maverick/cli/output.py b/src/maverick/cli/output.py index fdcc1975..6143f492 100644 --- a/src/maverick/cli/output.py +++ b/src/maverick/cli/output.py @@ -6,9 +6,12 @@ from __future__ import annotations import json +import sys from enum import StrEnum from typing import Any +from rich.console import Console + __all__ = [ "OutputFormat", "format_bytes", @@ -17,6 +20,7 @@ "format_warning", "format_json", "format_table", + "write_json_document", ] @@ -138,6 +142,42 @@ def format_json(data: Any) -> str: return json.dumps(data, indent=2) +def write_json_document(data: dict[str, Any]) -> None: + """Write a single JSON document to stdout with no markup or styling. + + Transport helper for ``--json`` mode (see + ``maverick.cli.json_output.emit_json``): serializes ``data`` compactly + and writes exactly one line to stdout via a dedicated Rich ``Console`` + configured for plain, unstyled, non-wrapping output — safe even when + stdout isn't a TTY (color/markup would otherwise corrupt the JSON for + machine consumers). + + A fresh ``Console`` is constructed on every call (rather than a + module-level singleton) so it always targets the *current* ``sys.stdout`` + — this matters for tests that patch ``sys.stdout`` after import (e.g. + ``capsys``). + + Args: + data: JSON-serializable mapping to emit as the sole document. + """ + payload = json.dumps(data, separators=(",", ":")) + json_console = Console( + file=sys.stdout, + markup=False, + highlight=False, + soft_wrap=True, + no_color=True, + # Rich substitutes `:name:` tokens with unicode emoji by default. + # Agent- and human-authored free text (questions, answers, waive + # reasons) routinely contains `:key:`-shaped runs, and silently + # rewriting them would corrupt every consumer's view of the ledger + # — including `review --answer ""` writing the + # mutated text straight back in. Must stay off. + emoji=False, + ) + json_console.print(payload) + + def format_table(headers: list[str], rows: list[list[str]]) -> str: """Format data as a simple text table with pipe separators. diff --git a/src/maverick/cli/workflow_executor.py b/src/maverick/cli/workflow_executor.py index cff2389c..d0c9979e 100644 --- a/src/maverick/cli/workflow_executor.py +++ b/src/maverick/cli/workflow_executor.py @@ -20,6 +20,7 @@ from maverick.cli.common import ( cli_error_handler, + resolve_verbosity, ) from maverick.cli.console import console, err_console from maverick.cli.context import ExitCode @@ -599,7 +600,7 @@ async def execute_python_workflow( console.print(f"[dim]Session log: {run_config.session_log_path}[/]") # Determine verbosity from Click context. - verbosity = ctx.obj.get("verbosity", 0) if ctx.obj else 0 + verbosity = resolve_verbosity(ctx) # Count workflow steps for progress display. steps_meta = getattr(workflow_class, "STEPS", None) or {} diff --git a/src/maverick/exceptions/__init__.py b/src/maverick/exceptions/__init__.py index 29ea7daf..43cfdfe7 100644 --- a/src/maverick/exceptions/__init__.py +++ b/src/maverick/exceptions/__init__.py @@ -134,6 +134,9 @@ # Workflow-related exceptions from maverick.exceptions.workflow import ( + REASON_CONCURRENT_RUN, + REASON_DIRTY_WORKING_COPY, + REASON_LOCKED, CheckpointNotFoundError, DuplicateComponentError, DuplicateStepNameError, @@ -223,6 +226,9 @@ "InputMismatchError", "ReferenceResolutionError", "StagesNotFoundError", + "REASON_CONCURRENT_RUN", + "REASON_DIRTY_WORKING_COPY", + "REASON_LOCKED", "WorkflowError", "WorkflowStepError", # Runway diff --git a/src/maverick/exceptions/workflow.py b/src/maverick/exceptions/workflow.py index 8cbee4f0..24834920 100644 --- a/src/maverick/exceptions/workflow.py +++ b/src/maverick/exceptions/workflow.py @@ -2,6 +2,15 @@ from maverick.exceptions.base import MaverickError +#: Stable, machine-branchable ``WorkflowError.reason_code`` values for the +#: precondition failures a CLI needs to distinguish. Kept here (not in +#: ``maverick.cli``) so the workflow layer can set them without importing +#: anything CLI-shaped; ``maverick.cli.json_output`` maps them onto its +#: ``ErrorKind`` registry. Additive only — never rename or remove a value. +REASON_DIRTY_WORKING_COPY = "dirty-working-copy" +REASON_CONCURRENT_RUN = "concurrent-run" +REASON_LOCKED = "locked" + class WorkflowError(MaverickError): """Base exception for workflow-related errors. @@ -9,16 +18,31 @@ class WorkflowError(MaverickError): Attributes: message: Human-readable error message. workflow_name: Name of the workflow that failed (if known). + reason_code: Optional stable code (one of the ``REASON_*`` + constants above) letting callers branch on *what* failed + without pattern-matching the human message. ``None`` for the + vast majority of workflow errors, which carry prose only. + Deliberately NOT named ``reason`` — :class:`WorkflowStepError` + already owns that attribute for free-text failure prose, a + different meaning. """ - def __init__(self, message: str, workflow_name: str | None = None) -> None: + def __init__( + self, + message: str, + workflow_name: str | None = None, + *, + reason_code: str | None = None, + ) -> None: """Initialize the WorkflowError. Args: message: Human-readable error message. workflow_name: Optional name of the workflow that failed. + reason_code: Optional stable code (``REASON_*`` constant). """ self.workflow_name = workflow_name + self.reason_code = reason_code super().__init__(message) diff --git a/src/maverick/init/__init__.py b/src/maverick/init/__init__.py index 73540b1c..34c9814a 100644 --- a/src/maverick/init/__init__.py +++ b/src/maverick/init/__init__.py @@ -52,6 +52,7 @@ discover_providers, ) from maverick.logging import get_logger +from maverick.skills import REVIEW_SKILL_RELATIVE_PATH __all__ = [ # Functions @@ -581,6 +582,62 @@ async def _maybe_offer_speckit_install(project_path: Path, verbose: bool) -> boo return await install_speckit(project_path) +# ============================================================================= +# Review-console skill install (053-assumption-review-console, always-on) +# ============================================================================= + +#: Where the packaged skill lands in the target project. Relative to +#: ``project_path``. Shared with ``maverick uninstall`` — see +#: :data:`maverick.skills.REVIEW_SKILL_RELATIVE_PATH`. +_REVIEW_SKILL_RELATIVE_PATH = REVIEW_SKILL_RELATIVE_PATH + + +async def _install_review_skill(project_path: Path, verbose: bool) -> bool | None: + """Install/refresh the packaged ``maverick-review`` Claude Code skill. + + Unlike the Spec Kit install offer, this is unconditional and + always-overwrite: the skill is Maverick-owned and versions with the + installed wheel, so every ``init`` run (fresh install or re-init) + stamps the packaged content back over whatever is on disk — a local + edit doesn't "stick" any more than a hand-edited ``maverick.yaml`` + header would. + + Best-effort: any I/O failure is logged and swallowed, never raised — + matches :func:`_ensure_gitignore_entries`'s "never blocks init" + contract. + + Args: + project_path: Project root directory. + verbose: Whether to log progress. + + Returns: + ``True`` on success, ``False`` on a (logged) failure. + """ + try: + from maverick.skills.review_console import load_review_skill_markdown + from maverick.utils.atomic import atomic_write_text + + content = load_review_skill_markdown() + target = project_path / _REVIEW_SKILL_RELATIVE_PATH + # Atomic (and `mkdir=True` by default, so no separate mkdir): + # an interrupted init (Ctrl-C, full disk) must never leave + # a truncated SKILL.md behind — Claude Code would load it and could + # be missing the Prohibitions section that forbids direct jj/git/bd + # access. Same reason `assumptions/land_report.py` writes this way. + atomic_write_text(target, content, encoding="utf-8") + except Exception as exc: + logger.warning( + "review_skill_install_failed", + project_path=str(project_path), + error=str(exc), + ) + return False + + if verbose: + logger.info("review_skill_installed", path=str(project_path / _REVIEW_SKILL_RELATIVE_PATH)) + return True + + # ============================================================================= # .gitignore maintenance (best-effort) # ============================================================================= @@ -906,6 +963,7 @@ async def run_init( await _ensure_gitignore_entries(effective_path, verbose) await _untrack_bd_local_state(effective_path, verbose) speckit_installed = await _maybe_offer_speckit_install(effective_path, verbose) + skill_installed = await _install_review_skill(effective_path, verbose) return InitResult( success=True, config_path=str(config_path), @@ -919,6 +977,7 @@ async def run_init( provider_discovery=None, config_existed=True, speckit_installed=speckit_installed, + skill_installed=skill_installed, ) # Step 3: Detect project type @@ -992,6 +1051,10 @@ async def run_init( # Step 9: Advisory Spec Kit prerequisite check + install offer (R7/US5). speckit_installed = await _maybe_offer_speckit_install(effective_path, verbose) + # Step 10: Install/refresh the packaged maverick-review skill + # (053-assumption-review-console, always-on, best-effort). + skill_installed = await _install_review_skill(effective_path, verbose) + # Build and return result return InitResult( success=True, @@ -1005,4 +1068,5 @@ async def run_init( runway_initialized=runway_initialized, provider_discovery=provider_discovery, speckit_installed=speckit_installed, + skill_installed=skill_installed, ) diff --git a/src/maverick/init/models.py b/src/maverick/init/models.py index 536f3071..c6b6a29d 100644 --- a/src/maverick/init/models.py +++ b/src/maverick/init/models.py @@ -684,6 +684,11 @@ class InitResult: but the installer failed, ``None`` not applicable (already installed) or the offer was skipped/declined (non-interactive session, or the user said no). + skill_installed: Packaged ``maverick-review`` review-console skill + install outcome (053-assumption-review-console): ``True`` the + skill file was written/refreshed this run, ``False`` the + best-effort write failed (logged, non-fatal), ``None`` not + applicable/not attempted. """ success: bool @@ -698,6 +703,7 @@ class InitResult: provider_discovery: ProviderDiscoveryResult | None = None config_existed: bool = False speckit_installed: bool | None = None + skill_installed: bool | None = None def to_dict(self) -> dict[str, Any]: """Convert to dictionary for serialization. @@ -720,6 +726,7 @@ def to_dict(self) -> dict[str, Any]: ), "config_existed": self.config_existed, "speckit_installed": self.speckit_installed, + "skill_installed": self.skill_installed, } diff --git a/src/maverick/skills/__init__.py b/src/maverick/skills/__init__.py new file mode 100644 index 00000000..b274267e --- /dev/null +++ b/src/maverick/skills/__init__.py @@ -0,0 +1,20 @@ +"""Packaged Claude Code skills bundled with maverick. + +Each subpackage ships a ``SKILL.md`` that ``maverick init`` installs into +the target project's ``.claude/skills/`` directory (see +``maverick.skills.review_console``). +""" + +from __future__ import annotations + +from pathlib import Path + +__all__ = ["REVIEW_SKILL_RELATIVE_PATH"] + +#: Where the packaged ``maverick-review`` skill lands in a target project, +#: relative to the project root. Owned here — a leaf module both the +#: install path (``maverick.init._install_review_skill``) and the removal +#: path (``maverick.cli.commands.uninstall``) already import transitively — +#: so the two can never disagree about the location and leave a stale +#: Maverick-owned skill behind after ``maverick uninstall``. +REVIEW_SKILL_RELATIVE_PATH = Path(".claude") / "skills" / "maverick-review" / "SKILL.md" diff --git a/src/maverick/skills/review_console/SKILL.md b/src/maverick/skills/review_console/SKILL.md new file mode 100644 index 00000000..1987948f --- /dev/null +++ b/src/maverick/skills/review_console/SKILL.md @@ -0,0 +1,238 @@ +--- +name: "maverick-review" +description: "Human review console for Maverick assumption sweeps — use when the user wants to review pending assumptions, answer or waive ledger entries, reconcile answers, or land." +user-invocable: true +disable-model-invocation: false +--- + +## Identity + +You are the human review console for Maverick's assumption ledger. Agents +working a Maverick flight plan record assumptions they had to adopt while +implementing beads — open questions with a recommended answer, recorded +alternatives, and a severity. Your job is to walk a human through any +still-open entries one at a time, record their decisions, and report +where the project's assumption frontier stands afterward. + +You are invoked explicitly as `/maverick-review`, and you are also +model-invocable: trigger on prose like "review my pending assumptions", +"walk me through open assumptions", "let's clear the review queue", or +similar requests to look at, answer, or waive ledger entries. + +Your only effect channel is the `maverick review` / `maverick reconcile` +/ `maverick land` CLI verbs, always invoked with `--json`. You never run +`jj`, `git`, or `bd` directly, and you never edit files yourself — see +Prohibitions at the end of this document, which apply to every section +below. + +## Preflight + +1. Run `maverick review --list --json`. +2. If the command's output is not parseable JSON, or the envelope's + `error.kind` is `bd-unavailable`: report the environment problem in + plain language (bd is missing, not initialized, or the ledger query + failed) and stop here. Suggest running `maverick init` or installing + `bd`, whichever fits the reported problem. Never guess at queue state + or invent entries — if you can't get a real listing, don't proceed. +3. If the listing succeeded and `result.entries` is empty: tell the human + nothing is pending review. Then run `maverick land --status --json` + and report the frontier state in plain language. If the frontier is + clear, mention that landing is available (`maverick land`); don't + invent confirmation mechanics here beyond a one-line mention. Stop + here — there is no sweep to run. + +## Sweep + +Only reachable when `result.entries` is non-empty. The listing is +already pre-sorted in canonical sweep order — owning spec (ascending), +then severity high→low, then stable ledger order (contract FR-009). +Never re-sort or re-group it yourself; present it exactly as returned. + +4. Walk `entries` **one at a time, in the order given**. Track the + `owner_spec` of the previous entry; whenever it changes (including the + very first entry), announce the new spec group before presenting its + first entry — e.g. "Moving on to spec `049-assumption-ledger`:". + +5. For each entry, ask exactly one `AskUserQuestion` covering: + - The question text itself. + - The owning spec (`owner_spec`). + - The severity (`severity`), and whether it was defaulted + (`severity_defaulted`). + - The affected change ids (`affected_change_ids`), so the human knows + what this decision touches. + + Build the options in this order: + - The adopted answer (`adopted_answer`) first, its label suffixed + with "(Recommended)". + - Each recorded alternative from `alternatives[]`, up to however many + the option surface can hold alongside the recommended answer and a + waive/skip route. + - If there are more alternatives than fit, add a "Waive / more…" + option as the last slot. Choosing it opens a **follow-up question** + listing the remaining alternatives plus explicit "Waive this entry" + and "Skip for now" choices. Never drop an alternative silently — if + a follow-up itself overflows, chain another follow-up the same way. + - When all alternatives fit alongside the recommended answer, still + include explicit "Waive this entry" and "Skip for now" options + directly (no need for the "Waive / more…" indirection in that + case). + - Free-form input is available via the tool's built-in "Other" + affordance — do not build your own free-text option. + +6. Map the human's decision to a verb call, applied **immediately** (no + batching, no deferral): + - Confirms the recommended answer → `maverick review + --answer "" --json`. + - Picks a recorded alternative → `maverick review --answer + "" --json`. + - Free-form via "Other" → if the text is empty or whitespace-only, + re-prompt (ask again) rather than invoking anything. Once non-empty, + `maverick review --answer "" --json`. + - Chooses "Waive this entry" → ask for a short reason, then + `maverick review --waive "" --json`. + - Chooses "Skip for now" → make no invocation; the entry stays open; + move on to the next entry. + +7. Handle the verb's result before moving to the next entry: + - `ok: true` → briefly acknowledge the recorded decision (answered or + waived) and continue. If the result also carries + `"degraded": true` (with `entry: null`), the decision **was + recorded** — only the post-write row couldn't be re-read. Say the + decision was recorded, mention the detail is unavailable, and + continue; never re-apply it. + - `ok: false` with `error.kind: "already-resolved"` → tell the human + this entry was already resolved elsewhere (e.g. by another reviewer + or a concurrent run), and show its current state from + `error.details.entry` (status, final answer or waiver). Continue + the sweep — never abort because of this. + - `ok: false` with any other `error.kind` → report the kind and + `error.message` in plain language, then continue with the remaining + entries. Never retry the same invocation without the human + explicitly asking you to. + + **Interruption tolerance**: you hold no sweep state across + invocations. Every decision is applied to the ledger the moment it's + made, so if this session is interrupted or ends partway through, a + later `/maverick-review` invocation simply restarts at Preflight — the + fresh listing will contain only the entries still open, and anything + already decided will not reappear. + +8. **Bulk-waive shortcut**: when you notice ≥2 still-open entries in the + current spec group are both `severity: "low"`, you may offer — once + per spec group, not once per entry — a shortcut question: "Waive all + remaining low-severity entries in ``?" If the human + accepts, ask for a reason, then run `maverick review --spec + --waive "" --json` (this defaults to low + severity, matching the offer). Report the result's `waived` and + `failed` entries. Any ids under `unprojected` **were waived + successfully** — their rows just couldn't be re-read; count them as + waived, never as failures, and never re-waive them. Per-entry presentation remains the default path — + only offer the shortcut, never apply it without an explicit yes — and + if the human declines, continue presenting the remaining entries in + that spec one at a time as usual. + +## Batched reconcile + +9. After the last entry in the sweep (step 8), decide whether reconcile + runs at all. Over the course of this sweep you already know, from + your own conversational context, exactly which verb you called for + each entry — confirmed/alternative/free-form answers all invoke + `maverick review --answer ...` (step 6); waives invoke + `--waive ...`; skips and the bulk-waive shortcut invoke no `--answer` + at all. If **zero** `--answer` invocations occurred anywhere in this + sweep — every decision was a waive, a skip, or both — skip reconcile + entirely; do not run `maverick reconcile --json`. This is a + within-session judgment, not persisted state: you hold no sweep state + across separate invocations (step 7's Interruption tolerance applies + here too), but within one continuous sweep-to-landing conversation you + track your own actions just fine. (If the queue was empty at + Preflight step 3, that path already stopped before the Sweep section + was ever reached — this step is simply never reached in that case, + consistent with, not contradicting, step 3.) + +10. Otherwise — at least one `--answer` invocation occurred during the + sweep — run `maverick reconcile --json` **exactly once**. Never run + it once per answer, and never re-run it automatically for any + reason, including a failed or partial outcome (see step 11). + +11. Report outcomes from `result.outcomes`: + - For each outcome with `status: "reconciled"`, briefly acknowledge + it (its `entry_id` is enough; no need to re-litigate the answer). + - For each outcome with `status: "needs_interactive_review"` or + `status: "skipped"`, explicitly call it out to the human with its + `reason` and `escalation_bead_id` (FR-017) — these are never + silently retried, and you MUST NOT invoke `maverick reconcile` + again to try to resolve them. + - If the envelope itself is `ok: false` (`error.kind` such as + `dirty-working-copy`, `concurrent-run`, `locked`): explain the + problem in plain language and suggest the matching remedy — + `concurrent-run` / `locked` → "try again after the other run + finishes"; `dirty-working-copy` → "commit or discard your changes + first" — then stop. Do not retry automatically. + +## Frontier report & landing + +12. Run `maverick land --status --json`. **First check + `result.degraded`**: when it is `true` the assumption ledger could not + be read at all (bd unavailable or the query failed), so + `result.frontier_clear` is `true` only because zero entries were + materialized — it does **not** mean the frontier is clear. Say so in + plain language, do not offer to land, and skip to step 15. + + Otherwise report `result.frontier_clear` / `result.verification` in + plain language as one of: **verified**, **conditionally verified**, or + **still blocked**. When still blocked, list every entry in + `result.blocking.open` and `result.blocking.pending_reconcile` with a + next-step hint: + - Each `open` entry → "review it with `maverick review `". + - Each `pending_reconcile` entry → "run `maverick reconcile` or + resolve its escalation". + +13. If `result.frontier_clear` is `true` **and `result.degraded` is not + `true`**: ask the human exactly one + explicit confirm question — "Land now?" (or equivalent) — before + doing anything else. Only on an explicit yes, run `maverick land + --yes --json`: + - `ok: true` → report the `result.verification` classification + (`verified` or `conditionally-verified`) and relay `result.hint`. + - `ok: false` (`error.kind` such as `frontier-blocked` from a race, + or `curation-failed`): report the kind and `error.message` in + plain language. Do not retry. + +14. If the human declines the landing offer (or the frontier was not + clear, so no offer was made): end with the frontier summary from + step 12 and mention that `maverick land` remains available whenever + they're ready. Nothing is landed. + +## Reporting + +15. End every session with a short summary covering: + - How many entries were answered, waived, and skipped during the + sweep. + - Any bulk-waive shortcuts applied (step 8) and their counts. + - The reconcile outcome counts, or "skipped — no answers recorded" + when step 9 applied. + - The frontier state from step 12. + - The landing result, if any action was taken in steps 13-14. + +## Prohibitions + +These apply to every section above — Identity, Preflight, Sweep, Batched +reconcile, and Frontier report & landing alike: + +- Never run `jj`, `git`, or `bd` directly, and never edit files + yourself. Your only effect channel is the verbs already named in this + document: `maverick review --list/--answer/--waive`, `maverick review + --spec --waive `, `maverick reconcile [--dry-run]`, + `maverick land --status`, and `maverick land --yes`. Never invoke any + other verb or flag combination. +- Never blindly retry a failed invocation. Every failure branch in this + document ends in reporting to the human, not in trying again — a + failed verb is only re-invoked if the human explicitly asks you to. +- Never land without gathering explicit human confirmation in this same + session (step 13). A prior sweep's answers, or the frontier being + clear, are not substitutes for that confirmation. +- Never parse the human-mode (non-`--json`) output of `review`, + `reconcile`, or `land`. Every invocation in this document carries + `--json`; if you find yourself reading prose output instead of a + `result`/`error` field, stop and re-check the command you ran. diff --git a/src/maverick/skills/review_console/__init__.py b/src/maverick/skills/review_console/__init__.py new file mode 100644 index 00000000..d6623a12 --- /dev/null +++ b/src/maverick/skills/review_console/__init__.py @@ -0,0 +1,35 @@ +"""Bundled ``maverick-review`` Claude Code skill. + +The skill's body ships as a sibling ``SKILL.md`` file in this directory, +following the same simple "read a packaged ``.md`` file next to the +module" pattern used by +:mod:`maverick.agents.system_prompts` (``_PROMPT_DIR`` / +``load_persona_system_prompt``) rather than ``importlib.resources`` — it's +simpler and already proven in this codebase. ``maverick init`` installs +this content into the target project's +``.claude/skills/maverick-review/SKILL.md`` (see +:func:`maverick.init._install_review_skill`). +""" + +from __future__ import annotations + +from pathlib import Path + +__all__ = ["load_review_skill_markdown"] + + +_SKILL_DIR = Path(__file__).parent + + +def load_review_skill_markdown() -> str: + """Return the packaged ``maverick-review`` ``SKILL.md`` content. + + Raises: + FileNotFoundError: If the packaged ``SKILL.md`` is missing — + this would indicate a broken install/build, so unlike the + best-effort persona prompt loader, this is not swallowed + here. Callers that want best-effort behavior (e.g. + ``maverick init``'s install step) catch this themselves. + """ + path = _SKILL_DIR / "SKILL.md" + return path.read_text(encoding="utf-8") diff --git a/src/maverick/workflows/reconcile/workflow.py b/src/maverick/workflows/reconcile/workflow.py index 00870981..528e1f40 100644 --- a/src/maverick/workflows/reconcile/workflow.py +++ b/src/maverick/workflows/reconcile/workflow.py @@ -52,7 +52,12 @@ ) from maverick.assumptions.models import AssumptionRecord from maverick.beads.client import BeadClient -from maverick.exceptions import WorkflowError +from maverick.exceptions import ( + REASON_CONCURRENT_RUN, + REASON_DIRTY_WORKING_COPY, + REASON_LOCKED, + WorkflowError, +) from maverick.jj.client import JjClient from maverick.library.actions.jj import ( jj_check_mutability, @@ -249,7 +254,8 @@ async def _run(self, inputs: dict[str, Any]) -> dict[str, Any]: working_copy_stat = await jj_client.diff_stat(revision="@") if working_copy_stat.files_changed != 0: raise WorkflowError( - "working copy is not clean — commit or discard changes before running reconcile" + "working copy is not clean — commit or discard changes before running reconcile", + reason_code=REASON_DIRTY_WORKING_COPY, ) # Concurrency guards (contract "Preconditions" item 4). The @@ -257,12 +263,16 @@ async def _run(self, inputs: dict[str, Any]) -> dict[str, Any]: # before lock acquisition, which mutates the filesystem. flying_run = _find_flying_run(cwd, exclude_run_id=active_fly_run_id) if flying_run is not None: - raise WorkflowError("cannot run reconcile while a fly run is in progress") + raise WorkflowError( + "cannot run reconcile while a fly run is in progress", + reason_code=REASON_CONCURRENT_RUN, + ) acquired = await acquire_lock(cwd) if not acquired: raise WorkflowError( - "another reconcile run is already in progress (lockfile held by a live process)" + "another reconcile run is already in progress (lockfile held by a live process)", + reason_code=REASON_LOCKED, ) try: diff --git a/tests/integration/cli/test_json_verbs_scenario.py b/tests/integration/cli/test_json_verbs_scenario.py new file mode 100644 index 00000000..5d8c3258 --- /dev/null +++ b/tests/integration/cli/test_json_verbs_scenario.py @@ -0,0 +1,534 @@ +"""End-to-end scenario test for the ``--json`` verbs added by +053-assumption-review-console (T016). + +Automates quickstart.md Scenarios 1-3: a seeded assumption ledger flows +through ``review --list --json`` -> answer one entry -> waive another -> +bulk-waive a spec's low-severity entries -> ``reconcile --dry-run --json`` +-> ``land --status --json``. Each step is an independent ``CliRunner`` +invocation (no persistent process), so the ledger fixtures for step N+1 +are hand-updated to reflect what step N "wrote" — the mutation is asserted +against the mocked ledger call, not against real bd/jj state, but the +mock data threads through so the scenario reads as one coherent narrative. + +Mocking style mirrors the existing unit-level JSON tests this feature +added (never real bd/jj processes): + +* ``tests/unit/cli/commands/test_review_json.py`` — ``BeadClient.show``/ + ``verify_available`` + ``maverick.assumptions.ledger.{answer,waive, + bulk_waive}`` patches. +* ``tests/unit/cli/commands/test_reconcile_json.py`` — precondition + bypass (``_require_bd_ready_json``, a fake ``JjClient``) plus a + ``ReconcileWorkflow._run`` stub returning a canned report. +* ``tests/unit/cli/commands/test_land_json.py`` / ``test_land_report.py`` + — ``AssumptionRecord``/``AssumptionReportEntry`` construction and + ``maverick.assumptions.ledger.report_entries`` patching for the land + frontier gate. + +Every invocation asserts stdout parses as exactly one JSON document (the +FR-005/error-envelope.md stream-discipline guarantee) — explicitly via a +single-line stdout check at least once, and implicitly every time +``json.loads(result.stdout)`` succeeds without a trailing-content error. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest +from click.testing import CliRunner + +from maverick.assumptions.models import ( + ASSUMPTION_LABEL, + KEY_ANSWER, + KEY_OWNER_SPEC, + KEY_RECONCILE_STATUS, + KEY_SEVERITY, + KEY_SOURCE_BEAD, + KEY_STATUS, + KEY_WAIVE_REASON, + KEY_WAIVED_AT, + KEY_WAIVED_BY, + RECONCILE_STATUS_PENDING, + STATUS_ANSWERED, + STATUS_OPEN, + STATUS_WAIVED, + AssumptionRecord, + AssumptionReportEntry, + BulkWaiveResult, + Severity, +) +from maverick.beads.models import BeadDetails +from maverick.cli.commands.land import land +from maverick.cli.commands.review import review +from maverick.main import cli +from maverick.workflows.reconcile.workflow import ReconcileWorkflow + +# ── Shared fixture identifiers ────────────────────────────────────────── + +_SPEC_A = "052-conditional-landing" +_SPEC_B = "049-assumption-ledger" + +_DEA_1 = "dea-1" # spec A, medium — answered in step 3 +_DEA_2 = "dea-2" # spec A, high — waived in step 4 +_DEA_3 = "dea-3" # spec B, low — bulk-waived in step 5 +_DEA_4 = "dea-4" # spec B, low — bulk-waived in step 5 + +_ANSWER_TEXT = "Use ISO-8601 timestamps everywhere." +_WAIVE_REASON_SINGLE = "Accepted risk for MVP." +_WAIVE_REASON_BULK = "Low-severity noise accepted for this spec." + + +def _description(question: str, adopted_answer: str, source_bead: str = "src-1") -> str: + """Build a ledger-entry description in the fixed markdown shape + ``ledger.parse_description`` expects (mirrors ``_LEDGER_DESCRIPTION`` in + ``tests/unit/cli/commands/test_review_json.py``).""" + return ( + "## Question\n\n" + f"{question}\n\n" + "## Adopted Answer\n\n" + f"{adopted_answer}\n\n" + "## Alternatives Considered\n\n(none)\n\n" + "## Context\n\n" + f"Source bead: {source_bead} — Implement the thing\n" + ) + + +def _report_entry( + *, + bead_id: str, + owner_spec: str, + severity: Severity, + status: str = STATUS_OPEN, + question: str = "Should retries be per bead?", + adopted_answer: str = "Per bead — matches existing scoping.", + final_answer: str | None = None, + waived_by: str | None = None, + waived_at: str | None = None, + waive_reason: str | None = None, + reconcile_status: str | None = None, + pending_reconcile: bool = False, +) -> AssumptionReportEntry: + """Build one seeded ``AssumptionReportEntry`` fixture (mirrors + ``_report_entry`` in ``tests/unit/cli/commands/test_land_json.py`` and + ``_entry`` in ``tests/unit/assumptions/test_land_report.py``).""" + record = AssumptionRecord( + bead_id=bead_id, + question=question, + adopted_answer=adopted_answer, + alternatives=(), + severity=severity, + severity_defaulted=False, + status=status, + owner_spec=owner_spec, + source_bead="src-1", + change_ids=(), + is_legacy=False, + ) + return AssumptionReportEntry( + record=record, + final_answer=final_answer, + waived_by=waived_by, + waived_at=waived_at, + waive_reason=waive_reason, + reconcile_status=reconcile_status, + reconciled_answer=None, + reconcile_change_id=None, + reconcile_reason=None, + pending_reconcile=pending_reconcile, + ) + + +def _ledger_details(bead_id: str, owner_spec: str, question: str, **state: str) -> BeadDetails: + """Build the ``BeadDetails`` ``BeadClient.show`` would return for one + ledger-entry bead (mirrors ``_ledger_details``/``_waived_details`` in + ``tests/unit/cli/commands/test_review_json.py``).""" + return BeadDetails( + id=bead_id, + title=f"Assumption: {question}", + description=_description(question, "Per bead — matches existing scoping."), + bead_type="task", + status="open" if state.get(KEY_STATUS) != STATUS_WAIVED else "closed", + labels=[ASSUMPTION_LABEL], + state={KEY_OWNER_SPEC: owner_spec, KEY_SOURCE_BEAD: "src-1", **state}, + ) + + +def _seed_open_entries() -> list[AssumptionReportEntry]: + """The initial open population — 4 entries across 2 specs, mixed + severity (Scenario 1 step 1: "sweep population").""" + return [ + _report_entry(bead_id=_DEA_1, owner_spec=_SPEC_A, severity=Severity.MEDIUM), + _report_entry(bead_id=_DEA_2, owner_spec=_SPEC_A, severity=Severity.HIGH), + _report_entry(bead_id=_DEA_3, owner_spec=_SPEC_B, severity=Severity.LOW), + _report_entry(bead_id=_DEA_4, owner_spec=_SPEC_B, severity=Severity.LOW), + ] + + +def _assert_single_json_document(stdout: str) -> dict[str, Any]: + """Assert *stdout* is exactly one parseable JSON document (FR-005 / + error-envelope.md stream discipline) and return the parsed envelope.""" + stripped = stdout.strip() + assert stripped, "expected non-empty stdout" + # A single JSON document has no sibling top-level values after it — + # json.loads raises on trailing garbage, giving us the check for free, + # but assert the line-count invariant too (matches the reconcile JSON + # tests' explicit stdout-purity assertion). + assert stdout.count("\n") == 1, f"expected exactly one line of stdout, got: {stdout!r}" + doc = json.loads(stripped) + assert doc["schema_version"] == 1 + assert isinstance(doc["ok"], bool) + return doc + + +class TestJsonVerbsScenario: + """One coherent walkthrough: list -> answer -> waive -> bulk-waive -> + reconcile --dry-run -> land --status, all under ``--json``.""" + + def test_full_scenario( + self, + temp_dir: Path, + clean_env: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + runner = CliRunner() + + # ── Step 1/2: seed + review --list --json ─────────────────────── + seeded = _seed_open_entries() + with ( + patch( + "maverick.beads.client.BeadClient.verify_available", + new=AsyncMock(return_value=True), + ), + patch( + "maverick.assumptions.ledger.report_entries", + new=AsyncMock(return_value=tuple(seeded)), + ), + ): + result = runner.invoke(review, ["--list", "--json"]) + + assert result.exit_code == 0 + doc = _assert_single_json_document(result.output) + assert doc["ok"] is True + assert doc["verb"] == "review.list" + + listed_ids = {row["bead_id"] for row in doc["result"]["entries"]} + assert listed_ids == {_DEA_1, _DEA_2, _DEA_3, _DEA_4} + counts = doc["result"]["counts"] + assert counts["total"] == 4 + assert counts["by_status"]["open"] == 4 + assert counts["by_status"]["answered"] == 0 + assert counts["by_status"]["waived"] == 0 + assert counts["by_severity"] == {"low": 2, "medium": 1, "high": 1} + assert counts["pending_reconcile"] == 0 + + # ── Step 3: answer dea-1 (medium, spec A) ──────────────────────── + before_dea1 = _ledger_details( + _DEA_1, + _SPEC_A, + "Should retries be per bead?", + **{KEY_SEVERITY: "medium", KEY_STATUS: STATUS_OPEN}, + ) + after_dea1 = _ledger_details( + _DEA_1, + _SPEC_A, + "Should retries be per bead?", + **{ + KEY_SEVERITY: "medium", + KEY_STATUS: STATUS_ANSWERED, + KEY_ANSWER: _ANSWER_TEXT, + KEY_RECONCILE_STATUS: RECONCILE_STATUS_PENDING, + }, + ) + with ( + patch( + "maverick.beads.client.BeadClient.verify_available", + new=AsyncMock(return_value=True), + ), + patch( + "maverick.beads.client.BeadClient.show", + new=AsyncMock(side_effect=[before_dea1, after_dea1]), + ), + patch("maverick.assumptions.ledger.answer", new=AsyncMock()) as mock_answer, + ): + result = runner.invoke(review, [_DEA_1, "--answer", _ANSWER_TEXT, "--json"]) + + assert result.exit_code == 0 + doc = _assert_single_json_document(result.output) + assert doc["ok"] is True + assert doc["verb"] == "review.answer" + assert doc["result"]["action"] == "answered" + assert doc["result"]["entry"]["bead_id"] == _DEA_1 + assert doc["result"]["entry"]["status"] == "answered" + assert doc["result"]["entry"]["reconcile"]["status"] == "pending" + mock_answer.assert_awaited_once() + assert mock_answer.await_args.kwargs["answer_text"] == _ANSWER_TEXT + + # ── Step 4: waive dea-2 (high, spec A) ─────────────────────────── + before_dea2 = _ledger_details( + _DEA_2, + _SPEC_A, + "Should retries be per bead?", + **{KEY_SEVERITY: "high", KEY_STATUS: STATUS_OPEN}, + ) + after_dea2 = _ledger_details( + _DEA_2, + _SPEC_A, + "Should retries be per bead?", + **{ + KEY_SEVERITY: "high", + KEY_STATUS: STATUS_WAIVED, + KEY_WAIVED_BY: "alice", + KEY_WAIVED_AT: "2026-07-25T00:00:00+00:00", + KEY_WAIVE_REASON: _WAIVE_REASON_SINGLE, + }, + ) + with ( + patch( + "maverick.beads.client.BeadClient.verify_available", + new=AsyncMock(return_value=True), + ), + patch( + "maverick.beads.client.BeadClient.show", + new=AsyncMock(side_effect=[before_dea2, after_dea2]), + ), + patch( + "maverick.cli.commands.review._resolve_git_user_name", + return_value="alice", + ), + patch("maverick.assumptions.ledger.waive", new=AsyncMock()) as mock_waive, + ): + result = runner.invoke(review, [_DEA_2, "--waive", _WAIVE_REASON_SINGLE, "--json"]) + + assert result.exit_code == 0 + doc = _assert_single_json_document(result.output) + assert doc["ok"] is True + assert doc["verb"] == "review.waive" + assert doc["result"]["action"] == "waived" + assert doc["result"]["entry"]["bead_id"] == _DEA_2 + assert doc["result"]["entry"]["status"] == "waived" + mock_waive.assert_awaited_once() + + # ── Step 5: bulk-waive spec B's low-severity entries ───────────── + bulk_result = BulkWaiveResult( + waived=( + AssumptionRecord( + bead_id=_DEA_3, + question="Should retries be per bead?", + adopted_answer="Per bead — matches existing scoping.", + alternatives=(), + severity=Severity.LOW, + severity_defaulted=False, + status=STATUS_WAIVED, + owner_spec=_SPEC_B, + source_bead="src-1", + change_ids=(), + is_legacy=False, + ), + AssumptionRecord( + bead_id=_DEA_4, + question="Should retries be per bead?", + adopted_answer="Per bead — matches existing scoping.", + alternatives=(), + severity=Severity.LOW, + severity_defaulted=False, + status=STATUS_WAIVED, + owner_spec=_SPEC_B, + source_bead="src-1", + change_ids=(), + is_legacy=False, + ), + ), + failed={}, + ) + after_dea3 = _ledger_details( + _DEA_3, + _SPEC_B, + "Should retries be per bead?", + **{ + KEY_SEVERITY: "low", + KEY_STATUS: STATUS_WAIVED, + KEY_WAIVED_BY: "alice", + KEY_WAIVED_AT: "2026-07-25T00:00:00+00:00", + KEY_WAIVE_REASON: _WAIVE_REASON_BULK, + }, + ) + after_dea4 = _ledger_details( + _DEA_4, + _SPEC_B, + "Should retries be per bead?", + **{ + KEY_SEVERITY: "low", + KEY_STATUS: STATUS_WAIVED, + KEY_WAIVED_BY: "alice", + KEY_WAIVED_AT: "2026-07-25T00:00:00+00:00", + KEY_WAIVE_REASON: _WAIVE_REASON_BULK, + }, + ) + with ( + patch( + "maverick.beads.client.BeadClient.verify_available", + new=AsyncMock(return_value=True), + ), + patch( + "maverick.beads.client.BeadClient.show", + new=AsyncMock(side_effect=[after_dea3, after_dea4]), + ), + patch( + "maverick.cli.commands.review._resolve_git_user_name", + return_value="alice", + ), + patch( + "maverick.assumptions.ledger.bulk_waive", + new=AsyncMock(return_value=bulk_result), + ), + ): + result = runner.invoke( + review, ["--spec", _SPEC_B, "--waive", _WAIVE_REASON_BULK, "--json"] + ) + + assert result.exit_code == 0 + doc = _assert_single_json_document(result.output) + assert doc["ok"] is True + assert doc["verb"] == "review.bulk-waive" + assert doc["result"]["owner_spec"] == _SPEC_B + assert doc["result"]["severities"] == ["low"] + assert {row["bead_id"] for row in doc["result"]["waived"]} == {_DEA_3, _DEA_4} + assert doc["result"]["failed"] == {} + + # ── Step 6: reconcile --dry-run --json ─────────────────────────── + # dea-1 is now answered with a reconcile status of "pending" — the + # detection predicate reconcile relies on. Predict it as + # "reconciled" (dry-run vocabulary only ever produces + # reconciled/skipped per the workflow's predictor). + os.chdir(temp_dir) + monkeypatch.setattr(Path, "home", lambda: temp_dir) + (temp_dir / ".jj").mkdir() + + class _FakeJjClient: + def __init__(self, *, cwd: Path) -> None: + self._cwd = cwd + + async def diff_stat(self, revision: str = "@") -> SimpleNamespace: + return SimpleNamespace(files_changed=0) + + monkeypatch.setattr( + "maverick.cli.commands.reconcile._require_bd_ready_json", lambda cwd: None + ) + monkeypatch.setattr("maverick.cli.commands.reconcile.JjClient", _FakeJjClient) + + dry_run_report: dict[str, object] = { + "run_id": "scenario1", + "outcomes": [ + { + "entry_id": _DEA_1, + "status": "reconciled", + "reason": "", + "stage_reached": "terminal", + "target_change_id": "qxyzabc", + "escalation_bead_id": None, + "gate_passed": True, + "no_change_required": False, + } + ], + "dry_run": True, + "started_at": "2026-07-25T00:00:00+00:00", + "finished_at": "2026-07-25T00:00:01+00:00", + "exit_success": True, + } + + async def _fake_run( + self: ReconcileWorkflow, inputs: dict[str, object] + ) -> dict[str, object]: + return dry_run_report + + monkeypatch.setattr(ReconcileWorkflow, "_run", _fake_run) + + cli_runner = CliRunner() + result = cli_runner.invoke(cli, ["reconcile", "--dry-run", "--json"]) + + assert result.exit_code == 0 + doc = _assert_single_json_document(result.stdout) + assert doc["ok"] is True + assert doc["verb"] == "reconcile.dry-run" + assert doc["result"]["dry_run"] is True + predicted_statuses = {o["status"] for o in doc["result"]["outcomes"]} + assert predicted_statuses <= {"reconciled", "skipped"} + assert doc["result"]["outcomes"][0]["entry_id"] == _DEA_1 + + # ── Step 7: land --status --json ───────────────────────────────── + # Reconcile above was only a dry-run preview — nothing was actually + # reconciled — so dea-1 still carries a "pending" reconcile status + # and (per 051's detection predicate) blocks the land frontier as + # pending-reconcile even though every entry has been answered or + # waived by a human. dea-2/3/4 are waived and no longer block. + current_entries = [ + _report_entry( + bead_id=_DEA_1, + owner_spec=_SPEC_A, + severity=Severity.MEDIUM, + status=STATUS_ANSWERED, + final_answer=_ANSWER_TEXT, + reconcile_status=RECONCILE_STATUS_PENDING, + pending_reconcile=True, + ), + _report_entry( + bead_id=_DEA_2, + owner_spec=_SPEC_A, + severity=Severity.HIGH, + status=STATUS_WAIVED, + waived_by="alice", + waived_at="2026-07-25T00:00:00+00:00", + waive_reason=_WAIVE_REASON_SINGLE, + ), + _report_entry( + bead_id=_DEA_3, + owner_spec=_SPEC_B, + severity=Severity.LOW, + status=STATUS_WAIVED, + waived_by="alice", + waived_at="2026-07-25T00:00:00+00:00", + waive_reason=_WAIVE_REASON_BULK, + ), + _report_entry( + bead_id=_DEA_4, + owner_spec=_SPEC_B, + severity=Severity.LOW, + status=STATUS_WAIVED, + waived_by="alice", + waived_at="2026-07-25T00:00:00+00:00", + waive_reason=_WAIVE_REASON_BULK, + ), + ] + + runner2 = CliRunner() + with runner2.isolated_filesystem(): + with ( + patch( + "maverick.beads.client.BeadClient.verify_available", + new=AsyncMock(return_value=True), + ), + patch( + "maverick.assumptions.ledger.report_entries", + new=AsyncMock(return_value=tuple(current_entries)), + ), + ): + result = runner2.invoke(land, ["--status", "--json"]) + + assert result.exit_code == 0 + doc = _assert_single_json_document(result.stdout) + assert doc["ok"] is True + assert doc["verb"] == "land.status" + res = doc["result"] + assert "frontier_clear" in res + assert "verification" in res + assert "blocking" in res + assert "report" in res + assert "report_paths" in res + assert res["frontier_clear"] is False + assert res["blocking"]["open"] == [] + assert res["blocking"]["pending_reconcile"] == [_DEA_1] diff --git a/tests/unit/assumptions/test_serialize.py b/tests/unit/assumptions/test_serialize.py new file mode 100644 index 00000000..25b80672 --- /dev/null +++ b/tests/unit/assumptions/test_serialize.py @@ -0,0 +1,268 @@ +"""Tests for maverick.assumptions.serialize — the canonical row projection. + +Covers data-model.md's ``entry_to_dict(entry) -> dict[str, object]`` +(research R4): the same row shape used by both the land report and +``review --list``, extracted from ``land_report._entry_to_dict`` and +extended additively with ``owner_spec``/``status``/``bucket``/ +``blocks_landing``. See specs/053-assumption-review-console/data-model.md +"entry_to_dict" for the authoritative key list. +""" + +from __future__ import annotations + +from maverick.assumptions.models import ( + STATUS_ANSWERED, + STATUS_OPEN, + STATUS_WAIVED, + AssumptionRecord, + AssumptionReportEntry, + Severity, +) +from maverick.assumptions.serialize import entry_to_dict + + +def _record( + *, + bead_id: str = "dea-1", + status: str = STATUS_OPEN, + severity: Severity = Severity.LOW, + severity_defaulted: bool = False, + is_legacy: bool = False, + owner_spec: str = "053-assumption-review-console", + source_bead: str = "dea-0", + change_ids: tuple[str, ...] = (), + question: str = "Q?", + adopted_answer: str = "A.", + alternatives: tuple[str, ...] = (), +) -> AssumptionRecord: + return AssumptionRecord( + bead_id=bead_id, + question=question, + adopted_answer=adopted_answer, + alternatives=alternatives, + severity=severity, + severity_defaulted=severity_defaulted, + status=status, + owner_spec=owner_spec, + source_bead=source_bead, + change_ids=change_ids, + is_legacy=is_legacy, + ) + + +def _entry( + *, + bead_id: str = "dea-1", + status: str = STATUS_OPEN, + severity: Severity = Severity.LOW, + severity_defaulted: bool = False, + is_legacy: bool = False, + owner_spec: str = "053-assumption-review-console", + change_ids: tuple[str, ...] = (), + pending_reconcile: bool = False, + reconcile_status: str | None = None, + reconciled_answer: str | None = None, + reconcile_change_id: str | None = None, + reconcile_reason: str | None = None, +) -> AssumptionReportEntry: + return AssumptionReportEntry( + record=_record( + bead_id=bead_id, + status=status, + severity=severity, + severity_defaulted=severity_defaulted, + is_legacy=is_legacy, + owner_spec=owner_spec, + change_ids=change_ids, + ), + final_answer="Yes." if status == STATUS_ANSWERED else None, + waived_by="alice" if status == STATUS_WAIVED else None, + waived_at="2026-07-24T14:00:00Z" if status == STATUS_WAIVED else None, + waive_reason="n/a" if status == STATUS_WAIVED else None, + reconcile_status=reconcile_status, + reconciled_answer=reconciled_answer, + reconcile_change_id=reconcile_change_id, + reconcile_reason=reconcile_reason, + pending_reconcile=pending_reconcile, + ) + + +class TestEntryToDictProjection: + """Field-for-field projection from a built AssumptionReportEntry.""" + + def test_record_fields_projected(self) -> None: + entry = _entry( + bead_id="dea-42", + severity=Severity.HIGH, + severity_defaulted=True, + is_legacy=True, + ) + row = entry_to_dict(entry) + assert row["bead_id"] == "dea-42" + assert row["question"] == "Q?" + assert row["adopted_answer"] == "A." + assert row["severity"] == "high" + assert row["severity_defaulted"] is True + assert row["is_legacy"] is True + assert row["source_bead"] == "dea-0" + + def test_alternatives_is_a_list(self) -> None: + entry = _entry() + row = entry_to_dict(entry) + assert row["alternatives"] == [] + assert isinstance(row["alternatives"], list) + + def test_affected_change_ids_is_a_list(self) -> None: + entry = _entry(change_ids=("zzkw",)) + row = entry_to_dict(entry) + assert row["affected_change_ids"] == ["zzkw"] + assert isinstance(row["affected_change_ids"], list) + + def test_annotations_is_a_list(self) -> None: + entry = _entry(is_legacy=True, status=STATUS_OPEN) + row = entry_to_dict(entry) + assert isinstance(row["annotations"], list) + assert "legacy" in row["annotations"] + + def test_reconcile_dict_shape(self) -> None: + entry = _entry( + status=STATUS_ANSWERED, + reconcile_status="reconciled", + reconciled_answer="Final.", + reconcile_change_id="rlvk", + reconcile_reason="drifted", + ) + row = entry_to_dict(entry) + assert row["reconcile"] == { + "status": "reconciled", + "reconciled_answer": "Final.", + "change_id": "rlvk", + "reason": "drifted", + } + + def test_pending_reconcile_bool(self) -> None: + entry = _entry(status=STATUS_ANSWERED, pending_reconcile=True) + row = entry_to_dict(entry) + assert row["pending_reconcile"] is True + + +class TestEntryToDictNewFields: + """The three additive fields new in 053: owner_spec, status, bucket, + blocks_landing.""" + + def test_owner_spec_from_record(self) -> None: + entry = _entry(owner_spec="052-conditional-landing") + row = entry_to_dict(entry) + assert row["owner_spec"] == "052-conditional-landing" + + def test_status_from_record(self) -> None: + entry = _entry(status=STATUS_ANSWERED) + row = entry_to_dict(entry) + assert row["status"] == "answered" + + def test_bucket_open(self) -> None: + entry = _entry(status=STATUS_OPEN) + row = entry_to_dict(entry) + assert row["bucket"] == "open" + + def test_bucket_resolved(self) -> None: + entry = _entry(status=STATUS_ANSWERED) + row = entry_to_dict(entry) + assert row["bucket"] == "resolved" + + def test_bucket_waived(self) -> None: + entry = _entry(status=STATUS_WAIVED) + row = entry_to_dict(entry) + assert row["bucket"] == "waived" + + def test_blocks_landing_true_when_open(self) -> None: + entry = _entry(status=STATUS_OPEN) + row = entry_to_dict(entry) + assert row["blocks_landing"] is True + + def test_blocks_landing_true_when_pending_reconcile(self) -> None: + entry = _entry(status=STATUS_ANSWERED, pending_reconcile=True) + row = entry_to_dict(entry) + assert row["blocks_landing"] is True + + def test_blocks_landing_false_when_resolved(self) -> None: + entry = _entry(status=STATUS_ANSWERED) + row = entry_to_dict(entry) + assert row["blocks_landing"] is False + + def test_blocks_landing_false_when_waived(self) -> None: + entry = _entry(status=STATUS_WAIVED) + row = entry_to_dict(entry) + assert row["blocks_landing"] is False + + +class TestNullOmissionRule: + """waiver is None unless the entry is in the waived bucket.""" + + def test_waiver_none_when_open(self) -> None: + entry = _entry(status=STATUS_OPEN) + row = entry_to_dict(entry) + assert row["waiver"] is None + + def test_waiver_none_when_resolved(self) -> None: + entry = _entry(status=STATUS_ANSWERED) + row = entry_to_dict(entry) + assert row["waiver"] is None + + def test_waiver_present_when_waived(self) -> None: + entry = _entry(status=STATUS_WAIVED) + row = entry_to_dict(entry) + assert row["waiver"] == { + "by": "alice", + "at": "2026-07-24T14:00:00Z", + "reason": "n/a", + } + + +class TestEqualityWithLandReportRow: + """entry_to_dict must not silently diverge from land_report's own + row projection for the pre-existing keys.""" + + _PRE_EXISTING_KEYS = ( + "bead_id", + "question", + "adopted_answer", + "final_answer", + "alternatives", + "severity", + "severity_defaulted", + "is_legacy", + "source_bead", + "affected_change_ids", + "waiver", + "reconcile", + "pending_reconcile", + "annotations", + "bucket", + ) + + def test_matches_land_report_private_builder(self) -> None: + from maverick.assumptions.land_report import _entry_to_dict + + entry = _entry( + bead_id="dea-7", + status=STATUS_WAIVED, + severity=Severity.HIGH, + is_legacy=True, + change_ids=("zzkw",), + reconcile_change_id="rlvk", + ) + new_row = entry_to_dict(entry) + legacy_row = _entry_to_dict(entry) + for key in self._PRE_EXISTING_KEYS: + assert new_row[key] == legacy_row[key], f"mismatch on key {key!r}" + + def test_full_dict_equality_on_pre_existing_keys(self) -> None: + from maverick.assumptions.land_report import _entry_to_dict + + entry = _entry(status=STATUS_ANSWERED, pending_reconcile=True) + new_row = entry_to_dict(entry) + legacy_row = _entry_to_dict(entry) + filtered_new = {k: v for k, v in new_row.items() if k in self._PRE_EXISTING_KEYS} + filtered_legacy = {k: v for k, v in legacy_row.items() if k in self._PRE_EXISTING_KEYS} + assert filtered_new == filtered_legacy diff --git a/tests/unit/cli/commands/test_land_json.py b/tests/unit/cli/commands/test_land_json.py new file mode 100644 index 00000000..441cbbca --- /dev/null +++ b/tests/unit/cli/commands/test_land_json.py @@ -0,0 +1,528 @@ +"""Unit tests for ``maverick land``'s JSON modes (053-assumption-review-console). + +Covers ``land --status [--json]`` (read-only frontier query, verb +``land.status``) and ``land [--json]`` (the apply path, verb ``land.run``). +Human-mode behavior is exercised by ``test_land_command.py`` and must stay +byte-identical (FR-018) — these tests only exercise the new ``--json`` +surface, reusing the mocking conventions from that file +(``_patch_curation``, ``_patch_gate``, ``_report_entry``). + +Click 8.2+ always captures stdout/stderr separately, so tests parse +``result.stdout`` (never ``result.output``, which mixes both streams) — +narration in ``--json`` mode is routed to stderr and must never corrupt +the single parseable JSON document on stdout. +""" + +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest +from click.testing import CliRunner + +from maverick.assumptions.models import ( + STATUS_OPEN, + AssumptionRecord, + AssumptionReportEntry, + Severity, +) +from maverick.cli.commands.land import land + +# ── Shared helpers (mirrors test_land_command.py) ─────────────────── + + +def _patch_curation( + *, + commits: list[Any] | None = None, + curate_result: dict[str, Any] | None = None, +) -> Any: + if commits is None: + commits = [{"id": "abc", "subject": "test"}] + if curate_result is None: + curate_result = { + "success": True, + "absorb_ran": False, + "squashed_count": 0, + "error": None, + } + + return ( + patch( + "maverick.library.actions.jj.gather_curation_context", + new=AsyncMock( + return_value={ + "success": True, + "commits": commits, + "log_summary": "...", + "error": None, + }, + ), + ), + patch( + "maverick.library.actions.jj.curate_history", + new=AsyncMock(return_value=curate_result), + ), + patch( + "maverick.cli.commands.land._maybe_consolidate", + new=AsyncMock(), + ), + ) + + +def _report_entry( + *, + bead_id: str = "dea-1", + severity: Severity = Severity.MEDIUM, + status: str = STATUS_OPEN, + owner_spec: str = "049-assumption-ledger", + pending_reconcile: bool = False, +) -> AssumptionReportEntry: + record = AssumptionRecord( + bead_id=bead_id, + question="Should retries be per bead?", + adopted_answer="Per bead.", + alternatives=(), + severity=severity, + severity_defaulted=False, + status=status, + owner_spec=owner_spec, + source_bead="src-1", + change_ids=(), + is_legacy=False, + ) + return AssumptionReportEntry( + record=record, + final_answer=None, + waived_by=None, + waived_at=None, + waive_reason=None, + reconcile_status=None, + reconciled_answer=None, + reconcile_change_id=None, + reconcile_reason=None, + pending_reconcile=pending_reconcile, + ) + + +def _patch_gate(*, entries: list[AssumptionReportEntry] | None = None, bd_available: bool = True): + return ( + patch( + "maverick.beads.client.BeadClient.verify_available", + new=AsyncMock(return_value=bd_available), + ), + patch( + "maverick.assumptions.ledger.report_entries", + new=AsyncMock(return_value=tuple(entries or ())), + ), + ) + + +def _json_runner() -> CliRunner: + """A ``CliRunner`` — Click 8.2+ always captures stdout/stderr separately. + + JSON mode routes narration to stderr; tests parse ``result.stdout`` + (never ``result.output``, which mixes both streams) so stderr text can + never corrupt the single JSON document under test. + """ + return CliRunner() + + +# ── land --status [--json] ────────────────────────────────────────── + + +class TestLandStatusJson: + def test_status_json_clear_frontier(self) -> None: + runner = _json_runner() + verify, entries_patch = _patch_gate(entries=[]) + with runner.isolated_filesystem(): + with verify, entries_patch: + result = runner.invoke(land, ["--status", "--json"]) + assert result.exit_code == 0 + doc = json.loads(result.stdout) + assert doc["ok"] is True + assert doc["verb"] == "land.status" + res = doc["result"] + assert res["frontier_clear"] is True + assert res["verification"] == "verified" + assert res["blocking"] == {"open": [], "pending_reconcile": []} + assert res["report"]["schema_version"] == 1 + assert "json" in res["report_paths"] + assert "md" in res["report_paths"] + + def test_status_json_blocked_frontier_exits_zero(self) -> None: + runner = _json_runner() + entry = _report_entry(severity=Severity.LOW, status=STATUS_OPEN) + verify, entries_patch = _patch_gate(entries=[entry]) + with runner.isolated_filesystem(): + with verify, entries_patch: + result = runner.invoke(land, ["--status", "--json"]) + # Blocked is an ANSWER for a status query, not a failure. + assert result.exit_code == 0 + doc = json.loads(result.stdout) + assert doc["ok"] is True + res = doc["result"] + assert res["frontier_clear"] is False + assert res["blocking"]["open"] == ["dea-1"] + + def test_status_json_degraded_verification_is_null(self) -> None: + runner = _json_runner() + verify, entries_patch = _patch_gate(bd_available=False) + with runner.isolated_filesystem(): + with verify, entries_patch: + result = runner.invoke(land, ["--status", "--json"]) + assert result.exit_code == 0 + doc = json.loads(result.stdout) + assert doc["result"]["verification"] is None + + @pytest.mark.parametrize( + "flag", + ["--dry-run", "--eject", "--finalize", "--no-curate", "--heuristic-only", "--yes"], + ) + def test_status_mutually_exclusive_with_apply_flags(self, flag: str) -> None: + runner = _json_runner() + result = runner.invoke(land, ["--status", flag]) + assert result.exit_code != 0 + + def test_status_mutually_exclusive_json_error_envelope(self) -> None: + runner = _json_runner() + result = runner.invoke(land, ["--status", "--json", "--dry-run"]) + assert result.exit_code != 0 + doc = json.loads(result.stdout) + assert doc["ok"] is False + assert doc["error"]["kind"] == "validation" + + def test_status_does_not_invoke_curation(self) -> None: + runner = _json_runner() + verify, entries_patch = _patch_gate(entries=[]) + with ( + verify, + entries_patch, + patch("maverick.library.actions.jj.execute_curation_plan") as mock_execute, + patch("maverick.cli.commands.land._agent_curate") as mock_agent_curate, + patch("maverick.library.actions.jj.curate_history") as mock_curate_history, + ): + result = runner.invoke(land, ["--status", "--json"]) + assert result.exit_code == 0 + mock_execute.assert_not_called() + mock_agent_curate.assert_not_called() + mock_curate_history.assert_not_called() + + +# ── land [--json] (apply path) ────────────────────────────────────── + + +class TestLandRunJsonGateRefusal: + def test_gate_refusal_json(self) -> None: + runner = _json_runner() + gather, curate, consolidate = _patch_curation() + entry = _report_entry(severity=Severity.LOW, status=STATUS_OPEN) + verify, entries_patch = _patch_gate(entries=[entry]) + with runner.isolated_filesystem(): + with gather, curate, consolidate, verify, entries_patch: + result = runner.invoke(land, ["--no-curate", "--yes", "--json"]) + assert result.exit_code != 0 + doc = json.loads(result.stdout) + assert doc["ok"] is False + assert doc["verb"] == "land.run" + assert doc["error"]["kind"] == "frontier-blocked" + assert "totals" in doc["error"]["details"]["report"] + + +class TestLandRunJsonConfirmationRequired: + def test_confirmation_required_before_execute(self) -> None: + runner = _json_runner() + gather, curate, consolidate = _patch_curation() + verify, entries_patch = _patch_gate(entries=[]) + + class _FakeStep: + command = "describe" + args = ("-m", "msg") + reason = "tidy" + + class _FakePayload: + steps = (_FakeStep(),) + + class _FakeCuratorAgent: + def __init__(self, **_kwargs: Any) -> None: + pass + + async def __aenter__(self) -> _FakeCuratorAgent: + return self + + async def __aexit__(self, *_args: Any) -> bool: + return False + + async def curate(self, _prompt: str) -> _FakePayload: + return _FakePayload() + + with runner.isolated_filesystem(): + with ( + gather, + curate, + consolidate, + verify, + entries_patch, + patch("maverick.agents.personas.CuratorAgent", _FakeCuratorAgent), + patch("maverick.config.load_config"), + patch( + "maverick.runtime.agent_factory.runtime_for_agent", + return_value=(object(), None), + ), + patch( + "maverick.library.actions.curation.build_curator_prompt", + return_value="prompt", + ), + patch( + "maverick.library.actions.curation.ensure_refs_trailers", + side_effect=lambda plan, commits: plan, + ), + patch("maverick.library.actions.jj.execute_curation_plan") as mock_execute, + ): + result = runner.invoke(land, ["--json"]) + assert result.exit_code != 0 + doc = json.loads(result.stdout) + assert doc["ok"] is False + assert doc["error"]["kind"] == "confirmation-required" + mock_execute.assert_not_called() + + +class TestLandRunJsonSuccess: + def test_success_default_mode(self) -> None: + runner = _json_runner() + gather, curate, consolidate = _patch_curation() + verify, entries_patch = _patch_gate(entries=[]) + with runner.isolated_filesystem(): + with gather, curate, consolidate, verify, entries_patch: + result = runner.invoke(land, ["--no-curate", "--yes", "--json"]) + assert result.exit_code == 0 + doc = json.loads(result.stdout) + assert doc["ok"] is True + res = doc["result"] + assert res["landed"] is True + assert res["verification"] in {"verified", "conditionally-verified"} + assert res["mode"] == "approve" + assert res["curation"]["strategy"] == "none" + assert "report" in res + assert "report_paths" in res + + def test_success_eject_mode(self) -> None: + runner = _json_runner() + gather, curate, consolidate = _patch_curation() + verify, entries_patch = _patch_gate(entries=[]) + with runner.isolated_filesystem(): + with gather, curate, consolidate, verify, entries_patch: + result = runner.invoke(land, ["--no-curate", "--yes", "--eject", "--json"]) + assert result.exit_code == 0 + doc = json.loads(result.stdout) + assert doc["result"]["mode"] == "eject" + assert doc["result"]["hint"] is not None + + def test_success_finalize_mode(self) -> None: + runner = _json_runner() + gather, curate, consolidate = _patch_curation() + verify, entries_patch = _patch_gate(entries=[]) + with runner.isolated_filesystem(): + with gather, curate, consolidate, verify, entries_patch: + result = runner.invoke(land, ["--no-curate", "--yes", "--finalize", "--json"]) + assert result.exit_code == 0 + doc = json.loads(result.stdout) + assert doc["result"]["mode"] == "finalize" + + +class TestLandRunJsonNothingToLand: + def test_nothing_to_land_json(self) -> None: + runner = _json_runner() + gather, curate, consolidate = _patch_curation(commits=[]) + with gather, curate, consolidate: + result = runner.invoke(land, ["--no-curate", "--json"]) + assert result.exit_code == 0 + doc = json.loads(result.stdout) + assert doc["ok"] is True + # Same key set as every other `land.run` success document, so a + # consumer reading `verification`/`report`/`curation` never + # KeyErrors just because nothing sat above base. + assert doc["result"] == { + "landed": False, + "reason": "nothing-to-land", + "mode": "approve", + "verification": None, + "degraded": False, + "curation": {"strategy": "none", "executed_count": 0, "total_count": 0}, + "report": None, + "report_paths": {}, + "hint": None, + } + + def test_nothing_to_land_shape_matches_landed_shape(self) -> None: + """The early return must not drop keys the apply path emits.""" + runner = _json_runner() + gather, curate, consolidate = _patch_curation(commits=[]) + with gather, curate, consolidate: + empty = runner.invoke(land, ["--no-curate", "--json"]) + gather, curate, consolidate = _patch_curation() + verify, entries_patch = _patch_gate(entries=[]) + with _json_runner().isolated_filesystem(): + with gather, curate, consolidate, verify, entries_patch: + landed = runner.invoke(land, ["--no-curate", "--yes", "--json"]) + empty_keys = set(json.loads(empty.stdout)["result"]) + landed_keys = set(json.loads(landed.stdout)["result"]) + assert landed_keys - {"reason"} <= empty_keys + + +class TestLandRunJsonDryRunDeferred: + def test_dry_run_blocked_frontier_defers_exit(self) -> None: + runner = _json_runner() + gather, curate, consolidate = _patch_curation() + entry = _report_entry(severity=Severity.LOW, status=STATUS_OPEN) + verify, entries_patch = _patch_gate(entries=[entry]) + with runner.isolated_filesystem(): + with gather, curate, consolidate, verify, entries_patch: + result = runner.invoke(land, ["--no-curate", "--dry-run", "--json"]) + # Preview still runs and the document reports the block; exit + # is deferred to the end rather than short-circuiting early. + assert result.exit_code != 0 + doc = json.loads(result.stdout) + assert doc["ok"] is False + assert doc["error"]["kind"] == "frontier-blocked" + + def test_dry_run_clear_frontier_succeeds(self) -> None: + runner = _json_runner() + gather, curate, consolidate = _patch_curation() + verify, entries_patch = _patch_gate(entries=[]) + with runner.isolated_filesystem(): + with gather, curate, consolidate, verify, entries_patch: + result = runner.invoke(land, ["--no-curate", "--dry-run", "--json"]) + assert result.exit_code == 0 + doc = json.loads(result.stdout) + assert doc["ok"] is True + assert doc["result"]["landed"] is False + assert doc["result"]["mode"] == "dry-run" + + +class TestLandStatusDegradedSignal: + """A ledger that couldn't be read is NOT a clear frontier. + + `_check_assumption_gate` degrades open (returns no entries) when bd is + unavailable, so `frontier_clear` is trivially true. Without a top-level + `degraded` flag, that document is indistinguishable from a genuinely + verified one and a consumer would offer to land over unresolved + high-severity entries. + """ + + def test_bd_unavailable_marks_degraded(self) -> None: + runner = _json_runner() + verify, entries_patch = _patch_gate(bd_available=False) + with runner.isolated_filesystem(): + with verify, entries_patch: + result = runner.invoke(land, ["--status", "--json"]) + assert result.exit_code == 0 + doc = json.loads(result.stdout) + assert doc["ok"] is True + assert doc["result"]["degraded"] is True + assert doc["result"]["verification"] is None + # `frontier_clear` alone must not be read as landable. + assert doc["result"]["frontier_clear"] is True + + def test_healthy_ledger_is_not_degraded(self) -> None: + runner = _json_runner() + verify, entries_patch = _patch_gate(entries=[]) + with runner.isolated_filesystem(): + with verify, entries_patch: + result = runner.invoke(land, ["--status", "--json"]) + doc = json.loads(result.stdout) + assert doc["result"]["degraded"] is False + assert doc["result"]["verification"] == "verified" + + def test_degraded_is_distinct_from_degraded_persistence(self) -> None: + """The two flags mean different things and must not be conflated.""" + runner = _json_runner() + verify, entries_patch = _patch_gate(bd_available=False) + with runner.isolated_filesystem(): + with verify, entries_patch: + result = runner.invoke(land, ["--status", "--json"]) + doc = json.loads(result.stdout) + assert doc["result"]["degraded"] is True + # Persistence succeeded — only the ledger read degraded. + assert "degraded_persistence" not in doc["result"] + + +class TestLandRunDegradedSignal: + def test_apply_path_reports_degraded(self) -> None: + runner = _json_runner() + gather, curate, consolidate = _patch_curation() + verify, entries_patch = _patch_gate(bd_available=False) + with runner.isolated_filesystem(): + with gather, curate, consolidate, verify, entries_patch: + result = runner.invoke(land, ["--no-curate", "--yes", "--json"]) + doc = json.loads(result.stdout) + assert doc["result"]["degraded"] is True + assert doc["result"]["verification"] is None + + +class TestHeuristicCurationSummary: + """An absorb-only rewrite must not report as a no-op. + + `squashed_count` alone can't distinguish "absorb folded 6 fixups, + nothing squashable" from "nothing to do" — both are 0. + """ + + def test_absorb_only_is_reported(self) -> None: + runner = _json_runner() + gather, curate, consolidate = _patch_curation( + curate_result={ + "success": True, + "absorb_ran": True, + "squashed_count": 0, + "error": None, + } + ) + verify, entries_patch = _patch_gate(entries=[]) + with runner.isolated_filesystem(): + with gather, curate, consolidate, verify, entries_patch: + result = runner.invoke(land, ["--heuristic-only", "--yes", "--json"]) + doc = json.loads(result.stdout) + curation = doc["result"]["curation"] + assert curation["strategy"] == "heuristic" + assert curation["absorb_ran"] is True + assert curation["squashed_count"] == 0 + + def test_squash_counts_are_reported(self) -> None: + runner = _json_runner() + gather, curate, consolidate = _patch_curation( + curate_result={ + "success": True, + "absorb_ran": False, + "squashed_count": 3, + "error": None, + } + ) + verify, entries_patch = _patch_gate(entries=[]) + with runner.isolated_filesystem(): + with gather, curate, consolidate, verify, entries_patch: + result = runner.invoke(land, ["--heuristic-only", "--yes", "--json"]) + curation = json.loads(result.stdout)["result"]["curation"] + assert curation["absorb_ran"] is False + assert curation["squashed_count"] == 3 + assert curation["executed_count"] == 3 + assert curation["total_count"] == 3 + + +class TestLandStatusHumanModeNoDuplicateRows: + """`land --status` renders the full provenance report itself. + + Letting the gate print its own blocking panel too would list every + blocking entry twice in one invocation. + """ + + def test_blocking_entry_appears_once(self) -> None: + runner = CliRunner() + entry = _report_entry(bead_id="dea-77", severity=Severity.HIGH, status=STATUS_OPEN) + verify, entries_patch = _patch_gate(entries=[entry]) + with runner.isolated_filesystem(): + with verify, entries_patch: + result = runner.invoke(land, ["--status"]) + assert result.exit_code == 0 + assert result.output.count("dea-77") == 1 + assert "Blocking Assumptions" not in result.output diff --git a/tests/unit/cli/commands/test_reconcile_json.py b/tests/unit/cli/commands/test_reconcile_json.py new file mode 100644 index 00000000..ba6e46f6 --- /dev/null +++ b/tests/unit/cli/commands/test_reconcile_json.py @@ -0,0 +1,570 @@ +"""CLI contract tests for ``maverick reconcile --json`` (T008) per +specs/053-assumption-review-console/contracts/cli-reconcile-json.md. + +Mirrors ``tests/unit/cli/test_reconcile_command.py``'s stubbing style, but +JSON mode bypasses ``execute_python_workflow``/``load_run_state`` entirely +(both the real run and dry-run paths drive ``ReconcileWorkflow.execute()`` +directly — see ``src/maverick/cli/commands/reconcile.py``'s +``_run_reconcile_json`` docstring for why), so these tests stub +``ReconcileWorkflow._run`` — the one seam ``execute()`` calls into — same +pattern as the existing file's ``_stub_dry_run_result`` helper. +""" + +from __future__ import annotations + +import json +import os +import shutil +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +from click.testing import CliRunner, Result + +from maverick.cli.context import ExitCode +from maverick.exceptions import WorkflowError +from maverick.main import cli +from maverick.workflows.reconcile.workflow import ReconcileWorkflow + + +def _make_jj_client(files_changed: int) -> type: + """Build a fake ``JjClient`` replacement reporting *files_changed*.""" + + class _FakeJjClient: + def __init__(self, *, cwd: Path) -> None: + self._cwd = cwd + + async def diff_stat(self, revision: str = "@") -> SimpleNamespace: + return SimpleNamespace(files_changed=files_changed) + + return _FakeJjClient + + +def _stub_preconditions(monkeypatch: pytest.MonkeyPatch, *, files_changed: int = 0) -> None: + """Bypass bd-ready + jj-clean preconditions so tests reach dispatch.""" + monkeypatch.setattr("maverick.cli.commands.reconcile._require_bd_ready_json", lambda cwd: None) + monkeypatch.setattr("maverick.cli.commands.reconcile.JjClient", _make_jj_client(files_changed)) + + +def _report( + outcomes: list[dict[str, object]], + *, + run_id: str = "ab12cd34", + dry_run: bool = False, +) -> dict[str, object]: + return { + "run_id": run_id, + "outcomes": outcomes, + "dry_run": dry_run, + "started_at": "2026-07-25T00:00:00+00:00", + "finished_at": "2026-07-25T00:00:01+00:00", + "exit_success": all(o["status"] == "reconciled" for o in outcomes), + } + + +def _outcome( + entry_id: str, + status: str, + *, + reason: str = "", + stage_reached: str = "terminal", + target_change_id: str | None = "qxyzabc", + escalation_bead_id: str | None = None, + gate_passed: bool | None = True, + no_change_required: bool = False, +) -> dict[str, object]: + return { + "entry_id": entry_id, + "status": status, + "reason": reason, + "stage_reached": stage_reached, + "target_change_id": target_change_id, + "escalation_bead_id": escalation_bead_id, + "gate_passed": gate_passed, + "no_change_required": no_change_required, + } + + +def _stub_run_returns( + monkeypatch: pytest.MonkeyPatch, + report: dict[str, object], + *, + emit_progress: bool = False, +) -> None: + """Stub ``ReconcileWorkflow._run`` to return *report* directly. + + Optionally emits a couple of progress events first (via the base + class's real ``emit_step_started``/``emit_step_completed``) so tests + can assert progress narration lands on stderr, not stdout. + """ + + outcomes = report["outcomes"] + assert isinstance(outcomes, list) + + async def _fake_run(self: ReconcileWorkflow, inputs: dict[str, object]) -> dict[str, object]: + if emit_progress: + await self.emit_step_started("detect", display_label="Detecting changed answers") + await self.emit_step_completed("detect", output={"count": len(outcomes)}) + return report + + monkeypatch.setattr(ReconcileWorkflow, "_run", _fake_run) + + +def _stub_run_raises(monkeypatch: pytest.MonkeyPatch, exc: Exception) -> None: + async def _fake_run(self: ReconcileWorkflow, inputs: dict[str, object]) -> dict[str, object]: + raise exc + + monkeypatch.setattr(ReconcileWorkflow, "_run", _fake_run) + + +def _invoke(cli_runner: CliRunner, *args: str) -> Result: + return cli_runner.invoke(cli, ["reconcile", *args]) + + +class TestReconcileRunJsonSuccess: + def test_empty_outcomes_exits_success( + self, + cli_runner: CliRunner, + temp_dir: Path, + clean_env: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + os.chdir(temp_dir) + monkeypatch.setattr(Path, "home", lambda: temp_dir) + (temp_dir / ".jj").mkdir() + _stub_preconditions(monkeypatch) + _stub_run_returns(monkeypatch, _report([])) + + result = _invoke(cli_runner, "--json") + + assert result.exit_code == ExitCode.SUCCESS + data = json.loads(result.stdout) + assert data["ok"] is True + assert data["verb"] == "reconcile.run" + assert data["result"]["outcomes"] == [] + assert data["result"]["exit_success"] is True + + def test_all_reconciled_exits_success( + self, + cli_runner: CliRunner, + temp_dir: Path, + clean_env: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + os.chdir(temp_dir) + monkeypatch.setattr(Path, "home", lambda: temp_dir) + (temp_dir / ".jj").mkdir() + _stub_preconditions(monkeypatch) + report = _report([_outcome("bd-1", "reconciled")]) + _stub_run_returns(monkeypatch, report) + + result = _invoke(cli_runner, "--json") + + assert result.exit_code == ExitCode.SUCCESS + data = json.loads(result.stdout) + assert data["ok"] is True + assert data["result"] == report + + def test_escalated_outcome_exits_failure_but_ok_true( + self, + cli_runner: CliRunner, + temp_dir: Path, + clean_env: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + os.chdir(temp_dir) + monkeypatch.setattr(Path, "home", lambda: temp_dir) + (temp_dir / ".jj").mkdir() + _stub_preconditions(monkeypatch) + report = _report( + [ + _outcome( + "bd-2", + "needs_interactive_review", + reason="conflict resolution budget exhausted", + target_change_id=None, + gate_passed=False, + ) + ] + ) + _stub_run_returns(monkeypatch, report) + + result = _invoke(cli_runner, "--json") + + assert result.exit_code == ExitCode.FAILURE + data = json.loads(result.stdout) + assert data["ok"] is True + assert data["result"]["outcomes"][0]["status"] == "needs_interactive_review" + + def test_skipped_outcome_exits_failure_but_ok_true( + self, + cli_runner: CliRunner, + temp_dir: Path, + clean_env: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + os.chdir(temp_dir) + monkeypatch.setattr(Path, "home", lambda: temp_dir) + (temp_dir / ".jj").mkdir() + _stub_preconditions(monkeypatch) + report = _report( + [_outcome("bd-3", "skipped", reason="unresolvable target", target_change_id=None)] + ) + _stub_run_returns(monkeypatch, report) + + result = _invoke(cli_runner, "--json") + + assert result.exit_code == ExitCode.FAILURE + data = json.loads(result.stdout) + assert data["ok"] is True + + +class TestReconcileRunJsonPreconditions: + def test_bd_unavailable( + self, + cli_runner: CliRunner, + temp_dir: Path, + clean_env: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + os.chdir(temp_dir) + monkeypatch.setattr(Path, "home", lambda: temp_dir) + + # Only "bd" should appear missing — a global `shutil.which` stub + # (as the human-mode precondition test uses) also blanks out + # git/gh, tripping the CLI group's own git/gh preflight gate + # (main.py) before this command ever runs. + real_which = shutil.which + + def _fake_which( + cmd: str, mode: int = os.F_OK | os.X_OK, path: str | None = None + ) -> str | None: + if cmd == "bd": + return None + return real_which(cmd, mode, path) + + with patch("shutil.which", side_effect=_fake_which): + result = _invoke(cli_runner, "--json") + + assert result.exit_code == ExitCode.FAILURE + data = json.loads(result.stdout) + assert data["ok"] is False + assert data["error"]["kind"] == "bd-unavailable" + assert data["verb"] == "reconcile.run" + + def test_jj_missing_maps_to_vcs( + self, + cli_runner: CliRunner, + temp_dir: Path, + clean_env: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + os.chdir(temp_dir) + monkeypatch.setattr(Path, "home", lambda: temp_dir) + monkeypatch.setattr( + "maverick.cli.commands.reconcile._require_bd_ready_json", lambda cwd: None + ) + # No .jj/ directory created under temp_dir. + + result = _invoke(cli_runner, "--json") + + assert result.exit_code == ExitCode.FAILURE + data = json.loads(result.stdout) + assert data["ok"] is False + assert data["error"]["kind"] == "vcs" + + def test_dirty_working_copy( + self, + cli_runner: CliRunner, + temp_dir: Path, + clean_env: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + os.chdir(temp_dir) + monkeypatch.setattr(Path, "home", lambda: temp_dir) + (temp_dir / ".jj").mkdir() + _stub_preconditions(monkeypatch, files_changed=3) + + result = _invoke(cli_runner, "--json") + + assert result.exit_code == ExitCode.FAILURE + data = json.loads(result.stdout) + assert data["ok"] is False + assert data["error"]["kind"] == "dirty-working-copy" + + def test_concurrent_fly_run( + self, + cli_runner: CliRunner, + temp_dir: Path, + clean_env: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + os.chdir(temp_dir) + monkeypatch.setattr(Path, "home", lambda: temp_dir) + (temp_dir / ".jj").mkdir() + _stub_preconditions(monkeypatch) + _stub_run_raises( + monkeypatch, WorkflowError("cannot run reconcile while a fly run is in progress") + ) + + result = _invoke(cli_runner, "--json") + + assert result.exit_code == ExitCode.FAILURE + data = json.loads(result.stdout) + assert data["ok"] is False + assert data["error"]["kind"] == "concurrent-run" + + def test_lockfile_held( + self, + cli_runner: CliRunner, + temp_dir: Path, + clean_env: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + os.chdir(temp_dir) + monkeypatch.setattr(Path, "home", lambda: temp_dir) + (temp_dir / ".jj").mkdir() + _stub_preconditions(monkeypatch) + _stub_run_raises( + monkeypatch, + WorkflowError( + "another reconcile run is already in progress (lockfile held by a live process)" + ), + ) + + result = _invoke(cli_runner, "--json") + + assert result.exit_code == ExitCode.FAILURE + data = json.loads(result.stdout) + assert data["ok"] is False + assert data["error"]["kind"] == "locked" + + +class TestReconcileDryRunJson: + def test_always_exits_success_with_dry_run_true( + self, + cli_runner: CliRunner, + temp_dir: Path, + clean_env: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + os.chdir(temp_dir) + monkeypatch.setattr(Path, "home", lambda: temp_dir) + (temp_dir / ".jj").mkdir() + _stub_preconditions(monkeypatch) + report = _report([_outcome("bd-1", "skipped", target_change_id=None)], dry_run=True) + _stub_run_returns(monkeypatch, report) + + result = _invoke(cli_runner, "--dry-run", "--json") + + assert result.exit_code == ExitCode.SUCCESS + data = json.loads(result.stdout) + assert data["ok"] is True + assert data["verb"] == "reconcile.dry-run" + assert data["result"]["dry_run"] is True + + def test_empty_outcomes_exits_success( + self, + cli_runner: CliRunner, + temp_dir: Path, + clean_env: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + os.chdir(temp_dir) + monkeypatch.setattr(Path, "home", lambda: temp_dir) + (temp_dir / ".jj").mkdir() + _stub_preconditions(monkeypatch) + _stub_run_returns(monkeypatch, _report([], dry_run=True)) + + result = _invoke(cli_runner, "--dry-run", "--json") + + assert result.exit_code == ExitCode.SUCCESS + data = json.loads(result.stdout) + assert data["result"]["outcomes"] == [] + + def test_predicted_statuses_only_reconciled_or_skipped( + self, + cli_runner: CliRunner, + temp_dir: Path, + clean_env: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + os.chdir(temp_dir) + monkeypatch.setattr(Path, "home", lambda: temp_dir) + (temp_dir / ".jj").mkdir() + _stub_preconditions(monkeypatch) + report = _report( + [_outcome("bd-1", "reconciled"), _outcome("bd-2", "skipped", target_change_id=None)], + dry_run=True, + ) + _stub_run_returns(monkeypatch, report) + + result = _invoke(cli_runner, "--dry-run", "--json") + + assert result.exit_code == ExitCode.SUCCESS + statuses = {o["status"] for o in json.loads(result.stdout)["result"]["outcomes"]} + assert statuses <= {"reconciled", "skipped"} + + def test_dirty_working_copy_precondition_still_enforced( + self, + cli_runner: CliRunner, + temp_dir: Path, + clean_env: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + os.chdir(temp_dir) + monkeypatch.setattr(Path, "home", lambda: temp_dir) + (temp_dir / ".jj").mkdir() + _stub_preconditions(monkeypatch, files_changed=3) + + result = _invoke(cli_runner, "--dry-run", "--json") + + assert result.exit_code == ExitCode.FAILURE + data = json.loads(result.stdout) + assert data["error"]["kind"] == "dirty-working-copy" + + +class TestReconcileJsonStdoutPurity: + def test_stdout_is_exactly_one_document_progress_on_stderr( + self, + cli_runner: CliRunner, + temp_dir: Path, + clean_env: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + os.chdir(temp_dir) + monkeypatch.setattr(Path, "home", lambda: temp_dir) + (temp_dir / ".jj").mkdir() + _stub_preconditions(monkeypatch) + report = _report([_outcome("bd-1", "reconciled")]) + _stub_run_returns(monkeypatch, report, emit_progress=True) + + result = _invoke(cli_runner, "--json") + + assert result.exit_code == ExitCode.SUCCESS + # Exactly one parseable JSON document on stdout, no interleaved + # progress text. + assert result.stdout.count("\n") == 1 + parsed = json.loads(result.stdout.strip()) + assert parsed["ok"] is True + assert "Detecting changed answers" not in result.stdout + + # Progress narration landed on stderr instead. + assert "Detecting changed answers" in result.stderr + + def test_dry_run_stdout_is_exactly_one_document( + self, + cli_runner: CliRunner, + temp_dir: Path, + clean_env: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + os.chdir(temp_dir) + monkeypatch.setattr(Path, "home", lambda: temp_dir) + (temp_dir / ".jj").mkdir() + _stub_preconditions(monkeypatch) + report = _report([_outcome("bd-1", "reconciled")], dry_run=True) + _stub_run_returns(monkeypatch, report, emit_progress=True) + + result = _invoke(cli_runner, "--dry-run", "--json") + + assert result.exit_code == ExitCode.SUCCESS + assert result.stdout.count("\n") == 1 + json.loads(result.stdout.strip()) + assert "Detecting changed answers" in result.stderr + assert "Detecting changed answers" not in result.stdout + + +class TestReconcileJsonDispatchParity: + """`--json` bypasses `execute_python_workflow`, so the things that + helper wires up must be wired here too — otherwise the JSON path + silently loses behavior the human path has. + """ + + def test_real_run_gets_a_checkpoint_store( + self, + cli_runner: CliRunner, + temp_dir: Path, + clean_env: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + os.chdir(temp_dir) + monkeypatch.setattr(Path, "home", lambda: temp_dir) + (temp_dir / ".jj").mkdir() + _stub_preconditions(monkeypatch) + _stub_run_returns(monkeypatch, _report([])) + + seen: list[object] = [] + real_init = ReconcileWorkflow.__init__ + + def _spy_init(self: ReconcileWorkflow, **kwargs: object) -> None: + seen.append(kwargs.get("checkpoint_store")) + real_init(self, **kwargs) # type: ignore[arg-type] + + monkeypatch.setattr(ReconcileWorkflow, "__init__", _spy_init) + + result = _invoke(cli_runner, "--json") + + assert result.exit_code == ExitCode.SUCCESS + assert seen and seen[0] is not None, "real --json run must get a checkpoint store" + + def test_dry_run_gets_no_checkpoint_store( + self, + cli_runner: CliRunner, + temp_dir: Path, + clean_env: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """`--dry-run` guarantees zero filesystem mutations.""" + os.chdir(temp_dir) + monkeypatch.setattr(Path, "home", lambda: temp_dir) + (temp_dir / ".jj").mkdir() + _stub_preconditions(monkeypatch) + _stub_run_returns(monkeypatch, _report([], dry_run=True)) + + seen: list[object] = [] + real_init = ReconcileWorkflow.__init__ + + def _spy_init(self: ReconcileWorkflow, **kwargs: object) -> None: + seen.append(kwargs.get("checkpoint_store")) + real_init(self, **kwargs) # type: ignore[arg-type] + + monkeypatch.setattr(ReconcileWorkflow, "__init__", _spy_init) + + result = _invoke(cli_runner, "--dry-run", "--json") + + assert result.exit_code == ExitCode.SUCCESS + assert seen and seen[0] is None + assert not (temp_dir / ".maverick" / "checkpoints").exists() + + def test_verbosity_is_threaded_into_progress_rendering( + self, + cli_runner: CliRunner, + temp_dir: Path, + clean_env: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """`maverick -v reconcile --json` must render verbose progress.""" + os.chdir(temp_dir) + monkeypatch.setattr(Path, "home", lambda: temp_dir) + (temp_dir / ".jj").mkdir() + _stub_preconditions(monkeypatch) + _stub_run_returns(monkeypatch, _report([])) + + captured: dict[str, object] = {} + import maverick.cli.workflow_executor as wfe + + real_render = wfe.render_workflow_events + + async def _spy_render(events: object, console_obj: object, **kwargs: object) -> None: + captured.update(kwargs) + await real_render(events, console_obj, **kwargs) # type: ignore[arg-type] + + monkeypatch.setattr(wfe, "render_workflow_events", _spy_render) + + result = cli_runner.invoke(cli, ["-v", "reconcile", "--json"]) + + assert result.exit_code == ExitCode.SUCCESS + assert captured.get("verbosity") == 1 + assert "total_steps" in captured diff --git a/tests/unit/cli/commands/test_review_json.py b/tests/unit/cli/commands/test_review_json.py new file mode 100644 index 00000000..5a5dcd3f --- /dev/null +++ b/tests/unit/cli/commands/test_review_json.py @@ -0,0 +1,626 @@ +"""Unit tests for ``--json`` mode on the decision paths of ``maverick review`` +(T007/T011/T012, 053-assumption-review-console): ``review.answer``, +``review.waive``, ``review.bulk-waive``, and the legacy escalation-bead +approve/reject/defer flow under ``--json``. + +Mocking style mirrors ``tests/unit/cli/test_review_command.py`` — patch +``BeadClient`` methods and the ``maverick.assumptions.ledger`` functions +(function-local imports in the command modules), never real ``bd``. +""" + +from __future__ import annotations + +import json +from unittest.mock import AsyncMock, patch + +from click.testing import CliRunner + +from maverick.assumptions.models import ( + ASSUMPTION_LABEL, + ASSUMPTION_REVIEW_LABEL, + KEY_ANSWER, + KEY_RECONCILE_STATUS, + KEY_SEVERITY, + KEY_STATUS, + KEY_WAIVE_REASON, + KEY_WAIVED_AT, + KEY_WAIVED_BY, + NEEDS_HUMAN_REVIEW_LABEL, + RECONCILE_STATUS_PENDING, + STATUS_ANSWERED, + STATUS_OPEN, + STATUS_WAIVED, + AssumptionRecord, + BulkWaiveResult, + Severity, +) +from maverick.beads.models import BeadDetails +from maverick.cli.commands.review import review +from maverick.exceptions.beads import BeadQueryError + +_LEDGER_DESCRIPTION = ( + "## Question\n\nShould retries be per bead?\n\n" + "## Adopted Answer\n\nPer bead — matches existing scoping.\n\n" + "## Alternatives Considered\n\n(none)\n\n" + "## Context\n\nSource bead: src-1 — Implement the thing\n" +) + + +def _ledger_details(**state: str) -> BeadDetails: + return BeadDetails( + id="dea-1", + title="Assumption: Should retries be per bead?", + description=_LEDGER_DESCRIPTION, + bead_type="task", + status="open", + labels=[ASSUMPTION_LABEL], + state=state, + ) + + +def _legacy_details(**state: str) -> BeadDetails: + return BeadDetails( + id="dea-legacy", + title="Review: legacy", + description="legacy escalation", + bead_type="task", + status="open", + labels=[ASSUMPTION_REVIEW_LABEL, NEEDS_HUMAN_REVIEW_LABEL], + state=state, + ) + + +def _base_patches(show_side_effect, *, available: bool = True): + return ( + patch( + "maverick.beads.client.BeadClient.verify_available", + new=AsyncMock(return_value=available), + ), + patch( + "maverick.beads.client.BeadClient.show", + new=AsyncMock(side_effect=show_side_effect), + ), + ) + + +class TestAnswerJson: + def test_success(self) -> None: + before = _ledger_details(**{KEY_SEVERITY: "medium", KEY_STATUS: STATUS_OPEN}) + after = _ledger_details( + **{ + KEY_SEVERITY: "medium", + KEY_STATUS: STATUS_ANSWERED, + KEY_ANSWER: "Per bead.", + KEY_RECONCILE_STATUS: RECONCILE_STATUS_PENDING, + } + ) + verify, show = _base_patches([before, after]) + runner = CliRunner() + with ( + verify, + show, + patch("maverick.assumptions.ledger.answer", new=AsyncMock()) as mock_answer, + ): + result = runner.invoke(review, ["dea-1", "--answer", "Per bead.", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["ok"] is True + assert data["result"]["action"] == "answered" + assert data["result"]["entry"]["status"] == "answered" + assert data["result"]["entry"]["reconcile"]["status"] == "pending" + mock_answer.assert_awaited_once() + assert mock_answer.await_args.kwargs["answer_text"] == "Per bead." + + def test_re_answer_of_answered_entry_is_legal(self) -> None: + before = _ledger_details(**{KEY_SEVERITY: "medium", KEY_STATUS: STATUS_ANSWERED}) + after = _ledger_details( + **{ + KEY_SEVERITY: "medium", + KEY_STATUS: STATUS_ANSWERED, + KEY_ANSWER: "Updated.", + KEY_RECONCILE_STATUS: RECONCILE_STATUS_PENDING, + } + ) + verify, show = _base_patches([before, after]) + runner = CliRunner() + with ( + verify, + show, + patch("maverick.assumptions.ledger.answer", new=AsyncMock()) as mock_answer, + ): + result = runner.invoke(review, ["dea-1", "--answer", "Updated.", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["ok"] is True + assert data["result"]["action"] == "answered" + mock_answer.assert_awaited_once() + + def test_no_decision_flag_is_validation_error(self) -> None: + before = _ledger_details(**{KEY_SEVERITY: "medium", KEY_STATUS: STATUS_OPEN}) + verify, show = _base_patches([before]) + runner = CliRunner() + with verify, show: + result = runner.invoke(review, ["dea-1", "--json"]) + assert result.exit_code != 0 + data = json.loads(result.output) + assert data["ok"] is False + assert data["error"]["kind"] == "validation" + + def test_empty_answer_rejected_before_write(self) -> None: + before = _ledger_details(**{KEY_SEVERITY: "medium", KEY_STATUS: STATUS_OPEN}) + verify, show = _base_patches([before]) + runner = CliRunner() + with ( + verify, + show, + patch("maverick.assumptions.ledger.answer", new=AsyncMock()) as mock_answer, + ): + result = runner.invoke(review, ["dea-1", "--answer", " ", "--json"]) + assert result.exit_code != 0 + data = json.loads(result.output) + assert data["error"]["kind"] == "validation" + mock_answer.assert_not_called() + + def test_already_resolved_when_waived(self) -> None: + before = _ledger_details( + **{ + KEY_SEVERITY: "medium", + KEY_STATUS: STATUS_WAIVED, + KEY_WAIVED_BY: "alice", + KEY_WAIVED_AT: "2026-01-01T00:00:00+00:00", + KEY_WAIVE_REASON: "n/a", + } + ) + verify, show = _base_patches([before]) + runner = CliRunner() + with ( + verify, + show, + patch("maverick.assumptions.ledger.answer", new=AsyncMock()) as mock_answer, + ): + result = runner.invoke(review, ["dea-1", "--answer", "Per bead.", "--json"]) + assert result.exit_code != 0 + data = json.loads(result.output) + assert data["error"]["kind"] == "already-resolved" + assert data["error"]["details"]["entry"]["bead_id"] == "dea-1" + assert data["error"]["details"]["entry"]["status"] == "waived" + mock_answer.assert_not_called() + + def test_not_found(self) -> None: + verify, show = _base_patches(BeadQueryError("no such bead", query="show dea-x")) + runner = CliRunner() + with verify, show: + result = runner.invoke(review, ["dea-x", "--answer", "text", "--json"]) + assert result.exit_code != 0 + data = json.loads(result.output) + assert data["error"]["kind"] == "not-found" + assert data["error"]["details"]["bead_id"] == "dea-x" + + +class TestWaiveJson: + def test_success(self) -> None: + before = _ledger_details(**{KEY_SEVERITY: "medium", KEY_STATUS: STATUS_OPEN}) + after = _ledger_details( + **{ + KEY_SEVERITY: "medium", + KEY_STATUS: STATUS_WAIVED, + KEY_WAIVED_BY: "alice", + KEY_WAIVED_AT: "2026-01-01T00:00:00+00:00", + KEY_WAIVE_REASON: "no longer applicable", + } + ) + verify, show = _base_patches([before, after]) + runner = CliRunner() + with ( + verify, + show, + patch( + "maverick.cli.commands.review._resolve_git_user_name", + return_value="alice", + ), + patch("maverick.assumptions.ledger.waive", new=AsyncMock()) as mock_waive, + ): + result = runner.invoke(review, ["dea-1", "--waive", "no longer applicable", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["ok"] is True + assert data["result"]["action"] == "waived" + assert data["result"]["entry"]["status"] == "waived" + mock_waive.assert_awaited_once() + + def test_already_resolved_when_waived(self) -> None: + before = _ledger_details( + **{ + KEY_SEVERITY: "medium", + KEY_STATUS: STATUS_WAIVED, + KEY_WAIVED_BY: "alice", + KEY_WAIVED_AT: "2026-01-01T00:00:00+00:00", + KEY_WAIVE_REASON: "n/a", + } + ) + verify, show = _base_patches([before]) + runner = CliRunner() + with ( + verify, + show, + patch("maverick.assumptions.ledger.waive", new=AsyncMock()) as mock_waive, + ): + result = runner.invoke(review, ["dea-1", "--waive", "another reason", "--json"]) + assert result.exit_code != 0 + data = json.loads(result.output) + assert data["error"]["kind"] == "already-resolved" + mock_waive.assert_not_called() + + def test_empty_reason_rejected_before_write(self) -> None: + before = _ledger_details(**{KEY_SEVERITY: "medium", KEY_STATUS: STATUS_OPEN}) + verify, show = _base_patches([before]) + runner = CliRunner() + with ( + verify, + show, + patch("maverick.assumptions.ledger.waive", new=AsyncMock()) as mock_waive, + ): + result = runner.invoke(review, ["dea-1", "--waive", " ", "--json"]) + assert result.exit_code != 0 + data = json.loads(result.output) + assert data["error"]["kind"] == "validation" + mock_waive.assert_not_called() + + +class TestLegacyJson: + def test_approve(self) -> None: + details = _legacy_details(source_bead="src-1") + verify, show = _base_patches([details]) + runner = CliRunner() + with ( + verify, + show, + patch("maverick.beads.client.BeadClient.close", new=AsyncMock()) as mock_close, + ): + result = runner.invoke(review, ["dea-legacy", "--approve", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["ok"] is True + assert data["result"]["action"] == "approved" + mock_close.assert_awaited_once() + + def test_reject_creates_correction_bead(self) -> None: + details = _legacy_details(source_bead="src-1") + source_details = BeadDetails( + id="src-1", + title="Implement the thing", + description="", + bead_type="task", + status="open", + labels=[], + parent_id="epic-1", + state={}, + ) + verify, show = _base_patches([details, source_details]) + from maverick.beads.models import BeadCategory, BeadDefinition, BeadType, CreatedBead + + with ( + verify, + show, + patch("maverick.beads.client.BeadClient.close", new=AsyncMock()) as mock_close, + patch( + "maverick.beads.client.BeadClient.create_bead", + new=AsyncMock( + return_value=CreatedBead( + bd_id="correction-1", + definition=BeadDefinition( + title="Correction", + bead_type=BeadType.TASK, + priority=1, + category=BeadCategory.VALIDATION, + ), + ) + ), + ), + ): + result = CliRunner().invoke( + review, ["dea-legacy", "--reject", "Use Dockerfile instead", "--json"] + ) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["ok"] is True + assert data["result"]["action"] == "rejected" + assert data["result"]["correction_bead_id"] == "correction-1" + mock_close.assert_awaited_once() + + def test_defer(self) -> None: + details = _legacy_details(source_bead="src-1") + verify, show = _base_patches([details]) + runner = CliRunner() + with verify, show: + result = runner.invoke(review, ["dea-legacy", "--defer", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["ok"] is True + assert data["result"]["action"] == "deferred" + + def test_no_decision_flag_is_validation_error(self) -> None: + details = _legacy_details(source_bead="src-1") + verify, show = _base_patches([details]) + runner = CliRunner() + with verify, show: + result = runner.invoke(review, ["dea-legacy", "--json"]) + assert result.exit_code != 0 + data = json.loads(result.output) + assert data["error"]["kind"] == "validation" + + +def _waived_record(bead_id: str, question: str = "Q?") -> AssumptionRecord: + return AssumptionRecord( + bead_id=bead_id, + question=question, + adopted_answer="A.", + alternatives=(), + severity=Severity.LOW, + severity_defaulted=False, + status=STATUS_WAIVED, + owner_spec="052-conditional-landing", + source_bead="dea-0", + change_ids=(), + is_legacy=False, + ) + + +def _waived_details(bead_id: str) -> BeadDetails: + return BeadDetails( + id=bead_id, + title="Assumption: Q?", + description=( + "## Question\n\nQ?\n\n## Adopted Answer\n\nA.\n\n" + "## Alternatives Considered\n\n(none)\n\n## Context\n\nSource bead: dea-0 — x\n" + ), + bead_type="task", + status="closed", + labels=[ASSUMPTION_LABEL], + state={ + KEY_SEVERITY: "low", + KEY_STATUS: STATUS_WAIVED, + KEY_WAIVED_BY: "alice", + KEY_WAIVED_AT: "2026-01-01T00:00:00+00:00", + KEY_WAIVE_REASON: "accepted for MVP", + }, + ) + + +class TestBulkWaiveJson: + def test_success_empty_failed(self) -> None: + result_obj = BulkWaiveResult( + waived=(_waived_record("dea-1"), _waived_record("dea-2")), + failed={}, + ) + verify, show = _base_patches([_waived_details("dea-1"), _waived_details("dea-2")]) + runner = CliRunner() + with ( + verify, + show, + patch( + "maverick.cli.commands.review._resolve_git_user_name", + return_value="alice", + ), + patch( + "maverick.assumptions.ledger.bulk_waive", + new=AsyncMock(return_value=result_obj), + ), + ): + result = runner.invoke( + review, + ["--spec", "052-conditional-landing", "--waive", "accepted for MVP", "--json"], + ) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["ok"] is True + assert data["result"]["owner_spec"] == "052-conditional-landing" + assert data["result"]["severities"] == ["low"] + assert {row["bead_id"] for row in data["result"]["waived"]} == {"dea-1", "dea-2"} + assert data["result"]["failed"] == {} + + def test_zero_matches_is_success(self) -> None: + result_obj = BulkWaiveResult(waived=(), failed={}) + verify, show = _base_patches([]) + runner = CliRunner() + with ( + verify, + show, + patch( + "maverick.cli.commands.review._resolve_git_user_name", + return_value="alice", + ), + patch( + "maverick.assumptions.ledger.bulk_waive", + new=AsyncMock(return_value=result_obj), + ), + ): + result = runner.invoke( + review, + ["--spec", "052-conditional-landing", "--waive", "noise", "--json"], + ) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["ok"] is True + assert data["result"]["waived"] == [] + + def test_partial_failure_exits_nonzero_but_ok_true(self) -> None: + result_obj = BulkWaiveResult( + waived=(_waived_record("dea-ok"),), + failed={"dea-fails": "bd write failed"}, + ) + verify, show = _base_patches([_waived_details("dea-ok")]) + runner = CliRunner() + with ( + verify, + show, + patch( + "maverick.cli.commands.review._resolve_git_user_name", + return_value="alice", + ), + patch( + "maverick.assumptions.ledger.bulk_waive", + new=AsyncMock(return_value=result_obj), + ), + ): + result = runner.invoke( + review, + ["--spec", "052-conditional-landing", "--waive", "noise", "--json"], + ) + assert result.exit_code != 0 + data = json.loads(result.output) + assert data["ok"] is True + assert data["result"]["failed"] == {"dea-fails": "bd write failed"} + + +class TestBdUnavailableKind: + """`verify_available()` returning False is `bd-unavailable`, never + `validation`. + + `json_error_handler` can't classify a check that never raises, so each + verb translates the precondition itself — and all of them must agree + with `review --list`, which already reported `bd-unavailable` for the + identical condition. The skill's Preflight branches on this kind to + tell an environment problem apart from bad user input. + """ + + def test_single_entry_answer(self) -> None: + verify, show = _base_patches([], available=False) + runner = CliRunner() + with verify, show: + result = runner.invoke(review, ["dea-1", "--answer", "x", "--json"]) + assert result.exit_code != 0 + data = json.loads(result.stdout) + assert data["ok"] is False + assert data["verb"] == "review.answer" + assert data["error"]["kind"] == "bd-unavailable" + + def test_single_entry_waive_uses_waive_verb(self) -> None: + verify, show = _base_patches([], available=False) + runner = CliRunner() + with verify, show: + result = runner.invoke(review, ["dea-1", "--waive", "why", "--json"]) + data = json.loads(result.stdout) + assert data["verb"] == "review.waive" + assert data["error"]["kind"] == "bd-unavailable" + + def test_bulk_waive(self) -> None: + verify, show = _base_patches([], available=False) + runner = CliRunner() + with verify, show: + result = runner.invoke(review, ["--spec", "052-x", "--waive", "r", "--json"]) + assert result.exit_code != 0 + data = json.loads(result.stdout) + assert data["verb"] == "review.bulk-waive" + assert data["error"]["kind"] == "bd-unavailable" + + +class TestPostWriteReadFailsSoft: + """A ledger write that succeeded must never be reported as a failure. + + The post-write re-read exists only to project the response row. If it + fails, the decision is still recorded — reporting `ok: false` would + tell the human their answer wasn't saved while the ledger says it was. + """ + + def test_answer_write_succeeds_but_reread_fails(self) -> None: + before = _ledger_details(**{KEY_SEVERITY: "medium", KEY_STATUS: STATUS_OPEN}) + verify, show = _base_patches([before, BeadQueryError("transient bd failure")]) + runner = CliRunner() + answer_mock = AsyncMock() + with ( + verify, + show, + patch("maverick.assumptions.ledger.answer", new=answer_mock), + ): + result = runner.invoke(review, ["dea-1", "--answer", "Per bead.", "--json"]) + + answer_mock.assert_awaited_once() + assert result.exit_code == 0 + data = json.loads(result.stdout) + assert data["ok"] is True + assert data["result"]["action"] == "answered" + assert data["result"]["entry"] is None + assert data["result"]["degraded"] is True + + def test_waive_write_succeeds_but_reread_fails(self) -> None: + before = _ledger_details(**{KEY_SEVERITY: "low", KEY_STATUS: STATUS_OPEN}) + verify, show = _base_patches([before, BeadQueryError("transient bd failure")]) + runner = CliRunner() + waive_mock = AsyncMock() + with ( + verify, + show, + patch("maverick.assumptions.ledger.waive", new=waive_mock), + patch( + "maverick.cli.commands.review._resolve_git_user_name", + return_value="alice", + ), + ): + result = runner.invoke(review, ["dea-1", "--waive", "not applicable", "--json"]) + + waive_mock.assert_awaited_once() + assert result.exit_code == 0 + data = json.loads(result.stdout) + assert data["ok"] is True + assert data["result"]["action"] == "waived" + assert data["result"]["entry"] is None + assert data["result"]["degraded"] is True + + def test_successful_reread_carries_no_degraded_flag(self) -> None: + before = _ledger_details(**{KEY_SEVERITY: "medium", KEY_STATUS: STATUS_OPEN}) + after = _ledger_details( + **{ + KEY_SEVERITY: "medium", + KEY_STATUS: STATUS_ANSWERED, + KEY_ANSWER: "Per bead.", + KEY_RECONCILE_STATUS: RECONCILE_STATUS_PENDING, + } + ) + verify, show = _base_patches([before, after]) + runner = CliRunner() + with ( + verify, + show, + patch("maverick.assumptions.ledger.answer", new=AsyncMock()), + ): + result = runner.invoke(review, ["dea-1", "--answer", "Per bead.", "--json"]) + + data = json.loads(result.stdout) + assert data["result"]["entry"]["bead_id"] == "dea-1" + assert "degraded" not in data["result"] + + def test_bulk_waive_partial_projection_failure_keeps_successes(self) -> None: + """One unreadable row must not discard the other waives.""" + result_obj = BulkWaiveResult( + waived=(_waived_record("dea-1"), _waived_record("dea-2")), + failed={}, + ) + verify, show = _base_patches( + [_waived_details("dea-1"), BeadQueryError("transient bd failure")] + ) + runner = CliRunner() + with ( + verify, + show, + patch( + "maverick.cli.commands.review._resolve_git_user_name", + return_value="alice", + ), + patch( + "maverick.assumptions.ledger.bulk_waive", + new=AsyncMock(return_value=result_obj), + ), + ): + result = runner.invoke( + review, + ["--spec", "052-conditional-landing", "--waive", "accepted", "--json"], + ) + + assert result.exit_code == 0 + data = json.loads(result.stdout) + assert data["ok"] is True + assert [row["bead_id"] for row in data["result"]["waived"]] == ["dea-1"] + # dea-2 WAS waived — only its row couldn't be re-read. + assert data["result"]["unprojected"] == ["dea-2"] + assert data["result"]["failed"] == {} diff --git a/tests/unit/cli/commands/test_review_listing.py b/tests/unit/cli/commands/test_review_listing.py new file mode 100644 index 00000000..e6615283 --- /dev/null +++ b/tests/unit/cli/commands/test_review_listing.py @@ -0,0 +1,303 @@ +"""Unit tests for ``maverick review --list [--json]`` (T006/T010, +053-assumption-review-console). + +Mocks ``BeadClient.verify_available`` and +``maverick.assumptions.ledger.report_entries`` — the same mocking style as +``tests/unit/cli/test_review_command.py`` — so no real ``bd`` invocation +occurs. +""" + +from __future__ import annotations + +import json +from unittest.mock import AsyncMock, patch + +from click.testing import CliRunner + +from maverick.assumptions.models import ( + STATUS_ANSWERED, + STATUS_OPEN, + STATUS_WAIVED, + AssumptionRecord, + AssumptionReportEntry, + Severity, +) +from maverick.cli.commands.review import review + + +def _entry( + bead_id: str, + *, + owner_spec: str, + severity: Severity, + status: str = STATUS_OPEN, + pending_reconcile: bool = False, + question: str = "Q?", +) -> AssumptionReportEntry: + record = AssumptionRecord( + bead_id=bead_id, + question=question, + adopted_answer="A.", + alternatives=(), + severity=severity, + severity_defaulted=False, + status=status, + owner_spec=owner_spec, + source_bead="src-1", + change_ids=(), + is_legacy=False, + ) + return AssumptionReportEntry( + record=record, + final_answer="A." if status == STATUS_ANSWERED else None, + waived_by="alice" if status == STATUS_WAIVED else None, + waived_at="2026-01-01T00:00:00+00:00" if status == STATUS_WAIVED else None, + waive_reason="n/a" if status == STATUS_WAIVED else None, + reconcile_status=None, + reconciled_answer=None, + reconcile_change_id=None, + reconcile_reason=None, + pending_reconcile=pending_reconcile, + ) + + +def _patched(entries: tuple[AssumptionReportEntry, ...], *, available: bool = True): + return ( + patch( + "maverick.beads.client.BeadClient.verify_available", + new=AsyncMock(return_value=available), + ), + patch( + "maverick.assumptions.ledger.report_entries", + new=AsyncMock(return_value=entries), + ), + ) + + +class TestDefaultStatusFilter: + def test_default_selects_open_only(self) -> None: + entries = ( + _entry("dea-1", owner_spec="049-spec", severity=Severity.MEDIUM, status=STATUS_OPEN), + _entry( + "dea-2", owner_spec="049-spec", severity=Severity.MEDIUM, status=STATUS_ANSWERED + ), + _entry("dea-3", owner_spec="049-spec", severity=Severity.MEDIUM, status=STATUS_WAIVED), + ) + verify, sweep = _patched(entries) + runner = CliRunner() + with verify, sweep: + result = runner.invoke(review, ["--list", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["ok"] is True + ids = [row["bead_id"] for row in data["result"]["entries"]] + assert ids == ["dea-1"] + + +class TestRepeatableOptions: + def test_status_option_repeatable_or_within_option(self) -> None: + entries = ( + _entry("dea-1", owner_spec="049-spec", severity=Severity.MEDIUM, status=STATUS_OPEN), + _entry( + "dea-2", owner_spec="049-spec", severity=Severity.MEDIUM, status=STATUS_ANSWERED + ), + _entry("dea-3", owner_spec="049-spec", severity=Severity.MEDIUM, status=STATUS_WAIVED), + ) + verify, sweep = _patched(entries) + runner = CliRunner() + with verify, sweep: + result = runner.invoke( + review, + ["--list", "--status", "open", "--status", "answered", "--json"], + ) + assert result.exit_code == 0 + data = json.loads(result.output) + ids = {row["bead_id"] for row in data["result"]["entries"]} + assert ids == {"dea-1", "dea-2"} + + def test_spec_and_severity_and_across_options(self) -> None: + entries = ( + _entry("dea-1", owner_spec="049-spec", severity=Severity.HIGH, status=STATUS_OPEN), + _entry("dea-2", owner_spec="049-spec", severity=Severity.LOW, status=STATUS_OPEN), + _entry("dea-3", owner_spec="050-spec", severity=Severity.HIGH, status=STATUS_OPEN), + ) + verify, sweep = _patched(entries) + runner = CliRunner() + with verify, sweep: + result = runner.invoke( + review, + [ + "--list", + "--spec", + "049-spec", + "--severity", + "high", + "--json", + ], + ) + assert result.exit_code == 0 + data = json.loads(result.output) + ids = [row["bead_id"] for row in data["result"]["entries"]] + assert ids == ["dea-1"] + + def test_severity_option_repeatable_or_within_option(self) -> None: + entries = ( + _entry("dea-1", owner_spec="049-spec", severity=Severity.HIGH, status=STATUS_OPEN), + _entry("dea-2", owner_spec="049-spec", severity=Severity.LOW, status=STATUS_OPEN), + _entry("dea-3", owner_spec="049-spec", severity=Severity.MEDIUM, status=STATUS_OPEN), + ) + verify, sweep = _patched(entries) + runner = CliRunner() + with verify, sweep: + result = runner.invoke( + review, + ["--list", "--severity", "high", "--severity", "low", "--json"], + ) + assert result.exit_code == 0 + data = json.loads(result.output) + ids = {row["bead_id"] for row in data["result"]["entries"]} + assert ids == {"dea-1", "dea-2"} + + +class TestCanonicalOrdering: + def test_owner_spec_asc_then_severity_desc_then_stable(self) -> None: + # Deliberately constructed out of final order. + entries = ( + _entry("dea-a", owner_spec="050-spec", severity=Severity.LOW, status=STATUS_OPEN), + _entry("dea-b", owner_spec="049-spec", severity=Severity.LOW, status=STATUS_OPEN), + _entry("dea-c", owner_spec="049-spec", severity=Severity.HIGH, status=STATUS_OPEN), + _entry("dea-d", owner_spec="049-spec", severity=Severity.HIGH, status=STATUS_OPEN), + ) + verify, sweep = _patched(entries) + runner = CliRunner() + with verify, sweep: + result = runner.invoke(review, ["--list", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + ids = [row["bead_id"] for row in data["result"]["entries"]] + # 049-spec before 050-spec; within 049-spec, high (c, d — stable + # order preserved) before low (b). + assert ids == ["dea-c", "dea-d", "dea-b", "dea-a"] + + +class TestCounts: + def test_counts_reflect_filtered_selection(self) -> None: + entries = ( + _entry("dea-1", owner_spec="049-spec", severity=Severity.HIGH, status=STATUS_OPEN), + _entry( + "dea-2", + owner_spec="049-spec", + severity=Severity.MEDIUM, + status=STATUS_OPEN, + pending_reconcile=True, + ), + _entry("dea-3", owner_spec="050-spec", severity=Severity.LOW, status=STATUS_ANSWERED), + ) + verify, sweep = _patched(entries) + runner = CliRunner() + with verify, sweep: + result = runner.invoke( + review, ["--list", "--status", "open", "--status", "answered", "--json"] + ) + assert result.exit_code == 0 + data = json.loads(result.output) + counts = data["result"]["counts"] + assert counts["total"] == 3 + assert counts["by_status"] == {"open": 2, "answered": 1, "waived": 0} + assert counts["by_severity"] == {"low": 1, "medium": 1, "high": 1} + assert counts["pending_reconcile"] == 1 + + +class TestEmptyQueue: + def test_empty_queue_is_ok_true_empty_entries(self) -> None: + verify, sweep = _patched(()) + runner = CliRunner() + with verify, sweep: + result = runner.invoke(review, ["--list", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["ok"] is True + assert data["result"]["entries"] == [] + assert data["result"]["counts"]["total"] == 0 + + +class TestBdUnavailable: + def test_bd_unavailable_json(self) -> None: + verify, sweep = _patched((), available=False) + runner = CliRunner() + with verify, sweep: + result = runner.invoke(review, ["--list", "--json"]) + assert result.exit_code != 0 + data = json.loads(result.output) + assert data["ok"] is False + assert data["error"]["kind"] == "bd-unavailable" + + def test_bd_unavailable_human(self) -> None: + verify, sweep = _patched((), available=False) + runner = CliRunner() + with verify, sweep: + result = runner.invoke(review, ["--list"]) + assert result.exit_code != 0 + + +class TestMutualExclusion: + def test_list_and_bead_id_json(self) -> None: + runner = CliRunner() + result = runner.invoke(review, ["dea-1", "--list", "--json"]) + assert result.exit_code != 0 + data = json.loads(result.output) + assert data["ok"] is False + assert data["error"]["kind"] == "validation" + + def test_list_and_bead_id_human(self) -> None: + runner = CliRunner() + result = runner.invoke(review, ["dea-1", "--list"]) + assert result.exit_code != 0 + assert "mutually exclusive" in result.output.lower() + + def test_list_and_answer_flag(self) -> None: + runner = CliRunner() + result = runner.invoke(review, ["--list", "--answer", "text", "--json"]) + assert result.exit_code != 0 + data = json.loads(result.output) + assert data["error"]["kind"] == "validation" + + def test_list_and_waive_flag(self) -> None: + runner = CliRunner() + result = runner.invoke(review, ["--list", "--waive", "reason", "--json"]) + assert result.exit_code != 0 + + def test_list_and_approve_flag(self) -> None: + runner = CliRunner() + result = runner.invoke(review, ["--list", "--approve", "--json"]) + assert result.exit_code != 0 + + def test_list_and_reject_flag(self) -> None: + runner = CliRunner() + result = runner.invoke(review, ["--list", "--reject", "guidance", "--json"]) + assert result.exit_code != 0 + + def test_list_and_defer_flag(self) -> None: + runner = CliRunner() + result = runner.invoke(review, ["--list", "--defer", "--json"]) + assert result.exit_code != 0 + + +class TestHumanModeRendering: + def test_renders_table_without_crashing(self) -> None: + entries = ( + _entry("dea-1", owner_spec="049-spec", severity=Severity.HIGH, status=STATUS_OPEN), + ) + verify, sweep = _patched(entries) + runner = CliRunner() + with verify, sweep: + result = runner.invoke(review, ["--list"]) + assert result.exit_code == 0 + assert "dea-1" in result.output + + def test_renders_empty_without_crashing(self) -> None: + verify, sweep = _patched(()) + runner = CliRunner() + with verify, sweep: + result = runner.invoke(review, ["--list"]) + assert result.exit_code == 0 diff --git a/tests/unit/cli/test_json_output.py b/tests/unit/cli/test_json_output.py new file mode 100644 index 00000000..e2cbc396 --- /dev/null +++ b/tests/unit/cli/test_json_output.py @@ -0,0 +1,414 @@ +"""Unit tests for the JSON envelope machinery (feature 053). + +Covers `ErrorKind`, `JsonEnvelope`/`JsonError`, `emit_json`, and +`json_error_handler` per `specs/053-assumption-review-console/contracts/ +error-envelope.md`. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from maverick.assumptions.errors import AssumptionLedgerError +from maverick.cli.context import ExitCode +from maverick.cli.json_output import ( + ErrorKind, + JsonEnvelope, + JsonError, + emit_json, + json_error_handler, +) +from maverick.exceptions import ( + REASON_CONCURRENT_RUN, + REASON_DIRTY_WORKING_COPY, + REASON_LOCKED, + JjConflictError, + JjError, + MaverickError, + WorkflowError, +) +from maverick.exceptions.beads import BeadQueryError + +# ============================================================================= +# ErrorKind registry +# ============================================================================= + + +class TestErrorKind: + """The 12-value ErrorKind registry is frozen per the contract.""" + + def test_exact_registry_values(self) -> None: + expected = { + "validation", + "not-found", + "already-resolved", + "bd-unavailable", + "dirty-working-copy", + "concurrent-run", + "locked", + "frontier-blocked", + "confirmation-required", + "curation-failed", + "vcs", + "internal", + } + actual = {member.value for member in ErrorKind} + assert actual == expected + + def test_is_str_enum(self) -> None: + assert ErrorKind.VALIDATION == "validation" + assert isinstance(ErrorKind.VALIDATION, str) + + +# ============================================================================= +# JsonEnvelope / JsonError shape +# ============================================================================= + + +class TestJsonEnvelope: + def test_success_shape(self) -> None: + envelope = JsonEnvelope.success("review.list", {"entries": []}) + data = envelope.to_dict() + + assert data == { + "schema_version": 1, + "verb": "review.list", + "ok": True, + "result": {"entries": []}, + } + + def test_success_omits_error_key(self) -> None: + envelope = JsonEnvelope.success("review.list", {"entries": []}) + data = envelope.to_dict() + + assert "error" not in data + + def test_failure_shape(self) -> None: + envelope = JsonEnvelope.failure( + "review.answer", + ErrorKind.NOT_FOUND, + "no such entry", + details={"bead_id": "bd-1"}, + ) + data = envelope.to_dict() + + assert data == { + "schema_version": 1, + "verb": "review.answer", + "ok": False, + "error": { + "kind": "not-found", + "message": "no such entry", + "details": {"bead_id": "bd-1"}, + }, + } + + def test_failure_omits_result_key(self) -> None: + envelope = JsonEnvelope.failure("review.answer", ErrorKind.NOT_FOUND, "no such entry") + data = envelope.to_dict() + + assert "result" not in data + + def test_failure_default_details_empty_dict(self) -> None: + envelope = JsonEnvelope.failure("review.answer", ErrorKind.INTERNAL, "boom") + data = envelope.to_dict() + + assert data["error"]["details"] == {} + + def test_schema_version_is_one(self) -> None: + success = JsonEnvelope.success("review.list", {}) + failure = JsonEnvelope.failure("review.list", ErrorKind.INTERNAL, "x") + + assert success.schema_version == 1 + assert failure.schema_version == 1 + + def test_json_error_dataclass_fields(self) -> None: + err = JsonError(kind=ErrorKind.VCS, message="jj failed") + assert err.kind == ErrorKind.VCS + assert err.message == "jj failed" + assert err.details == {} + + def test_result_and_error_mutually_exclusive_in_dict(self) -> None: + success = JsonEnvelope.success("review.list", {"a": 1}) + failure = JsonEnvelope.failure("review.list", ErrorKind.INTERNAL, "x") + + success_dict = success.to_dict() + failure_dict = failure.to_dict() + + assert ("result" in success_dict) and ("error" not in success_dict) + assert ("error" in failure_dict) and ("result" not in failure_dict) + + +# ============================================================================= +# emit_json — stdout stream discipline +# ============================================================================= + + +class TestEmitJson: + def test_writes_single_parseable_document(self, capsys: pytest.CaptureFixture[str]) -> None: + envelope = JsonEnvelope.success("review.list", {"entries": [1, 2, 3]}) + emit_json(envelope) + + captured = capsys.readouterr() + assert captured.err == "" + parsed = json.loads(captured.out) + assert parsed == envelope.to_dict() + + def test_no_ansi_or_markup_leaks(self, capsys: pytest.CaptureFixture[str]) -> None: + envelope = JsonEnvelope.failure("review.list", ErrorKind.INTERNAL, "boom") + emit_json(envelope) + + captured = capsys.readouterr() + assert "\x1b[" not in captured.out + assert "[red]" not in captured.out + + def test_emoji_shortcodes_survive_verbatim(self, capsys: pytest.CaptureFixture[str]) -> None: + """`:name:` runs in free text must not be rewritten to unicode emoji. + + Rich substitutes emoji shortcodes by default. Assumption questions, + adopted answers and waive reasons are agent- and human-authored and + routinely contain `:key:`-shaped runs; silently rewriting them + corrupts the ledger round-trip (`review --answer ""` + writes the mutated text straight back). + """ + question = "Should we key off the :key: field or :id: for lookup?" + emit_json(JsonEnvelope.success("review.list", {"question": question})) + + parsed = json.loads(capsys.readouterr().out) + assert parsed["result"]["question"] == question + + def test_free_text_round_trips_unchanged(self, capsys: pytest.CaptureFixture[str]) -> None: + """Nothing in the transport may mutate a string value.""" + payload = { + "markup": "[red]not a style tag[/red]", + "emoji": ":rocket: :100: :-)", + "unicode": "café — naïve … ✓", + "control": "tab\there\nnewline", + "long": "x" * 500, # must not be wrapped + } + emit_json(JsonEnvelope.success("review.list", payload)) + + parsed = json.loads(capsys.readouterr().out) + assert parsed["result"] == payload + + def test_exactly_one_trailing_newline(self, capsys: pytest.CaptureFixture[str]) -> None: + envelope = JsonEnvelope.success("review.list", {"x": 1}) + emit_json(envelope) + + captured = capsys.readouterr() + # Exactly one document: stripping one trailing newline gives valid JSON + # with no further newlines embedded (single-line-safe JSON output). + assert captured.out.count("\n") == 1 + assert captured.out.endswith("\n") + json.loads(captured.out.strip()) + + +# ============================================================================= +# json_error_handler — exception → envelope mapping +# ============================================================================= + + +class TestJsonErrorHandler: + def test_keyboard_interrupt_emits_no_document( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + with pytest.raises(SystemExit) as exc_info, json_error_handler("review.list"): + raise KeyboardInterrupt() + + assert exc_info.value.code == ExitCode.INTERRUPTED + captured = capsys.readouterr() + assert captured.out == "" + + def test_workflow_error_dirty_working_copy(self, capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as exc_info, json_error_handler("reconcile.run"): + raise WorkflowError( + "working copy is not clean — commit or discard changes before running reconcile" + ) + + assert exc_info.value.code == ExitCode.FAILURE + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data["ok"] is False + assert data["error"]["kind"] == "dirty-working-copy" + + def test_workflow_error_concurrent_run(self, capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit), json_error_handler("reconcile.run"): + raise WorkflowError("cannot run reconcile while a fly run is in progress") + + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data["error"]["kind"] == "concurrent-run" + + def test_workflow_error_locked(self, capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit), json_error_handler("reconcile.run"): + raise WorkflowError( + "another reconcile run is already in progress (lockfile held by a live process)" + ) + + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data["error"]["kind"] == "locked" + + def test_workflow_error_other_message_falls_back( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + with pytest.raises(SystemExit), json_error_handler("reconcile.run"): + raise WorkflowError("something else entirely broke") + + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data["error"]["kind"] == "internal" + + @pytest.mark.parametrize( + ("reason", "expected_kind"), + [ + (REASON_DIRTY_WORKING_COPY, "dirty-working-copy"), + (REASON_CONCURRENT_RUN, "concurrent-run"), + (REASON_LOCKED, "locked"), + ], + ) + def test_typed_reason_wins_over_prose( + self, + reason: str, + expected_kind: str, + capsys: pytest.CaptureFixture[str], + ) -> None: + """Reworded messages must keep classifying — that's the point of `reason_code`. + + The message below matches none of the legacy prose markers, so a + substring-only implementation would classify all three as + ``internal``. + """ + with pytest.raises(SystemExit), json_error_handler("reconcile.run"): + raise WorkflowError("a completely reworded precondition message", reason_code=reason) + + data = json.loads(capsys.readouterr().out) + assert data["error"]["kind"] == expected_kind + + def test_unknown_reason_falls_back_to_prose_markers( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + with pytest.raises(SystemExit), json_error_handler("reconcile.run"): + raise WorkflowError("working copy is not clean", reason_code="not-a-known-reason") + + data = json.loads(capsys.readouterr().out) + assert data["error"]["kind"] == "dirty-working-copy" + + def test_reconcile_workflow_raises_carry_typed_reasons(self) -> None: + """The workflow's own raises set `reason_code`, not just prose (regression). + + Without this, rewording ``workflow.py``'s precondition messages + would silently break the skill's remediation branches with no test + failure — the exact fragility the typed reason removes. + """ + source = ( + Path(__file__).resolve().parents[3] + / "src" + / "maverick" + / "workflows" + / "reconcile" + / "workflow.py" + ).read_text(encoding="utf-8") + for constant in ( + "REASON_DIRTY_WORKING_COPY", + "REASON_CONCURRENT_RUN", + "REASON_LOCKED", + ): + assert f"reason_code={constant}" in source + + def test_jj_error_maps_to_vcs_with_operation_detail( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + with pytest.raises(SystemExit), json_error_handler("reconcile.run"): + raise JjError("jj describe failed", command="describe") + + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data["error"]["kind"] == "vcs" + assert data["error"]["details"] == {"operation": "describe"} + + def test_jj_conflict_error_subclass_maps_to_vcs( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + with pytest.raises(SystemExit), json_error_handler("reconcile.run"): + raise JjConflictError("conflicts detected", command="rebase") + + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data["error"]["kind"] == "vcs" + assert data["error"]["details"] == {"operation": "rebase"} + + def test_jj_error_without_command_has_no_operation_detail( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + with pytest.raises(SystemExit), json_error_handler("reconcile.run"): + raise JjError("something failed") + + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data["error"]["kind"] == "vcs" + assert data["error"]["details"] == {} + + def test_bead_query_error_maps_to_bd_unavailable( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + with pytest.raises(SystemExit), json_error_handler("review.list"): + raise BeadQueryError("bd query failed", query="ready") + + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data["error"]["kind"] == "bd-unavailable" + + def test_assumption_ledger_error_maps_to_validation( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + with pytest.raises(SystemExit), json_error_handler("review.answer"): + raise AssumptionLedgerError("answer text must not be empty") + + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data["error"]["kind"] == "validation" + + def test_maverick_error_catch_all_maps_to_internal( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + with pytest.raises(SystemExit), json_error_handler("review.list"): + raise MaverickError("something maverick-specific broke") + + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data["error"]["kind"] == "internal" + + def test_bare_exception_maps_to_internal(self, capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as exc_info, json_error_handler("review.list"): + raise ValueError("unexpected") + + assert exc_info.value.code == ExitCode.FAILURE + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data["ok"] is False + assert data["error"]["kind"] == "internal" + + def test_failure_envelope_carries_verb(self, capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit), json_error_handler("land.run"): + raise MaverickError("boom") + + captured = capsys.readouterr() + data = json.loads(captured.out) + assert data["verb"] == "land.run" + + def test_raises_system_exit_with_failure_code(self) -> None: + with pytest.raises(SystemExit) as exc_info, json_error_handler("review.list"): + raise MaverickError("boom") + + assert exc_info.value.code == ExitCode.FAILURE + + def test_success_case_no_exception(self) -> None: + result = None + with json_error_handler("review.list"): + result = "success" + + assert result == "success" diff --git a/tests/unit/cli/test_verify_bd_ready.py b/tests/unit/cli/test_verify_bd_ready.py index 8ad26ab1..4c9429c1 100644 --- a/tests/unit/cli/test_verify_bd_ready.py +++ b/tests/unit/cli/test_verify_bd_ready.py @@ -83,3 +83,63 @@ def test_verify_bd_ready_jsonl_only_is_not_initialized(temp_dir: Path) -> None: with pytest.raises(SystemExit) as exc_info: verify_bd_ready(cwd=temp_dir) assert exc_info.value.code == ExitCode.FAILURE + + +# ── The shared predicate both modes consume ───────────────────────── +# +# `verify_bd_ready` (human: prints + exits) and reconcile's +# `_require_bd_ready_json` (JSON: raises BeadError) must never disagree +# about whether bd is usable, so the conditions live in exactly one place. + + +def test_bd_ready_reason_bd_missing(temp_dir: Path) -> None: + from maverick.cli.common import BD_MISSING, bd_ready_reason + + with patch("shutil.which", return_value=None): + assert bd_ready_reason(cwd=temp_dir) == BD_MISSING + + +def test_bd_ready_reason_not_initialized(temp_dir: Path) -> None: + from maverick.cli.common import BD_NOT_INITIALIZED, bd_ready_reason + + with patch("shutil.which", return_value="/usr/bin/bd"): + assert bd_ready_reason(cwd=temp_dir) == BD_NOT_INITIALIZED + + +def test_bd_ready_reason_none_when_ready(temp_dir: Path) -> None: + from maverick.cli.common import bd_ready_reason + + (temp_dir / ".beads" / "embeddeddolt").mkdir(parents=True) + _seed_metadata(temp_dir / ".beads") + with patch("shutil.which", return_value="/usr/bin/bd"): + assert bd_ready_reason(cwd=temp_dir) is None + + +@pytest.mark.parametrize( + ("which_return", "seed"), + [(None, False), ("/usr/bin/bd", False), ("/usr/bin/bd", True)], +) +def test_json_and_human_modes_agree(temp_dir: Path, which_return: str | None, seed: bool) -> None: + """Same repo state → same verdict in both modes. + + A third readiness condition added to the shared predicate is picked up + by both paths automatically; before it was factored out, the JSON path + silently accepted repos the human path rejected. + """ + from maverick.cli.commands.reconcile import _require_bd_ready_json + from maverick.cli.common import bd_ready_reason + from maverick.exceptions import BeadError + + if seed: + (temp_dir / ".beads" / "embeddeddolt").mkdir(parents=True) + _seed_metadata(temp_dir / ".beads") + + with patch("shutil.which", return_value=which_return): + human_ok = bd_ready_reason(cwd=temp_dir) is None + try: + _require_bd_ready_json(temp_dir) + json_ok = True + except BeadError: + json_ok = False + + assert human_ok == json_ok diff --git a/tests/unit/init/test_skill_install.py b/tests/unit/init/test_skill_install.py new file mode 100644 index 00000000..75dc059d --- /dev/null +++ b/tests/unit/init/test_skill_install.py @@ -0,0 +1,374 @@ +"""Unit tests for the packaged ``maverick-review`` skill install/uninstall. + +Covers the ``maverick init`` install step (``_install_review_skill`` in +:mod:`maverick.init`) and the ``maverick uninstall`` removal path +(:mod:`maverick.cli.commands.uninstall`), plus a shape-only assertion on +the packaged ``src/maverick/skills/review_console/SKILL.md`` frontmatter +(content is authored by a separate task; we only assert the frontmatter +contract, not exact prose). +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest + +from maverick.init import ( + InitPreflightResult, + PreflightStatus, + PrerequisiteCheck, + ProjectType, + run_init, +) +from maverick.main import cli + +_SKILL_RELATIVE_PATH = Path(".claude") / "skills" / "maverick-review" / "SKILL.md" +_PACKAGED_SKILL_PATH = ( + Path(__file__).resolve().parents[3] + / "src" + / "maverick" + / "skills" + / "review_console" + / "SKILL.md" +) + + +def _packaged_skill_content() -> str: + """Read the packaged SKILL.md source shipped with maverick.""" + return _PACKAGED_SKILL_PATH.read_text(encoding="utf-8") + + +def _parse_frontmatter(text: str) -> dict[str, str]: + """Minimal YAML-frontmatter parser for simple ``key: value`` pairs. + + Deliberately naive (no real YAML parser) — the packaged SKILL.md is + authored by a sibling task, so this only needs to confirm the + frontmatter *shape* (delimiters + flat key/value lines), not parse + arbitrary YAML. + """ + assert text.startswith("---\n"), "SKILL.md must start with a '---' frontmatter fence" + end = text.index("\n---", 4) + block = text[4:end] + fields: dict[str, str] = {} + for line in block.splitlines(): + if not line.strip() or ":" not in line: + continue + key, _, value = line.partition(":") + fields[key.strip()] = value.strip().strip('"').strip("'") + return fields + + +# --------------------------------------------------------------------------- +# Packaged source shape +# --------------------------------------------------------------------------- + + +class TestPackagedSkillFrontmatter: + def test_packaged_skill_file_exists(self) -> None: + assert _PACKAGED_SKILL_PATH.is_file(), ( + f"Expected packaged skill at {_PACKAGED_SKILL_PATH} — " + "if this fails because the sibling authoring task hasn't landed " + "yet, re-run once it has." + ) + + def test_frontmatter_has_expected_name(self) -> None: + fields = _parse_frontmatter(_packaged_skill_content()) + assert fields.get("name") == "maverick-review" + + def test_frontmatter_has_nonempty_description(self) -> None: + fields = _parse_frontmatter(_packaged_skill_content()) + assert fields.get("description") + + def test_frontmatter_has_invocability_marker(self) -> None: + """Skills invocable via a slash command in this repo's own + ``.claude/skills/*/SKILL.md`` files carry a ``user-invocable`` + frontmatter key (see e.g. ``.claude/skills/speckit-clarify/SKILL.md``). + Assert the packaged skill declares itself invocable the same way, + without hardcoding any of its prose. + """ + fields = _parse_frontmatter(_packaged_skill_content()) + assert "user-invocable" in fields + assert fields["user-invocable"].lower() == "true" + + +# --------------------------------------------------------------------------- +# maverick init — install step +# --------------------------------------------------------------------------- + + +class TestInstallReviewSkillHelper: + async def test_fresh_install_writes_packaged_content(self, tmp_path: Path) -> None: + from maverick.init import _install_review_skill + + result = await _install_review_skill(tmp_path, verbose=False) + + assert result is True + installed_path = tmp_path / _SKILL_RELATIVE_PATH + assert installed_path.is_file() + assert installed_path.read_text(encoding="utf-8") == _packaged_skill_content() + + async def test_locally_modified_file_is_overwritten(self, tmp_path: Path) -> None: + from maverick.init import _install_review_skill + + installed_path = tmp_path / _SKILL_RELATIVE_PATH + installed_path.parent.mkdir(parents=True) + installed_path.write_text("--- locally hacked content ---", encoding="utf-8") + + result = await _install_review_skill(tmp_path, verbose=False) + + assert result is True + assert installed_path.read_text(encoding="utf-8") == _packaged_skill_content() + + async def test_write_failure_is_non_fatal_and_returns_false(self, tmp_path: Path) -> None: + from maverick.init import _install_review_skill + + # The install goes through `atomic_write_text` (so an interrupted + # init can't leave a truncated SKILL.md), hence patching that rather + # than `Path.write_text`. + with patch( + "maverick.utils.atomic.atomic_write_text", + side_effect=OSError("permission denied"), + ): + result = await _install_review_skill(tmp_path, verbose=False) + + assert result is False + assert not (tmp_path / ".claude" / "skills" / "maverick-review" / "SKILL.md").exists() + # No exception propagated — best-effort, matches _ensure_gitignore_entries. + + +class TestRunInitWiresSkillInstall: + """Exercise ``run_init`` end-to-end (mocking only the heavy externals: + prerequisite checks and bd) to confirm both return branches thread + ``_install_review_skill``'s outcome into ``InitResult.skill_installed``. + """ + + @pytest.fixture + def preflight_ok(self) -> InitPreflightResult: + return InitPreflightResult( + success=True, + checks=( + PrerequisiteCheck( + name="git_installed", + display_name="Git", + status=PreflightStatus.PASS, + message="Git installed", + ), + ), + total_duration_ms=10, + ) + + async def test_fresh_init_installs_skill( + self, + tmp_path: Path, + preflight_ok: InitPreflightResult, + ) -> None: + with ( + patch( + "maverick.init.verify_prerequisites", + new=AsyncMock(return_value=preflight_ok), + ), + patch( + "maverick.init._init_beads", + new=AsyncMock(return_value=True), + ), + ): + result = await run_init( + project_path=tmp_path, + type_override=ProjectType.PYTHON, + ) + + assert result.success is True + assert result.skill_installed is True + installed_path = tmp_path / _SKILL_RELATIVE_PATH + assert installed_path.is_file() + assert installed_path.read_text(encoding="utf-8") == _packaged_skill_content() + + async def test_config_existed_branch_still_installs_skill( + self, + tmp_path: Path, + preflight_ok: InitPreflightResult, + ) -> None: + (tmp_path / "maverick.yaml").write_text("# existing config\n", encoding="utf-8") + + with ( + patch( + "maverick.init.verify_prerequisites", + new=AsyncMock(return_value=preflight_ok), + ), + patch( + "maverick.init._init_beads", + new=AsyncMock(return_value=True), + ), + ): + result = await run_init(project_path=tmp_path) + + assert result.success is True + assert result.config_existed is True + assert result.skill_installed is True + installed_path = tmp_path / _SKILL_RELATIVE_PATH + assert installed_path.is_file() + assert installed_path.read_text(encoding="utf-8") == _packaged_skill_content() + + async def test_skill_install_failure_is_non_fatal( + self, + tmp_path: Path, + preflight_ok: InitPreflightResult, + ) -> None: + with ( + patch( + "maverick.init.verify_prerequisites", + new=AsyncMock(return_value=preflight_ok), + ), + patch( + "maverick.init._init_beads", + new=AsyncMock(return_value=True), + ), + patch( + "maverick.init._install_review_skill", + new=AsyncMock(return_value=False), + ), + ): + result = await run_init( + project_path=tmp_path, + type_override=ProjectType.PYTHON, + ) + + assert result.success is True + assert result.skill_installed is False + + +# --------------------------------------------------------------------------- +# maverick uninstall — removal step +# --------------------------------------------------------------------------- + + +class TestUninstallRemovesSkill: + def test_dry_run_lists_skill_file_without_removing(self, cli_runner, tmp_path: Path) -> None: + skill_path = tmp_path / _SKILL_RELATIVE_PATH + skill_path.parent.mkdir(parents=True) + skill_path.write_text("content", encoding="utf-8") + (tmp_path / "maverick.yaml").write_text("# config\n", encoding="utf-8") + + import os + + cwd = os.getcwd() + try: + os.chdir(tmp_path) + result = cli_runner.invoke(cli, ["uninstall", "--dry-run"]) + finally: + os.chdir(cwd) + + assert result.exit_code == 0, result.output + # The full path must appear intact on one line. Rich wraps at the + # terminal width by default, which split long paths mid-segment + # (CI's tmp dirs are longer than a dev box's, so this only ever + # failed there) and left the path uncopyable. + assert str(skill_path) in result.output + # Dry run must not touch the filesystem. + assert skill_path.is_file() + + def test_long_paths_are_not_wrapped(self, cli_runner, tmp_path: Path) -> None: + """Regression: a path longer than the terminal width stays on one line.""" + deep = tmp_path + for segment in ("a-fairly-long-directory-segment", "and-another-one", "plus-a-third"): + deep = deep / segment + skill_path = deep / _SKILL_RELATIVE_PATH + skill_path.parent.mkdir(parents=True) + skill_path.write_text("content", encoding="utf-8") + (deep / "maverick.yaml").write_text("# config\n", encoding="utf-8") + assert len(str(skill_path)) > 80 # wider than Rich's default width + + import os + + cwd = os.getcwd() + try: + os.chdir(deep) + result = cli_runner.invoke(cli, ["uninstall", "--dry-run"]) + finally: + os.chdir(cwd) + + assert result.exit_code == 0, result.output + assert str(skill_path) in result.output + assert str(deep / "maverick.yaml") in result.output + + def test_real_uninstall_removes_skill_file_and_empty_dirs( + self, cli_runner, tmp_path: Path + ) -> None: + skill_dir = tmp_path / ".claude" / "skills" / "maverick-review" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("content", encoding="utf-8") + (tmp_path / "maverick.yaml").write_text("# config\n", encoding="utf-8") + + import os + + cwd = os.getcwd() + try: + os.chdir(tmp_path) + result = cli_runner.invoke(cli, ["uninstall", "--force"]) + finally: + os.chdir(cwd) + + assert result.exit_code == 0, result.output + assert not (skill_dir / "SKILL.md").exists() + assert not skill_dir.exists() + # .claude/skills/ removed too since it's now empty. + assert not (tmp_path / ".claude" / "skills").exists() + # .claude/ itself must never be removed. + assert (tmp_path / ".claude").exists() + + def test_uninstall_preserves_other_skill_dirs(self, cli_runner, tmp_path: Path) -> None: + skill_dir = tmp_path / ".claude" / "skills" / "maverick-review" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text("content", encoding="utf-8") + + other_skill_dir = tmp_path / ".claude" / "skills" / "some-other-skill" + other_skill_dir.mkdir(parents=True) + (other_skill_dir / "SKILL.md").write_text("other content", encoding="utf-8") + + (tmp_path / "maverick.yaml").write_text("# config\n", encoding="utf-8") + + import os + + cwd = os.getcwd() + try: + os.chdir(tmp_path) + result = cli_runner.invoke(cli, ["uninstall", "--force"]) + finally: + os.chdir(cwd) + + assert result.exit_code == 0, result.output + assert not skill_dir.exists() + # Other skill dirs untouched, and .claude/skills/ survives because + # it's non-empty. + assert other_skill_dir.exists() + assert (tmp_path / ".claude" / "skills").exists() + + +class TestSkillPathIsSharedNotDuplicated: + """Install and removal must resolve the same location. + + They used to each own a literal copy of the path; changing one would + silently strand a Maverick-owned skill in every uninstalled project. + """ + + def test_init_and_uninstall_use_the_same_constant(self) -> None: + from maverick.cli.commands import uninstall as uninstall_mod + from maverick.init import _REVIEW_SKILL_RELATIVE_PATH + from maverick.skills import REVIEW_SKILL_RELATIVE_PATH + + assert _REVIEW_SKILL_RELATIVE_PATH is REVIEW_SKILL_RELATIVE_PATH + assert uninstall_mod.REVIEW_SKILL_RELATIVE_PATH is REVIEW_SKILL_RELATIVE_PATH + + async def test_installed_file_is_the_one_uninstall_removes(self, tmp_path: Path) -> None: + from maverick.cli.commands.uninstall import _remove_review_skill + from maverick.init import _install_review_skill + from maverick.skills import REVIEW_SKILL_RELATIVE_PATH + + assert await _install_review_skill(tmp_path, verbose=False) is True + target = tmp_path / REVIEW_SKILL_RELATIVE_PATH + assert target.is_file() + + assert _remove_review_skill(target, verbose=False) is True + assert not target.exists() diff --git a/tests/unit/test_workflow_errors.py b/tests/unit/test_workflow_errors.py index 456fa70d..2d1f1ea0 100644 --- a/tests/unit/test_workflow_errors.py +++ b/tests/unit/test_workflow_errors.py @@ -116,6 +116,35 @@ def test_is_workflow_error(self) -> None: assert isinstance(error, WorkflowError) assert isinstance(error, MaverickError) + def test_reason_is_not_clobbered_by_base_reason_code(self) -> None: + """``reason`` (free-text prose) and ``reason_code`` (stable code) + are different attributes with different meanings — the base class + must not overwrite the subclass's ``reason``.""" + error = WorkflowStepError("Validation failed") + assert error.reason == "Validation failed" + assert error.reason_code is None + + +class TestWorkflowErrorReasonCode: + """`reason_code` lets callers branch on *what* failed without + pattern-matching prose (see `maverick.cli.json_output`).""" + + def test_defaults_to_none(self) -> None: + assert WorkflowError("boom").reason_code is None + + def test_round_trips(self) -> None: + from maverick.exceptions import REASON_LOCKED + + error = WorkflowError("boom", reason_code=REASON_LOCKED) + assert error.reason_code == REASON_LOCKED + assert error.message == "boom" + + def test_is_keyword_only(self) -> None: + """Positional arg 2 stays `workflow_name` — existing callers unaffected.""" + error = WorkflowError("boom", "my-workflow") + assert error.workflow_name == "my-workflow" + assert error.reason_code is None + class TestCheckpointNotFoundError: """Test CheckpointNotFoundError attributes and message."""