Skip to content

Commit 2c839cc

Browse files
feat(cli): pearlite reconcile --commit / --adopt-all + prompt loop (#71)
* feat(state): extend ReconciliationEntry with adopted/skipped vectors Adds two `Vec<String>` fields to `pearlite_state::ReconciliationEntry`, both `#[serde(default)]` so pre-ADR `[[reconciliations]]` rows deserialize cleanly with empty vectors. Tightens `package_count`'s docstring to name it as the audit denominator (ADR-0014 §9). Backfills three round-trip tests in `reconciliation::tests`: - legacy entry without decision vectors deserializes with empty `[]`, - populated decision vectors round-trip through TOML, - AdoptAll with empty `skipped` round-trips. Schema change is additive; the `ReconciliationAction` unit-variant enum (AdoptAll / Interactive / Skipped) keeps its existing TOML representation per ADR-0014 §7. Refs: ADR-0014, PRD §7.3 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(engine): Engine::reconcile_commit and probe_manual_drift Adds the write-side of reconcile (ADR-0014, M4 W1): - `Engine::reconcile_commit(state_path, &decisions, threshold)` — probes via the existing adapter, classifies Manual drift through `pearlite_diff::{classify_pacman, classify_cargo}` against an empty declared `PackageSet` (the fresh-import path from PRD §11), enforces the `Some(N)` threshold defensively, unions adopted names into `state.adopted.{pacman,cargo}`, and appends one `[[reconciliations]]` row with a fresh `Uuid::now_v7()`. Atomic write via `StateStore`; `state.last_modified` set, `state.last_apply` deliberately untouched. - `Engine::probe_manual_drift(state_path)` — read-only helper that returns the merged sorted-deduplicated Manual list. The CLI uses it to drive the threshold pre-check and per-package prompt loop without reaching into the engine's private probe accessor. - `ReconcileDecisions` enum (`AdoptAll` / `Selective { adopt }`) and `ReconcileCommitOutcome` mirror the ADR §7 split: enum carries the *policy*, vectors carry the *decisions*. - `ReconcileCommitError::{Probe, State, ThresholdExceeded}` for the three failure modes; threshold variant carries `count` and `threshold` so the CLI can surface ADR-0014 §2 wording verbatim. Module doc now describes both reconcile entry points (read-only import + commit). Ten new tests in `reconcile::tests`: AdoptAll happy path, Selective partition, threshold exceeded refuses without writing, threshold boundary allowed, `state.managed` packages stay classified as Forgotten (not adopted), existing `state.adopted` preserved on union, no-Manual-drift still records empty entry, probe / state-read failure propagation, fresh `plan_id` per call. Refs: ADR-0014, PRD §11 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(cli): pearlite reconcile --commit / --adopt-all + prompt loop Wires the engine's reconcile-commit through the CLI (ADR-0014, M4 W1): - New `agents` module (`pearlite_cli::agents::is_non_interactive`) per ADR-0014 §6 — M4 stub returning `!stdin.is_terminal() || AI_AGENT.is_some()`. The full AGENT/CI/CLAUDECODE matrix lands in M5 W2 (TODO comment marks the spot). - `Reconcile` arg becomes a struct variant: `--commit`, `--adopt-all` (`requires = "commit"`), `--commit-threshold <Option<u32>>`. Clap enforces the requires-relationship; help text points at ADR-0014. - `dispatch_reconcile_commit` — non-interactive refusal first (`RECONCILE_REQUIRES_INTERACTIVE`, exit 2), then either bulk (`AdoptAll`) → engine, or interactive: probe + classify via `Engine::probe_manual_drift` → CLI threshold gate (`RECONCILE_THRESHOLD_EXCEEDED`, exit 2, ADR-0014 §2 wording — count + threshold + fresh-install case + `--adopt-all` pointer) → per-package prompt loop with `[y]es / [N]o (default) / [a]dopt-all / [s]kip-all / [q]uit` (ADR-0014 §4) → engine call with `Selective`. `q` aborts byte-identically; bare-Enter defaults to skip; EOF mid-loop is treated as quit. - `effective_threshold()` codifies the four-way ADR §3 mapping: bare `--commit` → default 5; `--commit-threshold N` → Some(N); bare `--adopt-all` → unbounded; `--adopt-all --commit-threshold N` → Some(N). - `ReconcileCommitError` → typed `ErrorPayload` so engine-side `ThresholdExceeded` (e.g. `--adopt-all --commit-threshold 3`) surfaces with the same `RECONCILE_THRESHOLD_EXCEEDED` code as the CLI-side gate. Prompt loop is generic over `<R: BufRead, W: Write>` so tests script stdin/stderr without a TTY. 18 new tests cover the four ADR table entries, all five prompt menu paths (y/N/a/s/q), EOF-as-quit, and end-to-end dispatch arms (non-interactive refusal, threshold exceeded refuses without writing, threshold boundary allowed via `a`, `q` aborts byte-identically, label reflects `--commit`). Refs: ADR-0014, PRD §11 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 34f75f0 commit 2c839cc

4 files changed

Lines changed: 899 additions & 25 deletions

File tree

crates/pearlite-cli/src/agents.rs

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// Copyright (C) 2026 Mohamed Hammad
3+
4+
//! Agent / interactivity detection.
5+
//!
6+
//! Per CLAUDE.md hard invariant: the CLI is the one place that reads
7+
//! the environment. Other crates ask `pearlite_cli::agents` rather than
8+
//! touching `std::env` directly.
9+
10+
use std::io::IsTerminal as _;
11+
12+
/// Returns `true` when the current invocation has no operator at the
13+
/// keyboard.
14+
///
15+
/// M4 stub per ADR-0014 §6: looks at stdin's TTY-ness and the
16+
/// `AI_AGENT` env var only. The full `AGENT` / `CI` / `CLAUDECODE`
17+
/// matrix lands in M5 W2 alongside the broader agent-UX work.
18+
///
19+
// TODO(M5): extend with AGENT=1, CI=true, and CLAUDECODE/CURSOR_AGENT
20+
// per the ADR-0014 §5 acceptance contract.
21+
#[must_use]
22+
pub fn is_non_interactive() -> bool {
23+
!std::io::stdin().is_terminal() || std::env::var_os("AI_AGENT").is_some()
24+
}
25+
26+
#[cfg(test)]
27+
#[allow(
28+
clippy::expect_used,
29+
clippy::unwrap_used,
30+
reason = "tests may use expect()/unwrap() per Plan §4.2 + CLAUDE.md"
31+
)]
32+
mod tests {
33+
use super::*;
34+
35+
/// Smoke-test: the helper must not panic when called. Real
36+
/// behaviour is environment-dependent and exercised by the
37+
/// dispatch-level tests that inject the input/policy directly.
38+
#[test]
39+
fn is_non_interactive_is_callable() {
40+
let _ = is_non_interactive();
41+
}
42+
}

crates/pearlite-cli/src/args.rs

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -165,17 +165,41 @@ pub enum Command {
165165
/// Probe the live system and write a fresh
166166
/// `<config_dir>/hosts/<hostname>.imported.ncl` for operator review.
167167
///
168-
/// Read-only with respect to `state.toml`: probes pacman, cargo,
169-
/// systemd, and the kernel via `Engine::reconcile`, renders the
170-
/// result through `pearlite_nickel::emit_host`, and lands the text
171-
/// at the imported path. Refuses to clobber an existing
168+
/// Read-only with respect to `state.toml` by default: probes pacman,
169+
/// cargo, systemd, and the kernel via `Engine::reconcile`, renders
170+
/// the result through `pearlite_nickel::emit_host`, and lands the
171+
/// text at the imported path. Refuses to clobber an existing
172172
/// `.imported.ncl` — `RECONCILE_ALREADY_EXISTS` surfaces and the
173173
/// operator removes or renames it before retrying.
174174
///
175-
/// The interactive `--commit` and `--adopt-all` flags that mutate
176-
/// `state.toml` ride along with `Engine::reconcile_commit` in a
177-
/// follow-up PR (M4 W1 remainder).
178-
Reconcile,
175+
/// Pass `--commit` to additionally promote Manual drift items into
176+
/// `state.adopted` and append a `[[reconciliations]]` entry
177+
/// (ADR-0014). The default threshold of 5 protects against silent
178+
/// mass-adoption on a stale or unexpectedly-drifted host; raise it
179+
/// with `--commit-threshold N` or bypass it entirely with
180+
/// `--adopt-all`.
181+
Reconcile {
182+
/// Mutate `state.toml`: adopt Manual drift items into
183+
/// `state.adopted` and append a `[[reconciliations]]` entry
184+
/// (ADR-0014). Without this flag, `pearlite reconcile` only
185+
/// writes the `.imported.ncl` review draft.
186+
#[arg(long)]
187+
commit: bool,
188+
/// Bypass per-package prompts and adopt every Manual drift
189+
/// item. Required for non-interactive invocations (no TTY,
190+
/// `AI_AGENT=1`). Implies `--commit`. Combinable with
191+
/// `--commit-threshold N` to cap blast radius even when
192+
/// bypassing prompts (ADR-0014 §3).
193+
#[arg(long, requires = "commit")]
194+
adopt_all: bool,
195+
/// Maximum number of Manual drift items reconcile-commit will
196+
/// adopt without explicit override. When omitted, defaults to
197+
/// 5 per ADR-0014 §1; bare `--adopt-all` (no threshold)
198+
/// disables the cap entirely. Above the threshold, the CLI
199+
/// refuses with `RECONCILE_THRESHOLD_EXCEEDED`.
200+
#[arg(long, requires = "commit")]
201+
commit_threshold: Option<u32>,
202+
},
179203
}
180204

181205
/// Sub-actions for [`Command::Gen`].

0 commit comments

Comments
 (0)