diff --git a/.claude/skills/gov/SKILL.md b/.claude/skills/gov/SKILL.md index 5b318239..9356443b 100644 --- a/.claude/skills/gov/SKILL.md +++ b/.claude/skills/gov/SKILL.md @@ -94,12 +94,12 @@ The active work item is durable outcome context. Read it with `govctl work show ### Loop usage -Use a loop only when a task has multiple independently meaningful work items. A loop coordinates durable work; it is not permission to split one cleanup/refactor into mechanical work-item fragments. +Use a loop for non-trivial governed execution that needs local execution memory. This includes single-Work-Item work now that transient journal-style execution trace belongs in loop state and round artifacts. Multi-Work-Item loops add dependency and batch coordination; they are not permission to split one cleanup/refactor into mechanical work-item fragments. 1. Create or activate only the work items that represent durable outcomes a future reader should see. 2. Add `depends_on` edges for hard execution ordering. 3. Run `govctl check` so dependency cycles or missing work item IDs are caught before the loop starts. -4. Start one loop for the batch root set with `govctl loop start [...]`; let govctl generate the `LOOP-YYYY-MM-DD-NNN` ID. +4. Start one loop for the root set with `govctl loop start [...]`; let govctl generate the `LOOP-YYYY-MM-DD-NNN` ID. 5. Run `govctl loop run ` to open a local round for ready work. 6. Perform implementation, verification, and any explicit `govctl work move` commands yourself. 7. Fill the opened `.govctl/loops//rounds/round-NNN.toml` summary evidence. @@ -117,7 +117,7 @@ If the scope changes during execution, keep the same loop identity: `work` is the editable loop work-item field. `wi` is accepted as a short alias, but examples should prefer `work`. -Do not create scattered single-item loops for work that is part of one coherent batch. +Do not create multiple scattered loops for work that belongs in one coherent execution session. Do not create separate work items for low-level implementation slices such as helper moves, test fixture sharing, module normalization, comment cleanup, snapshot reshaping, or other changes whose only durable record should be the commit diff or one higher-level work item. @@ -145,7 +145,7 @@ govctl work list pending - Matching active item: use it - Matching queued item: `govctl work move active` -- No match and the task has one durable outcome: `govctl work new --active ""` +- No match and the task has one durable outcome: `govctl work new --active ""`, then start a loop if the work is non-trivial - No match and the task has multiple independently reviewable durable outcomes: create that small batch first, wire only hard `depends_on` edges, then start one generated-ID loop for the batch. - No match and the apparent split is only mechanical implementation steps: create at most one coarse work item, or route trivial cleanup to `/quick`. diff --git a/.claude/skills/wi-writer/SKILL.md b/.claude/skills/wi-writer/SKILL.md index 5cc2430d..d2a839a9 100644 --- a/.claude/skills/wi-writer/SKILL.md +++ b/.claude/skills/wi-writer/SKILL.md @@ -126,7 +126,8 @@ Create multiple work items only when each item is independently meaningful to fu - For trivial cleanup or docs-only edits, no work item may be the right answer; follow the invoking workflow instead of inventing tracking. - For one coherent cleanup/refactor, prefer one coarse work item over many narrow slices. - Use `depends_on` only for hard execution ordering; keep `refs` for informational links. -- Use one batch loop only when there are multiple durable work items; do not use loops to justify creating mechanical work-item fragments. +- Use a loop for non-trivial governed execution that needs local execution memory, including single-work-item execution after journal-style trace moved out of Work Item fields. +- Use one multi-work-item loop only when there are multiple durable work items; do not use loops to justify creating mechanical work-item fragments. - Let govctl generate the loop ID with `govctl loop start [...]`; use the returned `LOOP-YYYY-MM-DD-NNN` ID for later loop commands. - Use `govctl loop list open` to discover existing non-terminal loops before resuming interrupted batch work. diff --git a/CHANGELOG.md b/CHANGELOG.md index 39ccc37e..d38e2fa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,21 @@ Release entries are curated summaries for readers. Work item traceability remain ## [Unreleased] +### Added + +- loop list and show expose stale loop plans without mutating local state (WI-2026-06-09-001) + ### Changed - Writer skills include matching authority tests and examples so authors avoid boundary drift before review (WI-2026-06-07-005) - Reviewer agents include explicit boundary probes and boundary finding output for RFC, ADR, and Work Item reviews (WI-2026-06-07-005) +- init-skills installs full skill directory bundles, including bundled references and assets (WI-2026-06-08-002) + +### Fixed + +- cargo-binstall Windows override resolves to govctl-v{ version }-{ target }.zip (WI-2026-06-08-001) +- cargo-binstall Unix pkg-url resolves to govctl-v{ version }-{ target }.tar.gz (WI-2026-06-08-001) +- loop run rejects stale stored dependency closures before opening new work (WI-2026-06-09-001) ## [0.9.3] - 2026-06-07 diff --git a/Cargo.toml b/Cargo.toml index e5d2b79a..3e7a04cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -99,12 +99,14 @@ insta = { version = "1", features = ["yaml"] } regex = "1" chrono = "0.4" +# [[ADR-0041]] governs package.metadata.binstall archive naming, including the Windows override in package.metadata.binstall.overrides.x86_64-pc-windows-msvc. [package.metadata.binstall] -pkg-url = "{ repo }/releases/download/v{ version }/govctl-v{ version }-{ target }.{ archive-format }" +pkg-url = "{ repo }/releases/download/v{ version }/govctl-v{ version }-{ target }.tar.gz" bin-dir = "govctl-v{ version }-{ target }/{ bin }{ binary-ext }" pkg-fmt = "tgz" [package.metadata.binstall.overrides.x86_64-pc-windows-msvc] +pkg-url = "{ repo }/releases/download/v{ version }/govctl-v{ version }-{ target }.zip" pkg-fmt = "zip" [lints.clippy] diff --git a/build.rs b/build.rs index 1117a3ac..b465fa56 100644 --- a/build.rs +++ b/build.rs @@ -11,36 +11,101 @@ use edit_ops_spec::{ }; use std::error::Error; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; + +// Project-local assets installed by `govctl init-skills`. +// Plugin/global-only skills such as `init` are intentionally omitted. [[RFC-0002:C-GLOBAL-COMMANDS]] +const DISTRIBUTED_SKILL_DIRS: &[&str] = &[ + "discuss", + "gov", + "quick", + "spec", + "rfc-writer", + "adr-writer", + "wi-writer", + "guard-writer", + "commit", + "migrate", + "decision-analysis", + "detach", +]; fn main() { // Recompile if any embedded .claude/ assets change - // Skills - println!("cargo:rerun-if-changed=.claude/skills/discuss/SKILL.md"); - println!("cargo:rerun-if-changed=.claude/skills/gov/SKILL.md"); - println!("cargo:rerun-if-changed=.claude/skills/quick/SKILL.md"); - println!("cargo:rerun-if-changed=.claude/skills/spec/SKILL.md"); - println!("cargo:rerun-if-changed=.claude/skills/rfc-writer/SKILL.md"); - println!("cargo:rerun-if-changed=.claude/skills/adr-writer/SKILL.md"); - println!("cargo:rerun-if-changed=.claude/skills/wi-writer/SKILL.md"); - println!("cargo:rerun-if-changed=.claude/skills/commit/SKILL.md"); - println!("cargo:rerun-if-changed=.claude/skills/migrate/SKILL.md"); - println!("cargo:rerun-if-changed=.claude/skills/decision-analysis/SKILL.md"); - println!("cargo:rerun-if-changed=.claude/skills/detach/SKILL.md"); - // Agents - println!("cargo:rerun-if-changed=.claude/agents/rfc-reviewer.md"); - println!("cargo:rerun-if-changed=.claude/agents/adr-reviewer.md"); - println!("cargo:rerun-if-changed=.claude/agents/wi-reviewer.md"); - println!("cargo:rerun-if-changed=.claude/agents/compliance-checker.md"); + println!("cargo:rerun-if-changed=.claude/skills"); + println!("cargo:rerun-if-changed=.claude/agents"); // Edit rules SSOT + schema (ADR-0030) println!("cargo:rerun-if-changed=gov/schema/edit-ops.schema.json"); println!("cargo:rerun-if-changed=gov/schema/edit-ops.json"); + generate_skill_assets().expect("failed to generate skill asset manifest"); generate_edit_rules().expect("failed to generate edit rules from SSOT"); generate_codex_agent_templates().expect("failed to generate codex agent templates"); } +fn generate_skill_assets() -> Result<(), Box> { + let skill_root = Path::new(".claude/skills"); + let mut files = Vec::new(); + for skill_dir in DISTRIBUTED_SKILL_DIRS { + collect_files(&skill_root.join(skill_dir), &mut files)?; + } + files.sort(); + + let mut out = String::new(); + out.push_str("// @generated by build.rs from distributed .claude/skills/* bundles\n"); + out.push_str("// Do not edit manually.\n\n"); + out.push_str("pub const SKILL_ASSETS: &[(&str, &str)] = &[\n"); + for file in files { + let skill_rel = file + .strip_prefix(skill_root) + .map_err(|e| format!("failed to relativize skill asset {}: {e}", file.display()))?; + let skill_rel = path_to_slash_string(skill_rel)?; + let output_rel = format!("skills/{skill_rel}"); + let source_rel = format!("/.claude/skills/{skill_rel}"); + out.push_str(&format!( + " ({:?}, include_str!(concat!(env!(\"CARGO_MANIFEST_DIR\"), {:?}))),\n", + output_rel, source_rel + )); + } + out.push_str("];\n"); + + let out_dir = std::env::var("OUT_DIR")?; + let out_path = Path::new(&out_dir).join("skill_assets.rs"); + fs::write(out_path, out)?; + Ok(()) +} + +fn collect_files(root: &Path, files: &mut Vec) -> Result<(), Box> { + for entry in + fs::read_dir(root).map_err(|e| format!("failed to read {}: {e}", root.display()))? + { + let entry = entry?; + let path = entry.path(); + let file_type = entry.file_type()?; + if file_type.is_dir() { + collect_files(&path, files)?; + } else if file_type.is_file() { + files.push(path); + } + } + Ok(()) +} + +fn path_to_slash_string(path: &Path) -> Result> { + let mut parts = Vec::new(); + for component in path.components() { + let component = component.as_os_str().to_str().ok_or_else(|| { + format!( + "skill asset path is not valid UTF-8: {}", + path.to_string_lossy() + ) + })?; + parts.push(component.to_string()); + } + Ok(parts.join("/")) +} + fn generate_edit_rules() -> Result<(), Box> { let spec_path = Path::new("gov/schema/edit-ops.json"); let schema_path = Path::new("gov/schema/edit-ops.schema.json"); diff --git a/docs/guide/recommended-workflows.md b/docs/guide/recommended-workflows.md index a6008627..6fd5b964 100644 --- a/docs/guide/recommended-workflows.md +++ b/docs/guide/recommended-workflows.md @@ -120,8 +120,8 @@ Moving to `done` runs verification guards when verification is enabled. ## When To Use Loops -Use a loop when execution is bigger than one simple Work Item or when you need -resumable local round evidence. +Use a loop when non-trivial execution needs resumable local round evidence, +including single-Work-Item work. Good loop use cases: @@ -160,6 +160,10 @@ govctl loop remove LOOP-YYYY-MM-DD-NNN work WI-YYYY-MM-DD-002 govctl loop replan LOOP-YYYY-MM-DD-NNN ``` +If `loop show` or `loop list` reports a stale plan, the current Work Item +dependency closure differs from the stored loop plan. Run `govctl loop replan +LOOP-YYYY-MM-DD-NNN` before opening another round. + Loop state is local execution memory under `.govctl/loops/`. Work Items remain the durable task record. diff --git a/docs/rfc/RFC-0002.md b/docs/rfc/RFC-0002.md index 9b6e6b98..8f691079 100644 --- a/docs/rfc/RFC-0002.md +++ b/docs/rfc/RFC-0002.md @@ -1,9 +1,9 @@ - + # RFC-0002: CLI Resource Model and Command Architecture -> **Version:** 0.10.1 | **Status:** normative | **Phase:** test +> **Version:** 0.10.2 | **Status:** normative | **Phase:** test --- @@ -552,8 +552,9 @@ Syntax: `govctl init-skills [--force] [--format ] [--dir PATH]` Behavior: - `--dir` overrides the output directory for this invocation. Resolution order: `--dir` flag > `agent_dir` from config > format-implied default (`.claude` for claude, `.codex` for codex) - `--format` selects the output format for agent definitions (default `claude`): - - `claude`: skills as `skills/*/SKILL.md`, agents as `agents/*.md` with YAML frontmatter (compatible with Claude Code, Cursor, Windsurf, and similar editors) - - `codex`: skills as `skills/*/SKILL.md` (same format), agents as `agents/*.toml` with `developer_instructions` field (compatible with Codex CLI) + - `claude`: skills as full `skills/*/` bundles rooted at `SKILL.md`, agents as `agents/*.md` with YAML frontmatter (compatible with Claude Code, Cursor, Windsurf, and similar editors) + - `codex`: skills as full `skills/*/` bundles rooted at `SKILL.md` (same format), agents as `agents/*.toml` with `developer_instructions` field (compatible with Codex CLI) +- Skill bundle resources under directories such as `references/`, `assets/`, and `scripts/` MUST be installed with their parent skill; agent output remains format-specific - Skips files that already exist unless `--force` is used - Reports created/updated/skipped counts - This command is separate from `init` because plugin users receive skills globally and do not need local copies @@ -731,6 +732,14 @@ Search is discovery across the governance corpus, not a resource-specific CRUD o ## Changelog +### v0.10.2 (2026-06-08) + +Clarify init-skills skill bundle installation + +#### Changed + +- init-skills installs full skill bundles rooted at SKILL.md, including bundled references/assets/scripts + ### v0.10.1 (2026-06-04) Set search clause version metadata diff --git a/gov/rfc/RFC-0002/clauses/C-GLOBAL-COMMANDS.toml b/gov/rfc/RFC-0002/clauses/C-GLOBAL-COMMANDS.toml index 93be5663..56f7ca90 100644 --- a/gov/rfc/RFC-0002/clauses/C-GLOBAL-COMMANDS.toml +++ b/gov/rfc/RFC-0002/clauses/C-GLOBAL-COMMANDS.toml @@ -150,8 +150,9 @@ Syntax: `govctl init-skills [--force] [--format ] [--dir PATH]` Behavior: - `--dir` overrides the output directory for this invocation. Resolution order: `--dir` flag > `agent_dir` from config > format-implied default (`.claude` for claude, `.codex` for codex) - `--format` selects the output format for agent definitions (default `claude`): - - `claude`: skills as `skills/*/SKILL.md`, agents as `agents/*.md` with YAML frontmatter (compatible with Claude Code, Cursor, Windsurf, and similar editors) - - `codex`: skills as `skills/*/SKILL.md` (same format), agents as `agents/*.toml` with `developer_instructions` field (compatible with Codex CLI) + - `claude`: skills as full `skills/*/` bundles rooted at `SKILL.md`, agents as `agents/*.md` with YAML frontmatter (compatible with Claude Code, Cursor, Windsurf, and similar editors) + - `codex`: skills as full `skills/*/` bundles rooted at `SKILL.md` (same format), agents as `agents/*.toml` with `developer_instructions` field (compatible with Codex CLI) +- Skill bundle resources under directories such as `references/`, `assets/`, and `scripts/` MUST be installed with their parent skill; agent output remains format-specific - Skips files that already exist unless `--force` is used - Reports created/updated/skipped counts - This command is separate from `init` because plugin users receive skills globally and do not need local copies diff --git a/gov/rfc/RFC-0002/rfc.toml b/gov/rfc/RFC-0002/rfc.toml index e0ef9258..863c84c5 100644 --- a/gov/rfc/RFC-0002/rfc.toml +++ b/gov/rfc/RFC-0002/rfc.toml @@ -3,12 +3,12 @@ [govctl] id = "RFC-0002" title = "CLI Resource Model and Command Architecture" -version = "0.10.1" +version = "0.10.2" status = "normative" phase = "test" owners = ["@govctl-org"] created = "2026-01-19" -updated = "2026-06-04" +updated = "2026-06-08" tags = [ "cli", "editing", @@ -16,7 +16,7 @@ tags = [ "validation", "release", ] -signature = "681093335724498e211e93fb519c9e16f9990da7986a2f50db914b9e60e88339" +signature = "dd126a9195850f89dbb0ba90abee032ce072eb83017a465fd798276b9a7dbd3f" [[sections]] title = "Summary" @@ -36,6 +36,12 @@ clauses = [ "clauses/C-SEARCH-COMMAND.toml", ] +[[changelog]] +version = "0.10.2" +date = "2026-06-08" +notes = "Clarify init-skills skill bundle installation" +changed = ["init-skills installs full skill bundles rooted at SKILL.md, including bundled references/assets/scripts"] + [[changelog]] version = "0.10.1" date = "2026-06-04" diff --git a/gov/work/2026-06-08-fix-cargo-binstall-unix-asset-suffix.toml b/gov/work/2026-06-08-fix-cargo-binstall-unix-asset-suffix.toml new file mode 100644 index 00000000..74271834 --- /dev/null +++ b/gov/work/2026-06-08-fix-cargo-binstall-unix-asset-suffix.toml @@ -0,0 +1,31 @@ +#:schema ../schema/work.schema.json + +[govctl] +id = "WI-2026-06-08-001" +title = "Fix cargo-binstall Unix asset suffix" +status = "done" +created = "2026-06-08" +started = "2026-06-08" +completed = "2026-06-08" +refs = [ + "ADR-0041", + "RFC-0002", +] + +[content] +description = "Align cargo-binstall metadata with the actual GitHub Release asset names: Unix release archives are uploaded as .tar.gz files while cargo-binstall pkg-fmt remains tgz for extraction semantics, so the metadata must not expand to .tgz URLs." + +[[content.acceptance_criteria]] +text = "release metadata regression tests pass" +status = "done" +category = "chore" + +[[content.acceptance_criteria]] +text = "cargo-binstall Windows override resolves to govctl-v{ version }-{ target }.zip" +status = "done" +category = "fixed" + +[[content.acceptance_criteria]] +text = "cargo-binstall Unix pkg-url resolves to govctl-v{ version }-{ target }.tar.gz" +status = "done" +category = "fixed" diff --git a/gov/work/2026-06-08-support-bundled-skill-installation.toml b/gov/work/2026-06-08-support-bundled-skill-installation.toml new file mode 100644 index 00000000..15221569 --- /dev/null +++ b/gov/work/2026-06-08-support-bundled-skill-installation.toml @@ -0,0 +1,29 @@ +#:schema ../schema/work.schema.json + +[govctl] +id = "WI-2026-06-08-002" +title = "Support bundled skill installation" +status = "done" +created = "2026-06-08" +started = "2026-06-08" +completed = "2026-06-08" +refs = [ + "ADR-0024", + "ADR-0028", + "ADR-0035", + "RFC-0002", +] +tags = ["skills-agents"] + +[content] +description = "Support skill directory bundles so init-skills installs all files under explicitly distributed skill roots instead of relying on SKILL.md-only template entries." + +[[content.acceptance_criteria]] +text = "init-skills installs full skill directory bundles, including bundled references and assets" +status = "done" +category = "changed" + +[[content.acceptance_criteria]] +text = "govctl check and relevant tests pass" +status = "done" +category = "chore" diff --git a/gov/work/2026-06-09-detect-stale-loop-plans.toml b/gov/work/2026-06-09-detect-stale-loop-plans.toml new file mode 100644 index 00000000..1cf1074d --- /dev/null +++ b/gov/work/2026-06-09-detect-stale-loop-plans.toml @@ -0,0 +1,33 @@ +#:schema ../schema/work.schema.json + +[govctl] +id = "WI-2026-06-09-001" +title = "Detect stale loop plans" +status = "done" +created = "2026-06-09" +started = "2026-06-09" +completed = "2026-06-09" +refs = ["RFC-0006"] + +[content] +description = "Detect when persisted loop execution plans no longer match the current Work Item dependency closure, and guide users to replan without binding loops to VCS revisions." + +[[content.acceptance_criteria]] +text = "loop run rejects stale stored dependency closures before opening new work" +status = "done" +category = "fixed" + +[[content.acceptance_criteria]] +text = "loop list and show expose stale loop plans without mutating local state" +status = "done" +category = "added" + +[[content.acceptance_criteria]] +text = "regression tests cover stale dependency closure detection and replan repair" +status = "done" +category = "chore" + +[[content.acceptance_criteria]] +text = "govctl check passes" +status = "done" +category = "chore" diff --git a/src/cmd/loop_cmd/execution/mod.rs b/src/cmd/loop_cmd/execution/mod.rs index 53ee151b..a2a13784 100644 --- a/src/cmd/loop_cmd/execution/mod.rs +++ b/src/cmd/loop_cmd/execution/mod.rs @@ -1,6 +1,7 @@ use super::output::print_loop; use super::state::{ - ensure_loop_not_terminal, ensure_unique_work_item_ids, loop_dependencies, loop_item_state, + ensure_loop_not_terminal, ensure_loop_plan_fresh, ensure_unique_work_item_ids, + loop_dependencies, loop_item_state, }; use crate::cmd::work_lookup::load_work_item_by_id; use crate::config::Config; @@ -129,6 +130,9 @@ fn open_round( max_rounds: u32, op: WriteOp, ) -> DiagnosticResult { + // Implements [[RFC-0006:C-ROUND-EXECUTION]] by rejecting stale cached plans + // before selecting new round work from current Work Item dependencies. + ensure_loop_plan_fresh(config, state)?; reflect_terminal_work_statuses(config, state)?; propagate_blocked_outcomes(state)?; diff --git a/src/cmd/loop_cmd/mod.rs b/src/cmd/loop_cmd/mod.rs index 721deb4c..75982e80 100644 --- a/src/cmd/loop_cmd/mod.rs +++ b/src/cmd/loop_cmd/mod.rs @@ -16,10 +16,10 @@ use crate::loop_state::{ LoopLifecycleState, LoopState, load_loop_state, validate_loop_id, write_loop_state_with_op, }; use crate::write::WriteOp; -use output::{LoopListEntry, print_loop, print_loop_list}; +use output::{LoopListEntry, print_loop, print_loop_list, print_loop_with_plan}; use state::{ canonical_loop_ids, ensure_loop_not_terminal, ensure_work_values, find_reusable_loop, - generated_loop_id, + generated_loop_id, loop_plan_status, loop_plan_status_from_work_items, }; pub fn start( @@ -54,7 +54,8 @@ pub fn start( pub fn show(config: &Config, loop_id: &str) -> DiagnosticResult { let state = load_loop_state(config, loop_id)?; - print_loop("Loop", &state)?; + let plan_status = loop_plan_status(config, &state); + print_loop_with_plan("Loop", &state, plan_status.as_str())?; Ok(vec![]) } @@ -74,9 +75,17 @@ pub fn list( if let Some(limit) = limit { states.truncate(limit); } + let all_work_items = crate::parse::load_work_items(config).ok(); let entries = states .iter() - .map(LoopListEntry::from_state) + .map(|state| { + let plan_status = all_work_items + .as_deref() + .map_or(state::LoopPlanStatus::Stale, |work_items| { + loop_plan_status_from_work_items(state, work_items) + }); + LoopListEntry::from_state(state, plan_status.as_str()) + }) .collect::>(); print_loop_list(&entries, output); Ok(vec![]) diff --git a/src/cmd/loop_cmd/output.rs b/src/cmd/loop_cmd/output.rs index 182472f2..bee8130e 100644 --- a/src/cmd/loop_cmd/output.rs +++ b/src/cmd/loop_cmd/output.rs @@ -11,6 +11,7 @@ use serde::Serialize; pub(super) struct LoopListEntry { id: String, state: String, + plan: String, work: Vec, items: usize, rounds: u32, @@ -18,10 +19,11 @@ pub(super) struct LoopListEntry { } impl LoopListEntry { - pub(super) fn from_state(state: &LoopState) -> Self { + pub(super) fn from_state(state: &LoopState, plan: &str) -> Self { Self { id: state.loop_meta.id.clone(), state: state.loop_meta.state.as_str().to_string(), + plan: plan.to_string(), work: state.loop_meta.work.clone(), items: state.loop_meta.resolved.len(), rounds: state.items.values().map(|item| item.round_count).sum(), @@ -42,9 +44,10 @@ pub(super) fn print_loop_list(entries: &[LoopListEntry], output: OutputFormat) { OutputFormat::Plain => { for entry in entries { println!( - "{}\t{}\t{}\t{}\t{}\t{}", + "{}\t{}\t{}\t{}\t{}\t{}\t{}", entry.id, entry.state, + entry.plan, entry.work_display(), entry.items, entry.rounds, @@ -53,12 +56,14 @@ pub(super) fn print_loop_list(entries: &[LoopListEntry], output: OutputFormat) { } } OutputFormat::Table => { - let mut table = - table_with_bold_headers(&["ID", "State", "Work", "Items", "Rounds", "Action"]); + let mut table = table_with_bold_headers(&[ + "ID", "State", "Plan", "Work", "Items", "Rounds", "Action", + ]); for entry in entries { table.add_row(vec![ Cell::new(&entry.id), Cell::new(&entry.state), + Cell::new(&entry.plan), Cell::new(entry.work_display()), Cell::new(entry.items.to_string()), Cell::new(entry.rounds.to_string()), @@ -71,12 +76,27 @@ pub(super) fn print_loop_list(entries: &[LoopListEntry], output: OutputFormat) { } pub(super) fn print_loop(verb: &str, state: &LoopState) -> DiagnosticResult<()> { + print_loop_inner(verb, state, None) +} + +pub(super) fn print_loop_with_plan( + verb: &str, + state: &LoopState, + plan: &str, +) -> DiagnosticResult<()> { + print_loop_inner(verb, state, Some(plan)) +} + +fn print_loop_inner(verb: &str, state: &LoopState, plan: Option<&str>) -> DiagnosticResult<()> { if verb == "Loop" { println!("Loop {}", state.loop_meta.id); } else { println!("{} loop {}", verb, state.loop_meta.id); } println!("State: {}", state.loop_meta.state.as_str()); + if let Some(plan) = plan { + println!("Plan status: {plan}"); + } println!("Current round: {}", state.loop_meta.current_round); println!("Next action: {}", state.loop_meta.next_action.as_str()); println!("Work: {}", state.loop_meta.work.join(", ")); diff --git a/src/cmd/loop_cmd/state.rs b/src/cmd/loop_cmd/state.rs index cf8d1dcf..238e9ee0 100644 --- a/src/cmd/loop_cmd/state.rs +++ b/src/cmd/loop_cmd/state.rs @@ -1,9 +1,11 @@ use crate::config::Config; use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticResult}; +use crate::loop_planner::replan_loop_state; use crate::loop_state::{ LoopItemState, LoopLifecycleState, LoopState, load_loop_state, loop_state_path, loop_state_root, validate_loop_id, }; +use crate::model::WorkItemEntry; use std::collections::BTreeSet; pub(super) fn find_reusable_loop( @@ -188,6 +190,86 @@ pub(super) fn ensure_loop_not_terminal(state: &LoopState, action: &str) -> Diagn Ok(()) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum LoopPlanStatus { + Fresh, + Stale, +} + +impl LoopPlanStatus { + pub(super) fn as_str(self) -> &'static str { + match self { + Self::Fresh => "fresh", + Self::Stale => "stale", + } + } +} + +pub(super) fn loop_plan_status(config: &Config, state: &LoopState) -> LoopPlanStatus { + if current_plan_matches_state(config, state).unwrap_or(false) { + LoopPlanStatus::Fresh + } else { + LoopPlanStatus::Stale + } +} + +pub(super) fn loop_plan_status_from_work_items( + state: &LoopState, + all_work_items: &[WorkItemEntry], +) -> LoopPlanStatus { + if current_plan_matches_work_items(state, all_work_items).unwrap_or(false) { + LoopPlanStatus::Fresh + } else { + LoopPlanStatus::Stale + } +} + +pub(super) fn ensure_loop_plan_fresh(config: &Config, state: &LoopState) -> DiagnosticResult<()> { + match current_plan_matches_state(config, state) { + Ok(true) => Ok(()), + Ok(false) => Err(stale_loop_plan_diagnostic( + state, + "stored dependency closure no longer matches current Work Item files", + )), + Err(err) => Err(stale_loop_plan_diagnostic( + state, + format!( + "current Work Item dependency closure cannot be resolved ({})", + err.message + ), + )), + } +} + +// Implements [[RFC-0006:C-DEPENDENCY-SEMANTICS]] and +// [[RFC-0006:C-LOOP-SCOPE-MUTATION]] by treating stored scope as a cached plan. +fn current_plan_matches_state(config: &Config, state: &LoopState) -> DiagnosticResult { + let all_work_items = crate::parse::load_work_items(config)?; + current_plan_matches_work_items(state, &all_work_items) +} + +fn current_plan_matches_work_items( + state: &LoopState, + all_work_items: &[WorkItemEntry], +) -> DiagnosticResult { + let plan = replan_loop_state(state, &state.loop_meta.work, all_work_items)?; + Ok(plan.state.loop_meta.resolved == state.loop_meta.resolved + && plan.state.dependencies == state.dependencies) +} + +fn stale_loop_plan_diagnostic(state: &LoopState, reason: impl AsRef) -> Diagnostic { + Diagnostic::new( + DiagnosticCode::E1201LoopStateInvalid, + format!( + "Loop '{}' is stale: {}. Run `govctl loop replan {}` before opening another round.", + state.loop_meta.id, + reason.as_ref(), + state.loop_meta.id + ), + state.loop_meta.id.clone(), + ) +} + pub(super) fn generated_loop_id(config: &Config) -> DiagnosticResult { let date = chrono::Local::now().format("%Y-%m-%d").to_string(); generated_loop_id_for_date(config, &date) diff --git a/src/cmd/new/skills.rs b/src/cmd/new/skills.rs index 93d970fb..ebf86442 100644 --- a/src/cmd/new/skills.rs +++ b/src/cmd/new/skills.rs @@ -5,59 +5,9 @@ use crate::diagnostic::{DiagnosticResult, Diagnostics}; use crate::ui; use crate::write::{WriteOp, create_dir_all, write_file}; -/// Skill templates: (relative_path, content) pairs. -/// Source of truth: .claude/skills/; embedded at compile time. -/// Per ADR-0028, all workflow commands are now skills for cross-platform compatibility. -const SKILL_TEMPLATES: &[(&str, &str)] = &[ - ( - "skills/discuss/SKILL.md", - include_str!("../../../.claude/skills/discuss/SKILL.md"), - ), - ( - "skills/gov/SKILL.md", - include_str!("../../../.claude/skills/gov/SKILL.md"), - ), - ( - "skills/quick/SKILL.md", - include_str!("../../../.claude/skills/quick/SKILL.md"), - ), - ( - "skills/spec/SKILL.md", - include_str!("../../../.claude/skills/spec/SKILL.md"), - ), - ( - "skills/rfc-writer/SKILL.md", - include_str!("../../../.claude/skills/rfc-writer/SKILL.md"), - ), - ( - "skills/adr-writer/SKILL.md", - include_str!("../../../.claude/skills/adr-writer/SKILL.md"), - ), - ( - "skills/wi-writer/SKILL.md", - include_str!("../../../.claude/skills/wi-writer/SKILL.md"), - ), - ( - "skills/guard-writer/SKILL.md", - include_str!("../../../.claude/skills/guard-writer/SKILL.md"), - ), - ( - "skills/commit/SKILL.md", - include_str!("../../../.claude/skills/commit/SKILL.md"), - ), - ( - "skills/migrate/SKILL.md", - include_str!("../../../.claude/skills/migrate/SKILL.md"), - ), - ( - "skills/decision-analysis/SKILL.md", - include_str!("../../../.claude/skills/decision-analysis/SKILL.md"), - ), - ( - "skills/detach/SKILL.md", - include_str!("../../../.claude/skills/detach/SKILL.md"), - ), -]; +// Skill bundle assets are generated recursively from .claude/skills/** by build.rs. +// Implements [[RFC-0002:C-GLOBAL-COMMANDS]] and [[ADR-0028]]. +include!(concat!(env!("OUT_DIR"), "/skill_assets.rs")); /// Agent templates: (relative_path, content) pairs. /// Source of truth: .claude/agents/; embedded at compile time. @@ -130,7 +80,7 @@ pub fn sync_skills( let mut synced = 0; let mut skipped = 0; - for (rel_path, template) in SKILL_TEMPLATES.iter().chain(agent_templates.iter()) { + for (rel_path, template) in SKILL_ASSETS.iter().chain(agent_templates.iter()) { let path = agent_dir.join(rel_path); let display_path = config.display_path(&path); diff --git a/src/cmd/self_update_tests.rs b/src/cmd/self_update_tests.rs index d638c3e6..eb549d30 100644 --- a/src/cmd/self_update_tests.rs +++ b/src/cmd/self_update_tests.rs @@ -102,17 +102,49 @@ fn test_release_metadata_uses_matching_archive_layout() -> Result<(), Box common::Tes ); Ok(()) } + +#[test] +fn test_loop_run_rejects_stale_dependency_plan_until_replanned() -> common::TestResult { + let (temp_dir, date) = init_project_with_date()?; + let root_id = format!("WI-{date}-001"); + let dependency_id = format!("WI-{date}-002"); + let loop_id = loop_id(&date, 1); + + let setup_output = run_dynamic_commands( + temp_dir.path(), + &[ + work_new("Root"), + work_new("Dependency"), + loop_start_with_id(&loop_id, &[&root_id]), + work_add_dependency(&root_id, &dependency_id), + ], + )?; + assert!(setup_output.contains("exit: 0"), "{setup_output}"); + + let stale_output = + run_dynamic_commands(temp_dir.path(), &[loop_run_with_max_rounds(&loop_id, "2")])?; + assert!(stale_output.contains("error[E1201]"), "{stale_output}"); + assert!( + stale_output.contains(&format!("Loop '{loop_id}' is stale")), + "{stale_output}" + ); + assert!( + stale_output.contains(&format!("govctl loop replan {loop_id}")), + "{stale_output}" + ); + + let repaired_output = run_dynamic_commands( + temp_dir.path(), + &[ + loop_replan(&loop_id), + loop_run_with_max_rounds(&loop_id, "2"), + ], + )?; + assert!( + repaired_output.contains(&format!("Replanned loop {loop_id}")), + "{repaired_output}" + ); + assert!( + repaired_output.contains(&format!("Opened round 1 for loop {loop_id}")), + "{repaired_output}" + ); + + let state_toml = fs::read_to_string( + temp_dir + .path() + .join(format!(".govctl/loops/{loop_id}/state.toml")), + )?; + let state: toml::Value = toml::from_str(&state_toml)?; + assert_eq!( + loop_resolved(&state)?, + vec![root_id.clone(), dependency_id.clone()] + ); + assert_eq!(loop_item_status(&state_toml, &dependency_id)?, "active"); + assert_eq!(loop_item_status(&state_toml, &root_id)?, "pending"); + Ok(()) +} diff --git a/tests/loop_tests/surface_cases/listing.rs b/tests/loop_tests/surface_cases/listing.rs index d5c85c93..7d7501ba 100644 --- a/tests/loop_tests/surface_cases/listing.rs +++ b/tests/loop_tests/surface_cases/listing.rs @@ -33,8 +33,8 @@ fn test_loop_list_plain_and_json_are_stable() -> common::TestResult { ], )?; - let first_plain = format!("{first_loop}\tpending\t{first_root}\t1\t0\tstart"); - let second_plain = format!("{second_loop}\tpending\t{second_root}\t1\t0\tstart"); + let first_plain = format!("{first_loop}\tpending\tfresh\t{first_root}\t1\t0\tstart"); + let second_plain = format!("{second_loop}\tpending\tfresh\t{second_root}\t1\t0\tstart"); assert!(output.contains(&first_plain), "{output}"); assert!(output.contains(&second_plain), "{output}"); assert!( @@ -57,6 +57,7 @@ fn test_loop_list_plain_and_json_are_stable() -> common::TestResult { ); assert_eq!(loops[0]["id"], first_loop); assert_eq!(loops[0]["state"], "pending"); + assert_eq!(loops[0]["plan"], "fresh"); assert_eq!(loops[0]["work"][0], first_root); assert_eq!(loops[0]["items"], 1); assert_eq!(loops[0]["rounds"], 0); @@ -182,7 +183,7 @@ fn test_loop_list_filters_resumable_aliases_and_limit() -> common::TestResult { run_dynamic_commands(temp_dir.path(), &[loop_list(&["paused", "-o", "plain"])])?; assert!( paused_output.contains(&format!( - "{paused_loop}\tpaused\t{paused_root}\t1\t1\tresolve_blocker" + "{paused_loop}\tpaused\tfresh\t{paused_root}\t1\t1\tresolve_blocker" )), "{paused_output}" ); @@ -195,7 +196,7 @@ fn test_loop_list_filters_resumable_aliases_and_limit() -> common::TestResult { )?; assert!( limited_output.contains(&format!( - "{pending_loop}\tpending\t{pending_root}\t1\t0\tstart" + "{pending_loop}\tpending\tfresh\t{pending_root}\t1\t0\tstart" )), "{limited_output}" ); @@ -220,3 +221,43 @@ fn test_loop_list_reports_invalid_canonical_state() -> common::TestResult { assert!(output.contains(&loop_id), "{output}"); Ok(()) } + +#[test] +fn test_loop_show_and_list_report_stale_dependency_plan() -> common::TestResult { + let (temp_dir, date) = init_project_with_date()?; + let root_id = format!("WI-{date}-001"); + let dependency_id = format!("WI-{date}-002"); + let loop_id = loop_id(&date, 1); + + let setup_output = run_dynamic_commands( + temp_dir.path(), + &[ + work_new("Root"), + work_new("Dependency"), + loop_start_with_id(&loop_id, &[&root_id]), + work_add_dependency(&root_id, &dependency_id), + ], + )?; + assert!(setup_output.contains("exit: 0"), "{setup_output}"); + + let show_output = run_dynamic_commands(temp_dir.path(), &[loop_show(&loop_id)])?; + assert!(show_output.contains("Plan status: stale"), "{show_output}"); + + let plain_output = run_dynamic_commands(temp_dir.path(), &[loop_list(&["-o", "plain"])])?; + assert!( + plain_output.contains(&format!( + "{loop_id}\tpending\tstale\t{root_id}\t1\t0\tstart" + )), + "{plain_output}" + ); + + let json_output = run_dynamic_commands(temp_dir.path(), &[loop_list(&["-o", "json"])])?; + let json_start = json_output.find("[\n").ok_or("missing JSON list output")?; + let json_end = json_output[json_start..] + .find("\nexit:") + .ok_or("missing JSON command terminator")? + + json_start; + let loops: serde_json::Value = serde_json::from_str(&json_output[json_start..json_end])?; + assert_eq!(loops[0]["plan"], "stale", "{json_output}"); + Ok(()) +} diff --git a/tests/test_agent_dir.rs b/tests/test_agent_dir.rs index 41fd53d8..69a84d9e 100644 --- a/tests/test_agent_dir.rs +++ b/tests/test_agent_dir.rs @@ -25,6 +25,25 @@ fn test_default_agent_dir() -> common::TestResult { Ok(()) } +#[test] +fn test_init_skills_excludes_plugin_only_init_skill() -> common::TestResult { + let temp_dir = init_project()?; + + run_commands(temp_dir.path(), &[&["init-skills"]])?; + + let init_dir = temp_dir.path().join(".claude/skills/init"); + assert!( + !init_dir.exists(), + "init is a plugin/global onboarding skill, not a project-local init-skills asset" + ); + let init_skill = temp_dir.path().join(".claude/skills/init/SKILL.md"); + assert!( + !init_skill.exists(), + "init is a plugin/global onboarding skill, not a project-local init-skills asset" + ); + Ok(()) +} + #[test] fn test_wi_writer_recommends_verification_guards() -> common::TestResult { let temp_dir = init_project()?;