From 2221c27b541ba907d5be19d96ceb3887dc19ab8a Mon Sep 17 00:00:00 2001 From: droidnoob Date: Sat, 30 May 2026 19:50:16 +0530 Subject: [PATCH 1/9] fix(loop): thread dispatcher pre-claim to worker so jobs>=2 actually runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parallel loop's atomic claim flipped each ready task to in_progress before any worker ran, which removed it from bd.ready(). The worker then re-polled bd.ready() for its initial task, saw an empty set, and exited iter 0 with `stop_reason: ready_empty` — stranding every claim and producing 0 iters per worker. Worker now carries `assigned_task: Option` populated from the dispatcher's `DispatchTick::assignments`. Iter 1 prepends the pre-claim to the worker's bd.ready() view so the existing task pick + signal eval paths stay unchanged; iter 2+ source from bd.ready() like the serial path. Closes hew-zt4z. --- hew/src/commands/loop_cmd.rs | 32 +++++- hew/tests/loop_backpressure.rs | 172 +++++++++++++++++++++++++++++++++ hew/tests/loop_parallel_e2e.rs | 95 +++++++++++++++++- 3 files changed, 296 insertions(+), 3 deletions(-) diff --git a/hew/src/commands/loop_cmd.rs b/hew/src/commands/loop_cmd.rs index 0b833ec..9a94b5f 100644 --- a/hew/src/commands/loop_cmd.rs +++ b/hew/src/commands/loop_cmd.rs @@ -841,6 +841,16 @@ pub struct Worker { /// existing `hew loop summary` / `hew loop logs` surfaces continue /// to find logs at the run-dir root. pub worker_n: Option, + /// Task pre-claimed by the dispatcher for this worker's first iter. + /// The parallel dispatcher claims tasks atomically before any worker + /// starts, which removes them from `bd ready` — a worker that + /// re-polled `bd.ready()` for its initial task would see an empty + /// set and exit on iter 0 (`stop_reason: ready_empty`), stranding + /// the claim in `in_progress`. Threading the assignment through the + /// `Worker` lets iter 1 run against the pre-claimed task; iter 2+ + /// fall back to `bd.ready()` as before. `None` for the serial path + /// (no pre-claim) and for tests that drive the worker loop directly. + pub assigned_task: Option, } /// Final state returned by [`run_worker_loop`]. The dispatcher reads @@ -1069,6 +1079,7 @@ fn run_loop_serial( branch: String::new(), log_dir: dir.clone(), worker_n: None, + assigned_task: None, }; let started_at = iso_now_utc(); @@ -1253,6 +1264,7 @@ fn run_loop_parallel( branch: branch_str, log_dir: dir.clone(), worker_n: Some(n), + assigned_task: Some(a.task.clone()), }); } @@ -1489,9 +1501,24 @@ pub fn run_worker_loop_with_scope( let mut last_outcome: Option = None; let mut iter_logs: Vec = Vec::new(); + // Pre-claimed task from the parallel dispatcher. Consumed on the + // first iter that needs a task; iter 2+ fall back to `bd.ready()`. + // The dispatcher claimed this task atomically before the worker + // started, so it is already in `in_progress` and absent from + // `bd.ready()` — without this hand-off the worker would exit on + // iter 0 with `stop_reason: ready_empty`. + let mut pending_assigned: Option = worker.assigned_task.clone(); loop { - let ready = bd.ready().map_err(|e| miette::miette!("bd ready: {e}"))?; + let bd_ready = bd.ready().map_err(|e| miette::miette!("bd ready: {e}"))?; + // Surface the pre-claimed task to the iter as if it were the + // head of `bd ready` — the rest of the loop body (signal eval, + // task pick, out-of-band closure detection) is unchanged. + let ready: Vec = if let Some(t) = pending_assigned.as_ref() { + std::iter::once(t.clone()).chain(bd_ready).collect() + } else { + bd_ready + }; let signals = collector.snapshot(&run_state, ready.len() as u32, last_outcome); if let Some(reason) = signals.evaluate(&cfg) { run_state.stop_reason = Some(reason); @@ -1516,6 +1543,9 @@ pub fn run_worker_loop_with_scope( break; } }; + // The pre-claim is one-shot: once we've picked a task this iter, + // future iters source from `bd.ready()` like the serial path. + pending_assigned = None; let iter_number = run_state.next_iter_number(); let started_at = iso_now_utc(); diff --git a/hew/tests/loop_backpressure.rs b/hew/tests/loop_backpressure.rs index 215c4dd..c789e4c 100644 --- a/hew/tests/loop_backpressure.rs +++ b/hew/tests/loop_backpressure.rs @@ -959,6 +959,7 @@ fn gate_is_called_with_worker_worktree_dir() { branch: "loop/test/w0".into(), worker_n: None, log_dir: log_dir.clone(), + assigned_task: None, }; let stop_path = log_dir.join(".stop"); @@ -1144,6 +1145,7 @@ fn run_worker_loop_uses_worker_worktree_for_git_calls() { branch: "loop/test/w0".into(), worker_n: None, log_dir: log_dir.clone(), + assigned_task: None, }; let stop_path = log_dir.join(".stop"); @@ -1289,3 +1291,173 @@ fn jobs_2_uses_dispatcher_path() { let m: serde_json::Value = serde_json::from_str(&body).expect("parse manifest"); assert_eq!(m["jobs"].as_u64(), Some(2), "parallel path Manifest.jobs == args.jobs"); } + +/// Records the per-iter prompt tail (which carries the task id the +/// worker decided to run). Used by the `worker.assigned_task` hand-off +/// regression tests below. +#[derive(Debug, Default)] +struct PromptRecordingSpawner { + tails: std::sync::Mutex>, +} + +impl RuntimeSpawner for PromptRecordingSpawner { + fn spawn( + &self, + prompt: &AssembledPrompt, + _tools: &[String], + _opts: &SpawnOpts, + ) -> HewResult { + self.tails.lock().unwrap().push(prompt.tail.clone()); + Ok(SpawnOutcome { + success: true, + closed_task: None, + tokens: TokenSpend::default(), + stderr_tail: String::new(), + raw_text: String::new(), + failure_class: SpawnFailureClass::Success, + }) + } +} + +fn synthetic_ready(id: &str) -> ReadyTask { + ReadyTask { + id: id.into(), + title: format!("synthetic {id}"), + description: String::new(), + priority: 1, + status: "open".into(), + issue_type: "task".into(), + parent: None, + } +} + +/// Regression for hew-zt4z. When the parallel dispatcher pre-claims a +/// task and hands it to a worker via `Worker.assigned_task`, the worker +/// must run that task on iter 1 even though `bd.ready()` no longer +/// includes it (the claim flipped it to `in_progress`). Without this +/// hand-off the worker would exit on iter 0 with `stop_reason: +/// ready_empty`, stranding the claim. +#[test] +fn worker_runs_assigned_task_when_some_skipping_bd_ready_poll() { + let tmp = tempfile::tempdir().expect("tempdir"); + let worktree = tmp.path().join("wt"); + let log_dir = tmp.path().join("logs"); + std::fs::create_dir_all(&worktree).expect("mkdir worktree"); + std::fs::create_dir_all(&log_dir).expect("mkdir logs"); + + // Empty bd.ready — mirrors the parallel-dispatch state where the + // dispatcher already claimed every ready task before the worker ran. + let bd = CapturingBd { ready: vec![], remembered: RefCell::new(Vec::new()) }; + let spawner = PromptRecordingSpawner::default(); + let gate = RecordingGateRunner::passing(); + + let args = args_one_iter(); + let skill = hew_core::skills::find("hew-execute").expect("hew-execute skill present"); + let allowed = hew_core::allowed_tools::for_skill("hew-execute"); + let worker = Worker { + id: 0, + worktree_dir: worktree.clone(), + branch: "loop/test/w0".into(), + worker_n: None, + log_dir: log_dir.clone(), + assigned_task: Some(synthetic_ready("hew-preclaimed")), + }; + let stop_path = log_dir.join(".stop"); + + run_worker_loop( + &ctx(), + &args, + &bd, + Some(&spawner), + None, + FallbackConfig::default(), + LoopModelConfig::default(), + &gate, + &worker, + &skill, + "", + "loop-test", + &allowed, + &stop_path, + ) + .expect("worker loop runs"); + + let tails = spawner.tails.lock().unwrap(); + assert_eq!(tails.len(), 1, "expected exactly one spawn against the pre-claimed task"); + assert!( + tails[0].contains("hew-preclaimed"), + "spawn prompt must reference the pre-claimed task, got tail: {}", + tails[0], + ); +} + +/// Companion to `worker_runs_assigned_task_when_some_skipping_bd_ready_poll`: +/// after the pre-claimed task is consumed, iter 2+ must source from +/// `bd.ready()` like the serial path. Pre-claim is one-shot, not a +/// pinned override. +#[test] +fn worker_falls_back_to_bd_ready_for_subsequent_iters() { + let tmp = tempfile::tempdir().expect("tempdir"); + let worktree = tmp.path().join("wt"); + let log_dir = tmp.path().join("logs"); + std::fs::create_dir_all(&worktree).expect("mkdir worktree"); + std::fs::create_dir_all(&log_dir).expect("mkdir logs"); + + // A fresh task waiting in bd.ready that iter 2 should pick up. + let bd = CapturingBd { + ready: vec![synthetic_ready("hew-followup")], + remembered: RefCell::new(Vec::new()), + }; + let spawner = PromptRecordingSpawner::default(); + let gate = RecordingGateRunner::passing(); + + let mut args = args_one_iter(); + args.max_iter = Some(2); + let skill = hew_core::skills::find("hew-execute").expect("hew-execute skill present"); + let allowed = hew_core::allowed_tools::for_skill("hew-execute"); + let worker = Worker { + id: 0, + worktree_dir: worktree.clone(), + branch: "loop/test/w0".into(), + worker_n: None, + log_dir: log_dir.clone(), + assigned_task: Some(synthetic_ready("hew-preclaimed")), + }; + let stop_path = log_dir.join(".stop"); + + run_worker_loop( + &ctx(), + &args, + &bd, + Some(&spawner), + None, + FallbackConfig::default(), + LoopModelConfig::default(), + &gate, + &worker, + &skill, + "", + "loop-test", + &allowed, + &stop_path, + ) + .expect("worker loop runs"); + + let tails = spawner.tails.lock().unwrap(); + assert_eq!(tails.len(), 2, "expected two spawns: pre-claim then bd.ready fallback"); + assert!( + tails[0].contains("hew-preclaimed"), + "iter 1 must run the pre-claimed task, got tail: {}", + tails[0], + ); + assert!( + tails[1].contains("hew-followup"), + "iter 2 must fall back to bd.ready, got tail: {}", + tails[1], + ); + assert!( + !tails[1].contains("hew-preclaimed"), + "iter 2 must not re-run the pre-claim, got tail: {}", + tails[1], + ); +} diff --git a/hew/tests/loop_parallel_e2e.rs b/hew/tests/loop_parallel_e2e.rs index 05b0f04..4f4a984 100644 --- a/hew/tests/loop_parallel_e2e.rs +++ b/hew/tests/loop_parallel_e2e.rs @@ -313,14 +313,23 @@ impl RuntimeSpawner for ConflictingSpawner { // Resolve which worker dir this call corresponds to. The // dispatcher created `//{0,1,...}` before the // serial worker loop began, so we just discover the (single) - // run-id dir and pick `n`. + // run-id dir and round-robin across whatever worker subdirs are + // present. Modulo keeps the test robust to extra spawns made + // by the iter-end planner (which reuses this same spawner) — + // every call still writes to an existing worktree. let run_dir = std::fs::read_dir(&self.wt_root) .expect("wt_root populated") .filter_map(|e| e.ok()) .map(|e| e.path()) .find(|p| p.is_dir()) .expect("run-id subdir"); - let wt = run_dir.join(n.to_string()); + let worker_dirs: Vec = std::fs::read_dir(&run_dir) + .expect("read run dir") + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.is_dir()) + .collect(); + let wt = worker_dirs[(n as usize) % worker_dirs.len()].clone(); let body = format!("worker {n} version line\n"); std::fs::write(wt.join(&self.conflict_file), body).unwrap(); git(&wt, &["add", &self.conflict_file]); @@ -499,3 +508,85 @@ fn e2e_parallel_merge_conflict_files_bug_task() { ); assert!(run_dir.join("1").is_dir(), "worker 1 worktree (conflict) must remain on disk"); } + +/// Regression for hew-zt4z. The parallel dispatcher's atomic claim +/// flips each assigned task to `in_progress`, which removes it from +/// `bd ready`. Before the `Worker.assigned_task` hand-off, the worker +/// loop's `bd.ready()` poll saw an empty queue and exited on iter 0 +/// (`stop_reason: ready_empty`), stranding the claims in_progress +/// with zero spawns. This test pins the bug: with exactly `jobs` ready +/// tasks (no surplus) every worker must still drive its pre-claimed +/// assignment to a real spawn. +#[test] +#[ignore = "slow"] +fn e2e_parallel_dispatcher_claim_removes_task_from_ready_but_worker_still_runs_it() { + if !git_available() { + eprintln!("git not on PATH, skipping"); + return; + } + let (_home, _home_dir) = HomeGuard::install(); + + let repo_tmp = tempfile::tempdir().expect("repo tempdir"); + let repo = repo_tmp.path().to_path_buf(); + seed_repo(&repo); + + // Exactly two ready tasks for two workers — no surplus. The + // dispatcher's pre-claim drains `bd ready` before either worker + // gets a turn; the only way both spawns fire is via the + // `Worker.assigned_task` hand-off. + let bd = SharedBd::with(vec![ready_task("hew-r1"), ready_task("hew-r2")]); + let spawner = DrainingMockSpawner::new(bd.clone()); + let gate = + StaticGateRunner(GateCheck { tests_passed: true, lint_passed: true, ..Default::default() }); + + let mut args = args_parallel(2); + // One iter per worker is the minimum signal — each worker must + // spawn once against its pre-claim. Without `until_empty` capped + // the workers would loop into a now-empty bd.ready and inflate + // the call count. + args.max_iter = Some(1); + args.until_empty = false; + + run_loop_with( + &ctx(), + args, + &*bd, + Some(&spawner), + None, + FallbackConfig::default(), + LoopModelConfig::default(), + &gate, + &repo, + ) + .expect("parallel loop runs"); + + // Both pre-claims surfaced into real spawns. Pre-fix this was 0 + // (the workers exited on iter 0 with `stop_reason: ready_empty`). + // Pin loosely (`>= 2`) so the iter-end planner — which spawns + // through the same `RuntimeSpawner` once per iter — doesn't break + // the regression signal. The hard assertion is on the manifest's + // per-worker `iter_count` below. + assert!( + spawner.call_count() >= 2, + "expected at least one spawn per worker against the pre-claimed task, got {}", + spawner.call_count(), + ); + + // Manifest pins each worker actually ran an iter (vs the pre-fix + // `stop_reason: ready_empty` with `iter_count: 0`). + let m = read_manifest(&repo); + let workers = m["workers"].as_array().expect("workers array"); + assert_eq!(workers.len(), 2); + for w in workers { + assert_eq!( + w["iter_count"].as_u64(), + Some(1), + "every worker must run at least one iter against its pre-claim, got {w}", + ); + assert_ne!( + w["stop_reason"].as_str(), + Some("ready_empty"), + "worker exited on `ready_empty` — pre-claim hand-off regression, got {w}", + ); + } +} From 970f3ddd9719ef8f9bd5c10ad8542dd2542d3080 Mon Sep 17 00:00:00 2001 From: droidnoob Date: Sat, 30 May 2026 19:58:31 +0530 Subject: [PATCH 2/9] feat(config): discover_project_root + discover_project_config - discover_project_root walks cwd ancestors; .beads wins over .git per level - worktree gitlink resolves via git rev-parse --git-common-dir (--show-toplevel returns the worktree dir, not the main repo) - discover_project_config prefers .hew.toml, falls back to hew.toml, warns when both exist - 9 tests including a real-worktree e2e via tempdir Closes hew-ja44 --- hew-core/src/config.rs | 240 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 240 insertions(+) diff --git a/hew-core/src/config.rs b/hew-core/src/config.rs index 51e051a..4e77480 100644 --- a/hew-core/src/config.rs +++ b/hew-core/src/config.rs @@ -784,6 +784,91 @@ pub fn parse_budget_wall(raw: &str) -> Result { Ok(dur) } +/// Walk `cwd` ancestors looking for the project root. At each level, +/// `.beads/` wins over `.git`; the first hit terminates the walk. +/// When `.git` is a file (worktree gitlink — common in hew's own +/// `~/.hew/wt///` workers), resolves to the underlying +/// main-repo working tree via `git rev-parse --show-toplevel` rather +/// than the worktree directory itself. +/// +/// Returns `None` if neither marker is found before the filesystem +/// root. +pub fn discover_project_root(cwd: &Path) -> Option { + for dir in cwd.ancestors() { + if dir.join(".beads").is_dir() { + return Some(dir.to_path_buf()); + } + let git = dir.join(".git"); + let meta = match std::fs::symlink_metadata(&git) { + Ok(m) => m, + Err(_) => continue, + }; + if meta.is_dir() { + return Some(dir.to_path_buf()); + } + if meta.is_file() { + return Some(resolve_worktree_root(dir).unwrap_or_else(|| dir.to_path_buf())); + } + } + None +} + +/// Resolve the real repo root for a git worktree by asking git +/// directly. Returns `None` on any failure — caller falls back to the +/// worktree directory. +fn resolve_worktree_root(worktree_dir: &Path) -> Option { + // `git rev-parse --show-toplevel` inside a linked worktree returns + // the worktree's own working dir — not what we want here. The + // main repo's working tree is the parent of its `.git` dir, which + // `--git-common-dir` reports (shared across all linked worktrees). + let out = std::process::Command::new("git") + .args(["rev-parse", "--path-format=absolute", "--git-common-dir"]) + .current_dir(worktree_dir) + .env_remove("GIT_DIR") + .env_remove("GIT_WORK_TREE") + .env_remove("GIT_COMMON_DIR") + .output() + .ok()?; + if !out.status.success() { + return None; + } + let s = std::str::from_utf8(&out.stdout).ok()?.trim(); + if s.is_empty() { + return None; + } + let common = PathBuf::from(s); + // common dir is typically `/.git`; the main repo root + // is its parent. Bare repos have no working tree — fall back to + // the worktree dir in that case. + let parent = common.parent()?; + if parent.as_os_str().is_empty() { None } else { Some(parent.to_path_buf()) } +} + +/// Locate the project-local config file at ``. Prefers +/// `.hew.toml` (dotfile convention); falls back to `hew.toml`. When +/// both exist, the dotfile wins and a warning is emitted so the user +/// notices the duplicate. +pub fn discover_project_config(root: &Path) -> Option { + let dotfile = root.join(".hew.toml"); + let plain = root.join("hew.toml"); + let dot_present = dotfile.is_file(); + let plain_present = plain.is_file(); + if dot_present && plain_present { + tracing::warn!( + target: "hew::config", + ".hew.toml and hew.toml both present in {}; using .hew.toml", + root.display() + ); + } + if dot_present { + Some(dotfile) + } else if plain_present { + Some(plain) + } else { + None + } +} + #[cfg(test)] mod tests { use super::*; @@ -1397,4 +1482,159 @@ fallback_runtime = "codex" assert!(loaded.compact.allow_recompact_default); assert_eq!(loaded.compact.exempt, vec!["STATUS:custom", "STATUS:other"]); } + + // ──────── discover_project_root / discover_project_config ──────── + + fn scrub_git_env_in_process() { + // SAFETY: integration with the host pre-commit hook can leak + // GIT_* into our subprocess invocations. The test binary may + // run multiple tests in threads, but these vars only need to + // be absent at the moment we spawn git; once removed they + // stay removed for the process lifetime. + for var in [ + "GIT_DIR", + "GIT_INDEX_FILE", + "GIT_WORK_TREE", + "GIT_COMMON_DIR", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_CONFIG", + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_SYSTEM", + ] { + unsafe { std::env::remove_var(var) }; + } + } + + #[test] + fn discover_root_finds_beads_dir_first() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::create_dir(root.join(".beads")).unwrap(); + std::fs::create_dir(root.join(".git")).unwrap(); + let sub = root.join("a").join("b"); + std::fs::create_dir_all(&sub).unwrap(); + let found = discover_project_root(&sub).unwrap(); + assert_eq!(found.canonicalize().unwrap(), root.canonicalize().unwrap()); + } + + #[test] + fn discover_root_falls_back_to_git_dir() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::create_dir(root.join(".git")).unwrap(); + let sub = root.join("nested"); + std::fs::create_dir_all(&sub).unwrap(); + let found = discover_project_root(&sub).unwrap(); + assert_eq!(found.canonicalize().unwrap(), root.canonicalize().unwrap()); + } + + #[test] + fn discover_root_returns_none_when_neither_marker() { + let tmp = tempfile::tempdir().unwrap(); + let sub = tmp.path().join("x").join("y"); + std::fs::create_dir_all(&sub).unwrap(); + // Avoid walking into an ancestor that happens to contain + // a .git (e.g. /Users/.../hew) by canonicalizing into the + // tempdir then asserting Option::is_none only when None. + // If a parent contains .git, this test would resolve there — + // which is still a valid behavior; we only assert "no panic" + // and that the result, if Some, is an ancestor of cwd. + if let Some(found) = discover_project_root(&sub) { + assert!( + sub.starts_with(&found) || sub.canonicalize().unwrap().starts_with(&found), + "found {found:?} is not an ancestor of {sub:?}" + ); + } + } + + #[test] + fn discover_root_stops_at_filesystem_root_when_no_marker_found() { + // Walking from "/" (an existing path with no .beads/.git of + // our making) must terminate cleanly — not loop forever. + // We can't guarantee "/" has no .git on the host, so we only + // assert termination. + let _ = discover_project_root(Path::new("/")); + } + + #[test] + fn discover_root_resolves_worktree_to_real_repo() { + use std::process::Command; + if which::which("git").is_err() { + eprintln!("git not on PATH, skipping"); + return; + } + scrub_git_env_in_process(); + + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + std::fs::create_dir(&repo).unwrap(); + + let git = |dir: &Path, args: &[&str]| { + let out = Command::new("git") + .args(args) + .current_dir(dir) + .env("GIT_AUTHOR_NAME", "hew-test") + .env("GIT_AUTHOR_EMAIL", "hew@test.local") + .env("GIT_COMMITTER_NAME", "hew-test") + .env("GIT_COMMITTER_EMAIL", "hew@test.local") + .output() + .expect("git invocation"); + assert!( + out.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + }; + + git(&repo, &["init", "-q", "-b", "main"]); + std::fs::write(repo.join("README"), "x\n").unwrap(); + git(&repo, &["add", "README"]); + git(&repo, &["commit", "-q", "-m", "init"]); + + let wt = tmp.path().join("worker"); + git(&repo, &["worktree", "add", "-b", "worker-br", wt.to_str().unwrap(), "main"]); + + // From inside the worktree, discover_project_root must resolve + // to the main repo, not the worktree dir. + let found = discover_project_root(&wt).expect("found root"); + assert_eq!(found.canonicalize().unwrap(), repo.canonicalize().unwrap()); + } + + #[test] + fn discover_project_config_dotfile_wins() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::write(root.join(".hew.toml"), "").unwrap(); + std::fs::write(root.join("hew.toml"), "").unwrap(); + let found = discover_project_config(root).unwrap(); + assert_eq!(found, root.join(".hew.toml")); + } + + #[test] + fn discover_project_config_falls_back_to_plain_name() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::write(root.join("hew.toml"), "").unwrap(); + let found = discover_project_config(root).unwrap(); + assert_eq!(found, root.join("hew.toml")); + } + + #[test] + fn discover_project_config_warns_when_both_exist() { + // The warning goes through tracing; we can't easily intercept it + // here without pulling in a subscriber. Smoke-check that the + // dotfile is still selected deterministically. + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + std::fs::write(root.join(".hew.toml"), "a = 1\n").unwrap(); + std::fs::write(root.join("hew.toml"), "b = 2\n").unwrap(); + assert_eq!(discover_project_config(root), Some(root.join(".hew.toml"))); + } + + #[test] + fn discover_project_config_returns_none_when_neither() { + let tmp = tempfile::tempdir().unwrap(); + assert!(discover_project_config(tmp.path()).is_none()); + } } From be906adef0f267a377de550f8bd4b6eb36094f6c Mon Sep 17 00:00:00 2001 From: droidnoob Date: Sat, 30 May 2026 20:13:46 +0530 Subject: [PATCH 3/9] feat(config): load_layered + per-struct merge (hew-36of) - New load_layered(user, project) reads each file (missing = empty) and merges per documented rules: scalars project-wins, Option uses Option::or, Vec concats with order-preserving dedupe, nested structs recurse, BTreeMap extends with project-wins on collision. - Config::load() now resolves the XDG user path + walks cwd for a project root, then calls load_layered. HEW_CONFIG bypasses layering. - 7 new tests in hew-core/src/config.rs cover the merge axes plus the HEW_CONFIG bypass path. --- hew-core/src/config.rs | 399 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 398 insertions(+), 1 deletion(-) diff --git a/hew-core/src/config.rs b/hew-core/src/config.rs index 4e77480..aeccc8f 100644 --- a/hew-core/src/config.rs +++ b/hew-core/src/config.rs @@ -362,7 +362,18 @@ fn io_other(e: impl std::fmt::Display) -> std::io::Error { } pub fn load() -> Result { - load_from(&config_path()?) + // `HEW_CONFIG` is the documented escape hatch for tests / scripts: + // it bypasses layering entirely and treats the named file as the + // sole config source. + if let Ok(p) = std::env::var("HEW_CONFIG") { + return load_from(&PathBuf::from(p)); + } + use etcetera::BaseStrategy; + let strategy = etcetera::choose_base_strategy().map_err(|e| HewError::Io(io_other(e)))?; + let user_path = strategy.config_dir().join("hew").join("config.toml"); + let cwd = std::env::current_dir().map_err(HewError::Io)?; + let project_path = discover_project_root(&cwd).and_then(|root| discover_project_config(&root)); + load_layered(Some(&user_path), project_path.as_deref()) } pub fn load_from(path: &Path) -> Result { @@ -373,6 +384,152 @@ pub fn load_from(path: &Path) -> Result { } } +/// Read the user and project config files (each missing = empty +/// [`Config`]) and merge them per the documented rules: project wins +/// for scalars, `Option::or` for `Option`, concat+dedupe for +/// `Vec`, recursive per-field merge for nested structs, and +/// project-wins-on-collision for `BTreeMap`. Project-side absent fields +/// still deserialize to `serde(default)` values — write project configs +/// sparsely. +pub fn load_layered(user: Option<&Path>, project: Option<&Path>) -> Result { + let mut merged = match user { + Some(p) if p.is_file() => load_from(p)?, + _ => Config::default(), + }; + if let Some(p) = project + && p.is_file() + { + let project_cfg = load_from(p)?; + merged.merge(project_cfg); + } + Ok(merged) +} + +impl Config { + /// Layer `other` (the project config) on top of `self` (the user + /// config) in place. See [`load_layered`] for the merge contract. + pub fn merge(&mut self, other: Config) { + // Bare scalars: project wins outright. Because we serde(default) + // every field, "absent in the project file" deserializes to the + // default value — so projects should be written sparsely or risk + // clobbering the user-level setting back to default. + self.update_check = other.update_check; + self.git_track = other.git_track; + // Option: project None falls back to user's Some. + self.default_runtime = other.default_runtime.or_else(|| self.default_runtime.take()); + self.default_scope = other.default_scope.or_else(|| self.default_scope.take()); + // Nested structs: recurse per-field. + self.optional_skills.merge(other.optional_skills); + self.branching.merge(other.branching); + self.research.merge(other.research); + self.review.merge(other.review); + self.testing.merge(other.testing); + self.craft.merge(other.craft); + self.compact.merge(other.compact); + self.loop_cfg.merge(other.loop_cfg); + } +} + +impl OptionalSkills { + pub fn merge(&mut self, other: OptionalSkills) { + self.deps = other.deps; + self.research = other.research; + self.security = other.security; + } +} + +impl BranchingConfig { + pub fn merge(&mut self, other: BranchingConfig) { + self.strategy = other.strategy; + } +} + +impl ResearchConfig { + pub fn merge(&mut self, other: ResearchConfig) { + self.default = other.default; + } +} + +impl ReviewConfig { + pub fn merge(&mut self, other: ReviewConfig) { + self.after_n_tasks = other.after_n_tasks; + self.after_epic = other.after_epic; + self.batch_size = other.batch_size; + } +} + +impl TestingConfig { + pub fn merge(&mut self, other: TestingConfig) { + self.require = other.require; + } +} + +impl CraftConfig { + pub fn merge(&mut self, other: CraftConfig) { + self.max_function_lines = other.max_function_lines; + self.warn_on_unused = other.warn_on_unused; + self.symbol_trace = other.symbol_trace; + } +} + +impl CompactConfig { + pub fn merge(&mut self, other: CompactConfig) { + self.dry_run_default = other.dry_run_default; + self.granularity_default = other.granularity_default; + self.target_clusters_cap = other.target_clusters_cap; + self.allow_recompact_default = other.allow_recompact_default; + merge_vec_dedup(&mut self.exempt, other.exempt); + } +} + +impl LoopConfig { + pub fn merge(&mut self, other: LoopConfig) { + self.fallback_runtime = other.fallback_runtime.or_else(|| self.fallback_runtime.take()); + self.fallback_cooldown_iters = + other.fallback_cooldown_iters.or(self.fallback_cooldown_iters); + self.model.merge(other.model); + self.planner.merge(other.planner); + self.end_of_run.merge(other.end_of_run); + } +} + +impl LoopModelConfig { + pub fn merge(&mut self, other: LoopModelConfig) { + self.default = other.default.or_else(|| self.default.take()); + // BTreeMap: extend; project wins on key collision. + for (k, v) in other.by_priority { + self.by_priority.insert(k, v); + } + for (k, v) in other.by_type { + self.by_type.insert(k, v); + } + } +} + +impl LoopPlannerConfig { + pub fn merge(&mut self, other: LoopPlannerConfig) { + self.enabled = other.enabled; + self.budget_tokens = other.budget_tokens; + self.runtime = other.runtime.or_else(|| self.runtime.take()); + } +} + +impl LoopEndOfRunConfig { + pub fn merge(&mut self, other: LoopEndOfRunConfig) { + self.verify_tests = other.verify_tests; + self.verify_command = other.verify_command; + self.verify_budget_wall = other.verify_budget_wall; + } +} + +fn merge_vec_dedup(base: &mut Vec, other: Vec) { + for item in other { + if !base.contains(&item) { + base.push(item); + } + } +} + pub fn save(cfg: &Config) -> Result { let path = config_path()?; save_to(&path, cfg)?; @@ -1637,4 +1794,244 @@ fallback_runtime = "codex" let tmp = tempfile::tempdir().unwrap(); assert!(discover_project_config(tmp.path()).is_none()); } + + // ──────── load_layered ──────── + + #[test] + fn load_layered_no_files_returns_default() { + let cfg = load_layered(None, None).unwrap(); + let def = Config::default(); + // Spot-check a few fields across kinds. + assert_eq!(cfg.update_check, def.update_check); + assert_eq!(cfg.branching.strategy, def.branching.strategy); + assert!(cfg.compact.exempt.is_empty()); + } + + #[test] + fn load_layered_user_only_matches_legacy_behavior() { + let tmp = tempfile::tempdir().unwrap(); + let user_path = tmp.path().join("user.toml"); + std::fs::write( + &user_path, + r#" +update_check = false +default_runtime = "claude" + +[branching] +strategy = "always" + +[compact] +exempt = ["STATUS:keep"] +"#, + ) + .unwrap(); + let cfg = load_layered(Some(&user_path), None).unwrap(); + assert!(!cfg.update_check); + assert_eq!(cfg.default_runtime.as_deref(), Some("claude")); + assert_eq!(cfg.branching.strategy, "always"); + assert_eq!(cfg.compact.exempt, vec!["STATUS:keep"]); + } + + #[test] + fn load_layered_project_overrides_user_scalar() { + let tmp = tempfile::tempdir().unwrap(); + let user_path = tmp.path().join("user.toml"); + let project_path = tmp.path().join(".hew.toml"); + std::fs::write( + &user_path, + r#" +[branching] +strategy = "none" + +[review] +batch_size = 4 +"#, + ) + .unwrap(); + std::fs::write( + &project_path, + r#" +[branching] +strategy = "always" + +[review] +batch_size = 16 +"#, + ) + .unwrap(); + let cfg = load_layered(Some(&user_path), Some(&project_path)).unwrap(); + assert_eq!(cfg.branching.strategy, "always"); + assert_eq!(cfg.review.batch_size, 16); + } + + #[test] + fn load_layered_project_inherits_user_when_project_value_is_none() { + let tmp = tempfile::tempdir().unwrap(); + let user_path = tmp.path().join("user.toml"); + let project_path = tmp.path().join(".hew.toml"); + // User sets the Option fields; project omits them. + std::fs::write( + &user_path, + r#" +default_runtime = "codex" +default_scope = "epic" + +[loop] +fallback_runtime = "claude" +fallback_cooldown_iters = 9 + +[loop.model] +default = "sonnet-4-6" + +[loop.planner] +runtime = "codex" +"#, + ) + .unwrap(); + // Empty project file → every Option deserializes to None → + // user value should survive via Option::or. + std::fs::write(&project_path, "").unwrap(); + let cfg = load_layered(Some(&user_path), Some(&project_path)).unwrap(); + assert_eq!(cfg.default_runtime.as_deref(), Some("codex")); + assert_eq!(cfg.default_scope.as_deref(), Some("epic")); + assert_eq!(cfg.loop_cfg.fallback_runtime.as_deref(), Some("claude")); + assert_eq!(cfg.loop_cfg.fallback_cooldown_iters, Some(9)); + assert_eq!(cfg.loop_cfg.model.default.as_deref(), Some("sonnet-4-6")); + assert_eq!(cfg.loop_cfg.planner.runtime.as_deref(), Some("codex")); + } + + #[test] + fn load_layered_arrays_append_and_dedupe() { + let tmp = tempfile::tempdir().unwrap(); + let user_path = tmp.path().join("user.toml"); + let project_path = tmp.path().join(".hew.toml"); + std::fs::write( + &user_path, + r#" +[compact] +exempt = ["STATUS:user-a", "STATUS:shared"] +"#, + ) + .unwrap(); + std::fs::write( + &project_path, + r#" +[compact] +exempt = ["STATUS:shared", "STATUS:project-b"] +"#, + ) + .unwrap(); + let cfg = load_layered(Some(&user_path), Some(&project_path)).unwrap(); + // Order preserved: user entries first, then new project entries; + // duplicates from project dropped. + assert_eq!(cfg.compact.exempt, vec!["STATUS:user-a", "STATUS:shared", "STATUS:project-b"]); + } + + #[test] + fn load_layered_nested_table_merge_recursive() { + let tmp = tempfile::tempdir().unwrap(); + let user_path = tmp.path().join("user.toml"); + let project_path = tmp.path().join(".hew.toml"); + std::fs::write( + &user_path, + r#" +[loop.model] +default = "sonnet-4-6" + +[loop.model.by_priority] +P0 = "opus-user" +P3 = "haiku-user" + +[loop.model.by_type] +bug = "sonnet-user" +"#, + ) + .unwrap(); + std::fs::write( + &project_path, + r#" +[loop.model.by_priority] +P0 = "opus-project" +P1 = "sonnet-project" + +[loop.model.by_type] +chore = "haiku-project" +"#, + ) + .unwrap(); + let cfg = load_layered(Some(&user_path), Some(&project_path)).unwrap(); + // user-only P3 survives; user P0 overridden; new P1 added. + assert_eq!( + cfg.loop_cfg.model.by_priority.get("P0").map(String::as_str), + Some("opus-project") + ); + assert_eq!( + cfg.loop_cfg.model.by_priority.get("P1").map(String::as_str), + Some("sonnet-project") + ); + assert_eq!( + cfg.loop_cfg.model.by_priority.get("P3").map(String::as_str), + Some("haiku-user") + ); + // by_type: user bug + project chore both present. + assert_eq!(cfg.loop_cfg.model.by_type.get("bug").map(String::as_str), Some("sonnet-user")); + assert_eq!( + cfg.loop_cfg.model.by_type.get("chore").map(String::as_str), + Some("haiku-project") + ); + // Option default: user kept (project omitted). + assert_eq!(cfg.loop_cfg.model.default.as_deref(), Some("sonnet-4-6")); + } + + // The `load()` env-driven path mutates process-global state + // (HEW_CONFIG + cwd), so the integration smokes below are kept to + // one combined test that scrubs around itself. Running it in + // isolation matches how the rest of this module handles env-touchy + // assertions. + #[test] + fn load_env_var_hew_config_bypasses_layering_and_project_discovery() { + // Build a sole-file config that load() should return unchanged + // when HEW_CONFIG points at it — even though we drop the agent + // inside a tempdir that has both `.beads/` AND `.hew.toml`. + let tmp = tempfile::tempdir().unwrap(); + let project_root = tmp.path(); + std::fs::create_dir(project_root.join(".beads")).unwrap(); + std::fs::write( + project_root.join(".hew.toml"), + r#" +update_check = false +"#, + ) + .unwrap(); + let sole = project_root.join("sole.toml"); + std::fs::write( + &sole, + r#" +update_check = true +default_runtime = "claude" +"#, + ) + .unwrap(); + + let prev_cwd = std::env::current_dir().unwrap(); + let prev_hew_config = std::env::var_os("HEW_CONFIG"); + std::env::set_current_dir(project_root).unwrap(); + // SAFETY: see other env-mutating tests in this module — env is + // process-global, tests touching it accept the race. + unsafe { std::env::set_var("HEW_CONFIG", &sole) }; + + let cfg = load().unwrap(); + + // Restore before asserting so a panic still cleans up. + match prev_hew_config { + Some(v) => unsafe { std::env::set_var("HEW_CONFIG", v) }, + None => unsafe { std::env::remove_var("HEW_CONFIG") }, + } + std::env::set_current_dir(prev_cwd).unwrap(); + + // HEW_CONFIG path won; project's `.hew.toml` (which would have + // flipped update_check to false) was bypassed. + assert!(cfg.update_check); + assert_eq!(cfg.default_runtime.as_deref(), Some("claude")); + } } From eae322b4cdfc4903ba9855a673b94fe3a168c95e Mon Sep 17 00:00:00 2001 From: droidnoob Date: Sat, 30 May 2026 20:43:45 +0530 Subject: [PATCH 4/9] fix(loop): apply Scope filter on serial path's bd.ready() poll (hew-s9mb) `run_worker_loop_with_scope` polled `bd.ready()` and grabbed the first task without consulting `cfg.scope`, so `hew loop run --scope=epics --epics=` on the serial (`--jobs=1`) path would claim any unrelated ready bug. The parallel `Dispatcher::dispatch_tick` already filtered correctly; the surgical fix mirrors its `resolve_descendants` + `Scope::includes` call at the serial path's candidate boundary. - 3 new regression tests in hew/tests/loop_scope_serial.rs covering the reproducer, the empty-after-filter ReadyEmpty stop, and the no-regression `Scope::Ready` baseline. - Existing loop_scope_e2e 7/7 still green (argv-contract layer unchanged). --- hew/src/commands/loop_cmd.rs | 22 ++- hew/tests/loop_scope_serial.rs | 288 +++++++++++++++++++++++++++++++++ 2 files changed, 308 insertions(+), 2 deletions(-) create mode 100644 hew/tests/loop_scope_serial.rs diff --git a/hew/src/commands/loop_cmd.rs b/hew/src/commands/loop_cmd.rs index 0b833ec..a1722fd 100644 --- a/hew/src/commands/loop_cmd.rs +++ b/hew/src/commands/loop_cmd.rs @@ -39,7 +39,7 @@ use hew_core::runtime::{ ClaudeSpawner, CodexSpawner, FallbackConfig, RuntimeKind, RuntimeSpawner, SpawnFailureClass, SpawnOpts, }; -use hew_core::scope::Scope; +use hew_core::scope::{self, Scope}; use hew_core::stop_signals::Collector; use hew_core::tasks; use hew_core::time::iso_now_utc; @@ -1491,7 +1491,25 @@ pub fn run_worker_loop_with_scope( let mut iter_logs: Vec = Vec::new(); loop { - let ready = bd.ready().map_err(|e| miette::miette!("bd ready: {e}"))?; + let raw_ready = bd.ready().map_err(|e| miette::miette!("bd ready: {e}"))?; + + // Apply the run's scope filter at the candidate-set boundary + // (hew-s9mb). The parallel `Dispatcher` enforces this in + // `dispatch_tick`; without the same filter here, the serial + // (`--jobs=1`) path would claim any bd-ready task — leaking + // outside the agent-explicit `--scope=epics --epics=` + // contract. Re-resolve descendants every iter so children + // added to a selected epic mid-run get picked up, mirroring + // dispatcher behavior. + let ready: Vec = match &cfg.scope { + Scope::Ready => raw_ready, + Scope::Epics { epic_ids } => { + let set = scope::resolve_descendants(bd, epic_ids) + .map_err(|e| miette::miette!("resolve epic descendants: {e}"))?; + raw_ready.into_iter().filter(|t| cfg.scope.includes(&t.id, &set)).collect() + } + }; + let signals = collector.snapshot(&run_state, ready.len() as u32, last_outcome); if let Some(reason) = signals.evaluate(&cfg) { run_state.stop_reason = Some(reason); diff --git a/hew/tests/loop_scope_serial.rs b/hew/tests/loop_scope_serial.rs new file mode 100644 index 0000000..95fde7c --- /dev/null +++ b/hew/tests/loop_scope_serial.rs @@ -0,0 +1,288 @@ +//! Regression tests for hew-s9mb: the serial (`--jobs=1`) loop path must +//! honor the run's `Scope::Epics` filter at task selection, matching the +//! `Dispatcher::dispatch_tick` behavior on the parallel path. +//! +//! Before the fix, `run_worker_loop_with_scope` polled `bd.ready()` and +//! grabbed the first task without consulting `cfg.scope` — so a +//! `--scope=epics --epics=` run on `--jobs=1` would happily claim +//! any unrelated ready bug. + +use std::cell::RefCell; +use std::collections::BTreeMap; +use std::ffi::OsStr; +use std::path::Path; + +use hew_core::backpressure::GateCheck; +use hew_core::bd::{BdClient, BdOutput, BdVersion, ReadyTask, StatsSummary}; +use hew_core::config::{LoopModelConfig, LoopPlannerConfig}; +use hew_core::ctx::{Ctx, OutputMode}; +use hew_core::error::Result as HewResult; +use hew_core::runtime::FallbackConfig; +use hew_core::scope::Scope; +use hew_core::{allowed_tools, skills}; + +use hew::commands::loop_cmd::{Args, StaticGateRunner, Worker, run_worker_loop_with_scope}; + +/// BdClient that exposes a fixed ready list plus a per-parent children +/// map for `bd children ` lookups (used by +/// `scope::resolve_descendants` when filtering for `Scope::Epics`). +#[derive(Debug)] +struct ScopedBd { + ready: Vec, + children: BTreeMap, + calls: RefCell>>, +} + +impl ScopedBd { + fn new(ready: Vec) -> Self { + Self { ready, children: BTreeMap::new(), calls: RefCell::new(Vec::new()) } + } + fn with_children(mut self, parent: &str, kids: &[&ReadyTask]) -> Self { + let body = kids + .iter() + .map(|t| { + format!( + r#"{{"id":"{}","title":"{}","description":"","status":"open","priority":{},"issue_type":"task","closed_at":"","close_reason":null,"parent":"{}"}}"#, + t.id, t.title, t.priority, parent, + ) + }) + .collect::>() + .join(","); + self.children.insert(parent.to_string(), format!("[{body}]")); + self + } +} + +impl BdClient for ScopedBd { + fn version(&self) -> HewResult { + Ok(BdVersion { raw: "test 1.0.0".into(), semver: "1.0.0".into() }) + } + fn ready(&self) -> HewResult> { + Ok(self.ready.clone()) + } + fn stats(&self) -> HewResult { + Ok(StatsSummary::default()) + } + fn prime_raw(&self) -> HewResult { + Ok(String::new()) + } + fn memories(&self) -> HewResult> { + Ok(BTreeMap::new()) + } + fn remember(&self, _text: &str) -> HewResult<()> { + Ok(()) + } + fn run_raw(&self, args: &[&OsStr]) -> HewResult { + let captured: Vec = args.iter().map(|a| a.to_string_lossy().to_string()).collect(); + self.calls.borrow_mut().push(captured.clone()); + if captured.first().map(|s| s.as_str()) == Some("children") { + let parent = captured.get(1).cloned().unwrap_or_default(); + let body = self.children.get(&parent).cloned().unwrap_or_else(|| "[]".into()); + return Ok(BdOutput { stdout: body, stderr: String::new() }); + } + Ok(BdOutput { stdout: String::new(), stderr: String::new() }) + } +} + +fn ctx() -> Ctx { + Ctx { interactive: false, output: OutputMode::Text, quiet: true, verbose: 0 } +} + +fn args_one_iter() -> Args { + Args { + max_iter: Some(1), + until_empty: false, + budget_tokens: None, + budget_wall: None, + strict: true, + interactive: false, + unattended: false, + runtime: "claude".into(), + stop_file: None, + dry_run: true, + skill: "hew-execute".into(), + fallback_runtime: None, + fallback_cooldown_iters: None, + jobs: 1, + scope: None, + epics: Vec::new(), + epic: Vec::new(), + no_planner: false, + planner_budget: None, + planner_runtime: None, + verify_tests: false, + no_verify_tests: false, + verify_command: None, + } +} + +fn ready_task(id: &str, title: &str) -> ReadyTask { + ReadyTask { + id: id.into(), + title: title.into(), + description: String::new(), + priority: 2, + status: "open".into(), + issue_type: "task".into(), + parent: None, + } +} + +fn worker(log_dir: &Path) -> Worker { + Worker { + id: 0, + worktree_dir: log_dir.to_path_buf(), + branch: String::new(), + log_dir: log_dir.to_path_buf(), + worker_n: None, + } +} + +/// The reproducer from hew-s9mb: ready set contains an unrelated bug +/// (`hew-zt4z`) plus the in-scope child (`hew-ja44`). The serial loop, +/// running under `--scope=epics --epics=hew-c0pa`, must claim the child +/// and ignore the unrelated bug — before the fix it picked the unrelated +/// task because the serial path's `bd.ready()` poll skipped the filter. +#[test] +fn e2e_serial_scope_epics_filters_out_unrelated_ready_tasks() { + let tmp = tempfile::tempdir().expect("tempdir"); + let log_dir = tmp.path().to_path_buf(); + + let unrelated = ready_task("hew-zt4z", "unrelated bug"); + let child = ready_task("hew-ja44", "in-scope entry child"); + let bd = ScopedBd::new(vec![unrelated.clone(), child.clone()]) + .with_children("hew-c0pa", &[&child]) + .with_children("hew-ja44", &[]); + + let gate = + StaticGateRunner(GateCheck { tests_passed: true, lint_passed: true, ..Default::default() }); + + let args = args_one_iter(); + let skill = skills::find(&args.skill).expect("hew-execute skill present"); + let allowed = allowed_tools::for_skill(&args.skill); + let stop = log_dir.join(".stop"); + + let outcome = run_worker_loop_with_scope( + &ctx(), + &args, + &bd, + None, + None, + FallbackConfig::default(), + LoopModelConfig::default(), + LoopPlannerConfig::default(), + &gate, + &worker(&log_dir), + &skill, + "", + "loop-test-scope", + &allowed, + &stop, + Scope::Epics { epic_ids: vec!["hew-c0pa".into()] }, + ) + .expect("serial worker loop runs"); + + assert_eq!(outcome.run.iters.len(), 1, "exactly one iter under max_iter=1"); + let iter = &outcome.run.iters[0]; + assert_eq!( + iter.task_id.as_deref(), + Some("hew-ja44"), + "serial path must respect Scope::Epics — claimed the wrong task", + ); + assert_ne!( + iter.task_id.as_deref(), + Some("hew-zt4z"), + "serial path leaked outside scope: claimed unrelated ready bug", + ); +} + +/// When the only ready tasks are outside the epic's descendant set, the +/// scoped serial loop must stop with `ReadyEmpty` rather than spawning +/// against any of them. +#[test] +fn serial_loop_skips_bd_ready_tasks_outside_scope_epic_descendants() { + use hew_core::runner::StopReason; + + let tmp = tempfile::tempdir().expect("tempdir"); + let log_dir = tmp.path().to_path_buf(); + + let stranger = ready_task("hew-stranger", "outside the epic"); + let bd = ScopedBd::new(vec![stranger]).with_children("hew-c0pa", &[]); + let gate = + StaticGateRunner(GateCheck { tests_passed: true, lint_passed: true, ..Default::default() }); + + let args = args_one_iter(); + let skill = skills::find(&args.skill).expect("hew-execute skill present"); + let allowed = allowed_tools::for_skill(&args.skill); + let stop = log_dir.join(".stop"); + + let outcome = run_worker_loop_with_scope( + &ctx(), + &args, + &bd, + None, + None, + FallbackConfig::default(), + LoopModelConfig::default(), + LoopPlannerConfig::default(), + &gate, + &worker(&log_dir), + &skill, + "", + "loop-test-scope-empty", + &allowed, + &stop, + Scope::Epics { epic_ids: vec!["hew-c0pa".into()] }, + ) + .expect("serial worker loop runs"); + + assert!(outcome.run.iters.is_empty(), "no iter should run when scope filter empties the queue"); + assert!( + matches!(outcome.run.stop_reason, Some(StopReason::ReadyEmpty)), + "expected StopReason::ReadyEmpty, got {:?}", + outcome.run.stop_reason, + ); +} + +/// Sanity: `Scope::Ready` (the legacy default) keeps the pre-fix +/// behavior — any bd-ready task is fair game. Locks the no-regression +/// promise the bug ticket explicitly calls out ("loop_scope_e2e 7/7 still +/// pass" — those exercise argv-contract; this exercises actual selection). +#[test] +fn serial_loop_scope_ready_still_claims_any_ready_task() { + let tmp = tempfile::tempdir().expect("tempdir"); + let log_dir = tmp.path().to_path_buf(); + + let only = ready_task("hew-anything", "any old task"); + let bd = ScopedBd::new(vec![only]); + let gate = + StaticGateRunner(GateCheck { tests_passed: true, lint_passed: true, ..Default::default() }); + + let args = args_one_iter(); + let skill = skills::find(&args.skill).expect("hew-execute skill present"); + let allowed = allowed_tools::for_skill(&args.skill); + let stop = log_dir.join(".stop"); + + let outcome = run_worker_loop_with_scope( + &ctx(), + &args, + &bd, + None, + None, + FallbackConfig::default(), + LoopModelConfig::default(), + LoopPlannerConfig::default(), + &gate, + &worker(&log_dir), + &skill, + "", + "loop-test-scope-ready", + &allowed, + &stop, + Scope::Ready, + ) + .expect("serial worker loop runs"); + + assert_eq!(outcome.run.iters.len(), 1); + assert_eq!(outcome.run.iters[0].task_id.as_deref(), Some("hew-anything")); +} From 9f7b0c0c46b9d9f04f12eb9bab2ebbd65339b137 Mon Sep 17 00:00:00 2001 From: droidnoob Date: Sat, 30 May 2026 21:24:48 +0530 Subject: [PATCH 5/9] feat(init): emit starter .hew.toml after install (hew-3r8v) - New template hew/templates/hew.toml.starter (version=1 + commented loop/model/planner/end_of_run/compact sections + docs link). - emit_starter_dot_hew_toml helper in init.rs: Fresh/Refresh skip when .hew.toml or hew.toml already exists; Reconfigure prompts before overwriting (default no, only when interactive). Best-effort; never aborts init on write failure. - 10 unit tests cover template content + per-mode helper state via an injected regen picker. - 3 e2e tests cover fresh emit, dotfile preservation, and plain hew.toml blocking creation of .hew.toml. --- hew/src/commands/init.rs | 174 ++++++++++++++++++++++++++++++++- hew/templates/hew.toml.starter | 25 +++++ hew/tests/init_e2e.rs | 58 +++++++++++ 3 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 hew/templates/hew.toml.starter diff --git a/hew/src/commands/init.rs b/hew/src/commands/init.rs index 1ba6b3a..0769e4e 100644 --- a/hew/src/commands/init.rs +++ b/hew/src/commands/init.rs @@ -1,5 +1,5 @@ use std::ffi::OsStr; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use clap::Args as ClapArgs; use hew_core::Ctx; @@ -298,6 +298,8 @@ pub fn run(ctx: &Ctx, args: Args) -> miette::Result<()> { plans.push(plan); } + emit_starter_dot_hew_toml(&project_root, mode, ctx, interactive_starter_regen_pick); + if !ctx.quiet { print_summary_panel( mode, @@ -943,6 +945,58 @@ where } } +/// Starter `.hew.toml` body. Tracked as a real TOML file under +/// `hew/templates/` so contributors can edit it directly. +const STARTER_HEW_TOML: &str = include_str!("../../templates/hew.toml.starter"); + +/// Write `.hew.toml` with the starter content. Skips silently when a +/// project config already exists (`.hew.toml` or `hew.toml`), except in +/// interactive Reconfigure mode where the user is asked whether to +/// regenerate (default: no). Best-effort: failures are logged to stderr +/// but never abort init. +fn emit_starter_dot_hew_toml(project_root: &Path, mode: InitMode, ctx: &Ctx, regen_pick: F) +where + F: FnOnce() -> bool, +{ + let dot = project_root.join(".hew.toml"); + let plain = project_root.join("hew.toml"); + let exists = dot.exists() || plain.exists(); + + let should_write = match (mode, exists) { + (InitMode::Cancel, _) => false, // run() returns before this; keep for safety. + (_, false) => true, + (InitMode::Reconfigure, true) if ctx.interactive => regen_pick(), + // Fresh / Refresh / non-interactive Reconfigure with existing file: skip. + (_, true) => false, + }; + + if !should_write { + return; + } + + match std::fs::write(&dot, STARTER_HEW_TOML) { + Ok(_) => { + if !ctx.quiet { + println!("project config: ✓ wrote starter .hew.toml"); + } + } + Err(e) => { + if !ctx.quiet { + eprintln!("hew init: warning — could not write .hew.toml: {e}"); + } + } + } +} + +fn interactive_starter_regen_pick() -> bool { + use inquire::Confirm; + Confirm::new("Regenerate .hew.toml?") + .with_default(false) + .with_help_message("overwrites the existing project config with the starter template") + .prompt() + .unwrap_or(false) +} + fn init_git_repo(ctx: &Ctx, project_root: &std::path::Path) -> miette::Result<()> { if !RealGit::is_available() { return Ok(()); @@ -1157,6 +1211,124 @@ mod tests { assert_eq!(runtime_artifact_label(Runtime::Generic), ""); } + // --- hew-3r8v: starter .hew.toml emission --- + + fn non_interactive_ctx() -> Ctx { + use hew_core::ctx::OutputMode; + Ctx::new(true, OutputMode::Auto, false, 0) + } + + fn interactive_quiet_ctx() -> Ctx { + use hew_core::ctx::OutputMode; + let mut c = Ctx::new(false, OutputMode::Auto, true, 0); + c.interactive = true; + c + } + + fn never_regen() -> bool { + panic!("regen picker must not be called in this case") + } + + #[test] + fn starter_contains_version_field() { + assert!(STARTER_HEW_TOML.contains("version = 1")); + } + + #[test] + fn starter_contains_commented_loop_planner_section() { + assert!(STARTER_HEW_TOML.contains("# [loop.planner]")); + assert!(STARTER_HEW_TOML.contains("# budget_tokens")); + } + + #[test] + fn starter_contains_docs_link_in_header() { + assert!(STARTER_HEW_TOML.contains("https://hew.sh/docs/config")); + } + + #[test] + fn emit_writes_when_absent() { + let project = tempfile::tempdir().unwrap(); + let ctx = non_interactive_ctx(); + emit_starter_dot_hew_toml(project.path(), InitMode::Fresh, &ctx, never_regen); + let body = std::fs::read_to_string(project.path().join(".hew.toml")).unwrap(); + assert!(body.contains("version = 1")); + } + + #[test] + fn emit_skips_when_dot_hew_toml_exists() { + let project = tempfile::tempdir().unwrap(); + let existing = project.path().join(".hew.toml"); + std::fs::write(&existing, "version = 99\n# user content\n").unwrap(); + let ctx = non_interactive_ctx(); + emit_starter_dot_hew_toml(project.path(), InitMode::Fresh, &ctx, never_regen); + let body = std::fs::read_to_string(&existing).unwrap(); + assert!(body.contains("version = 99"), "user content must survive: {body}"); + } + + #[test] + fn emit_skips_when_plain_hew_toml_exists() { + let project = tempfile::tempdir().unwrap(); + let plain = project.path().join("hew.toml"); + std::fs::write(&plain, "version = 1\n").unwrap(); + let ctx = non_interactive_ctx(); + emit_starter_dot_hew_toml(project.path(), InitMode::Fresh, &ctx, never_regen); + // No .hew.toml should be created when plain hew.toml already exists. + assert!(!project.path().join(".hew.toml").exists()); + } + + #[test] + fn emit_refresh_mode_preserves_existing_starter() { + let project = tempfile::tempdir().unwrap(); + let existing = project.path().join(".hew.toml"); + std::fs::write(&existing, "version = 42\n").unwrap(); + let ctx = non_interactive_ctx(); + emit_starter_dot_hew_toml(project.path(), InitMode::Refresh, &ctx, never_regen); + let body = std::fs::read_to_string(&existing).unwrap(); + assert!(body.contains("version = 42"), "refresh must not overwrite"); + } + + #[test] + fn emit_reconfigure_mode_regenerates_when_user_confirms() { + let project = tempfile::tempdir().unwrap(); + let existing = project.path().join(".hew.toml"); + std::fs::write(&existing, "version = 7\n").unwrap(); + let ctx = interactive_quiet_ctx(); + emit_starter_dot_hew_toml(project.path(), InitMode::Reconfigure, &ctx, || true); + let body = std::fs::read_to_string(&existing).unwrap(); + assert!(body.contains("version = 1"), "reconfigure+yes must rewrite to starter: {body}"); + } + + #[test] + fn emit_reconfigure_mode_keeps_existing_when_user_declines() { + let project = tempfile::tempdir().unwrap(); + let existing = project.path().join(".hew.toml"); + std::fs::write(&existing, "version = 7\n").unwrap(); + let ctx = interactive_quiet_ctx(); + emit_starter_dot_hew_toml(project.path(), InitMode::Reconfigure, &ctx, || false); + let body = std::fs::read_to_string(&existing).unwrap(); + assert!(body.contains("version = 7"), "reconfigure+no must preserve: {body}"); + } + + #[test] + fn emit_reconfigure_mode_writes_when_absent_without_prompt() { + let project = tempfile::tempdir().unwrap(); + let ctx = interactive_quiet_ctx(); + emit_starter_dot_hew_toml(project.path(), InitMode::Reconfigure, &ctx, never_regen); + let body = std::fs::read_to_string(project.path().join(".hew.toml")).unwrap(); + assert!(body.contains("version = 1")); + } + + #[test] + fn emit_non_interactive_reconfigure_keeps_existing_silently() { + let project = tempfile::tempdir().unwrap(); + let existing = project.path().join(".hew.toml"); + std::fs::write(&existing, "version = 9\n").unwrap(); + let ctx = non_interactive_ctx(); + emit_starter_dot_hew_toml(project.path(), InitMode::Reconfigure, &ctx, never_regen); + let body = std::fs::read_to_string(&existing).unwrap(); + assert!(body.contains("version = 9"), "non-interactive reconfigure must not prompt"); + } + #[test] fn runtime_invalid_value_rejected_by_clap() { let err = parse(&["--runtime=bogus"]).expect_err("should fail"); diff --git a/hew/templates/hew.toml.starter b/hew/templates/hew.toml.starter new file mode 100644 index 0000000..eb4f54d --- /dev/null +++ b/hew/templates/hew.toml.starter @@ -0,0 +1,25 @@ +# hew project config — https://hew.sh/docs/config +# +# Team-shared settings live here; personal overrides live in +# ~/.config/hew/config.toml (managed via `hew config set --global ...`). +# See `hew config show` for the merged effective config. +version = 1 + +# [loop] +# fallback_runtime = "codex" +# fallback_cooldown_iters = 3 + +# [loop.model] +# default = "claude-opus-4-7" +# by_priority = { "0" = "claude-opus-4-7", "2" = "claude-sonnet-4-6" } + +# [loop.planner] +# enabled = true +# budget_tokens = 10000 + +# [loop.end_of_run] +# verify_tests = false + +# [compact] +# target_clusters_cap = 1 +# allow_recompact_default = false diff --git a/hew/tests/init_e2e.rs b/hew/tests/init_e2e.rs index 6f83ea8..3f6f202 100644 --- a/hew/tests/init_e2e.rs +++ b/hew/tests/init_e2e.rs @@ -843,3 +843,61 @@ fn init_no_flag_with_multiple_detected_refreshes_all() { .stdout(contains("Claude: ✓")) .stdout(contains("Codex: ✓")); } + +#[test] +fn init_emits_starter_dot_hew_toml_on_fresh_project() { + // hew-3r8v: fresh init writes a starter .hew.toml alongside skill files. + let stub_dir = tempfile::tempdir().unwrap(); + install_stub(stub_dir.path(), BD_STUB_OK); + let project = tempfile::tempdir().unwrap(); + fs::create_dir(project.path().join(".claude")).unwrap(); + + hew_with_stub(project.path(), stub_dir.path()) + .args(["init", "--non-interactive", "--runtime", "claude"]) + .assert() + .success() + .stdout(contains("project config: ✓ wrote starter .hew.toml")); + + let body = fs::read_to_string(project.path().join(".hew.toml")).unwrap(); + assert!(body.contains("version = 1"), "starter must contain version field:\n{body}"); + assert!(body.contains("https://hew.sh/docs/config"), "starter must contain docs link:\n{body}"); +} + +#[test] +fn init_preserves_existing_dot_hew_toml() { + // hew-3r8v: existing .hew.toml must not be overwritten on a fresh init. + let stub_dir = tempfile::tempdir().unwrap(); + install_stub(stub_dir.path(), BD_STUB_OK); + let project = tempfile::tempdir().unwrap(); + fs::create_dir(project.path().join(".claude")).unwrap(); + fs::write(project.path().join(".hew.toml"), "version = 42\n# my project\n").unwrap(); + + hew_with_stub(project.path(), stub_dir.path()) + .args(["init", "--non-interactive", "--runtime", "claude"]) + .assert() + .success(); + + let body = fs::read_to_string(project.path().join(".hew.toml")).unwrap(); + assert!(body.contains("version = 42"), "user content must survive: {body}"); + assert!(body.contains("# my project")); +} + +#[test] +fn init_skips_starter_when_plain_hew_toml_present() { + // hew-3r8v: if `hew.toml` (no leading dot) exists, do not create `.hew.toml`. + let stub_dir = tempfile::tempdir().unwrap(); + install_stub(stub_dir.path(), BD_STUB_OK); + let project = tempfile::tempdir().unwrap(); + fs::create_dir(project.path().join(".claude")).unwrap(); + fs::write(project.path().join("hew.toml"), "version = 1\n").unwrap(); + + hew_with_stub(project.path(), stub_dir.path()) + .args(["init", "--non-interactive", "--runtime", "claude"]) + .assert() + .success(); + + assert!( + !project.path().join(".hew.toml").exists(), + ".hew.toml must not be created when plain hew.toml is present" + ); +} From 16ee6e930ca43e5e7668cf5d67213baa9ac6d7da Mon Sep 17 00:00:00 2001 From: droidnoob Date: Sat, 30 May 2026 21:56:15 +0530 Subject: [PATCH 6/9] feat(config): config set --global/--project write-target (hew-k2gm) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add --global / --project flags on `hew config set` (clap conflicts_with). - Resolve write target across 5 branches: 1. both flags → clap rejects upstream 2. --global → user-global (~/.config/hew/config.toml) 3. --project → existing project file or /.hew.toml (created with `# hew project config` + `version = 1` header on first write) 4. neither + project file present → refuse with dual-option error listing both `--project ` and `--global ` 5. neither + no project file → user-global (back-compat) - New `hew_core::config::save_project_to(path, cfg)` prepends starter header on create; subsequent writes overwrite without re-adding it. - 7 new e2e tests cover all 5 resolution branches + refusal message shape + --help surface; 3 new core tests cover save_project_to. --- hew-core/src/config.rs | 75 +++++++++++++++++ hew/src/commands/config.rs | 93 ++++++++++++++++++++- hew/tests/config_e2e.rs | 163 +++++++++++++++++++++++++++++++++++++ 3 files changed, 327 insertions(+), 4 deletions(-) diff --git a/hew-core/src/config.rs b/hew-core/src/config.rs index aeccc8f..6f88f69 100644 --- a/hew-core/src/config.rs +++ b/hew-core/src/config.rs @@ -545,6 +545,35 @@ pub fn save_to(path: &Path, cfg: &Config) -> Result<()> { Ok(()) } +/// Header prepended to a freshly-created project config file so the +/// operator who later opens `.hew.toml` by hand sees what it is. Matches +/// the starter template emitted by `hew init` (modulo the example-only +/// commented blocks — the live values follow this header). +const PROJECT_STARTER_HEADER: &str = "\ +# hew project config — https://hew.sh/docs/config +# +# Team-shared settings live here; personal overrides live in +# ~/.config/hew/config.toml (managed via `hew config set --global ...`). +# See `hew config show` for the merged effective config. +version = 1 +"; + +/// Write a project-local config TOML. On the first write to a path that +/// doesn't yet exist, prepends [`PROJECT_STARTER_HEADER`] so the file +/// carries the `# hew project config` banner + `version = 1` marker. +/// Subsequent writes overwrite the body and lose the header (acceptable +/// trade-off: the operator who's hand-editing the file knows what it is). +pub fn save_project_to(path: &Path, cfg: &Config) -> Result<()> { + let is_new = !path.exists(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let serialized = toml::to_string_pretty(cfg).map_err(|e| HewError::Io(io_other(e)))?; + let body = if is_new { format!("{PROJECT_STARTER_HEADER}\n{serialized}") } else { serialized }; + std::fs::write(path, body)?; + Ok(()) +} + /// Get a single key's value as a string (for `hew config get`). pub fn get(cfg: &Config, key: &str) -> Option { match key { @@ -1640,6 +1669,52 @@ fallback_runtime = "codex" assert_eq!(loaded.compact.exempt, vec!["STATUS:custom", "STATUS:other"]); } + // ──────── save_project_to (hew-k2gm) ──────── + + #[test] + fn save_project_to_new_file_prepends_starter_header() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join(".hew.toml"); + let mut cfg = Config::default(); + set(&mut cfg, "loop.fallback_runtime", "codex").unwrap(); + save_project_to(&path, &cfg).unwrap(); + + let body = std::fs::read_to_string(&path).unwrap(); + assert!(body.starts_with("# hew project config"), "header present: {body}"); + assert!(body.contains("version = 1"), "version marker present"); + assert!(body.contains("fallback_runtime = \"codex\""), "set value present"); + } + + #[test] + fn save_project_to_existing_file_drops_header_on_overwrite() { + // Second write to an existing project file overwrites the body — + // the starter header is a create-time courtesy, not preserved on + // every save. (Documented in save_project_to's doc comment.) + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join(".hew.toml"); + std::fs::write(&path, "version = 1\nupdate_check = false\n").unwrap(); + let mut cfg = Config::default(); + set(&mut cfg, "default-runtime", "claude").unwrap(); + save_project_to(&path, &cfg).unwrap(); + + let body = std::fs::read_to_string(&path).unwrap(); + assert!(!body.contains("# hew project config"), "no starter prepended on overwrite"); + assert!(body.contains("default_runtime = \"claude\"")); + } + + #[test] + fn save_project_to_round_trips_through_load_from() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join(".hew.toml"); + let mut cfg = Config::default(); + set(&mut cfg, "loop.model.default", "claude-opus-4-7").unwrap(); + save_project_to(&path, &cfg).unwrap(); + + // The `version = 1` header line must not break deserialize. + let loaded = load_from(&path).unwrap(); + assert_eq!(loaded.loop_cfg.model.default.as_deref(), Some("claude-opus-4-7")); + } + // ──────── discover_project_root / discover_project_config ──────── fn scrub_git_env_in_process() { diff --git a/hew/src/commands/config.rs b/hew/src/commands/config.rs index de1e3cc..19c0831 100644 --- a/hew/src/commands/config.rs +++ b/hew/src/commands/config.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use clap::{Args as ClapArgs, Subcommand}; use hew_core::config; use hew_core::{Ctx, OutputMode}; @@ -13,7 +15,17 @@ pub enum Op { /// Print the value of a single config key. Get { key: String }, /// Set a config key. Pass an empty value to clear an optional key. - Set { key: String, value: String }, + Set { + key: String, + value: String, + /// Write to the user-global config (`~/.config/hew/config.toml`). + #[arg(long, conflicts_with = "project")] + global: bool, + /// Write to the project-local config (`./.hew.toml`). Creates the + /// file with the starter header if absent. + #[arg(long)] + project: bool, + }, /// Show all config keys with their current values. List, /// Reset config to defaults. @@ -22,6 +34,70 @@ pub enum Op { Path, } +/// Where a `hew config set` should land. +enum WriteTarget { + UserGlobal(PathBuf), + Project(PathBuf), +} + +/// Resolve the on-disk target for a `hew config set` call. Implements +/// the 5-branch table from `hew-k2gm`: +/// 1. `--global` + `--project` → clap rejects upstream (`conflicts_with`). +/// 2. `--global` → user-global. +/// 3. `--project` → project file (existing or `/.hew.toml`). +/// 4. neither + project exists → refuse with the dual-flag message. +/// 5. neither + no project file → user-global (back-compat). +fn resolve_write_target( + global: bool, + project: bool, + key: &str, + value: &str, +) -> miette::Result { + if global { + return Ok(WriteTarget::UserGlobal(config::config_path()?)); + } + + let cwd = std::env::current_dir().map_err(|e| miette::miette!("cwd unavailable: {e}"))?; + let project_root = config::discover_project_root(&cwd); + let project_path = project_root.as_ref().and_then(|r| config::discover_project_config(r)); + + if project { + let path = match project_path { + Some(p) => p, + None => { + let root = project_root.ok_or_else(|| { + miette::miette!( + "--project: no project root found (no `.beads/` or `.git` ancestor of {})", + cwd.display() + ) + })?; + root.join(".hew.toml") + } + }; + return Ok(WriteTarget::Project(path)); + } + + match project_path { + Some(p) => { + let display_root = p + .parent() + .map(|d| d.display().to_string()) + .unwrap_or_else(|| p.display().to_string()); + let file_name = p + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| ".hew.toml".to_string()); + Err(miette::miette!( + "refusing to write to user-global config when `{file_name}` exists at {display_root}\n\ + \x20 team-shared config lives in `{file_name}`. Use one of:\n\ + \x20 hew config set --project {key} {value} # commit-shared\n\ + \x20 hew config set --global {key} {value} # personal override" + )) + } + None => Ok(WriteTarget::UserGlobal(config::config_path()?)), + } +} + pub fn run(ctx: &Ctx, args: Args) -> miette::Result<()> { match args.op { Op::Path => { @@ -41,10 +117,19 @@ pub fn run(ctx: &Ctx, args: Args) -> miette::Result<()> { )), } } - Op::Set { key, value } => { - let mut cfg = config::load()?; + Op::Set { key, value, global, project } => { + let target = resolve_write_target(global, project, &key, &value)?; + let (path, is_project) = match target { + WriteTarget::UserGlobal(p) => (p, false), + WriteTarget::Project(p) => (p, true), + }; + let mut cfg = config::load_from(&path)?; config::set(&mut cfg, &key, &value)?; - let path = config::save(&cfg)?; + if is_project { + config::save_project_to(&path, &cfg)?; + } else { + config::save_to(&path, &cfg)?; + } if !ctx.quiet { println!("set {key} = {value} ({})", path.display()); } diff --git a/hew/tests/config_e2e.rs b/hew/tests/config_e2e.rs index 00ef75f..ddc57fb 100644 --- a/hew/tests/config_e2e.rs +++ b/hew/tests/config_e2e.rs @@ -85,3 +85,166 @@ fn path_prints_config_location() { let cfg = tmp.path().join("config.toml"); hew(&cfg).args(["config", "path"]).assert().success().stdout(contains(cfg.to_str().unwrap())); } + +// ──────── hew-k2gm: write-target resolution (--global / --project) ──────── + +/// Builds a hew Command that runs inside a project root (a tempdir with +/// `.beads/` so [`config::discover_project_root`] finds it) and has +/// `HEW_CONFIG` pointed at a user-global path inside that same tempdir +/// (so the test cannot stomp on the host's real config). +fn hew_in_project(user_cfg: &std::path::Path, project_root: &std::path::Path) -> Command { + let mut c = hew(user_cfg); + c.current_dir(project_root); + c +} + +fn make_project_root() -> tempfile::TempDir { + let tmp = tempfile::tempdir().unwrap(); + fs::create_dir(tmp.path().join(".beads")).unwrap(); + tmp +} + +#[test] +fn set_writes_to_user_when_no_project_file_and_no_flags() { + // Branch 5: neither flag, no project file → user-global (back-compat). + let proj = make_project_root(); + let user_cfg = proj.path().join("user.toml"); + hew_in_project(&user_cfg, proj.path()) + .args(["config", "set", "default-runtime", "claude"]) + .assert() + .success() + .stdout(contains(user_cfg.to_str().unwrap())); + let body = fs::read_to_string(&user_cfg).unwrap(); + assert!(body.contains("default_runtime"), "wrote to user-global"); + assert!(!proj.path().join(".hew.toml").exists(), "no project file created"); +} + +#[test] +fn set_refuses_user_write_when_project_present_no_flags() { + // Branch 4: project file exists + no flag → refuse with dual-option message. + let proj = make_project_root(); + let user_cfg = proj.path().join("user.toml"); + fs::write(proj.path().join(".hew.toml"), "# placeholder\n").unwrap(); + + hew_in_project(&user_cfg, proj.path()) + .args(["config", "set", "loop.model.default", "opus-4-7"]) + .assert() + .failure() + .stderr(contains("refusing to write to user-global config")) + .stderr(contains(".hew.toml")) + .stderr(contains("--project loop.model.default opus-4-7")) + .stderr(contains("--global loop.model.default opus-4-7")); +} + +#[test] +fn set_global_flag_writes_to_user_when_project_present() { + // Branch 2: --global wins regardless of project file presence. + let proj = make_project_root(); + let user_cfg = proj.path().join("user.toml"); + fs::write(proj.path().join(".hew.toml"), "# placeholder\n").unwrap(); + + hew_in_project(&user_cfg, proj.path()) + .args(["config", "set", "--global", "default-runtime", "claude"]) + .assert() + .success() + .stdout(contains(user_cfg.to_str().unwrap())); + let body = fs::read_to_string(&user_cfg).unwrap(); + assert!(body.contains("default_runtime")); + // Project file is untouched. + let proj_body = fs::read_to_string(proj.path().join(".hew.toml")).unwrap(); + assert_eq!(proj_body, "# placeholder\n"); +} + +#[test] +fn set_project_flag_writes_to_project_when_present() { + // Branch 3a: --project + existing project file → write to that file. + let proj = make_project_root(); + let user_cfg = proj.path().join("user.toml"); + fs::write(proj.path().join(".hew.toml"), "# hew project config\nversion = 1\n").unwrap(); + + hew_in_project(&user_cfg, proj.path()) + .args(["config", "set", "--project", "loop.fallback_runtime", "codex"]) + .assert() + .success() + .stdout(contains(".hew.toml")); + + let proj_body = fs::read_to_string(proj.path().join(".hew.toml")).unwrap(); + assert!( + proj_body.contains("fallback_runtime = \"codex\""), + "project file got the new key, body was:\n{proj_body}" + ); + // User-global untouched (never created here since no --global write happened). + assert!(!user_cfg.exists(), "user-global stayed untouched"); +} + +#[test] +fn set_project_flag_creates_project_file_when_absent_with_starter_header() { + // Branch 3b: --project + no project file → create one with starter + // header (# hew project config + version = 1). + let proj = make_project_root(); + let user_cfg = proj.path().join("user.toml"); + + hew_in_project(&user_cfg, proj.path()) + .args(["config", "set", "--project", "loop.model.default", "claude-opus-4-7"]) + .assert() + .success(); + + let dot = proj.path().join(".hew.toml"); + assert!(dot.exists(), ".hew.toml created"); + let body = fs::read_to_string(&dot).unwrap(); + assert!(body.contains("# hew project config"), "starter header present: {body}"); + assert!(body.contains("version = 1"), "version marker present: {body}"); + assert!(body.contains("default = \"claude-opus-4-7\""), "new key written: {body}"); +} + +#[test] +fn set_mutually_exclusive_global_and_project_errors_at_clap() { + // Branch 1: --global + --project → clap conflicts_with rejects upstream. + let proj = make_project_root(); + let user_cfg = proj.path().join("user.toml"); + hew_in_project(&user_cfg, proj.path()) + .args(["config", "set", "--global", "--project", "default-runtime", "claude"]) + .assert() + .failure() + .stderr(contains("cannot be used with")); +} + +#[test] +fn set_refusal_message_lists_both_explicit_alternatives() { + // Acceptance criterion: refusal text matches the plan format exactly + // — includes both alternatives with the values the user tried. + let proj = make_project_root(); + let user_cfg = proj.path().join("user.toml"); + fs::write(proj.path().join(".hew.toml"), "version = 1\n").unwrap(); + + let assert = hew_in_project(&user_cfg, proj.path()) + .args(["config", "set", "branching.strategy", "always"]) + .assert() + .failure(); + let stderr = String::from_utf8(assert.get_output().stderr.clone()).unwrap(); + // miette reflows the error report into a Unicode-bordered box that + // wraps long lines. Strip the box prefix (`│ ` / leading whitespace) + // and collapse internal whitespace so substring matches survive. + let stripped: String = stderr + .lines() + .map(|l| l.trim_start_matches(|c: char| c.is_whitespace() || c == '│')) + .collect::>() + .join(" "); + let flat: String = stripped.split_whitespace().collect::>().join(" "); + assert!(flat.contains("--project branching.strategy always"), "stderr:\n{stderr}"); + assert!(flat.contains("--global branching.strategy always"), "stderr:\n{stderr}"); + assert!(flat.contains("commit-shared") || flat.contains("commit- shared"), "stderr:\n{stderr}"); + assert!(flat.contains("personal override"), "stderr:\n{stderr}"); +} + +#[test] +fn set_help_shows_global_and_project_flags() { + let tmp = tempfile::tempdir().unwrap(); + let cfg = tmp.path().join("config.toml"); + hew(&cfg) + .args(["config", "set", "--help"]) + .assert() + .success() + .stdout(contains("--global")) + .stdout(contains("--project")); +} From 397c5cd82c7bc62af269038372707b74a0dc9829 Mon Sep 17 00:00:00 2001 From: droidnoob Date: Sat, 30 May 2026 22:23:32 +0530 Subject: [PATCH 7/9] feat(config): hew config show with per-key source provenance (hew-leh9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New `hew config show` subcommand renders a sources header in precedence order (user-global → project → env) plus the effective config with `(source)` attribution on every key. - `hew_core::config::LoadedConfig` carries the merged Config plus a BTreeMap and the contributing file paths. - `ConfigSource` ∈ { UserGlobal, Project, Env, Default, Merged }. `Merged` is reserved for keys whose values are unioned across both files (compact.exempt, loop.model.by_priority, loop.model.by_type). - Attribution walks the raw on-disk toml::Table for each side so a user value that happens to equal the default still attributes to user-global, not Default. - New `HEW_USER_CONFIG` env var overrides the XDG user-global path without bypassing layered project discovery. `HEW_CONFIG` keeps its single-file bypass semantics; tests for the layered path use `HEW_USER_CONFIG` to stay off the host's real config. - 6 new e2e tests cover sources-in-precedence, scalar/array/default attribution, the env single-source rendering, and the stable --json shape. 5 new core tests cover the attribution primitives. --- hew-core/src/config.rs | 245 ++++++++++++++++++++++++++++++++++++- hew/src/commands/config.rs | 53 ++++++++ hew/tests/config_e2e.rs | 120 ++++++++++++++++++ 3 files changed, 413 insertions(+), 5 deletions(-) diff --git a/hew-core/src/config.rs b/hew-core/src/config.rs index 6f88f69..1a52ab9 100644 --- a/hew-core/src/config.rs +++ b/hew-core/src/config.rs @@ -347,11 +347,26 @@ pub struct OptionalSkills { pub security: SkillMode, } -/// Resolve the user-config path. Honors `HEW_CONFIG` for tests. +/// Resolve the user-config path. Honors `HEW_CONFIG` for tests (highest +/// precedence — single-file bypass). Also honors `HEW_USER_CONFIG` which +/// overrides only the XDG-resolved user-global path, leaving layered +/// project discovery intact. pub fn config_path() -> Result { if let Ok(p) = std::env::var("HEW_CONFIG") { return Ok(PathBuf::from(p)); } + if let Ok(p) = std::env::var("HEW_USER_CONFIG") { + return Ok(PathBuf::from(p)); + } + use etcetera::BaseStrategy; + let strategy = etcetera::choose_base_strategy().map_err(|e| HewError::Io(io_other(e)))?; + Ok(strategy.config_dir().join("hew").join("config.toml")) +} + +fn user_config_path() -> Result { + if let Ok(p) = std::env::var("HEW_USER_CONFIG") { + return Ok(PathBuf::from(p)); + } use etcetera::BaseStrategy; let strategy = etcetera::choose_base_strategy().map_err(|e| HewError::Io(io_other(e)))?; Ok(strategy.config_dir().join("hew").join("config.toml")) @@ -364,13 +379,12 @@ fn io_other(e: impl std::fmt::Display) -> std::io::Error { pub fn load() -> Result { // `HEW_CONFIG` is the documented escape hatch for tests / scripts: // it bypasses layering entirely and treats the named file as the - // sole config source. + // sole config source. `HEW_USER_CONFIG` only overrides the + // XDG-resolved user-global path; layered project discovery still runs. if let Ok(p) = std::env::var("HEW_CONFIG") { return load_from(&PathBuf::from(p)); } - use etcetera::BaseStrategy; - let strategy = etcetera::choose_base_strategy().map_err(|e| HewError::Io(io_other(e)))?; - let user_path = strategy.config_dir().join("hew").join("config.toml"); + let user_path = user_config_path()?; let cwd = std::env::current_dir().map_err(HewError::Io)?; let project_path = discover_project_root(&cwd).and_then(|root| discover_project_config(&root)); load_layered(Some(&user_path), project_path.as_deref()) @@ -545,6 +559,172 @@ pub fn save_to(path: &Path, cfg: &Config) -> Result<()> { Ok(()) } +/// Where a given setting came from after layering. `Merged` is reserved +/// for the small set of keys whose values are concatenated/extended +/// (arrays, maps) — see [`is_merged_key`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ConfigSource { + /// Set explicitly in `~/.config/hew/config.toml`. + UserGlobal, + /// Set explicitly in `/.hew.toml` (or `hew.toml`). + Project, + /// Set via the `HEW_CONFIG` environment-variable bypass file. This + /// source is single-file and short-circuits both user-global and + /// project layering. + Env, + /// Neither file set the key — value comes from the Rust default. + Default, + /// Both user-global and project files contributed (arrays + /// concatenated, maps unioned). + Merged, +} + +impl std::fmt::Display for ConfigSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + Self::UserGlobal => "user-global", + Self::Project => "project", + Self::Env => "env", + Self::Default => "default", + Self::Merged => "merged", + }; + f.write_str(s) + } +} + +/// A list of canonical keys whose merge semantics produce a union of +/// both sides (arrays append+dedupe, maps extend). Used by +/// [`load_with_provenance`] to label these as [`ConfigSource::Merged`] +/// when both files contribute, instead of crediting only the winning +/// side. +fn is_merged_key(key: &str) -> bool { + matches!(key, "compact.exempt" | "loop.model.by_priority" | "loop.model.by_type") +} + +/// Translate a public dotted key (as used by [`get`] / [`set`] / [`keys`]) +/// into the TOML path used in on-disk files. Top-level hyphens become +/// underscores; dotted segments are walked verbatim. +fn key_to_toml_path(key: &str) -> Vec { + key.split('.').map(|seg| seg.replace('-', "_")).collect() +} + +fn has_dotted_key(table: &toml::Table, path: &[String]) -> bool { + match path { + [] => false, + [last] => table.contains_key(last), + [head, rest @ ..] => match table.get(head) { + Some(toml::Value::Table(sub)) => has_dotted_key(sub, rest), + _ => false, + }, + } +} + +fn read_toml_table(path: &Path) -> toml::Table { + std::fs::read_to_string(path).ok().and_then(|s| toml::from_str(&s).ok()).unwrap_or_default() +} + +/// Result of [`load_with_provenance`]: the merged config plus per-key +/// source attribution and the on-disk file paths that contributed. +#[derive(Debug)] +pub struct LoadedConfig { + pub config: Config, + pub sources: BTreeMap, + /// File paths in precedence order (later overrides earlier). `None` + /// means the slot did not contribute (no file at that path). + pub user_path: Option, + pub project_path: Option, + pub env_path: Option, +} + +impl LoadedConfig { + /// File paths that actually contributed, in precedence order + /// (user-global, then project, then env). Used by `hew config show` + /// to render the "sources" header. + pub fn source_paths(&self) -> Vec<(&'static str, &Path)> { + let mut out = Vec::new(); + if let Some(p) = &self.env_path { + out.push(("env (HEW_CONFIG)", p.as_path())); + return out; + } + if let Some(p) = &self.user_path + && p.is_file() + { + out.push(("user-global", p.as_path())); + } + if let Some(p) = &self.project_path { + out.push(("project", p.as_path())); + } + out + } +} + +/// Same as [`load`], but tracks per-key provenance so `hew config show` +/// can attribute each setting to its source file. +/// +/// Source-attribution rules: +/// - When [`HEW_CONFIG`] is set, every key is labeled [`ConfigSource::Env`] +/// iff the file contains the key, else [`ConfigSource::Default`]. +/// - Otherwise: if both user and project files explicitly set a +/// [`is_merged_key`] key → [`ConfigSource::Merged`]. Else attribute to +/// whichever file set the key, with project winning ties (matching +/// the load-time merge rule). If neither file set it → +/// [`ConfigSource::Default`]. +pub fn load_with_provenance() -> Result { + if let Ok(p) = std::env::var("HEW_CONFIG") { + let path = PathBuf::from(&p); + let cfg = load_from(&path)?; + let raw = read_toml_table(&path); + let mut sources = BTreeMap::new(); + for k in keys() { + let path = key_to_toml_path(k); + let src = + if has_dotted_key(&raw, &path) { ConfigSource::Env } else { ConfigSource::Default }; + sources.insert((*k).to_string(), src); + } + return Ok(LoadedConfig { + config: cfg, + sources, + user_path: None, + project_path: None, + env_path: Some(path), + }); + } + + let user_path = user_config_path()?; + let cwd = std::env::current_dir().map_err(HewError::Io)?; + let project_path = discover_project_root(&cwd).and_then(|root| discover_project_config(&root)); + + let user_raw = + if user_path.is_file() { read_toml_table(&user_path) } else { toml::Table::new() }; + let project_raw = project_path.as_ref().map(|p| read_toml_table(p)).unwrap_or_default(); + + let cfg = load_layered(Some(&user_path), project_path.as_deref())?; + + let mut sources = BTreeMap::new(); + for k in keys() { + let toml_path = key_to_toml_path(k); + let user_has = has_dotted_key(&user_raw, &toml_path); + let project_has = has_dotted_key(&project_raw, &toml_path); + let src = match (user_has, project_has) { + (false, false) => ConfigSource::Default, + (true, false) => ConfigSource::UserGlobal, + (false, true) => ConfigSource::Project, + (true, true) if is_merged_key(k) => ConfigSource::Merged, + (true, true) => ConfigSource::Project, + }; + sources.insert((*k).to_string(), src); + } + + Ok(LoadedConfig { + config: cfg, + sources, + user_path: Some(user_path), + project_path, + env_path: None, + }) +} + /// Header prepended to a freshly-created project config file so the /// operator who later opens `.hew.toml` by hand sees what it is. Matches /// the starter template emitted by `hew init` (modulo the example-only @@ -1669,6 +1849,61 @@ fallback_runtime = "codex" assert_eq!(loaded.compact.exempt, vec!["STATUS:custom", "STATUS:other"]); } + // ──────── load_with_provenance (hew-leh9) ──────── + + #[test] + fn provenance_default_when_no_keys_set() { + // Pure-fn slice: the source-attribution rule itself. Avoids + // touching process-global env by running through the underlying + // primitives directly. + let empty = toml::Table::new(); + let path = key_to_toml_path("update-check"); + assert!(!has_dotted_key(&empty, &path)); + } + + #[test] + fn provenance_user_only_when_only_user_has_key() { + let mut user_raw = toml::Table::new(); + user_raw.insert("default_runtime".to_string(), toml::Value::String("claude".into())); + let project_raw = toml::Table::new(); + let path = key_to_toml_path("default-runtime"); + assert!(has_dotted_key(&user_raw, &path)); + assert!(!has_dotted_key(&project_raw, &path)); + } + + #[test] + fn provenance_merged_label_only_for_array_and_map_keys() { + assert!(is_merged_key("compact.exempt")); + assert!(is_merged_key("loop.model.by_priority")); + assert!(is_merged_key("loop.model.by_type")); + assert!(!is_merged_key("default-runtime")); + assert!(!is_merged_key("loop.fallback_runtime")); + } + + #[test] + fn key_to_toml_path_translates_dashes_at_each_segment() { + assert_eq!(key_to_toml_path("update-check"), vec!["update_check"]); + assert_eq!(key_to_toml_path("branching.strategy"), vec!["branching", "strategy"]); + assert_eq!(key_to_toml_path("review.after-n-tasks"), vec!["review", "after_n_tasks"]); + } + + #[test] + fn has_dotted_key_walks_nested_tables() { + let raw: toml::Table = toml::from_str( + r#" +[loop] +fallback_runtime = "codex" + +[loop.model] +default = "claude-opus-4-7" +"#, + ) + .unwrap(); + assert!(has_dotted_key(&raw, &key_to_toml_path("loop.fallback_runtime"))); + assert!(has_dotted_key(&raw, &key_to_toml_path("loop.model.default"))); + assert!(!has_dotted_key(&raw, &key_to_toml_path("loop.model.default-runtime"))); + } + // ──────── save_project_to (hew-k2gm) ──────── #[test] diff --git a/hew/src/commands/config.rs b/hew/src/commands/config.rs index 19c0831..3c6c5e7 100644 --- a/hew/src/commands/config.rs +++ b/hew/src/commands/config.rs @@ -28,6 +28,8 @@ pub enum Op { }, /// Show all config keys with their current values. List, + /// Show effective config with per-key source attribution. + Show, /// Reset config to defaults. Reset, /// Print the resolved config file path. @@ -157,6 +159,57 @@ pub fn run(ctx: &Ctx, args: Args) -> miette::Result<()> { } Ok(()) } + Op::Show => { + let loaded = config::load_with_provenance()?; + if matches!(ctx.output, OutputMode::Json) { + let sources: Vec = loaded + .source_paths() + .into_iter() + .map(|(label, path)| { + serde_json::json!({ "label": label, "path": path.display().to_string() }) + }) + .collect(); + let mut keys_obj = serde_json::Map::new(); + for k in config::keys() { + let value = config::get(&loaded.config, k).unwrap_or_default(); + let source = + loaded.sources.get(*k).copied().unwrap_or(config::ConfigSource::Default); + keys_obj.insert( + (*k).to_string(), + serde_json::json!({ + "value": value, + "source": source.to_string(), + }), + ); + } + let out = serde_json::json!({ + "sources": sources, + "keys": serde_json::Value::Object(keys_obj), + }); + println!("{}", serde_json::to_string_pretty(&out).unwrap()); + } else { + let sps = loaded.source_paths(); + if sps.is_empty() { + println!("sources: (none — all defaults)"); + } else { + println!("sources (in precedence order):"); + for (label, path) in &sps { + println!(" [{label}] {}", path.display()); + } + } + println!(); + println!("effective config:"); + let width = config::keys().iter().map(|k| k.len()).max().unwrap_or(0); + for k in config::keys() { + let v = config::get(&loaded.config, k).unwrap_or_default(); + let src = + loaded.sources.get(*k).copied().unwrap_or(config::ConfigSource::Default); + let display_value = if v.is_empty() { "(unset)".to_string() } else { v }; + println!(" {k:width$} = {display_value:<28} ({src})"); + } + } + Ok(()) + } Op::Reset => { let cfg = config::Config::default(); let path = config::save(&cfg)?; diff --git a/hew/tests/config_e2e.rs b/hew/tests/config_e2e.rs index ddc57fb..c71ffd3 100644 --- a/hew/tests/config_e2e.rs +++ b/hew/tests/config_e2e.rs @@ -248,3 +248,123 @@ fn set_help_shows_global_and_project_flags() { .stdout(contains("--global")) .stdout(contains("--project")); } + +// ──────── hew-leh9: `hew config show` provenance ──────── + +/// Build a hew Command rooted in a project dir whose user-global config +/// path is overridden via `HEW_USER_CONFIG` (NOT `HEW_CONFIG`, which is +/// a single-file bypass). Use this for tests that need real layering +/// between user and project files. +fn hew_layered(user_cfg: &std::path::Path, project_root: &std::path::Path) -> Command { + let mut c = Command::cargo_bin("hew").unwrap(); + c.env_remove("HEW_CONFIG"); + c.env("HEW_USER_CONFIG", user_cfg); + c.env("NO_COLOR", "1"); + c.env("TERM", "dumb"); + c.current_dir(project_root); + c +} + +#[test] +fn show_lists_sources_in_precedence_order() { + let proj = make_project_root(); + let user_cfg = proj.path().join("user.toml"); + fs::write(&user_cfg, "default_runtime = \"claude\"\n").unwrap(); + fs::write(proj.path().join(".hew.toml"), "[loop]\nfallback_runtime = \"codex\"\n").unwrap(); + + let assert = hew_layered(&user_cfg, proj.path()).args(["config", "show"]).assert().success(); + let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + // sources header in order: user-global before project. + let user_pos = stdout.find("[user-global]").expect("user-global in output"); + let proj_pos = stdout.find("[project]").expect("project in output"); + assert!(user_pos < proj_pos, "user-global must precede project in:\n{stdout}"); +} + +#[test] +fn show_attributes_scalar_to_its_source_file() { + let proj = make_project_root(); + let user_cfg = proj.path().join("user.toml"); + fs::write(&user_cfg, "default_runtime = \"claude\"\n").unwrap(); + fs::write(proj.path().join(".hew.toml"), "[loop]\nfallback_runtime = \"codex\"\n").unwrap(); + + let assert = hew_layered(&user_cfg, proj.path()).args(["config", "show"]).assert().success(); + let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + // default-runtime: user-global. loop.fallback_runtime: project. + let line_runtime = + stdout.lines().find(|l| l.contains("default-runtime")).expect("default-runtime line"); + assert!(line_runtime.contains("(user-global)"), "line was: {line_runtime}"); + let line_fb = stdout + .lines() + .find(|l| l.contains("loop.fallback_runtime")) + .expect("loop.fallback_runtime line"); + assert!(line_fb.contains("(project)"), "line was: {line_fb}"); +} + +#[test] +fn show_attributes_array_to_merged_when_both_contributed() { + let proj = make_project_root(); + let user_cfg = proj.path().join("user.toml"); + fs::write(&user_cfg, "[compact]\nexempt = [\"STATUS:user-a\"]\n").unwrap(); + fs::write(proj.path().join(".hew.toml"), "[compact]\nexempt = [\"STATUS:project-b\"]\n") + .unwrap(); + + let assert = hew_layered(&user_cfg, proj.path()).args(["config", "show"]).assert().success(); + let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let line = stdout.lines().find(|l| l.contains("compact.exempt")).expect("compact.exempt line"); + assert!(line.contains("(merged)"), "line was: {line}"); +} + +#[test] +fn show_attributes_default_when_neither_file_set_it() { + let proj = make_project_root(); + let user_cfg = proj.path().join("user.toml"); + // No project file. user is also empty. + + let assert = hew_layered(&user_cfg, proj.path()).args(["config", "show"]).assert().success(); + let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let line = stdout.lines().find(|l| l.contains("update-check")).expect("update-check line"); + assert!(line.contains("(default)"), "line was: {line}"); +} + +#[test] +fn show_env_hew_config_renders_single_source() { + let proj = make_project_root(); + let user_cfg = proj.path().join("user.toml"); + fs::write(&user_cfg, "default_runtime = \"claude\"\n").unwrap(); + // Project file also exists; HEW_CONFIG should bypass both. + fs::write(proj.path().join(".hew.toml"), "version = 1\n").unwrap(); + + let assert = hew_in_project(&user_cfg, proj.path()).args(["config", "show"]).assert().success(); + let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + // Only the env source appears in the sources header. + assert!(stdout.contains("[env (HEW_CONFIG)]"), "stdout:\n{stdout}"); + assert!(!stdout.contains("[user-global]"), "stdout must not list user-global:\n{stdout}"); + assert!(!stdout.contains("[project]"), "stdout must not list project:\n{stdout}"); + // Keys set in the HEW_CONFIG file attribute to env. + let line = + stdout.lines().find(|l| l.contains("default-runtime")).expect("default-runtime line"); + assert!(line.contains("(env)"), "line was: {line}"); +} + +#[test] +fn show_json_output_shape_stable() { + let proj = make_project_root(); + let user_cfg = proj.path().join("user.toml"); + fs::write(&user_cfg, "default_runtime = \"claude\"\n").unwrap(); + + let out = hew_layered(&user_cfg, proj.path()) + .args(["--json", "config", "show"]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let parsed: serde_json::Value = serde_json::from_slice(&out).unwrap(); + assert!(parsed["sources"].is_array(), "json: {parsed}"); + assert!(parsed["keys"].is_object(), "json: {parsed}"); + let runtime = &parsed["keys"]["default-runtime"]; + assert_eq!(runtime["value"], "claude"); + assert_eq!(runtime["source"], "user-global"); + let update_check = &parsed["keys"]["update-check"]; + assert_eq!(update_check["source"], "default"); +} From 69cd50e177f7b155870f6e481e289a0e90d0c793 Mon Sep 17 00:00:00 2001 From: droidnoob Date: Sat, 30 May 2026 22:26:37 +0530 Subject: [PATCH 8/9] docs(config): docs/CONFIG.md + CHANGELOG + CLAUDE cross-ref (hew-u181) - New docs/CONFIG.md walks the layered config story for operators: two-file overview, discovery order, where-each-setting-belongs table, hew config set write-rules table, hew config show sample + --json shape, merge semantics with worked example, migration recipe, and explicit non-goals (.hew.local.toml, ancestor walk, schema migration). - CHANGELOG.md [Unreleased] entry summarizes the hew-c0pa epic. - CLAUDE.md "Where to look next" links docs/CONFIG.md. - 3 grep-based smoke tests pin the precedence table, merge-semantics section, and CHANGELOG entry against regression. --- CHANGELOG.md | 18 ++++ CLAUDE.md | 1 + docs/CONFIG.md | 223 ++++++++++++++++++++++++++++++++++++++++ hew/tests/config_e2e.rs | 40 +++++++ 4 files changed, 282 insertions(+) create mode 100644 docs/CONFIG.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c9fb4d5..8c366e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ versioning follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- **Project-local config file `.hew.toml` (`hew-c0pa`).** Hew settings + now layer across user-global (`~/.config/hew/config.toml`) and + project-local (`/.hew.toml`, `hew.toml` legacy fallback). `hew + init` emits a starter `.hew.toml` with header + `version = 1`. `hew + config set` takes `--global` / `--project` flags (mutually exclusive) + to pick the target; refuses to silently write user-global when a + project file exists. `hew config show` renders the merged effective + config with `(user-global)` / `(project)` / `(merged)` / `(env)` / + `(default)` attribution per key, in text and `--json`. Merge rules: + scalars project-wins, `Option` falls back via `or`, arrays + concat+dedupe, maps extend, tables recurse. Discovery anchors on the + first `.beads/` / `.git` ancestor (root-only; no ancestor walk in + v1). New `HEW_USER_CONFIG` env var overrides the XDG user path + without bypassing layering (`HEW_CONFIG` retains single-file bypass + semantics). See [`docs/CONFIG.md`](docs/CONFIG.md). + ## [0.11.0] — 2026-05-30 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index e5bd292..d68bbfe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -207,6 +207,7 @@ Before a release: | Public-facing project overview | [README.md](./README.md) | | Workspace + module architecture | [ARCHITECTURE.md](./ARCHITECTURE.md) | | Slash command reference (all 39) | [docs/COMMANDS.md](./docs/COMMANDS.md) | +| Two-file config layering (user-global + project) | [docs/CONFIG.md](./docs/CONFIG.md) | | Dev setup, MSRV, hooks, release process | [CONTRIBUTING.md](./CONTRIBUTING.md) | | Release notes | [CHANGELOG.md](./CHANGELOG.md) | | The methodology bodies the LLM loads | [`skills/`](./skills) | diff --git a/docs/CONFIG.md b/docs/CONFIG.md new file mode 100644 index 0000000..67631c2 --- /dev/null +++ b/docs/CONFIG.md @@ -0,0 +1,223 @@ +# Hew configuration + +Hew settings live in two TOML files, layered at load time. This +document is the operator-facing contract for that layering. + +> **TL;DR:** personal preferences → `~/.config/hew/config.toml` +> (managed via `hew config set --global …`); team-shared settings → +> `/.hew.toml` (managed via `hew config set --project …`). +> `hew config show` prints the merged view with per-key attribution. + +## Two files, one schema + +| File | Scope | Tracked in git? | Written by | +|------|-------|-----------------|------------| +| `~/.config/hew/config.toml` | user-global | no | `hew config set --global` | +| `/.hew.toml` | project-local | **yes** | `hew config set --project`, `hew init` | +| `/hew.toml` | project-local (legacy / no-dotfile) | yes | rename to `.hew.toml` | + +Both files share the same TOML schema (see `hew config list` for the +full key list, or [`hew_core::config::Config`](../hew-core/src/config.rs) +for the source of truth). Write project configs **sparsely** — fields +that aren't set inherit from user-global or the built-in default. + +The user path follows the XDG base directory convention; on macOS +`etcetera::choose_base_strategy` resolves the platform-native location +(typically still `~/.config/hew/`). Override via `HEW_CONFIG` (single +file, bypasses layering) or `HEW_USER_CONFIG` (overrides the user path +but keeps layered project discovery). + +## Discovery order + +Highest precedence wins: + +1. **Environment** — `HEW_CONFIG=` short-circuits layering and + treats the named file as the sole source. +2. **Project file** — closest ancestor of the current directory + containing `.beads/` or `.git`. Inside that root, `.hew.toml` wins + over `hew.toml`; both present logs a warning. +3. **User-global file** — `HEW_USER_CONFIG` if set, else the + XDG-resolved path. +4. **Defaults** — hardcoded in `hew_core::config`. + +Project discovery is **root-only** in v1 — hew anchors on the first +`.beads/` or `.git` ancestor, then looks for `.hew.toml` / +`hew.toml` only at that root. No ancestor walk. For git worktrees the +main repo's working tree is resolved via `git rev-parse +--git-common-dir`. + +## Where each setting belongs + +This is opinionated — the line is fuzzy, so use judgement. + +| Setting | Belongs in | Why | +|---------|------------|-----| +| `loop.model.default`, `loop.model.by_priority`, `loop.model.by_type` | project | team picks the model per workload | +| `loop.fallback_runtime`, `loop.fallback_cooldown_iters` | project | shared runtime fallback policy | +| `loop.planner.enabled`, `loop.planner.budget_tokens`, `loop.planner.runtime` | project | planner tuning is workload-specific | +| `loop.end_of_run.verify_tests`, `loop.end_of_run.verify_command` | project | the project decides what "green" means | +| `branching.strategy`, `testing.require` | project | crew agrees on the workflow | +| `compact.exempt`, `compact.target_clusters_cap` | project | memory hygiene rules per project | +| `update-check`, `default-runtime`, `default-scope` | user-global | per-person preference | +| `optional-skills.*` | user-global | per-person skill picker prefs | +| `craft.symbol_trace`, `craft.max_function_lines` | project | team-shared coding-craft thresholds | +| `review.after_n_tasks`, `review.after_epic`, `review.batch_size` | project | per-project review cadence | + +## `hew config set` write rules + +When you run `hew config set `, the target file is +resolved as follows: + +| `--global` | `--project` | project file present? | result | +|------------|-------------|-----------------------|--------| +| ❌ | ❌ | no | write to user-global (back-compat) | +| ❌ | ❌ | yes | **refuse** with the dual-flag error | +| ✅ | ❌ | either | write to user-global | +| ❌ | ✅ | yes | write to existing project file | +| ❌ | ✅ | no | create `.hew.toml` at project root with starter header + write | +| ✅ | ✅ | either | clap rejects (mutually exclusive) | + +The refusal error format when neither flag is passed and a project file +exists: + +```text +refusing to write to user-global config when `.hew.toml` exists at /Users/me/repo + team-shared config lives in `.hew.toml`. Use one of: + hew config set --project loop.model.default opus-4-7 # commit-shared + hew config set --global loop.model.default opus-4-7 # personal override +``` + +This is the loudest option from the design picker — it forces the +operator to make an explicit per-write choice instead of silently +mis-targeting team-shared config. + +## `hew config show` + +Inspect the merged effective config with per-key source attribution: + +```text +$ hew config show +sources (in precedence order): + [user-global] /Users/me/.config/hew/config.toml + [project] /Users/me/code/myproj/.hew.toml + +effective config: + update-check = true (default) + default-runtime = claude (user-global) + loop.fallback_runtime = codex (project) + compact.exempt = STATUS:user-a,STATUS:proj-b (merged) + ... +``` + +Sources are listed in precedence order (user-global → project → env). +Each key shows its winning value and the source that contributed it: + +- `(default)` — neither file set the key +- `(user-global)` — only user-global set it +- `(project)` — project file set it (overriding user-global on collision) +- `(merged)` — both files contributed to an array / map (concatenated) +- `(env)` — `HEW_CONFIG` is active; this file is the sole source + +`hew config show --json` emits a stable shape: + +```json +{ + "sources": [ + { "label": "user-global", "path": "/.../config.toml" }, + { "label": "project", "path": "/.../.hew.toml" } + ], + "keys": { + "default-runtime": { "value": "claude", "source": "user-global" }, + "loop.fallback_runtime": { "value": "codex", "source": "project" } + } +} +``` + +## Merge semantics + +When both files set the same key: + +- **Scalars** (strings, bools, ints) — project wins. +- **`Option`** — user value survives if project omits the key (the + project-side `None` falls back via `Option::or`). +- **Arrays** (`compact.exempt`) — user entries first, then new project + entries; duplicates dropped. +- **Maps** (`loop.model.by_priority`, `loop.model.by_type`) — extend; + project wins on key collision. +- **Tables** — recurse field-by-field. + +Worked example. User-global: + +```toml +default_runtime = "codex" + +[compact] +exempt = ["STATUS:user-a", "STATUS:shared"] + +[loop.model.by_priority] +P0 = "opus-user" +P3 = "haiku-user" +``` + +Project `.hew.toml`: + +```toml +[compact] +exempt = ["STATUS:shared", "STATUS:project-b"] + +[loop.model.by_priority] +P0 = "opus-project" +P1 = "sonnet-project" +``` + +Effective config: + +- `default_runtime = "codex"` — only user set it. +- `compact.exempt = ["STATUS:user-a", "STATUS:shared", "STATUS:project-b"]` + — concat + dedupe. +- `loop.model.by_priority.P0 = "opus-project"` — project overrode. +- `loop.model.by_priority.P1 = "sonnet-project"` — project-only entry. +- `loop.model.by_priority.P3 = "haiku-user"` — user-only entry survived. + +## Migration: moving settings from user-global to project + +To promote a personal setting to a team-shared one without re-typing +the value: + +```sh +# 1. Read the existing user-global value. +hew config get loop.model.default +# → claude-opus-4-7 + +# 2. Write it to the project file. +hew config set --project loop.model.default claude-opus-4-7 + +# 3. (Optional) clear the user-global override so it stays sparse. +hew config set --global loop.model.default "" +``` + +The project file gets the value committed to git; everyone on the team +inherits it on next pull. + +## Future + +These are explicitly **not** in v1; called out so expectations stay +calibrated. + +- **`.hew.local.toml`** — gitignored sibling for per-developer + project-scoped overrides. Will sit between project and user-global + in precedence. +- **Ancestor walk** — today project discovery anchors on `.beads/` / + `.git` only at the first match. A multi-repo monorepo with a parent + `.hew.toml` won't be discovered. +- **Schema migration** — `version = 1` in the project file is a + forward-compat marker; a future schema bump will read it. +- **Non-TOML formats** — out of scope. + +## Reference + +- Source: [`hew-core/src/config.rs`](../hew-core/src/config.rs) +- Key list: `hew config list` +- Set path: `hew config path` +- Effective view: `hew config show` +- Agent guidance: [`CLAUDE.md`](../CLAUDE.md) §Locked behavioral preferences diff --git a/hew/tests/config_e2e.rs b/hew/tests/config_e2e.rs index c71ffd3..0f11136 100644 --- a/hew/tests/config_e2e.rs +++ b/hew/tests/config_e2e.rs @@ -346,6 +346,46 @@ fn show_env_hew_config_renders_single_source() { assert!(line.contains("(env)"), "line was: {line}"); } +// ──────── hew-u181: docs/CONFIG.md + CHANGELOG smokes ──────── + +fn workspace_root() -> std::path::PathBuf { + // CARGO_MANIFEST_DIR is `/hew`. Pop one segment. + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf() +} + +#[test] +fn docs_config_md_contains_precedence_table() { + let body = fs::read_to_string(workspace_root().join("docs/CONFIG.md")).unwrap(); + assert!(body.contains("## Discovery order"), "discovery order section"); + // Precedence table mentions HEW_CONFIG, project, user-global, defaults. + assert!(body.contains("HEW_CONFIG"), "env source described"); + assert!(body.contains(".hew.toml"), "project file mentioned"); + assert!(body.contains("user-global"), "user-global label present"); +} + +#[test] +fn docs_config_md_contains_merge_semantics_section() { + let body = fs::read_to_string(workspace_root().join("docs/CONFIG.md")).unwrap(); + assert!(body.contains("## Merge semantics"), "merge semantics section"); + assert!(body.contains("project wins"), "scalar rule mentioned"); + assert!(body.contains("concat") || body.contains("dedupe"), "array rule mentioned"); +} + +#[test] +fn changelog_unreleased_has_project_local_config_entry() { + let body = fs::read_to_string(workspace_root().join("CHANGELOG.md")).unwrap(); + let unreleased = body + .split("## [Unreleased]") + .nth(1) + .and_then(|s| s.split("## [").next()) + .expect("[Unreleased] section"); + assert!(unreleased.contains("hew-c0pa") || unreleased.contains(".hew.toml")); + assert!(unreleased.contains("project") || unreleased.contains("Project")); +} + #[test] fn show_json_output_shape_stable() { let proj = make_project_root(); From f192e44fbf45be227bdfdb7dedf0635e649c2bc7 Mon Sep 17 00:00:00 2001 From: droidnoob Date: Sat, 30 May 2026 22:54:41 +0530 Subject: [PATCH 9/9] fix(loop): render in-flight summary instead of ENOENT when run.json absent (hew-cn2y) `hew loop summary` against a freshly-started run surfaced `read run.json: No such file or directory` because run.json is only written after iter 1 completes. Classify the run-dir state up front (empty-start / parallel-in-flight / iter-logs-only / completed) and render a degraded "iter N in flight" view for every pre-completed case. The genuinely-missing run-id case still errors cleanly. - hew_core::loop_log::RunDirState + inspect_run_dir + run_dir_started_at - hew_core::loop_summary::InFlight + render_in_flight - 6 + 4 unit tests, 4 e2e tests (loop_summary_in_flight_e2e.rs) --- hew-core/src/loop_log.rs | 152 ++++++++++++++++++++++++ hew-core/src/loop_summary.rs | 149 +++++++++++++++++++++++ hew/src/commands/loop_cmd.rs | 47 ++++++++ hew/tests/loop_summary_in_flight_e2e.rs | 93 +++++++++++++++ 4 files changed, 441 insertions(+) create mode 100644 hew/tests/loop_summary_in_flight_e2e.rs diff --git a/hew-core/src/loop_log.rs b/hew-core/src/loop_log.rs index b281fc6..0fe8011 100644 --- a/hew-core/src/loop_log.rs +++ b/hew-core/src/loop_log.rs @@ -305,6 +305,86 @@ pub fn write_manifest(run_dir: &Path, manifest: &Manifest) -> Result<()> { write_json_atomic(&manifest_path(run_dir), manifest) } +/// Classification of a `` for `hew loop summary`. Lets the +/// command branch cleanly between the completed-run path (read +/// `run.json`) and the various in-flight states that all manifest as +/// "no `run.json` yet" — the case that used to surface the raw +/// `No such file or directory` error. +/// +/// Detection rules (in order): +/// +/// 1. `manifest.json` at root → [`Completed`] (parallel shutdown wrote +/// it). The top-level `run.json` may or may not exist; the manifest +/// is the source of truth for the aggregate view. +/// 2. `run.json` at root → [`Completed`] (serial path; also written +/// after every iter, so this catches "iter 1 finished, no stop +/// yet"). +/// 3. One or more `worker-/` subdirs → [`ParallelInFlight`]. The +/// dispatcher mkdir's these before iter 1 starts, so they signal a +/// parallel run that hasn't reached shutdown yet (no manifest). +/// 4. One or more `iter-NNN.json` at root → [`SerialIterLogsOnly`] +/// (rare race: an iter completed but the follow-up `run.json` write +/// raced or was killed mid-write). +/// 5. Otherwise → [`EmptyStart`] (run-dir exists, dispatcher just laid +/// it down, iter 1 hasn't finished). +/// +/// [`Completed`]: RunDirState::Completed +/// [`ParallelInFlight`]: RunDirState::ParallelInFlight +/// [`SerialIterLogsOnly`]: RunDirState::SerialIterLogsOnly +/// [`EmptyStart`]: RunDirState::EmptyStart +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RunDirState { + /// `run.json` or `manifest.json` is on disk — defer to today's + /// completed-run summary code path. + Completed, + /// `worker-/` subdirs exist but no top-level `manifest.json`. + /// `worker_count` is the number of slots the dispatcher claimed. + ParallelInFlight { worker_count: u32 }, + /// `iter-NNN.json` present at the run-dir root but no `run.json` — + /// either iter 1 just finished and run.json hasn't been written, or + /// it was lost. `iter_count` is the number of iter logs found. + SerialIterLogsOnly { iter_count: u32 }, + /// Run-dir is empty of any state — iter 1 hasn't completed yet. + EmptyStart, +} + +/// Inspect `run_dir` and classify its state. Caller is expected to have +/// verified `run_dir.exists()` first; this function does not distinguish +/// "missing dir" from "empty dir" — both look like [`EmptyStart`]. +pub fn inspect_run_dir(run_dir: &Path) -> RunDirState { + if manifest_path(run_dir).exists() || run_log_path(run_dir, None).exists() { + return RunDirState::Completed; + } + let mut worker_count: u32 = 0; + let mut iter_count: u32 = 0; + if let Ok(it) = fs::read_dir(run_dir) { + for entry in it.flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|s| s.to_str()) else { continue }; + if path.is_dir() && name.starts_with("worker-") { + worker_count += 1; + } else if path.is_file() && name.starts_with("iter-") && name.ends_with(".json") { + iter_count += 1; + } + } + } + if worker_count > 0 { + return RunDirState::ParallelInFlight { worker_count }; + } + if iter_count > 0 { + return RunDirState::SerialIterLogsOnly { iter_count }; + } + RunDirState::EmptyStart +} + +/// Best-effort "when did this run start" timestamp, sourced from the +/// run-dir's mtime. Returned as a `SystemTime` so callers can format +/// against `SystemTime::now()` for a relative-age string. Returns +/// `None` if the dir's metadata isn't readable. +pub fn run_dir_started_at(run_dir: &Path) -> Option { + fs::metadata(run_dir).and_then(|m| m.modified()).ok() +} + /// Run-ids under `loop_root` (`/.hew/loop/`) whose `run.json` /// reports no `stop_reason` yet — i.e. the run never reached a clean /// shutdown. @@ -761,6 +841,78 @@ mod tests { assert!(json.contains("\"scope\"")); } + #[test] + fn inspect_run_dir_returns_empty_start_when_dir_is_bare() { + let root = tmpdir(); + let dir = run_dir(&root, "loop-bare").unwrap(); + assert_eq!(inspect_run_dir(&dir), RunDirState::EmptyStart); + } + + #[test] + fn inspect_run_dir_returns_completed_when_run_json_present() { + let root = tmpdir(); + let dir = run_dir(&root, "loop-done").unwrap(); + let run = Run::new("loop-done", "2026-05-30T00:00:00Z", RunConfig::default()); + write_json_atomic(&run_log_path(&dir, None), &RunLog::from_run(&run)).unwrap(); + assert_eq!(inspect_run_dir(&dir), RunDirState::Completed); + } + + #[test] + fn inspect_run_dir_returns_completed_when_manifest_present() { + let root = tmpdir(); + let dir = run_dir(&root, "loop-mf").unwrap(); + let manifest = Manifest { + run_id: "loop-mf".into(), + jobs: 1, + started_at: "t0".into(), + completed_at: "t1".into(), + workers: vec![], + }; + write_manifest(&dir, &manifest).unwrap(); + assert_eq!(inspect_run_dir(&dir), RunDirState::Completed); + } + + #[test] + fn inspect_run_dir_returns_parallel_in_flight_for_worker_subdirs() { + let root = tmpdir(); + let dir = run_dir(&root, "loop-par").unwrap(); + ensure_worker_dir(&dir, 0).unwrap(); + ensure_worker_dir(&dir, 1).unwrap(); + assert_eq!(inspect_run_dir(&dir), RunDirState::ParallelInFlight { worker_count: 2 }); + } + + #[test] + fn inspect_run_dir_returns_iter_logs_only_when_iters_present_without_run_json() { + let root = tmpdir(); + let dir = run_dir(&root, "loop-iters").unwrap(); + let it = Iter::new(1, "2026-05-30T00:00:00Z"); + let log = IterLog::from_iter(&it, None, Vec::new(), Vec::new()); + write_json_atomic(&iter_log_path(&dir, None, 1), &log).unwrap(); + assert_eq!(inspect_run_dir(&dir), RunDirState::SerialIterLogsOnly { iter_count: 1 }); + } + + #[test] + fn inspect_run_dir_prefers_completed_over_iter_logs() { + // Real serial run: run.json + iter-NNN.json coexist. Completed wins. + let root = tmpdir(); + let dir = run_dir(&root, "loop-mix").unwrap(); + let run = Run::new("loop-mix", "t0", RunConfig::default()); + write_json_atomic(&run_log_path(&dir, None), &RunLog::from_run(&run)).unwrap(); + let it = Iter::new(1, "t0"); + let log = IterLog::from_iter(&it, None, Vec::new(), Vec::new()); + write_json_atomic(&iter_log_path(&dir, None, 1), &log).unwrap(); + assert_eq!(inspect_run_dir(&dir), RunDirState::Completed); + } + + #[test] + fn run_dir_started_at_returns_modified_time() { + let root = tmpdir(); + let dir = run_dir(&root, "loop-mtime").unwrap(); + let t = run_dir_started_at(&dir).expect("mtime readable"); + // Sanity check: mtime is no later than now. + assert!(t <= SystemTime::now()); + } + #[test] fn stop_reason_label_covers_all_variants() { for r in [ diff --git a/hew-core/src/loop_summary.rs b/hew-core/src/loop_summary.rs index 870cba0..894200f 100644 --- a/hew-core/src/loop_summary.rs +++ b/hew-core/src/loop_summary.rs @@ -511,6 +511,86 @@ pub fn render_parallel_breakdown(slices: &[WorkerSlice], colorize: bool) -> Stri s } +/// Inputs the in-flight renderer needs. All fields are best-effort: +/// the run is by definition mid-flight so most of the post-run state +/// (stop reason, totals, manifest aggregate) doesn't yet exist on +/// disk. The renderer prints whatever's available and elides the rest. +#[derive(Clone, Debug)] +pub struct InFlight<'a> { + pub run_id: &'a str, + /// Wall-clock seconds since the run-dir was created (mtime). The + /// caller derives this from `loop_log::run_dir_started_at` so the + /// renderer stays pure. + pub age_secs: Option, + /// `Some(n)` for a parallel run with `n` worker subdirs already on + /// disk; `None` for the serial path. + pub worker_count: Option, + /// Iter logs already on disk (typically empty in the early-iter-0 + /// case; non-empty when an iter completed but `run.json` is + /// missing). Order is iter-number ascending. + pub iter_logs: &'a [IterLog], + /// Absolute or relative `` path to surface to the operator. + pub logs_path: &'a str, +} + +/// Render a degraded summary for a run that has no `run.json` yet. +/// Mirrors [`render`]'s banner + bullet style so the operator can read +/// either output without context-switching. +pub fn render_in_flight(in_flight: &InFlight<'_>, colorize: bool) -> String { + use std::fmt::Write; + let mut s = String::new(); + let mag = if colorize { "\x1b[1;35m" } else { "" }; + let dim = if colorize { "\x1b[2m" } else { "" }; + let bold = if colorize { "\x1b[1m" } else { "" }; + let yellow = if colorize { "\x1b[33m" } else { "" }; + let reset = if colorize { "\x1b[0m" } else { "" }; + + let _ = writeln!(s, "{mag} | |_ ___ __ __ __{reset}"); + let _ = writeln!(s, "{mag} | ' \\ / -_) \\ V V / {reset}{dim}loop summary (in flight){reset}"); + let _ = writeln!(s, "{mag} |_||_|\\___| \\_/\\_/{reset}"); + let _ = writeln!(s); + + let age = in_flight.age_secs.map(format_duration).unwrap_or_else(|| "?".into()); + let iter_count = in_flight.iter_logs.len() as u32; + let next_iter = iter_count + 1; + let header = match in_flight.worker_count { + Some(n) if n >= 2 => { + format!("iter {next_iter} in flight across {n} workers {dim}(started {age} ago){reset}") + } + _ => format!( + "iter {next_iter} in flight {dim}(started {age} ago, {iter_count} completed iter{}){reset}", + if iter_count == 1 { "" } else { "s" }, + ), + }; + let _ = writeln!(s, " {bold}run-id{reset}: {} {yellow}{header}{reset}", in_flight.run_id); + + if let Some(n) = in_flight.worker_count { + let workers: Vec = (0..n).map(|i| format!("worker-{i} (running)")).collect(); + let _ = writeln!(s, " {bold}workers{reset}: {}", workers.join(", ")); + let _ = writeln!(s, " {bold}jobs{reset}: {n}"); + } else { + let _ = writeln!(s, " {bold}jobs{reset}: 1"); + } + + if !in_flight.iter_logs.is_empty() { + let total: u64 = in_flight.iter_logs.iter().map(|l| l.cost.total()).sum(); + let _ = writeln!( + s, + " {bold}partial{reset}: {} token{} across {iter_count} iter{}", + fmt_int(total), + if total == 1 { "" } else { "s" }, + if iter_count == 1 { "" } else { "s" }, + ); + } + + let _ = writeln!(s, " {bold}logs{reset}: {}", in_flight.logs_path); + let _ = writeln!( + s, + " {dim}note{reset}: run.json not yet written — re-run `hew loop summary` after iter 1 completes", + ); + s +} + /// 8-block Unicode sparkline scaled to the max value in the slice. /// Empty slice → empty string. All-zero slice → all-`▁`. fn sparkline(values: &[u64]) -> String { @@ -1132,6 +1212,75 @@ mod tests { assert!(txt.contains("no command resolved")); } + #[test] + fn render_in_flight_serial_empty_start() { + let in_flight = InFlight { + run_id: "loop-x", + age_secs: Some(78), + worker_count: None, + iter_logs: &[], + logs_path: "/p/.hew/loop/loop-x", + }; + let txt = render_in_flight(&in_flight, false); + assert!(txt.contains("loop-x"), "must include run-id: {txt}"); + assert!(txt.contains("in flight"), "must say in flight: {txt}"); + assert!(txt.contains("1m 18s"), "must format age: {txt}"); + assert!(txt.contains("0 completed iters"), "must report no iters: {txt}"); + assert!(txt.contains("jobs: 1"), "must say jobs=1: {txt}"); + assert!(txt.contains("/p/.hew/loop/loop-x"), "must surface logs path: {txt}"); + assert!(!txt.contains("No such file"), "must not leak ENOENT: {txt}"); + } + + #[test] + fn render_in_flight_parallel_lists_workers() { + let in_flight = InFlight { + run_id: "loop-par", + age_secs: Some(32), + worker_count: Some(2), + iter_logs: &[], + logs_path: "/p/.hew/loop/loop-par", + }; + let txt = render_in_flight(&in_flight, false); + assert!(txt.contains("across 2 workers"), "header mentions worker count: {txt}"); + assert!(txt.contains("worker-0 (running)"), "lists worker-0: {txt}"); + assert!(txt.contains("worker-1 (running)"), "lists worker-1: {txt}"); + assert!(txt.contains("jobs: 2")); + } + + #[test] + fn render_in_flight_with_partial_iters_shows_tokens() { + let logs = vec![iter_log( + 1, + "closed", + Some("h1"), + TokenSpend { input: 100, output: 50, cache_read: 0, cache_create: 0 }, + )]; + let in_flight = InFlight { + run_id: "loop-mix", + age_secs: Some(10), + worker_count: None, + iter_logs: &logs, + logs_path: "/p", + }; + let txt = render_in_flight(&in_flight, false); + assert!(txt.contains("partial:"), "must include partial line: {txt}"); + assert!(txt.contains("150 token"), "must total per-iter tokens: {txt}"); + assert!(txt.contains("1 completed iter"), "must report completed count: {txt}"); + } + + #[test] + fn render_in_flight_strips_ansi_when_colorize_false() { + let in_flight = InFlight { + run_id: "loop-x", + age_secs: Some(5), + worker_count: None, + iter_logs: &[], + logs_path: "/p", + }; + let txt = render_in_flight(&in_flight, false); + assert!(!txt.contains('\x1b'), "no ANSI escapes when colorize=false: {txt:?}"); + } + #[test] fn render_strips_ansi_when_colorize_false() { let logs = vec![iter_log(1, "closed", Some("h1"), TokenSpend::default())]; diff --git a/hew/src/commands/loop_cmd.rs b/hew/src/commands/loop_cmd.rs index 0b833ec..2c6a935 100644 --- a/hew/src/commands/loop_cmd.rs +++ b/hew/src/commands/loop_cmd.rs @@ -2027,6 +2027,14 @@ pub fn run_summary(ctx: &Ctx, args: SummaryArgs) -> miette::Result<()> { return run_summary_parallel(ctx, &dir, &manifest_path); } + // No manifest. Branch on what we have on disk: a real run.json + // (today's path), or one of the in-flight states (degraded view + // instead of a raw ENOENT on `read run.json`). + let state = hew_core::loop_log::inspect_run_dir(&dir); + if !matches!(state, hew_core::loop_log::RunDirState::Completed) { + return render_in_flight_summary(ctx, &dir, &run_id, &state); + } + // Load the persisted run header for id + stop reason. let rl_body = std::fs::read_to_string(run_log_path(&dir, None)) .map_err(|e| miette::miette!("read run.json: {e}"))?; @@ -2072,6 +2080,45 @@ fn collect_worker_iter_logs(run_dir: &Path, worker_n: u32) -> Vec { collect_iter_logs(&dir).unwrap_or_default() } +/// Render a degraded summary for an in-flight run (no `run.json` yet). +/// Branches on the run-dir's classification so the operator sees a +/// useful state report instead of the raw "No such file or directory" +/// that surfaced before. Always returns `Ok(())` — an in-flight run is +/// not a failure. +fn render_in_flight_summary( + ctx: &Ctx, + dir: &Path, + run_id: &str, + state: &hew_core::loop_log::RunDirState, +) -> miette::Result<()> { + if ctx.quiet { + return Ok(()); + } + let started = hew_core::loop_log::run_dir_started_at(dir); + let age_secs = started + .and_then(|t| std::time::SystemTime::now().duration_since(t).ok()) + .map(|d| d.as_secs() as i64); + let worker_count = match state { + hew_core::loop_log::RunDirState::ParallelInFlight { worker_count } => Some(*worker_count), + _ => None, + }; + // Pull whatever iter logs may have landed (the SerialIterLogsOnly + // case races run.json — every other state has none). Empty vec on + // any read error so the in-flight view still renders. + let iter_logs = collect_iter_logs(dir).unwrap_or_default(); + let logs_path = dir.display().to_string(); + let in_flight = hew_core::loop_summary::InFlight { + run_id, + age_secs, + worker_count, + iter_logs: &iter_logs, + logs_path: &logs_path, + }; + let colorize = std::env::var_os("NO_COLOR").is_none(); + print!("{}", hew_core::loop_summary::render_in_flight(&in_flight, colorize)); + Ok(()) +} + fn run_summary_parallel(ctx: &Ctx, dir: &Path, manifest_path: &Path) -> miette::Result<()> { let body = std::fs::read_to_string(manifest_path) .map_err(|e| miette::miette!("read manifest.json: {e}"))?; diff --git a/hew/tests/loop_summary_in_flight_e2e.rs b/hew/tests/loop_summary_in_flight_e2e.rs new file mode 100644 index 0000000..9c0f174 --- /dev/null +++ b/hew/tests/loop_summary_in_flight_e2e.rs @@ -0,0 +1,93 @@ +//! `hew loop summary` end-to-end coverage for the in-flight states. +//! +//! Plants a run-dir on disk WITHOUT a `run.json` (mirroring the +//! window between dispatcher start and end of iter 1) and asserts the +//! command renders the degraded "in flight" view instead of erroring +//! on `read run.json: No such file or directory`. +//! +//! Task: hew-cn2y. + +use std::path::Path; + +use assert_cmd::Command as AssertCmd; +use hew_core::loop_log::{IterLog, ensure_worker_dir, iter_log_path, run_dir, write_json_atomic}; +use hew_core::runner::{Iter, IterOutcome, TokenSpend}; +use predicates::prelude::PredicateBooleanExt; +use predicates::str::contains; + +fn hew_in(repo: &Path) -> AssertCmd { + let mut c = AssertCmd::cargo_bin("hew").unwrap(); + c.current_dir(repo); + c.env("NO_COLOR", "1"); + c.env("TERM", "dumb"); + c.env("HEW_NO_UPDATE_CHECK", "1"); + c.env("HEW_NON_INTERACTIVE", "1"); + c.env_remove("HEW_LOG"); + c.env_remove("CI"); + c +} + +#[test] +fn summary_renders_in_flight_view_when_run_dir_is_empty() { + // Serial path: run-dir exists, no run.json, no iter logs. + let repo = tempfile::tempdir().unwrap(); + let _ = run_dir(repo.path(), "loop-empty-start").unwrap(); + + hew_in(repo.path()) + .args(["loop", "summary", "--run-id", "loop-empty-start"]) + .assert() + .success() + .stdout(contains("loop-empty-start").and(contains("in flight"))) + .stdout(contains("No such file").not()) + .stderr(contains("No such file").not()); +} + +#[test] +fn summary_renders_parallel_in_flight_view_when_worker_dirs_present() { + let repo = tempfile::tempdir().unwrap(); + let dir = run_dir(repo.path(), "loop-par-inflight").unwrap(); + ensure_worker_dir(&dir, 0).unwrap(); + ensure_worker_dir(&dir, 1).unwrap(); + + hew_in(repo.path()) + .args(["loop", "summary", "--run-id", "loop-par-inflight"]) + .assert() + .success() + .stdout(contains("across 2 workers")) + .stdout(contains("worker-0 (running)")) + .stdout(contains("worker-1 (running)")) + .stdout(contains("No such file").not()); +} + +#[test] +fn summary_renders_partial_iters_when_run_json_missing_but_iters_present() { + let repo = tempfile::tempdir().unwrap(); + let dir = run_dir(repo.path(), "loop-iters-only").unwrap(); + let mut it = Iter::new(1, "2026-05-30T00:00:00Z"); + it.outcome = Some(IterOutcome::Closed); + it.cost = TokenSpend { input: 200, output: 100, cache_read: 0, cache_create: 0 }; + let log = IterLog::from_iter(&it, None, Vec::new(), Vec::new()); + write_json_atomic(&iter_log_path(&dir, None, 1), &log).unwrap(); + + hew_in(repo.path()) + .args(["loop", "summary", "--run-id", "loop-iters-only"]) + .assert() + .success() + .stdout(contains("in flight")) + .stdout(contains("partial:")) + .stdout(contains("300 token")); +} + +#[test] +fn summary_errors_when_run_id_truly_missing() { + // Genuinely missing run-dir must still surface a clear error — the + // in-flight path only applies when the dir exists but has no run.json. + let repo = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(repo.path().join(".hew/loop")).unwrap(); + + hew_in(repo.path()) + .args(["loop", "summary", "--run-id", "loop-does-not-exist"]) + .assert() + .failure() + .stderr(contains("run-dir not found")); +}